二分法的综合运用(二分查找+上下界)
0.参考
参考链接:二分查找的左闭右开和左闭右闭写法
参考链接:二分查找——区间开闭性
参考链接:二分查找的细节(左闭右闭、左闭右开、左开右闭)及其两段性
具体到代码上,二分查找时区间的左右端取开区间还是闭区间在绝大多数时候都可以,因此有些初学者会容易搞不清楚如何定义区间开闭性。
这里我提供两个小诀窍,第一是尝试熟练使用一种写法,比如左闭右开(满足C++、Python 等语言的习惯)或左闭右闭(便于处理边界条件),尽量只保持这一种写法;
第二是在刷题时思考如果最后区间只剩下一个数或者两个数,自己的写法是否会陷入死循环,如果某种写法无法跳出死循环,则考虑尝试另一种写法。
三种取值范围的写法区别在于:
(1)每次折半的时候两端的坐标应该移到mid的位置上还是多偏移一个元素
(2)while判断结束的条件是left<right还是left<=right
(3)对于mid的取值
下面是区间开闭性的总结:
三种写法:left和right最初的取值
①左闭右闭:left=0,right=numSize-1
②左闭右开:left=0,right=numSize
③左开右闭:left=-1,right=numSize-1
三种写法:left和right索引时的偏移
①左闭右闭:left = mid + 1, right = mid - 1
②左闭右开:left = mid + 1, right = mid
③左开右闭:left = mid, right = mid - 1
三种写法:while里的循环条件
①左闭右闭:left <= right
②左闭右开:left < right
③左开右闭:left < right
三种写法:mid的取值
①左闭右闭:mid = left + (right - left) / 2 或者 mid = left + (right - left + 1) / 2
②左闭右开:mid = left + (right - left) / 2
③左开右闭:mid = left + (right - left + 1) / 2
1.思路
0. 序言
lower_bound查找的是升序序列中的第一个出现target的pos,区间应从右向左收缩。
upper_bound查找的是升序序列中的最后一个出现target的pos,区间应从左向右收缩。
就是同时出现多个==target的值时,选第一个还是最后一个
主循环判断本质目的是为了确保整个区间能够被检索到。
这里的搜索空间每次是搜索新的区域的,如[left,mid),[mid+1,right) 或者 [left,mid-1],[mid+1,right], 是不包含之前搜到的mid的
具体说明下:
1.如果我是左闭右开,lower_bound, 走[left, right), 循环条件:while(left < right)
我在nums[mid] >= target 的时候, 会不断向左找[left, mid), 也就是right = mid
反之nums[mid] < target 的时候, 会不断向右找[mid + 1, left), 也就是'left = mid + 1'
即使找到了 nums[mid'] = target, 也会去[left, mid')向左继续寻找到第一个满足的
当此时[left, right)中左侧的所有值 <= target(非 < target):
若是小于的话,我们就将left不断逼近right,
若是等于的话,我们的right = mid
最终会逼近最左边的target(lower_bound)
-
1.1 最后的情况(有可能是)是 left + 1 == right,
由于 mid = (left + right) / 2 偏左, 即 mid = left;
nums[mid] = nums[left] < target, left = mid + 1 = right(即target下标)
此时[target(下标), target(下标)) 已经找不到新的搜索空间了
因为left == right,结束循环,返回left即可(也就是没有搜索空间再给我们找了,我们就返回找到的最左边的) -
1.2 如果是 left + 2 == right,
由于 mid = (left + right) / 2 处于中间位置,
如果 nums[mid] >= target, right = mid; 回到1.1
如果 nums[mid] < target, left = mid + 1 = right; 直接结束!
这里由于最后right的位置定然是满足nums[right] >= target, 故找到的是第一个等于(有target)或者第一个大于target(无target)的数
2.如果我是左闭右开,upper_bound, 走[left, right),循环条件while(left < right)
我在nums[mid] <= target 的时候, 会不断向右找 [mid + 1, right), 即 'left = mid + 1'
否则nums[mid] > target 的时候,在[left, mid) 继续寻找, 即'right = mid'
即使找到了 nums[mid'] = target, 也会去[mid'+1, right)向右继续寻找直到最后一个满足的
这时候肯定[left, right)中右侧的所有值 >= target:
若是大于的话,我们就将right不断逼近left,
若是等于的话,我们的left = mid + 1
最终会逼近最右边的target下标 + 1(upper_bound)
- 2.1 最后的情况可能是是 left + 1 = right,
由于 mid = (left + right) / 2 偏左, 即 mid = left
nums[mid] = nums[left] <= target, right = mid(即target下标 + 1)
此时[target(下标)+1, target(下标)+1) 已经找不到新的搜索空间了
因为left == right,结束循环,返回left - 1即可
这里由于最后right的位置定然是满足nums[right] > target, 故找到的是最后一个等于(有target)或者第一个大于target的数(无target)
3.如果我是左闭右闭,lower_bound, 走[left, right],循环条件 while(left <= right)
我在nums[mid] >= target 的时候, 会不断向左找[left, mid - 1], 即 'right = mid -1'
nums[mid] < target 的时候, 会不断向右找[mid + 1, right], 即 'left = mid + 1'
即使找到了 nums[mid'] = target, 也会去[left, mid'-1]继续寻找
这时候肯定需要[left, right]左侧的所有值 <= target:
若是小于的话,我们就将left不断逼近right,排除掉不满足的0
若是等于的话,我们的right = mid - 1,去除重复的右端target
最终会逼近最左边的 target - 1(lower_bound)
最后的情况可能是 left == right,
由于 mid = (left + right) / 2 == left = right,
对于[left, right], 此时target的位置肯定不在此新搜索范围(搜索范围是寻找不包括已寻找到target位置的,因为是向外搜索)内,但就在前方或者后方
由于是搜索范围,故nums[mid]的具体范围无法确定,我们分三种情况讨论
- 3.1 nums[mid] < target 说明第一个target或者第一个大于target的数 在右边(mid+1)
left = mid + 1 找到 target, left > right结束循环 - 3.2 nums[mid] > target ,但由于我们是lower_bound处理,优先向左收敛,这种情况不可能出现!!!
- 3.3 nums[mid] == target 说明找到的target不是最左的,是第二个target(lower_bound)
right = mid - 1 向前一个找到最左的target
此时[target(下标), target(下标)] 已经找不到新的搜索空间了
因为left == right,结束循环,返回left即可(也就是没有搜索空间再给我们找了,我们就返回找到的最左边的)
总之找到第一个target(target存在)或者第一个大于target(target不存在)的数
4.如果我是左闭右闭,upper_bound, 走[left, right], 同理!
总之找到最后一个target(target存在)或者第一个大于target的数(target不存在)
1.左闭右开
-
每次循环的区间都是[left,right),在二分的时候,
搜索区间去掉mid使得原始区间分为两块[left,mid),[mid+1,right),这样才能保证整个区间都被检索。
所以left = mid + 1
和right = mid
-
主循环判断条件:left< right,这和我们在其他循环中是一样的。
比如for (int i= 0; i< nums.size(); ++i)
左闭右开的写法更常见,c++中迭代器返回的end就是右开的。
在主循环跳出的时候,其实是[left,left),这个搜索空间为空,主循环确保了整个区间的检索。
1.1 lower_bound
实现lower_bound关键是target == nums[mid]的时候,我们仍然让right = mid,实现从右向左收缩.
int lower_bound(vector<int>& nums, int target){
int left = 0, right = nums.size(), mid; // 初始值[0, nums.size())
while (left < right){
mid = left + (right - left)/2;
if (nums[mid] == target){
right = mid;
}else if (nums[mid] < target){
left = mid + 1;
}else if (nums[mid] > target){
right = mid;
}
}
// 边界(全都小于target,最后left=right初始值=nums.size() / 不存在target,只存在num1 < target < num2)
if (left >= nums.size() || nums[left] != target) return -1;
return left;
}
1.2 upper_bound
- 实现 upper_bound关键是target = nums[mid]的时候,left = mid + 1 实现从左向右搜索。
- 最终推出循环返回的left是最后出现target的下一位,需要 left - 1 来正确返回下标
int upper_bound(vector<int>& nums, int target){
int left = 0, right = nums.size(), mid; // 初始值[0, nums.size())
while (left < right) {
mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid;
} else if (nums[mid] == target) {
left = mid + 1;
}
}
// 最后要检查 越界的情况(如果存在一个==target的数,left至少>=1,因为mid+1)
if (left <= 0 || nums[left-1] != target)
return -1;
return left - 1;
}
2.左闭右闭
2.1 lower_bound
int left_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1; // 初始值 [0, nums.length-1]
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 别返回,锁定左侧边界
right = mid - 1;
}
}
// 最后要检查 left 越界的情况
if (left >= nums.length || nums[left] != target)
return -1;
return left;
}
2.2 upper_bound
int right_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1; // 初始值 [0, nums.length-1]
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 别返回,锁定右侧边界
left = mid + 1;
}
}
// 最后要检查 right 越界的情况
if (right < 0 || nums[right] != target)
return -1;
return right;
}
3. 利用二分查找的二段性确定上下界
左闭右开型的适合用来寻找下界,左开右闭型的适合用来寻找上界