题目描述
给定一个按照升序排列的长度为n的整数数组,以及 q 个查询。
对于每个查询,返回一个元素k的起始位置和终止位置(位置从0开始计数)。
如果数组中不存在该元素,则返回“-1 -1”。
输入格式
第一行包含整数n和q,表示数组长度和询问个数。
第二行包含n个整数(均在1~10000范围内),表示完整数组。
接下来q行,每行包含一个整数k,表示一个询问元素。
输出格式
共q行,每行包含两个整数,表示所求元素的起始位置和终止位置。
如果数组中不存在该元素,则返回“-1 -1”。
数据范围
1≤n≤100000
1≤n≤100000
1≤q≤10000
1≤q≤10000
1≤k≤10000
1≤k≤10000
样例
输入样例:
6 3
1 2 2 3 3 4
3
4
5
输出样例:
3 4
5 5
-1 -1
分析:
本题是练习二分很好的一道题目,二分程序虽然简单,但是如果写之前不考虑好想要查找的是什么,十有八九会是死循环或者查找错误,就算侥幸写对了也只是运气好而已。
用二分去查找元素要求数组的有序性或者拥有类似于有序的性质,对本题而言,一个包含重复元素的有序序列,要求输出某元素出现的起始位置和终止位置,翻译一下就是:在数组中查找某元素,找不到就输出−1,找到了就输出不小于该元素的最小位置和不大于该元素的最大位置。所以,需要写两个二分,一个需要找到>=x的第一个数,另一个需要找到<=x的最后一个数。查找不小于x的第一个位置,较为简单:
int l = 0, r = n - 1;
while (l < r) {
int mid = l + r >> 1;
if (a[mid] < x) l = mid + 1;
else r = mid;
}
当a[mid]<x时,令l=mid+1,mid及其左边的位置被排除了,可能出现解的位置是mid+1及其后面的位置。
当a[mid]>=x时,说明mid及其左边可能含有值为x的元素。
当查找结束时,l与r相遇,l所在元素若是x则一定是x出现最小位置,因为l左边的元素必然都小于x。
查找不大于x的最后一个位置,便不容易了:
int l1 = l, r1 = n;
while (l1 + 1 < r1) {
int mid = l1 + r1 >> 1;
if (a[mid] <= x) l1 = mid;
else r1 = mid;
}
要查找不大于x的最后一个位置:
当a[mid]<=x时,待查找元素只可能在mid及其后面,所以l=mid;
当a[mid]>x时,待查找元素只会在mid左边,令r=mid。
为什么不令r=mid−1呢?因为如果按照上一个二分的写法,循环判断条件还是l<r,当只有两个元素比如2 2时,l指向第一个元素,r指向第二个元素,mid指向第一个元素,a[mid]<=x,l=mid还是指向第一个元素,指针不移动了,陷入死循环了,此刻l+1==r,未能退出循环。
那么直接把循环判断条件改成l+1<r呢?此时一旦只有两个元素,l和r差1,循环便不再执行,查找错误。
所以这里出现了二分的典型错误,l==r作为循环终止条件,会出现死循环,l+1==r作为循环终止条件,会出现查找错误。
问题如何解决,一种方法就是将查找的区间设置为左闭右开,比如待查找元素在[0,n−1]范围内,可以写成[0,n),令r=n,这时候只有两个元素时,r是取最右边元素的后一个位置的,l和r相差2,还会执行循环。
现在再来理解上一段的r1=mid,说明a[mid]>x时,r=mid就表示待查找元素会是在r的左边,因为r是开区间。上面这种写法修改了循环条件使得二分不会死循环,修改了区间的开闭性使得不会查找错误。另一种解决办法就是:
int l = 0, r = n - 1;
while (l < r)
{
int mid = l + r + 1 >> 1;
if (a[mid] <= x) l = mid;
else r = mid - 1;
}
不修改循环终止条件,想办法解决死循环的问题,首先想下为什么查找不小于x的第一个位置不会死循环?因为这时就算只有两个元素,l+1=r,mid=l,a[mid]小于x时l是会+1的,不小于x时r=mid也会缩小区间。
而查找不大于x的最后一个位置之所以会死循环是因为编程语言里面除以2的下取整性,试想下如果l+1=r时,mid=(l+r)/2=l,一旦a[mid]<=x,l=mid=l,区间并没有缩小,从而陷入死循环;如果一开始取mid为r,一旦a[mid]<=x,l=mid=r,区间缩小,否则r=mid−1=l区间缩小,l都会与r相遇,就不会陷入死循环了。
如何做到上取整呢?只需要取mid时在l+r后面再加1即可,这里l和r都是闭区间,所以当a[mid]>x时,r=mid−1.
是否还有其他办法既不修改区间的开闭性和循环终止条件,又不用上取整呢?答案是肯定的。
int l1 = l, r1 = n - 1;
while (l1 < r1) {
int mid = l1 + r1 >> 1;
if (a[mid] <= x) l1 = mid + 1;
else r1 = mid - 1;
}
printf("%d %d\n", l, l1 - (a[l1] == x ? 0 : 1));
我们之所以会进行第二轮查找不大于x的最后一个位置,是因为第一轮已经找到了一个等于x的位置。所以完全可以当a[mid]<=x时,令l=mid+1,此时,l指向的元素可能是x也可能比x大,但是由于不论大小,l和r的指针都移动了,就不会陷入死循环了,最后,如果a[l]==x则,l就是x出现的最后的位置,否则,l−1就是x出现的最后一个位置。
或许有人会疑惑,当a[mid]<=x时,l已经右移,最后l不是肯定指向的是大于x的位置嘛,为什么也可能指向等于x的位置?这是因为一旦第一轮查找的x出现的位置就是x唯一出现的位置,当x出现在数组末尾时,l==r,循环不会执行,此刻l指向的还是x,所以加上这个判断就可以解决该问题了。这也是二分程序可能遇见的第三种问题,当左右指针都移动时,待查找元素处在元素末尾会引起查找错误。总的代码如下:
#include <iostream>
using namespace std;
const int maxn = 100005;
int n, q, x, a[maxn];
int main() {
scanf("%d%d", &n, &q);
for (int i = 0; i < n; i++) scanf("%d", &a[i]);
while (q--) {
scanf("%d", &x);
int l = 0, r = n - 1;
while (l < r) {
int mid = l + r >> 1;
if (a[mid] < x) l = mid + 1;
else r = mid;
}
if (a[l] != x) {
printf("-1 -1\n");
continue;
}
int l1 = l, r1 = n;
while (l1 + 1 < r1) {
int mid = l1 + r1 >> 1;
if (a[mid] <= x) l1 = mid;
else r1 = mid;
}
printf("%d %d\n", l, l1);
}
return 0;
}
(补充说明:没想到写的众多题解里会是这篇获赞最多,一直尝试用费曼技巧去写题解,每篇题解都尽可能的写的详尽。如果大佬们看y总视频有不明白的地方,欢迎造访个人博客 昂昂累世士的博客 ,目前更新完成的博客有剑指offer,算法基础课,正在更新的有算法提高课(更新一百多篇了),还有早期写的一些算法竞赛进阶指南的题解,如果博客题解中有不明白或者写的不好的地方,欢迎留言或者私聊。)
https://blog.csdn.net/WJPnb1/article/details/126360962?spm=1001.2014.3001.5502
这篇blog写的好,不用考虑mid-1和mid+1的边界问题
真的不错!!
这博客里最后直接输出ll不行吗
可以的 因为最起码也有起始位置可以当成终止位置来输出
好理解且几乎不会错!!!
这个是不是只能针对数组使用,还是有局限性的?
为什么用这个思想写分巧克力问题就会出错
大佬解决了吗 我一直没想明白为啥报错
太棒啦,直接通透!
已经搞懂了,这个思想一开始将左指针赋值为-1是因为刚好在所要区间的左端(也就是说对于一个数组来说,左指针赋值为-1满足条件左右指针把需要求的整个区间包含进去了,
但是分巧克力问题很显然最小的可能是1,也就是每个巧克力可以分的最小边长肯定是1,不可能是0,所以左指针要赋为0,这样就把整个要求的答案区间包含进去了。
不得不说这个二分写法理解后确实要好记许多
真不错,感觉自己又有脑子了
确实很好,但是我更习惯y总的
牛的 这个写法确实想不到
https://www.seayj.cn/articles/ee6ec901/
我写了个更精简、易理解的文章,包会的😎
我觉得还是y总的模版好
感觉对二分查找的理解更深入了
逐渐头大
### 好难理解
看到曾经的自己
###
你可以的
加油
只要一个三分支结构的模板,大家看看
#include <iostream> using namespace std; const int N = 1E5+20; int n,a[N],q,k; int binary1(int l,int r,int x){ while(l < r){ int mid = l + (r - l) / 2; if (a[mid] > x) r = mid - 1; else if (a[mid] < x) l = mid + 1; else if (a[mid] == x){ if (a[mid - 1] != x && mid >= 0) return mid ; else r = mid - 1; } } if (a[l] == x) return l; else return -1; } int binary2(int l,int r,int x){ while(l < r){ int mid = l + (r - l) / 2; if (a[mid] > x) r = mid - 1; else if (a[mid] < x) l = mid + 1; else if (a[mid] == x){ // 2 2 2 2 2 if (a[mid + 1] != x && (mid + 1) <= n) return mid ; else l = mid + 1; } } if (a[l] == x) return l ; else return -1; } int main(){ scanf("%d",&n); scanf("%d",&q); for(int i = 0; i < n; i++) scanf("%d",&a[i]); while(q--){ scanf("%d",&k); cout << binary1(0,n-1,k) << ' '<< binary2(0,n-1,k) << endl; } return 0; }
我觉得 l + r >> 1可以写成 l + (r - l) / 2
从数学角度两个式子是等量的同时也能防止右值过大而爆int使得mid溢出
同时 l + r + 1 >> 1可以写成 l + (r - l) / 2 + 1,一样的道理
没事的,之前写
l+r>>1
是为了节省时间,现在编译器都有自动优化,所以写不写都不所谓了编译器可不会做这种优化,这是两种计算方式,至少到目前为止 C++ 编译器还不会智能到将一种数值计算转化为另一种算法不同的数值计算。不信你可以试试看:
int l = INT_MAX - 2000; int r = INT_MAX - 1000; int m1 = l + r >> 1; int m2 = l + (r - l) / 2; cout << m1 << " " << m2 << endl;
看看 m1, m2 分别是多少
这是两种运算符的本质性差异吧,
/2
本来就不能和>>1
完全等价是的,但就这道题给出的数据范围而言是不用的,不过这个防溢出的思想是很好的!
goodgood
但
l+r>>1
其实和(l+r)/2
时间其实都一样。我写l+r>>1
是懒得写括号。一个需要找到>=x的第一个数,另一个需要找到<=x的最后一个数。看到你这句话豁然开朗,因为需要找到这个>=x的第一个数所以有Mid = l+r>>1,因为需要找到<=x的最后一个数,所以有mid = 1+l+r>>1
希望更丰富的展现?使用 Markdown、LATEX。
不是数学公式LaTeX可能用不上,markdown我csdn上面的题解,后期都是使用Markdown编辑的,三年前的题解可能还没注重阅读体验
只要是数字都可以用 LaTeX 啊,比如 2 就是 2:
$2$
。用 MarkDown 排版看着比较舒服
生活中的实例:
某拍卖行: 50 一次, 50 两次, 150 一次, 150 两次, 150 三次, 300 一次…
经理: 帮我统计下xx 这个价位有几个人, 第一次叫价什么时候,最后一个叫这个价什么时候
@树雪雪查
*方法一: #include<iostream> using namespace std; const int N = 100010; int a[N], n, q; int main() { scanf("%d%d", &n, &q); for(int i = 0 ; i < n ; i ++) scanf("%d", &a[i]); while(q --) { int x; scanf("%d", &x); int l = 0, r = n - 1; while(l < r) { int mid = (l + r) >> 1; if(a[mid] >= x) r = mid; else l = mid + 1; } if(a[l] == x) cout << l << ' ' ; else cout << -1 << ' ' ; l = 0, r = n - 1; while(l < r) { int mid = (l + r + 1) >> 1; if(a[mid] <= x) l = mid; else r = mid - 1; } if(a[l] == x) cout << l <<endl ; else cout << -1 << endl; } return 0; }
*方法二:
#include <iostream> using namespace std; const int N = 100010; int a[N], n, q; int bsearch(int a[], int x) { int l = 0, r = n - 1; while(l < r) { int mid = l + r >> 1; if(a[mid] >= x) r = mid; else l = mid + 1; } if(a[l] == x) return l; return -1; } int esearch(int a[], int x) { int l = 0, r = n - 1; while(l < r) { int mid = l + r + 1 >> 1; if(a[mid] <= x) l = mid; else r = mid - 1; } if(a[l] == x) return l; return -1; } int main() { scanf("%d%d", &n, &q); for(int i = 0 ; i < n ; i ++) scanf("%d", &a[i]); while(q --) { int x; scanf("%d", &x); cout << bsearch(a, x) << ' ' << esearch(a, x) << endl; } return 0; }
这个解法有问题吧
啊啊啊啊啊泰裤辣
当查找结束时,l与r相遇,l所在元素若是x则一定是x出现最小位置,因为l左边的元素必然都小于x
这句话不懂,那他为什么没有可能是 第二个 x 呢,为什么 l 左面没有相同的 x
这是查找x的起始位置,判断条件是a[mid] < x时,l = mid + 1,否则r = mid,不难看出当l移动的时候必然是mid所在的位置即a[mid]一定是小于x的,此时l指针移动,下标加一,增加后的左指针指向的值有两种情况,要么小于x,要么等于x(因为表是有序的嘛,这也是二分的前提),小于x时,左指针l等待下一次移动;等于x时,左指针不再移动,等待右指针r逼近(因为右指针判断条件是if(a[mid] >= x) r = mid,那么a[mid]大于x时,右指针移动,当a[mid] 等于x时,右指针也移动,由于向下取整的特性,当r > l且a[l] == x时,右指针一定会向左逼近。)
写的真好
感谢,二分的边界在这看懂了。
本人已悟,谢谢。
用查找lower(k+1)的方法查找upper(k),是这个意思吗?
感谢分享!
非常受用
岩岩钟山首,赫赫炎天路。昂昂累世士,结根在所固。
如果你的target比数组中所有的数都大,那么结束后l的下标应该就是a.size(), 这时候你直接判断a[mid] != x,应该会报错的啊
费曼学习法