← назад к разделу

Двоичный поиск мы знаем как способ найти число в отсортированном массиве: делим пополам, отбрасываем половину, повторяем. Оказывается, тот же приём работает там, где никакого массива нет вовсе — и это одно из самых неочевидных применений идеи деления пополам.

Задача, где ответ не вычисляется

На складе лежат партии товара. Сборщик работает h часов, за час берёт одну партию и собирает из неё не больше speed позиций; если в партии осталось меньше — час всё равно тратится целиком. Какая наименьшая скорость позволит успеть за h часов?

Формулы для ответа нет — скорость входит в задачу через округление вверх, и вывести её напрямую не получается. Зато проверить конкретную скорость легко: посчитать, сколько часов уйдёт, и сравнить с h. Это O(N) на проверку.

Перебирать все скорости подряд можно, но их бывает миллиард. Здесь и выручает главное наблюдение.

Монотонность — условие применимости

Если скорость 10 позволяет успеть, то 11 тем более. Если 5 не позволяет, то и 4 не позволит. То есть по мере роста скорости ответ на вопрос «успеваем?» меняется ровно один раз: сначала сплошные «нет», потом сплошные «да».

Это и есть монотонность. По сути перед нами отсортированный массив из «нет» и «да» — просто он не лежит в памяти, а вычисляется по требованию. А в отсортированном массиве границу между «нет» и «да» ищут двоичным поиском.

low=1, high=8 → mid=4: успеваем → high=4 low=1, high=4 → mid=2: не успеваем → low=3 low=3, high=4 → mid=3: не успеваем → low=4 low = high = 4 — наименьшая подходящая 1нет 2нет 3нет 4да 5да 6да 7да 8да mid mid mid 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; порядок и монотонность не проверяются.
- «Максимальный такой, что» зеркален: `low` подходит, `low = mid`, `high = mid - 1` и середина с округлением вверх; `<=` ищет элемент и умеет сказать «нет», `<` ищет границу, которая есть всегда. - По вещественным ответам делят фиксированное число раз (сто итераций); стоимость приёма это стоимость проверки, умноженная на тридцать.

Что почитать дальше