Учебные материалы: механизм вместо тезисов, блоки «Факты для карточек», Ловушки (Claude Code) + diag/cards_src.tsv
This commit is contained in:
+122
-17
@@ -3,10 +3,11 @@
|
||||
Это не проверка, а урок: сначала разбираем, потом сам решаешь. Читать сверху вниз,
|
||||
код в разборах можно копировать и запускать — это образец, а не ответ на задачу.
|
||||
|
||||
## 1. Что такое O-нотация (без воды)
|
||||
## 1. Что такое O-нотация
|
||||
|
||||
O-нотация отвечает на вопрос «как растёт время работы, когда данных становится в 10 раз
|
||||
больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n).
|
||||
больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n) — при n → ∞ слагаемое
|
||||
100 и множитель 3 не меняют форму роста, поэтому их не пишут.
|
||||
|
||||
Три правила, которых хватает для 90% вопросов:
|
||||
|
||||
@@ -18,14 +19,32 @@ O-нотация отвечает на вопрос «как растёт вре
|
||||
Полезно помнить наизусть: `log₂(1000) ≈ 10`, `log₂(10⁶) ≈ 20`, `log₂(10⁹) ≈ 30`.
|
||||
Каждое умножение данных на 1000 добавляет примерно 10 шагов — это и есть смысл log n.
|
||||
|
||||
**Амортизированная сложность** — средняя стоимость операции, если редкая дорогая операция
|
||||
«размазывается» по множеству дешёвых. Классический пример: `std::vector::push_back` —
|
||||
обычно O(1), но при переполнении копирует весь массив за O(n); в среднем всё равно
|
||||
**амортизированное O(1)**, потому что ёмкость удваивается.
|
||||
**Амортизированная сложность** — средняя стоимость операции на длинной серии вызовов, а не
|
||||
гарантия для каждого отдельного вызова. Механизм на примере `std::vector::push_back`:
|
||||
когда выделенной памяти не хватает, вектор не увеличивает ёмкость на 1, а **удваивает** её,
|
||||
выделяет новый блок и переносит туда все элементы — это разовая операция O(n). Из-за
|
||||
геометрического роста ёмкости такие реаллокации случаются экспоненциально реже: после
|
||||
k-й реаллокации следующая наступит примерно через 2^k новых вставок. Сумма стоимости всех
|
||||
реаллокаций на n вставок — геометрическая прогрессия n/2 + n/4 + n/8 + ... ≈ n, то есть
|
||||
суммарно O(n) на n операций, а не O(n²) — отсюда амортизированное **O(1)** на одну вставку.
|
||||
Если бы ёмкость росла линейно (+1 каждый раз), каждая вставка копировала бы весь массив —
|
||||
суммарно O(n²). Геометрический рост — не оптимизация, а необходимое условие амортизации.
|
||||
Практическое следствие: реаллокация инвалидирует все указатели, ссылки и итераторы на
|
||||
элементы вектора, потому что блок памяти физически переехал — источник use-after-free,
|
||||
который ловит ASAN.
|
||||
|
||||
**Худший случай ≠ средний.** Хеш-таблица: в среднем поиск O(1), но если хеш-функция плохая
|
||||
и все ключи попали в одну корзину, поиск вырождается в перебор → **O(n)**.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Во что превращается 3n + 100 в O-нотации? — O(n)
|
||||
- base | Сколько шагов у бинарного поиска в массиве из 10⁶ элементов? — 20 (log₂ 10⁶ ≈ 20)
|
||||
- core | Почему push_back в среднем O(1), а не O(n)? — ёмкость растёт геометрически (удвоение), сумма реаллокаций на n вставок — геометрическая прогрессия ≈ n, а не n²
|
||||
- core | Что ломает удвоение ёмкости у vector? — указатели/итераторы/ссылки на старые элементы (реаллокация переносит блок памяти)
|
||||
- deep | Что будет, если ёмкость vector растить на +1 за раз вместо удвоения? — суммарная стоимость n вставок станет O(n²)
|
||||
|
||||
Почему дальше: если push_back амортизированно O(1) за счёт удвоения, какие структуры данных вообще гарантируют O(1) в среднем на операцию — переходим к таблице сложностей и хеш-таблицам.
|
||||
|
||||
## 2. Таблица сложностей, которую надо знать
|
||||
|
||||
| Структура | Поиск | Вставка | Удаление | Память |
|
||||
@@ -38,17 +57,44 @@ O-нотация отвечает на вопрос «как растёт вре
|
||||
| Бинарная куча | O(n) поиск | O(log n) | O(log n) удалить корень | O(n) |
|
||||
| Сбалансированное BST (map) | O(log n) | O(log n) | O(log n) | O(n) |
|
||||
|
||||
Почему связный список даёт O(1) на вставку/удаление: если узел уже найден (есть указатель),
|
||||
операция — просто перелинковка соседних указателей без сдвига остальных элементов; O(n) в
|
||||
поиске появляется отдельно, потому что нет арифметики адреса — только последовательный проход.
|
||||
Почему у отсортированного массива поиск O(log n), а вставка O(n): бинарный поиск делит
|
||||
диапазон пополам, но вставка сдвигает все элементы после точки вставки, чтобы сохранить
|
||||
непрерывность блока памяти.
|
||||
|
||||
Кучи отдельно: **построение из произвольного массива — O(n)** (не O(n log n) — это
|
||||
частый вопрос), вставка одного элемента — O(log n), взятие максимума — O(1).
|
||||
частый вопрос: heapify идёт снизу вверх от середины массива к началу, и суммарная работа
|
||||
по всем уровням даёт линейную оценку), вставка одного элемента — O(log n) (просеивание
|
||||
вверх на высоту кучи), взятие максимума — O(1) (это корень).
|
||||
|
||||
Сортировки: quicksort — в среднем O(n log n), в худшем **O(n²)** (уже отсортированный
|
||||
массив при плохом выборе опорного); mergesort — всегда O(n log n) и **устойчив**;
|
||||
heapsort — O(n log n), неустойчив, O(1) доп. памяти. Нижняя оценка для сортировки
|
||||
сравнениями — **Ω(n log n)**, быстрее сравнениями нельзя.
|
||||
сравнениями — **Ω(n log n)**, быстрее сравнениями нельзя (это доказывается через дерево
|
||||
решений: n! перестановок, глубина дерева бинарных сравнений — минимум log₂(n!) ≈ n log n).
|
||||
|
||||
Устойчивость = равные элементы сохраняют исходный порядок. Устойчивы: merge, insertion,
|
||||
bubble, counting. Неустойчивы: quick, heap, selection.
|
||||
|
||||
**map vs unordered_map**: `map` — красно-чёрное дерево, инвариант балансировки (чередование
|
||||
цветов узлов, равное число чёрных узлов на любом пути от корня до листа) держит высоту
|
||||
порядка log n, отсюда O(log n) на все операции и ключи всегда в отсортированном порядке при
|
||||
обходе. `unordered_map` в среднем быстрее на чистом поиске/вставке (O(1) и меньше косвенных
|
||||
переходов по указателям), но не даёт упорядоченного обхода и не гарантирует порядок бакетов
|
||||
между вызовами rehash.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Сложность построения кучи (heapify) из произвольного массива? — O(n), не O(n log n)
|
||||
- base | Какая сортировка всегда O(n log n) и устойчива? — mergesort
|
||||
- core | Худший случай quicksort и когда он достигается? — O(n²), на уже отсортированном массиве при плохом выборе опорного
|
||||
- core | Нижняя граница сортировки сравнениями? — Ω(n log n)
|
||||
- core | За счёт чего map держит высоту log n? — инвариант красно-чёрного дерева: чередование цветов + равное число чёрных узлов на пути от корня до листа
|
||||
- deep | Почему нельзя полагаться на порядок обхода unordered_map? — порядок бакетов не гарантирован и может меняться при rehash
|
||||
|
||||
Почему дальше: таблица говорит, что хеш-таблица в среднем O(1) — дальше разбираем механизм, который это обеспечивает и почему он иногда ломается до O(n).
|
||||
|
||||
## 3. Хеш-таблица: как устроена
|
||||
|
||||
Идея: по ключу считаем число (хеш) и превращаем его в индекс массива. Хотим получить
|
||||
@@ -57,7 +103,8 @@ bubble, counting. Неустойчивы: quick, heap, selection.
|
||||
Компоненты: **массив корзин**, **хеш-функция**, **правило разрешения коллизий**, **фактор
|
||||
загрузки** (сколько занято от общего размера).
|
||||
|
||||
Коллизия — два разных ключа дали один индекс. Это норма, а не ошибка.
|
||||
Коллизия — два разных ключа дали один индекс. Это норма, а не ошибка: при n ключах и m
|
||||
корзинах коллизии статистически неизбежны уже при n, сравнимом с √m (парадокс дней рождения).
|
||||
|
||||
Два способа разрешения:
|
||||
|
||||
@@ -66,17 +113,44 @@ bubble, counting. Неустойчивы: quick, heap, selection.
|
||||
- **Открытая адресация (open addressing):** все элементы лежат в самом массиве. Занято —
|
||||
ищем следующую свободную ячейку по правилу: линейное зондирование `(i+1) % cap`,
|
||||
квадратичное `(i + k²) % cap`, двойное хеширование `(i + k·h2) % cap`.
|
||||
Быстрее по кэшу, но есть проблема **удаления**: если просто очистить ячейку, цепочка
|
||||
зондирования порвётся и поиск не найдёт элемент дальше. Решение — **tombstone**
|
||||
(надгробие): помечаем ячейку «был элемент», поиск идёт дальше, вставка может её занять.
|
||||
Быстрее по кэшу (элементы лежат подряд в памяти, меньше промахов кэша, чем при обходе
|
||||
разбросанных по куче узлов списка), но есть проблема **удаления**: если просто очистить
|
||||
ячейку, цепочка зондирования порвётся и поиск не найдёт элемент дальше. Решение —
|
||||
**tombstone** (надгробие): помечаем ячейку «был элемент, но сейчас пусто», поиск идёт
|
||||
дальше сквозь неё, а вставка может её переиспользовать. Без tombstone поиск останавливался
|
||||
бы на первой пустой ячейке и не долистывал бы до элемента, который на самом деле лежит
|
||||
дальше по цепочке зондирования.
|
||||
|
||||
**Фактор загрузки** `load = size / capacity`. При открытой адресации держат ≤ 0.7:
|
||||
чем плотнее, тем длиннее пробеги. При превышении — **rehash**: выделяем массив вдвое
|
||||
больше и переносим все элементы (это O(n), но редко, поэтому амортизированно дёшево).
|
||||
чем плотнее массив, тем длиннее пробеги до свободной ячейки (при load → 1 среднее число
|
||||
проб на поиск растёт неограниченно). При превышении порога — **rehash**: физически
|
||||
выделяется новый массив вдвое больше старого, и **каждый** элемент вставляется в него заново
|
||||
по новому индексу (`hash % new_cap`), потому что индекс зависит от текущей ёмкости — старые
|
||||
позиции для новой ёмкости в общем случае неверны. Это разовая операция O(n), но происходит
|
||||
она редко и по той же геометрической прогрессии, что и рост `vector` (раздел 1) — отсюда
|
||||
**амортизированное O(1)** на вставку, а не просто «в среднем быстро».
|
||||
|
||||
Почему ёмкость берут степенью двойки: тогда `idx = hash & (cap - 1)` вместо дорогого
|
||||
деления по модулю. Отсюда же требование: хеш-функция должна хорошо перемешивать младшие
|
||||
биты (для строк — FNV-1a или `std::hash<std::string>`).
|
||||
деления по модулю — побитовое И на порядок дешевле целочисленного деления на процессоре.
|
||||
Отсюда же требование: хеш-функция должна хорошо перемешивать именно младшие биты (при
|
||||
делении по модулю участвуют все биты хеша, при `& (cap-1)` — только младшие log₂(cap)),
|
||||
для строк — FNV-1a или `std::hash<std::string>`.
|
||||
|
||||
**Ловушки**
|
||||
- Взять ёмкость не степенью двойки при использовании `hash & (cap-1)` → маска отрежет не те биты → часть корзин никогда не используется, видно по неравномерному распределению цепочек.
|
||||
- Удалять элемент простой очисткой ячейки при открытой адресации вместо tombstone → поиск последующих элементов той же цепочки зондирования обрывается раньше времени → `get` возвращает false для существующего ключа, видно в тесте «insert A, B (коллизия с A), delete A, get B» → false.
|
||||
- Не проверять load factor перед вставкой → цепочки/пробеги растут неограниченно → поиск деградирует к O(n), видно по профилировщику как рост времени `unordered_map::find` с размером таблицы.
|
||||
- Пользовательский ключ с плохим/предсказуемым хешем → все элементы в одном бакете → тихая деградация до O(n) без ошибки компиляции, ловится только профилировщиком.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Формула фактора загрузки? — load = size / capacity
|
||||
- base | Порог load factor при открытой адресации, после которого делают rehash? — обычно ≤ 0.7
|
||||
- core | Зачем tombstone при открытой адресации? — чтобы удаление не обрывало цепочку зондирования: поиск должен пройти сквозь помеченную ячейку до элемента, вставленного позже
|
||||
- core | Почему ёмкость хеш-таблицы берут степенью двойки? — idx = hash & (cap-1) вместо деления по модулю — дешевле на процессоре
|
||||
- core | Что физически происходит при rehash? — выделяется массив вдвое больше, каждый элемент переставляется по новому индексу (зависит от cap)
|
||||
- deep | Почему rehash даёт амортизированное O(1), а не O(n) на вставку? — та же геометрическая прогрессия, что у vector::push_back: суммарная стоимость n вставок ≈ n, а не n²
|
||||
|
||||
Почему дальше: те же формулы сложности стоит применить к конкретному коду — переходим к разбору задач, где нужно на глаз определить сложность.
|
||||
|
||||
## 4. Разбор примера: считаем сложности
|
||||
|
||||
@@ -109,6 +183,16 @@ bool has_pair_fast(const std::vector<int>& v, int k) { // O(n) в среднем
|
||||
|
||||
(в) — типовой ответ на собеседовании: «перебор O(n²), но с хеш-множеством получаем O(n)
|
||||
за счёт O(n) дополнительной памяти». Уметь назвать и время, и память — половина ответа.
|
||||
Механизм ускорения: (б) на каждой паре (i, j) делает сравнение за O(1), но пар — O(n²);
|
||||
(в) вместо перебора пар один раз кладёт каждый элемент в хеш-множество (O(1) в среднем на
|
||||
вставку) и один раз проверяет наличие дополнения k - x (O(1) в среднем на поиск) — итого
|
||||
O(n) вставок и O(n) поисков вместо O(n²) сравнений.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Сложность has_pair (вложенный цикл по всем парам)? — O(n²)
|
||||
- core | Сложность has_pair_fast по времени и по памяти? — O(n) по времени в среднем, O(n) дополнительной памяти под хеш-множество
|
||||
|
||||
Почему дальше: чтобы поверить в O(1) на вставку/поиск из примера (в), нужно понять, что внутри unordered_set/unordered_map — переходим к ручной сборке хеш-таблицы.
|
||||
|
||||
## 5. Разбор примера: как руками собрать хеш-таблицу
|
||||
|
||||
@@ -168,11 +252,21 @@ struct HashTable {
|
||||
|
||||
Что здесь важно понять по шагам: `index()` — где именно ищем; `put` — сначала ищем
|
||||
существующий ключ (иначе будут дубли), потом вставляем; `rehash` — заново раскладываем
|
||||
**все** узлы, потому что индекс зависит от размера массива.
|
||||
**все** узлы, потому что индекс зависит от размера массива (`fnv1a(k) % buckets.size()`) —
|
||||
после удвоения `buckets.size()` старый индекс для того же ключа почти всегда неверен, поэтому
|
||||
пересчёт нужен для каждого узла, а не только для новых. Здесь ёмкость (8, 16, 32, ...) —
|
||||
степень двойки, но индекс считается через `%`, а не `&`; замена на `hash & (cap-1)` дала бы
|
||||
тот же результат быстрее, именно потому что cap — степень двойки.
|
||||
|
||||
Открытая адресация отличается только поиском места: вместо цепочки идём вперёд по массиву
|
||||
до свободной ячейки, а при удалении ставим tombstone.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Какие константы использует FNV-1a в этом коде (offset basis / prime)? — 1469598103934665603 / 1099511628211
|
||||
- core | Почему rehash пересчитывает индекс для каждого узла, а не переносит их как есть? — индекс = hash % buckets.size(), а size() изменился (удвоился), старые индексы для нового размера в общем случае неверны
|
||||
|
||||
Почему дальше: разобрав таблицу вручную, полезно свести готовые формулировки ответов на типовые вопросы интервью в один блок.
|
||||
|
||||
## 6. Что спросят на собеседовании (готовые ответы)
|
||||
|
||||
- «Средняя и худшая сложность поиска в хеш-таблице?» — амортизированное O(1), худшая O(n)
|
||||
@@ -184,6 +278,10 @@ struct HashTable {
|
||||
- «Чем цепочки отличаются от открытой адресации?» — цепочки проще и терпят load > 1,
|
||||
но аллокации; открытая адресация кэш-дружелюбнее, но требует load ≤ 0.7 и tombstone.
|
||||
|
||||
**Факты для карточек**
|
||||
- core | Чем открытая адресация выигрывает у цепочек по производительности и почему? — она кэш-дружелюбнее: элементы лежат подряд в массиве, а не разбросаны по куче отдельными узлами
|
||||
- deep | Может ли load factor у цепочек быть больше 1? — да, цепочка просто станет длиннее (в отличие от открытой адресации, где load ≤ 1 всегда)
|
||||
|
||||
## 7. Материалы (первопартийные)
|
||||
|
||||
- cppreference: `std::unordered_map`, `std::hash` — https://en.cppreference.com/w/cpp/container/unordered_map
|
||||
@@ -198,6 +296,8 @@ struct HashTable {
|
||||
3. `heapify` из произвольного массива — за сколько?
|
||||
4. Какая из сортировок устойчива: quick, merge, heap?
|
||||
5. Зачем tombstone при открытой адресации?
|
||||
6. Почему `push_back` вектора и `rehash` хеш-таблицы оба амортизированно O(1) — что у них общего в механизме?
|
||||
7. Почему ёмкость хеш-таблицы удобно делать степенью двойки?
|
||||
|
||||
<details>
|
||||
<summary>Ответы</summary>
|
||||
@@ -208,6 +308,11 @@ struct HashTable {
|
||||
4. merge (устойчива), quick и heap — нет.
|
||||
5. Чтобы удаление не разрывало цепочку зондирования: поиск должен пройти дальше удалённой
|
||||
ячейки до элемента, который был вставлен за ней.
|
||||
6. Оба удваивают ёмкость при переполнении вместо роста на фиксированный шаг — редкая
|
||||
операция O(n) размазывается по геометрической прогрессии вставок, суммарная стоимость
|
||||
n операций ≈ n, а не n².
|
||||
7. Индекс можно считать как `hash & (cap - 1)` (побитовое И) вместо деления по модулю —
|
||||
дешевле для процессора.
|
||||
</details>
|
||||
|
||||
## 9. Ссылки на задачи этого дня
|
||||
|
||||
Reference in New Issue
Block a user