бинарный поиск что это
Бинарный поиск
Линейный поиск
Мы считаем, что вы уже знаете линейный поиск, а именно умеете решать задачи такого типа:
Задание
Убедитесь, что вы умеете решать эти задачи. Докажите, что быстрее, чем O(n) их решить в худшем случае нельзя.
Бинарный поиск
Однако иногда найти число X в массиве можно и быстрее! Для этого надо добавить условие на то, что массив отсортирован. Но давайте начнем не с этого.
Задание
Я загадал число X от 1 до 100. Вы можете спрашивать, больше ли мое число чем число T, я отвечаю “да” или “нет”. За сколько вопросов в худшем случае вы сможете найти число X? Как нужно действовать?
Почему нужно делить обязательно пополам? Почему бы не спросить “число X больше, чем 80?” первым же вопросом? Но если вдруг ответ “нет”, то мы останемся с 80 вариантами вместо 100. То есть деление отрезка ровно пополам гарантирует, что в худшем случае мы останемся не более чем с половиной вариантов.
Теперь вернёмся к нашей задаче. Можно понять, что такой алгоритм работает как раз за \(O(\log n)\) вопросов (если число 100 на заменить абстрактную переменную \(n\) ). Несложно убедиться, что именно логарифм раз нужно поделить число на два, чтобы получилось 1.
Общий принцип
А теперь представьте такую задачу: у вас есть массив, состоящий из некоторого количества подряд идущих нулей, за которыми следует какое-то количество подряд идущих единиц.
Вам дан массив, и вам нужно найти позицию первой единицы, то есть найти такое место, где заканчиваются нули, и начинаются единицы. Это можно сделать с помощью линейного поиска за один проход по массиву, но хочется сделать это быстрее.
Есть много способов писать бинарный поиск, и в его написании люди очень часто путаются. Очень удобно в данном случае воспользоваться инвариантом (это слово значит “постоянное свойство”):
Пусть переменная left всегда указывает на \(0\) в массиве, а переменная right всегда указывает на \(1\) в массиве.
Дальше мы будем переменные left и right постепенно сдвигать друг к другу, и в какой-то момент они станут соседними числами. Это и будет означать, что мы нашли место, где заканчиваются нули и начинаются единицы.
Осталось нам написать цикл while :
Мы решили задачу для ноликов и единичек, но это легко обобщается на абсолютно любую задачу, где есть какое-то свойство, которое в начале массива не выполняется, а потом выполняется.
Задание
Поэтому зачастую такие задачи сформулированы таким образом:
Задание
Найдите время работы, за которое решается эта задача?
Целочисленный двоичный поиск
Целочисленный двоичный поиск (бинарный поиск) (англ. binary search) — алгоритм поиска объекта по заданному признаку в множестве объектов, упорядоченных по тому же самому признаку, работающий за логарифмическое время.
Задача: |
Пусть нам дан упорядоченный массив, состоящий только из целочисленных элементов. Требуется найти позицию, на которой находится заданный элемент. |
Содержание
Принцип работы [ править ]
Двоичный поиск заключается в том, что на каждом шаге множество объектов делится на две части и в работе остаётся та часть множества, где находится искомый объект. Или же, в зависимости от постановки задачи, мы можем остановить процесс, когда мы получим первый или же последний индекс вхождения элемента. Последнее условие — это левосторонний/правосторонний двоичный поиск.
Правосторонний/левосторонний целочисленный двоичный поиск [ править ]
Использовав эти два вида двоичного поиска, мы можем найти отрезок позиций [math][l,r][/math] таких, что [math]\forall i \in [l,r] : a[i] = x[/math] и [math] \forall i \notin [l,r] : a[i] \neq x [/math]
Пример: [ править ]
Если искомого элемента в массиве нет, то правосторонний поиск выдаст максимальный элемент, меньший искомого, а левосторонний наоборот, минимальный элемент, больший искомого.
Алгоритм двоичного поиска [ править ]
Код [ править ]
Несколько слов об эвристиках [ править ]
Эвристика с завершением поиска, при досрочном нахождении искомого элемента
Заметим, что если нам необходимо просто проверить наличие элемента в упорядоченном множестве, то можно использовать любой из правостороннего и левостороннего поиска. При этом будем на каждой итерации проверять «не попали ли мы в элемент, равный искомому», и в случае попадания заканчивать поиск.
Эвристика с запоминанием ответа на предыдущий запрос
Применение двоичного поиска на некоторых неотсортированных массивах [ править ]
Задача: |
Массив образован путем приписывания в конец массива, отсортированного по возрастанию, массива, отсортированного по убыванию. Требуется максимально быстро найти элемент в таком массиве. |
Время выполнения алгоритма — [math] O(\log n)[/math] (так как и бинарный поиск, и тернарный поиск работают за логарифмическое время с точностью до константы).
Задача: |
Два отсортированных по возрастанию массива записаны один в конец другого. Требуется максимально быстро найти элемент в таком массиве. |
Мы имеем массив, образованный из двух отсортированных подмассивов, записанных один в конец другого. Запустить сразу бинарный или тернарный поиски на таком массиве нельзя, так как массив не будет обязательно отсортированным и он не будет иметь [math]1[/math] точку экстремума. Поэтому попробуем найти индекс последнего элемента левого массива, чтобы потом запустить бинарный поиск два раза на отсортированных массивах.
Задача: |
Массив образован путем циклического сдвига массива, образованного приписыванием отсортированного по убыванию массива в конец отсортированного по возрастанию. Требуется максимально быстро найти элемент в таком массиве. |
В случае же убывание-возрастание-убывание ( [math] \searrow \nearrow \searrow [/math] ) может быть, что будет неправильно найден минимум. Найдем правильный минимум аналогично поиску максимума в предыдущем абзаце.
Затем, в зависимости от типа нашего массива, запустим бинарный поиск три раза на каждом промежутке.
Переполнение индекса середины [ править ]
Двоичный поиск
Основные авторы описания: А. В. Чупин.
Синонимы названия метода: двоичный поиск, бинарный поиск, метод деления пополам, метод половинного деления, дихотомия.
Содержание
1 Свойства и структура алгоритма
1.1 Общее описание алгоритма
Метод двоичного поиска используется в качестве быстрого варианта поиска в заранее отсортированном массиве. Получил распространение благодаря как наименьшей из возможных высоте алгоритма, так и из-за ряда своих вычислительных характеристик, а также (в среде нечисленных алгоритмов) из-за своей рекурсивности, то есть лёгкости записи.
Основная идея заключается в дроблении массива на половины и дальнейшем рекурсивном поиске в одной из них.
Альтернативой служит метод линейного поиска (то есть полного перебора), имеющий, соответственно, линейную вычислительную сложность ( [math]O(n)[/math] ), однако работающий на произвольно упорядоченных массивах. Если сравнивать возможности применения обоих методов, то линейный поиск выгоднее применять 1) для поиска в коротких массивах; 2) когда требуется провести поиск небольшое число раз; 3) в потоковом случае. В отличие от него, бинарный поиск требует предобработку (сортировку, сложность которой не менее [math]O(n \log_2 n)[/math] ) и особенно полезен, если массив большой и поиск проводится неоднократное (точнее, более, чем порядка [math]O(\log_2 n)[/math] ) число раз.
Метод позволяет различные обобщения и видоизменения:
Вариации также могут касаться формата и сути вывода результата в отдельных случаях (см. варианты возврата при ненахождении).
Очень близко к метод бинарного поиска по массиву стоит непрерывный аналог — метод бисекции (метод деления пополам) нахождения корня непрерывной функции на заданном отрезке. Самое большое отличие заключается в вычислении значения функции вместо нахождения элемента массива по его индексу, а также использование действительных чисел в качестве аргумента в отличие от целых чисел для индексации элементов массива.
По своему определению, алгоритм может быть записан в одной из двух основных форм — рекурсивной и итеративной. Поскольку вычислительное ядро в них одинаковое, но в рекурсивном есть дополнительная нагрузка по вызову функций и хранению их стека, в данной статье рассматривается только итеративная версия.
1.2 Математическое описание алгоритма
Вычисляемые данные: индекс (номер позиции) элемента, равного искомому (или ответ, что такого элемента нет).
1.3 Вычислительное ядро алгоритма
1.4 Макроструктура алгоритма
По определению алгоритм состоит из рекурсивных вызовов самого себя для меньших массивов. Стандартные макрооперации не используются.
1.5 Схема реализации последовательного алгоритма
В рекурсивном варианте определяется функция, принимающая в числе прочих параметров индексы концов отрезка массива, на котором идёт текущий поиск, а операция «идём к шагу 2» означает вызов той же функции поиска с другими индексами концов отрезка.
В итеративном варианте пп. 2-5 обёрнуты в бесконечный цикл, выход из которого возможен либо в п. 2 (как неуспешный), либо в п. 5, когда значение найдено.
1.6 Последовательная сложность алгоритма
Таким образом, алгоритм обладает логарифмической временной сложностью по данным и даже последовательная реализация достаточно быстра и, в принципе, не нуждается в распараллеливании.
1.7 Информационный граф
Граф алгоритма является параметризованным (зависит от размера массива) и недетерминированным (в смысле, что порядок вычислений зависит от начальных данных).
1.8 Ресурс параллелизма алгоритма
1.9 Входные и выходные данные алгоритма
Дополнительные ограничения: массив упорядочен.
Выходные данные: индекс элемента [math]A[/math] в массиве, если он есть.
Объём выходных данных: один скаляр.
1.10 Свойства алгоритма
Алгоритм абсолютно устойчив, так как действия производятся только над целыми числами, и поэтому погрешность всегда равна 0.
2 Программная реализация алгоритма
Возможная простейшая реализация алгоритма на языке C:
Здесь ряд индексов [math]l_k, m_k, r_k[/math] из математической схемы алгоритма превращён в одну переменную, поскольку требуются только последние значения этих рядов.
2.1 Особенности реализации последовательного алгоритма
Обычно в качестве ответа выводят −1, если число не найдено.
Другие возможные варианты в данной ситуации (выбор зависит от того, как и где будет использоваться метод поиска как базовая единица) [8] :
2.1.1 Частые ошибки при реализации
Несмотря на то, что код достаточно прост, в нём есть несколько ловушек, на которые нужно обратить внимание [9] :
2.2 Локальность данных и вычислений
Пространственная локальность достаточно плохая — начальные обращения в память гарантированно далеки друг от друга. Однако расстояние с номером итерации экспоненциально уменьшается и в конце есть надежда на использование преимуществ локальности обращений.
Временная локальность ещё хуже — один и тот же элемент массива никогда не требуется дважды.
2.2.1 Локальность реализации алгоритма
2.2.1.1 Структура обращений в память и качественная оценка локальности
На рис. 3 представлен профиль обращений в память для реализации последовательного алгоритма двоичного поиска. В программе задействован массив и три переменные, на рисунке показаны обращения только к элементам этого массива. Каждая точка соответствует одной итерации алгоритма.
Видно, что на каждой итерации используется одна ячейка памяти, при этом адреса располагаются достаточно хаотично. Пространственная локальность от очень плохой постепенно улучшается до идеальной, когда идут обращения только в близкие ячейки памяти. Данные из массива никогда не используются повторно, поэтому временная локальность плохая.
2.2.1.2 Количественная оценка локальности
2.3 Возможные способы и особенности параллельной реализации алгоритма
Однако при этом потребуется предусмотреть обмен результатами сравнения своей точки деления на каждом процессоре для определения искомого интервала для следующей итерации. Это потребует минимум одной коллективной операции обмена, что ставит под сомнение эффективность распараллеливания.
2.4 Масштабируемость алгоритма и его реализации
2.4.1 Масштабируемость алгоритма
2.4.2 Масштабируемость реализации алгоритма
2.5 Динамические характеристики и эффективность реализации алгоритма
2.6 Выводы для классов архитектур
Использовать алгоритм в любой реализации для архитектур с разделённой памятью смысла не имеет — из-за нелокального доступа к исходным данным и малого количества вычислительных операций даже в параллельной модификации выигрыш будет съедаться коммуникациями.
Применение систем с общей памятью с помощью OpenMP теоретически имеет смысл, однако для получения хоть какого-то ускорения требуется выполнение нескольких условий:
30 поискам в массиве из миллиарда элементов).
Я не могу написать бинарный поиск
Недавно (буквально два года назад) тут пробегала статья Только 10% программистов способны написать двоичный поиск. Двоичный поиск — это классический алгоритм поиска. Мало того, это еще чрезвычайно простой алгоритм, который можно очень легко описать: берем отсортированный массив, смотрим в середину, если не нашли там число, в зависимости от того, что в середине — ищем это число этим же методом либо в левой части, либо в правой, откидывая средний элемент. Для функций также, просто берем не массив, а функцию. Все очень и очень просто, алгоритм описан почти везде, все баги словлены и описаны.
Так вот, я не могу реализовать двоичный поиск. Для меня он ни капельки не тривиален. Наверное, я ненастоящий программист. А ведь так и есть, я всего-лишь студент, но ведь это не оправдание? Если точнее, я не могу реализовать хороший корректный красивый двоичный поиск. Все время у меня вылезает проблема либо с корректностью, либо с красивостью, либо и с тем, и с другим. Так что, да, заголовок немного желтоват.
Прежде чем читать этот топик, напишите свою версию бинарного поиска — для отсортированного массива. Причем, в зависимости от параметра, поиск должен выдавать или первый элемент, или любой из дублирующих. Еще для сравнения, напишите бинарный поиск для функций
Для начала, я буду писать код на C#. Я надеюсь, поклонники других языков с легкостью поймут мой код. Границы поиска я буду представлять в виде полуинтервала [left; right), т.е. точка left включается, а точка right выключается. Для меня полуинтервалы удобнее, к их поведению я уже привык, кроме того, использование полуинтервалов имеет некоторые преимущества при программировании различных алгоритмов (о чем мы поговорим позже). Вообще, использовать полуинтервалы более правильно с точки зрения поддержки «единого стиля программирования», так как я пишу циклы вот так:а не так:
Я буду писать одновременно рекурсивную и итеративную версию, но мне ближе рекурсивная, так что не обессудьте. Для рекурсивной версии мы сделаем вот такую вот красивую обертку:
Первая попытка
Кстати, теперь можно добавить еще одну причину к использованию полуинтервалов: если у нас есть интервал [left, right], то мы должны его разбить на [left, mid — 1] и [mid + 1; right] (что конечно, чуть проще для запоминания, но весьма странно).
У полуинтервалов различие в индексах равно 1 (одному элементу, который мы выбрасываем), а у интервалов — 2 — магической цифре, взятой с потолка.
Особенно это заметно для сортировки слиянием, где для полуинтервалов различия в индексах нету (массив делится на [left, mid) и [mid, right)), а для интервалов появляется различие равное 1 (так как массив делится на [left, mid] и [mid + 1, right], или [left, mid — 1] и [mid, right]).
Теперь, осталось определить, в какой части массива нужно искать элемент, в зависимости от того, меньше ли средний элемент (array[mid]), чем ключ (key). Сделать это весьма просто — нужно просто подставить сначала один знак, проверить, работает ли программа, а если не работает, то другой :-). Почему-то именно такой способ проверки я и использую. Мне постоянно кажется, что перекомпилировать быстрее, чем «подумать».
Рекурсивно:
Итеративно:
Разбор полетов:
Если внимательно вчитаться в итеративную версию программы, то сразу становится понятно, что если элемента нет, то алгоритм никогда не остановится. Пониманию этого факта очень способствует while(true), которого в рекурсивной версии программы, естественно нет. И хотя я написал кучу реализаций рекурсивных алгоритмов, я все еще иногда сталкиваюсь с тем, что забываю остановить рекурсию. Как можно написать итеративную версию с подвохом, я не знаю.
Вторая попытка
Кстати, останавливать рекурсию мы будем в случае left == right, т.е., когда интервал стал таким — [left, right). Ну или в случае, когда right — left key
Т.е. если флаг descendingOrder не установлен, то выбор идет как обычно, если установлен, то выбор идет наоборот. Но это «хак», и возможно, что нужно написать какой-нибудь комментарий. Я не знаю, что именно он должен содержать.
Рекурсивно:
Итеративно:
Разбор полетов:
На всякий случай: один из вариантов проверки направления сортировки не учитывал, что массив может быть пуст. Я решил не выделять этот фейл в отдельную попытку.
Попытка №4
Дабы мы не попали на еще одну попытку, сразу скажу, что теперь рекурсию нужно останавливать еще и в случае, когда самый первый элемент из полуинтервала равен ключу. Ну а в случае, когда мы узнали, что средний элемент равен ключу у нас есть два варианта. Либо средний следует за первым и нужно выдавать именно его, ибо первый не равен ключу, поскольку рекурсия еще не остановилась. Либо нужно укорачивать область поиска (см. картинку выше)
Бинарный поиск
Бинарный поиск производится в упорядоченном массиве.
При бинарном поиске искомый ключ сравнивается с ключом среднего элемента в массиве. Если они равны, то поиск успешен. В противном случае поиск осуществляется аналогично в левой или правой частях массива.
Алгоритм может быть определен в рекурсивной и нерекурсивной формах.
Количество шагов поиска определится как
где n-количество элементов,
↑ — округление в большую сторону до ближайшего целого числа.
На каждом шаге осуществляется поиск середины отрезка по формуле
mid = (left + right)/2
Если искомый элемент равен элементу с индексом mid, поиск завершается.
В случае если искомый элемент меньше элемента с индексом mid, на место mid перемещается правая граница рассматриваемого отрезка, в противном случае — левая граница.
left = 0, right = 19
mid = (19+0)/2=9
Сравниваем значение по этому индексу с искомым:
mid = (9+19)/2=14
Сравниваем значение по этому индексу с искомым:
84 > 82
Сдвигаем правую границу:
right = mid = 14
mid = (9+14)/2=11
Сравниваем значение по этому индексу с искомым:
mid = (11+14)/2=12
Сравниваем значение по этому индексу с искомым:
mid = (12+14)/2=13
Сравниваем значение по этому индексу с искомым:
82 = 82
Решение найдено!
Чтобы уменьшить количество шагов поиска можно сразу смещать границы поиска на элемент, следующий за серединой отрезка:
left = mid + 1
right = mid — 1