Двоичный поиск мы знаем как способ найти число в отсортированном массиве: делим пополам, отбрасываем половину, повторяем. Оказывается, тот же приём работает там, где никакого массива нет вовсе — и это одно из самых неочевидных применений идеи деления пополам.
Задача, где ответ не вычисляется
На складе лежат партии товара. Сборщик работает h часов, за час берёт одну партию и собирает из неё не больше speed позиций; если в партии осталось меньше — час всё равно тратится целиком. Какая наименьшая скорость позволит успеть за h часов?
Формулы для ответа нет — скорость входит в задачу через округление вверх, и вывести её напрямую не получается. Зато проверить конкретную скорость легко: посчитать, сколько часов уйдёт, и сравнить с h. Это O(N) на проверку.
Перебирать все скорости подряд можно, но их бывает миллиард. Здесь и выручает главное наблюдение.
Монотонность — условие применимости
Если скорость 10 позволяет успеть, то 11 тем более. Если 5 не позволяет, то и 4 не позволит. То есть по мере роста скорости ответ на вопрос «успеваем?» меняется ровно один раз: сначала сплошные «нет», потом сплошные «да».
Это и есть монотонность. По сути перед нами отсортированный массив из «нет» и «да» — просто он не лежит в памяти, а вычисляется по требованию. А в отсортированном массиве границу между «нет» и «да» ищут двоичным поиском.
Ищем не в данных, а в диапазоне ответов 1…8. Каждая проверка отбрасывает половину скоростей, пока границы не сойдутся на первой подходящей.
Проверять монотонность нужно каждый раз: если условие «прыгает» — да, нет, да, — приём даст произвольный подходящий ответ, а не наименьший.
Решение
Ищем в диапазоне возможных ответов: от минимально мыслимой скорости до заведомо достаточной.
живой пример
import java.util.ArrayList;
import java.util.List;
public class MinSpeed {
public static void main(String[] args) {
int[] piles = {30, 11, 23, 4, 20};
int h = 6;
int top = 0;
for (int p : piles) top = Math.max(top, p);
int low = 1;
int high = top;
List<String> steps = new ArrayList<>();
while (low < high) {
int mid = low + (high - low) / 2;
long hours = 0;
for (int p : piles) hours += (p + mid - 1) / mid;
steps.add(mid + (hours <= h ? " да" : " нет"));
if (hours <= h) high = mid;
else low = mid + 1;
}
System.out.println("проверяли скорости: " + String.join(", ", steps));
System.out.println("ответ: " + low + " (проверок " + steps.size() + " из " + top + ")");
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import (
"fmt"
"strings"
)
func main() {
piles := []int{30, 11, 23, 4, 20}
h := 6
top := 0
for _, p := range piles {
top = max(top, p)
}
low, high := 1, top
var steps []string
for low < high {
mid := low + (high-low)/2
hours := 0
for _, p := range piles {
hours += (p + mid - 1) / mid
}
if hours <= h {
steps = append(steps, fmt.Sprintf("%d да", mid))
high = mid
} else {
steps = append(steps, fmt.Sprintf("%d нет", mid))
low = mid + 1
}
}
fmt.Println("проверяли скорости:", strings.Join(steps, ", "))
fmt.Printf("ответ: %d (проверок %d из %d)\n", low, len(steps), top)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
const piles = [30, 11, 23, 4, 20];
const h = 6;
const top = Math.max(...piles);
let low = 1;
let high = top;
const steps = [];
while (low < high) {
const mid = low + Math.floor((high - low) / 2);
let hours = 0;
for (const p of piles) hours += Math.ceil(p / mid);
steps.push(`${mid} ${hours <= h ? "да" : "нет"}`);
if (hours <= h) high = mid;
else low = mid + 1;
}
console.log("проверяли скорости:", steps.join(", "));
console.log(`ответ: ${low} (проверок ${steps.length} из ${top})`);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
piles = [30, 11, 23, 4, 20]
h = 6
top = max(piles)
low, high = 1, top
steps = []
while low < high:
mid = low + (high - low) // 2
hours = sum((p + mid - 1) // mid for p in piles)
steps.append(f"{mid} {'да' if hours <= h else 'нет'}")
if hours <= h:
high = mid
else:
low = mid + 1
print("проверяли скорости:", ", ".join(steps))
print(f"ответ: {low} (проверок {len(steps)} из {top})")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Сложность — O(N · log(ширина диапазона)): тридцать вариантов разошлись за пять проверок, миллиард уложился бы в тридцать.
Три места, где обычно ошибаются
Границы. Нижняя должна быть заведомо возможной, верхняя — заведомо достаточной. Здесь верхняя граница — самая большая партия: при такой скорости любая партия собирается за один час, быстрее уже некуда. Если верхнюю границу занизить, ответа в диапазоне не окажется.
Зацикливание. Инвариант цикла такой: high всегда указывает на подходящий вариант, low — на первый ещё не отвергнутый. Поэтому при успехе пишем high = mid (сам mid подходит, отбрасывать его нельзя), а при неудаче low = mid + 1 (mid точно не подходит). Если по невнимательности написать low = mid, цикл может застрять: при high - low == 1 середина совпадёт с low, и границы перестанут сближаться.
Переполнение и округление. low + (high - low) / 2 — не украшательство: (low + high) / 2 на больших границах выходит за пределы int. А (p + mid - 1) / mid — это деление с округлением вверх; обычное деление здесь дало бы заниженное число часов.
Зеркальная задача. Шаблон выше ищет «минимальный такой, что подходит». Для «максимального такого, что подходит» (наибольшая длина куска, чтобы кусков хватило; наибольшая скорость, при которой не перегреется) условие меняет сторону, и инвариант зеркальный: теперь low всегда подходит, high первый ещё не отвергнутый, при успехе пишут low = mid, при неудаче high = mid - 1. Ловушка ровно там же, где в прямом варианте, только наоборот: середину надо считать с округлением вверх, mid = low + (high - low + 1) / 2, иначе при high - low == 1 середина совпадёт с low, и low = mid ничего не сдвинет. Переворачивая задачу, переворачивают все три вещи разом: инвариант, присваивания и округление.
< или <=. В статье о массивах двоичный поиск написан с while (low <= high), здесь с while (low < high), и это не небрежность. Вариант с <= ищет конкретный элемент и должен уметь сказать «нет»: диапазон сжимается до пустого, границы перекрещиваются, и выход из цикла означает «не нашли». Вариант с < ищет границу, которая существует всегда, поэтому диапазон сжимается до одного элемента и он же ответ; пустым он стать не может. Смешивать формы нельзя: <= с high = mid зациклится, < с high = mid - 1 пропустит ответ.
Вещественные ответы. Если ответ не целый, скажем наибольшая длина верёвки с точностью до миллиметра, «границы сошлись» не наступает никогда: между любыми двумя дробями есть третья. Тогда либо делят диапазон фиксированное число раз (сто итераций дают точность 2⁻¹⁰⁰ от ширины, больше, чем у double), либо останавливаются, когда high - low меньше требуемой точности. Первое надёжнее: не зависит от ошибок округления.
Цена. Оценка O(проверка · log(диапазон)) говорит, что всё решает стоимость проверки: логарифм от миллиарда это тридцать, а проверка за O(N) на миллионе записей это тридцать миллионов шагов. Приём проигрывает там, где ответ выводится формулой напрямую, и там, где проверка дороже, чем прямой перебор небольшого диапазона; но когда проверка дешёвая, а диапазон огромный, тридцать проверок против миллиарда это и есть весь смысл.
Как узнать приём в задаче
Верные признаки:
- в условии есть слова «минимальный такой, что» или «максимальный такой, что»;
- ответ — число из диапазона, а не элемент данных;
- посчитать ответ напрямую нельзя, но проверить готовый ответ просто;
- если ответ подходит, то и любой больший (или любой меньший) тоже подходит.
Типичные задачи такой формы: минимальная вместимость машины, чтобы развезти всё за k рейсов; наибольшая длина, на которые можно нарезать верёвки, чтобы получить k кусков; минимальный день, к которому успевают все работы.
Как это сделано в стандартной библиотеке
Сам поиск по ответу в стандартной библиотеке не найти, и это ожидаемо: «массива» в такой задаче нет, а есть проверка, которую вы пишете под условие, — цикл с делением диапазона тут всегда свой. А вот поиск границы среди уже сохранённых данных реализован давно, и переписывать его руками смысла нет.
Для отсортированного массива это Arrays.binarySearch, для списка — Collections.binarySearch: внутри тот же цикл с делением диапазона пополам, только середину там считают беззнаковым сдвигом ((low + high) >>> 1) — приём против переполнения из той же семьи, что и low + (high - low) / 2 выше.
И отвечают они не только «есть или нет»: когда точного совпадения нет, возвращается -(позиция вставки) - 1, то есть та самая граница «первый подходящий», ради которой обычно и пишут свой поиск; подробности и главная их грабля разобраны в статье о массивах.
Ту же задачу «первый подходящий» решают и TreeSet с TreeMap, только не в массиве, а в дереве. Внутри у них красно-чёрное дерево: двоичное дерево поиска, которое при вставках подкручивает свою форму само, поэтому глубина остаётся порядка log N и не вырождается в список даже на данных, приходящих по возрастанию. Отсюда сложность: add, contains, get, remove — O(log N), а firstKey и lastKey — O(log N) на спуск к краю. И там же живут методы, которые отвечают ровно на вопрос «первый подходящий»: ceiling(x) отдаёт наименьший элемент не меньше x, floor(x) — наибольший не больше, higher и lower — то же самое строго, а headMap, tailMap и subMap возвращают целый диапазон ключей. Та же граница между «не подходит» и «подходит», только ищется она среди хранимых ключей, а не вычисляется проверкой:
живой пример
import java.util.List;
import java.util.TreeSet;
public class Boundary {
public static void main(String[] args) {
TreeSet<Integer> speeds = new TreeSet<>(List.of(4, 7, 11, 18, 25));
System.out.println("первая не меньше 12: " + speeds.ceiling(12));
System.out.println("последняя не больше 12: " + speeds.floor(12));
System.out.println("весь хвост от 12: " + speeds.tailSet(12));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Грабля у деревьев своя, и она тихая: порядок для них — это и есть определение равенства. TreeSet считает два элемента одинаковыми, когда сравнение вернуло ноль, и на equals при этом не смотрит вовсе. Если компаратор написан, скажем, только по цене, то второй товар с той же ценой в множество просто не попадёт — без исключения, без предупреждения, add вернёт false, а вы этого не проверяли. Сравнение в дереве должно различать ровно то же, что различает equals; иначе структура молча теряет данные.
Сам поиск по ответу в стандартной библиотеке Go есть, и это редкость среди языков: sort.Search(n, f) возвращает наименьший индекс i из [0, n), для которого f(i) истинно, и требует от f ровно той монотонности, о которой шла речь выше. Проверку вы пишете сами, а цикл с делением диапазона, инвариантом и защитой от переполнения написан за вас.
Для отсортированного среза это slices.BinarySearch, а для своего порядка slices.BinarySearchFunc: внутри тот же цикл с делением диапазона пополам, только середину там считают беззнаковым сдвигом, приём против переполнения из той же семьи, что и low + (high - low) / 2 выше. Отвечают они не только «есть или нет»: вторым значением идёт found, а первым — позиция, куда элемент надо вставить, то есть та самая граница «первый подходящий», ради которой обычно и пишут свой поиск; подробности разобраны в статье о массивах.
Упорядоченного множества с ceiling и floor, как TreeSet в Java, в стандартной библиотеке нет; ту же задачу на хранимых данных решает отсортированный срез: ceiling(x) это элемент по индексу из BinarySearch, floor(x) — элемент перед ним, а «весь хвост от x» — срез от этого индекса. Пока вставок мало, это быстрее любого дерева; когда данные постоянно меняются, берут пакет github.com/google/btree с AscendGreaterOrEqual, разобрано в статье про двоичные деревья. Та же граница между «не подходит» и «подходит», только ищется среди хранимых значений:
живой пример
package main
import (
"fmt"
"slices"
"sort"
)
func main() {
piles := []int{30, 11, 23, 4, 20}
h, top := 6, 30
speed := 1 + sort.Search(top, func(i int) bool {
hours := 0
for _, p := range piles {
hours += (p + i) / (i + 1)
}
return hours <= h
})
fmt.Println("минимальная скорость через sort.Search:", speed)
speeds := []int{4, 7, 11, 18, 25}
i, _ := slices.BinarySearch(speeds, 12)
fmt.Println("первая не меньше 12:", speeds[i])
fmt.Println("последняя не больше 12:", speeds[i-1])
fmt.Println("весь хвост от 12:", speeds[i:])
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
sort.Search ищет по индексам от нуля, поэтому скорость mid превращается в индекс mid - 1, отсюда (p + i) / (i + 1) и единица в ответе. Ответ тот же, что у цикла выше: 23.
Грабля у sort.Search своя, и она тихая: если f не монотонна, функция не упадёт и ничего не заметит, а вернёт какой-то индекс, на котором f случайно оказалась истинной. И вторая, из той же статьи о массивах: на неотсортированном срезе BinarySearch тоже молчит и возвращает мусор. Обе лечатся одинаково — монотонность проверки и порядок среза это ваша обязанность, библиотека их не проверяет.
Сам поиск по ответу в стандартной библиотеке не найти, и это ожидаемо: «массива» в такой задаче нет, а есть проверка, которую вы пишете под условие, — цикл с делением диапазона тут всегда свой. Но и поиска границы среди уже сохранённых данных в JavaScript тоже нет: ни двоичного поиска по массиву, ни упорядоченного множества с ceiling и floor. Функцию lowerBound пишут один раз и кладут в утилиты проекта.
Она и есть граница «первый подходящий»: возвращает индекс первого элемента, который не меньше искомого, или длину массива, если такого нет. Внутри тот же цикл с делением диапазона пополам; середину считают как (lo + hi) >>> 1, приём против переполнения из той же семьи, что и low + (high - low) / 2 выше, хотя в JavaScript до 2⁵³ переполнения нет. Через неё выражаются и ceiling(x) — элемент по найденному индексу, и floor(x) — элемент перед ним, и «весь хвост от x» — slice от индекса. Для данных, которые постоянно меняются, берут пакет вроде sorted-btree, разобрано в статье про двоичные деревья. Та же граница между «не подходит» и «подходит», только ищется среди хранимых значений:
живой пример
function lowerBound(a, key) {
let lo = 0;
let hi = a.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (a[mid] < key) lo = mid + 1;
else hi = mid;
}
return lo;
}
const speeds = [4, 7, 11, 18, 25];
const i = lowerBound(speeds, 12);
console.log("первая не меньше 12:", speeds[i]);
console.log("последняя не больше 12:", speeds[i - 1]);
console.log("весь хвост от 12:", speeds.slice(i));
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Та же функция с проверкой вместо сравнения превращается в поиск по ответу: lowerBound над диапазоном скоростей, где «меньше» значит «не успевает», и это ровно цикл из решения выше.
Грабля тут одна, и она тихая: indexOf и includes выглядят как поиск, но идут перебором и про порядок массива не знают; на отсортированном массиве в миллион элементов они в тысячи раз медленнее lowerBound, а результат тот же, поэтому подмену никто не замечает, пока не вырастут данные. И вторая, общая для всех языков: на неотсортированном массиве lowerBound не упадёт, а вернёт мусор — порядок это ваша обязанность.
Сам поиск по ответу в стандартной библиотеке есть, хоть и под другим именем: bisect_left с версии 3.10 принимает key=, и если скормить ему range возможных ответов с ключом-проверкой, он найдёт первый ответ, на котором проверка стала истинной. False меньше True, а проверка монотонна, значит, диапазон «ложь, ложь, …, истина, истина» отсортирован, и двоичный поиск по нему законен. Цикл с делением диапазона и инвариантом при этом написан за вас.
Для отсортированного списка это bisect_left и bisect_right: внутри тот же цикл с делением диапазона пополам, а переполнения индексов в Python не бывает, целые растут сколько нужно. Отвечают они не «есть или нет», а позицией, куда элемент надо вставить, то есть той самой границей «первый подходящий», ради которой обычно и пишут свой поиск; подробности разобраны в статье о массивах.
Упорядоченного множества с ceiling и floor, как TreeSet в Java, в стандартной библиотеке нет; ту же задачу на хранимых данных решает отсортированный список: ceiling(x) это элемент по индексу из bisect_left, floor(x) — элемент по индексу bisect_right(x) - 1, а «весь хвост от x» — срез от индекса. Когда данные постоянно меняются, берут SortedList из sortedcontainers, у которого те же bisect_left и irange работают за O(log N) вместе со вставкой; разобрано в статье про двоичные деревья. Та же граница между «не подходит» и «подходит», только ищется среди хранимых значений:
живой пример
from bisect import bisect_left, bisect_right
piles = [30, 11, 23, 4, 20]
h, top = 6, 30
def fits(speed: int) -> bool:
return sum((p + speed - 1) // speed for p in piles) <= h
speed = bisect_left(range(1, top + 1), True, key=fits) + 1
print("минимальная скорость через bisect:", speed)
speeds = [4, 7, 11, 18, 25]
i = bisect_left(speeds, 12)
print("первая не меньше 12:", speeds[i])
print("последняя не больше 12:", speeds[bisect_right(speeds, 12) - 1])
print("весь хвост от 12:", speeds[i:])
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
range начинается с единицы, а bisect_left возвращает индекс внутри него, отсюда + 1. Ответ тот же, что у цикла выше: 23.
Грабля тут одна, и она тихая: bisect не проверяет ни порядок списка, ни монотонность ключа. На неотсортированных данных или с немонотонной проверкой он не упадёт, а вернёт какую-то позицию, на которой сравнение случайно сошлось. Порядок и монотонность это ваша обязанность, библиотека их не проверяет. И мелочь: x in sorted_list идёт перебором и про порядок не знает; на миллионе элементов bisect с проверкой a[i] == x быстрее в тысячи раз.
Коротко
- Двоичный поиск по ответу ищет не в данных, а в диапазоне возможных ответов; данные нужны только для проверки.
- Условие применимости — монотонность: ответ на вопрос «подходит?» меняется по диапазону ровно один раз.
- Стоимость — O(проверка · log(ширина диапазона)); миллиард вариантов сводится к трём десяткам проверок.
- Границы: нижняя заведомо возможна, верхняя заведомо достаточна.
- При успехе
high = mid, при неудачеlow = mid + 1— иначе цикл может не сойтись. low + (high - low) / 2вместо(low + high) / 2спасает от переполнения; деление вверх пишется как(a + b - 1) / b.
- Готовую границу среди сохранённых ключей отдают
Arrays.binarySearchиceiling/floorуTreeSetиTreeMap; сравнение в дереве и есть его равенство.
- Поиск по ответу готов:
sort.Search(n, f)отдаёт первый индекс, гдеfистинна; границу среди хранимых значений даётslices.BinarySearchна отсортированном срезе; монотонность и порядок не проверяются.
- Ни поиска по ответу, ни двоичного поиска в языке нет: одна функция
lowerBoundзакрывает и границу в массиве, и поиск по ответу;indexOfидёт перебором.
bisect_leftсkey=поrangeответов и есть поиск по ответу; границу среди хранимых значений даютbisect_leftиbisect_right; порядок и монотонность не проверяются.
Что почитать дальше
- Массивы, двоичный поиск и O-нотация — откуда деление пополам и что возвращает библиотечный двоичный поиск.
- Красно-чёрные деревья — что крутится внутри упорядоченных множеств.
- Два указателя и скользящее окно — соседний приём, когда ответ лежит в самих данных.
- Как выбрать структуру данных — когда хватает массива, а когда нужно дерево.