12 KiB
Задача 10 — хеш-таблица с открытой адресацией
Перед этой задачей прочитай урок
lessons/D1_algo.md(разделы 3 и 5) — там разобран принцип и есть рабочий образец с цепочками. Здесь то же самое, но сложнее: коллизии разрешаются внутри массива.
Глава I. Общая информация
- Цель: научиться писать контейнер, который используется в реальном коде (кэши, таблицы маршрутизации, счётчики в логах) и который спрашивают на собеседовании в формате «а сам сможешь?».
- Почему это в Eltex: таблицы MAC-адресов, FDB коммутатора, кэши сессий — всё это хеш-таблицы с открытой адресацией и жёсткими требованиями к памяти.
- Время: 1,5–2,5 часа со ступенями. Если больше 3 часов — не долби в одиночку, скажи мне.
- Что сдаётся: файл
solution.cpp, проходящийpython3 grade.py 10.
Глава II. Что нужно знать до старта
idx = hash & (cap - 1)работает как «остаток от деления», только еслиcap— степень двойки. Пример:cap = 8(маска0b111),hash = 19(0b10011) →19 & 7 = 3.- Линейное зондирование: если ячейка занята, пробуем
(i+1) & (cap-1), потом(i+2) & …и так по кругу, пока не найдём свободную (для вставки) или нужный ключ (для поиска). - Удалять «в ноль» нельзя: если между началом зондирования и элементом появится пустая ячейка, поиск до него не дойдёт. Поэтому пустая ячейка помечается tombstone — «здесь был элемент, иди дальше».
- Фактор загрузки
load = size / capacity. Держим ≤ 0.7: при 0.8+ пробеги становятся длинными, и O(1) превращается в O(n).
Глава III. Задание
Свой контейнер, std::unordered_map использовать нельзя. Интерфейс ровно такой:
class HashTable {
public:
explicit HashTable(std::size_t initial_capacity = 16);
void put(int key, int value); // обновляет значение, если ключ уже есть
bool get(int key, int& out) const; // false — ключа нет
bool erase(int key); // false — ключа не было
std::size_t size() const;
std::size_t capacity() const; // степень двойки, >= 16
};
Требования:
- ключи с совпадающими младшими битами обязаны находиться — тест вставляет 512 ключей, кратных 16, то есть все они попадают в одну-две стартовые ячейки. Это проверка зондирования, а не «повезло с хешем»;
put100 000 ключей суммарно быстрее 2 секунд;- после вставок
capacity()растёт (степень двойки), загрузка остаётся ≤ 0.7; size()честно считает элементы: повторныйputтого же ключа не увеличиваетsize;eraseработает: после удаления ключ не находится, но поиск другого ключа, стоявшего за ним в цепочке зондирования, по-прежнему работает;- удаление и последующая вставка не должны «терять» элементы, а
size()после удаления и вставки возвращается к правильному значению.
Глава IV. Ступени (делай по одной, после каждой — прогон)
Не пытайся написать всё сразу. Каждая ступень проверяется отдельно, и это нормальный порядок работы инженера.
Ступень 1. Хеш-функция и индекс (20 минут).
Напиши size_t hash_of(int key) и size_t index_of(int key) const. Для целых ключей
хорошая хеш-функция — перемешать биты умножением на большую нечётную константу:
h = (uint64_t)key * 2654435761u; (это золотое сечение, классика).
Проверь себя на бумаге или в маленькой программе: для cap = 16 ключи 16, 32, 48 должны
дать разные индексы. Если дают одинаковые — ты забыл перемешать биты, и все кратные 16
свалятся в одну ячейку.
Ступень 2. Массивы и конструктор (15 минут).
Нужны три вещи: массив значений, массив признаков состояния ячейки и счётчик размера.
Признаки: EMPTY, OCCUPIED, TOMBSTONE. Ёмкость в конструкторе приводи к степени двойки
(16 по умолчанию) — тест ожидает capacity() >= 16 и степень двойки.
После этого прогон должен уже собираться, а часть проверок на пустой таблице — проходить.
Ступень 3. Вставка без перехеширования (30 минут).
Идёшь по ячейкам от index_of(key), пока не найдёшь: свой ключ (обновить значение),
EMPTY или TOMBSTONE (вставить). Важно: если ключ найден — не увеличивай size().
Tombstone можно переиспользовать под вставку, но тогда его признак меняется на OCCUPIED.
Ступень 4. Поиск (15 минут).
Идёшь так же, но: свой ключ — нашли; EMPTY — стоп, ключа нет; TOMBSTONE — идём дальше
(здесь легко ошибиться и вернуть false раньше времени).
Ступень 5. Удаление через tombstone (20 минут).
Нашёл ключ → ставим TOMBSTONE, size--. Если ключа нет — false, ничего не меняем.
Ступень 6. Перехеширование (30 минут).
Когда size * 10 > capacity * 7, создай массив вдвое больше и заново вставь все занятые
ключи (tombstone не переносим — в новой таблице пусто). Учти: после этого size не должен
измениться, а пробеги станут короче. Прогон: python3 grade.py 10.
Ступень 7. Прогон под санитайзерами (10 минут).
grade.py уже собирает с ASAN/UBSAN — если он зелёный, память чистая. Отдельно проверь,
что деструктор освобождает ровно то, что выделил конструктор (или не выделяй вручную вовсе,
а используй std::vector — так короче и безопаснее).
Глава V. Критерии приёмки
Задача сдана, когда python3 grade.py 10 печатает PASS — это значит: сборка без
предупреждений, ASAN/UBSAN чистые, все проверки ok, включая 512 ключей, кратных 16,
перехеширование, удаление из середины цепочки и повторные вставки.
Глава VI. Подсказки (открывать после первой честной попытки)
Не находит ключи, кратные 16
Ты используешь key & (cap-1) без перемешивания. Ключи 16, 32, 48, … в маске дают 0 —
все в одну ячейку, пробег растёт, а при большом числе ключей ты упираешься в «таблица
полна». Лечится хеш-функцией: умножить ключ на большую нечётную константу перед маской.
size() растёт от повторных put
Сначала ищи существующий ключ. Нашёл — обнови значение и выйди, size++ не делай.
Увеличивай счётчик только когда записал в пустую или tombstone-ячейку.
После erase пропадают соседние ключи
Ты ставишь EMPTY вместо TOMBSTONE, и поиск обрывается на этом месте. EMPTY — только
для никогда не занятых ячеек; после удаления — TOMBSTONE.
Зацикливается при полной таблице
Цикл зондирования обязан ограничиться capacity шагами. Если таблица переполнилась —
значит не сработал порог перехеширования (0.7) или ты неверно считаешь size.
Глава VII. Частые ошибки и как их не допустить
- Не перемешать хеш → кластеризация, тест на ключи, кратные 16, падает. Самая частая.
%вместо&→ работает, но теряется смысл требования «степень двойки»; и на медленных платформах деление дороже.- Забыть про tombstone при rehash → «мёртвые» ячейки переезжают и занимают место.
- Хранить ключ отдельно от значения и перепутать порядок → ASAN поймает, но лучше держать их в одной структуре ячейки.
- Проверять
size == capacityвместо load factor → таблица почти полна, пробеги огромны, время уходит за лимит 2 секунды.
После сдачи
Разбор со мной: почему ёмкость — степень двойки; чем открытая адресация лучше цепочек по кэшу и хуже по кластеризации; как tombstone-метки спасают поиск после удаления; как это устроено в реальных FDB коммутаторов (там обычно хеш с цепочками и ограничением глубины).