还能不能正确的二分搜索了?
一. 几个比较犯二的错误:
二分搜索貌似很容易,但是想写正确了还真不容易啊!我之前就犯过很多错误!作为算法方面的屌丝,现在把我曾经的错误的想法和写法都一一写出来!
实际上,犯错误不可能一次只犯一个,但为了方面讨论,就一个情况只讨论写一个错误吧,写这些例子代码时,我尽量考虑不出现其他错误,但是真的也保不齐啊!
先定义题目 在已经递增排序的整型数组A[nlen] 里查找 一个整型数 key,如果key 在A里,返回key所在的数组下标,否则,返回-1.
int BinSearch(int A[], size_t nlen, int key)
1. 一个错误的想法:先比较端点以排除 key不在一个区间里的这种可能,误以为这样做会提高效率:
1 int BadSearch1(int A[], size_t nlen, int key) 2 { 3 size_t l = 0; 4 size_t r = nlen - 1; 5 while (true) 6 { 7 if (r < l || key < A[l] || key > A[r]) //bad idea 8 { 9 return -1; 10 } 11 size_t m = (l + r) / 2; 12 if (key == A[m]) 13 return m; 14 else if (key < A[m]) 15 r = m - 1; 16 else 17 l = m + 1; 18 } 19 }
上面的代码是正确的,但line 7 中key < A[l] || key > A[r] 就是属于过度的考虑。虽然在key不在区间的情况下减少了比较的次数,但是在正常情况下,额外增加了两次关键字的比较,如果关键字不是整型的,那么额外增加的比较以及A[l] 与A[r]的读取消耗将变得更加显著。而且对于一个相同的l或者r,会比较多次。
另外这种错误的想法增加了代码的复杂度,代码写得越多,出错的几率也就越大。相反,越简单的实现越不容易出错。
基于比较端点的这种错误思想,本人也写考虑过其他更加复杂,更狗血的实现代码,比如比较端点是否与key值相等的比较:
1 int BadSearch2(int A[], size_t nlen, int key) 2 { 3 size_t l = 0; 4 size_t r = nlen - 1; 5 while (true) 6 { 7 if (r < l ) 8 { 9 return -1; 10 } 11 12 // bad idea start 13 if (key == A[l]) 14 { 15 return l; 16 } 17 else if (key < A[l]) 18 { 19 return -1; 20 } 21 22 if (key == A[r]) 23 { 24 return r; 25 } 26 else if (key > A[r]) 27 { 28 return -1; 29 } 30 // bad idea end 31 32 size_t m = (l + r) / 2; 33 if (key == A[m]) 34 return m; 35 else if (key < A[m]) 36 { 37 r = m - 1; 38 ++l; 39 } 40 else 41 { 42 l = m + 1; 43 r--; 44 } 45 }
相比BadSearch1,BadSearch2更加复杂,相应的,出错的几率更大,时间复杂度也更高,虽然端点比较,缩小了一个元素的搜索区间,但是代价太大了。因为这样一个元素的比较只减少了一个元素的搜索区间,这实际上是把二分搜索的对数复杂度向线性复杂度靠近了。
不知道本人脑子为何会闪现出这样的念头,也许是其他的错误导致了去考虑端点能否搜索上,但是如果能好掌握并充分利用好时间复杂度分析的这一工具,应该就不头脑这样发热了。
也许出现这样奇葩想法的也就只有我一个了!
下面要说的这个错误若隐若现,直到最近这几天重新复习算法的时候才给摁住了,看到了其本质面目!!!
2. 一个比较隐蔽的bug,会导致端点搜索不上。
1 int BadSearch3(int A[], size_t nlen, int key) 2 { 3 size_t l = 0; 4 size_t r = nlen - 1; 5 while (l < r ) 6 { 7 size_t m = (l + r) / 2; 8 if (key == A[m]) 9 return m; 10 else if (key < A[m]) 11 r = m - 1; 12 else 13 l = m + 1; 14 } 15 return -1; 16 }
line5: 我把端点的校验放在了while条件里,但是对终止条件的理解还是不够深入透彻。如果l==r, 则遗漏了端点,直接返回-1. 说难发现,其实也比较容易,就是各种情况都考虑全就可以了!关于类似于<与<=的区别这样的情况,可以好好分析一下。如果在面试的时候,没有电脑帮你运行,那你自己就得充当电脑的角色,给上几种特殊的输入,就能发现可能的错误。
总结:终止条件是r比l小。如果两者相等,说明还有一个元素
3.一种容易发现的bug,影响收敛。
1 int BadSearch4(int A[], size_t nlen, int key) 2 { 3 size_t l = 0; 4 size_t r = nlen - 1; 5 while (true) 6 { 7 if (l > r) 8 { 9 return -1; 10 } 11 size_t m = (l + r) / 2; 12 if (key == A[m]) 13 return m; 14 else if (key < A[m]) 15 r = m;// bug 16 else 17 l = m;// bug 18 }19 }
line 15&17, 新的区间折半时没有排除已经比较过的元素A[m], 如果在电脑上运行,会很快发现死循环,但是如果面试的时候,可能会一时笔误或者对收敛条件理解不透彻而导致这样的错误。
这个在《算法珠玑》一书第五章中也举到了这个例子。同时第四章讲述的利用不变式的初始化,循环保存以及终止条件来检查算法实现的正确性,都是是非常有用的工具,值得去深入学习。
之前看书时只知道直接看算法的实现,却不看重作者的思路。相当于只注重了"术”,却忽略了“道”!捡了芝麻,丢了西瓜!!!
二. 写正确的二分搜索
我给自己总结的关键几点:
1. 首先对算法有深刻的了解,分而治之,一次比较,搜索区间减半,最坏情况(如数组中不存在key,包括key不在[A[0], A[n-1]]范围内的情况, 或者在A[0]或者A[n-1], )下,lg(n)次结束。 所以第一种错误就没有必要发生。因为最坏情况也是完全可以接受的!
2. 学会使用检验算法正确性的方法 ----不变式
3. 学会构造简单的测试数据验证边界条件下的程序处理是否正确。
4. 一般正确的实现都是短小精悍,流畅工整。如果在写的时候,发现自己一会忘记比较这个,一会觉得要查那个,说明自己对算法还没有理解透彻,很可能写不出正确的代码!!!!
最后,把正确的二分搜索实现贴上吧:
1 int BinSearch1(int A[], size_t nlen, int key) 2 { 3 size_t l = 0; 4 size_t r = nlen - 1; 5 while (l <= r) 6 { 7 size_t m = (l + r) / 2; 8 if (key == A[m]) 9 return m; 10 else if (key < A[m]) 11 r = m - 1; 12 else 13 l = m + 1; 14 } 15 return -1; 16 }
再写一个在也许严格递增也许严格循环递增的数组里实现二分查找:
1 //给定一个数组,它或者严格递增,如{1,2,3,4,5,6},或者严格循环递增,如{4,5,6,1,2,3},查找某个给x在数组中位置下标。 2 int BinSearchInLoopIncrementArray(int array[], size_t nlen, int key) 3 { 4 int left = 0; 5 int right = nlen; 6 while (left <= right) 7 { 8 int mid = (left + right) / 2; 9 if (key == array[mid]) 10 return mid; 11 else if (key < array[mid]) 12 { 13 if (array[left] < array[mid] && key < array[left]) 14 { 15 // array[left...mid] strictly increment, 16 // key smaller than all elements in array[left...mid] ---> 17 // So key can't be in array[left...mid] 18 left = mid + 1; 19 } 20 else 21 { 22 // case1: if array[left] > array[mid] > key ---> 23 // array[left...mid] loop-increment, ---> 24 // array[mid...right] have to be strictly increment, since key < array[mid] ---> 25 // key bigger than all element in array[left, mid] ---> 26 // key can't be in array[mid...right] 27 28 // case2: array[left] < array[mid] AND key >= array[left]---> 29 // array[left...mid] strictly increment and key is between array[left] and array[mid] 30 right = mid - 1; 31 } 32 } 33 else 34 { 35 if (array[mid] < array[right] && array[right] < key) 36 { 37 right = mid - 1; 38 } 39 else 40 { 41 left = mid + 1; 42 } 43 } 44 } 45 return -1; 46 }