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

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

Цепочки: в каждой ячейке - список

Коротко

Раз коллизии неизбежны, таблице нужен способ хранить НЕСКОЛЬКО ключей в одной ячейке. Метод цепочек (chaining) - самый прямой: каждая ячейка хранит не один ключ, а СПИСОК всех, кто в неё попал. Вставка - добавить в список ячейки h(ключ) (O(1)); поиск - вычислить h(ключ) и пройти по короткому списку этой ячейки. При равномерной функции списки в среднем длиной α = n/m, поэтому поиск - O(1 + α): пока α мал, это почти константа. Цепочки просты, допускают α > 1 (списки просто длиннее) и не портятся от заполнения - за это платят памятью на указатели списков.

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

Как думаешь, куда деть второй ключ, попавший в занятую ячейку

Коллизия случилась: h(8) = h(15) = 1, ячейка 1 уже занята восьмёркой, а надо положить и пятнадцать. Выбросить восьмёрку нельзя - потеряем данные. Класть пятнадцать в другую ячейку? Тогда при поиске мы посчитаем h(15) = 1, посмотрим ячейку 1, там восьмёрка - и решим, что пятнадцати нет, хотя она есть. Значит всё, что имеет один номер, должно лежать ТАМ ЖЕ, где его будут искать - в ячейке 1. Но ячейка одна, а ключей два. Простейший выход - пусть ячейка хранит не один ключ, а СПИСОК: и восьмёрку, и пятнадцать, и всех, кто ещё сюда попадёт. Поиск тогда - зайти в ячейку и пройти её список.

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

Многоквартирный дом с почтовыми ящиками, подписанными по первой букве фамилии. Все жильцы на «К» - Кузнецов, Ковалёв, Кириллов - бросают письма в ОДИН ящик «К». Почтальон не мечется по дому: он открывает ящик «К» и перебирает небольшую стопку писем внутри, пока не найдёт нужное. Ящиков всего 33 (по буквам), жильцов сотни - но в каждом ящике лежит лишь горстка. Ячейка хеш-таблицы с цепочкой - этот самый ящик: номер (буква) приводит к ящику мгновенно, а внутри - короткий список, который быстро перебрать.

Механизм: список в каждой ячейке

Таблица - массив из m ячеек, но каждая ячейка хранит СПИСОК (цепочку) попавших в неё ключей (с их значениями).

положить(ключ, значение):
    i = h(ключ)
    добавить (ключ, значение) в список таблица[i]     # O(1) - в начало списка

найти(ключ):
    i = h(ключ)
    пройти по списку таблица[i], сравнивая ключи        # длина списка ≈ α
    вернуть значение или «нет»

удалить(ключ):
    i = h(ключ)
    выкинуть узел с этим ключом из списка таблица[i]

Вставка - O(1): посчитали номер, добавили в начало списка ячейки. Поиск и удаление - O(1) на подсчёт h плюс проход по списку этой ячейки. При равномерной функции n ключей размазаны по m спискам, средняя длина списка - α = n/m, поэтому поиск - O(1 + α). Пока α небольшой, это почти константа.

0123456 8 15 3 ячейка 1: цепочка 8 → 15 (была коллизия) ячейка 3: цепочка из одного - 3 поиск: h(ключ) + проход по списку (≈ α)
При h(k)=k mod 7 ключи 8 и 15 дают один номер 1 и лежат в одной цепочке; 3 - в ячейке 3. Поиск заходит в ячейку h(ключ) и перебирает её короткий список.

Правило

Метод цепочек хранит в каждой ячейке СПИСОК попавших в неё ключей. Вставка - добавить в список ячейки h(ключ) за O(1); поиск и удаление - посчитать h(ключ) и пройти список этой ячейки. При равномерной функции средняя длина списка α = n/m, поэтому поиск - O(1 + α). Плата за простоту - память на указатели списков и то, что элементы разбросаны по памяти (плохая локальность). Зато α может быть и больше 1 (списки просто длиннее), а таблица не «портится» от заполнения.

Разбор: почему поиск - это O(1 + α), а не O(α)

Мелочь «1 +» важна, и вот почему. Любой поиск, даже в ПУСТОЙ ячейке, делает фиксированную работу: посчитать h(ключ) и обратиться к ячейке - это O(1), и оно есть всегда. Дальше идёт проход по списку - в среднем α шагов. Складываем: O(1) + O(α) = O(1 + α). Почему не просто O(α)? Потому что при α = 0 (пустая таблица) поиск всё равно стоит O(1) - подсчёт h никуда не девается, а O(0) неверно описал бы это. При маленьком α (скажем, 0.5) главный член - именно 1, поиск почти константа. При большом α (переполнение) главным становится α, и поиск деградирует к линейному по длине цепочек. Запись O(1 + α) честно показывает оба режима: константа плюс длина списка.

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

Ключевое достоинство цепочек - устойчивость к высокому α. Можно положить в таблицу из m = 100 ячеек хоть 1000 ключей (α = 10): просто списки станут в среднем по 10 элементов, поиск замедлится до O(1 + 10), но НИЧЕГО не сломается. Список растёт неограниченно. Это резко отличает цепочки от открытой адресации (следующий урок), где ключи лежат прямо в ячейках массива: там при α → 1 свободных ячеек не остаётся, и вставить некуда - таблица физически переполняется. Цепочки допускают α > 1, открытая адресация требует α < 1 жёстко. Поэтому цепочки прощают ошибку с размером: забыл рехешировать - получишь медленно, но верно; в открытой адресации забыл - получишь сбой вставки. За эту устойчивость цепочки платят памятью на указатели и разбросом элементов по куче.

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

Первая ошибка - при коллизии класть второй ключ в СОСЕДНЮЮ ячейку, но при поиске смотреть только h(ключ): положил в одну ячейку, ищешь в другой - не находишь. В цепочках всё с одним номером лежит в ОДНОМ списке. Второе - не сравнивать ключи при проходе по цепочке, а верить номеру ячейки: в списке лежат РАЗНЫЕ ключи с одним хешем, надо сверять сам ключ, а не только попадание в ячейку. Третье - думать, что цепочки дают O(1) независимо от α: при переполнении списки длинные, поиск O(1 + α) деградирует. Четвёртое - забыть про память: на каждый ключ - накладные расходы на узел списка (указатель), и на миллионах ключей это заметный перерасход против открытой адресации.

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

Метод цепочек - классическая и самая понятная реализация хеш-таблицы; так устроены HashMap в Java (при коллизиях - список, а при очень длинных цепочках он даже превращается в дерево для защиты худшего случая), хеш-таблицы в старых версиях Python и многих библиотеках. Знание цепочек объясняет, почему словарь остаётся рабочим даже при переполнении (просто медленнее) и почему рекомендация «сравнивай сами ключи, а не только хеши» - не педантизм, а необходимость (в одной ячейке разные ключи). Понимание O(1 + α) даёт практическое правило: держи α небольшим - и цепочки почти константны; дай ему вырасти - и получишь линейный поиск. А сравнение цепочек с открытой адресацией (следующие уроки) - это выбор, который делают авторы каждой стандартной библиотеки, и понимать его компромиссы полезно, чтобы знать, чего ждать от dict своего языка.

Теперь сам

В таблице с цепочками m = 4, h(k) = k mod 4. Кладём ключи 5, 9, 13, 2 по очереди. Как будут выглядеть цепочки и сколько сравнений займёт поиск 13?

Ответ. Считаем номера: 5 mod 4 = 1, 9 mod 4 = 1, 13 mod 4 = 1, 2 mod 4 = 2. Ключи 5, 9, 13 все попали в ячейку 1 (тройная коллизия), 2 - в ячейку 2. Если добавлять в начало списка, цепочка ячейки 1 будет 13 → 9 → 5 (последний добавленный - первый), ячейки 2 - 2, ячейки 0 и 3 пусты. Поиск 13: считаем h(13) = 1, заходим в ячейку 1, идём по списку - первый же узел 13, нашли за ОДНО сравнение (мы клали в начало, а 13 добавили последним). Если бы добавляли в конец, 13 был бы третьим, и потребовалось бы три сравнения. Средняя длина списка тут α = 4/4 = 1, но из-за плохой функции (mod 4 = младшие два бита) три ключа свалились в одну ячейку - наглядный пример, почему m берут простым.

Главное

Метод цепочек хранит в каждой ячейке СПИСОК попавших в неё ключей - так решается проблема «несколько ключей в одну ячейку». Вставка O(1) (в начало списка ячейки h(ключ)), поиск и удаление - h(ключ) плюс проход по списку, в среднем O(1 + α) (длина списка ≈ α = n/m). Достоинства: просто, допускает α > 1 (списки длиннее), не ломается от переполнения (в отличие от открытой адресации). Плата - память на указатели и разброс элементов по куче. Всегда сравнивай сами КЛЮЧИ при проходе (в одной ячейке разные ключи с одним хешем), а не только попадание в ячейку.

Комментарии

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

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

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