Тернарный поиск
Что делать, когда функция не монотонна, а сначала убывает и потом растёт. И почему обычный бинарный поиск справляется с этим не хуже.
6 мин
Бинарный поиск требует монотонности: слева от границы одно, справа другое. Но постоянно встречаются задачи, где функция сначала убывает, а потом растёт, — и нужен её минимум.
Такая функция называется унимодальной. Формально: существует точка , левее которой функция строго убывает, а правее строго возрастает.
flowchart LR
A["строго убывает"] --> B(("минимум x*"))
B --> C["строго возрастает"]
Условие «строго» существенно. Если у функции есть плоский участок — например, она равна константе на целом отрезке, — тернарный поиск ломается: он не может определить, в какую сторону двигаться.
Идея
Возьмём две точки внутри отрезка, , и сравним значения.
Если , то минимум лежит левее . Действительно: будь он правее, функция на участке от до ещё убывала бы, и было бы . Значит, правую границу можно двинуть в .
Симметрично при минимум правее .
Каждый шаг отрезает треть отрезка. За шагов длина умножается на — тоже логарифм, только основание не 2, а 1.5.
Вещественный вариант
double ternary(double lo, double hi, function<double(double)> f) {
for (int i = 0; i < 200; i++) {
double m1 = lo + (hi - lo) / 3;
double m2 = hi - (hi - lo) / 3;
if (f(m1) < f(m2)) hi = m2; else lo = m1;
}
return (lo + hi) / 2;
}
Число итераций считается так же, как в вещественном бинарном поиске, только основание другое: каждый шаг делит на , а не на . Чтобы сузить отрезок в раз, бинарному нужно шагов, тернарному — примерно . Двести с запасом хватает почти всегда.
Обратите внимание: фиксированное число итераций, а не while (hi - lo > eps). Причина ровно та же, что и в бинарном поиске: при большом ответе разность границ может никогда не стать меньше эпсилона, и цикл зависнет.
И ещё: f вызывается дважды на каждой итерации. Если проверка стоит , общая сложность — . Это заметно дороже бинарного поиска, и на пределе по времени разница видна.
Целочисленный вариант
С целыми числами тернарный поиск неудобен: деление на три даёт округления, и на коротком отрезке легко потерять ответ. Надёжнее свести к трём элементам и добить перебором.
long long ternaryInt(long long lo, long long hi, function<long long(long long)> f) {
while (hi - lo > 2) {
long long m1 = lo + (hi - lo) / 3;
long long m2 = hi - (hi - lo) / 3;
if (f(m1) <= f(m2)) hi = m2; else lo = m1;
}
long long best = lo;
for (long long x = lo; x <= hi; x++) if (f(x) < f(best)) best = x;
return best;
}
Но есть способ проще.
Бинарный поиск вместо тернарного
Целочисленный минимум унимодальной функции ищется обычным бинарным поиском — по признаку «функция ещё убывает».
Предикат: . Он ложен, пока функция убывает, и истинен с момента, где она начала расти. Ровно то, для чего бинарный поиск и предназначен.
long long argmin(long long lo, long long hi, function<long long(long long)> f) {
while (hi - lo > 1) {
long long m = lo + (hi - lo) / 2;
if (f(m) < f(m + 1)) hi = m; else lo = m + 1;
}
return f(lo) <= f(hi) ? lo : hi;
}
Это лучше во всём: одно вычисление функции на шаг вместо двух, основание логарифма 2 вместо 1.5, нет возни с округлением. По сути, это бинарный поиск по дискретной производной.
Тот же приём работает и для вещественных: если производную можно выписать явно, бинарный поиск по её знаку точнее и вдвое дешевле тернарного.
Поэтому на практике тернарный поиск нужен реже, чем кажется. Он оправдан там, где функция вещественная, производную выписывать лень, а вычисление дёшево.
Классическая задача: путь через две среды
Из точки надо попасть в точку . Прямая делит квадрат на две зоны: сверху поле со скоростью , снизу лес со скоростью .
Идти по прямой невыгодно: если по полю бежится втрое быстрее, разумно дать крюк, чтобы больше пути пройти по полю.
Пусть граница пересекается в точке с абсциссой . Тогда
Каждое слагаемое выпукло, сумма выпуклых выпукла, значит функция унимодальна — тернарный поиск применим. Границы: от до , потому что выходить за квадрат заведомо невыгодно.
Проверить унимодальность стоит до написания кода. Самая частая ошибка на таких задачах — навесить тернарный поиск на функцию с двумя локальными минимумами и получить не тот ответ на закрытых тестах.
Вторая классика: велогонка
Гонщики стартуют из точек и едут со скоростями . В момент каждый находится в . Нужен момент, когда расстояние между самым передним и самым задним минимально:
Здесь унимодальность видна из общего факта: максимум линейных функций выпуклый, минимум линейных вогнутый, а разность выпуклой и вогнутой выпукла. Значит, тернарный поиск применим, причём это доказано, а не «видно из картинки».
Проверка стоит — один проход по гонщикам. Итог: с константой 2 из-за двух вызовов на шаг.
Когда унимодальности нет
Если функция имеет несколько локальных минимумов, ни тернарный, ни бинарный поиск не помогут. Варианты:
- разбить область на участки, где унимодальность есть, и обработать каждый;
- пройтись грубой сеткой, найти окрестность лучшего значения и уже там запустить тернарный поиск;
- поискать другую формулировку задачи — часто «неунимодальная» функция становится унимодальной после замены переменной.
Проверять унимодальность удобно стрессом: сгенерировать случайные входные данные, посчитать функцию в тысяче точек и убедиться, что знак разности меняется ровно один раз.