实现二分查找都有哪些陷阱? [关闭]

Posted

技术标签:

【中文标题】实现二分查找都有哪些陷阱? [关闭]【英文标题】:What are the pitfalls in implementing binary search? [closed]实现二分查找有哪些陷阱? [关闭] 【发布时间】:2010-10-04 23:55:16 【问题描述】:

二分搜索比看起来更难实现。 “虽然二分搜索的基本思想相对简单,但细节却出奇地棘手……”——Donald Knuth。

哪些错误最有可能被引入到新的二分搜索实现中?

【问题讨论】:

如果你写了足够多的二分搜索,你就不会问这个问题了。 :-) 现在你知道我的秘密羞耻了。 【参考方案1】:

以下是我能想到的一些:

逐一错误,在确定下一个区间的边界时 重复项的处理,如果您想返回数组中的第一个相等项,而是返回随后的相等项 计算索引时的数值下溢/上溢,具有巨大的数组 递归vs非递归实现,您应该考虑的设计选择

这些是你的想法吗?

【讨论】:

正如 Sreevatsa 指出的那样,不处理空数组是另一回事。我看到很多关于这个缺陷的答案。 只是不是二分搜索,几乎所有分而治之的算法在实现上都比较棘手,但人们会非常适应它【参考方案2】:

Read this。 Java 的二分搜索实现在有人发现之前隐藏了将近十年的错误。

错误是整数溢出。它不会给人们带来问题,因为几乎没有人在搜索足够大的数据结构。

【讨论】:

【参考方案3】:

在计算两个索引之间的中点时,如果不考虑将高值和低值相加可能会导致整数溢出。

Reference

【讨论】:

谦卑。程序员试图实现二分搜索,但不能正确地做一些看似简单的事情,比如取两个数字的平均值。【参考方案4】:

这个问题是just asked again recently。除了 Knuth 的名言“虽然二分搜索的基本思想比较简单,但细节可能出奇地棘手”,还有一个惊人的历史事实(参见 TAOCP,第 3 卷,第 6.2.1 节),二分搜索首次发表于1946 年,但第一次发布的二进制搜索没有错误是在 1962 年。Bentley 的经验是,当他在贝尔实验室和 IBM 等地的专业程序员课程中分配二进制搜索并给他们两个小时时,每个人报告说他们做对了,并且在检查他们的代码时,90% 的人年复一年地有错误。

也许除了鲟鱼定律之外,这么多程序员在使用二分搜索时犯错的根本原因是他们不够小心:Programming Pearls 引用这句话为“写你的代码,把它扔掉墙,并让质量保证或测试处理错误”的方法。并且有很大的错误空间。不仅仅是这里其他几个答案提到的溢出错误,还有逻辑错误。

以下是一些二进制搜索错误的示例。这绝不是详尽无遗的。 (正如托尔斯泰在 Anna Karenina 中所写的那样——“所有幸福的家庭都是相似的;每个不幸的家庭都各有各的不幸”——每个错误的二分搜索程序都有其自身的错误。)

帕蒂斯

以下 Pascal 代码取自 Richard E Pattis 的论文二分搜索中的教科书错误 (1988)。他看了二十本教科书,想到了这个二分搜索(顺便说一句,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,您也应该能够猜到代码的作用。


他描述了许多程序存在的五个错误,尤其是上述错误:

错误 1:它不会在 O(log n) 时间内运行,其中 n = Size。出于对正确编程实践的热情,一些程序员将二进制搜索编写为一个函数/过程,并将其传递给一个数组。 (这不是 Pascal 特有的;想象在 C++ 中通过值而不是通过引用传递向量。)这只是将数组传递给过程的 Θ(n) 时间,这违背了整个目的。更糟糕的是,一些作者显然给出了 recursive 二分搜索,它每次都传递一个数组,给出的运行时间是 Θ(n log n)。 (这并不牵强,我确实见过这样的代码。)

错误 2:当 size = 0 时失败。这可能没问题。但是根据预期的应用程序,正在搜索的列表/表可能缩小到 0,必须在某个地方进行处理。

错误3:给出错误答案。每当循环的最终迭代以 Low=High 开始时(例如,当 Size=1 时),它就会设置 Found:=False,即使 Key 在数组中也是如此。

错误 4:只要Key 小于数组的最小元素,就会导致错误。 (Index 变为 1 后,将High 设置为 0 等;导致越界错误。)

错误 5:只要Key 大于数组的最大元素,就会导致错误。 (Index 变为 Size 后,将 Low 设置为 Size+1 等;导致越界错误。)

他还指出,“修复”这些错误的一些明显方法也被证明是错误的。现实生活中的代码也经常有这个属性,当程序员写了一些不正确的东西,发现了一个错误,然后“修复”它直到它似乎没有仔细考虑是正确的。

在他尝试的 20 本书中,只有 5 本书有正确的二分搜索。在剩下的 15 个(讽刺的是,他说 16 个)中,他发现了 11 个错误 1 ​​实例,6 个错误 2 实例,错误 3 和 4 各两个,以及错误 5 一个。这些数字加起来远远超过 15,因为其中有几个有多个错误。


更多示例

二分搜索不仅仅用于搜索一个数组以查看它是否包含一个值,所以这里再举一个例子。当我想到更多时,我可能会更新此列表。

假设您有一个递增(非递减)函数 f:R->R,并且(例如,因为您想要 f 的根),您想要找到最大的 t,使得 f(t) &lt; 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% 的时间是某个地方出现了错误——直到我找到了简单的方法正确地写它。从那以后(我记得)我没有错误地进行二分搜索。诀窍很简单:

维护一个不变量。

找到/决定并明确一些不变的属性,你的“低”和“高”变量在整个循环中都满足:之前、期间和之后。确保它永远不会被违反。当然你还需要考虑终止条件。这在 Programming Pearls 的第 4 章中有详细解释,它派生一个从半正式方法中得到的二分搜索程序。

例如,为了稍微抽象出正在检查的条件,假设您想要找到某个条件poss(x) 为真的最大整数值x。即使是问题定义的这种明确性也超出了许多程序员的起点。 (例如,poss(x) 对于某个值v 可能是a[x] ≤ 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) 始终为真。类似地,只有当poss(mid) 为假时,您才将hi 设置为mid,因此您保持poss(hi) 始终为假的不变量。单独考虑终止条件。 (注意当hi-lo为1时,midlo相同。所以不要把循环写成while(hi&gt;lo),否则会死循环。)在循环的最后,您可以保证hi-lo 最多为 1,并且因为您始终保持不变量(poss(lo) 为真,poss(hi) 为假),所以它不能为 0。同样,由于您的不变量,您知道lo 是返回/打印/使用的值。当然,还有其他编写二分搜索的方法,但保持不变量是一种总是有帮助的技巧/纪律。

【讨论】:

呃,我最近写信给a post about this,结果和建议非常相似。似乎只有2-3种方式来写搜索。 只是好奇:为什么这个答案突然受到关注?它与某个地方有关吗? @zellio 感谢您发现错误,但我已回滚您的编辑并仅修复了大部分。请讨论它,也请只编辑您认为错误的内容,而不是语言本身。 @ShreevatsaR - 我发现论文的文字更加清晰,因此使用几乎直接的引用来纠正错误对我来说是有意义的。我不确定您所说的“讨论”是什么意思。这个例子现在是正确的,这才是真正重要的。 我喜欢这个主意。但是为了完整起见,由于poss(hi) 必须保持为假,因此您需要特别检查何时(不失一般性)MAX_INT 是答案。在这种情况下,它需要hiMAX_INT+1 开始,这显然是不可能的【参考方案5】:

现代处理器的流水线架构更适合进行线性搜索,而不是进行具有大量决策和分支的二进制搜索。

因此,一个常见的性能错误和一个过早的优化(你知道,这些是万恶之源)在使用简单的线性搜索时使用二分搜索。更快,当然更简单。

根据读取次数,即使对数据进行排序也会使事情变慢。对于简单的键(例如 32 位整数),线性和二进制之间的收支平衡点很容易在 1000 个元素处。

【讨论】:

【参考方案6】:

人们不能正确实现二分搜索的一个关键原因是我们没有很好地描述二分搜索,这是一个定义明确的问题,但通常没有很好地定义它。

一个普遍的规则是从失败中学习。在这里,考虑“无效”案例有助于澄清问题。如果输入为空怎么办?如果输入包含重复项怎么办?每次迭代我应该通过一个条件测试还是两个测试(加上一个额外的提前终止测试)来实现它?以及其他技术问题,例如计算索引中的数值上溢/下溢,以及其他技巧。

正如@Zach Scrivena 所指出的那样,可以通过很好地描述问题来避免的错误是Off-by-one 错误和重复项的处理。

许多人将二分搜索视为在给定排序数组的情况下找到目标值。 这就是使用二进制的方式,而不是二进制搜索本身。

我将尝试给出一个相对严格的二分搜索定义/公式,并展示一种解决一个错误和重复问题的方法,遵循一种特定的方法,这当然不是新的。

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

因此,如果您使 "if True, Recede (end)""if 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

至于通过额外的测试提前终止,我认为不值得,但你可以试试。

【讨论】:

【参考方案7】:

我在实现二分搜索时遇到的一个错误是在计算中间元素时整数范围溢出。众所周知,二分查找的实现步骤是:

    找到中间元素。 检查是否:
目标元素大于中间元素,从中间+1搜索到结束索引 目标元素小于中间元素,从开始到中间1索引搜索 目标元素等于中间元素,返回索引。

在上述所有步骤中,都会重新计算中间元素。现在,中间元素被发现为: 中=(开始+结束)/2 现在,如果结束索引太高(对于一个非常大的数组),它可能会溢出 INT 范围。 所以,一个更好的方法是: mid = start + (end-start)/2

如果你仔细检查,最终它给了我们相同的值,但我们摆脱了我们可能遇到的溢出问题。

此外,正如我们所知,二分查找只能在排序列表上实现。假设在问题中说明输入列表已排序,但未提及它是按升序还是降序排列。在这种情况下,您可能需要添加一个额外的条件来检查。

【讨论】:

以上是关于实现二分查找都有哪些陷阱? [关闭]的主要内容,如果未能解决你的问题,请参考以下文章

二分查找常见笔试面试题

java 二分查找法

二分查找

如何实现数组的二分查找

java泛型 二分查找

Java实现二分查找