分析:
这道题用到的是二分法,对于二分的本质——边界问题,一直没有理解透彻。在《算法笔记》中看到有关二分的论述让我很有收获,在这里做一下总结。顺便改写了一下y总的模板。
我们先看一下另外一道类似的题目:求出序列中第一个大于等于x的元素的位置L以及第一个大于x的元素的位置R,这样元素x在序列中的存在区间就是左闭右开[L,R)。
假如对下标从 0 开始,有 5 个元素的序列 {1,3,3,3,6}来说,如果要查询 3,则应当得到 L=1、R=4;如果查询 5,则应当得到 L=R=4;如果查询 6,则应当得到L=4、R=5;而如果查询 8,则应当得到 L=R=5。显然,如果序列中没有 x,那么 L 和 R 也可以理解为假设序列中存在 x,则 x 应当存在的位置。
先来考虑第一小问:求序列中第一个大于等于 x 的元素的位置。
假设当前二分区间为 [l,r],那么根据 mid 位置处的元素与欲查元素 x 的大小判断应当往哪个子区间继续查找:
①如果 A[mid]>=x,则说明第一个大于等于 x 的元素的位置,一定在 mid 处或 mid 的左侧,应往左子区间 [l,mid] 继续查询,即令 r=mid 。
②如果A[mid]<x,说明第一个大于等于x的元素的位置一定在 mid 的的右侧,应往右子区间 [mid+1,r] 继续查询,即令 l=mid+1。
相应代码为:
// A[]为递增序列,x为欲查询的数,函数返回第一个大于等于x的位置
// 二分上下界为左闭右闭的[l,r],传入的初值为[0,n]
int lower_bound(int A[], int l, int r, int x)
{
while (l < r)
{
int mid = l + r >> 1
if (A[mid] >= x)
{
r = mid;
}
else
{
l = mid + 1;
}
}
return l;
}
上述代码有几个需要注意的地方:
1. 循环条件为 l<r 而非 l≤r (课本上的二分),这是由问题本身决定的,“课本上”的二分问题中,查找序列元素不存在时需要返回−1,这样当 l>r 时 [l,r] 就不再是闭区间,可以该情况为判定元素不存在的标准,因此 l≤r 满足时循环一直进行。但是如果要返回第一个大于等于 x 的元素的位置,就不需要判断该元素本身是否存在,因为就算它不存在,返回的也是“假设它存在,它应该在的位置”,于是当 l=r 时,[l,r] 刚好能夹出唯一的位置,就是需要的结果,因此只要当 l<r 时让循环一直执行即可。
2. 由于当 l=r 时 while 循环停止,因此最后的返回值既可以是 l,也可以是 r。
3. 二分的所有区间应当能覆盖到所有可能返回的结果。首先,二分下界是0是显然的,但是二分上届是 n 还是 n−1? 考虑到欲查询元素有可能比序列中所有的元素都要大,此时应当返回 n (即假设它存在,它应该在的位置),因此二分上界是 n,故二分的初始区间为 [l,r]=[0,n]。
接下来解决第二小问:求序列中第一个大于 x 的元素的位置。
做法是类似的。假设当前区间为 [l,r],那么可以根据 mid 位置的元素与欲查元素 x 的大小来判断应往哪个子区间继续查找:
①如果 A[mid]>x ,则说明第一个大于 x 的元素的位置,一定在 mid 处或 mid 的左侧,应往左子区间 [l,r] 继续查询,即令 r=mid 。
②如果 A[mid]≤x ,说明第一个大于 x 的元素的位置一定在 mid 的的右侧,应往右子区间 [mid+1,r] 继续查询,即令 l=mid+1 。
相应代码为:
// A[]为递增序列,x为欲查询的数,函数返回第一个大于x的位置
// 二分上下界为左闭右闭的[l,r],传入的初值为[0,n]
int upper_bound(int A[], int l, int r, int x)
{
while (l < r)
{
int mid = l + r >> 1
if (A[mid] > x)
{
r = mid;
}
else
{
l = mid + 1;
}
}
return l;
}
通过思考会发现,lower_bound和upper_bound都在解决这样一个问题:寻找有序序列中第一个满足某条件的元素的位置。这是一个非常重要且经典的问题,平时能遇到的大部分二分法问题都可以归结为这个问题。所谓的“某条件”在序列中一定是从左到右先不满足,然后满足的(否则把该条件取反即可)。
对lower_bound来说,它寻找的就是第一个满足条件“值大于等于 x ”的元素的位置;对upper_bound函数来说,它寻找的是第一个满足“值大于 x ”的元素的位置。
另外,如果要寻找最后一个满足“条件C”的元素的位置,则可以先求第一个满足“条件!C”的元素的位置,然后将该位置减1即可。
分析完毕,在这道题里我们需要用到lower_bound和upper_bound减1。
代码(C++)
#include <iostream>
using namespace std;
const int N = 100010;
int a[N];
int main()
{
int n, q;
cin >> n >> q;
for (int i = 0; i < n; i ++) cin >> a[i];
while (q --)
{
int k;
cin >> k;
// 确定二分区间,这里 r 为 n
int l = 0, r = n;
while (l < r)
{
int mid = l + r >> 1;
// a[mid] 大于等于 k 说明第一个大于等于 k 的元素一定在 mid 处或 mid 左边
// 右边界变小,更加关注左半区间
if (a[mid] >= k) r = mid;
//a[mid] 小于 k 说明第一个大于等于 k 的元素一定在 mid 右边(不包含 mid)
// 左边界变大,更加关注右半区间
else l = mid + 1;
}
// 二分一定有答案,但要检查答案是否正确
// 若序列中没有等于 k 的元素,查找出的是第一个大于 k 的元素的下标
if (a[l] != k) cout << "-1 -1" << endl;
else
{
cout << l << ' ';
int l = 0, r = n;
while (l < r)
{
int mid = l + r >> 1;
// 不同点在这里的 check 函数
// 这一部分要寻找的是元素 k 的终止位置
// 我们可以理解为求第一个满足大于 k 的位置,再将结果减 1 ,即是最后一个大于等于 k 的位置
if (a[mid] > k) r = mid;
else l = mid + 1;
}
// 这也是我们的 r 要开到 n 的原因,假设我们的目标 k 是 a[n-1]
// 那么第一个大于 k 的下标将达到 n ,如果二分区间到不了这,将导致答案错误
cout << l - 1 << endl;
}
}
}
大佬你好, 上面low_bound 函数是不是写错了呀int mid = l + r >> 1;
不应该放在循环里面吗
我也发现了这个问题,debug十分钟才发现,作者大佬有空纠正一下吧!
是的,我写错了!现在已改正,很感谢同学的反馈!
十分抱歉,现在已改正!
大佬居然上线了, Orz
作业大大的题解写得挺好的🐂,算法笔记书上的二分我觉得比y总版更好理解
感谢大佬的分享,但好像有一处细节写错了
也就是第二小问配图上方的文字
应该不是大于等于,而就是大于吧
感谢反馈!经检查发现第二小问的①②都写错了,已改正。
这个时间复杂度没有y总的快
相比y总的模板有点小缺陷:
5 1
-5 -4 -3 -2 -1
0
结果为5 4
非常感谢你提供的样例~我解释下模板出错的原因:因为初始化数组a后,默认赋值为0,恰好本样例要查询的目标为0,而5个输入里又没有,又由于a[5](不作为输入元素的存储位置)由于初始化后变成了0,因此导致判断出错。如果初始化数组后,将数组全部赋值为一个极大的数,比如0x3f3f3f,或许可以避免这个问题。
给跪了,大佬,太感谢了
谢谢大佬
感觉大佬
谢谢!虽然还没完全会用,但是已经懂好多了!
太妙了
这不是胡凡算法笔记的内容?
感谢!!!真的很有用 醍醐灌顶了
卧槽,太强了啊!!!
我也感觉y总的框架少了一种特判情况,就是l=r的时候,会缺少判断
#include [HTML_REMOVED]
const int N = 100000;
int a[N];
using namespace std;
int main()
{
int n, m;
cin >> n >> m;
for (int i = 0; i < n; i++) cin >> a[i];
while (m–) {
int b;
cin >> b;
int left = 0;
int right = n-1;
while (left < right) {
int mid = (left + right) / 2;
if (a[mid] >= b) right = mid;
else left = mid + 1;
}
if (a[left] != b) cout << “-1 -1” << endl;
else {
cout << left << ” “;
int left = 0;
int right = n;
while (left < right) {
int mid = (left + right) / 2;
if (a[mid] > b) right = mid;
else left = mid + 1;
}
cout << left - 1 << endl;
}
}
return 0;
}
上面改为n-1 下面是n也能运行通过
为什么是r=n 不是r=n-1
感觉l + r + 1 >> 1就有向最后移动的性质
佬真是太棒了!二分求第一个匹配性质的对应于右半边匹配性质的边界点,求最后一个匹配性质的对应于左半边匹配性质的边界点。感谢!
感谢分享~ 顺便发现了一本好书<算法笔记>
我明白你r取到n的意思了 ,,,就是假设你想查找的是大于数组最后一个数的第一个元素对吧 如果查找不到的话,l和r也可以理解为假设序列中存在k,则k应当在的位置对吧 但是你这个正常情况也是出现了问题 要特判一下
就是还想问一下这种r取n也是根据题目条件来的吧 就是题目要求我找不到就输出-1就没必要取到n