從LeetCode No.34說起
題目描述:
給定一個按照升序排列的整數數組 nums,和一個目標值 target。找出給定目標值在數組中的開始位置和結束位置。
你的算法時間複雜度必須是 O(log n) 級別。
如果數組中不存在目標值,返回 [-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]
首先貼一下我的LeetCode運行結果:
分析
一般我們看到時間複雜度是O(log n)級別的話,第一反應就是使用二分查找法。實際上二分查找是一種思想很簡單,但是細節很困難的算法。
困難的地方就在於要明確分區點。我們先說說一般的二分查找
常規二分查找
Java代碼:
public int search(int[] nums, int target) {
if (nums == null || nums.length == 0) {
return -1;
}
int start = 0;
int end = nums.length - 1;
while (start <= end) {
int mid = (end + start) / 2;
if (nums[mid] == target) {
return mid;
} else if (target < nums[mid]) {
end = mid - 1;
} else if (target > nums[mid]){
start = mid + 1;
}
}
return -1;
}
分析:
首先確定我們的查找區間是[left, right],也就是[0 , nums.length - 1], 這個時候末尾區間元素是數組nums的最後一個數字,因此爲了覆蓋到最後一個元素,我們的循環條件應該是 '<= ’ 而不是 ‘<’
然後我們來看看分區點的判斷條件(注意查找的數組是有序的)
- 如果mid正好是要找數字的下標,那麼直接返回,這個應該很容易理解
- 如果mid比target要小,那麼說明target在右半區間,因此要把start變大,而mid已經判斷過了,因此是start = mid + 1
- 如果mid比target要打,那麼說明target在左半區間,因此要把end變小,而mid已經判斷過了,因此是end = mid - 1
最後我們來分析一下結束循環的幾種情況
- 目標處於數組中,通過不斷修改mid,最終一定是可以找到對應下標的,正常結束循環
- 目標比數組中所有數字都要小,例如:[1,2,3,4,5], target = 0, 這個時候不斷縮小end,從左半區間中查找,直到最後mid = 0,end = mid - 1 = -1, 不滿足
start <= end
結束循環。 - 目標比數組中所有數字都要打,例如:[1,2,3,4,5], target= 6, 這個時候不斷增大start,從右半區間中查找,直到最後mid = 4(因爲我們下標區間就是[0, 4]),start = mid + 1 = 5, 不滿足條件,結束循環
可以看到,在常規的二分查找中,分區點是很明確的,因爲每個元素都只會判斷一次,不符合就不用再去管它了。
這個時候我們再來思考一個問題:如果我要找的元素不是唯一的,而我想要找到第一個出現這個元素的位置,該怎麼辦呢?
舉個例子:target = 2,nums = [1,2,2,3,3]
我們想要得到的結果是1,因爲第一個2出現的下標就是1,但是如果用上面的算法,在第一次循環中就會直接返回2了,我們該怎麼修改一下,使得他能夠返回我們要的答案呢?
二分查找變形 - 第一個元素位置
我們先直接貼代碼,然後再來分析爲什麼要這麼寫
Java代碼:
private int leftBound(int[] nums, int target) {
if (nums == null || nums.length == 0 ) return -1;
int left = 0;
int right = nums.length - 1; // 確定右區間
// 確定邊界條件
while (left <= right) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
right = mid - 1;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
}
}
if (left == nums.length || nums [left] != target) return -1;
return left;
}
分析:
首先要注意的是,我們這裏的右區間是nums.length - 1,也就是數組的最後一個元素,通過上面常規算法的分析,我們知道了結束循環的條件是 <=
,因爲如果是 <
的話,萬一target正好是最後一個,那就永遠無法找到了
理解了這個前置條件之後,我們再來看看分區點是如何被改變的,上面說到,如果按照常規的算法,那麼在第一次找到target的時候就會返回結果,爲了解決這個問題,我們在第一次找到的時候不返回,而是找到最後確定左邊沒有target元素了,再返回,這樣就可以了。
這個算法的查找過程:
- 如果mid = target,繼續從左半區間查找target,看看有沒有相同的值,把right修改爲 mid - 1
- 如果不等,target比mid大就從右半邊找,target比mid就從左半邊找
- 最終返回的值區間爲[-1, nums.length - 1]
這裏重點分析一下幾種特殊情況:
nums = [2,2,2,2,2] target = 2,不斷修改mid,最終mid = 0,right = -1,結束循環,left = 0
nums = [2,2,2,2,3] target = 3,不斷修改mid,最終mid = 4,right = 3,結束循環,left = 4
nums = [1,2,3,4,5] target = 0,注意:這種情況下雖然left是0,但是實際上nums[0]並不是我們要找的target,因此要在函數的最後加上一個判斷:nums[left] == target
nums = [1,2,3,4,5] target = 6, 注意:這種情況下left = 5,因此加上判斷 left = nums.length,否則會出現數組越界的錯誤。
二分查找變形 - 最後一個元素位置
這種情況和上面的情況類似,我們就直接貼代碼吧
Java代碼:
private int rightBound(int[] nums, int target) {
if (nums == null || nums.length == 0 ) return -1;
int left = 0;
int right = nums.length - 1;
// 確定邊界條件
while (left <= right) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
left = mid + 1;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
}
}
if (right == -1 || nums[right] != target) return -1;
return right;
}
分析:
這裏的情況和上面的變形類似,不過有個地方需要注意,就是在最後加的判斷條件
如果:
nums = [1,2,3,4,5] target = 0, 這個時候right = -1 結束循環,因此我們判斷要寫 - 1,而不是 0
nums = [1,2,3,4,5] target = 6, 這個時候 right = 4 循環結束,但是nums[4] 不是我們要找的。因此要加上判斷
LeetCode No.34解答
結合上面所說的兩種情況,這題的思路就比較清晰了,我們可以通過兩次二分查找,分別找到第一個和最後一個元素的位置。
Java代碼:
public int[] searchRange(int[] nums, int target) {
int[] result = new int[2];
result[0] = leftBound(nums, target);
result[1] = rightBound(nums, target);
return result;
}
private int leftBound(int[] nums, int target) {
if (nums == null || nums.length == 0 ) return -1;
int left = 0;
int right = nums.length - 1;
// 確定邊界條件
while (left <= right) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
right = mid - 1;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
}
}
if (left == nums.length || nums [left] != target) return -1;
return left;
}
// 尋找最後一個出現的下標(最大右邊界)
private int rightBound(int[] nums, int target) {
if (nums == null || nums.length == 0 ) return -1;
int left = 0;
int right = nums.length - 1;
// 確定邊界條件
while (left <= right) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
left = mid + 1;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
}
}
if (right == -1 || nums[right] != target) return -1;
return right;
}
總結
我們通過LeetCode第34題來分析了一下二分查找和二分查找的變形。除了LeetCode的34題,還有33題也是屬於二分的變形題。大家有興趣可以去看看。
二分查找的主要難點就是在於如何劃分分區點,避免數組越界和查找不到的問題。想要熟練掌握二分查找,只能通過多做題多總結,在做題的過程中可以自己試一下這些特殊的數組,看看自己設定的查找分區和分區點條件是否有問題,然後再進行調整。
感謝大家的閱讀,也希望大家多溝通交流,寫的不夠好的地方歡迎批評指正~