1. 程式人生 > >算法系列 - 01 二分思想

算法系列 - 01 二分思想

從一個例子開始,兩個人進行猜數遊戲,其中一個人寫下一個數字,另外一個人猜,每猜一個數,給這個人說大了還是小了,繼續猜,比如猜一個100以內的數,寫下的數是64,最多猜7次就可以猜到這個數,這裡就使用了二分思想。   二分思想是一個應用很廣泛的思想,比如對於一個有序陣列,它能將查詢效率從O(n)優化到O(logn),因為每次可以將範圍縮小為上一次的一半。這是在陣列中的應用場景,我們以這個為基礎來分析一下二分查詢的時間複雜度   對於一個有 n 個元素的有序陣列中,每次查詢後縮小資料範圍為上一次的二分之一,所以有  n/2 , n/4 , n/8, … , n/(2^k)    當 n/(2^k) = 1 時,得到最終結果,則 k = logn,記作二分查詢的時間複雜度 O(logn),是一個非常高效的演算法;舉個例子,如果我們在一個40億的資料中查詢某個數,也只需要32次,相對於順序查詢效率提升了太多,可見其威力。   總結一下,二分查詢是針對一個有序集合,每次通過將要查詢的資料範圍縮小為上一次的一半,直到找到目標值,或者區間縮小為0。二分查詢正是在有序陣列上應用了二分思想。   二分思想其實是一種解決問題的思想,為了加速查詢效率而生,所謂的二分並不代表一定是二,也可以是三,可以是N,只是一種表述,表達的意思是以最快的速率將搜尋資料的範圍縮小。  

二分思想在有序陣列上的應用及其變形

  二分細想在陣列上的實現演算法是二分查詢,二分查詢的一般實現,有幾個需要注意的點  
 1 public class BinarySearch {
 2     // 二分查詢實現
 3     public static int search(int[] arr, int target) {
 4         int low = 0, high = arr.length - 1;
 5 
 6         // 這裡的中止條件是 low <= high, 因為 high = arr.length - 1
 7         while (low <= high) {
 8             // 使用 low + (high - low) / 2, 而不使用 (high + low) / 2, 是因為 high + low 可能造成整型溢位
 9             // int mid = low + (high - low) / 2;  // 這種方式是可以的,不如位運算效率高
10             int mid = low + ((high - low) >> 1);  // 這種方式是最優的,效率最高
11 
12             if (arr[mid] == target) {
13                 return mid;
14             } else if (arr[mid] > target) {
15                 high = mid - 1;
16             } else {
17                 low = mid + 1;
18             }
19         }
20         return -1;
21     }
22 
23     // 利用遞迴實現二分查詢
24     public static int searchRecursive(int[] arr, int target) {
25         return recurSearch(arr, target, 0, arr.length - 1);
26     }
27     private static int recurSearch(int[] arr, int target, int left, int right) {
28         // terminator
29         if (left > right) 
30             return -1;
31 
32         int mid = left + ((right - left) >> 1);
33 
34         if (arr[mid] == target) {
35             return mid;
36         } else if (arr[mid] > target) {
37             return recurSearch(arr, target, left, mid - 1);
38         } else {
39             return recurSearch(arr, target, mid + 1, right);
40         }
41     }
42 }
以上是一個常規的二分查詢實現,這個陣列中沒有重複元素,查詢給定值的元素,但是還有更難的:
  1. 查詢第一個值等於給定值的元素位置
  2. 查詢最後一個值等於給定值的元素位置
  3. 查詢第一個大於等於給定值的元素位置
  4. 查詢最後一個小於等於給定值的元素位置

這幾個問題的程式碼都相對難寫,程式碼實現如下:

 

 1 class BinarySearchExt {
 2     // 查詢第一個值等於給定值的元素位置
 3     public static int searchFirst(int[] arr, int target) {
 4         int left = 0, high = arr.length - 1;
 5 
 6         while (left <= right) {
 7             int mid = left + (right - left) / 2;
 8             if (arr[mid] > target) {
 9                 right = mid - 1;
10             } else if (arr[mid] < target) {
11                 left = mid + 1;
12             } else {
13                 if (mid == 0 || arr[mid - 1] != target) return mid;
14                 else high = mid - 1;
15             }
16         }
17 
18         return -1;
19     }
20 
21     // 查詢最後一個值等於給定值的元素位置
22     public static int searchLast(int[] arr, int target) {
23         int left = 0, high = arr.length - 1;
24         while (left <= right) {
25             int mid = left + (right - left) / 2;
26             if (arr[mid] > target) {
27                 right = mid - 1;
28             } else if (arr[mid] < target) {
29                 left = mid + 1;
30             } else {
31                 if ((mid == arr.length - 1) || (arr[mid + 1] != target)) return mid;
32                 else left = mid + 1;
33             }
34         }
35         return -1;
36     }
37 
38     // 查詢第一個大於等於給定值的元素位置
39     public static int searchGte(int[] arr, int target) {
40         int left = 0, right = arr.length - 1;
41         while (left <= right) {
42             int mid = left + ((right - left) >> 1);
43             if (arr[mid] >= target) {
44                 if ((mid == 0) || (arr[mid - 1] < target)) return mid;
45                 else right = mid - 1;
46             } else {
47                 left = mid + 1;
48             }
49         }
50         return -1;
51     }
52 
53     // 查詢最後一個小於等於給定值的元素位置
54     public static int searchLte(int[] arr, int target) {
55         int left = 0, right = arr.length - 1;
56         while (left <= right) {
57             int mid = left + ((right - left) >> 1);
58             if (arr[mid] <= target) {
59                 if ((mid == arr.length - 1) || (arr[mid + 1] > target)) return mid;
60                 else left = mid + 1;
61             } else {
62                 right = mid - 1;
63             }
64         }
65         return -1;
66     }
67 }

 

做個總結,分析一下二分查詢的應用場景:
  1. 二分查詢依賴於順序表結構,如陣列;在連結串列上直接運用二分查詢效率低
  2. 二分查詢需要資料是有序的,亂序的資料集合中無法應用,因為沒有辦法二分;所以對於相對靜態的資料,排序後應用二分查詢的效率還是很不錯的;而對於動態變化的資料集合,維護成本會很高
  3. 資料量太小,發揮不出二分查詢的威力;但是如果比較操作比較耗時,還是推薦使用二分查詢
  4. 資料量太大,記憶體放不下
一個思考題:如何在1000萬整數中快速查詢某個整數呢?要求記憶體限制是100M,可以使用二分查詢,先對1000萬整數分配一個數組,然後進行排序,然後再使用二分查詢。其他的方法,可能無法滿足記憶體限制的問題,比如散列表、跳錶、AVL樹等。 另外一個思考題:如何快速定位一個IP的歸屬地?假設有10萬+的IP地址段和歸屬地的對映關係,我們先對IP地址段的起始地址轉成整數後排序,利用二分查詢“在有序陣列中,查詢最後一個小於等於給定值的元素位置“,這樣就可以找到一個ip段,然後取出來判斷是不是在這個段裡,不在的話,返回未找到;否則返回對應的歸屬地。

延伸之連結串列上的二分思想應用

上面,我們分析說在連結串列上應用二分查詢的效率很低,那麼為什麼呢?分析一下

假設有n個元素的有序連結串列,現在用二分查詢搜尋資料,第一次移動指標次數 n/2,第二次移動 n/4,一直到 1,所以總的移動次數相加就是 n-1 次
可見時間複雜度是 O(n), 這個和順序查詢連結串列的時間複雜度 O(n) 是同級別的,其實二分查詢比順序查詢的效率更低,因為它做了更多次無謂的指標移動

我們知道了二分思想直接應用到連結串列上是不可行的,有沒有其他的辦法,其實有,就是為有序連結串列增加多級索引,在搜尋的時候根據索引應用二分思想。

 

二分查詢常見的演算法題目

搜尋插入位置,這是一道二分查詢的直接應用 實現如下
 1 class Solution {
 2     public int searchInsert(int[] nums, int target) {
 3         int left = 0, right = nums.length - 1;
 4         
 5         int pos = -1;
 6         while (left <= right) {
 7             int mid = left + (right - left) / 2;
 8             if (nums[mid] == target) {
 9                 pos = mid;
10                 break;
11             } else if (nums[mid] > target) {
12                 right = mid - 1;
13             } else {
14                 left = mid + 1;
15             }
16         }
17         return pos == -1 ? left : pos;
18     }
19 }

 

搜尋二維矩陣 搜尋二維矩陣II 有序矩陣中第K小的元素 兩數相除 搜尋旋轉排序陣列 這個題目也是二分查詢的一個拓展題目,程式碼實現如下
 1 class Solution {
 2     public int search(int[] nums, int target) {
 3         if (nums.length == 1) return nums[0] == target ? 0 : -1;
 4         int low = 0, high = nums.length - 1;
 5         
 6         while (low <= high) {
 7             int mid = low + (high - low) / 2;
 8             
 9             if (nums[mid] == target) return mid;
10             
11             // 這裡是關鍵
12             // nums[low] <= target && target < nums[mid] 表示 low mid 是有序的,且target在它們中間,需要將high向前移動
13             // nums[low] > nums[mid] && target > nums[high] 表示 low ~ mid 是無序的,而且 target 比 high 位置的元素還要大,因為 mid ~ high 是有序的,所以必然在 low ~ mid 中間,移動high
14             // nums[low] > nums[mid] && target < nums[mid] 表示 low ~ mid 是無序的, 而且 target 比mid位置處的值還要小,因為 mid ~ high 是有序的,所以必然在 low ~ mid 中間,移動high
15             // 否則,就是移動low
16             if ((nums[low] <= target && target < nums[mid]) || 
17                   (nums[low] > nums[mid] && target > nums[high]) ||
18                   (nums[low] > nums[mid] && target < nums[mid])) {
19                 // 這裡是
20                 high = mid - 1;
21             } else {
22                 low = mid + 1;
23             }
24         }
25         
26         return low == high && nums[low] == target ? low : -1;
27     }
28 }
  搜尋旋轉排序陣列II 在排序陣列中查詢元素的第一個位置和最後一個位置 Pow(x,n) x的平方根,如果是精確到小數後6位呢? 有效的完全平方數 尋找旋轉排序陣列中的最小值
 1 class Solution {
 2     // 關鍵是邊界
 3     public int findMin(int[] nums) {
 4         int low = 0, high = nums.length - 1;
 5         int lastElement = nums[high];
 6         while (low < high) {
 7             int mid = low + ((high - low) >> 1);
 8             // 比最後一個元素小,說明轉折點必定在mid的左邊, 搜尋左邊
 9             if (nums[mid] < lastElement) high = mid;
10             // 否則在右邊
11             else low = mid + 1;
12         }
13         return nums[low];
14     }
15 }
尋找峰值 長度最小的子陣列 完全二叉樹的節點個數 二叉搜尋樹第K小的元素 尋找重複數 最長上升子序列 兩個陣列的交集 兩個陣列的交集II 尋找右區間 找到K個最接近的元素 基於時間的鍵值儲存 在D天內送達包裹的能力 有效括號的巢狀深度 元素和小於等於閾值的正方形的最大邊長