如何寫出正確的二分查詢?——利用迴圈不變式理解二分查詢及其變體的正確性以及構造方式
序言
本文以經典的二分查詢為例,介紹如何使用迴圈不變式來理解演算法並利用迴圈不變式在原始演算法的基礎上根據需要產生演算法的變體。謹以本文獻給在理解演算法思路時沒有頭緒而又不甘心於死記硬背的人。
二分查詢究竟有多重要?《程式設計之美》第2.16節的最長遞增子序列演算法,如果想實現O(n2)到O(nlogn)的時間複雜度下降,必須藉助於二分演算法的變形。其實很多演算法都是這樣,如果出現了在有序序列中元素的查詢,使用二分查詢總能提升原先使用線性查詢的演算法。
然而,雖然很多人覺得二分查詢簡單,但隨手寫一寫卻不能得到正確的結果:死迴圈、邊界條件等等問題伴隨著出現。《程式設計珠璣》第四章提到:提供充足的時間,僅有約10%的專業程式設計師能夠完成一個正確的二分查詢。當然,正確的二分查詢和變體在演算法書籍以及網路上隨處可得,但是如果不加以理解,如何掌握?理解時,又往往因想不清楚,一知半解,效果有限。我在看相關的變體演算法時就覺得一片茫然,不得要領:或許這個演算法可以這麼寫,稍微變下要求就不能這麼寫了;舉正例說明演算法在某些情況下可以正常工作、舉反例說明演算法有錯誤固然可行,但僅有例子是不夠的,怎樣一勞永逸地證明自己幾經修改的演算法之正確?如果每一個變體都進行孤立地理解,那麼太花費時間,而且效果也不好。如何解決這個問題?在思考方法和查閱書籍之後發現,還是要靠迴圈不變式來完成演算法正確性的理論支撐。
或許你曾瞭解過迴圈不變式,但如果不使用的話,是看不到它的強大之處的:不僅僅能幫助你證明演算法正確性,同時也幫助你理解演算法,甚至能幫助你在基本演算法的基礎上,構造出符合要求的相應演算法變體。這些都將在後文的各個演算法說明中看到。
知識準備
結合《演算法導論》和《程式設計珠璣》,下面說明迴圈不變式的概念與性質。
迴圈不變式主要用來幫助理解演算法的正確性。形式上很類似與數學歸納法,它是一個需要保證正確斷言。對於迴圈不變式,必須證明它的三個性質:
初始化:它在迴圈的第一輪迭代開始之前,應該是正確的。
保持:如果在迴圈的某一次迭代開始之前它是正確的,那麼,在下一次迭代開始之前,它也應該保持正確。
終止:迴圈能夠終止,並且可以得到期望的結果。
文章說明
(1)在推導每次陣列減少的長度時,mid是不能代換成(left+right)/2的。這種形式代表了非整型的運算,沒有捨去小數部分,而在程式碼中實際的mid是會捨去小數部分的。
(2)程式碼部分的=和==意義同C語言;文字說明部分的=代表賦值,==代表等式推導或者邏輯判斷,由上下文而定。
(3)除了3和5外,最初的各個變體程式碼參考於:二分查詢,你真的會嗎? 為了符合思路的前後連貫和說明迴圈不變式,做了一些修改。原文的測試很方便,讀者可以自行參考。
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)/2;
- if(array[mid] < key)
- {
- left = mid + 1;
- }elseif(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)/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)/2;
- if(array[mid] > key)
- right = mid - 1;
- elseif(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)/2;
- if(array[mid] <= key)
- left = mid;
- else
- right = mid;
- }
- if(array[right] == key)
- return right;
- elseif(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)/2;
- if(array[mid] < key)
- left = mid;
- else
- right = mid - 1;
- }
- if(array[right] < key)
- return right;
- elseif(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,陣列長度減少了mi