減治思想——二分查詢詳細總結
二分查詢應用於有序陣列,可以在以\(O(\log(n))\)時間複雜度進行查詢。其思想在於利用陣列的有序性直接排除掉一些元素,這也是進行“減治”的地方。二分查詢思想看起來簡單,但是其邊界條件其實很容易弄混,下面就對各種情況的二分查詢(基礎情形、左邊界二分查詢、右邊界二分查詢、應插入位置二分查詢)進行詳細的說明,完整的程式碼附在附錄裡面。
文章的的後半部分挑選了部分leetcode中典型應用二分思想的題目。
1. 二分查詢的總結
二分查詢寫法的核心在於縮小查詢的邊界,也就是不斷地縮小查詢範圍,使得目標值在範圍內。當查詢範圍為空集或找到了目標值時,就可以結束迴圈。如果懂得這個道理,二分查詢寫起來就簡單多了。
1.1 普通的二分查詢
最普通的二分查詢,我們只要求它能找到目標元素或返回異常值(返回-1表示未查詢到目標元素)就好。其基本思想非常好理解,就是利用陣列的有序性來篩掉不必要進行查詢的元素。
注意:
二分查詢最重要一點就是邊界條件的判斷,在整個陣列中進行查詢時,可以使用\(lo<=hi\)的寫法,其中hi的起始值為nums.length - 1。也可以採用\(lo<hi\)的的寫法,其中hi的起始值為num.length:而且兩種不同的邊界條件lo和hi的變動也有所不同(詳見如下程式碼)。
二分查詢非遞迴實現:
public static int search(int[] nums, int target) {
return search(nums, target, 0, nums.length - 1);
}
// lo <= hi的寫法,hi的初始值為nums.length - 1
public static int search(int[] nums, int target, int lo, int hi) {
while (lo <= hi) {
int mid = lo + (hi - lo) / 2; // 避免可能的溢位
if (nums[mid] < target)
lo = mid + 1; // 已經確定mid位置不可能是目標元素,將lo設定為mid的下一位
else if (nums[mid] > target)
hi = mid - 1; // 已經確定mid位置不可能是目標元素,將hi設定為mid的上一位
else
return mid; // 找到了就直接返回mid就好
}
return -1; // 沒找到返回異常值
}
// lo < hi的寫法,hi的初始值為nums.length - 1
public static int search2(int[] nums, int target, int lo, int hi) {
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (nums[mid] < target)
lo = mid + 1; // lo這側為閉區間,寫法與上面示例相同
else if (nums[mid] > target)
hi = mid; // hi這側為開區間,寫法與上面示例有區別!開區間這側端點是取不到的,所以將hi設定為mid就好
else
return mid;
}
return -1;
}
二分查詢遞迴實現:
public static int binarySearchRecur(int[] nums, int target, int low, int high) {
int mid = low + (high - low) / 2;
if (target > nums[mid])
return binarySearchRecur(nums, target, mid + 1, high);
else if (target < nums[mid])
return binarySearchRecur(nums, target, low, mid - 1);
else if (target == nums[mid])
return mid;
return -1;
}
注意:
-
為什麼mid的計算方式不是$mid = \frac{(lo + hi)}{2} \(而是\)mid = lo + \frac{hi - lo}{2}$呢?
解答:毫無疑問的是,從數學上看兩者是完全等價的。但是從數值計算的角度來看,若lo和hi都很大,lo直接與hi做和有溢位的可能,而第二種方式通過讓hi與lo先做差,一定程度上避免了溢位的可能。
類似的有 mid * mid < target 可寫為 mid < target < mid,也可以在一定程度上防止數字過大帶來的溢位。
-
二分查詢可以通過迴圈或遞迴的方式實現,但是遞迴方法的效率往往較低,在這裡更推薦非遞迴寫法。
一個困難:
上面實現的二分查詢僅僅是找到陣列中的一元素,可是有時候我們希望能獲取更多資訊。例如,當陣列中可能存在很多個相同元素時,我們希望二分查詢返回相同元素的最左邊元素或最右邊元素,這樣方便我們提取出所有該重複元素。
有的同學可能會產生疑問,我只要通過二分查詢找到目標元素,向左遍歷或者向右遍歷就好啦!總可以找到該元素最左側或者最右側位置嘛!可是這樣在最壞情況的複雜度可能會上升至O(n),例如陣列中所有元素均相同時候。能不能將上面的二分查詢程式碼稍作改進以在最壞情況下時間複雜度仍能保持在對數級別呢?
1.2 左邊界二分查詢
首先,普通的二分查詢在找到目標元素位置後就直接返回了,但是若要查詢左邊界就不能讓其直接返回,也就是在查詢到目標元素後我們得想辦法繼續改變lo和hi指標,使得它們逐漸收縮至最左側目標元素。這裡由於個人習慣採取了lo和hi的選取設定為閉開區間的寫法,也可以寫為閉區間的形式。
public static int leftBound(int[] nums, int target) {
return leftBound(nums, target, 0, nums.length);
}
public static int leftBound(int[] nums, int target, int lo, int hi) {
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (nums[mid] < target)
lo = mid + 1;
// 其實下面兩種情況都是一種解決方法,用一個else即可
// 但是為了邏輯清晰,這裡暫時全部列出來
else if (nums[mid] > target)
hi = mid;
else
hi = mid; // 若找到了先不要返回,不斷地將hi指標靠向lo指標方向
}
return nums[lo] == target ? lo : -1; // 可能找不到,不要忘記判斷
}
1.3 右邊界二分查詢
如果你理解了左邊界二分查詢,右邊界二分查詢就十分簡單了,思路都是相同的,只不過是在相等的情況下我們讓lo指標靠近hi指標
public static int rightBound(int[] nums, int target) {
return rightBound(nums, target, 0, nums.length);
}
public static int rightBound(int[] nums, int target, int lo, int hi) {
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (nums[mid] < target)
lo = mid + 1;
else if (nums[mid] > target)
hi = mid;
else
lo = mid + 1; // 若找到了先不要返回,不斷地將lo指標靠向hi指標方向
}
// 可以仔細推算一下,迴圈結束後lo與hi指向目標位置的下一個位置,所以下面是用lo - 1
// 不要忘記陣列中可能不存在這個元素
return nums[lo - 1] == target ? lo - 1 : -1; // 仔細體會這個返回值
}
1.4 插入位置二分查詢
如果有序陣列中有目標元素值,我們直接返回其對應陣列下標;若有序陣列中沒有目標元素值,我們返回目標值應該插入到的位置,也就是把這個目標值插入到此位置,新生成的陣列是仍是有序的。
我們採用的形式十分類似於左邊界二分查詢,唯一的差別在於我們在return語句中不檢查是否找到目標元素,直接返回lo對應的值即可。為了更好的理解這段程式碼為什麼能夠滿足我們的要求,以下對lo指標的行為進行分析:
-
如果陣列中存在目標元素:
顯然此時程式碼的行為與左邊界二分查詢完全相同,會返回最左端的目標元素位置
-
如果陣列中不存在目標元素:
lo指標與hi指標收縮到的位置只能有以下可能: 陣列的最左端,陣列的最右端、小於目標元素的最大元素(結合程式碼多看看),所以lo返回的位置就是目標元素應該插入到的位置。
public static int insertSearch(int[] nums, int target) {
return insertSearch(nums, target, 0, nums.length);
}
public static int insertSearch(int[] nums, int target, int lo, int hi) {
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (nums[mid] < target)
lo = mid + 1;
else if (nums[mid] > target)
hi = mid;
else
lo = mid + 1;
}
return lo;
}
2. 二分思想的應用(指Offer例題)
2.1 34. 在排序陣列中查詢元素的第一個和最後一個位置
給定一個按照升序排列的整數陣列 nums,和一個目標值 target。找出給定目標值在陣列中的開始位置和結束位置。
如果陣列中不存在目標值 target,返回 [-1, -1]。
進階:
你可以設計並實現時間複雜度為 O(log n) 的演算法解決此問題嗎?
示例 1:
輸入:nums = [5,7,7,8,8,10], target = 8
輸出:[3,4]
示例 2:
輸入:nums = [5,7,7,8,8,10], target = 6
輸出:[-1,-1]
示例 3:
輸入:nums = [], target = 0
輸出:[-1,-1]
提示:
0 <= nums.length <= 105
-109 <= nums[i] <= 109
nums 是一個非遞減陣列
-109 <= target <= 109
2.1.1 題目分析
這道題看起來思路很清晰,我們只需要分別使用二分查詢尋找左邊界和右邊界就好。
2.2.2 程式碼
class Solution {
public int[] searchRange(int[] nums, int target) {
if (nums.length == 0)
return new int[] {-1, -1};
int lo = 0, hi = nums.length;
// 尋找左邊界
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (nums[mid] < target)
lo = mid + 1;
else
hi = mid;
}
// 這個地方需要仔細思考一下,如果查詢左邊界時發現了問題就直接返回異常值就好
if (lo == nums.length || nums[lo] != target)
return new int[] {-1, -1};
// 尋找右邊界
int i = 0, j = nums.length;
while (i < j) {
int mid = i + (j - i) / 2;
if (nums[mid] > target)
j = mid;
else
i = mid + 1;
}
return new int[] {lo, i-1};
}
}
這是一個比較初級的寫法,實際上可以通過一個函式同時查詢左邊界和右邊界,可以檢視對應題目的官方題解。
2.2 69. x 的平方根
由於返回型別是整數,結果只保留整數部分,小數部分將被 捨去 。
注意:不允許使用任何內建指數函式和算符,例如 pow(x, 0.5) 或者 x ** 0.5 。
示例 1:
輸入:x = 4
輸出:2
示例 2:
輸入:x = 8
輸出:2
解釋:8 的算術平方根是 2.82842..., 由於返回型別是整數,小數部分將被捨去。
提示:\(0 <= x <= 2^{31} - 1\)
2.2.1 題目分析
只要讀完題目就知道本題實際上是要我們求出x平方根的整數部分,也就是求一個整數k,滿足k的平方小於x且\(0<x - k^2<1\)。
如果按照暴力的解法來看,我們只需要從1開始遍歷整數k,直到找到合適的k為止(也就是當此時的k平方大於x時,返回k-1),這樣的時間複雜度為O(n)。
其實在上面的過程中我們使用了順序查詢,而且查詢的序列還是一個有序的序列,我們可以使用二分查詢來降低時間複雜度。(為什麼本題與二分查詢聯絡在一起)。
2.2.2 程式碼
class Solution {
public int mySqrt(int x) {
// 設定一個ans來記錄當前的一個合適的mid值
int l = 0, r = x, ans = -1;
while (l <= r) { // 當然你可以選擇 l < r的寫法,但是本題x的取值範圍可達到整數最大值,這麼做會不太方便
int mid = l + (r - l) / 2;
// 由於mid的範圍很大,不轉換則會溢位
// 可以使用我們上面提到的方法來防止溢位,但是需要額外設定一些特殊值檢查來防止特殊值(0,1)
if ((long)mid * mid <= x) {
ans = mid;
l = mid + 1;
} else {
r = mid - 1;
}
}
// 當迴圈結束時,ans一定與較小的lo或者hi相等,要注意理解
// 這個ans是我們妥協出來的產物,如果x範圍不會達到int型別最大值,採用l < r則不需要ans了
return ans;
}
}
這道題實際上還有很多有趣的解法:例如袖珍計算器法、牛頓迭代法,但由於本專題是在討論二分查詢問題,故不再介紹這些方法,詳見對應官方解析頁面。
2.3 367. 有效的完全平方數
給定一個 正整數 num ,編寫一個函式,如果 num 是一個完全平方數,則返回 true ,否則返回 false 。
進階:不要 使用任何內建的庫函式,如 sqrt 。
示例 1:
輸入:num = 16
輸出:true
示例 2:
輸入:num = 14
輸出:false
2.3.1 題目分析
檢驗完全平方數實際上就是從1到num這個整數序列中查詢是否存在一個數k,其滿足k的平方等於num,如果不存在這樣的k則意味著num不是完全平方數。所以可以用二分的思想來尋找這個整數k。
2.3.2 程式碼
class Solution {
public boolean isPerfectSquare(int num) {
int lo = 1, hi = num;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
long val = (long)mid * mid; // 注意這個顯示的型別轉換,尤其是mid前面的那個
if (val > num)
hi = mid - 1;
else if (val < num)
lo = mid + 1;
else
return true; // 找到k就直接返回true
}
return false; // 如果迴圈都結束了還沒找到只能說明這樣的k是不存在的,所以返回false
}
}
2.4 153. 尋找旋轉排序陣列中的最小值與154. 尋找旋轉排序陣列中的最小值 II
已知一個長度為 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 ,它原來是一個升序排列的陣列,並按上述情形進行了多次旋轉。請你找出並返回陣列中的 最小元素 。
你必須設計一個時間複雜度為 O(log n) 的演算法解決此問題。
示例 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 原來是一個升序排序的陣列,並進行了 1 至 n 次旋轉
2.4.1 題目分析
陣列的一次旋轉其實就是將最後一位元素提到最前面來,旋轉n次之後可能會出現這樣的結果:陣列被分為兩個有序部分,我們不妨分別稱之為左有序陣列、右有序陣列。當左有序陣列、右有序陣列長度不為0時,左有序陣列元素是要大於等於右有序陣列的元素的;當左有序陣列為0時,最左端位置就是最小元素;當右有序陣列為0時,最右端元素為最小元素。
這樣一道題看起來與二分查詢有什麼關聯呢?而且這個陣列甚至不是完全有序的!下面來分析一下為什麼二分思想在本題是可以應用。
可以利用左子陣列與右子陣列特性——左子樹組與右子陣列均遞增且左子陣列元素大於等於右子陣列元素
我們設定兩個指標lo和hi,分別指向陣列頭和尾,比較mid = (lo + hi) / 2,比較nums[mid]與nums[hi]的值
- 若nums[mid] < nums[hi],說明mid指向右子陣列元素,將lo指標挪至mid處(mid指向的位置本身就可以為最小元素)
- 若nums[mid] > nums[hi],說明mid指向左子陣列元素,將lo指標挪至mid + 1處(mid所指向位置不可能是最小元素)
-
如果nums[mid] == nums[hi],遇到重複值情形,此時可以將hi -- (如果考慮這個情況可能會出錯)
注意:
如果不考慮重複值帶來的影響,可能會出現死迴圈或者無法找到正確值的情況(取決於你的程式碼實現)。如何合理處理以上情況呢?
當左子陣列和右子陣列均存在時(當左子陣列不存在時,相當於陣列是升序情況,二分查詢的思想很顯然可以應用),左子陣列元素大於等於右子陣列且兩者均升序。如果mid所指向值等於hi指向值,可以將斷定刪除hi所指向的值並不影響最終結果的查詢。刪除掉hi所指向的元素值,不會使最小值從陣列中被刪去(即使被刪除的元素是最小值,那麼陣列中仍存在該最小值)。要好好理解!
2.4.2 程式碼
class Solution {
public int findMin(int[] nums) {
int lo = 0, hi = nums.length - 1;
int mid;
while (lo < hi) {
mid = lo + (hi - lo) / 2;
if (nums[mid] > nums[hi])
lo = mid + 1;
else if (nums[mid] < nums[hi])
hi = mid;
else
hi -= 1;
}
return nums[lo];
}
}
2.5 劍指 Offer 53 - II. 0~n-1中缺失的數字
一個長度為n-1的遞增排序陣列中的所有數字都是唯一的,並且每個數字都在範圍0~n-1之內。在範圍0~n-1內的n個數字中有且只有一個數字不在該陣列中,請找出這個數字。
示例 1:輸入: [0,1,3]
輸出: 2
示例 2:輸入: [0,1,2,3,4,5,6,7,9]
輸出: 8
限制:1 <= 陣列長度 <= 10000
2.5.1 題目分析
由於本題還是一個有序陣列,應該首先想想能否使用二分查詢或利用類似的思想去解決問題。
首先,長度為n-1的陣列取值為0到n,僅缺失一個元素,而題目要我們查詢出這個缺失值。可能看起來有些奇怪是不是?其實這個形式與之前我們寫過的查詢插入位置的二分查詢是類似的。
如果陣列長度是n且升序排列,那麼實際上陣列元素與其下標是相等的,但是缺失了一個元素之後,缺失元素右側全部元素陣列下標均較少1,也就是在缺失元素前的元素值仍等於陣列下標。陣列被分為了兩個部分,我們要做的也就是查詢卻缺失元素右側第一個元素,或者說右側陣列的開頭元素。
換個角度來看,事實上這個陣列可以被視為只有兩種取值,一種取值為下標等於值,另一種是下標不等於值,我們要查詢的就是下標不等於值的最左側元素,而缺失的元素的值正好為leftBound查詢出來的元素最左端下標。
2.5.2 程式碼
// 二分搜尋
class Solution {
public int missingNumber(int[] nums) {
int mid;
int lo = 0, hi = nums.length;
while (lo < hi) {
mid = lo + (hi - lo) / 2;
if (nums[mid] == mid) // 注意這個搜尋條件
lo = mid + 1;
else
hi = mid;
}
return lo;
}
}