前提:有序数组的查找
二分查找的结构中含有两个关键的地方:循环终止的条件、边界移动的条件,这两个分别有下面的可能:
循环终止条件:
left < right
left <= right
边界移动条件同样有带不带等号的问题。
对于不同的实际问题,这两个地方的等号取不取有时候很麻烦,让人困扰,并且进一步影响结果的下标,是left?left+1?left-1?还是 什么。
所以把最近遇到的几个二分查找的问题(这些问题常常是其他问题的子步骤)总结到一起,通过比较来学习。
一、找出不小于target的最小元素并用target替换
void func(vector<int> nums, int target){
int left=0,right=nums.size()-1;
while(left < right){
int mid = left + right >> 1; // >> 的优先级低于 +
if(nums[mid] < target){
// 我们要找的元素(大于等于target者)不会落在mid以及其左边的元素
// 所以我们在这种情况果断收缩左边边界以便在可能的范围内继续寻找
left=mid+1;
}else if(nums[mid] >= target){
// 因为要找的是大于等于target的最小元素
// 所以当前mid满足条件,不能过度收缩有边界,即不能right=mid-1;
right=mid;
}
}
/*
考虑最终的情况,左右边界都会指向>=target的最小元素,所以循环的终断条件不能带等号
*/
nums[right]=target;
}
上面的写法可以通过leetcode题力扣的检验,下面是leetcode官方的方案供大家比较
int l = 1, r = len, pos = 0; // 如果找不到说明所有的数都比 nums[i] 大,此时要更新 d[1],所以这里将 pos 设为 0
while (l <= r) {
int mid = (l + r) >> 1;
if (d[mid] < nums[i]) {
pos = mid;
l = mid + 1;
} else {
r = mid - 1;
}
}
d[pos + 1] = nums[i];
通过做题发现,二分查找也常常用于下面这种情况的查找:
- 数组中存在一个分界点将其分割为前后两个部分
- 这两个部分具有互斥的特性
比如下面两个问题:
二、重复数查找
力扣链接:力扣
问题:
给定一个包含 n + 1 个整数的数组 nums ,其数字都在 [1, n] 范围内(包括 1 和 n),可知至少存在一个重复的整数。
假设 nums 只有 一个重复的整数 ,返回 这个重复的数 。
你设计的解决方案必须 不修改 数组 nums 且只用常量级 O(1) 的额外空间。
如果定义长度为n+1的数组cnt,cnt[ i ]表示nums中小于等于i的元素的个数。设重复数的下标为target,那么cnt数组满足:
- 对于下标i属于[1,target),有cnt[i]<=i。
- 对于下标i属于[target,n],有cnt[i]>i。
- 证明:设重复的元素为x,重复次数为m
- 当m=2,数组是1到n完整出现一次,再加上重复的元素。对于target左侧的元素都恰好出现一次,那么第一个不等式取等号;对于target以及其右边的元素cnt[i]=i+1
-
当m>2,可以理解为1到n中有m-2个元素被替换成x。对于下标i属于[target,n]的元素,情况比较清楚,cnt[i]=i+1,原因是x本身也是满足小于等于nums[i]的,所以不影响结论。对于下标i属于[1,target),如果i的左侧没有元素被替换,cnt[i]=i;如果i的左侧有元素被替换,cnt[i]<i。
所以,以target为界限分nums为两部分,并且这两部分各有互斥的条件。
class Solution {
public:
int findDuplicate(vector<int>& nums) {
int n = nums.size();
int l = 1, r = n - 1, ans = -1;
while (l < r) {
int mid = (l + r) >> 1;
int cnt = 0;
// 计数cnt
for (int i = 0; i < n; ++i) {
cnt += nums[i] <= mid;
}
if (cnt <= mid) {
// 如果cnt[i]<=i
// 我们寻找的target不属于i以及其左,所以l向右过度收缩
l = mid + 1;
} else {
r = mid;
}
}
return l;
}
};
三、旋转数组的最小元素
力扣链接:力扣
把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。
给你一个可能存在 重复 元素值的数组 numbers ,它原来是一个升序排列的数组,并按上述情形进行了一次旋转。请返回旋转数组的最小元素。例如,数组 [3,4,5,1,2] 为 [1,2,3,4,5] 的一次旋转,该数组的最小值为 1。
注意,数组 [a[0], a[1], a[2], ..., a[n-1]] 旋转一次 的结果为数组 [a[n-1], a[0], a[1], a[2], ..., a[n-2]] 。
有了上面两题的经验,这道题可以看到旋转后的数组有这样的性质:
如果数组a[0], a[1], a[2], ...a[t],a[t+1],.., a[n-1]旋转为a[t+1],.., a[n-1],a[0], a[1], a[2], ...a[t],因为原先是升序的数组,所以有a[t]相关的条件来分割数组为两部分
设最小元素为x,其下标为target
- 对于下标i属于[target,n-1]的元素,有nums[i]<=a[t]
- 对于下标i属于[1,target)的元素,有nums[i]>=a[t]
- 当且仅当a[t]有重复,且t不是重复元素的开头或者结尾,两个等号同时取得;当且仅当a[t]有重复,且t是重复元素的开头的下标,第一个等号取得,第二个等号取不得;当且仅当a[t]有重复,且t是重复元素的结尾的下标,第一个等号取不得,第二个等号能取得。
class Solution {
public:
int minArray(vector<int>& numbers) {
if(numbers.size()==1){
return numbers[0];
}
int n=numbers.size(),left=0,right=n-1;
while(left<right){
int mid=left+(right-left >> 1);
if(numbers[mid]>numbers[right]){
//mid一定在target以左,所以左边界过度收缩
left=mid+1;
}else if(numbers[mid]<numbers[right]){
//mid可能是target
right=mid;
}else{
right--;
//a[t]不会被除去
}
}
return numbers[right];
}
};
分析numbers[mid]==numbers[right]的情况:此时mid可能取到left附近的元素或者right附近的元素,并且这个元素可能是目标元素。
首先,为什么不能折半收缩边界?在上面取等号的分析中,取等号在两种情况都能取得,不互斥,所以不能直接折半收缩。
再者,我们的目标是最小的元素,当 numbers[mid]==numbers[right]删除right指向的元素不会遗漏可行解,所以只可以把right向左收缩一位,这样做是妥当的。
总结
在上面三个例子的中,有共性特征:
分析折半条件,并且设置终止循环的条件是left<right,使得最终的解就是left或者right,关键是合理舍去不可能解,保留可行解的范围不被删去。
感谢您的阅读~