Алгоритм бинарного (или двоичного) поиска - это один из базовых алгоритмов, который часто применяется при решении алгоритмических задач. На LeetCode на момент написания этой статьи порядка 190 задач в решении которых он используется (можно посмотреть здесь: https://leetcode.com/tag/binary-search/). Бинарный поиск разбирается во множестве статей, его идея достаточно несложная и интуитивно понятная. Однако алгоритм имеет некоторое количество "подводных камней". В этой заметке я хотел бы показать решение одной из задач с его помощью.

Для начала несколько слов о самом алгоритме. Задача, которую он решает, может быть сформулирована следующим образом: "найти в отсортированном массиве индекс элемента, значение которого соответствует заданному". Обычно массив отсортирован по возрастанию и в данной заметке мы будем предполагать, что это так и есть.

Основная идея алгоритма в том, чтобы проверить элемент в середине текущего среза массива (изначально берется весь массив). Если значение этого элемента меньше искомого, то необходимо проверить средний элемент в правой части массива, если больше, то в левой части массива. Мы будем повторять этот процесс, пока значения среднего элемента очередного среза не окажется равным искомому значению, либо больше не останется элементов (и тогда в массиве нет такого элемента). Более наглядно это видно на рисунке:

Пример бинарного поиска - Шаг 1
Пример бинарного поиска - Шаг 1
Пример бинарного поиска - Шаг 2
Пример бинарного поиска - Шаг 2
Пример бинарного поиска - Шаг 3
Пример бинарного поиска - Шаг 3

Сложность алгоритма бинарного поиска по времени выполнения O(logN) (так как мы уменьшаем срез массива на 2 на каждой итерации и проверяем только 1 элемент), и O(1) по памяти. Что касается реализации алгоритма, то обычно она выглядит следующим образом:

left, right = 0, len(array)-1
while left <= right do
	middle = (left + right) / 2
  if array[middle] > target then
  	right = middle - 1 // дальше будем проверять левую часть массива
  else if array[middle] < target then
  	left = middle + 1 // дальше будем проверять правую часть массива
  else
  	return middle // нашли элемент
  end if
end while
return -1 // элемента нет в массиве

Код на Golang можете посмотреть здесь. Есть тесты, которые включают часть corner cases. На эти случаи стоит обратить внимание и, возможно, почитать про них, например на stackoverflow (найти их описание сейчас не составляет проблем, есть много материалов как на английском, так и на русском языках).

Давайте перейдем к решению задачки с LeetCode. Возьмем для примера задачу Find Target Indices After Sorting Array. Она довольно простая, уровня easy, что позволит нам сосредоточится на одном алгоритме, а точнее его небольшой модификации. Если кратко, то суть задачи состоит в том, что нам необходимо в отсортированном массиве найти индексы всех элементов, значения которых соответствуют заданному. Есть еще одно дополнительное условие: необходимо вернуть их в порядке возрастания значений элементов массива (то есть надо сначала массив отсортировать). Таким образом, очень похоже, что нам подойдет алгоритм бинарного поиска, однако он позволяет найти индекс только одного из элементов.

Наиболее простым способом для нас будет нахождение элемента (любого) с заданным значением, затем распространение вправо и влево от него, пока значение не станет отличным с обеих сторон или мы не достигнем конца и начала массива. Однако, такой подход может привести к O(N) сложности по времени, тогда как бинарный поиск имеет сложность O(logN). Здесь может быть полезным модификация бинарного поиска, которая вернет наиболее левый элемент массива с искомым значением. Реализация алгоритма выглядит так (код на Golang можете посмотреть здесь, тесты здесь):

left, right = 0, len(array)
while left < right do
	middle = (left + right) / 2
  if array[middle] < target then
  	left = middle + 1 // дальше будем проверять правую часть массива
  else
  	right = middle // дальше будем проверять левую часть массива
  end if
end while
return left

Отлично, теперь мы сможем найти первый индекс в массиве и просто добавить все элементы начиная от него и пока не встретится элемент с отличным значением. Алгоритм будет выглядеть следующим образом (код на Golang здесь, тесты здесь):

sort(array) // сортируем массив по возрастанию ( обычно за O(NlogN) )

// бинарный поиск наиболее левого элемента
left, right = 0, len(array)
while left < right do
	middle = (left + right) / 2
  if array[middle] < target then
  	left = middle + 1 // дальше будем проверять правую часть массива
  else
  	right = middle // дальше будем проверять левую часть массива
  end if
end while

// собираем индексы элементов в результирующий массив
result = []
while left < length(array) and array[left] == target do
	append(result, left)
  left = left + 1
end while

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

Дальнейшим развитием решения может быть поиск индекса наиболее правого элемента массива с искомым значением. Это еще одна небольшая модификация бинарного поиска. Вот так выглядит сам алгоритм (код на Golang здесь, тесты здесь):

left, right = 0, len(array)
while left < right do
	middle = (left + right) / 2
  if array[middle] > target then
  	right = middle // дальше будем проверять левую часть массива
  else
  	left = middle + 1 // дальше будем проверять правую часть массива
  end if
end while
return right

Теперь мы можем найти оба (самый левый и самый правый) элемента за время O(logN) каждый. Код на golang можете посмотреть здесь и тесты.

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

Алгоритм

Время выполнения

Сортировка и линейный поиск

8 ms

Сортировка, бинарный поиск левого элемента и линейный поиск правого элемента

7 ms

Сортировка, бинарный поиск левого и правого элементов

5 ms

Бенчмарки на Golang с массивом из 30 одинаковых элементов, каждый из которых равен искомому дают следующие результаты

Алгоритм

Время выполнения

Сортировка и линейный поиск

717 - 1028 ns/op

Сортировка, бинарный поиск левого элемента и линейный поиск правого элемента

943 - 1418 ns/op

Сортировка, бинарный поиск левого и правого элементов

581 - 639 ns/op

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

Комментарии (3)


  1. Myclass
    26.08.2022 10:57
    +2

    Не хочу быть дотошным, но название статьи

    Решение задач с использованием алгоритма бинарного поиска

    предусматривает описание задач, где используется этот бинарный поиск. Вы-же описали сам бинарный поиск. И добавили от себя

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

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

    Это первое. А второе - сортировать с величиной n только в 30 элементов- это значить не сказать ничто. Различные варианты проходов имеют не только время, но разные времена - в зависимости от входных данных. Например, если массив предварительно отсортирован по возрастанию или убыванию. Всё это отражается на времени. Поэтому я не увидел детального теоретической подхода к описанию алгоритма. И величина n тоже должна играть роль. А в свете современных требований к bigdata и возможность параллельного вычисления.


    1. evgmih Автор
      26.08.2022 11:50

      Спасибо за замечания.

      Я хотел показать, как алгоритм с небольшими модификациями может быть использован для решения задач и разобрал одну из них с литкода.

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

      Я учту ваши замечания и пожелания относительно количества разнообразных задач и тестов)


  1. Zara6502
    26.08.2022 11:55

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