Інформатика · Алгоритми · Програмування
📅 Квітень 2026 ⏱ ≈ 11 хв читання 🎯 Початковий–середній · Останнє оновлення: 28 травня 2026 р.

Рекурсія простими словами — стек викликів, мемоізація та оптимізація хвостових викликів

Рекурсія — це функція, яка викликає сама себе. Але глибоке розуміння рекурсії — стека викликів, записів активації, того, чому наївний алгоритм Фібоначчі експоненційно повільний, як мемоізація зводить його до лінійного часу і як оптимізація хвостових викликів згортає глибоку рекурсію в цикл — перетворює її з таємничого патерна на потужний і точний інструмент для вираження задач, що мають природну самоподібну структуру.

1. Стек викликів

Коли функцію викликають, середовище виконання виділяє запис активації (кадр стека) на стеку викликів. Кадр зберігає локальні змінні, адресу повернення та аргументи функції. Коли функція повертає значення, кадр знімається зі стека.

Приклад: factorial(4) Зростання стека викликів → factorial(4) → кадр: {n=4, адреса повернення} factorial(3) → кадр: {n=3, адреса повернення} factorial(2) → кадр: {n=2, адреса повернення} factorial(1) → кадр: {n=1, адреса повернення} повертає 1 ← кадр знімається, n=2 обчислює 2×1=2 ← кадр знімається, n=3 обчислює 3×2=6 ← кадр знімається, n=4 обчислює 4×6=24 повертає 24 Глибина стека: O(n) ← переповнення стека для великих n! Ліміт стека викликів JavaScript: зазвичай 10 000–15 000 кадрів. factorial(15000) → "Maximum call stack size exceeded"
// Проста лінійна рекурсія
function factorial(n) {
  if (n <= 1) return 1;           // базовий випадок
  return n * factorial(n - 1);    // рекурсивний крок
}

2. Базові випадки та структурна індукція

Кожна рекурсивна функція повинна мати базовий випадок — умову завершення, що повертає значення без рекурсивного виклику. Без нього рекурсія працює нескінченно (на практиці — переповнення стека).

Коректність рекурсивних функцій зазвичай доводять методом структурної індукції:

  1. Базовий випадок: Довести, що функція коректна для найменшого вхідного значення.
  2. Індуктивний крок: Припустити, що функція коректна для всіх вхідних значень, менших за n. Довести, що вона коректна для n, використовуючи це припущення.
Рекурсивний підхід проти ітеративного: Будь-яку рекурсивну функцію можна перетворити на ітеративну за допомогою явного стека. Перетворення іноді тривіальне (лінійна рекурсія → цикл), іноді складне (обхід дерева → ітерація на основі стека). Рекурсивна форма часто чистіша й точніше відображає математичне означення, а ітеративна форма уникає ризику переповнення стека.

3. Деревоподібна рекурсія: пастка Фібоначчі

fib(n) = fib(n-1) + fib(n-2), fib(0)=0, fib(1)=1 Дерево рекурсії для fib(5): fib(5) / \ fib(4) fib(3) / \ / \ fib(3) fib(2) fib(2) fib(1) / \ / \ / \ fib(2) fib(1) ... Вузлів на глибині d: до 2^d Усього вузлів: T(n) = T(n-1) + T(n-2) + 1 ≈ φⁿ де φ = (1+√5)/2 ≈ 1.618 Часова складність: O(φⁿ) ≈ O(1.618ⁿ) — ЕКСПОНЕНЦІЙНА fib(50) потребує ~1.26 × 10¹⁰ викликів (займає хвилини) Ключова проблема: fib(3) обчислюється двічі, fib(2) — тричі, fib(1) — п'ять разів... Підзадачі переобчислюються експоненційно багато разів. Відповідь: fib(40) = 102334155 потребує ~1 мільярд рекурсивних викликів. fib(40) ≈ 10⁸ викликів у JavaScript ≈ кілька секунд.

4. Мемоізація

Мемоізація (динамічне програмування зверху вниз) зберігає результат кожного унікального виклику функції в кеші. Перед обчисленням перевіряємо кеш; якщо значення є — одразу повертаємо його.

// Мемоізований Фібоначчі (зверху вниз)
function fibMemo(n, memo = new Map()) {
  if (n <= 1) return n;
  if (memo.has(n)) return memo.get(n);
  const result = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
  memo.set(n, result);
  return result;
}
// O(n) час — кожне унікальне n обчислюється один раз
// O(n) пам'ять — memo зберігає n значень + глибина стека викликів O(n)
Після мемоізації: виклики fib(5): fib(5) → fib(4) → fib(3) → fib(2) → fib(1), fib(0) далі fib(3) — це влучання в кеш ← без зайвої роботи Усього унікальних викликів: рівно n+1 = O(n) Перетворення: O(2ⁿ) → O(n) час [експоненційний → лінійний] Загальне правило: якщо рекурсивна функція з k параметрами має T унікальних станів (параметрів), кожен з яких обчислюється за O(роботи) часу, то складність із мемоізацією = O(T × робота).

5. Динамічне програмування (знизу вгору)

ДП знизу вгору обчислює підзадачі по порядку від найменшої до найбільшої, зберігаючи лише необхідне. Повністю уникає накладних витрат рекурсії та глибини стека:

// ДП Фібоначчі знизу вгору — O(n) час, O(1) пам'ять
function fibDP(n) {
  if (n <= 1) return n;
  let a = 0, b = 1;
  for (let i = 2; i <= n; i++) {
    [a, b] = [b, a + b];   // потрібні лише два останні значення
  }
  return b;
}

ДП знизу вгору лежить в основі: найкоротших шляхів Флойда-Воршелла, пошуку рядка Кнута-Морріса-Пратта, вирівнювання послідовностей (Сміта-Вотермана), оптимального двійкового дерева пошуку, розміну монет, задачі про рюкзак та алгоритму Вітербі у прихованих марковських моделях.

6. Оптимізація хвостових викликів

Хвостовий виклик — це рекурсивний виклик, який є найостаннішою операцією у функції: тому, хто викликав, не лишається жодної роботи після того, як рекурсивний виклик поверне значення. У цьому випадку кадр стека того, хто викликав, більше не потрібен і може бути замінений (а не додаватися до стека) кадром викликаної функції.

// Не хвостова рекурсія: множення виконується ПІСЛЯ повернення factorial
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1);   // ← не хвостовий: "n *" виконується після повернення
}

// Хвостова рекурсія: акумулятор несе поточний добуток
function factTail(n, acc = 1) {
  if (n <= 1) return acc;
  return factTail(n - 1, n * acc);   // ← хвостовий: останнє — це виклик
}
// З TCO: O(1) пам'ять стека — компілюється в цикл
// Специфікація JavaScript ES2015 вимагає TCO; V8 прибрав його у 2016 (обхід через ShadowRealm)
Підтримка TCO у 2026: ECMAScript 2015 зробив належні хвостові виклики обов'язковими, але V8 (Node.js/Chrome) прибрав підтримку TCO через проблеми з налагодженням. JavaScriptCore від Safari її підтримує. Для продакшн-коду на JS використовуйте натомість явний цикл або трамплін.

7. Взаємна рекурсія та трампліни

Взаємна рекурсія: функція A викликає B, яка викликає A. Приклад: `isEven(n) = isOdd(n-1); isOdd(n) = isEven(n-1)`. Створює глибокі стеки для великих n, навіть якщо кожна функція окремо є хвостовою рекурсією.

Трамплін — це цикл, який багаторазово викликає функцію, доки вона не поверне звичайне значення замість нової функції:

// Трамплін: перетворює глибоку рекурсію на стек O(1)
function trampoline(fn) {
  return function(...args) {
    let result = fn(...args);
    while (typeof result === 'function') {
      result = result();
    }
    return result;
  };
}

// Кожен крок повертає thunk (функцію без аргументів) замість прямого виклику
const isEven = trampoline(function _isEven(n) {
  if (n === 0) return true;
  return () => _isOdd(n - 1);    // повертаємо thunk, а не прямий виклик
});

Трампліни відокремлюють глибину рекурсії від глибини стека викликів. Цикл трампліна використовує стек O(1) під час обробки скільки завгодно глибокої взаємної рекурсії. Цей патерн часто застосовують у Scheme, Clojure та під час реалізації інтерпретаторів.

💻 Дослідити алгоритми →