Learning Platform
Глоссарий Troubleshooting
Урок 09.04 · 28 мин
Начальный
collision resolutionchainingopen addressingcache localityJava HashMap

Коллизии неизбежны — что делать

Из прошлых уроков вы знаете: хеш-функция превращает ключ в число, число выбирает слот, в слоте лежит пара (key, value). Но два разных ключа могут попасть в один слот. Это коллизия. Не «может случиться» — а «обязательно случится» при достаточном числе ключей.

Hash-таблица должна уметь хранить несколько пар в одном слоте и при lookup’е находить нужную. Есть две принципиально разные стратегии:

  1. Chaining — в каждом слоте хранится связный список (или дерево) всех ключей с этим хешем.
  2. Open addressing — каждый слот вмещает одну пару, при коллизии ищем следующий свободный слот по детерминированной формуле.

Это главный архитектурный выбор при реализации hash-таблицы. Java HashMap и Go map используют chaining. Python dict и Rust HashMap — open addressing. Каждый выбор имеет свои pro и contra.

Chaining vs open addressing — общая идея

Chaining — список в слоте. Open addressing — пробирование по массиву. Структура хранения принципиально разная.

chainingJava HashMap, Go map, C++ unordered_map
slot 0[]указатель на связный список
slot 1[(k1,v1) -> (k4,v4)]два ключа с одинаковым хешем mod size
slot 2[(k2,v2)]один ключ
slot 3[]
slot 4[(k3,v3)]
open addressingPython dict, Rust HashMap, robin-hood hashing
slot 0empty
slot 1(k1, v1)первый ключ занял свой слот
slot 2(k4, v4)хотел в slot 1, но занято — занял следующий по пробированию
slot 3(k2, v2)
slot 4empty
slot 5(k3, v3)

Chaining: связные списки в слотах

Идея: каждый слот хранит указатель на список всех элементов с этим хешем. При коллизии новый элемент просто добавляется в этот список.

# схематическая Python-имплементация (упрощённая)
class ChainedHashMap:
    def __init__(self, size=16):
        self.size = size
        self.slots = [[] for _ in range(size)]   # каждый слот — list

    def put(self, key, value):
        idx = hash(key) % self.size
        bucket = self.slots[idx]
        for i, (k, v) in enumerate(bucket):
            if k == key:
                bucket[i] = (key, value)   # обновление
                return
        bucket.append((key, value))         # вставка нового

    def get(self, key):
        idx = hash(key) % self.size
        for k, v in self.slots[idx]:
            if k == key:
                return v
        raise KeyError(key)

Java HashMap делает то же самое, с одной оптимизацией: если в bucket’е накапливается больше 8 элементов, связный список превращается в сбалансированное дерево (red-black). Это спасает от деградации до O(n) при патологических коллизиях — становится O(log n).

Pro chaining

  1. Простая логика: вставка всегда работает (даже когда load factor = 1.5).
  2. Никогда не нужно resize’ить в момент вставки — список просто растёт.
  3. Удаление простое: убрать элемент из списка.
  4. Tolerant к высокому load factor: можно запихнуть n=2*capacity и работает (хуже, но работает).

Contra chaining

  1. Cache-unfriendly: каждый узел списка — отдельный malloc, размещён в случайной части heap. CPU prefetcher не угадывает следующий узел.
  2. Memory overhead: на каждую пару нужно ~16-32 байта на указатель + аллокация узла.
  3. Лишний indirection: чтобы прочитать значение, нужно сначала dereference указатель на bucket, потом обойти список.

Open addressing: пробирование

Идея: один слот = одна пара. При коллизии ищем следующий свободный слот по формуле пробирования. Если массив заполнен — resize.

# схематическая упрощённая имплементация
class OpenAddressedMap:
    def __init__(self, size=8):
        self.size = size
        self.slots = [None] * size

    def put(self, key, value):
        idx = hash(key) % self.size
        while True:
            slot = self.slots[idx]
            if slot is None or slot[0] == key:
                self.slots[idx] = (key, value)
                return
            idx = (idx + 1) % self.size   # linear probing

    def get(self, key):
        idx = hash(key) % self.size
        while True:
            slot = self.slots[idx]
            if slot is None:
                raise KeyError(key)
            if slot[0] == key:
                return slot[1]
            idx = (idx + 1) % self.size

Это linear probing — простейшая форма пробирования: при коллизии идём в следующий слот. Реальный Python dict использует более хитрую формулу с perturbation (увидим в модуле 9), но идея та же.

Pro open addressing

  1. Cache-friendly: все данные в одном непрерывном массиве. CPU prefetch’ит следующие слоты.
  2. Меньше памяти: нет указателей, нет аллокаций узлов. На пару нужно столько, сколько занимает слот (16-32 байта на 64-битной машине).
  3. Меньше indirection: данные читаются прямо из массива, никаких dereferences.

Contra open addressing

  1. Нужно resize’ить раньше: при load factor больше 2/3 (Python) или 0.5-0.75 деградация наступает быстро.
  2. Сложное удаление: нельзя просто очистить слот, иначе lookup сломается. Используют tombstone маркер.
  3. Кластеризация: linear probing накапливает «комки» занятых слотов, что увеличивает число проб.

Где быстрее на практике

Современный CPU сильнее всего страдает от cache miss: доступ к L1 кешу — 1 ns, к RAM — 100 ns. Разница в 100 раз. Поэтому cache locality часто важнее теоретической сложности.

Open addressing выигрывает там, где данные помещаются в кеш или последовательно prefetch’ятся. Chaining выигрывает там, где load factor высокий и таблица плотно набита.

Цена операции lookup в hash-таблице

Главное — сколько cache miss'ов. Все цифры в наносекундах на M-class CPU.

chaining lookupJava HashMap, типичный случай
L1 hit на bucket1-2 nsпрочитать слот массива
malloc'd node read50-100 nsузел списка не в кеше — RAM access
итог при 0 коллизий~60 nsодна цепочка одного узла
итог при 3 коллизий~200 nsтри RAM-read'а
open addressing lookupPython dict, типичный случай
L1 hit на slot1-2 ns
prefetched probingдополнительно ~1 ns на слотCPU prefetcher тащит следующие слоты в кеш
итог при 0 коллизий~10-20 nsхеш + одно чтение
итог при 3 коллизий~25-30 nsтри последовательных чтения из кеша

В типичных условиях (load factor 50-66%) open addressing в 3-5 раз быстрее chaining’а из-за cache locality. Это и есть главная причина, почему Python dict — open addressing.

Память: tradeoffs

Память тоже отличается принципиально:

Chaining (Java HashMap), 1M ключей:
- Массив указателей: 1.33M × 8 = ~10 MB
- Node объекты: 1M × ~48 байт (key, value, next, hash) = ~48 MB
- Итого: ~58 MB

Open addressing (Python dict), 1M ключей:
- Слоты: 1.5M × 24 байта (hash, key, value) = ~36 MB
- Итого: ~36 MB

Open addressing экономит ~40% памяти на хеш-таблице. На больших датасетах это значимо.

Но! Python dict дополнительно использует compact representation с 3.6+ — два массива вместо одного: индексная таблица (плотная) + entries (плотный массив пар). Это экономит ещё 20-25% памяти. Об этом — в модуле 9.

Resize: оба подхода

И chaining, и open addressing требуют resize при переполнении. Логика одинакова:

  1. Создать новый массив (обычно в 2 раза больше).
  2. Перехешировать все ключи и положить в новые слоты.
  3. Освободить старый массив.

Стоимость resize O(n) на размер таблицы. Но в chaining можно работать с большим load factor (до 1.0+), а в open addressing — нужно начинать resize при 0.66-0.75. Поэтому chaining делает resize реже, но open addressing работает быстрее между resize’ами.

Что выбирают реальные библиотеки

Выбор стратегии в популярных реализациях

Современные библиотеки склоняются к open addressing из-за cache locality. Chaining остаётся в Java по историческим причинам.

chaining
Java HashMapchaining + treeс Java 8 — bucket >8 элементов превращается в red-black tree
Go mapchaining (bucket = 8 пар)hybrid: каждый bucket это маленький массив на 8 пар, потом overflow в следующий bucket
C++ unordered_mapchainingпо стандарту требуется bucket interface
open addressing
Python dictopen addr + perturbationкомпактное представление с 3.6, probing с perturbation
Rust HashMaprobin-hood hashingopen addressing с reordering для минимизации варианса proba length
Swift Dictionaryopen addressing
ClickHouse hash tablesopen addressingнесколько вариантов для разных load patterns

Попробуй сам

Реализуйте простую open-addressing hash-таблицу и сравните её с Python dict:

class SimpleHashMap:
    def __init__(self, size=8):
        self.size = size
        self.slots = [None] * size
        self.n = 0

    def put(self, key, value):
        if self.n / self.size > 0.66:
            self._resize()
        idx = hash(key) % self.size
        while self.slots[idx] is not None and self.slots[idx][0] != key:
            idx = (idx + 1) % self.size
        if self.slots[idx] is None:
            self.n += 1
        self.slots[idx] = (key, value)

    def get(self, key):
        idx = hash(key) % self.size
        while self.slots[idx] is not None:
            if self.slots[idx][0] == key:
                return self.slots[idx][1]
            idx = (idx + 1) % self.size
        raise KeyError(key)

    def _resize(self):
        old = self.slots
        self.size *= 2
        self.slots = [None] * self.size
        self.n = 0
        for slot in old:
            if slot is not None:
                self.put(slot[0], slot[1])

# теперь сравните
import time

m = SimpleHashMap()
start = time.perf_counter()
for i in range(100_000):
    m.put(i, i * 2)
print(f"SimpleHashMap: {(time.perf_counter()-start)*1000:.0f} ms")

d = {}
start = time.perf_counter()
for i in range(100_000):
    d[i] = i * 2
print(f"Python dict:   {(time.perf_counter()-start)*1000:.0f} ms")

Ожидаемо: Python dict в 10-20 раз быстрее, потому что:

  • Реализован на C, не на Python.
  • Использует perturbation вместо linear probing — меньше кластеризации.
  • Имеет compact representation.

Но архитектурно оба используют open addressing — это та же идея.

NOTE

Python dict — open addressing с perturbation. Это означает: один слот = одна пара, при коллизии прыгаем по формуле, при переполнении 2/3 — resize. Java HashMap делает иначе: chaining со списками (с Java 8 — с tree-fallback). Оба подхода работают, но open addressing на современных CPU быстрее из-за cache locality.

В следующем уроке разберём load factor — главную метрику hash-таблицы. Что происходит при достижении 2/3, почему Python именно 2/3, а не 0.75, как Java.

Проверка знанийKnowledge check
Java HashMap использует chaining (список в bucket), Python dict — open addressing (пробирование). Какая из двух реализаций обычно быстрее на одинаковых данных и почему?
ОтветAnswer
Open addressing (Python dict) обычно быстрее в 3-5 раз для типичного load factor. Главная причина — cache locality. В open addressing все данные хранятся в одном непрерывном массиве: при пробировании CPU prefetcher автоматически тащит соседние слоты в L1-кеш, и каждый дополнительный шаг probing стоит ~1 ns. В chaining каждый узел списка — это отдельный malloc, расположенный в случайной части heap; чтение узла после bucket требует RAM-доступа (~100 ns против 1-2 ns L1). Дополнительный плюс open addressing — меньше памяти (нет указателей, нет хедера у каждой пары). Минусы: нужно делать resize раньше (при 0.66-0.75 load factor), удаление через tombstone сложнее. Но эти издержки на современных CPU перевешиваются выигрышем от cache locality.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. В чём принципиальное отличие chaining от open addressing при разрешении коллизий?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5