$ sudo teach IT
РАЗДЕЛ 6 · УРОК 7

Рекурсия

Рекурсия — это когда функция вызывает саму себя. Звучит странно? На самом деле это элегантный способ решать задачи, которые по природе своей «вложенные»: деревья, вложенные папки, математические последовательности. Но главное — понять два обязательных условия, без которых рекурсия превращается в бесконечный цикл.

⏱ ~30 минут 🧠 Средний уровень 🟡 JavaScript

🌀 Что такое рекурсия?

Рекурсия — это приём программирования, при котором функция вызывает саму себя для решения подзадачи. Идея: если задача слишком большая — разбей её на маленькую задачу того же типа, реши маленькую, используй её результат для большой.

Аналогия из жизни: чтобы пересчитать, сколько людей в большой очереди, можно сказать: «Я первый, плюс сколько людей за мной». А человек за мной скажет: «Я первый за тобой, плюс сколько людей за мной». И так пока не дойдём до последнего, который скажет: «Я один — счёт равен 1». Это и есть рекурсия.

🔁 Рекурсия = функция + вызов самой себя + условие остановки. Без условия остановки — бесконечный цикл и ошибка. Это главное правило.

📋 Два обязательных условия рекурсии

Любая правильная рекурсивная функция обязательно содержит оба условия:

🛑
1. Базовый случай (base case)

Условие, при котором функция не вызывает себя рекурсивно, а возвращает конкретный результат. Это «дно» — точка, где рекурсия останавливается.

🔄
2. Рекурсивный вызов (recursive case)

Вызов функции с изменённым аргументом, который становится ближе к базовому случаю. Ключевое слово: аргумент должен меняться, иначе никогда не достигнем базового случая.

🧮 Пример 1: Факториал

Факториал числа n (записывается n!) — это произведение всех натуральных чисел от 1 до n. Например: 5! = 5 × 4 × 3 × 2 × 1 = 120. По определению: 0! = 1 и 1! = 1.

Замечаем: 5! = 5 × 4!, а 4! = 4 × 3!, и так далее. Задача сводится к самой себе — это рекурсивная структура!

function factorial(n) {
  // Базовый случай: 0! = 1, 1! = 1
  if (n <= 1) {
    return 1;
  }

  // Рекурсивный вызов: n! = n × (n-1)!
  return n * factorial(n - 1);
}

console.log(factorial(0)); // 1
console.log(factorial(1)); // 1
console.log(factorial(5)); // 120
console.log(factorial(10)); // 3628800

Давайте пошагово проследим, что происходит при вызове factorial(4):

factorial(4) → 4 × factorial(3)
  factorial(3) → 3 × factorial(2)
    factorial(2) → 2 × factorial(1)
      factorial(1) → 1     ← БАЗОВЫЙ СЛУЧАЙ
    factorial(2) → 2 × 1 = 2
  factorial(3) → 3 × 2 = 6
factorial(4) → 4 × 6 = 24

Функция «уходит вглубь», пока не достигает базового случая, а потом «поднимается обратно», собирая результаты.

🏗️ Стек вызовов: что происходит под капотом

Каждый вызов функции в JavaScript добавляет фрейм в стек вызовов (call stack). Стек — это структура «последний зашёл — первый вышел» (как стопка тарелок). При рекурсии каждый вызов ждёт, пока вернётся вложенный.

// При factorial(4) стек выглядит так:
// [factorial(4)] ← первый вызов
// [factorial(4), factorial(3)] ← второй вызов
// [factorial(4), factorial(3), factorial(2)] ← третий вызов
// [factorial(4), factorial(3), factorial(2), factorial(1)] ← базовый случай
// ← factorial(1) вернул 1, убирается из стека
// [factorial(4), factorial(3), factorial(2)] ← factorial(2) получил 1
// [factorial(4), factorial(3)] ← factorial(3) получил 2
// [factorial(4)] ← factorial(4) получил 6
// [] ← factorial(4) вернул 24, стек пуст

💥 Stack Overflow: когда рекурсия бесконечна

Если рекурсия никогда не достигает базового случая — стек вызовов переполняется. Это называется Stack Overflow (переполнение стека). JavaScript выбросит ошибку: RangeError: Maximum call stack size exceeded.

// ❌ Нет базового случая — бесконечная рекурсия
function countdown(n) {
  console.log(n);
  countdown(n - 1); // никогда не остановится!
}
countdown(5); // RangeError: Maximum call stack size exceeded

// ❌ Аргумент не меняется — никогда не достигнем базы
function broken(n) {
  if (n <= 0) return 0;
  return broken(n); // n не уменьшается! Бесконечный цикл.
}

// ✅ Правильно: аргумент уменьшается, есть базовый случай
function countdown(n) {
  if (n <= 0) {
    console.log("Старт!");
    return;
  }
  console.log(n);
  countdown(n - 1); // n уменьшается
}
countdown(5); // 5, 4, 3, 2, 1, Старт!

⚠️ Три вопроса перед написанием рекурсии:
1. Какой базовый случай? (когда мы останавливаемся?)
2. Как меняется аргумент при каждом вызове? (становится ли ближе к базовому?)
3. Правильно ли я использую результат рекурсивного вызова?

🐇 Пример 2: Числа Фибоначчи

Числа Фибоначчи: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34... Каждое число — сумма двух предыдущих. Определение само по себе рекурсивное: fib(n) = fib(n-1) + fib(n-2).

function fibonacci(n) {
  // Базовые случаи: fib(0) = 0, fib(1) = 1
  if (n === 0) return 0;
  if (n === 1) return 1;

  // Рекурсивный вызов: fib(n) = fib(n-1) + fib(n-2)
  return fibonacci(n - 1) + fibonacci(n - 2);
}

console.log(fibonacci(0));  // 0
console.log(fibonacci(1));  // 1
console.log(fibonacci(5));  // 5
console.log(fibonacci(10)); // 55

⚡ Важный момент: наивная рекурсия Фибоначчи очень медленная — одни и те же подзадачи вычисляются многократно. fib(40) делает миллиарды вызовов! Для решения — мемоизация (помним из прошлого урока) или итеративный подход. Но для понимания рекурсии этот пример идеален.

⚖️ Рекурсия vs цикл: когда что использовать?

Любую рекурсию можно переписать через цикл и наоборот. Так когда же рекурсия лучше?

// Факториал через цикл (итеративно):
function factorialLoop(n) {
  let result = 1;
  for (let i = 2; i <= n; i++) {
    result *= i;
  }
  return result;
}

// Факториал рекурсивно:
function factorialRecursive(n) {
  if (n <= 1) return 1;
  return n * factorialRecursive(n - 1);
}

// Для факториала цикл эффективнее — рекурсия здесь просто для обучения
✅ Рекурсия лучше для:
  • Обхода деревьев и графов
  • Вложенных структур данных
  • Алгоритмов типа «разделяй и властвуй»
  • Задач, которые рекурсивны по природе
✅ Цикл лучше для:
  • Линейных итераций
  • Больших n (нет риска stack overflow)
  • Когда важна производительность
  • Простых накоплений (сумма, поиск)

Рассмотрим пример, где рекурсия действительно блистает — обход вложенного объекта (дерева):

// Подсчёт суммы всех вложенных чисел в дереве объектов
function sumTree(node) {
  // Базовый случай: если это число — вернуть его
  if (typeof node === "number") {
    return node;
  }

  // Рекурсивный случай: если объект — суммировать все дочерние узлы
  let total = 0;
  for (let key in node) {
    total += sumTree(node[key]); // рекурсивный вызов для каждой ветки
  }
  return total;
}

let tree = {
  value: 5,
  left: {
    value: 3,
    left: 1,
    right: 2
  },
  right: {
    value: 8,
    left: 4,
    right: {
      value: 7,
      left: 6,
      right: 9
    }
  }
};

console.log(sumTree(tree)); // 45

// Попробуйте написать это через цикл — это будет очень сложно!

🌳 Практический пример: обход вложенных папок

Представьте файловую систему: папки могут содержать другие папки. Рекурсия позволяет обойти всё дерево независимо от глубины вложенности:

const fileSystem = {
  name: "root",
  type: "folder",
  children: [
    { name: "document.txt", type: "file" },
    {
      name: "photos",
      type: "folder",
      children: [
        { name: "vacation.jpg", type: "file" },
        { name: "party.jpg", type: "file" },
        {
          name: "2024",
          type: "folder",
          children: [
            { name: "new_year.jpg", type: "file" }
          ]
        }
      ]
    },
    {
      name: "work",
      type: "folder",
      children: [
        { name: "report.pdf", type: "file" }
      ]
    }
  ]
};

function listFiles(node, indent = 0) {
  let prefix = "  ".repeat(indent);

  if (node.type === "file") {
    // Базовый случай: файл
    console.log(prefix + "📄 " + node.name);
    return;
  }

  // Рекурсивный случай: папка
  console.log(prefix + "📁 " + node.name);
  for (let child of node.children) {
    listFiles(child, indent + 1); // уровень вложенности увеличивается
  }
}

listFiles(fileSystem);
// 📁 root
//   📄 document.txt
//   📁 photos
//     📄 vacation.jpg
//     📄 party.jpg
//     📁 2024
//       📄 new_year.jpg
//   📁 work
//     📄 report.pdf

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

✅ Итоги урока

  • Рекурсия — функция, которая вызывает саму себя для решения подзадачи
  • Два обязательных условия: базовый случай (когда остановиться) и рекурсивный вызов с изменённым аргументом
  • Аргумент при рекурсивном вызове должен становиться ближе к базовому случаю
  • Stack Overflow — переполнение стека при бесконечной рекурсии (без базового случая)
  • Каждый вызов добавляет фрейм в стек вызовов; базовый случай «разматывает» стек обратно
  • Рекурсия лучше цикла для деревьев, графов и вложенных структур
  • Для простых линейных задач (факториал, сумма) — цикл эффективнее

Раздел 6 завершён! Вы освоили все ключевые аспекты функций в JavaScript — от базового объявления до рекурсии. В следующем разделе — объекты и массивы. 🚀

Тест: Рекурсия

7 вопросов

Факториал через рекурсию

Напишите рекурсивную функцию factorial, которая принимает целое неотрицательное число n и возвращает его факториал. Факториал 0 и 1 равен 1. Для n > 1: n! = n * (n-1)!.

Сумма массива через рекурсию

Напишите рекурсивную функцию sumArray, которая принимает массив чисел и возвращает их сумму, не используя циклы или методы массивов (кроме slice/pop). Для пустого массива верните 0. Подсказка: используйте arr[0] + sumArray(arr.slice(1)).