Раздел 16 — Рекурсия и «разделяй и властвуй»

Функция, которая вызывает себя, сортировки за O(n log n) и перебор с возвратом

Прогресс курса Раздел 16 из 28

Что вы освоите в этом разделе

4 академических часа теории и 2 часа практики: практическая работа №14 «Сортировки: реализация и замер времени» — на портале.
01

Рекурсия: базовый случай и шаг

Рекурсивная функция вызывает сама себя. Она решает задачу, сводя её к такой же задаче меньшего размера. У неё всегда две части:

/* n! с выводом входа и выхода: отступ = глубина рекурсии */
unsigned long long factorial(int n, int depth)
{
    printf("%*sfactorial(%d)\n", depth * 2, "", n);
    unsigned long long result = n <= 1 ? 1 : n * factorial(n - 1, depth + 1);
    printf("%*s= %llu\n", depth * 2, "", result);
    return result;
}
factorial(4)
  factorial(3)
    factorial(2)
      factorial(1)
      = 1
    = 2
  = 6
= 24

Вызов factorial(4) не может закончиться, пока не закончится factorial(3), и так до factorial(1). Ответы возвращаются в обратном порядке: 1, 2, 6, 24. (%*s печатает пустую строку шириной depth * 2 — отступ.)

Без базового случая или с шагом, который к нему не ведёт, рекурсия бесконечна: factorial(-1) с условием n == 1 вместо n <= 1 уходит в −2, −3, … пока не кончится стек.

02

Стек вызовов и его переполнение

Каждый вызов получает свой кадр в стеке: параметры, локальные переменные, адрес возврата (раздел 7). В примере выше одновременно жили четыре кадра factorial — у каждого своё n. Глубина рекурсии — сколько кадров живёт одновременно; память на рекурсию — глубина × размер кадра.

Стек мал: 8 МБ в Linux, 1 МБ в Windows. Программа examples/16/depth.c считает сумму 1…N рекурсией sum_to(n) = n + sum_to(n − 1); кадр у неё около сотни байтов:

$ ./depth 10000
sum 1..10000 = 50005000
$ ./depth 1000000
Segmentation fault                     (код 139; в Windows программа завершится без сообщения)
Рекурсия с глубиной n годится только для небольших n. Факториал, сумма, обход списка рекурсией на n = 10⁶ кладут программу. Если глубина растёт как n, пишут цикл. Рекурсия хороша там, где глубина — log₂ n (деление пополам: для миллиона это 20 уровней) или размер задачи мал по смыслу (перебор с возвратом).
03

Цена рекурсии: Фибоначчи и мемоизация

Числа Фибоначчи определены рекурсивно: F(0) = 0, F(1) = 1, F(n) = F(n − 1) + F(n − 2). Функция, переписанная прямо с определения, верна, но каждый вызов порождает два, и одни и те же значения считаются снова и снова: fib(5) вызывает fib(3) дважды, fib(2) — трижды.

long long fib(int n)
{
    calls++;
    return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
fib(10) = 55, calls 177
fib(20) = 6765, calls 21891
fib(30) = 832040, calls 2692537

Число вызовов растёт примерно в 1,6 раза на каждую единицу n — экспоненциально: fib(50) — уже около 40 миллиардов вызовов и минута работы, fib(60) — часы. Мемоизация — запоминать уже посчитанные значения в таблице и не считать их второй раз:

/* С мемоизацией: каждое значение считается один раз; 0 в таблице — «ещё не посчитано» */
long long fib_memo(int n)
{
    static long long memo[93];
    calls++;
    if (n < 2) {
        return n;
    }
    if (memo[n] == 0) {
        memo[n] = fib_memo(n - 1) + fib_memo(n - 2);
    }
    return memo[n];
}
fib_memo(90) = 2880067194370816120, calls 179

179 вызовов вместо астрономического числа: O(n). Таблица — static-массив из раздела 15, она обнулена и живёт между вызовами. Размер 93 — F(92) ещё помещается в long long, F(93) уже нет. А простой цикл с двумя переменными считает F(n) тоже за O(n) и без таблицы — рекурсия здесь лишь наглядна, но не нужна.

04

Деление пополам: поиск и возведение в степень

Рекурсия естественна, когда задача делится на половины. Двоичный поиск из раздела 8 рекурсией: искать в левой или правой половине — та же задача вдвое меньшего размера, глубина — log₂ n:

/* Двоичный поиск в отсортированном a[low..high]: индекс или -1 */
int binary_search(const int a[], int low, int high, int key)
{
    if (low > high) {
        return -1;
    }
    int mid = low + (high - low) / 2;
    if (a[mid] == key) {
        return mid;
    }
    return a[mid] < key ? binary_search(a, mid + 1, high, key) : binary_search(a, low, mid - 1, key);
}

Быстрое возведение в степень: xn = (xn/2)², при нечётном n — ещё умножить на x. Вместо n − 1 умножений — около 2 log₂ n: 313 — за 7 умножений вместо 12, а x1 000 000 — за 40 вместо миллиона.

long long power(long long x, int n)
{
    if (n == 0) {
        return 1;
    }
    long long half = power(x, n / 2);        /* половину считаем один раз */
    return n % 2 == 0 ? half * half : half * half * x;
}
/* 3^13 = 1594323, 2^62 = 4611686018427387904 */

Ключ — half вычисляется один раз. Запись power(x, n / 2) * power(x, n / 2) делает два одинаковых вызова на каждом уровне — та же ошибка, что у Фибоначчи, и снова n умножений.

05

Сортировка слиянием

«Разделяй и властвуй»: разделить задачу на части, решить каждую той же функцией, объединить решения. Сортировка слиянием делит массив пополам, сортирует половины рекурсивно и сливает две отсортированные половины в одну: смотрит на первые элементы обеих, меньший переносит в результат, и так до конца.

/* Слить отсортированные a[left..mid) и a[mid..right) */
void merge(int a[], int left, int mid, int right)
{
    int i = left, j = mid, k = left;
    while (i < mid && j < right) {
        buffer[k++] = a[i] <= a[j] ? a[i++] : a[j++];   /* <= — равные берутся слева: устойчиво */
    }
    while (i < mid) {
        buffer[k++] = a[i++];
    }
    while (j < right) {
        buffer[k++] = a[j++];
    }
    for (k = left; k < right; k++) {
        a[k] = buffer[k];
    }
}

/* Сортировка слиянием a[left..right) */
void merge_sort(int a[], int left, int right)
{
    if (right - left < 2) {         /* 0 или 1 элемент — уже упорядочено */
        return;
    }
    int mid = left + (right - left) / 2;
    merge_sort(a, left, mid);
    merge_sort(a, mid, right);
    merge(a, left, mid, right);
}
source     38 27 43 3 9 82 10
merge      3 9 10 27 38 43 82
06

Быстрая сортировка

Быстрая сортировка (Хоар, 1960) делит иначе: выбирает опорный элемент и разбивает массив так, чтобы слева оказались меньшие опорного, справа — не меньшие, а опорный встал на своё окончательное место. Потом рекурсивно сортирует обе части; сливать ничего не нужно.

/* Разбиение Ломуто: опорный — последний; слева от результата меньшие, справа — не меньшие */
int partition(int a[], int low, int high)
{
    int pivot = a[high];
    int i = low;
    for (int j = low; j < high; j++) {
        if (a[j] < pivot) {
            swap(&a[i], &a[j]);
            i++;
        }
    }
    swap(&a[i], &a[high]);
    return i;
}

void quick_sort(int a[], int low, int high)
{
    if (low >= high) {
        return;
    }
    int p = partition(a, low, high);
    quick_sort(a, low, p - 1);
    quick_sort(a, p + 1, high);
}
source     38 27 43 3 9 82 10
partition of b around 10 -> index 2
           3 9 10 38 27 82 43             (10 на своём месте; левая и правая части ещё не упорядочены)
quick      3 9 10 27 38 43 82
07

Замер времени

Оценка O(…) говорит, как растёт время, но не сколько секунд займёт работа. Проверяют замером: функция clock() из time.h возвращает процессорное время программы в «тиках», CLOCKS_PER_SEC тиков в секунде:

memcpy(work, source, n * sizeof work[0]);   /* каждая сортировка получает одни и те же данные */
clock_t start = clock();
sort(work, n);                             /* указатель на функцию сортировки, раздел 15 */
double seconds = (double)(clock() - start) / CLOCKS_PER_SEC;

Замер examples/16/bench.c на сервере курса (gcc -O2; быстрая — Хоар со средним опорным; вставками — до 10⁵):

n, случайныевставкамислияниембыстраяqsort
1 0000,0002 с0,0001 с0,0001 с0,0001 с
10 0000,017 с0,0009 с0,0007 с0,0010 с
100 0001,71 с0,010 с0,0077 с0,012 с
1 000 000≈ 3 мин (оценка)0,12 с0,090 с0,15 с
08

Перебор с возвратом

Когда ответ — выбор из многих вариантов (расстановка, маршрут, расписание), его строят по шагам: на каждом шаге пробуют все допустимые продолжения, для каждого рекурсивно строят остальное, а затем отменяют выбор и пробуют следующий. Это перебор с возвратом (backtracking). Все перестановки чисел 1…n:

void permutations(int pos, int n)
{
    if (pos == n) {                      /* перестановка готова — вывести */
        …
        return;
    }
    for (int v = 1; v <= n; v++) {
        if (!used[v]) {
            used[v] = true;
            perm[pos] = v;
            permutations(pos + 1, n);
            used[v] = false;            /* возврат: освободить число для следующих вариантов */
        }
    }
}
/* n = 3: 123 132 213 231 312 321 */

Ферзи: расставить n ферзей на доске n × n так, чтобы никакие два не били друг друга. В каждой строке ровно один ферзь; ставим по строкам, проверяя столбец и диагонали уже поставленных, а если в строке нет безопасной клетки — возвращаемся и двигаем предыдущего (examples/16/backtrack.c):

4 queens: 2 solutions, 60 positions tried (of 4^4)
6 queens: 4 solutions, 894 positions tried (of 6^6)
8 queens: 92 solutions, 15720 positions tried (of 8^8)
10 queens: 724 solutions, 348150 positions tried (of 10^10)

Ветвь отбрасывается, как только в ней появилось противоречие, и её продолжения не перебираются. Поэтому для 8 ферзей проверено 15 720 клеток, а не 88 ≈ 16,7 млн расстановок. Время перебора всё равно растёт экспоненциально, но отсечение делает его посильным для разумных n. Глубина рекурсии — число шагов (n), стек не переполняется.

Ключевые выводы раздела

Запомните главное

  • Рекурсия = базовый случай + шаг к нему; без них — бесконечные вызовы и переполнение стека
  • Глубина рекурсии ограничена стеком (1 МБ в Windows): глубина n — только для малых n, иначе цикл
  • Повторные вычисления одних и тех же значений убирает мемоизация — таблица результатов
  • Деление пополам даёт глубину log₂ n: двоичный поиск, быстрое возведение в степень
  • Слияние — O(n log n) всегда, устойчиво, нужен буфер размера n
  • Быстрая — O(n log n) в среднем и обычно быстрее всех, но O(n²) при плохом опорном; опорный — средний, медиана трёх или случайный; неустойчива
  • Время проверяют замером clock() на одинаковых данных разного вида; рост в 10 раз — 100 раз для O(n²) и ≈ 10 раз для O(n log n)
  • Перебор с возвратом: выбрать, рекурсивно продолжить, отменить выбор; отсекать ветви с противоречием
Связи раздела. Опирается на раздел 7 — стек вызовов; раздел 8 — сортировки O(n²), двоичный поиск, O-нотация; раздел 15 — static, указатели на функции, qsort. Нужен для: раздела 17 — рекурсивная обработка списка; раздела 19 — рекурсия на дереве поиска (ПЗ №18); раздела 24 — обход графа в глубину; раздела 28 — поиск пути в итоговом проекте.
Практическая работа №14 и тест по разделу 16 — на учебном портале. Задания по вариантам, критерии оценивания и сдача — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 15: Функции подробно Все разделы