awesome-everything EN
↑ Обратно к восхождению

Алгоритмы с нуля

Сложность рекурсии

Суть Чтобы найти Big-O рекурсивной функции, рисуем дерево рекурсии: каждый узел — вызов, а стоимость = число узлов × работа на узел. Функция с одним вызовом за уровень — O(n); с двумя вызовами за уровень — O(2ⁿ).
◷ 22 min

Из прошлого урока ты знаешь, что 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 и глубина

Ключ к чтению дерева рекурсии — два числа:

  1. Branching factor — сколько рекурсивных вызовов делает один вызов функции? Для factorial это 1 (один вызов за уровень). Для fibonacci(n) = fibonacci(n-1) + fibonacci(n-2) это 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 — разница между “мгновенно” и “невозможно”.

Вот почему наивный рекурсивный Фибоначчи — классический пример “не пиши такой код.”

Практика 0 / 5

Сколько вызовов делает 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) тратит массив работы. Позже техники как мемоизация исправляют это помня ответы.

Продолжить восхождение ↑Backtracking
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.