前言
說到二分查找很多人都是耳熟能詳,這個算法基本是每個工科生(不僅僅是計算機相關專業)的必備知識點,在各種算法的題目中出現的頻率也是極高的。然而很多考題並不會簡簡單單的去讓你實現是個二分算法,而是通過各種變形來考驗同學們對二分查找算法的理解程度,比如在在排序數組中查找元素的第一個和最后一個位置以及數組中的第K個最大元素這兩道題里面就要用到二分搜索來尋找邊界點和逼近最后的正確答案。我猜大多數人可能和我以前一樣也是僅僅大概知道二分搜索這個東西並且能夠簡單實現,但是對於二分搜索的變形並不清楚。這篇文章將從最簡單的二分查找開始講起,然后用兩個簡單的二分搜索的變形的題目來加深對二分法的理解,希望能夠讓大家透徹的理解二分搜索這個重要的算法。
簡介
在計算機科學中,二分搜索,也稱為半間隔搜索、對數搜索或二分截斷,是一種搜索算法,用於查找排序數組中目標值的位置。二分搜索將目標值與數組的中間元素進行比較。如果它們不相等,則消除目標不能位於其中的那一半,並在剩余的一半上繼續搜索,再次將中間元素與目標值進行比較,並重復此過程直到找到目標值。如果搜索以其余一半為空結束,則目標不在數組中。
在最壞的情況下,二分搜索的時間復雜度為\(O(logN)\),其中\(N\)為有序數組的長度。有專門為快速搜索而設計的專用數據結構,例如哈希表,可以比二分搜索更有效地進行搜索。但是,二分搜索可用於解決范圍更廣的問題,例如,在數組中查找相對於目標數字的下一個最小或下一個最大的元素。
基礎——二分查找
與其空講不如直接用一個題目來演示,這是LeetCode第704題二分查找
給定一個n
個元素有序的(升序)整型數組nums
和一個目標值target
,寫一個函數搜索nums
中的target
,如果目標值存在返回下標,否則返回-1
。
示例1:
輸入:
nums = [-1,0,3,5,9,12], target = 9
輸出:4
解釋: 9 出現在 nums 中並且下標為 4
示例2:
輸入:
nums = [-1,0,3,5,9,12], target = 2
輸出:-1
解釋: 2 不存在 nums 中因此返回 -1
這是最基本的二分搜索,看到這道題,幾乎本能地寫下了答案:
public int search(int[] nums, int target) {
int left = 0 , right = nums.length-1, mid;
while (left <= right) { // 循環結束條件 ?
mid = (left+right) / 2;
if (target == nums[mid]) // 發現目標元素
return mid; // ?
else if (target < nums[mid]) // 目標在左半區
right = mid-1; // ?
else // 目標在右半區
left = mid+1; // ?
}
return -1; // 找不到目標
}
簡單解釋一下代碼的邏輯:
while (left <= right)
循環結束的條件,當left==right
的時候,說明范圍已經縮減到了最后一個能夠尋找的值,不管有沒有找到,結束了這次循環之后,整個搜索都應該結束(left>right
沒有意義了)
mid = (left+right) / 2;
每次都取中間的一個元素,在這里左右相加可能出現溢出的情況,可以用mid = left+(right-left) / 2;
來代替。注意這個地方可能出現left==mid
的情況,在后面會提到。
if (target == nums[mid]) return mid;
當找到這個元素的時候就返回這個位置的索引。
else if (target < nums[mid]) right = mid-1;
目標元素比中間位置元素的位置還要小,那么就以mid-1
作為右邊界繼續搜索。注意這里的mid
是包含在原來的查找范圍內的,所以需要排除mid
繼續搜索。
else left = mid+1;
目標元素比中間的大,把mid
元素排除掉,再從mid
右邊一個元素mid+1
開始尋找。
當循環終止的時候,如果找不到目標元素,一定是left>right
,從邏輯內的計算可以發現一定是left==right+1
。因為最后一個循環一定是left==right==mid
,在經過下面的right=mid-1
或者left=mid+1
計算之后,得到left==right+1
。
OK,現在我們花了大概30秒的時間把代碼寫了下來,然后測試通過————有沒有感覺這是一段藏在我們工科生內心里面的代碼,不用多想就能寫出來!不過先別高興,這里我就有點疑惑,如果數組中存在重復的目標元素,那么這段代碼返回的索引是左邊元素還是右邊的還是中間的?!我們來試一下:
兩個9:
輸入:
nums=[-1,0,3,5,9,9,12] target=9
輸出:5
三個9:
輸入:
nums=[-1,0,3,5,9,9,9,12] target=9
輸出:5
四個9:
輸入:
nums=[-1,0,3,5,9,9,9,9,12] target=9
輸出:4
四個9(修改其他元素):
輸入:
nums=[-1,9,9,9,9,10,11,12,13] target=9
輸出:4
所以從上面的例子可以看出來,隨着9
的增加,最后返回的索引的位置可能是左邊也可能是中間的也有可能是右邊的,至於到底應該是哪個位置的取決於9
的數量以及在數組中的位置,上面這段代碼唯一能做的就是就到目標元素的其中一個索引(出現重復則不能確定索引位置相對於其他重復元素的位置),如果找不到就返回-1。
那么現在新的挑戰過來了,如何找到元素在排序數組中第一個位置和最后一個呢?
進階——在排序數組中查找元素的第一個和最后一個位置
在排序數組中查找元素的第一個和最后一個位置是leetcode第34題
給定一個按照升序排列的整數數組nums
,和一個目標值target
。找出給定目標值在數組中的開始位置和結束位置。
你的算法時間復雜度必須是\(O(logn)\)級別。
如果數組中不存在目標值,返回[-1, -1]
。
示例1:
輸入:
nums = [5,7,7,8,8,10], target = 8
輸出:[3,4]
示例2:
輸入:
nums = [5,7,7,8,8,10], target = 6
輸出:[-1,-1]
一看這個題目就是二分法來做,最簡單直接的方法就是用上面一個案例的二分法找到這個元素,然后從這個元素位置同時向左向右開始遍歷,直到直到第一個不是這個目標值的元素,就是邊界了。雖說這個方法用了二分法並且在尋找元素的時候時間復雜度做到了\(O(logn)\),但是如果這個數組里面所有元素都是目標元素,比如把第一個示例改成[8,8,8,8,8,8]
,那么在找到這個元素之后,還是要遍歷整個數組,時間復雜度就降低到了\(O(n)\)了,那么要如何只用兩次二分搜索就找到上下邊界呢?
二分搜索是有一套自己的模板,前一個案例用到的是基本的套用,在上一題的代碼里面有四個?
,而這個四個就是整個二分搜索的關鍵點,修改了這幾個值就是修改了邏輯。
那么我們在邏輯上再重新梳理一下這個情況下二分搜索邏輯,現在我們要尋找左邊界:
- 如果
target==nums[mid]
,那么左邊界可能就是mid
,也有可能在mid
的左邊 - 如果
target<nums[mid]
,那么左邊界一定在mid
左邊 - 如果
target>nums[mid]
,那么左邊界一定在mid
右邊
看上面的第一個和第二個條件,歸納一下可以得出:如果target<=nums[mid]
的時候,target
可能在mid
這個位置,也可能在mid
的左邊,下一步的right
直接用mid
就行了,不用減一。所以我們可以把while
循環內部的邏輯縮減成下面這個樣子:
mid = (left+right) / 2;
if (target <= nums[mid])
right = mid; // 兩個條件合二為一
else
left = mid+1; // 保持不變
每次循環都能夠縮小范圍並且始終把左邊界的位置控制在left
和right
中間,直到循環結束。
似乎找到了一絲曙光,有點頭緒了,不過我們好像剛才把return
語句給刪掉了,返回的值到底在哪?回答是返回值在循環結束之后處理,根據邊界賦值的條件,我們在循環里面沒有辦法判斷是否已經找到了這個元素,能使用的只是循環結束之后的left
和right
兩個值(其實只有一個,因為循環結束之后left==right
)。
可是這個循環真的能夠結束嗎?我們有循環結束的條件left<=right
,如果left==right==mid
並且target==nums[mid]
,這個循環不就死循環了?所以這里為了不讓循環死在這個地方,修改一下條件為left<right
,分析一下這個循環:如果left+1==right
,那么mid=(left+right)/2=left
,則有:
- 如果
target<=nums[mid]
,right=mid=left
,循環結束 - 如果
target>nums[mid]
,left=mid+1=left+1=right
,循環也結束
所以如果把循環結束的條件變成left<right
,無論如何循環都會結束。那么現在要根據循環結束之后的left
和right
這個兩個值,找到邊界值。根據前面的篩選條件,我們到了最后一層的循環,left+1=right
,范圍縮小到了最后兩個元素,只有幾種結果,我們定義num1<target<num2
,num1
和num2
都是數組里面的元素:
[num1, target]
此時nums[mid]=nums[left]=num1
,判斷條件target>nums[mid]
成立,left=mid+1
,得到最后的循環結束的left
的位置的值就是target
[target, num2]
此時同樣nums[mid]=nums[left]=target
,判斷條件target<=nums[mid]
成立,right=mid
,最后得到nums[left]=nums[right]=target
[target, target]
這個情況和第二種情況一樣,判斷條件target<=nums[mid]
成立,得到nums[left]=nums[right]=target
所以從上面的討論可以看出,當數組中存在一個或者多個target
元素的時候,一定能夠通過這個方法找到target
的左邊界為left
;如果不存在的時候,循環終止,這個left
位置的值肯定不是target
。
所以最后尋找左邊界的代碼如下:
public int searchRangeLeft(int[] nums, int target) {
int left = 0 , right = nums.length-1, mid;
while (left < right) { // 注意
mid = (left+right) / 2;
if (target <= nums[mid]) // 在左側
right = mid;
else // 在右側
left = mid+1;
}
return nums[left]==target?left:-1;
}
這個時候從上面這個代碼一定能夠找到元素的左邊界,同樣的我們根據上面的分析過程,完成右邊界的代碼:
// 錯誤代碼
public int searchRangeRight(int[] nums, int target) {
int left = 0 , right = nums.length-1, mid;
while (left < right) {
mid = (left+right) / 2 + 1 ;
if (target >= nums[mid]) // 右邊界在右側 和上面代碼不同的地方
left = mid; // 這里變成了left
else // 右邊界在左側
right = mid-1; // 這里變成了right
}
return nums[left]==target?left:-1;
}
然后把運行一下nums = [5,7,7,8,8,10], target = 8
這個測試用例發現出現了死循環!WTF!發生了什么!明明和尋找左邊界的代碼一樣啊!問題就出在mid = (left+right) / 2 ;
這行代碼里面。我們回頭看一下循環的終止條件的分析:
- 如果
target<=nums[mid]
(改成了target<nums[mid]
),right=mid=left
(根據mid=(left+right)/2
來的,所以此時right=mid-1=left-1
),循環結束 - 如果
target>nums[mid]
(改成了target>=nums[mid]
),left=mid+1=left+1=right
(變成了left=mid=right-1
,此時left<right
仍然成立,所以循環會一直進行下去),循環也結束
在尋找左邊界的時候如果left=right-1
,計算出來的mid
值一直是left
,這樣能夠讓mid
的位置始終靠左;但是在計算右邊界的時候,需要讓mid
的位置向右靠,終止循環,所以此時mid
的計算公式要改成mid = (left+right) / 2 + 1
,最后讓mid=right
才能最后讓left==right
。修改之后代碼如下:
// 正確代碼
public int searchRangeRight(int[] nums, int target) {
int left = 0 , right = nums.length-1, mid;
while (left < right) {
mid = (left+right) / 2 + 1 ; // 注意
if (target >= nums[mid])
left = mid;
else
right = mid-1;
}
return nums[left]==target?left:-1;
}
組合上面兩個方法就可以得到我們最后的結果了。上面講的兩個方法,也可以用來代替案例一中的二分搜索,畢竟在案例一只要找到了這個元素就可以了,不在乎是左邊界還是有邊界。
好了,感覺我們已經徹底學習完了二分搜索並且完全理解了,可以關上電腦好好休息了。但是——不!Hold On!我還有一個問題:如果在有序數組里面不存在這個target
,我們想把它插入到這個數組里面應該怎么做?上面的代碼應該怎么修改。
進階——搜索插入位置
這是leetcdoe第35題搜索插入位置
給定一個排序數組和一個目標值,在數組中找到目標值,並返回其索引。如果目標值不存在於數組中,返回它將會被按順序插入的位置。
你可以假設數組中無重復元素。
示例1:
輸入:
[1,3,5,6], 5
輸出:2
示例2:
輸入:
[1,3,5,6], 2
輸出:1
看到這道題,感覺和案例二特別類似,我們找到元素的左邊界不就可以了,但是我們不知道當元素不存在的時候,返回的位置是不是正確的位置,所以我們修改一下上面的searchRangeLeft
方法如下:
public int searchInsert(int[] nums, int target) {
int left = 0 , right = nums.length-1, mid;
while (left < right) {
mid = (left+right) / 2;
if (target <= nums[mid])
right = mid;
else
left = mid+1;
}
return left; // 這里修改返回的結果
}
然后運行一下代碼測試一下示例1和示例2,發現返回的結果果然是正確的,分別是2
和1
,接下來不管我們怎么修改target
的值只要是target<=6
都能夠得到正確的結果,當target==8
的時候,返回了3
,是錯誤的結果。所以直接照搬代碼肯定不行,那我們要怎么修改代碼呢?記住我們現在要尋找的坐標是第一個大於等於target
的元素的坐標
還記得案例二里面的分析方法嗎,我們再分三種條件來分析一下:
- 如果
target==nums[mid]
,找到了等於target
的元素,保留下來mid
位置,設置right=mid
- 如果
target<nums[mid]
,通過以下兩個結論可以得到right=mid
- 如果
target
存在,一定在mid
左邊 - 如果
target
不存在,第一個大於等於target
的元素可以能是nums[mid]
,也有可能在mid
的左邊
- 如果
- 如果
target>nums[mid]
,通過以下兩個結論可以設置left=mid+1
- 如果
target
存在,一定在mid
右邊 - 如果
target
不存在,第一個大於等於target
的元素也一定在mid
的右邊
- 如果
這里發現邏輯和原來的尋找左邊界的時候情況一模一樣,但是對於返回的結果是否正確還不確定,還要繼續討論一下。
對於target
的值我們分三種情況討論:
1 當target<nums[0]
也就是當target
比數組里面的最小值還要小的時候,可知在循環中每次都進入第二個判斷條件,最后得到的left
為0
,符合預期。
2 當target>nums[nums.length-1]
的時候,在循環中每次都進入最后一個判斷主體,最后得到left=num.length-1
,也就是right
的初始值,這樣得到的答案是明顯不對的。所以在進入這個循環之前,我們可以直接判斷一下target
的值是否大於數組最右側的值,如果是的就直接返回nums.length
;如果不是才進入下面的循環。
3 如果target
處於元素中間的位置,就進入接下來的分析。
這里我們仔細看這個循環
while (left < right) {
mid = (left+right) / 2;
if (target <= nums[mid])
right = mid;
else
left = mid+1;
}
在進行最后一輪循環的時候,left==right-1
,我們定義兩個元素num1=nums[left]
和num2=nums[right]
,並且一定滿足num1<=num2
,在這個討論中我們可以忽視target
存在於數組中的情況(我們在上面已經討論過了這個計算結果的正確性),target
和這兩個值之間只有可能有下面三種情況:
target<num1<=num2
可得target<nums[mid]=nums[start]=num1
,所以進入第一個判斷條件,最后得到nums[left]=nums[right]=num1
,也就是第一個大於target
的元素num1<target<num2
可得target>nums[mid]=nums[start]=num1
,所以進入第二個判斷條件,最后得到nums[left]=nums[mid+1]=nums[right]=num2
,這也是第一個大於target
的元素num1<=num2<target
這種情況不可能出現在這個循環里面,因為根據循環的條件決定了不可能出現right
所在元素的值小於target
所以從上面就可以判斷出最后得到的left
的值就是第一個大於等於target
的元素的位置的索引。
歸納一下上面的代碼可以得到:
public int searchInsert(int[] nums, int target) {
int left = 0 , right = nums.length-1, mid;
if (target > nums[nums.length-1]) // 循環前置條件
return nums.length;
while (left < right) {
mid = (left+right) / 2;
if (target <= nums[mid])
right = mid;
else
left = mid+1;
}
return left;
}
其實這道題還有一個小小的技巧,我們可以拋棄這個前置條件target<=nums[nums.lenght-1]
,然后初始化right
的時候設置成nums.length
,這里其實我們是自己偷偷的把數組的最后面增加了一個值為無窮大的元素,target
一定小於無窮大,最遠也只能插入到這個位置,不會越界。而且也不用擔心指針會溢出,因為mid
值在計算的過程中永遠不可能等於nums.length
。
總結
二分搜索的核心就是循環結束條件和左右邊界迭代規則,明白了如何確定這兩點,二分搜索就能輕松為你所用。
二分搜索本身並不是特別難的一個知識點,但是是非常重要的一個概念,如果題目提到了或者暗示要用\(O(logN)\)(或者更普遍的說類似\(O(NlogN)\)甚至\(O(N^2logN)\))的時間復雜度,首先應該想到二分,如果不能二分搜索,二分搜索的相關思想分治法也應該從腦袋里面出現。
這篇文章是二分搜索基礎知識以及應用,在LeetCode上面還有其他的二分搜索精彩應用的題目,希望大家能夠學習完這篇文章之后能夠熟練使用二分搜索解決下面的問題
更多內容請看我的個人博客
參考
二分查找算法細節詳解
Binary search algorithm
Fractional cascading
Clean iterative solution with two binary searches (with explanation)