题目描述
给定一个按照升序排列的长度为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$,还会执行循环。
现在再来理解上一段的$r_1 = 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/
我写了个更精简、易理解的文章,包会的😎
逐渐头大
### 好难理解
看到曾经的自己
###
你可以的
加油
只要一个三分支结构的模板,大家看看
我觉得 l + r >> 1可以写成 l + (r - l) / 2
从数学角度两个式子是等量的同时也能防止右值过大而爆int使得mid溢出
同时 l + r + 1 >> 1可以写成 l + (r - l) / 2 + 1,一样的道理
没事的,之前写
l+r>>1
是为了节省时间,现在编译器都有自动优化,所以写不写都不所谓了编译器可不会做这种优化,这是两种计算方式,至少到目前为止 C++ 编译器还不会智能到将一种数值计算转化为另一种算法不同的数值计算。不信你可以试试看:
看看 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
希望更丰富的展现?使用 $\text{Markdown}$、$\LaTeX$。
不是数学公式LaTeX可能用不上,markdown我csdn上面的题解,后期都是使用Markdown编辑的,三年前的题解可能还没注重阅读体验
只要是数字都可以用 LaTeX 啊,比如 2 就是 $2$:
$2$
。用 MarkDown 排版看着比较舒服
生活中的实例:
某拍卖行: 50 一次, 50 两次, 150 一次, 150 两次, 150 三次, 300 一次…
经理: 帮我统计下xx 这个价位有几个人, 第一次叫价什么时候,最后一个叫这个价什么时候
@树雪雪查
*方法二:
啊啊啊啊啊泰裤辣
写的真好
感谢,二分的边界在这看懂了。
岩岩钟山首,赫赫炎天路。昂昂累世士,结根在所固。
如果你的target比数组中所有的数都大,那么结束后l的下标应该就是a.size(), 这时候你直接判断a[mid] != x,应该会报错的啊
$$\color{darkblue}{费曼学习法}$$
为什么我写的时候样例不过,提交确能够成功
$I AK I$
为啥l=0,r=n-1和l=-1,r=n和l=1,r=n+1,都对
当查找结束时,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时,右指针一定会向左逼近。)
为什么不能把求左边和右边的点的模板交换呢
不交换你输出的是[起始点,终止点],交换以后输出的是[终止点,起始点],刚好相反