Алгоритмы — как выбрать, а не вспомнить
Алгоритм — это не заклинание, которое надо вспомнить на собеседовании. Это решение, которое вы принимаете каждый день: взять std::map или std::unordered_map, написать рекурсию или цикл, отсортировать заранее или искать линейно. Цена ошибки в этом выборе не теоретическая — она измеряется в миллисекундах задержки и мегабайтах памяти.
В C++ выбор алгоритма особенно нагляден, потому что язык не прячет цену. Вы видите, как рекурсия растит стек, как лишняя аллокация бьёт по кэшу, как O(n²) на отсортированном входе превращает быстрый код в зависший. Поэтому уметь алгоритмы — значит не помнить псевдокод, а понимать стоимость каждого варианта и осознанно выбирать.
Сложность алгоритмов
Сложность (Big-O) описывает, как растут время или память с ростом размера входа n. Это скорость роста, а не абсолютная скорость: O(n) с большой константой может на малых n проигрывать O(n²).
Различают три оценки: худший случай (O), средний (Θ) и лучший (Ω). На собеседовании по умолчанию спрашивают худший случай. Основные классы по возрастанию:
O(1)— константа: доступ по индексу, вставка в хеш-таблицу (в среднем).O(log n)— бинарный поиск, высота сбалансированного дерева.O(n)— линейный проход.O(n log n)— нижняя граница для сортировки сравнениями.O(n²)— вложенные циклы по входу.O(2ⁿ)— полный перебор, наивная рекурсия Фибоначчи.
Не забывайте про пространственную сложность: рекурсивный алгоритм часто экономит время ценой стека вызовов. И операции хеш-таблицы — O(1) лишь в среднем; в худшем случае коллизии дают O(n).
Рекурсия
Рекурсия — это функция, вызывающая саму себя. Каждый вызов кладёт на стек новый кадр, поэтому у рекурсии обязан быть базовый случай — условие остановки. Без него или при слишком глубокой рекурсии стек переполняется.
// Наивная рекурсия Фибоначчи: O(2ⁿ) — fib(40) уже считается секунды
long long fib_slow(int n) {
if (n < 2) return n; // базовый случай
return fib_slow(n - 1) + fib_slow(n - 2);
}
// Мемоизация: каждый fib(k) считается один раз → O(n)
long long fib_memo(int n, std::vector<long long>& cache) {
if (n < 2) return n;
if (cache[n] != -1) return cache[n]; // готовый ответ
return cache[n] = fib_memo(n - 1, cache) + fib_memo(n - 2, cache);
}
Рекурсия против итерации — это размен. Рекурсия короче и яснее для деревьев и «разделяй и властвуй»; итерация не растит стек и предсказуема по памяти. Мемоизация превращает экспоненциальную рекурсию в линейную, кэшируя результаты подзадач — наивный fib(40) считается секунды, мемоизированный мгновенен.
Сортировка и поиск
Сортировка сравнениями не может быть быстрее O(n log n) — это доказанная нижняя граница. Три алгоритма, которые надо знать:
- Quicksort — на месте, в среднем
O(n log n), но при плохом выборе опорного элемента (например, всегда первый) на отсортированном входе деградирует доO(n²). - Merge sort — стабильна, гарантированные
O(n log n), но требуетO(n)дополнительной памяти. - Introsort — то, что внутри
std::sort: quicksort, который при слишком глубокой рекурсии переключается на heapsort, а на маленьких подмассивах — на сортировку вставками.
Стабильность означает, что элементы с равными ключами сохраняют исходный относительный порядок. Это важно при сортировке по вторичному ключу: std::stable_sort стабильна (ценой O(n) памяти), std::sort — нет.
Бинарный поиск работает за O(log n), но только на отсортированных данных.
int binary_search(const std::vector<int>& v, int target) {
int low = 0, high = (int)v.size() - 1;
while (low <= high) {
int mid = low + (high - low) / 2; // не (low+high)/2 — переполнение
if (v[mid] == target) return mid;
if (v[mid] < target) low = mid + 1;
else high = mid - 1;
}
return -1;
}
Две классические ловушки: mid = (low + high) / 2 переполняется при больших индексах — пишите low + (high - low) / 2; и граница цикла while (low <= high) против < high меняет семантику — ошибка на единицу здесь даёт неверный результат.
Структуры данных
Связный список — это узлы, связанные указателями. Вставка и удаление в известной позиции — O(1), но доступ по индексу — O(n). При развороте списка главное — сохранить указатель на следующий узел до того, как перезапишете next:
Node* reverse(Node* head) {
Node* prev = nullptr;
while (head) {
Node* next = head->next; // сохранить ДО перезаписи
head->next = prev;
prev = head;
head = next;
}
return prev; // вернуть prev, не head — head уже nullptr
}
Деревья. Двоичное дерево поиска (BST) держит инвариант: слева — меньшие значения, справа — большие. Поиск — O(log n) на сбалансированном дереве и O(n) на вырожденном (BST гарантирует порядок значений, но не балансировку). Обходы: pre-order, in-order, post-order и level-order; in-order обход BST выдаёт значения в отсортированном порядке.
Хеш-таблица отображает ключ в корзину через хеш-функцию: поиск в среднем O(1), в худшем O(n) при кластеризации. При превышении коэффициента заполнения таблица перехеширует данные в больший массив.
Стек (LIFO) и очередь (FIFO) — простейшие структуры. Очередь фиксированного размера удобно реализовать кольцевым буфером; там главная ловушка — различить «пусто» и «полно» (отдельный счётчик размера или жертва одной ячейки).
Графовые алгоритмы
Граф — это вершины и рёбра; в памяти его представляют списком смежности (компактно для разреженных графов) или матрицей. Два базовых обхода:
- BFS (поиск в ширину) использует очередь, идёт по уровням и находит кратчайший путь в невзвешенном графе.
- DFS (поиск в глубину) использует стек или рекурсию, уходит вглубь — основа топологической сортировки и анализа достижимости.
В любом обходе обязательно помечайте посещённые вершины — иначе на циклическом графе обход зациклится.
Алгоритм Дейкстры ищет кратчайшие пути во взвешенном графе с неотрицательными рёбрами; эффективным его делает приоритетная очередь. На графе с отрицательными рёбрами Дейкстра даёт неверный результат — там нужен Беллман-Форд.
Обнаружение цикла зависит от типа графа: в ориентированном — DFS с тремя цветами вершин (обратное ребро ведёт в «серую» вершину на текущем пути); в неориентированном — система непересекающихся множеств (union-find).
Динамическое программирование
Динамическое программирование (DP) применимо, когда задача распадается на перекрывающиеся подзадачи и обладает оптимальной подструктурой. Два стиля:
- Мемоизация (сверху вниз) — обычная рекурсия плюс кэш уже посчитанных подзадач.
- Табуляция (снизу вверх) — итеративное заполнение таблицы от базовых случаев к ответу.
Когда рекуррента опирается только на последнюю строку таблицы, память сворачивают с O(n²) до O(n). И кэш мемоизации лучше держать в массиве, а не в std::map — O(1) против O(log n) на обращение.
Строковые алгоритмы
Поиск подстроки наивно стоит O(n·m); алгоритм Кнута — Морриса — Пратта (KMP) делает это за O(n + m), переиспользуя информацию о уже совпавшем префиксе. Вызывать std::string::find в цикле — типичный способ случайно получить O(n²).
Проверку палиндрома не нужно делать через разворот строки с лишней O(n) памятью — достаточно двух указателей навстречу друг другу. И помните: сравнение строк в C++ — это O(n), а не O(1) как сравнение указателей. С не-ASCII текстом работайте по кодовым точкам, а не по байтам.
Битовые операции
Битовые операции (&, |, ^, сдвиги) — компактный и быстрый инструмент. Несколько приёмов, которые любят на собеседованиях:
// Подсчёт установленных битов — трюк Кернигана: O(числа битов), не O(32)
int count_bits(unsigned n) {
int count = 0;
while (n) { n &= (n - 1); ++count; } // n & (n-1) гасит младший единичный бит
return count;
}
n & (n - 1) сбрасывает младший установленный бит — отсюда подсчёт за число единиц, а не за разрядность. В C++20 это и вовсе однострочник std::popcount. Ещё один классический приём — XOR: если в массиве все элементы парные, кроме одного, XOR всех элементов даёт уникальный за O(1) памяти. Для битовых операций берите беззнаковые типы — у знаковых сдвиг и переполнение ведут к UB.
Алгоритмы STL
Стандартная библиотека даёт <algorithm> — std::sort, std::find, std::transform, std::accumulate и десятки других. Их преимущество над ручным циклом: они корректны, оптимизированы и выразительны — намерение читается с первого взгляда.
Главные ловушки:
- Идиома erase-remove.
std::removeиstd::remove_ifфизически не уменьшают контейнер — они лишь сдвигают «выжившие» элементы и возвращают новый конец; реально удаляет элементы последующийerase. В C++20 чище —std::erase_if. - Требования к итераторам.
std::sortнужен произвольный доступ — наstd::listон не скомпилируется, у списка свой методlist::sort().std::binary_search,lower_bound,upper_boundтребуют отсортированный диапазон. - Execution policy (C++17). Передав
std::execution::par, вы просите распараллелить алгоритм. Ноparне делает код потокобезопасным сам по себе — общее состояние всё равно нужно защищать; и на маленьком входе накладные расходы на потоки съедают выигрыш.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
| Big-O принимают за абсолютную скорость | На малых n O(n) с большой константой проигрывает O(n²) |
Хеш-операции считают всегда O(1) | В худшем случае коллизии дают O(n) |
| Рекурсия без базового случая или слишком глубокая | Переполнение стека |
| Наивная рекурсия там, где нужна мемоизация | Экспоненциальное время вместо линейного |
mid = (low + high) / 2 в бинарном поиске | Переполнение при больших индексах |
| Бинарный поиск по неотсортированным данным | Неверный результат |
| Quicksort с первым элементом как опорным | O(n²) на уже отсортированном входе |
При развороте списка next не сохранён до перезаписи | Потеря хвоста списка |
| Обход графа без отметки посещённых вершин | Зацикливание на циклическом графе |
| Дейкстра на графе с отрицательными рёбрами | Неверные кратчайшие пути — нужен Беллман-Форд |
std::remove без последующего erase | Контейнер не уменьшился — «удалённые» элементы остались |
std::execution::par без защиты общего состояния | Гонка данных |
Значение для собеседований
Алгоритмы — ядро технического собеседования на любую C++-позицию. Но проверяют не умение вспомнить псевдокод, а инженерное суждение: умеете ли вы оценить сложность, выбрать структуру данных и заметить ловушку.
Что проверяет интервьюер:
- Оценку сложности по времени и памяти, разницу худшего и среднего случая
- Рекурсию против итерации и роль мемоизации
- Выбор сортировки: quicksort, merge sort, introsort — и что значит стабильность
- Корректный бинарный поиск без переполнения и ошибки на единицу
- Структуры данных: связные списки, деревья, хеш-таблицы — их сложности
- Графовые обходы (BFS vs DFS) и границы применимости Дейкстры
- Знание
<algorithm>: идиому erase-remove, требования к итераторам
Типичные вопросы:
- Что такое сложность Big-O и как её определить?
- В чём разница мемоизации и табуляции?
- Сравните quicksort и merge sort по сложности, стабильности и памяти.
- Когда выбрать BFS, а когда DFS?
- Как работает Дейкстра и почему он ломается на отрицательных рёбрах?
- Зачем нужна идиома erase-remove?
Типичная ошибка: писать код, не оценив его сложность и не назвав граничные случаи (пустой вход, один элемент, переполнение). Интервьюер ищет того, кто выбирает алгоритм осознанно и видит ловушки заранее, а не вспоминает заученное решение.