(轉載)如何寫出正確的二分查找?——利用循環不變式理解二分查找及其變體的正確性以及構造方式

原文連接https://www.cnblogs.com/wuyue...html

序言

  本文以經典的二分查找爲例,介紹如何使用循環不變式來理解算法並利用循環不變式在原始算法的基礎上根據須要產生算法的變體。謹以本文獻給在理解算法思路時沒有頭緒而又不甘心於死記硬背的人。程序員

  二分查找究竟有多重要?《編程之美》第2.16節的最長遞增子序列算法,若是想實現O(n2)到O(nlogn)的時間複雜度降低,必須藉助於二分算法的變形。其實不少算法都是這樣,若是出現了在有序序列中元素的查找,使用二分查找總能提高原先使用線性查找的算法。面試

  然而,雖然不少人以爲二分查找簡單,但隨手寫一寫卻不能獲得正確的結果:死循環、邊界條件等等問題伴隨着出現。《編程珠璣》第四章提到:提供充足的時間,僅有約10%的專業程序員可以完成一個正確的二分查找。固然,正確的二分查找和變體在算法書籍以及網絡上隨處可得,可是若是不加以理解,如何掌握?理解時,又每每因想不清楚,只知其一;不知其二,效果有限。我在看相關的變體算法時就以爲一片茫然,不得要領:或許這個算法能夠這麼寫,稍微變下要求就不能這麼寫了;舉正例說明算法在某些狀況下能夠正常工做、舉反例說明算法有錯當然可行,但僅有例子是不夠的,怎樣一勞永逸地證實本身幾經修改的算法之正確?若是每個變體都進行孤立地理解,那麼太花費時間,並且效果也很差。如何解決這個問題?在思考方法和查閱書籍以後發現,仍是要靠循環不變式來完成算法正確性的理論支撐。算法

  或許你曾瞭解過循環不變式,但若是不使用的話,是看不到它的強大之處的:不只僅能幫助你證實算法正確性,同時也幫助你理解算法,甚至能幫助你在基本算法的基礎上,構造出符合要求的相應算法變體。這些都將在後文的各個算法說明中看到。編程

知識準備

  結合《算法導論》和《編程珠璣》,下面說明循環不變式的概念與性質。數組

  循環不變式主要用來幫助理解算法的正確性。形式上很相似與數學概括法,它是一個須要保證正確斷言。對於循環不變式,必須證實它的三個性質:網絡

初始化:它在循環的第一輪迭代開始以前,應該是正確的。框架

保持:若是在循環的某一次迭代開始以前它是正確的,那麼,在下一次迭代開始以前,它也應該保持正確。less

終止:循環可以終止,而且能夠獲得指望的結果。函數

文章說明

(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).

相關文章
相關標籤/搜索