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