DAY 01 二分查找
一、寻找一个数(基本的二分搜索)
int erfen(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1; // 注意左闭右闭区间
while(left <= right) {
int mid = left + (right - left) / 2;
if(nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1; // 注意
else if (nums[mid] > target)
right = mid - 1; // 注意
}
return -1;
}
输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4
输入: nums = [-1,0,3,5,9,12], target = 2
输出: -1
解释: 2 不存在 nums 中因此返回 -1
几点说明:
- 计算mid时需要防止溢出,代码中left + (right - left) / 2和(right + left) / 2的结果相同,这样写可以防止left和right太大,直接相加导致溢出的情况。
2. 为什么 while 循环的条件中是 <=,而不是 <?
其实 <=和 <都可以的,用哪一个取决于我们的初始条件,我们初始化时right= nums.size() - 1,是最后一个元素的索引,相当于两端都闭区间 [left,right]。
如果我们初始化right=num.size(),这个索引大小是越界的,相当于左闭右开区间[left,right)。
3.搜索的终止条件主要分两个:
第一,找到目标值的时候终止。
if(nums[mid] == target) return mid
第二,没找到就while循环终止。(又分两种情况)
- while(left <= right)
该循环终止条件是left==right+1,区间形式就为[right+1,right],这个时候区间为空,while循环终止,直接返回-1。
- while(left < right)
该循环终止条件是left==right,区间形式就为[right,right),这个时候区间为非空,比如[2,2],还有个数2,但此时while循环终止。那么索引2就会被漏掉,这里直接返回-1就是错误的。如果非要用 while(left < right) 也可以
while(left < right){
//…
}
return nums[left] == target ? left : -1;
4.缩小搜索区间
在上面的代码中的搜索区间是两端都闭的,即[left,right]。那么当发现索引mid不是要找的target时,就要去搜索区间[left,mid-1]或者[mid+1,right]。
因为mid已经搜索过,应该从搜索区间中除去。
5.算法缺点
算法存在局限性。比如一个有序数组 nums = [1,2,2,2,3],target 为 2,此算法返回的索引是 2,没错。但是如果我想得到 target 的左侧边界,即索引 1,或者我想得到 target 的右侧边界,即索引 3,这样的话此算法是无法处理的。
二、寻找左侧边界的二分搜索
int left_bound(vector<int>& nums, int target) {
int left = 0;
int right = nums.size(); // 注意
while (left < right) // 注意
{
int 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; // 注意
}
}
return left;
}
几点说明:
1、为什么 while 中是 < 而不是 <=?==
用相同的方法分析,因为 right = nums.size(); 而不是 nums.size() - 1。因此每次循环的「搜索区间」是 [left, right) 左闭右开。
while(left < right) 终止的条件是 left == right,此时搜索区间 [left, left) 为空,所以可以正确终止。
2、如果 nums 中不存在 target 这个值,计算出来的这个索引含义是什么
如果 target 不存在,搜索左侧边界的二分搜索返回的索引是大于 target 的最小索引。
举个例子,nums = [2,3,5,7], target = 4,left_bound 函数返回值是 2,因为元素 5 是大于 4 的最小元素。
现在让写这么一个函数
// 在一个有序数组中,找到「小于 target 的最大元素的索引」
// 比如说输入 nums = [1,2,2,2,3],target = 2,函数返回 0,因为 1 是小于 2 的最大元素。
// 再比如输入 nums = [1,2,3,5,6],target = 4,函数返回 2,因为 3 是小于 4 的最大元素。
int floor(int[] nums, int target);
那么这个函数就可以直接用 left_bound 函数来实现:
int floor(int[] nums, int target) {
// 当 target 不存在,比如输入 [4,6,8,10], target = 7
// left_bound 返回 2,减一就是 1,元素 6 就是小于 7 的最大元素
// 当 target 存在,比如输入 [4,6,8,8,8,10], target = 8
// left_bound 返回 2,减一就是 1,元素 6 就是小于 8 的最大元素
return left_bound(nums, target) - 1;
}
如果没找到想让它返回 -1,怎么做?
while (left < right)
{
//...
}
// 如果索引越界,说明数组中无目标元素,返回 -1
if (left < 0 || left >= nums.length)
{
return -1;
}
// 提示:其实上面的 if 中 left < 0 这个判断可以省略,因为对于这个算法,left 不可能小于 0
// 判断一下 nums[left] 是不是 target
return nums[left] == target ? left : -1;
3、为什么 left = mid + 1,right = mid ?
我们的「搜索区间」是 [left, right) 左闭右开,所以当 nums[mid] 被检测之后,下一步应该去 mid 的左侧或者右侧区间搜索,即 [left, mid) 或 [mid + 1, right)。
4、为什么该算法能够搜索左侧边界?
关键在于对于 nums[mid] == target 这种情况的处理:
if (nums[mid] == target)
right = mid;
找到 target 时不要立即返回,而是缩小「搜索区间」的上界 right,在区间 [left, mid) 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。
5、为什么返回 left 而不是 right?
都是一样的,因为 while 终止的条件是 left == right
6、可不可以把 right 变成 nums.size() - 1,也就是使用两边都闭的「搜索区间」?这样就可以和第一种二分搜索在某种程度上统一起来了。
让搜索区间两端都闭,所以 right 应该初始化为 nums.size() - 1,while 的终止条件应该是 left == right + 1,也就是其中应该用 <=
完整代码:
int left_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
// 搜索区间为 [left, right]
while (left <= right)
{
int mid = left + (right - left) / 2;
if (nums[mid] < target)
{
// 搜索区间变为 [mid+1, right]
left = mid + 1;
}
else if (nums[mid] > target)
{
// 搜索区间变为 [left, mid-1]
right = mid - 1;
}
else if (nums[mid] == target)
{
// 收缩右侧边界
right = mid - 1;
}
}
// 判断 target 是否存在于 nums 中
// 如果越界,target 肯定不存在,返回 -1
if (left < 0 || left >= nums.size())
{
return -1;
}
// 判断一下 nums[left] 是不是 target
return nums[left] == target ? left : -1;
}
三、寻找右侧边界的二分查找
类似寻找左侧边界的算法,这里也会提供两种写法,还是先写常见的左闭右开的写法,只有两处和搜索左侧边界不同:
int right_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target)
{
left = mid + 1; // 注意
}
else if (nums[mid] < target)
{
left = mid + 1;
}
else if (nums[mid] > target)
{
right = mid;
}
}
return left - 1; // 注意
}
几点说明:
1、为什么这个算法能够找到右侧边界?==
if (nums[mid] == target) {
left = mid + 1;
当 nums[mid] == target 时,不要立即返回,而是增大「搜索区间」的左边界 left,使得区间不断向右靠拢,达到锁定右侧边界的目的。
2、为什么最后返回 left - 1 而不像左侧边界的函数,返回 left?而且我觉得这里既然是搜索右侧边界,应该返回 right 才对。
首先,while 循环的终止条件是 left == right,所以 left 和 right 是一样的,你非要体现右侧的特点,返回 right - 1 好了。
为什么要减一呢,这是搜索右侧边界的一个特殊点,关键在锁定右边界时的这个条件判断:
// 增大 left,锁定右侧边界
if (nums[mid] == target) {
left = mid + 1;
// 这样想: mid = left - 1
因为我们对 left 的更新必须是 left = mid + 1,就是说 while 循环结束时,nums[left] 一定不等于 target 了,而 nums[left-1] 可能是 target。
为什么 left 的更新必须是 left = mid + 1,当然是为了把 nums[mid] 排除出搜索区间嘛。
3、如果 nums 中不存在 target 这个值,计算出来的这个索引含义是什么?如果我想让它返回 -1,怎么做?
如果 target 不存在,搜索右侧边界的二分搜索返回的索引是小于 target 的最大索引。
想一想刚才左边界返回的是什么,记住几个例子就可以。
比如 nums = [2,3,5,7], target = 4,right_bound 函数返回值是 1,因为元素 3 是小于 4 的最大元素。
如果想在 target 不存在时返回 -1,
只要在最后判断一下 nums[left-1] 是不是 target 就行,类似之前的左侧边界搜索,做一点额外的判断即可:
while (left < right) {
// ...
}
// 判断 target 是否存在于 nums 中
// left - 1 索引越界的话 target 肯定不存在
if (left - 1 < 0 || left - 1 >= nums.size()) {
return -1;
}
// 判断一下 nums[left - 1] 是不是 target
return nums[left - 1] == target ? (left - 1) : -1;
也可以把这个算法的「搜索区间」也统一成两端都闭的形式,其实只要改两个地方
int right_bound(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 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;
}
}
// 最后改成返回 left - 1
if (left - 1 < 0 || left - 1 >= nums.size()) {
return -1;
}
return nums[left - 1] == target ? (left - 1) : -1;
//或者return nums[right] == target ? right : -1;
//因为right=left-1嘛
}
总结:
第一个,最基本的二分查找算法:
因为我们初始化 right = nums.size() - 1
所以决定了我们的「搜索区间」是 [left, right]
所以决定了 while (left <= right)
同时也决定了 left = mid+1 和 right = mid-1
因为我们只需找到一个 target 的索引即可
所以当 nums[mid] == target 时可以立即返回
第二个,寻找左侧边界的二分查找:
因为我们初始化 right = nums.size()
所以决定了我们的「搜索区间」是 [left, right)
所以决定了 while (left < right)
同时也决定了 left = mid + 1 和 right = mid
因为我们需找到 target 的最左侧索引
所以当 nums[mid] == target 时不要立即返回
而要收紧右侧边界以锁定左侧边界
第三个,寻找右侧边界的二分查找:
因为我们初始化 right = nums.size()
所以决定了我们的「搜索区间」是 [left, right)
所以决定了 while (left < right)
同时也决定了 left = mid + 1 和 right = mid
因为我们需找到 target 的最右侧索引
所以当 nums[mid] == target 时不要立即返回
而要收紧左侧边界以锁定右侧边界
又因为收紧左侧边界时必须 left = mid + 1
所以最后无论返回 left 还是 right,必须减一
并且这三种方法都可以统一的写成左右闭合区间形式,上面都有总结,这里不再重复。