Алгоритмы и Структуры Данных
Полный справочник на C++ — от базовых сортировок до продвинутых алгоритмов. Каждый алгоритм с кодом, трассировкой и анализом сложности.
⌨️ Горячие клавиши
🗺️ 12-недельный план изучения АиСД
📋 Шпаргалка — Все алгоритмы на одной странице
Нажмите на название → перейти к алгоритму. Идеально для печати перед экзаменом!
| Алгоритм | Время | Память | Ключевая идея | Где нужно |
|---|---|---|---|---|
| 🔄 СОРТИРОВКИ | ||||
| Bubble Sort | O(n²) | O(1) | Соседние swap, всплывание максимума | Универ |
| Insertion Sort | O(n²) | O(1) | Вставка в отсортированную часть. O(n) на почти отсорт. | УниверСобес |
| Merge Sort | O(n log n) | O(n) | Разделяй и властвуй. Стабильная. Слияние за O(n) | УниверСобес |
| Quick Sort | O(n log n)* | O(log n) | Pivot + partition. Рандомизация! Худший O(n²) | СобесУнивер |
| Heap Sort | O(n log n) | O(1) | Max-heap → извлечение корня. Гарантия O(n log n) | Универ |
| Counting Sort | O(n+k) | O(k) | Подсчёт вхождений. Не сравнивает! Только целые | Универ |
| 🔍 ПОИСК | ||||
| Binary Search | O(log n) | O(1) | Делим пополам. lo + (hi-lo)/2. Lower/upper bound | СобесУнивер |
| Two Pointers | O(n) | O(1) | Навстречу или в одном направлении. Two Sum, palindrome | Собес |
| Sliding Window | O(n) | O(1) | Непрерывный подмассив. Расширяем right, сужаем left | Собес |
| 📦 СТРУКТУРЫ ДАННЫХ | ||||
| Stack | O(1) | O(n) | LIFO. Скобки, RPN, DFS, монотонный стек | СобесУнивер |
| Hash Table | O(1)* | O(n) | Ключ→хеш→индекс. Коллизии: цепочки/открытая адр. | СобесУнивер |
| Heap / PQ | O(log n) | O(n) | Min/Max за O(1). Insert/extract за O(log n). Dijkstra! | СобесОлимп |
| DSU | ≈O(1) | O(n) | Find/Union. Сжатие пути + ранг. Kruskal, компоненты | Олимп |
| Trie | O(L) | O(N·L) | Префиксное дерево. Автодополнение. Поиск по префиксу | Собес |
| 🌐 ГРАФЫ | ||||
| BFS | O(V+E) | O(V) | Очередь. Кратчайший в невзвешенном. По уровням | СобесУнивер |
| DFS | O(V+E) | O(V) | Рекурсия/стек. Циклы, компоненты, топо-сорт | СобесУнивер |
| Dijkstra | O((V+E)logV) | O(V) | Min-heap. Жадный. Нет отриц. рёбер! Релаксация | СобесОлимп |
| Bellman-Ford | O(V·E) | O(V) | V-1 итераций. Отриц. веса ✅. Детекция отриц. циклов | Универ |
| Kruskal | O(E log E) | O(V) | Сорт рёбра + DSU. MST. Добавляем если не цикл | УниверОлимп |
| Topo Sort | O(V+E) | O(V) | DAG only. Kahn (BFS) или DFS+reverse. Зависимости | Собес |
| 📊 DP | ||||
| Knapsack 0/1 | O(n·W) | O(W) | dp[w]=max(skip,take). 1D: справа налево! | СобесУнивер |
| LIS | O(n log n) | O(n) | tails[] + lower_bound. Бинпоиск по хвостам | СобесОлимп |
| Coin Change | O(n·S) | O(S) | dp[a]=min(dp[a-coin]+1). Порядок циклов важен! | Собес |
| Edit Distance | O(n·m) | O(n·m) | min(insert, delete, replace). Левенштейн | СобесУнивер |
| 🔤 СТРОКИ + 🔢 МАТЕМАТИКА | ||||
| KMP | O(n+m) | O(m) | Prefix-функция. Не откатываем i при несовпадении | УниверСобес |
| GCD/LCM | O(log n) | O(1) | Евклид: gcd(a,b)=gcd(b,a%b). lcm=a/gcd*b | Универ |
| Решето | O(n log log n) | O(n) | Вычёркиваем кратные. Начинаем с i². Все простые до n | УниверОлимп |
| Fast Power | O(log n) | O(1) | a^n: чёт→(a^(n/2))², нечёт→a·a^(n-1). Обратный: a^(p-2) | Олимп |
📊 Ваша статистика
📈 Прогресс по главам
🔖 Мои закладки
🔗 Граф зависимостей алгоритмов
Что нужно знать перед изучением каждой темы. Кликни на узел → перейти к алгоритму.
🖱️ Перетаскивай узлы · Кликай для перехода
🧩 Паттерны задач
⏰ Spaced Repetition — Интервальное повторение
Отмечайте алгоритмы как «повторил» — система напомнит через 1, 3, 7, 14, 30 дней.
Глава 1. Сортировки
📌 Зачем учить сортировки?
Сортировки — фундамент алгоритмики. Они учат анализировать сложность, понимать рекурсию, разделяй-и-властвуй, работу с памятью. На собеседованиях просят реализовать с нуля, объяснить выбор алгоритма и сравнить подходы.
| Алгоритм | Лучший | Средний | Худший | Память | Стабильная? |
|---|---|---|---|---|---|
| Пузырьковая | O(n) | O(n²) | O(n²) | O(1) | ✅ Да |
| Выбором | O(n²) | O(n²) | O(n²) | O(1) | ❌ Нет |
| Вставками | O(n) | O(n²) | O(n²) | O(1) | ✅ Да |
| Слиянием | O(n log n) | O(n log n) | O(n log n) | O(n) | ✅ Да |
| Быстрая | O(n log n) | O(n log n) | O(n²) | O(log n) | ❌ Нет |
| Пирамидальная | O(n log n) | O(n log n) | O(n log n) | O(1) | ❌ Нет |
| Подсчётом | O(n+k) | O(n+k) | O(n+k) | O(k) | ✅ Да |
| Поразрядная | O(d·n) | O(d·n) | O(d·n) | O(n+k) | ✅ Да |
| Блочная | O(n+k) | O(n+k) | O(n²) | O(n+k) | ✅ Да |
| Шелла | O(n log n) | зависит | O(n²) | O(1) | ❌ Нет |
🫧 Пузырьковая сортировка (Bubble Sort)
🎯 Интуиция — аналогия из жизни
Представьте пузырьки воздуха, поднимающиеся со дна стакана с газировкой. Так же и самые большие элементы массива шаг за шагом «всплывают» в самый конец при каждом проходе.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Мы делаем n проходов по массиву, и в каждом проходе сравниваем до n элементов. Получается вложенный цикл: n × n = O(n²). Если массив уже отсортирован, благодаря флагу swapped алгоритм завершится за один проход — O(n).
🔎 Подробная трассировка: [5, 3, 8, 1, 2]
Проход 1: [5,3]→swap→[3,5,8,1,2] → [5,8]→ок → [8,1]→swap→[3,5,1,8,2] → [8,2]→swap→[3,5,1,2,8] Проход 2: [3,5]→ок → [5,1]→swap→[3,1,5,2,8] → [5,2]→swap→[3,1,2,5,8] Проход 3: [3,1]→swap→[1,3,2,5,8] → [3,2]→swap→[1,2,3,5,8] Проход 4: [1,2]→ок → нет swap → СТОП ✅ Результат: [1, 2, 3, 5, 8]
#include <iostream>
#include <vector>
using namespace std;
void bubbleSort(vector& arr) {
int n = arr.size();
for (int i = 0; i < n - 1; i++) {
bool swapped = false; // оптимизация: флаг обмена
for (int j = 0; j < n - 1 - i; j++) { // -i: последние i уже на месте
if (arr[j] > arr[j + 1]) {
swap(arr[j], arr[j + 1]);
swapped = true;
}
}
if (!swapped) break; // если не было обменов — уже отсортировано
}
}
int main() {
vector arr = {5, 3, 8, 1, 2};
bubbleSort(arr);
for (int x : arr) cout << x << " "; // 1 2 3 5 8
return 0;
}
⚠️ Подводные камни
Часто забывают добавить флаг swapped. Без этой оптимизации алгоритм всегда будет работать за O(n²), даже если вы передадите ему уже отсортированный массив.
💼 Где спрашивают
Чаще всего в университете на первом курсе или на базовых скрининг-интервью для проверки минимального знания синтаксиса циклов. На практике не используется.
🔗 Связь с другими
Является прародителем Шейкерной сортировки (Cocktail Shaker Sort), которая ходит в обе стороны. Идейная противоположность Selection Sort (там мы фиксируем элемент, тут — "пузырек").
Забыли флаг оптимизации
❌ Всегда O(n²)
// 💀 Без флага — даже для [1,2,3,4,5]
// выполнит ВСЕ n² сравнений
for (int i = 0; i < n-1; i++)
for (int j = 0; j < n-1-i; j++)
if (arr[j] > arr[j+1])
swap(arr[j], arr[j+1]);✅ O(n) на отсортированном
for (int i = 0; i < n-1; i++) {
bool swapped = false; // ✅ Флаг
for (int j = 0; j < n-1-i; j++)
if (arr[j] > arr[j+1]) {
swap(arr[j], arr[j+1]);
swapped = true;
}
if (!swapped) break; // ✅ Ранний выход
}❓ Проверь себя
🔗 Задачи для практики
👆 Сортировка выбором (Selection Sort)
🎯 Интуиция — аналогия из жизни
Представьте, что вы собираете колоду карт по возрастанию. Вы просматриваете всю стопку, находите самую младшую карту (туза) и кладёте её наверх. Затем ищете двойку и кладёте следом. И так до конца.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
На каждом шаге мы вынуждены просмотреть всю оставшуюся часть массива, чтобы гарантированно найти минимум. Вложенный цикл отработает (n-1) + (n-2) + ... + 1 раз, что дает арифметическую прогрессию со сложностью O(n²) в лучшем, среднем и худшем случаях.
🔎 Подробная трассировка: [29, 10, 14, 37, 13]
| Шаг | Массив | Минимум | Swap с |
|---|---|---|---|
| 1 | [29, 10, 14, 37, 13] | 10 (idx=1) | arr[0]↔arr[1] |
| 2 | [10, 29, 14, 37, 13] | 13 (idx=4) | arr[1]↔arr[4] |
| 3 | [10, 13, 14, 37, 29] | 14 (idx=2) | уже на месте |
| 4 | [10, 13, 14, 37, 29] | 29 (idx=4) | arr[3]↔arr[4] |
| ✅ | [10, 13, 14, 29, 37] | ||
#include <iostream>
#include <vector>
using namespace std;
void selectionSort(vector& arr) {
int n = arr.size();
for (int i = 0; i < n - 1; i++) {
int minIdx = i;
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[minIdx]) {
minIdx = j; // запоминаем индекс минимума
}
}
if (minIdx != i) {
swap(arr[i], arr[minIdx]); // ставим минимум на позицию i
}
}
}
int main() {
vector arr = {29, 10, 14, 37, 13};
selectionSort(arr);
for (int x : arr) cout << x << " "; // 10 13 14 29 37
return 0;
}
⚠️ Подводные камни
Алгоритм нестабилен из-за длинных прыжков при обмене. Пример: [5a, 3, 5b, 1]. На первом шаге минимум 1 меняется с 5a, и мы получаем [1, 3, 5b, 5a]. Относительный порядок одинаковых элементов нарушен!
💼 Где спрашивают
Часто в универах как пример нестабильной сортировки. На практике может использоваться в Embedded-системах, где запись в память (EEPROM) крайне медленная или изнашивает чип (Selection Sort делает строго O(n) обменов памяти).
🔗 Связь с другими
Heap Sort (пирамидальная) — это, по сути, умный Selection Sort, где вместо медленного линейного поиска O(n) используется куча, находящая минимум/максимум за O(log n).
Попытка сделать стабильной через swap
❌ Нестабильна из-за swap
// [5a, 3, 5b, 1] → min=1(idx=3)
// swap(arr[0], arr[3]):
// [1, 3, 5b, 5a] ← 5a после 5b!
// 💀 Порядок равных нарушен
swap(arr[i], arr[minIdx]);✅ Стабильно: сдвиг вместо swap
// Вместо swap — вставка со сдвигом:
int key = arr[minIdx];
for (int k = minIdx; k > i; k--)
arr[k] = arr[k-1]; // сдвигаем
arr[i] = key;
// Но это уже Insertion Sort!❓ Проверь себя
🔗 Задачи для практики
📥 Сортировка вставками (Insertion Sort)
🎯 Интуиция — аналогия из жизни
Как при сортировке карт в руке: вы берёте новую карту из стопки, просматриваете свои уже отсортированные карты справа налево и вставляете новую карту на её правильное место.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Худший случай: массив отсортирован в обратном порядке, и каждый новый элемент нужно протаскивать в самое начало (O(n²)). Лучший случай: массив уже отсортирован, внутренний цикл сразу останавливается, давая сложность O(n).
🔎 Подробная трассировка: [7, 3, 5, 1, 9]
key=3: [7,3,5,1,9] → 3<7, сдвиг → [,7,5,1,9] → вставка → [3,7,5,1,9] key=5: [3,7,5,1,9] → 5<7, сдвиг → [3,,7,1,9] → 5>3, стоп → [3,5,7,1,9] key=1: сдвигаем 7,5,3 → [_,3,5,7,9] → вставка → [1,3,5,7,9] key=9: 9>7, сдвигов нет → [1,3,5,7,9] ✅
#include <iostream>
#include <vector>
using namespace std;
void insertionSort(vector& arr) {
int n = arr.size();
for (int i = 1; i < n; i++) {
int key = arr[i]; // элемент для вставки
int j = i - 1;
// сдвигаем все элементы > key вправо
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key; // вставляем на своё место
}
}
int main() {
vector arr = {7, 3, 5, 1, 9};
insertionSort(arr);
for (int x : arr) cout << x << " "; // 1 3 5 7 9
return 0;
}
⚠️ Подводные камни
Не забывайте условие j >= 0 во внутреннем цикле while. Без него при вставке самого маленького элемента алгоритм выйдет за левую границу массива, вызвав Segmentation Fault или мусорные данные.
💼 Где спрашивают
Обязательный вопрос на знание гибридных сортировок. Полезно уметь писать, если попросят «отсортировать связный список» (insertion sort на списках работает очень естественно).
🔗 Связь с другими
Является базой для сортировки Шелла. В стандартных библиотеках (C++ std::sort, Python/Java TimSort) вставки применяются для малых подмассивов (обычно 16-64 элемента), так как из-за отсутствия накладных расходов на рекурсию они работают быстрее.
❓ Проверь себя
🔗 Задачи для практики
🔀 Сортировка слиянием (Merge Sort)
🎯 Интуиция — аналогия из жизни
Представьте две уже отсортированные папки с документами. Чтобы слить их в одну, вы просто смотрите на верхние документы обеих папок, берете тот, чей номер меньше, и кладете в новую стопку. Алгоритм разделяет пачку до тех пор, пока не останется по 1 листу (что очевидно отсортировано), а потом начинает их сливать.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Каждый раз мы делим массив пополам. Дерево вызовов будет иметь высоту log₂n. На каждом уровне дерева операция слияния проходит по всем элементам суммарно за O(n). Умножаем высоту на работу на уровне: O(n log n).
🔎 Подробная трассировка: [38, 27, 43, 3, 9, 82, 10]
Разделение: [38, 27, 43, 3, 9, 82, 10] → [38, 27, 43] и [3, 9, 82, 10] → [38] [27, 43] и [3, 9] [82, 10] → [38] [27] [43] и [3] [9] [82] [10] Слияние: [27] + [43] → [27, 43] [38] + [27, 43] → [27, 38, 43] [3] + [9] → [3, 9] и [82] + [10] → [10, 82] [3, 9] + [10, 82] → [3, 9, 10, 82] [27, 38, 43] + [3, 9, 10, 82] → [3, 9, 10, 27, 38, 43, 82] ✅
#include <iostream>
#include <vector>
using namespace std;
// Слияние двух отсортированных частей arr[l..m] и arr[m+1..r]
void merge(vector& arr, int l, int m, int r) {
vector left(arr.begin() + l, arr.begin() + m + 1);
vector right(arr.begin() + m + 1, arr.begin() + r + 1);
int i = 0, j = 0, k = l;
while (i < (int)left.size() && j < (int)right.size()) {
if (left[i] <= right[j]) // <= для стабильности!
arr[k++] = left[i++];
else
arr[k++] = right[j++];
}
while (i < (int)left.size()) arr[k++] = left[i++];
while (j < (int)right.size()) arr[k++] = right[j++];
}
void mergeSort(vector& arr, int l, int r) {
if (l >= r) return; // базовый случай
int m = l + (r - l) / 2; // середина (без переполнения)
mergeSort(arr, l, m); // сортируем левую
mergeSort(arr, m + 1, r); // сортируем правую
merge(arr, l, m, r); // сливаем
}
int main() {
vector arr = {38, 27, 43, 3, 9, 82, 10};
mergeSort(arr, 0, arr.size() - 1);
for (int x : arr) cout << x << " "; // 3 9 10 27 38 43 82
return 0;
}
⚠️ Подводные камни
1. Ошибка OBOE (off-by-one error) при расчете индексов — частая причина выходов за границы. Будьте внимательны с m и m+1.
2. Выделение памяти (создание векторов left и right) внутри рекурсии неэффективно. В идеальном коде лучше заранее выделить один вектор temp размера N и переиспользовать его.
💼 Где спрашивают
Практически на всех собеседованиях (Яндекс, VK) как пример классического алгоритма O(n log n). Также алгоритм слияния (merge) — частый вопрос при сортировке связных списков и файлов большого размера (External Sorting).
🔗 Связь с другими
Конкурент Quick Sort. Уступает ему по скорости в памяти (из-за O(n) аллокации), но превосходит стабильностью и гарантией времени работы O(n log n).
❓ Проверь себя
🔗 Задачи для практики
⚡ Быстрая сортировка (Quick Sort)
🎯 Интуиция — аналогия из жизни
Допустим, нужно построить класс по росту. Берем случайного человека (опорный элемент / pivot). Просим всех, кто ниже, стать слева от него, а кто выше — справа. Сам человек оказался на своем финальном месте. Повторяем эту же логику для левой и правой групп независимо.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
В среднем pivot делит массив на две примерно равные части (дерево рекурсии высоты log n, на каждом уровне работа O(n)). Но если pivot постоянно оказывается минимальным или максимальным (например, массив уже отсортирован), то деление идет на 1 и n-1 элементов. Высота дерева становится n, что дает O(n²).
🔎 Подробная трассировка partition: [10, 80, 30, 90, 40, 50, 70], pivot=70
| j | arr[j] | arr[j] ≤ 70? | Действие | i | Массив |
|---|---|---|---|---|---|
| 0 | 10 | ✅ Да | swap(arr[0],arr[0]), i++ | 1 | [10, 80, 30, 90, 40, 50, 70] |
| 1 | 80 | ❌ Нет | — | 1 | [10, 80, 30, 90, 40, 50, 70] |
| 2 | 30 | ✅ Да | swap(arr[1],arr[2]), i++ | 2 | [10, 30, 80, 90, 40, 50, 70] |
| 3 | 90 | ❌ Нет | — | 2 | — |
| 4 | 40 | ✅ Да | swap(arr[2],arr[4]), i++ | 3 | [10, 30, 40, 90, 80, 50, 70] |
| 5 | 50 | ✅ Да | swap(arr[3],arr[5]), i++ | 4 | [10, 30, 40, 50, 80, 90, 70] |
| — | Ставим pivot: swap(arr[4], arr[6]) → [10, 30, 40, 50, 70, 90, 80] | ||||
#include <iostream>
#include <vector>
#include <cstdlib>
using namespace std;
// Схема разделения Ломуто
int partition(vector& arr, int l, int r) {
// Рандомизация: выбираем случайный pivot, чтобы избежать O(n²)
int rndIdx = l + rand() % (r - l + 1);
swap(arr[rndIdx], arr[r]);
int pivot = arr[r];
int i = l; // граница элементов ≤ pivot
for (int j = l; j < r; j++) {
if (arr[j] <= pivot) {
swap(arr[i], arr[j]);
i++;
}
}
swap(arr[i], arr[r]); // pivot на своё место
return i;
}
void quickSort(vector& arr, int l, int r) {
if (l >= r) return;
int p = partition(arr, l, r);
quickSort(arr, l, p - 1);
quickSort(arr, p + 1, r);
}
int main() {
vector arr = {10, 80, 30, 90, 40, 50, 70};
quickSort(arr, 0, arr.size() - 1);
for (int x : arr) cout << x << " "; // 10 30 40 50 70 80 90
return 0;
}
⚠️ Подводные камни
Без рандомизации pivot'а или медианы трех вы гарантированно получите Timeout (O(n²)) на тестах с уже отсортированными массивами или массивами из одинаковых элементов. Также глубокая рекурсия может вызывать переполнение стека (Stack Overflow).
💼 Где спрашивают
Базовый вопрос на любом собеседовании. Попросят написать метод partition на доске без ошибок. Также спрашивают про оптимизации (переход на HeapSort — алгоритм IntroSort).
🔗 Связь с другими
Идея разбиения (partition) используется в алгоритме QuickSelect для поиска k-й порядковой статистики за линейное время (O(n)).
❓ Проверь себя
🏔 Пирамидальная сортировка (Heap Sort)
🎯 Интуиция — аналогия из жизни
Игра "Царь горы". Мы организуем данные так, что самый сильный (максимальный элемент) всегда на вершине пирамиды. Забираем его и ставим в конец очереди победителей. На вершину ставим кого-то слабого со дна, и он "проваливается" вниз до своего уровня (восстановление кучи). Повторяем, пока гора не исчезнет.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Построение начальной кучи занимает математически доказанное время O(n). Затем мы n раз извлекаем максимум. Восстановление кучи heapify опускает элемент по высоте дерева (log n). Итого n × log n = O(n log n) гарантированно.
🔎 Подробная трассировка: [4, 10, 3, 5, 1]
Строим max-heap: heapify от (n/2-1) до 0 i=1: [4,10,3,5,1] → 10 > 5? Да → ок i=0: [4,10,3,5,1] → max(10,3)=10 > 4 → swap → [10,4,3,5,1] → heapify(1): max(5,1)=5 > 4 → swap → [10,5,3,4,1] Max-heap: [10, 5, 3, 4, 1] Извлечение: swap(10,1) → [1,5,3,4,|10] → heapify → [5,4,3,1,|10] swap(5,1) → [1,4,3,|5,10] → heapify → [4,1,3,|5,10] swap(4,3) → [3,1,|4,5,10] → heapify → [3,1,|4,5,10] swap(3,1) → [1,|3,4,5,10] → [1,3,4,5,10] ✅
#include <iostream>
#include <vector>
using namespace std;
// Просеивание вниз: поддерживаем свойство max-heap
void heapify(vector& arr, int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left] > arr[largest])
largest = left;
if (right < n && arr[right] > arr[largest])
largest = right;
if (largest != i) {
swap(arr[i], arr[largest]);
heapify(arr, n, largest); // рекурсивно просеиваем дальше
}
}
void heapSort(vector& arr) {
int n = arr.size();
// 1. Строим max-heap (от последнего нелистового узла до корня)
for (int i = n / 2 - 1; i >= 0; i--)
heapify(arr, n, i);
// 2. Извлекаем максимумы один за другим
for (int i = n - 1; i > 0; i--) {
swap(arr[0], arr[i]); // максимум → конец
heapify(arr, i, 0); // восстанавливаем heap (размер = i)
}
}
int main() {
vector arr = {4, 10, 3, 5, 1};
heapSort(arr);
for (int x : arr) cout << x << " "; // 1 3 4 5 10
return 0;
}
⚠️ Подводные камни
Очень часто делают ошибки в индексах детей 2*i + 1 и 2*i + 2 (0-based индексация). Если использовать 1-based массив, индексы будут 2*i и 2*i + 1. Будьте внимательны с границей кучи n.
💼 Где спрашивают
Любят спрашивать как логическое продолжение структуры данных "Приоритетная очередь". Используется в системах реального времени (в Linux Kernel), так как гарантирует O(n log n) без риска исчерпания стека памяти.
🔗 Связь с другими
Это логическая эволюция Selection Sort: вместо поиска максимума линейным проходом (O(n)), мы используем структуру данных Куча (Heap), делая это за O(log n).
❓ Проверь себя
🔢 Сортировка подсчётом (Counting Sort)
🎯 Интуиция — аналогия из жизни
Представьте, что вы учитель, и вам нужно отсортировать оценки (от 2 до 5) тридцати учеников. Вы не сравниваете оценки между собой. Вы просто считаете: "Так, у меня три двойки, десять троек, десять четверок и семь пятерок". А потом просто выписываете их по порядку.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Мы не используем сравнения. Мы проходим по массиву n раз и по массиву счетчиков k раз. В сумме получаем линейное время O(n + k). Память также линейная от диапазона — O(k).
🔎 Подробная трассировка: [4, 2, 2, 8, 3, 3, 1]
Шаг 1 — подсчёт: count = [0, 1, 2, 2, 1, 0, 0, 0, 1] (индексы 0-8) Шаг 2 — префиксные суммы: count = [0, 1, 3, 5, 6, 6, 6, 6, 7] Шаг 3 — расстановка (справа налево для стабильности): arr[6]=1 → output[count[1]-1]=output[0]=1, count[1]-- arr[5]=3 → output[count[3]-1]=output[4]=3, count[3]-- ... → [1, 2, 2, 3, 3, 4, 8] ✅
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
void countingSort(vector& arr) {
if (arr.empty()) return;
int maxVal = *max_element(arr.begin(), arr.end());
int minVal = *min_element(arr.begin(), arr.end());
int range = maxVal - minVal + 1;
vector<int> count(range, 0);
vector<int> output(arr.size());
// 1. Подсчитываем вхождения
for (int x : arr)
count[x - minVal]++;
// 2. Префиксные суммы (позиции)
for (int i = 1; i < range; i++)
count[i] += count[i - 1];
// 3. Расставляем (справа налево для стабильности)
for (int i = arr.size() - 1; i >= 0; i--) {
output[count[arr[i] - minVal] - 1] = arr[i];
count[arr[i] - minVal]--;
}
arr = output;
}
int main() {
vector arr = {4, 2, 2, 8, 3, 3, 1};
countingSort(arr);
for (int x : arr) cout << x << " "; // 1 2 2 3 3 4 8
return 0;
}
⚠️ Подводные камни
Неприменима, если диапазон значений (k) сильно превышает размер массива (n). Например, для сортировки чисел [1, 10^9] потребуется массив на миллиард элементов, что убьет всю оперативную память.
💼 Где спрашивают
В специфичных задачах на собеседованиях. Любят давать задачи типа: "Отсортируйте миллион возрастов пользователей соцсети" (возраст ограничен 0..150, так что k=150, и алгоритм отработает мгновенно).
🔗 Связь с другими
Используется как основа (вспомогательная подпрограмма) для поразрядной сортировки (Radix Sort).
❓ Проверь себя
🔗 Задачи для практики
🎰 Поразрядная сортировка (Radix Sort)
🎯 Интуиция — аналогия из жизни
Помните, как в библиотеке сортируют даты? Сначала собирают карточки по дням (все первые числа, потом вторые). Потом не перемешивая сдвигают по месяцам (январь, февраль). В конце — по годам. В результате даты оказываются строго отсортированы.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Мы делаем d проходов (d - количество разрядов). В каждом проходе мы вызываем сортировку подсчетом, которая работает за O(n + k), где k=10 (цифры 0-9). Итоговая сложность — O(d * (n + k)).
🔎 Подробная трассировка: [170, 45, 75, 90, 802, 24, 2, 66]
По единицам (exp=1): [170, 90, 802, 2, 24, 45, 75, 66] → [170, 90, 802, 2, 24, 45, 75, 66] По десяткам (exp=10): [802, 02, 24, 45, 66, 170, 75, 90] → [802, 2, 24, 45, 66, 170, 75, 90] По сотням (exp=100): [002, 024, 045, 066, 075, 090, 170, 802] → [2, 24, 45, 66, 75, 90, 170, 802] ✅
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// Counting sort по разряду exp
void countingSortByDigit(vector& arr, int exp) {
int n = arr.size();
vector output(n);
int count[10] = {0};
for (int i = 0; i < n; i++)
count[(arr[i] / exp) % 10]++;
for (int i = 1; i < 10; i++)
count[i] += count[i - 1];
for (int i = n - 1; i >= 0; i--) { // справа налево = стабильность
output[count[(arr[i] / exp) % 10] - 1] = arr[i];
count[(arr[i] / exp) % 10]--;
}
arr = output;
}
void radixSort(vector& arr) {
int maxVal = *max_element(arr.begin(), arr.end());
// Сортируем по каждому разряду
for (int exp = 1; maxVal / exp > 0; exp *= 10)
countingSortByDigit(arr, exp);
}
int main() {
vector arr = {170, 45, 75, 90, 802, 24, 2, 66};
radixSort(arr);
for (int x : arr) cout << x << " "; // 2 24 45 66 75 90 170 802
return 0;
}
⚠️ Подводные камни
Жизненно необходимо, чтобы внутренняя сортировка по разряду была стабильной. Если использовать нестабильную (например, QuickSort по разряду), то сортировка по старшим разрядам перепутает результаты сортировки по младшим разрядам.
💼 Где спрашивают
При сортировке строк (суффиксные массивы) или очень больших массивов 32/64-битных чисел, где можно делать битовые сдвиги (radix = 256) и обгонять std::sort.
🔗 Связь с другими
Решает главную проблему Counting Sort: устраняет зависимость от огромного размера массива счетчиков, разбивая ключи на разряды.
❓ Проверь себя
🔗 Задачи для практики
🪣 Блочная сортировка (Bucket Sort)
🎯 Интуиция — аналогия из жизни
Представьте сортировку писем на почте. Сначала письма раскладывают по крупным ящикам (корзинам) для каждого региона. А потом почтальон внутри каждого ящика сортирует письма уже по конкретным улицам и домам.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Разброс по корзинам занимает O(n). Если данные равномерны, в каждой корзине окажется O(1) элементов, их сортировка O(1). Итого O(n+k). Но если все элементы свалятся в одну корзину, алгоритм деградирует до сложности внутренней сортировки (обычно O(n²)).
🔎 Подробная трассировка: [0.42, 0.32, 0.23, 0.52, 0.25, 0.47, 0.51]
5 корзин (по первой цифре после запятой): Bucket[2]: [0.23, 0.25] Bucket[3]: [0.32] Bucket[4]: [0.42, 0.47] Bucket[5]: [0.52, 0.51] → после сортировки [0.51, 0.52] Объединяем → [0.23, 0.25, 0.32, 0.42, 0.47, 0.51, 0.52] ✅
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// Для вещественных чисел в диапазоне [0, 1)
void bucketSort(vector& arr) {
int n = arr.size();
if (n <= 0) return;
vector<vector<float>> buckets(n);
// Распределяем по корзинам
for (float x : arr) {
int idx = (int)(x * n); // индекс корзины
buckets[idx].push_back(x);
}
// Сортируем каждую корзину
for (auto& bucket : buckets)
sort(bucket.begin(), bucket.end());
// Собираем обратно
int k = 0;
for (auto& bucket : buckets)
for (float x : bucket)
arr[k++] = x;
}
int main() {
vector arr = {0.42f, 0.32f, 0.23f, 0.52f, 0.25f, 0.47f, 0.51f};
bucketSort(arr);
for (float x : arr) cout << x << " ";
// 0.23 0.25 0.32 0.42 0.47 0.51 0.52
return 0;
}
⚠️ Подводные камни
Чрезвычайно чувствителен к распределению данных. Работает хорошо только для равномерно распределенных ключей (uniform distribution). Выбор функции маппинга элемента в корзину (hash-функции) критически важен.
💼 Где спрашивают
Распределенные вычисления. В Big Data (например, MapReduce) данные распределяются на разные узлы кластера (buckets) и сортируются там независимо, после чего сливаются.
🔗 Связь с другими
Radix Sort часто рассматривают как частный случай Bucket Sort, где в качестве "корзин" выступают цифры разряда (от 0 до 9).
❓ Проверь себя
🔗 Задачи
🐚 Сортировка Шелла (Shell Sort)
🎯 Интуиция — аналогия из жизни
Представьте, что вы причесываете сильно запутанные волосы. Вы не берете сразу мелкую расческу (Insertion sort), иначе порвете волосы. Вы берете расческу с очень редкими зубьями, распутываете "глобальные" узлы, потом меняете расческу на более частую, и в конце причесываетесь мелкой. Волосы уже почти гладкие!
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Сложность сильно зависит от выбранной последовательности шагов. Идея в том, что элементы не ползут по одному шагу (O(n²)), а совершают длинные прыжки. С хорошими шагами (например, Седжвика) сложность падает до O(n^(4/3)), что значительно быстрее классических квадратичных.
🔎 Подробная трассировка: [35, 33, 42, 10, 14, 19, 27, 44]
gap=4: Сравниваем пары (0,4), (1,5), (2,6), (3,7): 35↔14→[14,33,42,10,35,19,27,44] | 33↔19→[14,19,42,10,35,33,27,44] | 42↔27→[14,19,27,10,35,33,42,44] | 10↔44→ок gap=2: Insertion sort с шагом 2... gap=1: Обычный insertion sort (но массив уже «почти отсортирован») → [10, 14, 19, 27, 33, 35, 42, 44] ✅
#include <iostream>
#include <vector>
using namespace std;
void shellSort(vector& arr) {
int n = arr.size();
// Последовательность Шелла: n/2, n/4, ..., 1
for (int gap = n / 2; gap > 0; gap /= 2) {
// Insertion sort с шагом gap
for (int i = gap; i < n; i++) {
int temp = arr[i];
int j = i;
while (j >= gap && arr[j - gap] > temp) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp;
}
}
}
int main() {
vector arr = {35, 33, 42, 10, 14, 19, 27, 44};
shellSort(arr);
for (int x : arr) cout << x << " "; // 10 14 19 27 33 35 42 44
return 0;
}
⚠️ Подводные камни
Стандартная последовательность Дональда Шелла (N/2, N/4... 1) уязвима. Если на четных шагах не сравнивать четные и нечетные позиции, то на gap=1 придется делать огромную работу (O(n²)). Обязательно гуглите последовательность Кнута или Седжвика для продакшена.
💼 Где спрашивают
Как исторический пример оптимизации алгоритмов. Использовалась во встроенных библиотеках C (uClibc) из-за невероятной компактности кода и малого потребления памяти.
🔗 Связь с другими
Это прямое улучшение Сортировки вставками (Insertion Sort). Shell Sort устраняет её главный недостаток — перемещение маленьких элементов из конца в начало массива по одному шагу.
❓ Проверь себя
🔗 Задачи
Глава 2. Алгоритмы поиска
📌 Обзор
Поиск — одна из самых частых операций. От линейного перебора до хитрых техник с двумя указателями — эти приёмы нужны на каждом собеседовании.
| Алгоритм | Сложность | Требование | Тип |
|---|---|---|---|
| Линейный | O(n) | Любой массив | Перебор |
| Бинарный | O(log n) | Отсортированный | Деление |
| Тернарный | O(log₃ n) | Унимодальная функция | Деление |
| Интерполяционный | O(log log n) ~ O(n) | Равном. распределение | Деление |
| Два указателя | O(n) | Отсортированный | Техника |
| Скользящее окно | O(n) | Непрерывный подмассив | Техника |
📋 Линейный поиск (Linear Search)
🎯 Интуиция — аналогия из жизни
Представьте, что вы ищете конкретную книгу на длинной полке. Они стоят в случайном порядке. Единственный способ найти нужную — просматривать корешки по одному слева направо, пока не найдете совпадение.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
В худшем случае (элемента нет в массиве или он стоит самым последним) алгоритм сделает ровно n сравнений. Следовательно, время выполнения растёт линейно — O(n).
🔎 Подробная трассировка: arr=[5, 3, 8, 1, 2], target=1
i = 0: arr[0]=5. 5 == 1? Нет, идём дальше.
i = 1: arr[1]=3. 3 == 1? Нет, идём дальше.
i = 2: arr[2]=8. 8 == 1? Нет, идём дальше.
i = 3: arr[3]=1. 1 == 1? Да! Возвращаем индекс 3. ✅
#include <iostream>
#include <vector>
using namespace std;
// Возвращает индекс первого вхождения или -1
int linearSearch(const vector<int>& arr, int target) {
for (int i = 0; i < (int)arr.size(); i++) {
if (arr[i] == target)
return i;
}
return -1; // не найден
}
// Вариант: поиск всех вхождений
vector<int> linearSearchAll(const vector<int>& arr, int target) {
vector<int> result;
for (int i = 0; i < (int)arr.size(); i++) {
if (arr[i] == target)
result.push_back(i);
}
return result;
}
// Вариант с «барьером» (sentinel) — убирает проверку границы
int sentinelSearch(vector<int>& arr, int target) {
int n = arr.size();
int last = arr[n - 1];
arr[n - 1] = target; // ставим «барьер»
int i = 0;
while (arr[i] != target) i++; // без проверки i < n!
arr[n - 1] = last; // восстанавливаем
if (i < n - 1 || last == target) return i;
return -1;
}
int main() {
vector<int> arr = {5, 3, 8, 1, 2, 8, 7};
cout << linearSearch(arr, 8) << endl; // 2
auto all = linearSearchAll(arr, 8);
for (int idx : all) cout << idx << " "; // 2 5
cout << endl;
cout << linearSearch(arr, 99) << endl; // -1
return 0;
}
⚠️ Подводные камни
Метод с барьером (sentinel search) требует изменения исходного массива (запись в последний элемент). Если массив передан по константной ссылке или программа работает в многопоточной среде, это приведет к ошибкам (Data Race или Compilation Error).
💼 Где спрашивают
Редко задают как самостоятельную задачу, но могут спросить, как ускорить линейный поиск. Ответ: развернуть циклы (loop unrolling) или использовать SIMD-инструкции для сравнения блоков по 16/32 байта за раз.
🔗 Связь с другими
Худший и простейший вид поиска. Антипод бинарного поиска, но единственный вариант, когда массив абсолютно хаотичен и не имеет структуры.
❓ Проверь себя
🔗 Задачи
🎯 Бинарный поиск (Binary Search)
🎯 Интуиция — аналогия из жизни
Поиск слова в толстом бумажном словаре. Вы не читаете каждую страницу. Вы открываете словарь ровно посередине. Если нужная буква меньше текущей по алфавиту, вы отбрасываете правую половину словаря и повторяете процесс для левой.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
На каждом шаге мы отбрасываем ровно половину оставшихся элементов. Количество раз, которое число n можно поделить на 2, пока не останется 1 элемент, равно логарифму по основанию 2. Итоговая сложность — O(log n).
🔎 Подробная трассировка: arr=[2, 5, 8, 12, 16, 23, 38, 56, 72, 91], target=23
| Шаг | lo | hi | mid | arr[mid] | Сравнение |
|---|---|---|---|---|---|
| 1 | 0 | 9 | 4 | 16 | 23 > 16 → цель правее, lo = 5 |
| 2 | 5 | 9 | 7 | 56 | 23 < 56 → цель левее, hi = 6 |
| 3 | 5 | 6 | 5 | 23 | 23 == 23 → ✅ Найден на индексе 5! |
#include <iostream>
#include <vector>
using namespace std;
// ─── Итеративный бинарный поиск ───
int binarySearch(const vector<int>& arr, int target) {
int lo = 0, hi = (int)arr.size() - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2; // безопасно от переполнения
if (arr[mid] == target) return mid;
else if (arr[mid] < target) lo = mid + 1;
else hi = mid - 1;
}
return -1;
}
// ─── Рекурсивный бинарный поиск ───
int binarySearchRec(const vector<int>& arr, int target, int lo, int hi) {
if (lo > hi) return -1;
int mid = lo + (hi - lo) / 2;
if (arr[mid] == target) return mid;
if (arr[mid] < target) return binarySearchRec(arr, target, mid + 1, hi);
return binarySearchRec(arr, target, lo, mid - 1);
}
// ─── Lower Bound: первый элемент ≥ target ───
int lowerBound(const vector<int>& arr, int target) {
int lo = 0, hi = arr.size(); // hi = n (за пределами!)
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (arr[mid] < target) lo = mid + 1;
else hi = mid;
}
return lo; // позиция вставки или первое вхождение
}
// ─── Upper Bound: первый элемент > target ───
int upperBound(const vector<int>& arr, int target) {
int lo = 0, hi = arr.size();
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (arr[mid] <= target) lo = mid + 1;
else hi = mid;
}
return lo;
}
// ─── Бинарный поиск по ответу (пример: квадратный корень) ───
double sqrtBinarySearch(double x, double eps = 1e-9) {
double lo = 0, hi = max(1.0, x);
while (hi - lo > eps) {
double mid = (lo + hi) / 2;
if (mid * mid < x) lo = mid;
else hi = mid;
}
return (lo + hi) / 2;
}
int main() {
vector<int> arr = {2, 5, 8, 12, 16, 23, 38, 56, 72, 91};
cout << "Search 23: " << binarySearch(arr, 23) << endl; // 5
cout << "Search 99: " << binarySearch(arr, 99) << endl; // -1
cout << "LowerBound 8: " << lowerBound(arr, 8) << endl; // 2
cout << "UpperBound 8: " << upperBound(arr, 8) << endl; // 3
// Подсчёт вхождений числа:
vector<int> arr2 = {1,2,2,2,3,4,5};
int count = upperBound(arr2, 2) - lowerBound(arr2, 2);
cout << "Count of 2: " << count << endl; // 3
cout << "sqrt(2) = " << sqrtBinarySearch(2.0) << endl; // 1.41421...
return 0;
}
⚠️ Подводные камни
1. mid = (lo + hi) / 2 может вызвать целочисленное переполнение (Integer Overflow), если массив огромный. Правильно: lo + (hi - lo) / 2.
2. Бесконечный цикл: при поиске Lower/Upper Bound важно правильно сдвигать границы (`lo = mid + 1` и `hi = mid`), иначе `lo` и `hi` залипнут на соседних индексах.
💼 Где спрашивают
Абсолютно везде. Частые вариации: поиск в повернутом массиве (Rotated Sorted Array), поиск пика, поиск первого/последнего вхождения (lower/upper bound) и самое главное — Бинарный поиск по ответу (максимизация минимума / минимизация максимума).
🔗 Связь с другими
Лежит в основе структуры данных Бинарное Дерево Поиска (BST) и является фундаментом для Тернарного поиска.
Переполнение при вычислении mid
❌ Переполнение
int mid = (lo + hi) / 2; // 💀 overflow!✅ Безопасно
int mid = lo + (hi - lo) / 2; // ✅❓ Проверь себя
🔺 Тернарный поиск (Ternary Search)
🎯 Интуиция — аналогия из жизни
Представьте, что вы стоите у подножия холма и хотите найти самую высокую точку, но из-за тумана видите только высоту в местах, где стоите. Вы выбираете две точки на склоне. Если правая точка выше левой, значит пик точно находится правее левой точки. Отбрасываем левый край и сужаем зону поиска.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
На каждом шаге мы отбрасываем ровно 1/3 отрезка, оставляя 2/3. Это логарифмическое убывание длины. Формально сложность $\log_{1.5} n$, что асимптотически равно O(log n). Но так как мы делаем 2 вычисления функции за шаг (вместо 1 в бинарном поиске), константа чуть хуже.
🔎 Подробная трассировка: f(x) = -(x-3)² + 10 на отрезке [0, 10]
Ищем максимум.
Шаг 1: lo=0, hi=10. m1=3.33, m2=6.66.
f(3.33) ≈ 9.89, f(6.66) ≈ -3.44.
f(3.33) > f(6.66) → максимум левее m2. Сдвигаем hi = 6.66. Отрезок [0, 6.66].
Шаг 2: lo=0, hi=6.66. m1=2.22, m2=4.44.
f(2.22) ≈ 9.39, f(4.44) ≈ 7.92.
f(2.22) > f(4.44) → сдвигаем hi = 4.44. Отрезок [0, 4.44].
(Спустя много шагов границы сходятся к 3.0).
#include <iostream>
#include <cmath>
using namespace std;
// Пример: ищем максимум функции f(x) = -(x-3)^2 + 10
// Максимум при x = 3, f(3) = 10
double f(double x) {
return -(x - 3) * (x - 3) + 10;
}
// Тернарный поиск максимума на [lo, hi]
double ternarySearchMax(double lo, double hi, double eps = 1e-9) {
while (hi - lo > eps) {
double m1 = lo + (hi - lo) / 3.0;
double m2 = hi - (hi - lo) / 3.0;
if (f(m1) < f(m2))
lo = m1; // максимум в [m1, hi]
else
hi = m2; // максимум в [lo, m2]
}
return (lo + hi) / 2.0;
}
// Тернарный поиск минимума (для выпуклой функции)
// Аналогично, но меняем знак сравнения
double ternarySearchMin(double lo, double hi, double eps = 1e-9) {
while (hi - lo > eps) {
double m1 = lo + (hi - lo) / 3.0;
double m2 = hi - (hi - lo) / 3.0;
if (f(m1) > f(m2)) // наоборот!
lo = m1;
else
hi = m2;
}
return (lo + hi) / 2.0;
}
// Дискретный тернарный поиск (для массива)
int ternarySearchDiscrete(int arr[], int lo, int hi) {
while (hi - lo > 2) {
int m1 = lo + (hi - lo) / 3;
int m2 = hi - (hi - lo) / 3;
if (arr[m1] < arr[m2])
lo = m1 + 1;
else
hi = m2 - 1;
}
// Проверяем 1-3 оставшихся элемента
int best = lo;
for (int i = lo; i <= hi; i++)
if (arr[i] > arr[best]) best = i;
return best;
}
int main() {
double x = ternarySearchMax(0, 10);
cout << "Max at x = " << x << ", f(x) = " << f(x) << endl;
// Max at x = 3, f(x) = 10
return 0;
}
⚠️ Подводные камни
Функция строго обязана быть унимодальной (сначала строго возрастать, потом строго убывать, или наоборот). Если на графике есть плоские "плато" (например, ступенчатая функция), тернарный поиск может ложно отбросить часть с экстремумом, потому что `f(m1) == f(m2)`.
💼 Где спрашивают
В олимпиадном программировании. Применяется для нахождения оптимальной точки (например, где разместить склад, чтобы минимизировать сумму расстояний до всех городов), когда функция затрат имеет форму параболы (выпуклая).
🔗 Связь с другими
Брат бинарного поиска. Если можно вычислить производную функции $f'(x)$, то экстремум можно найти обычным бинарным поиском (там, где $f'(x) = 0$). Тернарный нужен, когда производную вычислить сложно или невозможно.
❓ Проверь себя
📐 Интерполяционный поиск
🎯 Интуиция — аналогия из жизни
Как мы ищем фамилию «Яковлев» в телефонном справочнике? Мы не открываем книгу посередине, как в бинарном поиске. Мы интуитивно понимаем, что «Я» — в самом конце алфавита, и открываем книгу где-то на 95% её толщины.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Если числа распределены равномерно (например, `10, 20, 30, 40...`), формула сразу угадывает почти точный индекс. Размер области сужается фантастически быстро, давая сложность O(log log n). Но если данные распределены неравномерно (экспоненциально), алгоритм вырождается в перебор за O(n).
🔎 Подробная трассировка: arr=[10, 20, 30, 40, 50, 60, 70, 80, 90, 100], target=70
lo=0, hi=9, arr[0]=10, arr[9]=100
Формула: pos = lo + ((target - arr[lo]) * (hi - lo)) / (arr[hi] - arr[lo])
pos = 0 + ((70 - 10) * 9) / (100 - 10) = (60 * 9) / 90 = 540 / 90 = 6
Проверяем: arr[6] = 70. Совпало с target!
✅ Найден за 1 шаг! (Бинарному поиску потребовалось бы 3-4 шага).
#include <iostream>
#include <vector>
using namespace std;
int interpolationSearch(const vector<int>& arr, int target) {
int lo = 0, hi = (int)arr.size() - 1;
while (lo <= hi && target >= arr[lo] && target <= arr[hi]) {
if (lo == hi) {
return (arr[lo] == target) ? lo : -1;
}
// Интерполяция позиции
int pos = lo + (long long)(target - arr[lo]) * (hi - lo)
/ (arr[hi] - arr[lo]);
if (arr[pos] == target)
return pos;
else if (arr[pos] < target)
lo = pos + 1;
else
hi = pos - 1;
}
return -1;
}
int main() {
vector<int> arr = {10, 20, 30, 40, 50, 60, 70, 80, 90, 100};
cout << interpolationSearch(arr, 70) << endl; // 6
cout << interpolationSearch(arr, 25) << endl; // -1
cout << interpolationSearch(arr, 10) << endl; // 0
cout << interpolationSearch(arr, 100) << endl; // 9
return 0;
}
⚠️ Подводные камни
Формула `pos` содержит умножение: `(target - arr[lo]) * (hi - lo)`. Если значения большие, это гарантированно вызовет переполнение (Integer Overflow). Обязательно приводите к `long long`.
💼 Где спрашивают
Спрашивают на senior-позициях при проектировании баз данных и индексов как теоретический вопрос для оценки понимания того, как распределение данных влияет на алгоритмы поиска.
🔗 Связь с другими
Эволюция бинарного поиска. Вместо слепого деления пополам, алгоритм «смотрит» на значения на концах отрезка и прогнозирует, где находится искомое.
❓ Проверь себя
🔗 Задачи
👈👉 Два указателя (Two Pointers)
🎯 Интуиция — аналогия из жизни
Представьте, что вы читаете книгу на иностранном языке и выписываете незнакомые слова. Один ваш палец (Fast pointer) бежит по тексту и ищет слова, а другой палец (Slow pointer) держится на месте в блокноте, куда вы их записываете.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Каждый указатель проходит по массиву строго в одном направлении. Он не может вернуться назад. Поэтому суммарно совершается не более 2*n шагов, что даёт строгую линейную сложность O(n) вместо O(n²) при двойных вложенных циклах.
🔎 Подробная трассировка: Two Sum в отсортированном [2, 7, 11, 15], target=9
lo=0 (arr[0]=2), hi=3 (arr[3]=15).
Шаг 1: Сумма: 2 + 15 = 17. 17 > 9. Сумма слишком большая, нужно её уменьшить. Двигаем правый указатель влево: hi--.
Шаг 2: lo=0 (2), hi=2 (11). Сумма: 2 + 11 = 13. 13 > 9. Двигаем левее: hi--.
Шаг 3: lo=0 (2), hi=1 (7). Сумма: 2 + 7 = 9. 9 == 9! ✅ Пара найдена (0, 1).
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// ─── Задача 1: Two Sum (отсортированный массив) ───
pair<int,int> twoSum(const vector<int>& arr, int target) {
int lo = 0, hi = (int)arr.size() - 1;
while (lo < hi) {
int sum = arr[lo] + arr[hi];
if (sum == target) return {lo, hi};
else if (sum < target) lo++;
else hi--;
}
return {-1, -1}; // не найдено
}
// ─── Задача 2: Удаление дубликатов in-place ───
int removeDuplicates(vector<int>& arr) {
if (arr.empty()) return 0;
int slow = 0; // указатель на последний уникальный
for (int fast = 1; fast < (int)arr.size(); fast++) {
if (arr[fast] != arr[slow]) {
slow++;
arr[slow] = arr[fast];
}
}
return slow + 1; // количество уникальных
}
// ─── Задача 3: Контейнер с наибольшим количеством воды ───
int maxWater(const vector<int>& height) {
int lo = 0, hi = (int)height.size() - 1;
int maxArea = 0;
while (lo < hi) {
int area = min(height[lo], height[hi]) * (hi - lo);
maxArea = max(maxArea, area);
if (height[lo] < height[hi]) lo++;
else hi--;
}
return maxArea;
}
// ─── Задача 4: Проверка палиндрома ───
bool isPalindrome(const string& s) {
int lo = 0, hi = (int)s.size() - 1;
while (lo < hi) {
if (s[lo] != s[hi]) return false;
lo++;
hi--;
}
return true;
}
// ─── Задача 5: Слияние двух отсортированных массивов ───
vector<int> mergeSorted(const vector<int>& a, const vector<int>& b) {
vector<int> result;
int i = 0, j = 0;
while (i < (int)a.size() && j < (int)b.size()) {
if (a[i] <= b[j]) result.push_back(a[i++]);
else result.push_back(b[j++]);
}
while (i < (int)a.size()) result.push_back(a[i++]);
while (j < (int)b.size()) result.push_back(b[j++]);
return result;
}
int main() {
// Two Sum
vector<int> arr = {2, 7, 11, 15};
auto [a, b] = twoSum(arr, 9);
cout << "Two Sum: [" << a << ", " << b << "]" << endl; // [0, 1]
// Remove Duplicates
vector<int> arr2 = {1, 1, 2, 2, 3, 4, 4, 5};
int len = removeDuplicates(arr2);
cout << "Unique count: " << len << endl; // 5
// Palindrome
cout << "abcba is palindrome: " << isPalindrome("abcba") << endl; // 1
// Max Water
vector<int> h = {1,8,6,2,5,4,8,3,7};
cout << "Max water: " << maxWater(h) << endl; // 49
return 0;
}
⚠️ Подводные камни
Внимательно следите за условием остановки `while (lo < hi)` или `while (lo <= hi)`. Ошибка на единицу может привести к тому, что вы дважды обработаете центральный элемент массива (что фатально, если вы, например, меняете их местами, как в задаче Reverse String).
💼 Где спрашивают
Техника №1 на LeetCode и собеседованиях. Если вы видите в условии "отсортированный массив" и задачу на поиск пар/троек — это гарантия того, что от вас ждут алгоритм двух указателей.
🔗 Связь с другими
Скользящее окно (Sliding Window) — это частный случай алгоритма "Два указателя", применяемый для непрерывных подмассивов (substrings/subarrays).
Two Sum: хеш vs два указателя
❌ O(n²) brute force
// 💀 Два вложенных цикла
for (int i = 0; i < n; i++)
for (int j = i+1; j < n; j++)
if (arr[i]+arr[j] == target)
return {i, j};✅ O(n) два указателя
// ✅ Для ОТСОРТИРОВАННОГО массива
int lo = 0, hi = n - 1;
while (lo < hi) {
int s = arr[lo] + arr[hi];
if (s == target) return {lo, hi};
else if (s < target) lo++;
else hi--;
}❓ Проверь себя
🪟 Скользящее окно (Sliding Window)
🎯 Интуиция — аналогия из жизни
Представьте видоискатель фотоаппарата, который вы двигаете вдоль панорамы города. Вы хотите найти лучший кадр определенного размера (фиксированное окно). Либо вы можете раздвигать зум объектива (переменное окно), пока в кадр не попадут все нужные вам объекты.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Хотя у нас есть цикл `while` внутри цикла `for`, каждый элемент массива добавляется в окно ровно один раз (правым указателем) и удаляется ровно один раз (левым указателем). Суммарно 2n операций $\to$ O(n).
🔎 Подробная трассировка: Мин. подмассив с суммой ≥ 7, arr = [2, 3, 1, 2, 4, 3]
R=0: окно [2], sum=2. sum < 7.
R=1: окно [2,3], sum=5. sum < 7.
R=2: окно [2,3,1], sum=6. sum < 7.
R=3: окно [2,3,1,2], sum=8. sum ≥ 7! Условие выполнено.
→ Обновляем minLen = 4.
→ Сужаем слева: убираем 2 (L=1). sum=6. Цикл сужения остановился.
R=4: окно [3,1,2,4], sum=10. sum ≥ 7!
→ Обновляем minLen = 4.
→ Сужаем: убираем 3 (L=2). sum=7 ≥ 7! → minLen = 3. Убираем 1 (L=3). sum=6.
R=5: окно [2,4,3], sum=9. sum ≥ 7!
→ Обновляем minLen = 3.
→ Сужаем: убираем 2 (L=4). sum=7 ≥ 7! → minLen = 2. Убираем 4 (L=5). sum=3.
Ответ: 2 ✅ (подмассив [4, 3])
#include <iostream>
#include <vector>
#include <unordered_map>
#include <algorithm>
using namespace std;
// ─── Задача 1: Макс. сумма подмассива длины k (фиксированное окно) ───
int maxSumSubarray(const vector<int>& arr, int k) {
int n = arr.size();
if (n < k) return -1;
// Считаем сумму первого окна
int windowSum = 0;
for (int i = 0; i < k; i++)
windowSum += arr[i];
int maxSum = windowSum;
// Сдвигаем окно: добавляем правый, убираем левый
for (int i = k; i < n; i++) {
windowSum += arr[i] - arr[i - k];
maxSum = max(maxSum, windowSum);
}
return maxSum;
}
// ─── Задача 2: Минимальный подмассив с суммой ≥ target (переменное окно) ───
int minSubarrayLen(const vector<int>& arr, int target) {
int n = arr.size();
int left = 0, sum = 0;
int minLen = n + 1;
for (int right = 0; right < n; right++) {
sum += arr[right]; // расширяем окно
while (sum >= target) { // пока условие выполняется
minLen = min(minLen, right - left + 1);
sum -= arr[left]; // сужаем окно
left++;
}
}
return (minLen > n) ? 0 : minLen;
}
// ─── Задача 3: Самая длинная подстрока без повторов ───
int longestUniqueSubstring(const string& s) {
unordered_map<char, int> lastSeen; // символ → последняя позиция
int maxLen = 0, left = 0;
for (int right = 0; right < (int)s.size(); right++) {
if (lastSeen.count(s[right]) && lastSeen[s[right]] >= left) {
left = lastSeen[s[right]] + 1; // сдвигаем левую границу
}
lastSeen[s[right]] = right;
maxLen = max(maxLen, right - left + 1);
}
return maxLen;
}
// ─── Задача 4: Подсчёт анаграмм подстроки ───
int countAnagrams(const string& text, const string& pattern) {
int n = text.size(), m = pattern.size();
if (n < m) return 0;
vector<int> pCount(26, 0), wCount(26, 0);
for (char c : pattern) pCount[c - 'a']++;
int count = 0;
for (int i = 0; i < n; i++) {
wCount[text[i] - 'a']++;
if (i >= m) wCount[text[i - m] - 'a']--;
if (wCount == pCount) count++;
}
return count;
}
int main() {
// Задача 1
vector<int> arr1 = {2, 1, 5, 1, 3, 2};
cout << "Max sum k=3: " << maxSumSubarray(arr1, 3) << endl; // 9
// Задача 2
vector<int> arr2 = {2, 3, 1, 2, 4, 3};
cout << "Min subarray sum>=7: " << minSubarrayLen(arr2, 7) << endl; // 2
// Задача 3
cout << "Longest unique: " << longestUniqueSubstring("abcabcbb") << endl; // 3
// Задача 4
cout << "Anagram count: " << countAnagrams("cbaebabacd", "abc") << endl; // 2
return 0;
}
⚠️ Подводные камни
Переменное окно ломается, если массив содержит отрицательные числа, а мы ищем подмассив с суммой ≥ target. Причина: выкидывание отрицательного числа из окна увеличивает сумму, ломая логику "сужение окна всегда уменьшает сумму". Для массивов с отрицательными числами применяют Префиксные суммы + Хеш-таблицы (O(n)).
💼 Где спрашивают
Один из любимейших паттернов на собеседованиях в FAANG. Ключевые слова-триггеры в условии: "непрерывный подмассив" (contiguous subarray), "подстрока" (substring), "минимальный/максимальный".
🔗 Связь с другими
Часто комбинируется с хеш-таблицами (хранение частот элементов в окне). Может применяться совместно с Деком (Deque) для задач типа "Максимум в скользящем окне".
Фиксированное окно — пересчёт с нуля
❌ O(n·k) — пересчитываем сумму
// 💀 Каждый раз считаем сумму заново
for (int i = 0; i <= n-k; i++) {
int sum = 0;
for (int j = i; j < i+k; j++)
sum += arr[j]; // O(k) на каждое окно!
maxSum = max(maxSum, sum);
}✅ O(n) — скользящая сумма
// ✅ Добавляем правый, убираем левый
int sum = 0;
for (int i = 0; i < k; i++) sum += arr[i];
int maxSum = sum;
for (int i = k; i < n; i++) {
sum += arr[i] - arr[i-k]; // O(1)!
maxSum = max(maxSum, sum);
}❓ Проверь себя
Глава 3. Структуры данных
📌 Зачем знать структуры данных?
Структуры данных определяют, как хранятся и обрабатываются данные. Правильный выбор структуры может превратить O(n²) решение в O(n log n) или O(n). На собеседованиях — это 50% всех вопросов.
| Структура | Вставка | Удаление | Поиск | Доступ | Когда использовать |
|---|---|---|---|---|---|
| Массив | O(n) | O(n) | O(n) | O(1) | Индексный доступ |
| Стек | O(1) | O(1) | O(n) | O(n) | LIFO: откат, скобки |
| Очередь | O(1) | O(1) | O(n) | O(n) | FIFO: BFS, буферы |
| Связный список | O(1)* | O(1)* | O(n) | O(n) | Частые вставки/удаления |
| Хеш-таблица | O(1) | O(1) | O(1) | — | Быстрый поиск по ключу |
| Куча | O(log n) | O(log n) | O(n) | O(1) min/max | Приоритеты |
| DSU | ≈O(1) | — | ≈O(1) | — | Компоненты связности |
| Trie | O(L) | O(L) | O(L) | — | Поиск по префиксу |
📚 Стек (Stack) — LIFO
🎯 Интуиция — аналогия из жизни
Представьте стопку тарелок на кухне (или стопку блинов). Вы всегда кладете новую тарелку наверх. И когда вам нужна чистая тарелка, вы тоже берете ее сверху. Нельзя вытащить тарелку из середины, не уронив остальные.
📝 Пошаговое текстовое описание
push(x) — положить элемент на вершину (в конец массива).pop() — убрать верхний элемент (удалить последний элемент).top() — посмотреть значение верхнего элемента, не удаляя его.empty() — проверить, пуста ли стопка.📐 Почему такая сложность?
Поскольку мы взаимодействуем только с одним концом структуры (вершиной), нам не нужно сдвигать остальные элементы. Все операции (добавление, удаление, просмотр) выполняются мгновенно за O(1). Память O(n) для хранения n элементов.
🔎 Подробная трассировка
Начало: []
push(5): [5] (вершина = 5)
push(3): [5, 3] (вершина = 3)
push(7): [5, 3, 7] (вершина = 7)
top(): возвращает 7, стек не меняется: [5, 3, 7]
pop(): удаляет 7 → [5, 3]
top(): возвращает 3
pop(): удаляет 3 → [5]
pop(): удаляет 5 → [] (стек пуст)
#include <iostream>
#include <vector>
#include <stack>
#include <string>
using namespace std;
// ═══ Реализация стека на массиве ═══
template <typename T>
class MyStack {
vector<T> data;
public:
void push(T val) { data.push_back(val); }
void pop() {
if (data.empty()) throw runtime_error("Stack underflow");
data.pop_back();
}
T top() const {
if (data.empty()) throw runtime_error("Stack is empty");
return data.back();
}
bool empty() const { return data.empty(); }
int size() const { return data.size(); }
};
// ═══ Задача 1: Проверка сбалансированности скобок ═══
bool isBalanced(const string& s) {
stack<char> st;
for (char c : s) {
if (c == '(' || c == '[' || c == '{') {
st.push(c);
} else {
if (st.empty()) return false;
char top = st.top(); st.pop();
if (c == ')' && top != '(') return false;
if (c == ']' && top != '[') return false;
if (c == '}' && top != '{') return false;
}
}
return st.empty();
}
// ═══ Задача 2: Обратная польская запись (RPN) ═══
int evalRPN(const vector<string>& tokens) {
stack<int> st;
for (const string& t : tokens) {
if (t == "+" || t == "-" || t == "*" || t == "/") {
int b = st.top(); st.pop();
int a = st.top(); st.pop();
if (t == "+") st.push(a + b);
else if (t == "-") st.push(a - b);
else if (t == "*") st.push(a * b);
else st.push(a / b);
} else {
st.push(stoi(t));
}
}
return st.top();
}
// ═══ Задача 3: Следующий больший элемент (Next Greater Element) ═══
vector<int> nextGreater(const vector<int>& arr) {
int n = arr.size();
vector<int> result(n, -1);
stack<int> st; // стек индексов
for (int i = 0; i < n; i++) {
// пока текущий элемент больше того, что на вершине стека
while (!st.empty() && arr[st.top()] < arr[i]) {
result[st.top()] = arr[i];
st.pop();
}
st.push(i);
}
return result;
}
// ═══ Задача 4: Инфикс → Постфикс (алгоритм Shunting Yard) ═══
int precedence(char op) {
if (op == '+' || op == '-') return 1;
if (op == '*' || op == '/') return 2;
return 0;
}
string infixToPostfix(const string& infix) {
string result;
stack<char> ops;
for (char c : infix) {
if (isalnum(c)) {
result += c;
} else if (c == '(') {
ops.push(c);
} else if (c == ')') {
while (!ops.empty() && ops.top() != '(') {
result += ops.top(); ops.pop();
}
ops.pop(); // убираем '('
} else { // оператор
while (!ops.empty() && precedence(ops.top()) >= precedence(c)) {
result += ops.top(); ops.pop();
}
ops.push(c);
}
}
while (!ops.empty()) { result += ops.top(); ops.pop(); }
return result;
}
int main() {
// Стек
MyStack<int> s;
s.push(10); s.push(20); s.push(30);
cout << "Top: " << s.top() << endl; // 30
s.pop();
cout << "Top after pop: " << s.top() << endl; // 20
// Скобки
cout << "({[]}) balanced: " << isBalanced("({[]})") << endl; // 1
cout << "([)] balanced: " << isBalanced("([)]") << endl; // 0
// RPN: (3 + 4) * 2 = 14
cout << "RPN: " << evalRPN({"3","4","+","2","*"}) << endl; // 14
// Next Greater
vector<int> arr = {4, 5, 2, 10, 8};
auto ng = nextGreater(arr);
for (int x : ng) cout << x << " "; // 5 10 10 -1 -1
cout << endl;
// Инфикс → Постфикс
cout << "a+b*c = " << infixToPostfix("a+b*c") << endl; // abc*+
return 0;
}
⚠️ Подводные камни
Вызов top() или pop() у пустого стека (Stack Underflow) приведет к крашу программы (Undefined Behavior в C++). Всегда проверяйте !st.empty() перед извлечением.
💼 Где спрашивают
Регулярно попадается на собеседованиях. Классические задачи: валидация скобок, вычисление арифметических выражений (калькулятор), монотонный стек (задачи на гистограммы, спан акций), отслеживание пути в файловой системе (cd ../../folder).
🔗 Связь с другими
Рекурсия под капотом использует "стек вызовов" (Call Stack). Алгоритм поиска в глубину (DFS) работает на стеке (явном или скрытом рекурсивном). Полная противоположность Очереди.
Не проверяем пустоту стека
❌ Краш при pop пустого
stack<int> st;
// 💀 undefined behavior!
int val = st.top(); // краш
st.pop(); // краш✅ Проверяем empty()
stack<int> st;
if (!st.empty()) { // ✅ всегда проверяем!
int val = st.top();
st.pop();
}❓ Проверь себя
🚶 Очередь (Queue) — FIFO
🎯 Интуиция — аналогия из жизни
Очередь в кассу супермаркета. Кто пришел первым (First In), того первым и обслужат (First Out). Никто не лезет без очереди (вставка только в конец), и кассир обслуживает только того, кто стоит в самом начале.
📝 Пошаговое текстовое описание
enqueue(x) / push(x) — добавить элемент в конец (хвост) очереди.dequeue() / pop() — забрать элемент из начала (головы) очереди.front() — посмотреть, кто стоит первым, не выгоняя его из очереди.empty() — проверить, нет ли никого в очереди.📐 Почему такая сложность?
Реализуется с помощью двух указателей: на начало (head) и на конец (tail). Добавление и извлечение происходит мгновенно по этим указателям за O(1). Память — O(n).
🔎 Подробная трассировка
Начало: []
enqueue(10): [10] (front=10, tail=10)
enqueue(20): [10, 20] (front=10, tail=20)
enqueue(30): [10, 20, 30] (front=10, tail=30)
front(): возвращает 10
dequeue(): уходит 10 → [20, 30]
front(): возвращает 20
dequeue(): уходит 20 → [30]
#include <iostream>
#include <queue>
#include <vector>
#include <stack>
using namespace std;
// ═══ Кольцевая очередь (Circular Queue) на массиве ═══
template <typename T>
class CircularQueue {
vector<T> data;
int head, tail, cnt, cap;
public:
CircularQueue(int capacity)
: data(capacity), head(0), tail(0), cnt(0), cap(capacity) {}
void enqueue(T val) {
if (cnt == cap) throw runtime_error("Queue overflow");
data[tail] = val;
tail = (tail + 1) % cap; // кольцо!
cnt++;
}
T dequeue() {
if (cnt == 0) throw runtime_error("Queue underflow");
T val = data[head];
head = (head + 1) % cap;
cnt--;
return val;
}
T front() const {
if (cnt == 0) throw runtime_error("Queue is empty");
return data[head];
}
bool empty() const { return cnt == 0; }
bool full() const { return cnt == cap; }
int size() const { return cnt; }
};
// ═══ Очередь на двух стеках (собеседование!) ═══
class QueueViaStacks {
stack<int> inbox, outbox;
void transfer() {
if (outbox.empty()) {
while (!inbox.empty()) {
outbox.push(inbox.top());
inbox.pop();
}
}
}
public:
void enqueue(int val) { inbox.push(val); }
int dequeue() {
transfer();
int val = outbox.top();
outbox.pop();
return val;
}
int front() {
transfer();
return outbox.top();
}
bool empty() { return inbox.empty() && outbox.empty(); }
};
// ═══ Задача: Генерация двоичных чисел от 1 до n ═══
vector<string> generateBinary(int n) {
vector<string> result;
queue<string> q;
q.push("1");
for (int i = 0; i < n; i++) {
string curr = q.front(); q.pop();
result.push_back(curr);
q.push(curr + "0"); // порождаем следующие
q.push(curr + "1");
}
return result;
}
int main() {
// Кольцевая очередь
CircularQueue<int> cq(5);
cq.enqueue(10); cq.enqueue(20); cq.enqueue(30);
cout << "Front: " << cq.front() << endl; // 10
cout << "Dequeue: " << cq.dequeue() << endl; // 10
cout << "Front: " << cq.front() << endl; // 20
// Очередь на стеках
QueueViaStacks qs;
qs.enqueue(1); qs.enqueue(2); qs.enqueue(3);
cout << "Queue via stacks: ";
cout << qs.dequeue() << " "; // 1
cout << qs.dequeue() << " "; // 2
qs.enqueue(4);
cout << qs.dequeue() << " "; // 3
cout << qs.dequeue() << endl; // 4
// Генерация двоичных чисел
auto bins = generateBinary(5);
for (auto& b : bins) cout << b << " "; // 1 10 11 100 101
cout << endl;
return 0;
}
⚠️ Подводные камни
Реализация очереди на обычном массиве путем сдвига head вправо приводит к утечке памяти — слева образуется "мертвая зона". Поэтому используют "Кольцевой буфер" (Circular Queue), где индекс вычисляется по модулю (tail + 1) % capacity.
💼 Где спрашивают
Реализация очереди через два стека (амортизированное O(1)) — хрестоматийная задача FAANG. Очередь используется для планирования задач ОС (Task Scheduling), обработки сообщений (RabbitMQ, Kafka).
🔗 Связь с другими
Очередь — сердце алгоритма Поиск в ширину (BFS). Дек (Deque) — это двусторонняя очередь.
❓ Проверь себя
🔗 Задачи для практики
↔️ Дек (Deque — Double-Ended Queue)
🎯 Интуиция — аналогия из жизни
Представьте колоду карт. Вы можете положить карту на самый верх колоды или подложить в самый низ. И брать карты вы можете тоже как сверху, так и снизу. Это "Очередь с двумя концами".
📝 Пошаговое текстовое описание
push_front(x) / push_back(x) — добавить элемент в начало или в конец.pop_front() / pop_back() — извлечь элемент из начала или конца.std::deque дополнительно поддерживает доступ по индексу: dq[i].📐 Почему такая сложность?
Под капотом (в C++) это массив указателей на блоки памяти фиксированного размера. Это позволяет быстро расширяться в обе стороны без копирования всего содержимого, обеспечивая O(1) для вставки на концах.
🔎 Подробная трассировка: Максимум в окне, arr=[1, 3, -1, -3, 5], k=3
Используем монотонно убывающий дек, хранящий ИНДЕКСЫ:
i=0 (1): dq=[0]
i=1 (3): 3 > 1 → выкидываем 0. dq=[1] (значение 3)
i=2 (-1): -1 < 3 → добавляем. dq=[1, 2], окно [1, 3, -1] → max=arr[dq[0]]=3
i=3 (-3): -3 < -1 → добавляем. dq=[1, 2, 3], окно [3, -1, -3] → max=arr[dq[0]]=3
i=4 (5): 5 > -3, -1, 3 → выкидываем всё! Индекс 1 (т.к. 4-1=3 >= k) устарел. dq=[4] → max=arr[4]=5
Результат: [3, 3, 5]
#include <iostream>
#include <deque>
#include <vector>
using namespace std;
// ═══ Задача: Максимум в скользящем окне размера k ═══
// Используем монотонный дек (убывающий)
vector<int> maxSlidingWindow(const vector<int>& arr, int k) {
vector<int> result;
deque<int> dq; // хранит ИНДЕКСЫ в убывающем порядке значений
for (int i = 0; i < (int)arr.size(); i++) {
// Удаляем элементы вне окна
while (!dq.empty() && dq.front() <= i - k)
dq.pop_front();
// Удаляем все элементы меньше текущего (поддерживаем убывание)
while (!dq.empty() && arr[dq.back()] <= arr[i])
dq.pop_back();
dq.push_back(i);
// Первое полное окно
if (i >= k - 1)
result.push_back(arr[dq.front()]); // front = максимум
}
return result;
}
int main() {
// STL deque
deque<int> dq;
dq.push_back(10); // [10]
dq.push_front(5); // [5, 10]
dq.push_back(20); // [5, 10, 20]
dq.push_front(1); // [1, 5, 10, 20]
cout << "Front: " << dq.front() << endl; // 1
cout << "Back: " << dq.back() << endl; // 20
dq.pop_front(); // [5, 10, 20]
dq.pop_back(); // [5, 10]
// Доступ по индексу тоже O(1)!
cout << "dq[1] = " << dq[1] << endl; // 10
// Максимум в скользящем окне
vector<int> arr = {1, 3, -1, -3, 5, 3, 6, 7};
auto res = maxSlidingWindow(arr, 3);
cout << "Max sliding window k=3: ";
for (int x : res) cout << x << " "; // 3 3 5 5 6 7
cout << endl;
return 0;
}
⚠️ Подводные камни
В отличие от std::vector, элементы дека std::deque не хранятся в непрерывном блоке памяти. Поэтому арифметика указателей с ним работает медленнее, и кеш процессора (Cache Misses) утилизируется хуже.
💼 Где спрашивают
Задача "Sliding Window Maximum" на LeetCode (Hard) полностью решается через монотонный дек. Применяется в 0-1 BFS алгоритме Дейкстры.
🔗 Связь с другими
Дек обобщает и Стек, и Очередь.
❓ Проверь себя
🔗 Связный список (Singly Linked List)
🎯 Интуиция — аналогия из жизни
Охота за сокровищами (квест с записками). Вы находите первую записку (head). В ней написан текст (данные) и адрес места, где спрятана вторая записка (next). Вы идете туда, читаете вторую записку с указанием на третью. Вы не можете найти 5-ю записку, не пройдя по всем предыдущим.
📝 Пошаговое текстовое описание
head. Последний указывает на nullptr.next направляем на текущий head, обновляем head.while(curr != nullptr) { curr = curr->next; }.📐 Почему такая сложность?
Поскольку узлы разбросаны в оперативной памяти случайным образом, мы не можем вычислить адрес N-го элемента формулой. Приходится идти по цепочке от начала до конца — O(n). Зато вставка в начало обходится переброской одного указателя — O(1).
🔎 Подробная трассировка: Разворот списка 1 → 2 → 3
prev = NULL, curr = 1
Шаг 1: next = 2; curr.next = prev (NULL); prev = 1; curr = 2.
Состояние: NULL <- 1 | 2 -> 3
Шаг 2: next = 3; curr.next = prev (1); prev = 2; curr = 3.
Состояние: NULL <- 1 <- 2 | 3 -> NULL
Шаг 3: next = NULL; curr.next = prev (2); prev = 3; curr = NULL.
Состояние: NULL <- 1 <- 2 <- 3
Возвращаем prev (3). Новый список: 3 → 2 → 1 ✅
#include <iostream>
using namespace std;
struct ListNode {
int val;
ListNode* next;
ListNode(int v) : val(v), next(nullptr) {}
};
class LinkedList {
ListNode* head;
public:
LinkedList() : head(nullptr) {}
// Вставка в начало: O(1)
void pushFront(int val) {
ListNode* node = new ListNode(val);
node->next = head;
head = node;
}
// Вставка в конец: O(n)
void pushBack(int val) {
ListNode* node = new ListNode(val);
if (!head) { head = node; return; }
ListNode* cur = head;
while (cur->next) cur = cur->next;
cur->next = node;
}
// Удаление по значению: O(n)
void remove(int val) {
if (!head) return;
if (head->val == val) {
ListNode* tmp = head;
head = head->next;
delete tmp;
return;
}
ListNode* cur = head;
while (cur->next && cur->next->val != val)
cur = cur->next;
if (cur->next) {
ListNode* tmp = cur->next;
cur->next = tmp->next;
delete tmp;
}
}
// Поиск: O(n)
bool contains(int val) const {
ListNode* cur = head;
while (cur) {
if (cur->val == val) return true;
cur = cur->next;
}
return false;
}
void print() const {
ListNode* cur = head;
while (cur) {
cout << cur->val;
if (cur->next) cout << " → ";
cur = cur->next;
}
cout << " → NULL" << endl;
}
ListNode* getHead() { return head; }
~LinkedList() {
while (head) { ListNode* t = head; head = head->next; delete t; }
}
};
// ═══ Задача 1: Развернуть список (итеративно) ═══
ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr;
ListNode* curr = head;
while (curr) {
ListNode* next = curr->next;
curr->next = prev; // разворачиваем стрелку
prev = curr;
curr = next;
}
return prev; // новый head
}
// ═══ Задача 2: Найти середину списка ═══
ListNode* findMiddle(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
while (fast && fast->next) {
slow = slow->next; // шаг 1
fast = fast->next->next; // шаг 2
}
return slow; // slow теперь в середине
}
// ═══ Задача 3: Обнаружить цикл (Floyd's Algorithm) ═══
bool hasCycle(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) return true; // встретились → цикл!
}
return false;
}
int main() {
LinkedList list;
list.pushBack(1);
list.pushBack(2);
list.pushBack(3);
list.pushBack(4);
list.pushBack(5);
list.print(); // 1 → 2 → 3 → 4 → 5 → NULL
// Середина
ListNode* mid = findMiddle(list.getHead());
cout << "Middle: " << mid->val << endl; // 3
return 0;
}
⚠️ Подводные камни
Самая частая ошибка — обращение по нулевому указателю (Null Pointer Exception / Segfault). Всегда проверяйте if (head == nullptr) и аккуратно переставляйте указатели (не теряйте ссылку на остаток списка).
💼 Где спрашивают
FAANG обожает задачи на связные списки. Обязательный пул задач: Разворот списка (Reverse Linked List), Обнаружение цикла (Hare and Tortoise), Слияние сортированных списков, Удаление N-го узла с конца.
🔗 Связь с другими
Списки лежат в основе Хеш-таблиц (метод цепочек для разрешения коллизий), Стеков и очередей (если размер не ограничен).
❓ Проверь себя
🔗🔗 Двусвязный список (Doubly Linked List)
🎯 Интуиция — аналогия из жизни
Поезд, в котором двери между вагонами работают в обе стороны. Вы можете пройти от первого вагона до пятого, развернуться и пойти обратно от пятого ко второму.
📝 Пошаговое текстовое описание
next и указатель prev.B, мы берем B.prev и связываем его с B.next, минуя B.📐 Почему такая сложность?
В отличие от односвязного списка, где для удаления узла X нужно найти узел, стоящий перед ним (за O(n)), в двусвязном списке X сам знает, кто стоит перед ним (X->prev). Поэтому удаление/вставка в середине происходит мгновенно за O(1) (если адрес уже известен).
🔎 Подробная трассировка: удаление узла B (A ↔ B ↔ C)
До: A.next = B, B.prev = A | B.next = C, C.prev = B
Удаление B:
B.prev.next = B.next (A.next становится C)
B.next.prev = B.prev (C.prev становится A)
После: A.next = C, C.prev = A (A ↔ C) ✅
#include <iostream>
#include <unordered_map>
using namespace std;
struct DNode {
int key, val;
DNode* prev;
DNode* next;
DNode(int k, int v) : key(k), val(v), prev(nullptr), next(nullptr) {}
};
// ═══ LRU Cache (Least Recently Used) — собеседование! ═══
class LRUCache {
int capacity;
unordered_map<int, DNode*> map; // key → node pointer
DNode *head, *tail; // фиктивные (sentinel) узлы для удобства
// Добавление сразу после head (самый "свежий")
void addToFront(DNode* node) {
node->next = head->next;
node->prev = head;
head->next->prev = node;
head->next = node;
}
// Удаление из списка
void removeNode(DNode* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
}
// Перемещение обновленного элемента в начало
void moveToFront(DNode* node) {
removeNode(node);
addToFront(node);
}
public:
LRUCache(int cap) : capacity(cap) {
head = new DNode(0, 0); // sentinel
tail = new DNode(0, 0); // sentinel
head->next = tail;
tail->prev = head;
}
int get(int key) {
if (map.find(key) == map.end()) return -1;
DNode* node = map[key];
moveToFront(node); // мы его использовали, он теперь самый свежий
return node->val;
}
void put(int key, int value) {
if (map.find(key) != map.end()) {
DNode* node = map[key];
node->val = value;
moveToFront(node);
} else {
if ((int)map.size() == capacity) {
// Выкидываем LRU (тот, что перед tail)
DNode* lru = tail->prev;
removeNode(lru);
map.erase(lru->key);
delete lru;
}
DNode* node = new DNode(key, value);
addToFront(node);
map[key] = node;
}
}
~LRUCache() {
DNode* cur = head;
while (cur) { DNode* t = cur; cur = cur->next; delete t; }
}
};
int main() {
LRUCache cache(3); // ёмкость 3
cache.put(1, 100);
cache.put(2, 200);
cache.put(3, 300);
cout << cache.get(2) << endl; // 200 (2 становится самым свежим)
cache.put(4, 400); // вытесняет ключ 1 (самый старый)
cout << cache.get(1) << endl; // -1 (вытеснен!)
cout << cache.get(3) << endl; // 300
cout << cache.get(4) << endl; // 400
return 0;
}
⚠️ Подводные камни
Сложно не запутаться в переплетении 4-х указателей при вставке узла. Чтобы избежать проверок на nullptr в краях (когда список пуст или мы удаляем последний элемент), используют фиктивные узлы (sentinel nodes) для head и tail. Это спасает от 90% багов сегментации.
💼 Где спрашивают
Спроектировать LRU (Least Recently Used) или LFU (Least Frequently Used) Cache — это задача №1 на System Design и собеседованиях Senior уровня. В этих кешах двусвязный список + Хеш-таблица.
🔗 Связь с другими
Стандартный `std::list` в C++ реализован именно так. Расширяет односвязный список за счет дополнительных накладных расходов памяти по одному указателю на узел.
Забыли обновить обе ссылки при удалении узла
❌ Обновляется только next
// 💀 Ошибка: меняем только одну сторону
node->prev->next = node->next;
// node->next->prev НЕ обновили!
// список стал битым
delete node;
✅ Нужно обновлять обе стороны
node->prev->next = node->next;
node->next->prev = node->prev; // ✅
delete node;
Удаление головы/хвоста без sentinel-узлов
❌ Много if и легко ошибиться
// 💀 Крайние случаи: head, tail, один элемент
if (node == head) ...
if (node == tail) ...
if (head == tail) ...
// код быстро становится хрупким
✅ Использовать sentinel head/tail
// ✅ фиктивные head/tail сильно упрощают код
head = new DNode(0,0);
tail = new DNode(0,0);
head->next = tail;
tail->prev = head;
// теперь удаление/вставка одинаковы для всех узлов
❓ Проверь себя
🔗 Задачи для практики
#️⃣ Хеш-таблица (Hash Table)
🎯 Интуиция — аналогия из жизни
Библиотека, где номера книг вычисляются по названию. Вы даете библиотекарю книгу "Гарри Поттер", он пропускает это название через математическую мясорубку (Хеш-функцию), которая выдает: "Полка №42". Он идет к полке 42 и ставит книгу. Чтобы найти ее, он делает ту же математику, получает "42" и мгновенно забирает книгу, не перебирая все остальные.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Вычисление хеша и доступ по индексу массива занимает O(1). Если хеш-функция хорошая и равномерно размазывает элементы, коллизии редки. Но если злоумышленник специально подберет ключи, дающие один хеш, все элементы свалятся в один список в ячейке, и поиск выродится в линейный O(n).
🔎 Подробная трассировка: Вставка в хеш-таблицу размером 7
Используем хеш: h(key) = key % 7
Insert 10: 10 % 7 = 3 → table[3] = [10]
Insert 20: 20 % 7 = 6 → table[6] = [20]
Insert 17: 17 % 7 = 3 → Коллизия с 10!
(Метод цепочек): table[3] = [10, 17]
Search 17: 17 % 7 = 3. Идем в table[3]. Перебираем список: 10!=17, 17==17. Найдено! ✅
#include <iostream>
#include <list>
#include <vector>
#include <unordered_map>
#include <unordered_set>
#include <algorithm>
using namespace std;
// ═══ Реализация хеш-таблицы (метод цепочек) ═══
class HashTable {
int size;
vector<list<pair<int,string>>> table;
int hash(int key) const { return key % size; }
public:
HashTable(int sz = 13) : size(sz), table(sz) {}
void insert(int key, const string& value) {
int idx = hash(key);
// Обновляем если ключ есть
for (auto& [k, v] : table[idx]) {
if (k == key) { v = value; return; }
}
table[idx].push_back({key, value});
}
string search(int key) const {
int idx = hash(key);
for (auto& [k, v] : table[idx]) {
if (k == key) return v;
}
return "NOT FOUND";
}
void remove(int key) {
int idx = hash(key);
table[idx].remove_if([key](auto& p){ return p.first == key; });
}
};
// ═══ Задача 1: Two Sum (самая популярная на LeetCode!) ═══
vector<int> twoSum(const vector<int>& nums, int target) {
unordered_map<int, int> seen; // value → index
for (int i = 0; i < (int)nums.size(); i++) {
int complement = target - nums[i];
if (seen.count(complement))
return {seen[complement], i};
seen[nums[i]] = i;
}
return {};
}
// ═══ Задача 2: Группировка анаграмм ═══
vector<vector<string>> groupAnagrams(const vector<string>& strs) {
unordered_map<string, vector<string>> groups;
for (const string& s : strs) {
string key = s;
sort(key.begin(), key.end()); // ключ = отсортированная строка
groups[key].push_back(s);
}
vector<vector<string>> result;
for (auto& [k, v] : groups)
result.push_back(v);
return result;
}
int main() {
// Two Sum
auto res = twoSum({2, 7, 11, 15}, 9);
cout << "Two Sum: [" << res[0] << ", " << res[1] << "]" << endl; // [0, 1]
return 0;
}
⚠️ Подводные камни
Рехеширование (Rehashing): когда таблица заполняется (Load Factor > 0.75), массив нужно увеличить в 2 раза и перераспределить все элементы. Это вызывает единовременную просадку производительности O(n). Не используйте в системах жесткого реального времени.
💼 Где спрашивают
Хеш-таблицы — самый частый инструмент решения задач с массивами для снижения сложности с O(n²) до O(n). Задачи "Two Sum", "Subarray sum equals K", мемоизация в ДП. В C++ `std::unordered_map` реализована на хеш-таблице, а `std::map` — на красно-черном дереве.
🔗 Связь с другими
Уступает бинарным деревьям поиска (BST) в том, что не хранит элементы в отсортированном порядке и не может эффективно искать элементы "в диапазоне" [A, B].
Забываем о коллизиях
❌ Не обрабатываем коллизии
// 💀 Если hash(a)==hash(b), b затирает a!
int table[SIZE];
table[hash(key)] = value;
// Потеря данных при коллизии!✅ Метод цепочек
// ✅ Список на каждой позиции
vector<list<pair<K,V>>> table(SIZE);
table[hash(key)].push_back({key, val});
// Коллизии хранятся в списке❓ Проверь себя
⬆️ Приоритетная очередь / Бинарная куча
🎯 Интуиция — аналогия из жизни
Приемный покой больницы. Неважно, кто пришел первым, доктор примет пациента с самой высокой температурой или тяжелой травмой. Это очередь, отсортированная по приоритету.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Дерево плотно упаковано и всегда сбалансировано. Его высота строго log₂n. Операции просеивания (Sift Up / Sift Down) проходят путь от листа до корня (или наоборот), делая максимум log₂n шагов. Извлечение максимума — мгновенно O(1).
🔎 Подробная трассировка: Вставка в Min-Heap [2, 4, 8] значения 1
Начало (логически дерево):
2 (Массив: [2, 4, 8])
/ \
4 8
Шаг 1 (Вставка в конец): добавляем 1.
2 (Массив: [2, 4, 8, 1])
/ \
4 8
/
1
Шаг 2 (Sift Up): 1 меньше родителя (4). Меняем их!
2 (Массив: [2, 1, 8, 4])
/ \
1 8
/
4
Шаг 3 (Sift Up): 1 меньше родителя (2). Меняем их!
1 (Массив: [1, 2, 8, 4]) ✅ Куча восстановлена.
/ \
2 8
/
4
#include <iostream>
#include <vector>
#include <queue>
#include <functional>
using namespace std;
// ═══ Min-Heap с нуля ═══
class MinHeap {
vector<int> heap;
void siftUp(int i) {
while (i > 0) {
int parent = (i - 1) / 2;
if (heap[parent] > heap[i]) {
swap(heap[parent], heap[i]);
i = parent;
} else break;
}
}
void siftDown(int i) {
int n = heap.size();
while (2 * i + 1 < n) {
int left = 2 * i + 1;
int right = 2 * i + 2;
int smallest = i;
if (left < n && heap[left] < heap[smallest]) smallest = left;
if (right < n && heap[right] < heap[smallest]) smallest = right;
if (smallest == i) break;
swap(heap[i], heap[smallest]);
i = smallest;
}
}
public:
void insert(int val) {
heap.push_back(val);
siftUp(heap.size() - 1);
}
int extractMin() {
int minVal = heap[0];
heap[0] = heap.back();
heap.pop_back();
if (!heap.empty()) siftDown(0);
return minVal;
}
int peekMin() const { return heap[0]; }
bool empty() const { return heap.empty(); }
int size() const { return heap.size(); }
};
// ═══ Задача 1: K-й наибольший элемент ═══
int kthLargest(vector<int>& arr, int k) {
// Min-heap размера k
priority_queue<int, vector<int>, greater<int>> pq;
for (int x : arr) {
pq.push(x);
if ((int)pq.size() > k) pq.pop(); // удаляем наименьший
}
return pq.top(); // k-й наибольший
}
// ═══ Задача 3: Медиана потока данных ═══
class MedianFinder {
priority_queue<int> maxHeap; // левая половина
priority_queue<int, vector<int>, greater<int>> minHeap; // правая половина
public:
void addNum(int num) {
maxHeap.push(num);
minHeap.push(maxHeap.top());
maxHeap.pop();
// Балансировка: maxHeap может быть на 1 больше
if (maxHeap.size() < minHeap.size()) {
maxHeap.push(minHeap.top());
minHeap.pop();
}
}
double findMedian() {
if (maxHeap.size() > minHeap.size())
return maxHeap.top();
return (maxHeap.top() + minHeap.top()) / 2.0;
}
};
int main() {
// Своя куча
MinHeap mh;
mh.insert(15); mh.insert(10); mh.insert(20); mh.insert(5);
cout << "Min: " << mh.extractMin() << endl; // 5
// K-й наибольший
vector<int> arr = {3, 2, 1, 5, 6, 4};
cout << "2nd largest: " << kthLargest(arr, 2) << endl; // 5
return 0;
}
⚠️ Подводные камни
Индексация в куче (если массив с 0-го элемента): Левый ребёнок: `2i+1`, Правый: `2i+2`, Родитель: `(i-1)/2`. Будьте предельно внимательны к проверкам выхода за границы массива при Sift Down.
💼 Где спрашивают
Куча используется как движок для Алгоритма Дейкстры (поиск кратчайшего пути), в Кодировании Хаффмана и в Алгоритме Прима (построение минимального остовного дерева). На собеседованиях часто дают задачу "Слияние K отсортированных списков".
🔗 Связь с другими
Пирамидальная сортировка (Heap Sort) полностью основана на этой структуре. Альтернативой может выступать Сбалансированное бинарное дерево (AVL/Red-Black), но куча быстрее на константу и не хранит указателей.
❓ Проверь себя
priority_queue<int, vector<int>, greater<int>>. Вариант С тоже работает на практике, но менее чистый.priority_queue<int, vector<int>, greater<int>> — три параметра шаблона. По умолчанию max-heap!🔗 Система непересекающихся множеств (Union-Find / DSU)
🎯 Интуиция — аналогия из жизни
Группировка мафиозных кланов. Изначально каждый бандит — сам себе босс. Если Вася и Петя объединяются, то босс Васи становится подчиненным босса Пети. Чтобы узнать, в одном ли клане Джон и Джек, мы находим их "крестных отцов" и проверяем, один ли это человек.
📝 Пошаговое текстовое описание
parent, где parent[i] указывает на начальника узла i.find(x): идем вверх по цепочке parent, пока не дойдем до корня (где parent[root] == root). Применяем сжатие пути: сразу переподключаем x к корню.union(x, y): находим корни X и Y. Если они разные, делаем один корень начальником другого (эвристика рангов: маленькое дерево вешаем на большое).📐 Почему такая сложность?
Благодаря сжатию путей деревья становятся очень плоскими (высотой почти 1). Математически доказано, что амортизированное время операции равно O(α(n)), где α(n) — обратная функция Аккермана. Для всех мысленно представимых чисел (например, 10^80) α(n) ≤ 4. На практике это O(1).
🔎 Подробная трассировка: Слияние узлов
Начало: каждый сам по себе {0}, {1}, {2}, {3}, {4}
union(0,1): корень 0 становится боссом 1. Дерево: 0->1. Множества: {0,1}, {2}, {3}, {4}
union(2,3): корень 2 становится боссом 3. Дерево: 2->3. Множества: {0,1}, {2,3}, {4}
union(1,3): find(1) возвращает 0. find(3) возвращает 2. Связываем корни: 2 подчиняем 0. Дерево: 0->1, 0->2->3.
find(3): (Сжатие пути!) идем 3 → 2 → 0. Переподключаем 3 напрямую к 0: 0->3.
find(0) == find(3)? Да (оба 0) ✅
find(0) == find(4)? Нет (0 и 4) ❌
#include <iostream>
#include <vector>
using namespace std;
class DSU {
vector<int> parent, rank_;
int components;
public:
DSU(int n) : parent(n), rank_(n, 0), components(n) {
for (int i = 0; i < n; i++) parent[i] = i; // каждый — свой корень
}
// Найти корень с СЖАТИЕМ ПУТИ
int find(int x) {
if (parent[x] != x)
parent[x] = find(parent[x]); // рекурсивно сжимаем путь
return parent[x];
}
// Объединить по РАНГУ
bool unite(int x, int y) {
int rx = find(x), ry = find(y);
if (rx == ry) return false; // уже в одном множестве
// Подвешиваем меньшее дерево к большему
if (rank_[rx] < rank_[ry]) swap(rx, ry);
parent[ry] = rx;
if (rank_[rx] == rank_[ry]) rank_[rx]++;
components--;
return true;
}
bool connected(int x, int y) { return find(x) == find(y); }
int getComponents() const { return components; }
};
// ═══ Задача: Обнаружение цикла в неориентированном графе ═══
bool hasCycle(int n, vector<pair<int,int>>& edges) {
DSU dsu(n);
for (auto& [u, v] : edges) {
if (!dsu.unite(u, v))
return true; // u и v уже в одном множестве → добавление ребра создает цикл!
}
return false;
}
int main() {
DSU dsu(5);
dsu.unite(0, 1);
dsu.unite(2, 3);
dsu.unite(1, 3);
cout << "0 и 3 связаны: " << dsu.connected(0, 3) << endl; // 1
cout << "0 и 4 связаны: " << dsu.connected(0, 4) << endl; // 0
cout << "Компонент: " << dsu.getComponents() << endl; // 2
// Цикл
vector<pair<int,int>> edges = {{0,1},{1,2},{2,0}};
cout << "Has cycle: " << hasCycle(3, edges) << endl; // 1
return 0;
}
⚠️ Подводные камни
Без эвристики "Сжатие пути" (Path Compression) алгоритм деградирует до O(n) на операцию. Это одна строчка кода: parent[x] = find(parent[x]);. Забудете её — алгоритм завалит тесты по Time Limit.
💼 Где спрашивают
Фундаментальная структура для графовых задач: Подсчет количества островов, Проверка двудольности, Динамическая связность в сети.
🔗 Связь с другими
Лежит в основе Алгоритма Краскала (Kruskal) для поиска минимального остовного дерева (MST). DFS может выполнять многие те же задачи, но DSU выигрывает, когда ребра добавляются динамически (в онлайн-режиме).
DSU без оптимизаций
❌ O(n) на find
// 💀 Без сжатия пути: линейная цепочка
int find(int x) {
while (parent[x] != x) x = parent[x];
return x; // O(n) в худшем!
}✅ ≈O(1) со сжатием пути
// ✅ Сжатие пути: все узлы → корень
int find(int x) {
if (parent[x] != x)
parent[x] = find(parent[x]); // ✅
return parent[x]; // ≈O(α(n))≈O(1)
}❓ Проверь себя
🌿 Trie (Префиксное дерево)
🎯 Интуиция — аналогия из жизни
Система автодополнения (T9) или бумажный словарь. Вы ищете слово "CAT". Открываете секцию "C", внутри нее ищете букву "A", а затем "T". Слова, начинающиеся одинаково (например, "CAT" и "CAR"), делят общий префикс "CA" и хранятся вместе, экономя время поиска.
📝 Пошаговое текстовое описание
isEnd).isEnd = true.isEnd.📐 Почему такая сложность?
Поиск и вставка зависят только от длины слова (L), а не от количества слов (N) в словаре. Это дает мгновенное O(L). Однако, каждый узел выделяет память под 26 указателей (или хеш-таблицу), что может быть очень накладно: O(N·L·Σ), где Σ — размер алфавита.
🔎 Подробная трассировка: вставляем "cat", "car", "dog"
root
├── c
│ └── a
│ ├── t* (конец слова "cat")
│ └── r* (конец слова "car")
└── d
└── o
└── g* (конец слова "dog")
search("car") → Идем: корень -> c -> a -> r. Флаг `isEnd` true → ✅ Найден!
search("can") → Идем: корень -> c -> a -> n (нет узла 'n') → ❌ Ошибка!
startsWith("ca") → Идем: корень -> c -> a. Узлы есть → ✅ Начинается с этого!
#include <iostream>
#include <vector>
#include <string>
using namespace std;
struct TrieNode {
TrieNode* children[26];
bool isEnd;
int prefixCount; // сколько слов проходят через этот узел
TrieNode() : isEnd(false), prefixCount(0) {
for (int i = 0; i < 26; i++) children[i] = nullptr;
}
};
class Trie {
TrieNode* root;
void collectWords(TrieNode* node, string& current, vector<string>& result) {
if (node->isEnd) result.push_back(current);
for (int i = 0; i < 26; i++) {
if (node->children[i]) {
current += ('a' + i);
collectWords(node->children[i], current, result);
current.pop_back();
}
}
}
public:
Trie() { root = new TrieNode(); }
// Вставка слова: O(L)
void insert(const string& word) {
TrieNode* cur = root;
for (char c : word) {
int idx = c - 'a';
if (!cur->children[idx])
cur->children[idx] = new TrieNode();
cur = cur->children[idx];
cur->prefixCount++;
}
cur->isEnd = true;
}
// Поиск слова: O(L)
bool search(const string& word) const {
TrieNode* cur = root;
for (char c : word) {
int idx = c - 'a';
if (!cur->children[idx]) return false;
cur = cur->children[idx];
}
return cur->isEnd;
}
// Есть ли слово с таким префиксом: O(L)
bool startsWith(const string& prefix) const {
TrieNode* cur = root;
for (char c : prefix) {
int idx = c - 'a';
if (!cur->children[idx]) return false;
cur = cur->children[idx];
}
return true;
}
// Автодополнение: все слова с данным префиксом
vector<string> autocomplete(const string& prefix) {
vector<string> result;
TrieNode* cur = root;
for (char c : prefix) {
int idx = c - 'a';
if (!cur->children[idx]) return result;
cur = cur->children[idx];
}
string current = prefix;
collectWords(cur, current, result);
return result;
}
};
int main() {
Trie trie;
trie.insert("apple");
trie.insert("app");
trie.insert("application");
trie.insert("banana");
cout << "search 'app': " << trie.search("app") << endl; // 1
cout << "search 'ap': " << trie.search("ap") << endl; // 0
cout << "startsWith 'ap': " << trie.startsWith("ap") << endl; // 1
cout << "Autocomplete 'app': ";
auto suggestions = trie.autocomplete("app");
for (auto& s : suggestions) cout << s << " ";
// app apple application
cout << endl;
return 0;
}
⚠️ Подводные камни
Реализация с массивом размера 26 TrieNode* children[26] съедает много памяти (особенно на 64-битных системах, где каждый указатель = 8 байт). Если алфавит велик (например, UTF-8 символы), используйте std::unordered_map<char, TrieNode*>, жертвуя микросекундами времени ради гигантской экономии памяти.
💼 Где спрашивают
Задачи типа "Поиск слова (Boggle)", "Реализуйте движок поисковых подсказок", поиск максимального XOR пары чисел (бинарный Trie). Системы роутинга в веб-фреймворках.
🔗 Связь с другими
Это специфичный вид дерева (Дерево префиксов). Является альтернативой Хеш-таблице для строковых множеств, когда важен поиск по частичному совпадению (prefix match), а не только по полному ключу.
❓ Проверь себя
Глава 4. Деревья
📌 Деревья — основа алгоритмики
Деревья используются повсюду: файловые системы, базы данных (B-деревья), компиляторы (AST), DOM в браузере, XML/JSON парсинг. На собеседованиях — один из самых частых типов задач.
| Структура | Поиск | Вставка | Удаление | Особенность |
|---|---|---|---|---|
| BST (несбалансированный) | O(h) | O(h) | O(h) | h может быть n |
| AVL | O(log n) | O(log n) | O(log n) | Строго сбалансированное |
| Red-Black | O(log n) | O(log n) | O(log n) | std::map / std::set |
| Дерево отрезков | O(log n) | O(log n) | — | Запросы на отрезке |
| Дерево Фенвика | O(log n) | O(log n) | — | Префиксные суммы |
🌲 Бинарное дерево поиска (BST)
🎯 Интуиция — аналогия из жизни
Представьте огромный библиотечный каталог. Все книги слева от текущей полки начинаются на более раннюю букву алфавита, а все книги справа — на более позднюю. Чтобы найти нужную, вы не перебираете все полки, а каждый раз сужаете область поиска в два раза, выбирая левый или правый коридор.
📝 Пошаговое текстовое описание
nullptr (туда и вставляем новый узел).📐 Почему такая сложность?
Все операции пропорциональны высоте дерева h. Если элементы вставляются в случайном порядке, дерево получается "пушистым", и его высота h ≈ log₂n. Но если вставлять отсортированный массив (1, 2, 3, 4, 5), дерево выродится в длинную "макаронину" (связный список), и высота станет h = n, что даст сложность O(n).
🔎 Подробная трассировка: Поиск 6, Вставка 7
8
/ \
3 10
/ \ \
1 6 14
/ /
4 13
Поиск 6: Корень 8. 6 < 8 → идем влево (на 3). 6 > 3 → идем вправо (на 6). 6 == 6. ✅ Найдено!
Вставка 7: 7 < 8 (влево) → 7 > 3 (вправо) → 7 > 6 (вправо). У узла 6 правого ребенка нет (nullptr). Создаем узел 7 и привязываем его справа к 6. ✅
Inorder обход (лево-корень-право) всегда дает отсортированный массив!
#include <iostream>
#include <queue>
using namespace std;
struct TreeNode {
int val;
TreeNode *left, *right;
TreeNode(int v) : val(v), left(nullptr), right(nullptr) {}
};
class BST {
public:
TreeNode* root = nullptr;
// ═══ Вставка ═══
TreeNode* insert(TreeNode* node, int val) {
if (!node) return new TreeNode(val);
if (val < node->val) node->left = insert(node->left, val);
else if (val > node->val) node->right = insert(node->right, val);
return node; // дубликаты игнорируем
}
void insert(int val) { root = insert(root, val); }
// ═══ Поиск ═══
bool search(TreeNode* node, int val) {
if (!node) return false;
if (val == node->val) return true;
if (val < node->val) return search(node->left, val);
return search(node->right, val);
}
bool search(int val) { return search(root, val); }
// ═══ Минимум и максимум ═══
TreeNode* findMin(TreeNode* node) {
while (node->left) node = node->left;
return node;
}
TreeNode* findMax(TreeNode* node) {
while (node->right) node = node->right;
return node;
}
// ═══ Удаление (3 случая) ═══
TreeNode* remove(TreeNode* node, int val) {
if (!node) return nullptr;
if (val < node->val)
node->left = remove(node->left, val);
else if (val > node->val)
node->right = remove(node->right, val);
else {
// Случай 1: лист
if (!node->left && !node->right) {
delete node;
return nullptr;
}
// Случай 2: один ребёнок
if (!node->left) {
TreeNode* tmp = node->right;
delete node;
return tmp;
}
if (!node->right) {
TreeNode* tmp = node->left;
delete node;
return tmp;
}
// Случай 3: два ребёнка → заменяем на inorder successor
TreeNode* successor = findMin(node->right);
node->val = successor->val;
node->right = remove(node->right, successor->val);
}
return node;
}
void remove(int val) { root = remove(root, val); }
// ═══ Обходы ═══
void inorder(TreeNode* node) {
if (!node) return;
inorder(node->left);
cout << node->val << " ";
inorder(node->right);
}
// ═══ Высота дерева ═══
int height(TreeNode* node) {
if (!node) return -1; // или 0, зависит от определения
return 1 + max(height(node->left), height(node->right));
}
// ═══ Проверка: является ли BST (Собеседование!) ═══
bool isValidBST(TreeNode* node, long long minVal, long long maxVal) {
if (!node) return true;
if (node->val <= minVal || node->val >= maxVal) return false;
return isValidBST(node->left, minVal, node->val) &&
isValidBST(node->right, node->val, maxVal);
}
};
int main() {
BST tree;
tree.insert(8); tree.insert(3); tree.insert(10);
tree.insert(1); tree.insert(6); tree.insert(14);
cout << "Inorder: ";
tree.inorder(tree.root); // 1 3 6 8 10 14
cout << endl;
cout << "Search 6: " << tree.search(6) << endl; // 1
tree.remove(3); // удаляем узел с двумя детьми
cout << "After remove 3: ";
tree.inorder(tree.root); // 1 6 8 10 14
cout << endl;
return 0;
}
⚠️ Подводные камни
При проверке валидности BST isValidBST недостаточно проверить, что левый ребенок меньше корня, а правый — больше. Левый ребенок правого поддерева должен быть больше самого первого корня! Нужно передавать границы minVal и maxVal (и использовать long long, чтобы не поймать переполнение на тестах с INT_MAX).
💼 Где спрашивают
Фундамент для любых задач на деревья. "Проверить, является ли дерево BST", "Найти K-й наименьший элемент в BST" (решается Inorder-обходом), "Lowest Common Ancestor в BST".
🔗 Связь с другими
Логический потомок алгоритма Бинарного поиска на массиве. Является родителем для сбалансированных деревьев (AVL, Red-Black).
❓ Проверь себя
⚖️ AVL-дерево (самобалансирующееся BST)
🎯 Интуиция — аналогия из жизни
Представьте весы с чашами. Как только одна чаша перевешивает другую больше чем на одну гирьку (дерево начало "косить" в одну сторону), мы сразу же перекладываем гирьки (делаем "балансирующий поворот"), чтобы весы снова стали ровными. Дерево само поддерживает свою симметричность.
📝 Пошаговое текстовое описание
BF = h(left) - h(right). У валидного AVL-дерева BF может быть только -1, 0 или 1.BF > 1 или BF < -1, делаем повороты (LL, RR, LR, RL), чтобы восстановить баланс.📐 Почему такая сложность?
Из-за строгих правил балансировки высота AVL-дерева математически ограничена значением ~1.44 log₂n. Любой поиск, вставка и спуск/подъем занимает O(log n). Сами операции поворота — это просто перевешивание пары указателей за O(1).
🔎 Подробная трассировка: Вставляем 30, 20, 10 (Случай LL - перевес влево)
30 (h=1)
Вставляем 20:
30 (h=2, BF=1)
Вставляем 10:
/
20 (h=1, BF=0)
30 (h=3, BF=2) <-- Нарушение баланса! Перевес слева (Left-Left).
/
20 (h=2, BF=1)
/
10 (h=1, BF=0)
Правый поворот (RR-rotation) вокруг 30:
20 становится корнем. 30 становится его правым ребенком.
20 (h=2, BF=0)
✅ Дерево снова сбалансировано!
/ \
10 30 (оба h=1, BF=0)
#include <iostream>
using namespace std;
struct AVLNode {
int val, height;
AVLNode *left, *right;
AVLNode(int v) : val(v), height(1), left(nullptr), right(nullptr) {}
};
class AVLTree {
int height(AVLNode* n) { return n ? n->height : 0; }
int balanceFactor(AVLNode* n) { return n ? height(n->left) - height(n->right) : 0; }
void updateHeight(AVLNode* n) {
n->height = 1 + max(height(n->left), height(n->right));
}
// Правый поворот (LL case)
// y x
// / \ / \
// x C → A y
// / \ / \
// A B B C
AVLNode* rotateRight(AVLNode* y) {
AVLNode* x = y->left;
AVLNode* B = x->right;
x->right = y;
y->left = B;
updateHeight(y);
updateHeight(x);
return x; // новый корень
}
// Левый поворот (RR case)
AVLNode* rotateLeft(AVLNode* x) {
AVLNode* y = x->right;
AVLNode* B = y->left;
y->left = x;
x->right = B;
updateHeight(x);
updateHeight(y);
return y;
}
// Балансировка узла
AVLNode* balance(AVLNode* node) {
updateHeight(node);
int bf = balanceFactor(node);
// LL: левое поддерево тяжелее, вставка в его левое
if (bf > 1 && balanceFactor(node->left) >= 0)
return rotateRight(node);
// RR: правое поддерево тяжелее, вставка в его правое
if (bf < -1 && balanceFactor(node->right) <= 0)
return rotateLeft(node);
// LR: левое тяжелее, но вставка в его правое
if (bf > 1 && balanceFactor(node->left) < 0) {
node->left = rotateLeft(node->left);
return rotateRight(node);
}
// RL: правое тяжелее, но вставка в его левое
if (bf < -1 && balanceFactor(node->right) > 0) {
node->right = rotateRight(node->right);
return rotateLeft(node);
}
return node; // уже сбалансировано
}
AVLNode* findMin(AVLNode* node) {
while (node->left) node = node->left;
return node;
}
public:
AVLNode* root = nullptr;
AVLNode* insert(AVLNode* node, int val) {
if (!node) return new AVLNode(val);
if (val < node->val) node->left = insert(node->left, val);
else if (val > node->val) node->right = insert(node->right, val);
else return node; // дубликат
return balance(node); // балансируем при возврате по стеку вызовов!
}
AVLNode* remove(AVLNode* node, int val) {
if (!node) return nullptr;
if (val < node->val) node->left = remove(node->left, val);
else if (val > node->val) node->right = remove(node->right, val);
else {
if (!node->left || !node->right) {
AVLNode* tmp = node->left ? node->left : node->right;
delete node;
return tmp;
}
AVLNode* succ = findMin(node->right);
node->val = succ->val;
node->right = remove(node->right, succ->val);
}
return balance(node);
}
void insert(int val) { root = insert(root, val); }
void remove(int val) { root = remove(root, val); }
void preorder(AVLNode* node) {
if (!node) return;
cout << node->val << " ";
preorder(node->left);
preorder(node->right);
}
};
int main() {
AVLTree avl;
// Вставляем в порядке, который бы создал вырожденное BST
for (int x : {10, 20, 30, 40, 50, 25}) {
avl.insert(x);
}
cout << "Preorder (показывает структуру): ";
avl.preorder(avl.root); // 30 20 10 25 40 50
cout << endl;
// Дерево само сбалансировалось! Корень стал 30.
return 0;
}
⚠️ Подводные камни
Очень легко запутаться в LR и RL поворотах. Главное правило: при LR (левое поддерево тяжелее, но перевес в его правой ветви) мы сначала делаем левый поворот вокруг ребенка, чтобы свести задачу к простому LL, а затем правый поворот вокруг самого родителя.
💼 Где спрашивают
На хардовых алгоритмических секциях для проверки умения писать объемный, но строгий код с указателями. Могут попросить "нарисовать" на доске, что произойдет с деревом после вставки числа.
🔗 Связь с другими
AVL дерево строже сбалансировано, чем Красно-черное дерево (Red-Black Tree). Поэтому поиск в AVL немного быстрее, но вставка/удаление требует больше операций балансировки. В стандартной библиотеке C++ (std::map, std::set) используется Красно-черное дерево из-за лучшего баланса времени модификации.
❓ Проверь себя
🚶 Обходы дерева (Tree Traversals)
🎯 Интуиция — аналогия из жизни
Представьте, что вы читаете сложную книгу.
Preorder: Вы читаете оглавление (Корень), а затем погружаетесь в каждую главу по порядку.
Inorder: Вы читаете страницы последовательно слева направо.
Postorder: Вы сначала читаете все подразделы, и только потом делаете общий вывод (Корень) по всей главе.
Level-order: Вы сначала читаете первые строчки всех глав, потом вторые строчки и т.д.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Каждый узел посещается ровно один раз, что дает время O(n). При рекурсии (Pre/In/Post) память расходуется на стек вызовов, глубина которого равна высоте дерева O(h). В уровневом обходе (BFS) очередь хранит узлы одного слоя, что в худшем случае (полное дерево) равно O(n).
🔎 Подробная трассировка дерева
1
/ \
2 3
/ \ \
4 5 6
Preorder (K-L-R): 1 → (идем в 2) 2 → 4 → 5 → (возврат) → 3 → 6. (1, 2, 4, 5, 3, 6)
Inorder (L-K-R): 4 → 2 → 5 → 1 → 3(нет левого) → 6. (4, 2, 5, 1, 3, 6)
Postorder (L-R-K): 4 → 5 → 2 → 6 → 3 → 1. (4, 5, 2, 6, 3, 1)
Level-order: Уровень 1: [1]. Уровень 2: [2, 3]. Уровень 3: [4, 5, 6].
#include <iostream>
#include <vector>
#include <queue>
#include <stack>
using namespace std;
struct TreeNode {
int val;
TreeNode *left, *right;
TreeNode(int v) : val(v), left(nullptr), right(nullptr) {}
};
// ═══ Рекурсивные обходы ═══
void preorder(TreeNode* node) {
if (!node) return;
cout << node->val << " "; // 1) корень
preorder(node->left); // 2) лево
preorder(node->right); // 3) право
}
void inorder(TreeNode* node) {
if (!node) return;
inorder(node->left); // 1) лево
cout << node->val << " "; // 2) корень
inorder(node->right); // 3) право
}
void postorder(TreeNode* node) {
if (!node) return;
postorder(node->left); // 1) лево
postorder(node->right); // 2) право
cout << node->val << " "; // 3) корень
}
// ═══ Level-order (BFS) ═══
vector<vector<int>> levelOrder(TreeNode* root) {
vector<vector<int>> result;
if (!root) return result;
queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
int size = q.size(); // узлы на текущем уровне
vector<int> level;
for (int i = 0; i < size; i++) {
TreeNode* node = q.front(); q.pop();
level.push_back(node->val);
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
result.push_back(level);
}
return result;
}
// ═══ Итеративный inorder (без рекурсии, на стеке) ═══
vector<int> inorderIterative(TreeNode* root) {
vector<int> result;
stack<TreeNode*> st;
TreeNode* curr = root;
while (curr || !st.empty()) {
while (curr) { // идём максимально влево
st.push(curr);
curr = curr->left;
}
curr = st.top(); st.pop();
result.push_back(curr->val);
curr = curr->right;
}
return result;
}
int main() {
// 1
// / \
// 2 3
// / \ \
// 4 5 6
TreeNode* root = new TreeNode(1);
root->left = new TreeNode(2);
root->right = new TreeNode(3);
root->left->left = new TreeNode(4);
root->left->right = new TreeNode(5);
root->right->right = new TreeNode(6);
cout << "Preorder: "; preorder(root); cout << endl; // 1 2 4 5 3 6
cout << "Inorder: "; inorder(root); cout << endl; // 4 2 5 1 3 6
cout << "Postorder: "; postorder(root); cout << endl; // 4 5 2 6 3 1
cout << "Level-order: ";
auto levels = levelOrder(root);
for (auto& lvl : levels) {
cout << "[";
for (int x : lvl) cout << x << " ";
cout << "] ";
}
cout << endl; // [1] [2 3] [4 5 6]
return 0;
}
⚠️ Подводные камни
Написать итеративный Postorder (без рекурсии) гораздо сложнее, чем Inorder или Preorder. Чаще всего его реализуют с помощью двух стеков (эмулируя обратный Preorder) или одним стеком с отслеживанием `last_visited` узла.
💼 Где спрашивают
Реализация итеративного Inorder — одна из любимых задач на понимание работы стека. Задачи на деревья (например, "Являются ли два дерева симметричными") решаются одновременным обходом.
🔗 Связь с другими
Preorder, Inorder и Postorder — это специализированные версии Алгоритма Поиска в Глубину (DFS). Level-order — это чистый Поиск в Ширину (BFS).
❓ Проверь себя
📊 Дерево отрезков (Segment Tree)
🎯 Интуиция — аналогия из жизни
Иерархия корпорации. Рядовые сотрудники (листья дерева) сдают свои отчеты о продажах менеджерам. Менеджер суммирует данные своего отдела и передает директору (корень). Если гендиректору нужна сумма продаж по 4 отделам, он не опрашивает всех 100 сотрудников, а берет 4 готовых агрегированных отчета от менеджеров. Если сотрудник меняет данные, он сообщает менеджеру, тот — директору. Обновление идет быстро снизу вверх.
📝 Пошаговое текстовое описание
[0, n-1].[L, R] делится пополам. Левый ребенок отвечает за [L, mid], правый — за [mid+1, R]. Значение узла = сумма/мин/макс его детей.[l, r], возвращаем значение узла. Если не пересекается — возвращаем 0. Если пересекается частично, рекурсивно опрашиваем обоих детей.📐 Почему такая сложность?
Высота дерева отрезков составляет log₂n. При любом запросе на отрезок мы разбиваем его не более чем на 4 * log n узлов дерева. Поэтому запрос и обновление работают за логарифмическое время O(log n). Дерево занимает массив размером строго 4*n.
🔎 Подробная трассировка: Сумма на массиве [1, 3, 5, 7, 9, 11]
[36] (0-5: сумма всего)
/ \
[9] [27] (0-2) (3-5)
/ \ / \
[4] [5] [16] [11]
/ \ / \
[1][3] [7][9]
Запрос суммы [1, 4]:
Корень [0,5] (36) разбивает запрос.
Идем влево [0,2] (9) -> запрашиваем [1,2]. Он берет лист [1] (3) и лист [2] (5). Сумма = 8.
Идем вправо [3,5] (27) -> запрашиваем [3,4]. Он берет узел [3,4] (16).
Итого: 8 + 16 = 24. Сделано за O(log n) шагов!
#include <iostream>
#include <vector>
using namespace std;
class SegTree {
int n;
vector<long long> tree, lazy;
// Вспомогательная: 1-based индексация (дети 2v и 2v+1)
void build(const vector<int>& arr, int v, int tl, int tr) {
if (tl == tr) {
tree[v] = arr[tl];
return;
}
int tm = (tl + tr) / 2;
build(arr, 2*v, tl, tm);
build(arr, 2*v+1, tm+1, tr);
tree[v] = tree[2*v] + tree[2*v+1];
}
// Проталкивание отложенных операций детям
void push(int v, int tl, int tr) {
if (lazy[v] != 0) {
int tm = (tl + tr) / 2;
apply(2*v, tl, tm, lazy[v]);
apply(2*v+1, tm+1, tr, lazy[v]);
lazy[v] = 0;
}
}
void apply(int v, int tl, int tr, long long val) {
tree[v] += val * (tr - tl + 1);
lazy[v] += val;
}
// Запрос суммы на [l, r]
long long query(int v, int tl, int tr, int l, int r) {
if (l > tr || r < tl) return 0; // вне отрезка
if (l <= tl && tr <= r) return tree[v]; // полностью внутри
push(v, tl, tr); // Обязательно проталкиваем lazy перед спуском
int tm = (tl + tr) / 2;
return query(2*v, tl, tm, l, r) + query(2*v+1, tm+1, tr, l, r);
}
// Обновление: прибавить val ко всем элементам на [l, r]
void update(int v, int tl, int tr, int l, int r, long long val) {
if (l > tr || r < tl) return;
if (l <= tl && tr <= r) {
apply(v, tl, tr, val);
return;
}
push(v, tl, tr);
int tm = (tl + tr) / 2;
update(2*v, tl, tm, l, r, val);
update(2*v+1, tm+1, tr, l, r, val);
tree[v] = tree[2*v] + tree[2*v+1];
}
// Точечное обновление: arr[pos] = val (если без lazy)
void pointUpdate(int v, int tl, int tr, int pos, int val) {
if (tl == tr) { tree[v] = val; return; }
int tm = (tl + tr) / 2;
if (pos <= tm) pointUpdate(2*v, tl, tm, pos, val);
else pointUpdate(2*v+1, tm+1, tr, pos, val);
tree[v] = tree[2*v] + tree[2*v+1];
}
public:
SegTree(const vector<int>& arr) {
n = arr.size();
tree.assign(4 * n, 0);
lazy.assign(4 * n, 0);
build(arr, 1, 0, n - 1);
}
long long query(int l, int r) { return query(1, 0, n-1, l, r); }
void update(int l, int r, long long val) { update(1, 0, n-1, l, r, val); }
void pointUpdate(int pos, int val) { pointUpdate(1, 0, n-1, pos, val); }
};
int main() {
vector<int> arr = {1, 3, 5, 7, 9, 11};
SegTree st(arr);
cout << "Sum [1,4]: " << st.query(1, 4) << endl; // 3+5+7+9 = 24
st.pointUpdate(2, 10); // arr[2] = 10 (было 5)
cout << "After point update, Sum [1,4]: " << st.query(1, 4) << endl; // 3+10+7+9 = 29
st.update(0, 3, 2); // прибавить 2 ко всем на [0,3] (Lazy Propagation)
cout << "After range update +2 on [0,3], Sum [0,5]: "
<< st.query(0, 5) << endl; // (1+2)+(3+2)+(10+2)+(7+2)+9+11 = 49
return 0;
}
⚠️ Подводные камни
1. Забыть выделить массив размера 4 * n. Это самая частая причина Segmentation Fault. Для дерева из n листьев требуется 2n узлов, но из-за свойств 1-индексации некоторые ячейки пустуют, поэтому нужно именно 4n.
2. Забыть вызвать push() при сдвиге вниз в методе query(). Lazy Propagation не сработает, и вы получите старые данные.
💼 Где спрашивают
Олимпиадное программирование (Codeforces, AtCoder), секции Яндекса. Задачи: RMQ (Range Minimum Query), поиск количества нулей на отрезке, поиск наибольшей возрастающей подпоследовательности в массиве с обновлениями.
🔗 Связь с другими
Если массив никогда не меняется, лучше использовать Префиксные суммы O(1) или Sparse Table O(1). Если есть точечные обновления — можно использовать более простое Дерево Фенвика (но оно ограничено операциями с обратным элементом).
❓ Проверь себя
🔢 Дерево Фенвика (Binary Indexed Tree / BIT)
🎯 Интуиция — аналогия из жизни
Представьте русскую матрешку, но для математики. Любое число можно представить как сумму степеней двойки (например, 13 = 8 + 4 + 1). Фенвик хранит заранее посчитанные блоки сумм длин 1, 2, 4, 8... Чтобы получить сумму 13 элементов, мы просто складываем готовый блок из 8 элементов, блок из 4 элементов и блок из 1 элемента.
📝 Пошаговое текстовое описание
tree должен быть строго 1-индексирован.tree[i] отвечает за сумму отрезка длины i & -i (младший единичный бит).tree[i] и отнимаем младший бит (идем к предыдущим блокам: i -= i & -i).tree[i] и ко всем блокам, которые его покрывают, прибавляя младший бит (i += i & -i).📐 Почему такая сложность?
В битовом представлении числа не может быть больше log₂n единиц (например, в 32-битном числе их максимум 32). Поэтому циклы добавления/отнимания младшего бита сделают не более O(log n) итераций. Память — ровно O(n) (в 4 раза меньше Segment Tree!).
🔎 Подробная трассировка: Ключевая операция i & (-i)
Операция i & (-i) выделяет самый младший единичный бит.
i = 6 в двоичной: ...00000110
-i (доп. код): ...11111010
6 & -6 = ...00000010 = 2.
Значит, tree[6] хранит сумму двух элементов: arr[5] + arr[6].
Для суммы префикса 6: ans += tree[6], затем 6 -= 2 -> i = 4. ans += tree[4].
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
class BIT {
vector<long long> tree;
int n;
public:
BIT(int n) : n(n), tree(n + 1, 0) {}
BIT(const vector<int>& arr) : n(arr.size()), tree(arr.size() + 1, 0) {
for (int i = 0; i < n; i++)
update(i, arr[i]);
}
// Прибавить delta к arr[idx]
void update(int idx, long long delta) {
idx++; // BIT работает в 1-индексации
while (idx <= n) {
tree[idx] += delta;
idx += idx & (-idx); // переход к родителю (добавляем младший бит)
}
}
// Сумма arr[0..idx]
long long prefixSum(int idx) {
idx++;
long long sum = 0;
while (idx > 0) {
sum += tree[idx];
idx -= idx & (-idx); // переход к предыдущему блоку (убираем младший бит)
}
return sum;
}
// Сумма arr[l..r]
long long rangeSum(int l, int r) {
return prefixSum(r) - (l > 0 ? prefixSum(l - 1) : 0);
}
};
// ═══ Задача: Подсчёт инверсий за O(n log n) ═══
long long countInversions(vector<int>& arr) {
int n = arr.size();
// Координатная компрессия (чтобы индексы BIT не были гигантскими)
vector<int> sorted_arr = arr;
sort(sorted_arr.begin(), sorted_arr.end());
sorted_arr.erase(unique(sorted_arr.begin(), sorted_arr.end()), sorted_arr.end());
BIT bit(sorted_arr.size());
long long inv = 0;
// Идем справа налево
for (int i = n - 1; i >= 0; i--) {
int compressed = lower_bound(sorted_arr.begin(), sorted_arr.end(), arr[i])
- sorted_arr.begin();
inv += bit.prefixSum(compressed - 1); // сколько меньших элементов уже встретили справа
bit.update(compressed, 1); // добавляем текущий элемент в BIT
}
return inv;
}
int main() {
vector<int> arr = {1, 3, 5, 7, 9, 11};
BIT bit(arr);
cout << "Sum [1,4]: " << bit.rangeSum(1, 4) << endl; // 3+5+7+9 = 24
bit.update(2, 5); // arr[2] += 5 → arr[2] = 10
cout << "After +5 at idx 2, Sum [1,4]: " << bit.rangeSum(1, 4) << endl; // 29
// Инверсии
vector<int> arr2 = {5, 3, 2, 4, 1};
cout << "Inversions: " << countInversions(arr2) << endl; // 8
return 0;
}
⚠️ Подводные камни
Алгоритм ломается, если попытаться передать idx = 0 в 1-индексированном массиве без предварительного сдвига (idx++). Выражение 0 += 0 & -0 приведет к зацикливанию. Также Фенвик классически не умеет находить Максимум/Минимум на произвольном отрезке (только на префиксе), для этого нужно Дерево Отрезков.
💼 Где спрашивают
Решение классической задачи "Подсчет инверсий в массиве" (вместо Merge Sort). Используется там, где важна скорость и экономия памяти (код занимает всего 15 строк!).
🔗 Связь с другими
Легковесный, быстрый, но ограниченный (операции должны иметь обратный элемент: +, -, XOR) брат Дерева Отрезков (Segment Tree).
❓ Проверь себя
👨👩👧 LCA — Наименьший общий предок
🎯 Интуиция — аналогия из жизни
Поиск общего начальника в генеалогическом древе. Если один узел глубже другого, он "поднимается" на один уровень с ним. Затем оба начинают подниматься вверх. Вместо того чтобы идти шаг за шагом (долго!), мы используем Binary Lifting: делаем "прыжки" сразу на 16, 8, 4, 2, 1 шагов вверх, перепрыгивая огромные куски дерева за логарифм времени.
📝 Пошаговое текстовое описание
depth[v] (глубину) для каждого узла. Заодно строим таблицу up[v][k] — это предок вершины v на расстоянии 2^k.up[v][k] = up[ up[v][k-1] ][ k-1 ] (предок на расстоянии 8 — это предок на 4 от предка на 4).up[u][k] != up[v][k]. Ответ — up[u][0] (родитель).📐 Почему такая сложность?
Таблица `up` имеет размер N x log(N), её заполнение занимает O(N log N). При ответе на запрос мы проходим по всем битам высоты (цикл от log N до 0), делая строго O(1) действий внутри. Запрос работает за O(log N).
🔎 Подробная трассировка: LCA(6, 5)
0
/ \
1 2
/ \ \
3 4 5
/
6
Глубины: d[6]=3, d[5]=2. Разница = 1.
Поднимаем 6 на 1 шаг вверх (2^0): 6 → 3. Теперь d[3]=2, d[5]=2.
Пытаемся прыгнуть: k=0 (прыжок на 1): up[3][0]=1, up[5][0]=2. Они не равны! Прыгаем: u=1, v=2.
Ответ: up[1][0] = 0. LCA(6, 5) = 0.
#include <iostream>
#include <vector>
#include <cmath>
using namespace std;
class LCA {
int n, LOG;
vector<vector<int>> adj; // граф (дерево)
vector<vector<int>> up; // up[v][k] = предок v на расстоянии 2^k
vector<int> depth;
void dfs(int v, int parent, int d) {
depth[v] = d;
up[v][0] = parent; // 2^0 = 1 шаг (непосредственный родитель)
for (int k = 1; k < LOG; k++)
up[v][k] = up[up[v][k-1]][k-1]; // 2^k = 2^(k-1) + 2^(k-1)
for (int u : adj[v])
if (u != parent)
dfs(u, v, d + 1);
}
public:
LCA(int n, const vector<vector<int>>& adj, int root = 0)
: n(n), adj(adj), LOG(log2(n) + 2), depth(n) {
up.assign(n, vector<int>(LOG, 0));
dfs(root, root, 0);
}
int query(int u, int v) {
if (depth[u] < depth[v]) swap(u, v); // Пусть u всегда глубже
// 1. Поднимаем u до уровня v
int diff = depth[u] - depth[v];
for (int k = 0; k < LOG; k++)
if ((diff >> k) & 1)
u = up[u][k];
if (u == v) return u; // Если v оказался предком u
// 2. Поднимаем обоих, пока предки не совпадут
for (int k = LOG - 1; k >= 0; k--) {
// Прыгаем, только если предки РАЗНЫЕ (ищем уровень прямо под LCA)
if (up[u][k] != up[v][k]) {
u = up[u][k];
v = up[v][k];
}
}
return up[u][0]; // Возвращаем родителя
}
// Расстояние между вершинами
int dist(int u, int v) {
return depth[u] + depth[v] - 2 * depth[query(u, v)];
}
};
int main() {
// 0
// / \
// 1 2
// / \ \
// 3 4 5
// /
// 6
int n = 7;
vector<vector<int>> adj(n);
auto addEdge = [&](int u, int v) { adj[u].push_back(v); adj[v].push_back(u); };
addEdge(0, 1); addEdge(0, 2);
addEdge(1, 3); addEdge(1, 4);
addEdge(2, 5); addEdge(3, 6);
LCA lca(n, adj);
cout << "LCA(3, 4) = " << lca.query(3, 4) << endl; // 1
cout << "LCA(6, 5) = " << lca.query(6, 5) << endl; // 0
cout << "LCA(6, 4) = " << lca.query(6, 4) << endl; // 1
cout << "LCA(3, 3) = " << lca.query(3, 3) << endl; // 3
// Расстояние в дереве!
cout << "Dist(6, 5) = " << lca.dist(6, 5) << endl; // 4
cout << "Dist(3, 4) = " << lca.dist(3, 4) << endl; // 2
return 0;
}
⚠️ Подводные камни
В цикле "подъема обоих" мы ищем не сам LCA, а вершину, находящуюся строго под ним. Поэтому условие проверки — up[u][k] != up[v][k]. Если они равны, это может быть как сам LCA, так и кто-то выше него, поэтому мы туда не прыгаем. Также цикл обязательно должен идти от `LOG-1` вниз к `0`, от старших битов к младшим.
💼 Где спрашивают
Решение сложных задач на пути в графах и деревьях. Классика: "Найти кратчайшее расстояние между двумя узлами в дереве". Формула `dist(u,v) = depth[u] + depth[v] - 2*depth[LCA(u,v)]` используется повсеместно.
🔗 Связь с другими
Метод Двоичного подъема (Binary Lifting) может применяться не только для LCA, но и для нахождения K-го предка узла, а также минимума/максимума на пути между двумя узлами. Альтернативный метод поиска LCA: Эйлеров обход дерева + Sparse Table (запрос O(1), предрасчет O(N)).
❓ Проверь себя
Глава 5. Графы
📌 Графы — ключевая тема на собеседованиях
Графы моделируют связи: социальные сети, карты дорог, зависимости задач, сети компьютеров. Почти любая задача с «связями» между объектами — задача на граф. Обязательно знать BFS, DFS, Dijkstra, топологическую сортировку.
| Алгоритм | Задача | Сложность | Ограничения |
|---|---|---|---|
| BFS | Кратчайший путь (невзвешенный) | O(V+E) | — |
| DFS | Обход, компоненты, циклы | O(V+E) | — |
| Dijkstra | Кратчайший путь (взвешенный) | O((V+E) log V) | Нет отриц. рёбер |
| Bellman-Ford | Кратчайший путь | O(V·E) | Обнаруживает отриц. циклы |
| Floyd-Warshall | Все пары кратчайших путей | O(V³) | Любые веса |
| Kruskal | MST | O(E log E) | — |
| Prim | MST | O((V+E) log V) | — |
| Topological Sort | Порядок зависимостей | O(V+E) | DAG |
| Tarjan / Kosaraju | SCC | O(V+E) | Ориентированный |
| Bridges | Мосты / точки сочленения | O(V+E) | Неориентированный |
📐 Представление графа
🎯 Интуиция — аналогия из жизни
Список смежности — это телефонная книга: у каждого человека записаны только те люди, которых он знает лично. Матрица смежности — это огромная таблица Excel, где по горизонтали и вертикали выписаны все люди планеты, и на пересечении ставится «галочка» или «крестик». Список ребер — это просто список всех дорог с указанием начала и конца.
📝 Пошаговое текстовое описание
vector<vector<int>>). Для каждой вершины u храним список её соседей v. Если есть вес w, храним пары pair<v, w>.V × V. Если есть ребро из u в v, ставим matrix[u][v] = 1 (или вес ребра). Иначе 0 (или INF).struct Edge { int u, v, w; } и просто добавляем все рёбра подряд.📐 Почему такая сложность?
Матрица требует O(V²) памяти, так как мы храним ячейку для любой пары вершин, даже если связи нет. Список смежности хранит только реально существующие ребра, поэтому занимает O(V + E), что критически важно для разреженных графов (где рёбер мало).
🔎 Подробная трассировка добавления ребра (u=1, v=2, w=5)
Неориентированный:
Список: adj[1].push(2); adj[2].push(1);
Матрица: mat[1][2] = 5; mat[2][1] = 5;
Ориентированный (1 → 2):
Список: adj[1].push(2);
Матрица: mat[1][2] = 5;
#include <iostream>
#include <vector>
using namespace std;
int main() {
int V = 5, E = 6;
// Граф: 0—1, 0—4, 1—2, 1—3, 1—4, 2—3
// ═══ 1. Список смежности (самый частый) ═══
vector<vector<int>> adj(V);
auto addEdge = [&](int u, int v) {
adj[u].push_back(v);
adj[v].push_back(u); // для неориентированного
};
addEdge(0,1); addEdge(0,4); addEdge(1,2);
addEdge(1,3); addEdge(1,4); addEdge(2,3);
cout << "Adjacency List:" << endl;
for (int i = 0; i < V; i++) {
cout << i << ": ";
for (int v : adj[i]) cout << v << " ";
cout << endl;
}
// ═══ 2. Список смежности (взвешенный) ═══
vector<vector<pair<int,int>>> wadj(V); // {сосед, вес}
wadj[0].push_back({1, 10});
wadj[0].push_back({4, 5});
wadj[1].push_back({2, 3});
// ═══ 3. Матрица смежности ═══
vector<vector<int>> matrix(V, vector<int>(V, 0));
matrix[0][1] = matrix[1][0] = 1;
matrix[0][4] = matrix[4][0] = 1;
matrix[1][2] = matrix[2][1] = 1;
// Для взвешенного: matrix[u][v] = weight
// ═══ 4. Список рёбер ═══
struct Edge { int u, v, w; };
vector<Edge> edges = {{0,1,10},{0,4,5},{1,2,3},{1,3,7},{1,4,2},{2,3,1}};
return 0;
}
⚠️ Подводные камни
Использование матрицы смежности для разреженного графа с 100 000 вершинами приведёт к крашу по памяти (Memory Limit Exceeded). 100k × 100k = 10 миллиардов ячеек int — это около 40 ГБ оперативной памяти. Всегда используйте списки смежности, если V > 1000.
💼 Где спрашивают
Абсолютно любая графовая задача на собеседованиях начинается с этапа "парсинг ввода" и "построение графа".
🔗 Связь с другими
Список смежности — лучший выбор для BFS, DFS, Dijkstra. Матрица нужна для алгоритма Флойда-Уоршелла. Список рёбер идеален для Краскала и Беллмана-Форда.
❓ Проверь себя
🔗 Задачи
🌊 BFS — Обход в ширину (Breadth-First Search)
🎯 Интуиция — аналогия из жизни
Представьте круги на воде от брошенного камня или лесной пожар. Огонь сначала сжигает все деревья на расстоянии 1 метра, потом перекидывается на те, что в 2 метрах, и так далее. Мы равномерно расширяем границу поиска во все стороны.
📝 Пошаговое текстовое описание
visited.visited и добавляем её в очередь.v.u для v. Если сосед не посещен — красим его как visited, фиксируем расстояние dist[u] = dist[v] + 1 и кладем в очередь.📐 Почему такая сложность?
Каждая вершина добавляется в очередь строго 1 раз (спасибо массиву visited). Каждое ребро просматривается ровно 2 раза (по разу с каждого конца в неориентированном графе). Отсюда строгая линейная сложность O(V + E).
🔎 Подробная трассировка из вершины 0
Граф: 0—1, 0—2, 1—3, 2—4, 3—4, 3—5
Очередь: [0] → visited: {0}
Берём 0, соседи 1,2 → Очередь: [1, 2] → visited: {0,1,2}
Берём 1, сосед 3 → Очередь: [2, 3] → visited: {0,1,2,3}
Берём 2, сосед 4 → Очередь: [3, 4] → visited: {0,1,2,3,4}
Берём 3, сосед 5 (4 уже visited) → Очередь: [4, 5] → visited: {0,1,2,3,4,5}
Берём 4, соседи 2,3 (уже visited) → Очередь: [5]
Берём 5 → пусто.
Порядок: 0, 1, 2, 3, 4, 5
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
// ═══ Базовый BFS ═══
void bfs(const vector<vector<int>>& adj, int start) {
int n = adj.size();
vector<bool> visited(n, false);
queue<int> q;
visited[start] = true;
q.push(start);
while (!q.empty()) {
int v = q.front(); q.pop();
cout << v << " ";
for (int u : adj[v]) {
if (!visited[u]) {
visited[u] = true;
q.push(u);
}
}
}
}
// ═══ BFS с кратчайшим путём ═══
vector<int> bfsShortestPath(const vector<vector<int>>& adj, int start) {
int n = adj.size();
vector<int> dist(n, -1);
vector<int> parent(n, -1);
queue<int> q;
dist[start] = 0;
q.push(start);
while (!q.empty()) {
int v = q.front(); q.pop();
for (int u : adj[v]) {
if (dist[u] == -1) {
dist[u] = dist[v] + 1;
parent[u] = v;
q.push(u);
}
}
}
return dist;
}
// Восстановление пути
vector<int> restorePath(const vector<int>& parent, int target) {
vector<int> path;
for (int v = target; v != -1; v = parent[v])
path.push_back(v);
reverse(path.begin(), path.end());
return path;
}
// ═══ Задача: BFS на сетке (лабиринт) ═══
int bfsGrid(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size();
if (grid[0][0] == 1 || grid[m-1][n-1] == 1) return -1;
int dx[] = {0,0,1,-1};
int dy[] = {1,-1,0,0};
queue<pair<int,int>> q;
q.push({0, 0});
grid[0][0] = 1; // помечаем как посещённый
int steps = 0;
while (!q.empty()) {
int sz = q.size();
for (int i = 0; i < sz; i++) {
auto [x, y] = q.front(); q.pop();
if (x == m-1 && y == n-1) return steps;
for (int d = 0; d < 4; d++) {
int nx = x + dx[d], ny = y + dy[d];
if (nx >= 0 && nx < m && ny >= 0 && ny < n && grid[nx][ny] == 0) {
grid[nx][ny] = 1;
q.push({nx, ny});
}
}
}
steps++;
}
return -1; // недостижимо
}
// ═══ Задача: Проверка двудольности (bipartite) ═══
bool isBipartite(const vector<vector<int>>& adj) {
int n = adj.size();
vector<int> color(n, -1); // -1 = не раскрашен
for (int start = 0; start < n; start++) {
if (color[start] != -1) continue;
queue<int> q;
q.push(start);
color[start] = 0;
while (!q.empty()) {
int v = q.front(); q.pop();
for (int u : adj[v]) {
if (color[u] == -1) {
color[u] = 1 - color[v]; // другой цвет
q.push(u);
} else if (color[u] == color[v]) {
return false; // конфликт — не двудольный
}
}
}
}
return true;
}
int main() {
// Граф: 0—1, 0—2, 1—3, 2—4, 3—4, 3—5
int n = 6;
vector<vector<int>> adj(n);
adj[0] = {1,2}; adj[1] = {0,3}; adj[2] = {0,4};
adj[3] = {1,4,5}; adj[4] = {2,3}; adj[5] = {3};
cout << "BFS: ";
bfs(adj, 0); // 0 1 2 3 4 5
cout << endl;
auto dist = bfsShortestPath(adj, 0);
cout << "Distance 0→5: " << dist[5] << endl; // 3
// Лабиринт
vector<vector<int>> grid = {
{0,0,0},
{1,1,0},
{0,0,0}
};
cout << "Grid shortest path: " << bfsGrid(grid) << endl; // 4
return 0;
}
⚠️ Подводные камни
Частая ошибка — помечать вершину как `visited` во время извлечения из очереди (`q.pop()`), а не во время добавления (`q.push()`). Если сделать так, одна и та же вершина может быть добавлена в очередь несколько раз разными соседями, что приведет к Memory Limit Exceeded.
💼 Где спрашивают
Задачи типа "Shortest Path in Binary Matrix", "Word Ladder", "Rotting Oranges". Это золотой стандарт для поиска кратчайшего пути в невзвешенном графе.
🔗 Связь с другими
Брат-близнец DFS (только использует Очередь вместо Стека). Алгоритм Дейкстры — это модификация BFS, где обычная очередь заменена на Priority Queue.
❓ Проверь себя
🕳️ DFS — Обход в глубину (Depth-First Search)
🎯 Интуиция — аналогия из жизни
Прохождение лабиринта по правилу левой руки с нитью Ариадны. Мы идем в первый попавшийся коридор до самого конца, пока не упремся в тупик. Только оказавшись в тупике, мы "сматываем нить" (возвращаемся на шаг назад) и проверяем другой коридор.
📝 Пошаговое текстовое описание
v.v как visited.u. Если сосед не посещен — вызываем DFS от u.📐 Почему такая сложность?
Как и в BFS, рекурсия заходит в каждую вершину ровно 1 раз. Цикл внутри функции перебирает все рёбра. Суммарная сложность — линейная O(V + E). Память уходит на стек вызовов: в худшем случае графа в виде "бамбука" глубина рекурсии будет O(V).
🔎 Подробная трассировка: DFS(0)
Граф: 0—1, 0—2, 1—3, 2—4, 3—5
Стек вызовов:
dfs(0) → посетили 0, идём в 1
dfs(1) → посетили 1, идём в 3
dfs(3) → посетили 3, идём в 5
dfs(5) → посетили 5. Соседей нет! (backtrack)
назад в 3. Соседей больше нет! (backtrack)
назад в 1. Соседей больше нет! (backtrack)
назад в 0. Сосед 2 свободен! идём в 2.
dfs(2) → посетили 2, идём в 4
dfs(4) → тупик. (backtrack).
Порядок посещения: 0, 1, 3, 5, 2, 4
#include <iostream>
#include <vector>
#include <stack>
using namespace std;
// ═══ Рекурсивный DFS ═══
void dfsRecursive(const vector<vector<int>>& adj, int v, vector<bool>& visited) {
visited[v] = true;
cout << v << " ";
for (int u : adj[v]) {
if (!visited[u])
dfsRecursive(adj, u, visited);
}
}
// ═══ Итеративный DFS ═══
void dfsIterative(const vector<vector<int>>& adj, int start) {
int n = adj.size();
vector<bool> visited(n, false);
stack<int> st;
st.push(start);
while (!st.empty()) {
int v = st.top(); st.pop();
if (visited[v]) continue;
visited[v] = true;
cout << v << " ";
// В обратном порядке чтобы обрабатывать в прямом
for (int i = adj[v].size() - 1; i >= 0; i--)
if (!visited[adj[v][i]])
st.push(adj[v][i]);
}
}
// ═══ Подсчёт компонент связности ═══
int countComponents(const vector<vector<int>>& adj) {
int n = adj.size(), count = 0;
vector<bool> visited(n, false);
for (int i = 0; i < n; i++) {
if (!visited[i]) {
dfsRecursive(adj, i, visited);
count++;
}
}
return count;
}
// ═══ Обнаружение цикла в ориентированном графе ═══
// Состояния: 0=белый, 1=серый (в обработке), 2=чёрный (завершён)
bool hasCycleDFS(const vector<vector<int>>& adj, int v, vector<int>& state) {
state[v] = 1; // серый — начали обрабатывать
for (int u : adj[v]) {
if (state[u] == 1) return true; // встретили серого → цикл!
if (state[u] == 0 && hasCycleDFS(adj, u, state))
return true;
}
state[v] = 2; // чёрный — полностью обработан
return false;
}
bool hasCycleDirected(const vector<vector<int>>& adj) {
int n = adj.size();
vector<int> state(n, 0);
for (int i = 0; i < n; i++)
if (state[i] == 0 && hasCycleDFS(adj, i, state))
return true;
return false;
}
// ═══ Обнаружение цикла в неориентированном графе ═══
bool hasCycleUndirected(const vector<vector<int>>& adj, int v, int parent,
vector<bool>& visited) {
visited[v] = true;
for (int u : adj[v]) {
if (!visited[u]) {
if (hasCycleUndirected(adj, u, v, visited)) return true;
} else if (u != parent) {
return true; // посещённый сосед ≠ родитель → цикл
}
}
return false;
}
// ═══ DFS с временами входа/выхода ═══
int timer_val = 0;
void dfsTimestamps(const vector<vector<int>>& adj, int v,
vector<bool>& visited, vector<int>& tin, vector<int>& tout) {
visited[v] = true;
tin[v] = timer_val++;
for (int u : adj[v])
if (!visited[u])
dfsTimestamps(adj, u, visited, tin, tout);
tout[v] = timer_val++;
}
int main() {
// Неориентированный граф
int n = 6;
vector<vector<int>> adj(n);
adj[0] = {1,2}; adj[1] = {0,3}; adj[2] = {0,4};
adj[3] = {1,5}; adj[4] = {2}; adj[5] = {3};
cout << "DFS recursive: ";
vector<bool> vis(n, false);
dfsRecursive(adj, 0, vis); // 0 1 3 5 2 4
cout << endl;
cout << "DFS iterative: ";
dfsIterative(adj, 0);
cout << endl;
// Ориентированный граф с циклом: 0→1→2→0
vector<vector<int>> dag = {{1},{2},{0}};
cout << "Has cycle (directed): " << hasCycleDirected(dag) << endl; // 1
// DAG (без цикла): 0→1→2, 0→2
vector<vector<int>> dag2 = {{1,2},{2},{}};
cout << "Has cycle (DAG): " << hasCycleDirected(dag2) << endl; // 0
return 0;
}
⚠️ Подводные камни
Рекурсивный DFS подвержен Stack Overflow, если цепочка графа слишком длинная (например, бамбук из 100,000 вершин). В соревновательном программировании или на платформах с малым лимитом стека памяти лучше использовать итеративную версию с std::stack.
💼 Где спрашивают
Поиск количества островов (Number of Islands), Топологическая сортировка, Поиск циклов в графе зависимостей, Поиск мостов/точек сочленения.
🔗 Связь с другими
Фундамент для алгоритмов Тарьяна (SCC), Косарайю, Топологической сортировки. Является графовым аналогом Backtracking'а.
Забыли отметить visited
❌ Бесконечный цикл
void dfs(int v) {
// 💀 Без visited!
cout << v;
for (int u : adj[v])
dfs(u); // зацикливается!
}✅ С отметкой visited
void dfs(int v) {
visited[v] = true; // ✅
cout << v;
for (int u : adj[v])
if (!visited[u]) // ✅
dfs(u);
}❓ Проверь себя
🗺️ Алгоритм Дейкстры (Dijkstra)
🎯 Интуиция — аналогия из жизни
Ищем кратчайший маршрут в навигаторе (GPS). Навигатор рисует круги вокруг вашего текущего положения, постепенно увеличивая радиус. Он всегда выбирает ближайший город, в котором еще не был, и обновляет информацию о том, как быстрее добраться до его соседей.
📝 Пошаговое текстовое описание
dist[], где все элементы равны INF (бесконечность), а dist[start] = 0.{расстояние=0, вершина=start}.v с минимальным расстоянием d. Если d > dist[v], значит мы уже нашли более короткий путь ранее, пропускаем (skip).u. Если текущий путь до u (т.е. dist[v] + weight_to_u) короче, чем записанный dist[u], обновляем dist[u] и кладём {dist[u], u} в очередь.📐 Почему такая сложность?
Извлечение минимума из кучи занимает O(log V). В худшем случае мы положим в кучу E элементов. Суммарно: E добавлений и V извлечений → O((V + E) log V). Это очень быстро даже для миллиона вершин.
🔎 Подробная трассировка: старт в 0
Граф: 0→1(4), 0→2(2), 1→3(5), 2→1(1), 2→3(7)
| Шаг | Heap: Извлекли | dist[] | Обновления соседей (Релаксация) |
|---|---|---|---|
| init | — | [0, ∞, ∞, ∞] | push(0, 0) |
| 1 | {0, v=0} | [0, 4, 2, ∞] | 0→1(4) 0→2(2) |
| 2 | {2, v=2} | [0, 3, 2, 9] | 2→1(2+1=3 < 4) ✓ 2→3(2+7=9 < ∞) ✓ |
| 3 | {3, v=1} | [0, 3, 2, 8] | 1→3(3+5=8 < 9) ✓ |
| 4 | {4, v=1} | — | Skip (4 > dist[1]=3) устаревшая запись |
| 5 | {8, v=3} | [0, 3, 2, 8] | Соседей нет |
Итог: кратчайший путь 0→3 равен 8 (маршрут 0→2→1→3).
#include <iostream>
#include <vector>
#include <queue>
#include <climits>
using namespace std;
typedef pair<int,int> pii; // {вес, вершина}
struct DijkstraResult {
vector<long long> dist;
vector<int> parent;
};
DijkstraResult dijkstra(const vector<vector<pii>>& adj, int start) {
int n = adj.size();
vector<long long> dist(n, LLONG_MAX);
vector<int> parent(n, -1);
// min-heap: {расстояние, вершина}
priority_queue<pair<long long,int>, vector<pair<long long,int>>, greater<>> pq;
dist[start] = 0;
pq.push({0, start});
while (!pq.empty()) {
auto [d, v] = pq.top(); pq.pop();
if (d > dist[v]) continue; // устаревшая запись — пропускаем
for (auto [u, w] : adj[v]) {
if (dist[v] + w < dist[u]) { // релаксация
dist[u] = dist[v] + w;
parent[u] = v;
pq.push({dist[u], u});
}
}
}
return {dist, parent};
}
// Восстановление пути
vector<int> restorePath(const vector<int>& parent, int target) {
vector<int> path;
for (int v = target; v != -1; v = parent[v])
path.push_back(v);
reverse(path.begin(), path.end());
return path;
}
int main() {
// Граф: 0→1(4), 0→2(2), 1→3(5), 1→4(7), 2→1(1), 2→3(7), 3→4(1)
int n = 5;
vector<vector<pii>> adj(n);
adj[0] = {{1,4},{2,2}};
adj[1] = {{3,5},{4,7}};
adj[2] = {{1,1},{3,7}};
adj[3] = {{4,1}};
auto [dist, parent] = dijkstra(adj, 0);
cout << "Distances from 0:" << endl;
for (int i = 0; i < n; i++)
cout << " 0 → " << i << " = " << dist[i] << endl;
// 0→0=0, 0→1=3, 0→2=2, 0→3=8, 0→4=9
auto path = restorePath(parent, 4);
cout << "Path 0→4: ";
for (int v : path) cout << v << " "; // 0 2 1 3 4
cout << endl;
return 0;
}
⚠️ Подводные камни
Отрицательные веса ребер! Алгоритм "фиксирует" финальное расстояние, как только извлекает вершину из очереди. Если где-то есть ребро весом -10, оно может сделать "зафиксированное" расстояние короче, но Дейкстра туда уже не вернется. Итог — неверный ответ. Для отрицательных ребер используйте Bellman-Ford.
💼 Где спрашивают
FAANG. Часто задача замаскирована под "Network Delay Time", "Cheapest Flights Within K Stops", или поиск пути в 2D-матрице, где каждая ячейка имеет стоимость прохода.
🔗 Связь с другими
Это BFS, но вместо Queue используется Priority Queue. Если добавить эвристику (направление к цели), Дейкстра превращается в алгоритм A*.
❓ Проверь себя
📉 Алгоритм Беллмана-Форда
🎯 Интуиция — аналогия из жизни
Эффект сломанного телефона или распространение слухов. За один шаг слух (информация об улучшении маршрута) продвигается максимум на один город. Самый длинный возможный маршрут без циклов проходит через V-1 городов. Следовательно, за V-1 шагов слух гарантированно долетит до любой точки.
📝 Пошаговое текстовое описание
0, до остальных INF.V-1. На каждой итерации просто перебираем абсолютно все рёбра графа.dist[u] + w < dist[v], обновляем dist[v].📐 Почему такая сложность?
У нас внешний цикл на V итераций, а внутри мы проходим по всем E ребрам. Получается вложенность: O(V · E). Медленнее Дейкстры, но безопаснее.
🔎 Подробная трассировка: выявление отрицательного цикла
Граф: 0→1(5), 1→2(-3), 2→0(-4). Старт в 0.
Шаг 0: [0, INF, INF]
Итерация 1 (V=3):
ребро 0→1: dist[1] = 0+5 = 5. dist = [0, 5, INF]
ребро 1→2: dist[2] = 5+(-3) = 2. dist = [0, 5, 2]
ребро 2→0: dist[0] = 2+(-4) = -2. dist = [-2, 5, 2]
Итерация 2:
ребро 0→1: dist[1] = -2+5 = 3. Идет улучшение!
Итерация 3 (V-й проход):
Продолжает улучшаться -> Отрицательный цикл найден! ✅
#include <iostream>
#include <vector>
#include <climits>
using namespace std;
struct Edge { int u, v, w; };
// Возвращает dist[] или пустой вектор если есть отрицательный цикл
vector<long long> bellmanFord(int n, const vector<Edge>& edges, int start) {
vector<long long> dist(n, LLONG_MAX);
dist[start] = 0;
// V-1 итераций
for (int i = 0; i < n - 1; i++) {
bool updated = false;
for (auto& [u, v, w] : edges) {
if (dist[u] != LLONG_MAX && dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
updated = true;
}
}
if (!updated) break; // раннее завершение (оптимизация)
}
// Проверка отрицательного цикла (V-я итерация)
for (auto& [u, v, w] : edges) {
if (dist[u] != LLONG_MAX && dist[u] + w < dist[v]) {
cout << "NEGATIVE CYCLE DETECTED!" << endl;
return {}; // есть отрицательный цикл
}
}
return dist;
}
int main() {
int n = 5;
vector<Edge> edges = {
{0,1,6}, {0,2,7}, {1,2,8}, {1,3,5},
{1,4,-4}, {2,3,-3}, {2,4,9}, {3,1,-2}, {4,0,2}, {4,3,7}
};
auto dist = bellmanFord(n, edges, 0);
if (!dist.empty()) {
for (int i = 0; i < n; i++)
cout << "0 → " << i << " = " << dist[i] << endl;
// 0→0=0, 0→1=2, 0→2=7, 0→3=4, 0→4=-2
}
return 0;
}
⚠️ Подводные камни
Условие dist[u] != LLONG_MAX критически важно! Если не сделать эту проверку, то `LLONG_MAX + (-5)` приведет к отрицательному переполнению (Underflow), или алгоритм "прорелаксирует" путь от недостижимой вершины до другой недостижимой.
💼 Где спрашивают
Классика для систем алготрейдинга (поиск арбитража между валютными парами, где логарифмы курсов валют образуют граф с отрицательным циклом).
🔗 Связь с другими
Уступает Дейкстре в скорости, но превосходит в надежности. Алгоритм SPFA (Shortest Path Faster Algorithm) — это Беллман-Форд с очередью, где мы смотрим только на те ребра, которые изменились на прошлом шаге.
❓ Проверь себя
🔄 Алгоритм Флойда-Уоршелла
🎯 Интуиция — аналогия из жизни
Поиск авиабилетов с пересадками. Вы смотрите: прямой рейс Москва-Токио стоит $1000. А если лететь Москва -> Дубай -> Токио? Если составной маршрут дешевле ($800), вы вычеркиваете прямой путь из блокнота и записываете маршрут с пересадкой. Алгоритм проверяет абсолютно все возможные "города пересадки".
📝 Пошаговое текстовое описание
dist[i][j], где записаны веса прямых рёбер. Диагональ dist[i][i] = 0. Отсутствующие рёбра = INF.I в J, если зайти по пути в K? То есть: dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]).📐 Почему такая сложность?
Три вложенных цикла (K, I, J), каждый от 1 до V. Итого: O(V³). Внутри цикла простейшая операция O(1). Огромный плюс в том, что константа очень мала, поэтому для графов до 400 вершин алгоритм отрабатывает мгновенно.
🔎 Подробная трассировка (i=0, j=2, k=1)
Матрица (0-прямые дороги):
0→2: INF (нет дороги)
0→1: 3
1→2: 2
Шаг k=1 (проверяем возможность пересадки через вершину 1):
dist[0][2] = min(INF, dist[0][1] + dist[1][2])
dist[0][2] = min(INF, 3 + 2) = 5
Мы нашли путь 0→2 стоимостью 5! ✅
#include <iostream>
#include <vector>
using namespace std;
const long long INF = 1e18;
void floydWarshall(vector<vector<long long>>& dist) {
int n = dist.size();
// Ключевая тройка циклов — k ПЕРВЫЙ (промежуточные вершины)
for (int k = 0; k < n; k++)
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
if (dist[i][k] < INF && dist[k][j] < INF)
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
}
// Проверка отрицательного цикла: dist[i][i] < 0
bool hasNegativeCycle(const vector<vector<long long>>& dist) {
for (int i = 0; i < (int)dist.size(); i++)
if (dist[i][i] < 0) return true;
return false;
}
int main() {
int n = 4;
vector<vector<long long>> dist(n, vector<long long>(n, INF));
for (int i = 0; i < n; i++) dist[i][i] = 0;
// Рёбра: 0→1(3), 0→3(7), 1→0(8), 1→2(2), 2→0(5), 2→3(1), 3→0(2)
dist[0][1]=3; dist[0][3]=7; dist[1][0]=8; dist[1][2]=2;
dist[2][0]=5; dist[2][3]=1; dist[3][0]=2;
floydWarshall(dist);
cout << "All-pairs shortest paths:" << endl;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (dist[i][j] >= INF) cout << "INF ";
else cout << dist[i][j] << " ";
}
cout << endl;
}
return 0;
}
⚠️ Подводные камни
Порядок циклов критичен! Внешний цикл всегда должен быть по `k` (промежуточной вершине). Если поставить `k` вовнутрь, алгоритм не учтет пути, требующие нескольких пересадок. Также осторожно с переполнением INF + INF (используйте проверку на < INF).
💼 Где спрашивают
В задачах "Find the City With the Smallest Number of Neighbors at a Threshold Distance", транзитивное замыкание (доступность графа).
🔗 Связь с другими
Альтернатива — запустить Дейкстру V раз (по разу из каждой вершины). Это займет O(V * (V+E) log V), что на разреженных графах быстрее Флойда, но писать дольше.
Неправильный порядок циклов
❌ k внутри — НЕКОРРЕКТНО
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
for (int k = 0; k < n; k++) // 💀
d[i][j] = min(d[i][j], d[i][k]+d[k][j]);✅ k снаружи — КОРРЕКТНО
for (int k = 0; k < n; k++) // ✅ k ПЕРВЫЙ
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
d[i][j] = min(d[i][j], d[i][k]+d[k][j]);❓ Проверь себя
🌉 Алгоритм Краскала (MST)
🎯 Интуиция — аналогия из жизни
Государству нужно соединить асфальтированными дорогами 10 городов так, чтобы из любого можно было проехать в любой, потратив минимум денег. Жадный подрядчик сортирует все возможные дороги по смете. Сначала он строит самую дешевую. Затем следующую. Если дорога ведет в город, куда уже можно доехать по построенным трассам (кольцо), он ее выбрасывает. Строит до тех пор, пока все города не свяжутся.
📝 Пошаговое текстовое описание
find(). Если концы в разных компонентах, добавляем ребро в ответ и объединяем их через union().📐 Почему такая сложность?
Сортировка рёбер занимает O(E log E). Проход по ребрам и вызов операций DSU занимает практически O(1) на ребро. Главное «бутылочное горлышко» — это сортировка, поэтому итоговая сложность определяется ей.
🔎 Подробная трассировка:
Рёбра отсортированы: (1-2, вес 1), (2-3, вес 2), (0-3, вес 3), (0-1, вес 4), (1-3, вес 5)
(1-2, 1): разные компоненты ({1}, {2}) → ✅ добавляем. DSU: {1,2}
(2-3, 2): разные компоненты ({1,2}, {3}) → ✅ добавляем. DSU: {1,2,3}
(0-3, 3): разные ({0}, {1,2,3}) → ✅ добавляем. DSU: {0,1,2,3}
(0-1, 4): компоненты совпадают ({0,1,2,3} и {0,1,2,3}) — это образует цикл! ❌ пропускаем.
(1-3, 5): пропускаем.
MST вес = 1+2+3 = 6
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
class DSU {
vector<int> p, rank_;
public:
DSU(int n) : p(n), rank_(n, 0) {
for(int i=0; i<n; i++) p[i] = i;
}
int find(int x) { return p[x] == x ? x : p[x] = find(p[x]); }
bool unite(int a, int b) {
a = find(a); b = find(b);
if (a == b) return false;
if (rank_[a] < rank_[b]) swap(a, b);
p[b] = a;
if (rank_[a] == rank_[b]) rank_[a]++;
return true;
}
};
struct Edge {
int u, v, w;
bool operator<(const Edge& o) const { return w < o.w; }
};
pair<long long, vector<Edge>> kruskal(int n, vector<Edge>& edges) {
sort(edges.begin(), edges.end()); // сортируем по весу
DSU dsu(n);
long long totalWeight = 0;
vector<Edge> mstEdges;
for (auto& e : edges) {
if (dsu.unite(e.u, e.v)) { // разные компоненты?
totalWeight += e.w;
mstEdges.push_back(e);
if ((int)mstEdges.size() == n - 1) break; // MST готово
}
}
return {totalWeight, mstEdges};
}
int main() {
int n = 4;
vector<Edge> edges = {{0,1,4},{0,3,3},{1,2,1},{1,3,5},{2,3,2}};
auto [weight, mst] = kruskal(n, edges);
cout << "MST weight: " << weight << endl; // 6
cout << "MST edges:" << endl;
for (auto& e : mst)
cout << " " << e.u << " — " << e.v << " (" << e.w << ")" << endl;
return 0;
}
⚠️ Подводные камни
Решение опирается на DSU. Если вы забудете сжатие путей (p[x] = find(p[x])) внутри метода find, то проверки на цикл начнут работать за O(V) вместо O(1), и алгоритм получит TLE.
💼 Где спрашивают
Сетевая маршрутизация, объединение кабелей/трубопроводов, задачи вида "Min Cost to Connect All Points" на LeetCode.
🔗 Связь с другими
Жадный алгоритм + DSU. Является прямым конкурентом алгоритма Прима. Краскал лучше работает на разреженных графах (где мало рёбер), так как сортировать маленький массив быстрее.
❓ Проверь себя
🌲 Алгоритм Прима (MST)
🎯 Интуиция — аналогия из жизни
Распространение плесени на хлебе. Она начинает расти из одной случайной точки. На каждом шаге она выбирает ближайший (самый легкий) нетронутый кусочек хлеба вокруг себя и поглощает его, присоединяя к своей колонии.
📝 Пошаговое текстовое описание
{вес, вершина_v}.v уже в MST — пропускаем. Если нет — помечаем как часть MST, добавляем вес к общей сумме.v в те вершины u, которые еще не в MST.📐 Почему такая сложность?
Каждая вершина извлекается из кучи 1 раз. Каждое ребро может быть добавлено в кучу. Очередь с приоритетами дает логарифм на каждую операцию. Итого O((V+E) log V), что архитектурно идентично алгоритму Дейкстры.
🔎 Подробная трассировка: старт в 0
Граф: 0—1(4), 0—3(3), 1—2(1), 1—3(5), 2—3(2)
Шаг 1: Старт 0. Куча: {4, v=1}, {3, v=3}. MST={0}.
Шаг 2: Берем мин: {3, v=3}. Добавляем 3 в MST. MST={0,3}. Соседи 3: 1, 2. В кучу: {5, v=1}, {2, v=2}.
Шаг 3: Берем мин: {2, v=2}. Добавляем 2 в MST. MST={0,3,2}. Соседи 2: 1. В кучу: {1, v=1}.
Шаг 4: Берем мин: {1, v=1}. Добавляем 1 в MST. MST={0,3,2,1}.
Все 4 вершины в дереве. Итоговый вес: 0 + 3 + 2 + 1 = 6 ✅
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
typedef pair<int,int> pii;
long long prim(const vector<vector<pii>>& adj) {
int n = adj.size();
vector<bool> inMST(n, false);
// min-heap: {вес_ребра, вершина}
priority_queue<pii, vector<pii>, greater<pii>> pq;
pq.push({0, 0}); // начинаем с вершины 0
long long totalWeight = 0;
int edgesAdded = 0;
while (!pq.empty() && edgesAdded < n) {
auto [w, v] = pq.top(); pq.pop();
if (inMST[v]) continue; // уже в MST
inMST[v] = true;
totalWeight += w;
edgesAdded++;
for (auto [u, weight] : adj[v]) {
if (!inMST[u])
pq.push({weight, u});
}
}
return totalWeight;
}
int main() {
int n = 4;
vector<vector<pii>> adj(n);
auto add = [&](int u, int v, int w) {
adj[u].push_back({v,w}); adj[v].push_back({u,w});
};
add(0,1,4); add(0,3,3); add(1,2,1); add(1,3,5); add(2,3,2);
cout << "MST weight (Prim): " << prim(adj) << endl; // 6
return 0;
}
⚠️ Подводные камни
Очень легко перепутать с Дейкстрой. Главное отличие: в очередь Прима вы кладете вес текущего ребра, а в Дейкстре — суммарное расстояние от старта.
💼 Где спрашивают
Редко на алгоритмических секциях, но часто на понимание различий в Computer Science.
🔗 Связь с другими
Прим лучше Краскала работает на плотных графах (где много рёбер), так как не тратит время на начальную сортировку огромного количества рёбер.
Забыли проверку inMST
❌ Бесконечный цикл
while (!pq.empty()) {
auto [w, v] = pq.top(); pq.pop();
// 💀 Без проверки inMST!
totalWeight += w;
for (auto [u, wt] : adj[v])
pq.push({wt, u}); // добавляем уже в MST!
}✅ Проверяем inMST
while (!pq.empty()) {
auto [w, v] = pq.top(); pq.pop();
if (inMST[v]) continue; // ✅ пропускаем!
inMST[v] = true;
totalWeight += w;
for (auto [u, wt] : adj[v])
if (!inMST[u]) pq.push({wt, u}); // ✅
}Путаница Prim vs Dijkstra
❌ Prim: хранить dist от старта
// 💀 Prim ≠ Dijkstra!
// В Dijkstra: dist[u] = dist[v] + w
// В Prim: это НЕПРАВИЛЬНО!
if (dist[v] + w < dist[u]) // 💀 НЕТ!
dist[u] = dist[v] + w;✅ Prim: хранить ВЕС РЕБРА
// ✅ Prim: в очередь кладём ВЕС РЕБРА
// а не расстояние от старта!
pq.push({weight, u}); // ✅ вес ребра v→u
// Берём минимальное ребро к вершине вне MST❓ Проверь себя
📋 Топологическая сортировка
🎯 Интуиция — аналогия из жизни
Процесс одевания: вы не можете надеть ботинки, пока не надели носки; вы не можете надеть куртку до рубашки. Топологическая сортировка выстраивает все элементы в линию так, чтобы каждое действие выполнялось строго после своих предпосылок.
📝 Пошаговое текстовое описание (Алгоритм Кана)
in-degree) для каждой вершины.in-degree == 0 (нет зависимостей) кидаем в Очередь.in-degree на 1.in-degree стал 0 — кидаем его в очередь.📐 Почему такая сложность?
Мы проходим по каждой вершине 1 раз и "обрезаем" каждое ребро 1 раз. Линейная сложность O(V + E). Этот алгоритм также бесплатно выявляет циклы (если в конце размер ответа меньше V).
🔎 Подробная трассировка: зависимости курсов
Граф: Матан(0) → Линалг(1), Алгоритмы(2) → ML(3), Линалг(1) → ML(3).
In-degrees: [0:0, 1:1, 2:0, 3:2]
Очередь: [0, 2] (они независимы).
Извлекли 0 (Матан). Сосед 1: in-degree(1) становится 0 → в Очередь [2, 1].
Извлекли 2 (Алгоритмы). Сосед 3: in-degree(3) становится 1. Очередь [1].
Извлекли 1 (Линалг). Сосед 3: in-degree(3) становится 0 → в Очередь [3].
Извлекли 3 (ML). Конец.
Порядок: Матан, Алгоритмы, Линалг, ML ✅
#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;
// ═══ Метод 1: Kahn (BFS по входящим степеням) ═══
vector<int> topologicalSortKahn(int n, const vector<vector<int>>& adj) {
vector<int> inDegree(n, 0);
for (int v = 0; v < n; v++)
for (int u : adj[v])
inDegree[u]++;
queue<int> q;
for (int i = 0; i < n; i++)
if (inDegree[i] == 0)
q.push(i); // вершины без зависимостей
vector<int> order;
while (!q.empty()) {
int v = q.front(); q.pop();
order.push_back(v);
for (int u : adj[v]) {
inDegree[u]--;
if (inDegree[u] == 0)
q.push(u);
}
}
// Если order.size() < n → есть цикл!
if ((int)order.size() != n) return {}; // не DAG
return order;
}
// ═══ Метод 2: DFS ═══
void topoSortDFS(int v, const vector<vector<int>>& adj,
vector<bool>& visited, vector<int>& order) {
visited[v] = true;
for (int u : adj[v])
if (!visited[u])
topoSortDFS(u, adj, visited, order);
order.push_back(v); // добавляем ПОСЛЕ обработки всех потомков (postorder)
}
vector<int> topologicalSortDFS(int n, const vector<vector<int>>& adj) {
vector<bool> visited(n, false);
vector<int> order;
for (int i = 0; i < n; i++)
if (!visited[i])
topoSortDFS(i, adj, visited, order);
reverse(order.begin(), order.end()); // переворачиваем результат
return order;
}
int main() {
// 5→0, 5→2, 4→0, 4→1, 2→3, 3→1
int n = 6;
vector<vector<int>> adj(n);
adj[5] = {0, 2}; adj[4] = {0, 1}; adj[2] = {3}; adj[3] = {1};
auto order1 = topologicalSortDFS(n, adj);
cout << "Topo (DFS): ";
for (int v : order1) cout << v << " "; // 5 4 2 3 1 0
cout << endl;
auto order2 = topologicalSortKahn(n, adj);
cout << "Topo (Kahn): ";
for (int v : order2) cout << v << " "; // 4 5 0 2 3 1 (другой порядок, тоже валидный)
cout << endl;
return 0;
}
⚠️ Подводные камни
Граф строго обязан быть DAG (Directed Acyclic Graph). В графе с циклом (Курица -> Яйцо -> Курица) не существует топологической сортировки. Метод Кана элегантно возвращает неполный массив, если есть цикл, что служит отличной проверкой корректности данных.
💼 Где спрашивают
Классика LeetCode: "Course Schedule I / II", "Alien Dictionary". Архитектура: как работают пакетные менеджеры (npm, maven) при разрешении зависимостей.
🔗 Связь с другими
Основан на DFS (postorder обход) или BFS (подсчет in-degrees). Является фундаментом для расчета Динамического Программирования на графах (сначала топологически сортируем, затем вычисляем DP).
Топо-сортировка на графе с циклом
❌ Не проверяем цикл
// 💀 Kahn: если цикл — тихо вернёт
// неполный результат!
auto order = kahnSort(n, adj);
// order.size() < n, но мы не проверяем!✅ Проверяем размер результата
auto order = kahnSort(n, adj);
if ((int)order.size() != n) {
cout << "CYCLE DETECTED!"; // ✅
return {};
}DFS topo: забыли reverse
❌ Порядок перевёрнут
void dfs(int v) {
visited[v] = true;
for (int u : adj[v]) if (!visited[u]) dfs(u);
order.push_back(v);
}
// 💀 order в ОБРАТНОМ порядке!
// Нужно reverse!✅ reverse в конце
// После DFS:
reverse(order.begin(), order.end()); // ✅
// Или сразу используйте стек❓ Проверь себя
🔗 Компоненты сильной связности (SCC — Tarjan / Kosaraju)
🎯 Интуиция — аналогия из жизни
Представьте улицы с односторонним движением в городе. SCC (компонента сильной связности) — это такой район, где, стартовав из любой точки, вы сможете доехать по правилам до любой другой точки в этом же районе. Выезд из района в соседний возможен, но вернуться назад будет нельзя (иначе они бы слились в один огромный район).
📝 Пошаговое текстовое описание (Алгоритм Косарайю)
📐 Почему такая сложность?
Мы делаем два линейных прохода DFS: один по исходному графу, другой по перевернутому. Копирование графа занимает O(V+E). Суммарная сложность — чистые O(V + E). Тарьян делает то же самое за один проход, но алгоритм Косарайю гораздо проще для понимания и написания.
🔎 Подробная трассировка: Kosaraju
Граф: 0→1, 1→2, 2→0, 2→3.
DFS 1 (определяем время выхода):
dfs(0) → dfs(1) → dfs(2) → dfs(3). Из 3 некуда идти, пушим 3 в стек.
Возврат в 2, пушим 2. Возврат в 1, пушим 1. Возврат в 0, пушим 0.
Стек: [3, 2, 1, 0] (0 — на вершине).
Транспонируем граф: 1→0, 2→1, 0→2, 3→2.
DFS 2 (по стеку):
Берем 0. dfs(0) → идет в 2 → идет в 1. Из 1 в 0 (но он посещен). Цикл замкнулся! SCC 1 = {0, 2, 1}.
Берем 1 (посещен), берем 2 (посещен).
Берем 3. dfs(3) → идет в 2 (посещен). SCC 2 = {3}. ✅
#include <iostream>
#include <vector>
#include <stack>
using namespace std;
class Kosaraju {
int n;
vector<vector<int>> adj, radj; // граф и транспонированный
vector<bool> visited;
stack<int> order;
void dfs1(int v) {
visited[v] = true;
for (int u : adj[v])
if (!visited[u]) dfs1(u);
order.push(v); // запоминаем порядок завершения
}
void dfs2(int v, vector<int>& component) {
visited[v] = true;
component.push_back(v);
for (int u : radj[v])
if (!visited[u]) dfs2(u, component);
}
public:
Kosaraju(int n) : n(n), adj(n), radj(n) {}
void addEdge(int u, int v) {
adj[u].push_back(v);
radj[v].push_back(u); // транспонированный
}
vector<vector<int>> findSCCs() {
// Шаг 1: DFS по прямому графу, запоминаем порядок
visited.assign(n, false);
for (int i = 0; i < n; i++)
if (!visited[i]) dfs1(i);
// Шаг 2: DFS по транспонированному в обратном порядке
visited.assign(n, false);
vector<vector<int>> sccs;
while (!order.empty()) {
int v = order.top(); order.pop();
if (!visited[v]) {
vector<int> component;
dfs2(v, component);
sccs.push_back(component);
}
}
return sccs;
}
};
int main() {
Kosaraju k(8);
k.addEdge(0,1); k.addEdge(1,2); k.addEdge(2,0); // SCC: {0,1,2}
k.addEdge(2,3); k.addEdge(3,4); k.addEdge(4,3); // SCC: {3,4}
k.addEdge(4,5); k.addEdge(5,6); k.addEdge(6,5); // SCC: {5,6}
k.addEdge(6,7); // SCC: {7}
auto sccs = k.findSCCs();
cout << "SCCs (" << sccs.size() << " components):" << endl;
for (auto& scc : sccs) {
cout << " {";
for (int v : scc) cout << v << " ";
cout << "}" << endl;
}
return 0;
}
⚠️ Подводные камни
Не забывайте, что алгоритмы SCC работают только с ориентированными графами. Если скормить Косарайю неориентированный граф, он найдет обычные компоненты связности (каждое неориентированное ребро воспринимается как двунаправленное, образуя мини-циклы).
💼 Где спрашивают
В олимпиадах (построение "конденсации графа" — когда каждая SCC сжимается в одну супервершину). Используется компиляторами при анализе зависимостей модулей (есть ли круговая зависимость).
🔗 Связь с другими
Алгоритм Тарьяна решает ту же задачу за 1 проход DFS, используя времена входа. Косарайю чуть медленнее по константе, но использует элегантное свойство Топологической сортировки (DFS1 генерирует обратный топологический порядок для DAG компонент).
❓ Проверь себя
🌉 Мосты и точки сочленения
🎯 Интуиция — аналогия из жизни
Поиск "критической инфраструктуры". Представьте дорожную сеть между островами. Мост (Bridge) — это дорога, взрыв которой разобьет архипелаг на две изолированные части. Точка сочленения (Articulation Point) — это перекресток (город), закрытие которого парализует движение между остальными частями.
📝 Пошаговое текстовое описание
v присваиваем ей "время входа" tin[v] = timer и параметр low[v] = timer (насколько высоко по дереву DFS можно подняться по обратным рёбрам).u уже посещен (и это не наш родитель), значит мы нашли "обратное ребро". Обновляем low[v] = min(low[v], tin[u]).low[v] = min(low[v], low[u]).u нет путей "наверх" (т.е. low[u] > tin[v]) — ребро (v, u) является мостом!📐 Почему такая сложность?
Алгоритм представляет собой всего один проход DFS, дополненный двумя массивами (tin и low). Вычисления внутри цикла занимают O(1). Итого — O(V + E).
🔎 Подробная трассировка: поиск моста
Граф: Треугольник 0-1, 1-2, 2-0 плюс отросток 2-3.
dfs(0): tin=1, low=1.
dfs(1): tin=2, low=2.
dfs(2): tin=3, low=3. Сосед 0 — обратное ребро! low[2] = min(3, tin[0]=1) = 1.
dfs(3): tin=4, low=4. Соседей нет, возвращаемся в 2.
Смотрим из 2 на 3: low[3]=4 > tin[2]=3. Мост найден! Ребро 2-3.
Возвращаемся из 2 в 1. low[1] = min(2, low[2]=1) = 1.
Смотрим из 1 на 2: low[2]=1 > tin[1]=2 ложно (не мост).
#include <iostream>
#include <vector>
using namespace std;
class BridgesAndAP {
int n, timer = 0;
vector<vector<int>> adj;
vector<int> tin, low;
vector<bool> visited;
vector<pair<int,int>> bridges;
vector<bool> isAP;
void dfs(int v, int parent) {
visited[v] = true;
tin[v] = low[v] = timer++;
int children = 0;
for (int u : adj[v]) {
if (u == parent) continue;
if (visited[u]) {
low[v] = min(low[v], tin[u]); // обратное ребро
} else {
dfs(u, v);
low[v] = min(low[v], low[u]);
children++;
// Мост: нет обратного ребра из поддерева u выше v
if (low[u] > tin[v])
bridges.push_back({v, u});
// Точка сочленения (не корень)
if (parent != -1 && low[u] >= tin[v])
isAP[v] = true;
}
}
// Точка сочленения (корень дерева DFS с ≥2 независимыми поддеревьями)
if (parent == -1 && children > 1)
isAP[v] = true;
}
public:
BridgesAndAP(int n) : n(n), adj(n), tin(n), low(n), visited(n, false), isAP(n, false) {}
void addEdge(int u, int v) {
adj[u].push_back(v);
adj[v].push_back(u);
}
void find() {
for (int i = 0; i < n; i++)
if (!visited[i])
dfs(i, -1);
}
void printBridges() {
cout << "Bridges: ";
for (auto [u, v] : bridges)
cout << u << "-" << v << " ";
cout << endl;
}
void printAPs() {
cout << "Articulation Points: ";
for (int i = 0; i < n; i++)
if (isAP[i]) cout << i << " ";
cout << endl;
}
};
int main() {
// 0 — 1 — 2
// | |
// 3 4
// |
// 5 — 6
BridgesAndAP g(7);
g.addEdge(0,1); g.addEdge(1,2); g.addEdge(0,3);
g.addEdge(2,4); g.addEdge(3,5); g.addEdge(5,6);
g.find();
g.printBridges(); // 0-1 1-2 0-3 2-4 3-5 5-6 (все рёбра — мосты в дереве)
g.printAPs(); // 0 1 2 3 5
return 0;
}
⚠️ Подводные камни
Корень дерева DFS (точка старта) обрабатывается по особым правилам при поиске Точек Сочленения. У корня нет tin[parent]. Корень является точкой сочленения, только если из него выходят минимум две независимые ветки DFS (children > 1). Без этой проверки вы ложно пометите концы "графа-бамбука" как уязвимости.
💼 Где спрашивают
LeetCode: "Critical Connections in a Network". Очень популярная задача на позицию Infrastructure / Backend, где просят найти уязвимые линки в кластере серверов.
🔗 Связь с другими
Прямой родственник алгоритма Тарьяна (SCC). Это классический пример того, как глубокое понимание структуры дерева DFS и обратных рёбер позволяет решать сложные топологические задачи за один проход.
Проверка моста: low vs tin
❌ Мост: low[u] >= tin[v]
// 💀 >= для ТОЧКИ СОЧЛЕНЕНИЯ, не моста!
if (low[u] >= tin[v])
bridges.push_back({v, u}); // НЕВЕРНО!✅ Мост: low[u] > tin[v] (строго)
// ✅ Строго >: нет обратного ребра выше v
if (low[u] > tin[v]) // мост
bridges.push_back({v, u});
if (low[u] >= tin[v]) // точка сочленения
isAP[v] = true;Обратное ребро к родителю
❌ Обновляем low по ребру к parent
for (int u : adj[v]) {
if (visited[u])
low[v] = min(low[v], tin[u]); // 💀
// Ребро v→parent тоже обновит!
// → мост не будет найден
}✅ Пропускаем parent
for (int u : adj[v]) {
if (u == parent) continue; // ✅
if (visited[u])
low[v] = min(low[v], tin[u]);
// ...
}❓ Проверь себя
🔁 Эйлеров путь / цикл
🎯 Интуиция — аналогия из жизни
Детская головоломка "нарисуй конвертик, не отрывая карандаша от бумаги и не проводя дважды по одной линии". Математически: можно ли пройти по каждому ребру ровно один раз? Леонард Эйлер доказал (задача о Кёнигсбергских мостах), что это возможно только если у большинства перекрестков чётное число дорог.
📝 Пошаговое текстовое описание (Алгоритм Хирхольцера)
📐 Почему такая сложность?
Мы проходим по каждому ребру строго один раз и тут же его "сжигаем". Поэтому алгоритм работает ровно за количество рёбер: O(E). Чтобы удаление было O(1), мы используем массив указателей ptr[], запоминая, какие рёбра уже посещены.
🔎 Подробная трассировка: Цикл-конвертик
Граф: 0→1, 1→2, 2→0, 0→3, 3→4, 4→0.
Стек вызовов (движемся, сжигая ребра):
0 → ребро 0-1 → 1 → ребро 1-2 → 2 → ребро 2-0 → 0.
У вершины 0 еще остались дороги! Идем в 3: 0 → 3 → 4 → 0.
В 0 больше нет дорог (тупик). Пушим 0 в ответ. Откат в 4. В 4 нет дорог — пушим 4.
Порядок вытаскивания (тупиков): 0, 4, 3, 0, 2, 1, 0.
Разворачиваем: 0 → 1 → 2 → 0 → 3 → 4 → 0 ✅
#include <iostream>
#include <vector>
#include <stack>
#include <algorithm>
using namespace std;
// Эйлеров цикл/путь для ориентированного графа (алгоритм Хирхольцера)
vector<int> eulerPath(int n, vector<vector<int>>& adj) {
// Определяем стартовую вершину
vector<int> inDeg(n, 0), outDeg(n, 0);
for (int v = 0; v < n; v++) {
outDeg[v] = adj[v].size();
for (int u : adj[v]) inDeg[u]++;
}
int start = 0;
for (int i = 0; i < n; i++)
if (outDeg[i] - inDeg[i] == 1) { start = i; break; }
// Алгоритм Хирхольцера
stack<int> st;
vector<int> path;
vector<int> ptr(n, 0); // указатель на следующее ребро (оптимизация удаления)
st.push(start);
while (!st.empty()) {
int v = st.top();
if (ptr[v] < (int)adj[v].size()) {
st.push(adj[v][ptr[v]++]); // идём и "сжигаем" ребро
} else {
path.push_back(v); // тупик — пишем в ответ
st.pop();
}
}
reverse(path.begin(), path.end());
return path;
}
int main() {
// Граф: 0→1→2→0, 0→3→4→0
int n = 5;
vector<vector<int>> adj(n);
adj[0] = {1, 3}; adj[1] = {2}; adj[2] = {0};
adj[3] = {4}; adj[4] = {0};
auto path = eulerPath(n, adj);
cout << "Euler circuit: ";
for (int v : path) cout << v << " ";
// 0 1 2 0 3 4 0
cout << endl;
return 0;
}
⚠️ Подводные камни
Реальное физическое удаление ребер через adj[v].erase() в C++ занимает O(E). Если вы сделаете это внутри DFS, сложность станет O(E²) (TLE на 10^5). Обязательно используйте массив сдвигов ptr[v], который за O(1) перекидывает указатель на следующее нетронутое ребро.
💼 Где спрашивают
Реконструкция ДНК в биоинформатике (Граф де Брёйна), задача "Reconstruct Itinerary" на LeetCode.
🔗 Связь с другими
Не путайте с Гамильтоновым циклом (пройти по каждой вершине один раз). Гамильтонов цикл — это NP-полная задача (решается за O(2^N) полным перебором/DP по маскам), а Эйлеров цикл — простейшая O(E).
❓ Проверь себя
Глава 6. Динамическое программирование (DP)
📌 DP — король собеседований
DP = разбиение задачи на подзадачи + запоминание результатов (мемоизация). Если задача имеет оптимальную подструктуру (решение большой задачи состоит из решений малых) и перекрывающиеся подзадачи — используй DP. Два подхода: top-down (рекурсия + мемо) и bottom-up (таблица).
| Задача | Состояние | Переход | Сложность |
|---|---|---|---|
| Фибоначчи | dp[i] | dp[i-1]+dp[i-2] | O(n) |
| Рюкзак 0/1 | dp[i][w] | max(skip, take) | O(n·W) |
| Рюкзак ∞ | dp[w] | max(skip, take multiple) | O(n·W) |
| LIS | dp[i] или tails | max(dp[j]+1) если a[j]<a[i] | O(n²) / O(n log n) |
| LCS | dp[i][j] | match / max(skip) | O(n·m) |
| Edit Distance | dp[i][j] | min(ins, del, rep) | O(n·m) |
| Coin Change | dp[amount] | min(dp[a-coin]+1) | O(n·amount) |
🐇 Фибоначчи (Каноничный пример DP)
🎯 Интуиция — аналогия из жизни
Представьте, что вы поднимаетесь по лестнице. За один шаг можно переступить на 1 или 2 ступеньки. Чтобы узнать, сколькими способами можно попасть на 5-ю ступеньку, вам нужно сложить способы попасть на 4-ю (и сделать шаг в 1) и способы попасть на 3-ю (и сделать шаг в 2).
📝 Пошаговое текстовое описание
F(0) = 0, F(1) = 1.F(n) = F(n-1) + F(n-2).a и b, хранящие два предыдущих значения.c = a + b, после чего сдвигаем "окно": a = b, b = c.📐 Почему такая сложность?
Мы делаем один линейный проход циклом от 2 до n, выполняя O(1) арифметических операций на каждом шаге. Итоговое время O(n). Память O(1), так как мы храним только два числа, а не весь массив.
🔎 Подробная трассировка: вычисление F(5)
База: a = 0 (F(0)), b = 1 (F(1))
i=2: c = 0 + 1 = 1. a становитcя 1, b становится 1.
i=3: c = 1 + 1 = 2. a становитcя 1, b становится 2.
i=4: c = 1 + 2 = 3. a становитcя 2, b становится 3.
i=5: c = 2 + 3 = 5. a становитcя 3, b становится 5.
Ответ: переменная b = 5 ✅
#include <iostream>
#include <vector>
#include <unordered_map>
using namespace std;
// 1. Наивная рекурсия — O(2^n) ❌ (Будет TLE для n > 40)
long long fibNaive(int n) {
if (n <= 1) return n;
return fibNaive(n - 1) + fibNaive(n - 2);
}
// 2. Top-down (мемоизация) — O(n), O(n) память
unordered_map<int, long long> memo;
long long fibMemo(int n) {
if (n <= 1) return n;
if (memo.count(n)) return memo[n];
return memo[n] = fibMemo(n - 1) + fibMemo(n - 2);
}
// 3. Bottom-up (таблица) — O(n), O(n) память
long long fibDP(int n) {
if (n <= 1) return n;
vector<long long> dp(n + 1);
dp[0] = 0; dp[1] = 1;
for (int i = 2; i <= n; i++)
dp[i] = dp[i - 1] + dp[i - 2];
return dp[n];
}
// 4. Оптимальный — O(n), O(1) память ✅
long long fibOptimal(int n) {
if (n <= 1) return n;
long long a = 0, b = 1;
for (int i = 2; i <= n; i++) {
long long c = a + b;
a = b;
b = c;
}
return b;
}
int main() {
for (int n : {0, 1, 5, 10, 45}) {
cout << "F(" << n << ") = " << fibOptimal(n) << endl;
}
// F(0)=0, F(1)=1, F(5)=5, F(10)=55, F(45)=1134903170
return 0;
}
⚠️ Подводные камни
Классическая ошибка — использование int вместо long long. Числа Фибоначчи растут экспоненциально: уже F(47) переполнит стандартный 32-битный знаковый `int`.
💼 Где спрашивают
Начальный скрининг везде. Задачи "Climbing Stairs", "Decode Ways", "House Robber" на LeetCode — это переодетый Фибоначчи.
🔗 Связь с другими
Матричное возведение в степень может вычислить Фибоначчи за O(log n) (смотрите главу Математика).
Наивная рекурсия = экспонента
❌ O(2^n) — 30 секунд на fib(45)
int fib(int n) {
if (n <= 1) return n;
return fib(n-1) + fib(n-2); // 💀 2^n!
// fib(5) вычисляет fib(3) ДВАЖДЫ!
}✅ O(n) с мемоизацией
long long dp[100] = {};
long long fib(int n) {
if (n <= 1) return n;
if (dp[n]) return dp[n]; // ✅ уже считали
return dp[n] = fib(n-1) + fib(n-2);
}Переполнение int для больших n
❌ int переполняется при n≥47
int fib(int n) { ... }
// fib(46) = 1836311903 ✅ (помещается)
// fib(47) = 2971215073 💀 > INT_MAX!✅ long long или модуль
long long fib(int n) { ... } // ✅ до n≈92
// Или считаем по модулю:
dp[i] = (dp[i-1] + dp[i-2]) % MOD; // ✅❓ Проверь себя
🎒 Рюкзак 0/1 (0/1 Knapsack)
🎯 Интуиция — аналогия из жизни
Вы вор в музее с рюкзаком, который выдерживает строго W кг. Перед вами картины (каждая имеет свой вес и стоимость). Вы не можете отпилить кусок картины (поэтому 0/1 — либо берем целиком, либо не берем). Ваша цель — унести максимальную ценность, не порвав рюкзак.
📝 Пошаговое текстовое описание
dp[i][w], где i — рассмотренные первые предметы, w — текущая вместимость рюкзака.i, ценность равна dp[i-1][w].i (и он влезает: weight[i] <= w), ценность равна dp[i-1][w - weight[i]] + value[i].📐 Почему такая сложность?
Мы заполняем матрицу размером n строк и W столбцов. Итоговое время O(n · W). Это псевдополиномиальная сложность (зависит не от количества входных данных, а от их значения W). Память при оптимизации сжимается до O(W).
🔎 Подробная трассировка: weights=[1,3,4,5], values=[1,4,5,7], W=7
| i \ w | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 0 (w=1,v=1) | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
| 1 (w=3,v=4) | 0 | 1 | 1 | 4 | 5 | 5 | 5 | 5 |
| 2 (w=4,v=5) | 0 | 1 | 1 | 4 | 5 | 6 | 6 | 9 |
| 3 (w=5,v=7) | 0 | 1 | 1 | 4 | 5 | 7 | 8 | 9 |
#include <iostream>
#include <vector>
using namespace std;
// ═══ 2D версия ═══
int knapsack2D(int W, vector<int>& wt, vector<int>& val) {
int n = wt.size();
vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0));
for (int i = 1; i <= n; i++) {
for (int w = 0; w <= W; w++) {
dp[i][w] = dp[i-1][w]; // не берём предмет i
if (wt[i-1] <= w)
dp[i][w] = max(dp[i][w], dp[i-1][w - wt[i-1]] + val[i-1]); // берём
}
}
return dp[n][W];
}
// ═══ 1D оптимизация (O(W) памяти) ═══
int knapsack1D(int W, vector<int>& wt, vector<int>& val) {
int n = wt.size();
vector<int> dp(W + 1, 0);
for (int i = 0; i < n; i++) {
// Идём СПРАВА НАЛЕВО! (чтобы не использовать предмет дважды)
for (int w = W; w >= wt[i]; w--) {
dp[w] = max(dp[w], dp[w - wt[i]] + val[i]);
}
}
return dp[W];
}
// ═══ Восстановление набора предметов ═══
vector<int> knapsackItems(int W, vector<int>& wt, vector<int>& val) {
int n = wt.size();
vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0));
for (int i = 1; i <= n; i++)
for (int w = 0; w <= W; w++) {
dp[i][w] = dp[i-1][w];
if (wt[i-1] <= w)
dp[i][w] = max(dp[i][w], dp[i-1][w - wt[i-1]] + val[i-1]);
}
// Обратный ход
vector<int> items;
int w = W;
for (int i = n; i > 0; i--) {
if (dp[i][w] != dp[i-1][w]) { // предмет i был взят
items.push_back(i - 1);
w -= wt[i-1];
}
}
return items;
}
int main() {
vector<int> wt = {1, 3, 4, 5};
vector<int> val = {1, 4, 5, 7};
int W = 7;
cout << "Max value (2D): " << knapsack2D(W, wt, val) << endl; // 9
cout << "Max value (1D): " << knapsack1D(W, wt, val) << endl; // 9
auto items = knapsackItems(W, wt, val);
cout << "Items taken: ";
for (int i : items) cout << i << "(w=" << wt[i] << ",v=" << val[i] << ") ";
// Items: 2(w=4,v=5) 1(w=3,v=4) → total weight=7, value=9
cout << endl;
return 0;
}
⚠️ Подводные камни
При оптимизации памяти до 1D-массива обход весов ОБЯЗАН идти справа налево (от W до weight[i]). Если идти слева направо, вы обновите ячейку dp[3], а затем при расчете dp[6] используете уже обновленную dp[3], положив один и тот же предмет в рюкзак дважды (это уже Неограниченный рюкзак!).
💼 Где спрашивают
Основа основ DP. Задачи на разделение массива на две части (Partition Equal Subset Sum), Target Sum на LeetCode.
🔗 Связь с другими
Является базой для неограниченного рюкзака и Subset Sum. Жадный подход не работает для 0/1 рюкзака, но идеально работает для Дробного (Fractional Knapsack).
0/1 рюкзак: направление цикла
❌ Слева направо = неограниченный рюкзак!
for (int i = 0; i < n; i++)
for (int w = wt[i]; w <= W; w++) // 💀 →
dp[w] = max(dp[w], dp[w-wt[i]]+val[i]);
// Предмет берётся МНОГОКРАТНО!✅ Справа налево = 0/1 рюкзак
for (int i = 0; i < n; i++)
for (int w = W; w >= wt[i]; w--) // ✅ ←
dp[w] = max(dp[w], dp[w-wt[i]]+val[i]);
// Каждый предмет не более 1 раза!Забыли базовый случай
❌ dp не инициализирован
vector dp(W+1); // 💀 мусор внутри!
// Или dp(W+1, -1) — забыли dp[0]=0 ✅ Правильная инициализация
vector dp(W+1, 0); // ✅ всё = 0
// dp[0] = 0: пустой рюкзак = 0 ценности ❓ Проверь себя
🎒∞ Неограниченный рюкзак (Unbounded Knapsack)
🎯 Интуиция — аналогия из жизни
Шведский стол. У вас есть поднос вместимостью `W`. Блюд бесконечно много. Вы можете взять 5 кусков пиццы, если они влезут и дадут максимальную калорийность/удовольствие. Ограничений на количество одного предмета нет.
📝 Пошаговое текстовое описание
wt[i] до W).📐 Почему такая сложность?
Тот же двойной цикл: для каждого из n предметов мы проходим по всем весам до W. Итого O(n · W).
🔎 Подробная трассировка: wt=[2, 3], val=[3, 4], W=6
Внешний i=0 (wt=2, val=3). Идем слева направо:
dp[2] = max(0, dp[0] + 3) = 3
dp[4] = max(0, dp[2] + 3) = 6 (Взяли дважды!)
dp[6] = max(0, dp[4] + 3) = 9 (Взяли трижды!)
Внешний i=1 (wt=3, val=4):
dp[3] = max(0, dp[0] + 4) = 4
dp[5] = max(0, dp[2] + 4) = 7
dp[6] = max(9, dp[3] + 4) = max(9, 8) = 9
Ответ: 9 (взять 3 предмета весом 2).
#include <iostream>
#include <vector>
using namespace std;
int unboundedKnapsack(int W, vector<int>& wt, vector<int>& val) {
vector<int> dp(W + 1, 0);
for (int i = 0; i < (int)wt.size(); i++) {
for (int w = wt[i]; w <= W; w++) { // СЛЕВА НАПРАВО!
dp[w] = max(dp[w], dp[w - wt[i]] + val[i]);
}
}
return dp[W];
}
int main() {
vector<int> wt = {2, 3, 5};
vector<int> val = {3, 4, 7};
int W = 10;
cout << "Max value: " << unboundedKnapsack(W, wt, val) << endl;
// Можно взять: 5×(w=2,v=3)=15 или 2×(w=5,v=7)=14 → 15
return 0;
}
⚠️ Подводные камни
Путаница с направлением цикла. Запомните: 0/1 рюкзак = справа налево. ∞ рюкзак = слева направо.
💼 Где спрашивают
Редко дают в чистом виде. Но задача Coin Change (Размен монет) — это буквально неограниченный рюкзак, только с поиском минимума (а не максимума).
Неправильное направление цикла по весу
❌ Справа налево — получится 0/1 рюкзак
// 💀 Так каждый предмет используется только 1 раз
for (int i = 0; i < n; i++) {
for (int w = W; w >= wt[i]; w--) {
dp[w] = max(dp[w], dp[w - wt[i]] + val[i]);
}
}
✅ Для неограниченного — слева направо
// ✅ Один и тот же предмет можно брать снова
for (int i = 0; i < n; i++) {
for (int w = wt[i]; w <= W; w++) {
dp[w] = max(dp[w], dp[w - wt[i]] + val[i]);
}
}
Путают с дробным рюкзаком
❌ Берут по value/weight жадно
// 💀 Это greedy для Fractional Knapsack,
// а не для unbounded knapsack
sort(items.begin(), items.end(), byRatio);
takeBestRatioFirst();
✅ Здесь нужен DP
// ✅ Потому что предметы целиком, но unlimited
dp[w] = max(dp[w], dp[w - wt[i]] + val[i]);
❓ Проверь себя
🔗 Задачи для практики
📈 Наибольшая возрастающая подпоследовательность (LIS)
🎯 Интуиция — аналогия из жизни
Выстраивание русской матрешки: каждая следующая должна быть строго больше предыдущей. У вас ряд разбросанных матрешек разного размера, и вам нужно выбрать подмножество так, чтобы собрать самую большую вложенность, не меняя их исходный порядок.
📝 Пошаговое текстовое описание (O(n log n))
tails. tails[i] хранит наименьший из возможных конечных элементов для всех возрастающих подпоследовательностей длины i+1.x в исходном массиве используем Бинарный Поиск (lower_bound), чтобы найти первое число в tails, которое больше или равно x.x больше всех чисел в tails — отлично, мы удлинили нашу подпоследовательность! Добавляем x в конец tails.x. Это "открывает потенциал" для продолжения последовательности, так как число стало меньше.📐 Почему такая сложность?
Мы проходим циклом по n элементам. Внутри цикла делаем бинарный поиск по массиву tails, что занимает O(log n). Итого O(n log n).
🔎 Подробная трассировка: [10, 9, 2, 5, 3, 7, 101, 18]
x = 10: tails = [10] (LIS = 1)
x = 9: 9 < 10, заменяем. tails = [9]
x = 2: 2 < 9, заменяем. tails = [2]
x = 5: 5 > 2, добавляем. tails = [2, 5] (LIS = 2)
x = 3: 3 между 2 и 5, заменяем 5 на 3. tails = [2, 3]
x = 7: 7 > 3, добавляем. tails = [2, 3, 7] (LIS = 3)
x = 101: 101 > 7, добавляем. tails = [2, 3, 7, 101] (LIS = 4)
x = 18: 18 между 7 и 101, заменяем 101 на 18. tails = [2, 3, 7, 18]
Ответ: размер tails = 4 ✅
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// ═══ O(n²) DP (Проще понять, но медленно) ═══
int lisQuadratic(const vector<int>& arr) {
int n = arr.size();
vector<int> dp(n, 1); // dp[i] = длина LIS, заканчивающейся в i
int maxLen = 1;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (arr[j] < arr[i])
dp[i] = max(dp[i], dp[j] + 1);
}
maxLen = max(maxLen, dp[i]);
}
return maxLen;
}
// ═══ O(n log n) с бинарным поиском ═══
int lisBinarySearch(const vector<int>& arr) {
vector<int> tails; // tails[i] = наименьший хвост LIS длины i+1
for (int x : arr) {
auto it = lower_bound(tails.begin(), tails.end(), x);
if (it == tails.end())
tails.push_back(x); // удлиняем LIS
else
*it = x; // заменяем на меньший хвост
}
return tails.size();
}
// ═══ O(n log n) с восстановлением самой LIS ═══
vector<int> lisWithRecovery(const vector<int>& arr) {
int n = arr.size();
vector<int> tails, tailIdx, parent(n, -1);
for (int i = 0; i < n; i++) {
auto it = lower_bound(tails.begin(), tails.end(), arr[i]);
int pos = it - tails.begin();
if (it == tails.end()) {
tails.push_back(arr[i]);
tailIdx.push_back(i);
} else {
*it = arr[i];
tailIdx[pos] = i;
}
parent[i] = (pos > 0) ? tailIdx[pos - 1] : -1;
}
// Восстанавливаем
vector<int> lis;
for (int i = tailIdx.back(); i != -1; i = parent[i])
lis.push_back(arr[i]);
reverse(lis.begin(), lis.end());
return lis;
}
int main() {
vector<int> arr = {10, 9, 2, 5, 3, 7, 101, 18};
cout << "LIS length (O(n^2)): " << lisQuadratic(arr) << endl; // 4
cout << "LIS length (O(n log n)): " << lisBinarySearch(arr) << endl; // 4
auto lis = lisWithRecovery(arr);
cout << "LIS itself: ";
for (int x : lis) cout << x << " "; // 2 3 7 18
cout << endl;
return 0;
}
⚠️ Подводные камни
В методе за O(n log n) массив tails НЕ является самой LIS на момент окончания алгоритма! Он содержит мусор из разных подпоследовательностей. Он гарантирует только правильную длину. Для восстановления самих элементов используйте массив предков `parent`.
💼 Где спрашивают
Очень частая задача на алгоритмических секциях. Вариация "Russian Doll Envelopes" на LeetCode (Hard) требует сначала отсортировать конверты по ширине, а затем найти LIS по высоте.
🔗 Связь с другими
LIS массива `A` эквивалентна LCS (Наибольшей общей подпоследовательности) массива `A` и его отсортированной копии (если в массиве нет дубликатов).
❓ Проверь себя
🔠 Наибольшая общая подпоследовательность (LCS)
🎯 Интуиция — аналогия из жизни
Утилита git diff. У нас есть две версии текстового документа. Нужно найти максимальное количество слов/символов, которые присутствуют в обеих версиях в одинаковом порядке (это то, что "не изменилось").
📝 Пошаговое текстовое описание
dp[n+1][m+1], заполненную нулями. dp[i][j] — это длина LCS для префиксов s1[0..i-1] и s2[0..j-1].s1[i-1] == s2[j-1]: мы добавляем этот символ в общую подпоследовательность. dp[i][j] = dp[i-1][j-1] + 1.dp[i][j] = max(dp[i-1][j], dp[i][j-1]).📐 Почему такая сложность?
Мы заполняем матрицу размером n × m. Обработка каждой ячейки (сравнение и взятие максимума) занимает O(1). Итоговое время и память — O(n · m). (Память можно оптимизировать до O(min(n, m)), храня только 2 строки, но тогда мы потеряем возможность восстановить саму строку).
🔎 Подробная трассировка: ABC и BCD
| ∅(0) | B(1) | C(2) | D(3) | |
|---|---|---|---|---|
| ∅(0) | 0 | 0 | 0 | 0 |
| A(1) | 0 | 0 (A≠B) | 0 (A≠C) | 0 (A≠D) |
| B(2) | 0 | 1 (B=B) | 1 (B≠C) | 1 (B≠D) |
| C(3) | 0 | 1 (C≠B) | 2 (C=C) | 2 (C≠D) |
Ответ = 2 ("BC").
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
using namespace std;
int lcsLength(const string& s1, const string& s2) {
int n = s1.size(), m = s2.size();
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (s1[i-1] == s2[j-1])
dp[i][j] = dp[i-1][j-1] + 1; // совпадение
else
dp[i][j] = max(dp[i-1][j], dp[i][j-1]); // пропуск
}
}
return dp[n][m];
}
string lcsString(const string& s1, const string& s2) {
int n = s1.size(), m = s2.size();
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
dp[i][j] = (s1[i-1] == s2[j-1])
? dp[i-1][j-1] + 1
: max(dp[i-1][j], dp[i][j-1]);
// Восстановление
string result;
int i = n, j = m;
while (i > 0 && j > 0) {
if (s1[i-1] == s2[j-1]) {
result += s1[i-1]; // собираем с конца
i--; j--;
} else if (dp[i-1][j] > dp[i][j-1]) {
i--; // идём туда, откуда пришло большее значение
} else {
j--;
}
}
reverse(result.begin(), result.end()); // переворачиваем!
return result;
}
int main() {
cout << "LCS length: " << lcsLength("ABCBDAB", "BDCAB") << endl; // 4
cout << "LCS string: " << lcsString("ABCBDAB", "BDCAB") << endl; // BCAB
return 0;
}
⚠️ Подводные камни
Индексы в таблице `dp` сдвинуты на 1 относительно индексов строк. `dp[1][1]` соответствует сравнению `s1[0]` и `s2[0]`. При восстановлении строки результат собирается "задом наперед" (от правого нижнего угла в левый верхний), поэтому в конце нужно не забыть сделать `reverse()`.
💼 Где спрашивают
Решение задач на пересечение геномов (Биоинформатика), создание утилит типа `diff` и `merge`.
🔗 Связь с другими
Близкий родственник "Расстояния редактирования". В отличие от LIS (где 1 массив), тут у нас 2 строки.
❓ Проверь себя
✏️ Расстояние редактирования (Левенштейна)
🎯 Интуиция — аналогия из жизни
Автоисправление (T9). Как превратить слово "корова" в "корона"? Нужно заменить 1 букву. А "кот" в "ток"? Удалить 'к', вставить 'т'. Левенштейн считает минимальное количество опечаток: вставок, удалений и замен.
📝 Пошаговое текстовое описание
s2 длины j, нужно сделать j вставок. Столбец 0: i удалений.s1[i-1] == s2[j-1]: символы равны, опечатки нет. Берём диагональ dp[i][j] = dp[i-1][j-1].[i-1][j-1]), Удаление (сверху [i-1][j]) или Вставку (слева [i][j-1]). Берём минимум из трёх + 1 штрафное очко.📐 Почему такая сложность?
Аналогично LCS, мы заполняем матрицу n × m. Сложность O(n · m).
🔎 Трассировка: "cat" → "cut"
База: dp[i][0] = i (удаления), dp[0][j] = j (вставки)
c=c: диагональ, штраф 0. dp[1][1] = dp[0][0] = 0
a≠u: 1 + min(dp[0][1](удаление), dp[1][0](вставка), dp[0][0](замена)) = 1 + min(1, 1, 0) = 1 (Замена 'a' на 'u')
t=t: диагональ, штраф 0. Итог = 1 операция ✅
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
using namespace std;
int editDistance(const string& s1, const string& s2) {
int n = s1.size(), m = s2.size();
vector<vector<int>> dp(n + 1, vector<int>(m + 1));
// База: превратить пустую строку
for (int i = 0; i <= n; i++) dp[i][0] = i; // удаления
for (int j = 0; j <= m; j++) dp[0][j] = j; // вставки
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (s1[i-1] == s2[j-1]) {
dp[i][j] = dp[i-1][j-1]; // символы совпадают
} else {
dp[i][j] = 1 + min({
dp[i-1][j], // удаление
dp[i][j-1], // вставка
dp[i-1][j-1] // замена
});
}
}
}
return dp[n][m];
}
int main() {
cout << editDistance("kitten", "sitting") << endl; // 3 (k->s, e->i, +g)
cout << editDistance("sunday", "saturday") << endl; // 3
cout << editDistance("", "abc") << endl; // 3
cout << editDistance("abc", "abc") << endl; // 0
return 0;
}
⚠️ Подводные камни
Часто забывают проинициализировать базовые случаи (первую строку и столбец) значениями `1, 2, 3...`. Если оставить нули, алгоритм будет думать, что удаление букв бесплатное.
💼 Где спрашивают
Алгоритмы нечеткого поиска (Fuzzy Search), ElasticSearch, проверка орфографии в IDE.
🔗 Связь с другими
Обобщение LCS. Если запретить операцию "Замена", Левенштейн сведется к поиску LCS, а ответ будет равен `(len(s1) - LCS) + (len(s2) - LCS)`.
❓ Проверь себя
🪙 Размен монет (Coin Change)
🎯 Интуиция — аналогия из жизни
Вы кассир. Клиенту нужно дать 11 рублей сдачи. У вас бесконечно много монет по 1, 2 и 5 рублей. Жадный подход (5+5+1) тут работает. Но если монеты 1, 3, 4 и сдача 6 рублей? Жадный даст 4+1+1 (3 монеты), а правильный ответ 3+3 (2 монеты). DP проверяет все варианты умно.
📝 Пошаговое текстовое описание
dp размером amount + 1. Заполняем "бесконечностью". dp[0] = 0.amount.coin <= a), обновляем: dp[a] = min(dp[a], dp[a - coin] + 1).📐 Почему такая сложность?
Двойной цикл: внешний по amount, внутренний по количеству монет n. Итого O(n · amount).
🔎 Трассировка: монеты [1, 5], сумма 6
База: dp = [0, ∞, ∞, ∞, ∞, ∞, ∞]
a=1: coin 1 → dp[1] = min(∞, dp[0]+1) = 1.
a=2: coin 1 → dp[2] = min(∞, dp[1]+1) = 2.
...
a=5: coin 1 → dp[5] = 5.
a=5: coin 5 → dp[5] = min(5, dp[0]+1) = 1. (Выгоднее взять одну по 5)
a=6: coin 1 → dp[6] = min(∞, dp[5]+1) = 2.
a=6: coin 5 → dp[6] = min(2, dp[1]+1) = 2.
Ответ: 2 монеты.
#include <iostream>
#include <vector>
#include <climits>
using namespace std;
// Задача 1: Минимальное количество монет для суммы amount
int coinChangeMin(const vector<int>& coins, int amount) {
// Инициализируем (amount + 1) как безопасный аналог бесконечности,
// чтобы избежать переполнения INT_MAX + 1
vector<int> dp(amount + 1, amount + 1);
dp[0] = 0;
for (int a = 1; a <= amount; a++) {
for (int coin : coins) {
if (coin <= a)
dp[a] = min(dp[a], dp[a - coin] + 1);
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
// Задача 2: Количество комбинаций способов набрать сумму
long long coinChangeWays(const vector<int>& coins, int amount) {
vector<long long> dp(amount + 1, 0);
dp[0] = 1;
// Перебираем МОНЕТЫ во внешнем цикле (чтобы не считать перестановки)
for (int coin : coins) {
for (int a = coin; a <= amount; a++) {
dp[a] += dp[a - coin];
}
}
return dp[amount];
}
int main() {
vector<int> coins = {1, 5, 10, 25};
cout << "Min coins for 30: " << coinChangeMin(coins, 30) << endl; // 2 (25+5)
cout << "Min coins for 11: " << coinChangeMin(coins, 11) << endl; // 2 (10+1)
cout << "Ways to make 10: " << coinChangeWays(coins, 10) << endl; // 4 (10; 5+5; 5+1x5; 1x10)
return 0;
}
⚠️ Подводные камни
Для задачи "Количество комбинаций способов" (Задача 2) внешний цикл обязан быть по монетам, а внутренний по суммам! Если сделать наоборот (сумма -> монеты), алгоритм посчитает перестановки (т.е. 1+5 и 5+1 посчитаются как два разных способа).
💼 Где спрашивают
LeetCode: Coin Change 1 & 2. Практически на всех технических интервью начального и среднего уровня.
🔗 Связь с другими
Это классическая вариация Неограниченного рюкзака, только целевая функция — min вместо max.
Порядок циклов: комбинации vs перестановки
❌ Считает перестановки (1+5 ≠ 5+1)
// 💀 Суммы во внешнем → монеты внутри
for (int a = 1; a <= amount; a++)
for (int coin : coins)
if (coin <= a) dp[a] += dp[a-coin];
// {1,5} и {5,1} считаются отдельно!✅ Считает комбинации (1+5 = 5+1)
// ✅ Монеты во внешнем → суммы внутри
for (int coin : coins)
for (int a = coin; a <= amount; a++)
dp[a] += dp[a-coin];
// {1,5} считается один раз!❓ Проверь себя
📐 Оптимальное перемножение матриц
🎯 Интуиция — аналогия из жизни
Вам нужно расставить скобки в школьном примере. Умножение матриц некоммутативно, но ассоциативно: (A × B) × C = A × (B × C). Но вычислительная сложность разная! Умножить (10x100) на (100x5) стоит 5000 операций, а умножить (10x10) на (10x10) всего 1000. Наша цель — найти такой порядок действий, который сделает компьютер поменьше математики.
📝 Пошаговое текстовое описание
len задает длину рассматриваемой цепочки (от 2 до n).i. Конечный индекс j = i + len - 1.k (где поставить главные скобки). Считаем: cost = dp[i][k] + dp[k+1][j] + cost_merge. Ищем минимум по всем k.📐 Почему такая сложность?
Три вложенных цикла по параметрам до n. Итоговое время O(n³). Память — двумерная матрица O(n²).
🔎 Трассировка: 3 матрицы A(10x30), B(30x5), C(5x60)
Размерности dims = [10, 30, 5, 60]
len = 2:
A*B (i=0, j=1): 10 × 30 × 5 = 1500
B*C (i=1, j=2): 30 × 5 × 60 = 9000
len = 3 (A*B*C, i=0, j=2):
Вариант 1 (k=0): A*(BC) = 0 + 9000 + 10×30×60 = 27000
Вариант 2 (k=1): (AB)*C = 1500 + 0 + 10×5×60 = 4500
Минимум = 4500 ✅
#include <iostream>
#include <vector>
#include <climits>
using namespace std;
int matrixChain(const vector<int>& dims) {
int n = dims.size() - 1; // количество матриц
// dp[i][j] = мин. число умножений для матриц от i до j
vector<vector<int>> dp(n, vector<int>(n, 0));
// len — длина цепочки
for (int len = 2; len <= n; len++) {
for (int i = 0; i <= n - len; i++) {
int j = i + len - 1;
dp[i][j] = INT_MAX;
// Пробуем все точки разделения k
for (int k = i; k < j; k++) {
int cost = dp[i][k] + dp[k+1][j]
+ dims[i] * dims[k+1] * dims[j+1];
dp[i][j] = min(dp[i][j], cost);
}
}
}
return dp[0][n-1];
}
int main() {
// 3 матрицы: 10×30, 30×5, 5×60
vector<int> dims = {10, 30, 5, 60};
cout << "Min multiplications: " << matrixChain(dims) << endl;
// (10×30)×(30×5) + (10×5)×(5×60) = 1500+3000 = 4500
// vs 10×(30×5×60) + ... = 27000
return 0;
}
⚠️ Подводные камни
Самая большая ошибка — перебирать i и j в двух внешних циклах. Это ломает логику DP! К моменту расчета dp[i][j], все "малые" подзадачи (короче по длине) должны быть уже решены. Поэтому внешний цикл всегда по длине `len`.
💼 Где спрашивают
Стандартная задача в университетских курсах. На LeetCode: "Burst Balloons", "Minimum Cost to Merge Stones".
🔗 Связь с другими
Пример парадигмы "DP на отрезках". Очень похоже на алгоритм синтаксического анализа CYK.
❓ Проверь себя
➕ Сумма подмножества (Subset Sum)
🎯 Интуиция — аналогия из жизни
Сможете ли вы набрать на весах ровно 11 килограмм из набора гирь {1, 5, 11, 5}? Да, можно взять просто 11, или 1+5+5. А ровно 13? Нет. Это проверка на "существование" комбинации.
📝 Пошаговое текстовое описание
dp размером target + 1. База: dp[0] = true (сумму 0 можно набрать пустым множеством гирь). Остальные false.x из массива идем справа налево по массиву dp (от target до x).dp[s] = dp[s] OR dp[s - x]. Если мы могли ранее набрать сумму s - x, то добавив текущую гирю x, мы сможем набрать сумму s.📐 Почему такая сложность?
Сложность O(n · S) по времени (внешний цикл по N элементам, внутренний по S суммам) и O(S) по памяти.
🔎 Трассировка: гири [1, 5], target=6
База: dp[0]=T, dp[1..6]=F
x=1: идём от 6 до 1.
dp[1] = dp[1] | dp[0] = F | T = T.
Остальные F. dp = [T, T, F, F, F, F, F]
x=5: идём от 6 до 5.
dp[6] = dp[6] | dp[1] = F | T = T!
dp[5] = dp[5] | dp[0] = F | T = T!
dp[6] == True ✅
#include <iostream>
#include <vector>
#include <numeric>
using namespace std;
// ═══ Можно ли набрать сумму target? ═══
bool subsetSum(const vector<int>& arr, int target) {
vector<bool> dp(target + 1, false);
dp[0] = true;
for (int x : arr) {
for (int s = target; s >= x; s--) { // СПРАВА НАЛЕВО!
if (dp[s - x]) dp[s] = true;
}
}
return dp[target];
}
// ═══ Задача: Разбить массив на две части с равной суммой ═══
bool canPartition(const vector<int>& arr) {
int total = accumulate(arr.begin(), arr.end(), 0);
// Если сумма нечетная, разбить на две целые равные части невозможно
if (total % 2 != 0) return false;
return subsetSum(arr, total / 2);
}
// ═══ Подсчёт количества подмножеств с данной суммой ═══
int countSubsetSum(const vector<int>& arr, int target) {
vector<int> dp(target + 1, 0);
dp[0] = 1;
for (int x : arr) {
for (int s = target; s >= x; s--) {
dp[s] += dp[s - x];
}
}
return dp[target];
}
int main() {
vector<int> arr = {3, 34, 4, 12, 5, 2};
cout << "Subset sum 9: " << subsetSum(arr, 9) << endl; // 1 (4+5)
cout << "Subset sum 30: " << subsetSum(arr, 30) << endl; // 0
// Equal Partition
vector<int> arr2 = {1, 5, 11, 5};
cout << "Can partition: " << canPartition(arr2) << endl; // 1 ({1,5,5} и {11})
// Count
vector<int> arr3 = {1, 2, 3, 3};
cout << "Count subsets sum=6: " << countSubsetSum(arr3, 6) << endl; // 3
return 0;
}
⚠️ Подводные камни
Опять же, обход 1D массива строго справа налево! Иначе гиря "используется" бесконечное количество раз, и это превратится в задачу "можно ли набрать сумму, если каждую гирю можно брать сколько угодно раз".
💼 Где спрашивают
Задача "Partition Equal Subset Sum" — одна из самых частых на LeetCode. Встречается на алгоритмических раундах VK и Yandex.
🔗 Связь с другими
Это полная логическая калька алгоритма 0/1 Рюкзака. Отличие в том, что там мы максимизировали стоимость (`max`), а тут нам нужна только булева логика (`OR`) или сложение способов (`+`).
❓ Проверь себя
Глава 7. Жадные алгоритмы
📌 Что такое жадный алгоритм?
На каждом шаге делаем локально оптимальный выбор, надеясь получить глобально оптимальное решение. Не всегда работает! Нужно доказать (или интуитивно понять), что жадный выбор безопасен. Если не уверены — используйте DP.
⚠️ Когда жадный НЕ работает
Пример: монеты {1, 3, 4}, сумма 6.
Жадный: 4+1+1 = 3 монеты ❌
Оптимально: 3+3 = 2 монеты ✅
Жадный подход к размену монет работает только для «канонических» систем (1, 5, 10, 25).
📅 Выбор активностей (Activity Selection / Interval Scheduling)
🎯 Интуиция — аналогия из жизни
Представьте, что вы сдаёте в аренду единственный конференц-зал. У вас есть куча заявок на разное время. Чтобы провести максимальное количество мероприятий, нужно каждый раз пускать в зал того, кто освободит его как можно раньше. Чем раньше зал освободится, тем больше шансов впихнуть туда следующие встречи.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Основное время уходит на сортировку массива мероприятий, что занимает O(n log n). После сортировки мы проходим по массиву ровно один раз за линейное время O(n). Итоговая сложность определяется сортировкой: O(n log n).
🔎 Подробная трассировка:
Мероприятия (start, end): (1,4), (3,5), (0,6), (5,7), (3,9), (5,9), (6,10), (8,11), (8,12), (2,14), (12,16)
Шаг 1: Сортируем по end: (1,4), (3,5), (0,6), (5,7), (3,9), (5,9), (6,10), (8,11), (8,12), (2,14), (12,16)
Шаг 2: Жадный выбор:
✅ Берём (1,4) — lastEnd = 4
❌ (3,5): start=3 < 4 — пересекается, пропускаем
❌ (0,6): start=0 < 4 — пропускаем
✅ (5,7): start=5 ≥ 4 — берём! lastEnd = 7
❌ (3,9): start=3 < 7 — пропускаем
❌ (5,9): start=5 < 7 — пропускаем
❌ (6,10): start=6 < 7 — пропускаем
✅ (8,11): start=8 ≥ 7 — берём! lastEnd = 11
❌ (8,12): start=8 < 11 — пропускаем
❌ (2,14): start=2 < 11 — пропускаем
✅ (12,16): start=12 ≥ 11 — берём! lastEnd = 16
Результат: 4 мероприятия: (1,4), (5,7), (8,11), (12,16) ✅
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
struct Activity {
int start, end, index;
};
// ═══ Максимальное количество непересекающихся интервалов ═══
vector<Activity> activitySelection(vector<Activity>& acts) {
// Сортируем по времени ОКОНЧАНИЯ
sort(acts.begin(), acts.end(), [](const Activity& a, const Activity& b) {
return a.end < b.end;
});
vector<Activity> selected;
int lastEnd = -1;
for (auto& act : acts) {
if (act.start >= lastEnd) {
selected.push_back(act);
lastEnd = act.end;
}
}
return selected;
}
// ═══ Вариация: минимальное количество комнат для всех встреч ═══
// (Meeting Rooms II — популярная задача на собеседованиях)
int minMeetingRooms(vector<pair<int,int>>& intervals) {
vector<int> starts, ends;
for (auto& [s, e] : intervals) {
starts.push_back(s);
ends.push_back(e);
}
sort(starts.begin(), starts.end());
sort(ends.begin(), ends.end());
int rooms = 0, maxRooms = 0;
int i = 0, j = 0;
while (i < (int)starts.size()) {
if (starts[i] < ends[j]) {
rooms++;
i++;
} else {
rooms--;
j++;
}
maxRooms = max(maxRooms, rooms);
}
return maxRooms;
}
// ═══ Вариация: слияние пересекающихся интервалов ═══
vector<pair<int,int>> mergeIntervals(vector<pair<int,int>>& intervals) {
sort(intervals.begin(), intervals.end());
vector<pair<int,int>> merged;
for (auto& [s, e] : intervals) {
if (!merged.empty() && s <= merged.back().second) {
merged.back().second = max(merged.back().second, e);
} else {
merged.push_back({s, e});
}
}
return merged;
}
int main() {
vector<Activity> acts = {
{1,4,0}, {3,5,1}, {0,6,2}, {5,7,3}, {3,9,4},
{5,9,5}, {6,10,6}, {8,11,7}, {8,12,8}, {2,14,9}, {12,16,10}
};
auto selected = activitySelection(acts);
cout << "Selected " << selected.size() << " activities:" << endl;
for (auto& a : selected)
cout << " [" << a.start << ", " << a.end << ")" << endl;
// [1,4), [5,7), [8,11), [12,16)
// Meeting Rooms
vector<pair<int,int>> meetings = {{0,30},{5,10},{15,20}};
cout << "Min rooms: " << minMeetingRooms(meetings) << endl; // 2
// Merge intervals
vector<pair<int,int>> intervals = {{1,3},{2,6},{8,10},{15,18}};
auto merged = mergeIntervals(intervals);
cout << "Merged: ";
for (auto& [s,e] : merged) cout << "[" << s << "," << e << "] ";
// [1,6] [8,10] [15,18]
cout << endl;
return 0;
}
// ═══ Доказательство корректности жадного выбора ═══
// Пусть OPT — оптимальное решение. Пусть жадный выбрал активность A
// с наименьшим end. Если OPT не содержит A, заменим первую активность
// OPT на A — это не ухудшит решение (A заканчивается не позже).
// Значит, жадный выбор безопасен. По индукции — всё решение оптимально.
⚠️ Подводные камни
Частая ошибка — сортировать по времени НАЧАЛА (start) или по ДЛИТЕЛЬНОСТИ. Это провальная стратегия. Пример: одно мероприятие на весь день (0..24h) начнётся раньше всех, вы выберете его, и заблокируете 10 коротких часовых встреч.
💼 Где спрашивают
Одна из самых популярных задач на LeetCode (задача Non-overlapping Intervals). Абсолютная классика на алгоритмических секциях FAANG. База для создания планировщиков задач в ОС.
🔗 Связь с другими
Очень похожа на задачу Meeting Rooms (там нужно найти минимальное количество комнат для всех встреч). В Activity Selection комната всего одна, и ищем максимальное количество встреч.
❓ Проверь себя
🌳 Кодирование Хаффмана (Huffman Coding)
🎯 Интуиция — аналогия из жизни
Вспомните азбуку Морзе: буква 'E' (самая частая в английском) кодируется всего одной точкой, а редкая 'Q' — длинной комбинацией «тире-тире-точка-тире». Хаффман делает то же самое математически идеально для любого алфавита: частым символам даются короткие коды, редким — длинные, экономя память.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Мы добавляем и извлекаем $n$ узлов из Priority Queue (кучи). Каждая операция с кучей занимает $O(\log n)$. Суммарно для $n$ символов мы тратим O(n log n) времени. Построение кодов обходом дерева занимает O(n).
🔎 Подробная трассировка: символы a:5, b:9, c:12, d:13, e:16, f:45
Heap: [5(a), 9(b), 12(c), 13(d), 16(e), 45(f)]
Шаг 1: Извлекаем 5(a) и 9(b), создаём узел 14(ab). Heap: [12(c), 13(d), 14(ab), 16(e), 45(f)]
Шаг 2: Извлекаем 12(c) и 13(d), создаём 25(cd). Heap: [14(ab), 16(e), 25(cd), 45(f)]
Шаг 3: Извлекаем 14(ab) и 16(e), создаём 30(abe). Heap: [25(cd), 30(abe), 45(f)]
Шаг 4: Извлекаем 25(cd) и 30(abe), создаём 55(cdabe). Heap: [45(f), 55(cdabe)]
Шаг 5: Извлекаем 45(f) и 55(cdabe), создаём 100(корень).
Дерево Хаффмана:
100
/ \
45(f) 55
/ \
25 30
/ \ / \
12(c)13(d)14 16(e)
/ \
5(a) 9(b)
Коды: f=0, c=100, d=101, a=1100, b=1101, e=111
Проверка: 45×1 + 12×3 + 13×3 + 5×4 + 9×4 + 16×3 = 45+36+39+20+36+48 = 224 бита
(Фиксированный код 3 бита: 100×3 = 300 бит → экономия 25%!)
#include <iostream>
#include <queue>
#include <unordered_map>
#include <string>
using namespace std;
struct HuffNode {
char ch;
int freq;
HuffNode *left, *right;
HuffNode(char c, int f) : ch(c), freq(f), left(nullptr), right(nullptr) {}
};
// Компаратор для min-heap
struct Compare {
bool operator()(HuffNode* a, HuffNode* b) {
return a->freq > b->freq;
}
};
// Рекурсивно собираем коды
void buildCodes(HuffNode* root, string code, unordered_map<char, string>& codes) {
if (!root) return;
if (!root->left && !root->right) { // лист
codes[root->ch] = code.empty() ? "0" : code;
return;
}
buildCodes(root->left, code + "0", codes);
buildCodes(root->right, code + "1", codes);
}
unordered_map<char, string> huffmanCoding(const unordered_map<char, int>& freq) {
priority_queue<HuffNode*, vector<HuffNode*>, Compare> pq;
// Создаём листья
for (auto& [ch, f] : freq)
pq.push(new HuffNode(ch, f));
// Строим дерево
while (pq.size() > 1) {
HuffNode* left = pq.top(); pq.pop();
HuffNode* right = pq.top(); pq.pop();
HuffNode* parent = new HuffNode('\0', left->freq + right->freq);
parent->left = left;
parent->right = right;
pq.push(parent);
}
unordered_map<char, string> codes;
buildCodes(pq.top(), "", codes);
return codes;
}
// Кодирование строки
string encode(const string& text, const unordered_map<char, string>& codes) {
string result;
for (char c : text)
result += codes.at(c);
return result;
}
// Декодирование
string decode(const string& encoded, HuffNode* root) {
string result;
HuffNode* cur = root;
for (char bit : encoded) {
cur = (bit == '0') ? cur->left : cur->right;
if (!cur->left && !cur->right) {
result += cur->ch;
cur = root;
}
}
return result;
}
int main() {
unordered_map<char, int> freq = {
{'a',5}, {'b',9}, {'c',12}, {'d',13}, {'e',16}, {'f',45}
};
auto codes = huffmanCoding(freq);
cout << "Huffman Codes:" << endl;
int totalBits = 0;
for (auto& [ch, code] : codes) {
cout << " '" << ch << "': " << code
<< " (len=" << code.size() << ", freq=" << freq[ch] << ")" << endl;
totalBits += code.size() * freq[ch];
}
cout << "Total bits: " << totalBits << endl;
cout << "Fixed 3-bit: " << 3 * 100 << " bits" << endl;
cout << "Savings: " << (300 - totalBits) * 100 / 300 << "%" << endl;
// Кодирование/декодирование
string text = "abcdef";
string encoded = encode(text, codes);
cout << "Encoded: " << encoded << endl;
return 0;
}
⚠️ Подводные камни
При одинаковых частотах символов приоритетная очередь может выдать узлы в разном порядке, из-за чего деревья (и сами коды 0/1) будут разными. Длина итогового сжатого сообщения не изменится, но коды не будут совпадать с эталонными. В реальных архиваторах для предсказуемости используют каноническое дерево Хаффмана.
💼 Где спрашивают
Обязательная часть университетского курса. На собеседованиях по System Design (как сжимать данные). Применяется везде: архиваторы (ZIP, GZIP, DEFLATE), форматы мультимедиа (JPEG AC-коэффициенты, MP3), веб-протоколы (HTTP/2 HPACK использует статический словарь Хаффмана).
🔗 Связь с другими
Является префиксным кодом, из-за чего структура очень похожа на Trie (префиксное дерево). Алгоритм полагается на структуру Min-Heap.
❓ Проверь себя
🎒✂️ Дробный рюкзак (Fractional Knapsack)
🎯 Интуиция — аналогия из жизни
Вы в пещере с сокровищами, у вас рюкзак на 10 кг. Перед вами мешки с золотым песком, серебряной крошкой и платиновой пылью. В отличие от слитков, песок можно отсыпать. Логично сначала под завязку насыпать самой дорогой пудры за грамм (платину), затем досыпать золота, пока рюкзак не треснет.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Подсчет удельной ценности занимает O(n). Сортировка предметов занимает O(n log n). Жадный проход по массиву и заполнение рюкзака требует еще O(n). Итоговое время доминируется сортировкой: O(n log n).
🔎 Подробная трассировка: W=50, предметы: (60,10), (100,20), (120,30)
Удельная ценность: 60/10=6, 100/20=5, 120/30=4
Сортируем: (60,10,6), (100,20,5), (120,30,4)
Шаг 1: Берём полностью (60,10): осталось 50-10=40, ценность=60
Шаг 2: Берём полностью (100,20): осталось 40-20=20, ценность=160
Шаг 3: Места 20, а вес 30. Берём 20/30 = 2/3 от (120,30): ценность += 120×(2/3) = 80
Итого: ценность = 60+100+80 = 240 ✅
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
struct Item {
double value, weight;
double ratio() const { return value / weight; }
};
double fractionalKnapsack(double W, vector<Item>& items) {
// Сортируем по удельной ценности (убывание)
sort(items.begin(), items.end(), [](const Item& a, const Item& b) {
return a.ratio() > b.ratio();
});
double totalValue = 0;
double remainW = W;
for (auto& item : items) {
if (remainW <= 0) break;
if (item.weight <= remainW) {
// Берём полностью
totalValue += item.value;
remainW -= item.weight;
cout << " Take full: v=" << item.value << ", w=" << item.weight << endl;
} else {
// Берём дробную часть
double fraction = remainW / item.weight;
totalValue += item.value * fraction;
cout << " Take " << (fraction*100) << "%: v="
<< item.value * fraction << endl;
remainW = 0;
}
}
return totalValue;
}
int main() {
vector<Item> items = {{60,10}, {100,20}, {120,30}};
double W = 50;
double result = fractionalKnapsack(W, items);
cout << "Max value: " << result << endl; // 240
return 0;
}
⚠️ Подводные камни
Жадный подход работает ТОЛЬКО потому, что мы можем разрезать/дробить предметы. Если применить его к классической задаче "0/1 Рюкзак" (где предметы резать нельзя), он даст ошибочный ответ. Не забудьте использовать тип double, иначе при целочисленном делении `value/weight` алгоритм сломается из-за потери точности.
💼 Где спрашивают
В чистом виде на собеседованиях встречается редко (слишком простая задача). Зато часто используется в олимпиадах как вспомогательная эвристика — он даёт оценку сверху (upper bound) для ускорения метода "ветвей и границ" (Branch and Bound) при поиске точного решения для 0/1 рюкзака.
🔗 Связь с другими
Это жадный брат-близнец задачи о "0/1 Рюкзаке" из раздела Динамического Программирования. Если в условии задачи сказано, что можно брать "часть", то это жадный алгоритм (дробный). Если "только целиком" — сразу переключайтесь на DP.
Путают с 0/1 рюкзаком
❌ Используют DP как для 0/1
// 💀 Для дробного рюкзака это лишнее
// и даже хуже по сложности
vector<int> dp(W + 1, 0);
...
✅ Для дробного достаточно greedy
// ✅ Сортируем по value / weight
sort(items.begin(), items.end(), byRatioDesc);
// берём целиком, потом дробную часть последнего
Сортируют по value вместо value/weight
❌ Неверный критерий
// 💀 Нельзя сортировать только по value
sort(items.begin(), items.end(),
[](auto &a, auto &b){ return a.value > b.value; });
✅ Нужна удельная ценность
sort(items.begin(), items.end(),
[](auto &a, auto &b){
return a.value / a.weight > b.value / b.weight;
});
❓ Проверь себя
Глава 8. Строковые алгоритмы
📌 Обзор строковых алгоритмов
Поиск подстроки в строке — фундаментальная задача. Наивный алгоритм O(n·m), но KMP, Z-функция и Рабин-Карп решают за O(n+m). На собеседованиях часто спрашивают палиндромы (Манакер) и поиск паттерна.
| Алгоритм | Задача | Сложность | Особенность |
|---|---|---|---|
| Наивный | Поиск подстроки | O(n·m) | Простой |
| KMP | Поиск подстроки | O(n+m) | prefix-function |
| Рабин-Карп | Поиск подстроки | O(n+m) средний | Хеширование |
| Z-функция | Поиск подстроки | O(n+m) | Z-array |
| Манакер | Все палиндромы | O(n) | Удивительно красивый |
🔍 Алгоритм Кнута-Морриса-Пратта (KMP)
Идея: При несовпадении символов в наивном алгоритме мы откатываемся и начинаем сначала. KMP использует prefix-функцию (массив неудач), чтобы не пересматривать уже совпавшие символы. Prefix-функция для позиции i — длина наибольшего собственного суффикса подстроки [0..i], который одновременно является её префиксом.
🔎 Построение prefix-функции для "ABABAC"
| i | Символ | Сравнение | π[i] | Пояснение |
|---|---|---|---|---|
| 0 | A | — | 0 | Один символ — всегда 0 |
| 1 | B | B ≠ A | 0 | Нет совпадающего префикса-суффикса |
| 2 | A | A = A | 1 | "A" — и префикс, и суффикс "ABA" |
| 3 | B | B = B | 2 | "AB" — префикс и суффикс "ABAB" |
| 4 | A | A = A | 3 | "ABA" — префикс и суффикс "ABABA" |
| 5 | C | C ≠ B, откат к π[2]=1, C ≠ B, откат к π[0]=0, C ≠ A | 0 | Нет совпадения |
Результат: π = [0, 0, 1, 2, 3, 0]
🔎 Поиск "ABABAC" в "ABABABABAC"
text: A B A B A B A B A C
pattern: A B A B A C
i=0..4: совпадают "ABABA"
i=5: text[5]=B ≠ pattern[5]=C → откат: j = π[4] = 3
text: A B A B A B A B A C
A B A B A C
Продолжаем с j=3: text[5]=B = pattern[3]=B ✓, text[6]=A = pattern[4]=A ✓, text[7]=B ≠ pattern[5]=C → откат: j=π[4]=3
text: A B A B A B A B A C
A B A B A C
Продолжаем: совпадает "ABABAC" → Найдено на позиции 4! ✅
#include <iostream>
#include <vector>
#include <string>
using namespace std;
// ═══ Построение prefix-функции ═══
vector<int> buildPrefixFunction(const string& pattern) {
int m = pattern.size();
vector<int> pi(m, 0);
int len = 0; // длина текущего совпавшего префикса-суффикса
int i = 1;
while (i < m) {
if (pattern[i] == pattern[len]) {
len++;
pi[i] = len;
i++;
} else {
if (len != 0) {
len = pi[len - 1]; // откатываемся, НЕ увеличивая i
} else {
pi[i] = 0;
i++;
}
}
}
return pi;
}
// ═══ Поиск всех вхождений pattern в text ═══
vector<int> kmpSearch(const string& text, const string& pattern) {
int n = text.size(), m = pattern.size();
vector<int> result;
if (m == 0) return result;
vector<int> pi = buildPrefixFunction(pattern);
int j = 0; // указатель в pattern
for (int i = 0; i < n; i++) {
// При несовпадении откатываемся по prefix-функции
while (j > 0 && text[i] != pattern[j])
j = pi[j - 1];
if (text[i] == pattern[j])
j++;
if (j == m) {
result.push_back(i - m + 1); // найдено на позиции i-m+1
j = pi[j - 1]; // продолжаем искать
}
}
return result;
}
// ═══ Применение: проверка, является ли строка периодической ═══
// Строка s периодична с периодом p, если p | n и s = s[0..p-1] повторённая
int findPeriod(const string& s) {
auto pi = buildPrefixFunction(s);
int n = s.size();
int period = n - pi[n - 1];
if (n % period == 0) return period;
return n; // непериодическая
}
int main() {
// Поиск подстроки
string text = "ABABABABAC";
string pattern = "ABABAC";
auto positions = kmpSearch(text, pattern);
cout << "Pattern found at: ";
for (int p : positions) cout << p << " "; // 4
cout << endl;
// Prefix-функция
auto pi = buildPrefixFunction(pattern);
cout << "Prefix function: ";
for (int x : pi) cout << x << " "; // 0 0 1 2 3 0
cout << endl;
// Множественные вхождения
auto pos2 = kmpSearch("AABAACAADAABAABA", "AABA");
cout << "AABA in AABAACAADAABAABA: ";
for (int p : pos2) cout << p << " "; // 0 9 12
cout << endl;
// Период строки
cout << "Period of 'abcabcabc': " << findPeriod("abcabcabc") << endl; // 3
cout << "Period of 'abcabd': " << findPeriod("abcabd") << endl; // 6
return 0;
}
💡 Почему KMP работает за O(n+m)?
Ключевое наблюдение: указатель i никогда не откатывается. Указатель j может уменьшаться, но суммарно за все итерации j увеличивается не более n раз → уменьшается не более n раз. Итого O(n) для поиска + O(m) для prefix-функции.
Наивный поиск подстроки
❌ O(n·m) наивный
for (int i = 0; i <= n-m; i++) {
bool match = true;
for (int j = 0; j < m; j++) // 💀 O(m) на каждую позицию
if (text[i+j] != pattern[j]) { match=false; break; }
if (match) found.push_back(i);
} // Итого O(n·m)✅ O(n+m) KMP
// KMP: при несовпадении не откатываем i!
// Используем prefix-функцию:
while (j > 0 && text[i] != pattern[j])
j = pi[j-1]; // ✅ откат по π
if (text[i] == pattern[j]) j++;
if (j == m) { found.push_back(i-m+1); j = pi[j-1]; }❓ Проверь себя
#️⃣ Алгоритм Рабина-Карпа (Rolling Hash)
Идея: Вычисляем хеш паттерна. Катим «окно» по тексту, пересчитывая хеш за O(1) (полиномиальный rolling hash). Если хеши совпали — сравниваем строки посимвольно (для исключения коллизий).
Формула хеша: h(s) = (s[0]·d^(m-1) + s[1]·d^(m-2) + ... + s[m-1]) mod q
При сдвиге окна: h_new = (d · (h_old - s[i]·d^(m-1)) + s[i+m]) mod q
🔎 Трассировка: text="ABCABCD", pattern="BCD", d=256, q=101
hash(BCD) = (66·256² + 67·256 + 68) mod 101 = 68 (пример)
Окно "ABC": hash = ... mod 101 = 12 ≠ 68 → нет
Окно "BCA": rolling hash → 45 ≠ 68 → нет
Окно "CAB": → 91 ≠ 68 → нет
Окно "ABC": → 12 ≠ 68 → нет
Окно "BCD": → 68 == 68 → хеши совпали! Проверяем: "BCD"=="BCD" ✅ → Найдено на позиции 4
#include <iostream>
#include <vector>
#include <string>
using namespace std;
// ═══ Базовый Rabin-Karp ═══
vector<int> rabinKarp(const string& text, const string& pattern) {
int n = text.size(), m = pattern.size();
vector<int> result;
if (m > n) return result;
const long long d = 256; // размер алфавита
const long long q = 1e9 + 7; // большое простое число
// Вычисляем d^(m-1) mod q
long long h = 1;
for (int i = 0; i < m - 1; i++)
h = (h * d) % q;
// Хеш паттерна и первого окна
long long pHash = 0, tHash = 0;
for (int i = 0; i < m; i++) {
pHash = (d * pHash + pattern[i]) % q;
tHash = (d * tHash + text[i]) % q;
}
for (int i = 0; i <= n - m; i++) {
if (pHash == tHash) {
// Хеши совпали — проверяем посимвольно (защита от коллизий)
if (text.substr(i, m) == pattern)
result.push_back(i);
}
// Rolling hash: убираем text[i], добавляем text[i+m]
if (i < n - m) {
tHash = (d * (tHash - text[i] * h) + text[i + m]) % q;
if (tHash < 0) tHash += q; // отрицательный остаток
}
}
return result;
}
// ═══ Полиномиальное хеширование строки (для задач) ═══
struct StringHash {
vector<long long> h, pw;
long long mod = 1e9 + 7, base = 31;
StringHash(const string& s) {
int n = s.size();
h.resize(n + 1, 0);
pw.resize(n + 1, 1);
for (int i = 0; i < n; i++) {
h[i + 1] = (h[i] * base + s[i] - 'a' + 1) % mod;
pw[i + 1] = pw[i] * base % mod;
}
}
// Хеш подстроки s[l..r] (0-indexed)
long long getHash(int l, int r) {
long long result = (h[r + 1] - h[l] * pw[r - l + 1] % mod + mod * mod) % mod;
return result;
}
// Сравнение подстрок за O(1)
bool equal(int l1, int r1, int l2, int r2) {
return getHash(l1, r1) == getHash(l2, r2);
}
};
int main() {
// Rabin-Karp
auto pos = rabinKarp("AABAACAADAABAABA", "AABA");
cout << "Found at: ";
for (int p : pos) cout << p << " "; // 0 9 12
cout << endl;
// String Hashing
StringHash sh("abcabc");
cout << "hash(abc) == hash(abc): "
<< sh.equal(0, 2, 3, 5) << endl; // 1
cout << "hash(abc) == hash(bca): "
<< sh.equal(0, 2, 1, 3) << endl; // 0
return 0;
}
💡 Когда Rabin-Karp лучше KMP?
• Поиск нескольких паттернов одновременно (вычисляем хеш каждого, проверяем одним проходом)
• 2D поиск паттерна в матрице
• Задачи со сравнением подстрок (string hashing — O(1) сравнение после O(n) предобработки)
Не проверяем после совпадения хешей
❌ Ложные срабатывания
if (pHash == tHash)
result.push_back(i); // 💀 коллизия хеша!
// "ab" и "ba" могут иметь одинаковый хеш✅ Проверяем строки при совпадении
if (pHash == tHash) {
if (text.substr(i, m) == pattern) // ✅
result.push_back(i);
}Отрицательный остаток при rolling hash
❌ Не обрабатываем отрицательный mod
tHash = (d * (tHash - text[i]*h) + text[i+m]) % q;
// 💀 В C++: (-5) % 7 = -5, НЕ 2!✅ Добавляем q при отрицательном
tHash = (d * (tHash - text[i]*h) + text[i+m]) % q;
if (tHash < 0) tHash += q; // ✅❓ Проверь себя
📏 Z-функция
Определение: z[i] = длина наибольшей подстроки, начинающейся с позиции i, которая совпадает с префиксом строки. z[0] = 0 (или n, по определению).
Поиск подстроки: Конкатенируем pattern + "$" + text. Если z[i] == len(pattern) → вхождение на позиции i - len(pattern) - 1.
🔎 Z-функция для "aabxaab"
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| Символ | a | a | b | x | a | a | b |
| z[i] | 0 | 1 | 0 | 0 | 3 | 1 | 0 |
| Пояснение | по опр. | "a"=пр."a" | "b"≠"a" | "x"≠"a" | "aab"=пр."aab" | "a"=пр."a" | "b"≠"a" |
#include <iostream>
#include <vector>
#include <string>
using namespace std;
vector<int> zFunction(const string& s) {
int n = s.size();
vector<int> z(n, 0);
int l = 0, r = 0; // [l, r) — самый правый Z-блок
for (int i = 1; i < n; i++) {
if (i < r)
z[i] = min(r - i, z[i - l]); // используем уже вычисленное
// Наивно расширяем
while (i + z[i] < n && s[z[i]] == s[i + z[i]])
z[i]++;
// Обновляем Z-блок
if (i + z[i] > r) {
l = i;
r = i + z[i];
}
}
return z;
}
// ═══ Поиск pattern в text через Z-функцию ═══
vector<int> zSearch(const string& text, const string& pattern) {
string concat = pattern + "$" + text;
auto z = zFunction(concat);
vector<int> result;
int m = pattern.size();
for (int i = m + 1; i < (int)concat.size(); i++)
if (z[i] == m)
result.push_back(i - m - 1);
return result;
}
// ═══ Задача: сжатие строки ═══
// Найти кратчайшую строку t, чтобы s = t + t + ... + t
string compress(const string& s) {
auto z = zFunction(s);
int n = s.size();
for (int i = 1; i < n; i++) {
// Если s можно разбить на блоки длины i
if (n % i == 0 && z[i] == n - i)
return s.substr(0, i);
}
return s;
}
int main() {
// Z-функция
auto z = zFunction("aabxaab");
cout << "Z-function: ";
for (int x : z) cout << x << " "; // 0 1 0 0 3 1 0
cout << endl;
// Поиск
auto pos = zSearch("AABAACAADAABAABA", "AABA");
cout << "Found at: ";
for (int p : pos) cout << p << " "; // 0 9 12
cout << endl;
// Сжатие
cout << "Compress 'abcabcabc': " << compress("abcabcabc") << endl; // "abc"
cout << "Compress 'abcd': " << compress("abcd") << endl; // "abcd"
return 0;
}
❓ Проверь себя
🪞 Алгоритм Манакера (все палиндромы за O(n))
Задача: Для каждой позиции найти длину максимального палиндрома с центром в этой позиции. Наивно O(n²), Манакер — O(n).
Трюк: Вставляем между символами разделители (#), чтобы единообразно обрабатывать палиндромы чётной и нечётной длины. "abc" → "#a#b#c#"
🔎 Трассировка для "abacaba"
Трансформация: "#a#b#a#c#a#b#a#"
p[] = [0,1,0,3,0,1,0,7,0,1,0,3,0,1,0]
p[7]=7 → палиндром "#a#b#a#c#a#b#a#" с центром 'c' → "abacaba" (длина 7)
p[3]=3 → палиндром "#a#b#a#" с центром 'b' → "aba" (длина 3)
p[11]=3 → палиндром "#a#b#a#" с центром 'b' → "aba" (длина 3)
Самый длинный палиндром: "abacaba"
#include <iostream>
#include <vector>
#include <string>
using namespace std;
string longestPalindrome(const string& s) {
// Трансформация: "abc" → "^#a#b#c#$"
string t = "^#";
for (char c : s) { t += c; t += '#'; }
t += '$';
int n = t.size();
vector<int> p(n, 0); // p[i] = радиус палиндрома с центром i
int center = 0, right = 0;
for (int i = 1; i < n - 1; i++) {
int mirror = 2 * center - i;
if (i < right)
p[i] = min(right - i, p[mirror]);
// Расширяем палиндром
while (t[i + p[i] + 1] == t[i - p[i] - 1])
p[i]++;
// Обновляем центр и правую границу
if (i + p[i] > right) {
center = i;
right = i + p[i];
}
}
// Находим максимум
int maxLen = 0, maxCenter = 0;
for (int i = 1; i < n - 1; i++) {
if (p[i] > maxLen) {
maxLen = p[i];
maxCenter = i;
}
}
// Конвертируем обратно
int start = (maxCenter - maxLen) / 2;
return s.substr(start, maxLen);
}
// ═══ Подсчёт всех палиндромных подстрок ═══
int countPalindromicSubstrings(const string& s) {
string t = "^#";
for (char c : s) { t += c; t += '#'; }
t += '$';
int n = t.size();
vector<int> p(n, 0);
int center = 0, right = 0;
for (int i = 1; i < n - 1; i++) {
if (i < right)
p[i] = min(right - i, p[2 * center - i]);
while (t[i + p[i] + 1] == t[i - p[i] - 1])
p[i]++;
if (i + p[i] > right) {
center = i;
right = i + p[i];
}
}
// Каждый p[i] для '#' — палиндромы чётной длины
// Каждый p[i] для буквы — палиндромы нечётной длины
int count = 0;
for (int i = 1; i < n - 1; i++)
count += (p[i] + 1) / 2; // каждый радиус r даёт ⌈r/2⌉ палиндромов
return count;
}
int main() {
cout << longestPalindrome("babad") << endl; // "bab" или "aba"
cout << longestPalindrome("cbbd") << endl; // "bb"
cout << longestPalindrome("abacaba") << endl; // "abacaba"
cout << longestPalindrome("a") << endl; // "a"
cout << longestPalindrome("racecar") << endl; // "racecar"
cout << "Palindromic substrings in 'abc': "
<< countPalindromicSubstrings("abc") << endl; // 3 (a,b,c)
cout << "Palindromic substrings in 'aaa': "
<< countPalindromicSubstrings("aaa") << endl; // 6 (a,a,a,aa,aa,aaa)
return 0;
}
❓ Проверь себя
Глава 9. Математические алгоритмы
📌 Математика в алгоритмах
Теория чисел, комбинаторика, модульная арифметика — основа олимпиадного программирования и многих практических задач (криптография, хеширование, генерация псевдослучайных чисел). Эти алгоритмы — маст-хэв для сложных секций собеседований.
🔢 НОД и НОК (алгоритм Евклида)
🎯 Интуиция — аналогия из жизни
Представьте, что у вас есть прямоугольный кусок ткани размером 252x105 см. Вы хотите разрезать его на одинаковые идеальные квадраты максимального размера, без остатков. Вы отрезаете самые большие квадраты 105x105 (2 штуки), и у вас остается кусок 105x42. Теперь повторяете процесс для него. Квадрат, на котором ткань закончится ровно — и есть НОД.
📝 Пошаговое текстовое описание
a и b. Если b == 0, то НОД = a.a на b.a на b, а b на остаток.b не станет нулём.(a / НОД) * b.📐 Почему такая сложность?
На каждом втором шаге алгоритма число гарантированно уменьшается минимум в два раза. Поэтому количество шагов пропорционально количеству битов в числе, что даёт логарифмическую сложность O(log(min(a,b))).
🔎 Подробная трассировка: gcd(252, 105)
Шаг 1: 252 mod 105 = 42. Вызываем gcd(105, 42)
Шаг 2: 105 mod 42 = 21. Вызываем gcd(42, 21)
Шаг 3: 42 mod 21 = 0. Вызываем gcd(21, 0)
Шаг 4: b = 0. Возвращаем 21 ✅
Поиск НОК(252, 105):
252 / 21 = 12
12 × 105 = 1260 ✅
#include <iostream>
#include <numeric> // C++17: std::gcd, std::lcm
using namespace std;
// Рекурсивный
long long gcd(long long a, long long b) {
return b == 0 ? a : gcd(b, a % b);
}
// Итеративный (чуть быстрее из-за отсутствия вызовов стека)
long long gcdIter(long long a, long long b) {
while (b) {
a %= b;
swap(a, b);
}
return a;
}
long long lcm(long long a, long long b) {
return a / gcd(a, b) * b; // делим первым для защиты от переполнения!
}
// GCD массива
long long gcdArray(long long arr[], int n) {
long long result = arr[0];
for (int i = 1; i < n; i++)
result = gcd(result, arr[i]);
return result;
}
int main() {
cout << "gcd(252, 105) = " << gcd(252, 105) << endl; // 21
cout << "lcm(252, 105) = " << lcm(252, 105) << endl; // 1260
cout << "gcd(0, 5) = " << gcd(0, 5) << endl; // 5
cout << "gcd(17, 13) = " << gcd(17, 13) << endl; // 1 (взаимно простые)
// C++17
cout << "std::gcd(12,8) = " << std::gcd(12, 8) << endl; // 4
return 0;
}
⚠️ Подводные камни
1. Отрицательные числа: алгоритм Евклида рассчитан на положительные числа. Всегда берите abs() от аргументов.
2. Переполнение в НОК: если считать (a * b) / gcd, умножение может выйти за пределы 64-битного типа. Правильно: (a / gcd) * b.
💼 Где спрашивают
На любых скринингах и олимпиадах. В реальной жизни — основа RSA шифрования, сокращения дробей, нахождения периодов циклов в графах.
🔗 Связь с другими
Является фундаментом для Расширенного алгоритма Евклида (нахождение модульного обратного) и Китайской теоремы об остатках.
Переполнение при вычислении LCM
❌ Перемножают сразу
long long lcm(long long a, long long b) {
return a * b / gcd(a, b); // 💀 a*b может переполниться
}
✅ Делим раньше
long long lcm(long long a, long long b) {
return a / gcd(a, b) * b; // ✅ безопаснее
}
Путают gcd(0, n)
❌ Думают что ответ 0
// 💀 gcd(0, n) НЕ равен 0
// gcd(0, 5) = 5
✅ Правильная база
long long gcd(long long a, long long b) {
return b == 0 ? a : gcd(b, a % b);
}
❓ Проверь себя
🔗 Задачи для практики
🔢 Решето Эратосфена
🎯 Интуиция — аналогия из жизни
Представьте, что вы просеиваете гравий через несколько сит разного размера. Сначала вы убираете все камни, кратные 2 (четные). Затем берете следующий оставшийся камень (3) и убираете все кратные 3. То, что осталось в конце на столе — чистые, неделимые (простые) числа.
📝 Пошаговое текстовое описание
n, заполненный true.false (они не простые).√n.i равно true, то оно простое. Мы проходим по всем его кратным, начиная с i² (т.к. меньшие кратные уже вычеркнуты) и ставим им false.📐 Почему такая сложность?
Мы делаем n/2 + n/3 + n/5 + n/7 + ... операций. Сумма ряда обратных простых чисел растёт как log(log(n)). Итоговое время выполнения — O(n log log n), что на практике почти неотличимо от O(n).
🔎 Подробная трассировка: Решето для n = 30
i = 2: простое. Вычёркиваем 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30.
i = 3: простое. Вычёркиваем начиная с 3²=9: 9, 15, 21, 27 (остальные уже вычеркнуты двойкой).
i = 4: уже вычеркнуто (не простое).
i = 5: простое. Вычёркиваем начиная с 5²=25: 25.
i = 6: уже вычеркнуто.
i = 7: 7² = 49 > 30 → СТОП (внутренний цикл не запустится).
Остались: 2, 3, 5, 7, 11, 13, 17, 19, 23, 29 ✅
#include <iostream>
#include <vector>
using namespace std;
// ═══ Классическое решето ═══
vector<bool> sieve(int n) {
vector<bool> is_prime(n + 1, true);
is_prime[0] = is_prime[1] = false;
for (int i = 2; i * i <= n; i++) {
if (is_prime[i]) {
// Оптимизация: начинаем с i², так как меньшие кратные
// (i*2, i*3) уже были вычеркнуты ранее
for (int j = i * i; j <= n; j += i)
is_prime[j] = false;
}
}
return is_prime;
}
// ═══ Линейное решето (O(n), хранит минимальный делитель SPF) ═══
vector<int> linearSieve(int n) {
vector<int> spf(n + 1, 0); // smallest prime factor
vector<int> primes;
for (int i = 2; i <= n; i++) {
if (spf[i] == 0) { // i — простое
spf[i] = i;
primes.push_back(i);
}
for (int p : primes) {
// Каждое составное число вычёркивается ровно 1 раз
// своим наименьшим простым делителем
if (p > spf[i] || (long long)i * p > n) break;
spf[i * p] = p;
}
}
return spf;
}
// ═══ Быстрая факторизация за O(log n) через SPF ═══
vector<int> factorize(int n, const vector<int>& spf) {
vector<int> factors;
while (n > 1) {
factors.push_back(spf[n]);
n /= spf[n];
}
return factors;
}
int main() {
auto is_prime = sieve(30);
cout << "Primes up to 30: ";
for (int i = 2; i <= 30; i++)
if (is_prime[i]) cout << i << " ";
cout << endl; // 2 3 5 7 11 13 17 19 23 29
auto spf = linearSieve(100);
auto factors = factorize(84, spf);
cout << "84 = ";
for (int f : factors) cout << f << " × "; // 2 × 2 × 3 × 7
cout << endl;
return 0;
}
⚠️ Подводные камни
Частая ошибка — переполнение int в цикле for (int j = i * i; ...). Если i около 46340, i * i превысит предел 32-битного знакового целого числа и уйдет в минус (Segmentation Fault). Решение: писать long long j = 1LL * i * i.
💼 Где спрашивают
Обязательно при решении задач на факторизацию чисел или теорию чисел на LeetCode (Math). Линейное решето спрашивают на Yandex алгоритмических секциях.
🔗 Связь с другими
Решето — это динамическое программирование "наоборот" (мы смотрим в будущее и обновляем состояния элементов впереди, Push DP).
Начинают вычёркивать с 2*i
❌ Лишняя работа
for (int j = 2 * i; j <= n; j += i) {
is_prime[j] = false;
}
// 💀 кратные меньше i*i уже были обработаны раньше
✅ Начинаем с i*i
for (int j = i * i; j <= n; j += i) {
is_prime[j] = false;
}
// ✅ оптимальнее
Забывают что 0 и 1 не простые
❌ Все true по умолчанию
vector<bool> is_prime(n + 1, true);
// 💀 0 и 1 останутся "простыми"
✅ Нужно явно выключить
vector<bool> is_prime(n + 1, true);
is_prime[0] = is_prime[1] = false; // ✅
❓ Проверь себя
🔗 Задачи для практики
⚡ Быстрое возведение в степень (Binary Exponentiation)
🎯 Интуиция — аналогия из жизни
Если вам нужно сложить лист бумаги 1024 раза, вы не складываете его 1024 раза по одному. Вы складываете его пополам 10 раз (т.к. $2^{10} = 1024$). Мы заменяем множество мелких шагов на несколько огромных прыжков возведением базы в квадрат.
📝 Пошаговое текстовое описание
result = 1.exp > 0, проверяем её младший бит. Если она нечётная (exp % 2 == 1), умножаем result на текущую base.exp >>= 1).base в квадрат (base = base * base).📐 Почему такая сложность?
На каждом шаге мы делим показатель степени на 2. Количество делений числа n до 1 — это в точности логарифм по основанию 2. Итого O(log n).
🔎 Подробная трассировка: 3^13
Изначально: result = 1, base = 3, exp = 13 (1101₂)
Итерация 1: exp=13 (нечётное) → result = 1 * 3 = 3. base = 3*3 = 9. exp = 6.
Итерация 2: exp=6 (чётно) → result = 3 (не меняется). base = 9*9 = 81. exp = 3.
Итерация 3: exp=3 (нечётное) → result = 3 * 81 = 243. base = 81*81 = 6561. exp = 1.
Итерация 4: exp=1 (нечётное) → result = 243 * 6561 = 1594323. base = 6561². exp = 0.
СТОП. Результат 1594323 (Всего 4 умножения вместо 12!) ✅
#include <iostream>
using namespace std;
// ═══ Итеративный (рекомендуется, не тратит память стека) ═══
long long fastPow(long long base, long long exp, long long mod) {
long long result = 1;
base %= mod;
while (exp > 0) {
if (exp & 1) // бит 1 (степень нечётная)
result = (result * base) % mod;
base = (base * base) % mod; // возводим основание в квадрат
exp >>= 1; // целочисленное деление на 2
}
return result;
}
// ═══ Рекурсивный (проще для понимания) ═══
long long fastPowRec(long long base, long long exp, long long mod) {
if (exp == 0) return 1;
if (exp % 2 == 1)
return (base % mod * fastPowRec(base, exp - 1, mod)) % mod;
long long half = fastPowRec(base, exp / 2, mod);
return (half * half) % mod;
}
int main() {
const long long MOD = 1e9 + 7;
cout << "3^13 mod MOD = " << fastPow(3, 13, MOD) << endl; // 1594323
cout << "2^10 mod 1000 = " << fastPow(2, 10, 1000) << endl; // 24 (1024 % 1000)
// По Малой теореме Ферма: a^(-1) ≡ a^(p-2) (mod p)
long long inv7 = fastPow(7, MOD - 2, MOD);
cout << "7^(-1) mod (10^9+7) = " << inv7 << endl;
cout << "Check: (7 * inv7) % MOD = " << (7LL * inv7 % MOD) << endl; // 1
return 0;
}
⚠️ Подводные камни
При перемножении `(result * base)` оба числа могут быть около `10^9`. Их произведение будет `10^{18}`, что помещается в `long long`, но переполнит обычный 32-битный `int`. Обязательно используйте `long long` везде.
💼 Где спрашивают
Любые задачи на комбинаторику (нахождение обратного факториала), матричное возведение в степень (алгоритм там 1-в-1 такой же), реализация функции `pow()` с плавающей запятой.
🔗 Связь с другими
Построено по парадигме Разделяй-и-властвуй. Является корнем алгоритма RSA (там возводятся огромные числа по модулю).
Наивное умножение exp раз
❌ O(n)
long long power(long long a, long long n) {
long long res = 1;
for (long long i = 0; i < n; i++) res *= a;
return res;
}
// 💀 слишком медленно для n = 10^18
✅ O(log n)
while (n > 0) {
if (n & 1) res = res * a % mod;
a = a * a % mod;
n >>= 1;
}
Забывают брать mod после умножения
❌ Быстрое переполнение
res = res * a; // 💀 overflow
a = a * a; // 💀 overflow ещё быстрее
✅ mod после каждого шага
res = res * a % mod;
a = a * a % mod; // ✅
❓ Проверь себя
🔗 Задачи для практики
➗ Модульная арифметика
🎯 Интуиция — аналогия из жизни
Циферблат механических часов — это арифметика по модулю 12. Если сейчас 10 часов, и прошло 5 часов, мы не говорим "15 часов". Мы говорим "3 часа" (15 mod 12). Мир зацикливается, предотвращая бесконечный рост чисел (переполнение памяти компьютера).
📝 Пошаговое текстовое описание
% MOD.MOD: (A - B + MOD) % MOD.(A / B) мы умножаем A на модульное обратное число B⁻¹.B^(MOD-2) % MOD (работает только если MOD — простое число).📐 Почему такая сложность?
Арифметика занимает $O(1)$. Поиск обратного элемента (деление) требует быстрого возведения в степень, что занимает $O(\log(\text{MOD}))$.
🔎 Подробная трассировка: вычисляем (5! / 3!) mod 7
5! = 120, 3! = 6. Ответ должен быть 120 / 6 = 20 mod 7 = 6.
Считаем по модулю:
Fact5 = (1 * 2 * 3 * 4 * 5) mod 7 = 120 mod 7 = 1.
Fact3 = (1 * 2 * 3) mod 7 = 6 mod 7 = 6.
Нужно: (1 / 6) mod 7.
Ищем обратное для 6: 6^(7-2) mod 7 = 6^5 mod 7.
6^5 = 7776. 7776 mod 7 = 6.
Умножаем: (1 * 6) mod 7 = 6. ✅ Совпало!
#include <iostream>
using namespace std;
const long long MOD = 1e9 + 7;
long long mod(long long a) { return ((a % MOD) + MOD) % MOD; }
long long add(long long a, long long b) { return mod(mod(a) + mod(b)); }
long long sub(long long a, long long b) { return mod(mod(a) - mod(b) + MOD); }
long long mul(long long a, long long b) { return mod(mod(a) * mod(b)); }
// Быстрая степень для обратного
long long power(long long base, long long exp) {
long long result = 1; base %= MOD;
while (exp > 0) {
if (exp & 1) result = (result * base) % MOD;
base = (base * base) % MOD;
exp >>= 1;
}
return result;
}
// Обратный элемент
long long modInverse(long long a) {
return power(a, MOD - 2);
}
// Деление по модулю
long long divide(long long a, long long b) {
return mul(a, modInverse(b));
}
int main() {
// (10^18 + 10^18) mod (10^9+7)
long long hugeA = 1000000000000000000LL;
long long hugeB = 1000000000000000000LL;
cout << "Add: " << add(hugeA, hugeB) << endl; // Без переполнений
// Вычитание, уходящее в минус
cout << "Sub: " << sub(2, 5) << endl; // -3 mod (1e9+7) = 1000000004
// Деление (5! / 3!)
cout << "Div (5!/3!): " << divide(120, 6) << endl; // 20
return 0;
}
⚠️ Подводные камни
В C++ оператор % для отрицательных чисел дает отрицательный результат (-5 % 3 = -2). Если это использовать как индекс массива или хэш — будет краш. Всегда нормализуйте через (a % MOD + MOD) % MOD.
💼 Где спрашивают
Везде, где в задаче сказано: "Так как ответ может быть огромным, верните его по модулю 10^9+7". Это стандарт LeetCode / Codeforces.
🔗 Связь с другими
Фундамент для комбинаторики и вычисления хешей Рабина-Карпа. Если модуль не простое число, теорема Ферма не работает, и нужно использовать Расширенный алгоритм Евклида для деления.
Отрицательный mod в C++
❌ Думают что (-3) % MOD положительный
long long x = (a - b) % MOD; // 💀 может быть отрицательным
✅ Нормализуем
long long x = (a - b + MOD) % MOD; // ✅
Деление по модулю как обычное деление
❌ a / b mod MOD — не так
long long ans = (a / b) % MOD; // 💀 почти всегда неверно
✅ Умножаем на обратный элемент
long long ans = a * modInverse(b) % MOD; // ✅
❓ Проверь себя
🔗 Задачи для практики
🎰 Комбинаторика (C(n,k), факториалы)
🎯 Интуиция — аналогия из жизни
Сколькими способами можно собрать команду из 5 человек, если в классе 30 учеников? Это "Сочетания" или Биномиальный коэффициент C(n, k). Важно не то, в каком порядке их назовут, а конечный состав команды.
📝 Пошаговое текстовое описание
C(n, k) = n! / (k! × (n-k)!).MAXN (массив `fact`).inv_fact[MAXN-1].inv_fact[i] = inv_fact[i+1] * (i+1).📐 Почему такая сложность?
Мы тратим O(n) времени и памяти в самом начале программы, чтобы один раз заполнить массивы. После этого запрос на любые параметры n, k обрабатывается за O(1) умножением 3-х элементов массивов.
🔎 Подробная трассировка: C(5,2)
fact массив: [1, 1, 2, 6, 24, 120] (факториалы 0! .. 5!)
Нужно найти 120 / (2 * 6) = 10.
В коде мы берем:
fact[5] = 120
inv_fact[2] = обратный к 2
inv_fact[3] = обратный к 6
120 * inv(2) * inv(6) mod MOD даст ровно 10 ✅.
#include <iostream>
#include <vector>
using namespace std;
const int MAXN = 200001;
const long long MOD = 1e9 + 7;
long long fact[MAXN], inv_fact[MAXN];
long long power(long long base, long long exp) {
long long result = 1; base %= MOD;
while (exp > 0) {
if (exp & 1) result = result * base % MOD;
base = base * base % MOD;
exp >>= 1;
}
return result;
}
void precompute() {
fact[0] = 1;
for (int i = 1; i < MAXN; i++)
fact[i] = (fact[i - 1] * i) % MOD;
// Вычисляем обратный факториал только для самого большого числа O(log MOD)
inv_fact[MAXN - 1] = power(fact[MAXN - 1], MOD - 2);
// Остальные получаем за O(1) итеративно! (1/3! = 1/4! * 4)
for (int i = MAXN - 2; i >= 0; i--)
inv_fact[i] = (inv_fact[i + 1] * (i + 1)) % MOD;
}
// ═══ Сочетания C(n, k) за O(1) ═══
long long C(int n, int k) {
if (k < 0 || k > n) return 0;
return fact[n] * inv_fact[k] % MOD * inv_fact[n - k] % MOD;
}
// ═══ Перестановки P(n, k) за O(1) ═══
long long P(int n, int k) {
if (k < 0 || k > n) return 0;
return fact[n] * inv_fact[n - k] % MOD;
}
// ═══ Числа Каталана: C_n = C(2n, n) / (n+1) ═══
long long catalan(int n) {
return C(2 * n, n) * power(n + 1, MOD - 2) % MOD;
}
// ═══ Треугольник Паскаля (без предрасчетов, O(N^2)) ═══
vector<vector<long long>> pascalTriangle(int n) {
vector<vector<long long>> dp(n + 1, vector<long long>(n + 1, 0));
for (int i = 0; i <= n; i++) {
dp[i][0] = dp[i][i] = 1;
for (int j = 1; j < i; j++)
dp[i][j] = (dp[i-1][j-1] + dp[i-1][j]) % MOD;
}
return dp;
}
int main() {
precompute(); // НЕ ЗАБУДЬТЕ ВЫЗВАТЬ В MAIN!
cout << "C(5,2) = " << C(5, 2) << endl; // 10
cout << "C(10,3) = " << C(10, 3) << endl; // 120
cout << "C(52,5) = " << C(52, 5) << endl; // 2598960
cout << "P(5,3) = " << P(5, 3) << endl; // 60
// Числа Каталана
for (int i = 0; i <= 6; i++)
cout << "Cat(" << i << ")=" << catalan(i) << " ";
// 1 1 2 5 14 42 132
cout << endl;
return 0;
}
⚠️ Подводные камни
Частая ошибка — забыть вызвать `precompute()` в начале `main()`. Вторая частая ошибка — попытаться считать обратные факториалы в прямом порядке (в лоб вызывая `power` $N$ раз), что даст $O(N \log M)$ и приведет к Time Limit.
💼 Где спрашивают
Основа для задач по теории вероятностей и динамическому программированию (например, "сколько путей из левого верхнего в правый нижний угол сетки" = $C(n+m-2, n-1)$).
🔗 Связь с другими
Числа Каталана — частный случай комбинаторики, решают огромный пласт задач (число правильных скобочных последовательностей, количество бинарных деревьев с $n$ узлами, триангуляции многоугольника).
Считают C(n,k) через factorial без модуля
❌ Быстрое переполнение
long long c = fact(n) / (fact(k) * fact(n-k)); // 💀 overflow уже на маленьких n
✅ factorial + inv_factorial mod p
C(n,k) = fact[n] * invFact[k] % MOD * invFact[n-k] % MOD;
Не проверяют k > n
❌ Индексы уходят в минус
// 💀 C(5, 7) не существует
// если не проверить — программа сломается
✅ Возвращаем 0
if (k < 0 || k > n) return 0; // ✅
❓ Проверь себя
🔗 Задачи для практики
φ Функция Эйлера (Euler's Totient)
🎯 Интуиция — аналогия из жизни
Функция Эйлера $\phi(n)$ показывает количество чисел до $n$, которые "не имеют ничего общего" (взаимно просты) с $n$. Как будто вы ищете людей в толпе, с которыми у вас нет ни одного общего родственника (простого множителя).
📝 Пошаговое текстовое описание
res -= res / p.while (n % p == 0) n /= p).res -= res / n.📐 Почему такая сложность?
Такая же как у факторизации — мы идём до $\sqrt{n}$ O(√n). При использовании алгоритма наподобие решета Эратосфена, мы находим значения для всех чисел до $n$ за O(n log log n).
🔎 Подробная трассировка: φ(12)
Изначально: res = 12, n = 12.
p = 2: 12 % 2 == 0.
- Обновляем ответ: res = 12 - 12/2 = 6.
- Делим 12 на 2 пока делится: 12 → 6 → 3. Теперь n = 3.
p = 3: 3 % 3 == 0.
- Обновляем ответ: res = 6 - 6/3 = 4.
- Делим 3 на 3: 3 → 1. Теперь n = 1.
Цикл до $\sqrt{1}$ завершён. Конечный $n=1$, значит простых множителей больше нет.
Результат 4. И правда, взаимно простые с 12 до 12: 1, 5, 7, 11 (их 4 штуки) ✅.
#include <iostream>
#include <vector>
using namespace std;
// ═══ Для одного числа: O(√n) ═══
long long eulerTotient(long long n) {
long long result = n;
for (long long p = 2; p * p <= n; p++) {
if (n % p == 0) {
// Вычитаем долю чисел, кратных p
result -= result / p;
// Удаляем все множители p
while (n % p == 0)
n /= p;
}
}
// Если остался простой делитель > √n
if (n > 1)
result -= result / n;
return result;
}
// ═══ Решето для вычисления φ от 1 до n: O(n log log n) ═══
vector<int> eulerSieve(int n) {
vector<int> phi(n + 1);
for (int i = 0; i <= n; i++) phi[i] = i; // Изначально phi[i] = i
for (int i = 2; i <= n; i++) {
// Если phi[i] не изменилось, то i — простое число
if (phi[i] == i) {
for (int j = i; j <= n; j += i) {
// Все кратные числу i содержат делитель i
phi[j] -= phi[j] / i;
}
}
}
return phi;
}
int main() {
cout << "φ(12) = " << eulerTotient(12) << endl; // 4
cout << "φ(36) = " << eulerTotient(36) << endl; // 12
cout << "φ(97) = " << eulerTotient(97) << endl; // 96 (простое)
auto phi = eulerSieve(20);
for (int i = 1; i <= 20; i++)
cout << "φ(" << i << ")=" << phi[i] << " ";
cout << endl;
return 0;
}
⚠️ Подводные камни
Крайне частая ошибка — забыть блок if (n > 1) result -= result / n;. У чисел может быть ровно один простой делитель больше квадратного корня из числа (например у 10 это 5, так как $\sqrt{10} \approx 3.16$, цикл остановится на 3, и 5 будет утеряно). Эта проверка гарантирует учет последнего хвоста.
💼 Где спрашивают
Редко напрямую, но необходимо для Теоремы Эйлера $a^{\phi(m)} \equiv 1 \pmod m$, которая позволяет брать обратный по модулю элемент для составных модулей.
🔗 Связь с другими
Код почти идентичен поиску делителей (факторизации) и Решету Эратосфена.
Пытаются считать φ(n) перебором всех чисел
❌ O(n log n)
int cnt = 0;
for (int i = 1; i <= n; i++)
if (gcd(i, n) == 1) cnt++; // 💀 слишком медленно
✅ Разложение по простым делителям
phi(n) = n * Π(1 - 1/p)
// по всем различным простым делителям p
Обрабатывают одинаковый простой делитель несколько раз
❌ Вычитают для каждого вхождения p
// 💀 Для n = 12 = 2^2 * 3
// нельзя применять result -= result / 2 дважды
✅ Только по различным простым
if (n % p == 0) {
while (n % p == 0) n /= p;
result -= result / p; // ✅ один раз на p
}
❓ Проверь себя
🔗 Задачи для практики
🔢 Расширенный алгоритм Евклида
🎯 Интуиция — аналогия из жизни
У вас есть две гири весом $A$ и $B$ грамм и чашечные весы. Какой вес вы сможете отмерить? Вы сможете отмерить любой вес $C$, который кратен НОД(A, B). Расширенный Евклид скажет вам, не только можно ли отмерить вес $C$, но и сколько гирь каждого типа положить на левую ($x$) и правую ($y$) чашу.
📝 Пошаговое текстовое описание
gcd(B, A % B).x = y1 и y = x1 - (A / B) * y1.📐 Почему такая сложность?
Так как это обычный алгоритм Евклида с парой дополнительных математических операций на каждом возврате из рекурсии, сложность остается логарифмической — O(log(min(a,b))).
🔎 Подробная трассировка: extgcd(30, 21)
Спуск:
1. a=30, b=21 → 30 % 21 = 9. Вызов(21, 9)
2. a=21, b=9 → 21 % 9 = 3. Вызов(9, 3)
3. a=9, b=3 → 9 % 3 = 0. Вызов(3, 0)
4. b=0 → x=1, y=0. База.
Подъём (пересчёт):
3. (9, 3): x1=1, y1=0 → x=0, y=1 - (9/3)*0 = 1.
2. (21, 9): x1=0, y1=1 → x=1, y=0 - (21/9)*1 = -2.
1. (30, 21): x1=1, y1=-2 → x=-2, y=1 - (30/21)*(-2) = 1 - 1*(-2) = 3.
Ответ: x = -2, y = 3.
Проверка: 30*(-2) + 21*3 = -60 + 63 = 3. (А 3 — это НОД(30,21)) ✅.
#include <iostream>
using namespace std;
// Возвращает gcd, записывает x, y: a*x + b*y = gcd(a,b)
long long extgcd(long long a, long long b, long long &x, long long &y) {
if (b == 0) {
x = 1;
y = 0;
return a;
}
long long x1, y1;
long long g = extgcd(b, a % b, x1, y1);
x = y1;
y = x1 - (a / b) * y1;
return g;
}
// ═══ Использование 1: Модульное обратное по ЛЮБОМУ модулю ═══
// Работает, только если a и m взаимно просты (НОД=1)
long long modInverse(long long a, long long m) {
long long x, y;
long long g = extgcd(a, m, x, y);
if (g != 1) return -1; // обратного не существует
return (x % m + m) % m; // x может быть отрицательным!
}
// ═══ Использование 2: Линейное диофантово уравнение ax + by = c ═══
// Решение существует тогда и только тогда, когда c кратно НОД(a, b)
bool solveDiophantine(long long a, long long b, long long c,
long long &x, long long &y) {
long long g = extgcd(abs(a), abs(b), x, y);
if (c % g != 0) return false;
x *= c / g;
y *= c / g;
if (a < 0) x = -x;
if (b < 0) y = -y;
return true;
}
int main() {
long long x, y;
long long g = extgcd(30, 21, x, y);
cout << "30 * (" << x << ") + 21 * (" << y << ") = " << g << endl;
// 30*(-2) + 21*(3) = 3
cout << "3^(-1) mod 11 = " << modInverse(3, 11) << endl; // 4 (3*4=12≡1)
// Модуль 26 не простое! Ферма здесь не работает, а Евклид работает:
cout << "7^(-1) mod 26 = " << modInverse(7, 26) << endl; // 15 (7*15=105≡1)
return 0;
}
⚠️ Подводные камни
Коэффициенты $x$ и $y$ могут перекрещиваться знаками (один +, другой -). При использовании $x$ как "модульного обратного" обязательно делайте `(x % M + M) % M`, чтобы сдвинуть отрицательное число в положительный остаток.
💼 Где спрашивают
Любые задачи на деление по модулю, где модуль НЕ является простым числом (значит Малая теорема Ферма бессильна).
🔗 Связь с другими
Основа для решения Китайской теоремы об остатках (CRT).
Путают x и y в обратном ходе
❌ Неверная формула восстановления
// 💀 x = x1, y = y1 — так нельзя
x = x1;
y = y1;
✅ Правильный переход
x = y1;
y = x1 - (a / b) * y1; // ✅
Ищут модульный обратный когда gcd(a,m) ≠ 1
❌ Обратного может не существовать
// 💀 inverse(6 mod 9) не существует,
// потому что gcd(6,9)=3 ≠ 1
✅ Сначала проверяем gcd
long long g = extgcd(a, m, x, y);
if (g != 1) return -1; // ✅ inverse doesn't exist
❓ Проверь себя
🔗 Задачи для практики
🇨🇳 Китайская теорема об остатках (CRT)
🎯 Интуиция — аналогия из жизни
Представьте, что у полководца есть отряд солдат. Он приказывает им построиться по 3 человека в ряд — остаются 2 лишних. Построиться по 5 — остаются 3 лишних. Построиться по 7 — остаются 2 лишних. Сколько всего солдат? Китайская теорема позволяет по системе таких остатков найти точное число (оно единственно в рамках произведения модулей: $3 \times 5 \times 7 = 105$).
📝 Пошаговое текстовое описание
X такое, что X ≡ r₁ (mod m₁), X ≡ r₂ (mod m₂) и т.д. Все модули $m_i$ должны быть взаимно простыми.M = m₁ * m₂ * ... * mₖ.X = X + r_i * M_i * inv_i.X берем по модулю M.📐 Почему такая сложность?
У нас $k$ уравнений. Для каждого мы вызываем Расширенный Евклид, который работает за $O(\log(m_i))$. Значит общая сложность — O(k log(max\_m)).
🔎 Подробная трассировка: X ≡ 2 (mod 3), X ≡ 3 (mod 5), X ≡ 2 (mod 7)
M = 3×5×7 = 105.
Уравнение 1 (m=3, r=2): M₁ = 105/3 = 35. Обратное для 35 по мод. 3: 35 mod 3 = 2. 2⁻¹ mod 3 = 2. Слагаемое = 2 × 35 × 2 = 140.
Уравнение 2 (m=5, r=3): M₂ = 105/5 = 21. Обратное для 21 по мод. 5: 21 mod 5 = 1. 1⁻¹ mod 5 = 1. Слагаемое = 3 × 21 × 1 = 63.
Уравнение 3 (m=7, r=2): M₃ = 105/7 = 15. Обратное для 15 по мод. 7: 15 mod 7 = 1. 1⁻¹ mod 7 = 1. Слагаемое = 2 × 15 × 1 = 30.
Итого: X = 140 + 63 + 30 = 233.
Финальный ответ: 233 mod 105 = 23 ✅.
(23/3 = 7 ост 2, 23/5 = 4 ост 3, 23/7 = 3 ост 2).
#include <iostream>
#include <vector>
using namespace std;
// Расширенный алгоритм Евклида из предыдущего шага
long long extgcd(long long a, long long b, long long &x, long long &y) {
if (b == 0) { x = 1; y = 0; return a; }
long long x1, y1;
long long g = extgcd(b, a % b, x1, y1);
x = y1; y = x1 - (a / b) * y1;
return g;
}
// CRT для системы x ≡ r[i] (mod m[i])
long long crt(const vector<long long>& r, const vector<long long>& m) {
long long M = 1;
for (long long mi : m)
M *= mi;
long long x = 0;
for (int i = 0; i < (int)r.size(); i++) {
long long Mi = M / m[i];
long long xi, yi;
// Mi * xi ≡ 1 (mod m[i])
extgcd(Mi, m[i], xi, yi);
// Нормализуем xi (может быть отрицательным)
xi = (xi % m[i] + m[i]) % m[i];
// Собираем всё вместе, берём по модулю M во избежание переполнений
long long term = (r[i] % M) * (Mi % M) % M;
term = (term * xi) % M;
x = (x + term) % M;
}
return x;
}
int main() {
// Задача с солдатами: x ≡ 2 (mod 3), x ≡ 3 (mod 5), x ≡ 2 (mod 7)
vector<long long> r = {2, 3, 2};
vector<long long> m = {3, 5, 7};
cout << "X = " << crt(r, m) << endl; // 23
return 0;
}
⚠️ Подводные камни
Произведение `r[i] * Mi * xi` может легко превысить 64 бита (long long) даже если $M$ помещается. В C++ используйте тип `__int128` (если поддерживается компилятором) или умножайте пошагово с взятием `% M` как в коде выше.
💼 Где спрашивают
Олимпиадное программирование. RSA шифрование: ускорение дешифровки часто базируется на CRT (CRT-RSA).
🔗 Связь с другими
Это надстройка над Расширенным алгоритмом Евклида. Взаимно простые модули — обязательное условие. Если они не взаимно простые, CRT заменяется пошаговым решением Диофантовых уравнений.
Не проверяют взаимную простоту модулей
❌ Используют CRT на любых модулях
// 💀 Классическая CRT требует,
// чтобы модули были попарно взаимно просты
✅ Проверять gcd(mi, mj)=1
// ✅ Иначе нужна обобщённая CRT,
// а не простая классическая версия
Забывают брать mod M в сумме
❌ Сумма растёт без ограничений
x += r[i] * Mi * inv; // 💀 может переполниться
✅ Каждое действие по mod M
x = (x + r[i] % M * (Mi % M) % M * inv % M) % M; // ✅
❓ Проверь себя
🔗 Задачи для практики
📐 Матричное возведение в степень
🎯 Интуиция — аналогия из жизни
Если машина разгоняется со скоростью, зависящей от её скорости в предыдущие 2 секунды (как Фибоначчи), то чтобы узнать скорость на 10-й секунде, мы можем не шагать по 1 секунде. Мы можем составить "матрицу перехода" (формулу шага), возвести эту формулу в 10-ю степень за логарифмическое время и применить 1 раз.
📝 Пошаговое текстовое описание
k — порядок рекурренты (сколько предыдущих членов нужно для вычисления нового). Для Фибоначчи k = 2.M размером $k \times k$. Верхняя строка — это коэффициенты уравнения. Остальные строки — сдвиг "предыдущих" состояний (единицы под главной диагональю).M * M).M^{n-1} на столбец базовых случаев.📐 Почему такая сложность?
Умножение двух матриц размера $k \times k$ требует $k^3$ операций. При быстром возведении мы умножаем матрицы $\log n$ раз. Итого O(k³ log n).
🔎 Подробная трассировка: Фибоначчи через матрицу
Матрица M: [[1, 1], [1, 0]]. База: [F(1), F(0)] = [1, 0].
Нужно найти F(3). Значит, считаем M^(3-1) = M^2.
M^2 = [[1,1],[1,0]] × [[1,1],[1,0]] = [[2,1],[1,1]]
Умножаем на базу:
[[2, 1], [1, 1]] × [1, 0]ᵀ = [2*1+1*0, 1*1+1*0]ᵀ = [2, 1]ᵀ
Верхний элемент вектора — 2. Это F(3)! ✅
#include <iostream>
#include <vector>
using namespace std;
const long long MOD = 1e9 + 7;
typedef vector<vector<long long>> Matrix;
// Умножение матриц: O(K^3)
Matrix multiply(const Matrix& A, const Matrix& B) {
int n = A.size(), m = B[0].size(), k = B.size();
Matrix C(n, vector<long long>(m, 0));
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
for (int p = 0; p < k; p++)
C[i][j] = (C[i][j] + A[i][p] * B[p][j]) % MOD;
return C;
}
// Быстрое возведение матрицы в степень: O(K^3 log(exp))
Matrix matPow(Matrix M, long long exp) {
int n = M.size();
Matrix result(n, vector<long long>(n, 0));
// Единичная матрица (аналог 1 для чисел)
for (int i = 0; i < n; i++) result[i][i] = 1;
while (exp > 0) {
if (exp & 1) result = multiply(result, M);
M = multiply(M, M);
exp >>= 1;
}
return result;
}
// ═══ Классика: Фибоначчи за O(log n) ═══
long long fibMatrix(long long n) {
if (n <= 1) return n;
Matrix M = {{1, 1}, {1, 0}};
Matrix result = matPow(M, n - 1);
return result[0][0]; // Ответ в [0][0], т.к. база [1,0]
}
// ═══ Общая рекуррента: f(n) = c1*f(n-1) + ... + ck*f(n-k) ═══
long long linearRecurrence(const vector<long long>& coeffs,
const vector<long long>& initial,
long long n) {
int k = coeffs.size();
if (n < k) return initial[n] % MOD;
// Матрица перехода k×k
Matrix M(k, vector<long long>(k, 0));
// Верхняя строка = коэффициенты
for (int j = 0; j < k; j++) M[0][j] = coeffs[j] % MOD;
// Диагональ под главной
for (int i = 1; i < k; i++) M[i][i-1] = 1;
Matrix result = matPow(M, n - k + 1);
long long ans = 0;
for (int j = 0; j < k; j++)
ans = (ans + result[0][j] * initial[k - 1 - j]) % MOD;
return ans;
}
int main() {
cout << "Fib(10) = " << fibMatrix(10) << endl; // 55
cout << "Fib(50) = " << fibMatrix(50) << endl; // 12586269025
cout << "Fib(10^18) mod MOD = " << fibMatrix(1000000000000000000LL) << endl;
// Трибоначчи: f(n) = 1*f(n-1) + 1*f(n-2) + 1*f(n-3), база: 0, 0, 1
cout << "Tribonacci(10) = "
<< linearRecurrence({1,1,1}, {0,0,1}, 10) << endl; // 81
return 0;
}
⚠️ Подводные камни
Не забудьте правильно проинициализировать единичную матрицу `result`. Это матрица из нулей, у которой на главной диагонали стоят единицы `result[i][i] = 1`. Ошибка в инициализации (например, вся матрица из 1) сломает логику.
💼 Где спрашивают
Задачи типа: "Муравей ходит по графу, найдите количество путей длины $10^9$". В этом случае матрица смежности возводится в степень $10^9$. Динамическое программирование на огромных диапазонах ($N \approx 10^{18}$).
🔗 Связь с другими
Это комбинация Быстрого возведения в степень и Динамического программирования.
Считают Фибоначчи линейно для огромного n
❌ O(n)
// 💀 fib(10^18) так не посчитать
for (long long i = 2; i <= n; i++) ...
✅ O(log n) через матрицы
// ✅ M^n за O(log n)
// потом умножаем на вектор начальных значений
Забывают единичную матрицу как нейтральный элемент
❌ result = нулевая матрица
// 💀 Тогда умножение всё обнулит
Matrix result(n, vector<long long>(n, 0));
✅ result = identity matrix
Matrix result(n, vector<long long>(n, 0));
for (int i = 0; i < n; i++) result[i][i] = 1; // ✅
❓ Проверь себя
🔗 Задачи для практики
〰️ БПФ / NTT (Быстрое преобразование Фурье)
🎯 Интуиция — аналогия из жизни
Представьте, что вы смешиваете две банки разной краски. Делать это по каплям (наивное умножение) долго. Но вы можете разложить каждую банку на спектр цветов через призму (прямое Фурье), смешать нужные спектры вместе поточечно, а потом пропустить результат через обратную призму (обратное Фурье), получив идеальный цвет за меньшее время.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Разделяй-и-властвуй. На каждом уровне рекурсии (или бабочки) мы делим полином на четные и нечетные степени. Глубина дерева $\log N$, работа на уровне $N$, итого O(N log N).
🔎 Подробная трассировка: умножение полиномов
A(x) = 1 + 2x + 3x² → Вектор: [1, 2, 3]
B(x) = 4 + 5x → Вектор: [4, 5]
C(x) = A(x) × B(x) = 4 + (5+8)x + (10+12)x² + 15x³ = 4 + 13x + 22x² + 15x³
Вектор ответа: [4, 13, 22, 15]
Наивный метод требует $3 \times 2 = 6$ умножений. FFT преобразует их в массивы по 4 элемента, перемножает 4 раза поточечно и возвращает. При числах длиной $10^5$ цифр FFT делает $10^6$ операций вместо $10^{10}$!
#include <iostream>
#include <vector>
using namespace std;
const long long MOD = 998244353; // NTT-friendly prime: 998244353 = 119 * 2^23 + 1
const long long g_root = 3; // первообразный корень (primitive root)
long long power(long long base, long long exp) {
long long result = 1; base %= MOD;
while (exp > 0) {
if (exp & 1) result = result * base % MOD;
base = base * base % MOD;
exp >>= 1;
}
return result;
}
// Само преобразование
void ntt(vector<long long>& a, bool inverse) {
int n = a.size();
if (n == 1) return;
// Bit-reversal перестановка
for (int i = 1, j = 0; i < n; i++) {
int bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i < j) swap(a[i], a[j]);
}
// Фазы бабочки (butterfly)
for (int len = 2; len <= n; len <<= 1) {
long long w = inverse ? power(g_root, MOD - 1 - (MOD - 1) / len)
: power(g_root, (MOD - 1) / len);
for (int i = 0; i < n; i += len) {
long long wn = 1;
for (int j = 0; j < len / 2; j++) {
long long u = a[i + j];
long long v = a[i + j + len/2] * wn % MOD;
a[i + j] = (u + v) % MOD;
a[i + j + len/2] = (u - v + MOD) % MOD;
wn = wn * w % MOD;
}
}
}
// При обратном делим всё на n
if (inverse) {
long long n_inv = power(n, MOD - 2);
for (auto& x : a) x = x * n_inv % MOD;
}
}
// Умножение двух полиномов (или длинных чисел)
vector<long long> multiply(vector<long long> a, vector<long long> b) {
int result_size = a.size() + b.size() - 1;
int n = 1;
while (n < result_size) n <<= 1; // добиваем до степени двойки
a.resize(n); b.resize(n);
ntt(a, false); // Прямое преобразование A
ntt(b, false); // Прямое преобразование B
for (int i = 0; i < n; i++)
a[i] = a[i] * b[i] % MOD; // Поточечное умножение
ntt(a, true); // Обратное преобразование
a.resize(result_size);
return a;
}
int main() {
// Полиномы: [1, 2, 3] × [4, 5] = [4, 13, 22, 15]
vector<long long> a = {1, 2, 3};
vector<long long> b = {4, 5};
auto c = multiply(a, b);
cout << "Polynomial Product: ";
for (long long x : c) cout << x << " "; // 4 13 22 15
cout << endl;
// Умножение больших чисел: 123 × 456
// 123 = [3, 2, 1], 456 = [6, 5, 4] (ВАЖНО: младшие разряды в массиве первыми)
vector<long long> num1 = {3, 2, 1};
vector<long long> num2 = {6, 5, 4};
auto product = multiply(num1, num2);
// Нормализация разрядов (обработка переносов)
long long carry = 0;
cout << "123 × 456 = ";
string result;
for (int i = 0; i < (int)product.size(); i++) {
long long val = product[i] + carry;
carry = val / 10;
result = char('0' + val % 10) + result;
}
while (carry) {
result = char('0' + carry % 10) + result;
carry /= 10;
}
cout << result << endl; // 56088
return 0;
}
⚠️ Подводные камни
NTT работает только для специальных "NTT-friendly" простых модулей (чаще всего 998244353), потому что модулю нужно свойство: $MOD - 1$ должно делиться на большую степень двойки. Классический FFT с std::complex страдает от ошибок округления `double`.
💼 Где спрашивают
Редкий гость на стандартных собеседованиях, но золотой стандарт для сложных олимпиад (свёртки вероятностей, строки с джокерами (wildcards), перемножение супер-длинных чисел).
🔗 Связь с другими
Использует битовые операции (bit-reversal) и модульную арифметику. Это кульминация техники Divide & Conquer.
Пытаются умножать полиномы наивно при больших n
❌ O(n²)
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
c[i+j] += a[i] * b[j]; // 💀 слишком долго при n=10^5
✅ FFT / NTT даёт O(n log n)
// ✅ сначала transform, потом point-wise multiply,
// потом inverse transform
Забывают нормализовать после inverse transform
❌ Ответ увеличен в n раз
// 💀 После inverse нужно делить на n
// иначе коэффициенты будут неверны
✅ Домножаем на n^{-1}
if (inverse) {
long long n_inv = power(n, MOD - 2, MOD);
for (auto &x : a) x = x * n_inv % MOD;
}
❓ Проверь себя
🔗 Задачи для практики
Глава 10. Прочие техники
📌 Мощные подходы
В этой главе собраны алгоритмические парадигмы и техники, которые не привязаны к конкретным структурам данных, но являются ключом к решению огромного класса задач (особенно уровня Medium и Hard на собеседованиях).
🔙 Бэктрекинг (Backtracking)
🎯 Интуиция — аналогия из жизни
Прохождение лабиринта по "нити Ариадны". Вы идете вперёд, разматывая нить. Дошли до развилки — выбрали один путь. Если упёрлись в тупик, вы не начинаете лабиринт заново, а просто сматываете нить обратно (откат) до последней развилки и идёте в другой рукав.
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Это алгоритм полного (или частичного) перебора. Мы перебираем все комбинации или перестановки. Например, для перестановок сложность O(n!), для подмножеств O(2^n). Отсечения не меняют асимптотику в худшем случае, но радикально снижают константу на реальных данных.
🔎 Подробная трассировка: N-Queens (4 ферзя на доске 4×4)
. Q . . Шаг 1: ставим ферзя в (row 0, col 1)
. . . Q Шаг 2: в row 1 доступна только col 3 (остальные под боем)
Q . . . Шаг 3: в row 2 доступна только col 0
. . Q . Шаг 4: в row 3 ставим в col 2 → дошли до конца! РЕШЕНИЕ! ✅
(Откат назад к row 3): других вариантов нет.
(Откат к row 2): убираем ферзя (2,0), других вариантов нет.
(Откат к row 1): убираем (1,3)... и так далее, пока не переберем все.
#include <iostream>
#include <vector>
#include <string>
using namespace std;
// ═══ Задача 1: N Queens ═══
class NQueens {
int n;
vector<string> board;
vector<vector<string>> solutions;
// Оптимизация проверок: заняты ли столбец и диагонали
vector<bool> cols, diag1, diag2;
void solve(int row) {
if (row == n) {
solutions.push_back(board); // Базовый случай: сохраняем
return;
}
for (int col = 0; col < n; col++) {
if (cols[col] || diag1[row - col + n - 1] || diag2[row + col])
continue; // Отсечение (pruning)! Клетка под боем
// ДЕЛАЕМ ШАГ
board[row][col] = 'Q';
cols[col] = diag1[row - col + n - 1] = diag2[row + col] = true;
solve(row + 1); // РЕКУРСИЯ
// ОТКАТ (BACKTRACK)
board[row][col] = '.';
cols[col] = diag1[row - col + n - 1] = diag2[row + col] = false;
}
}
public:
int totalSolutions(int n_) {
n = n_;
board.assign(n, string(n, '.'));
cols.assign(n, false);
diag1.assign(2 * n, false);
diag2.assign(2 * n, false);
solutions.clear();
solve(0);
return solutions.size();
}
};
// ═══ Задача 2: Все подмножества (Subsets) ═══
void subsets(vector<int>& nums, int idx, vector<int>& current,
vector<vector<int>>& result) {
result.push_back(current);
for (int i = idx; i < (int)nums.size(); i++) {
current.push_back(nums[i]); // Шаг
subsets(nums, i + 1, current, result); // Рекурсия
current.pop_back(); // Откат
}
}
// ═══ Задача 3: Все перестановки (Permutations) ═══
void permutations(vector<int>& nums, int start, vector<vector<int>>& result) {
if (start == (int)nums.size()) {
result.push_back(nums);
return;
}
for (int i = start; i < (int)nums.size(); i++) {
swap(nums[start], nums[i]); // Шаг
permutations(nums, start + 1, result); // Рекурсия
swap(nums[start], nums[i]); // Откат
}
}
int main() {
NQueens nq;
cout << "8 queens solutions: " << nq.totalSolutions(8) << endl; // 92
vector<int> nums = {1, 2, 3};
vector<int> cur;
vector<vector<int>> subs;
subsets(nums, 0, cur, subs);
cout << "Subsets of {1,2,3}: " << subs.size() << endl; // 8
vector<vector<int>> perms;
permutations(nums, 0, perms);
cout << "Permutations of {1,2,3}: " << perms.size() << endl; // 6
return 0;
}
⚠️ Подводные камни
Самая распространенная ошибка — забыть сделать откат. Если вы используете глобальные переменные или передаете структуры по ссылке (`&`), мутация состояния испортит все последующие ветви рекурсии. Второе: передача по значению (копирование векторов) убивает производительность.
💼 Где спрашивают
Решение Судоку, поиск слов на доске (Word Search), генерация скобочных последовательностей, комбинации суммы (Combination Sum). Это один из самых популярных паттернов на собеседованиях в FAANG.
🔗 Связь с другими
По сути, это алгоритм DFS (Depth-First Search), примененный к "дереву возможных состояний задачи", а не к физическому графу в памяти.
Забыли откатить (backtrack)
❌ Нет отката
board[row][col] = 'Q';
solve(row + 1);
// 💀 Забыли убрать ферзя!
// Следующие варианты будут некорректны✅ Откатываем после рекурсии
board[row][col] = 'Q';
solve(row + 1);
board[row][col] = '.'; // ✅ BACKTRACK!Нет отсечения (pruning)
❌ Проверяем только в конце
void solve(int row) {
if (row == n) {
if (isValid(board)) count++; // 💀
}
// Ставим ферзя без проверки → O(n^n)
}✅ Отсекаем на каждом шаге
for (int col = 0; col < n; col++) {
if (canPlace(row, col)) { // ✅ проверяем СРАЗУ
place(row, col);
solve(row + 1);
remove(row, col);
}
} // Отсечение → из O(n^n) в O(n!)❓ Проверь себя
✂️ Разделяй и властвуй (Divide and Conquer)
🎯 Интуиция — аналогия из жизни
Представьте, что вам нужно сломать толстый веник. Целиком — нереально. Вы разделяете веник на две половинки, их еще на две, пока в руках не окажутся отдельные прутики. Вы легко ломаете по одному прутику (базовый случай), а затем "собираете" факт поломки наверх. Задача решена!
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Анализируется через Мастер-Теорему. Если мы делим задачу пополам и тратим линейное время на слияние (как в Merge Sort), то дерево вызовов имеет глубину $\log_2(N)$, и на каждом уровне выполняется $O(N)$ работы. Итого $O(N \log N)$.
🔎 Подробная трассировка: Подсчет инверсий в [2, 4, 1, 3]
Инверсия — пара $(i, j)$ где $i < j$ и $A[i] > A[j]$.
Разделяем: [2, 4] и [1, 3].
Властвуем:
- В [2, 4] инверсий 0. Массив отсортирован: [2, 4].
- В [1, 3] инверсий 0. Массив отсортирован: [1, 3].
Объединяем (Слияние [2, 4] и [1, 3]):
Сравниваем элементы:
- 1 < 2. Берем 1. Так как 1 пришла из правого массива, а в левом осталось 2 элемента (2 и 4), то 1 образует 2 инверсии! (Пары 2-1 и 4-1). Счётчик += 2.
- 2 < 3. Берем 2. Из левого (инверсий нет).
- 3 < 4. Берем 3. В левом остался 1 элемент (4) → 1 инверсия. Счётчик += 1.
- Берем 4.
Итого инверсий: 0 + 0 + 3 = 3. ✅
#include <iostream>
#include <vector>
#include <climits>
using namespace std;
// ═══ Подсчёт инверсий через merge sort: O(n log n) ═══
long long mergeSortCount(vector<int>& arr, int l, int r) {
if (l >= r) return 0;
int mid = (l + r) / 2;
long long count = 0;
count += mergeSortCount(arr, l, mid); // Divide & Conquer
count += mergeSortCount(arr, mid + 1, r);
// Combine (Слияние с подсчётом)
vector<int> temp;
int i = l, j = mid + 1;
while (i <= mid && j <= r) {
if (arr[i] <= arr[j]) {
temp.push_back(arr[i++]);
} else {
temp.push_back(arr[j++]);
// Все элементы, оставшиеся в левой части, больше arr[j]
count += (mid - i + 1);
}
}
while (i <= mid) temp.push_back(arr[i++]);
while (j <= r) temp.push_back(arr[j++]);
for (int k = l; k <= r; k++) arr[k] = temp[k - l];
return count;
}
// ═══ Максимальный подмассив (D&C иллюстрация): O(n log n) ═══
// (Хотя алгоритм Кадане делает это за O(n))
struct Result { long long sum; int left, right; };
Result maxCrossing(const vector<int>& arr, int l, int mid, int r) {
long long leftSum = LLONG_MIN, rightSum = LLONG_MIN, sum = 0;
int maxL = mid, maxR = mid + 1;
for (int i = mid; i >= l; i--) {
sum += arr[i];
if (sum > leftSum) { leftSum = sum; maxL = i; }
}
sum = 0;
for (int i = mid + 1; i <= r; i++) {
sum += arr[i];
if (sum > rightSum) { rightSum = sum; maxR = i; }
}
return {leftSum + rightSum, maxL, maxR};
}
Result maxSubarrayDC(const vector<int>& arr, int l, int r) {
if (l == r) return {arr[l], l, l};
int mid = (l + r) / 2;
auto left = maxSubarrayDC(arr, l, mid);
auto right = maxSubarrayDC(arr, mid + 1, r);
auto cross = maxCrossing(arr, l, mid, r); // Центр
if (left.sum >= right.sum && left.sum >= cross.sum) return left;
if (right.sum >= left.sum && right.sum >= cross.sum) return right;
return cross;
}
int main() {
vector<int> arr = {5, 3, 2, 4, 1};
cout << "Inversions: " << mergeSortCount(arr, 0, arr.size()-1) << endl; // 8
vector<int> arr2 = {-2, 1, -3, 4, -1, 2, 1, -5, 4};
auto res = maxSubarrayDC(arr2, 0, arr2.size()-1);
cout << "Max subarray sum: " << res.sum
<< " [" << res.left << ".." << res.right << "]" << endl; // 6 [3..6]
return 0;
}
⚠️ Подводные камни
Рекурсивные вызовы могут привести к переполнению стека (Stack Overflow), если разбиение неудачное. Постоянное создание новых векторов внутри рекурсии (вместо работы с индексами `l` и `r`) убьет скорость алгоритма из-за аллокации памяти.
💼 Где спрашивают
Классика Computer Science: Сортировка слиянием, Быстрая сортировка, Быстрое возведение в степень, Быстрое преобразование Фурье (FFT), алгоритм Штрассена для умножения матриц.
🔗 Связь с другими
Главное отличие от Динамического Программирования (DP) — в парадигме Разделяй и Властвуй подзадачи независимы и не пересекаются (нет смысла применять мемоизацию).
❓ Проверь себя
🔧 Битовые операции (Bit Manipulation)
🎯 Интуиция — аналогия из жизни
Представьте пульт диджея на 32 рычага (бита). Вы можете щелкать их по одному (медленно), а можете использовать "шаблоны" (маски) — опустить трафарет на пульт и за одно мгновение переключить нужную группу рычагов (операция процессора). Это самый близкий к железу и быстрый код.
📝 Пошаговое текстовое описание
1 << k — это единица, сдвинутая на k позиций влево (выбираем конкретный выключатель).x & (1 << k) — проверка, включен ли k-й бит.x | (1 << k) — включить k-й бит.x & ~(1 << k) — выключить k-й бит.x ^ (1 << k) — инвертировать (переключить) k-й бит.x & (x - 1) — трюк, убирающий младший единичный бит (самый правый).📐 Почему такая сложность?
Побитовые И, ИЛИ, ИСКЛЮЧАЮЩЕЕ ИЛИ, СДВИГ — это базовые ассемблерные инструкции процессора. Они выполняются за 1 такт тактового генератора (мгновенно). O(1).
🔎 Подробная трассировка: Поиск "Одинокого числа" (все по 2 раза, одно — 1)
Массив: [4, 1, 2, 1, 2]
Свойство XOR (^): `A ^ A = 0` и `A ^ 0 = A`. XOR коммутативен (порядок не важен).
4 ^ 1 ^ 2 ^ 1 ^ 2 = 4 ^ (1 ^ 1) ^ (2 ^ 2) = 4 ^ 0 ^ 0 = 4.
За O(N) без дополнительной памяти для хеш-таблицы! ✅
#include <iostream>
#include <vector>
using namespace std;
// 1. Является ли число степенью двойки?
bool isPowerOf2(int n) {
return n > 0 && (n & (n - 1)) == 0;
}
// 2. Подсчёт установленных битов (Алгоритм Брайана Кернигана)
int countBits(int n) {
int count = 0;
while (n) {
n &= (n - 1); // убирает младший единичный бит
count++;
}
return count;
// В современном C++ лучше юзать: __builtin_popcount(n)
}
// 3. Единственный неповторяющийся (все 2 раза, один - 1 раз)
int singleNumber(const vector<int>& nums) {
int result = 0;
for (int x : nums) result ^= x;
return result;
}
// 4. Два неповторяющихся числа (LeetCode Hard)
pair<int,int> twoSingleNumbers(const vector<int>& nums) {
int xorAll = 0;
for (int x : nums) xorAll ^= x;
// xorAll содержит A ^ B. Найдем бит, которым они отличаются
// x & (-x) оставляет только самый младший бит числа
int diffBit = xorAll & (-xorAll);
int a = 0, b = 0;
// Разделяем массив на две группы по этому биту
for (int x : nums) {
if (x & diffBit) a ^= x;
else b ^= x;
}
return {a, b};
}
// 5. Перебор всех подмножеств через битовую маску
void printSubsets(const vector<int>& set) {
int n = set.size();
// Идем от 0 до 2^n - 1
for (int mask = 0; mask < (1 << n); mask++) {
cout << "{ ";
for (int i = 0; i < n; i++)
if (mask & (1 << i)) // проверяем, включен ли i-й элемент
cout << set[i] << " ";
cout << "}" << endl;
}
}
// 6. Перебор подмасок данной маски (олимпиадный трюк O(3^N))
void enumSubmasksOf(int mask) {
cout << "Submasks of " << mask << ": ";
for (int sub = mask; sub > 0; sub = (sub - 1) & mask)
cout << sub << " ";
cout << "0" << endl;
}
int main() {
cout << "isPowerOf2(16) = " << isPowerOf2(16) << endl; // 1
cout << "isPowerOf2(18) = " << isPowerOf2(18) << endl; // 0
auto [a, b] = twoSingleNumbers({1, 2, 1, 3, 2, 5});
cout << "Two singles: " << a << " " << b << endl; // 3 5
printSubsets({10, 20});
// { } { 10 } { 20 } { 10 20 }
return 0;
}
⚠️ Подводные камни
Приоритет битовых операций в C++ ниже, чем у операторов сравнения (`==`). Выражение if (n & 1 == 0) сначала вычислит `1 == 0`, и код сломается. Обязательны скобки: if ((n & 1) == 0). Также, 1 << 31 вызовет Undefined Behavior, для 64-битных масок пишите 1LL << i.
💼 Где спрашивают
Множество задач LeetCode ("Number of 1 Bits", "Reverse Bits", "Counting Bits"). Битовые маски используются в Динамическом программировании по профилю/маске (TSP — задача коммивояжера за $O(2^N N^2)$).
🔗 Связь с другими
Маски — это компактная альтернатива массивам `bool[]`. Оперировать 64 булевыми флагами в одном `long long` радикально экономит кэш и время.
❓ Проверь себя
📊 Монотонный стек
🎯 Интуиция — аналогия из жизни
Представьте, что вы стоите в строю новобранцев. Вы видите перед собой только тех, кто выше вас. Если перед вами стоит низкий человек, он "перекрывается" вами для тех, кто смотрит сзади. Как только в строй встает кто-то высокий, все низкие перед ним становятся нерелевантными — мы их "выгоняем" из видимости (из стека).
📝 Пошаговое текстовое описание
pop() и проверяем следующую верхушку.📐 Почему такая сложность?
Хотя у нас вложенный цикл `while` внутри `for`, каждый элемент массива помещается в стек (push) ровно 1 раз и удаляется (pop) ровно 1 раз. Суммарно не более 2N операций. Сложность строгая O(n).
🔎 Трассировка: наибольший прямоугольник в гистограмме [2,1,5,6,2,3]
Мы храним индексы. Для наглядности покажу значения [val]:
i=0 (2): стек=[0(2)]
i=1 (1): 1 < 2 → POP. Прямоугольник для (2): высота 2, ширина 1 = 2. стек=[1(1)]
i=2 (5): стек=[1(1), 2(5)]
i=3 (6): стек=[1(1), 2(5), 3(6)]
i=4 (2):
- 2 < 6 → POP. Площадь для (6): высота 6, ширина (4-2-1)=1 → 6.
- 2 < 5 → POP. Площадь для (5): высота 5, ширина (4-1-1)=2 → 10.
- стек=[1(1), 4(2)]
i=5 (3): стек=[1(1), 4(2), 5(3)]
Очистка остатков (доходим до фиктивного 0):
- POP(3): высота 3, ширина 1 = 3.
- POP(2): высота 2, ширина 4 = 8.
- POP(1): высота 1, ширина 6 = 6.
Максимум = 10 ✅
#include <iostream>
#include <vector>
#include <stack>
using namespace std;
// ═══ Задача 1: Следующий бóльший элемент (Next Greater Element) ═══
vector<int> nextGreaterElement(const vector<int>& nums) {
int n = nums.size();
vector<int> res(n, -1);
stack<int> st; // стек индексов
for (int i = 0; i < n; i++) {
// Поддерживаем строго убывающий стек
while (!st.empty() && nums[i] > nums[st.top()]) {
res[st.top()] = nums[i]; // Нашли ответ для верхушки!
st.pop();
}
st.push(i);
}
return res;
}
// ═══ Задача 2: Максимальный прямоугольник в гистограмме (Hard) ═══
int largestRectangleHistogram(const vector<int>& heights) {
stack<int> st;
int maxArea = 0;
int n = heights.size();
// i <= n чтобы фиктивным нулём в конце вытолкнуть все остатки из стека
for (int i = 0; i <= n; i++) {
int h = (i == n) ? 0 : heights[i];
while (!st.empty() && heights[st.top()] > h) {
int height = heights[st.top()];
st.pop();
// Если стек пуст, значит этот столбец был самым низким на данный момент
int width = st.empty() ? i : (i - st.top() - 1);
maxArea = max(maxArea, height * width);
}
st.push(i);
}
return maxArea;
}
// ═══ Задача 3: Trapping Rain Water (Сбор воды, Hard) ═══
int trap(const vector<int>& height) {
stack<int> st;
int water = 0;
for (int i = 0; i < (int)height.size(); i++) {
while (!st.empty() && height[i] > height[st.top()]) {
int bottom = st.top();
st.pop();
if (st.empty()) break; // Воде не за что зацепиться слева
int left_bound = st.top();
int width = i - left_bound - 1;
// Вода заполняется по уровню меньшей из стенок (левой или правой) минус дно
int h = min(height[i], height[left_bound]) - height[bottom];
water += width * h;
}
st.push(i);
}
return water;
}
int main() {
auto nge = nextGreaterElement({2, 1, 2, 4, 3});
cout << "Next greater: ";
for (int x : nge) cout << x << " "; // 4 2 4 -1 -1
cout << endl;
cout << "Largest rect: "
<< largestRectangleHistogram({2,1,5,6,2,3}) << endl; // 10
cout << "Trapped water: "
<< trap({0,1,0,2,1,0,1,3,2,1,2,1}) << endl; // 6
return 0;
}
⚠️ Подводные камни
Чаще всего забывают сохранять индексы вместо значений. В гистограмме и воде критически важно знать, где находилась граница, чтобы вычислить ширину по формуле i - st.top() - 1.
💼 Где спрашивают
Это классический шаблон FAANG. Если в задаче просят найти что-то "ближайшее бóльшее" или "ближайшее мéньшее", или задачи на построение площадей поверх рельефа — это 100% монотонный стек.
🔗 Связь с другими
Дек (Deque), используемый в алгоритме "Скользящее окно" (Sliding Window Maximum) — это двусторонний монотонный стек.
❓ Проверь себя
🤝 Meet in the Middle
🎯 Интуиция — аналогия из жизни
Представьте, что вы копаете туннель сквозь гору. Если копать только с одной стороны, вам потребуется 10 лет (экспоненциальное время). Если две команды начнут копать с двух сторон горы и встретятся посередине, каждая потратит 5 лет. Время работы падает драматически, хотя задача осталась той же!
📝 Пошаговое текстовое описание
📐 Почему такая сложность?
Для $N=40$ полный перебор $2^{40} \approx 10^{12}$ операций (будет считаться часы).
Разбивая пополам: генерация массива A: $2^{20} \approx 10^6$ операций. Поиск: $10^6 \times \log_2(10^6) \approx 10^6 \times 20 = 2 \cdot 10^7$ операций.
Алгоритм отработает за миллисекунды! Происходит замена основания степени.
🔎 Подробная трассировка: Subset sum для Target = 10
Массив: [3, 1, 4, 1, 5, 9] (N=6)
Разбиваем: Левая половина [3, 1, 4], Правая [1, 5, 9].
Генерируем суммы (A): {0, 3, 1, 4, 4, 7, 5, 8}
Генерируем суммы (B): {0, 1, 5, 9, 6, 10, 14, 15}
Сортируем B: {0, 1, 5, 6, 9, 10, 14, 15}
Ищем встречу:
Берем из A: 3. Ищем в B 10-3 = 7. Нет.
Берем из A: 1. Ищем в B 10-1 = 9. Бинарный поиск нашел 9! ✅
Ответ: ДА, можно (1 + 9 = 10).
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// Генерация всех сумм подмножеств с помощью битовых масок
void generateSums(const vector<long long>& arr, int l, int r, vector<long long>& sums) {
int n = r - l;
for (int mask = 0; mask < (1 << n); mask++) {
long long sum = 0;
for (int i = 0; i < n; i++) {
if (mask & (1 << i)) {
sum += arr[l + i];
}
}
sums.push_back(sum);
}
}
// ═══ Классическая задача: можно ли собрать сумму ═══
bool meetInMiddle(const vector<long long>& arr, long long target) {
int n = arr.size();
int half = n / 2;
vector<long long> sumsA, sumsB;
// Генерируем 2^(N/2) для левой и правой половин
generateSums(arr, 0, half, sumsA);
generateSums(arr, half, n, sumsB);
// Сортируем одну половину для бинарного поиска
sort(sumsB.begin(), sumsB.end());
for (long long a : sumsA) {
long long need = target - a;
if (binary_search(sumsB.begin(), sumsB.end(), need))
return true;
}
return false;
}
// ═══ Вариация: подсчёт количества способов собрать сумму ≤ target ═══
long long countPairsBelow(const vector<long long>& arr, long long target) {
int n = arr.size(), half = n / 2;
vector<long long> sumsA, sumsB;
generateSums(arr, 0, half, sumsA);
generateSums(arr, half, n, sumsB);
sort(sumsB.begin(), sumsB.end());
long long count = 0;
for (long long a : sumsA) {
// Ищем первое число, которое строго больше чем (target - a)
// Все числа левее него нам подходят (т.к. они меньше или равны)
auto it = upper_bound(sumsB.begin(), sumsB.end(), target - a);
count += (it - sumsB.begin());
}
return count;
}
int main() {
vector<long long> arr = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3};
cout << "Subset sum 15: " << meetInMiddle(arr, 15) << endl; // 1
cout << "Subset sum 100: " << meetInMiddle(arr, 100) << endl; // 0
cout << "Pairs with sum ≤ 10: " << countPairsBelow(arr, 10) << endl;
return 0;
}
⚠️ Подводные камни
Память! Массив размера $2^{20}$ элементов `long long` займёт 8 Мегабайт, что влезет в любой лимит. Но массив размера $2^{25}$ займет уже 256 МБ, что на многих олимпиадах вызывает `Memory Limit Exceeded`. Знайте свои пределы (идеально для $N \le 40$).
💼 Где спрашивают
Спортивное программирование (Codeforces, LeetCode Hard). Используется в криптографии: уязвимость "Meet-in-the-middle attack" на алгоритм шифрования Double DES полностью основана на этой технике.
🔗 Связь с другими
Это симбиоз Бэктрекинга (перебора подмножеств) и Бинарного поиска / Двух указателей для слияния списков.
❓ Проверь себя
70+ алгоритмов • 10 глав • Полный курс АиСД на C++
Сортировки • Поиск • Структуры данных • Деревья • Графы
Динамическое программирование • Жадные • Строки • Математика • Прочие