資料結構和演算法面試題系列—二分查詢演算法詳解

ssjhust發表於2018-09-26

這個系列是我多年前找工作時對資料結構和演算法總結,其中有基礎部分,也有各大公司的經典的面試題,最早釋出在CSDN。現整理為一個系列給需要的朋友參考,如有錯誤,歡迎指正。本系列完整程式碼地址在 這裡

0 概述

二分查詢本身是個簡單的演算法,但是正是因為其簡單,更容易寫錯。甚至於在二分查詢演算法剛出現的時候,也是存在bug的(溢位的bug),這個bug直到幾十年後才修復(見《程式設計珠璣》)。本文打算對二分查詢演算法進行總結,並對由二分查詢引申出來的問題進行分析和彙總。若有錯誤,請指正。本文完整程式碼在 這裡

1 二分查詢基礎

相信大家都知道二分查詢的基本演算法,如下所示,這就是二分查詢演算法程式碼:

/**
 * 基本二分查詢演算法
 */
int binarySearch(int a[], int n, int t)
{
    int l = 0, u = n - 1;
    while (l <= u) {
        int m = l + (u - l) / 2; // 同(l+u)/ 2,這裡是為了防溢位
        if (t > a[m])
            l = m + 1;
        else if (t < a[m])
            u = m - 1;
        else
            return m;
    }
    return -(l+1);
}
複製程式碼

演算法的思想就是:從陣列中間開始,每次排除一半的資料,時間複雜度為O(lgN)。這依賴於陣列有序這個性質。如果t存在陣列中,則返回t在陣列的位置;否則,不存在則返回-(l+1)

這裡需要解釋下為什麼t不存在陣列中時不是返回-1而要返回-(l+1)。首先我們可以觀察 l 的值,如果查詢不成功,則 l 的值恰好是 t 應該在陣列中插入的位置。

舉個例子,假定有序陣列a={1, 3, 4, 7, 8}, 那麼如果t=0,則顯然t不在陣列中,則二分查詢演算法最終會使得l=0 > u=-1退出迴圈;如果t=9,則t也不在陣列中,則最後l=5 > u=4退出迴圈。如果t=5,則最後l=3 > u=2退出迴圈。因此在一些演算法中,比如DHT(一致性雜湊)中,就需要這個返回值來使得新加入的節點可以插入到合適的位置中,在求最長遞增子序列的NlgN演算法中,也用到了這一點,參見博文最長遞增子序列演算法

還有一個小點就是之所以返回-(l+1)而不是直接返回 -l 是因為 l 可能為0,如果直接返回 -l 就無法判斷是正常返回位置0還是查詢不成功返回的0。

2 查詢有序陣列中數字第一次出現位置

現在考慮一個稍微複雜點的問題,如果有序陣列中有重複數字,比如陣列a={1, 2, 3, 3, 5, 7, 8},需要在其中找出3第一次出現的位置。這裡3第一次出現位置為2。這個問題在《程式設計珠璣》第九章有很好的分析,這裡就直接用了。演算法的精髓在於迴圈不變式的巧妙設計,程式碼如下:

/**
 * 二分查詢第一次出現位置
 */
int binarySearchFirst(int a[], int n, int t)
{
    int l = -1, u = n;
    while (l + 1 != u) {
        /*迴圈不變式a[l]<t<=a[u] && l<u*/
        int m = l + (u - l) / 2; //同(l+u)/ 2
        if (t > a[m])
            l = m;
        else
            u = m;
    }
    /*assert: l+1=u && a[l]<t<=a[u]*/
    int p = u;
    if (p>=n || a[p]!=t)
        p = -1;
    return p;
}
複製程式碼

演算法分析:設定兩個不存在的元素a[-1]和a[n],使得a[-1] < t <= a[n],但是我們並不會去訪問這兩個元素,因為(l+u)/2 > l=-1, (l+u)/2 < u=n。迴圈不變式為l<u && t>a[l] && t<=a[u] 。迴圈退出時必然有l+1=u, 而且a[l] < t <= a[u]。迴圈退出後u的值為t可能出現的位置,其範圍為[0, n],如果t在陣列中,則第一個出現的位置p=u,如果不在,則設定p=-1返回。該演算法的效率雖然解決了更為複雜的問題,但是其效率比初始版本的二分查詢還要高,因為它在每次迴圈中只需要比較一次,前一程式則通常需要比較兩次。

舉個例子:對於陣列a={1, 2, 3, 3, 5, 7, 8},我們如果查詢t=3,則可以得到p=u=2,如果查詢t=4,a[3]<t<=a[4], 所以p=u=4,判斷a[4] != t,所以設定p=-1。 一種例外情況是u>=n, 比如t=9,則u=7,此時也是設定p=-1.特別注意的是,l=-1,u=n這兩個值不能寫成l=0,u=n-1。雖然這兩個值不會訪問到,但是如果改成後面的那樣,就會導致二分查詢失敗,那樣就訪問不到第一個數字。如在a={1,2,3,4,5}中查詢1,如果初始設定l=0,u=n-1,則會導致查詢失敗。

擴充套件 如果要查詢數字在陣列中最後出現的位置呢?其實這跟上述演算法是類似的,稍微改一下上面的演算法就可以了,程式碼如下:

/**
 * 二分查詢最後一次出現位置
 */
int binarySearchLast(int a[], int n, int t)
{
    int l = -1, u = n;
    while (l + 1 != u) {
        /*迴圈不變式, a[l] <= t < a[u]*/
        int m = l + (u - l) / 2;
        if (t >= a[m])
            l = m;
        else
            u = m;
    }
    /*assert: l+1 = u && a[l] <= t < a[u]*/
    int p = l;
    if (p<=-1 || a[p]!=t)
        p = -1;
    return p;
}
複製程式碼

當然還有一種方法可以將查詢數字第一次出現和最後一次出現的程式碼寫在一個程式中,只需要對原始的二分查詢稍微修改即可,程式碼如下:

/**
 * 二分查詢第一次和最後一次出現位置
 */
int binarySearchFirstAndLast(int a[], int n, int t, int firstFlag)
{
    int l = 0;
    int u = n - 1;
    while(l <= u) {
        int m = l + (u - l) / 2;
        if(a[m] == t) { //找到了,判斷是第一次出現還是最後一次出現
            if(firstFlag) { //查詢第一次出現的位置
                if(m != 0 && a[m-1] != t)
                    return m;
                else if(m == 0)
                    return 0;
                else
                    u = m - 1;
            } else {   //查詢最後一次出現的位置
                if(m != n-1 && a[m+1] != t)
                    return m;
                else if(m == n-1)
                    return n-1;
                else
                    l = m + 1;
            }
        }
        else if(a[m] < t)
            l = m + 1;
        else
            u = m - 1;
    }

    return -1;
}
複製程式碼

3 旋轉陣列元素查詢問題

題目

把一個有序陣列最開始的若干個元素搬到陣列的末尾,我們稱之為陣列的旋轉。例如陣列{3, 4, 5, 1, 2}為{1, 2, 3, 4, 5}的一個旋轉。現在給出旋轉後的陣列和一個數,旋轉了多少位不知道,要求給出一個演算法,算出給出的數在該陣列中的下標,如果沒有找到這個數,則返回-1。要求查詢次數不能超過n。

分析

由題目可以知道,旋轉後的陣列雖然整體無序了,但是其前後兩部分是部分有序的。由此還是可以使用二分查詢來解決該問題的。

解1:兩次二分查詢

首先確定陣列分割點,也就是說分割點兩邊的陣列都有序。比如例子中的陣列以位置2分割,前面部分{3,4,5}有序,後半部分{1,2}有序。然後對這兩部分分別使用二分查詢即可。程式碼如下:

/**
 * 旋轉陣列查詢-兩次二分查詢
 */
int binarySearchRotateTwice(int a[], int n, int t)
{
    int p = findRotatePosition(a, n); //找到旋轉位置
    if (p == -1)
        return binarySearchFirst(a, n, t); //如果原陣列有序,則直接二分查詢即可

    int left = binarySearchFirst(a, p+1, t); //查詢左半部分
    if (left != -1)
        return left; //左半部分找到,則直接返回

    int right = binarySearchFirst(a+p+1, n-p-1, t); //左半部分沒有找到,則查詢右半部分
    if (right == -1)
        return -1;

    return right+p+1;  //返回位置,注意要加上p+1
}

/**
 * 查詢旋轉位置
 */
int findRotatePosition(int a[], int n)
{
    int i;
    for (i = 0; i < n-1; i++) {
        if (a[i+1] < a[i])
            return i;
    }
    return -1;
}
複製程式碼

解2:一次二分查詢

二分查詢演算法有兩個關鍵點:1)陣列有序;2)根據當前區間的中間元素與t的大小關係,確定下次二分查詢在前半段區間還是後半段區間進行。

仔細分析該問題,可以發現,每次根據 lu 求出 m 後,m 左邊([l, m])和右邊([m, u])至少一個是有序的。a[m]分別與a[l]和a[u]比較,確定哪一段是有序的。

  • 如果左邊是有序的,若 t<a[m] && t>a[l], 則 u=m-1;其他情況,l =m+1
  • 如果右邊是有序的,若 t> a[m] && t<a[u]l=m+1;其他情況,u =m-1; 程式碼如下:
/**
 * 旋轉陣列二分查詢-一次二分查詢
 */
int binarySearchRotateOnce(int a[], int n, int t)
{
    int l = 0, u = n-1;
    while (l <= u) {
        int m = l + (u-l) / 2;
        if (t == a[m])
            return m;
        if (a[m] >= a[l]) { //陣列左半有序
            if (t >= a[l] && t < a[m])
                u = m - 1;
            else
                l = m + 1;
        } else {       //陣列右半段有序
            if (t > a[m] && t <= a[u])
                l = m + 1;
            else
                u = m - 1;
        }   
    }   
    return -1; 
}
複製程式碼

參考資料

相關文章