Що таке рекурсія?
Рекурсія - це функція, яка викликає саму себе, щоб розбити задачу на менші версії тієї ж задачі, поки не дійде до базового випадку, який можна вирішити напряму.
Теорія
TL;DR
- Уяви матрьошку: відкриваєш одну, всередині менша, і так далі, поки не дійдеш до найменшої, всередині якої вже нічого немає.
- Кожна рекурсивна функція потребує двох речей: базового випадку (умова зупинки) і рекурсивного виклику (виклик себе з меншим аргументом).
- Рекурсія добре підходить для дерев, графів і вкладених структур. Для плоских даних використовуй цикл, щоб не переповнити стек.
- Node.js і браузери дозволяють приблизно 10,000 фреймів у стеку викликів, після чого кидають
RangeError: Maximum call stack size exceeded.
Швидкий приклад
function factorial(n) {
if (n <= 1) return 1; // базовий випадок: зупинка
return n * factorial(n - 1); // рекурсивний виклик: менша задача
}
console.log(factorial(5)); // 120
// Стек: factorial(5) -> 5 * factorial(4) -> ... -> 5 * 4 * 3 * 2 * 1Кожен виклик додає фрейм до стека викликів. Коли factorial(1) повертає 1, стек розкручується назад, і кожен фрейм множить своє n на результат попереднього.
Як це працює в стеку викликів
V8 (Chrome/Node.js) кладе кожен рекурсивний виклик у стек як новий контекст виконання зі своїми локальними змінними. Фрейми чекають там, поки не спрацює базовий випадок. Потім значення розкручуються назад через кожен фрейм у зворотному порядку.
Стандартна глибина стека в Node.js становить близько 10,000 фреймів. Перевищиш і отримаєш RangeError: Maximum call stack size exceeded. Саме тому factorial(100000) завалить Node.js, і саме тому рекурсія для великих плоских масивів є поганим вибором.
Коли використовувати
- Обхід дерева або графу (DOM, файлова система, дерево компонентів): структура вкладена, тому рекурсія відображає її напряму.
- Парсинг вкладених даних (JSON, HTML): кожен вузол може мати дочірні вузли тієї ж форми.
- Алгоритми "розділяй і пануй" (quicksort, merge sort): задача ділиться навпіл з кожним викликом.
- Великі плоскі масиви: використовуй цикл. Немає вкладеності, немає переваги, є тільки ризик переповнення стека.
- Fibonacci без оптимізації: підходить для демонстрацій, але складність O(2^n). Перед деплоєм додай мемоізацію.
Типові помилки
Немає базового випадку:
function bad(n) {
return n * bad(n - 1); // немає умови зупинки
}
// RangeError: Maximum call stack size exceededРішення: додати if (n <= 1) return 1; перед рекурсивним викликом.
Аргумент не зменшується:
function factorial(n) {
if (n === 0) return 1;
return n * factorial(n); // n не змінюється, нескінченний цикл
}Передати n замість n - 1 - одна з найпоширеніших помилок у код-рев'ю. Функція виглядає нормально на перший погляд. Node.js повідомить про проблему тільки коли стек вичерпається.
Рішення: передавати n - 1. Кожен виклик повинен наближати до базового випадку.
Ігнорування глибини стека:
factorial(100000) завалить Node.js. Якщо вхідні дані можуть бути великими, використовуй ітеративний підхід. Можна збільшити стек через node --stack-size=65500, але це обхідне рішення, не виправлення.
Де зустрічається
- React Fiber обходить дерево компонентів рекурсивно під час рендерингу (reconciler йде вглибину).
- Lodash
_.flattenDeepрекурсивно обходить вкладені масиви для повного вирівнювання. - Обхід директорій у Node.js використовує рекурсивні патерни для обходу файлової ієрархії.
- D3.js рекурсивно обходить ієрархії вузлів для розрахунку позицій у деревовидних візуалізаціях.
Питання на співбесіді
Q: Напиши factorial ітеративно.
A: function factIter(n, acc = 1) { if (n <= 1) return acc; return factIter(n - 1, acc * n); }. Формально це хвостова рекурсія, але V8 її не оптимізує, тому стек все одно зростає.
Q: Яка часова складність наївної рекурсії для Fibonacci?
A: O(2^n). Кожен виклик розгалужується на два, тому загальна кількість викликів подвоюється на кожному рівні. Виправляється мемоізацією або ітеративним підходом знизу вгору.
Q: Чи підтримує JavaScript оптимізацію хвостових викликів (TCO)?
A: Ні. V8 не виконує TCO на практиці. Навіть правильно написаний хвостовий виклик все одно додає фрейм у стек. Для глибокої рекурсії використовуй цикл.
Q: Що відбувається з рекурсивним стеком, якщо користувач переключає вкладку браузера під час виконання?
A: JavaScript однопотоковий. Браузер призупиняє виконання, коли вкладка втрачає фокус, і відновлює при поверненні. Важка рекурсія, що блокує головний потік, заморожує UI до повного розкручування стека. Для затратних операцій використовуй setTimeout або Web Workers.
Приклади
Базовий: сума всіх чисел у масиві
function sumArray(arr, index = 0) {
if (index >= arr.length) return 0; // базовий випадок: вийшли за межі
return arr[index] + sumArray(arr, index + 1); // додаємо поточний, рекурсія на решту
}
console.log(sumArray([1, 2, 3, 4])); // 10
// Розкручується як: 1 + (2 + (3 + (4 + 0)))Базовий випадок спрацьовує, коли index досягає довжини масиву. Далі кожен фрейм повертає свій елемент плюс суму всього, що правіше.
Середній: рендер вкладеного дерева компонентів
Спрощена версія того, що React робить при обході дерева компонентів:
function renderTree(node) {
if (!node.children || node.children.length === 0) {
return `<div>${node.text}</div>`; // базовий випадок: листовий вузол
}
let html = `<div>${node.text}`;
node.children.forEach(child => {
html += renderTree(child); // рекурсія для кожного дочірнього вузла
});
return html + '</div>';
}
const tree = {
text: 'App',
children: [
{ text: 'Header', children: [] },
{ text: 'Main', children: [{ text: 'Article', children: [] }] }
]
};
console.log(renderTree(tree));
// <div>App<div>Header</div><div>Main<div>Article</div></div></div>Цикл не може чисто обробити довільну глибину вкладеності. Рекурсія справляється з цим у кілька рядків, бо функції не потрібно знати заздалегідь, наскільки глибоке дерево.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.