Алгоритмы и Структуры Данных

Полный справочник на C++ — от базовых сортировок до продвинутых алгоритмов. Каждый алгоритм с кодом, трассировкой и анализом сложности.

70+
Алгоритмов
10
Разделов
C++
Язык кода
Примеров
🎛️ Управление:
Изучено: 0 / 0

⌨️ Горячие клавиши

J
Следующий алгоритм
K
Предыдущий алгоритм
Enter
Развернуть / свернуть текущий
/
Фокус на поиск
R
Случайный алгоритм
E
Развернуть все
C
Свернуть все
T
Сменить тему
Esc
Закрыть это окно
?
Показать горячие клавиши

🗺️ 12-недельный план изучения АиСД

📅 Неделя 1-2: Основы Начальный
Сложность алгоритмов (Big-O), массивы, базовые сортировки. Научитесь анализировать время и память.
📅 Неделя 3-4: Эффективные сортировки + Структуры Начальный
Разделяй и властвуй. Стек, очередь, связный список — основа всего.
📅 Неделя 5: Хеш-таблицы + Two Pointers Средний
Хеширование — ключ к O(1) поиску. Two pointers и sliding window — мастхэв для собеседований.
📅 Неделя 6-7: Деревья Средний
BST, обходы, балансировка. Деревья — основа баз данных и файловых систем.
📅 Неделя 8-9: Графы Средний
BFS, DFS, кратчайшие пути, MST. Графы моделируют реальный мир: карты, соцсети, зависимости.
📅 Неделя 10-11: Динамическое программирование Продвинутый
Самая сложная тема. Мемоизация, табуляция, классические задачи. Решайте по 2-3 задачи в день!
📅 Неделя 12: Строки + Математика + Продвинутое Продвинутый
KMP, Z-функция, теория чисел, бэктрекинг, битовые операции. Финальный рывок!

📋 Шпаргалка — Все алгоритмы на одной странице

Нажмите на название → перейти к алгоритму. Идеально для печати перед экзаменом!

АлгоритмВремяПамятьКлючевая идеяГде нужно
🔄 СОРТИРОВКИ
Bubble SortO(n²)O(1)Соседние swap, всплывание максимумаУнивер
Insertion SortO(n²)O(1)Вставка в отсортированную часть. O(n) на почти отсорт.УниверСобес
Merge SortO(n log n)O(n)Разделяй и властвуй. Стабильная. Слияние за O(n)УниверСобес
Quick SortO(n log n)*O(log n)Pivot + partition. Рандомизация! Худший O(n²)СобесУнивер
Heap SortO(n log n)O(1)Max-heap → извлечение корня. Гарантия O(n log n)Универ
Counting SortO(n+k)O(k)Подсчёт вхождений. Не сравнивает! Только целыеУнивер
🔍 ПОИСК
Binary SearchO(log n)O(1)Делим пополам. lo + (hi-lo)/2. Lower/upper boundСобесУнивер
Two PointersO(n)O(1)Навстречу или в одном направлении. Two Sum, palindromeСобес
Sliding WindowO(n)O(1)Непрерывный подмассив. Расширяем right, сужаем leftСобес
📦 СТРУКТУРЫ ДАННЫХ
StackO(1)O(n)LIFO. Скобки, RPN, DFS, монотонный стекСобесУнивер
Hash TableO(1)*O(n)Ключ→хеш→индекс. Коллизии: цепочки/открытая адр.СобесУнивер
Heap / PQO(log n)O(n)Min/Max за O(1). Insert/extract за O(log n). Dijkstra!СобесОлимп
DSU≈O(1)O(n)Find/Union. Сжатие пути + ранг. Kruskal, компонентыОлимп
TrieO(L)O(N·L)Префиксное дерево. Автодополнение. Поиск по префиксуСобес
🌐 ГРАФЫ
BFSO(V+E)O(V)Очередь. Кратчайший в невзвешенном. По уровнямСобесУнивер
DFSO(V+E)O(V)Рекурсия/стек. Циклы, компоненты, топо-сортСобесУнивер
DijkstraO((V+E)logV)O(V)Min-heap. Жадный. Нет отриц. рёбер! РелаксацияСобесОлимп
Bellman-FordO(V·E)O(V)V-1 итераций. Отриц. веса ✅. Детекция отриц. цикловУнивер
KruskalO(E log E)O(V)Сорт рёбра + DSU. MST. Добавляем если не циклУниверОлимп
Topo SortO(V+E)O(V)DAG only. Kahn (BFS) или DFS+reverse. ЗависимостиСобес
📊 DP
Knapsack 0/1O(n·W)O(W)dp[w]=max(skip,take). 1D: справа налево!СобесУнивер
LISO(n log n)O(n)tails[] + lower_bound. Бинпоиск по хвостамСобесОлимп
Coin ChangeO(n·S)O(S)dp[a]=min(dp[a-coin]+1). Порядок циклов важен!Собес
Edit DistanceO(n·m)O(n·m)min(insert, delete, replace). ЛевенштейнСобесУнивер
🔤 СТРОКИ + 🔢 МАТЕМАТИКА
KMPO(n+m)O(m)Prefix-функция. Не откатываем i при несовпаденииУниверСобес
GCD/LCMO(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 PowerO(log n)O(1)a^n: чёт→(a^(n/2))², нечёт→a·a^(n-1). Обратный: a^(p-2)Олимп

📊 Ваша статистика

0
Всего алгоритмов
0
Изучено ✅
0%
0
В избранном ⭐
0
С заметками 📝
0
Пора повторить 🔴
0
Квизов решено

📈 Прогресс по главам

🔖 Мои закладки

Пока нет закладок. Нажми 🔖 на любом алгоритме!

🔗 Граф зависимостей алгоритмов

Что нужно знать перед изучением каждой темы. Кликни на узел → перейти к алгоритму.

Основы
Средний уровень
Продвинутый
Олимпиадный

🖱️ Перетаскивай узлы · Кликай для перехода

🧩 Паттерны задач

Как понять по условию, какой алгоритм применять? Ищите ключевые слова, структуру ограничения и тип ответа.
🔍 Отсортированный массив / ответ в отсортированном массиве
отсортирован найти позицию первое/последнее вхождение минимальный x максимальный x
Что применять
Binary Search, lower_bound / upper_bound, иногда binary search по ответу.
Как распознать
В условии есть отсортированные данные, либо можно проверить условие вида «если x подходит, то все большие/меньшие тоже подходят».
✅ Ключевая мысль: есть монотонность → почти всегда можно бинарить.
❌ Частая ошибка: использовать бинарный поиск на неотсортированном массиве.
↔️ Два конца массива / пара с суммой / палиндром
два числа сумма = x палиндром контейнер отсортированный массив
Что применять
Two Pointers.
Как распознать
Нужна пара элементов, работа идёт с концов массива, либо есть условие «двигать левую/правую границу».
✅ Часто уменьшает O(n²) до O(n).
❌ Ошибка: забыть, что для classic two pointers массив обычно должен быть отсортирован.
🪟 Непрерывный подмассив / подстрока
подмассив подстрока максимальная длина минимальная длина окно
Что применять
Sliding Window.
Как распознать
В задаче фигурирует именно непрерывный отрезок массива/строки. Нужно расширять и сужать границы.
⚠️ Если речь о подпоследовательности, а не о непрерывном куске — sliding window обычно не подходит.
🌐 Кратчайший путь в графе
кратчайший путь минимальная стоимость расстояние добраться
Что применять
BFS — если граф невзвешенный. Dijkstra — если веса неотрицательные. Bellman-Ford — если возможны отрицательные рёбра. Floyd-Warshall — если нужны расстояния между всеми парами.
❌ Частая ошибка: запускать Dijkstra при отрицательных рёбрах.
🌊 Обход графа / сетки / острова / лабиринт
матрица острова лабиринт компоненты обход
Что применять
DFS или BFS. Если нужно просто обойти/пометить — обычно DFS. Если нужен кратчайший путь по клеткам — BFS.
✅ Подсказка: «острова», «области», «связные компоненты» почти всегда кричат DFS/BFS.
📋 Зависимости / порядок выполнения
зависимости до / после расписание courses порядок задач
Что применять
Topological Sort (Kahn или DFS).
⚠️ Если есть цикл — корректного порядка не существует.
🌲 Минимальное остовное дерево
соединить все вершины минимальная стоимость соединения остов MST
Что применять
Kruskal или Prim.
✅ Если в условии «соединить все точки/города минимально» — думайте про MST.
🎒 Выбор предметов / максимум пользы / ограничение по весу
рюкзак вес ценность максимум прибыли можно / нельзя взять
Что применять
Knapsack DP. Если можно брать дробно — Fractional Knapsack (жадный). Если 0/1 — DP. Если можно брать бесконечно — Unbounded Knapsack.
❌ Частая ошибка: применять жадный подход к 0/1 рюкзаку.
🔁 Есть перекрывающиеся подзадачи
максимум / минимум количество способов можно ли выбор на каждом шаге подзадачи повторяются
Что применять
Dynamic Programming.
✅ Если вы видите, что одна и та же подзадача вычисляется много раз — почти наверняка нужен DP.
📈 Подпоследовательность, не обязательно непрерывная
подпоследовательность LIS LCS непрерывность не важна
Что применять
Обычно DP. Для LIS — DP или binary search + tails. Для LCS — классический 2D DP.
⚠️ Не путайте подпоследовательность с подмассивом/подстрокой.
🧮 Запросы на отрезке + обновления
отрезок сумма на диапазоне обновить элемент много запросов range query
Что применять
Segment Tree или Fenwick Tree. Если только префиксные суммы — BIT. Если range update / min / max / lazy — Segment Tree.
❌ Частая ошибка: делать каждый запрос линейным проходом → TLE.
🔤 Поиск подстроки / шаблона в строке
найти подстроку pattern вхождение строка в строке
Что применять
KMP, Z-функция, Rabin-Karp. KMP — стабильная классика. Rabin-Karp — если много паттернов/нужны хеши. Z — удобна для олимпиадных задач.
🪞 Палиндромы
палиндром самая длинная палиндромная подстрока центр
Что применять
Для простой задачи — expand around center. Для оптимума O(n) — Manacher.
🔢 Простые числа / делители / остатки
простые делители mod остатки обратный элемент
Что применять
GCD, Sieve, Fast Power, Extended GCD, CRT, Euler Totient.
🧠 «Нужно перебрать все варианты»
все варианты все расстановки все подмножества судоку ферзи
Что применять
Backtracking. Если n около 40 и полный перебор слишком большой — Meet in the Middle.
✅ Признаки: «все расстановки», «все комбинации», «проверить все варианты», «constraint маленький».
📚 Выражения, скобки, ближайший больший элемент
скобки postfix next greater гистограмма
Что применять
Stack или Monotone Stack.
✂️ Можно разделить задачу на две независимые части
пополам разделить слияние рекурсивно
Что применять
Divide and Conquer.
⚖️ Локально лучший выбор на каждом шаге
максимум числа задач минимальная стоимость сейчас самый выгодный берём лучший сейчас
Что применять
Greedy, но только если можно обосновать корректность жадного выбора.
❌ Если не можете доказать жадность — скорее всего нужен DP.
😕 Ничего не найдено. Попробуйте другое ключевое слово.
⏱ Сложность: O(n) O(n log n) O(n²) O(log n) O(V+E)
🏷 Где нужно: 💼 Собеседование 🎓 Университет 🏆 Олимпиада 🔧 Практика
✕ Сброс

⏰ Spaced Repetition — Интервальное повторение

Отмечайте алгоритмы как «повторил» — система напомнит через 1, 3, 7, 14, 30 дней.

0
🔴 Пора повторить
0
🟡 Скоро
0
🟢 Свежие
0
🔵 Не изучены
Нужно повторить:
🔍 Ничего не найдено. Попробуйте другие фильтры.
🔄

Глава 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)

⏱ O(n²) 💾 O(1) 📌 Стабильная 🏆 Лучший: O(n)

🎯 Интуиция — аналогия из жизни

Представьте пузырьки воздуха, поднимающиеся со дна стакана с газировкой. Так же и самые большие элементы массива шаг за шагом «всплывают» в самый конец при каждом проходе.

📝 Пошаговое текстовое описание

Начинаем с первого элемента массива, сравниваем соседние элементы a[i] и a[i+1].
Если левый элемент больше правого — меняем их местами (swap).
Доходим до конца. Теперь самый большой элемент точно находится на последнем месте.
Повторяем процесс для оставшейся части массива. Если за целый проход не было ни одного обмена — массив отсортирован.

📐 Почему такая сложность?

Мы делаем 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]

C++
#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 (там мы фиксируем элемент, тут — "пузырек").

До: 5 3 8 1 2 swap После: 1 2 3 5 8

Забыли флаг оптимизации

❌ Всегда O(n²)

C++
// 💀 Без флага — даже для [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) на отсортированном

C++
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; // ✅ Ранний выход
}

❓ Проверь себя

Сколько проходов нужно Bubble Sort чтобы понять, что массив [1,2,3,4,5] уже отсортирован?
A 1 проход
B 4 прохода
C 5 проходов
D 0 проходов
✅ 1 проход без swap → флаг swapped=false → break → O(n).
❌ Ответ: 1 проход. За один проход без обменов алгоритм понимает что массив отсортирован.

👆 Сортировка выбором (Selection Sort)

⏱ O(n²) всегда 💾 O(1) 📌 Нестабильная

🎯 Интуиция — аналогия из жизни

Представьте, что вы собираете колоду карт по возрастанию. Вы просматриваете всю стопку, находите самую младшую карту (туза) и кладёте её наверх. Затем ищете двойку и кладёте следом. И так до конца.

📝 Пошаговое текстовое описание

Ищем минимальный элемент во всём неотсортированном массиве (от индекса 0 до n-1).
Меняем этот минимум местами с элементом на нулевой позиции.
Смещаем границу поиска на шаг вправо. Ищем минимум в массиве от 1 до n-1.
Повторяем процесс, пока не дойдём до конца массива.

📐 Почему такая сложность?

На каждом шаге мы вынуждены просмотреть всю оставшуюся часть массива, чтобы гарантированно найти минимум. Вложенный цикл отработает (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]
C++
#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

C++
// [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

C++
// Вместо swap — вставка со сдвигом:
int key = arr[minIdx];
for (int k = minIdx; k > i; k--)
  arr[k] = arr[k-1];  // сдвигаем
arr[i] = key;
// Но это уже Insertion Sort!

❓ Проверь себя

Сколько swap'ов выполняет Selection Sort для массива из n элементов?
A O(n²)
B Ровно n-1 (или меньше)
C O(n log n)
D 0
✅ Один swap за проход × (n-1) проходов = максимум n-1 swap'ов. Плюс: минимум записей в память!
❌ Ответ: n-1. Selection Sort делает ровно один swap на каждой итерации внешнего цикла.

📥 Сортировка вставками (Insertion Sort)

⏱ O(n²) 💾 O(1) 📌 Стабильная 🏆 Лучший: O(n)

🎯 Интуиция — аналогия из жизни

Как при сортировке карт в руке: вы берёте новую карту из стопки, просматриваете свои уже отсортированные карты справа налево и вставляете новую карту на её правильное место.

📝 Пошаговое текстовое описание

Считаем первый элемент (индекс 0) отсортированной частью.
Берём следующий элемент (ключ) и сравниваем его с элементами отсортированной части справа налево.
Пока элемент отсортированной части больше ключа, сдвигаем этот элемент вправо.
Вставляем ключ на освободившееся место и переходим к следующему элементу.

📐 Почему такая сложность?

Худший случай: массив отсортирован в обратном порядке, и каждый новый элемент нужно протаскивать в самое начало (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] ✅

C++
#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 элемента), так как из-за отсутствия накладных расходов на рекурсию они работают быстрее.

❓ Проверь себя

Почему std::sort использует Insertion Sort для маленьких подмассивов?
A Потому что Insertion Sort стабильна
B Потому что у неё меньше сложность
C Малые константы + хорошая работа с кэшем CPU
D Потому что она проще в реализации
✅ На малых n (≤16) константы важнее асимптотики. Insertion Sort последовательно читает память → cache-friendly. Рекурсия Quick/Merge Sort создаёт overhead.
❌ Ответ: малые константы + кэш. O(n²) при n≤16 это всего 256 операций — меньше чем overhead рекурсии O(n log n) алгоритмов.

🔀 Сортировка слиянием (Merge Sort)

⏱ O(n log n) всегда 💾 O(n) 📌 Стабильная

🎯 Интуиция — аналогия из жизни

Представьте две уже отсортированные папки с документами. Чтобы слить их в одну, вы просто смотрите на верхние документы обеих папок, берете тот, чей номер меньше, и кладете в новую стопку. Алгоритм разделяет пачку до тех пор, пока не останется по 1 листу (что очевидно отсортировано), а потом начинает их сливать.

📝 Пошаговое текстовое описание

Если длина подмассива ≤ 1, он уже отсортирован (базовый случай рекурсии).
Делим текущий массив на две равные половины.
Рекурсивно сортируем левую и правую половины.
Сливаем (Merge) два отсортированных подмассива в один используя вспомогательную память.

📐 Почему такая сложность?

Каждый раз мы делим массив пополам. Дерево вызовов будет иметь высоту 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]

C++
#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).

38 27 43 3 9 82 10 38 27 43 3 9 82 10 38 27 43 3 9 10 27 38 43 82 ↑ merge ↑

❓ Проверь себя

Merge Sort стабильна. Какая строка кода обеспечивает стабильность?
A if (l >= r) return;
B if (left[i] <= right[j]) — знак <=
C int m = l + (r-l)/2;
D Стабильность обеспечивается автоматически
✅ При <= равные элементы из левой части идут первыми → их изначальный порядок сохраняется.
❌ Ответ: <= в сравнении при слиянии. Если использовать <, равные элементы из правой части попадут раньше → порядок нарушится.

⚡ Быстрая сортировка (Quick Sort)

⏱ O(n log n) средний 💾 O(log n) 📌 Нестабильная ⚠️ Худший: O(n²)

🎯 Интуиция — аналогия из жизни

Допустим, нужно построить класс по росту. Берем случайного человека (опорный элемент / pivot). Просим всех, кто ниже, стать слева от него, а кто выше — справа. Сам человек оказался на своем финальном месте. Повторяем эту же логику для левой и правой групп независимо.

📝 Пошаговое текстовое описание

Выбираем опорный элемент (pivot) — например, последний, случайный или медиану.
Делаем разбиение (Partition): перекидываем все элементы меньше pivot в левую часть, большие — в правую.
Ставим сам pivot строго между этими частями. Теперь его позиция финальная.
Рекурсивно запускаем алгоритм для массива слева от pivot и справа от него.

📐 Почему такая сложность?

В среднем pivot делит массив на две примерно равные части (дерево рекурсии высоты log n, на каждом уровне работа O(n)). Но если pivot постоянно оказывается минимальным или максимальным (например, массив уже отсортирован), то деление идет на 1 и n-1 элементов. Высота дерева становится n, что дает O(n²).

🔎 Подробная трассировка partition: [10, 80, 30, 90, 40, 50, 70], pivot=70

jarr[j]arr[j] ≤ 70?ДействиеiМассив
010✅ Даswap(arr[0],arr[0]), i++1[10, 80, 30, 90, 40, 50, 70]
180❌ Нет1[10, 80, 30, 90, 40, 50, 70]
230✅ Даswap(arr[1],arr[2]), i++2[10, 30, 80, 90, 40, 50, 70]
390❌ Нет2
440✅ Даswap(arr[2],arr[4]), i++3[10, 30, 40, 90, 80, 50, 70]
550✅ Да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]
C++
#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)).

❓ Проверь себя

Что использует std::sort в C++ когда рекурсия Quick Sort слишком глубокая?
A Merge Sort
B Bubble Sort
C Heap Sort
D Radix Sort
✅ IntroSort = Quick Sort + Heap Sort (при глубокой рекурсии) + Insertion Sort (малые массивы). Heap Sort гарантирует O(n log n) в худшем случае.
❌ Ответ: Heap Sort. std::sort = IntroSort. При глубине рекурсии > 2·log₂(n) переключается на Heap Sort для гарантии O(n log n).

🏔 Пирамидальная сортировка (Heap Sort)

⏱ O(n log n) всегда 💾 O(1) 📌 Нестабильная

🎯 Интуиция — аналогия из жизни

Игра "Царь горы". Мы организуем данные так, что самый сильный (максимальный элемент) всегда на вершине пирамиды. Забираем его и ставим в конец очереди победителей. На вершину ставим кого-то слабого со дна, и он "проваливается" вниз до своего уровня (восстановление кучи). Повторяем, пока гора не исчезнет.

📝 Пошаговое текстовое описание

Виртуально представляем массив в виде бинарного дерева.
Превращаем массив в Max-Heap (нестрого убывающее дерево, где родитель всегда больше детей) вызывая функцию heapify с нижних уровней.
Меняем первый элемент (максимум) с последним неотсортированным элементом массива.
Отсекаем этот последний элемент из кучи (уменьшаем размер кучи на 1) и восстанавливаем кучу вызовом heapify(0). Повторяем до 1 элемента.

📐 Почему такая сложность?

Построение начальной кучи занимает математически доказанное время 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]

C++
#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).

10 5 3 4 1 ← max (корень)

❓ Проверь себя

Для элемента с индексом i (0-based) в массиве, где его левый ребёнок?
A i + 1
B 2 * i
C 2 * i + 1
D i / 2
✅ В 0-based массиве: left = 2i+1, right = 2i+2, parent = (i-1)/2.
❌ Ответ: 2*i+1. В 1-based: left=2i. В 0-based: left=2i+1.

🔢 Сортировка подсчётом (Counting Sort)

⏱ O(n + k) 💾 O(k) 📌 Стабильная

🎯 Интуиция — аналогия из жизни

Представьте, что вы учитель, и вам нужно отсортировать оценки (от 2 до 5) тридцати учеников. Вы не сравниваете оценки между собой. Вы просто считаете: "Так, у меня три двойки, десять троек, десять четверок и семь пятерок". А потом просто выписываете их по порядку.

📝 Пошаговое текстовое описание

Находим максимум и минимум, чтобы понять диапазон возможных значений k.
Создаем массив частот (счетчиков) размера k и заполняем его, проходя по исходному массиву.
Преобразуем массив частот в префиксные суммы (накапливаем значения), чтобы получить точные индексы для размещения элементов.
Проходим по исходному массиву с конца (для стабильности) и расставляем элементы в новый массив по вычисленным индексам.

📐 Почему такая сложность?

Мы не используем сравнения. Мы проходим по массиву 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]

C++
#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).

❓ Проверь себя

Counting Sort обходит нижнюю границу O(n log n). Почему это возможно?
A Использует параллельные вычисления
B Не является сортировкой сравнением
C Работает только для отсортированных данных
D Использует больше памяти
✅ O(n log n) — нижняя граница только для сортировок СРАВНЕНИЕМ. Counting Sort не сравнивает пары, а считает вхождения → O(n+k).
❌ Ответ: не сравнивает элементы. Теорема об O(n log n) применяется только к comparison-based сортировкам.

🎰 Поразрядная сортировка (Radix Sort)

⏱ O(d · (n + k)) 💾 O(n + k) 📌 Стабильная

🎯 Интуиция — аналогия из жизни

Помните, как в библиотеке сортируют даты? Сначала собирают карточки по дням (все первые числа, потом вторые). Потом не перемешивая сдвигают по месяцам (январь, февраль). В конце — по годам. В результате даты оказываются строго отсортированы.

📝 Пошаговое текстовое описание

Находим максимальное число, чтобы понять, сколько в нем разрядов (d).
Используя стабильную сортировку (обычно Counting 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]

C++
#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: устраняет зависимость от огромного размера массива счетчиков, разбивая ключи на разряды.

❓ Проверь себя

Почему в Radix Sort каждый разряд ОБЯЗАТЕЛЬНО сортировать стабильной сортировкой?
A Иначе сортировка по предыдущим разрядам «перемешается»
B Для экономии памяти
C Это не обязательно, просто удобнее
D Для работы с отрицательными числами
✅ После сортировки по единицам числа 42 и 43 стоят в правильном порядке. Если при сортировке по десяткам нестабильная — они могут поменяться местами, хотя десятки одинаковы.
❌ Ответ: стабильность сохраняет результат предыдущих разрядов. Без стабильности весь алгоритм некорректен!

🪣 Блочная сортировка (Bucket Sort)

⏱ O(n + k) средний 💾 O(n + k) 📌 Стабильная ⚠️ Худший: O(n²)

🎯 Интуиция — аналогия из жизни

Представьте сортировку писем на почте. Сначала письма раскладывают по крупным ящикам (корзинам) для каждого региона. А потом почтальон внутри каждого ящика сортирует письма уже по конкретным улицам и домам.

📝 Пошаговое текстовое описание

Создаем N пустых «корзин» (buckets).
Раскидываем элементы массива по корзинам в зависимости от их значения (через формулу-хеш).
Сортируем каждую непустую корзину любой другой сортировкой (обычно Вставками).
Собираем элементы из корзин по порядку обратно в массив.

📐 Почему такая сложность?

Разброс по корзинам занимает 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]

C++
#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).

❓ Проверь себя

Когда Bucket Sort деградирует до O(n²)?
A Когда элементов слишком много
B Когда все элементы отрицательные
C Когда все элементы попадают в одну корзину
D Никогда
✅ Если все n элементов в одной корзине — сортировка корзины = O(n²). Нужно равномерное распределение!
❌ Ответ: все в одной корзине. Bucket Sort полагается на равномерное распределение по корзинам.

🐚 Сортировка Шелла (Shell Sort)

⏱ O(n log²n) ~ O(n^1.5) 💾 O(1) 📌 Нестабильная

🎯 Интуиция — аналогия из жизни

Представьте, что вы причесываете сильно запутанные волосы. Вы не берете сразу мелкую расческу (Insertion sort), иначе порвете волосы. Вы берете расческу с очень редкими зубьями, распутываете "глобальные" узлы, потом меняете расческу на более частую, и в конце причесываетесь мелкой. Волосы уже почти гладкие!

📝 Пошаговое текстовое описание

Выбираем начальный "шаг" gap (например, половина длины массива).
Сортируем элементы, стоящие друг от друга на расстоянии gap, с помощью сортировки вставками. Далекие элементы быстро прыгают на места.
Уменьшаем gap по выбранной последовательности (например, делим на 2) и повторяем процесс.
Последний проход делается с gap = 1 — это классическая сортировка вставками, но массив уже "почти отсортирован", поэтому она отрабатывает молниеносно.

📐 Почему такая сложность?

Сложность сильно зависит от выбранной последовательности шагов. Идея в том, что элементы не ползут по одному шагу (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]

C++
#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 устраняет её главный недостаток — перемещение маленьких элементов из конца в начало массива по одному шагу.

❓ Проверь себя

Чем Shell Sort отличается от Insertion Sort?
A Использует другую структуру данных
B Сравнивает элементы на расстоянии gap, а не соседние
C Работает только со строками
D Всегда быстрее
✅ Shell Sort = Insertion Sort с уменьшающимся шагом (gap). Это позволяет перемещать элементы на большие расстояния быстрее.
❌ Ответ: gap. Insertion Sort сравнивает соседей (gap=1). Shell Sort начинает с большого gap и уменьшает до 1.
📦

Глава 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)Компоненты связности
TrieO(L)O(L)O(L)Поиск по префиксу

📚 Стек (Stack) — LIFO

push/pop/top: O(1) 💾 O(n)

🎯 Интуиция — аналогия из жизни

Представьте стопку тарелок на кухне (или стопку блинов). Вы всегда кладете новую тарелку наверх. И когда вам нужна чистая тарелка, вы тоже берете ее сверху. Нельзя вытащить тарелку из середины, не уронив остальные.

📝 Пошаговое текстовое описание

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 → [] (стек пуст)

C++ — реализация на массиве + STL + задачи
#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) работает на стеке (явном или скрытом рекурсивном). Полная противоположность Очереди.

5 (bottom) 3 7 9 (top) ← push/pop LIFO

Не проверяем пустоту стека

❌ Краш при pop пустого

C++
stack<int> st;
// 💀 undefined behavior!
int val = st.top(); // краш
st.pop();           // краш

✅ Проверяем empty()

C++
stack<int> st;
if (!st.empty()) {   // ✅ всегда проверяем!
    int val = st.top();
    st.pop();
}

❓ Проверь себя

Строка "(([]))" — сбалансирована? Какой алгоритм проверяет?
A Стек: открывающие push, закрывающие — проверяем top и pop
B Счётчик открывающих и закрывающих скобок
C Рекурсия
D Регулярные выражения
✅ Стек идеален! Открывающую push. При закрывающей — проверяем что top = парная открывающая, pop. В конце стек должен быть пуст. Счётчик не работает для "([)]"!
❌ Стек! Счётчик не различает "([])" и "([)]". Нужно проверять КАКАЯ скобка на вершине.

🚶 Очередь (Queue) — FIFO

enqueue/dequeue: O(1) 💾 O(n)

🎯 Интуиция — аналогия из жизни

Очередь в кассу супермаркета. Кто пришел первым (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]

C++ — кольцевая очередь + STL + задачи
#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) — это двусторонняя очередь.

❓ Проверь себя

Очередь на двух стеках: почему amortized O(1) на dequeue?
A Потому что стеки работают за O(1)
B Потому что перенос делается параллельно
C Каждый элемент переносится inbox→outbox ровно 1 раз за всё время
D Перенос не нужен
✅ Хотя одна операция dequeue может стоить O(n) (перенос всего inbox), каждый элемент переносится ОДИН раз → суммарно n переносов на n операций → O(1) amortized.
❌ Amortized анализ: n элементов, каждый переносится 1 раз. Суммарно O(n) переносов ÷ n операций = O(1) на операцию.

↔️ Дек (Deque — Double-Ended Queue)

push/pop с обоих концов: O(1) 💾 O(n)

🎯 Интуиция — аналогия из жизни

Представьте колоду карт. Вы можете положить карту на самый верх колоды или подложить в самый низ. И брать карты вы можете тоже как сверху, так и снизу. Это "Очередь с двумя концами".

📝 Пошаговое текстовое описание

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]

C++ — STL deque + задача «максимум в окне»
#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 алгоритме Дейкстры.

🔗 Связь с другими

Дек обобщает и Стек, и Очередь.

❓ Проверь себя

Какая структура данных решает «максимум в скользящем окне» за O(n)?
A Стек
B Монотонный дек (убывающий)
C Хеш-таблица
D Приоритетная очередь
✅ Монотонный дек хранит индексы в убывающем порядке значений. Front = максимум окна. При добавлении — удаляем все меньшие с конца. При выходе за окно — удаляем с начала.
❌ Монотонный дек! Priority queue даёт O(n log n). Дек — O(n), т.к. каждый элемент добавляется/удаляется не более 1 раза.

🔗 Связный список (Singly Linked List)

Вставка/удаление в начало: O(1) 💾 O(n) Поиск: O(n)

🎯 Интуиция — аналогия из жизни

Охота за сокровищами (квест с записками). Вы находите первую записку (head). В ней написан текст (данные) и адрес места, где спрятана вторая записка (next). Вы идете туда, читаете вторую записку с указанием на третью. Вы не можете найти 5-ю записку, не пройдя по всем предыдущим.

📝 Пошаговое текстовое описание

Каждый узел (Node) содержит данные и указатель на следующий узел.
Первый узел называется 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 ✅

C++ — полная реализация + классические задачи
#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-го узла с конца.

🔗 Связь с другими

Списки лежат в основе Хеш-таблиц (метод цепочек для разрешения коллизий), Стеков и очередей (если размер не ограничен).

head 1 3 5 7 NULL

❓ Проверь себя

Как найти середину списка за один проход?
A Посчитать длину, потом пройти n/2
B Два указателя: slow (×1) и fast (×2)
C Рекурсия
D Невозможно за один проход
✅ Fast/slow pointers: slow шагает ×1, fast ×2. Когда fast дойдёт до конца, slow будет ровно в середине!
❌ Slow/fast pointer! Один проход: slow+=1, fast+=2. Когда fast=null → slow в середине.

🔗🔗 Двусвязный список (Doubly Linked List)

Вставка/удаление: O(1) при известном узле 💾 O(n)

🎯 Интуиция — аналогия из жизни

Поезд, в котором двери между вагонами работают в обе стороны. Вы можете пройти от первого вагона до пятого, развернуться и пойти обратно от пятого ко второму.

📝 Пошаговое текстовое описание

Каждый узел содержит данные, указатель next и указатель prev.
Удаление: если у нас есть адрес узла B, мы берем B.prev и связываем его с B.next, минуя B.
Вставка: аккуратно "разрываем" связь между двумя узлами и встраиваем новый, перекидывая 4 указателя.

📐 Почему такая сложность?

В отличие от односвязного списка, где для удаления узла 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) ✅

C++ — двусвязный список + LRU Cache
#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++ реализован именно так. Расширяет односвязный список за счет дополнительных накладных расходов памяти по одному указателю на узел.

prev | 10 | next prev | 20 | next prev | 30 | next head tail NULL NULL

Забыли обновить обе ссылки при удалении узла

❌ Обновляется только next

C++
// 💀 Ошибка: меняем только одну сторону
node->prev->next = node->next;
// node->next->prev НЕ обновили!
// список стал битым
delete node;

✅ Нужно обновлять обе стороны

C++
node->prev->next = node->next;
node->next->prev = node->prev;  // ✅
delete node;

Удаление головы/хвоста без sentinel-узлов

❌ Много if и легко ошибиться

C++
// 💀 Крайние случаи: head, tail, один элемент
if (node == head) ...
if (node == tail) ...
if (head == tail) ...
// код быстро становится хрупким

✅ Использовать sentinel head/tail

C++
// ✅ фиктивные head/tail сильно упрощают код
head = new DNode(0,0);
tail = new DNode(0,0);
head->next = tail;
tail->prev = head;
// теперь удаление/вставка одинаковы для всех узлов

❓ Проверь себя

Почему LRU Cache обычно реализуют через hash map + doubly linked list?
A Потому что двусвязный список хранит данные отсортированно
B Hash map даёт O(1) доступ по ключу, а двусвязный список — O(1) перемещение/удаление узла
C Потому что queue не умеет удалять элементы
D Потому что vector слишком медленный
✅ Именно так. Хеш-таблица находит узел по ключу за O(1), а двусвязный список позволяет удалить/переместить этот узел за O(1), зная указатель.
❌ Правильный ответ: hash map + doubly linked list дают обе ключевые операции O(1): поиск по ключу и перемещение узла в начало.

#️⃣ Хеш-таблица (Hash Table)

⏱ O(1) среднее 💾 O(n) ⚠️ Худший: O(n) при коллизиях

🎯 Интуиция — аналогия из жизни

Библиотека, где номера книг вычисляются по названию. Вы даете библиотекарю книгу "Гарри Поттер", он пропускает это название через математическую мясорубку (Хеш-функцию), которая выдает: "Полка №42". Он идет к полке 42 и ставит книгу. Чтобы найти ее, он делает ту же математику, получает "42" и мгновенно забирает книгу, не перебирая все остальные.

📝 Пошаговое текстовое описание

Берем ключ (например, строку "apple").
Хеш-функция превращает "apple" в число (например, 59814).
Берем остаток от деления на размер массива (59814 % 10 = 4). Кладём значение в ячейку с индексом 4.
Если в ячейке 4 уже что-то есть (Коллизия), используем массив/список внутри этой ячейки (метод цепочек) или ищем следующую свободную ячейку (открытая адресация).

📐 Почему такая сложность?

Вычисление хеша и доступ по индексу массива занимает 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. Найдено!

C++ — реализация + STL + задачи
#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].

Забываем о коллизиях

❌ Не обрабатываем коллизии

C++
// 💀 Если hash(a)==hash(b), b затирает a!
int table[SIZE];
table[hash(key)] = value;
// Потеря данных при коллизии!

✅ Метод цепочек

C++
// ✅ Список на каждой позиции
vector<list<pair<K,V>>> table(SIZE);
table[hash(key)].push_back({key, val});
// Коллизии хранятся в списке

❓ Проверь себя

unordered_map vs map в C++: когда выбрать map?
A Когда нужна скорость
B Когда ключи — строки
C Когда нужен порядок ключей или гарантия O(log n)
D Всегда лучше map
✅ map (красно-чёрное дерево): O(log n) гарантированно, ключи отсортированы. unordered_map: O(1) в среднем, но O(n) в худшем при коллизиях. На олимпиадах map безопаснее!
❌ map когда нужен порядок или гарантия. unordered_map может деградировать до O(n) при специально подобранных данных (антихеш-тесты).

⬆️ Приоритетная очередь / Бинарная куча

insert: O(log n) extractMin/Max: O(log n) peek: O(1)

🎯 Интуиция — аналогия из жизни

Приемный покой больницы. Неважно, кто пришел первым, доктор примет пациента с самой высокой температурой или тяжелой травмой. Это очередь, отсортированная по приоритету.

📝 Пошаговое текстовое описание

Физически куча — это обычный массив. Логически — полное бинарное дерево (заполняется по слоям слева направо).
Свойство кучи (Max-Heap): родитель всегда больше или равен своим детям.
Для вставки: кладем элемент в конец массива и "просеиваем вверх" (Sift Up), меняя с родителем, пока он не станет меньше родителя.
Для извлечения: забираем корень (максимум), ставим на его место последний элемент и "просеиваем вниз" (Sift Down).

📐 Почему такая сложность?

Дерево плотно упаковано и всегда сбалансировано. Его высота строго log₂n. Операции просеивания (Sift Up / Sift Down) проходят путь от листа до корня (или наоборот), делая максимум log₂n шагов. Извлечение максимума — мгновенно O(1).

🔎 Подробная трассировка: Вставка в Min-Heap [2, 4, 8] значения 1

Начало (логически дерево):
   2
  / \
 4   8
(Массив: [2, 4, 8])
Шаг 1 (Вставка в конец): добавляем 1.
   2
  / \
 4   8
/
1
(Массив: [2, 4, 8, 1])
Шаг 2 (Sift Up): 1 меньше родителя (4). Меняем их!
   2
  / \
 1   8
/
4
(Массив: [2, 1, 8, 4])
Шаг 3 (Sift Up): 1 меньше родителя (2). Меняем их!
   1
  / \
 2   8
/
4
(Массив: [1, 2, 8, 4]) ✅ Куча восстановлена.

C++ — реализация min-heap + STL + задачи
#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), но куча быстрее на константу и не хранит указателей.

❓ Проверь себя

Как сделать min-heap из priority_queue в C++?
A priority_queue<int, min>
B priority_queue<int, vector<int>, greater<int>>
C Вставлять отрицательные числа
D priority_queue по умолчанию min-heap
✅ По умолчанию max-heap. Для min-heap: priority_queue<int, vector<int>, greater<int>>. Вариант С тоже работает на практике, но менее чистый.
priority_queue<int, vector<int>, greater<int>> — три параметра шаблона. По умолчанию max-heap!

🔗 Система непересекающихся множеств (Union-Find / DSU)

find/union: ≈ O(α(n)) ≈ O(1) 💾 O(n)

🎯 Интуиция — аналогия из жизни

Группировка мафиозных кланов. Изначально каждый бандит — сам себе босс. Если Вася и Петя объединяются, то босс Васи становится подчиненным босса Пети. Чтобы узнать, в одном ли клане Джон и Джек, мы находим их "крестных отцов" и проверяем, один ли это человек.

📝 Пошаговое текстовое описание

Поддерживаем массив 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)

C++ — DSU + задачи
#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

C++
// 💀 Без сжатия пути: линейная цепочка
int find(int x) {
    while (parent[x] != x) x = parent[x];
    return x;  // O(n) в худшем!
}

✅ ≈O(1) со сжатием пути

C++
// ✅ Сжатие пути: все узлы → корень
int find(int x) {
    if (parent[x] != x)
        parent[x] = find(parent[x]); // ✅
    return parent[x]; // ≈O(α(n))≈O(1)
}

❓ Проверь себя

Что такое α(n) в сложности DSU?
A Обратная функция Аккермана — растёт невероятно медленно (≤4 для любых практических n)
B log log n
C Константа 1
D √n
✅ α(n) ≤ 4 для n ≤ 10⁸⁰ (число атомов во Вселенной). На практике O(α(n)) = O(1).
❌ α(n) — обратная функция Аккермана. Растёт так медленно, что на практике ≤ 4. Почти O(1)!

🌿 Trie (Префиксное дерево)

insert/search/startsWith: O(L) 💾 O(N·L·Σ)

🎯 Интуиция — аналогия из жизни

Система автодополнения (T9) или бумажный словарь. Вы ищете слово "CAT". Открываете секцию "C", внутри нее ищете букву "A", а затем "T". Слова, начинающиеся одинаково (например, "CAT" и "CAR"), делят общий префикс "CA" и хранятся вместе, экономя время поиска.

📝 Пошаговое текстовое описание

Дерево состоит из узлов, каждый содержит массив ссылок на детей (обычно 26 для английского алфавита) и флаг конца слова (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. Узлы есть → ✅ Начинается с этого!

C++ — Trie + автодополнение + подсчёт
#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), а не только по полному ключу.

root c d c d a a t r t* r* * = конец слова "cat", "car"

❓ Проверь себя

Какая сложность поиска слова длины L в Trie с N словами?
A O(L) — не зависит от N!
B O(N)
C O(N × L)
D O(log N)
✅ Trie идёт по буквам слова, на каждую O(1) — переход по указателю. Итого O(L). Количество слов N не влияет!
❌ O(L)! Trie проходит ровно L узлов (по одному на букву), не зависит от общего количества слов.
🌳

Глава 4. Деревья

📌 Деревья — основа алгоритмики

Деревья используются повсюду: файловые системы, базы данных (B-деревья), компиляторы (AST), DOM в браузере, XML/JSON парсинг. На собеседованиях — один из самых частых типов задач.

СтруктураПоискВставкаУдалениеОсобенность
BST (несбалансированный)O(h)O(h)O(h)h может быть n
AVLO(log n)O(log n)O(log n)Строго сбалансированное
Red-BlackO(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)

⏱ O(h), h = log n в среднем 💾 O(n) ⚠️ Худший: O(n) если дерево вырождено

🎯 Интуиция — аналогия из жизни

Представьте огромный библиотечный каталог. Все книги слева от текущей полки начинаются на более раннюю букву алфавита, а все книги справа — на более позднюю. Чтобы найти нужную, вы не перебираете все полки, а каждый раз сужаете область поиска в два раза, выбирая левый или правый коридор.

📝 Пошаговое текстовое описание

Свойство: для каждого узла все элементы в левом поддереве строго меньше, а в правом — строго больше.
Поиск и вставка: начинаем с корня. Если искомое значение меньше текущего — идем налево, если больше — направо. Повторяем, пока не найдем значение или не упремся в nullptr (туда и вставляем новый узел).
Удаление: 1) Узел — лист: просто удаляем. 2) Узел имеет 1 ребенка: заменяем узел его ребенком. 3) Узел имеет 2 детей: находим минимальный элемент в правом поддереве (inorder successor), копируем его значение в удаляемый узел и рекурсивно удаляем этот найденный минимум.

📐 Почему такая сложность?

Все операции пропорциональны высоте дерева 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 обход (лево-корень-право) всегда дает отсортированный массив!

C++ — полный BST с удалением
#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).

8 3 10 1 6 14 left < root < right

❓ Проверь себя

Какой обход BST даёт отсортированную последовательность?
A Preorder (корень-лево-право)
B Inorder (лево-корень-право)
C Postorder (лево-право-корень)
D Level-order (BFS)
✅ Inorder: сначала всё левое (меньшее), потом корень, потом правое (большее) → отсортированный порядок!
❌ Inorder! Лево→Корень→Право. Свойство BST: left < root < right → Inorder = сортировка.

⚖️ AVL-дерево (самобалансирующееся BST)

Всё: O(log n) гарантированно 💾 O(n)

🎯 Интуиция — аналогия из жизни

Представьте весы с чашами. Как только одна чаша перевешивает другую больше чем на одну гирьку (дерево начало "косить" в одну сторону), мы сразу же перекладываем гирьки (делаем "балансирующий поворот"), чтобы весы снова стали ровными. Дерево само поддерживает свою симметричность.

📝 Пошаговое текстовое описание

Это обычное BST, но каждый узел дополнительно хранит свою высоту.
Balance Factor (BF): разница высот левого и правого поддерева: 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)
  /
 20 (h=1, BF=0)
Вставляем 10:
   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)
✅ Дерево снова сбалансировано!

C++ — AVL с вставкой, удалением, поворотами
#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) используется Красно-черное дерево из-за лучшего баланса времени модификации.

❓ Проверь себя

Сколько типов поворотов в AVL-дереве?
A 1
B 2
C 3
D 4 (LL, RR, LR, RL)
✅ LL→правый поворот. RR→левый. LR→левый+правый. RL→правый+левый. Всего 4 случая, 2 базовых поворота.
❌ 4 случая: LL, RR, LR, RL. Но базовых поворотов 2 (левый и правый), LR и RL — комбинации.

🚶 Обходы дерева (Tree Traversals)

⏱ O(n) 💾 O(h) рекурсия / O(n) BFS

🎯 Интуиция — аналогия из жизни

Представьте, что вы читаете сложную книгу.
Preorder: Вы читаете оглавление (Корень), а затем погружаетесь в каждую главу по порядку.
Inorder: Вы читаете страницы последовательно слева направо.
Postorder: Вы сначала читаете все подразделы, и только потом делаете общий вывод (Корень) по всей главе.
Level-order: Вы сначала читаете первые строчки всех глав, потом вторые строчки и т.д.

📝 Пошаговое текстовое описание

Preorder (Корень-Лево-Право): Обработать узел до обработки его детей. Подходит для копирования дерева или сериализации.
Inorder (Лево-Корень-Право): Для BST выдает элементы в отсортированном по возрастанию порядке.
Postorder (Лево-Право-Корень): Обработать узел после обработки всех его детей. Идеально для удаления дерева (сначала удаляем детей, потом себя).
Level-order (BFS): Обход по слоям сверху вниз. Реализуется с помощью Очереди.

📐 Почему такая сложность?

Каждый узел посещается ровно один раз, что дает время 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].

C++ — все обходы (рекурсивно + итеративно)
#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).

❓ Проверь себя

Preorder обход дерева [1(корень), 2(лев), 3(прав), 4(лев-лев), 5(лев-прав)] даёт:
A 1, 2, 4, 5, 3
B 4, 2, 5, 1, 3
C 4, 5, 2, 3, 1
D 1, 2, 3, 4, 5
✅ Preorder = Корень→Лево→Право. 1→2→4→5→3. Мнемоника: «сначала я, потом дети».
❌ Pre=Корень-Лево-Право: 1,2,4,5,3. In=Лево-Корень-Право: 4,2,5,1,3. Post=Лево-Право-Корень: 4,5,2,3,1.

📊 Дерево отрезков (Segment Tree)

build: O(n) | query/update: O(log n) 💾 O(4n)

🎯 Интуиция — аналогия из жизни

Иерархия корпорации. Рядовые сотрудники (листья дерева) сдают свои отчеты о продажах менеджерам. Менеджер суммирует данные своего отдела и передает директору (корень). Если гендиректору нужна сумма продаж по 4 отделам, он не опрашивает всех 100 сотрудников, а берет 4 готовых агрегированных отчета от менеджеров. Если сотрудник меняет данные, он сообщает менеджеру, тот — директору. Обновление идет быстро снизу вверх.

📝 Пошаговое текстовое описание

Дерево строится рекурсивно. Корень отвечает за весь массив [0, n-1].
Каждый узел [L, R] делится пополам. Левый ребенок отвечает за [L, mid], правый — за [mid+1, R]. Значение узла = сумма/мин/макс его детей.
Точечное обновление: спускаемся от корня к листу, меняем значение и при возврате из рекурсии пересчитываем узлы.
Запрос на отрезке [l, 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) шагов!

C++ — Segment Tree (сумма + обновление + lazy propagation)
#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). Если есть точечные обновления — можно использовать более простое Дерево Фенвика (но оно ограничено операциями с обратным элементом).

❓ Проверь себя

Зачем нужен lazy propagation в дереве отрезков?
A Чтобы экономить память
B Чтобы обновлять ОТРЕЗОК за O(log n) вместо O(n)
C Чтобы ускорить построение дерева
D Чтобы поддерживать удаление
✅ Без lazy: обновление [l,r] = O(n) (каждый элемент). С lazy: «откладываем» обновление, спускаем вниз только при запросе → O(log n) на обновление отрезка!
❌ Обновление отрезка! Без lazy каждый update(l,r,val) = O((r-l)·log n). С lazy = O(log n).

🔢 Дерево Фенвика (Binary Indexed Tree / BIT)

update/query: O(log n) 💾 O(n)

🎯 Интуиция — аналогия из жизни

Представьте русскую матрешку, но для математики. Любое число можно представить как сумму степеней двойки (например, 13 = 8 + 4 + 1). Фенвик хранит заранее посчитанные блоки сумм длин 1, 2, 4, 8... Чтобы получить сумму 13 элементов, мы просто складываем готовый блок из 8 элементов, блок из 4 элементов и блок из 1 элемента.

📝 Пошаговое текстовое описание

Массив tree должен быть строго 1-индексирован.
Каждый элемент tree[i] отвечает за сумму отрезка длины i & -i (младший единичный бит).
Сумма до i: суммируем tree[i] и отнимаем младший бит (идем к предыдущим блокам: 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].

C++ — BIT + задачи
#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).

❓ Проверь себя

Что делает операция i & (-i) в BIT?
A Обнуляет число
B Делит на 2
C Выделяет наименьший установленный бит
D Инвертирует все биты
✅ i&(-i) = lowest set bit. Например: 12=1100₂, -12=0100₂, 12&(-12)=0100₂=4. Это определяет «ответственность» каждой ячейки BIT.
❌ Lowest set bit! i&(-i) даёт число с одним битом — самым младшим установленным в i.

👨‍👩‍👧 LCA — Наименьший общий предок

Препроцессинг: O(n log n) | Запрос: O(log n) 💾 O(n log n)

🎯 Интуиция — аналогия из жизни

Поиск общего начальника в генеалогическом древе. Если один узел глубже другого, он "поднимается" на один уровень с ним. Затем оба начинают подниматься вверх. Вместо того чтобы идти шаг за шагом (долго!), мы используем Binary Lifting: делаем "прыжки" сразу на 16, 8, 4, 2, 1 шагов вверх, перепрыгивая огромные куски дерева за логарифм времени.

📝 Пошаговое текстовое описание

Предрасчет: Запускаем DFS, чтобы узнать depth[v] (глубину) для каждого узла. Заодно строим таблицу up[v][k] — это предок вершины v на расстоянии 2^k.
Формула: up[v][k] = up[ up[v][k-1] ][ k-1 ] (предок на расстоянии 8 — это предок на 4 от предка на 4).
Запрос LCA(u, v): Если глубины разные, "поднимаем" нижнюю вершину до уровня верхней, используя биты разницы высот.
Если после этого u == v, мы нашли ответ. Иначе поднимаем обе вершины одновременно большими шагами 2^k, пока их предки 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.

C++ — LCA с двоичным подъёмом
#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)).

❓ Проверь себя

Что хранит up[v][k] в Binary Lifting?
A Глубину вершины v + k
B Предка вершины v на расстоянии 2^k
C Вес ребра к k-му соседу
D Количество потомков на уровне k
✅ up[v][k] = предок v на расстоянии 2^k. Рекуррента: up[v][k] = up[up[v][k-1]][k-1] (предок предка). Это позволяет «прыгать» за O(log n).
❌ Предок на расстоянии 2^k! up[v][0]=parent, up[v][1]=grandparent, up[v][2]=прапрадед и т.д.
🌐

Глава 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³)Любые веса
KruskalMSTO(E log E)
PrimMSTO((V+E) log V)
Topological SortПорядок зависимостейO(V+E)DAG
Tarjan / KosarajuSCCO(V+E)Ориентированный
BridgesМосты / точки сочлененияO(V+E)Неориентированный

📐 Представление графа

Список смежности: O(V+E) памяти Матрица смежности: O(V²) памяти

🎯 Интуиция — аналогия из жизни

Список смежности — это телефонная книга: у каждого человека записаны только те люди, которых он знает лично. Матрица смежности — это огромная таблица Excel, где по горизонтали и вертикали выписаны все люди планеты, и на пересечении ставится «галочка» или «крестик». Список ребер — это просто список всех дорог с указанием начала и конца.

📝 Пошаговое текстовое описание

Список смежности: Создаем массив массивов (или vector<vector<int>>). Для каждой вершины u храним список её соседей v. Если есть вес w, храним пары pair<v, w>.
Матрица смежности: Создаем 2D-массив размером 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;

C++ — 3 способа представления
#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. Матрица нужна для алгоритма Флойда-Уоршелла. Список рёбер идеален для Краскала и Беллмана-Форда.

❓ Проверь себя

Когда лучше использовать матрицу смежности вместо списка?
A Всегда
B Когда граф разреженный (мало рёбер)
C Когда граф плотный (E≈V²) или нужна проверка ребра за O(1)
D Когда граф ориентированный
✅ Матрица: O(V²) памяти, O(1) проверка ребра. Хороша для плотных графов и Floyd-Warshall. Список: O(V+E) памяти — для разреженных.
❌ Плотный граф (E≈V²) или O(1) проверка ребра. Для разреженных (E≪V²) матрица тратит слишком много памяти.

🌊 BFS — Обход в ширину (Breadth-First Search)

⏱ O(V + E) 💾 O(V)

🎯 Интуиция — аналогия из жизни

Представьте круги на воде от брошенного камня или лесной пожар. Огонь сначала сжигает все деревья на расстоянии 1 метра, потом перекидывается на те, что в 2 метрах, и так далее. Мы равномерно расширяем границу поиска во все стороны.

📝 Пошаговое текстовое описание

Создаем Очередь (Queue) и массив 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

C++ — BFS + кратчайший путь + задачи
#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.

❓ Проверь себя

BFS находит кратчайший путь в каком графе?
A Любом взвешенном
B Невзвешенном (все рёбра вес 1)
C Только в деревьях
D Только в DAG
✅ BFS обходит по «волнам» — сначала все на расстоянии 1, потом 2, ... Для взвешенных нужен Dijkstra!
❌ Невзвешенный! BFS = «поиск по уровням». Кратчайший путь = минимум рёбер. Для весов ≠ 1 → Dijkstra.

🕳️ DFS — Обход в глубину (Depth-First Search)

⏱ O(V + E) 💾 O(V)

🎯 Интуиция — аналогия из жизни

Прохождение лабиринта по правилу левой руки с нитью Ариадны. Мы идем в первый попавшийся коридор до самого конца, пока не упремся в тупик. Только оказавшись в тупике, мы "сматываем нить" (возвращаемся на шаг назад) и проверяем другой коридор.

📝 Пошаговое текстовое описание

Вызываем рекурсивную функцию для текущей вершины v.
Немедленно помечаем v как visited.
Перебираем всех соседей u. Если сосед не посещен — вызываем DFS от u.
Когда у вершины больше нет непосещенных соседей, рекурсия автоматически делает "шаг назад" (return) к родителю.

📐 Почему такая сложность?

Как и в 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

C++ — DFS + обнаружение циклов + компоненты связности
#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

❌ Бесконечный цикл

C++
void dfs(int v) {
    // 💀 Без visited!
    cout << v;
    for (int u : adj[v])
        dfs(u);  // зацикливается!
}

✅ С отметкой visited

C++
void dfs(int v) {
    visited[v] = true;  // ✅
    cout << v;
    for (int u : adj[v])
        if (!visited[u]) // ✅
            dfs(u);
}

❓ Проверь себя

DFS vs BFS: что лучше для поиска цикла в ориентированном графе?
A DFS с тремя цветами (белый/серый/чёрный)
B BFS
C Оба одинаково хороши
D Ни один не может
✅ DFS с 3 цветами: белый=не посещён, серый=в стеке рекурсии, чёрный=завершён. Встретили серого → обратное ребро → цикл! BFS может через Kahn (topo sort).
❌ DFS с цветами! Серый узел = в текущем пути → цикл. BFS тоже может (если topo sort не покрывает все вершины).

🗺️ Алгоритм Дейкстры (Dijkstra)

⏱ O((V+E) log V) 💾 O(V) ⚠️ Без отрицательных весов!

🎯 Интуиция — аналогия из жизни

Ищем кратчайший маршрут в навигаторе (GPS). Навигатор рисует круги вокруг вашего текущего положения, постепенно увеличивая радиус. Он всегда выбирает ближайший город, в котором еще не был, и обновляет информацию о том, как быстрее добраться до его соседей.

📝 Пошаговое текстовое описание

Создаем массив dist[], где все элементы равны INF (бесконечность), а dist[start] = 0.
Помещаем старт в Приоритетную очередь (Min-Heap): {расстояние=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).

C++ — Dijkstra + восстановление пути
#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*.

0 1 2 3 4 4 2 1 5 1 dist=0

❓ Проверь себя

Что произойдёт если в графе есть отрицательное ребро?
A Dijkstra может дать неправильный ответ
B Зациклится
C Выдаст ошибку компиляции
D Ничего, работает корректно
✅ Dijkstra «фиксирует» расстояние при извлечении из очереди. Отрицательное ребро может дать более короткий путь к уже зафиксированной вершине → ответ неверный. Используй Bellman-Ford!
❌ Неправильный ответ! Зафиксированная вершина может быть улучшена через отрицательное ребро, но Dijkstra уже не пересмотрит.

📉 Алгоритм Беллмана-Форда

⏱ O(V · E) 💾 O(V) ✅ Работает с отрицательными весами

🎯 Интуиция — аналогия из жизни

Эффект сломанного телефона или распространение слухов. За один шаг слух (информация об улучшении маршрута) продвигается максимум на один город. Самый длинный возможный маршрут без циклов проходит через V-1 городов. Следовательно, за V-1 шагов слух гарантированно долетит до любой точки.

📝 Пошаговое текстовое описание

Устанавливаем расстояние до старта 0, до остальных INF.
Делаем цикл от 1 до V-1. На каждой итерации просто перебираем абсолютно все рёбра графа.
Делаем релаксацию: если dist[u] + w < dist[v], обновляем dist[v].
Делаем 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-й проход):
Продолжает улучшаться -> Отрицательный цикл найден! ✅

C++ — Bellman-Ford с детекцией цикла
#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) — это Беллман-Форд с очередью, где мы смотрим только на те ребра, которые изменились на прошлом шаге.

❓ Проверь себя

Сколько итераций нужно Bellman-Ford? Почему?
A E итераций (по числу рёбер)
B V-1 итераций: кратчайший путь содержит не более V-1 рёбер
C V итераций
D log V итераций
✅ Путь без циклов проходит не более V-1 рёбер (V вершин). За i-ю итерацию гарантированно находим кратчайшие пути длины ≤ i. V-я итерация — проверка отрицательного цикла.
❌ V-1! Кратчайший путь = максимум V-1 рёбер. После V-1 итераций все кратчайшие расстояния найдены. V-я итерация → если есть улучшение → отрицательный цикл.

🔄 Алгоритм Флойда-Уоршелла

⏱ O(V³) 💾 O(V²) 🏆 Все пары вершин (All-Pairs)

🎯 Интуиция — аналогия из жизни

Поиск авиабилетов с пересадками. Вы смотрите: прямой рейс Москва-Токио стоит $1000. А если лететь Москва -> Дубай -> Токио? Если составной маршрут дешевле ($800), вы вычеркиваете прямой путь из блокнота и записываете маршрут с пересадкой. Алгоритм проверяет абсолютно все возможные "города пересадки".

📝 Пошаговое текстовое описание

Подготавливаем матрицу dist[i][j], где записаны веса прямых рёбер. Диагональ dist[i][i] = 0. Отсутствующие рёбра = INF.
Выбираем промежуточную вершину K (это будет внешний цикл).
Перебираем все возможные пары старт I и конец J.
Смотрим: не короче ли будет путь из 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! ✅

C++ — матрица расстояний
#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 внутри — НЕКОРРЕКТНО

C++
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 снаружи — КОРРЕКТНО

C++
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]);

❓ Проверь себя

Как обнаружить отрицательный цикл через Floyd-Warshall?
A Если есть отрицательное ребро
B Если dist[0][n-1] отрицательный
C Если dist[i][i] < 0 для какого-то i
D Невозможно обнаружить
✅ dist[i][i] = стоимость цикла из i в i. Изначально = 0. Если стало < 0 → есть отрицательный цикл проходящий через i!
❌ Проверяем диагональ: dist[i][i] < 0. Расстояние от вершины до самой себя = 0, если стало отрицательным → негативный цикл.

🌉 Алгоритм Краскала (MST)

⏱ O(E log E) 💾 O(V + E)

🎯 Интуиция — аналогия из жизни

Государству нужно соединить асфальтированными дорогами 10 городов так, чтобы из любого можно было проехать в любой, потратив минимум денег. Жадный подрядчик сортирует все возможные дороги по смете. Сначала он строит самую дешевую. Затем следующую. Если дорога ведет в город, куда уже можно доехать по построенным трассам (кольцо), он ее выбрасывает. Строит до тех пор, пока все города не свяжутся.

📝 Пошаговое текстовое описание

Помещаем все рёбра графа в линейный массив.
Сортируем рёбра по возрастанию их веса.
Инициализируем структуру DSU (Union-Find), чтобы каждый город был в своем множестве.
Идём по отсортированным ребрам. Проверяем концы ребра через 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

C++ — Краскал + DSU
#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. Является прямым конкурентом алгоритма Прима. Краскал лучше работает на разреженных графах (где мало рёбер), так как сортировать маленький массив быстрее.

❓ Проверь себя

Когда Kruskal добавляет ребро в MST?
A Когда оно самое тяжёлое
B Когда оно соединяет две РАЗНЫЕ компоненты (не образует цикл)
C Всегда
D Когда оно инцидентно уже добавленной вершине
✅ Сортируем рёбра по весу. Берём лёгкие первыми. Добавляем только если find(u)≠find(v) — иначе образуется цикл! Используем DSU для проверки за ≈O(1).
❌ Когда ребро соединяет разные компоненты! Проверяем через DSU: find(u)≠find(v). Если равны → цикл → пропускаем.

🌲 Алгоритм Прима (MST)

⏱ O((V+E) log V) 💾 O(V + E)

🎯 Интуиция — аналогия из жизни

Распространение плесени на хлебе. Она начинает расти из одной случайной точки. На каждом шаге она выбирает ближайший (самый легкий) нетронутый кусочек хлеба вокруг себя и поглощает его, присоединяя к своей колонии.

📝 Пошаговое текстовое описание

Начинаем с произвольной вершины (например, 0). Помещаем ее в Min-Heap с весом 0.
Пока куча не пуста, извлекаем пару {вес, вершина_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

C++ — Prim с Priority Queue
#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

❌ Бесконечный цикл

C++
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

C++
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 от старта

C++
// 💀 Prim ≠ Dijkstra!
// В Dijkstra: dist[u] = dist[v] + w
// В Prim: это НЕПРАВИЛЬНО!
if (dist[v] + w < dist[u]) // 💀 НЕТ!
    dist[u] = dist[v] + w;

✅ Prim: хранить ВЕС РЕБРА

C++
// ✅ Prim: в очередь кладём ВЕС РЕБРА
// а не расстояние от старта!
pq.push({weight, u}); // ✅ вес ребра v→u
// Берём минимальное ребро к вершине вне MST

❓ Проверь себя

В чём ключевое отличие Prim от Dijkstra?
A Prim быстрее
B Dijkstra для MST, Prim для кратчайших путей
C Prim хранит вес ребра, Dijkstra — расстояние от старта
D Нет отличий
✅ Prim: priority = вес ребра к вершине. Dijkstra: priority = суммарное расстояние от старта. Код почти одинаковый, но семантика разная!
❌ Prim: min(вес ребра). Dijkstra: min(расстояние от старта). Prim→MST. Dijkstra→кратчайшие пути.

📋 Топологическая сортировка

⏱ O(V + E) 💾 O(V) 📌 Только для DAG!

🎯 Интуиция — аналогия из жизни

Процесс одевания: вы не можете надеть ботинки, пока не надели носки; вы не можете надеть куртку до рубашки. Топологическая сортировка выстраивает все элементы в линию так, чтобы каждое действие выполнялось строго после своих предпосылок.

📝 Пошаговое текстовое описание (Алгоритм Кана)

Подсчитываем количество входящих рёбер (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

C++ — Метод Кана (BFS) и Метод DFS
#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).

Топо-сортировка на графе с циклом

❌ Не проверяем цикл

C++
// 💀 Kahn: если цикл — тихо вернёт
// неполный результат!
auto order = kahnSort(n, adj);
// order.size() < n, но мы не проверяем!

✅ Проверяем размер результата

C++
auto order = kahnSort(n, adj);
if ((int)order.size() != n) {
    cout << "CYCLE DETECTED!"; // ✅
    return {};
}

DFS topo: забыли reverse

❌ Порядок перевёрнут

C++
void dfs(int v) {
    visited[v] = true;
    for (int u : adj[v]) if (!visited[u]) dfs(u);
    order.push_back(v);
}
// 💀 order в ОБРАТНОМ порядке!
// Нужно reverse!

✅ reverse в конце

C++
// После DFS:
reverse(order.begin(), order.end()); // ✅
// Или сразу используйте стек

❓ Проверь себя

Топологическая сортировка возможна для какого графа?
A Любого ориентированного
B Неориентированного без циклов
C DAG (ориентированный ациклический)
D Любого графа
✅ Только DAG! Если есть цикл A→B→C→A, кто первый? Невозможно. Неориентированный тоже нельзя (A—B: кто раньше?).
❌ DAG = Directed Acyclic Graph. Ориентированный + без циклов. Других вариантов нет.

🔗 Компоненты сильной связности (SCC — Tarjan / Kosaraju)

⏱ O(V + E) 💾 O(V)

🎯 Интуиция — аналогия из жизни

Представьте улицы с односторонним движением в городе. SCC (компонента сильной связности) — это такой район, где, стартовав из любой точки, вы сможете доехать по правилам до любой другой точки в этом же районе. Выезд из района в соседний возможен, но вернуться назад будет нельзя (иначе они бы слились в один огромный район).

📝 Пошаговое текстовое описание (Алгоритм Косарайю)

Выполняем DFS на исходном графе. При выходе из рекурсии для вершины (postorder) кладем её в Стек.
Строим транспонированный граф (переворачиваем все стрелки рёбер).
Достаем вершины из Стека одну за другой.
Если вершина еще не посещена во втором обходе, запускаем от нее DFS по перевернутому графу. Все достигнутые вершины составляют одну 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}. ✅

C++ — Kosaraju
#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 компонент).

❓ Проверь себя

Kosaraju использует два прохода DFS. Зачем второй по транспонированному графу?
A Чтобы найти циклы
B В транспонированном графе DFS из корня SCC не выйдет за границы компоненты
C Для подсчёта рёбер
D Транспонирование не нужно
✅ Первый DFS даёт порядок завершения. Во втором (по транспонированному) DFS «не может выйти» из SCC, потому что рёбра между компонентами направлены в другую сторону!
❌ DFS по транспонированному графу в порядке убывания finish time → каждый DFS обходит ровно одну SCC.

🌉 Мосты и точки сочленения

⏱ O(V + E) 💾 O(V)

🎯 Интуиция — аналогия из жизни

Поиск "критической инфраструктуры". Представьте дорожную сеть между островами. Мост (Bridge) — это дорога, взрыв которой разобьет архипелаг на две изолированные части. Точка сочленения (Articulation Point) — это перекресток (город), закрытие которого парализует движение между остальными частями.

📝 Пошаговое текстовое описание

Запускаем DFS. При входе в вершину 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 ложно (не мост).

C++ — Алгоритм Тарьяна для мостов
#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]

C++
// 💀 >= для ТОЧКИ СОЧЛЕНЕНИЯ, не моста!
if (low[u] >= tin[v])
    bridges.push_back({v, u}); // НЕВЕРНО!

✅ Мост: low[u] > tin[v] (строго)

C++
// ✅ Строго >: нет обратного ребра выше v
if (low[u] > tin[v])          // мост
    bridges.push_back({v, u});
if (low[u] >= tin[v])         // точка сочленения
    isAP[v] = true;

Обратное ребро к родителю

❌ Обновляем low по ребру к parent

C++
for (int u : adj[v]) {
    if (visited[u])
        low[v] = min(low[v], tin[u]); // 💀
    // Ребро v→parent тоже обновит!
    // → мост не будет найден
}

✅ Пропускаем parent

C++
for (int u : adj[v]) {
    if (u == parent) continue; // ✅
    if (visited[u])
        low[v] = min(low[v], tin[u]);
    // ...
}

❓ Проверь себя

Мост в графе — это ребро, удаление которого...
A Уменьшает число вершин
B Увеличивает число компонент связности
C Создаёт цикл
D Уменьшает диаметр графа
✅ Мост «разрезает» граф на две части. Если low[u] > tin[v] → из поддерева u нельзя попасть выше v без ребра v-u → это мост.
❌ Увеличивает число компонент! Мост — единственный путь между двумя частями графа.

🔁 Эйлеров путь / цикл

⏱ O(E) 💾 O(V + E)

🎯 Интуиция — аналогия из жизни

Детская головоломка "нарисуй конвертик, не отрывая карандаша от бумаги и не проводя дважды по одной линии". Математически: можно ли пройти по каждому ребру ровно один раз? Леонард Эйлер доказал (задача о Кёнигсбергских мостах), что это возможно только если у большинства перекрестков чётное число дорог.

📝 Пошаговое текстовое описание (Алгоритм Хирхольцера)

Проверка: Граф должен быть связанным. Для цикла: все вершины имеют чётную степень. Для пути: ровно две вершины имеют нечётную степень (старт и финиш).
Стартуем DFS из правильной вершины (с нечётной степенью, если она есть).
На каждом шаге берем первое доступное ребро, удаляем его из графа и идем по нему.
Когда уперлись в тупик (нет неиспользованных рёбер), добавляем вершину в массив ответа (postorder) и "откатываемся" назад.
В конце разворачиваем массив ответа.

📐 Почему такая сложность?

Мы проходим по каждому ребру строго один раз и тут же его "сжигаем". Поэтому алгоритм работает ровно за количество рёбер: 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: 0340.
В 0 больше нет дорог (тупик). Пушим 0 в ответ. Откат в 4. В 4 нет дорог — пушим 4.
Порядок вытаскивания (тупиков): 0, 4, 3, 0, 2, 1, 0.
Разворачиваем: 0 → 1 → 2 → 0 → 3 → 4 → 0

C++ — алгоритм Хирхольцера
#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).

❓ Проверь себя

Эйлеров цикл существует в неориентированном графе когда:
A Все вершины имеют чётную степень
B Граф полный
C Ровно 2 вершины с нечётной степенью
D Граф — дерево
✅ Цикл: ВСЕ степени чётные. Путь (не цикл): ровно 2 нечётные (начало и конец). + граф связный!
❌ Все чётные → цикл. Ровно 2 нечётные → путь (начинаем из нечётной). Иначе — эйлерова пути нет.
📊

Глава 6. Динамическое программирование (DP)

📌 DP — король собеседований

DP = разбиение задачи на подзадачи + запоминание результатов (мемоизация). Если задача имеет оптимальную подструктуру (решение большой задачи состоит из решений малых) и перекрывающиеся подзадачи — используй DP. Два подхода: top-down (рекурсия + мемо) и bottom-up (таблица).

ЗадачаСостояниеПереходСложность
Фибоначчиdp[i]dp[i-1]+dp[i-2]O(n)
Рюкзак 0/1dp[i][w]max(skip, take)O(n·W)
Рюкзак ∞dp[w]max(skip, take multiple)O(n·W)
LISdp[i] или tailsmax(dp[j]+1) если a[j]<a[i]O(n²) / O(n log n)
LCSdp[i][j]match / max(skip)O(n·m)
Edit Distancedp[i][j]min(ins, del, rep)O(n·m)
Coin Changedp[amount]min(dp[a-coin]+1)O(n·amount)

🐇 Фибоначчи (Каноничный пример DP)

⏱ O(n) 💾 O(1) оптимально

🎯 Интуиция — аналогия из жизни

Представьте, что вы поднимаетесь по лестнице. За один шаг можно переступить на 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

C++ — 4 способа (от наивного до оптимального)
#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)

C++
int fib(int n) {
    if (n <= 1) return n;
    return fib(n-1) + fib(n-2); // 💀 2^n!
    // fib(5) вычисляет fib(3) ДВАЖДЫ!
}

✅ O(n) с мемоизацией

C++
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

C++
int fib(int n) { ... }
// fib(46) = 1836311903 ✅ (помещается)
// fib(47) = 2971215073 💀 > INT_MAX!

✅ long long или модуль

C++
long long fib(int n) { ... }  // ✅ до n≈92
// Или считаем по модулю:
dp[i] = (dp[i-1] + dp[i-2]) % MOD; // ✅

❓ Проверь себя

Можно ли вычислить fib(10^18) за разумное время?
A Нет, даже O(n) слишком долго
B Да, через матричное возведение в степень за O(log n)
C Да, через формулу Бине
D Только приблизительно
✅ Матрица [[1,1],[1,0]]^n даёт fib(n). Возведение в степень за O(log n) = O(8·60) ≈ 500 операций для n=10^18!
❌ Матричное возведение в степень! [[1,1],[1,0]]^n × [1,0]ᵀ = [fib(n+1), fib(n)]ᵀ. O(log n) умножений матриц 2×2.

🎒 Рюкзак 0/1 (0/1 Knapsack)

⏱ O(n · W) 💾 O(W) оптимально

🎯 Интуиция — аналогия из жизни

Вы вор в музее с рюкзаком, который выдерживает строго W кг. Перед вами картины (каждая имеет свой вес и стоимость). Вы не можете отпилить кусок картины (поэтому 0/1 — либо берем целиком, либо не берем). Ваша цель — унести максимальную ценность, не порвав рюкзак.

📝 Пошаговое текстовое описание

Строим 2D-таблицу dp[i][w], где i — рассмотренные первые предметы, w — текущая вместимость рюкзака.
Если мы не берем предмет i, ценность равна dp[i-1][w].
Если мы берем предмет i (и он влезает: weight[i] <= w), ценность равна dp[i-1][w - weight[i]] + value[i].
Берем максимум из этих двух вариантов.
Оптимизация до 1D: можно хранить только предыдущую строку (веса). Идти нужно справа налево, чтобы не использовать один предмет дважды.

📐 Почему такая сложность?

Мы заполняем матрицу размером n строк и W столбцов. Итоговое время O(n · W). Это псевдополиномиальная сложность (зависит не от количества входных данных, а от их значения W). Память при оптимизации сжимается до O(W).

🔎 Подробная трассировка: weights=[1,3,4,5], values=[1,4,5,7], W=7

i \ w01234567
0 (w=1,v=1)01111111
1 (w=3,v=4)01145555
2 (w=4,v=5)01145669
3 (w=5,v=7)01145789
C++ — 2D и оптимизация до 1D с восстановлением
#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 рюкзак: направление цикла

❌ Слева направо = неограниченный рюкзак!

C++
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 рюкзак

C++
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 не инициализирован

C++
vector dp(W+1); // 💀 мусор внутри!
// Или dp(W+1, -1) — забыли dp[0]=0

✅ Правильная инициализация

C++
vector dp(W+1, 0); // ✅ всё = 0
// dp[0] = 0: пустой рюкзак = 0 ценности

❓ Проверь себя

Почему в 1D оптимизации 0/1 рюкзака цикл идёт справа налево?
A Чтобы dp[w-wt[i]] содержал значение с предыдущей строки (без текущего предмета)
B Для скорости
C Это не обязательно
D Чтобы избежать переполнения
✅ При проходе ← значение dp[w-wt[i]] ещё НЕ обновлено текущим предметом → мы используем «предыдущую строку» → предмет берётся не более 1 раза!
❌ Справа налево = dp[w-wt[i]] ещё «старое». Слева направо = dp[w-wt[i]] уже обновлено текущим предметом → предмет берётся многократно.

🎒∞ Неограниченный рюкзак (Unbounded Knapsack)

⏱ O(n · W) 💾 O(W)

🎯 Интуиция — аналогия из жизни

Шведский стол. У вас есть поднос вместимостью `W`. Блюд бесконечно много. Вы можете взять 5 кусков пиццы, если они влезут и дадут максимальную калорийность/удовольствие. Ограничений на количество одного предмета нет.

📝 Пошаговое текстовое описание

Логика идентична 0/1 рюкзаку, но с одним принципиальным отличием.
Когда мы решаем, брать ли предмет, мы можем опираться на результат, где этот же предмет уже был взят.
Поэтому в 1D оптимизации мы идем слева направо (от 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).

C++
#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 рюкзак

C++
// 💀 Так каждый предмет используется только 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]);
    }
}

✅ Для неограниченного — слева направо

C++
// ✅ Один и тот же предмет можно брать снова
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 жадно

C++
// 💀 Это greedy для Fractional Knapsack,
// а не для unbounded knapsack
sort(items.begin(), items.end(), byRatio);
takeBestRatioFirst();

✅ Здесь нужен DP

C++
// ✅ Потому что предметы целиком, но unlimited
dp[w] = max(dp[w], dp[w - wt[i]] + val[i]);

❓ Проверь себя

Чем отличается неограниченный рюкзак от 0/1 рюкзака в 1D DP?
A В 0/1 нельзя использовать DP
B В 0/1 всегда нужна 2D таблица
C В unbounded идём по весам слева направо, а в 0/1 — справа налево
D Отличий нет
✅ Именно направление цикла определяет, можно ли повторно использовать тот же предмет в рамках одной итерации.
❌ Правильный ответ: в 0/1 идём справа налево, в unbounded — слева направо.

📈 Наибольшая возрастающая подпоследовательность (LIS)

⏱ O(n log n) оптимально 💾 O(n)

🎯 Интуиция — аналогия из жизни

Выстраивание русской матрешки: каждая следующая должна быть строго больше предыдущей. У вас ряд разбросанных матрешек разного размера, и вам нужно выбрать подмножество так, чтобы собрать самую большую вложенность, не меняя их исходный порядок.

📝 Пошаговое текстовое описание (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

C++ — O(n²) и O(n log n) + Восстановление
#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` и его отсортированной копии (если в массиве нет дубликатов).

❓ Проверь себя

Что хранит массив tails[] в O(n log n) алгоритме LIS?
A Длины всех LIS
B Минимальный последний элемент LIS каждой длины
C Саму LIS
D Индексы элементов
✅ tails[i] = наименьший хвост среди всех возрастающих подпоследовательностей длины i+1. Это позволяет использовать бинарный поиск!
❌ Минимальный последний элемент! tails[0]=мин. хвост LIS длины 1, tails[1]=мин. хвост длины 2, и т.д.

🔠 Наибольшая общая подпоследовательность (LCS)

⏱ O(n · m) 💾 O(n · m)

🎯 Интуиция — аналогия из жизни

Утилита git diff. У нас есть две версии текстового документа. Нужно найти максимальное количество слов/символов, которые присутствуют в обеих версиях в одинаковом порядке (это то, что "не изменилось").

📝 Пошаговое текстовое описание

Создаем 2D таблицу 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)0000
A(1)00 (A≠B)0 (A≠C)0 (A≠D)
B(2)01 (B=B)1 (B≠C)1 (B≠D)
C(3)01 (C≠B)2 (C=C)2 (C≠D)

Ответ = 2 ("BC").

C++ — таблица DP + восстановление
#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 строки.

❓ Проверь себя

LCS("ABCDE", "ACE") = ?
A "AB" (длина 2)
B "ABCDE" (длина 5)
C "ACE" (длина 3)
D "AE" (длина 2)
✅ ACE есть в обеих строках как подпоследовательность. ABCDE: A_C_E. ACE: ACE. Длина 3.
❌ "ACE", длина 3. Подпоследовательность — не обязательно непрерывная!

✏️ Расстояние редактирования (Левенштейна)

⏱ O(n · m) 💾 O(n · m)

🎯 Интуиция — аналогия из жизни

Автоисправление (T9). Как превратить слово "корова" в "корона"? Нужно заменить 1 букву. А "кот" в "ток"? Удалить 'к', вставить 'т'. Левенштейн считает минимальное количество опечаток: вставок, удалений и замен.

📝 Пошаговое текстовое описание

Строим 2D таблицу. Строка 0 (база): чтобы из пустой строки получить 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 операция

C++
#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)`.

❓ Проверь себя

edit_distance("kitten","sitting") = ?
A 1
B 2
C 3 (k→s, e→i, +g)
D 7
✅ kitten→sitten (замена k→s)→sittin (замена e→i)→sitting (вставка g). 3 операции.
❌ 3 операции: замена k→s, замена e→i, вставка g в конец.

🪙 Размен монет (Coin Change)

⏱ O(n · amount) 💾 O(amount)

🎯 Интуиция — аналогия из жизни

Вы кассир. Клиенту нужно дать 11 рублей сдачи. У вас бесконечно много монет по 1, 2 и 5 рублей. Жадный подход (5+5+1) тут работает. Но если монеты 1, 3, 4 и сдача 6 рублей? Жадный даст 4+1+1 (3 монеты), а правильный ответ 3+3 (2 монеты). DP проверяет все варианты умно.

📝 Пошаговое текстовое описание

Создаем 1D массив dp размером amount + 1. Заполняем "бесконечностью". dp[0] = 0.
Идем по суммам от 1 до 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 монеты.

C++ — минимум монет + количество способов
#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)

C++
// 💀 Суммы во внешнем → монеты внутри
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)

C++
// ✅ Монеты во внешнем → суммы внутри
for (int coin : coins)
  for (int a = coin; a <= amount; a++)
    dp[a] += dp[a-coin];
// {1,5} считается один раз!

❓ Проверь себя

Минимум монет {1,5,10,25} для суммы 30?
A 3 монеты (10+10+10)
B 2 монеты (25+5)
C 6 монет (5×6)
D 30 монет (1×30)
✅ 25+5=30, 2 монеты. Жадный алгоритм работает для {1,5,10,25}!
❌ 2 монеты: 25+5. Для этой системы монет жадный оптимален.

📐 Оптимальное перемножение матриц

⏱ O(n³) 💾 O(n²)

🎯 Интуиция — аналогия из жизни

Вам нужно расставить скобки в школьном примере. Умножение матриц некоммутативно, но ассоциативно: (A × B) × C = A × (B × C). Но вычислительная сложность разная! Умножить (10x100) на (100x5) стоит 5000 операций, а умножить (10x10) на (10x10) всего 1000. Наша цель — найти такой порядок действий, который сделает компьютер поменьше математики.

📝 Пошаговое текстовое описание

Это "DP на отрезках". Мы не можем решать задачу слева направо. Мы должны решать её от мелких цепочек матриц к крупным.
Внешний цикл 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

C++
#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.

❓ Проверь себя

Почему перебор всех скобочных расстановок экспоненциальный?
A Число расстановок = числа Каталана: C(n) ≈ 4^n / n^1.5
B Потому что матрицы большие
C Потому что умножение дорогое
D Не экспоненциальный
✅ Для n матриц число расстановок = C(n-1) (число Каталана). C(10)=4862, C(20)≈1.8 млрд. DP решает за O(n³).
❌ Числа Каталана! Для n=20 расстановок ≈ 1.8 млрд. DP: O(n³) = 8000 операций.

➕ Сумма подмножества (Subset Sum)

⏱ O(n · S) 💾 O(S)

🎯 Интуиция — аналогия из жизни

Сможете ли вы набрать на весах ровно 11 килограмм из набора гирь {1, 5, 11, 5}? Да, можно взять просто 11, или 1+5+5. А ровно 13? Нет. Это проверка на "существование" комбинации.

📝 Пошаговое текстовое описание

Создаем 1D булевский массив 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

C++ — Subset Sum + Partition + Count
#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`) или сложение способов (`+`).

❓ Проверь себя

Можно ли разбить {1, 5, 11, 5} на два подмножества с равной суммой?
A Да: {1,5,5}=11 и {11}=11
B Нет, сумма нечётная
C Нет, невозможно
D Нужно больше элементов
✅ Сумма = 22, делим на 2 = 11. Проверяем subset_sum(arr, 11) → {1,5,5}=11. Задача сводится к 0/1 рюкзаку!
❌ Да! Сумма=22, target=11. {1,5,5}=11 ✓. Equal Partition = Subset Sum с target=total/2.
🏆

Глава 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). После сортировки мы проходим по массиву ровно один раз за линейное время 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) ✅

C++ — Activity Selection + вариации
#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 комната всего одна, и ищем максимальное количество встреч.

❓ Проверь себя

Почему сортируем по ВРЕМЕНИ ОКОНЧАНИЯ, а не по началу?
A По началу тоже работает
B По окончанию быстрее
C Контрпример: (0,100),(1,2),(3,4). По началу: 1. По концу: 2
D Нет разницы
✅ По началу: берём (0,100) → блокирует всё! По концу: (1,2),(3,4) → 2 активности. Раннее окончание оставляет больше места.
❌ Контрпример доказывает! Сортировка по концу → берём то, что заканчивается раньше → больше места для следующих.

🌳 Кодирование Хаффмана (Huffman Coding)

⏱ O(n log n) 💾 O(n)

🎯 Интуиция — аналогия из жизни

Вспомните азбуку Морзе: буква 'E' (самая частая в английском) кодируется всего одной точкой, а редкая 'Q' — длинной комбинацией «тире-тире-точка-тире». Хаффман делает то же самое математически идеально для любого алфавита: частым символам даются короткие коды, редким — длинные, экономя память.

📝 Пошаговое текстовое описание

Считаем частоту появления каждого символа в тексте и создаём для каждого узел-лист.
Помещаем все узлы-листья в минимальную приоритетную очередь (Min-Heap), сортируя по частоте.
Извлекаем два узла с наименьшими частотами.
Создаём новый "родительский" узел, чья частота равна сумме частот двух извлечённых узлов. Делаем извлечённые узлы его левым и правым детьми.
Помещаем новый узел обратно в очередь. Повторяем, пока в очереди не останется ровно один узел — корень дерева Хаффмана.
Обходим дерево от корня к листьям: шаг влево даёт бит "0", шаг вправо — бит "1". Так формируются коды для символов.

📐 Почему такая сложность?

Мы добавляем и извлекаем $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%!)

C++ — полная реализация Хаффмана
#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.

❓ Проверь себя

Почему код Хаффмана — префиксный?
A Все коды одинаковой длины
B Ни один код не является префиксом другого → однозначное декодирование
C Используются только 0 и 1
D Коды отсортированы
✅ Если код A=01, код B=011 — при чтении "011" непонятно: A+"1" или B? Дерево Хаффмана гарантирует: символы только в листьях → ни один код не префикс другого!
❌ Префиксный = ни один код не начало другого. Это свойство дерева: символы только в листьях, путь до листа не продолжается.

🎒✂️ Дробный рюкзак (Fractional Knapsack)

⏱ O(n log n) 💾 O(n)

🎯 Интуиция — аналогия из жизни

Вы в пещере с сокровищами, у вас рюкзак на 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

C++
#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.

v=60, w=10 ratio = 6 v=100, w=20 ratio = 5 v=120, w=30 ratio = 4 рюкзак W = 50 берём 2/3 Сначала берём максимальный value/weight

Путают с 0/1 рюкзаком

❌ Используют DP как для 0/1

C++
// 💀 Для дробного рюкзака это лишнее
// и даже хуже по сложности
vector<int> dp(W + 1, 0);
...

✅ Для дробного достаточно greedy

C++
// ✅ Сортируем по value / weight
sort(items.begin(), items.end(), byRatioDesc);
// берём целиком, потом дробную часть последнего

Сортируют по value вместо value/weight

❌ Неверный критерий

C++
// 💀 Нельзя сортировать только по value
sort(items.begin(), items.end(),
    [](auto &a, auto &b){ return a.value > b.value; });

✅ Нужна удельная ценность

C++
sort(items.begin(), items.end(),
    [](auto &a, auto &b){
        return a.value / a.weight > b.value / b.weight;
    });

❓ Проверь себя

Жадный рюкзак оптимален для дробного, но не для 0/1. Почему?
A В 0/1 нельзя взять часть — жадный может «застрять» на тяжёлом предмете
B В 0/1 больше предметов
C Дробный рюкзак проще
D Жадный всегда оптимален
✅ Дробный: берём лучшее по v/w, остаток заполняем частью. 0/1: взяв тяжёлый предмет целиком, можем не вместить два лёгких с большей суммарной ценностью.
❌ В 0/1 нельзя взять часть. Жадный может взять тяжёлый с хорошим v/w, но не оставить место для двух лёгких с лучшей суммарной ценностью.
🔤

Глава 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)

⏱ O(n + m) 💾 O(m)

Идея: При несовпадении символов в наивном алгоритме мы откатываемся и начинаем сначала. KMP использует prefix-функцию (массив неудач), чтобы не пересматривать уже совпавшие символы. Prefix-функция для позиции i — длина наибольшего собственного суффикса подстроки [0..i], который одновременно является её префиксом.

🔎 Построение prefix-функции для "ABABAC"

iСимволСравнениеπ[i]Пояснение
0A0Один символ — всегда 0
1BB ≠ A0Нет совпадающего префикса-суффикса
2AA = A1"A" — и префикс, и суффикс "ABA"
3BB = B2"AB" — префикс и суффикс "ABAB"
4AA = A3"ABA" — префикс и суффикс "ABABA"
5CC ≠ B, откат к π[2]=1, C ≠ B, откат к π[0]=0, C ≠ A0Нет совпадения

Результат: π = [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!

C++ — KMP полная реализация
#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) наивный

C++
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

C++
// 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]; }

❓ Проверь себя

prefix-функция для "ABCABD" = ?
A [0,0,0,0,0,0]
B [0,0,0,1,2,0]
C [0,0,0,1,2,0]
D [0,1,2,3,4,5]
✅ π[0]=0, π[1]=0(B≠A), π[2]=0(C≠A), π[3]=1("A"=prefix=suffix of "ABCA"), π[4]=2("AB"), π[5]=0(D≠C).
❌ [0,0,0,1,2,0]. π[i]=длина наибольшего собственного префикса-суффикса подстроки [0..i].

#️⃣ Алгоритм Рабина-Карпа (Rolling Hash)

⏱ O(n + m) средний 💾 O(1) ⚠️ Худший: O(n·m) при коллизиях

Идея: Вычисляем хеш паттерна. Катим «окно» по тексту, пересчитывая хеш за 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

C++ — Rabin-Karp + двойное хеширование
#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) предобработки)

Не проверяем после совпадения хешей

❌ Ложные срабатывания

C++
if (pHash == tHash)
    result.push_back(i); // 💀 коллизия хеша!
// "ab" и "ba" могут иметь одинаковый хеш

✅ Проверяем строки при совпадении

C++
if (pHash == tHash) {
    if (text.substr(i, m) == pattern) // ✅
        result.push_back(i);
}

Отрицательный остаток при rolling hash

❌ Не обрабатываем отрицательный mod

C++
tHash = (d * (tHash - text[i]*h) + text[i+m]) % q;
// 💀 В C++: (-5) % 7 = -5, НЕ 2!

✅ Добавляем q при отрицательном

C++
tHash = (d * (tHash - text[i]*h) + text[i+m]) % q;
if (tHash < 0) tHash += q; // ✅

❓ Проверь себя

Когда Rabin-Karp лучше KMP?
A Всегда
B При поиске нескольких паттернов одновременно
C Для коротких строк
D Никогда
✅ Несколько паттернов: вычисляем хеш каждого, ищем все за один проход. KMP нужно запускать отдельно для каждого. Также: 2D поиск паттерна, сравнение подстрок за O(1).
❌ Несколько паттернов! Один проход по тексту, проверяем хеш окна против множества хешей паттернов.

📏 Z-функция

⏱ O(n) 💾 O(n)

Определение: z[i] = длина наибольшей подстроки, начинающейся с позиции i, которая совпадает с префиксом строки. z[0] = 0 (или n, по определению).

Поиск подстроки: Конкатенируем pattern + "$" + text. Если z[i] == len(pattern) → вхождение на позиции i - len(pattern) - 1.

🔎 Z-функция для "aabxaab"

i0123456
Символaabxaab
z[i]0100310
Пояснениепо опр."a"=пр."a""b"≠"a""x"≠"a""aab"=пр."aab""a"=пр."a""b"≠"a"
C++ — Z-функция + поиск + задачи
#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;
}

❓ Проверь себя

Как через Z-функцию найти подстроку pattern в text?
A Конкатенируем pattern+"$"+text, где z[i]==len(pattern) → вхождение
B Вычисляем Z для text, ищем len(pattern)
C Z-функция не подходит для поиска
D Вычисляем Z для pattern
✅ concat = pattern + "$" + text. Z-функция. Если z[i] == m (длина pattern) → вхождение на позиции i-m-1 в text. "$" разделяет, чтобы совпадение не «перетекло».
❌ Конкатенация pattern+"$"+text → Z-функция → z[i]==m → найдено! "$" нужен чтобы z[i] не превысил m.

🪞 Алгоритм Манакера (все палиндромы за O(n))

⏱ O(n) 💾 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"

C++ — Манакер + самый длинный палиндром
#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;
}

❓ Проверь себя

Зачем в Манакере вставляют "#" между символами?
A Для красоты
B Чтобы единообразно обрабатывать палиндромы чётной и нечётной длины
C Для ускорения
D Чтобы строка стала длиннее
✅ "abba" → "#a#b#b#a#". Палиндром чётной длины "bb" становится нечётным "#b#b#" с центром в "#". Один алгоритм для обоих случаев!
❌ Единообразная обработка! Без "#" нужны два отдельных прохода: для нечётных и чётных палиндромов. С "#" — один.
🔢

Глава 9. Математические алгоритмы

📌 Математика в алгоритмах

Теория чисел, комбинаторика, модульная арифметика — основа олимпиадного программирования и многих практических задач (криптография, хеширование, генерация псевдослучайных чисел). Эти алгоритмы — маст-хэв для сложных секций собеседований.

🔢 НОД и НОК (алгоритм Евклида)

⏱ O(log(min(a,b))) 💾 O(1)

🎯 Интуиция — аналогия из жизни

Представьте, что у вас есть прямоугольный кусок ткани размером 252x105 см. Вы хотите разрезать его на одинаковые идеальные квадраты максимального размера, без остатков. Вы отрезаете самые большие квадраты 105x105 (2 штуки), и у вас остается кусок 105x42. Теперь повторяете процесс для него. Квадрат, на котором ткань закончится ровно — и есть НОД.

📝 Пошаговое текстовое описание

Берём два числа a и b. Если b == 0, то НОД = a.
Если нет, находим остаток от деления a на b.
Заменяем a на b, а b на остаток.
Повторяем процесс рекурсивно или в цикле, пока b не станет нулём.
Для поиска НОК (LCM) используем формулу: (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

C++
#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

❌ Перемножают сразу

C++
long long lcm(long long a, long long b) {
    return a * b / gcd(a, b); // 💀 a*b может переполниться
}

✅ Делим раньше

C++
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

✅ Правильная база

C++
long long gcd(long long a, long long b) {
    return b == 0 ? a : gcd(b, a % b);
}

❓ Проверь себя

Чему равен gcd(252, 105)?
A 7
B 14
C 21
D 35
✅ 252 mod 105 = 42, 105 mod 42 = 21, 42 mod 21 = 0 → gcd = 21.
❌ Ответ: 21. Алгоритм Евклида быстро это показывает за несколько шагов.

🔢 Решето Эратосфена

⏱ O(n log log n) 💾 O(n)

🎯 Интуиция — аналогия из жизни

Представьте, что вы просеиваете гравий через несколько сит разного размера. Сначала вы убираете все камни, кратные 2 (четные). Затем берете следующий оставшийся камень (3) и убираете все кратные 3. То, что осталось в конце на столе — чистые, неделимые (простые) числа.

📝 Пошаговое текстовое описание

Создаём массив булевых флагов от 0 до n, заполненный true.
Индексы 0 и 1 помечаем как false (они не простые).
Идём циклом от 2 до √n.
Если текущее число i равно true, то оно простое. Мы проходим по всем его кратным, начиная с (т.к. меньшие кратные уже вычеркнуты) и ставим им 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 ✅

C++ — Решето + линейное решето + факторизация
#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).

Решето до 20: 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Зелёные — простые, красные — вычеркнутые составные

Начинают вычёркивать с 2*i

❌ Лишняя работа

C++
for (int j = 2 * i; j <= n; j += i) {
    is_prime[j] = false;
}
// 💀 кратные меньше i*i уже были обработаны раньше

✅ Начинаем с i*i

C++
for (int j = i * i; j <= n; j += i) {
    is_prime[j] = false;
}
// ✅ оптимальнее

Забывают что 0 и 1 не простые

❌ Все true по умолчанию

C++
vector<bool> is_prime(n + 1, true);
// 💀 0 и 1 останутся "простыми"

✅ Нужно явно выключить

C++
vector<bool> is_prime(n + 1, true);
is_prime[0] = is_prime[1] = false; // ✅

❓ Проверь себя

Почему достаточно идти только до i*i ≤ n?
A Потому что дальше простых нет
B Потому что это быстрее, но не обязательно
C Потому что компилятор так оптимизирует
D Если число составное, у него есть делитель ≤ √n
✅ Любое составное число n = a·b. Хотя бы один из множителей не больше √n. Значит, если до √n не нашли делитель — число простое.
❌ Верный ответ: у любого составного числа есть делитель ≤ √n.

⚡ Быстрое возведение в степень (Binary Exponentiation)

⏱ O(log n) 💾 O(1) итеративно / O(log n) рекурсивно

🎯 Интуиция — аналогия из жизни

Если вам нужно сложить лист бумаги 1024 раза, вы не складываете его 1024 раза по одному. Вы складываете его пополам 10 раз (т.к. $2^{10} = 1024$). Мы заменяем множество мелких шагов на несколько огромных прыжков возведением базы в квадрат.

📝 Пошаговое текстовое описание

Подготавливаем result = 1.
Пока степень exp > 0, проверяем её младший бит. Если она нечётная (exp % 2 == 1), умножаем result на текущую base.
Делим степень на 2 (сдвиг вправо 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!) ✅

C++
#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)

C++
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)

C++
while (n > 0) {
    if (n & 1) res = res * a % mod;
    a = a * a % mod;
    n >>= 1;
}

Забывают брать mod после умножения

❌ Быстрое переполнение

C++
res = res * a; // 💀 overflow
a = a * a;     // 💀 overflow ещё быстрее

✅ mod после каждого шага

C++
res = res * a % mod;
a = a * a % mod; // ✅

❓ Проверь себя

Сколько умножений примерно нужно для вычисления a^1024 быстрым методом?
A 1024
B около 10
C около 100
D 512
✅ Потому что log₂(1024)=10. Быстрое возведение в степень работает за O(log n).
❌ Ответ: около 10. Именно поэтому fast power настолько важен.

➗ Модульная арифметика

Все операции: O(1), Деление: O(log n)

🎯 Интуиция — аналогия из жизни

Циферблат механических часов — это арифметика по модулю 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. ✅ Совпало!

C++ — библиотека для чистой модульной арифметики
#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 положительный

C++
long long x = (a - b) % MOD; // 💀 может быть отрицательным

✅ Нормализуем

C++
long long x = (a - b + MOD) % MOD; // ✅

Деление по модулю как обычное деление

❌ a / b mod MOD — не так

C++
long long ans = (a / b) % MOD; // 💀 почти всегда неверно

✅ Умножаем на обратный элемент

C++
long long ans = a * modInverse(b) % MOD; // ✅

❓ Проверь себя

Что означает (a / b) mod p при простом p?
A a % p / b % p
B Просто a / b
C a * b^(p-2) mod p
D Это не определено
✅ Деление по модулю — это умножение на обратный элемент. Для простого p: b^(-1) = b^(p-2) mod p.
❌ Верный ответ: a * b^(p-2) mod p.

🎰 Комбинаторика (C(n,k), факториалы)

Препроцессинг: O(N) | Запрос: O(1) 💾 O(N)

🎯 Интуиция — аналогия из жизни

Сколькими способами можно собрать команду из 5 человек, если в классе 30 учеников? Это "Сочетания" или Биномиальный коэффициент C(n, k). Важно не то, в каком порядке их назовут, а конечный состав команды.

📝 Пошаговое текстовое описание

Формула: C(n, k) = n! / (k! × (n-k)!).
Поскольку числа огромные, используем модульную арифметику.
В цикле за $O(n)$ предвычисляем все факториалы до MAXN (массив `fact`).
Находим обратный элемент для последнего факториала inv_fact[MAXN-1].
Обратным циклом вычисляем остальные обратные факториалы: inv_fact[i] = inv_fact[i+1] * (i+1).
Теперь на любой запрос можно ответить мгновенно (за $O(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 ✅.

C++ — предвычисление + запрос O(1) + треугольник Паскаля
#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 без модуля

❌ Быстрое переполнение

C++
long long c = fact(n) / (fact(k) * fact(n-k)); // 💀 overflow уже на маленьких n

✅ factorial + inv_factorial mod p

C++
C(n,k) = fact[n] * invFact[k] % MOD * invFact[n-k] % MOD;

Не проверяют k > n

❌ Индексы уходят в минус

Комментарий
// 💀 C(5, 7) не существует
// если не проверить — программа сломается

✅ Возвращаем 0

C++
if (k < 0 || k > n) return 0; // ✅

❓ Проверь себя

Чему равно C(5,2)?
A 5
B 10
C 20
D 25
✅ C(5,2)=5!/(2!*3!)=10.
❌ Ответ: 10.

φ Функция Эйлера (Euler's Totient)

⏱ O(√n) для одного, O(n log log n) для всех 💾 O(1) / O(n)

🎯 Интуиция — аналогия из жизни

Функция Эйлера $\phi(n)$ показывает количество чисел до $n$, которые "не имеют ничего общего" (взаимно просты) с $n$. Как будто вы ищете людей в толпе, с которыми у вас нет ни одного общего родственника (простого множителя).

📝 Пошаговое текстовое описание

Изначально результат равен $n$.
Начинаем факторизацию: циклом от $p=2$ до $\sqrt{n}$ ищем простые делители $n$.
Если $n$ делится на $p$, значит $p$ — простой делитель.
Умножаем текущий результат на $(1 - \frac{1}{p})$, что в целых числах выглядит как res -= res / p.
Избавляемся от всех степеней $p$ в $n$ (while (n % p == 0) n /= p).
Если в конце $n > 1$, значит оставшееся $n$ — само по себе простое число, делаем для него 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 штуки) ✅.

C++ — Для одного числа и для диапазона
#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)

C++
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 дважды

✅ Только по различным простым

C++
if (n % p == 0) {
    while (n % p == 0) n /= p;
    result -= result / p; // ✅ один раз на p
}

❓ Проверь себя

Чему равно φ(12)?
A 2
B 3
C 4
D 6
✅ Взаимно простые с 12 числа: 1, 5, 7, 11 → всего 4.
❌ Ответ: 4.

🔢 Расширенный алгоритм Евклида

⏱ O(log(min(a,b))) 💾 O(log(min(a,b))) память стека

🎯 Интуиция — аналогия из жизни

У вас есть две гири весом $A$ и $B$ грамм и чашечные весы. Какой вес вы сможете отмерить? Вы сможете отмерить любой вес $C$, который кратен НОД(A, B). Расширенный Евклид скажет вам, не только можно ли отмерить вес $C$, но и сколько гирь каждого типа положить на левую ($x$) и правую ($y$) чашу.

📝 Пошаговое текстовое описание

Задача: найти коэффициенты $x$ и $y$ в уравнении $A \cdot x + B \cdot y = \text{НОД}(A, B)$.
Сначала алгоритм спускается "на дно" рекурсии так же, как обычный НОД: gcd(B, A % B).
На дне (когда $B = 0$), мы точно знаем ответ: $\text{НОД} = A$, значит $A \cdot 1 + 0 \cdot 0 = A$. Отсюда базовый случай: $x_0 = 1, y_0 = 0$.
На обратном пути из рекурсии (поднимаясь наверх), пересчитываем $x$ и $y$ для текущего шага через предыдущие $x_1$ и $y_1$ по формулам: x = y1 и y = x1 - (A / B) * y1.

📐 Почему такая сложность?

Так как это обычный алгоритм Евклида с парой дополнительных математических операций на каждом возврате из рекурсии, сложность остается логарифмической — O(log(min(a,b))).

🔎 Подробная трассировка: extgcd(30, 21)

Спуск:
1. a=30, b=2130 % 21 = 9. Вызов(21, 9)
2. a=21, b=921 % 9 = 3. Вызов(9, 3)
3. a=9, b=39 % 3 = 0. Вызов(3, 0)
4. b=0x=1, y=0. База.
Подъём (пересчёт):
3. (9, 3): x1=1, y1=0x=0, y=1 - (9/3)*0 = 1.
2. (21, 9): x1=0, y1=1x=1, y=0 - (21/9)*1 = -2.
1. (30, 21): x1=1, y1=-2x=-2, y=1 - (30/21)*(-2) = 1 - 1*(-2) = 3.
Ответ: x = -2, y = 3.
Проверка: 30*(-2) + 21*3 = -60 + 63 = 3. (А 3 — это НОД(30,21)) ✅.

C++ — Алгоритм и Линейное Диофантово Уравнение
#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 в обратном ходе

❌ Неверная формула восстановления

C++
// 💀 x = x1, y = y1 — так нельзя
x = x1;
y = y1;

✅ Правильный переход

C++
x = y1;
y = x1 - (a / b) * y1; // ✅

Ищут модульный обратный когда gcd(a,m) ≠ 1

❌ Обратного может не существовать

Комментарий
// 💀 inverse(6 mod 9) не существует,
// потому что gcd(6,9)=3 ≠ 1

✅ Сначала проверяем gcd

C++
long long g = extgcd(a, m, x, y);
if (g != 1) return -1; // ✅ inverse doesn't exist

❓ Проверь себя

Что находит расширенный Евклид кроме gcd(a,b)?
A Только остаток
B Коэффициенты x, y такие что ax + by = gcd(a,b)
C Все делители числа
D Простые множители
✅ Это главное отличие от обычного gcd — дополнительно находятся коэффициенты Безу.
❌ Правильный ответ: коэффициенты x и y, удовлетворяющие ax + by = gcd(a,b).

🇨🇳 Китайская теорема об остатках (CRT)

⏱ O(k · log(max_m)) 💾 O(1)

🎯 Интуиция — аналогия из жизни

Представьте, что у полководца есть отряд солдат. Он приказывает им построиться по 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ₖ.
Для каждого уравнения $i$ вычисляем $M_i = M / m_i$ (произведение всех модулей, кроме текущего).
Находим $inv_i$ — модульное обратное для $M_i$ по модулю $m_i$ (через расширенного Евклида).
Добавляем к общему ответу 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).

C++
#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 в сумме

❌ Сумма растёт без ограничений

C++
x += r[i] * Mi * inv; // 💀 может переполниться

✅ Каждое действие по mod M

C++
x = (x + r[i] % M * (Mi % M) % M * inv % M) % M; // ✅

❓ Проверь себя

Решение системы x ≡ 2 (mod 3), x ≡ 3 (mod 5), x ≡ 2 (mod 7) равно:
A 17
B 19
C 21
D 23
✅ 23 mod 3 = 2, 23 mod 5 = 3, 23 mod 7 = 2.
❌ Ответ: 23.

📐 Матричное возведение в степень

⏱ O(k³ log n) 💾 O(k²)

🎯 Интуиция — аналогия из жизни

Если машина разгоняется со скоростью, зависящей от её скорости в предыдущие 2 секунды (как Фибоначчи), то чтобы узнать скорость на 10-й секунде, мы можем не шагать по 1 секунде. Мы можем составить "матрицу перехода" (формулу шага), возвести эту формулу в 10-ю степень за логарифмическое время и применить 1 раз.

📝 Пошаговое текстовое описание

Определяем k — порядок рекурренты (сколько предыдущих членов нужно для вычисления нового). Для Фибоначчи k = 2.
Составляем матрицу перехода M размером $k \times k$. Верхняя строка — это коэффициенты уравнения. Остальные строки — сдвиг "предыдущих" состояний (единицы под главной диагональю).
Пишем функцию умножения двух матриц (3 вложенных цикла).
Пишем функцию быстрого возведения матрицы в степень (абсолютно тот же код, что и для чисел, только с 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)! ✅

C++ — Фибоначчи за O(log n) + общая линейная рекуррента
#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

C++
Matrix result(n, vector<long long>(n, 0));
for (int i = 0; i < n; i++) result[i][i] = 1; // ✅

❓ Проверь себя

Почему матричное возведение полезно для линейных рекуррент?
A Потому что рекурренту можно записать как матрицу перехода и поднять её в степень за O(log n)
B Потому что матрицы всегда быстрее массивов
C Потому что DP не работает
D Потому что матрицы экономят память
✅ Именно переход n→n+1 можно оформить как линейное преобразование, а потом быстро возвести его в степень.
❌ Верный ответ: рекурренту можно представить матрицей перехода и ускорить до O(log n).

〰️ БПФ / NTT (Быстрое преобразование Фурье)

⏱ O(n log n) 💾 O(n)

🎯 Интуиция — аналогия из жизни

Представьте, что вы смешиваете две банки разной краски. Делать это по каплям (наивное умножение) долго. Но вы можете разложить каждую банку на спектр цветов через призму (прямое Фурье), смешать нужные спектры вместе поточечно, а потом пропустить результат через обратную призму (обратное Фурье), получив идеальный цвет за меньшее время.

📝 Пошаговое текстовое описание

Задача: Умножить два многочлена или два длинных числа. Наивное умножение — "каждый с каждым" $\to O(N^2)$.
Дополняем векторы коэффициентов нулями до ближайшей степени двойки (удобно для деления пополам).
Применяем прямое Преобразование (FFT/NTT) к обоим. Оно переводит коэффициенты полинома в значения полинома в спец. точках.
Перемножаем полученные значения поточечно за $O(N)$.
Применяем Обратное Преобразование, которое восстанавливает коэффициенты полинома по его значениям.

📐 Почему такая сложность?

Разделяй-и-властвуй. На каждом уровне рекурсии (или бабочки) мы делим полином на четные и нечетные степени. Глубина дерева $\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}$!

C++ — NTT (Number Theoretic Transform - точная целочисленная свёртка)
#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²)

C++
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}

C++
if (inverse) {
    long long n_inv = power(n, MOD - 2, MOD);
    for (auto &x : a) x = x * n_inv % MOD;
}

❓ Проверь себя

Главное преимущество NTT перед обычным FFT?
A Работает только с вещественными числами
B Не требует модулей
C Даёт точную целочисленную свёртку без ошибок округления
D Всегда быстрее в 100 раз
✅ NTT работает в конечном поле по модулю и избегает ошибок double/float.
❌ Ответ: точность. NTT особенно полезен когда нужен точный целочисленный результат.

Глава 10. Прочие техники

📌 Мощные подходы

В этой главе собраны алгоритмические парадигмы и техники, которые не привязаны к конкретным структурам данных, но являются ключом к решению огромного класса задач (особенно уровня Medium и Hard на собеседованиях).

🔙 Бэктрекинг (Backtracking)

⏱ O(k^n) или O(n!) 💾 O(n) память стека вызовов

🎯 Интуиция — аналогия из жизни

Прохождение лабиринта по "нити Ариадны". Вы идете вперёд, разматывая нить. Дошли до развилки — выбрали один путь. Если упёрлись в тупик, вы не начинаете лабиринт заново, а просто сматываете нить обратно (откат) до последней развилки и идёте в другой рукав.

📝 Пошаговое текстовое описание

Создаем рекурсивную функцию. Проверяем базовый случай: если решение собрано целиком, сохраняем его и выходим.
В цикле перебираем все возможные варианты следующего шага из текущего состояния.
Проверяем, валиден ли шаг (отсечение/pruning). Если нет — пропускаем.
Делаем шаг (модифицируем состояние: добавляем элемент в массив, ставим фигуру на доску).
Рекурсивно вызываем функцию для следующего шага.
Делаем откат (undo): возвращаем состояние в исходный вид, чтобы цикл мог попробовать следующий вариант.

📐 Почему такая сложность?

Это алгоритм полного (или частичного) перебора. Мы перебираем все комбинации или перестановки. Например, для перестановок сложность 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)... и так далее, пока не переберем все.

C++ — N Queens + генерация подмножеств + перестановки + судоку
#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)

❌ Нет отката

C++
board[row][col] = 'Q';
solve(row + 1);
// 💀 Забыли убрать ферзя!
// Следующие варианты будут некорректны

✅ Откатываем после рекурсии

C++
board[row][col] = 'Q';
solve(row + 1);
board[row][col] = '.'; // ✅ BACKTRACK!

Нет отсечения (pruning)

❌ Проверяем только в конце

C++
void solve(int row) {
    if (row == n) {
        if (isValid(board)) count++; // 💀
    }
    // Ставим ферзя без проверки → O(n^n)
}

✅ Отсекаем на каждом шаге

C++
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!)

❓ Проверь себя

Сколько решений задачи 8 ферзей?
A 8
B 64
C 92
D 0
✅ 92 различных расстановки для n=8. Для n=1→1, n=2→0, n=3→0, n=4→2, n=5→10, n=6→4, n=7→40, n=8→92.
❌ 92! Бэктрекинг с отсечением находит все 92 решения за доли секунды.

✂️ Разделяй и властвуй (Divide and Conquer)

⏱ Зависит (обычно O(n log n)) 💾 O(log n) рекурсия

🎯 Интуиция — аналогия из жизни

Представьте, что вам нужно сломать толстый веник. Целиком — нереально. Вы разделяете веник на две половинки, их еще на две, пока в руках не окажутся отдельные прутики. Вы легко ломаете по одному прутику (базовый случай), а затем "собираете" факт поломки наверх. Задача решена!

📝 Пошаговое текстовое описание

Divide (Разделяй): Разбить задачу на несколько подзадач меньшего размера.
Conquer (Властвуй): Рекурсивно решить эти подзадачи. Если они достаточно малы (базовый случай) — решить их напрямую.
Combine (Объединяй): Соединить решения подзадач для получения ответа к исходной задаче.

📐 Почему такая сложность?

Анализируется через Мастер-Теорему. Если мы делим задачу пополам и тратим линейное время на слияние (как в 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. ✅

C++ — подсчёт инверсий + максимальный подмассив
#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) — в парадигме Разделяй и Властвуй подзадачи независимы и не пересекаются (нет смысла применять мемоизацию).

❓ Проверь себя

Подсчёт инверсий через merge sort: где именно считаются инверсии?
A При разделении массива
B При слиянии: когда берём из правой части, все оставшиеся в левой — инверсии
C В отдельном проходе
D Нигде, merge sort не считает инверсии
✅ При merge: если right[j] < left[i], то right[j] меньше ВСЕХ left[i..mid] → count += (mid - i + 1) инверсий за один шаг!
❌ При слиянии! Когда right[j] < left[i], все left[i..end] образуют инверсии с right[j].

🔧 Битовые операции (Bit Manipulation)

⏱ O(1) за операцию 💾 O(1)

🎯 Интуиция — аналогия из жизни

Представьте пульт диджея на 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) без дополнительной памяти для хеш-таблицы! ✅

C++ — битовые трюки + задачи
#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` радикально экономит кэш и время.

❓ Проверь себя

Как проверить, является ли число степенью двойки?
A n % 2 == 0
B n > 0 && (n & (n-1)) == 0
C n == 2^k для целого k
D log2(n) — целое
✅ Степень двойки = ровно один бит. n&(n-1) убирает младший бит. Если результат 0 → был один бит → степень двойки! n>0 исключает 0.
❌ n & (n-1) == 0! Пример: 8=1000, 7=0111, 8&7=0. 6=110, 5=101, 6&5=100≠0.

📊 Монотонный стек

⏱ O(n) 💾 O(n)

🎯 Интуиция — аналогия из жизни

Представьте, что вы стоите в строю новобранцев. Вы видите перед собой только тех, кто выше вас. Если перед вами стоит низкий человек, он "перекрывается" вами для тех, кто смотрит сзади. Как только в строй встает кто-то высокий, все низкие перед ним становятся нерелевантными — мы их "выгоняем" из видимости (из стека).

📝 Пошаговое текстовое описание

Поддерживаем стек индексов элементов (а не самих значений).
Проходим по массиву слева направо.
Для задачи "Следующий бóльший элемент": пока стек не пуст и текущий элемент больше элемента на вершине стека, мы нашли ответ для вершины!
Записываем ответ, делаем 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

C++ — Next Greater + гистограмма + Trapping Rain Water
#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) — это двусторонний монотонный стек.

❓ Проверь себя

Максимальный прямоугольник в гистограмме [2,1,5,6,2,3] = ?
A 6
B 8
C 10 (высота 5, ширина 2)
D 12
✅ Столбцы 5 и 6: min(5,6)×2 = 10. Монотонный стек находит для каждого столбца границы, где он минимальный.
❌ 10! Прямоугольник высотой 5 шириной 2 (столбцы с высотой 5 и 6).

🤝 Meet in the Middle

⏱ O(2^(N/2) · log(2^(N/2))) 💾 O(2^(N/2))

🎯 Интуиция — аналогия из жизни

Представьте, что вы копаете туннель сквозь гору. Если копать только с одной стороны, вам потребуется 10 лет (экспоненциальное время). Если две команды начнут копать с двух сторон горы и встретятся посередине, каждая потратит 5 лет. Время работы падает драматически, хотя задача осталась той же!

📝 Пошаговое текстовое описание

Разбиваем входной массив (размера $N$) на две равные половины $N/2$.
Полным перебором (или масками) генерируем все возможные комбинации/суммы первой половины. Их будет $2^{N/2}$. Складываем в массив A.
Делаем то же самое для второй половины. Складываем в массив B.
Сортируем массив B за $O(K \log K)$, где $K = 2^{N/2}$.
Проходим по элементам массива A, и для каждого ищем дополнение в массиве B с помощью Бинарного поиска.

📐 Почему такая сложность?

Для $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).

C++ — subset sum для N=40
#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 полностью основана на этой технике.

🔗 Связь с другими

Это симбиоз Бэктрекинга (перебора подмножеств) и Бинарного поиска / Двух указателей для слияния списков.

❓ Проверь себя

Meet in the Middle превращает O(2^40) в:
A O(2^20)
B O(2^20 · 20) — с бинарным поиском
C O(n²)
D O(n log n)
✅ Две половины по 2^20 ≈ 10⁶. Сортируем одну → бинарный поиск для каждого элемента другой → 10⁶ × 20 = 2×10⁷. Вместо 10¹² — реально!
❌ O(2^20 · log(2^20)) = O(2^20 · 20). Разбиваем на 2 половины, перебираем каждую, комбинируем с бинпоиском.

70+ алгоритмов • 10 глав • Полный курс АиСД на C++

Сортировки • Поиск • Структуры данных • Деревья • Графы
Динамическое программирование • Жадные • Строки • Математика • Прочие