JavaRush /Курсы /Go SELF /Стабильный вывод: сортировка ключей и слайсов

Стабильный вывод: сортировка ключей и слайсов

Go SELF
15 уровень , 3 лекция
Открыта

1. Стабильный вывод и map

Если вы только начинаете, кажется, что порядок вывода — это какая-то эстетика: «ну вывело же все элементы, какая разница, в каком порядке». Проблема в том, что порядок внезапно становится важным в двух случаях: когда вы сравниваете результаты глазами («что изменилось?») и когда результаты сравниваются программно (автотесты, скрипты, проверяющие системы).

Представьте, что вы пишете маленькую утилиту, которая выводит статистику: сколько раз встречалось каждое слово, какие теги уникальны, какие задачи отмечены выполненными. Вы запускаете программу два раза с одинаковым вводом — и ожидаете одинаковый текст. Если порядок строк «плавает», то и человек, и тесты начинают нервничать. А нервные тесты — это как нервный кот: может уронить вам всё со стола просто потому что «сегодня так хочется».

map печатается в «случайном» порядке

Когда мы делаем:

for k, v := range m {
    fmt.Println(k, v)
}

мы получаем корректный обход: все пары «ключ значение» будут посещены ровно по одному разу (если вы не меняете мапу во время обхода). Но порядок обхода не является контрактом. Это значит, что нельзя писать логику вида «первый ключ — самый маленький» или «последний ключ — самый новый». У map в Go нет понятия «первый» и «последний».

Тут есть важный практический нюанс: даже если сегодня у вас «случайно» выводится красиво отсортировано, это не обещание. Завтра другой запуск, другое окружение, другая версия — и порядок уже другой. В официальных материалах по совместимости Go отдельно приводится пример, где изменение реализации сортировки между версиями меняло порядок равных элементов, и из-за этого ломались программы, которые «ожидали конкретный вывод».

Мы отсюда берём простое правило: если порядок важен для человека или тестов, значит порядок нужно задавать явно.

Базовый рецепт: «ключи → сортировка → печать»

Самый удобный и простой способ «упорядочить» map звучит почти как кулинарный рецепт, только вместо кастрюли — слайс. Мы делаем так: собираем все ключи в отдельный слайс, сортируем этот слайс, а потом печатаем значения строго в порядке отсортированных ключей. То есть сортируем мы не map (её «сортировать» напрямую нельзя), а слайс ключей.

Чтобы держать картинку в голове, можно представить это так:

flowchart TD
    A[map: key -> value] --> B["собрали ключи в []K"]
    B --> C["отсортировали []K"]
    C --> D["for _, k := range keys: печатаем k и m[k]"]

Теперь — к коду. Начнём с маленькой заготовки для нашего примера: мини‑программа, которая считает частоты слов (частотный словарь). Это удобно, потому что map[string]int одновременно демонстрирует и «счётчик», и необходимость стабильного вывода.

2. Сортировка простых ключей в пакете sort

Когда ключи — строки или числа, проще всего использовать пакет sort. Он старый, надёжный, как дедушка, который ворчит, но всегда приходит чинить кран.

Пример: вывод map[string]int по алфавиту

Считаем слова и печатаем частоты. Сначала покажем нестабильный вывод (как не надо), а потом исправим.

package main

import "fmt"

func main() {
	counts := map[string]int{"go": 3, "map": 1, "sort": 2}

	for w, c := range counts {
		fmt.Println(w, c) // порядок не гарантируется
	}
}

Теперь делаем стабильный вывод: собрали ключи, отсортировали, вывели.

package main

import (
	"fmt"
	"sort"
)

func main() {
	counts := map[string]int{"go": 3, "map": 1, "sort": 2}

	keys := make([]string, 0, len(counts))
	for k := range counts {
		keys = append(keys, k)
	}
	sort.Strings(keys)

	for _, k := range keys {
		fmt.Println(k, counts[k]) // go 3 / map 1 / sort 2 (всегда одинаково)
	}
}

Здесь особенно полезна штука make([]string, 0, len(counts)): мы сразу резервируем ёмкость под все ключи и не заставляем append лишний раз расширять буфер. Это не «оптимизация ради оптимизации», просто аккуратность.

Пример: если ключи — числа (sort.Ints)

Иногда ключ — не строка, а, например, ID. Рецепт точно такой же, меняется только тип ключей и сортировка.

package main

import (
	"fmt"
	"sort"
)

func main() {
	m := map[int]string{10: "ten", 2: "two", 7: "seven"}

	keys := make([]int, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Ints(keys)

	for _, k := range keys {
		fmt.Println(k, m[k]) // 2 two / 7 seven / 10 ten
	}
}

Важная мысль: стабильность здесь появляется потому, что ключи уникальны. Если ключи уникальны, то даже «нестабильная сортировка» всё равно даёт один и тот же результат, потому что нет равных элементов, которые можно переставлять.

3. Сортировка по правилу: sort.Slice

Очень быстро вам захочется не просто «по алфавиту», а «по смыслу». Например: вывести слова по убыванию частоты, а если частоты равны — по алфавиту. Или вывести строки по длине. Или вывести числа так, чтобы сначала были чётные, потом нечётные.

Для таких случаев в пакете sort есть инструмент «всё-в-одном»: sort.Slice. Он сортирует любой слайс, если вы дадите правило сравнения в виде функции less(i, j int) bool.

Под капотом идея простая: «элемент с индексом i должен идти раньше элемента j, если less(i, j) == true».

Пример: сортируем строки по длине и добавляем tie-breaker

package main

import (
	"fmt"
	"sort"
)

func main() {
	words := []string{"go", "gopher", "map", "slices"}

	sort.Slice(words, func(i, j int) bool {
		if len(words[i]) != len(words[j]) {
			return len(words[i]) < len(words[j])
		}
		return words[i] < words[j] // tie-breaker для детерминизма
	})

	fmt.Println(words) // [go map gopher slices]
}

Обратите внимание на вторую ветку: return words[i] < words[j]. Это tie-breaker (правило для равных). Он очень важен, если вы хотите действительно детерминированный вывод.

Почему это важно? Потому что разные версии и разные реализации сортировки могут по‑разному переставлять элементы, которые считаются равными по вашему основному критерию. В материалах по совместимости Go прямо подчёркивается, что изменения реализации сортировки могут менять порядок равных элементов, и полагаться на «случайный порядок равных» нельзя.

Пример: сортируем ключи мапы по значениям

Вернёмся к нашему map[string]int со счётчиком. Мы хотим вывести самое частое слово первым. Поскольку сортировать map нельзя, мы сортируем ключи, но сравниваем их через значения в мапе.

package main

import (
	"fmt"
	"sort"
)

func main() {
	counts := map[string]int{"go": 3, "map": 1, "sort": 3}

	keys := make([]string, 0, len(counts))
	for k := range counts {
		keys = append(keys, k)
	}

	sort.Slice(keys, func(i, j int) bool {
		wi, wj := keys[i], keys[j]
		if counts[wi] != counts[wj] {
			return counts[wi] > counts[wj] // больше — раньше
		}
		return wi < wj // равные частоты упорядочим по алфавиту
	})

	for _, k := range keys {
		fmt.Println(k, counts[k])
	}
}

Заметьте, насколько аккуратно мы делаем tie-breaker: если частоты равны, мы всё равно задаём порядок через wi < wj. Это превращает вывод в «железобетонный»: одинаковый ввод → одинаковый вывод.

4. Пакет slices: Sort и SortFunc

Начиная с Go 1.21 стандартная библиотека получила пакет slices, который содержит много удобных операций над слайсами, включая сортировку, и он обычно читается проще, чем комбинации с sort.Interface. В релизных заметках отдельно отмечается, что slices включает функции сортировки и в целом делает работу со слайсами удобнее и «эргономичнее».

Нам сегодня интересны две функции: slices.Sort (когда элементы «упорядочиваемые» — числа, строки) и slices.SortFunc (когда нужен свой порядок).

slices.Sort: сортировка строк и чисел «в лоб»

package main

import (
	"fmt"
	"slices"
)

func main() {
	nums := []int{5, 2, 10, 2}
	slices.Sort(nums)

	fmt.Println(nums) // [2 2 5 10]
}

Важно помнить: slices.Sort сортирует на месте и ничего не возвращает. Это нормальная модель для сортировки: перестановка элементов не меняет длину слайса, значит возвращать новый слайс не обязательно.

Кстати, у пакета slices есть и функции, которые возвращают новый слайс (например, удаление диапазона). И там типичная ошибка новичка — проигнорировать возвращаемое значение. В материалах Go как раз разбирают такие «грабли»: slices.Sort(s) корректно не возвращает, а вот slices.Delete возвращает новый слайс, и игнорировать результат нельзя.

slices.SortFunc: сортировка по компаратору

sort.Slice просит less(i, j) bool, а slices.SortFunc просит функцию сравнения элементов: cmp(a, b T) int.

Контракт обычно такой:

  • если a должен идти раньше b, возвращаем отрицательное число;
  • если позже — положительное;
  • если «равны» с точки зрения сортировки — 0.

Сделаем сортировку строк по длине, как раньше, но через slices.SortFunc:

package main

import (
	"fmt"
	"slices"
)

func main() {
	words := []string{"go", "gopher", "map", "slices"}

	slices.SortFunc(words, func(a, b string) int {
		if len(a) != len(b) {
			return len(a) - len(b)
		}
		if a < b {
			return -1
		}
		if a > b {
			return 1
		}
		return 0
	})

	fmt.Println(words) // [go map gopher slices]
}

Да, тут чуть больше строк, чем хотелось бы, но зато логика сравнения теперь работает напрямую с элементами a и b, без индексов. Для начинающих это часто читается легче: вы сравниваете значения, а не «элементы с индексом i».

И снова обратите внимание: tie-breaker обязателен, если вы хотите детерминированный результат. «Ноль» возвращаем только если строки действительно одинаковые, иначе лучше разрулить порядок самим.

5. Пример: частотный словарь со стабильным выводом

Сейчас соберём всё в маленькую программу, которая читает слова из stdin, считает частоты и печатает результат в стабильном виде. Мы сделаем два режима вывода: по алфавиту и по частоте. Это не «полноценное приложение», но это честный кусок практики: вы увидите, как мапа превращается в аккуратный, предсказуемый отчёт.

Чтение слов и подсчёт

package main

import "fmt"

func main() {
	counts := make(map[string]int)

	for {
		var w string
		_, err := fmt.Scan(&w)
		if err != nil {
			break
		}
		counts[w]++
	}

	fmt.Println("unique:", len(counts)) // например: unique: 3
}

Здесь мы используем fmt.Scan в цикле «до ошибки». Когда ввод закончится (EOF), Scan вернёт ошибку, и мы выйдем из цикла. Мы не углубляемся в виды ошибок, нам достаточно самого факта: «есть ошибка — значит, закончили читать».

Стабильный вывод по алфавиту (sort.Strings)

package main

import (
	"fmt"
	"sort"
)

func printAlpha(counts map[string]int) {
	keys := make([]string, 0, len(counts))
	for k := range counts {
		keys = append(keys, k)
	}
	sort.Strings(keys)

	for _, k := range keys {
		fmt.Println(k, counts[k])
	}
}

Эту функцию приятно вызывать: она не меняет мапу, она просто печатает. А ещё она печатает одинаково всегда.

Стабильный вывод по частоте (sort.Slice)

package main

import (
	"fmt"
	"sort"
)

func printByCount(counts map[string]int) {
	keys := make([]string, 0, len(counts))
	for k := range counts {
		keys = append(keys, k)
	}

	sort.Slice(keys, func(i, j int) bool {
		a, b := keys[i], keys[j]
		if counts[a] != counts[b] {
			return counts[a] > counts[b]
		}
		return a < b
	})

	for _, k := range keys {
		fmt.Println(k, counts[k])
	}
}

Даже если два слова встретились одинаковое количество раз, мы всё равно печатаем их в одном порядке (по алфавиту). Это важная мелочь: именно такие мелочи превращают программу в «предсказуемую».

Вызовем оба вывода из main

package main

import "fmt"

func main() {
	counts := map[string]int{"go": 3, "map": 1, "sort": 3}

	fmt.Println("== alphabet ==")
	printAlpha(counts)

	fmt.Println("== by count ==")
	printByCount(counts)
}

6. Типичные ошибки при стабильном выводе и сортировке

Ошибка №1: «Я отсортировал ключи, но печатаю через range по map».
Это выглядит так: вы сделали sort.Strings(keys), а потом случайно написали for k, v := range m { ... }. В итоге ключи у вас отсортированы, но они вообще не используются, и порядок снова «пляшет». Правильная мысль здесь простая: отсортированный источник порядка — это слайс keys, значит печатать нужно строго for _, k := range keys.

Ошибка №2: нет tie-breaker’а при сортировке по «вторичному» критерию.
Например, вы сортируете слова по частоте и возвращаете counts[a] > counts[b], а если частоты равны — возвращаете false. Тогда равные элементы могут переставляться как угодно, и вывод будет нестабильным. Это особенно неприятно тем, что «почти всегда работает», а потом внезапно начинает мигать в тестах. Изменения в реализации сортировки между версиями Go могут менять порядок равных элементов, и на это нельзя опираться.

Ошибка №3: ожидать, что сортировка вернёт новый слайс, и продолжать работать со старым.
Функции сортировки в sort и slices меняют слайс на месте. Поэтому если вы хотели «сохранить исходный порядок», сначала делайте копию (например, через append([]T(nil), s...)), и сортируйте копию. Отдельно держите в голове, что в пакете slices есть функции, которые меняют «на месте» (например, slices.Sort), и есть функции, которые возвращают новый слайс (например, slices.Delete), и игнорирование результата во втором случае — классическая ловушка.

Ошибка №4: пытаться «отсортировать map».
Иногда новичок ищет что-то вроде sort.Map(m) или пытается конвертировать мапу в «отсортированную мапу». В Go такого нет в базовом виде: map — это структура для быстрого доступа по ключу, а не для хранения элементов в порядке. Если нужен порядок — его задают поверх мапы: ключи в слайс, сортировка, вывод.

Ошибка №5: компаратор в slices.SortFunc возвращает «что попало».
Если функция сравнения иногда возвращает 1, иногда -1 для одинаковых значений (или нарушает здравый смысл «если a < b, то b > a»), сортировка может вести себя странно. Держитесь простого контракта: отрицательное — «раньше», положительное — «позже», ноль — «равны». И старайтесь делать 0 только когда элементы действительно равны, иначе лучше разрулить порядок tie-breaker’ом.

1
Задача
Go SELF, 15 уровень, 3 лекция
Недоступна
Сортировка показаний
Сортировка показаний
1
Задача
Go SELF, 15 уровень, 3 лекция
Недоступна
Прайс по алфавиту
Прайс по алфавиту
1
Задача
Go SELF, 15 уровень, 3 лекция
Недоступна
Частоты для отчёта
Частоты для отчёта
1
Задача
Go SELF, 15 уровень, 3 лекция
Недоступна
Лидерборд турнира
Лидерборд турнира
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ