不能继续开坑了,得整理一下。最近在刷二分法,思路很简单,细节是魔鬼。时而减一时而不用,仿佛在面向玄学编程,所以特意来整理一下。本文参考。
对于最常见的二分查找框架:
1 | int binarySearch(vector<int>& nums, int target) { |
分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节。本文都会使用 else if,旨在讲清楚,读者理解后可自行简化。
- 其中
...标记的部分,就是可能出现细节问题的地方,当你见到一个二分查找的代码时,首先注意这几个地方。后文用实例分析这些地方能有什么样的变化。 - 另外声明一下,计算
mid时需要防止溢出,代码中left + (right - left) / 2就和(left + right) / 2的结果相同,但是有效防止了left和right太大直接相加导致溢出。
查找一个数
这个场景是最简单的,在一个数组中搜索一个数,如果存在,返回其索引,否则返回 -1。
1 |
|
- 为什么
while循环的条件中是 <=,而不是 <?因为初始化right的赋值是nums.length - 1,而不是nums.length。这二者可能出现在不同功能的二分查找中,区别是:前者相当于两端都闭区间[left, right],后者相当于左闭右开区间[left, right)。我们这个算法中使用的是前者[left, right]两端都闭的区间,这个区间其实就是每次进行搜索的区间。 - 那
while循环什么时候应该终止?搜索区间为空的时候应该终止,意味着你没得找了,就等于没找到嘛。while(left <= right)的终止条件是left == right + 1,写成区间的形式就是[right + 1, right],或者带个具体的数字进去[3, 2],可见这时候区间为空,因为没有数字既大于等于 3 又小于等于 2 。所以这时候 while 循环终止是正确的,直接返回 -1 即可。 - 为什么
left = mid + 1,right = mid - 1?我看有的代码是right = mid或者left = mid,或者时而减时而不减,到底怎么回事,怎么判断?答:这也是二分查找的一个难点,不过只要你能理解前面的内容,就能够很容易判断。刚才明确了「搜索区间」这个概念,而且本算法的搜索区间是两端都闭的,即[left, right]。那么当我们发现索引mid不是要找的target时,下一步应该去搜索哪里呢?之前提到搜索区间是闭区间,所以当然是去搜索[left, mid-1]或者[mid+1, right],因为 mid 已经搜索过,应该从搜索区间中去除。之后还有有这方面的细节。 - 扩展一些,如果不返回 -1 而是直接返回
left。如果数字在数组中,返回的就是索引;如果不在数组中且以升序为例,返回的就是这个元素插入数组中应该放到哪个位置。 - 而对于
while(left < right)这种情况,也就是搜索区间是[left, right),那么终止条件是left == right,写成区间的形式就是[right, right],或者带个具体的数字进去[2, 2],这时候区间非空,还有一个数 2,但此时while循环终止了。也就是说这区间[2, 2]的第一个 2 被漏掉了,索引2没有被搜索,如果这时候直接返回 -1 就是错误的。我们已经知道了出错的原因,就打个补丁好了:1
2
3
4while(left < right) {
// ...
}
return left < right ? left : -1;
搜索左侧边界
给定一个数组,1 2 2 2 3,搜索数字 2 最开始出现的左侧区间,这里就返回索引 1。代码如下,这里写成左闭右开的形式:
1 |
|
- 为什么
while中是 < 而不是 <=? 用相同的方法分析,因为right = nums.length而不是nums.length - 1。因此每次循环的「搜索区间」是[left, right)左闭右开。while(left < right)终止的条件是left == right,此时搜索区间[left, left)为空,所以可以正确终止。 - 如果
nums中不存在target这个值,怎么办?对于一个数组1 2 2 2 3,target为 2 返回 1,含义是:nums中小于 2 的元素有 1 个;target = 1,算法会返回 0,nums中小于 1 的元素有 0 个;target = 8,算法会返回 4,nums中小于 8 的元素有 4 个。综上可以看出,函数的返回值(即 left 变量的值)取值区间是闭区间[0, nums.length],所以我们简单添加两行代码就能在正确的时候return -1。 - 为什么
left = mid + 1,right = mid和之前的算法不一样?因为我们的「搜索区间」是[left, right)左闭右开,所以当nums[mid]被检测之后,下一步的搜索区间应该去掉mid分割成两个区间,即[left, mid)或[mid + 1, right)。 - 为什么返回
left而不是right?都是一样的,因为while终止的条件是left == right。 - 能不能想办法把
right变成nums.length - 1,也就是继续使用两边都闭的「搜索区间」?这样就可以和第一种二分搜索在某种程度上统一起来了。由于while的退出条件是left == right + 1,所以当target比nums中所有元素都大时,会存在以下情况使得索引越界。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using namespace std;
int binarySearch(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)
right = mid - 1;
else if (nums[mid] < target)
left = mid + 1; // 注意
else if (nums[mid] > target)
right = mid - 1; // 注意
}
if (left == nums.size())
return -1;
return left;
}
int main() {
// 0 1 2 3 4 5 6 7 8 9 10
// 1 3 5 5 8 9 12 23 34 56 84
vector<int> v{12, 34, 23, 5, 1, 8, 56, 3, 5, 9, 84};
int target = 1000;
sort(v.begin(), v.end());
int a = binarySearch(v, target);
cout << a << endl;
return 0;
}
寻找右侧边界
给定一个数组,1 2 2 2 3,搜索数字 2 最开始出现的最右侧区间,这里就返回索引 3。代码如下:
1 | int right_bound(int[] nums, int target) { |
- 搜索区间」是
[left, right)左闭右开,所以当nums[mid]被检测之后,下一步的搜索区间应该去掉mid分割成两个区间,即[left, mid)或[mid + 1, right)。 - 为什么最后返回
left - 1而不像左侧边界的函数,返回left?而且我觉得这里既然是搜索右侧边界,应该返回right才对。首先,while循环的终止条件是left == right,所以left和right是一样的,你非要体现右侧的特点,返回right - 1好了。 - 至于为什么要减一,这是搜索右侧边界的一个特殊点,关键在等式条件
nums[mid] == target下的判断:left = mid + 1,因为最后一定是找到了和target相等的数字,且是最右侧的。对left的更新是left = mid + 1,就是说while循环结束时,nums[left]一定不等于target了,而nums[left-1]可能是target。
1 |
|
STL 开车
对于一些高级语言而言,其实都内置了二分搜索。以 C++ 为例,搜索数组 1 2 2 2 3 中有几个 2。第一种方案是搜索 2 出现的左边界,而后搜索出现的右边界。但是也可以通过 lower_bound 和 upper_bound 来解决,因为在某些复杂应用下,二分只是一个小点,没必要花费大多精力在不重要的点。
1 |
|