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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Алгоритм бинарного (или двоичного) поиска - это один из базовых алгоритмов, который часто применяется при решении алгоритмических задач. На 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

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

Источник: https://habr.com/ru/post/684756/


Интересные статьи

Интересные статьи

Существует классический способ выбрать, в какое кафе сходить или в какую организацию обратиться: достаточно почитать отзывы (которые, конечно, должны быть защищены от ботов). И такой способ правда...
Технология WebAssembly появилась относительно недавно (в 2015 году) и позиционировалась как альтернатива JavaScript для выполнения в среде браузера с максимально достижимой производительностью. Прилож...
Меня зовут Иванов Алексей. Я DS и скрам-мастер в центре ML-экспертизы разработчика HRtech-решений TalentTech. Мы занимаемся обучением моделей для проектов компании. Ровно год назад наша команда с...
Привет, Хабр! Меня зовут Рудаков Александр, я занимаюсь информационной безопасностью в компании "ЛАНИТ-Интеграция". Однажды, в рамках работы над проектом, мне понадобилось орган...
Думали ли вы о том, чтобы воспользоваться при разработке своего очередного веб-проекта простейшим из существующих набором технологий? Если это так — значит материал, перевод которого мы публикуем...