Бинарный поиск и рекурсия
Вспомните игру «угадай число от 1 до 100». Первым ходом разумно назвать 50: ответ «больше» или «меньше» сразу отбрасывает половину чисел. Ещё шесть таких вопросов — и число угадано.
std::find так не умеет: он проверяет элементы по очереди, и в векторе из миллиона чисел ему может понадобиться миллион сравнений. В отсортированном векторе одно сравнение со средним элементом отбрасывает сразу половину. Так ищет std::binary_search: он сообщает, есть ли значение в отсортированном диапазоне.
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{2, 5, 8, 13, 21, 34};
std::cout << std::binary_search(v.begin(), v.end(), 13) << ' '
<< std::binary_search(v.begin(), v.end(), 7) << '\n'; // 1 0
}Половина за шаг
Бинарный поиск помнит границы части вектора, где значение ещё может быть. Сначала это весь вектор. На каждом шаге значение сравнивают со средним элементом части, и если они равны, значение найдено.
Если средний элемент меньше значения, то и все элементы левее него меньше, поэтому искать дальше нужно справа. Если больше — слева. Когда часть становится пустой, значения в векторе нет.
В коде границы — это два индекса. Шаги поиска по индексам:
- Начните с
lo = 0иhi = v.size(): часть — элементы с индексаloпоhi - 1. - Пока
lo < hi, возьмите средний индексmid = (lo + hi) / 2. - Если
v[mid]равен значению, оно найдено. - Если
v[mid]меньше значения, отбросьте левую половину вместе сmid:lo = mid + 1. - Если
v[mid]больше значения, отбросьте правую половину вместе сmid:hi = mid. - Когда
lo == hi, часть пустая: значения в векторе нет.
Средний элемент в шагах 4 и 5 уже проверен, поэтому он не попадает в новую часть. Иначе часть может перестать уменьшаться, и цикл не закончится.
Каждый шаг уменьшает часть вдвое, поэтому миллиону элементов хватает около 20 сравнений — это O(log n). Работает это только на отсортированном векторе. В неотсортированном отброшенная половина могла содержать искомое значение.
Рекурсия
Задачу часто можно свести к меньшей задаче того же вида. Рекурсия — вызов функции из её собственного тела. Она похожа на матрёшку: вы открываете куклу за куклой, пока не дойдёте до самой маленькой, а потом закрываете их в обратном порядке. Функция sum_to складывает числа от 1 до n: это n плюс сумма чисел до n - 1.
#include <iostream>
int sum_to(int n) {
if (n == 0) {
return 0;
}
return n + sum_to(n - 1);
}
int main() {
std::cout << sum_to(4) << '\n'; // 10
}Давайте разберём, что происходит:
sum_to(4)вызываетsum_to(3)и ждёт ответа. Тот вызываетsum_to(2), и так доsum_to(0)— это открытые матрёшки.- Базовый случай
n == 0— самая маленькая матрёшка: он возвращает0сразу, без нового вызова. - Затем вызовы завершаются в обратном порядке, и каждый прибавляет своё
nк полученному результату: 0 + 1 + 2 + 3 + 4 = 10.
Пройдите вызовы по шагам: следите, как рамки вызовов ложатся друг на друга и снимаются в обратном порядке.
1–2#include <iostream>3int sum_to(int n) {4 if (n == 0) {5 return 0;6 }7 return n + sum_to(n - 1);8}9 10int main() {11 std::cout << sum_to(3) << "\n";12}
main вызывает sum_to(3) и ждёт ответа, чтобы его напечатать
Здесь аналогия заканчивается: закрытая матрёшка больше ничего не делает, а вызов, получив ответ, ещё доделывает свою работу — прибавляет n.
Рекурсивной функции нужны две части: базовый случай и шаг, с которым каждый вызов становится ближе к базовому случаю. Здесь шаг — n - 1. Бинарный поиск тоже можно записать рекурсивно: базовый случай — пустая часть, а шаг — поиск в одной из половин.
Частая ошибка
Базовый случай забывают:
int sum_to(int n) {
return n + sum_to(n - 1); // ✗
}warning: all paths through this function will call itself [-Winfinite-recursion]Компилятор только предупреждает. Каждый вызов делает ещё один, и ни один из них не возвращает результат. Такая рекурсия — неопределённое поведение: стандарт не говорит, что случится, когда закончится память для вызовов.
Главное
- Бинарный поиск работает только на отсортированном векторе и делает O(log n) сравнений.
- Проверенный средний элемент не попадает в новую часть:
lo = mid + 1илиhi = mid. - Рекурсивная функция вызывает себя, и вызовы завершаются в обратном порядке.
- Без базового случая рекурсия не заканчивается.
Подпишись, чтобы пройти курс
Курс бесплатный. Войди через Telegram и подпишись на канал — откроются все уроки.