Методы рехеширования

Модуль "Хеш-таблицы" • урок 2 из 4 • ~30 минут

Суть урока. Коллизии в хеш-таблицах математически неизбежны при любом реальном наборе ключей (следствие принципа Дирихле). Поэтому любая индустриальная реализация хеш-таблицы обязана включать эффективную стратегию их разрешения. Существуют два фундаментальных архитектурных подхода: метод цепочек (использование динамических связных списков внутри каждой ячейки) и открытая адресация (поиск альтернативной свободной позиции непосредственно в массиве таблицы с помощью пробирования).
Историческая справка: цепочки и пробы родились в IBM за один год

Оба метода из этого урока придуманы в самом начале 1950-х, еще до того, как появилось слово "хеширование". Цепочки и открытая адресация разошлись почти сразу - и с тех пор так и делят между собой все реализации хеш-таблиц.

Читать дальше

В январе 1953 года сотрудник IBM Ханс Петер Лун написал внутреннюю записку, где предложил хранить записи по адресу, вычисленному из самого ключа, а совпадения складывать в связные списки. Это и есть метод цепочек - самое раннее известное описание хеширования. Лун вообще был изобретателен: его же именем назван алгоритм контрольной цифры, который до сих пор проверяет номера банковских карт.

Примерно через год, в 1954-м, группа программистов IBM (среди них Джин Амдал и Элейн Макгроу) писала ассемблер для машины IBM 701 и пришла к другому решению: не заводить списки вовсе, а искать свободное место рядом, просматривая ячейки подряд. Так появилось линейное пробирование. Обе идеи сначала расходились по лабораториям устно и в записках - в печати они возникли позже: в 1956 году Арнольд Дюмей описал взятие остатка от деления на простое число, а в 1957-м Уэсли Питерсон опубликовал в журнале IBM первый анализ открытой адресации.

В СССР к тому же приему пришли независимо. А. П. Ершов в работе о программировании арифметических операторов (1958) описал ту же схему с линейным просмотром занятых ячеек; Кнут в "Искусстве программирования" отмечает это как самостоятельное открытие, сделанное параллельно с американскими группами.

Формулы, которые вы найдете ниже в блоке с математикой, тоже имеют автора и дату. Линейное пробирование проанализировал Дональд Кнут в 1962 году, и он не раз вспоминал этот эпизод как поворотный: это был первый нетривиальный алгоритм, который ему удалось честно посчитать, и именно с него началась целая дисциплина - анализ алгоритмов.

Само слово "хеширование" долго оставалось профессиональным жаргоном: в статьях 1950-60-х писали "scatter storage", то есть рассеянное хранение. Общеупотребительным термин сделал обзор Роберта Морриса 1968 года, вышедший в Communications of the ACM. К концу того же десятилетия появились и остальные способы пробирования из этого урока - квадратичное и двойное хеширование.

Природа коллизий в хеш-таблицах

Когда разные ключи при вычислении хеш-функции получают один и тот же индекс в массиве, возникает конфликт, называемый коллизией. Поскольку число возможных входных ключей практически безгранично, а размер массива таблицы ограничен, коллизии математически неизбежны (следствие принципа Дирихле):

Методы рехеширования: коллизия хешей, когда два ключа претендуют на одну ячейку
Ключи 10 и 24 оба дают H = 3 в таблице из семи ячеек - второй из них находит ячейку [3] уже занятой.
Два смысла слова "рехеширование"
В классической литературе, на которой построен этот курс, рехешированием называют повторное вычисление адреса при коллизии - то есть сами способы пробирования: линейное рехеширование, квадратичное рехеширование. Именно в этом смысле слово стоит в заголовке урока. В современной практике и в документации библиотек тем же словом (rehash) обычно называют совсем другое - перестройку всей таблицы с увеличением массива, когда коэффициент заполнения перевалил за порог. Оба смысла встретятся вам ниже, различить их просто по контексту: если речь о поиске места для одного ключа - это пробирование, если о размере массива - это перестройка таблицы.

Метод цепочек (Chaining)

Это классический, наиболее надежный и интуитивно понятный способ. Массив хеш-таблицы (называемый массивом корзин или buckets) хранит не сами записи, а указатели на связные списки. Если вычисленный хеш-индекс для двух разных ключей совпадает, новый элемент просто вставляется в список, соответствующий этому индексу.

Как это сделано в Java и в C++
Обе платформы разрешают коллизии именно цепочками, но по-разному ведут себя, когда одна корзина вырождается в длинный список (так работает атака Hash DoS: злоумышленник подбирает ключи с совпадающим хешем и роняет производительность сервера). В Java HashMap, начиная с версии 8, слишком длинная корзина перестраивается в сбалансированное дерево поиска: когда в одной ячейке набирается 8 элементов, а сама таблица доросла до 64 корзин, список превращается в дерево и поиск внутри корзины идет за O(log N) вместо O(N). Балансировку деревьев мы разбирали в уроке о сбалансированных деревьях; Java использует для этого красно-черное дерево - близкого родственника АВЛ с чуть более слабым критерием баланса. В C++ такой оптимизации нет и быть не может: стандарт фактически предписывает std::unordered_map хранить элементы в корзинах на связных списках (иначе не работали бы обход отдельной корзины и гарантии сохранности ссылок при рехешировании), поэтому вырожденная корзина остается линейной.

Открытая адресация (Open Addressing)

При открытой адресации мы полностью отказываемся от дополнительных динамических структур: все без исключения элементы хранятся непосредственно в массиве хеш-таблицы. Если при вставке ключа вычисленный индекс h оказывается уже занят другим элементом, алгоритм инициирует последовательность проб - проверяет другие ячейки таблицы по детерминированному математическому правилу до тех пор, пока не обнаружит пустой слот.

Четыре стратегии пробирования:

  1. Линейное пробирование (Linear Probing): при коллизии в домашней ячейке h (так называют ячейку, куда ключ ведет напрямую по хешу) последовательно проверяются ячейки (h + 1) mod N, (h + 2) mod N и так далее по кругу. Это простейший метод, обладающий превосходной локальностью кэша процессора, но страдающий от первичной кластеризации - слияния соседних занятых ячеек в длинные "массивы-пробки", что резко увеличивает среднее число проб.
  2. Квадратичное пробирование (Quadratic Probing): интервал между проверяемыми ячейками возрастает квадратично: (h + 1²) mod N, (h + 2²) mod N, (h + 3²) mod N. Это эффективно разрывает первичные кластеры, но требует тщательного выбора размера таблицы N.
  3. Случайное рехеширование: к вычисленному индексу добавляется случайная величина, масштабированная под длину таблицы. Главное условие - последовательность обязана восстанавливаться при поиске, иначе положенный ключ уже не найти. Поэтому берут не настоящий случай, а псевдослучайные числа линейного конгруэнтного датчика с фиксированным начальным значением (в C это srand()). Этот метод важен как теоретический эталон: формулы в математическом блоке ниже выведены именно для него, и в сравнительной таблице он стоит эталонной строкой. На практике его вытеснило двойное хеширование, дающее то же рассеивание дешевле и без генератора.
  4. Двойное хеширование (Double Hashing): шаг смещения вычисляется с помощью второй, независимой хеш-функции h₂(k). Последовательность проб имеет вид (h₁(k) + i · h₂(k)) mod N. Этот метод практически полностью устраняет как первичную, так и вторичную кластеризацию, приближая распределение к идеальному случайному пробированию.
Квадратичное пробирование от домашней ячейки [3] обходит только четыре адреса из семи прежде чем последовательность зациклится
От ячейки [3] квадратичные пробы идут по [3], [4], [0], [5] - ключ с тем же хешем 3 отвергается, даже если ячейки [1], [2] и [6] свободны.
Проблема удаления и метка "удален" (tombstone)
Критическая особенность открытой адресации - невозможность простого удаления элемента путем очистки ячейки (записи NULL). Если мы сотрем элемент в середине цепочки проб, последующий поиск ключа, который при вставке перешагнул через эту позицию из-за коллизии, оборвется на пустом месте и вернет ложное "элемент не найден".

Для решения этой проблемы освобожденной ячейке присваивается специальный флаг-состояние - метка "удален". Такая ячейка не пустая и не занятая: алгоритм поиска, встретив метку, продолжает движение дальше по цепочке проб, а алгоритм вставки имеет право переписать эту ячейку новым ключом, переиспользуя память.

В англоязычной литературе и в коде эту метку называют tombstone - буквально "надгробие": ключа уже нет, но памятный знак о нем остался. В русских текстах о распределенных базах данных (например, о Cassandra, где тот же прием применяется при удалении записей) прижилась и прямая калька "надгробие", но в алгоритмической литературе метку обычно просто описывают, а не называют отдельным словом.
Метка удален в хеш-таблице: после удаления ключа поиск идет дальше по цепочке проб вместо остановки на пустой ячейке
Ключи 10, 24, 31 (все с H = 3) стоят подряд. После удаления 24 меткой поиск ключа 31 все равно проходит через ячейку [4] и находит его за три пробы.
А как в Python?
Словарь dict и множество set в CPython построены на открытой адресации: никаких списков в ячейках нет, все элементы лежат в одном массиве. Последовательность проб при этом не линейная и не квадратичная - следующий индекс считается как j = (5 * j + 1 + perturb) mod N, где вспомогательная величина perturb на каждом шаге сдвигается вправо на 5 бит и постепенно подмешивает в адрес старшие биты хеша. Такая проба разбрасывает ключи не хуже двойного хеширования, но не требует второй хеш-функции. Таблица перестраивается при заполнении на две трети (у HashMap в Java, для сравнения, порог 0.75). А удаление сделано ровно так, как описано выше: освобожденная ячейка получает служебную метку удаления, и поиск, встретив ее, идет по цепочке проб дальше.

Методы рехеширования: сравнение цепочек и открытой адресации

На иллюстрации ниже показано принципиальное различие двух архитектурных подходов: в методе цепочек корзины хранят указатели на списки, а в открытой адресации все элементы живут строго внутри массива:

Метод цепочек: связный список из ключей 10, 24 и 31 с одинаковым хешем в одной корзине хеш-таблицы
Ключи 10, 24 и 31 (H = 3) складываются в один список корзины [3] - каждый новый ключ встает в голову списка.
Открытая адресация: те же ключи 10, 24 и 31 занимают подряд идущие ячейки массива хеш-таблицы линейным пробированием
Те же ключи 10, 24 и 31 без списков: каждый ищет свободную ячейку прямо в массиве и занимает ячейки [3], [4] и [5] подряд.

Тренажер: разрешение коллизий

Вставляем ключи 10 24 31 17 45 21 8 13 в таблицу из N = 7 ячеек с хеш-функцией H(k) = k mod 7. Набор ключей один и тот же, стратегии три - сравните, чем закончится каждая.

ячейка свободна ключ лежит в таблице проба: занято ключ записан
Шаг: 0 / 0 Вставлено: 0 / 8 Коллизий: 0 Load Factor (a): 0.00

Тренажер: удаление и метка "удален"

В таблице лежат три ключа с одним хешем: 10 24 31, все с H = 3, разложены линейным пробированием. Удалим средний ключ 24 двумя способами и сразу попробуем найти 31.

ячейка свободна ключ лежит в таблице метка "удален" проба ключ найден
Шаг: 0 / 0 Проб при поиске: 0 Ключ 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) в голову списка:

Хеш-таблица на цепочках: код целиком C, 72 строки
#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):

Открытая адресация с метками удаления: код целиком C, 100 строк
#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 алгоритм гарантированно исследует не менее половины ячеек без повторов.
Сложный материал: Математическое обоснование рехеширования и анализ кластеризации

Почему скорость поиска в хеш-таблице зависит исключительно от коэффициента заполнения, и почему индустриальные библиотеки удваивают массив именно при достижении порога в 75%?

Развернуть математический анализ

В теории алгоритмов приводится строгая математическая оценка производительности методов рехеширования. Ключевым параметром является коэффициент заполнения таблицы a = m / n (в англоязычной литературе - Load Factor, где n - размер таблицы, а m - число занятых ячеек).

Для идеального случайного рехеширования математическое ожидание E числа попыток (проб), необходимых для доступа к ключу, который в таблице уже лежит (успешный поиск), выражается зависимостью:

E(a) = -ln(1 - a) / a
Почему в учебниках встречается 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.

Для линейного рехеширования из-за эффекта первичной кластеризации среднее число попыток успешного поиска растет быстрее и описывается формулой:

E(a) = (1 - a / 2) / (1 - a)

Обе формулы описывают именно успешный поиск. Поиск ключа, которого в таблице нет, обходится заметно дороже - а это ровно та работа, которую делает вставка, когда ищет свободную ячейку. Обозначим число проб при неуспешном поиске через U:

случайное: U(a) = 1 / (1 - a)
линейное: 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 является простым числом, то квадратичные пробы гарантированно просмотрят не менее половины всех ячеек таблицы без зацикливания, прежде чем возникнет повтор индекса.

Проверьте себя

Почему в хеш-таблицах с открытой адресацией элементы нельзя просто "стирать" (возвращать ячейку в состояние "пусто") при удалении?

Как вам урок? Спасибо за отзыв!