原文链接
序言
本文以经典的二分查找为例,介绍如何使用循环不变式来理解算法并利用循环不变式在原始算法的基础上根据需要产生算法的变体。谨以本文献给在理解算法思路时没有头绪而又不甘心于死记硬背的人。
二分查找究竟有多重要?《编程之美》第2.16节的最长递增子序列算法,如果想实现O(n2)到O(nlogn)的时间复杂度下降,必须借助于二分算法的变形。其实很多算法都是这样,如果出现了在有序序列中元素的查找,使用二分查找总能提升原先使用线性查找的算法。
然而,虽然很多人觉得二分查找简单,但随手写一写却不能得到正确的结果:死循环、边界条件等等问题伴随着出现。《编程珠玑》第四章提到:提供充足的时间,仅有约10%的专业程序员能够完成一个正确的二分查找。当然,正确的二分查找和变体在算法书籍以及网络上随处可得,但是如果不加以理解,如何掌握?理解时,又往往因想不清楚,一知半解,效果有限。我在看相关的变体算法时就觉得一片茫然,不得要领:或许这个算法可以这么写,稍微变下要求就不能这么写了;举正例说明算法在某些情况下可以正常工作、举反例说明算法有错固然可行,但仅有例子是不够的,怎样一劳永逸地证明自己几经修改的算法之正确?如果每一个变体都进行孤立地理解,那么太花费时间,而且效果也不好。如何解决这个问题?在思考方法和查阅书籍之后发现,还是要靠循环不变式来完成算法正确性的理论支撑。
或许你曾了解过循环不变式,但如果不使用的话,是看不到它的强大之处的:不仅仅能帮助你证明算法正确性,同时也帮助你理解算法,甚至能帮助你在基本算法的基础上,构造出符合要求的相应算法变体。这些都将在后文的各个算法说明中看到。
知识准备
结合《算法导论》和《编程珠玑》,下面说明循环不变式的概念与性质。
循环不变式主要用来帮助理解算法的正确性。形式上很类似与数学归纳法,它是一个需要保证正确断言。对于循环不变式,必须证明它的三个性质:
初始化:它在循环的第一轮迭代开始之前,应该是正确的。
保持:如果在循环的某一次迭代开始之前它是正确的,那么,在下一次迭代开始之前,它也应该保持正确。
终止:循环能够终止,并且可以得到期望的结果。
文章说明
(1)在理论推导每次数组减少的长度而不是程序运行时,mid是不能代换成left + (right - left)/2的。这种形式代表了非整型的运算,没有舍去小数部分,而在代码中实际的mid是会舍去小数部分的。
(2)代码部分的=和==意义同C语言;文字说明部分的=代表赋值,==代表等式推导或者逻辑判断,由上下文而定。
(3)除了3和5外,最初的各个变体代码参考于:二分查找,你真的会吗? 为了符合思路的前后连贯和说明循环不变式,做了一些修改。原文的测试很方便,读者可以自行参考。
(4)根据@getgoing的提示和对原始参考代码的回顾,mid = (left+right)/2本身可能导致溢出,比较保险的写法是mid = left + (right-left)/2,如果想通过移位而不是除法提升速度,可以写成mid = left + ((right-left)>>1)。 本文最初版本没有注意到这一点,现在已经修改,这个表达式和最初的比不是很直观,专门在这里说明。当然,在理论推导而不是程序运行时是不必考虑溢出的,mid = (left+right)/2和mid = left + (right-left)/2总是相等,因此(1)和全文中的推导并未对此修改。
例题
1.二分查找值为key的下标,如果不存在返回-1。
循环不变式:
如果key存在于原始数组[0,n-1],那么它一定在[left,right]中。
初始化:
第一轮循环开始之前,处理的数组就是原始数组,这时显然成立。
保持:
每次循环开始前,key存在于待处理数组array[left, ..., right]中。
对于array[mid]<key,array[left, ..., mid]均小于key,key只可能存在于array[mid+1, ..., right]中;
对于array[mid]>key,array[mid, ..., right]均大于key,key只可能存在于array[left, ..., mid-1]中;
对于array[mid]==key,查找到了key对应的下标,直接返回。
在前两种情况中,数组长度每次至少减少1(实际减少的长度分别是mid-left+1和right-mid+1),直到由1(left==right)变为0(left>right),不会发生死循环。
终止:
结束时,left>right,待处理数组为空,表示key不存在于所有步骤的待处理数组,再结合每一步排除的部分数组中也不可能有key,因此key不存在于原数组。
int binsearch(int * array, int length, int key){ if(!array) return -1; int left = 0, right = length,mid; while(left <= right) { mid = left + (right-left)/2; if(array[mid] < key) { left = mid + 1; }else if(array[mid] > key) { right = mid - 1; }else return mid; } return -1;}
2.二分查找返回key(可能有重复)第一次出现的下标x,如果不存在返回-1
循环不变式:
如果key存在于数组,那么key第一次出现的下标x一定在[left,right]中,且有array[left]<=key, array[right]>=key。
初始化:
第一轮循环开始之前,处理的数组是[0,n-1],这时显然成立。
保持:
每次循环开始前,如果key存在于原数组,那么x存在于待处理数组array[left, ..., right]中。
对于array[mid]<key,array[left, ..., mid]均小于key,x只可能存在于array[mid+1, ..., right]中。数组减少的长度为mid-left+1,至少为1。
否则,array[mid]>=key, array[mid]是array[mid, ..., right]中第一个大于等于key的元素,后续的等于key的元素(如果有)不可能对应于下标x,舍去。此时x在[left, ..., mid]之中。数组减少的长度为right-(mid+1)+1,即right-mid,根据while的条件,当right==mid时为0。此时right==left,循环结束。
终止:
此时left>=right。在每次循环结束时,left总是x的第一个可能下标,array[right]总是第一个等于key或者大于key的元素。
那么对应于left==right的情况,检查array[left]即可获得key是否存在,若存在则下标为x;
对于left>right的情况,其实是不用考虑的。因为left==上一次循环的mid+1,而mid<=right。若mid+1>right,意味着mid == right,但此时必有left == right,这一轮循环从开始就不可能进入。
int binsearch_first(int * array, int length,int key){ if(!array) return -1; int left = 0, right = length-1,mid; while(left < right) { mid = left + (right-left)/2; if(array[mid] < key) left = mid+1; else right = mid; } if(array[left] == key) return left; return -1;}
3.二分查找返回key(可能有重复)最后一次出现的下标x,如果不存在返回-1(模仿2的第一版)
循环不变式:
如果key存在于数组,那么key最后一次出现的下标x一定在[left,right]中,且有array[left]<=key, array[right]>=key。
初始化:
第一轮循环开始之前,处理的数组是[0,n-1],这时显然成立。
保持:
每次循环开始前,如果key存在于原数组,那么x存在于待处理数组array[left, ..., right]中。
对于array[mid]<key,array[left, ..., mid]均小于key,x只可能存在于array[mid+1, ..., right]中。数组减少的长度为mid-left+1,至少为1。
对于array[mid]==key, array[mid]是array[left, ..., mid]中最后一个值为key的元素,那么x的候选只能在array[mid, ... ,right]中,数组减少长度为mid-left。除非left == right或left == right-1,否则数组长度至少减小1。由于while的条件,只有后一种情况可能发生,如果不进行干预会陷入死循环,加入判断分支即可解决。
对于array[mid]>key, array[mid, ..., right]均大于key,x只可能在[left, ..., mid-1]之中。数组减少的长度为(right-mid)+1,同样至少为1。
终止:
此时left>=right,right总是从数组末尾向开始的倒序中第一个候选的x,检查它的值是否符合要求即可。
而left总是上一轮删掉失去x资格的元素后的第一个元素,不过这里用不到。
说明:
与上一种不同,这个算法不能简单地根据对称,从上一个算法直接改过来,由于整数除法总是舍弃小数,mid有时会离left更近一些。所以这种算法只是沿着上一个算法思路的改进,看上去并不是很漂亮。
int binsearch_last(int * array, int length, int key){ if(!array) return -1; int left = 0, right = length,mid; while(left < right) { mid = left + (right-left)/2; if(array[mid] > key) right = mid - 1; else if(array[mid] == key) if(left == mid) if(array[right] == key) return right; else return left; else left = mid; else left = mid + 1; } if(array[right] == key) return right; return -1;}
4.二分查找返回key(可能有重复)最后一次出现的下标x,如果不存在返回-1(修改版)
根据3中的讨论,可以发现不能直接照搬的原因是mid=(left+right)/2的舍弃小数,在left+1==right且array[left]=key时,如果不加以人为干预会导致死循环。既然最终需要干预,干脆把需要干预的时机设置为终止条件就行了。
使用while(left<right-1)可以保证每次循环时数组长度都会至少减一,终止时数组长度可能为2(left+1==right)、1(left==mid,上一次循环时right取mid==left),但是不可能为0。(每一次循环前总有left<=mid<=right,无论令left=mid还是令right=mid,都不会发生left>right)。同3一样,right总是指向数组中候选的最后一个可能为key的下标,此时只需先检查right后检查left是否为key就能确定x的位置。这样就说明了循环不变式的保持和终止,就不再形式化地写下来了。
对于两种情况的合并:array[mid] == key时,mid有可能是x,不能将其排除;array[mid]<key时,如果让left = mid+1,不会违反循环不变式的条件。但是由上面的讨论可知,将left=mid也是可以的,在达到终止条件前能保证数组长度单调减少。因此把两种情况合并成最终形式。
int binsearch_last_v2(int * array, int length, int key){ if(!array) return -1; int left =0, right = length-1,mid; while(left < right -1) { mid = left + (right-left)/2; if(array[mid] <= key) left = mid; else right = mid; } if(array[right] == key) return right; else if(array[left] == key) return left; else return -1;}
5.二分查找返回key(可能有重复)最后一次出现的下标x,如果不存在返回-1(利用2的方法)
如果想最大限度地利用已有的函数,那么把需要处理的数组倒序,然后直接使用方法2,再把得到的第一次出现的下标做一次减法就可以得到最后一次出现的下标,略。
6.二分查找返回刚好小于key的元素下标x,如果不存在返回-1
如果第一反应是通过2的方法找出第一个为key的元素,返回它的下标减1,那么就错了:这个二分查找并没有要求key本身在数组中。
循环不变式:
如果原始数组中存在比key小的元素,那么原始数组中符合要求的元素存在于待处理的数组。
初始化:
第一轮循环开始之前,处理的数组是[0,n-1],这时显然成立。
保持:
每次循环开始前,x存在于待处理数组array[left, ..., right]中。
先用一个循环的条件为right>=left,违反则意味着x不存在。写下array[mid]的比较判断分支:
(1) array[mid]<key, 意味着x只可能在array[mid, ..., right]之间,下一次循环令left = mid,数组长度减少了(mid-1)-left+1 == mid-left,这个长度减少量只有在right-left<=1时小于1。
(2)array[mid]>=key,意味着x只可能在array[left ,... ,mid-1]之间,下一次循环令right = mid-1,同样推导出数组长度至少减少了1。
这样,把循环条件缩小为right>left+1,和4一样,保证了(1)中每次循环必然使数组长度减少,而且终止时也和4的情况类似:终止时待处理数组长度只能为2或1或者空(left>right)。
终止:
接着保持中的讨论,结束时,符合的x要么在最终的数组中,要么既不在最终的数组中也不在原始的数组中(因为每一次循环都是剔除不符合要求的下标)。
数组长度为2时,right==left+1,此时先检查right后检查left。如果都不符合其值小于key,那么返回-1。数组长度为1时,只用检查一次;数组长度为0时,这两个都是无效的,检查时仍然不符合条件。把这三种情况综合起来,可以写出通用的检查代码。反过来,根据精简的代码来理解这三种情况比正向地先给出直观方法再精简要难一些。
int binsearch_last_less(int * array, int length, int key){ if(!array) return -1; int left = 0, right = length,mid; while(left < right - 1) { mid = left + (right-left)/2; if(array[mid] < key) left = mid; else right = mid - 1; } if(array[right] < key) return right; else if(array[left] < key) return left; else return -1;}
7.二分查找返回刚好大于key的元素下标x,如果不存在返回-1
和6很类似,但如果只是修改循环中下标的改变而不修改循环条件是不合适的,下面仍要进行严谨的说明和修正。
循环不变式:
如果原始数组中存在比key大的元素,那么原始数组中符合要求的元素对应下标x存在于待处理的数组。
初始化:
第一轮循环开始之前,处理的数组是[0,n-1],这时显然成立。
保持:
每次循环开始前,x存在于待处理数组array[left, ..., right]中。
仍然先把执行while循环的条件暂时写为right>=left,违反则意味着x不存在。写下array[mid]的比较判断分支:
(1) array[mid]<=key, 意味着x只可能在array[mid+1, ..., right]之间,下一次循环令left = mid,数组长度减少了mid-left+1,减少量至少为1。
(2)array[mid]>key,意味着x只可能在array[left ,... ,mid]之间,下一次循环令right = mid,数组长度减少了right-(mid+1)+1== right-mid,只有在right==mid时为0,此时left==right==mid。因此,循环条件必须由right>=left收缩为right>left才能避免left==right时前者会进入的死循环。
终止:
由循环的终止条件,此时left>=right。类似2的分析,left>right是不可能的,只有left==right。此时检查array[right]>key成立否就可以下结论了,它是唯一的候选元素。
补充说明:
如果是对数组进行动态维护,返回值-1可以改为length+1,表示下一个需要填入元素的位置。
int binsearch_first_more(int * array, int length, int key){ if(!array) return -1; int left = 0, right = length-1,mid; while(left < right) { mid = left + (right-left)/2; if(array[mid] <= key) left = mid + 1; else right = mid; } if(array[right] > key) return right; return -1;}
总结:如何写出正确的二分查找代码?
结合以上各个算法,可以找出根据需要写二分查找的规律和具体步骤,比死记硬背要强不少,万变不离其宗嘛:
(1)大体框架必然是二分,那么循环的key与array[mid]的比较必不可少,这是基本框架;
(2)循环的条件可以先写一个粗略的,比如原始的while(left<=right)就行,这个循环条件在后面可能需要修改;
(3)确定每次二分的过程,要保证所求的元素必然不在被排除的元素中,换句话说,所求的元素要么在保留的其余元素中,要么可能从一开始就不存在于原始的元素中;
(4)检查每次排除是否会导致保留的候选元素个数的减少?如果没有,分析这个边界条件,如果它能导致循环的结束,那么没有问题;否则,就会陷入死循环。为了避免死循环,需要修改循环条件,使这些情况能够终结。新的循环条件可能有多种选择:while(left<right)、while(left<right-1)等等,这些循环条件的变种同时意味着循环终止时候选数组的形态。
(5)结合新的循环条件,分析终止时的候选元素的形态,并对分析要查找的下标是否它们之中、同时是如何表示的。
对于(3),有一些二分算法实现不是这样的,它会使left或right在最后一次循环时越界,相应的left或right是查找的目标的最终候选,这一点在理解时需要注意。当然,不利用这个思路也可以写出能完成功能的二分查找,而且易于理解。
应用:
1.查找排序数组中某个数出现的次数。(《剑指Offer》面试题38,2013.7.18更新)
解法:二分查找确定第一次和最后一次出现的下标,差值+1就是出现次数,时间复杂度O(logn).