🔁 Рекурсия: Принципи и Дълбочина
Рекурсията е фундаментална концепция в програмирането, при която една функция извиква сама себе си, за да реши по-малка инстанция на същия проблем. Тя е в основата на алгоритми за обхождане на графи (DFS), разделяй и владей (Divide and Conquer), динамичното оптимиране и др.
1. Анатомия на Рекурсията
За да работи коректно, всяка рекурсивна функция трябва да съдържа два задължителни компонента:
-
Базов случай (Base Case / Дъно на рекурсията):
- Това е най-простият случай на задачата, който може да се реши директно, без допълнителни извиквания.
- Служи за прекратяване на рекурсията.
- Липсата му води до безкраен цикъл и грешка
Stack Overflow.
-
Рекурсивна стъпка (Recursive Step):
- Логиката, която разделя задачата на по-малки подзадачи.
- Извиква функцията със стойности, които я доближават към базовия случай.
Пример: Факториел
\(n! = n \times (n-1)!\), с базово условие \(0! = 1\).
long long factorial(int n) {
// 1. Базов случай
if (n == 0) return 1;
// 2. Рекурсивна стъпка
return n * factorial(n - 1);
}
2. Как работи "под капака"? (Call Stack)
Когато извикате функция, компютърът заделя блок памет в стека (Call Stack), наречен "Stack Frame". Той пази: * Стойностите на локалните променливи. * Параметрите на функцията. * Адреса за връщане (къде да продължи програмата след приключване).
При рекурсия тези рамки се натрупват една върху друга.
Визуализация на factorial(3):
mainвикаfactorial(3). В стека влиза рамка за \(n=3\).factorial(3)викаfactorial(2). Нова рамка отгоре за \(n=2\).factorial(2)викаfactorial(1). Нова рамка за \(n=1\).factorial(1)викаfactorial(0). Нова рамка за \(n=0\).factorial(0)удря базовия случай и връща1. Рамката за \(n=0\) се унищожава.factorial(1)получава1, пресмята \(1 \times 1 = 1\) и връща1. Рамката се унищожава.factorial(2)получава1, пресмята \(2 \times 1 = 2\) и връща2.factorial(3)получава2, пресмята \(3 \times 2 = 6\) и връща6. Стекът е празен.
Лимит на стека: В състезанията стекът обикновено е ограничен (напр. 256MB), което позволява около \(10^5\) до \(10^6\) вложени извиквания (в зависимост от броя променливи във функцията).
3. Примери за Рекурсия
3.1. Числа на Фибоначи (Класически пример и предупреждение)
\(F_n = F_{n-1} + F_{n-2}\), \(F_0=0, F_1=1\).
Проблем: Тази имплементация е с експоненциална сложност \(O(2^n)\), защото преизчислява едни и същи стойности многократно. За \(N=50\) ще работи милиони години. Решение: Мемоизация (съхраняване на резултатите) или итерация. Това е връзката с Динамичното програмиране.3.2. Най-голям общ делител (Евклид)
Алгоритъмът на Евклид е естествено рекурсивен. \(\\gcd(a, b) = \\gcd(b, a \\pmod b)\), базово: \(\\gcd(a, 0) = a\).
Това е пример за Опашкова рекурсия (Tail Recursion) – рекурсивното извикване е последното действие във функцията. Компилаторите могат лесно да го оптимизират до цикъл, спестявайки памет.3.3. Отпечатване на цифрите на число
Ако искаме да отпечатаме цифрите на числото 12345 отзад напред, е лесно с цикъл (n % 10). Но в правилния ред?
void printDigits(int n) {
if (n < 10) {
cout << n << " ";
return;
}
printDigits(n / 10); // Първо отпечатай предните
cout << n % 10 << " "; // После текущата
}
3.4. Бързо степенуване (Fast Modular Exponentiation)
Изчисляване на \(a^b \pmod m\) за \(O(\\log b)\). Идея: * Ако \(b\) е четно: \(a^b = (a^{b/2})^2\) * Ако \(b\) е нечетно: \(a^b = a \cdot a^{b-1}\)
long long power(long long a, long long b, long long m) {
if (b == 0) return 1;
long long temp = power(a, b / 2, m);
long long res = (temp * temp) % m;
if (b % 2 == 1) res = (res * a) % m;
return res;
}
4. Често срещани грешки
- Липсващ базов случай: Програмата "забива" и гърми със "Segmentation fault" (поради Stack Overflow).
- Грешен базов случай: Например при факториел, ако пропуснете
if (n == 0), ще продължи къмfactorial(-1). - Локални vs Глобални променливи:
- Ако дефинирате масив
int arr[100000]вътре в рекурсивна функция, стекът ще се препълни почти веднага. Големите структури трябва да са глобални или подавани чрез референция (vector<int>& v).
- Ако дефинирате масив
- Прекалено дълбока рекурсия: Ако \(N=10^7\), рекурсията не е удачна. Използвайте цикъл.
5. Задачи за упражнение
- Напишете рекурсивна функция за проверка дали низ е палиндром.
- Напишете рекурсивна функция за двоично търсене.
- Ханойски кули (класическа задача).
- Flood Fill (запълване на област в матрица).
Рекурсията изисква практика, за да се "вижда" решението. Започнете с малки примери и рисувайте дървото на извикванията на хартия.
6. Рекурсия vs Итерация
Всяка рекурсивна функция може да се преобразува в итеративна (чрез използване на стек). Понякога итерацията е по-бърза и използва по-малко памет.
Примери за преобразуване
Рекурсивна Факториел:
Итеративна Факториел:
long long factorial(int n) {
long long result = 1;
for (int i = 2; i <= n; i++) {
result *= i;
}
return result;
}
Рекурсивна Фибоначи с мемоизация:
long long memo[100];
long long fib(int n) {
if (n <= 1) return n;
if (memo[n] != -1) return memo[n];
return memo[n] = fib(n-1) + fib(n-2);
}
Итеративна Фибоначи:
long long fib(int n) {
if (n <= 1) return n;
long long prev2 = 0, prev1 = 1;
for (int i = 2; i <= n; i++) {
long long current = prev1 + prev2;
prev2 = prev1;
prev1 = current;
}
return prev1;
}
Кога да избираме итерация?
- Когато дълбочината на рекурсията е голяма (\(> 10^5\)).
- Когато производителността е критична (итерацията избягва overhead-а от извикване на функции).
- Когато решението с цикъл е по-интуитивно.
Кога да избираме рекурсия?
- Задачи с древовидна структура (обхождане на дървета, графи).
- Алгоритми "Разделяй и владей" (Merge Sort, Quick Sort, Binary Search на дърво).
- Когато проблемът по природа е рекурсивен (Ханойски кули, комбинаторика).
7. Backtracking (Връщане назад)
Backtracking е специална форма на рекурсия, където изпробваме всички възможни решения, и когато стигнем до задънена улица, "се връщаме назад" и пробваме друг път.
Примери:
- N-Queens проблем: Поставете \(N\) царици на шахматна дъска \(N \times N\) така, че да не се атакуват взаимно.
- Судоку решаване.
- Генериране на всички подмножества.
Шаблон:
void backtrack(int position, vector<int>& current) {
// Базов случай: намерихме валидно решение
if (isComplete(current)) {
printSolution(current);
return;
}
// Пробваме всяка възможна стъпка
for (int choice : getPossibleChoices(position)) {
if (isValid(choice, current)) {
current.push_back(choice); // Избираме
backtrack(position + 1, current); // Рекурсия
current.pop_back(); // Връщаме се назад (undoing)
}
}
}
8. Допълнителни примери
8.1. Сума на цифрите
8.2. Обръщане на списък (Linked List)
struct Node {
int val;
Node* next;
};
Node* reverse(Node* head) {
if (!head || !head->next) return head;
Node* newHead = reverse(head->next);
head->next->next = head;
head->next = nullptr;
return newHead;
}
🏁 Заключение
Рекурсията е мощен инструмент, но изисква внимателна употреба. Ключовите принципи са: * Винаги дефинирайте ясен базов случай. * Уверете се, че всяко рекурсивно извикване приближава към базовия случай. * Внимавайте с дълбочината на рекурсията и използвайте мемоизация, където е възможно.
Практикувайте с различни задачи, за да развиете интуиция кога рекурсията е най-подходящият подход!