Методы рехеширования
Читать дальше
В январе 1953 года сотрудник IBM Ханс Петер Лун написал внутреннюю записку, где предложил хранить записи по адресу, вычисленному из самого ключа, а совпадения складывать в связные списки. Это и есть метод цепочек - самое раннее известное описание хеширования. Лун вообще был изобретателен: его же именем назван алгоритм контрольной цифры, который до сих пор проверяет номера банковских карт.
Примерно через год, в 1954-м, группа программистов IBM (среди них Джин Амдал и Элейн Макгроу) писала ассемблер для машины IBM 701 и пришла к другому решению: не заводить списки вовсе, а искать свободное место рядом, просматривая ячейки подряд. Так появилось линейное пробирование. Обе идеи сначала расходились по лабораториям устно и в записках - в печати они возникли позже: в 1956 году Арнольд Дюмей описал взятие остатка от деления на простое число, а в 1957-м Уэсли Питерсон опубликовал в журнале IBM первый анализ открытой адресации.
В СССР к тому же приему пришли независимо. А. П. Ершов в работе о программировании арифметических операторов (1958) описал ту же схему с линейным просмотром занятых ячеек; Кнут в "Искусстве программирования" отмечает это как самостоятельное открытие, сделанное параллельно с американскими группами.
Формулы, которые вы найдете ниже в блоке с математикой, тоже имеют автора и дату. Линейное пробирование проанализировал Дональд Кнут в 1962 году, и он не раз вспоминал этот эпизод как поворотный: это был первый нетривиальный алгоритм, который ему удалось честно посчитать, и именно с него началась целая дисциплина - анализ алгоритмов.
Само слово "хеширование" долго оставалось профессиональным жаргоном: в статьях 1950-60-х писали "scatter storage", то есть рассеянное хранение. Общеупотребительным термин сделал обзор Роберта Морриса 1968 года, вышедший в Communications of the ACM. К концу того же десятилетия появились и остальные способы пробирования из этого урока - квадратичное и двойное хеширование.
Природа коллизий в хеш-таблицах
Когда разные ключи при вычислении хеш-функции получают один и тот же индекс в массиве, возникает конфликт, называемый коллизией. Поскольку число возможных входных ключей практически безгранично, а размер массива таблицы ограничен, коллизии математически неизбежны (следствие принципа Дирихле):
В классической литературе, на которой построен этот курс, рехешированием называют повторное вычисление адреса при коллизии - то есть сами способы пробирования: линейное рехеширование, квадратичное рехеширование. Именно в этом смысле слово стоит в заголовке урока. В современной практике и в документации библиотек тем же словом (rehash) обычно называют совсем другое - перестройку всей таблицы с увеличением массива, когда коэффициент заполнения перевалил за порог. Оба смысла встретятся вам ниже, различить их просто по контексту: если речь о поиске места для одного ключа - это пробирование, если о размере массива - это перестройка таблицы.
Метод цепочек (Chaining)
Это классический, наиболее надежный и интуитивно понятный способ. Массив хеш-таблицы (называемый массивом корзин или buckets) хранит не сами записи, а указатели на связные списки. Если вычисленный хеш-индекс для двух разных ключей совпадает, новый элемент просто вставляется в список, соответствующий этому индексу.
- Вставка (Insertion): в среднем выполняется за строгое время O(1), поскольку новый узел выгоднее всего добавлять прямо в голову связного списка корзины, не обходя существующие элементы. Так можно, если проверка дубликатов не требуется: сама по себе она стоит O(1 + α), потому что заставляет пройти корзину до конца.
- Поиск и удаление (Search / Deletion): требуют обхода списка внутри конкретной корзины. В среднем время составляет O(1 + α), где α - коэффициент заполнения. В аномально худшем случае, если хеш-функция выродилась и направила все ключи в одну корзину, поиск деградирует до O(N).
Обе платформы разрешают коллизии именно цепочками, но по-разному ведут себя, когда одна корзина вырождается в длинный список (так работает атака Hash DoS: злоумышленник подбирает ключи с совпадающим хешем и роняет производительность сервера). В Java
HashMap, начиная с версии 8, слишком длинная корзина перестраивается в сбалансированное дерево поиска: когда в одной ячейке набирается 8 элементов, а сама таблица доросла до 64 корзин, список превращается в дерево и поиск внутри корзины идет за O(log N) вместо O(N). Балансировку деревьев мы разбирали в уроке о сбалансированных деревьях; Java использует для этого красно-черное дерево - близкого родственника АВЛ с чуть более слабым критерием баланса. В C++ такой оптимизации нет и быть не может: стандарт фактически предписывает std::unordered_map хранить элементы в корзинах на связных списках (иначе не работали бы обход отдельной корзины и гарантии сохранности ссылок при рехешировании), поэтому вырожденная корзина остается линейной.
Открытая адресация (Open Addressing)
При открытой адресации мы полностью отказываемся от дополнительных динамических структур: все без исключения элементы хранятся непосредственно в массиве хеш-таблицы. Если при вставке ключа вычисленный индекс h оказывается уже занят другим элементом, алгоритм инициирует последовательность проб - проверяет другие ячейки таблицы по детерминированному математическому правилу до тех пор, пока не обнаружит пустой слот.
Четыре стратегии пробирования:
- Линейное пробирование (Linear Probing): при коллизии в домашней ячейке
h(так называют ячейку, куда ключ ведет напрямую по хешу) последовательно проверяются ячейки(h + 1) mod N,(h + 2) mod Nи так далее по кругу. Это простейший метод, обладающий превосходной локальностью кэша процессора, но страдающий от первичной кластеризации - слияния соседних занятых ячеек в длинные "массивы-пробки", что резко увеличивает среднее число проб. - Квадратичное пробирование (Quadratic Probing): интервал между проверяемыми ячейками возрастает квадратично:
(h + 1²) mod N,(h + 2²) mod N,(h + 3²) mod N. Это эффективно разрывает первичные кластеры, но требует тщательного выбора размера таблицыN. - Случайное рехеширование: к вычисленному индексу добавляется случайная величина, масштабированная под длину таблицы. Главное условие - последовательность обязана восстанавливаться при поиске, иначе положенный ключ уже не найти. Поэтому берут не настоящий случай, а псевдослучайные числа линейного конгруэнтного датчика с фиксированным начальным значением (в C это
srand()). Этот метод важен как теоретический эталон: формулы в математическом блоке ниже выведены именно для него, и в сравнительной таблице он стоит эталонной строкой. На практике его вытеснило двойное хеширование, дающее то же рассеивание дешевле и без генератора. - Двойное хеширование (Double Hashing): шаг смещения вычисляется с помощью второй, независимой хеш-функции
h₂(k). Последовательность проб имеет вид(h₁(k) + i · h₂(k)) mod N. Этот метод практически полностью устраняет как первичную, так и вторичную кластеризацию, приближая распределение к идеальному случайному пробированию.
Критическая особенность открытой адресации - невозможность простого удаления элемента путем очистки ячейки (записи
NULL). Если мы сотрем элемент в середине цепочки проб, последующий поиск ключа, который при вставке перешагнул через эту позицию из-за коллизии, оборвется на пустом месте и вернет ложное "элемент не найден".Для решения этой проблемы освобожденной ячейке присваивается специальный флаг-состояние - метка "удален". Такая ячейка не пустая и не занятая: алгоритм поиска, встретив метку, продолжает движение дальше по цепочке проб, а алгоритм вставки имеет право переписать эту ячейку новым ключом, переиспользуя память.
В англоязычной литературе и в коде эту метку называют tombstone - буквально "надгробие": ключа уже нет, но памятный знак о нем остался. В русских текстах о распределенных базах данных (например, о Cassandra, где тот же прием применяется при удалении записей) прижилась и прямая калька "надгробие", но в алгоритмической литературе метку обычно просто описывают, а не называют отдельным словом.
Словарь
dict и множество set в CPython построены на открытой адресации: никаких списков в ячейках нет, все элементы лежат в одном массиве. Последовательность проб при этом не линейная и не квадратичная - следующий индекс считается как j = (5 * j + 1 + perturb) mod N, где вспомогательная величина perturb на каждом шаге сдвигается вправо на 5 бит и постепенно подмешивает в адрес старшие биты хеша. Такая проба разбрасывает ключи не хуже двойного хеширования, но не требует второй хеш-функции. Таблица перестраивается при заполнении на две трети (у HashMap в Java, для сравнения, порог 0.75). А удаление сделано ровно так, как описано выше: освобожденная ячейка получает служебную метку удаления, и поиск, встретив ее, идет по цепочке проб дальше.
Методы рехеширования: сравнение цепочек и открытой адресации
На иллюстрации ниже показано принципиальное различие двух архитектурных подходов: в методе цепочек корзины хранят указатели на списки, а в открытой адресации все элементы живут строго внутри массива:
Тренажер: разрешение коллизий
Вставляем ключи 10 24 31 17 45 21 8 13 в таблицу из N = 7 ячеек с хеш-функцией H(k) = k mod 7. Набор ключей один и тот же, стратегии три - сравните, чем закончится каждая.
a): 0.00
Тренажер: удаление и метка "удален"
В таблице лежат три ключа с одним хешем: 10 24 31, все с H = 3, разложены линейным пробированием. Удалим средний ключ 24 двумя способами и сразу попробуем найти 31.
Сравнительный анализ методов разрешения коллизий
Выбор оптимального метода разрешения коллизий зависит от архитектурных ограничений системы: доступной оперативной памяти, характера нагрузки (часто ли происходят удаления) и критичности кэш-промахов процессора.
| Метод | Доп. память | Локальность кэша | Удаление | Предельный Load Factor | Кластеризация |
|---|---|---|---|---|---|
| Метод цепочек (Chaining) | Высокая (указатели next) |
Низкая (узлы в куче) | Простое (без меток) | > 1.0 (любое заполнение) | Отсутствует |
| Линейное пробирование | O(1) (in-place) | Идеальная (чтение подряд) | Сложное (метка "удален") | ≤ 0.70 (далее деградация) | Сильная (первичная) |
| Квадратичное пробирование | O(1) (in-place) | Средняя (шаги по квадратам) | Сложное (метка "удален") | ≤ 0.75 (при простом N) | Средняя (вторичная) |
| Случайное рехеширование | O(1) (in-place) | Низкая (прыжки по всей таблице) | Сложное (метка "удален") | ≤ 0.85 (идеал, недостижим) | Отсутствует (эталон оценок) |
| Двойное хеширование | O(1) (in-place) | Низкая (случайные прыжки) | Сложное (метка "удален") | ≤ 0.80 | Минимальная (почти 0) |
Реализации на языке C
Рассмотрим индустриальные реализации обоих подходов на языке C. В коде наглядно демонстрируется управление памятью при цепочках и работа с маркерами удалений (Tombstone) при открытой адресации.
1. Реализация метода цепочек (Chaining)
В этой реализации каждая корзина таблицы содержит указатель на односвязный список. Вставка нового элемента происходит за константное время O(1) в голову списка:
Хеш-таблица на цепочках: код целиком
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define TABLE_SIZE 10
// Структура узла связного списка в корзине
typedef struct Node {
char *key;
int value;
struct Node *next; // Указатель на следующий элемент при коллизии
} Node;
// Структура хеш-таблицы
typedef struct {
Node *buckets[TABLE_SIZE];
} HashTableChaining;
// Хеш-функция djb2 (Dan Bernstein)
unsigned int hash_function(const char *key) {
unsigned long int hash = 5381;
int c;
while ((c = *key++)) {
hash = ((hash << 5) + hash) + c; // hash * 33 + c
}
return hash % TABLE_SIZE;
}
// Инициализация таблицы
void init_table(HashTableChaining *ht) {
for (int i = 0; i < TABLE_SIZE; i++) {
ht->buckets[i] = NULL;
}
}
// Вставка или обновление ключа
void insert_chaining(HashTableChaining *ht, const char *key, int value) {
unsigned int index = hash_function(key);
Node *current = ht->buckets[index];
// Проверяем, существует ли уже такой ключ в цепочке
while (current != NULL) {
if (strcmp(current->key, key) == 0) {
current->value = value; // Обновляем значение
return;
}
current = current->next;
}
// Коллизия (или новая корзина): выделяем память под новый узел
Node *new_node = (Node *)malloc(sizeof(Node));
new_node->key = strdup(key);
new_node->value = value;
// Вставляем в голову связного списка за O(1)
new_node->next = ht->buckets[index];
ht->buckets[index] = new_node;
}
// Поиск значения по ключу
int* search_chaining(HashTableChaining *ht, const char *key) {
unsigned int index = hash_function(key);
Node *current = ht->buckets[index];
while (current != NULL) {
if (strcmp(current->key, key) == 0) {
return &(current->value);
}
current = current->next;
}
return NULL; // Ключ не найден
}
2. Реализация открытой адресации с квадратичным рехешированием
Индустриальная реализация с открытой адресацией. Код использует размер таблицы N = 997 (простое число) и алгоритм квадратичного пробирования без операции умножения: на каждом шаге смещение d увеличивается на 2 (d = 1, 3, 5, 7...), что дает накопленные квадратичные сдвиги (1, 4, 9, 16...). В реализации полноценно поддержаны метки "удален" (tombstones):
Открытая адресация с метками удаления: код целиком
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>
#define N 997 // Размер таблицы обязательно должен быть простым числом!
#define WORD_LEN 64
typedef char Word[WORD_LEN];
typedef struct {
Word key;
size_t count;
bool is_deleted; // Метка "удален" (tombstone) для корректного удаления
} Item_T;
typedef Item_T Table[N];
// Очистка таблицы
void table_create(Table T) {
for (int i = 0; i < N; i++) {
T[i].key[0] = '\0';
T[i].count = 0;
T[i].is_deleted = false;
}
}
// Поиск или вставка ключа с квадратичным рехешированием
Item_T* search_or_insert_quadratic(Table T, Word key) {
int i = 0;
long int h = 0;
// Вычисляем хеш-индекс h по методу Горнера
while (key[i] != '\0') {
h = (256 * h + (unsigned char)key[i]) % N;
i++;
}
int d = 1; // Начальный шаг пробирования
int probes = 0; // Счетчик попыток
int first_tombstone = -1; // Индекс первой встреченной метки "удален"
while (probes < N) {
// 1. Нашли искомый ключ (и он не удален)
if (!T[h].is_deleted && strcmp(T[h].key, key) == 0) {
T[h].count++;
return &T[h];
}
// 2. Встретили метку удаленного элемента (tombstone)
else if (T[h].is_deleted) {
if (first_tombstone == -1) {
first_tombstone = h; // Запоминаем для переиспользования памяти
}
}
// 3. Встретили абсолютно пустую ячейку - цепочка пробирования закончилась!
else if (T[h].key[0] == '\0') {
// Если по пути была метка удаления, вставляем в нее (оптимизация кластеров)
int target = (first_tombstone != -1) ? first_tombstone : h;
strncpy(T[target].key, key, WORD_LEN - 1);
T[target].key[WORD_LEN - 1] = '\0';
T[target].count = 1;
T[target].is_deleted = false;
return &T[target];
}
// Коллизия! Выполняем шаг квадратичного рехеширования
// h = (h + d) % N, где d возрастает как 1, 3, 5... (дает сдвиги 1, 4, 9, 16...)
h = (h + d) % N;
d += 2;
probes++;
}
printf("Ошибка: переполнение таблицы (превышен Load Factor)\n");
return NULL;
}
// Корректное удаление с установкой метки "удален"
bool delete_key(Table T, Word key) {
int i = 0;
long int h = 0;
while (key[i] != '\0') {
h = (256 * h + (unsigned char)key[i]) % N;
i++;
}
int d = 1, probes = 0;
while (probes < N) {
if (T[h].key[0] == '\0') return false; // Элемента в таблице нет
if (!T[h].is_deleted && strcmp(T[h].key, key) == 0) {
T[h].is_deleted = true; // Ставим метку "удален"!
return true;
}
h = (h + d) % N;
d += 2;
probes++;
}
return false;
}
Коротко: главное
- Метод цепочек организует в корзинах связные списки. Он устойчив к высокому коэффициенту заполнения (
a > 1.0), прост в реализации и удалении элементов, но требует накладных расходов на динамическую память и проигрывает в локальности кэша процессора. - Открытая адресация хранит все данные в массиве. Она идеальна по использованию памяти и кэша CPU, но требует поддержания
a ≤ 0.70и сложного механизма удаления через метки "удален" (tombstone). - Линейное пробирование склонно к первичной кластеризации - образованию длинных непрерывных блоков занятых ячеек, из-за чего при
a > 0.75среднее число попыток поиска превышает 2.5 и стремительно растет. - Квадратичное пробирование эффективно устраняет первичные кластеры. Согласно математической теории, при простом размере таблицы
Nалгоритм гарантированно исследует не менее половины ячеек без повторов.
Развернуть математический анализ
В теории алгоритмов приводится строгая математическая оценка производительности методов рехеширования. Ключевым параметром является коэффициент заполнения таблицы a = m / n (в англоязычной литературе - Load Factor, где n - размер таблицы, а m - число занятых ячеек).
Для идеального случайного рехеширования математическое ожидание E числа попыток (проб), необходимых для доступа к ключу, который в таблице уже лежит (успешный поиск), выражается зависимостью:
a = m / (n + 1)Определение коэффициента заполнения - это
a = m / n, и именно в этом смысле его понимают библиотеки: порог 0.75 у HashMap и две трети у словаря Python считаются от размера массива. Но в классических выводах этой формулы (в том числе у Вирта, по которому построен курс) стоит a = m / (n + 1). Это не другое определение, а поправка на конечность таблицы: оценка E(a) асимптотическая, и при полностью занятой таблице (m = n) знаменатель 1 - a обратился бы в ноль, а с n + 1 оценка остается конечной. На реальных размерах разница пренебрежима: при n = 997 и m = 748 выходит 0.7503 против 0.7495.
Для линейного рехеширования из-за эффекта первичной кластеризации среднее число попыток успешного поиска растет быстрее и описывается формулой:
Обе формулы описывают именно успешный поиск. Поиск ключа, которого в таблице нет, обходится заметно дороже - а это ровно та работа, которую делает вставка, когда ищет свободную ячейку. Обозначим число проб при неуспешном поиске через U:
линейное: U(a) = (1 + 1 / (1 - a)²) / 2
Разрыв между этими оценками принципиален. При заполнении 90% успешный поиск в случайном рехешировании стоит 2.56 попытки, а неуспешный - уже 10; для линейного пробирования это 5.5 против 50.5 попытки. Значит, таблица начинает тормозить на вставках намного раньше, чем на чтении, и следить надо за более пессимистичной оценкой.
Табулирование функций успешного поиска наглядно демонстрирует, почему открытая адресация требует жесткого контроля за заполнением таблицы:
| Коэффициент заполнения (a) | Случайное рехеширование E(a) | Линейное рехеширование E(a) | Статус производительности |
|---|---|---|---|
| 0.10 (10%) | 1.05 попытки | 1.06 попытки | Идеально (почти без коллизий) |
| 0.50 (50%) | 1.39 попытки | 1.50 попытки | Оптимальный рабочий режим |
| 0.75 (75%) | 1.85 попытки | 2.50 попытки | Порог рехеширования (норма) |
| 0.90 (90%) | 2.56 попытки | 5.50 попыток | Начало резкой деградации |
| 0.95 (95%) | 3.15 попытки | 10.50 попыток | Критическое падение скорости |
Важнейший вывод теории: при 50-70% заполнении таблицы поиск требует в среднем всего 1.5-2.5 попытки стоимостью в константное время каждая. Число попыток зависит исключительно от коэффициента заполнения a, а не от общего числа элементов или размера массива! Однако за порогом a > 0.75 для линейного пробирования затраты растут лавинообразно: у всех приведенных формул в знаменателе стоит (1 - a), то есть при a, стремящемся к единице, число проб уходит в бесконечность. Именно поэтому в стандартных библиотеках перестройка таблицы (увеличение массива вдвое) автоматически срабатывает при достижении Load Factor 0.75.
Кроме того, доказана важнейшая теорема для квадратичного рехеширования: если размер таблицы N является простым числом, то квадратичные пробы гарантированно просмотрят не менее половины всех ячеек таблицы без зацикливания, прежде чем возникнет повтор индекса.
Проверьте себя
Почему в хеш-таблицах с открытой адресацией элементы нельзя просто "стирать" (возвращать ячейку в состояние "пусто") при удалении?