Хеш-таблица
Предпосылки: массив (индексация, формула адреса), связный список (для стратегии цепочек).
Все рассмотренные структуры — массив, список, стек, очередь, дек — работают с позицией: индекс, начало, конец. Ключ доступа всегда число. Но данные часто адресуются иначе: веб-сервер хранит активные сессии по уникальному идентификатору, роутер маппит URL на обработчик, DNS-резолвер кэширует домены. Искать строковый ключ перебором массива — O(n). Хеш-таблица даёт O(1) доступ по произвольному ключу.
Хеш-таблица реализует словарь — коллекцию пар ключ-значение:
| Операция | Сигнатура | Сложность |
|---|---|---|
| Получить значение | get(key) → value | O(1) в среднем |
| Записать значение | put(key, value) | O(1) в среднем |
| Удалить | delete(key) | O(1) в среднем |
| Проверить наличие | contains?(key) → bool | O(1) в среднем |
“В среднем” — потому что в худшем случае, при патологическом распределении ключей, все операции деградируют до O(n). Откуда берётся этот худший случай и как его избежать — об этом дальше.
От ключа к индексу
Слово “hash” пришло из кулинарии — буквально “рубить, крошить, мелко резать”. Хеш-функция “рубит” входные данные, перемешивает и выдаёт число, в котором исходная структура неузнаваема. Это число используется как индекс в массиве — и вот уже произвольный ключ превращается в O(1) доступ.
Хеш-функция: требования
Хеш-функция должна быть детерминированной (один вход → всегда один и тот же выход), равномерной (выходы распределены равномерно) и быстрой — не хуже O(длина ключа). Криптостойкость и уникальность выхода не требуются.
Пример хеш-функции для строки
Плохая (не учитывает порядок):
def bad_hash(str)
str.chars.sum(&:ord) # "ab" и "ba" дадут одинаковый хеш
endХорошая (классическая, как в Java):
def good_hash(str)
hash = 0
str.each_char { |c| hash = hash * 31 + c.ord }
hash
endУмножение на 31 делает хеш зависимым от порядка символов. Почему именно 31: это нечётное простое число, а умножение на него компилятор может заменить на (x << 5) - x — быстрый сдвиг вместо дорогого умножения.
"ab": (0*31 + 97)*31 + 98 = 3105
"ba": (0*31 + 98)*31 + 97 = 3135
От хеша к индексу
index = hash(key) % capacityЕсли capacity = 16 и hash("alice") = 3105, то index = 3105 % 16 = 1.
Индекс должен попадать в диапазон 0...capacity. В Ruby оператор % уже возвращает неотрицательный остаток, но в некоторых языках отрицательный hash может дать отрицательный результат — тогда индекс нормализуют.
Коллизия
Данных бесконечно много, индексов — ограниченное количество. Разные ключи могут получить одинаковый индекс. Это коллизия (столкновение). Две сессии с UUID "abc123" и "xyz789" могут дать одинаковый hash % capacity — и обе претендуют на одну ячейку. Нужна стратегия разрешения.
Стратегия 1: Chaining (цепочки)
Каждая ячейка хранит список (цепочку) элементов с одинаковым индексом:
┌───┬─────────────────────────────────────┐
│ 0 │ [("bob", data)] │
│ 1 │ [("alice", data) → ("charlie", data)]│ <-- коллизия
│ 2 │ [] │
└───┴─────────────────────────────────────┘
Ячейка хранит и ключ, и значение — ключ нужен, чтобы при коллизии отличить «свой» элемент от чужого. Без ключа get("alice") и get("charlie"), попавшие в одну ячейку, вернули бы одно и то же значение.
get(key): вычисляем индекс, идём по списку, сравниваем ключи.
put(key, value): вычисляем индекс, ищем в списке, обновляем или добавляем.
Пока элементов мало, цепочки короткие и get работает быстро. Но по мере заполнения таблицы цепочки растут — каждый get проходит по списку, и средняя длина цепочки определяет реальную стоимость операции.
Стратегия 2: Open Addressing (открытая адресация)
В open addressing весь массив “открыт” для размещения любого элемента, не только того, чей хеш указывает на эту ячейку. Все элементы живут прямо в массиве. Если ячейка занята — ищем следующую свободную.
Linear Probing: если ячейка i занята, пробуем i+1, i+2, …
put("alice"), hash % 8 = 1 → кладём в 1
put("charlie"), hash % 8 = 1 → занято, пробуем 2, кладём
put("dave"), hash % 8 = 2 → занято, пробуем 3, кладём
Занятые ячейки имеют свойство слипаться в группы — кластеры. Новый элемент, попавший хешем в любую точку кластера, добавляется в его конец — и кластер растёт. Два соседних кластера сливаются в один большой. Средняя длина поиска резко растёт по мере заполнения таблицы: для линейного пробинга при α = n / capacity (где n — количество элементов, capacity — размер массива) оценки такие:
- успешный поиск:
≈ (1/2) × (1 + 1/(1-α)) - неуспешный поиск:
≈ (1/2) × (1 + 1/(1-α)^2)
При α = 0.9 это уже порядка ~5–6 проб для успешного поиска и ~50 проб для неуспешного. Quadratic probing (i+1, i+4, i+9, ...) и double hashing уменьшают кластеризацию, но усложняют код.
Tombstones (надгробия)
Допустим, hash("alice") % 8 = 1 — alice в ячейке 1. hash("charlie") % 8 = 1 — ячейка 1 занята, линейная проба кладёт charlie в ячейку 2. Удаляем alice, ставим nil в ячейку 1. Теперь get("charlie"): вычисляем индекс hash % 8 = 1, смотрим ячейку 1 — nil — поиск прекращается. Возвращаем «не найдено», хотя charlie в ячейке 2.
Поэтому вместо nil ставят tombstone — специальный маркер («надгробие» на месте удалённого элемента). При поиске tombstone означает «продолжай искать», при вставке — «можно занять».
Деградация и load factor
И chaining, и open addressing деградируют по мере заполнения таблицы: цепочки удлиняются, кластеры сливаются, O(1) уползает к O(n). Чтобы количественно оценить степень заполнения, используют load factor (коэффициент заполнения):
load_factor = n / capacity
Где n — количество элементов, capacity — размер массива.
Для chaining средняя длина цепочки равна load factor: при lf = 3 каждый get в среднем проходит по 3 узлам. Load factor может превышать 1 — цепочки допускают любую длину. Для open addressing load factor обязан быть меньше 1 — физически нельзя разместить больше элементов, чем ячеек. На практике порог держат на уровне 0.7–0.75: выше этого кластеры растут лавинообразно.
Rehashing (перехеширование)
Когда load factor пересекает порог, таблицу перестраивают — выделяют новый массив большего размера и перехешируют все элементы:
- Выделяем новый массив большего размера (×2)
- Перехешируем все элементы (hash % новый_capacity ≠ hash % старый_capacity)
- Освобождаем старый массив
До (capacity=4): После (capacity=8):
│ 0 │ [A → B] │ │ 0 │ [B] │
│ 1 │ [C] │ → │ 2 │ [A] │
│ 2 │ [] │ │ 3 │ [C] │
│ 3 │ [D] │ │ 5 │ [D] │
hash % 4 и hash % 8 дают разные индексы — элементы перераспределяются, цепочки укорачиваются.
Стоит O(n), но происходит редко → амортизированно O(1) на операцию.
Реализация хеш-таблицы с chaining (Ruby)
class HashTable
LOAD_FACTOR_THRESHOLD = 0.75
def initialize(capacity = 8)
@buckets = Array.new(capacity)
@size = 0
end
def put(key, value)
resize if (@size + 1).to_f / @buckets.length > LOAD_FACTOR_THRESHOLD
index = bucket_index(key)
entry = find_entry(index, key)
if entry
entry[1] = value
else
@buckets[index] ||= []
@buckets[index] << [key, value]
@size += 1
end
end
def get(key)
entry = find_entry(bucket_index(key), key)
entry&.[](1)
end
def delete(key)
index = bucket_index(key)
chain = @buckets[index]
return nil unless chain
i = chain.index { |k, _| k == key }
return nil unless i
@size -= 1
chain.delete_at(i)[1]
end
private
def bucket_index(key)
key.hash % @buckets.length
end
def find_entry(index, key)
chain = @buckets[index]
return nil unless chain
chain.find { |k, _| k == key }
end
def resize
old_buckets = @buckets
@buckets = Array.new(old_buckets.length * 2)
@size = 0
old_buckets.each do |chain|
next unless chain
chain.each { |key, value| put(key, value) }
end
end
endКаждая ячейка (bucket) — массив пар [key, value]. При resize все элементы перехешируются: hash % новый_capacity даёт другие индексы, поэтому цепочки перераспределяются.
Сложность операций
| Операция | В среднем | В худшем |
|---|---|---|
| get | O(1) | O(n) |
| put | O(1)* | O(n) |
| delete | O(1) | O(n) |
* — амортизированно с учётом rehashing
Худший случай наступает, когда все ключи попадают в одну ячейку — из-за плохой хеш-функции или намеренно подобранных злоумышленником ключей.
Сравнение стратегий
| Аспект | Chaining | Open Addressing |
|---|---|---|
| Память | Указатели на списки | Только массив |
| Локальность | Хуже | Лучше |
| Load factor | Может быть > 1 | Должен быть < 1 |
| Удаление | Простое | Нужны tombstones |
| Код | Проще | Сложнее |
На практике реализации часто комбинируют обе стратегии. Ruby Hash использует адаптивный подход: маленькие хеши (≤8 пар) хранятся в компактном массиве с линейным поиском, а при росте конвертируются в полноценную хеш-таблицу с open addressing — подробности в заметке о внутренностях Ruby Hash.
Проблема: нет порядка для вытеснения
O(1) доступ по ключу — есть. Но хеш-таблица сама по себе не даёт порядка «по давности использования». Даже если конкретная реализация сохраняет порядок вставки, LRU зависит от обращений, а не от вставок.
Для кэша с ограниченным размером нужно вытеснять элементы. Какие? Нужна политика вытеснения — и структура, которая её поддерживает. LRU-кэш комбинирует хеш-таблицу с двусвязным списком для решения этой задачи.
Sources
- Donald E. Knuth, The Art of Computer Programming, Vol. 3: Sorting and Searching — оценки для линейного пробинга (linear probing).