數據結構與算法分析:(九)二分查找算法

一、前言

二分查找算法是針對有序數據集合的查找算法,將原本時間複雜度是線性時間提升到了對數時間範圍,大大縮短了搜索時間。二分查找算法的思想非常簡單,但細節是魔鬼。也就是你想寫成沒有bug的二分查找算法很難,出錯原因主要集中在判定條件邊界值的選擇上,很容易就會導致越界或者死循環的情況。

1、思想

二分查找又稱折半查找,它是一種效率較高的查找方法。折半查找的算法思想是將數列按有序化(遞增或遞減)排列,查找過程中採用跳躍式方式查找,即先以有序數列的中點位置爲比較對象,如果要找的元素值小 於該中點元素,則將待查序列縮小爲左半部分,否則爲右半部分。通過一次比較,將查找區間縮小一半。 折半查找是一種高效的查找方法。它可以明顯減少比較次數,提高查找效率。但是,折半查找的先決條件是查找表中的數據元素必須有序。

2、優缺點

優點:比較次數少,查找速度快,平均性能好。
缺點:要求待查表爲有序表,且插入刪除困難。因此,折半查找方法適用於不經常變動而查找頻繁的有序列表。

二、O(logn) 魔鬼查找速度

假設有 1000 條訂單數據,已經按照訂單金額從小到大排序,每個訂單金額都不同,並且最小單位是元。我們現在想知道是否存在金額等於 3 元的訂單。如果存在,則返回訂單數據,如果不存在則返回 null。

最簡單的辦法當然是從第一個訂單開始,一個一個遍歷這 1000 個訂單,直到找到金額等於 3 元的訂單爲止。但這樣查找會比較慢,最壞情況下,可能要遍歷完這 1000 條記錄才能找到。那用二分查找能不能更快速地解決呢?

爲了方便理解,假設只有 10 個訂單,訂單金額分別是:2,3,7,11,19,20,21,90,93。

利用二分思想,每次都與區間的中間數據比對大小,縮小查找區間的範圍。其中,low 和 high 表示待查找區間的下標,mid 表示待查找區間的中間元素下標。

在這裏插入圖片描述

看到了沒?我們兩次就找到了這個爲3元的訂單,簡直暴力。

我們來分析下這麼暴力的算法背後的時間複雜度爲多少。

我們假設數據大小是 n,每次查找後數據都會縮小爲原來的一半,也就是會除以 2。最壞情況下,直到查找區間被縮小爲空,才停止。

在這裏插入圖片描述

可以看出來,這是一個等比數列。其中 n/2k=1 時,k 的值就是總共縮小的次數。而每一次縮小操作只涉及兩個數據的大小比較,所以,經過了 k 次區間縮小操作,時間複雜度就是 O(k)。通過 n/2k=1,我們可以求得 k=log2n,所以時間複雜度就是 O(logn)

二分查找是我們目前爲止遇到的第一個時間複雜度爲 O(logn) 的算法。後面章節我們還會講堆、二叉樹的操作等等,它們的時間複雜度也是 O(logn)。我這裏就再深入地講講 O(logn) 這種對數時間複雜度。這是一種極其高效的時間複雜度,有的時候甚至比時間複雜度是常量級 O(1) 的算法還要高效。爲什麼這麼說呢?

因爲 logn 是一個非常“恐怖”的數量級,即便 n 非常非常大,對應的 logn 也很小。比如 n 等於 2 的 32 次方,這個數很大了吧?大約是 42 億。也就是說,如果我們在 42 億個數據中用二分查找一個數據,最多需要比較 32 次。

我們前面講過,用大 O 標記法表示時間複雜度的時候,會省略掉常數、係數和低階。對於常量級時間複雜度的算法來說,O(1) 有可能表示的是一個非常大的常量值,比如 O(1000)、O(10000)。所以,常量級時間複雜度的算法有時候可能還沒有 O(logn) 的算法執行效率高。

三、二分查找的遞歸與非遞歸實現

1、有序數組中不存在重複元素

// 有序數組中不存在重複元素
public int binarySearch(int[] arr, int n, int value) {
    int low = 0, high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] > value) {
            high = mid - 1;
        } else if (arr[mid] < value) {
            low = mid + 1;
        } else {
            return mid;
        }
    }
    return -1;
}

容易錯的幾個點:

  • 循環退出條件
    注意是 low<=high,而不是 low。

  • mid 的取值
    實際上,很多人寫法是這個mid=(low+high)/2,這種寫法是有問題的。因爲如果 low 和 high 比較大的話,兩者之和就有可能會溢出。改進的方法是將 mid 的計算方式寫成 low+(high-low)/2。更進一步,如果要將性能優化到極致的話,我們可以將這裏的除以 2 操作轉化成位運算 low+((high-low)>>1)。因爲相比除法運算來說,計算機處理位運算要快得多。

  • low 和 high 的更新
    low=mid+1,high=mid-1。注意這裏的 +1 和 -1,如果直接寫成 low=mid 或者 high=mid,就可能會發生死循環。比如,當 high=3,low=3 時,如果 arr[3]不等於 value,就會導致一直循環不退出。

2、二分查找的遞歸實現

// 二分查找的遞歸實現
public int binarySearchRecursive(int[] arr, int n, int value) {
    return binarySearchInternally(arr, 0, n - 1, value);
}

private int binarySearchInternally(int[] arr, int low, int high, int value) {
    if (low > high) return -1;
    int mid = low + ((high - low) >> 1);
    if (arr[mid] > value) {
        binarySearchInternally(arr, low, mid - 1, value);
    } else if (arr[mid] < value) {
        binarySearchInternally(arr, mid - 1, high, value);
    } else {
        return mid;
    }
    return -1;
}

四、幾種常見的二分查找變形

不知道你有沒有聽過這樣一個說法:“十個二分九個錯”。二分查找雖然原理極其簡單,但是想要寫出沒有 Bug 的二分查找並不容易。我們接下來來看一下以下幾種變形問題。

1、查找目標值區域的左邊界/查找與目標值相等的第一個位置/查找第一個不小於目標值數的位置

比如下面這樣一個有序數組,其中,a[5],a[6],a[7]的值都等於 20,是重複的數據。我們希望查找第一個等於 20 的數據,也就是下標是 5 的元素。

在這裏插入圖片描述
如果我們用上面講的二分查找的代碼實現,首先拿 20 與區間的中間值 arr[4]比較,20 比 19 大,於是在下標 5 到 8 之間繼續查找。下標 5 和 8 的中間位置是下標 6,arr[6]正好等於 8,所以代碼就返回了。

儘管 arr[6] 也等於 20,但它並不是我們想要找的第一個等於 20 的元素,因爲第一個值等於 20的元素是數組下標爲 5 的元素。我們上面講的二分查找代碼就無法處理這種情況了。所以,針對這個變形問題,我們可以稍微改造一下上一小節的代碼。

// 查找與目標值相等的第一個位置
public int binarySearch_1(int[] arr, int n, int value) {
    int low = 0, high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] > value) {
            high = mid - 1;
        } else if (arr[mid] < value) {
            low = mid + 1;
        } else {
            if (mid == 0 || arr[mid - 1] != mid) {
                return mid;
            } else {
                high = mid - 1;
            }
        }
    }
    return -1;
}

LeetCode參考:35. Search Insert Position

2、查找目標值區域的右邊界/查找與目標值相等的最後一個位置/查找最後一個不大於目標值數的位置

如果你掌握了前面的寫法,那這個問題你應該很輕鬆就能解決。

// 查找與目標值相等的最後一個位置
public int binarySearch_2(int[] arr, int n, int value) {
    int low = 0, high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] > value) {
            high = mid - 1;
        } else if (arr[mid] < value) {
            low = mid + 1;
        } else {
            if (mid == n - 1 || arr[mid + 1] != mid) {
                return mid;
            } else {
                low = mid + 1;
            }
        }
    }
    return -1;
}

3、查找最後一個小於目標值的數/查找比目標值小但是最接近目標值的數

此題可有第1小題變形而來

arr [2, 3, 7, 11, 19, 20, 20, 20, 93]
target 20
return 4

// 查找最後一個小於目標值的數
public static int binarySearch_3(int[] arr, int n, int value) {
    int low = 0, high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] >= value) {
            high = mid - 1;
        } else {
            if (mid == n - 1 || arr[mid + 1] >= value) {
                return mid;
            } else {
                low = mid + 1;
            }
        }
    }
    return -1;
}

4、查找第一個大於目標值的數/查找比目標值大但是最接近目標值的數

// 查找第一個大於目標值的數
public static int binarySearch_4(int[] arr, int n, int value) {
    int low = 0, high = n - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] > value) {
            if (mid == 0 || arr[mid - 1] <= value) {
                return mid;
            } else {
                high = mid - 1;
            }
        } else {
            low = mid + 1;
        }
    }
    return -1;
}

5、旋轉數組返回最小元素

5.1、查找旋轉數組的最小元素(假設不存在重複數字)

LeetCode參考:153. Find Minimum in Rotated Sorted Array

Input: [3,4,5,1,2]
Output: 1

// 查找旋轉數組的最小元素(假設不存在重複數字)
public static int binarySearch_5(int[] arr, int n) {
    int low = 0, high = n - 1;
    while (low < high) {
        int mid = low + ((high - low) >> 1);
        if(arr[mid] > arr[high])
            low = mid + 1;
        else{
            high = mid;
        }
    }
    return arr[low];
}

意這裏和之前的二分查找的幾點區別:

  • 循環判定條件爲low < high,沒有等於號。
  • 循環中,通過比較arr[low]與arr[mid]的值來判斷mid所在的位置。
  • 如果arr[mid] > arr[high],說明前半部分是有序的,最小值在後半部分,令low = mid + 1。
  • 如果arr[mid] <= arr[high],說明最小值在前半部分,令high = mid。

最後,left會指向最小值元素所在的位置。

5.2、查找旋轉數組的最小元素(存在重複項)

LeetCode參考:154. Find Minimum in Rotated Sorted Array II

Input: [2,2,2,0,1]
Output: 0

// 查找旋轉數組的最小元素(存在重複項)
public static int binarySearch_6(int[] arr, int n) {
    int low = 0, high = n - 1;
    while (low < high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] > arr[high]) {
            low = mid + 1;
        } else if (arr[mid] < arr[high]) {
            high = mid;
        } else {
            high--;
        }
    }
    return arr[low];
}

和之前不存在重複項的差別是:當arr[mid] == arr[high]時,我們不能確定最小值在 mid的左邊還是右邊,所以我們就讓右邊界減一。


6、在旋轉排序數組中搜索

6.1、不考慮重複項

LeetCode參考:33. Search in Rotated Sorted Array

// 在旋轉排序數組中搜索 (不考慮重複項)
public static int binarySearch_7(int[] arr, int target) {
    int low = 0, high = arr.length - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] == target) return mid;
        if (arr[low] <= arr[mid]) { // 左邊升序,注意此處用小於等於
            if (target >= arr[low] && target < arr[mid]) { // 在左邊範圍內
                high = mid - 1;
            } else { // 只能從右邊找
                low = mid + 1;
            }
        } else { // 右邊升序
            if (target <= arr[high] && target > arr[mid]) { // 在右邊範圍內
                low = mid + 1;
            } else { // 只能從左邊找
                high = mid - 1;
            }
        }
    }
    return -1;
}

6.2、存在重複項

LeetCode參考:81. Search in Rotated Sorted Array II

// 在旋轉排序數組中搜索 (存在重複項)
public static boolean binarySearch_8(int[] arr, int target) {
    int low = 0, high = arr.length - 1;
    while (low <= high) {
        int mid = low + ((high - low) >> 1);
        if (arr[mid] == target) return true;
        if (arr[low] == arr[mid]) {
            low++;
            continue;
        }
        if (arr[low] <= arr[mid]) { // 左邊升序,注意此處用小於等於
            if (target >= arr[low] && target < arr[mid]) { // 在左邊範圍內
                high = mid - 1;
            } else { // 只能從右邊找
                low = mid + 1;
            }
        } else { // 右邊升序
            if (target <= arr[high] && target > arr[mid]) { // 在右邊範圍內
                low = mid + 1;
            } else { // 只能從左邊找
                high = mid - 1;
            }
        }
    }
    return false;
}

如果你把幾道二分查找算法題都能自己做的出來,那麼你對二分查找掌握的到了一定水平了。

五、二分查找應用場景的侷限性

前面我們已經分析了二分查找的時間複雜度是 O(logn),查找數據的效率非常高。不過,並不是什麼情況下都可以用二分查找,它的應用場景是有很大侷限性的。那什麼情況下適合用二分查找,什麼情況下不適合呢?

1、二分查找依賴的是順序表結構,簡單點說就是數組。

那二分查找能否依賴其他數據結構呢?比如鏈表。答案是不可以的,主要原因是二分查找算法需要按照下標隨機訪問元素。我們在數組和鏈表那兩節講過,數組按照下標隨機訪問數據的時間複雜度是 O(1),而鏈表隨機訪問的時間複雜度是 O(n)。所以,如果數據使用鏈表存儲,二分查找的時間複雜就會變得很高。

二分查找只能用在數據是通過順序表來存儲的數據結構上。如果你的數據是通過其他數據結構存儲的,則無法應用二分查找。

2、二分查找針對的是有序數據。

二分查找對這一點的要求比較苛刻,數據必須是有序的。如果數據沒有序,我們需要先排序。前面章節裏我們講到,排序的時間複雜度最低是 O(nlogn)。所以,如果我們針對的是一組靜態的數據,沒有頻繁地插入、刪除,我們可以進行一次排序,多次二分查找。這樣排序的成本可被均攤,二分查找的邊際成本就會比較低。

但是,如果我們的數據集合有頻繁的插入和刪除操作,要想用二分查找,要麼每次插入、刪除操作之後保證數據仍然有序,要麼在每次二分查找之前都先進行排序。針對這種動態數據集合,無論哪種方法,維護有序的成本都是很高的。

所以,二分查找只能用在插入、刪除操作不頻繁,一次排序多次查找的場景中。針對動態變化的數據集合,二分查找將不再適用。那針對動態數據集合,如何在其中快速查找某個數據呢?別急,等到二叉樹那一節我會詳細講。

3、數據量太小不適合二分查找。

如果要處理的數據量很小,完全沒有必要用二分查找,順序遍歷就足夠了。比如我們在一個大小爲 10 的數組中查找一個元素,不管用二分查找還是順序遍歷,查找速度都差不多。只有數據量比較大的時候,二分查找的優勢纔會比較明顯。

不過,這裏有一個例外。如果數據之間的比較操作非常耗時,不管數據量大小,我都推薦使用二分查找。比如,數組中存儲的都是長度超過 300 的字符串,如此長的兩個字符串之間比對大小,就會非常耗時。我們需要儘可能地減少比較次數,而比較次數的減少會大大提高性能,這個時候二分查找就比順序遍歷更有優勢。

4、數據量太大也不適合二分查找。

二分查找的底層需要依賴數組這種數據結構,而數組爲了支持隨機訪問的特性,要求內存空間連續,對內存的要求比較苛刻。比如,我們有 1GB 大小的數據,如果希望用數組來存儲,那就需要 1GB 的連續內存空間。

注意這裏的“連續”二字,也就是說,即便有 2GB 的內存空間剩餘,但是如果這剩餘的 2GB 內存空間都是零散的,沒有連續的 1GB 大小的內存空間,那照樣無法申請一個 1GB 大小的數組。而我們的二分查找是作用在數組這種數據結構之上的,所以太大的數據用數組存儲就比較喫力了,也就不能用二分查找了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章