web-dev-qa-db-ja.com

二分探索を実装する際の落とし穴は何ですか?

二分探索は見た目よりも実装が難しいです。 「二分探索の基本的な考え方は比較的単純ですが、詳細は驚くほど難しい場合があります…」—ドナルドクヌース。

新しいバイナリ検索の実装に導入される可能性が最も高いバグはどれですか?

48
joeforker

これが私が考えることができるいくつかです:

  • オフバイワンエラー、次の間隔の境界を決定するとき
  • 重複アイテムの処理、配列内の最初の等しいアイテムを返すと想定されているが、代わりに後続の等しいアイテムを返した場合
  • 数値のアンダーフロー/オーバーフローインデックスを計算するとき、巨大な配列を使用
  • 再帰的vs非再帰的実装、考慮すべき設計上の選択

これらはあなたが考えていることですか?

30
Zach Scrivena

この質問は 最近もう一度尋ねられた でした。 「二分探索の基本的な考え方は比較的単純ですが、詳細は驚くほどトリッキーになる可能性があります」というクヌースの引用とは別に、二分探索が最初に公開されたという驚くべき歴史的事実があります(TAOCP、第3巻、セクション6.2.1を参照)。 1946年ですが、最初に公開されたバイナリ検索バグなしは1962年でした。BentleyがBell LabsやIBMなどの場所でプロのプログラマー向けのコースにバイナリ検索を割り当て、それらを提供したときの経験があります。 2時間後、誰もがそれを正しく理解したと報告し、コードを調べたところ、毎年90%にバグがありました。

おそらく、スタージョンの法則以外に、非常に多くのプログラマーがバイナリ検索で間違いを犯す基本的な理由は、十分に注意していないことです。 Programming Pearls は、これを「コードを書いて、投げる」と引用しています。壁を越えて、品質保証またはテストでバグに対処する」アプローチ。そして、エラーの余地はたくさんあります。ここで他のいくつかの回答が言及しているオーバーフローエラーだけでなく、論理エラーもあります。

以下は、バイナリ検索エラーの例です。これは決して網羅的なものではありません。 (トルストイが Anna Karenina に書いているように、「すべての幸せな家族は同じです。すべての不幸な家族は独自の方法で不幸です」—すべての誤った二分探索プログラムは独自の方法で正しくありません。)

パティス

次のPascalコードは、Richard E Pattisによる論文二分探索の教科書エラー(1988)から抜粋したものです。彼は20冊の教科書を見て、このバイナリ検索を思いつきました(BTW、Pascalは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を初めて見た場合でも、コードが何をするかを推測できるはずです。


彼は多くのプログラムが持っている5つのエラーを説明します、そして特に上記は持っています:

エラー1:O(log n)時間で実行されません。ここで、n =サイズです。適切なプログラミングの実践に熱心に取り組んでいるプログラマーの中には、バイナリ検索を関数/プロシージャとして記述し、配列を渡す人もいます。 (これはPascalに固有のものではありません。C++でベクトルを参照ではなく値で渡すことを想像してください。)これは、配列をプロシージャに渡すためだけのΘ(n)時間であり、目的全体が無効になります。さらに悪いことに、一部の作成者は明らかに recursive バイナリ検索を実行します。これは毎回配列を渡し、実行時間はΘ(n log n)になります。 (これは大したことではありません。実際にこのようなコードを見たことがあります。)

エラー2:サイズ= 0の場合は失敗します。これで問題ない可能性があります。ただし、目的のアプリケーションによっては、検索対象のリスト/テーブルが may 0に縮小されるため、どこかで処理する必要があります。

エラー3:間違った答えを出します。ループの最後の反復がLow = Highで始まる場合(たとえば、Size = 1の場合)、Keyが配列内にある場合でも、Found:= Falseに設定されます。

エラー4Keyが配列の最小要素よりも小さい場合は常にエラーが発生します。 (Indexが1になった後、Highを0に設定するなど、範囲外のエラーが発生します。)

エラー5Keyが配列の最大要素よりも大きい場合は常にエラーが発生します。 (IndexSizeになった後、LowをSize + 1などに設定します。範囲外エラーが発生します。)

彼はまた、これらのエラーを「修正」するいくつかの明白な方法も間違っていることが判明したことを指摘しています。実際のコードにもこの特性があることがよくあります。プログラマーが何か間違ったものを書き、エラーを見つけて、十分に慎重に考えずに正しいと思われるになるまで「修正」します。

彼が試した20冊の教科書のうち、正しい二分探索ができたのは5冊だけでした。残りの15個(皮肉なことに16個)で、彼はエラー1の11個のインスタンス、エラー2の6個のインスタンス、エラー3と4のそれぞれ2個、エラー5の1個を見つけました。これらの数は合計で15をはるかに超えます。それらのいくつかに複数のエラーがあったためです。


その他の例

バイナリ検索は、配列を検索して値が含まれているかどうかを確認するだけではないため、ここではもう1つの例を示します。もっと考えたら、このリストを更新するかもしれません。

増加する(減少しない)関数f:R-> Rがあり、(たとえば、fの根が必要なため)、f(t) < 0のような最大のtを見つけたいとします。以下で見つけることができるバグの数を確認してください。

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章で詳細に説明されています。これはセミフォーマル手法からバイナリ検索プログラムを派生させます

たとえば、調べている条件を少し抽象化するために、ある条件poss(x)が真である最大の整数値xを見つけたいとします。問題定義のこの明確さでさえ、多くのプログラマーが始めた以上のものです。 (たとえば、poss(x)vの値に対してa[x] ≤ vである可能性があります。これは、並べ替えられた配列aの要素がvよりも大きいかどうかを調べるためです。)次に、バイナリ検索を作成する1つの方法は:

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);

さらにassertステートメントやその他のチェックを追加できますが、基本的な考え方は、poss(mid)がtrueであることがわかっているときにlomid only に更新するため、不変条件を維持するということです。そのposs(lo)は常に真です。同様に、poss(mid)がfalseの場合にのみ、himidに設定するため、poss(hi)が常にfalseであるという不変条件を維持します。終了条件については別途ご検討ください。 (hi-loが1の場合、midloと同じであることに注意してください。したがって、ループをwhile(hi>lo)と記述しないでください。そうしないと、無限ループになります。)ループの最後で、 hi-loは最大1であることが保証され、常に不変条件を維持しているため(poss(lo)はtrue、poss(hi)はfalse)、0にすることはできません。あなたの不変量、あなたはloが返す/印刷/使用する値であることを知っています。もちろん、バイナリ検索を作成する方法は他にもありますが、不変条件を維持することは、常に役立つトリック/分野です。

58
ShreevatsaR

これを読んでください 。 Javaのバイナリ検索の実装は、だれもがそれを見つける前に、ほぼ10年間バグを隠していました。

バグは整数オーバーフローです。十分な大きさのデータ構造を検索している人はほとんどいないため、問題は発生しませんでした。

14
Dan Dyer

近くにProgrammingPearlsの本がある場合は、第4章を確認してください。

edit2:コメントで指摘されているように、著者のWebサイトで言及したドラフトの章をダウンロードできます: http://www.cs.bell-labs.com/cm/cs/pearls/sketch04.html ==

7
Tiago

人々が二分探索を正しく実装できない重要な理由の1つは、私たちが二分探索をうまく特徴付けていない、それは明確に定義された問題ですが、通常はうまく定義されていないということです。

普遍的なルールの1つは、失敗から学ぶことです。ここで、「無効な」ケースについて考えると、問題を明確にするのに役立ちます。入力が空の場合はどうなりますか?入力に重複が含まれている場合はどうなりますか?反復ごとに1つの条件付きテストまたは2つのテスト(および早期終了のための追加のテスト)で実装する必要がありますか?インデックスの計算における数値のオーバーフロー/アンダーフローやその他のトリックなど、その他の技術的な問題。

@Zach Scrivenaが指摘したように、問題を適切に特徴付けることで回避できるエラーは、オフバイワンエラーと重複アイテムの処理です。

多くの人は、バイナリ検索を、ソートされた配列を指定してターゲット値を見つけると見なしています。それ自体がバイナリ検索ではなく、バイナリがどのように使用されているかです。

バイナリ検索の比較的厳密な定義/定式化を行い、1つずつエラーと重複する問題を回避する1つの方法を示します 1つの特定のアプローチに準拠することにより、コースは新しいものではありません。

# (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

この定義は、重複する問題を自然に解決します。

配列を表すには、一般に[]と[)の2つの方法があります。後者の方が好きです。代わりに、[)アプローチの同等性は、(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)"および"Falseの場合、Forward(start)"を部分的に正しくすると、ほぼ完了です。私はそれを二分探索の「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

追加テストによる早期終了については、支払う価値はないと思いますが、お試しいただけます。

2
qeatzy

高い値と低い値を合計する2つのインデックスの中間点を計算するときに考慮しないと、整数オーバーフローが発生する可能性があります。

参照

1
tvanfosson