
Миф про O(1)
Говорят, что хэш-таблицы обеспечивают поиск за O(1) — константное время, вне зависимости от размера. В теории они идеальны.
На практике я сталкивался с тем, что производительность хэш-таблиц оказывалась ниже, чем у линейного поиска по массиву.
Я оптимизировал таблицу символов для компилятора. Таблица символов использовала хэш-таблицу с 1024 бакетами, и у нас было примерно 500 символов. Расчёты выглядели отлично: средний размер бакета = 500/1024 ≈ 0,5, поэтому большинство операций поиска должно выполняться за один запрос.
Но профилировщик рассказал иную историю:
$ perf stat -e cache-misses,instructions ./compiler Performance counter stats: 1,234,567 cache-misses 5,000,000 instructions
1,2 миллиона промахов кэша на 5 миллионов команд? Для хэш-таблицы, которая должна быть O(1)?
Проблема заключалась в конфликтах кэша. Хэш-таблица была большой (1024 бакетов × 8 байт = 8 КБ), а паттерн доступа вызывал конфликты линий кэша. Каждая операция поиска была промахом кэша.
Я заменил хэш-таблицу простым линейным поиском по массиву из 500 элементов. В результате выполнение ускорилось в 3 раза.
В этой главе мы разберёмся, когда хэш-таблицы бывают быстрыми, когда медленными, и как сделать их удобными для кэша.
Основы хэш-таблиц
Хэш-таблица сопоставляет ключи со значениями при помощи хэш-функции:
typedef struct { char *key; int value; } entry_t; #define TABLE_SIZE 1024 entry_t *table[TABLE_SIZE]; int hash(const char *key) { unsigned int h = 0; while (*key) { h = h * 31 + *key++; } return h % TABLE_SIZE; } void insert(const char *key, int value) { int index = hash(key); entry_t *entry = malloc(sizeof(entry_t)); entry->key = strdup(key); entry->value = value; table[index] = entry; } int lookup(const char *key) { int index = hash(key); entry_t *entry = table[index]; if (entry && strcmp(entry->key, key) == 0) { return entry->value; } return -1; // Не найдено }
Это хэш-таблица с прямым сопоставлением (одна запись на бакет). Она не обрабатывает коллизии.
Разрешение коллизий
Если два ключа хэшируются в один индекс, возникает коллизия. Существует две основные стратегии:

1. Объединение в цепочку (связанный список для каждого бакета):
typedef struct entry { char *key; int value; struct entry *next; } entry_t; entry_t *table[TABLE_SIZE]; void insert(const char *key, int value) { int index = hash(key); entry_t *entry = malloc(sizeof(entry_t)); entry->key = strdup(key); entry->value = value; entry->next = table[index]; table[index] = entry; } int lookup(const char *key) { int index = hash(key); entry_t *entry = table[index]; while (entry) { if (strcmp(entry->key, key) == 0) { return entry->value; } entry = entry->next; } return -1; // Не найдено }
2. Открытая адресация (зондирование следующего пустого слота):
typedef struct { char *key; int value; int occupied; } entry_t; entry_t table[TABLE_SIZE]; void insert(const char *key, int value) { int index = hash(key); // Линейное зондирование while (table[index].occupied) { index = (index + 1) % TABLE_SIZE; } table[index].key = strdup(key); table[index].value = value; table[index].occupied = 1; } int lookup(const char *key) { int index = hash(key); while (table[index].occupied) { if (strcmp(table[index].key, key) == 0) { return table[index].value; } index = (index + 1) % TABLE_SIZE; } return -1; // Не найдено }
Как сравнение этих решений выглядит в учебниках:
Объединение в цепочку: справляется с любым коэффициентом нагрузки, но требует дополнительной памяти (указатели)
Открытая адресация: дополнительная память не требуется, но деградирует при высоких коэффициентах нагрузки
С точки зрения кэша:
Объединение в цепочку: ужасно (следование по указателям, разбросанные по памяти распределения)
Открытая адресация: лучше (последовательное зондирование, на основе массива)
Проблема конфликтов кэша
Давайте проанализируем поведение кэша при поиске по хэш-таблице.
Объединение в цепочку (наихудший случай):
int lookup(const char *key) { int index = hash(key); // 1. Вычисление хэша entry_t *entry = table[index]; // 2. Загрузка указателя бакета (промах кэша) while (entry) { if (strcmp(entry->key, key) == 0) { // 3. Загрузка элемента (промах кэша) return entry->value; // 4. Загрузка ключа (промах кэша) } entry = entry->next; // 5. Следование по указателю (промах кэша) } return -1; }
Промахи кэша на одну операцию поиска:
Указатель бакета: 1 промах
Каждый элемент в цепочке: 2-3 промаха (элемент, ключ, возможно, следующий элемент)
Итого: 3-10 промахов для цепочки длиной 3
Открытая адресация (линейное зондирование):
int lookup(const char *key) { int index = hash(key); while (table[index].occupied) { // Последовательный доступ if (strcmp(table[index].key, key) == 0) { return table[index].value; } index = (index + 1) % TABLE_SIZE; } return -1; }
Промахи кэша:
Первый запрос: 1 промах (загружает линию кэша с примерно 8 элементами)
Следующие 7 запросов: 0 промахов (та же линия кэша)
Итого: 1-2 промахов для типичной операции поиска
При открытой адресации промахов кэша в 3-5 раз меньше.
Бенчмарк: объединение в цепочку и открытая адресация
Давайте замерим разницу:
// Тест: 1000 вставок, 10000 операций поиска // Коэффициент нагрузки: 0,5 (1000 элементов, 2048 бакетов) Объединение в цепочку: Вставка: 450 000 тактов Поиск: 2 100 000 тактов Промахов кэша: 45 000 Открытая адресация (линейное зондирование): Вставка: 180 000 тактов Поиск: 650 000 тактов Промахи кэша: 12 000
Открытая адресация в 3,2 быстрее и имеет в 3,75 раза меньше промахов кэша.
Качество хэш-функции
Хорошая хэш-функция крайне важна. Плохая хэш-функция вызывает кластеризацию, из-за чего страдает производительность.
Плохая хэш-функция (плохое распределение):
int bad_hash(const char *key) { return key[0] % TABLE_SIZE; // Использует только первый символ! }
Результат: у всех ключей, начинающихся с «a», возникают коллизии, как и у всех ключей, начинающихся с «b», и так далее.
Более качественная хэш-функция (FNV-1a):
uint32_t fnv1a_hash(const char *key) { uint32_t hash = 2166136261u; while (*key) { hash ^= (uint8_t)*key++; hash *= 16777619u; } return hash; }
Ещё более качественная (для integer, идентификационный хэш):
uint32_t int_hash(uint32_t key) { // Для последовательных integer, идеальная идентификация return key; }
Для указателей (умножение на нечётное число):
uint32_t ptr_hash(void *ptr) { uintptr_t p = (uintptr_t)ptr; // Указатели часто выровнены, так что выполняем сдвиг и умножение return (uint32_t)((p >> 3) * 2654435761u); }
Бенчмарк (1000 случайных строк):
Плохой хэш (первый символ): Средняя длина цепочки: 38,5 Простой хэш (сумма): Средняя длина цепочки: 2,1 FNV-1a: Средняя длина цепочки: 0,98
Хорошие хэш-функции снижают количество коллизий в 40 раз.
Коэффициент нагрузки и изменение размера
Коэффициент нагрузки = количество элементов / размер таблицы
Объединение в цепочку: может превышать 1.0, но при этом деградирует производительность
Открытая адресация: должен быть меньше 0.7-0.8, иначе падает производительность
Почему? Когда таблица заполняется, последовательности зондирования становятся дольше:
Коэффициент нагрузки 0,5: среднее количество запросов = 1,5 Коэффициент нагрузки 0,7: среднее количество запросов = 3,6 Коэффициент нагрузки 0,9: среднее количество запросов = 10,5 Коэффициент нагрузки 0,95: среднее количество запросов = 20,5
Решение: увеличение размера, когда коэффициент нагрузки превышает пороговое значение
void resize_table(void) { int old_size = table_size; entry_t *old_table = table; table_size *= 2; table = calloc(table_size, sizeof(entry_t)); // Рехэширование всех элементов for (int i = 0; i < old_size; i++) { if (old_table[i].occupied) { insert(old_table[i].key, old_table[i].value); } } free(old_table); } void insert(const char *key, int value) { if (count >= table_size * 0.7) { resize_table(); } // ... обычная вставка ... }
Затраты: изменение размера за O(n), но амортизированное O(1) при удвоении размера.
Структура хэш-таблицы, удобная для кэша
Вот, как выглядит удобная для кэша структура хэш-таблицы:
1. Использование открытой адресации (линейное зондирование)
2. Плотная упаковка элементов
typedef struct { uint32_t hash; // Сохраняем хэш, чтобы избежать повторного вычисления uint32_t key; // Предполагаем, что используются ключи integer uint32_t value; } entry_t; // 12 байт, в линию кэша умещается 5 элементов
3. Использование размера, равного степени двойки (быстрое деление с остатком)
#define TABLE_SIZE 2048 #define MASK (TABLE_SIZE - 1) int index = hash & MASK; // Быстро!
4. Разделение ключей и значений (если значения большие)
typedef struct { uint32_t keys[TABLE_SIZE]; uint32_t hashes[TABLE_SIZE]; value_t *values[TABLE_SIZE]; // Указатели на большие значения } hash_table_t;
Зачем? Зондирование затрагивает только ключи и хэши, не касаясь больших значений.
5. Использование SIMD для зондирования
// Проверяем по 8 элементов за раз при помощи AVX2 __m256i target = _mm256_set1_epi32(hash); __m256i entries = _mm256_loadu_si256((__m256i*)&table[index]); __m256i cmp = _mm256_cmpeq_epi32(target, entries); int mask = _mm256_movemask_epi8(cmp); if (mask) { int pos = __builtin_ctz(mask) / 4; return table[index + pos].value; }
Хэширование Robin Hood
Хэширование Robin Hood — это вариант линейного зондирования, снижающий разброс длительности зондирования.
Принцип: если при вставке расстояние зондирования уже существующего элемента меньше, чем у вашего, меняем их местами и продолжаем вставлять перемещённый элемент.
Процесс принятия решения:

Пример:
Исходное состояние: ┌─────┬──────────┬──────────┐ │ [0] │ Пусто │ dist: - │ │ [1] │ key1 │ dist: 0 │ (hash=1, идеальная позиция) │ [2] │ key2 │ dist: 1 │ (hash=1, зондирование на 1 шаг) │ [3] │ Пусто │ dist: - │ │ [4] │ Пусто │ dist: - │ └─────┴──────────┴──────────┘ Вставка key3 (hash=2): Пробуем [2]: занято key2 key3 probe_dist = 0 key2 probe_dist = 1 0 ≤ 1 → Продолжаем зондировать Пробуем [3]: Пусто → Вставка После key3: ┌─────┬──────────┬──────────┐ │ [1] │ key1 │ dist: 0 │ │ [2] │ key2 │ dist: 1 │ │ [3] │ key3 │ dist: 1 │ └─────┴──────────┴──────────┘ Вставка key4 (hash=1): Пробуем [1]: занято key1 key4 probe_dist = 0, key1 probe_dist = 0 → Продолжаем Пробуем [2]: занято key2 key4 probe_dist = 1, key2 probe_dist = 1 → Продолжаем Пробуем [3]: занято key3 key4 probe_dist = 2, key3 probe_dist = 1 2 > 1 → ОБМЕН! (Робин Гуд: забираем у богатых, отдаём бедным) После обмена, продолжаем вставлять удалённый ключ key3: Пробуем [4]: Пусто → Вставка key3 Окончательное состояние: ┌─────┬──────────┬──────────┐ │ [1] │ key1 │ dist: 0 │ │ [2] │ key2 │ dist: 1 │ │ [3] │ key4 │ dist: 2 │ ← Вставлено обменом │ [4] │ key3 │ dist: 2 │ ← Удалён, вставлен повторно └─────┴──────────┴──────────┘ Результат: более равномерные расстояния зондирования (max=2 вместо потенциально неограниченного)
void insert(uint32_t key, uint32_t value) { uint32_t hash = hash_func(key); int index = hash & MASK; int probe_dist = 0; entry_t entry = {hash, key, value}; while (1) { if (!table[index].occupied) { table[index] = entry; table[index].occupied = 1; return; } int existing_dist = (index - table[index].hash) & MASK; if (probe_dist > existing_dist) { // Обмен местами: мы зондировали дальше, чем имеющийся элемент entry_t temp = table[index]; table[index] = entry; entry = temp; probe_dist = existing_dist; } index = (index + 1) & MASK; probe_dist++; } }
Преимущества: более равномерные длины зондирования, улучшенная производительность в наихудшем случае.
Бенчмарк:
Линейное зондирование: В среднем: 1,5 проверки, Max: 12 проверок Хэширование Robin Hood: В среднем: 1,5 проверки, Max: 4 проверки
Улучшение для наихудшего случая (важно для систем реального времени).
Маленькие хэш-таблицы: просто используйте массивы
В случае маленьких таблиц (< 100 элементов) линейный поиск по массиву часто оказывается быстрее, чем хэширование.
Почему?
Затраты на вычисление хэшей
Затраты на операции деления с остатком
Потенциальные промахи кэша
Бенчмарк (50 элементов):
Хэш-таблица (открытая адресация): 850 тактов на операцию поиска Линейный поиск (массив): 420 тактов на операцию поиска
Для маленьких таблиц линейный поиск в 2 раза быстрее!
Рекомендация: используйте линейный поиск для < 50-100 элементов, хэш-таблицу для большего количества.
Встраиваемые системы: идеальное хэширование
Во встраиваемых системах все ключи часто бывают известны во время компиляции (например, имена команд, имена регистров). Можно использовать идеальное хэширование — хэш-функцию с нулевыми коллизиями.
Пример: парсер команд с 16 командами
// Команды: "read", "write", "reset", "status", ... // Генерация идеальной хэш-функции во время компиляции const char *commands[] = { "read", "write", "reset", "status", "start", "stop", "config", "debug", // ... всего 16 }; // Идеальная хэш-функция (генерируемая gperf или вручную) int command_hash(const char *cmd) { // Тщательно подобрана так, чтобы обеспечить ноль коллизий return (cmd[0] * 3 + cmd[1] * 7) & 15; } void (*handlers[16])(void) = { [command_hash("read")] = handle_read, [command_hash("write")] = handle_write, // ... }; void dispatch_command(const char *cmd) { int index = command_hash(cmd); if (strcmp(commands[index], cmd) == 0) { handlers[index](); } }
Преимущества:
Ноль коллизий (гарантировано O(1))
Нет зондирования
Минимальная память
Быстрая (один хэш, одно сравнение)
Инструменты: gperf генерирует идеальные хэш-функции из списков ключевых слов.
Пример из реального мира: оптимизация таблицы символов
Вернёмся к моей таблице символов компилятора. Вот, что я поменял:
┌─────────────────────────────────────────────────────────────────┐ │ ДО: Хэш-таблица с объединением в цепочку │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Хэш-таблица [1024 бакета] │ │ ┌────┐ │ │ │ [0]│ → NULL │ │ │ [1]│ → Symbol("foo") → Symbol("bar") → NULL │ │ │ [2]│ → NULL │ │ │ [3]│ → Symbol("baz") → NULL │ │ │... │ │ │ └────┘ │ │ │ │ Операции поиска: │ │ 1. Вычисление хэша (31 * n) │ │ 2. Операция деления с остатком (затратная) │ │ 3. Следование по указателям (промах кэша) │ │ 4. Сравнение строк (разыменование указателя) │ │ │ │ Производительность: 2400 тактов на операцию поиска │ └─────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ ПОСЛЕ: Массив с линейным поиском │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Массив [макс. 256 символов на диапазон] │ │ ┌──────────────────────────────────────────┐ │ │ │ [0] Symbol { name: "foo", type, offset } │ │ │ │ [1] Symbol { name: "bar", type, offset } │ │ │ │ [2] Symbol { name: "baz", type, offset } │ │ │ │ [3] ... │ │ │ │ (расположены в памяти последовательно) │ │ │ └──────────────────────────────────────────┘ │ │ │ │ Операции поиска: │ │ 1. Последовательное сканирование (удобно для кэша) │ │ 2. Сравнение строк (встроенные данные, без указателя) │ │ │ │ Производительность: 380 тактов на операцию поиска │ │ (в 6,3 раза быстрее!) │ └─────────────────────────────────────────────────────────────────┘ Почему это работает: ✅ Малый диапазон (< 256 символов на функцию) ✅ Последовательный доступ (помогает prefetcher) ✅ Встроенные строки (нет следования по указателям) ✅ Нет оверхеда malloc/free ✅ Удобство для кэша (весь массив умещается в L1)
До (хэш-таблица с объединением в цепочку):
#define TABLE_SIZE 1024 typedef struct symbol { char *name; int type; int offset; struct symbol *next; } symbol_t; symbol_t *symbol_table[TABLE_SIZE]; symbol_t *lookup_symbol(const char *name) { int index = hash(name) % TABLE_SIZE; symbol_t *sym = symbol_table[index]; while (sym) { if (strcmp(sym->name, name) == 0) { return sym; } sym = sym->next; } return NULL; }
После (линейный поиск для малых диапазонов):
#define MAX_SYMBOLS 256 typedef struct { char name[32]; // Встроенный, не указатель int type; int offset; } symbol_t; symbol_t symbols[MAX_SYMBOLS]; int symbol_count = 0; symbol_t *lookup_symbol(const char *name) { // Линейный поиск (удобный для кэша) for (int i = 0; i < symbol_count; i++) { if (strcmp(symbols[i].name, name) == 0) { return &symbols[i]; } } return NULL; }
Изменения:
Удалена хэш-таблица (< 256 символов на диапазон)
Встроены имена (нет следования по указателям)
На основе массива (последовательный доступ)
Нет malloc/free
Результаты:
Ускорение операций поиска в 3 раза
В 10 раз меньше промахов кэша
Более простой код
Предсказуемая производительность
Выводы: при малых датасетах простые решения побеждают умные.
Подведём итог
Миф о O(1) разрушен. Хэш-таблица с 1024 бакетами и 500 символами должна быть быстрой, но 1,2 миллиона промахов кэша на 5 миллионов команд всё испортили. Из-за конфликтов кэша, вызванных таблицей на 8 КБ, каждая операция поиска приводила к промаху кэша. Заменив её линейным поиском по массиву из 500 элементов, мы повысили производительность в 3 раза. Константное время ничего не значит, если каждая операция промахивается мимо кэша.
Главные наблюдения:
Объединение в цепочку: ужасное поведение кэша (следование по указателям)
Открытая адресация: намного лучше (последовательное зондирование)
Важно качество хэш-функции (позволяет избежать кластеризации)
Коэффициент нагрузки влияет на производительность (для открытой адресации он не должен быть < 0,7)
Маленькие таблицы: линейный поиск часто оказывается быстрее
Удобная для кэша архитектура:
Используйте открытую адресацию (линейное зондирование или Robin Hood)
Плотно упаковывайте элементы (12-16 байт на элемент)
Размер, равный степени двойки (быстрое деление с остатком)
Разделение ключей и больших значений
Возможно, стоит использовать SIMD для зондирования
Встраиваемые системы:
Идеальное хэширование для известных ключей
Линейный поиск для малых таблиц (< 100 элементов)
Таблицы фиксированного размера (без изменения размера)
Встроенные ключи (позволяют избежать указателей)
Когда использовать хэш-таблицы:
Большие датасеты (> 100 элементов)
Необходимость O(1) для среднего случая
Хорошее распределение ключей
Допустимо нечастое изменение размеров
Когда НЕ использовать хэш-таблицы:
Маленькие датасеты (< 100 элементов) → используйте массив
Нужно гарантированное O(1) → используйте идеальное хэширование
Нужна отсортированная итерация → используйте дерево
Ограниченный бюджет памяти → используйте массив
Следующая глава: динамические массивы (векторы) сочетают в себе удобство для кэша с гибкостью динамического изменения размера. Мы узнаем, как реализовывать их эффективно, и поймём, когда изменение размера становится узким местом.
