Алгоритмы с нуля
Группировка с хеш-картой
Тебе дан массив слов [“eat”, “tea”, “ate”, “tan”, “nat”] и нужно сгруппировать вместе слова, которые являются анаграммами друг друга. Один подход: для каждого слова, проверь все остальные — являются ли они анаграммами. Это O(n²). Но вот ключевая идея: для каждого слова вычисли ключ группы (буквы слова, отсортированные), затем добавь слово в список, хранящийся под этим ключом в хеш-карте. Когда ты закончишь, хеш-карта содержит все группы. Один проход, O(n) время (плюс стоимость сортировки каждого слова). Это приём группировки, и он разбивает данные по вычисленному свойству.
После этого урока ты можешь группировать предметы по вычисленному ключу с помощью хеш-карты, понять, почему приём группировки организует предметы, что делят свойство, в списки (не в счёты), реализовать пример group-anagrams с ключом отсортированных букв, спроектировать ключ группировки для любой задачи разбиения (по остатку, по первой букве, по строке, по возрастному диапазону), сравнить группировку с подсчётом частоты, и узнать форму: «map[key] это список; добавляй предметы, что принадлежат вместе».
Проблема, которую мы решаем:
Тебе дан массив слов [“eat”, “tea”, “ate”, “bat”, “tab”] и попросили: сгруппируй все анаграммы вместе.
Один подход: вложенные циклы и проверки анаграмм.
function groupAnagramsNaive(words) {
const groups = [];
const used = new Array(words.length).fill(false);
for (let i = 0; i < words.length; i++) {
if (used[i]) continue;
const group = [words[i]];
used[i] = true;
// Для каждого слова, проверь все остальные слова
for (let j = i + 1; j < words.length; j++) {
if (!used[j] && areAnagrams(words[i], words[j])) {
group.push(words[j]);
used[j] = true;
}
}
groups.push(group);
}
return groups;
}
function areAnagrams(s, t) {
if (s.length !== t.length) return false;
const sorted1 = s.split('').sort().join('');
const sorted2 = t.split('').sort().join('');
return sorted1 === sorted2;
}Это O(n² · m log m), где n это количество слов, m это средняя длина слова.
Идея приёма группировки:
Вместо этого используй хеш-карту, где значение это список, не счёт. Для каждого слова вычисли его ключ группировки (буквы отсортированы), затем добавь его в список под этим ключом.
function groupAnagrams(words) {
const map = new Map(); // ключ → [список слов]
for (let word of words) {
// Вычисли ключ группировки: отсортированные буквы
const key = word.split('').sort().join('');
// Добавь это слово в список под этим ключом
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(word);
}
// Вернуть все группы
return Array.from(map.values());
}
// Пример: ["eat", "tea", "ate", "tan", "nat"]
// "eat" → ключ "aet" → map["aet"] = ["eat"]
// "tea" → ключ "aet" → map["aet"] = ["eat", "tea"]
// "ate" → ключ "aet" → map["aet"] = ["eat", "tea", "ate"]
// "tan" → ключ "ant" → map["ant"] = ["tan"]
// "nat" → ключ "ant" → map["ant"] = ["tan", "nat"]
// Результат: [["eat", "tea", "ate"], ["tan", "nat"]]Один цикл, O(n · m log m) время (n слов, каждое занимает m log m для сортировки и хеша). Одна хеш-карта, O(n) память.
Почему это работает:
- Одинаковый ключ, одна группа: все анаграммы слова имеют одинаковые отсортированные буквы, поэтому они вычисляют одинаковый ключ и попадают в одно ведро.
- Порядок не важен: если “aet” приходит раньше “ate”, они оба отображают на “aet” и объединяются в один список.
- Один проход: пока ты итерируешь, предметы, что принадлежат вместе, сталкиваются нарочно под одним ключом.
Форма группировки:
Приём это: для каждого предмета: ключ = f(предмет); map[ключ].push(предмет).
Дизайн ключа это сердце группировки. Правильный ключ делает предметы, что должны быть вместе, сталкивающимися:
- Анаграммы: ключ = отсортированные буквы. “eat”, “tea”, “ate” все отображают на “aet”.
- Группы остатка: ключ = n % k. Числа 5, 11, 17 с k=6 все отображают на 5 (остаток).
- Первая буква: ключ = слово[0]. “apple”, “apricot” оба отображают на “a”.
- Возрастной диапазон: ключ = Math.floor(возраст / 10). Возрасты 23, 24, 25 все отображают на 2 (20s).
- Строка в сетке: ключ = точка.строка. Точки (3, 1), (3, 5), (3, 9) все отображают на строку 3.
Группировка vs. подсчёт частоты:
- Частота:
map[ключ]это счёт (число). Сколько раз каждый ключ встречается? - Группировка:
map[ключ]это список (массив). Какие предметы отображают на этот ключ?
Частота отвечает на “сколько”. Группировка отвечает на “какие”.
Пример:
// Частота: подсчитай символы в "mississippi"
const freq = new Map();
for (let char of "mississippi") {
freq.set(char, (freq.get(char) ?? 0) + 1);
}
// freq: m→1, i→4, s→4, p→2
// Группировка: группируй символы по значению в "mississippi"
const groups = new Map();
for (let char of "mississippi") {
if (!groups.has(char)) groups.set(char, []);
groups.get(char).push(char);
}
// groups: m→['m'], i→['i','i','i','i'], s→['s','s','s','s'], p→['p','p']1. Группируй анаграммы по отсортированным буквам:
function groupAnagrams(words) {
const map = new Map();
for (let word of words) {
// Ключ: буквы слова, отсортированные
const key = word.split('').sort().join('');
// Инициализируй список, если это первое слово с этим ключом
if (!map.has(key)) {
map.set(key, []);
}
// Добавь слово в группу
map.get(key).push(word);
}
// Вернуть все группы как массив массивов
return Array.from(map.values());
}
const result = groupAnagrams(["eat", "tea", "ate", "tan", "nat"]);
console.log(result);
// Результат: [["eat", "tea", "ate"], ["tan", "nat"]]2. Группируй числа по остатку (модуль):
function groupByRemainder(nums, k) {
const map = new Map();
for (let num of nums) {
const key = num % k; // Ключ группировки: остаток
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(num);
}
return Array.from(map.values());
}
const result = groupByRemainder([5, 11, 2, 8, 17, 3], 6);
// 5 % 6 = 5, 11 % 6 = 5, 2 % 6 = 2, 8 % 6 = 2, 17 % 6 = 5, 3 % 6 = 3
// Группировка: {5: [5, 11, 17], 2: [2, 8], 3: [3]}
console.log(result);
// Результат: [[5, 11, 17], [2, 8], [3]]3. Группируй слова по первой букве:
function groupByFirstLetter(words) {
const map = new Map();
for (let word of words) {
const key = word[0]; // Ключ группировки: первая буква
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(word);
}
return Array.from(map.values());
}
const result = groupByFirstLetter(["apple", "apricot", "banana", "blueberry", "cherry"]);
// 'a': ["apple", "apricot"], 'b': ["banana", "blueberry"], 'c': ["cherry"]
console.log(result);
// Результат: [["apple", "apricot"], ["banana", "blueberry"], ["cherry"]]Попробуй пример group-anagrams:
Давайте проследим приём group-anagrams на [“eat”, “tea”, “ate”, “tan”, “nat”]:
1
function groupAnagrams(words) {
2
const map = new Map();
3
for (let word of words) {
4
const key = word.split('').sort().join('');
5
if (!map.has(key)) {
6
map.set(key, []);
7
}
8
map.get(key).push(word);
9
}
10
return Array.from(map.values());
11
}
Заметь: все анаграммы попадают в одно ведро, потому что они вычисляют одинаковый ключ. Отсортированные буквы действуют как “отпечаток”, что одинаковые наборы букв делят.
Приём группировки:
| Операция | Время | Память |
|---|---|---|
| Группируй анаграммы (ключ с сортировкой) | O(n · m log m) | O(n · m) |
| Группируй по остатку | O(n) | O(n) |
| Группируй по первой букве | O(n) | O(n) |
Где n это количество предметов и m это средняя длина (для сортировки).
Сравнение: наивные вложенные циклы vs. приём группировки:
- Вложенные циклы: для каждого предмета, проверь все остальные. n × n = O(n²) (плюс проверки анаграмм).
- Приём группировки: один цикл, вычисли ключ для каждого, добавь. O(n · m log m) где m это стоимость функции ключа (например, сортировка).
Для анаграмм с n = 100 слов длины m = 10:
- Наивно: 100 × 100 × (10 log 10) = 100 000+ операций.
- Группировка: 100 × (10 log 10) = 1000 операций.
Приём группировки на порядки величины быстрее, когда функция ключа дешевле, чем сравнения.
Почему ведро-разбиение работает:
Хеш-карта разбивает предметы на ведра по ключу. Предметы, что вычисляют одинаковый ключ, автоматически группируются вместе. Это O(1) вставка в ведро и O(n) всего времени для заполнения всех вёдер.
В приёме group-anagrams, почему мы используем map[key].push(item) вместо map[key]++?
Для group anagrams, какой ключ группировки для "eat", "tea" и "ate"?
Если ты группируешь числа [3, 9, 5, 14, 7, 12] по остатку mod 4, какие числа в одной группе?
Какова временная сложность группировки n слов по анаграммам если каждое слово отсортировано (m log m)?
Если ты группируешь 50 предметов и каждая функция ключа занимает 5 единиц работы, примерно сколько всей работы (в единицах) ты выполняешь?
lesson.inset.misconception
«Группировка это просто подсчёт частоты со списком вместо числа.» Не совсем. Концептуальная разница важна: подсчёт частоты отвечает на «сколько раз?» Группировка отвечает на «какие предметы?» Два приёма решают разные проблемы. Частота работает, когда тебя интересуют счёты. Группировка работает, когда тебе нужно разбить предметы по общему свойству.
Граничные случаи
Пустой ввод: Пустой массив производит пустую карту. Вернуть пустой массив групп.
Нет совпадений: Если каждый предмет имеет уникальный ключ, каждая группа имеет один предмет. Вернуть массив из n одноэлементных массивов.
Null или undefined предметы: Если список содержит null или undefined, функция ключа должна их обработать. Для анаграмм, null и undefined не могут быть отсортированы, поэтому отфильтруй их первым или проверь ввод.
Очень длинные ключи: Если ключи это очень длинные строки или объекты, используй хеш-функцию для отображения их на целые числа, или сериализуй их в каноническую строку.
Почему группировка O(n · m log m) для анаграмм, а наивный подход O(n² · m log m)?
Группировка с хеш-картой это практика разбиения предметов по вычисленному свойству: назначь каждому предмету ключ, затем добавь его в список, хранящийся под этим ключом.
Ключевые факты:
- Форма группировки:
для каждого предмета: ключ = f(предмет); map[ключ].push(предмет); - Дизайн ключа это всё: правильный ключ делает предметы, что должны быть вместе, сталкивающимися в одном ведре.
- Частые ключи:
- Отсортированные буквы (анаграммы)
- Остаток mod k (числовые группы)
- Первый символ (словесные группы)
- Возрастной диапазон (групп населения)
- Строка или колонка (пространственные группы)
- Группировка vs. частота:
- Частота:
map[ключ]это счёт. - Группировка:
map[ключ]это список.
- Частота:
- Временная сложность:
- Анаграммы: O(n · m log m) (сортировка каждого слова).
- Остаток/первая-буква: O(n) (нет дорогой функции ключа).
- Пространственная сложность: O(n · m) для карты и списков.
Группировка превращает O(n²) задачи вложенных циклов в O(n · стоимость-ключа) задачи. Дальше ты изучишь, как коллизии хешей влияют на производительность, и как спроектировать лучшие ключи и хеш-функции.