一、二分算法简介
当我们要从一个序列中查找一个元素的时候,最简单无脑的方法就是顺序查找法,但由于在大数据情况下爆炸的时间复杂度而舍弃。
最常见的方法是二分查找,也称折半查找(Binary Search),它是一种效率较高的查找方法。
最近偶然看到 『LeetCode』 讨论中的大佬总结的 二分查找从入门到入睡 ,虽然文章巨长,但总结的很全,一些边界问题讲的也很细,其中包括了Y总的二分思路,非常推荐看一看!!
二、 算法基本思想和流程(时间复杂度 O ( l o g n ) O(logn) O(logn) )
- 算法思想: 假设在闭区间
[l, r]
中寻找目标值x
,二分的思想是每次将区间长度缩小一半,当l = r
时,我们就找到了目标值。 - 注意:
- 二分的本质并不是单调性,二者并没有必要的关系。 因为数据有单调性一定可以二分,但可以二分的题目不一定非要有单调性。
- 二分的本质在于:在区间
[l, r]
中有性质,使得区间可以一分为二,一半边区间满足该性质而另一半区间不满足性质, 这样的话二分算法可以寻找这个性质的边界(红色和绿色的边界都行,因为是整数二分所以两边界不重合)。
- 二分的本质并不是单调性,二者并没有必要的关系。 因为数据有单调性一定可以二分,但可以二分的题目不一定非要有单调性。
- 整数二分: 文章中的两个模板分别对应着二分红色边界和二分绿色边界。
- 【模板一】二分红色边界
- Step1——确定中间点
mid = (l + r + 1) / 2
; - Step2——判断
- 若
mid
满足性质check(mid)
,即if(check(mid)) = True
,则更新区间为[mid, r]
,即令l = mid
即可; - 若
mid
不满足性质check(mid)
,即if(check(mid)) = False
,则更新区间为[l, mid - 1]
,即令r = mid - 1
即可;
- 若
- Step3——循环前两步,不断缩短空间,直到
l >= r
,边界值为l
。
- Step1——确定中间点
- 【模板二】二分绿色边界
- Step1——确定中间点
mid = (l + r) / 2
(上取整是为了防止死循环); - Step2——判断
- 若
mid
满足性质check(mid)
,即if(check(mid)) = True
,则更新区间为[l, mid]
,即令r = mid
即可; - 若
mid
不满足性质check(mid)
,即if(check(mid)) = False
,则更新区间为[mid + 1, r]
,即令l = mid + 1
即可;
- 若
- Step3——循环前两步,不断缩短空间,直到
l >= r
,边界值为l
。
- Step1——确定中间点
- 【模板一】二分红色边界
- 浮点数二分 :相对于整数二分更简单,无需考虑边界问题,理解了整数二分后,浮点数二分不成问题。
三、 整数二分模板(背诵)
【模板一】
- 循环条件:
l < r
- 划分区间:
[l, r]
→[l, mid - 1]
和[mid, r]
- 更新操作:
r = mid - 1
或者l = mid
- 注意:计算
mid
时为了避免死循环需要加1
,即mid = l + r + 1 >> 1
。
bool check(int x) {/* ... */} // 检查x是否满足某种性质
int bsearch(int l, int r)
{
while (l < r)
{
int mid = l + r + 1 >> 1;
if (check(mid)) l = mid;
else r = mid - 1;
}
return l;
}
【模板二】
- 循环条件:
l < r
- 划分区间:
[l, r]
→[l, mid]
和[mid + 1, r]
- 更新操作:
r = mid
或者l = mid + 1
- 注意:计算
mid
时不需要加1
,即mid = l + r >> 1
。
bool check(int x) {/* ... */} // 检查x是否满足某种性质
int bsearch(int l, int r)
{
while (l < r)
{
int mid = l + r >> 1;
if (check(mid)) r = mid;
else l = mid + 1;
}
return l;
}
3. 浮点数二分模板(背诵)
bool check(double x) {/* ... */} // 检查x是否满足某种性质
double bsearch(double l, double r)
{
const double eps = 1e-6; // eps 表示精度,取决于题目对精度的要求
while (r - l > eps) // 两种写法:此时是用精度控制循环次数,直接控制循环100次也是OK的!
{
double mid = (l + r) / 2;
if (check(mid)) r = mid;
else l = mid;
}
return l;
}
四、 使用模板的几个关键问题
① 如何选择用哪个模板?
做题的顺序首先是确定 check()
函数,再进行区间划分的分析,再确定使用哪个模板。
可以看到:当 l = mid
时,我们使用模板一,且 mid = (l + r) / 2
为下取整;当 r = mid
时,我们使用模板二,且 mid = (l + r + 1) / 2
需要上取整;
② 为什么模板一需要加上 ‘1’ ,即 ‘mid = (l + r + 1) / 2’,而模板二又不需要了 ?
- 使用模板一时,若
l = r - 1
,即l
和r
只差1
的时候,如果mid = (l + r) / 2
下取整的话,结果是等于l
的,则一旦if(check(mid)) = True
,更新区间会一直陷入到[mid, r] = [l, r]
死循环中。 - 同理,使用模板二时,当
l = r - 1
,即l
和r
只差1
的时候,如果mid = (l + r + 1) / 2
下上取整的话,结果是等于r
的,则一旦if(check(mid)) = True
,更新区间会一直陷入到[l, mid] = [l, r]
死循环中。
五、 应用:模板题
【整数二分 - 模板题】AcWing 789. 数的范围
【思路】想要找到目标值 x
的起始坐标,可理解成找到 ≥x
的最小值,再判断找到的边界值是否与 x
相等,若不相等返回 -1
;同样,想要找到目标值 x
的终止坐标,可理解成找到 ≤x
的最大值,再判断找到的边界值是否与 x
相等,若不相等返回 -1
;
【C++代码】
#include <iostream>
using namespace std;
const int N = 100010;
int n, m;
int q[N];
int main()
{
scanf("%d%d", &n, &m);
for (int i = 0; i < n; i ++ ) scanf("%d", &q[i]);
while (m -- )
{
int x;
scanf("%d", &x);
int l = 0, r = n - 1;
while (l < r)
{
int mid = l + r >> 1;
if (q[mid] >= x) r = mid;
else l = mid + 1;
}
if (q[l] != x) cout << "-1 -1" << endl;
else
{
cout << l << ' ';
int l = 0, r = n - 1;
while (l < r)
{
int mid = l + r + 1 >> 1;
if (q[mid] <= x) l = mid;
else r = mid - 1;
}
cout << l << endl;
}
}
return 0;
}
【浮点数二分 - 模板题】AcWing 790. 数的三次方根
【C++代码】
#include <iostream>
using namespace std;
int main()
{
double x;
cin >> x;
double l = -100, r = 100;
while (r - l > 1e-8)
{
double mid = (l + r) / 2;
if (mid * mid * mid >= x) r = mid;
else l = mid;
}
printf("%.6lf\n", l);
return 0;
}
2022年06月26日:补充二分“相等返回”的模板
【模板三】相等返回
- 循环条件:
l <= r
- 划分区间:
[l, r]
→[l, mid - 1]
和[mid + 1, r]
- 更新操作:
r = mid - 1
或者l = mid + 1
- 注意:计算
mid
时不需要加1
,即mid = l + r >> 1
。
int bsearch(int l, int r)
{
while (l <= r)
{
int mid = l + r >> 1;
if (q[mid] == target) return mid;
else if (q[mid] < target) l = mid + 1;
else r = mid - 1;
}
return -1;
}
2022年06月30日:补充二分三个模板的理解
此处借鉴学习一下『 LeetCode 大佬 - yukiyama』 的总结表格,详解请参考:二分查找从入门到入睡
- Y总的两个二分模板使用的循环条件是
l < r
,结束循环条件必定是相等l = r
终止,且二分范围一般来讲是[0, n - 1]
,n
为数组长度。本质上在二分的过程中,mid
并没有完全覆盖整个数组,这怎么理解呢?- 我们先看模板一,我们考虑一种情况,当数组中所有元素都不满足性质时,这时候
r
值会一直缩小直到l = r = 0
,而在这个过程中mid
不会取到0
值就返回了,也就是说返回的l = 0
值并没有通过性质的判断,无法确定是否满足我们所设的二分性质,因为这个性质的边界是可能存在于小于0
上的。因此,保险起见,需要对输出的l
再进行一次判断即可。总结来说,Y总的模板一是左开右闭的。 - 同理我们分析模板二,同样考虑一种情况,当数组中所有元素都不满足性质时,这时候
l
值会一直扩大直到l = r = n - 1
,而在这个过程中mid
不会取到n - 1
值就返回了,也就是说返回的l = n - 1
值并没有通过性质的判断,无法确定是否满足我们所设的二分性质,因为这个性质的边界是可能存在于大于n - 1
上的。因此,保险起见,需要对输出的l
再进行一次判断即可。总结来说,Y总的模板二是左闭右开的。 - 所以,用Y总的模板一和模板二的时候需要小心一下
l
是否落到范围边界上了,此时就需要再次判定一下。或者将范围扩大到[-1, n - 1]
(模板一)/[0, n]
(模板二)。
- 我们先看模板一,我们考虑一种情况,当数组中所有元素都不满足性质时,这时候
- 我们用同样的思想去看一看模板三(相等返回),不同于前两个模板,模板三的退出循环条件必定是相错终止,即
l - r = 1
,按上述方法去分析的话,我们会发现模板三的mid
会覆盖整个数组元素,因此模板三是左闭右闭的,且由于更新操作为r = mid - 1
或者l = mid + 1
,因此计算mid
时不需要加1
,mid = l + r >> 1
即可。