1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6

# теория · шаг 2 из 6

Открытая адресация: искать свободное место рядом

Коротко

Открытая адресация хранит ключи ПРЯМО в ячейках массива, без списков. Если ячейка h(ключ) занята, ищут следующую свободную по фиксированному правилу - пробированию. Простейшее - линейное: h, h+1, h+2, … по кругу. Поиск идёт по той же цепочке проб, пока не найдёт ключ или пустую ячейку (пустая = «дальше искать незачем»). Плюс - вся таблица в одном массиве, отличная локальность памяти (кеш любит), никаких указателей. Минусы - α < 1 обязателен (в занятую таблицу вставить некуда), удаление хитрое (нельзя просто опустошить ячейку - порвёшь цепочку проб), и кластеризация: занятые ячейки слипаются в длинные сгустки, замедляя пробы. Лечится это лучшими пробированиями - квадратичным и двойным хешированием.

Если ты умеешь вставлять и искать линейным пробированием, понимаешь, почему α < 1 обязателен, почему удаление требует «надгробий» и что такое кластеризация (и как её лечат квадратичное/двойное хеширование), листай до «Теперь сам».

Как думаешь, можно ли обойтись без списков в ячейках

Цепочки просты, но за каждым ключом тянется узел списка - лишняя память и прыжок по куче (процессор не любит: данные разбросаны, кеш промахивается). А что если хранить ключи прямо в массиве, как обычные элементы, - плотно, без указателей? Тогда встаёт вопрос: куда деть ключ, если его ячейка h(ключ) уже занята другим? Ответ открытой адресации - положить в БЛИЖАЙШУЮ свободную по заранее оговорённому правилу. А при поиске идти по тому же правилу, проверяя ячейки одну за другой, пока не встретим искомый ключ или пустоту. Всё в одном массиве, вплотную - но за компактность придётся заплатить новыми сложностями.

На что это похоже

Парковка, где у тебя есть «своё» любимое место (номер h(ключ)). Приехал - место занято. Ты не уезжаешь и не строишь пристройку (это были бы цепочки) - ты едешь к следующему свободному: +1, ещё занято - +2, и так по кругу, пока не встанешь. Когда тебя ищут, делают то же самое: смотрят твоё место, потом следующее, следующее - пока не найдут твою машину или пустое место (пустое значит «дальше он бы не поехал, его тут нет»). Беда в том, что у популярного въезда машины скапливаются в плотный ряд, и объезжать его долго - это и есть кластеризация.

Механизм: линейное пробирование

Ключи лежат в самом массиве. При коллизии пробуем следующие ячейки по кругу:

вставить(ключ):
    i = h(ключ)
    пока ячейка i занята (и не тем же ключом):
        i = (i + 1) mod m          # линейная проба: следующая по кругу
    положить ключ в ячейку i

найти(ключ):
    i = h(ключ)
    пока ячейка i не пуста:
        если в ней ключ - нашли
        i = (i + 1) mod m
    вернуть «нет»                    # дошли до ПУСТОЙ - ключа нет

Пустая ячейка при поиске означает «стоп, дальше его быть не может» - если бы ключ был, при вставке он занял бы эту самую пустую ячейку раньше. Отсюда жёсткое требование: α < 1, хотя бы одна ячейка всегда пуста, иначе поиск несуществующего ключа зациклится, а вставке некуда класть. При равномерном пробировании среднее число проб на неудачный поиск - около 1/(1 − α): при α = 0.5 это 2, при α = 0.9 - уже 10. Поэтому открытую адресацию держат заметно недозаполненной (α ≤ 0.7).

0123456 AB ·· ··· h=2 занята 3 занята 4 свободна ← кластер (A,B слиплись) удлиняет пробы: 2 → 3 → 4
Ключ метит в ячейку 2, но она и 3 заняты (кластер), поэтому он садится в 4. Слипшиеся занятые ячейки - кластеризация - удлиняют цепочки проб для всех, кто в них метит.

Правило

Открытая адресация хранит ключи прямо в массиве; при коллизии ищет свободную ячейку по правилу ПРОБИРОВАНИЯ (линейное: h, h+1, h+2, … по кругу). Поиск идёт по той же цепочке до ключа или до ПУСТОЙ ячейки (пустая = «дальше нет»). Требует α < 1; среднее число проб на неудачный поиск ≈ 1/(1 − α). Достоинство - компактность и локальность (всё в массиве, кеш доволен, нет указателей). Недостатки - кластеризация, хитрое удаление и обязательный запас свободных ячеек.

Разбор: почему удаление требует «надгробий»

Удаление в открытой адресации - коварная ловушка. Нельзя просто опустошить ячейку удаляемого ключа: она стоит в чьей-то цепочке проб, и пустота посреди цепочки оборвёт поиск. Пример: A лёг в ячейку 2, B метил туда же и по пробе сел в 3. Удалим A, опустошив ячейку 2. Теперь ищем B: h(B) = 2, ячейка 2 ПУСТА - поиск говорит «B нет», хотя B рядом, в ячейке 3. Цепочка порвана. Лечат это надгробием (tombstone) - особой пометкой «удалено, но не пусто»: поиск проходит сквозь надгробие дальше (цепочка цела), а вставка может занять надгробие повторно. Расплата - надгробия копятся, удлиняют пробы, и таблицу приходится периодически перестраивать (рехешировать), очищая их. В цепочках такого нет вовсе: удалил узел из списка - и всё. Это ещё один налог открытой адресации за компактность.

Разбор: почему кластеризация замедляет линейное пробирование

Линейное пробирование страдает первичной КЛАСТЕРИЗАЦИЕЙ - занятые ячейки слипаются в длинные сгустки, и это самоусиливается. Смотри: чем длиннее сгусток занятых подряд ячеек, тем больше хешей в него метит (любой h, попавший в начало или середину сгустка, докатится до его конца), тем чаще к нему добавляется новый ключ - и сгусток растёт ещё быстрее. Большие кластеры притягивают ключи сильнее маленьких, богатые богатеют. В итоге даже при умеренном α пробы становятся длинными не потому, что таблица полна, а потому что занятость собрана в комки. Лечат это, разрывая линейность: - квадратичное пробирование: пробы h+1², h+2², h+3², … - шаги растут, ключи не липнут в один ряд (но остаётся вторичная кластеризация: одинаковый h даёт одинаковую последовательность проб); - двойное хеширование: шаг пробы задаёт ВТОРАЯ хеш-функция, h₁ + i·h₂(ключ) - у разных ключей разные шаги, кластеры почти исчезают, это лучшее из простых пробирований.

Что ломается без этого

Первая ошибка - допустить α = 1 (заполнить таблицу под завязку): вставить некуда, а поиск несуществующего ключа зациклится (нет пустой ячейки, чтобы остановиться). Держи α < 1, обычно ≤ 0.7. Второе - удалять ключ, просто опустошая ячейку: порвёшь чужие цепочки проб, и рядом лежащие ключи «пропадут». Нужны надгробия. Третье - линейное пробирование на высоком α без нужды: кластеризация сделает пробы длинными; при плотном заполнении бери двойное хеширование. Четвёртое - при поиске остановиться на надгробии как на пустой ячейке: сквозь надгробие надо идти дальше, иначе не найдёшь ключи за ним.

Зачем это в работе

Открытая адресация - выбор большинства СОВРЕМЕННЫХ быстрых хеш-таблиц: dict в Python (с хитрым пробированием), хеш-множества в Rust, flat_hash_map и подобные в C++. Причина - локальность памяти: все ключи лежат вплотную в одном массиве, процессор читает их из кеша пачками, тогда как цепочки прыгают по указателям и промахиваются мимо кеша. На современном железе это часто перевешивает теоретическую простоту цепочек. Понимать открытую адресацию - значит знать, почему dict так быстр, почему его нельзя набивать под завязку (α держат низким), и почему удаление из хеш-таблицы иногда «оставляет мусор» (надгробия), требующий периодической уборки. А знание про кластеризацию и двойное хеширование объясняет, почему авторы библиотек так возятся с выбором функции пробирования - от неё напрямую зависит скорость при высоком заполнении.

Теперь сам

Таблица открытой адресации, m = 7, h(k) = k mod 7, линейное пробирование. Вставляем 10, 17, 3. Куда ляжет каждый? Почему нельзя удалить 10, просто опустошив его ячейку?

Ответ. 10 mod 7 = 3 - ячейка 3 свободна, 10 садится в 3. 17 mod 7 = 3 - ячейка 3 занята десяткой, пробуем 4 - свободна, 17 садится в 4. 3 mod 7 = 3 - ячейка 3 занята, 4 занята (там 17), пробуем 5 - свободна, 3 садится в 5. Итог: ячейка 3 - 10, ячейка 4 - 17, ячейка 5 - 3; получился кластер из трёх подряд. Удалить 10, опустошив ячейку 3, нельзя: и 17, и 3 метили в ячейку 3 (h = 3 у всех троих) и стоят в цепочке проб, начинающейся с 3. Опустошим 3 - и поиск 17 (h = 3, ячейка 3 теперь ПУСТА) решит, что 17 нет, хотя он в ячейке 4. Цепочка порвётся. Поэтому вместо опустошения ставят надгробие: поиск проходит сквозь него к 17 и 3, а новая вставка может занять его место.

Главное

Открытая адресация хранит ключи ПРЯМО в массиве; при коллизии ищет свободную ячейку по правилу пробирования (линейное h, h+1, … по кругу; квадратичное h+i²; двойное h₁ + i·h₂). Поиск идёт по той же цепочке до ключа или до ПУСТОЙ ячейки. Требует α < 1 (обычно ≤ 0.7), среднее число проб ≈ 1/(1 − α). Достоинство - компактность и локальность памяти (кеш, нет указателей), оттого её выбирают быстрые современные dict. Цена: удаление требует НАДГРОБИЙ (пустота порвёт цепочку проб), а линейное пробирование страдает КЛАСТЕРИЗАЦИЕЙ (занятые слипаются, сгустки растут самоусиливаясь) - лечится квадратичным и особенно двойным хешированием.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.