目錄
二分查詢是在有序表中查詢目標元素的演演算法,其基本思想其實就是「猜數位遊戲」——已知某個數k在0~1000之內,如何猜出這個數具體是多大呢?二分查詢是這樣處理的:
- k大於500嗎?不大於。所以我們將資料範圍壓縮到0~500之間
- k大於250嗎?大於。所以我們將資料範圍壓縮到250~500之間
- k大於375嗎?大於。所以我們將資料範圍壓縮到375~500之間
- ……
如此不斷的壓縮範圍資料的範圍,最後當只剩下1個資料時答案已經被鎖定了(當然如果運氣好,那我們選定的「折半值」可能就是k了),可以看到二分查詢不斷折半縮小待查詢的資料範圍因此二分查詢也被翻譯成「折半查詢」。
試想如果我們從0~1000一一列舉,那我們最倒黴需要猜1000次,而使用二分的思路最壞也只 要需要10次(怎麼算出來的呢?,而,所以10次就可以包含完0~1000的所有情況)。如果資料範圍更大些,那效率的差距將會更加的突出。
假設檢查一個元素需要1ms,那我們可以得到以下的表格:
從這個實際問題中抽象出我們的演演算法,一一猜數對應著暴力列舉法,它的時間複雜度為,相比之下, 二分查詢的時間複雜度為,所以是相當優秀漂亮的演演算法。
使用二分查詢的前提是原資料是一個有序表,即具有單調性。所以說檢索也是排序最重要的應用之一。
二分查詢真的很簡單嗎?其實也並不簡單。看看 Knuth 大佬(發明 KMP 演演算法的那位)怎麼說的:
Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky...
這句話可以這樣理解:思路很簡單,細節是魔鬼。
所以在這篇文章中向大家介紹二分查詢的三種形式,雖然整體形式是極其相似的,但是略微的差別卻帶來了極大的不同。也相信學習完這篇部落格大家可以熟練運用這三種形式的二分查詢。
左閉右閉型是最典型的模板,也是我們學習的基礎,我們依然從猜數位的角度來理解:
int Binary_search(int*arr, int target, int numsSize)// (1)
{
int left = 0; //(2)
int right = numsSize - 1;
while(left <= right)
{
int mid = (left + right) >> 1; //(3)
if (arr[mid] < target)
left = mid + 1; //(4)
else if (arr[mid] > target)
right = mid - 1;
else
return mid;
}
return -1; //(5)
}
分析:
看不懂?沒關係,用下面的四張動圖來幫你理解。
(1)檢索 target = 29 是否存在
更加直觀的,我們將資料資料對應到二元樹中
【如何構建的呢】
其實就是我們對猜數位遊戲的模擬,舉個🌰:
【特性】
(2)檢索 target = 29 是否存在
(3)target 找不到的情況(搜尋 target = 35)
【和情況(2)有什麼區別呢】
(4)更復雜點的情況(採用mid = ( left + right + 1) / 2)
以一組資料 [ 1, 2, 3, 4 , 5, 6 ]為🌰:
⭐[小細節]
如果left + right 存在溢位的問題,則上述中間位置的取法可以改成下面兩種形式,保證不會溢位
因為左閉右閉型檢索的區域在 [left , right] 之間。同樣的道理,左閉右開檢索的區域在 [left, right), 左開右閉的檢索區域在 (left, right]
核心在於while判斷語句中有沒有等於號,以及中間值的取法。試想left 與 right不斷接近時,最終left 與right緊挨:
1) 中間值取法為 (left + right) / 2 時
2)中間值取法為 (left + right + 1) / 2 時
二分查詢除了可以用來檢索一個數是否存在,還可以用來尋找上下界,來看看是怎樣實現的吧!
📃題目①:
假設你有n個版本[1, 2, ..., n],已知每個版本都是基於前一版本開發的。由於中間某一版本發生錯誤,導致之後的所有版本都是錯誤的。現需要你找出一個發生錯誤的版本。你可以呼叫函數
bool isBadVersion(version)來檢測當前版本是否是錯誤版本(是錯誤版本返回1, 否則返回0)
int firstBadVersion(int n)
{
int left = 1;
int right = n;
while(left < right)
{
int mid = (right - left) / 2 + left;
if(isBadVersion(mid))
right = mid;
else
left = mid + 1;
}
return left;
}
【分析】
1)為什麼想到二分查詢?
因為二分查詢的本質是二段性,只要滿足二段性的問題都可以轉化為二分查詢的問題。
2)如何理解二段性呢?
在猜數位遊戲中,二段性體現在target左邊的元素都比它小,target右邊的元素都比它大,所以結合資料的單調性我們可以不斷的壓縮區間以定位到目標值。而在這個問題中仍然具有二段性,第一個發生錯誤的版本的之前版本的都是正確的,之後的都是錯誤的,由此我們仍然可以不斷的壓縮區間從而得到第一個發生錯誤的版本。
3)如何理解左開右閉型的程式碼?
若當前mid版本為正確,則 left 可以壓縮到mid + 1,若當前版本正確,則將right壓縮到mid。可以是mid - 1嗎?不妨這樣思考,如果當前mid正處於第一個發生錯誤的版本,right = mid - 1後不是徹底排除了正確答案了嗎,永遠也找不到了。
left 和 right 相遇之前,left不可能到達第一個錯誤版本,只會無限接近;right不可能超過第一個錯誤版本,只會到達第一個錯誤版本,所以最後 left 與 right 的關係是這樣的。最終, left = mid + 1使得 left 與 right重合也就鎖定了第一個錯誤版本 。
4)如果上體的中間元素取法為 (left + right + 1) / 2會發生什麼?
會陷入死迴圈! 因為 mid = right,mid是錯誤版本,因此執行語句"right = mid",也就意味著left 與 right的區間沒有發生壓縮。不斷重複這個過程就陷入了死迴圈。
5)延伸:如果最後的結果是這樣的呢?
此時我們的中間元素取法如果為(left + right ) / 2 ,則同樣的我們會陷入死迴圈。
6)總結
📃題目②:
給定一個排序陣列和一個目標值,在陣列中找到目標值,並返回其索引。如果目標值不存在於陣列中,返回它將會被按順序插入的位置。
請必須使用時間複雜度為 O(log n) 的演演算法
。
用左閉右閉型的二分查詢也可以實現上下界定位,並且好理解很多。來看看是怎麼實現的:
int searchInsert(int* nums, int numsSize, int target)
{
int left = 0, right = numsSize - 1, ans = numsSize;
while (left <= right)
{
int mid = ((right - left) >> 1) + left;
if (target <= nums[mid])
{
ans = mid;
right = mid - 1;
}
else
{
left = mid + 1;
}
}
return ans;
}
【分析】與模板唯一的區別在於,找到了繼續找。所以上述程式碼的作用是找到第一個比target小的位置(也就是要插入位置)。當然如果將if裡的條件改成 " target >= nums[mid] ",那麼相應的作用就變成找到第一個閉target大的位置,當然也可以作為插入位置。
給大家演示一下: nums[] = {1, 2, 2, 2, 2, 2, 2, 3 ,4 ,5} , target = 2 的情況吧
題目 | 地址 |
---|---|
力扣地址 | |
力扣地址 | |
力扣地址 |
程式碼上面都呈現過,一定要都嘗試下。