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

152 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Задача 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` использовать нельзя. Интерфейс ровно такой:
```c++
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. Подсказки (открывать после первой честной попытки)
<details>
<summary>Не находит ключи, кратные 16</summary>
Ты используешь `key & (cap-1)` без перемешивания. Ключи 16, 32, 48, … в маске дают 0 —
все в одну ячейку, пробег растёт, а при большом числе ключей ты упираешься в «таблица
полна». Лечится хеш-функцией: умножить ключ на большую нечётную константу перед маской.
</details>
<details>
<summary>size() растёт от повторных put</summary>
Сначала ищи существующий ключ. Нашёл — обнови значение и выйди, `size++` не делай.
Увеличивай счётчик только когда записал в пустую или tombstone-ячейку.
</details>
<details>
<summary>После erase пропадают соседние ключи</summary>
Ты ставишь `EMPTY` вместо `TOMBSTONE`, и поиск обрывается на этом месте. `EMPTY` — только
для никогда не занятых ячеек; после удаления — `TOMBSTONE`.
</details>
<details>
<summary>Зацикливается при полной таблице</summary>
Цикл зондирования обязан ограничиться `capacity` шагами. Если таблица переполнилась —
значит не сработал порог перехеширования (0.7) или ты неверно считаешь `size`.
</details>
## Глава VII. Частые ошибки и как их не допустить
- **Не перемешать хеш** → кластеризация, тест на ключи, кратные 16, падает. Самая частая.
- **`%` вместо `&`** → работает, но теряется смысл требования «степень двойки»; и на
медленных платформах деление дороже.
- **Забыть про tombstone при rehash** → «мёртвые» ячейки переезжают и занимают место.
- **Хранить ключ отдельно от значения и перепутать порядок** → ASAN поймает, но лучше
держать их в одной структуре ячейки.
- **Проверять `size == capacity` вместо load factor** → таблица почти полна, пробеги
огромны, время уходит за лимит 2 секунды.
## После сдачи
Разбор со мной: почему ёмкость — степень двойки; чем открытая адресация лучше цепочек по
кэшу и хуже по кластеризации; как tombstone-метки спасают поиск после удаления; как это
устроено в реальных FDB коммутаторов (там обычно хеш с цепочками и ограничением глубины).