算法基础1.2.1整数二分
前言
如果第一次接触二分其实很难理解它的含义
我对二分的理解就是找到一个条件,能够保证所有数据对于这个条件要么是True
要么是False
。二分的作用是查找。
二分本质不是单调性,对于一个满足单调性(也就是有序)的数组,我们一定可以用二分来解决,但是这不代表着非单调的数组就不能使用二分。二分的本质是二段性或者说是边界,需要我们能对这个数组想出一个性质,使得数组左半边满足,右半边不满足,同时他们没有交点(因为这是整数二分),那么我们通过二分就可以寻找左半边和右半边各自的边界
第一篇二分搜索论文是 1946 年发表,然而第一个没有 bug 的二分查找法却是在 1962 年才出现,中间用了 16 年的时间。
整数二分我花了三天时间才消化完(主要中间去玩碧蓝幻想versus了),说明这个还是很难理解的,所以学这个不要急,慢慢分析。最后要多做几道题,更加熟练一些。
注意一下:整数二分的“整数”不是说数据都是整数,而是比如说数据在数组里面,那么由于整除会向下取整,所以中间值的赋值需要分类讨论(看下文就知道了)。而浮点数二分可以理解为是一个连续函数,不存在整除的问题,所以直接用同一个模板就好。所以整数二分比浮点数二分难,学会了这个,浮点数二分就很简单了。
正文
直接先上代码和题目
#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;
}
了解思路
这道题要求我们查找一个具体的数,找出它的起始位置和终止位置。需要注意的是这是一个有序的升序数组,也就是说如果有多个x,那么他们一定是连着的一串,而且这一串之前的都是小于x的,后面的都是大于x的。
假如我们要查找3,那么我们就可以利用二分,先通过x>=3
作为条件二分出来两个部分,再通过x<=3
二分出来另外两个部分,结合来看我们就可以得到3所在的那个区间,区间里面都是3(或者数组里根本没有3),如下图
这其实就变成了一个集合的问题。图中第一部分我们的二分点是>=x
的开始位置,其实也就是第一个x
可能出现的位置(如果这个区间实际上都是>x
那么就说明没有x
)。第二部分我们的二分点是<=x
的结束位置,其实就是最后一次可能出现x
的位置。那么我们对两个橙色区间取交集,就是我们要查找的数据所在的位置,如果这是一个区间,那么区间里都是那个数;如果交集为一个元素,说明这个元素存在,而且只存在一个;如果交集为空,那么就说明数组里没有这个数据。
一定要明白,我们是进行了两次二分查询,如果你认为这是一次,那么说明还是对二分理解不够(两次的条件是不同的)。我们每次二分都只找了一个分界点,因为另一个找到了也没用处
抽象分析
上面只是根据题目来进行一个大致的过程了解,下面我们将这个问题抽象出来看
我们通过某种性质将区间分为了两半部分,红色为不满足,绿色为满足。
红色部分
我们先分析红色部分,再次明确我们的目的:找到红色部分的边界点,也就是第一个箭头指向的位置
我们还是从中点位置开始试,我们设变量mid=l+r+1>>1
,至于这里为什么要+1
我们后面再说。
我们从中点开始入手,通过判断语句来检测这个mid
是否满足红色区间的条件(也就是不满足我们设定的性质)
- 如果满足,说明现在
mid
在红色区间内,那么我们就要更新我们区间,将多余的删除,也就是将左边界收缩,从l
变为mid
,更新语句为l=mid
(注意更新后的区间是包含mid
的,他在红色区间内,同时他也有可能就是那个边界点) - 如果不满足,说明现在
mid
在绿色区间内,我们让右边界收缩,从r
变为mid
,更新语句为r=mid-1
(之所以这里不包含mid
,是由于mid
在绿色区间内,那么他绝对不可能是我们要找的红色区间边界点,又因为这是在数组中,所以它的上一位是不确定的,所以收缩到上一位mid-1
处)
一直循环到区间两边相遇,这里的条件我们一般写i<j不成立
,这样看起来更完善一些,其实经过测试,条件为i==j不成立
也是可以实现的
那么现在来说明一下为什么mid
要+1
我们举一个例子,这个在最后一次循环的时候很常见。假如现在l=r-1
,也就是区间只有2个元素,左边界和右边界邻着。如果没有加一,那么这次的mid=l+r>>1=2l+1>>1=l
,这是由整除向下取整的性质决定的,如果接下来判断为真,那么l=mid=l
,相当于这次没分,这就是一个无限划分的开始,而加一可以让mid=r
从而让两边界相遇结束循环。
绿色部分
整体思路同理,只不过这里的mid=r+l>>1
不需要加一,这是由于此时的更新语句分别为r=mid
和l=mid+1
,不会发生上述问题
对于这道题
为了更加方便理解我们用了两个二分查询,同时也方便理解用俩个的意义以及这两个各自的意义,我画了这个草图
我们通过两个二分找到了两个位置,那他们中间,也就是黄色区域,就是我们要查询的数
分析代码
while (m -- )
{
int x;
scanf("%d", &x);
这里m
代表我们要查找的个数,通过while
来进行循环,每次都代表一个查找的进程,当m
为0时,循环结束,也代表我们查完了所有要查的数
int l = 0, r = n - 1;
while (l < r)
{
int mid = l + r >> 1;
if (q[mid] >= x) r = mid;
else l = mid + 1;
}
这里是第一次二分查询,显然,条件是>=x
,可以认为这是上图中的绿色部分。通过更新语句我们不难发现,左边会收缩到最后一个不满足条件的位置,而右边即使满足条件,也会继续更新,直至两边界相遇。这说明我们找的是满足条件的最前面的位置,具体的讲就是假如区间中有一串x
,显然他们都是满足>=x
的,而我们最后找到的是这一串的开头,所以也就意味着我们找到了这个数第一次出现的位置
if (q[l] != x) cout << "-1 -1" << endl;
这句话用来判断这个数组中到底有没有这个要查询的数,如果我们的边界点!=x
,那么也就说明他以及他后面的数据其实严格意义上满足的是>x
。这就说明这个数组中就没有这个数。当然,这里也可以用q[r]
,毕竟循环结束的时候他俩相遇,他们指向的是同一个位置
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;
}
}
如果if
不成立,说明数组中有这个数,现在我们找到了开始位置,接下来要去找结束位置。我们先将找到的开始位置输出,然后重新初始化左右边界,进行第二个二分查询。这次我们找到了满足<=x
的最后一个位置,也就是这个数最后一次出现的位置。
结语
这个模板几乎包含了所有需要二分的问题,其实就是针对两种二分情况写了两个小模板,他们的缩进方式和mid
的初始化语句不一样。
通过这两个二分,我们就可以在交集处找到一个数据所在的位置或区间了。
做题时我们就需要通过图形理解,去明白我们现在要去使用哪个模板(两个模板找的东西是不一样的)。网上有这么一种理解方式,男左女右(判断为true时),男是1所以要+1,女是0所以不用。不是我想出来的哈
我们要知道一个观点:二分是一定有解的(只要条件选的正确,就一定可以找到二分后两个区间的边界点)。无解是题目的无解,比如这道题里就会找不到要查找的数,这是题目设置的无解。但是题目的无解不影响正常我们二分找到边界点这个解。所以我们进行二分查询的时候不需要担心有没有可能找不到边界点(也就是二分无解),因为只要你条件给的对,是一定有解的。
整数二分注意两点
- 一个mid = (l+r)>>1
一个mid = (l+r+1)>>1
加不加1 完全取决于 l = mid 还是r = mid
l等于mid时必须+1向上取整 不然会陷入l=l的死循环
r = mid 时候不用加1 因为下一步l = r 直接会退出循环 - 这两个模板解决的是 找>=||<=||>||< 某个数的
最左或最右的位置 但这个数不一定在二分的数组中
如果在就能准确找到
如果不在 找到的就是最接近答案的数(你要找大于等于5的第一个数)但
数组中没有5 那找到的就是6的位置(如果有6的话)
所以二分是一定有答案的