Files
eduplan-cpp-eltex/lessons/D1_algo.md
T

13 KiB

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<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. Материалы (первопартийные)

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 — кольцевой буфер, база для сетевого кода.