# D1, часть 1. Сложность и хеш-таблицы (урок) Это не проверка, а урок: сначала разбираем, потом сам решаешь. Читать сверху вниз, код в разборах можно копировать и запускать — это образец, а не ответ на задачу. ## 1. Что такое O-нотация (без воды) O-нотация отвечает на вопрос «как растёт время работы, когда данных становится в 10 раз больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n). Три правила, которых хватает для 90% вопросов: 1. **Последовательные блоки складываются, остаётся старший.** O(n) + O(n²) → O(n²). 2. **Вложенные циклы перемножаются.** Цикл n по циклу n → O(n²). 3. **Деление задачи вдвое даёт log n.** Бинарный поиск в 1 000 000 элементов: 2²⁰ ≈ 1 048 576, значит 20 шагов → **O(log n), а число сравнений ≈ 20**. Полезно помнить наизусть: `log₂(1000) ≈ 10`, `log₂(10⁶) ≈ 20`, `log₂(10⁹) ≈ 30`. Каждое умножение данных на 1000 добавляет примерно 10 шагов — это и есть смысл log n. **Амортизированная сложность** — средняя стоимость операции, если редкая дорогая операция «размазывается» по множеству дешёвых. Классический пример: `std::vector::push_back` — обычно O(1), но при переполнении копирует весь массив за O(n); в среднем всё равно **амортизированное O(1)**, потому что ёмкость удваивается. **Худший случай ≠ средний.** Хеш-таблица: в среднем поиск O(1), но если хеш-функция плохая и все ключи попали в одну корзину, поиск вырождается в перебор → **O(n)**. ## 2. Таблица сложностей, которую надо знать | Структура | Поиск | Вставка | Удаление | Память | |---|---|---|---|---| | Массив (не отсортирован) | O(n) | O(1) в конец | O(n) | O(n) | | Отсортированный массив | O(log n) | O(n) | O(n) | O(n) | | Связный список | O(n) | O(1) по указателю | O(1) по указателю | O(n) | | Хеш-таблица (средн.) | O(1) | O(1) | O(1) | O(n) | | Хеш-таблица (худш.) | O(n) | O(n) | O(n) | O(n) | | Бинарная куча | O(n) поиск | O(log n) | O(log n) удалить корень | O(n) | | Сбалансированное BST (map) | O(log n) | O(log n) | O(log n) | O(n) | Кучи отдельно: **построение из произвольного массива — O(n)** (не O(n log n) — это частый вопрос), вставка одного элемента — 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)**, быстрее сравнениями нельзя. Устойчивость = равные элементы сохраняют исходный порядок. Устойчивы: merge, insertion, bubble, counting. Неустойчивы: quick, heap, selection. ## 3. Хеш-таблица: как устроена Идея: по ключу считаем число (хеш) и превращаем его в индекс массива. Хотим получить адрес за одно действие, без перебора. Компоненты: **массив корзин**, **хеш-функция**, **правило разрешения коллизий**, **фактор загрузки** (сколько занято от общего размера). Коллизия — два разных ключа дали один индекс. Это норма, а не ошибка. Два способа разрешения: - **Цепочки (chaining):** в каждой корзине список/вектор элементов. Просто, но много мелких аллокаций; при плохом хеше одна цепочка растёт до O(n). - **Открытая адресация (open addressing):** все элементы лежат в самом массиве. Занято — ищем следующую свободную ячейку по правилу: линейное зондирование `(i+1) % cap`, квадратичное `(i + k²) % cap`, двойное хеширование `(i + k·h2) % cap`. Быстрее по кэшу, но есть проблема **удаления**: если просто очистить ячейку, цепочка зондирования порвётся и поиск не найдёт элемент дальше. Решение — **tombstone** (надгробие): помечаем ячейку «был элемент», поиск идёт дальше, вставка может её занять. **Фактор загрузки** `load = size / capacity`. При открытой адресации держат ≤ 0.7: чем плотнее, тем длиннее пробеги. При превышении — **rehash**: выделяем массив вдвое больше и переносим все элементы (это O(n), но редко, поэтому амортизированно дёшево). Почему ёмкость берут степенью двойки: тогда `idx = hash & (cap - 1)` вместо дорогого деления по модулю. Отсюда же требование: хеш-функция должна хорошо перемешивать младшие биты (для строк — FNV-1a или `std::hash`). ## 4. Разбор примера: считаем сложности ```cpp // (а) сумма элементов long long sum(const std::vector& v) { // O(n): один проход long long s = 0; for (int x : v) s += x; return s; } // (б) пары с суммой k bool has_pair(const std::vector& v, int k) { // O(n^2): вложенный цикл for (size_t i = 0; i < v.size(); ++i) for (size_t j = i + 1; j < v.size(); ++j) if (v[i] + v[j] == k) return true; return false; } // (в) то же, но через хеш-множество bool has_pair_fast(const std::vector& v, int k) { // O(n) в среднем std::unordered_set seen; for (int x : v) { if (seen.count(k - x)) return true; // поиск в среднем O(1) seen.insert(x); } return false; } ``` (в) — типовой ответ на собеседовании: «перебор O(n²), но с хеш-множеством получаем O(n) за счёт O(n) дополнительной памяти». Уметь назвать и время, и память — половина ответа. ## 5. Разбор примера: как руками собрать хеш-таблицу Учебный минимальный вариант (это разбор, не зачётная задача — запусти и поиграйся): ```cpp #include #include #include #include // 1. хеш-функция: FNV-1a, хорошо перемешивает uint64_t fnv1a(const std::string& s) { uint64_t h = 1469598103934665603ULL; for (unsigned char c : s) { h ^= c; h *= 1099511628211ULL; } return h; } // 2. таблица с цепочками struct HashTable { struct Node { std::string key; int val; Node* next; }; std::vector buckets; size_t sz = 0; explicit HashTable(size_t cap = 8) : buckets(cap, nullptr) {} size_t index(const std::string& k) const { return fnv1a(k) % buckets.size(); } void put(const std::string& k, int v) { Node* n = buckets[index(k)]; for (; n; n = n->next) if (n->key == k) { n->val = v; return; } // уже есть — обновляем buckets[index(k)] = new Node{k, v, buckets[index(k)]}; // вставка в голову цепочки ++sz; if (sz * 10 > buckets.size() * 7) rehash(); // load > 0.7 } bool get(const std::string& k, int& out) const { for (Node* n = buckets[index(k)]; n; n = n->next) if (n->key == k) { out = n->val; return true; } return false; } void rehash() { std::vector old = buckets; buckets.assign(old.size() * 2, nullptr); for (Node* head : old) for (Node* n = head; n; ) { Node* next = n->next; size_t i = index(n->key); n->next = buckets[i]; buckets[i] = n; n = next; } } }; ``` Что здесь важно понять по шагам: `index()` — где именно ищем; `put` — сначала ищем существующий ключ (иначе будут дубли), потом вставляем; `rehash` — заново раскладываем **все** узлы, потому что индекс зависит от размера массива. Открытая адресация отличается только поиском места: вместо цепочки идём вперёд по массиву до свободной ячейки, а при удалении ставим tombstone. ## 6. Что спросят на собеседовании (готовые ответы) - «Средняя и худшая сложность поиска в хеш-таблице?» — амортизированное O(1), худшая O(n) при коллизиях. - «Что такое load factor и зачем rehash?» — доля занятых ячеек; при превышении порога (обычно 0.7–1.0) массив растёт, иначе пробеги/цепочки удлиняются. - «Как удалять при открытой адресации?» — tombstone, иначе порвётся цепочка зондирования. - «Почему ёмкость — степень двойки?» — `hash & (cap-1)` вместо `%`, дешевле. - «Чем цепочки отличаются от открытой адресации?» — цепочки проще и терпят load > 1, но аллокации; открытая адресация кэш-дружелюбнее, но требует load ≤ 0.7 и tombstone. ## 7. Материалы (первопартийные) - cppreference: `std::unordered_map`, `std::hash` — https://en.cppreference.com/w/cpp/container/unordered_map - OSTEP, часть «Data Structures»/«Hashing» — https://pages.cs.wisc.edu/~remzi/OSTEP/ - Codeforces EDU, курс по структурам данных — https://codeforces.com/edu/courses - Визуализация открытой адресации — https://www.cs.usfca.edu/~galles/visualization/OpenHash.html ## 8. Проверь себя (ответы внизу, не подглядывай сразу) 1. Сложность поиска в `unordered_map` в среднем и в худшем? 2. Сколько сравнений в худшем случае у бинарного поиска в массиве из 10⁶ элементов? 3. `heapify` из произвольного массива — за сколько? 4. Какая из сортировок устойчива: quick, merge, heap? 5. Зачем tombstone при открытой адресации?
Ответы 1. Амортизированное O(1); худшая O(n) — все ключи в одной корзине. 2. 20 (`log₂ 10⁶ ≈ 20`). 3. O(n), снизу вверх от середины массива к началу. 4. merge (устойчива), quick и heap — нет. 5. Чтобы удаление не разрывало цепочку зондирования: поиск должен пройти дальше удалённой ячейки до элемента, который был вставлен за ней.
## 9. Ссылки на задачи этого дня - `tasks/10_hash` — своя таблица с открытой адресацией (главная задача дня, идёт ступенями). - `tasks/01_bits` — битовые операции на C, разминка для рук. - `tasks/03_ring` — кольцевой буфер, база для сетевого кода.