AcWing 789. 数的范围(二分终极分析!)

题目描述


分析:

这道题用到的是二分法,对于二分的本质——边界问题,一直没有理解透彻。在《算法笔记》中看到有关二分的论述让我很有收获,在这里做一下总结。顺便改写了一下y总的模板。

我们先看一下另外一道类似的题目:求出序列中第一个大于等于 x x x的元素的位置L以及第一个大于 x x x的元素的位置 R R R,这样元素 x x x 在序列中的存在区间就是左闭右开 [ L , R ) [L,R) [L,R)
假如对下标从 0 0 0 开始,有 5 5 5 个元素的序列 { 1 , 3 , 3 , 3 , 6 1, 3, 3, 3, 6 1,3,3,3,6}来说,如果要查询 3 3 3,则应当得到 L = 1 、 R = 4 L = 1、R = 4 L=1R=4;如果查询 5 5 5,则应当得到 L = R = 4 L = R = 4 L=R=4;如果查询 6 6 6,则应当得到 L = 4 、 R = 5 L = 4、R = 5 L=4R=5;而如果查询 8 8 8,则应当得到 L = R = 5 L = R = 5 L=R=5。显然,如果序列中没有 x x x,那么 L L L R R R 也可以理解为假设序列中存在 x x x,则 x x x 应当存在的位置。


先来考虑第一小问:求序列中第一个大于等于 x x x 的元素的位置。
假设当前二分区间为 [ l , r ] [l,r] [l,r],那么根据 m i d mid mid 位置处的元素与欲查元素 x x x 的大小判断应当往哪个子区间继续查找:

①如果 A [ m i d ] > = x A[mid] >= x A[mid]>=x,则说明第一个大于等于 x x x 的元素的位置,一定在 m i d mid mid 处或 m i d mid mid 的左侧,应往左子区间 [ l , r ] [l,r] [l,r] 继续查询,即令 r = m i d r = mid r=mid
bs1.jpg

②如果 A [ m i d ] < x A[mid] < x A[mid]<x,说明第一个大于等于 x x x的元素的位置一定在 m i d mid mid 的的右侧,应往右子区间 [ m i d + 1 , r ] [mid+1,r] [mid+1,r] 继续查询,即令 l = m i d + 1 l = mid + 1 l=mid+1
bs2.jpg

相应代码为:

// A[]为递增序列,x为欲查询的数,函数返回第一个大于等于x的位置
// 二分上下界为左闭右闭的[l,r],传入的初值为[0,n]

int lower_bound(int A[], int l, int r, int x)
{
    int mid = l + r >> 1;
    while (l < r)
    {
        if (A[mid] >= x)
        {
            r = mid;
        }
        else
        {
            l = mid + 1;
        }
    }
    return l;
}

上述代码有几个需要注意的地方:

  1. 循环条件为 l < r l < r l<r 而非 l ≤ r l ≤ r lr (课本上的二分),这是由问题本身决定的,“课本上”的二分问题中,查找序列元素不存在时需要返回 − 1 -1 1,这样当 l > r l > r l>r [ l , r ] [l,r] [l,r] 就不再是闭区间,可以该情况为判定元素不存在的标准,因此 l ≤ r l ≤ r lr 满足时循环一直进行。但是如果要返回第一个大于等于 x x x 的元素的位置,就不需要判断元素 x x x 本身是否存在,因为就算它不存在,返回的也是“假设它存在,它应该在的位置”,于是当 l = r l = r l=r 时, [ l , r ] [l,r] [l,r] 刚好能夹出唯一的位置,就是需要的结果,因此只要当 l < r l < r l<r时让循环一直执行即可。
  2. 由于当 l = r l = r l=r w h i l e while while循环停止,因此最后的返回值既可以是 l l l,也可以是 r r r
  3. 二分的所有区间应当能覆盖到所有可能返回的结果。首先,二分下界是 0 0 0是显然的,但是二分上届是 n n n 还是 n − 1 ? n-1? n1? 考虑到欲查询元素有可能比序列中所有的元素都要大,此时应当返回 n n n (即假设它存在,它应该在的位置),因此二分上界是 n n n,故二分的初始区间为 [ l , r ] = [ 0 , n ] [l,r] = [0,n] [l,r]=[0,n]

接下来解决第二小问:求序列中第一个大于 x x x 的元素的位置。
做法是类似的。假设当前区间为 [ l , r ] [l,r] [l,r],那么可以根据 m i d mid mid 位置的元素与欲查元素 x x x 的大小来判断应往哪个子区间继续查找:

①如果 A [ m i d ] > x A[mid] > x A[mid]>x ,则说明第一个大于等于 x x x 的元素的位置,一定在 m i d mid mid 处或 m i d mid mid 的左侧,应往左子区间 [ l , r ] [l,r] [l,r] 继续查询,即令 r = m i d r = mid r=mid
bs3.jpg
②如果 A [ m i d ] ≤ x A[mid] ≤ x A[mid]x ,说明第一个大于等于 x x x 的元素的位置一定在 m i d mid mid 的的右侧,应往右子区间 [ m i d + 1 , r ] [mid+1,r] [mid+1,r] 继续查询,即令 l = m i d + 1 l = mid + 1 l=mid+1
bs4.jpg

相应代码为:

// A[]为递增序列,x为欲查询的数,函数返回第一个大于x的位置
// 二分上下界为左闭右闭的[l,r],传入的初值为[0,n]

int upper_bound(int A[], int l, int r, int x)
{
    int mid = l + r >> 1;
    while (l < r)
    {
        if (A[mid] > x)
        {
            r = mid;
        }
        else
        {
            l = mid + 1;
        }
    }
    return l;
}

通过思考会发现,lower_bound和upper_bound都在解决这样一个问题:寻找有序序列中第一个满足某条件的元素的位置。 这是一个非常重要且经典的问题,平时能遇到的大部分二分法问题都可以归结为这个问题。所谓的“某条件”在序列中一定是从左到右先不满足,然后满足的(否则把该条件取反即可)。
对lower_bound来说,它寻找的就是第一个满足条件“值大于等于 x x x ”的元素的位置;对upper_bound函数来说,它寻找的是第一个满足“值大于 x x 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;
        }
    }
}
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值