Зачем нужна ещё одна статья про бинарный поиск?
Как правило, обучающие материалы сводятся к показу одного «правильного» решения. Такие решения можно попробовать запомнить, но они быстро забываются и не помогают по-настоящему понять алгоритм.
Меня интригует вопрос: возможно ли объяснение, которое позволит не просто заучивать формулы, а понять саму логику? И если такое объяснение существует, даст ли оно возможность решать похожие задачи — или даже помогает становиться лучшим программистом?
Сразу оговорюсь: мы не будем останавливаться на тривиальных проверках,
вроде пустого массива или некорректных параметров. Фокус статьи — на сути алгоритма.
В этой статье мы не просто посмотрим на готовый код. Вместо этого мы:
Разберем ключевую идею алгоритма на простом примере.
Сконцентрируемся на крайнем случае, в котором ошибается большинство.
Сравним два подхода к реализации — с закрытым и полуоткрытым диапазоном.
Увидим, как небольшие изменения в коде позволяют решать целый класс задач.
Чтобы дальнейшее объяснение было предметным, в последующих примерах я буду использовать конкретный массив, отсортированный по неубыванию, и искомое значение:
const arr = [2, 7, 8, 11, 15, 29, 31];
const target = 30;
Ключевая идея бинарного поиска
Я не буду слишком подробно останавливаться на основной логике бинарного поиска. Почти каждая первая статья демонстрирует её с красивыми визуализациями. В этой статье я хочу сделать акцент на ключевом крайнем случае, разбор которого, по моей задумке, позволит не запоминать, а понять реализацию.
1) У нас есть отсортированный по неубыванию массив arr и искомое значение target.
2) Определим два указателя: left и right. В left запишем крайний левый индекс, в right — крайний правый:
let left = 0;
let right = arr.length - 1;
3) Вычислим mid — индекс, который находится посередине между left и right:
const mid = Math.floor((right + left) / 2)
Получив индекс mid, мы можем взять соответствующее значение из массива и сравнить его с target. Благодаря тому, что массив отсортирован, мы сразу можем понять, находится ли target левее или правее значения arr[mid]:
if (target === arr[mid]) {
// нашли target, возвращаем индекс
} else if (target > arr[mid]) {
// target больше arr[mid] — значит он в правой половине
} else {
// target меньше arr[mid] — значит он в левой половине
}
4) Мы определили, в какой половине массива находится нужный элемент, а значит можем отбросить другую половину и продолжить поиск только в оставшейся. Таким образом, за одну операцию мы сокращаем диапазон поиска вдвое.
В бинарном поиске мы повторяем эту последовательность действий, на каждом шаге уменьшая диапазон, пока не найдём искомое значение или пока указатели left и right не пересекутся — это означает, что target в массиве не существует.
Классическое решение
Существует несколько вариантов реализации бинарного поиска. Сейчас мы познакомимся с классическим решением с "закрытым интервалом", которое чаще всего встречается в статьях и разборах.
Это решение, на мой взгляд, наиболее понятно для тех, кто только знакомится с бинарным поиском: упрощённое вычисление mid, и при сужении диапазона — для right вычитаем единицу, для left прибавляем.
export function binarySearchClosedInterval(arr, target) {
let left = 0;
let right = arr.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (target === arr[mid]) {
return mid;
} else if (target < arr[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return -1;
}
Как видите, эта реализация полностью соответствует логике, которую я описал в начале статьи. Но именно здесь появляются детали, которые, хотя и можно заучить, гораздо лучше — понять.
Ключевой крайний случай
Я утверждаю, что понимание крайнего случая, который мы разберём в этом разделе, упростит для вас «написание» логики бинарного поиска в любой задаче и позволит не заучивать детали, в которых ошибаются 90% программистов.
Вернёмся к базовой логике бинарного поиска:
Создаём указатели
leftиrightВычисляем средний индекс
midСравниваем искомое значение с
arr[mid]. Если target больше — берём правую половину массива, если меньше — левую.
Критичный для понимания крайний случай возникает на шагах 2 и 3. Он заключается в том, что при двух соседних индексах формула расчёта mid возвращает меньший из них, и без «вспомогательного сдвига» указатели не смогут пересечься.
Пример: предположим, что left = 5, right = 6. Тогда mid = Math.floor((5 + 6) / 2) даст 5.
Небольшая справка: существует несколько формул для вычисления mid, но этот крайний случай актуален для всех. (Про "расширенную" формулу я расскажу отдельно — пока продолжаем на примере из начала статьи).
Давайте предметно посмотрим, как бинарный поиск ломается, если при обработке соседних индексов не делать "вспомогательный сдвиг".
Чтобы это показать, заменим корректный код на ошибочный:
// ПРАВИЛЬНЫЙ КОД
} else if (target < arr[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
// НЕПРАВИЛЬНЫЙ КОД
} else if (target < arr[mid]) {
right = mid;
} else {
left = mid;
}
Теперь подставим ошибочный код в функцию бинарного поиска и посмотрим, что произойдёт:
export function binarySearchWithError(
arr = [2, 7, 8, 11, 15, 29, 31],
target = 30
) {
let left = 5;
let right = 6;
while (left <= right) { // true
const mid = Math.floor((left + right) / 2); // 5
if (target === arr[mid]) { // false
return mid;
} else if (target < arr[mid]) {
right = mid;
} else { // true
left = mid; // 5
}
}
return -1;
}
На следующей итерации указатели останутся теми же: [left, right] = [5, 6]. mid снова будет равен 5, и цикл никогда не завершится — он стал бесконечным.
Обратите внимание: неважно, какая из границ не сдвигается — даже если left и right в какой-то момент равны (например, [5, 5]), но mid всё ещё равен 5, ситуация останется тупиковой — и снова бесконечный цикл.
Эта ошибка решается добавлением вспомогательного сдвига на единицу:
} else if (target < arr[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
ВЫВОДЫ:
Бинарный поиск должен сужаться на каждой итерации. Это работает, пока поиск не упирается в сравнение двух соседних индексов.
В этом крайнем случае важно понимать, что mid равен left, и нельзя повторно присваивать left = mid или right = mid — иначе диапазон не изменится, и цикл станет бесконечным.
Можно взглянуть на это иначе: когда мы сдвигаем границы на единицу, мы просто исключаем из диапазона индекс mid, который уже был проверен. Нет никакой причины оставлять его в следующей итерации.
Закрытый и полуоткрытый диапазоны
Понимание того, как реализация бинарного поиска зависит от типа диапазона — закрытого или полуоткрытого — помогает разобраться с крайним случаем, связанным с бесконечным циклом при соседних индексах, и в целом больше никогда не путаться в деталях реализации.
Прежде чем идти дальше, хочу зафиксировать две мысли:
Тема диапазонов шире, чем бинарный поиск. В этой статье я не ставлю задачу рассказать теорию или историю диапазонов — я хочу дать практическую информацию в контексте бинарного поиска. Если тема интересна глубже, рекомендую начать со статьи Эдсгера Дейкстры «Почему нумерация должна начинаться с нуля».
Существует отдельный термин — «ошибка на единицу» (off‑by‑one error). Он описывает типичные ошибки в циклах, когда количество итераций оказывается на единицу больше или меньше, чем нужно. Бесконечный цикл, который мы разобрали выше, — это классический пример ошибки на единицу. И я посмею предположить, что одна из главных её причин — неосознанный выбор типа диапазона.
Простой цикл с закрытым диапазоном
Диапазон определяет, какие значения индексов будут участвовать в итерации.
В случае закрытого диапазона:
for (let i = 0; i <= arr.length - 1; i++) {}
Особенности:
Начальная (
0) и конечная (arr.length - 1) границы включаются.Используется условие
i <= ..., чтобы не пропустить последний элемент.«Визитная карточка» — необходимость вычитать 1 из длины массива при задании правой границы. Именно этот
-1часто считают источником потенциальных ошибок.
Простой цикл с полуоткрытым диапазоном
Это более современный и идиоматичный подход, особенно в JavaScript (вспомните .slice(start, end)).
for (let i = 0; i < arr.length; i++) {}
Начальная точка (0) является ВКЛЮЧИТЕЛЬНОЙ, а конечная (arr.length) — ИСКЛЮЧИТЕЛЬНОЙ.
Поэтому в условии цикла мы используем оператор строгого сравнения
<. Цикл остановится, как только итератор достигнет конечной, не-включительной точки.«Визитная карточка» этого подхода — использование «чистой» длины массива (
arr.length) в качестве конечной точки. Здесь нет никаких-1, что делает код чище и интуитивнее.
Бинарный поиск полуоткрытым диапазоном
Теперь посмотрим на бинарный поиск, реализованный через полуоткрытый диапазон:
function binarySearchHalfOpenInterval(arr, target) {
let left = 0;
let right = arr.length;
while (left < right) {
const mid = Math.floor((left + right) / 2);
if (target > arr[mid]) {
left = mid + 1;
} else {
right = mid;
}
}
if (left === arr.length || arr[left] !== target) return -1;
else return left;
}
В соответствии с элементарным примером полуоткрытого цикла, мы видим, что в данной реализации бинарного поиска начальная точка интервала всегда является ВКЛЮЧИТЕЛЬНОЙ, а конечная — ИСКЛЮЧИТЕЛЬНОЙ.
На практике это означает, что нам не нужно помнить о том, чтобы при обновлении правой границы вычитать единицу. Крайний правый индекс по умолчанию недостижим — он исключён из диапазона самим условием цикла while (left < right).
if (target > arr[mid]) {
left = mid + 1;
} else {
right = mid;
}
Другая важная особенность данной реализации в том, что мы напрямую не сравниваем arr[mid] с target. Мы возвращаем left, которое в конечном счёте, благодаря условию left = mid + 1, будет индексом элемента, у которого слева лежат все значения, строго меньшие target. Следовательно, само значение по индексу left является первым элементом, равным или большим target.
Эта особенность делает реализацию универсальной: она сразу решает несколько популярных задач, основанных на бинарном поиске. Например, задача findLeftBoundary / Find First Occurrence / lower_bound.
function findLeftBoundry(arr, target) {
let left = 0;
let right = arr.length;
while (left < right) {
const mid = Math.floor((left + right) / 2);
if (target > arr[mid]) {
left = mid + 1;
} else {
right = mid;
}
}
if (left === arr.length) return -1;
else return left;
}
А если немного изменить условие и вернуть left - 1, получим решение задачи findRightBoundary / Find Last Occurrence / upper_bound:
function findRightBoundry(arr, target) {
let left = 0;
let right = arr.length;
while (left < right) {
const mid = Math.floor((left + right) / 2);
if (target >= arr[mid]) {
left = mid + 1;
} else {
right = mid;
}
}
return left - 1;
}
Бинарный поиск закрытым диапазоном (снова)
Классическое решение поиска полуоткрытым диапазоном отличается от классической реализации закрытым диапазоном. Интересно, и вы могли это заметить, но разница заключается не только в самих диапазонах.
В реализации с полуоткрытым диапазоном поиск продолжается, пока все значения, строго меньшие target, не окажутся слева:
if (target > arr[mid]) {
left = mid + 1;
} else {
right = mid;
}
А в классической (и самой популярной!) реализации с закрытым диапазоном сравнение arr[mid] === target выполняется сразу, и при совпадении индекс немедленно возвращается:
if (target === arr[mid]) {
return mid;
} else if (target > arr[mid]) {
left = mid + 1;
} else {
right = mid - 1;
}
Следовательно, одна из финальных мыслей этой статьи в том, что реализация бинарного поиска с закрытым и полуоткрытым диапазоном различается не только формально — диапазонами, но и подходом к самой логике поиска.
Мы вполне можем написать реализацию с закрытым диапазоном, которая будет работать по принципу полуоткрытого варианта — без немедленного возврата, просто сужая диапазон до нужной точки.
function findLeftBoundryClosedInterval(arr, target) {
let left = 0;
let right = arr.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (target > arr[mid]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (left < arr.length && arr[left] === target) {
return left;
}
return -1;
}
Баг с переполнением
Вычисление mid кажется простой арифметикой, но есть нюанс: выражение (left + right) / 2 может переполниться в языках с фиксированным целым типом. В результате mid окажется неверным, и алгоритм поведёт себя непредсказуемо.
Чтобы этого избежать, используют более безопасную форму:
const mid = left + Math.floor((right - left) / 2);
Почему это безопасно:
right—leftникогда не превышает длину диапазона; покаright>= left, разность не переполнит тип.Далее прибавляем половину этой разности к
left— итог всегда остаётся в пределах допустимых индексов.
Комментарии (23)

Newm
02.10.2025 11:28Если кто-то собрался кого-то учить, то начинать он должен с понятий. Например, в я так и не увидел в статье, что такое бинарный поиск:(. И уж тем более надо описать, что такое открытый и закрытый диапазоны.
Когда сайты делали, просто в шок приходил от клиентов, когда они в свойствах товара писали какие-то отличия от конкурентов, которые понятны только конкурентам. А о том, что сначала товар просто описать надо для пользователя, который просто где-то про него услышал и раздумает, нужен он ему вообще или не нужен, клиент даже не задумывается. Вот и здесь примерно так. Статья понятна для тех, кто и так все знает:)

zeroc0de
02.10.2025 11:28Интересно почитать, но не осилил - слишком много букв для простого объяснения. Я не запоминаю эти алгоритмы, благо, подумав логически, их можно изобрести заново.
В функциях недостаточная проверка данных.findLeftBoundryиbinarySearchHalfOpenIntervalвернут Undefined offset, если target больше, чем количество элементов в массиве. Плохоleft === arr.length, хорошоleft>=arr.length

wannaseeemyguts Автор
02.10.2025 11:28Благодарю всех за дискуссию и обратную связь.
В ходе обсуждения прозвучали тезисы, которые могут ввести в заблуждение начинающих разработчиков. Считаю важным ответить на них, опираясь на объективные данные.
Приведу несколько фактов:
Задача №34 "Find First and Last Position of Element in Sorted Array", классическое применение бинарного поиска, имеет сложность Medium на LeetCode.
Эта же задача (и её вариации) используется на собеседованиях в крупнейших IT-компаниях, часто как одна из самых сложных на алгоритмической секции.
В стандартной библиотеке Java метод Arrays.binarySearch() почти 10 лет содержал баг с переполнением целого числа. Как отмечает Джошуа Блох (один из ключевых разработчиков Java), даже спустя годы в большинстве учебных материалов продолжает преподаваться именно ошибочная версия алгоритма.
Вывод:
Приведенные факты демонстрируют, что утверждение об "очевидности" бинарного поиска является заблуждением. Подобные заявления неверны по сути и вредны, поскольку демотивируют начинающих специалистов, сталкивающихся с реальными трудностями при изучении этой темы.

Zenitchik
02.10.2025 11:28Два аргумента от "авторитета". Третий аргумент - про поиск заклёпок. Очевидно, метод содержал эту уязвимость потому, что на практике в массиве никогда не бывает так много элементов, чтобы сумма двух индексов вызвала переполнение.
А вот контраргумент, опираясь на объективные данные: артиллерийская вилка известна спокон веку. И её прекрасно осваивают не программисты, а, прости господи, призывники.
сталкивающихся с реальными трудностями при изучении этой темы.
Назовите хоть одну реальную трудность? Трудно понять "проверь середину, и определи, решение ближе или дальше середины, а потом повтори то же самое для соответствующей половины отрезка"?

wannaseeemyguts Автор
02.10.2025 11:28Вот вы зачем мне ставите минусы?
Как вы сказали, я привел аргументы от авторитетов. Я их не придумал.
Вы с ними и спорьте, напишите: «Я, Zenitchik, автор 12 тысяч комментариев на Хабре, знаю лучше, чем Джошуа Блох (ключевой разработчик Java) и эксперты LeetCode. Баг с переполнением, который они пропустили, — это не проблема, а сложность задачи на LeetCode — искусственно завышена».
А обратную связь по статье я принимаю, спасибо.
Zenitchik
02.10.2025 11:28Как вы сказали, я привел аргументы от авторитетов. Я их не придумал.
Ну да. А апелляция к авторитету - это признак невалидного аргумента. Если не сказать, демагогический приём.
А реальную трудность вы так и не назвали.

wannaseeemyguts Автор
02.10.2025 11:28Давайте разберем то, что вы написали:
Апелляция к авторитету — это признак невалидного аргумента.
А то и демагогический приём.Ссылка на авторитетный источник сама по себе не делает аргумент невалидным. Он становится таким, если искажать факты, подгонять их под свою точку зрения или что-то выдумывать. Я этого не делал.
Похоже, вы пишете что пишите, потому что не прочитали статью Блоха, которую я уже привел как источник. В ней он рассуждает о сложности алгоритма, упоминая, что прошло почти два десятка лет с появления концепции до её первой стабильной реализации.
Но вместо того, чтобы оспорить эти факты из первоисточника, вы ставите минус и доводите ситуацию до абсурда: заявляете, что ссылаться на эксперта в его же области — это «невалидный аргумент».
Спасибо за комментарии, всего вам доброго!

Zenitchik
02.10.2025 11:28Ссылка на авторитетный источник сама по себе не делает аргумент невалидным
Нет, не это. А то, что "мнение авторитетного человека" и "авторитетный источник" - это не одно и то же. Сколь угодно авторитетный человек, может ошибиться. Если в источнике предложен метод, которым можно проверить утверждение, то это авторитетный источник. Если нет - то это просто слова.
А когда предлагают поверить человеку только потому, что он авторитетный - это демагогия. В приличном обществе за такое пинают.
В ней он рассуждает о сложности алгоритма, упоминая, что прошло почти два десятка лет с появления концепции до её первой стабильной реализации.
Вы это серьёзно? Артиллерийская вилка так же стара, как нарезная артиллерия. Т.е. концепция двоичного поиска появилась ОЧЕНЬ ДАВНО.
И называть реализацию на Java первой стабильной реализацией - по меньшей мере... странно. Знаете, до Java другие языки были. А до них другие. Вы же не думаете, что для них не было реализации двоичного поиска?
Статью я прочитал, и выше на утверждение возразил. Массивы длиной 4 млрд элементов - это ненаучная фантастика, поэтому ошибка была и остаётся некритичной. Впрочем, хорошо, что её всё-таки нашли и исправили.
Мне кажется, что Вы сами не разобрались толком в алгоритме, а только полагаетесь на чужие слова. Нельзя так делать.

wannaseeemyguts Автор
02.10.2025 11:28И называть реализацию на Java первой стабильной реализацией - по меньшей мере... странно. Знаете, до Java другие языки были. А до них другие. Вы же не думаете, что для них не было реализации двоичного поиска?
Еще раз прошу прочесть статью, Блох не утверждал, что первая стабильная реализация была на Java. Он разместил цитату Джона Бентли из книги "Programming Pearls": "While the first binary search was published in 1946, the first binary search that works correctly for all values of n did not appear until 1962."
Если бы Вы прочитали, наверно бы вы не ошиблись...
vadimr
02.10.2025 11:28Это просто какая-то демагогия у Бентли. До 1962 года заведомо не было компьютеров, у которых размер физической памяти достигал бы разрядности целых чисел. Поэтому для тех времён сложение было совершенно корректно. А уж то, что разработчики Java некритично заимствовали алгоритм в эпоху гигабайтной памяти – их личная проблема.
Тут же надо заметить, что некоторые языки имеют арифметику неограниченной разрядности.

wannaseeemyguts Автор
02.10.2025 11:28Вы знаете, я не учитывал этот момент, для меня это ценный комментарий. Спасибо.

zeroc0de
02.10.2025 11:28Без обид, мне кажется, что алгоритм и вправду лёгкий для новичка.
Но я буду рад узнать новое, если вы скажите, в чем трудность этого алгоритма.
И кста, спасибо за статью.ps. Надеюсь, вы не ответите в стиле "мопед не мой, ..." :)

wannaseeemyguts Автор
02.10.2025 11:28Конечно, без обид!
Сразу предупрежу, у меня нет 100% уверенности, что статья получилась и она кому-то поможет.
Написал я ее потому, что почти все алгоритмы, где бы я ни смотрел, преподносятся как готовые решения.
У меня была идея, что если проговорить больше, чем обычно, то это может быть полезно новичкам. Показать, каково это — идти по логике алгоритма, до момента, когда он оказывается элементарным.
Есть ли в этом смысл? Я не знаю.
Один скажет, что склонность к программированию должна быть очевидна. Такой новичок всё будет понимать сразу без объяснений.
Но у меня возникают сомнения: а может ли быть когорта людей, которым стартовать сложнее по какой-то причине, но при этом это не уменьшает их потенциальный талант?
И вот меня обвинили в том, что это текст чат-бота. Да, может, получилось не идеально. Но я несколько дней писал и редактировал текст, постарался объяснить то, чего не нашел ни на русском, ни на английском, и добавил подсказки про диапазоны как возможность задуматься о том, как вообще человек пишет и думает о циклах.

BugM
02.10.2025 11:28Простите, но если человек не понимает объяснение этого алгоритма на пальцах то ему нечего делать в разработке. Не надо понятнее, это не имеет смысла.
Я учился первому языку Кернигану и Ритчи, а алгоритмам по Кнуту. Их книжки все еще лучшие. Для уровня старшей школы или около того, первые курсы тоже ок.
Не смотрите на дату выпуска, с тех пор ничего не изменилось. А чтобы написать книжку все еще требуется талант.
Zenitchik
Простите, а что Вы в бинарном поиске собрались заучивать? Он же прост настолько, что его можно самому с нуля независимо изобрести.
vadimr
Согласен с Вами. Человеку, которому требуются усилия для понимания бинарного поиска, нечего делать в программировании. Но статья – просто вода чатбота.
vadimm37
С бинарным поиском все понятно, но у новичков возникает проблема в том чтобы закодить решение на собесе. По вашему в программирование попадают люди у которых с рождения есть навыки решения алгоритмических задач на js, для остальных вход закрыт?
vadimr
Вход не закрыт, но смысла нет.
BugM
Да что сложного? Ошибок на единицу понаделать есть где, но остальное пишется быстрее чем эта стена текста читается.
vitaliyterletskiy
С рождения, конечно такого навыка нет, он появляется в результате практики по решению этих самых алгоритмических задач. Но если человеку это даётся с огромным трудом, и человека интересует в этом вопросе только зарплата, то лучше поискать себя в другой сфере деятельности
aamonster
Насколько я могу судить, статья – типичное "пока объяснял, сам разобрался". Она нужна автору, а не сообществу.
GospodinKolhoznik
Мне так на 1м курсе на мат. анализе показалось, что теорема Больцано-Вейерштрасса очень легко доказывается, всего то надо рекурсивно делить отрезок, содержащий бесконечное число точек пополам. Ну по сути тот самый бинарный поиск.
Глупый был, не знал, что если теорема называется именами выдающихся людей, то она не может быть настолько тривиальной.
А на экзамене меня попросили доказать, что среди бесконечного множества этих самых вложенных отрезков, которые я построил, найдется хотя бы одна точка принадлежащая им всем. И тут то я сдулся. Оказывается это вообще невозможно доказать. Ну т.е. можно, если воспользоваться каким либо хитрыми аксиомами о существовании супремума или её эквивалентами, например аксиомой о непрерывносити числового множества. Но я этого не знал, а сам растерялся и не догадался на экзамене до этого, за что и получил трояк.