5. Design HashMap
«Хеш-таблицы — это компромисс между пространством и временем, возведенный в ранг абсолюта».
— Брайан Керниган
Проектирование хеш-таблицы: от открытой адресации до механики Swiss Tables
Хеш-таблица — ключевой строительный блок современных программных систем. Внутренние кэши, индексы баз данных, маршрутизаторы HTTP-запросов, таблицы символов компилятора — все они опираются на амортизированный доступ за $O(1)$.
На технических собеседованиях задача Design HashMap (LeetCode 706) является лакмусовой бумажкой: она проверяет не знание синтаксиса map[K]V, а глубокое инженерное понимание аппаратных компромиссов: локальности кэша L1/L2/L3, штрафов за ветвления (branch misprediction), фрагментации кучи и стоимости рехаширования под нагрузкой.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
A["Операция Put(key, val)"]:::entry --> B["Вычисление хеша: h = hash(key)"]:::process
B --> C["Индекс слота: idx = h & (cap - 1)"]:::process
C --> D{"Состояние слота?"}:::warning
D -->|"Empty или Tombstone"| E["Записать ключ и значение<br/>Пометить Occupied"]:::success
D -->|"Occupied, тот же ключ"| F["Переписать значение"]:::process
D -->|"Occupied, другой ключ"| G["Линейное зондирование:<br/>idx = (idx + 1) & (cap - 1)"]:::error
G --> D
E --> H{"Коэффициент заполнения<br/>size / cap > loadFactor?"}:::warning
H -->|"Да"| I["Рехаширование:<br/>cap = cap * 2, миграция элементов"]:::process
H -->|"Нет"| J["Завершение операции"]:::success
I --> J
F --> J
1. Архитектурные парадигмы: цепочки vs открытая адресация vs Swiss Tables
Разрешение коллизий хеш-функции — фундаментальная развилка в дизайне ассоциативных массивов:
| Подход | Суть механизма | Плюсы | Минусы | Где применяется |
|---|---|---|---|---|
| Метод цепочек (Separate Chaining) | В бакетах хранятся связные списки или динамические массивы. | Простота реализации; удаление тривиально. | Множество мелких аллокаций; ужасная кэш-локальность (pointer chasing). | Java HashMap (до перестроения в дерево), C++ std::unordered_map. |
| Открытая адресация (Open Addressing) | Все элементы хранятся в непрерывном массиве. Коллизия = зондирование следующих ячеек. | Максимальная плотность упаковки; превосходная кэш-локальность. | Проблема кластеризации; удаление требует маркеров tombstone; чувствительность к высокому Load Factor. |
Python dict, Rust hashbrown, многие high-performance кэши. |
| Бакеты Go (до Go 1.23) | Массив bmap по 8 слотов + tophash + overflow buckets. |
Компромисс: плоские 8 элементов в бакете + цепочка переполнения. | Дополнительные переходы по указателям при затяжных коллизиях. | Go runtime (runtime/map.go). |
| Swiss Tables (Go 1.24+) | Группы по 8 слотов (в Go) / 16 слотов (в C++) с контрольными байтами (Ctrl bytes) и SWAR/SIMD поиском. | Параллельный опрос слотов группы за единичные инструкции CPU; минимальные накладные расходы памяти. | Сложность низкоуровневой логики зондирования. | Go 1.24+, Abseil C++, Rust std::collections::HashMap. |
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph Chaining["1. Метод цепочек"]
A1["Bucket Array"]:::process --> B1["Node 1"]:::accent
B1 --> C1["Node 2 (в куче)"]:::error
end
subgraph OpenAddressing["2. Открытая адресация"]
A2["Непрерывный плоский массив [E1, E2, E3, Tombstone, E5]"]:::success
end
2. Идиоматичная реализация на Go: открытая адресация с линейным зондированием
Для интервью и системного программирования эталоном прозрачности и скорости является реализация с открытой адресацией, битовой маской степени двойки и поддержкой tombstone (маркеров удаления).
package hashmap
const (
stateEmpty uint8 = iota
stateOccupied
stateTombstone
)
// entry представляет ячейку таблицы.
type entry struct {
key int
value int
state uint8
}
// HashMap реализует хеш-таблицу с открытой адресацией и линейным зондированием.
type HashMap struct {
buckets []entry
size int // количество активных элементов
capacity int // размер массива buckets (всегда степень 2)
mask int // capacity - 1 для быстрого взятия модуля
loadFactor float64 // порог рехаширования (обычно 0.70)
}
// NewHashMap создает новую таблицу с заданной начальной емкостью.
func NewHashMap(initialCap int, loadFactor float64) *HashMap {
if initialCap < 8 {
initialCap = 8
}
if loadFactor <= 0.1 || loadFactor >= 0.9 {
loadFactor = 0.70
}
// Округляем емкость до ближайшей степени двойки
cap := 1
for cap < initialCap {
cap <<= 1
}
return &HashMap{
buckets: make([]entry, cap),
size: 0,
capacity: cap,
mask: cap - 1,
loadFactor: loadFactor,
}
}
// hash возвращает перемешанный 32-битный хеш целого числа (хэш Томаса Ванга).
func (m *HashMap) hash(key int) int {
k := uint64(key)
k = (^k) + (k << 21) // k = (k << 21) - k - 1
k = k ^ (k >> 24)
k = (k + (k << 3)) + (k << 8) // k * 265
k = k ^ (k >> 14)
k = (k + (k << 2)) + (k << 4) // k * 21
k = k ^ (k >> 28)
k = k + (k << 31)
return int(k)
}
// Put вставляет или перезаписывает значение ключа.
func (m *HashMap) Put(key, val int) {
// Проверка коэффициента заполнения перед вставкой
if float64(m.size+1) > float64(m.capacity)*m.loadFactor {
m.resize(m.capacity * 2)
}
idx := m.hash(key) & m.mask
firstTombstone := -1
for i := 0; i < m.capacity; i++ {
slot := (idx + i) & m.mask
curr := &m.buckets[slot]
if curr.state == stateOccupied {
if curr.key == key {
curr.value = val // Ключ найден — обновляем значение
return
}
continue
}
if curr.state == stateTombstone {
if firstTombstone == -1 {
firstTombstone = slot
}
continue
}
// Достигли пустого слота (stateEmpty)
targetSlot := slot
if firstTombstone != -1 {
targetSlot = firstTombstone // Переиспользуем могильный маркер
}
m.buckets[targetSlot] = entry{
key: key,
value: val,
state: stateOccupied,
}
m.size++
return
}
// Если дошли сюда — все слоты заняты tombstones
if firstTombstone != -1 {
m.buckets[firstTombstone] = entry{
key: key,
value: val,
state: stateOccupied,
}
m.size++
}
}
// Get ищет значение по ключу, возвращая значение и флаг наличия.
func (m *HashMap) Get(key int) (int, bool) {
idx := m.hash(key) & m.mask
for i := 0; i < m.capacity; i++ {
slot := (idx + i) & m.mask
curr := &m.buckets[slot]
if curr.state == stateEmpty {
// Цепочка зондирования прервалась — ключ гарантированно отсутствует
return 0, false
}
if curr.state == stateOccupied && curr.key == key {
return curr.value, true
}
// При встрече stateTombstone продолжаем поиск дальше
}
return 0, false
}
// Delete помечает ячейку маркером удаления (Tombstone).
func (m *HashMap) Delete(key int) bool {
idx := m.hash(key) & m.mask
for i := 0; i < m.capacity; i++ {
slot := (idx + i) & m.mask
curr := &m.buckets[slot]
if curr.state == stateEmpty {
return false
}
if curr.state == stateOccupied && curr.key == key {
curr.state = stateTombstone
m.size--
return true
}
}
return false
}
// resize переаллоцирует массив и переносит только активные элементы.
func (m *HashMap) resize(newCap int) {
oldBuckets := m.buckets
m.buckets = make([]entry, newCap)
m.capacity = newCap
m.mask = newCap - 1
m.size = 0 // Пересчитается в Put
for _, e := range oldBuckets {
if e.state == stateOccupied {
m.Put(e.key, e.value)
}
}
}
3. Механика памяти: битовая маска, Tombstones и инкрементальный ресайз
Степень двойки vs остаток от деления
Операция деления key % capacity в микроархитектуре процессора требует порядка 15–40 тактов (инструкция IDIV).
Если размер массива всегда является степенью двойки ($2^k$), операция вычисления остатка заменяется на битовое AND:
$$\text{slot} = h \ \& \ (\text{capacity} - 1)$$
Битовая маска выполняется ровно за 1 такт CPU.
Зачем нужен Tombstone?
При открытой адресации удаление элемента не может просто очистить слот в stateEmpty. Если при зондировании элемент $B$ был помещен после элемента $A$ из-за коллизии, физическая очистка ячейки $A$ разорвет цепочку поиска: метод Get(B) наткнется на stateEmpty и ошибочно решит, что $B$ в таблице нет!
Поэтому ячейка помечается как stateTombstone. Поиск продолжает зондирование сквозь tombstones, а новая вставка Put имеет право переиспользовать ячейку с tombstone.
4. Конкурентность и шардирование
Встроенный map в Go защищен от гонок на уровне рантайма: при обнаружении параллельной записи и чтения процесс немедленно аварийно завершается с паникой fatal error: concurrent map writes.
В кастомных реализациях для высоконагруженных систем применяют Striped Locking (Шардирование):
type ShardedHashMap struct {
shards []*ConcurrentShard
numShards int
mask int
}
type ConcurrentShard struct {
mu sync.RWMutex
m *HashMap
}
Шардирование распределяет запросы к разным мьютексам по хешу ключа, уменьшая время ожидания горутин в очереди на блокировку до пренебрежимо малых величин.
5. Вопросы для собеседования
Резюме
- Метод цепочек проще в реализации, но открытая адресация обеспечивает непревзойденную кэш-локальность благодаря непрерывному массиву памяти.
- Использование степеней двойки в качестве емкости заменяет медленное деление на битовую маску
cap - 1($1$ такт CPU). - Маркеры
tombstoneнеобходимы для сохранения целостности цепочки линейного зондирования при удалениях. - В Go 1.24+ архитектура ассоциативных массивов перешла на Swiss Tables, сочетающие 8-слотовые группы с SWAR/SIMD фильтрацией по контрольным байтам.
В следующей статье мы применим изученные структуры для проектирования распределенной социальной ленты твитов: 6. Design Twitter.