Алгоритмы с нуля
Сложность рекурсии
Из прошлого урока ты знаешь, что factorial(n) использует O(n) памяти стека: каждый рекурсивный вызов заталкивает frame, и самая глубокая рекурсия идёт n уровней. Но что насчёт времени? Рекурсивная функция, что вызывает саму себя дважды за уровень, занимает в два раза больше времени? Что если она вызывает себя дважды при каждом вызове? Мы вот-вот научимся инструменту под названием дерево рекурсии, что отвечает на это. И мы откроем шокирующую истину: небольшое изменение в том, сколько раз функция вызывает саму себя, может превратить практичный алгоритм в алгоритм, что занимает больше времени, чем возраст вселенной.
После этого урока ты можешь нарисовать дерево рекурсии для рекурсивной функции и использовать его, чтобы найти Big-O временную сложность. Ты понимаешь, как число рекурсивных вызовов за уровень (branching factor) и глубина рекурсии комбинируются, чтобы определить, линейное ли время, полиномиальное или экспоненциальное. Ты знаешь, почему наивный рекурсивный Фибоначчи — катастрофически медленный, и ты распознаёшь паттерн: функция, что переиспользует один и тот же подзадач много раз, взрывается в стоимости.
Что такое дерево рекурсии?
Дерево рекурсии — это картина всех вызовов функции, что делает рекурсивная функция. Каждый узел представляет один вызов функции. Дети узла — вызовы, что делает этот узел. Рёбра между родителем и ребёнком показывают цепь вызовов.
Например, дерево для factorial(4) — прямая линия: factorial(4) вызывает factorial(3), что вызывает factorial(2), что вызывает factorial(1). По одному узлу за уровень, итого 4 узла. Каждый узел делает O(1) работы (одно умножение). Итого: 4 узла × O(1) = O(4) = O(n).
Branching factor и глубина
Ключ к чтению дерева рекурсии — два числа:
- Branching factor — сколько рекурсивных вызовов делает один вызов функции? Для
factorialэто 1 (один вызов за уровень). Дляfibonacci(n) = fibonacci(n-1) + fibonacci(n-2)это 2 (два вызова). - Глубина — сколько уровней в дереве до базового случая? Для
factorial(n)это n. Дляfibonacci(n)тоже n.
Число узлов в дереве примерно branching_factor ^ depth (игнорируя члены низшего порядка и константные множители).
Временная сложность = число узлов × работа на узел. Если каждый узел делает O(1) работы, время — O(branching_factor ^ depth).
Пример 1: factorial(n)
factorial(n) = n × factorial(n - 1) вызывает себя один раз. Branching factor = 1. Глубина = n. Структура дерева:
factorial(4)
factorial(3)
factorial(2)
factorial(1)
factorial(0) [базовый случай]Число узлов = 5 (для factorial(4)). Каждый узел делает O(1) работы (одно умножение). Временная сложность = 5 = O(n).
Пример 2: Fibonacci (наивный рекурсивный)
fibonacci(n) = fibonacci(n-1) + fibonacci(n-2) вызывает себя дважды за вызов (кроме базовых случаев). Branching factor = 2. Глубина = n.
Дерево для fibonacci(5):
fib(5)
/ \
fib(4) fib(3)
/ \ / \
fib(3) fib(2) fib(2) fib(1)
/ \ / \ / \
fib(2) fib(1) ...
/ \
fib(1) fib(0)На уровне 0: 1 узел (корень, fib(5)).
На уровне 1: 2 узла (fib(4), fib(3)).
На уровне 2: 4 узла.
На уровне 3: 8 узлов.
…
На уровне n: 2^n узлов.
Всего узлов ≈ 1 + 2 + 4 + 8 + … + 2^n ≈ 2^(n+1) - 1 ≈ O(2^n).
Каждый узел делает O(1) работы. Временная сложность = O(2^n). Это экспоненциальное время.
Из курса математики Линейный и экспоненциальный ростПочему экспоненциальное — плохо?
Линейное (O(n)): factorial(100) нужны ~100 вызовов. Мгновенно.
Экспоненциальное (O(2^n)): fibonacci(100) нужны ~2^100 ≈ 10^30 вызовов. Возраст вселенной ~10^10 секунд. Не при твоей жизни.
Память стека отличается от времени
Дерево рекурсии измеряет время. Но помни: память стека определяется глубиной, не числом узлов. fibonacci(n) имеет:
- Время: O(2^n) (экспоненциальное — катастрофично)
- Память стека: O(n) (линейная — хорошо, до разумных глубин)
Время — узкое место, не стек.
Общая формула: O(b^d)
Для рекурсивной функции:
- b = branching factor (вызовы за уровень)
- d = глубина (уровни до базового случая)
- Время = O(b^d) × работа на узел
Если работа на узел O(1), то время O(b^d). Если работа на узел O(n), то время O(n × b^d). И так далее.
Когда b > 1 и d растёт с размером входа, время — экспоненциальное. И экспоненциальное — плохо.
Напишем и протестируем две рекурсивные функции и увидим разницу в числе вызовов.
Пример 1: Factorial (линейный, b=1)
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
console.log(factorial(5)); // 120Вызовы: factorial(5) → factorial(4) → factorial(3) → factorial(2) → factorial(1) [базовый]. Всего: 5 вызовов.
Пример 2: Наивный Fibonacci (экспоненциальный, b=2)
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
console.log(fibonacci(5)); // 5Вызовы: Намного больше. fibonacci(5) вызывает fibonacci(4) И fibonacci(3). Затем fibonacci(4) вызывает fibonacci(3) И fibonacci(2). Один и тот же подзадач (вроде fibonacci(3)) решается много-много раз.
Попробуй оба:
Визуализируем дерево рекурсии для fibonacci(5) показав структуру вызовов уровень за уровнем.
1
function fibonacci(n) {
2
if (n <= 1) return n;
3
return fibonacci(n-1) + fibonacci(n-2);
4
}
Factorial: O(n) время, O(n) память
- Branching factor = 1 (один рекурсивный вызов за уровень).
- Глубина = n.
- Число узлов = n.
- Работа на узел = O(1) (одно умножение).
- Время = O(n) × O(1) = O(n).
- Память (стек вызовов) = O(n) (глубина дерева).
Практика: factorial(100) мгновенно. factorial(10,000) может упереться в лимит стека, но время — никогда не проблема.
Fibonacci (наивный): O(2^n) время, O(n) память
- Branching factor = 2 (два рекурсивных вызова за уровень).
- Глубина = n.
- Число узлов ≈ 2^n.
- Работа на узел = O(1) (одно сложение).
- Время = O(2^n) × O(1) = O(2^n).
- Память (стек вызовов) = O(n) (глубина дерева).
Практика: fibonacci(20) делает ~20,000 вызовов. fibonacci(40) делает ~10^12 вызовов. Не практично без оптимизации (вроде мемоизации — больше об этом в unit динамического программирования).
Почему экспоненциальное время — катастрофа
С O(n): если удвоишь вход, работа примерно удвоится. С O(2^n): если удвоишь вход (например от 20 к 40), работа примерно возведётся в квадрат… нет подожди, она идёт от ~10^6 к ~10^12 — множитель в миллион. Экспоненциальный рост — жестокий.
Сила branching factor
- b = 1: линейное время O(n).
- b = 2: экспоненциальное время O(2^n).
- Изменение с 1 на 2 — разница между “мгновенно” и “невозможно”.
Вот почему наивный рекурсивный Фибоначчи — классический пример “не пиши такой код.”
Сколько вызовов делает factorial(6)? (Считай каждый вызов функции, включая базовый случай.)
Сколько вызовов делает fibonacci(4)? (Подсказка: нарисуй дерево или отследи вручную.)
Какой branching factor у этой функции? function mystery(n) { if (n <= 1) return 1; return mystery(n-2) + mystery(n-2) + mystery(n-1); }
Примерно сколько узлов в дереве рекурсии fibonacci(10)?
Если ты вызовешь fibonacci(20), примерно сколько раз достигается базовый случай?
Частая ошибка
Ошибка: Думать что O(2ⁿ) просто “медленный”. Ты пишешь рекурсивную функцию что вызывает себя дважды, тестируешь на маленьких входах (n=10), и она работает мгновенно. Итак ты деплоишь. Потом пользователь передаёт n=30. Функция не падает; она просто зависает навечно. Твой server timeout срабатывает. Пользователи жалуются.
Экспоненциальное время — не просто медленно. Это катастрофично. Маленький вход как 20 — хорошо. Вход как 40 — буквально не вычислим в человеческих масштабах времени. Вот почему так важно проанализировать дерево рекурсии перед кодингом.
Граничные случаи
Один и тот же подзадач решается много раз. Убийственное озарение в наивном дереве Fibonacci — что fibonacci(3) вычисляется дважды, fibonacci(2) — трижды, и так далее. Каждый раз что мы вычисляем fibonacci(3), мы тоже переиспользуем fibonacci(2) внутри неё. Это массив траты.
Позже мы выучим мемоизацию и динамическое программирование (next unit) чтобы это исправить: сохрани ответы так что каждый подзадач решено только раз. С мемоизацией, fibonacci(n) падает с O(2^n) к O(n). Один и тот же код, просто с памятью. Вот почему выучить распознавать экспоненциальную рекурсию сейчас — критично.
Ты пишешь рекурсивную функцию что вызывает себя трижды за уровень, с глубиной 10. Примерно сколько всего вызовов функции твоя программа делает?
Дерево рекурсии: Картина всех вызовов рекурсивная функция делает. Каждый узел — вызов; дети — вызовы что делает этот узел.
Branching factor (b): Сколько рекурсивных вызовов делает один вызов функции?
Глубина (d): Сколько уровней рекурсии до базового случая?
Число узлов ≈ b^d: Это всего число вызовов функции. Множь на работу за узел чтобы получить временную сложность.
Примеры:
factorial(n): b=1 (один вызов), d=n (n уровней). Время = O(1^n) = O(n).- Наивный
fibonacci(n): b=2 (два вызова), d=n (n уровней). Время = O(2^n) (экспоненциальное).
Почему экспоненциальное — плохо: С O(n), удвоение входа удвоит время. С O(2^n), добавив 10 к входу, множит время на 2^10 ≈ 1,000. Экспоненциальный рост так быстрый что становится непрактично для любого разумного большого входа.
Ключевое озарение: Рекурсивная функция что переиспользует один и тот же подзадач много раз (как Fibonacci) тратит массив работы. Позже техники как мемоизация исправляют это помня ответы.