Лекция. Рекурсия. Бинарный поиск
Лекция 4. Рекурсия. Бинарный поиск
Сначала разберём, как функции вызывают друг друга и самих себя. Затем увидим рекурсивное построение фракталов и перейдём к бинарному поиску.
План
- Синхронный вызов: сказка про репку.
- Рекуррентное задание функции, рекурсия и стек вызовов.
- Матрёшка: действия до и после рекурсивного вызова.
- Кривые Коха и дракона в ColabTurtle.
- Генерация двоичных последовательностей и перестановок.
- Корень делением пополам, бинарный поиск в массиве и поиск по ответу.
На семинаре 4 — бинарный поиск. Сортировки слиянием и Хоара разберём позднее.
Синхронный вызов: репка
Из тела функции можно вызвать другую функцию. Вызывающая сторона ждёт, пока вложенный вызов закончится, а затем продолжает работу со следующей команды. Такой вызов называется синхронным.
Пример из МФК: сначала прочитайте программу и предскажите порядок сообщений. Кто начнёт тянуть первым? Когда выполнится строка после grandmother()?
def grandfather():
print('Дедка зовёт бабку.')
grandmother()
print('Дедка за репку!')
def grandmother():
print('Бабка зовёт внучку.')
granddaughter()
print('Бабка за дедку.')
def granddaughter():
print('Внучка зовёт жучку.')
dog()
print('Внучка за бабку!')
def dog():
print('Жучка зовёт кошку.')
cat()
print('Жучка за внучку!')
def cat():
print('Кошка зовёт мышку.')
mouse()
print('Кошка за жучку!')
def mouse():
print('Мышка никого не зовёт. Мышка просто тянет за кошку.')
Все функции описаны, но ещё не вызваны. Запускаем сказку:
grandfather()
print("Ура! И вытянули репку!")
Вызовы идут от дедки к мышке, а возвраты — от мышки к дедке. Пока работает mouse(), предыдущие вызовы ещё не завершены.
Здесь нет рекурсии: каждая функция вызывает другую, и цепочка заканчивается. Но механизм ожидания и возврата уже тот, который нам понадобится.
Рекуррентное задание функции и рекурсия
Можно определить значение функции через её значение на меньшем аргументе:
$$F(0)=1, \qquad F(n)=n\cdot F(n-1),\quad n\geq 1.$$
Это рекуррентное задание факториала. Например, $F(3)=3\cdot F(2)=3\cdot2\cdot F(1)=3\cdot2\cdot1\cdot F(0)=6$.
Рекурсия в программе — вызов функцией самой себя. Рекуррентную формулу можно вычислить и рекурсией, и циклом: математическое определение не диктует единственную реализацию.
Чтобы рекурсивное вычисление завершилось, нужны:
- базовый случай, который решается без нового вызова;
- рекурсивный шаг, сводящий задачу к меньшей;
- уменьшение неотрицательного целого параметра, гарантирующее достижение базы.
Стек вызовов
У каждого вызова свои параметры и локальные переменные. Кроме них нужно помнить, где продолжить выполнение после возврата.
Представим вызов карточкой: «какая функция, с какими данными, где продолжить». Новый вызов кладёт карточку сверху; возврат снимает верхнюю карточку. Эта последовательность незавершённых вызовов называется стеком вызовов.
Для репки карточки разные по имени функции. При рекурсии имя повторяется, но карточки и значения параметров остаются отдельными. Реализацию стека как структуры данных рассмотрим позднее.
Матрёшка
Вместо разных персонажей — одна функция с параметром n: число матрёшек, включая внешнюю. Договор: n — целое число, n ≥ 1.
Открываем внешнюю матрёшку, разбираемся с вложенной, затем закрываем внешнюю. Самая маленькая не открывается.
def matryoshka(n):
"""Открыть и закрыть n вложенных матрёшек; n — целое, n >= 1."""
if n == 1:
print("Самая маленькая матрёшка: 1")
return
print("Открываем матрёшку:", n)
matryoshka(n - 1)
print("Закрываем матрёшку:", n)
matryoshka(3)
Предскажите результат перед запуском:
Открываем матрёшку: 3
Открываем матрёшку: 2
Самая маленькая матрёшка: 1
Закрываем матрёшку: 2
Закрываем матрёшку: 3
| Момент | Активные вызовы от внешнего к внутреннему |
|---|---|
| Открываем 3 | matryoshka(3) |
| Открываем 2 | matryoshka(3) → matryoshka(2) |
| Самая маленькая | matryoshka(3) → matryoshka(2) → matryoshka(1) |
| Закрываем 2 | matryoshka(3) → matryoshka(2) |
| Закрываем 3 | matryoshka(3) |
Вызов matryoshka(n - 1) не меняет n внешнего вызова. После возврата он продолжает работу со своим прежним значением.
Вопросы. Что будет, если убрать базовый случай? Если передавать n вместо n-1? Если перенести последнее сообщение перед вызовом?
Для допустимого n число вызовов равно n, максимальная глубина равна n. Python ограничивает глубину рекурсии: бесконечный самовызов закончится ошибкой RecursionError, а не полезным результатом.
Рекурсивное рисование в ColabTurtle
Теперь меньшая задача — нарисовать меньшую копию фигуры. На экране будут конечные приближения фракталов; параметр depth задаёт число уровней построения.
Подключение черепашки взято из МФК. Установочную ячейку достаточно выполнить при подготовке окружения. В Google Colab рисунок появляется прямо под ячейкой.
%pip install -q ColabTurtle
from ColabTurtle.Turtle import *
Кривая Коха: koch_curve
При глубине 0 рисуем отрезок длины length. На следующем уровне заменяем его четырьмя отрезками длины length / 3: первый прямо, второй под углом 60°, третий под углом −60°, четвёртый снова прямо.
В рекурсивном шаге каждый из этих отрезков заменяется меньшей кривой Коха. Четыре вызова выполняются по очереди: следующий начнётся только после возврата предыдущего.
Договор: length > 0, depth — целое неотрицательное. После выполнения направление черепашки такое же, как до вызова; конец находится на расстоянии length прямо от начала.
def koch_curve(length, depth):
if depth == 0:
forward(length)
return
koch_curve(length / 3, depth - 1)
left(60)
koch_curve(length / 3, depth - 1)
right(120)
koch_curve(length / 3, depth - 1)
left(60)
koch_curve(length / 3, depth - 1)
# Сначала depth = 0, затем 1 и 3. Перезапуск очищает рисунок.
initializeTurtle(initial_speed=13, initial_window_size=(800, 500))
penup()
goto(80, 350)
right(90) # после инициализации черепашка смотрит вверх
pendown()
koch_curve(630, 3)
Для depth=1 на доске четыре вызова с depth=0. Для depth=2 каждый из них заменяется ещё четырьмя.
Число нарисованных отрезков — $4^d$, а глубина стека функции koch_curve — всего $d+1$. Дерево всех вызовов и текущая цепочка активных вызовов — разные объекты.
Кривая дракона: dragon_curve
При глубине 0 снова рисуем отрезок. Иначе заменяем его двумя меньшими копиями с разной ориентацией. Параметр sign равен +1 или −1 и определяет зеркальный выбор поворотов.
Каждая половина имеет расстояние между концами length / √2. Повороты перед первой половиной, между половинами и после второй: sign · 45°, −sign · 90°, sign · 45°.
Договор такой же: после вызова направление восстановлено, конец находится на расстоянии length прямо от начала. Разбираем глубину 1 как два отрезка с прямым углом между ними, затем смотрим несколько уровней.
def dragon_curve(length, depth, sign=1):
"""length > 0; depth — целое >= 0; sign равен +1 или -1."""
if depth == 0:
forward(length)
return
left(sign * 45)
dragon_curve(length / 2**0.5, depth - 1, 1)
right(sign * 90)
dragon_curve(length / 2**0.5, depth - 1, -1)
left(sign * 45)
# Для первого разбора замените 8 на 1. sign=-1 отражает рисунок.
initializeTurtle(initial_speed=13, initial_window_size=(800, 500))
penup()
goto(240, 330)
right(90)
pendown()
dragon_curve(300, 8)
Здесь два рекурсивных вызова вместо четырёх: отрезков $2^d$, глубина стека функции dragon_curve — $d+1$.
Фракталы показывают, зачем одной функции вызывать себя несколько раз. Все ветви выполняются последовательно, каждая со своими параметрами.
Проверка понимания. Где база в каждом примере? Какой параметр уменьшается? Какая команда выполняется после первого рекурсивного вызова? Почему число отрезков растёт гораздо быстрее глубины?
Генерация двоичных последовательностей: gen_bin
Строим последовательность длины n (целое n ≥ 0). К готовому началу prefix приписываем сначала 0, затем 1. Печатаем только в базовом случае, когда длина достигла n.
def gen_bin(n, prefix=""):
if len(prefix) == n:
print(prefix)
return
gen_bin(n, prefix + "0")
gen_bin(n, prefix + "1")
gen_bin(3)
Генерация перестановок: gen_permutations
Переставляем числа от 1 до n (целое n ≥ 0). На очередное место пробуем каждое ещё не использованное число. Готовое начало храним в кортеже prefix; (number,) — кортеж из одного числа. Печатаем только готовую перестановку в базовом случае.
def gen_permutations(n, prefix=()):
if len(prefix) == n:
print(*prefix)
return
for number in range(1, n + 1):
if number not in prefix:
gen_permutations(n, prefix + (number,))
gen_permutations(3)
От рекурсии к бинарному поиску
Уменьшать задачу можно и в цикле. В бинарном поиске после проверки середины остаётся один диапазон, поэтому на семинаре будем поддерживать его границы итеративно.
Непрерывная функция и корень
Пусть $f$ задана на отрезке $[a, b]$ и непрерывна: график можно нарисовать не отрывая карандаш, без скачков.
Лемма о промежуточных значениях. Непрерывная $f$ принимает на $[a, b]$ все значения между $f(a)$ и $f(b)$. В частности, если $f(a)$ и $f(b)$ разных знаков, на отрезке есть корень: $f(x)=0$ для некоторого $x\in[a, b]$.
На доске: концы по разные стороны оси, график ось пересекает. Без непрерывности это неверно: функция может перепрыгнуть через ноль.
Лемма говорит, что корень есть. Как его найти с заданной точностью — другой вопрос.
Классический бинпоиск по вещественной оси
Границы, где функция точно больше нуля и точно меньше. Смотрим середину. По знаку $f$ в середине одну границу двигаем в эту точку: отрезок короче вдвое, знаки на концах по-прежнему разные, корень никуда не делся.
Стоп, когда длина отрезка меньше удвоенной допустимой ошибки $\varepsilon$. Тогда середина оставшегося отстоит от любого конца меньше чем на $\varepsilon$, и её можно выдать как ответ.
Это отрезок на $\mathbb{R}$ и порог $\varepsilon$, не полуинтервал из целых индексов. Полный цикл с float не пишем: идея и одна картинка. На семинаре 4 пишете целые $[l, r)$.
Пример: $f(x)=x^2-2$ на $[1, 2]$. $f(1)=-1<0$, $f(2)=2>0$, корень — $\sqrt{2}$. Один шаг:
def f(x):
return x * x - 2
left, right = 1.0, 2.0
print("знаки на концах:", f(left), f(right))
mid = (left + right) / 2
print("середина:", mid, " f(mid) =", f(mid))
if f(mid) > 0:
right = mid
else:
left = mid
print("отрезок после шага:", left, right)
print("длина:", right - left)
После шага корень всё ещё внутри, длина вдвое меньше. Повторять, пока длина больше $2\varepsilon$; ответ — середина того, что осталось.
Ту же идею «отрезать половину по знаку / сравнению» переносим на индексы массива. Там нет $\varepsilon$: кандидаты дискретные, и договор про границы другой.
Бинпоиск в массиве
Массив уже неубывает. Вопрос «есть ли ключ / где граница» не обязан смотреть все элементы: сравнение с серединой выбрасывает половину индексов.
Инвариант. Держим полуинтервал $[l, r)$: $l$ входит, $r$ не входит. Кандидаты — целые $l, l+1, \ldots, r-1$.
- на всех индексах строго левее $l$ предикат ложен;
- на всех индексах от $r$ до $n-1$ предикат истинен.
Непроверенные индексы находятся в $[l,r)$, а искомая граница — в $[l,r]$, включая правый конец. Ответ $n$ означает, что истинных значений нет.
Середина $m = (l + r)//2$ лежит в $[l, r)$. Одним сравнением отбрасываем одну сторону.
Ищем первый индекс, где предикат стал истинным (и дальше не становится ложным):
- если предикат на $m$ истинен — ответ не правее $m$, поэтому $r = m$;
- иначе $l = m + 1$.
Цикл, пока $l < r$. Когда остановились, $l = r$, и $l$ — первый истинный (если истинных не было, вернётся исходный правый конец).
Отрезок $[l, r]$ на этой неделе не используем. После каждой итерации полуинтервал строго короче. Если при ложном предикате оставить $m$ в диапазоне ($l = m$), при $r = l+1$ получится $m = l$, и цикл не сузится.
На каждом шаге сокращаем диапазон индексов вдвое. Основная запись — итеративная, не рекурсия: на семинаре 4 пишете while.
a = [1, 2, 4, 4, 7]
l, r = 0, len(a) # ищем первый индекс с a[i] >= 4
print("старт: полуинтервал [%d, %d)" % (l, r), "массив", a)
m = (l + r) // 2
print("m =", m, " a[m] =", a[m])
if a[m] >= 4:
r = m
else:
l = m + 1
print("после шага: [%d, %d)" % (l, r))
Дальше тем же правилом: $m = 1$, $a[1]=2$ ещё меньше $4$, поэтому $l = 2$. Полуинтервал $[2, 2)$ пуст — ответ $2$.
В худшем случае линейный просмотр требует $\Theta(n)$ сравнений, а бинарный поиск — $\Theta(\log n)$ при $n \ge 2$: каждый шаг длина диапазона падает вдвое. Память итеративной версии — несколько переменных.
lower_bound и upper_bound
Неубывающий массив — частный случай: $f(i) = a[i]$, шкала $[0, n)$.
lower_bound$(a, x)$ — первый индекс, где $a[i] \ge x$. Если все элементы меньше $x$, ответ $n$ (куда вставлять за концом).upper_bound$(a, x)$ — первый индекс, где $a[i] > x$.
«Есть ли ключ» — следствие: если lower_bound вернул $i < n$ и $a[i] = x$, ключ есть. Диапазон равных $x$ — полуинтервал $[\mathrm{lower}, \mathrm{upper})$.
индекс 0 1 2 3 4 5
значение 1 2 4 4 7
^ lower_bound(., 4) = 2
^ upper_bound(., 4) = 4
Равные четвёрки лежат в $[2, 4)$. Для $x=3$ оба конца те же $2$: ключа нет, вставка перед первой четвёркой. Для $x=8$ ответ $5$: все меньше, вставка за концом. Пустой массив, один элемент, все равны — те же два вопроса про первый $\ge$ и первый $>$.
bisect в стандартной библиотеке делает то же самое, но на зачёте нужен инвариант, не имя модуля.
Поиск по ответу
Ответ — число на монотонной шкале, не обязательно индекс массива: длина, порог, «минимальный максимум», целочисленный корень.
Предикат $P(t)$: «с этим $t$ уже можно». Он ложный на префиксе шкалы и истинный начиная с некоторого места (или наоборот — тогда ищем первое ложное, то же самое с отрицанием). Стоимость вычисления $P(t)$ зависит от задачи: бывает постоянной, бывает линейной по входу. Бинпоиск идёт по $t$.
Схема: придумать $P$ и границы $l,r$, для которых левее $l$ всё ложно, а начиная с $r$ всё истинно. Ответ может совпасть с $r$; сам индекс $l$ не обязан быть ложным. Дальше тот же цикл, что для массива.
Целый квадратный корень. Максимальное $x\ge 0$ с $x^2 \le n$. Предикат «$t^2 > n$» ложен, пока $t$ ещё не больше корня, и истинен после. Первый такой $t$ минус единица — ответ. Можно взять $[0,n+1)$: индекс $n+1$ заведомо удовлетворяет предикату и может быть ответом. При $n=0$ первый истинный равен 1, поэтому целый корень равен 0.
Скорость обработки кучек. Кучки размеров $a_i>0$ нужно обработать за $H$ часов, $H$ не меньше числа кучек. При целой скорости $v>0$ нужно $\sum_i \lceil a_i/v \rceil$ часов. Предикат «сумма не больше $H$» монотонен: большая скорость не увеличивает время. Одна проверка стоит $O(n)$, число проверок — $O(\log M)$, где $M=\max a_i$ (для $M\ge2$). Итого $O(n\log M)$.
Полный предикат здесь не пишем. На семинаре 4 предикат и цикл соберёте сами; в контесте акцент на монотонную функцию, не на «найти ключ в массиве».
К семинару 4
Бинарный поиск: собственный цикл, lower_bound / upper_bound, граничные случаи, поиск по ответу. Ноутбук служит для разбора; зачёт — задачи контеста на algo.ai.msu.ru.
Для самопроверки: проследите matryoshka(3) без запуска и объясните разницу между деревом вызовов и глубиной рекурсии.
Материалы
Пример с репкой и подключение ColabTurtle адаптированы из МФК, MSU2024/lec01_2024.ipynb. Матрёшка и функции фракталов добавлены для этой лекции. Блок бинарного поиска сохранён из предыдущей версии конспекта с уточнением инварианта границ.