1. 程式人生 > 實用技巧 >二分查詢及其優化

二分查詢及其優化

概念

二分查詢,又稱折半查詢。

  • 基本思想減小查詢序列的長度分而治之地進行關鍵字的查詢。
  • 前提:該序列必須是有序的。
  • 查詢過程:在有序表中,取中間的記錄作為比較關鍵字,若給目標值與中間記錄的關鍵字相等,則查詢成功;若目標值小於中間記錄的關鍵字,則在中間記錄的左半區間繼續查詢;若目標值大於中間記錄的關鍵字,則在中間記錄的右半區間繼續查詢;不斷重複這個過程,直到查詢成功,否則查詢失敗。
  • 實現:通常設定3個指標low, high, mid。二分查詢要求陣列必須是有序的,可以是升序,也可以是降序。

基礎實現

// 二分查詢
int half_search(int *num, int len, int
tar) { int left = 0, right = len - 1; int mid = 0; while (left <= right) { mid = left + (right - left) / 2; if (num[mid] == tar) return mid + 1; else { if (num[mid] > tar) right = mid - 1; else left = mid + 1; } } return
0; }

時間複雜度

實際上,二分查詢的過程可以繪製成一棵二叉樹,每次二分查詢的過程就相當於把原來的樹劃分為兩棵子樹,所以每次二分之後下次就只需要查詢其中一半的資料就可以了。在最好的情況下,只需要查詢一次就可以了,因為這時候中間記錄的關鍵字與要查詢的tar是相等,自然一次就夠了。在最壞的情況下是從根節點查詢到最下面的葉子結點,這個過程需要的時間複雜度是$O(log_n)$。

優缺點

需要注意的是,雖然二分查詢演算法的效率很高(這也是二分查詢演算法被廣泛應用的原因),但是仍然是有使用條件的:有序。所以在需要頻繁進行插入或者刪除操作的資料記錄中使用二分查詢演算法不太划算,因為要維持資料的有序還需要額外的排序開銷

變種:迴圈右(左)移

題目描述

升序陣列a經過迴圈右移後,使用二分查詢目標元素x。

如$a = {1,2,3,4,5,6,7}$,則迴圈移動後$a = {5,6,7,1,2,3,4}$。

解題思路

(1)類似正常的二分查詢,變化是不斷移動左右邊界,所以判斷條件更加複雜一點。
(2)每次計算中間元素mid,和左邊界的元素left比較,總能確定有一邊的區間是升序的;
(3)然後對升序那邊進行分析,若這個區間不可能包含x,則不再考慮這個區間;若可能包含x,則將查詢範圍限制在這個區間。即每次都可以排除一半區間

程式碼實現(C)

int b_search(int *num, int len, int tar) {
    int left = 0, right = len - 1;
    int mid = 0;
    while (left <= right) {
        mid = left + (right - left) / 2;
        if (num[mid] == tar) return mid + 1;
        if (num[mid] > num[left]) { //左邊是升序
            if (num[left] > tar) left = mid + 1;
            else {
                if (num[mid] > tar)  right = mid - 1;
                else left = mid + 1;
            }
        } else { //右邊是升序
            if (num[right] < tar) right = mid - 1;
            else {
                if (num[mid] < tar) left = mid + 1;
                else right = mid - 1;
            }
        }
    }
    return 0;
}

優化一:插值查詢演算法

可以發現二分查詢每次都是選取中間的記錄關鍵字作為劃分依據的,而在有些情況下,使用二分查詢演算法並不是最合適的。舉個例子:在1~1000中,一共有1000個關鍵字,如果要查詢目標值10,按照二分查詢演算法,需要從500開始劃分,這樣的話效率就比較低了,所以有人提出了插值查詢演算法。說白了就是改變劃分的比例,比如三分或者四分。

插值查詢演算法對二分查詢演算法的改進主要體現在mid的計算公式上,其計算公式為:$$mid = left + \frac{tar - num[left]}{num[right] - num[left]}(right - left)$$

而原來的二分查詢公式為:$$mid = left +\frac{1}{2}(right - left)$$

主要變化的地方是$\farc{1}{2}$這個比例係數。其思想可以總結為:插值查詢是根據要查詢的目標值與查詢表中最大最小記錄的關鍵字比較之後的查詢演算法核心是上述mid的計算公式。由於大體框架與二分查詢演算法是一致的,所以時間複雜度仍然是$O(log_n)$

優化二:斐波那契查詢演算法

從前面的分析中可以看到,無論劃分的關鍵字太大或者太小都不合適,所以又有人提出了斐波那契查詢演算法,其利用了黃金分割比原理來實現

一個數列如果滿足$F(n) = F(n - 1) + F(n - 2)$,則稱這個數列為斐波那契數列。在斐波那契查詢演算法中計算mid的公式如下:$$mid = left + F(k - 1) - 1$$

斐波那契查詢的前提是待查詢的查詢表必須順序儲存並且有序

波那契查詢與折半查詢很相似,根據斐波那契序列的特點對有序表進行分割。要求待查詢陣列的長度為某個斐波那契數:len = Fk - 1。則

首先將tar值與第$F(k - 1)$位置的記錄進行比較,即mid = low + F(k - 1) - 1。比較的結果分為三種:

<1> tar == num[mid],mid位置的元素即為所求;

<2> tar > num[mid],則low = mid + 1,k -= 2。前者說明待查詢的元素在[mid + 1, high]範圍內,後者說明範圍[mid + 1, high]內的元素個數為$n - F(k - 1) = Fk - 1 - F(k - 1) = Fk - F(k - 1) - 1 = F(k - 2) - 1$個,所以可以遞迴地應用斐波那契查詢;

<3> key < num[mid],則high = mid - 1, k -= 1。前者說明待查詢的元素在[low, mid - 1]範圍內,後者說明範圍[low, mid - 1]內的元素個數為$F(k - 1) - 1 個,所以可以遞迴地應用斐波那契查詢;

程式碼實現(C)

void fib_arr(int *F, int n) {
    F[0] = 0;
    F[1] = 1;
    for (int i = 2; i < n; i++) 
        F[i] = F[i - 1] + F[i - 2];
    return;
}

int fib_search(int *num, int len, int tar, int *F) {
    int left = 0, right = len - 1;
    int mid = 0;
    int *F = (int *)malloc(20 * sizeof(int));
    fib_arr(F, 20); // 構造一個長度為20的斐波拉契數列
    int k = 0;
    while (F[k] - 1 < len) k++; // 根據待查詢陣列的長度len確定k的值
    //將陣列num擴充套件到F[k]-1的長度 
    int *temp = (int *)malloc((F[k] - 1) * sizeof(int));
    memcpy(temp, num, len * sizeof(int));
    for (int i = len; i < F[k] - 1; i++) temp[i] = num[len - 1];
    
    while (left <= right) {
        mid = left + F[k - 1] - 1;
        if (num[mid] == tar) {
            if (mid < len) return mid + 1; //若相等, 則說明mid即為查詢到的位置  
            else return len; //若mid >= len, 則說明是擴充套件的數值,返回len
        } else if (num[mid] > tar) {
            right = mid - 1;
            k -= 1;
        } else {
            left = mid + 1;
            k -= 2;
        }
    }
    free(temp);
    free(F);
    return 0;
}

斐波那契查詢的核心是:

  1. tar = num[mid]時,查詢成功;
  2. tar < num[mid]時,新的查詢範圍是第left個到第mid - 1個,此時範圍個數為F[k - 1] - 1個,即陣列左邊的長度,所以要在[low, F[k - 1] - 1]範圍內查詢;
  3. tar > num[mid]時,新的查詢範圍是第mid + 1個到第right個,此時範圍個數為F[k - 2] - 1個,即陣列右邊的長度,所以要在[F[k - 2] - 1]範圍內查詢。

關鍵點1:

關於斐波那契查詢, 如果要查詢的記錄在右側,則左側的資料都不用再判斷了,不斷反覆進行下去,對處於當中的大部分資料,其工作效率要高一些。所以儘管

斐波那契查詢的時間複雜度也為$O(log_n)$,但就平均效能來說,斐波那契查詢要優於折半查詢。可惜如果是最壞的情況,比如這裡tar = 1,那麼始終都處於左側在查詢,則查詢效率低於折半查詢

關鍵點2:

(1)折半查詢是進行加法與除法運算:mid = left + (right - left) / 2;

(2)插值查詢則進行更復雜的四則運算:mid = left + (right - left) * ((tar- num[left]) / (num[left] - num[left]));

(3)而斐波那契查詢只進行最簡單的加減法運算:mid = left + F[k-1] - 1,海量資料的查詢過程中,這種細微的差別可能會影響最終的效率

(整理自網路)

參考資料:

https://blog.csdn.net/hacker00011000/article/details/48252131