Какие подводные камни в реализации бинарного поиска? - PullRequest
47 голосов
/ 02 февраля 2009

Бинарный поиск сложнее реализовать, чем кажется. «Хотя основная идея бинарного поиска сравнительно проста, детали могут быть на удивление хитрыми…» - Дональд Кнут.

Какие ошибки, скорее всего, появятся в новой реализации двоичного поиска?

Ответы [ 7 ]

58 голосов
/ 18 июня 2011

Этот вопрос был , только недавно заданный снова . Помимо цитаты Кнута о том, что «хотя основная идея бинарного поиска сравнительно проста, детали могут быть на удивление хитрыми», существует удивительный исторический факт (см. TAOCP, том 3, раздел 6.2.1), что бинарный поиск впервые был опубликован в 1946, но первый опубликованный бинарный поиск без ошибок был в 1962 году. И есть опыт Бентли, когда он назначал бинарный поиск на курсах для профессиональных программистов в таких местах, как Bell Labs и IBM, и давал им два часа, все сообщали они поняли это правильно, и при проверке кода 90% из них имели ошибки - год за годом.

Возможно, основная причина, по которой многие программисты допускают ошибки в бинарном поиске, помимо закона Стерджона, заключается в том, что они недостаточно внимательны: Программирование Pearls цитирует это как "напиши свой код, брось его поверх стену, и у нас есть контроль качества или тестирование с подходом к ошибкам ». И есть много возможностей для ошибки. Не только ошибка переполнения, о которой упоминают некоторые другие ответы, но и логические ошибки.

Ниже приведены некоторые примеры ошибок бинарного поиска. Это ни в коем случае не является исчерпывающим. (Как пишет Толстой в Анна Каренина - «Все счастливые семьи одинаковы; каждая несчастная семья по-своему несчастна» - каждая неправильная программа двоичного поиска неверна по-своему.)

Pattis

Следующий код Паскаля взят из статьи Ошибки учебника при бинарном поиске (1988) Ричарда Э. Паттиса. Он просмотрел двадцать учебников и предложил этот двоичный поиск (кстати, Паскаль использует индексы массивов, начиная с 1):

PROCEDURE BinarySearch (A         : anArray,
                        Size      : anArraySize,
                        Key       : INTEGER,
                        VAR Found : BOOLEAN;
                        VAR Index : anArrayIndex);
Var Low, High : anArrayIndex;
BEGIN         
   LOW := 1;
   High := Size;

   REPEAT
      Index := (Low + High) DIV 2;
      If Key < A[Index]
         THEN High := Index - 1
         ELSE Low  := Index + 1
   UNTIL (Low > High) OR (Key = A[Index]);

   FOUND := (Low <= High)
END;

выглядит хорошо? Это имеет более одной ошибки. Прежде чем читать дальше, посмотрите, сможете ли вы найти их все. Вы должны быть в состоянии угадать, что делает код, даже если вы впервые видите Pascal.


Он описывает пять ошибок, которые есть во многих программах, и, в частности, в приведенных выше:

Ошибка 1 : не выполняется за время O (log n), где n = размер. В своем увлечении правильной практикой программирования некоторые программисты пишут бинарный поиск как функцию / процедуру и передают ему массив. (Это не является специфическим для Паскаля; представьте, что в C ++ передача вектора осуществляется по значению, а не по ссылке.) Это - (n) время просто передать массив в процедуру, что наносит ущерб всей цели. Хуже того, некоторые авторы, по-видимому, дают рекурсивный бинарный поиск, который каждый раз пропускает массив, давая время выполнения, которое равно & Theta; (n log n). (Это не надумано; я действительно видел такой код.)

Ошибка 2 : сбой при размере = 0. Это может быть нормально. Но в зависимости от предполагаемого приложения, список / таблица, в которой выполняется поиск , может уменьшиться до 0, и его нужно где-то обработать.

Ошибка 3 : неверный ответ. Всякий раз, когда последняя итерация цикла начинается с Low = High (например, когда Size = 1), он устанавливает Found: = False, даже если Key находится в массиве.

Ошибка 4 : Вызывает ошибку всякий раз, когда Key меньше, чем наименьший элемент массива. (После того как Index становится 1, он устанавливает High в 0 и т. Д .; вызывает ошибку за пределами допустимого диапазона.)

Ошибка 5 : Вызывает ошибку всякий раз, когда Key больше, чем самый большой элемент массива. (После того, как Index станет Size, для Low будет задано значение Размер + 1 и т. Д .; возникнет ошибка за пределами допустимого диапазона.)

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

Из 20 учебников, которые он пробовал, только 5 имели правильный двоичный поиск. В оставшихся 15 (по иронии судьбы он говорит 16) он обнаружил 11 случаев ошибки 1, шесть случаев ошибки 2, по две ошибки 3 и 4 и одну ошибку 5. Эти цифры составляют в целом гораздо больше 15, потому что у нескольких из них было несколько ошибок.


Дополнительные примеры

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

Предположим, у вас есть возрастающая (неубывающая) функция f: R-> R, и (например, если вы хотите получить корень из f), вы хотите найти наибольшее t такое, что f(t) < 0. Посмотрите, сколько ошибок вы можете найти в следующем:

float high = INF, low = 0;
while(high != low) {
   float mid = (low + high)/2;
   if(f(mid)>0) high=mid;
   else low=mid;
}
printf("%f", high);

(Некоторые: в [0, INF] может не быть такого t, если f равно 0 на интервале, тогда это неправильно, никогда не сравнивайте числа с плавающей запятой для равенства и т. Д.)

Как написать бинарный поиск

Раньше я делал несколько таких ошибок - первые несколько десятков раз я писал бинарный поиск (который был во время соревнований по программированию с нехваткой времени), примерно в 30% случаев где-то была ошибка - пока я не нашел простой способ написать это правильно. С тех пор я не ошибся в двоичном поиске (насколько я помню). Трюк очень прост:

Поддерживать инвариант.

Найдите / определите и сделайте явное некое инвариантное свойство, которому ваши «низкие» и «высокие» переменные удовлетворяют на протяжении всего цикла: до, во время и после. Убедитесь, что он никогда не нарушается. Конечно, вам также нужно подумать о состоянии прекращения. Это подробно объясняется в главе 4 Programming Pearls , которая выводит программу двоичного поиска из полуформальных методов.

Например, чтобы немного абстрагировать исследуемое условие, предположим, что вы хотите найти наибольшее целочисленное значение x, для которого выполняется некоторое условие poss(x). Даже такая явность определения проблемы - это больше, чем многие программисты начинают с. (Например, poss(x) может быть a[x] ≤ v для некоторого значения v; это узнать, сколько элементов в отсортированном массиве a больше, чем, скажем, v.) Затем один способ записи бинарный поиск:

int lo=0, hi=n;
//INVARIANT: poss(lo) is true, poss(hi) is false
//Check and ensure invariant before starting binary search
assert(poss(lo)==true);
assert(poss(hi)==false);
while(hi-lo>1) {
    int mid = lo + (hi-lo)/2;
    if(poss(mid)) lo = mid;
    else hi = mid;
}
printf("%d \n",lo);

Вы можете добавить больше утверждений утверждений и других проверок, но основная идея заключается в том, что, поскольку вы обновляете lo до mid только , когда вы знаете, что poss(mid) является истинным, вы поддерживаете инвариант что poss(lo) всегда верно. Точно так же вы устанавливаете hi на mid только тогда, когда poss(mid) ложно, поэтому вы поддерживаете инвариант, что poss(hi) всегда ложно. Подумайте об условиях расторжения отдельно. (Обратите внимание, что когда hi-lo равно 1, mid совпадает с lo. Поэтому не пишите цикл как while(hi>lo), иначе у вас будет бесконечный цикл.) В конце цикла, вам гарантировано, что hi-lo не больше 1, и поскольку вы всегда сохраняете свой инвариант (poss(lo) - истина, а poss(hi) - ложь), оно не может быть 0. Кроме того, опять же из-за вашего инварианта вы знаете, что lo - это значение для возврата / печати / использования. Конечно, есть и другие способы написания бинарного поиска, но поддержание инварианта - это хитрость / дисциплина, которая всегда помогает.

29 голосов
/ 02 февраля 2009

Вот некоторые из них, которые я могу вспомнить:

  • Непостоянные ошибки , при определении границы следующего интервала
  • Обработка повторяющихся элементов , если вы предполагаете вернуть первый равный элемент в массиве, но вместо этого вернули следующий равный элемент
  • Числовые потери / переполнения при вычислении индексов с огромными массивами
  • Рекурсивная против нерекурсивная реализация, выбор дизайна, который вы должны рассмотреть

Это то, что вы имеете в виду?

14 голосов
/ 02 февраля 2009

Прочтите это . Реализация бинарного поиска в Java скрывала ошибку почти десять лет, пока никто ее не нашел.

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

7 голосов
/ 02 февраля 2009

Если у вас есть книга «Программирование жемчужин», вы должны проверить главу 4.

edit2: Как указано в комментариях, вы можете скачать черновик главы, о которой я упомянул на сайте автора: http://www.cs.bell -labs.com / cm / cs / pearls / sketch04.html

2 голосов
/ 23 апреля 2016

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

Одно универсальное правило - учиться на ошибках. Здесь размышления о «недействительных» случаях помогают прояснить проблему. Что если ввод пуст? что если вход содержит дубликаты? я должен выполнить это одним условным тестом или двумя тестами (плюс дополнительный тест для раннего завершения) за итерацию? и другие технические проблемы, такие как численное переполнение / недостаточное вычисление в вычислительных индексах и другие хитрости.

Ошибки, которых можно избежать, если хорошо охарактеризовать проблему, - это ошибки «Off-by-one» и обработка дубликатов, как указывал @Zach Scrivena.

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

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

# (my) definition of binary search:
input: 
    L: a 'partially sorted' array, 
    key: a function, take item in L as argument
prerequisite: 
    by 'partially sorted' I mean, if apply key function to all item of L, we get a 
    new array of bool, L_1, such that it can't be partitioned to two left, right blocks, 
    with all item in left being false, all item in right being true. 
    (left or/and right could be empty)
output: 
    the index of first item in right block of L_1 (as defined in prerequisite). 
    or equivalently, the index of first item in L such that key(item) == True

это определение естественным образом решает проблему дублирования.

Обычно для обозначения массивов существует два способа, [] и [), я предпочитаю последний, вместо этого эквивалентный подход [] использует пару (start, count).

# Algorithm: binary search
# input: L: a 'partially sorted' array, key: a function, take item in L as argument
    while L is not empty:
        mid = left + (right - left)/2  # or mid = left + count/2
        if key(mid item) is True:
            recede right # if True, recede right
        else:
            forward left # if False, forward left
    return left

Таким образом, если вы сделаете ваше "если True, Recede (end)" * и "if False, Forward (start)" part right, то вы почти закончили. Я называю это «правилом FFTR» бинарного поиска. Если вы собираетесь найти первый элемент, как в приведенном выше определении, слева будет запуск, однако справа будет запуск, если вы собираетесь найти последний пункт Если вы соответствуете [] моде, то возможный инструмент,

while left<right:
    mid = left + (right - left)/2
    if key(L(mid)) == True:
        right = mid
    else:
        left = mid+1
    return left

позволяет подтвердить это, сначала показав сходимость, а затем показать правильность.

конвергенция:

whenever left == right, we exit loop (also true if being empty at the first)

so, in the loop, if denote, 

    diff = (right - left)/2, 

    lfstep = 1 + diff/2, 'lfstep' for 'left index forward step size'

    rbstep = diff - diff/2, 'rbstep' for 'right index back (recede) step size'

it can be show that lfstep and rbstep are alway positive, so left and right 
will be equal at last. 

both lfstep and rbstep are asymptotically half of current subarray size, so it's 
of logarithm time complexity.

правильность:

if the input array is empty:
    return the left index;
else:
    if key(mid item) is true:
        "recede right"
        so mid and all item after mid are all true, we can reduce the search range 
        to [left, mid), to validate it, there are two possible cases,
        case 1:
            mid is the first item such that key(item) is True, so there are no true items 
            in new search range [left, mid), so the test will always be false, and we 
            forward left at each iteration until search range is empty, that is  
            [finalleft,mid), since we return finalleft, and finalleft == mid, correctly done!
        case 2:
            there are item before mid such that key(item) is True,
            in this case we just reduce to a new problem of smaller size
    else:
        "forward left"
        mid and all item before mid is false, since we are to find the first true one, 
        we can safely reduce to new search range [mid+1, right) without change the result.

эквивалентная (начало, счет) версия,

while count>0:
    mid = start + count/2
    if key(L[mid]) == True:
        right = mid
    else:
        left = mid+1
return left

для суммирования , если соответствует [) соглашению,

1. define your key function of your problem, 
2. implement your binary search with "FFTR rule" -- 
    "recede (end) if True ( key(item) == True) else forward (start)" 
    examples:
        if to find a value target, return index or -1 if not found,
        key = lambda x: x>=target, 
        if L[found_index] == target: return found_index
        else: return -1

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

1 голос
/ 02 февраля 2009

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

Ссылка

0 голосов
/ 24 февраля 2012

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

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

В зависимости от количества операций чтения, даже сортировка данных может замедлить процесс. Точка безубыточности между линейным и двоичным кодами может легко составлять 1000 элементов для простых ключей (например, 32-разрядных целых).

...