Деревья ищут за O(log N) — быстро. Но есть структура, которая ищет ещё быстрее: хеш-таблица даёт доступ по ключу в среднем за O(1) — постоянное время, не зависящее от объёма данных. Это самая быстрая структура для поиска по ключу — и потому одна из самых используемых.
Идея: превратить ключ в индекс
У массива есть суперсила — мгновенный доступ по индексу. Проблема лишь в том, что индекс — это число от 0 до размера массива, а ключи бывают любыми (большие числа, строки). Хеш-таблица наводит мост: она пропускает ключ через хеш-функцию, которая превращает его в индекс массива.
Простейший пример — взять ключ по модулю размера массива: индекс = ключ % размер. Тогда индекс вычисляется за один шаг, и мы сразу обращаемся к нужной ячейке: никакого перебора и спуска по дереву.
Равномерное распределение по корзинам даёт доступ в среднем за O(1). Совпал номер — второй ключ пристраивается цепочкой снизу.
Разложим ключи по корзинам: восемь ключей, семь корзин, остаток от деления вместо хеш-функции.
живой пример
import java.util.ArrayList;
import java.util.List;
public class Buckets {
public static void main(String[] args) {
int[] keys = {31, 9, 18, 25, 44, 12, 7, 20};
List<List<Integer>> buckets = new ArrayList<>();
for (int i = 0; i < 7; i++) buckets.add(new ArrayList<>());
int collisions = 0;
for (int key : keys) {
List<Integer> bucket = buckets.get(key % 7);
if (!bucket.isEmpty()) collisions++;
bucket.add(key);
}
System.out.println("корзины: " + buckets);
System.out.println("коллизий: " + collisions + " из " + keys.length);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import "fmt"
func main() {
keys := []int{31, 9, 18, 25, 44, 12, 7, 20}
buckets := make([][]int, 7)
collisions := 0
for _, key := range keys {
i := key % 7
if len(buckets[i]) > 0 {
collisions++
}
buckets[i] = append(buckets[i], key)
}
fmt.Println("корзины:", buckets)
fmt.Printf("коллизий: %d из %d\n", collisions, len(keys))
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
const keys = [31, 9, 18, 25, 44, 12, 7, 20];
const buckets = Array.from({ length: 7 }, () => []);
let collisions = 0;
for (const key of keys) {
const bucket = buckets[key % 7];
if (bucket.length > 0) collisions++;
bucket.push(key);
}
console.log("корзины:", JSON.stringify(buckets));
console.log(`коллизий: ${collisions} из ${keys.length}`);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
keys = [31, 9, 18, 25, 44, 12, 7, 20]
buckets = [[] for _ in range(7)]
collisions = 0
for key in keys:
bucket = buckets[key % 7]
if bucket:
collisions += 1
bucket.append(key)
print("корзины:", buckets)
print(f"коллизий: {collisions} из {len(keys)}")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Двум ключам из восьми досталась уже занятая корзина.
Разных ключей много, а ячеек ограниченное число, поэтому рано или поздно два ключа дадут один и тот же индекс. Это коллизия: избежать её нельзя, можно только разрулить — двумя способами.
Открытая адресация
При коллизии ищем другую свободную ячейку в самом массиве. Самый простой способ, линейное пробирование, идёт по соседним ячейкам, пока не найдёт свободную, и от этого занятые участки слипаются в длинные цепочки, которые замедляют поиск. Квадратичное пробирование шагает всё дальше (index+1, index+4, index+9…), а двойное хеширование берёт величину шага из второй хеш-функции, чтобы разные ключи разбредались по-разному. В библиотеках встречается и то и другое: HashMap в Java держит цепочки, map в Go и dict в Python — открытую адресацию; подробности в разделе про стандартную библиотеку.
Общая беда открытой адресации: чем плотнее заполнен массив, тем длиннее пробы. Отсюда важное понятие — коэффициент заполнения (load factor), отношение числа записей к размеру массива. Пока он невелик (скажем, до ~2/3), доступ остаётся близким к O(1); при приближении к единице таблица резко замедляется. Насколько резко — видно из цифры: при линейном пробировании и заполнении 0.9 неудачный поиск в среднем щупает уже около полусотни ячеек вместо одной. Поэтому таблицу расширяют заранее — перехешируют в массив побольше.
Метод цепочек
Другой подход — в каждой ячейке массива хранить не одну запись, а связанный список всех записей с этим индексом. Коллизия просто добавляет элемент в список нужной ячейки. Здесь коэффициент заполнения может превышать единицу, и таблица не «застывает» при заполнении, а деградирует плавно. Метод цепочек проще и устойчивее к высокой загрузке, поэтому его часто предпочитают.
Хеш-функции
Качество таблицы определяется хеш-функцией. Хорошая функция должна быть:
- быстрой — она вызывается на каждой операции;
- равномерной — разбрасывать ключи по всему диапазону индексов, не сваливая их в кучу (иначе коллизии зашкаливают).
Практические приёмы зависят от того, как считается номер корзины. Если берут остаток от деления хеша на размер массива, размер выбирают простым числом — так реже совпадают регулярные ключи вроде «каждый десятый». Другой путь — размер в виде степени двойки и дешёвые битовые операции вместо деления; так сделаны HashMap в Java и map в Go. Строки хешируют, комбинируя коды символов с домножением (схема Горнера), чтобы разные строки давали разные хеши. Плохая хеш-функция превращает быструю таблицу в медленный список.
Чего хеш-таблица не умеет
За скорость приходится платить трижды. Порядка нет: элементы разбросаны по массиву псевдослучайно, и обойти их по возрастанию или найти «следующий больший» быстро нельзя, для этого берут деревья. Без ключа она бесполезна: быстрый доступ есть только по точному ключу, поиск по диапазону или по части значения она не ускоряет. И она тратит память: чтобы коллизий было мало, массив держат заметно просторнее, чем данных.
Как это сделано в стандартной библиотеке
Свою хеш-таблицу писать почти никогда не нужно — в Java она уже есть. Важнее понимать, что именно берёте с полки: от этого зависит и скорость, и то, найдётся ли объект в таблице вообще.
Эталонная реализация — HashMap. Внутри у неё ровно то, о чём была статья: массив корзин и метод цепочек — записи, попавшие в одну корзину, лежат связанным списком. Номер корзины берут не остатком от деления, а дешёвой битовой операцией по младшим битам хеша: размер массива всегда степень двойки. Чтобы старшие биты не пропадали зря, HashMap сначала подмешивает их к младшим — если этого не делать, ключи с одинаковым «хвостом» хеша свалятся в одну корзину.
Коэффициент заполнения по умолчанию — 0.75: как только записей стало больше трёх четвертей от числа корзин, массив удваивается и все ключи раскладываются заново. Перестройка стоит O(n), поэтому под сто тысяч записей размер задают сразу в конструкторе — иначе таблица переживёт четырнадцать перестроек. Только число в конструкторе это ёмкость, а не число записей, и new HashMap<>(100_000) заведёт 131 072 корзины (ёмкость округляется до степени двойки) с порогом 98 304: на сотне тысяч записей перестройка всё равно случится. Правильно либо посчитать запас руками, new HashMap<>((int) (100_000 / 0.75) + 1), либо с Java 19 сказать прямо, сколько пар ожидается: HashMap.newHashMap(100_000), и он поделит на 0.75 сам.
Ещё одна деталь появилась в Java 8. Если в одну корзину набилось восемь записей, а корзин в таблице уже не меньше 64, список внутри корзины превращается в красно-чёрное дерево. Поиск внутри перегруженной корзины из O(k) становится O(log k) — это страховка от плохой хеш-функции и от специально подобранных данных, которые иначе вырождали бы таблицу в список.
Отсюда честная сложность. get и put — O(1) в среднем, при условии, что хеш-функция раскидывает ключи равномерно. Худший случай — не O(1), а O(log n) благодаря дереву в корзине, и то только если ключи сравнимы (Comparable); иначе HashMap упорядочивает их по запасному признаку, и выигрыш скромнее. Порядка обхода у HashMap нет вообще, и он вправе поменяться после любого расширения — если порядок вставки нужен, берут LinkedHashMap, который дополнительно связывает записи в список. HashSet — это тот же HashMap, где значения не используются.
Грабля, на которую наступают чаще всего: ключ, который меняется после того, как его положили в таблицу. Номер корзины посчитали один раз, при вставке. Изменили поле, участвующее в hashCode, — и объект остался лежать в старой корзине, а искать его теперь будут в новой.
живой пример
import java.util.HashMap;
import java.util.Map;
public class MutableKey {
static class Order {
int id;
Order(int id) { this.id = id; }
@Override public int hashCode() { return Integer.hashCode(id); }
@Override public boolean equals(Object o) { return o instanceof Order other && other.id == id; }
}
public static void main(String[] args) {
Map<Order, String> orders = new HashMap<>();
Order order = new Order(1);
orders.put(order, "оплачен");
System.out.println("сразу после put: " + orders.get(order));
order.id = 2;
System.out.println("после правки ключа: " + orders.get(order));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Отсюда правило: ключи хеш-таблицы делают неизменяемыми, а hashCode и equals переопределяют вместе и по одним и тем же полям. Сам контракт односторонний, и его стоит произнести точно: равные по equals объекты обязаны давать равный hashCode, а обратное не требуется, неравные могут совпадать по хешу, это и есть коллизия. Нарушение первого правила ломает поиск (равный ключ ищут в другой корзине), совпадение хешей у неравных лишь замедляет его. Разошлись поля — и два равных объекта попадут в разные корзины (или, наоборот, окажутся неразличимы).
Три мелочи, за которые платят в проде. Память. Каждая запись HashMap это объект-узел: заголовок, хеш, ссылки на ключ, значение и следующий узел, около 32 байт, плюс место в массиве корзин с запасом по коэффициенту заполнения, плюс сами объекты ключа и значения; на миллионе записей это десятки мегабайт сверх данных, и HashMap<Long, Long> тратит на пару восемь байт полезных и около шестьдесят служебных. null. HashMap принимает один null-ключ и сколько угодно null-значений, отчего get не отличает «ключа нет» от «значение null» (для этого containsKey); TreeMap при естественном порядке null-ключ отвергает, а ConcurrentHashMap отвергает и null-ключи, и null-значения. Потоки. HashMap рассчитана на один поток: две одновременные записи молча теряют пары, а в Java 7 могли ещё и зациклить цепочку в корзине. Для нескольких потоков берут ConcurrentHashMap, которая блокирует одну корзину, а не всю таблицу; как она устроена, разбирает статья про HashMap изнутри.
Свою хеш-таблицу писать почти никогда не нужно — в Go она встроена в язык. Важнее понимать, что именно берёте с полки: от этого зависит и скорость, и то, найдётся ли значение в таблице вообще.
Эталонная реализация — map[K]V. С Go 1.24 внутри у неё «швейцарская таблица» (Swiss Table): открытая адресация группами по восемь слотов, у каждой группы байты-метки с семью битами хеша, и поиск сначала сравнивает метки всей группы разом, а уже потом сами ключи. До 1.24 устройство было ближе к цепочкам: корзины по восемь записей с переполнением в дополнительные корзины. Номер группы берут по битам хеша, а сам хеш солят случайным числом, своим для каждой таблицы: подобрать ключи, которые сложатся в одну корзину, снаружи нельзя.
Коэффициент заполнения — 7/8 для швейцарской таблицы (раньше 6,5 записи на корзину из восьми): дальше таблица удваивается и ключи раскладываются заново, причём большая таблица разбита на части, и растут они по отдельности, чтобы одна вставка не стоила O(n) разом. Под сто тысяч записей размер задают сразу: make(map[string]int, 100_000), и это именно ожидаемое число записей, делить на коэффициент не нужно. Удаление таблицу не сжимает: миллион записей вставили, удалили, а память осталась; освобождает её только новая таблица.
Отсюда честная сложность. Чтение и запись — O(1) в среднем, и худший случай защищён не деревом в корзине, как в Java, а случайной солью: злонамеренный вход не может знать, куда лягут ключи. Порядка обхода у map нет, и это не просто «не гарантирован»: среда выполнения нарочно начинает обход со случайного места, чтобы никто не полагался на порядок. Нужен порядок — собирают ключи в срез и сортируют, slices.Sorted(maps.Keys(m)). Множество это map[K]struct{}: пустая структура не занимает памяти.
Ключом может быть только сравнимый тип: числа, строки, указатели, массивы и структуры из сравнимых полей. Срез, карта или функция ключом быть не могут, компилятор это не пропустит. Поэтому грабля с изменяемым ключом в Go выглядит иначе: ключ-структура копируется в таблицу при вставке, и правка исходной переменной на таблицу не влияет. А вот ключ-указатель сравнивается по адресу, и две одинаковые по содержимому структуры по разным указателям — разные ключи.
живой пример
package main
import "fmt"
type Order struct{ ID int }
func main() {
byValue := map[Order]string{}
order := Order{1}
byValue[order] = "оплачен"
order.ID = 2
fmt.Println("ключ скопирован при вставке:", byValue[Order{1}], "| по изменённому не найти:", byValue[order] == "")
byPointer := map[*Order]string{}
a, b := &Order{1}, &Order{1}
byPointer[a] = "оплачен"
fmt.Println("одинаковые структуры по разным указателям — разные ключи:", byPointer[b] == "")
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Отсюда правило: ключом делают значение, а не указатель, если равенство должно быть по содержимому; и структуру для ключа держат маленькой, потому что она копируется и хешируется целиком. Отдельная ловушка — float64 в ключе: NaN не равен самому себе, и m[math.NaN()] = 1 каждый раз заводит новую запись, которую потом не найти.
Три мелочи, за которые платят в проде. Нулевое значение. Чтение отсутствующего ключа не ошибка, а нулевое значение типа: m["нет"] даёт 0 или "", и «ключа нет» от «значение ноль» отличают второй переменной, v, ok := m[k]. Хуже другое: var m map[string]int без make это nil, читать из него можно, а запись падает с паникой assignment to entry in nil map. Память. Швейцарская таблица держит байт метки на слот плюс сами ключи и значения подряд, без объектов-узлов; map[int64]int64 на миллион записей это порядка 20 МБ, заметно меньше, чем в Java. Горутины. map рассчитана на одну горутину: одновременные запись и чтение среда выполнения ловит и роняет процесс фатальной ошибкой concurrent map writes, которую не поймать через recover. Для нескольких горутин таблицу закрывают sync.RWMutex, а для кеша «пишут редко, читают много» есть sync.Map.
Свою хеш-таблицу писать почти никогда не нужно — в JavaScript их две, и обе встроены. Важнее понимать, что именно берёте с полки: от этого зависит и скорость, и то, найдётся ли значение в таблице вообще.
Эталонная реализация — Map (и Set, который внутри та же таблица без значений). Внутри у неё в V8 массив корзин и метод цепочек, только записи лежат не в узлах, а в отдельном плотном массиве в порядке вставки, а корзина хранит индекс первой записи и цепочку индексов. Отсюда главное внешнее свойство: Map обходится в порядке вставки, и это гарантия стандарта, а не деталь движка. Когда записей становится столько же, сколько корзин, таблица удваивается и ключи раскладываются заново, а удалённые записи уплотняются при очередной перестройке.
Хеш у объектов это скрытое случайное число, которое движок кладёт в сам объект при первом использовании как ключа; у строк хеш считается по содержимому и солится случайным числом при старте процесса, чтобы снаружи нельзя было подобрать ключи в одну корзину. Число корзин снаружи не задать: конструктор new Map() ёмкости не принимает, таблица растёт сама.
Отсюда честная сложность. get, set, has, delete — O(1) в среднем; худший случай спасает соль хеша, дерева в корзине, как в Java, здесь нет. Равенство ключей — SameValueZero: как ===, но NaN равен NaN, а +0 равен -0. Для объектов это равенство по ссылке: два объекта с одинаковыми полями — разные ключи. Поэтому грабля с изменяемым ключом в JavaScript вывернута наизнанку: менять поля объекта-ключа можно сколько угодно, таблица его не потеряет, зато найти запись по «такому же» объекту нельзя.
живой пример
const orders = new Map();
const order = { id: 1 };
orders.set(order, "оплачен");
order.id = 2;
console.log("после правки ключа:", orders.get(order));
console.log("по равному объекту:", orders.get({ id: 2 }));
console.log("NaN находит NaN:", new Map([[NaN, "нашёлся"]]).get(NaN));
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Отсюда правило: ключом делают примитив, строку или число, если равенство должно быть по содержимому; составной ключ собирают в строку, ${city}:${day}, или заводят вложенные Map. Объект ключом кладут тогда, когда нужен именно «этот самый объект», и чаще через WeakMap, чтобы таблица не мешала сборщику мусора его убрать.
Вторая таблица — обычный объект {}. Это тоже словарь, но с оговорками: ключи приводятся к строкам, obj[1] и obj["1"] одно и то же; у объекта есть прототип, и obj["constructor"] найдётся, хотя вы его не клали, а запись по ключу __proto__ из пользовательского ввода может подменить прототип, это и есть prototype pollution. Словарь из ненадёжных ключей делают через Object.create(null) или берут Map. Движок к тому же переводит объект с тысячами разных ключей в «режим словаря», где доступ медленнее, чем у объекта с фиксированной формой.
Три мелочи, за которые платят в проде. Отсутствующий ключ. get возвращает undefined, и его не отличить от записанного undefined; для этого есть has. Память. Запись Map стоит порядка 30–40 байт служебных плюс сами ключ и значение; миллион записей это десятки мегабайт, а куча Node по умолчанию ограничена. Потоки. В одном потоке гонок нет, Map безопасна между обработчиками событий сама по себе; между воркерами она не разделяется вовсе, туда передают копии через postMessage.
Свою хеш-таблицу писать почти никогда не нужно — в Python на ней стоит весь язык: атрибуты объектов, пространства имён модулей, именованные аргументы. Важнее понимать, что именно берёте с полки: от этого зависит и скорость, и то, найдётся ли значение в таблице вообще.
Эталонная реализация — dict (и set, у которого та же таблица без значений). Внутри у неё открытая адресация: при коллизии следующая ячейка выбирается не соседняя, а вычисленная из старших битов хеша, так что регулярные ключи не слипаются в цепочки. С Python 3.6 таблица «компактная»: массив ячеек хранит только индексы, а записи лежат в отдельном плотном массиве в порядке добавления. Отсюда свойство, которого от хеш-таблицы не ждут: dict обходится в порядке вставки, и с 3.7 это гарантия языка.
Коэффициент заполнения — 2/3: когда занято две трети ячеек, таблица растёт и все ключи раскладываются заново. Перестройка стоит O(n), а задать ёмкость заранее нельзя: конструктор dict числа не принимает, и на сотне тысяч записей таблица переживёт полтора десятка перестроек. Это редко проблема, потому что хеш каждой записи хранится рядом с ней и при перестройке не пересчитывается.
Хеш строк и байтов солится случайным числом при старте интерпретатора (переменная PYTHONHASHSEED его фиксирует): подобрать ключи, которые сложатся в одну цепочку, снаружи нельзя, и дерева в корзине, как в Java, для защиты не нужно. Отсюда честная сложность: чтение и запись O(1) в среднем. Небольшие целые хешируются сами в себя, hash(5) == 5, поэтому словарь с ключами 0, 1, 2 вообще не знает коллизий.
Ключом может быть только хешируемый объект: числа, строки, кортежи из хешируемых, замороженные множества. Список, словарь и множество ключами быть не могут, TypeError: unhashable type, и это защита от той самой грабли с изменяемым ключом. Но у своих классов защиты нет: объект по умолчанию хешируется по адресу, а стоит определить __hash__ через изменяемое поле, и ключ, изменённый после вставки, теряется в старой ячейке, ровно как в Java.
живой пример
class Order:
def __init__(self, id: int) -> None:
self.id = id
def __hash__(self) -> int:
return hash(self.id)
def __eq__(self, other) -> bool:
return isinstance(other, Order) and other.id == self.id
orders = {}
order = Order(1)
orders[order] = "оплачен"
print("сразу после вставки:", orders.get(order))
order.id = 2
print("после правки ключа:", orders.get(order))
try:
{[1, 2]: "список"}
except TypeError as e:
print("а список ключом быть не может:", e)
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Отсюда правило: ключи хеш-таблицы делают неизменяемыми, а __hash__ и __eq__ определяют вместе и по одним и тем же полям. Язык здесь подстраховывает: класс, в котором определили __eq__ и не определили __hash__, становится нехешируемым автоматически. Для данных берут @dataclass(frozen=True): он сам делает и __eq__, и __hash__ по всем полям и запрещает их менять. Контракт односторонний: равные по __eq__ объекты обязаны давать равный хеш, а неравные могут совпадать по хешу, это и есть коллизия.
Три мелочи, за которые платят в проде. Память. Компактный dict держит на запись индекс, хеш и две ссылки, около 30 байт, плюс запас по коэффициенту заполнения, плюс сами объекты ключа и значения; словарь int → int на миллион пар это около 40 МБ вместе с объектами чисел. Атрибуты объектов лежат в таких же словарях, поэтому __slots__ у класса с миллионом экземпляров экономит гигабайты. Отсутствующий ключ. d[k] бросает KeyError, d.get(k) возвращает None, и «ключа нет» от «значение None» отличают через k in d или d.get(k, default); для счётчиков и группировок есть collections.Counter и defaultdict, которые сами заводят запись. Потоки. Отдельная операция с dict атомарна благодаря глобальной блокировке интерпретатора, а составная «проверить и записать» нет; кеш между потоками закрывают threading.Lock, а самый частый кеш в Python вообще не пишут руками: functools.lru_cache.
Глубже: хеш строки и поиск подстрокирасширенное
Строка в бекенде самый частый ключ, и хешируют её по схеме Горнера: h = h * 31 + c по всем символам, так устроен String.hashCode() в Java; Go, Python и V8 хешируют строки иначе и солят хеш случайным числом, но схема Горнера у них живёт в другом месте. У той же формулы есть второе применение, поиск подстроки. Наивный поиск сравнивает образец с каждой позицией текста, O(N·M). Алгоритм Рабина и Карпа сравнивает сначала хеши: хеш окна длиной M пересчитывается при сдвиге на один символ за O(1), убрали вклад ушедшего символа, добавили новый, и только при совпадении хешей проверяют сами символы.
int find(String text, String pattern) {
int m = pattern.length(), n = text.length();
if (m > n) return -1;
long base = 256, mod = 1_000_000_007L, high = 1;
for (int i = 1; i < m; i++) high = high * base % mod; // base^(m-1)
long hp = 0, ht = 0;
for (int i = 0; i < m; i++) {
hp = (hp * base + pattern.charAt(i)) % mod;
ht = (ht * base + text.charAt(i)) % mod;
}
for (int i = 0; ; i++) {
if (hp == ht && text.regionMatches(i, pattern, 0, m)) return i;
if (i + m >= n) return -1;
ht = ((ht - text.charAt(i) * high % mod + mod) * base + text.charAt(i + m)) % mod;
}
}
func find(text, pattern string) int {
m, n := len(pattern), len(text)
if m > n {
return -1
}
const base, mod = 256, 1_000_000_007
high := 1
for i := 1; i < m; i++ {
high = high * base % mod // base^(m-1)
}
hp, ht := 0, 0
for i := 0; i < m; i++ {
hp = (hp*base + int(pattern[i])) % mod
ht = (ht*base + int(text[i])) % mod
}
for i := 0; ; i++ {
if hp == ht && text[i:i+m] == pattern {
return i
}
if i+m >= n {
return -1
}
ht = ((ht-int(text[i])*high%mod+mod)*base + int(text[i+m])) % mod
}
}
function find(text, pattern) {
const m = pattern.length, n = text.length;
if (m > n) return -1;
const base = 256, mod = 1_000_000_007;
let high = 1;
for (let i = 1; i < m; i++) high = high * base % mod; // base^(m-1)
let hp = 0, ht = 0;
for (let i = 0; i < m; i++) {
hp = (hp * base + pattern.charCodeAt(i)) % mod;
ht = (ht * base + text.charCodeAt(i)) % mod;
}
for (let i = 0; ; i++) {
if (hp === ht && text.startsWith(pattern, i)) return i;
if (i + m >= n) return -1;
ht = ((ht - text.charCodeAt(i) * high % mod + mod) * base + text.charCodeAt(i + m)) % mod;
}
}
def find(text: str, pattern: str) -> int:
m, n = len(pattern), len(text)
if m > n:
return -1
base, mod = 256, 1_000_000_007
high = pow(base, m - 1, mod) # base^(m-1)
hp = ht = 0
for i in range(m):
hp = (hp * base + ord(pattern[i])) % mod
ht = (ht * base + ord(text[i])) % mod
for i in range(n - m + 1):
if hp == ht and text[i:i + m] == pattern:
return i
if i + m >= n:
return -1
ht = ((ht - ord(text[i]) * high) * base + ord(text[i + m])) % mod
return -1
В среднем это O(N + M); худший случай остаётся O(N·M), если хеши совпадают постоянно, поэтому берут большой простой модуль. Сила приёма в том, что он ищет сразу много образцов одной длины: хеши образцов кладут в множество и сверяют с ним каждое окно, так работают детекторы дубликатов. Гарантированно линейный поиск одного образца даёт алгоритм Кнута, Морриса и Пратта: он заранее считает для образца префикс-функцию, длину наибольшего собственного префикса, равного суффиксу, и при несовпадении сдвигает образец не на один символ, а сразу на столько, сколько уже совпало. Библиотечный поиск подстроки обычно начинает с простого перебора: String.indexOf в JDK так и работает, а strings.Index в Go переключается на Рабина и Карпа, только когда перебор на длинном образце буксует; на обычных текстах простота обгоняет умные алгоритмы.
Глубже: вероятностные структуры: фильтр Блума и HyperLogLogрасширенное
Хеш-таблица отвечает «есть ли ключ» точно, но хранит сами ключи. Когда ключей миллиарды, а нужен только ответ «точно нет или, возможно, да», хранить их незачем. Фильтр Блума это битовый массив и k хеш-функций: при добавлении ключа ставят k битов, при проверке смотрят те же k. Хоть один бит нулевой, ключа точно не было; все единицы, ключ, возможно, есть, а возможно, биты выставили другие ключи. Ложные срабатывания есть, ложных пропусков нет, а памяти около 10 бит на ключ при одном проценте ошибок, вне зависимости от длины ключа.
Где это живёт: перед дорогим обращением к диску или базе (у RocksDB и Cassandra фильтр на каждом файле, чтобы не читать файл, где ключа заведомо нет), проверка «этот email уже регистрировался», защита кеша от запросов к несуществующим ключам. Удалять из фильтра нельзя, для этого есть счётные варианты.
HyperLogLog отвечает на другой вопрос: сколько уникальных значений прошло через поток. Точный ответ требует помнить все значения; HyperLogLog хеширует каждое, смотрит, сколько нулей в начале хеша, и запоминает максимум по группам. Логика простая: хеш с десятью нулями подряд встречается примерно раз на тысячу уникальных значений, значит, максимум нулей выдаёт порядок числа уникальных. Полтора килобайта дают оценку с ошибкой около двух процентов для миллиардов значений; так считают уникальных посетителей и PFCOUNT в Redis. Обе структуры меняют точность на память, и это честная сделка там, где ответ нужен «примерно, но сейчас».
Глубже: бор: поиск по приставке, которого хеш-таблица не умеетрасширенное
Подсказка в поиске каталога: пользователь набрал «мол», и нужно показать все товары, которые так начинаются. Хеш-таблица здесь беспомощна: хеш от «мол» ничем не связан с хешем от «молоко», и остаётся перебирать все ключи. Упорядоченная структура справляется: в TreeMap берут ceilingKey("мол") и идут по возрастанию, пока ключ начинается с приставки, это O(log N) на вход плюс шаг на каждое найденное слово. Но есть структура, сделанная ровно под эту задачу.
Бор (trie, префиксное дерево) хранит слова по буквам: корень это пустая строка, каждое ребро одна буква, узел помечен, если на нём заканчивается слово. Слова «молоко» и «молот» делят путь «моло» и расходятся на последней букве. Вставка и точный поиск стоят O(L), где L длина слова, и от числа слов не зависят вообще; все слова с приставкой это спуск по приставке за O(P) и обход поддерева, который отдаёт ровно найденные слова, без лишних сравнений. Цена: память. На каждую букву узел с таблицей переходов, и миллион слов занимает в боре в несколько раз больше, чем в хеш-таблице; сжатый бор (radix tree) склеивает цепочки узлов с одним потомком и возвращает часть памяти.
живой пример
import java.util.*;
public class TrieDemo {
static final class Node {
final Map<Character, Node> next = new HashMap<>();
boolean end;
}
static final class Trie {
private final Node root = new Node();
void add(String word) {
Node n = root;
for (char c : word.toCharArray()) n = n.next.computeIfAbsent(c, k -> new Node());
n.end = true;
}
List<String> withPrefix(String prefix) {
Node n = root;
for (char c : prefix.toCharArray()) {
n = n.next.get(c);
if (n == null) return List.of();
}
List<String> out = new ArrayList<>();
collect(n, new StringBuilder(prefix), out);
return out;
}
private void collect(Node n, StringBuilder path, List<String> out) {
if (n.end) out.add(path.toString());
for (var e : new TreeMap<>(n.next).entrySet()) {
path.append(e.getKey());
collect(e.getValue(), path, out);
path.deleteCharAt(path.length() - 1);
}
}
}
public static void main(String[] args) {
Trie trie = new Trie();
for (String w : List.of("молоко", "молот", "мост", "масло")) trie.add(w);
System.out.println(trie.withPrefix("мол"));
System.out.println(trie.withPrefix("мо"));
System.out.println(trie.withPrefix("мы"));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import (
"fmt"
"sort"
)
type node struct {
next map[rune]*node
end bool
}
func newNode() *node { return &node{next: map[rune]*node{}} }
type trie struct{ root *node }
func (t *trie) add(word string) {
n := t.root
for _, c := range word {
if n.next[c] == nil {
n.next[c] = newNode()
}
n = n.next[c]
}
n.end = true
}
func (t *trie) withPrefix(prefix string) []string {
n := t.root
for _, c := range prefix {
if n = n.next[c]; n == nil {
return nil
}
}
var out []string
collect(n, []rune(prefix), &out)
return out
}
func collect(n *node, path []rune, out *[]string) {
if n.end {
*out = append(*out, string(path))
}
keys := make([]rune, 0, len(n.next))
for c := range n.next {
keys = append(keys, c)
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
for _, c := range keys {
collect(n.next[c], append(path, c), out)
}
}
func main() {
t := &trie{root: newNode()}
for _, w := range []string{"молоко", "молот", "мост", "масло"} {
t.add(w)
}
fmt.Println(t.withPrefix("мол"))
fmt.Println(t.withPrefix("мо"))
fmt.Println(t.withPrefix("мы"))
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
class Trie {
constructor() { this.root = { next: new Map(), end: false }; }
add(word) {
let n = this.root;
for (const c of word) {
if (!n.next.has(c)) n.next.set(c, { next: new Map(), end: false });
n = n.next.get(c);
}
n.end = true;
}
withPrefix(prefix) {
let n = this.root;
for (const c of prefix) {
n = n.next.get(c);
if (!n) return [];
}
const out = [];
const collect = (node, path) => {
if (node.end) out.push(path);
for (const c of [...node.next.keys()].sort()) collect(node.next.get(c), path + c);
};
collect(n, prefix);
return out;
}
}
const trie = new Trie();
for (const w of ["молоко", "молот", "мост", "масло"]) trie.add(w);
console.log(trie.withPrefix("мол"));
console.log(trie.withPrefix("мо"));
console.log(trie.withPrefix("мы"));
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
class Trie:
def __init__(self):
self.root = {} # буква -> поддерево, "$" -> конец слова
def add(self, word):
node = self.root
for c in word:
node = node.setdefault(c, {})
node["$"] = True
def with_prefix(self, prefix):
node = self.root
for c in prefix:
node = node.get(c)
if node is None:
return []
out = []
def collect(n, path):
if "$" in n:
out.append(path)
for c in sorted(k for k in n if k != "$"):
collect(n[c], path + c)
collect(node, prefix)
return out
trie = Trie()
for w in ["молоко", "молот", "мост", "масло"]:
trie.add(w)
print(trie.with_prefix("мол"))
print(trie.with_prefix("мо"))
print(trie.with_prefix("мы"))
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Где это живёт: автодополнение и подсказки в поиске (бор хранит популярные запросы, а узлы кешируют топ-K для своей приставки), маршрутизация в сети (таблица маршрутов это бор по битам адреса, где ищут самое длинное совпадение), проверка орфографии и поиск набора слов в тексте за один проход (алгоритм Ахо и Корасик это бор с дополнительными ссылками). Правило выбора: нужен точный ключ, хеш-таблица; нужен порядок и диапазоны, дерево поиска; нужна приставка, бор.
Коротко
- Хеш-таблица превращает ключ в индекс массива хеш-функцией: доступ в среднем за
O(1), быстрее деревьев. - Коллизии неизбежны; разрешают их открытой адресацией (искать другую свободную ячейку: линейное, квадратичное пробирование, двойное хеширование) или методом цепочек (список в каждой ячейке).
- Коэффициент заполнения решает, когда расширять таблицу: порог от двух третей до семи восьмых смотря по реализации, дальше массив удваивается и все ключи раскладываются заново —
O(n). - Хеш-функция должна быть быстрой и равномерной. При делении с остатком размер берут простым числом, а при размере в степень двойки старшие биты хеша подмешивают к младшим.
- Плата за скорость — нет порядка, бесполезна без точного ключа и требует запаса памяти.
- Ключи делают неизменяемыми, а хеш и равенство ключа определяют по одним и тем же полям — иначе запись теряется в старой корзине.
- Хеш строки по Горнеру даёт и поиск подстроки (Рабин-Карп); рядом живут вероятностные структуры: фильтр Блума отвечает «точно нет или возможно да» за 10 бит на ключ, HyperLogLog считает уникальные с ошибкой 2 %; бор хранит слова по буквам за длину слова - структура автодополнения.
new HashMap<>(100_000)даёт порог 98 304 и ещё одну перестройку: ёмкость округляется до степени двойки; нужноHashMap.newHashMap(100_000)или(int)(n / 0.75) + 1.- Контракт односторонний: равные по
equalsобязаны давать равный хеш, обратное не требуется; узелHashMapстоит около 32 байт плюс корзины, одинnull-ключ разрешён, а для потоков берутConcurrentHashMap.
make(map[K]V, n)принимает ожидаемое число записей; изnil-карты можно читать, запись паникует; с Go 1.24 внутри швейцарская таблица с открытой адресацией и случайной солью, а обход нарочно начинается со случайного места.- Ключ только сравнимого типа: структура копируется при вставке, указатель сравнивается по адресу; одновременные записи из горутин роняют процесс фатальной ошибкой, для них
sync.RWMutexилиsync.Map.
Mapобходится в порядке вставки и сравнивает ключи по SameValueZero: объекты по ссылке,NaNравенNaN; составной ключ собирают в строку; ёмкость заранее не задать.- Объект
{}как словарь приводит ключи к строкам и тянет прототип: для чужих ключейObject.create(null)илиMap;getне отличает отсутствие отundefined, для этогоhas.
dictс 3.6 компактный и обходится в порядке вставки, порог 2/3, ёмкость заранее не задать; хеш строк солится при старте интерпретатора;hash(5) == 5.- Ключ только хешируемый: список и словарь отвергаются, свой класс с
__eq__без__hash__нехешируем сам;__hash__и__eq__по одним полям, лучше@dataclass(frozen=True); отдельная операция атомарна, составная нет.
Что почитать дальше
- Пирамиды (кучи) — структура, которая мгновенно отдаёт наибольший элемент и держит приоритетную очередь.
- Красно-чёрные деревья — чем платят за порядок, которого у хеш-таблицы нет: упорядоченная карта вместо хеш-таблицы.
- Связанные списки — то, из чего собраны цепочки внутри корзин.
- Как выбрать структуру данных — когда таблица, когда дерево, когда обычный массив.