Кто такая рекурсия?
«Для того чтобы понять рекурсию, надо сначала понять рекурсию»

Рекурсия — это процесс, при котором объект или понятие описывается через самого себя, то есть является частью самого себя.
Рекурсия в программировании — это техника, при которой функция вызывает саму себя непосредственно или косвенно (через другую функцию, которая вызовет исходную).
Как правило, рекурсия содержит следующие компоненты: - Граничный случай - точка, в которой функция перестает себя вызывать. - Рекурсивный случай - когда функция вызывает саму себя. — ## Числа Фибоначчи
Функция вычисляет n-е число Фибоначчи как сумму двух предыдущих:
F(n) = F(n − 1) + F(n − 2), F(0) = 0, F(1) = 1
- Граничный случай: n = 0 или n = 1. В этом случае возвращаем 0 или 1 соответственно (или просто n).
- Рекурсивный случай: n > 1 вызываем функции для вычисления (n − 1)-го и (n − 2)-го чисел, суммируем полученные результаты. ### Наивная реализация
int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
Сложность этого алгоритма O(2n), что достаточно долго. Рассмотрим дерево вычислений 5-го числа Фибрначчи:
Сразу бросается в глаза, что мы вычисляем некоторые числа повторно. Например, F(3) сначала считается для вычисления F(4) = F(3) + F(2), а затем второй раз F(5) = F(4) + F(3).
Такие повторные вычисления помечены в дереве красным.
Мемоизация
Для ускорения добавим мемоизацию — запоминаем уже найденные ранее значения, чтобы не считать их снова.
map<int, int> memo;
int fib(int n) {
if (memo.count(n)) return memo[n];
if (n <= 1) return n;
return memo[n] = fib(n - 1) + fib(n - 2);
}
Примечание: в C++ операторы присваивания (=, += и т.п.) не только записывают новое значение, но и возвращают присвоенное значение. Благодаря этому можно писать цепные присваивания a = b = c, а так же сразу присвоить и вернуть return a = b.
Сложность реализации выше: O(n ⋅ log (n)) (лог от обращений к словарю). Избавимся от логарифма, заменив словарь на вектор:
vector<int> memo; // memo.assign(n, -1)
int fib(int n) {
if (memo[n] != -1) return memo[n];
if (n <= 1) return n;
return memo[n] = fib(n - 1) + fib(n - 2);
}
Стек вызовов
Стек вызовов — это память программы о том, какие функции сейчас выполняются и куда нужно вернуться, когда каждая из них завершится. Каждый рекурсивный вызов добавляет в стек новую запись, выход из функции - удаляет запись из стека и возвращается к той функции, что сейчас на вершине стека.
Рассмотрим на примере вычисления fib(3):
| Шаг | Что происходит | Стек вызовов |
|---|---|---|
| 1 | вызываем fib(3) |
[ fib(3) ] |
| 2 | fib(3) вызывает fib(2) |
[ fib(3), fib(2) ] |
| 3 | fib(2) вызывает fib(1) |
[ fib(3), fib(2), fib(1) ] |
| 4 | fib(1) возвращает 1, снимается со стека |
[ fib(3), fib(2) ] |
| 5 | fib(2) вызывает fib(0) |
[ fib(3), fib(2), fib(0) ] |
| 6 | fib(0) возвращает 0, снимается |
[ fib(3), fib(2) ] |
| 7 | fib(2) возвращает 1, снимается |
[ fib(3) ] |
| 8 | fib(3) вызывает fib(1) |
[ fib(3), fib(1) ] |
| 9 | fib(1) возвращает 1, снимается |
[ fib(3) ] |
| 10 | fib(3) возвращает 2, снимается |
[ ] |
Ввод чисел до 0 и вывод в обратном порядке
Условие
Вводятся числа, пока не встретится 0.
Вывести все введённые числа в обратном порядке. Использовать массивы нельзя.
Решение
Воспользуемся тем, как работает стек вызовов. Он сам по себе является готовым хранилищем. Пока читаем числа, мы просто складываем их в стек. Как только встречаем 0, рекурсия начинает “раскручиваться” обратно. Программа достаёт числа из стека и печатает их - в обратном порядке.
void read() {
int x;
cin >> x;
if (x == 0) return;
read();
cout << x << ' ';
}
При возврате из рекурсии элементы выводятся в обратном порядке,
поскольку стек вызовов хранит последовательность входов в функцию.
Асимптотика: O(n).
Цикл и рекурсия
- Любой цикл можно заменить на рекурсию. Однако цикл практически всегда предпочтительнее, поскольку каждый вызов функции тратит дополнительные время и память.
- В обратную сторону не работает - не всякую рекурсию можно заменить циклом. Пример посмотрим дальше
Перебор всех двоичных строк длины N
Условие
Каждая позиция строки может быть 0 или 1. Вывести все такие строки длины N в лексикографическом порядке.
Медленное решение

Выше изображено дерево рекурсии для N = 2. В каждой конечной вершине строка - путь до этой вершины. По сути, мы хотим вывести весь стек вызовов. Изнутри вызова узнать путь до нее нельзя. Чтобы этот путь был виден, будем передавать его дополнительным параметром в функцию:
void gen(string s, int n) {
if ((int)s.size() == n) {
cout << s << '\n';
return;
}
gen(s + '0', n);
gen(s + '1', n);
}
Главная проблема этого кода в том, что для каждого вызова функции значение s копируется, что значительно замедляет программу. ### Оптимальное решение
void gen(string& s, int n) {
if ((int)s.size() == n) {
cout << s << '\n';
return;
}
s.push_back('0');
gen(s, n);
s.back() = '1';
gen(s, n);
s.pop_back();
}
Важно: строку передаём по ссылке. Передаваемые по ссылке объекты изменяются внутри рекурсии, после чего возвращаются в исходное состояние при pop_back().
Асимптотика: O(2N ⋅ N). Всего 2N двоичных строк длины N, а так же O(N) на вывод каждой из них.
Перебор двоичных строк длины N с ровно K единицами
Решение
Добавляем счётчик числа единиц.
Если их уже больше K, или оставшихся позиций не хватит — рекурсию можно закончить.
void gen(int n, int k, string& s, int ones) {
if ((int)s.size() == n) {
if (ones == k) cout << s << '\n';
return;
}
if (ones > k) return;
if (ones + (n - (int)s.size()) < k) return;
s.push_back('0');
gen(n, k, s, ones);
s.back() = '1';
gen(n, k, s, ones + 1);
s.pop_back();
}
Отсечение позволяет исключить целые ветки рекурсии, что делает код значительно быстрее, особенно при больших n и малых k.
Асимптотика: O(Cnk).
Перестановки элементов массива
Решение
Пусть рекурсивная функция выводит все перестановки массива a, где первые pos элементов зафиксированы. Она перебирает элемент, который стоит на индексе pos и рекурсивно вызывает себя с аргументом pos + 1.
void permute(vector<int>& a, int pos) {
if (pos == (int)a.size()) {
for (int x : a) cout << x << ' ';
cout << '\n';
return;
}
for (int i = pos; i < (int)a.size(); i++) {
swap(a[i], a[pos]);
permute(a, pos + 1);
swap(a[i], a[pos]);
}
}
Асимптотика: O(n! ⋅ n).
Встроенная next_permutation
В C++ существует встроенная функция next_permutation, которая преобразует массив в перестановку, следующую за текущей в лексикографическом порядке. Если текущая перестановка — последняя в порядке возрастания, функция возвращает false и преобразует диапазон в минимальную перестановку.
Таким образом, имея отсортированный массив a можно перебрать все перестановки в лексикографическом порядке:
do {
for (int x : a) cout << x << ' ';
cout << '\n';
} while (next_permutation(a.begin(), a.end()));
Убывающие последовательности длины K из чисел 1…N
void dec_seq(int n, int k, vector<int>& cur) {
if ((int)cur.size() == k) {
for (int x : cur) cout << x << ' ';
cout << '\n';
return;
}
int last;
if ((int)cur.size() == 0) {
last = n + 1;
} else {
last = cur.back();
}
for (int x = last - 1; x >= 1; --x) {
if (x < k - (int)cur.size()) break;
cur.push_back(x);
dec_seq(n, k, cur);
cur.pop_back();
}
}
Асимптотика: O(Cnk).
В этом решении мы снова поддерживаем уже построенный префикс, как и в прошлых задачах. Появилось новое отсечение: чисел, меньших последнего, должно быть хотя бы столько же или больше количества элементов, которые нам осталось добавить до длины K. Иначе нам будет просто нечем закончить последовательность.
Разложение числа на слагаемые
Условие
Разбить число n на сумму положительных слагаемых.
Порядок слагаемых не важен.
Реализация
Порядок не важен = перебираем отсортированные последовательности. Это самый удобный способ перебрать каждый набор единожды.
void decomp(int n, int last, vector<int>& cur) {
if (n == 0) {
for (int x : cur) cout << x << ' ';
cout << '\n';
return;
}
for (int x = min(last, n); x >= 1; --x) {
cur.push_back(x);
decomp(n - x, x, cur);
cur.pop_back();
}
}
Экспоненциальное время работы, зависит от количества разложений.
Сортировки
Виды сортировок:
- in-place — сортировка в том же массиве (например,
quick sort); - out-of-place — создаёт новый массив (например, сортировка слиянием);
- Стабильные — не меняют порядок равных элементов относительно друг друга;
- Нестабильные — могут изменить порядок равных элементов.
Сортировка слиянием
Идея
- Разделяем массив на две части,
- Рекурсивно сортируем каждую,
- Сливаем в отсортированный массив.
void merge_sort(vector<int>& a) {
if (a.size() <= 1) return;
int m = a.size() / 2;
vector<int> left(a.begin(), a.begin() + m);
vector<int> right(a.begin() + m, a.end());
merge_sort(left);
merge_sort(right);
int i = 0, j = 0, k = 0;
while (i < (int)left.size() && j < (int)right.size())
a[k++] = (left[i] < right[j]) ? left[i++] : right[j++];
while (i < (int)left.size()) a[k++] = left[i++];
while (j < (int)right.size()) a[k++] = right[j++];
}
Асимптотика: O(n ⋅ log n)
Подсчёт числа инверсий
Идея
Инверсия — пара индексов (i, j), где i < j и ai > aj.
При слиянии массивов: если элемент из правой части меньше текущего из левой,
то все оставшиеся элементы слева образуют инверсии с этим элементом.
long long merge_count(vector<int>& a) {
if (a.size() <= 1) return 0;
int m = a.size() / 2;
vector<int> left(a.begin(), a.begin() + m);
vector<int> right(a.begin() + m, a.end());
long long inv = merge_count(left) + merge_count(right);
int i = 0, j = 0, k = 0;
while (i < (int)left.size() && j < (int)right.size()) {
if (left[i] <= right[j])
a[k++] = left[i++];
else {
a[k++] = right[j++];
inv += left.size() - i;
}
}
while (i < (int)left.size()) a[k++] = left[i++];
while (j < (int)right.size()) a[k++] = right[j++];
return inv;
}
Сортировка подсчётом
Идея
Работает, если элементы — неотрицательные целые и значения не слишком велики.
Создаём массив счётчиков, где cnt[x] хранит количество вхождений числа x.
vector<int> count_sort(vector<int>& a) {
int mx = ...; // max(a)
vector<int> cnt(mx + 1);
for (int x : a) cnt[x]++;
vector<int> res;
for (int i = 0; i <= mx; i++)
for (int j = 0; j < cnt[i]; j++)
res.push_back(i);
return res;
}
Асимптотика: O(max(a) + n)
Если диапазон слишком велик — можно использовать map вместо массива, хотя это и не дает выигрыша по времени в сравнении с другими сортировками:
vector<int> count_sort_map(vector<int>& a) {
map<int, int> cnt;
for (int x : a) cnt[x]++;
vector<int> res;
for (auto [val, num] : cnt)
for (int i = 0; i < num; i++)
res.push_back(val);
return res;
}
Асимптотика: O(n ⋅ log n)
Сортировка подсчётом — первая сортировка, где требуется дополнительная память,
но она может работать быстрее, чем O(nlog n) при малом диапазоне значений.