二进制搜索比它看起来更难实现."尽管二元搜索的基本思想相对简单,但细节却令人惊讶地难以理解......" - 唐纳德克努特.
哪些错误最有可能被引入新的二进制搜索实现?
最近刚刚再次提出这个问题.除了Knuth的引用之外,"尽管二元搜索的基本思想相对简单,但细节可能会非常棘手",有一个令人震惊的历史事实(参见TAOCP,第3卷,第6.2.1节),二元搜索首次发布于1946年,第一次发布没有错误的二进制搜索是在1962年.并且有宾利的经验,当他在贝尔实验室和IBM这样的地方为专业程序员分配二进制搜索并给他们两个小时时,每个人都报告他们说得对,并且在检查他们的代码时,90%的人都有年复一年的错误.
也许除了斯特金定律之外,这么多程序员在二元搜索中犯错误的根本原因在于他们不够谨慎:编程珍珠称这是"编写你的代码,把它扔到墙上,并有质量保证或测试交易与错误"接近.并且存在很多错误的余地.不仅仅是这里提到的其他几个答案的溢出错误,而是逻辑错误.
以下是二进制搜索错误的一些示例.这绝不是详尽无遗的.(正如托尔斯泰在安娜卡列尼娜写的那样- "所有幸福的家庭都是一样的;每个不幸的家庭都以自己的方式不开心" - 每一个不正确的二元搜索程序都以自己的方式不正确.)
以下Pascal代码取自Richard E Pattis的二元搜索(1988)中的Textbook errors.他查看了二十本教科书并提出了这种二分法搜索(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,您也应该能够猜出代码的作用.
他描述了许多程序所具有的五个错误,特别是上面的错误:
错误1:它不在O(log n)时间内运行,其中n = Size.在他们对Proper Programming Practice的热情中,一些程序员将二进制搜索作为函数/过程编写,并将其传递给数组.(这不是特定于Pascal;想象一下在C++中通过值而不是通过引用传递向量.)这是Θ(n)时间,只是为了将数组传递给过程,这会破坏整个目的.更糟糕的是,一些作者显然给出了递归二进制搜索,每次都传递一个数组,给出的运行时间为Θ(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) < 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%的时间存在错误 - 直到我找到了编写它的简单方法正确.(因为我记得),我没有错误的二进制搜索.诀窍很简单:
保持不变.
查找/决定并明确表示您的"低"和"高"变量在整个循环中满足的一些不变属性:之前,期间和之后.确保它永远不会被侵犯.当然,您还需要考虑终止条件.这在Pearls编程的第4章中有详细解释,它从半形式方法中导出二进制搜索程序.
例如,要稍微抽象出被检查的条件,假设您要查找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)
始终为true.同样,您设置hi
为mid
仅在poss(mid)
false时,因此您保持poss(hi)
始终为false 的不变量.分别考虑终止条件.(注意,当hi-lo
为1时,mid
是相同的lo
.所以不要把循环写成while(hi>lo)
,否则你将有一个无限循环.)在循环结束时,你保证hi-lo
最多为1,因为你永远保持你的不变性(poss(lo)
是真的,poss(hi)
是假的),它不能是0.而且,再次因为你的不变量,你知道这lo
是返回/打印/使用的值.当然,还有其他方法可以编写二进制搜索,但保持不变是一种总是有帮助的技巧/规则.
以下是我能想到的一些内容:
在确定下一个间隔的边界时,逐个错误
处理重复项目,如果您想要返回数组中的第一个相等项目,而是返回后续相等项目
计算索引时的数字下溢/溢出,具有大型数组
递归与非递归实现,您应该考虑的设计选择
这些是你的想法吗?
读这个.Java的二进制搜索实现在任何人发现它之前隐藏了差不多十年的错误.
这个bug是整数溢出.它没有引起人们的问题,因为几乎没有人在搜索足够大的数据结构.
如果你附近有编程珍珠书,你应该查看第4章.
edit2:正如评论中所指出的,你可以下载我提到作者网站的章节草稿:http://www.cs.bell-labs.com/cm/cs/pearls/sketch04.html