Учебные материалы: механизм вместо тезисов, блоки «Факты для карточек», Ловушки (Claude Code) + diag/cards_src.tsv
This commit is contained in:
+109
-23
@@ -14,17 +14,52 @@
|
||||
- **Время:** 1,5–2,5 часа со ступенями. Если больше 3 часов — не долби в одиночку, скажи мне.
|
||||
- **Что сдаётся:** файл `solution.cpp`, проходящий `python3 grade.py 10`.
|
||||
|
||||
## Глава II. Что нужно знать до старта
|
||||
**Факты для карточек**
|
||||
- base | Сколько времени закладывается на задачу 10? — 1,5–2,5 часа со ступенями
|
||||
- base | Какой командой проверяется решение? — `python3 grade.py 10`
|
||||
- deep | Где в реальных устройствах используется похожая структура? — таблицы MAC-адресов (FDB) коммутатора, кэши сессий — открытая адресация с жёсткими ограничениями по памяти
|
||||
|
||||
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).
|
||||
Почему дальше: прежде чем писать код, нужно понять, почему индекс считается через `&`,
|
||||
а не через `%`, и что физически происходит при коллизии.
|
||||
|
||||
## Глава II. Механизм: индекс, зондирование, tombstone, load factor
|
||||
|
||||
**Индекс через маску.** `idx = hash & (cap - 1)` работает как «остаток от деления», только
|
||||
если `cap` — степень двойки. Причина: у степени двойки `cap - 1` в двоичном виде — это
|
||||
подряд идущие единицы во всех младших битах (например, `cap = 8 = 0b1000` → `cap-1 = 0b0111`).
|
||||
Операция `AND` с такой маской обнуляет все биты хеша выше этой границы и оставляет ровно
|
||||
младшие `log2(cap)` бит — а это математически то же самое, что `hash mod cap`. Пример:
|
||||
`cap = 8` (маска `0b111`), `hash = 19` (`0b10011`) → `19 & 7 = 3`.
|
||||
`%` работает для любой ёмкости, но требует инструкции деления (на некоторых платформах
|
||||
дороже, чем сдвиг/AND); отсюда практика ограничивать ёмкость степенью двойки и считать
|
||||
индекс битовой маской.
|
||||
|
||||
**Линейное зондирование.** Если ячейка `idx` занята, пробуем `(idx+1) & (cap-1)`, потом
|
||||
`(idx+2) & (cap-1)` и так по кругу, пока не найдём свободную ячейку (для вставки) или
|
||||
нужный ключ (для поиска). Механически это обход массива по кругу начиная с `idx`.
|
||||
|
||||
**Tombstone.** Удалять «в ноль» (ставить `EMPTY`) нельзя: если между началом зондирования
|
||||
и искомым элементом появится пустая ячейка, поиск остановится на ней раньше, чем дойдёт до
|
||||
элемента — элемент физически на месте, но недостижим для `get`. Поэтому удалённая ячейка
|
||||
помечается **tombstone** — «здесь был элемент, поиск должен идти дальше», но **вставка**
|
||||
может переиспользовать tombstone-ячейку под новый ключ.
|
||||
|
||||
**Load factor и длина пробега.** `load = size / capacity`. По формуле среднего числа проб
|
||||
при линейном зондировании (Кнут): удачный поиск — `0.5·(1 + 1/(1-load))` проб,
|
||||
неудачный — `0.5·(1 + 1/(1-load)²)`. При `load = 0.7`: удачный поиск ≈ 2,2 пробы,
|
||||
неудачный ≈ 6,1. При `load = 0.9`: удачный ≈ 5,5, неудачный ≈ 50,5 — рост нелинейный,
|
||||
именно поэтому порог держат на 0.7, а не поднимают его «для экономии памяти».
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Почему `hash & (cap-1)` эквивалентно `hash % cap`? — только если `cap` — степень двойки: `cap-1` в двоичном виде — маска из всех младших бит, AND с ней даёт тот же результат, что остаток от деления
|
||||
- base | Пример: `cap=8` (маска `0b111`), `hash=19` (`0b10011`). Индекс? — `19 & 7 = 3`
|
||||
- core | Среднее число проб при удачном поиске, load factor 0.7? — ≈2,2 (формула Кнута `0.5·(1+1/(1-0.7))`)
|
||||
- core | То же при load factor 0.9? — ≈5,5 — рост почти в 2,5 раза от значения при 0.7
|
||||
- deep | Среднее число проб при НЕудачном поиске, load factor 0.9? — ≈50,5 (`0.5·(1+1/(1-0.9)²)`) — на порядок хуже удачного поиска
|
||||
- core | Почему `EMPTY` вместо `TOMBSTONE` после удаления ломает поиск чужих ключей? — поиск идёт по цепочке проб до первой `EMPTY`; если такая ячейка появляется раньше искомого ключа, элементы за ней в этой цепочке становятся ненаходимыми, хотя физически на месте
|
||||
|
||||
Почему дальше: механизм понятен — теперь нужен точный интерфейс и числа, по которым
|
||||
`grade.py` проверяет решение.
|
||||
|
||||
## Глава III. Задание
|
||||
|
||||
@@ -55,6 +90,12 @@ public:
|
||||
- удаление и последующая вставка не должны «терять» элементы, а `size()` после удаления
|
||||
и вставки возвращается к правильному значению.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Сколько ключей вставляет тест на кластеризацию и какие они? — 512 ключей, кратных 16
|
||||
- base | За какое время должны пройти 100 000 вставок? — быстрее 2 секунд
|
||||
- base | Какой порог load factor держит таблица? — ≤ 0.7
|
||||
- base | Минимальная ёмкость таблицы по умолчанию? — 16, степень двойки
|
||||
|
||||
## Глава IV. Ступени (делай по одной, после каждой — прогон)
|
||||
|
||||
Не пытайся написать всё сразу. Каждая ступень проверяется отдельно, и это нормальный
|
||||
@@ -63,7 +104,10 @@ public:
|
||||
**Ступень 1. Хеш-функция и индекс (20 минут).**
|
||||
Напиши `size_t hash_of(int key)` и `size_t index_of(int key) const`. Для целых ключей
|
||||
хорошая хеш-функция — перемешать биты умножением на большую нечётную константу:
|
||||
`h = (uint64_t)key * 2654435761u;` (это золотое сечение, классика).
|
||||
`h = (uint64_t)key * 2654435761u;` (это `2^32 / φ` при золотом сечении `φ ≈ 1.618`,
|
||||
классика — умножение на нечётную константу обратимо по модулю `2^32` и «размазывает»
|
||||
младшие биты ключа по всей ширине результата, поэтому соседние по младшим битам ключи
|
||||
после умножения расходятся по разным индексам).
|
||||
Проверь себя на бумаге или в маленькой программе: для `cap = 16` ключи 16, 32, 48 должны
|
||||
дать **разные** индексы. Если дают одинаковые — ты забыл перемешать биты, и все кратные 16
|
||||
свалятся в одну ячейку.
|
||||
@@ -87,9 +131,11 @@ Tombstone можно переиспользовать под вставку, н
|
||||
Нашёл ключ → ставим `TOMBSTONE`, `size--`. Если ключа нет — `false`, ничего не меняем.
|
||||
|
||||
**Ступень 6. Перехеширование (30 минут).**
|
||||
Когда `size * 10 > capacity * 7`, создай массив вдвое больше и **заново вставь все занятые
|
||||
ключи** (tombstone не переносим — в новой таблице пусто). Учти: после этого `size` не должен
|
||||
измениться, а пробеги станут короче. Прогон: `python3 grade.py 10`.
|
||||
Когда `size * 10 > capacity * 7` (то есть `load > 0.7`), создай массив вдвое больше и
|
||||
**заново вставь все занятые ключи** (tombstone не переносим — в новой таблице пусто).
|
||||
Учти: после этого `size` не должен измениться, а пробеги станут короче (см. формулу проб
|
||||
из главы II — вдвое большая ёмкость при том же `size` резко снижает `load`, а с ним и
|
||||
среднее число проб). Прогон: `python3 grade.py 10`.
|
||||
|
||||
**Ступень 7. Прогон под санитайзерами (10 минут).**
|
||||
`grade.py` уже собирает с ASAN/UBSAN — если он зелёный, память чистая. Отдельно проверь,
|
||||
@@ -133,16 +179,56 @@ Tombstone можно переиспользовать под вставку, н
|
||||
значит не сработал порог перехеширования (0.7) или ты неверно считаешь `size`.
|
||||
</details>
|
||||
|
||||
## Глава VII. Частые ошибки и как их не допустить
|
||||
## Глава VII. Ловушки
|
||||
|
||||
- **Не перемешать хеш** → кластеризация, тест на ключи, кратные 16, падает. Самая частая.
|
||||
- **`%` вместо `&`** → работает, но теряется смысл требования «степень двойки»; и на
|
||||
медленных платформах деление дороже.
|
||||
- **Забыть про tombstone при rehash** → «мёртвые» ячейки переезжают и занимают место.
|
||||
- **Хранить ключ отдельно от значения и перепутать порядок** → ASAN поймает, но лучше
|
||||
держать их в одной структуре ячейки.
|
||||
- **Проверять `size == capacity` вместо load factor** → таблица почти полна, пробеги
|
||||
огромны, время уходит за лимит 2 секунды.
|
||||
- Забыть перемешать хеш (использовать `key` напрямую как индекс) → ключи, кратные 16,
|
||||
все попадают в одну-две ячейки → кластеризация, пробег растёт линейно с числом таких
|
||||
ключей → тест на 512 ключей, кратных 16, падает по таймауту или по «not found».
|
||||
- Забыть про tombstone при rehash (перенести признак `TOMBSTONE` в новую таблицу вместо
|
||||
того, чтобы его отбросить) → «мёртвые» ячейки переезжают и занимают место в новой
|
||||
таблице без необходимости → эффективная ёмкость меньше заявленной, load factor растёт
|
||||
быстрее ожидаемого.
|
||||
- Хранить ключ отдельно от значения в параллельных массивах и перепутать индекс при
|
||||
перестановке → ASAN поймает не сразу, а в момент обращения к рассинхронизированной
|
||||
ячейке; надёжнее держать ключ и значение в одной структуре ячейки.
|
||||
- Проверять `size == capacity` вместо load factor (`size * 10 > capacity * 7`) → таблица
|
||||
почти полностью заполняется, пробеги становятся длиной в десятки-сотни ячеек (см. формулу
|
||||
неудачного поиска при load → 1), суммарное время `put` уходит за лимит 2 секунды на
|
||||
100 000 ключей.
|
||||
|
||||
## Проверь себя
|
||||
|
||||
<details>
|
||||
<summary>1. Почему `cap = 17` был бы плохим выбором ёмкости таблицы?</summary>
|
||||
`cap-1 = 16 = 0b10000` — не маска из подряд идущих единиц, поэтому `hash & (cap-1)`
|
||||
перестаёт быть эквивалентно `hash % cap`: часть значений индекса становится недостижимой,
|
||||
а распределение по ячейкам — неравномерным. Степень двойки — обязательное условие, при
|
||||
котором работает битовая маска вместо деления.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>2. Во сколько раз в среднем растёт число проб при удачном поиске, если load factor
|
||||
вырастет с 0.7 до 0.9?</summary>
|
||||
Примерно в 2,5 раза: `0.5·(1+1/(1-0.7)) ≈ 2,2` против `0.5·(1+1/(1-0.9)) ≈ 5,5`.
|
||||
Для неудачного поиска рост ещё резче — с ≈6,1 до ≈50,5, то есть почти в 8 раз.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>3. Почему при вставке нельзя сразу увеличивать `size`, не проверив, есть ли уже
|
||||
такой ключ?</summary>
|
||||
`put` обязан обновлять значение существующего ключа, а не создавать дубликат. Если
|
||||
инкрементировать `size` без предварительного поиска ключа по цепочке зондирования,
|
||||
повторная вставка одного и того же ключа завысит `size()` и тест на честный подсчёт
|
||||
элементов упадёт.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>4. Почему tombstone-ячейки не переносятся в новую таблицу при перехешировании?</summary>
|
||||
В новой, увеличенной вдвое таблице нет старых цепочек зондирования — переносятся только
|
||||
реально занятые ключи, вставленные заново с нуля. Перенос tombstone бессмысленно тратил бы
|
||||
место в и без того более просторной таблице и не даёт никакого выигрыша, поскольку сами
|
||||
цепочки проб после rehash пересчитываются заново.
|
||||
</details>
|
||||
|
||||
## После сдачи
|
||||
|
||||
|
||||
Reference in New Issue
Block a user