5. Group Anagrams
«Всякая надежная группировка требует канонического инварианта — признака, остающегося неизменным при любых перестановках».
— Брайан Керниган
Группировка анаграмм: Стратегии ключей, аллокации и механика map в Go
Задача Group Anagrams (LeetCode 49) требует разбить массив строк на группы так, чтобы каждая группа содержала только взаимные анаграммы.
Вход: ["eat", "tea", "tan", "ate", "nat", "bat"]
Выход: [
["eat", "tea", "ate"],
["tan", "nat"],
["bat"]
]
В реальных распределенных архитектурах этот паттерн применяется повсеместно:
- Дедупликация и нормализация текстовых сообщений и логов.
- Группировка запросов в поисковых системах с учетом опечаток и перестановок слов.
- Кластеризация биологических последовательностей (ДНК/РНК мутаций).
- Построение обратных индексов в полнотекстовых движках.
На собеседованиях уровня Middle+/Senior эта задача служит отличным тестом на знание внутренней механики хэш-таблиц (hmap) в Go, понимание сравнимости типов (Comparable types) и способность оптимизировать распределение памяти в куче.
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef warn fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
Input["Входной массив строк strs (размер N, длина K)"]:::entry --> Choice{"Выбор канонического ключа"}:::process
Choice --> SortKey["1. Сортировка символов:<br/>key = sort(s)<br/>Время O(N * K log K)"]:::process
Choice --> FreqArray["2. Массив частот [26]byte:<br/>Прямой ключ хэш-карты!<br/>Время O(N * K)"]:::success
Choice --> StringFreq["3. Сериализация в string:<br/>'1#0#2#...'<br/>O(N * K), но лишний мусор GC"]:::warn
SortKey --> Map["Группировка в хэш-таблице"]:::accent
FreqArray --> Map
StringFreq --> Map
Map --> Result["Сбор групп в [][]string"]:::success
Подход 1: Сортировка символов слова (Каноническая строка)
Самый наглядный подход: отсортировать буквы каждого слова по алфавиту. Для слов "eat", "tea", "ate" каноническим ключом станет строка "aet".
package anagram
import (
"slices"
)
// GroupAnagramsSort группирует анаграммы через сортировку срезов байт.
// Временная сложность: O(N * K log K), где N — число строк, K — максимальная длина слова.
// Пространственная сложность: O(N * K)
func GroupAnagramsSort(strs []string) [][]string {
// Предвыделение хэш-таблицы (эвристика начальной емкости)
groups := make(map[string][]string, len(strs)/2+1)
// Переиспользуемый буфер байт для снижения аллокаций
buf := make([]byte, 0, 64)
for _, s := range strs {
buf = append(buf[:0], s...)
slices.Sort(buf) // pdqsort на Go 1.21+
key := string(buf) // Конвертация в string (ключ хэш-карты)
groups[key] = append(groups[key], s)
}
result := make([][]string, 0, len(groups))
for _, group := range groups {
result = append(result, group)
}
return result
}
Подход 2: Фиксированный массив [26]byte как ключ (Go-специфичный шедевр)
Можем ли мы избавиться от множителя $\log K$ при сортировке и прийти к линейной сложности $O(N \cdot K)$?
Да! Для каждого слова можно подсчитать частоты букв.
Но как использовать частоты в качестве ключа хэш-карты?
В Python или Java массив нельзя напрямую использовать как ключ словаря (он мутабелен и нехэшируем). Но в языке Go массивы фиксированного размера являются сравниваемыми типами (Comparable)!
// GroupAnagramsArray использует массив [26]byte прямо в качестве ключа хэш-таблицы.
// Временная сложность: O(N * K) — строго линейная!
// Пространственная сложность: O(N * K)
func GroupAnagramsArray(strs []string) [][]string {
// Ключ мапы — фиксированный массив [26]byte!
groups := make(map[[26]byte][]string, len(strs)/2+1)
for _, s := range strs {
var count [26]byte
for i := 0; i < len(s); i++ {
count[s[i]-'a']++
}
// Массив передается по значению (26 байт) прямо в хэш-функцию
groups[count] = append(groups[count], s)
}
result := make([][]string, 0, len(groups))
for _, group := range groups {
result = append(result, group)
}
return result
}
Почему map[[26]byte] — идеальный выбор на интервью?
- Линейная сложность $O(N \cdot K)$: Подсчет частот занимает ровно $K$ шагов, никакой сортировки.
- Нулевые аллокации строк: Нам не нужно вызывать
fmt.Sprintfилиstrings.Builder, чтобы преобразовать частоты в строку вроде"1#0#2#...". - Минимум давления на GC: Массив
countживет на стеке, а ключ мапы занимает всего 26 байт прямо внутри бакетаhmap.
Под капотом: Механика hmap и цена роста
Когда вы выполняете groups[key] = append(groups[key], s), рантайм Go осуществляет сложный каскад операций:
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef warn fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
Key["Ключ: [26]byte"]:::accent --> Hash["runtime.memhash26(key)<br/>Аппаратный AES-NI хеш"]:::process
Hash --> Bucket["Поиск бакета в hmap.buckets"]:::process
Bucket --> Over{"Коэффициент загрузки<br/>loadFactor > 6.5?"}:::warn
Over -- "Да" --> Grow["Эвакуация: удвоение бакетов<br/>Дорогая аллокация в куче"]:::warn
Over -- "Нет" --> Insert["Запись значения в бакет"]:::process
Insert --> AppendSlice["groups[key] = append(...)<br/>Возможный рост внутреннего слайса"]:::success
- Хэширование: Для ключа
[26]byteрантайм использует специализированную ассемблерную инструкциюmemhash(часто с использованием аппаратного AES-NI). - Коэффициент загрузки (Load Factor): Когда среднее число элементов на бакет превышает 6.5, хэш-таблица инициирует инкрементальное удвоение количества бакетов (
hashGrow). - Предвыделение (Capacity Hint): Передача подсказки размера
make(map[...], len(strs)/2+1)гарантирует, что таблица выделит достаточное количество бакетов сразу, предотвратив дорогие фазы эвакуации памяти.
Сравнение подходов
| Параметр | Подход 1: Сортировка | Подход 2: map[[26]byte] |
Подход 3: Сериализация строки |
|---|---|---|---|
| Время | $O(N \cdot K \log K)$ | $O(N \cdot K)$ | $O(N \cdot K)$ |
| Доп. память на ключ | Строка длины $K$ в куче | 26 байт in-place | Строка $\approx 60$ байт в куче |
| Аллокаций на слово | $\ge 1$ (строка-ключ) | $0$ (массив на стеке) | $\ge 2$ (builder + string) |
| Домен символов | ASCII / Unicode | Строго ASCII 'a'..'z' |
Любой алфавит |
Вопросы для собеседования
Резюме
- Канонический ключ — основа решения задач на группировку эквивалентных структур.
- Сортировка слов дает надежное решение за $O(N \cdot K \log K)$, универсальное для любых алфавитов.
- Использование сравниваемого типа
[26]byteв качестве ключа хэш-карты — визитная карточка профессионального Go-инженера: строго линейная сложность $O(N \cdot K)$ и ноль аллокаций на формирование ключа. - Предвыделение емкости хэш-карты и итогового слайса существенно снижает накладные расходы на сборщик мусора.
В следующей лекции мы перейдем к синтаксическому анализу и работе со стеком в строках: 6. Decode String, где научимся распаковывать рекурсивные вложенные структуры без риска падения по Stack Overflow.