Целочисленный бинпоиск – правильная реализация

Правка ru6, от rembocoder, 2023-03-01 07:26:37

Казалось бы, по бинпоиску написано столько материалов, что все должны знать, как писать его, однако в каждом материале похоже рассказывается свой подход, и я вижу, что люди теряются и допускают ошибки в такой простой штуке. Поэтому я хочу рассказать о подходе, который нахожу наиболее элегантным. Из всего многообразия статей, я увидел свой подход только в этом комментарии (но менее обобщённый).

UPD: Также у nor в блоге.

Проблема

Предположим, вы хотите найти последний элемент в отсортированном массиве, меньший x, и просто храните отрезок кандидатов на ответ [l, r]. Если вы напишете:

int l = 0, r = n - 1; // отрезок кандидатов
while (l < r) { // больше одного кандидата
    int mid = (l + r) / 2;
    if (a[mid] < x) {
        l = mid;
    } else {
        r = mid - 1;
    }
}
cout << l;

...вы наткнётесь на проблему: предположим, l = 3, r = 4, тогда mid = 3. Если a[mid] < x, вы попадёте в цикл (на следующей итерации отрезок будет снова [3, 4]).

Ладно, это можно исправить округлением mid вверх – mid = (l + r + 1) / 2. Но есть ещё одна проблема: что если в массиве нет таких элементов? Нам потребуется дополнительная проверка на этот случай. В результате, мы получили довольно уродливый код, который тяжело обобщить.

Мой подход

Давайте обобщим нашу задачу. У нас есть утверждение о целом числе n, которое истинно для целых чисел меньше некоторой границы, но потом всегда остаётся ложным как толькоn превысит её. Мы хотим найти последнееn, для которого утверждение истинно.

Прежде всего, мы будем использовать полуинтервалы вместо отрезков (одна граница включительная, другая – нет). Полуинтервалы вообще очень полезны, элегантны и общеприняты в программировании, я рекомендую использовать их по возможности. В этом случае мы выбираем некое маленькое l, для которого мы знаем, что утверждение истинно и некое большое r, для которого мы знаем, что оно ложно. Тогда диапазоном кандидатов будет [l, r).

Моим код для этой задачи было бы:

int l = -1, r = n; // полуинтервал [l, r) кандидатов
while (r - l > 1) { // больше одного кандидата
    int mid = (l + r) / 2;
    if (a[mid] < x) {
        l = mid; // теперь это наибольшее, для которого мы знаем, что это истинно
    } else {
        r = mid; // теперь это наименьшее, для которого мы знаем, что это ложно
    }
}
cout << l; // в конце остаётся диапозон [l, l + 1)

Бинпоиск будет проверять только числа строго между изначальными l и r. Это значит, что он никогда не проверит условие для l и r, он поверит вам, что для l это истинно, а для rложно. Здесь мы считаем -1 корректным ответом, который соответствует случаю, когда ни один элемент массива не меньше x.

Заметьте, что у меня в коде нет "+ 1" или "- 1", и мне не нужны дополнительные проверки, и попадание в цикл невозможно (т. к. mid строго между текущими l и r).

Обратная задача

Единственная вариация, которую вам надо держать в голове – это что в половине случаев вам нужно будет найти не последнее, а первое число, для которого нечто выполняется. В этом случае утверждение должно быть всегда ложно для меньших чисел и всегда истинно, начиная с некоторого числа.

Мы будем делать примерно то же самое, но теперьr будет включительной границей, а l – не включительной. Другими словами, l теперь – некоторое число, для которого мы знаем, что утверждение ложно, а r – некоторое, для которого мы знаем, что оно истинно. Предположим, я хочу найти первое число n, для которогоn * (n + 1) >= x (x положительно):

int l = 0, r = x; // полуинтервал (l, r] кандидатов
while (r - l > 1) { // больше 1 кандидата
    int mid = (l + r) / 2;
    if (mid * (mid + 1) >= x) {
        r = mid; // теперь это наименьшее, для которого мы знаем, что это истинно
    } else {
        l = mid; // теперь это наибольшее, для которого мы знаем, что это ложно
    }
}
cout << r; // в конце остаётся диапозон (r - 1, r]

Только будьте осторожны с выбором r, слишком большое может привести к переполнению.

Пример

1201C - Максимальная медиана Вам дан массив a нечётной длины n и за один шаг можно увеличить один элемент на 1. Какую наибольшую медиану массива можно достичь за k шагов?

Рассмотрим утверждение о числе x: мы можем сделать медиану не меньше x за не более, чемk шагов. Конечно, это всегда истинно до некоторого числа, а затем всегда ложно, поэтому можно использовать бинпоиск. Так как нам нужно последнее число, для которого это истинно, мы будем использовать обычный полуинтервал [l, r).

Чтобы проверить данное x, мы можем воспользоваться свойством медианы. Медиана не меньше x тогда и только тогда, когда не менее половины всех элементов не меньше x. Конечно, оптимальным способом добиться этого будет взять наибольшую половину элементов и сделать их не меньше x.

Конечно, можно достичь медиану не менее 1 при данных ограничениях, так что l будет равно1. Но даже если есть только один элемент, равный 1e9 и k тоже равно1e9, мы всё равно не сможем добиться медианы 2e9 + 1, так что r будет равно 2e9 + 1. Реализация:

#define int int64_t

int n, k;
cin >> n >> k;
vector<int> a(n);
for (int i = 0; i < n; i++) {
    cin >> a[i];
}
sort(a.begin(), a.end());
int l = 1, r = 2e9 + 1; // полуинтервал [l, r) кандидатов
while (r - l > 1) {
    int mid = (l + r) / 2;
    int cnt = 0; // необходимое количество шагов
    for (int i = n / 2; i < n; i++) { // идём по наибольшей половине
        if (a[i] < mid) {
            cnt += mid - a[i];
        }
    }
    if (cnt <= k) {
        l = mid;
    } else {
        r = mid;
    }
}
cout << l << endl;

Заключение

Надеюсь, я разъяснил кое-что, и часть из вас переключится на эту имплементацию. Для уточнения, иногда другие имплементации могут оказаться более подходящими, например, в интерактивных задачах – когда нам нужно мыслить в терминах интервала поиска, а не в терминах первого/последнего числа, для которого что-то выполняется.

Я напоминаю, что даю частные уроки по спортивному программированию, цена – $30/ч. Пишите мне в Telegram, Discord: rembocoder#3782, или в личные сообщения на CF.

Теги бинарный поиск, бинпоиск

История

 
 
 
 
Правки
 
 
  Rev. Язык Кто Когда Δ Комментарий
en7 Английский rembocoder 2023-03-01 07:27:04 4 Tiny change: 'price is $25/h. Contac' -> 'price is $30/h. Contac'
ru6 Русский rembocoder 2023-03-01 07:26:37 4 Мелкая правка: ', цена – $25/ч. Пишите' -> ', цена – $30/ч. Пишите'
en6 Английский rembocoder 2022-05-29 14:21:23 0 (published)
en5 Английский rembocoder 2022-05-29 14:20:53 93 (saved to drafts)
ru5 Русский rembocoder 2022-05-29 14:20:25 90 Мелкая правка: '[user:nor]а в [блоге]' -> '[user:nor] в [блоге]'
en4 Английский rembocoder 2022-05-28 21:53:08 23 Tiny change: '\n\n~~~~\nint n, k' -> '\n\n~~~~\n#define int int64_t\n\nint n, k'
ru4 Русский rembocoder 2022-05-28 21:51:10 23 Мелкая правка: '\n\n~~~~\nint n, k' -> '\n\n~~~~\n#define int int64_t\n\nint n, k'
en3 Английский rembocoder 2022-05-28 20:37:33 9 Tiny change: 'e _first_ element for which' -> 'e _first_ number for which' (published)
ru3 Русский rembocoder 2022-05-28 20:36:26 0 (опубликовано)
ru2 Русский rembocoder 2022-05-28 20:31:27 1
ru1 Русский rembocoder 2022-05-28 20:31:12 6379 Первая редакция перевода на Русский (сохранено в черновиках)
en2 Английский rembocoder 2022-05-28 19:39:46 24 Tiny change: 'h the same, but now ' -> 'h the same thing, but now '
en1 Английский rembocoder 2022-05-28 19:28:15 6356 Initial revision (saved to drafts)