13 KiB
D1, часть 1. Сложность и хеш-таблицы (урок)
Это не проверка, а урок: сначала разбираем, потом сам решаешь. Читать сверху вниз, код в разборах можно копировать и запускать — это образец, а не ответ на задачу.
1. Что такое O-нотация (без воды)
O-нотация отвечает на вопрос «как растёт время работы, когда данных становится в 10 раз больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n).
Три правила, которых хватает для 90% вопросов:
- Последовательные блоки складываются, остаётся старший. O(n) + O(n²) → O(n²).
- Вложенные циклы перемножаются. Цикл n по циклу n → O(n²).
- Деление задачи вдвое даёт 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<std::string>).
4. Разбор примера: считаем сложности
// (а) сумма элементов
long long sum(const std::vector<int>& v) { // O(n): один проход
long long s = 0;
for (int x : v) s += x;
return s;
}
// (б) пары с суммой k
bool has_pair(const std::vector<int>& 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<int>& v, int k) { // O(n) в среднем
std::unordered_set<int> 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. Разбор примера: как руками собрать хеш-таблицу
Учебный минимальный вариант (это разбор, не зачётная задача — запусти и поиграйся):
#include <cstdint>
#include <string>
#include <vector>
#include <iostream>
// 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<Node*> 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<Node*> 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. Проверь себя (ответы внизу, не подглядывай сразу)
- Сложность поиска в
unordered_mapв среднем и в худшем? - Сколько сравнений в худшем случае у бинарного поиска в массиве из 10⁶ элементов?
heapifyиз произвольного массива — за сколько?- Какая из сортировок устойчива: quick, merge, heap?
- Зачем tombstone при открытой адресации?
Ответы
- Амортизированное O(1); худшая O(n) — все ключи в одной корзине.
- 20 (
log₂ 10⁶ ≈ 20). - O(n), снизу вверх от середины массива к началу.
- merge (устойчива), quick и heap — нет.
- Чтобы удаление не разрывало цепочку зондирования: поиск должен пройти дальше удалённой ячейки до элемента, который был вставлен за ней.
9. Ссылки на задачи этого дня
tasks/10_hash— своя таблица с открытой адресацией (главная задача дня, идёт ступенями).tasks/01_bits— битовые операции на C, разминка для рук.tasks/03_ring— кольцевой буфер, база для сетевого кода.