Лекция 20: Рекурсия
"Не радуйся радости, радуйся радости."
— Джеральд Грун
Суть рекурсии
Рекурсия основана на принципе самоподобия: объект или процесс определяется через самого себя. В программировании это реализуется через функции, вызывающие себя напрямую (прямая рекурсия) или опосредованно (косвенная рекурсия).
Ключ к корректной рекурсии:
- Базовый случай: Условие остановки рекурсии. Необходим, чтобы избежать бесконечных вызовов, ведущих к ошибке переполнения стека.
- Рекурсивный шаг: Вызов функцией самой себя с измененными входными данными, которые постепенно приближают выполнение к базовому случаю.
Пример: Факториал числа
Математически: \(n! = n \times (n-1)!\) при \(n > 0\), \(0! = 1\). Программно:
int factorial(int n) {
if (n == 0) { // Базовый случай
return 1;
} else {
return n * factorial(n - 1); // Рекурсивный шаг
}
}
Как работает рекурсия: Стек вызовов
При каждом вызове функции (включая рекурсивный) система использует стек вызовов. В стеке сохраняются локальные переменные функции и адрес возврата. Рекурсия — это своего рода итерация, которая неявно использует стек для сохранения локального состояния каждого вызова. Это создает цепочку приостановленных выполнений. Стек работает по принципу LIFO (Last In – First Out), обрабатывая последним вызванную функцию.
Количество рекурсивных обращений ограничено размером области памяти, выделяемой под стек. Слишком большая "глубина" рекурсии может привести к ошибке переполнения стека (Stack Overflow).
Применение рекурсии в алгоритмах
Рекурсия — естественный инструмент для задач, где решение большой проблемы сводится к решению подобных меньших проблем. Она особенно удобна для:
- Задач, обладающих самоподобием: Когда структура задачи повторяется на разных уровнях (например, фракталы).
- Рекурсивных структур данных: Обход и манипуляции со структурами типа деревьев или связанных списков.
- Стратегий решения задач:
- "Разделяй и властвуй" (Divide and Conquer): Задача разбивается на подзадачи, которые решаются рекурсивно (например, быстрая сортировка, слияние).
- Backtracking (Поиск с возвратом): Метод перебора вариантов. Рекурсия удобна для исследования путей и возврата от тупиков (например, поиск выхода из лабиринта).
Примеры классических рекурсивных алгоритмов:
- Ханойские башни: Перемещение \(n\) дисков сводится к рекурсивному перемещению \(n-1\) дисков.
Реализация программы с рекурсивной функцией, решающей задачу о Ханойских дисках
#include <stdio.h>
/**
* @brief Рекурсивная функция для решения задачи о Ханойских башнях.
*
* Перемещает n дисков с одного стержня на другой с использованием вспомогательного.
*
* @param n Количество дисков для перемещения.
* @param from_rod Стержень, с которого перемещаем диски.
* @param to_rod Стержень, на который перемещаем диски.
* @param aux_rod Вспомогательный стержень.
*/
void towerOfHanoi(int n, char from_rod, char to_rod, char aux_rod) {
// Базовый случай: Если остался только один диск, просто перемещаем его
if (n == 1) {
printf("Переместить диск 1 со стержня %c на стержень %c\n", from_rod, to_rod);
return;
}
// Шаг рекурсии:
// 1. Переместить n-1 дисков с from_rod на aux_rod, используя to_rod как вспомогательный
towerOfHanoi(n - 1, from_rod, aux_rod, to_rod);
// 2. Переместить оставшийся самый большой диск с from_rod на to_rod
printf("Переместить диск %d со стержня %c на стержень %c\n", n, from_rod, to_rod);
// 3. Переместить n-1 дисков с aux_rod на to_rod, используя from_rod как вспомогательный
towerOfHanoi(n - 1, aux_rod, to_rod, from_rod);
}
int main() {
int n = 4; // Количество дисков для примера
printf("--- Рекурсивное решение задачи о Ханойских башнях (%d дисков) ---\n", n);
// Вызываем функцию для перемещения n дисков со стержня 'A' на стержень 'C', используя 'B'
towerOfHanoi(n, 'A', 'C', 'B');
printf("\n");
return 0;
}
- Числа Фибоначчи: \(F_n = F_{n-1} + F_{n-2}\).
Реализация программы с рекурсивной функцией для вычисления n-го числа Фибоначчи
#include <stdio.h>
/**
* @brief Рекурсивная функция для вычисления n-го числа Фибоначчи.
*
* Наивная реализация, демонстрирующая прямое следование математическому определению.
* Обратите внимание, что для больших n эта реализация неэффективна из-за повторных вычислений.
*
* @param n Порядковый номер числа Фибоначчи (начиная с 0).
* @return int n-е число Фибоначчи.
*/
int fibonacci(int n) {
// Базовые случаи: первые два числа в последовательности Фибоначчи
if (n <= 1) {
return n; // F(0) = 0, F(1) = 1
}
// Рекурсивный шаг: каждое последующее число является суммой двух предыдущих
return fibonacci(n - 1) + fibonacci(n - 2);
}
int main() {
int num = 10; // Найдем 10-е число Фибоначчи (индекс 10 в последовательности 0, 1, 1, 2...)
printf("--- Рекурсивное вычисление чисел Фибоначчи ---\n");
// Выводим число Фибоначчи для заданного номера
printf("Число Фибоначчи с индексом %d: %d\n", num, fibonacci(num));
printf("\n");
return 0;
}
- Двоичный поиск: Поиск элемента в отсортированном массиве путем рекурсивного деления пополам.
Реализация программы с рекурсивной функцией для выполнения двоичного поиска в отсортированном массиве
#include <stdio.h>
/**
* @brief Рекурсивная функция для выполнения двоичного поиска в отсортированном массиве.
*
* Эффективно ищет элемент, многократно деля интервал поиска пополам.
*
* @param arr Отсортированный массив целых чисел.
* @param low Нижняя граница текущего интервала поиска (индекс).
* @param high Верхняя граница текущего интервала поиска (индекс).
* @param target Значение искомого элемента.
* @return int Индекс элемента, если найден, иначе -1.
*/
int recursiveBinarySearch(int arr[], int low, int high, int target) {
// Базовый случай: интервал поиска пуст (нижняя граница превысила верхнюю), элемент не найден
if (high < low) {
return -1;
}
// Вычисляем индекс среднего элемента в текущем интервале
// Использование low + (high - low) / 2 предотвращает возможное переполнение для больших low/high
int mid = low + (high - low) / 2;
// Базовый случай: элемент найден в середине
if (arr[mid] == target) {
return mid; // Возвращаем индекс найденного элемента
}
// Шаг рекурсии: Если элемент меньше среднего, ищем в левой половине
if (arr[mid] > target) {
return recursiveBinarySearch(arr, low, mid - 1, target);
}
// Шаг рекурсии: Если элемент больше среднего, ищем в правой половине
return recursiveBinarySearch(arr, mid + 1, high, target);
}
int main() {
// Отсортированный массив, необходимый для двоичного поиска
int arr[] = {2, 5, 8, 12, 16, 23, 38, 56, 72, 91};
int n = sizeof(arr) / sizeof(arr[0]); // Определяем размер массива
int target1 = 23; // Элемент, который будем искать (присутствует)
int target2 = 100; // Элемент, который будем искать (отсутствует)
int result;
printf("--- Рекурсивный двоичный поиск ---\n");
// Пример поиска существующего элемента
result = recursiveBinarySearch(arr, 0, n - 1, target1);
if (result != -1) {
printf("Элемент %d найден по индексу %d.\n", target1, result);
} else {
printf("Элемент %d не найден в массиве.\n", target1);
}
// Пример поиска отсутствующего элемента
result = recursiveBinarySearch(arr, 0, n - 1, target2);
if (result != -1) {
printf("Элемент %d найден по индексу %d.\n", target2, result);
} else {
printf("Элемент %d не найден в массиве.\n", target2);
}
printf("\n");
return 0;
}
- Обход рекурсивных структур данных: Рекурсия идеально подходит для работы с древовидными структурами или связными списками.
Реализация программы с рекурсивной функцией для печати элементов массива
#include <stdio.h>
/**
* @brief Рекурсивная функция для печати элементов массива.
*
* Обходит массив рекурсивно и выводит каждый элемент.
*
* @param arr Массив целых чисел.
* @param size Размер массива.
* @param index Текущий индекс элемента для обработки (начинать с 0).
*/
void printArrayRecursive(int arr[], int size, int index)
{
// Базовый случай: если индекс достиг размера массива, останавливаемся
if (index == size)
{
return; // [cite: 234]
}
// Действие: печатаем текущий элемент
printf("%d ", arr[index]); // [cite: 235]
// Рекурсивный шаг: вызываем функцию для следующего элемента
printArrayRecursive(arr, size, index + 1); // [cite: 235]
}
int main()
{
int arr[] = {1, 2, 3, 4, 5};
int size = sizeof(arr) / sizeof(arr[0]);
printf("Массив:\n"); // [cite: 236]
// Вызываем рекурсивную функцию для печати массива, начиная с индекса 0
printArrayRecursive(arr, size, 0); // [cite: 236]
printf("\n");
return 0;
}
Пример: Сумма цифр числа
int func(int a) {
if (!a) // Базовый случай
return 0;
return (a % 10) + func(a / 10); // Рекурсивный шаг
}
Здесь задача сводится к сумме последней цифры и суммы цифр оставшейся части числа, пока число не станет нулем.
Анализ рекурсивных алгоритмов
Анализ рекурсивных алгоритмов сложнее, чем итеративных, и часто приводит к рекуррентным соотношениям. Оценка временной и объемной сложности может быть нетривиальной, особенно для многократной рекурсии. Объемная сложность прямо связана с максимальной глубиной рекурсии из-за использования стека.
Хвостовая рекурсия
Хвостовая рекурсия — это частный случай, когда рекурсивный вызов является последней операцией перед возвратом из функции. Оптимизирующие компиляторы могут преобразовывать хвостовую рекурсию в эффективный цикл, устраняя накладные расходы стека и предотвращая переполнение. Однако, не всякая рекурсия может быть автоматически оптимизирована таким образом.
Пример: Факториал с хвостовой рекурсией
int fac_times (int n, int acc) {
return (n==0) ? acc : fac_times(n - 1, acc * n); // Рекурсивный вызов - последняя операция
}
int factorial (int n) {
return fac_times (n, 1);
}
Структура рекурсивных функций
Рекурсивная функция может выполнять действия:
- До рекурсивного вызова ("на спуске"): Операции выполняются при входе в каждую "глубину" рекурсии.
rec() { Действия1 // перед вызовом себя if (Условие) rec(); }
- После рекурсивного вызова ("на возврате"): Операции выполняются после завершения рекурсивного вызова, при "подъеме" из рекурсии.
rec() { if (Условие) rec(); Действия2 // после вызова себя }
- До и после рекурсивного вызова: Комбинация двух предыдущих форм.
rec() { Действия1 // на спуске if (Условие) rec(); Действия2 // на возврате }
Выбор структуры зависит от задачи и требуемого порядка выполнения операций.
Рекурсия vs Итерация
Характеристика | Итерация | Рекурсия |
---|---|---|
Память стека | Минимальное использование | Потенциально большое (риск переполнения) |
Скорость | Обычно быстрее (меньше накладных расходов) | Часто медленнее (кроме хвостовой рекурсии) |
Читаемость/Компактность | Может быть громоздкой для самоподобных задач | Нагляднее и компактнее для самоподобных задач и рекурсивных структур |
Отладка | Часто проще | Может быть сложнее |
Рекурсия является мощным и элегантным инструментом, делающим код более понятным и легким для написания/доработки в случаях задач с внутренней рекурсивной структурой или при работе с рекурсивными данными. Однако, требует внимательного контроля за глубиной и может быть менее эффективной по времени и памяти по сравнению с итерацией, если не оптимизирована компилятором (как в случае хвостовой рекурсии).
We hear you cry
We hear you wail
I see that smile on your face