Java 算法 - 二分法查找
數據結構與算法之美目錄(https://www.cnblogs.com/binarylei/p/10115867.html)
二分法查找是一種非常高效的查找方式,時間復雜度為 O(logn)。
唐納德·克努特(Donald E.Knuth)在《計算機程序設計藝術》的第 3 卷《排序和查找》中說到:"盡管第一個二分查找算法於 1946 年出現,然而第一個完全正確的二分查找算法實現直到 1962 年才出現。"
二分查找原理非常簡單,但想要寫出沒有 Bug 的二分查找並不容易,"十個二分九個錯"。本文先介紹最簡單的一種二分查找的代碼實現,再深入分析幾種二分查找的變形問題。
1. 工作原理
這一部分,我們說的簡單二分查找法,也是精確查找。如 JDK 的 Collections#binarySearch。
public int bsearch(int[] arr, int value) {
int low = 0;
int high = arr.length - 1;
while (low <= high) {
int mid = (low + high) / 2;
if (value < arr[mid]) {
high = mid - 1;
} else if (value == arr[mid]) {
return mid;
} else {
low = mid + 1;
}
}
return -1;
}
說明: 簡單的二分查找非常簡單,但還是有幾個細節需要特別注意一下:
- 循環退出條件。注意是 low <= high,而不是 low < high,否則可能會查找不到數組,反回 -1。
- mid 的取值。因為如果 low 和 high 比較大的話,兩者之和就有可能會溢出。寫成 low + (high - low) / 2,或改寫成位運算 low + ((high - low) >> 1),或 (low + high) >>> 1。
- low 和 high 的更新。如果直接寫成 low = mid 或者 high = mid,就可能會發生死循環。比如,當 high = 3,low = 3 時,如果 a[3] 不等於 value,就會導致一直循環不退出。
改進后的二分查找法如下,以 Collections#binarySearch 為例:
private static <T> int indexedBinarySearch(List<? extends Comparable<? super T>> list, T key) {
int low = 0;
int high = list.size() - 1;
while (low <= high) {
int mid = (low + high) >>> 1;
Comparable<? super T> midVal = list.get(mid);
int cmp = midVal.compareTo(key);
if (cmp < 0)
low = mid + 1;
else if (cmp > 0)
high = mid - 1;
else
return mid; // key found
}
return -(low + 1); // key not found
}
2. 使用場景
二分查找法雖然查找效率高效,但使用條件非常苛刻,場景使用有限:
-
二分查找的底層數據結構必須是數組。因為需要根據下標隨機訪問數組。
-
二分查找針對的是有序數組。二分查找只能用在插入、刪除操作不頻繁,一次排序多次查找的場景中。針對動態變化的數據集合,二分查找將不再適用。
對於頻繁變化的動態數據,二分法不適合。因為每次查找前都需要重新排序,雖然查找的時間復雜度是 O(logn),但排序的時間復雜度是 O(nlogn),因而總的時間復雜度就變成 O(nlogn)。
-
數據量太小不適合二分查找。數據量太小則不能體現二分查找法的優勢,還不如直接順序遍歷。
比如我們在一個大小為 10 的數組中查找一個元素,不管用二分查找還是順序遍歷,查找速度都差不多。只有數據量比較大的時候,二分查找的優勢才會比較明顯。當然,如果數據比較操作非常耗時,不管數據量大小,都推薦使用二分查找。如長度超過 300 的字符串比較。
-
數據量太大也不適合二分查找。數據量太大內存不夠,因為數組必須使用連續的內存進行存儲。二分查找的底層需要依賴數組這種數據結構,而數組為了支持隨機訪問的特性,要求內存空間連續,對內存的要求比較苛刻。比如,我們有 1GB 大小的數據,如果希望用數組來存儲,那就需要 1GB 的連續內存空間。
總結來說:二分法查找底層必須使用有序的靜態數組,對於動態數據,或數據量太小,或太大都不適合用二分法。
思考1:二分查找法的底層數據結構為什么不能是鏈表?
二分查找法每次都獲取鏈表的中間結點,采用快慢結點算法獲取鏈表的中間節點時,快慢指針都要移動鏈表長度的一半次,也就是 n / 2 次,總共需要移動 n 次指針才行。
- 第一次,鏈表長度為 n,需要移動指針 n 次;
- 第二次,鏈表長度為 n / 2,需要移動指針 n / 2 次;
- 第三次,鏈表長度為 n / 4,需要移動指針 n / 4 次;
- ......
- 以此類推,一直到 1 次為值
- 指針移動的總次數 n + n / 2 + n / 4 + n / 8 + ... + 1 = n(1 - 0.52) / (1 - 0.5) = 2n
也就是說,如果采用鏈表的數據結構,僅獲取中間結點的時間復雜度是 O(2n),不僅遠遠大於數組二分查找 O(logn),也要大於順序查找的時間復雜度 O(n)。
思考2:動態數據如何快速查找呢?
我們知道動態數據每次查找前都先進行排序后查找,查找的時間復雜度就變成 O(nlogn)。有沒有好的快速查找方法呢?這時跳表就登場了。跳表使用空間換時間的設計思路,通過構建多級索引來提高查詢的效率,實現了基於鏈表的“二分查找”。跳表是一種動態數據結構,支持快速的插入、刪除、查找操作,時間復雜度都是 O(logn)。
3. 模糊匹配 - 二分法查找法變形
事實上,二分法在精確匹配上使用的並不多,我們可以用 HashMap 等數據結構替換(雖然 HashMap 比數組更耗內存),二分法往往用在模糊查找上。
- 查找第一個值等於給定值的元素
- 查找最后一個值等於給定值的元素
- 查找第一個大於等於給定值的元素
- 查找最后一個小於等於給定值的元素
3.1 查找第一個值等於給定值的元素
public int bsearch(int[] arr, int value) {
int n = arr.length;
int low = 0;
int 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] != value)) return mid;
else high = mid - 1;
}
}
return -1;
}
說明: 只需要在二分查找的基礎上做一點改動即可,如果查找到相等的元素,需要進一步判斷前一個元素是否等於要查找的值,如果等於要查找的值,則需要繼續遞歸。
上述的二分法查找代碼可讀性最好,當然還有一種更高效的寫法,可讀性就稍微差一點了:
public int bsearch(int[] arr, int value) {
int n = arr.length;
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (arr[mid] >= value) {
high = mid - 1;
} else {
low = mid + 1;
}
}
if (low < n && arr[low] == value) return low;
else return -1;
}
3.2 查找最后一個值等於給定值的元素
public int bsearch(int[] arr, int value) {
int n = arr.length;
int low = 0;
int 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] != value)) return mid;
else low = mid + 1;
}
}
return -1;
}
3.3 查找第一個大於等於給定值的元素
public int bsearch(int[] arr, int value) {
int n = arr.length;
int low = 0;
int 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;
}
3.4 查找最后一個小於等於給定值的元素
public int bsearch(int[] arr, int value) {
int n = arr.length;
int low = 0;
int 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;
}
3.5 模糊匹配應用場景
比如,我們需要根據 IP 查找對應的地址,如果數據庫中有 100 萬個 IP 段對應的地址庫,如何高效的進行 IP 匹配呢?比如:
171.43.252.0 ~ 171.43.252.254 武漢
171.43.253.0 ~ 171.43.253.254 廣州
171.43.254.0 ~ 171.43.254.254 上海
...
我們的解決方案是這樣,首先我們知道 IPv4 可以轉換成一個 int 類型數據。我們以每個地址段的起始 IP 進行排序,這樣問題就轉換成了查找最后一個小於等於給定 IP 問題的解,如果查找的 IP 在查找的 IP 段內,就返回這個地址,否則返回空。
每天用心記錄一點點。內容也許不重要,但習慣很重要!
