EduBrick
классная работа

Бинарный поиск по ответу

Программирование на Python: от нуля до олимпиад

0/18решено 0 из 18до зачёта осталось 10

Чему научитесь

  • Превращать вопрос «найдите наименьшее» в проверку «годится ли»
  • Проверять монотонность прежде, чем применять приём
  • Различать поиск максимума и минимума и брать границы с запасом
  • Искать вещественный ответ фиксированным числом шагов

Как устроено занятие

Сначала разбор с примерами и короткими проверками понимания — они нужны, чтобы поймать непонятое сразу, а не через три темы.

Дальше 15 задач лестницей: разминка, основа, со звёздочкой. Занятие засчитывается, когда решено 10 — остальные не пропадают и учитываются отдельно.

После занятия — вторая часть, ещё 15 задач на те же приёмы в новых сюжетах.

Сколько это займёт

Примерно час-полтора вместе с задачами. Сроков нет: можно закрыть вкладку и вернуться когда удобно — прогресс сохранится.

теория

Когда массива нет

Есть nn верёвок, каждую можно резать на куски целой длины. Нужно получить kk кусков одинаковой длины — какой наибольшей?

Здесь нечего искать: списка ответов не существует. Зато есть кое-что не хуже — проверка. Для конкретной длины LL легко сказать, годится ли она:

def enough(L):
    return sum(a_i // L for a_i in a) >= k

Один проход, O(n)O(n) действий. Перебрать все длины от 1 до 10910^9 мы не можем — это миллиард проходов. Но посмотрим, как выглядят ответы проверки, если выписать их подряд:

L:        1    2    3    4    5    6    7    8
enough:  да   да   да   да   нет  нет  нет  нет

Это тот же упорядоченный массив, что и на прошлом занятии. Только он виртуальный: мы его не храним, а вычисляем любой элемент по требованию за O(n)O(n). И искать в нём границу между «да» и «нет» можно ровно тем же делением пополам.

Получается O(nlogC)O(n \log C), где CC — размах возможных ответов. Для n=2105n = 2 \cdot 10^5 и C=109C = 10^9 это примерно шесть миллионов действий вместо двухсот триллионов.

теория

Монотонный предикат

Приём работает не всегда, и условие применимости одно.

Проверка должна быть монотонной. То есть массив её ответов должен выглядеть как да да да нет нет нет или как нет нет нет да да да, но не как да нет да нет.

Проверить это можно вопросом к себе: «если ответ xx годится, годится ли x1x - 1 (или x+1x + 1 — смотря в какую сторону). Если да — приём применим. Если ответ «когда как» — нет.

Где монотонность есть

задача проверка почему монотонна
наибольшая длина куска кусков хватает чем короче кусок, тем их больше
наименьшая скорость успеваем за hh часов чем быстрее, тем меньше времени
наименьшая грузоподъёмность хватает dd дней чем больше берём, тем меньше дней

Где её нет

Вопрос «существует ли кусок с суммой ровно xx» немонотонен: сумма 10 может быть достижима, 11 — нет, 12 — снова да. Делить отрезок пополам тут бессмысленно: ответ «нет» в середине ничего не говорит о половинах.

Немонотонна и такая формулировка: «при каком xx значение f(x)f(x) наибольшее», если ff сначала растёт, потом убывает. Это другая задача — и другой приём.

Так что первым делом формулируйте проверку и убеждайтесь в её монотонности. Всё остальное — техника.

теория

Схема решения

Решение почти всегда собирается из одних и тех же четырёх шагов.

1. Переформулировать вопрос. Было: «найдите наименьшее xx, при котором…». Стало: «умею ли я для данного xx ответить да или нет?».

2. Написать проверку. Обычно это простой проход за O(n)O(n) — посчитать сумму, набрать жадно, сложить количества.

3. Убедиться в монотонности. Вслух, одним предложением.

4. Выбрать границы и поискать. Границы берутся заведомо верными, с запасом.

left = 0                 # заведомо "да"
right = 10 ** 9 + 1      # заведомо "нет"

while right - left > 1:
    mid = (left + right) // 2
    if enough(mid):
        left = mid
    else:
        right = mid

print(left)              # наибольшее подходящее

Это тот же инвариант, что и на прошлом занятии: left — заведомо годится, right — заведомо нет. Разница только в том, что вместо a[mid] < x стоит вызов проверки.

Обратите внимание: границы должны быть верны без проверки. Если начальный left на самом деле не годится, программа честно вернёт его — и ответ будет неправильным. Поэтому в задачах часто отдельно смотрят, есть ли решение вообще: например, если даже при длине 1 кусков не хватает, ответ 0.

тест

Проверка: где приём применим

Какой из вопросов можно решать бинарным поиском по ответу?

Войдите, чтобы ответить.
теория

Минимум и максимум по ответу

Задачи бывают двух зеркальных видов, и путать их — самый лёгкий способ получить неверный ответ.

Ищем наибольшее подходящее

Проверка выглядит как да да да нет нет. Границы: left — заведомо да, right — заведомо нет. Ответ — left.

if enough(mid):
    left = mid
else:
    right = mid
print(left)

Ищем наименьшее подходящее

Проверка выглядит как нет нет да да да. Границы: left — заведомо нет, right — заведомо да. Ответ — right.

if enough(mid):
    right = mid
else:
    left = mid
print(right)

Различие в двух местах: куда идёт mid при успешной проверке и какую границу печатать. Если помнить инвариант — «left заведомо одно, right заведомо другое», — перепутать нельзя: печатаем ту границу, которая заведомо годится.

Про запас в границах

Границу берут не «примерно достаточной», а точно верной. Несколько лишних шагов ничего не стоят — логарифм растёт медленно, — а недостаточная граница ломает ответ.

Типичный промах: в задаче «поднять минимум, раздав kk единиц» правой границей взяли max(a)\max(a). Но если kk велико, ответ спокойно окажется больше любого элемента.

расчёт

Проверка: хватит ли часов

В стопках 3, 6, 7 и 11 документов. За час берут одну стопку и обрабатывают из неё не больше vv документов; неполный час считается за целый.

Сколько часов уйдёт при v=4v = 4? Введите целое число.

Войдите, чтобы ответить.
теория

Вещественный случай

Иногда ответ — не целое число, а длина, скорость или среднее. Схема та же, но с двумя оговорками.

Не пишите while right - left > eps. Такой цикл может не закончиться: когда числа большие, а eps маленький, разность границ перестаёт уменьшаться из-за ограниченной точности вещественных чисел, и программа зависает.

Вместо этого делайте фиксированное число шагов:

left = 0.0
right = 1000.0

for _ in range(100):
    mid = (left + right) / 2
    if enough(mid):
        left = mid
    else:
        right = mid

print("%.9f" % left)

Сколько шагов нужно? Каждый шаг уменьшает отрезок вдвое. Чтобы из отрезка длины 10910^9 получить точность 10610^{-6}, нужно log2(1015)50\log_2(10^{15}) \approx 50 шагов. Сто шагов — с большим запасом и всё равно мгновенно.

Выводите с запасом знаков. Если требуется точность 10610^{-6}, печатайте девять знаков после точки: "%.9f" % x. Округление до шести знаков само по себе съедает часть точности.

И помните про границы. В задаче «найти xx, для которого x3=cx^3 = c» при c=0.001c = 0.001 ответ равен 0.10.1 — он больше самого cc. Отрезок [0,c][0, c] здесь не годится.

теория

Типичные ошибки

Границы без запаса. Самая частая. Ответ оказывается за пределами отрезка, и программа возвращает границу. Признак: неверный ответ ровно на крайних тестах — когда параметр очень большой или очень маленький.

Печатается не та граница. Искали минимум, а вывели left. Ответ стабильно отличается на единицу — и это заметно только на тестах, где проверка меняется резко.

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

Забыт случай «решения нет». Если ни одно значение не годится, начальная граница окажется неверной, и вернётся мусор. Проверяйте это до поиска отдельной строкой.

Проверка считает слишком много. В задачах, где складывают x - a_i, сумма может стать астрономической, хотя ответ давно ясен. Выходите из цикла досрочно, как только стало понятно «нет».

Как проверять себя

  • параметр минимально возможный и максимально возможный;
  • список из одного элемента;
  • случай, когда ответа не существует;
  • случай, когда ответ равен границе отрезка.
теория

Практика: пятнадцать задач

Лестница прежняя: пять разминочных, семь основных, три со звёздочкой. Зачёт при десяти решённых.

В каждой задаче начинайте одинаково: сформулируйте проверку одним предложением и скажите вслух, почему она монотонна. Код после этого пишется почти сам.

Две задачи — вещественные. В них считайте фиксированное число шагов и выводите девять знаков после точки.

лестница задач
развёрнутый ответ

Поиск, который не заканчивается

Задача: «есть nn верёвок с целыми длинами; нужно получить kk кусков одинаковой целой длины; найдите наибольшую такую длину». Гарантируется, что длина 1 всегда подходит.

Ученик написал:

n, k = map(int, input().split())
a = list(map(int, input().split()))

def enough(length):
    return sum(value // length for value in a) >= k

left = 1
right = max(a)
while left < right:
    mid = (left + right) // 2
    if enough(mid):
        left = mid
    else:
        right = mid - 1

print(left)

Проверка написана правильно, и на маленьких примерах программа отвечает верно. Но на некоторых тестах она не выводит ничего и работает до самого конца отведённого времени.

Объясните, что происходит, приведите конкретное состояние границ, при котором это случается, и скажите, как исправить.

Войдите, чтобы ответить.