概述
前面演算法系列文章有寫過分治演算法基本原理和實踐,對於分治演算法主要是理解遞迴的過程。二分法是分治演算法的一種,相比分治演算法會簡單很多,因為少了遞迴的存在。
在電腦科學中,二分查詢演算法(英語:binary search algorithm),也稱折半搜尋演算法(英語:half-interval search algorithm)、對數搜尋演算法(英語:logarithmic search algorithm)[2],是一種在有序陣列中查詢某一特定元素的搜尋演算法。搜尋過程從陣列的中間元素開始,如果中間元素正好是要查詢的元素,則搜尋過程結束;如果某一特定元素大於或者小於中間元素,則在陣列大於或小於中間元素的那一半中查詢,而且跟開始一樣從中間元素開始比較。如果在某一步驟陣列為空,則代表找不到。這種搜尋演算法每一次比較都使搜尋範圍縮小一半。
二分查詢演算法在情況下的複雜度是對數時間。二分查詢演算法使用常數空間,無論對任何大小的輸入資料,演算法使用的空間都是一樣的。除非輸入資料數量很少,否則二分查詢演算法比線性搜尋更快,但陣列必須事先被排序。儘管特定的、為了快速搜尋而設計的資料結構更有效(比如雜湊表),二分查詢演算法應用面更廣。
二分查詢演算法有許多中變種。比如分散層疊可以提升在多個陣列中對同一個數值的搜尋。分散層疊有效的解決了計算幾何學和其他領域的許多搜尋問題。指數搜尋將二分查詢演算法拓寬到無邊界的列表。二叉搜尋樹和B樹資料結構就是基於二分查詢演算法的。
入門 demo
對二分法的概念瞭解後,下面來看一道示例:
153. 尋找旋轉排序陣列中的最小值
已知一個長度為 n 的陣列,預先按照升序排列,經由 1 到 n 次 旋轉 後,得到輸入陣列。例如,原陣列 nums = [0,1,2,4,5,6,7] 在變化後可能得到:
若旋轉 4 次,則可以得到 [4,5,6,7,0,1,2]
若旋轉 7 次,則可以得到 [0,1,2,4,5,6,7]
注意,陣列 [a[0], a[1], a[2], ..., a[n-1]] 旋轉一次 的結果為陣列 [a[n-1], a[0], a[1], a[2], ..., a[n-2]] 。
給你一個元素值 互不相同 的陣列 nums ,它原來是一個升序排列的陣列,並按上述情形進行了多次旋轉。請你找出並返回陣列中的最小元素 。
示例 1:
輸入:nums = [3,4,5,1,2]
輸出:1
解釋:原陣列為 [1,2,3,4,5] ,旋轉 3 次得到輸入陣列。
示例 2:
輸入:nums = [4,5,6,7,0,1,2]
輸出:0
解釋:原陣列為 [0,1,2,4,5,6,7] ,旋轉 4 次得到輸入陣列。
示例 3:
輸入:nums = [11,13,15,17]
輸出:11
解釋:原陣列為 [11,13,15,17] ,旋轉 4 次得到輸入陣列。
提示:
n == nums.length
1 <= n <= 5000
-5000 <= nums[i] <= 5000
nums 中的所有整數 互不相同
nums 原來是一個升序排序的陣列,並進行了 1 至 n 次旋轉
下面來看一下我寫的一個失敗版的答案,此時的我還沒入門二分法:
class Solution { public int findMin(int[] nums) { int left = 0; int right = nums.length-1; while (left<=right) { int middle = left + (right -left)/2; if (nums[middle] > nums[left]) { left = middle + 1; }else { right = right-1; } } return nums[left]; } }
輸入:[4,5,6,7,8,9,10,0,1,2,3]
輸出:10
結果:0
可以看到結果是不對,那這裡的問題是什麼呢?都說失敗是成功之母,我們只有分析清楚為啥我們的解法會存在問題,才能更好地明白二分法的精髓。
先從答案分析,這裡輸出 10,為啥會是 10。
看上面這張圖,程式碼邏輯寫的是 middle > left,那麼 left = middle +1; 這個邏輯這麼寫是沒有問題的。
接著看,當不滿足 middle > left,說明 middle 處於最小值的右半部分,這時候我們讓 right--。那如果 right 就是最小值呢,這時候就會錯過最小值。
還有如果 middle 是最大值呢?那麼 left= middle +1 就是最小值,此時你再去計算 middle ,就直接把最小值錯過了。比如輸入陣列:[5,6,7,8,9,0,1,2,3,4];
還要考慮一種特殊情況,如果此時只有兩個元素了,有兩種情況 [1,2],[2,1] ,這時候如果按照 right--,就會直接取到第一個元素。所以在 middle 和 left 相等的時候也要在做額外的判斷。
完整版通過程式碼如下:
class Solution { public int findMin(int[] nums) { int left = 0; int right = nums.length-1; while (left<right) { int middle = left + (right -left)/2; if (nums[middle] > nums[left] && nums[middle] > nums[right]) { left = middle +1;
// 說明最小值就在最右邊,此時處於只有兩個元素的時候 } else if(middle == left && nums[left] > nums[right]) { left = right; } else { right = right-1; } } return nums[left]; } }
當你看到這段程式碼後,你懵逼了,這還是二分法嘛,分析下來這麼複雜。
那我們來看下官方給的程式碼:
class Solution { public int findMin(int[] nums) { int low = 0; int high = nums.length - 1; while (low < high) { int pivot = low + (high - low) / 2;
// 最小值一定是在和 high 在一個區間內的,所以這裡要判斷 pivot 和 high 的大小關係,不能去判斷和 low 的關係 if (nums[pivot] < nums[high]) { high = pivot; } else { low = pivot + 1; } } return nums[low]; } }
是不是覺得官方程式碼簡潔易懂。
那為啥這兩個解法的程式碼會差這麼多,答案在於 middle 到底是應該和 left 比較,還是應該和 right 比較。
這也說明了方向的選擇的重要性。可是我們應該怎麼選擇呢。這個主要是在分析問題的時候要想清楚。個人覺得也可以這麼理解:
本題是找最小值的。從最小值到最右端,這其實就是單調遞增的,因此我們只要關注右半部分,拋棄左半部分就好。
那麼本題錯誤原因就是跟左邊進行比較,你再怎麼找,最後得出值都不在這一部分上,就導致你得新增很多額外的邏輯來確保可以找到值。
PS:對於二分法要時刻關注只有兩個元素的情況。這時候 middle = left。這時候注意 left 和 right 之間的關係。
通過這道題目相信大家已經對二分法有一定的認識了。
二分法思想
二分查詢的思想就一個:逐漸縮小搜尋區間。 如下圖所示,它像極了「雙指標」演算法,left 和 right 向中間走,直到它們重合在一起:
根據看到的中間位置的元素的值 nums[mid] 可以把待搜尋區間分為三個部分:
- 情況 1:如果 nums[mid] = target,這時候我們直接返回即可。
- 情況 2: target 在 mid 左半部分 [left..mid - 1],此時分別設定 right = mid - 1 ;
- 情況 3: target 在 mid 右半部分 [mid+1..right],此時分別設定 left = mid + 1。
這樣就可以獲得二分法基本模板:
class Solution { public int search(int[] nums, int target) { int left = 0; int right = nums.length - 1; // 確保 left 和 right 都在陣列可取範圍內 while (left <= right) { // < 還是 <= 按照自己的習慣即可 int mid = left + (right -left)/2; if (nums[mid] == target) { // 找到結果就返回 return mid; }else if(nums[mid] > target) { right = mid-1; } else { left = mid +1; } }
// 退出迴圈就說明沒找到 return -1; } }
雖然我們看到的寫法有很多,但思想就這一個;為什麼總是有朋友覺得二分難?因為有很多二分的寫法,雖然都對,但是對於新手朋友們來說有一定干擾,因為不同的寫法其實對應著不同的前提和應用場景,比起套用模板,審題、練習和思考更重要。「二分查詢」就幾行程式碼,完全不需要記憶,也不應該用記憶的方式解題.
下面解釋一些細節:
1、模板的結束條件是 left <= right ,也就是結果一定是在 while 裡面找到的。否則就是沒找到。
2、有些學習資料上說 while (left < right) 表示區間是 [left..rihgt) ,為什麼你這裡是 [left..rihgt]?
區間的右端點到底是開還是閉,完全由編寫程式碼的人決定,不需要固定。主要還是看你 left 和 right 的取值。 如果 right = nums.length ; 那麼其實 right 這個位置是取不到的,也就是開區間了。所以開閉就是看點位能不能取到。
3、有些學習資料給出了三種模板,例如「力扣」推出的 LeetBook 之 「二分查詢專題」,應該如何看待它們?
回答:三種模板其實區別僅在於退出迴圈的時候,區間 [left..right] 裡有幾個數。
-
while (left <= right) :退出迴圈的時候,right 在左,left 在右,區間為空區間,所以要討論返回 left 和 right;
-
while (left < right) :退出迴圈的時候,left 與 right 重合,區間裡只有一個元素,這一點是我們很喜歡的;
-
while (left + 1 < right) :退出迴圈的時候,left 在左,right 在右,區間裡有 2 個元素,需要編寫專門的邏輯。這種寫法在設定 left 和 right 的時候不需要加 1 和減 1。
看似簡化了思考的難度,但實際上遮蔽了我們應該且完全可以分析清楚的細節。退出迴圈以後一定要討論返回哪一個,也增加了編碼的難度。
我個人的經驗是:
-
while (left <= right) 用在要找的數的性質簡單的時候,把區間分成三個部分,在迴圈體內就可以返回;
-
while (left < right) 用在要找的數的性質複雜的時候,把區間分成兩個部分,在退出迴圈以後才可以返回;
-
完全不用 while (left + 1 < right) ,理由是不會使得問題變得更簡單,反而很累贅。
很多題目在二分法的基礎上有變化,我們要學會靈活變化。還要理解題意。
示例:
給定一個排序陣列和一個目標值,在陣列中找到目標值,並返回其索引。如果目標值不存在於陣列中,返回它將會被按順序插入的位置。
請必須使用時間複雜度為 O(log n)
的演算法。
示例 1:
輸入: nums = [1,3,5,6], target = 5 輸出: 2
示例 2:
輸入: nums = [1,3,5,6], target = 2 輸出: 1
示例 3:
輸入: nums = [1,3,5,6], target = 7 輸出: 4
示例 4:
輸入: nums = [1,3,5,6], target = 0 輸出: 0
示例 5:
輸入: nums = [1], target = 0 輸出: 0
提示:
1 <= nums.length <= 104
-104 <= nums[i] <= 104
nums
為無重複元素的升序排列陣列-104 <= target <= 104
class Solution { public int searchInsert(int[] nums, int target) { int left =0; int right = nums.length -1; while (left<=right) { int mid = left + (right-left)/2; if (nums[mid] == target) { return mid; } if (nums[mid]>target) { right = mid-1; }else { left = mid+1; } } // 沒找到,那麼 left 就是它所處的位置 return left; } }
注意一點:二分法只是用於有序陣列,如果是無序的,此時是無法確定邊界的,這時候我們就需要自己創造條件,找到陣列的有序部分。
比如下面兩道,大家可以自己找二分法題目去練習。
33. 搜尋旋轉排序陣列
81. 搜尋旋轉排序陣列 II
關於二分法的理論就講到這裡了,剩下的就是靠大家多多練習了。
演算法系列文章:
參考文章
https://leetcode-cn.com/problems/search-insert-position/solution/te-bie-hao-yong-de-er-fen-cha-fa-fa-mo-ban-python-/