Хеш-таблица меняет память на поиск за почти константное время¶
Главная мысль: хеш-функция превращает ключ в индекс массива, поэтому поиск, вставка и удаление стоят
O(1)в среднем — пока коллизии остаются редкими.
Что вы сможете делать
- Объяснить, почему хеш-таблица даёт поиск
O(1)в среднем, а список —O(n). - Описать, как возникают коллизии, и сравнить цепочки и открытую адресацию.
- Предсказать, когда хеширование вырождается в
O(n)и какие ключи опасно хешировать. - Применить хеш-множество/словарь, чтобы заменить вложенный цикл одним проходом.
Требуется знать: массивы, нотация big-O, базовые функции.
Хеш-таблица — это массив, по которому вы индексируете по содержимому, а не по позиции¶
Чтобы найти значение в неотсортированном массиве, вы просматриваете все элементы:
O(n). Хеш-таблица убирает просмотр. Под капотом — массив корзин (buckets) и
хеш-функция h(key) -> int. Чтобы сохранить ключ, вы вычисляете
i = h(key) % capacity и кладёте его в корзину i; чтобы найти — вычисляете тот
же i и идёте прямо туда. Никакого просмотра — вы вычисляете позицию из самого ключа.
В этом весь приём: потратить лишнюю память (пустые корзины), чтобы купить прямой доступ.
Хорошая хеш-функция распределяет ключи равномерно и даёт O(1) в среднем¶
В массиве capacity корзин и n элементов. Отношение α = n / capacity —
коэффициент заполнения (load factor). Если хеш-функция рассеивает ключи
равномерно, в каждой корзине примерно α элементов, и поиск затрагивает ~1 + α
записей — константа, пока α мал (реальные реализации увеличивают массив, когда
α превышает ~0.7).
flowchart LR
K1["ключ: alice"] -->|h % 5 = 1| B1
K2["ключ: bob"] -->|h % 5 = 3| B3
K3["ключ: carol"] -->|h % 5 = 1| B1
subgraph Корзины
B0[0: ·]
B1[1: alice → carol]
B2[2: ·]
B3[3: bob]
B4[4: ·]
end
Здесь alice и carol попали в одну корзину — это коллизия. Таблица
по-прежнему корректна; в корзине 1 просто небольшой список. Поиск остаётся
быстрым именно потому, что при хорошем хеше и низком заполнении коллизии редки.
Коллизии разрешают цепочками или открытой адресацией¶
Рано или поздно два ключа попадут в одну корзину (принцип Дирихле). Два стандартных решения:
- Цепочки (separate chaining) — каждая корзина это небольшой список (или
дерево). При коллизии — добавляем в конец. Просто, деградирует плавно.
Используется в Java
HashMapи в концептуальной моделиdictв Python. - Открытая адресация (open addressing) — один элемент на корзину; при коллизии
пробируем следующий свободный слот (линейное/квадратичное пробирование,
двойное хеширование). Дружелюбна к кешу, без указателей на запись. Используется
в
mapв Go и в «плоских» хеш-картах C++.
Вот повседневная выгода — свернуть вложенный поиск O(n²) в один проход,
запоминая то, что уже видели:
std::vector<int> twoSum(std::vector<int>& nums, int target) {
std::unordered_map<int, int> seen; // value -> index
for (int i = 0; i < (int)nums.size(); ++i) {
auto it = seen.find(target - nums[i]); // O(1) average lookup
if (it != seen.end()) return {it->second, i};
seen[nums[i]] = i;
}
return {};
}
Хеширование подводит при плохих хешах, изменяемых ключах и нужде в порядке¶
O(1) — это среднее, а не гарантия. Три способа всё сломать:
- Состязательные / плохие хеши — если много ключей сталкиваются в одну
корзину, поиск идёт по длинной цепочке: худший случай
O(n). Атаки hash-flooding эксплуатируют ровно это. - Изменяемые ключи — если хеш ключа меняется после вставки (вы изменили ключ), вы уже никогда его не найдёте. Ключи должны быть неизменяемыми и хешируемыми (кортежи в Python — да, списки — нет; в C++/Go тип ключа должен иметь стабильный хеш/равенство).
- Нет порядка — у хеш-таблицы нет осмысленного порядка ключей. Нужна
отсортированная итерация или запросы по диапазону? Берите сбалансированное
дерево (
O(log n)), а не хеш-таблицу.
Равенство и хеш обязаны быть согласованы
Если два ключа равны, они обязаны хешироваться одинаково — иначе таблица
хранит дубликаты, которые никогда не сведёт воедино. Определяя свой тип ключа,
переопределяйте хеш и равенство вместе (__hash__/__eq__, std::hash +
operator==, в Go — сравнимая структура).
Проверь себя¶
Вопрос 1 — Поиск в хеш-таблице всегда O(1)?
Один лучший ответ:
- A. Да — хеширование константно по определению.
- B. Да, но только для целочисленных ключей.
- C. Нет — это
O(1)в среднем; плохой хеш или высокое заполнение даютO(n). ← верно - D. Нет — это всегда
O(log n).
Почему: ✅ C — средний O(1) предполагает равномерное хеширование и
ограниченный коэффициент заполнения; свалите всё в одну корзину — и вы идёте по
цепочке → O(n). ✗ A путает среднее с гарантией. ✗ B — тип ключа не меняет
асимптотику. ✗ D смешивает хеш-таблицы со сбалансированными деревьями.
Вопрос 2 — Что напечатает этот код?
- A.
3 1 - B.
1 3← верно - C.
3 3 - D.
1 1
Почему: ✅ B — все три 3 это один и тот же ключ, поэтому запись одна
(len == 1), а её счётчик увеличили три раза (seen[3] == 3). ✗ A и ✗ C
предполагают, что дубликаты ключей создают отдельные слоты — нет; словарь
индексирует по значению/равенству. ✗ D забывает про накопление.
Практика¶
- Two Sum — канонический проход «запомни, что видел».
- Contains Duplicate — хеш-множество как тест на принадлежность.
- Group Anagrams — хеширование производного ключа (отсортированные буквы / счётчики).
- Top K Frequent Elements — словарь частот + выборка.
Итог¶
- Хеш-таблица индексирует по содержимому:
i = h(key) % capacity→ прямой доступ. - Средний
O(1)держится, пока коэффициент заполнения низкий, а хеш равномерный; реализации увеличивают массив, чтобы так и было. - Коллизии неизбежны; разрешаем их цепочками или открытой адресацией.
- Вырождается в
O(n)при плохих хешах, ломается с изменяемыми ключами и не хранит порядок — нужен порядок, берите дерево. - Главная мысль: меняем память на поиск за почти константное время, пока коллизии редки.
Что почитать дальше¶
- Кормен, Лейзерсон, Ривест, Штайн — Алгоритмы: построение и анализ (CLRS), гл. 11 «Хеш-таблицы».
- Заметки о дизайне
dictв CPython — Objects/dictobject.c и «компактный» словарь. - Внутренности map в Go: "Macro View of Map Internals" и map на основе Swiss-table.
- cppreference:
std::unordered_map.