Files
eduplan-cpp-eltex/diag/tasks/10_hash/task.md
T

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. Что нужно знать до старта

  1. idx = hash & (cap - 1) работает как «остаток от деления», только если cap — степень двойки. Пример: cap = 8 (маска 0b111), hash = 19 (0b10011) → 19 & 7 = 3.
  2. Линейное зондирование: если ячейка занята, пробуем (i+1) & (cap-1), потом (i+2) & … и так по кругу, пока не найдём свободную (для вставки) или нужный ключ (для поиска).
  3. Удалять «в ноль» нельзя: если между началом зондирования и элементом появится пустая ячейка, поиск до него не дойдёт. Поэтому пустая ячейка помечается tombstone — «здесь был элемент, иди дальше».
  4. Фактор загрузки 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, то есть все они попадают в одну-две стартовые ячейки. Это проверка зондирования, а не «повезло с хешем»;
  • put 100 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 коммутаторов (там обычно хеш с цепочками и ограничением глубины).