🏖️作者:@malloc不出对象
⛺专栏:《初识C语言》
👦个人简介:一名双非本科院校大二在读的科班编程菜鸟,努力编程只为赶上各位大佬的步伐🙈🙈
目录
- 前言
- 一、二分查找是什么
- 二、二分查找需要满足的条件是什么
- 三、二分查找朴素模板
- 3.1 第一个模板
- 3.2 第二种模板
- 3.3 存在的缺陷
- 四、二分查找进阶模板
- 4.1 模板一
- 4.2 模板二
- 4.3 lower_bound()函数与upper_bound()函数
- 五、二分查找终极模版
- 六、二分VS单调性?
- 七、浮点型二分查找
前言
本篇文章给大家讲解的是二分查找算法,这时候有人可能就会说了这有什么好讲的二分查找不是很简单吗🙈🙈那我只能说你是大佬,至少我在学习的时候经常出现各种问题,因为这个地方要注意的细节实在是太多了,一个不小心就导致死循环、答案错误等问题…因此本菜鸟也是花了大量时间进行梳理总结,本篇文章博主也是总结了几个通用的模板对于解决二分查找出现的各种边界问题,当然了如果仅仅只记忆化的套用模板你是无法体会到二分最重要的思想的,最终还是得靠读者自己分析才能更好的套用模板。
一、二分查找是什么
二分查找算法也称折半搜索算法,是一种在有序数组中查找某一特定元素的搜索算法 。它的基本思想是将n个元素分成大致相等的两部分,取数组中间值与target做比较,如果target = 中间值则找到target,算法中止;如果 target < 中间值,则只要在数组的左半部分继续搜索target,如果target > 中间值,则只要在数组的右半部搜索target.
这种搜索算法充分利用了元素间的次序关系采用分治策略,每一次比较都使搜索范围缩小一半,时间复杂度为循环的次数O(logN),所以查找效率非常之高。假如在一个二分查找合适的条件中,要你在中国14亿人口中找到你,只需要查找O(log10^10) < O(log2 ^ 40)次就可以了,14亿只需要找小于40次就能找到你了可想而知查找效率有多高🙈🙈
二、二分查找需要满足的条件是什么
也许很多人说必须是在一个有序的数组里面才能进行二分查找(也有可能他们说的是在某一段区间内必须有序),包括我之前也是这么认为的。
其实二分查找是适用于“已排好序”的序列。注意,这里所说的“已排好序”,并不要求数据完全按照某个排序规则进行升序或降序排序,而仅仅要求 [l, r] 这段区域内满足某种性质使得它划分出俩个区间。
二分的本质就是是给定一段区间这段区间根据某种性质划分出左右区间,只满足左右区间的一种。有单调性一定可以进行二分,二分不一定数组具有单调性,可以理解为数学上的二分是单调性的充分不必要条件。
这里关于单调性的理解可能会出现一点小偏差,我在最后会提到这个问题怎么来看。
三、二分查找朴素模板
接下介绍的这两个模板也许是C语言初学阶段用的很多的两种模板,这两种模板都用来解决一个问题:在一个有序不重复
数组中找到等于目标值target的位置,如果找不到就返回-1.
为什么我着重标记了不重复呢?在讲完这两个模板之后我会给大家好好分析一下的。
3.1 第一个模板
第一个模板它的核心框架为:
1.搜索区间为:左闭右闭[left,right]
2.循环条件为while(l <= r)
3.l = mid + 1,r = mid - 1
接下来给出模板:
int Binary_Search1(int nums[], int sz, int target)
{
int l = 0, r = sz - 1; //sz为数组大小
while (l < r)
{
int mid = l + (r - l) / 2; //写成这样防止两个很大的整数相加数据溢出的情况
if (nums[mid] > target) { //当中间元素大于target时,说明target一定在mid的左侧
r = mid - 1; //r向左侧靠拢,将区间缩小至[l, mid - 1]
} else if (nums[mid] < target) { //当中间元素小于target时,说明target一定在mid的右侧
l = mid + 1; //l向右侧靠拢,将区间缩小至[mid + 1, r]
} else {
return mid; //当找到target时直接返回在数组中的下标
}
}
return -1; //如果找不到返回-1,当然找不到返回什么取决于题目要求,你只需要记住二分一定有答案,并且夹出的边界点是最接近答案的位置
}
关于这个代码部分也是很简单,这里我就不做过多的阐述了,下面一起来分析几个问题?
为什么此时 while 循环的条件中是 <=,而不是 < ?
因为初始化r等于sz - 1,即最后一个元素的索引,此时我们使用的是左闭右闭区间[left,right],这个区间就是我们每次搜索的区间,这个也称为搜索区间。我们什么时候停止呢?当我们找到目标值时停止直接返回目标值,另一种情况是循环正常终止,它的终止条件是l == r + 1,当l == r时这一步是有意义的,因为我们的搜索区间是左闭右闭的,我们也需要对当前位置进行判断。
如果循环条件为while (l < r)的话,假使在某种情况下l == r这个位置为目标值的话,此时当l == r时我们已经退出了循环,在循环内未找到目标值,最终返回-1.
我们随便来看一个例子,当我们使用正确的循环条件时得到了正确的下标:
当循环条件为l < r时,我们没有得出正确的答案,因为目标值在l == r这个位置时,我们没有对这个位置进行判断就已经退出循环了,得到的结果是-1,显然是有问题的
我简单的对这个例子进行分析一下吧,当target在l == r这个位置时,这时我们已经找到位置了,但是没有进行判断返回下标我们的循环就结束了。
为什么要此时使L = mid + 1,r = mid - 1?我们或许还看到过 L = mid,r = mid…该如何来理解呢?
我们的搜索区间为左闭右闭[left,right],当我们的mid不等于target时,因为 mid 已经搜索过了,所以应该从搜索区间中去除,之后我们当然是去搜索 [left, mid - 1] 或者 [mid + 1, right] 对不对?
3.2 第二种模板
第二个模板的基本框架:
1)区间:左闭右开
2)循环条件while(l < r)
3)l = mid + 1,r = mid
下面是模板二的代码:
int Binary_Search2(int nums[], int sz, int target)
{
int l = 0, r = sz;
while (l < r)
{
int mid = l + (r - l) / 2;
if (nums[mid] > target) {
r = mid;
} else if (nums[mid] < target) {
l = mid + 1;
} else {
return mid;
}
}
return -1;
}
关于代码我也不跟大家做解释了跟第一个模板是一样的,下面我们来看看不同于第一个模板的几个问题?
为什么 循环条件是 while(l < r) 而不是 <= ?
用相同的方法分析,因为初始化 r = sz 而不是sz - 1。每次循环的「搜索区间」是 [left, right) 左闭右开。while(left < right) 终止的条件是 left == right,此时搜索区间 [left, left) 恰巧为空,所以可以正确终止。
为什么r = mid而非mid + 1?
这个也很好解释,因为我们的「搜索区间」是 [left, right) 左闭右开,所以当 nums[mid] 被检测之后,下一步的搜索区间应该去掉mid分割成两个区间,即 [left, mid) 或 [mid + 1, right)。
3.3 存在的缺陷
好了,这两个模板的一些细节以及代码部分已经给大家说完了,那么我们来回答一下之前提到过的为什么这两个模板只能适用于有序不重复的数组中呢?
原因是因为它返回的元素下标不是唯一确定的,当找到一个与目标相同的元素了就返回该下标了。
关于这两个模板的缺陷是什么呢?
1)用于查找的内容逻辑上来说是需要有序的
2)查找的数量只能是一个,一旦有重复元素的出现,查找到的元素下标不是唯一的(在不同条件下有多种下标满足)。
四、二分查找进阶模板
二分查找的进阶版,它也分为两个模板,但是分别了解决不同的问题,这两个模板也是可以解决大部分涉及二分查找算法的题目,但是需要注意的细节很多,我们很容易写出死循环、边界点取错等一系列问题。
4.1 模板一
模板一:寻找第一个大于等于target元素的位置,也就是寻找左侧边界的二分搜索。
我们先给出第一种模板:
int Binary_Search1(int nums[], int sz, int target)
{
int l = 0, r = sz;
while (l < r)
{
int mid = l + (r - l) / 2;
if (nums[mid] >= target) { //这一步非常的关键,它是一个check条件,检查target是否满足某种性质,以此来划分左右区间,决定了你该怎样使用这个模板
r = mid; //当mid处值大于等于target时,证明target一定在mid的左侧和mid处,mid处也是满足的,所以r向左收缩区间至[l,mid]
} else {
l = mid + 1; //当mid处值小于target时,证明target一定在mid的右侧,mid处是取不到的,所以l向右收缩区间至[mid+1,r]
}
}
return l;
}
当然了上述模板也可以分成三步来写,形式上跟之前讲过的两种模板很相似,但肯定不一样
int Binary_Search1(int nums[], int sz, int target)
{
int l = 0, r = sz;
while (l < r)
{
int mid = (l + r) / 2;
if(a[mid] > x) {
r = mid;
} else if(a[mid] < x) {
l = mid + 1;
} else {
r = mid; //当找到target时不能直接进行返回,我们要继续向左缩小区间,达到锁定左侧边界的目的, 我们求的是第一个大于等于target元素的下标(数组中有重复元素的情况)
}
}
return l;
}
我个人更喜欢将 >= 合并成为一步的形式,读者可以任选一种喜欢的形式即可。
下面是我简单的做了一下搜索左侧区间的分析,如果有描述不清楚的地方,请读者最好是跟着代码来看每一步的过程。
下图我是直接按照写好的check条件(nums[mid] >= target)来进行分析的,也是随手举的一个例子,图中的x就是target,可能画的有点小抽象,读者可以搭配代码来模拟这个过程
4.2 模板二
模板二:寻找最后一个小于等于target元素的位置,也就是寻找右侧边界的二分搜索。
我们先给出模板二:
int Binary_Search2(int nums[], int sz, int target)
{
int l = 0, r = sz - 1;
while (l < r)
{
int mid = (l + r + 1) / 2;
if (nums[mid] <= target) { //此时check条件为nums[mid] <= target, 当满足该条件时,说明target一定在mid处或者mid的右侧
l = mid; //向右收缩区间至[mid,r]
}
else {
r = mid - 1; //当mid位置值大于target说明target一定在mid左侧,mid处取不到,向左收缩区间至[l,mid-1]
}
}
return r;
}
第二个模板与第一个模板的不同之处?
第一个不同的地方在于
r = sz - 1
,因为此时我们要找的是小于等于target的最后一个元素,它是一个闭区间;
第二个不同的地方我们的mid
写成了mid = (l + r + 1) / 2
,这里为何要多加1
呢?
假设我们这里没加上1的话,int mid = (l + r ) / 2
;当l == r - 1
时,mid = (2l + 1) / 2
,由于计算机中整数整除会向下取整所以我们得到的是mid = l
;假设满足条件l = mid
,此时我们的l
和r
都没变化那么mid
也就没变,此时就陷入了死循环;
为了解决整数相除会出现向下取整的问题所以我们加上一个1
,此时当l == r - 1
时,mid = r
,当满足条件时l == r
,循环结束这样就不会出现死循环的问题了。
那么为什么第一个模板不需要呢?我们一起来分析一下,当l == r - 1
时,mid
同样的等于l
,假设满足条件r = mid
,此时r == l
循环退出,假设不满足条件l = mid + 1
,l
是变化的那么自然也就不会导致死循环。
下面来简单的看下右侧二分搜索边界的过程:
最后我来总结一下这两个模板:
首先这两个模板是为了解决不同的问题,所以在边界一些问题上产生了差异,但是本质还是靠着二分的思想一步步夹出分界点,注意这两个模板在返回时l和r都是可以的,因为它们的循环条件都是当l == r时结束循环,只有一个分界点将性质不同的两个区间分开来。
那么我们该如何注意什么时候mid该加1呢?
我们先就按照原来mid的求法写一遍,然后其实是看check条件来决定我们的mid到底要不要加1,这里的check条件是由我们自己分析得到的,我的建议是尽量写成满足题目要求的条件,这样更容易清楚的知道我们此时寻找的哪一侧边界点,此时check条件满足为真向哪个方向缩小就知道寻找的是哪一侧的边界了,由此我们就能确定我们的mid到底要不要加1,读者也可以自行去验算一下,不要想当然的就直接套用模板了,题目是非常灵活的。
下面我们就来用这两个模板解决一道经典的题:数的范围。
下面给出我套用模板AC的代码,供大家参考
#include<iostream>
using namespace std;
const int N = 100010;
int q[N];
int n, m;
int main()
{
cin >> n >> m;
for(int i = 0; i < n; i++) scanf("%d",&q[i]);
int x = 0;
while(m--)
{
cin >> x;
int l = 0, r = n;
while(l < r)
{
int mid = (l + r) / 2;
if(q[mid] >= x) //check条件为大于等于x为真,说明此时我们的搜索的是左侧区间,向左边靠拢
r = mid;
else
l = mid + 1;
}
if(q[l] != x) //如果边界点处没找到x,那么该数组中就不存在x元素,此时输出-1 -1
cout << "-1 -1" << endl;
else
{
cout << l << " "; //如果数组中存在x,先输出左边界点,当然也可以写成r,,因为当l == r时我们夹出来边界点,l与r是一样的
int ll = 0, rr = n - 1;
while(ll < rr) //这里不能写成等于了,一旦写成等于当ll == rr,会陷入死循环mid = rr == ll
{
int mid = (ll + rr + 1) / 2; //这里一定要注意加1,不然会出现死循环
if(q[mid] <= x) //当check条件为小于等于x为真,说明此时我们搜索的是右侧区间,向右边靠拢
ll = mid;
else
rr = mid - 1;
}
cout << rr << endl;
}
}
return 0;
}
4.3 lower_bound()函数与upper_bound()函数
其实这道题可以使用另一种模板,也是二分算法里面最常用的两个库函数lower_bound()
和upper_bound()
,它们包含在C++中<algorithm>
头文件中。
函数lower_bound()
,用于在指定范围内查找大于等于目标值的第一个元素,如果所有元素都小于target
,则返回last的位置。这个函数其实我们已经实现过了,其实就是第一个模板;
函数upper_bound()
,用于在指定范围内查找大于目标值的第一个元素,如果不存在则返回last的位置。
以上只是对两个函数的功能简单的做下说明,因为博主水平有限还不懂其中具体使用C++怎么实现的,大家有兴趣的话可以上网查查资料如何去使用,这里就不做过多的说明了,我们只需要理解其中最重要的二分查找思想就好了。
好了,回到正题我们想一下upper_bound()
函数与最后一个小于等于target
元素的位置有什么关系?
大于target第一个元素的位置 -1是不是即为小于等于x的最后一个位置呢?
找到了这俩者的关系之后,接下来我们就来自己实现一下upper_bound()
函数。
int upper_bound(int x)
{
int l = 0, r = n;
while(l < r)
{
int mid = (l + r) / 2;
if(q[mid] > x) //其实upper_bound()函数就只是改了一下这个条件,其余的都没变
r = mid;
else
l = mid + 1;
}
return r;
}
下面给出AC代码:
#include<iostream>
using namespace std;
const int N = 100010;
int q[N];
int n, m;
int lowwer_bound(int x)
{
int l = 0, r = n;
while(l < r)
{
int mid = (l + r) / 2;
if(q[mid] >= x)
r = mid;
else
l = mid + 1;
}
return l;
}
int upper_bound(int x)
{
int l = 0, r = n;
while(l < r)
{
int mid = (l + r) / 2;
if(q[mid] > x)
r = mid;
else
l = mid + 1;
}
return r;
}
int main()
{
cin >> n >> m;
for(int i = 0; i < n; i++) scanf("%d",&q[i]);
int x;
while(m--)
{
cin >> x;
int l = lowwer_bound(x);
if(q[l] != x)
cout << "-1 -1" << endl;
else
{
cout << l << " ";
int r = upper_bound(x);
cout << r - 1 << endl;
}
}
return 0;
}
下面是直接使用STL大法AC的代码:
#include<iostream>
#include<algorithm>
using namespace std;
const int N = 100010;
int q[N];
int n, m;
int main()
{
cin >> n >> m;
for(int i = 0; i < n; i++) scanf("%d",&q[i]);
int x;
while(m--)
{
cin >> x;
int l = lower_bound(q, q + n, x) - q;
if(q[l] != x)
cout << "-1 -1" << endl;
else
{
cout << l << " ";
int r = upper_bound(q, q + n, x) - q;
cout << r - 1 << endl;
}
}
return 0;
}
STL大法好!!!,但是我们也不要太依赖它,最重要的还是了解它的底层思想🙈🙈
总结一下:
关于到底使用哪种模板,其实在这道题上用lower_bound()
和upper_bound()
更好,不容易出错,除了check()条件以外,代码几乎是一模一样,也不需要考虑死循环问题…
那么我个人认为对于这种类型的题的话,其实只需要使用lower_bound()
和upper_bound()
这俩个函数就能找到其他的位置了,例如:找出第一个大于等于target元素的位置、找出第一个大于target元素的位置、找出最后一个小于等于target元素的位置、找出最后一个小于target元素的位置…无外乎这四种情况,,我们通过lower_bound()
和upper_bound()
函数的位置就能知道其他两个条件的位置了。
五、二分查找终极模版
这个模板可以解决掉上述二分查找模板中出现的边界问题,而且也不需要考虑划分区间后l与r的取值,,这个模板与上述模板最大的不同是什么呢?
上述所有的二分查找模板都是通过l和r一步步夹出边界的,并且它的边界点其实只有一个即l == r,而这个模板是真真正正的划分出了两个边界,一个左边界和一个右边界它们是相邻的,最后具体返回时要根据check()条件来返回左边界点还是右边界点。
这里以返回右边界点为例给出模板:
int Binary_Search3(int a[], int sz, int x)
{
int l = -1, r = sz;
while (l + 1 != r)
{
int mid = (l + r) / 2;
if (check()) {
r = mid;
} else {
l = mid;
}
}
return r;
}
这个模板做了哪些改变呢?
细节1:mid始终处于[0, N)以内?
l 的最小值为 -1,r的最小值是1,mid的最小值就为0
l的最大值为N-2,因为l + 1 != N,r的最大值为N,mid的最大值就为N-1
所以mid始终处于有效范围之内。
细节2:会不会陷入死循环?
答案是不会的,因为无论哪种情况最后都会归结到第一种情况,当l + 1 == r时就会退出循环。
看到这儿我感觉有些读者可能还不能真正体会到它的妙用,这里我就通过一个例子让大家感受一下这个过程:
此时我们可以观察一下,当check()条件为a[mid] > target时,不满足时我们L向右收缩靠拢;满足时我们的R向左缩拢,最终会找到大于target的第一个元素,我们由此也划分出了两个区间左区间是小于等于target的元素,右区间是大于target的元素;而产生的两个边界点,R是大于target的第一个元素的右边界点,L是小于等于target的最后一个位置的左边界点。
好了关于这个模板我就讲到这里,读者也可以下去自行检测是否会出现死循环等问题,接下来我同样的给出我用这套模板AC的代码:
#include<iostream>
using namespace std;
const int N = 100010;
int q[N];
int main()
{
int n = 0, k = 0;
cin >> n >> k;
for(int i = 0; i < n; i++) scanf("%d",&q[i]);
while(k--)
{
int x = 0;
cin >> x;
int l = -1, r = n;
while(l + 1 != r)
{
int mid = (l + r) / 2;
if(q[mid] >= x) //跟据check划分出两块区间,左区间是小于x元素的,右区间是大于等于x元素的
r = mid;
else
l = mid;
}
if(q[r] != x) //判断右区间的边界点是否等于x,如果不等于x则数组中不存在x,返回-1 -1
cout << "-1 -1" << endl;
else
{
cout << r << ' '; //输出右区间的边界点
int ll = -1, rr = n;
while(ll + 1 != rr)
{
int mid = (ll + rr) / 2;
if(q[mid] <= x) //根据check划分出左右区间,左区间是小于等于x的,右区间是大于x元素的
ll = mid;
else
rr = mid;
}
cout << ll << endl; //输出左区间的边界点
}
}
return 0;
}
总结一下这个模板:这个模板解决了很多边界的问题可以说是非常的优秀的模板,我们来对比发现它有个"缺点"是什么呢?在返回边界点时要根据check()条件来进行返回边界点,而上述模板都是只有一个边界点随便返回哪个都行。但是不管如何,我觉得这真正意义上也不算个缺点,你只要控制check()条件恰当就能迅速的判断出要返回的边界点。
六、二分VS单调性?
在开头我们已经提及过,二分的本质不是单调性,有单调性一定可以二分,二分不一定需要单调性,这里的单调性我在网上查了查有两种解释,一般的不强调区间的情况下,所谓的单调函数是指, 对于整个定义域而言,函数具有单调性,而在强调区间的情况下,你的一段子区间有序就说明它具有单调性…这个地方也许有些许的争议,那么我们这里暂时按照不强调区间的情况下来(也就是全部有序才单调)。
下面我们就来看看这个不具单调性也能二分的案例:寻找峰值。
这里给出我的两种AC代码:
//代码1
int findPeakElement(vector<int>& nums) {
int l = 0, r = nums.size() - 1; //这里一定要写成闭区间,才能使下面的check()条件不发生越界访问
while(l < r) {
int mid = (l + r) / 2;
if(nums[mid] > nums[mid + 1]) { //check找到一个区间mid大于mid+1,此时mid可能是波峰,向左进行收缩,寻找左侧边界点
r = mid;
} else {
l = mid + 1; //mid小于等于mid+1,此时在走上坡路,向右靠拢一定能找到波峰
}
}
return l;
}
//代码2
int findPeakElement(vector<int>& nums) {
int l = -1, r = nums.size() - 1; //这里要写成闭区间
while(l + 1 != r) {
int mid = (l + r) / 2;
if(nums[mid] > nums[mid + 1]) {
r = mid;
} else {
l = mid;
}
}
return r;
}
看向这两种代码都说明了我们不要直接就照搬模板,模板只是用来做大思路参考的,一定要根据题目的check条件来判断边界问题。
七、浮点型二分查找
整数可以进行二分查找,那么浮点数能不能进行二分查找呢?
答案是可以的,并且浮点数进行二分查找更简单,因为不需要考虑边界以及死循环的问题,所以可以直接任意套用一种二分查找的进阶模板。
下面我们来看这道题:数的三次方根。
下面给出我的AC代码:
#include<iostream>
using namespace std;
const double ESP = 1e-8; //这里设置了一个精度,假如l和r相差的绝对值在这个精度范围之内,我们就认为l此时与r相等,此时循环结束
int main()
{
double l = -10000.0, r = 10000.0; //取边界点
double x = 0.0;
cin >> x;
while(r - l > ESP)
{
double mid = (r + l) / 2;
if(mid * mid * mid < x)
l = mid; //不需要使得l = mid + 1或r = mid - 1,因为浮点型比整型能够表示的精度更广,对于浮点型数据来说减1是一个莫大的差距
else
r = mid;
}
printf("%.6lf\n",l);
return 0;
}
浮点型二分查找并不需要考虑死循环与边界问题,个人觉得这里最重要的是需要控制好一个精度范围,当l与r相差在这个精度范围内时就认为l == r,关于这里为什么要控制精度的问题,如果读者有不懂的可以看我的这篇博客,里面详细介绍了使用浮点型的各种注意事项。
最后再进行总结一次:其实说到底其实所有模板都是二分思想,根据二分划分出两个性质不同的区间找到边界点,这篇文章所用的模板能够解决绝大部分二分查找算法的问题,模板虽好但是也不能就是记忆化的进行照搬套用,最关键的还是要理解二分查找的本质思想就是找出边界点,所以check()条件是一个关键 !!!
本文的内容就到这里了,这篇文章也是肝了一天总结出来的知识点,如果觉得写的不错请支持支持博主给博主点一个免费的赞🙈🙈 有任何疑问以及错处欢迎大家评论区随时交流哦orz~