AcWing
  • 首页
  • 课程
  • 题库
  • 更多
    • 竞赛
    • 题解
    • 分享
    • 问答
    • 应用
    • 校园
  • 关闭
    历史记录
    清除记录
    猜你想搜
    AcWing热点
  • App
  • 登录/注册

AcWing 789. 数的范围(详细分析二分过程)    原题链接    简单

作者: 作者的头像   昂昂累世士 ,  2019-08-02 10:15:00 ,  所有人可见 ,  阅读 70355


742


421

题目描述

给定一个按照升序排列的长度为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,算法基础课,正在更新的有算法提高课(更新一百多篇了),还有早期写的一些算法竞赛进阶指南的题解,如果博客题解中有不明白或者写的不好的地方,欢迎留言或者私聊。)

142 评论


用户头像
新人orz   2022-08-16 12:48      48    踩      回复

https://blog.csdn.net/WJPnb1/article/details/126360962?spm=1001.2014.3001.5502
这篇blog写的好,不用考虑mid-1和mid+1的边界问题

用户头像
老虎3岁了   2022-09-14 17:49      8    踩      回复

真的不错!!

用户头像
etherther   2022-09-27 10:02         踩      回复

这博客里最后直接输出ll不行吗

用户头像
新人orz   2022-09-27 10:54    回复了 etherther 的评论         踩      回复

可以的 因为最起码也有起始位置可以当成终止位置来输出

用户头像
力量与荣耀   2023-01-09 06:48      9    踩      回复

好理解且几乎不会错!!!

用户头像
洛绝尘   2023-01-15 17:55         踩      回复

这个是不是只能针对数组使用,还是有局限性的?

用户头像
liyoouli   2023-03-16 19:08      3    踩      回复

为什么用这个思想写分巧克力问题就会出错

用户头像
nooprush   2023-03-21 17:51    回复了 liyoouli 的评论      1    踩      回复

大佬解决了吗 我一直没想明白为啥报错

用户头像
Syihang   2023-03-26 20:11         踩      回复

太棒啦,直接通透!

用户头像
nooprush   2023-03-27 23:16    回复了 nooprush 的评论      11    踩      回复

已经搞懂了,这个思想一开始将左指针赋值为-1是因为刚好在所要区间的左端(也就是说对于一个数组来说,左指针赋值为-1满足条件左右指针把需要求的整个区间包含进去了,

但是分巧克力问题很显然最小的可能是1,也就是每个巧克力可以分的最小边长肯定是1,不可能是0,所以左指针要赋为0,这样就把整个要求的答案区间包含进去了。

不得不说这个二分写法理解后确实要好记许多

用户头像
风_963   2023-04-05 16:49         踩      回复

真不错,感觉自己又有脑子了

用户头像
明月逍遥   2023-11-26 22:25      1    踩      回复

确实很好,但是我更习惯y总的

用户头像
HikigayaHachiman   2024-02-07 16:55         踩      回复

牛的 这个写法确实想不到

用户头像
SeaYJ   2024-11-11 11:43      1    踩      回复

https://www.seayj.cn/articles/ee6ec901/
我写了个更精简、易理解的文章,包会的😎

用户头像
天空好暖   2024-12-09 21:45         踩      回复

我觉得还是y总的模版好

用户头像
太阳照常升起_90   2024-12-29 09:59    回复了 SeaYJ 的评论         踩      回复

感觉对二分查找的理解更深入了


用户头像
dw42113   2022-08-05 17:33      18    踩      回复

逐渐头大


用户头像
thebeatles   2023-09-02 11:27      7    踩      回复

### 好难理解

用户头像
thebeatles   2023-09-20 13:52      10    踩      回复

看到曾经的自己

用户头像
acwing_381230   2023-11-17 16:09    回复了 thebeatles 的评论      1    踩      回复

###
你可以的

用户头像
swear0916   2024-01-31 14:46      2    踩      回复

加油


用户头像
weige_9   2023-02-02 14:15      6    踩      回复

只要一个三分支结构的模板,大家看看

#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;
}

用户头像
杰_6   2021-07-30 11:27      6    踩      回复

我觉得 l + r >> 1可以写成 l + (r - l) / 2
从数学角度两个式子是等量的同时也能防止右值过大而爆int使得mid溢出
同时 l + r + 1 >> 1可以写成 l + (r - l) / 2 + 1,一样的道理

用户头像
OnlyJerry   2021-12-02 13:39         踩      回复

没事的,之前写l+r>>1是为了节省时间,现在编译器都有自动优化,所以写不写都不所谓了

用户头像
kevin213   2021-12-20 07:24    回复了 OnlyJerry 的评论      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 分别是多少

用户头像
Coros_Trusds   2022-02-11 12:18    回复了 kevin213 的评论         踩      回复

这是两种运算符的本质性差异吧,/2 本来就不能和 >>1 完全等价

用户头像
今天吃什么好呢   2022-02-28 21:23      2    踩      回复

是的,但就这道题给出的数据范围而言是不用的,不过这个防溢出的思想是很好的!

用户头像
反方向的钟   2022-05-06 16:33         踩      回复

goodgood

用户头像
买八嘎的小二   2023-11-14 20:06    回复了 OnlyJerry 的评论         踩      回复

但l+r>>1其实和(l+r)/2时间其实都一样。我写l+r>>1是懒得写括号。


用户头像
lz_3   2023-12-04 19:03      5    踩      回复

一个需要找到>=x的第一个数,另一个需要找到<=x的最后一个数。看到你这句话豁然开朗,因为需要找到这个>=x的第一个数所以有Mid = l+r>>1,因为需要找到<=x的最后一个数,所以有mid = 1+l+r>>1


用户头像
有机物   2022-07-13 19:59      3    踩      回复

希望更丰富的展现?使用 $\text{Markdown}$、$\LaTeX$。

用户头像
昂昂累世士   2022-07-14 10:16      1    踩      回复

不是数学公式LaTeX可能用不上,markdown我csdn上面的题解,后期都是使用Markdown编辑的,三年前的题解可能还没注重阅读体验

用户头像
有机物   2022-07-14 11:35    回复了 昂昂累世士 的评论         踩      回复

只要是数字都可以用 LaTeX 啊,比如 2 就是 $2$:$2$。

用 MarkDown 排版看着比较舒服


用户头像
White2_Cat   2023-11-13 23:02      2    踩      回复

生活中的实例:
某拍卖行: 50 一次, 50 两次, 150 一次, 150 两次, 150 三次, 300 一次…
经理: 帮我统计下xx 这个价位有几个人, 第一次叫价什么时候,最后一个叫这个价什么时候


用户头像
树雪雪查   2023-10-16 17:57      2    踩      回复

@树雪雪查

*方法一:
#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;
}


用户头像
super_hh   2025-02-19 21:03 · 湖北      1    踩      回复

这个解法有问题吧


用户头像
acwing_35961   2023-05-28 15:48      1    踩      回复

啊啊啊啊啊泰裤辣


用户头像
ying_flower_rain   2023-02-16 00:36      1    踩      回复

当查找结束时,l与r相遇,l所在元素若是x则一定是x出现最小位置,因为l左边的元素必然都小于x
这句话不懂,那他为什么没有可能是 第二个 x 呢,为什么 l 左面没有相同的 x

用户头像
c__c   2023-03-30 23:05      7    踩      回复

这是查找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时,右指针一定会向左逼近。)


用户头像
坂间bili   2022-09-13 15:35      1    踩      回复

写的真好


用户头像
鹿衔草   2022-04-11 14:30      1    踩      回复

感谢,二分的边界在这看懂了。


用户头像
明_04   2025-03-31 19:43 · 重庆         踩      回复

本人已悟,谢谢。


用户头像
天如棋盘星作子   2025-02-21 22:09 · 江西         踩      回复

用查找lower(k+1)的方法查找upper(k),是这个意思吗?


用户头像
seto   2024-12-11 18:56         踩      回复

感谢分享!
非常受用


用户头像
大意失荆州   2024-10-24 20:57         踩      回复

岩岩钟山首,赫赫炎天路。昂昂累世士,结根在所固。


用户头像
Lee咻偲   2024-09-15 11:29         踩      回复

如果你的target比数组中所有的数都大,那么结束后l的下标应该就是a.size(), 这时候你直接判断a[mid] != x,应该会报错的啊


用户头像
whale77   2024-08-25 22:39         踩      回复

$$\color{darkblue}{费曼学习法}$$


App 内打开
你确定删除吗?
1024
x

© 2018-2025 AcWing 版权所有  |  京ICP备2021015969号-2
用户协议  |  隐私政策  |  常见问题  |  联系我们
AcWing
请输入登录信息
更多登录方式: 微信图标 qq图标 qq图标
请输入绑定的邮箱地址
请输入注册信息