洛谷P3960 队列(线段树)(详解)

题目链接:https://www.luogu.com.cn/problem/P3960

接下来我将采用线段树解决这道问题(每一段之间用空格隔开,每一段开头不设缩进)。

要解决这道问题,我们先来看一道线段树的基本应用。

问题简述如下: 有一个数组a,大小为n,数组的每个值为0或者1,有m次查询,每次询问数组a从左边开始的第k个1在数组中什么位置?(保证1总和始终大于k)

如图:

那么询问第3个1时,答案应该是5。

接下来的问题就是如何高效求解这个问题,更确切的,我想在O(log n)复杂度解决这个问题应该怎么办?

我接下来将直接给出问题求解方案:

用一个线段树维护这个数组,维护区间总和sum(如果不熟悉线段树,建议了解一下线段树喔),那么如何找到第k个1呢?

以下为线段树 int query(int c,int k,int l,int r)(c表示查询当前区间第c个1,k表示线段树的结点,l,r表示线段树当前结点的左右端点,函数返回答案)函数中的主要步骤:

  1. 如果当前结点为叶节点,那么直接返回 r;
  2. 否则,比较c和sum[ls]大小(ls表示k的左儿子结点,rs表示k的右儿子结点),如果sum[ls]>=c,那么可以肯定第c个1在左区间,那么递归到左区间。若果sum[ls]<c,那么说明左边区间没有c个1,自然第c个1在右区间,则递归到右区间(注意:递归到右区间时,那么应该查询右区间上第c-sum[ls]个1);

你可以画个线段树验证以上步骤以便加深理解。

下面为具体参考代码

int query(int c, int k, int l, int r) {
    if (r == l)return r;

    int ls = k << 1, rs = k << 1 | 1, mid = l + r >> 1;
    if (sum[ls] >= c)return query(c, ls, l, mid);
    return query(c - sum[ls], rs, mid + 1, r);
}

 那么到目前为止,这个小问题就解决啦!

接下来回到这个问题,每一次操作,可以分为两步,假如第二行第二列的学生离队,(接下来我将对离队描述为删除,入队描述为插入

 1、首先删除第二行将第二列的id(学生编号)并记录一下,然后第二行的学生左对齐,再将记录的id插入第二行的最后,如中间那个图;

2、在最后一列上,删除第二行的id,最后一列向前对齐,再将删除的id插入最后,也即插入在最后一行最后一列,结果如上面右图。

 不知道你发现没有,这两个步骤其实属于同一种问题,问题描述如下:

问题简述:现在有一排(一行)学生,学生编号从左往右从1开始递增,每次第k列(最左边为第一列)的一个学生离队,右边的学生左对齐,然后学生归队到最右边,问每次离队的学生编号?

对于这个问题,你当然可以用一个数组a保存每个位置(从左往右位置从1递增)的id, 然后每次删除,将右边的id向左移一个位置。复杂度自然为O(n)(n为学生个数)。

可以发现,如果你移动右边的学生,无论通过什么方式,时间复杂度好像是降不下来,所以你不应该移动右边学生。那么你只能直接将删除的学生插入到最后一个的右边。

那么现在的问题变化了(因为你能很轻松的将删除的学生插入,同时其他学生不动),问题变为了如何找到第k个(列)学生位置及其编号。看一下下图

 第4个学生不在第4个位置上,而应该在第5个位置上,id为5。

那么如何高效解决这个问题呢? 

你可以发现不管前面删除了多少个人,第k个学生前面总有k-1个学生,所以如果我们重新设定一个数组b,如果第i个位置上有学生则b[i]=1,没有b[i]=0,那么你要找的第k个学生的位置p,满足\sum _{i=1}^{p}{b[i]}=k,即第k个1的位置。

这里的要点在于,你应该把有学生的位置标记为1,没有标记为0,那么第k个学生即第k个1.

接下来使用我们前面的方法即可。

现在,一排的问题解决了,改为解决矩阵的问题了,前面说过,第x行y列的学生离队,相当于在x行上,第y列的学生被删除然后插入在最后。接着,在m列(最后一列)上,第x行的学生被删除,再插入在最后。所以你应该为最后一列维护一颗线段树,然后为每行再维护一颗线段树(共n颗)。你可能会发现,我维护这么多颗线段树,我空间怎么过啊。确实,你如果在还没有离队操作之前就建(build)这么多树的话,空间肯定过不去的,更多的,每一个应该是O(n)(最后一列)或O(m)(每一行),总共O(n+n*m)的空间复杂度。

那怎么办呢?你应该能发现,虽然有这么多点,但是我并不会用到这么多,应为我每次查询第k个学生,插入、删除操作都是O(log n),那么总共也就O(q*logn)复杂度(复杂度这里并不严谨,但也应该能够讲明白),也即我最多考察这么多个线段树的结点 。所以这里就要动态开点了,即你并不一开始就建这么多线段树,更多的你开始一个点都不会建(你可以理解为,当我要考察线段树这个结点,但这个结点并没有被建立,那么我就建,简单说就是我用多少,建多少结点),如果还不理解,可能是我讲解水平有限,可以移步其他博客先了解一下。

到这里,基本大致编码思路就有了,但是有很多细节问题,我简单总结如下

  1. 因为你要插入最多q个结点,那么考察线段树的根结点的边界应该为L=1,R=max(m,n)+q。
  2. 对于每行的线段树,你只用维护每行1到m-1范围的数组,因为最后一列你单独维护了。
  3. 动态开点时,应该初始化sum[k](线段树该结点的1的总和),初始化的时候你可要小心一点,因为比如如果你查询的是最后一列的线段树,第k个结点左右边界为L,R,那么sum[k]应为这个区间范围在1-n范围内重叠的范围,即[L,R]与[1,n]重叠的点的个数
  4. 每个位置的编号应该存储在线段树的叶节点上,因为线段树的叶节点从左到右就是你线段树维护的数组。
  5. 动态开点一般不用push_up(),至少我这里是,你可以思考一下,我在最后会有提示哦。

以上细节你但看可能不好理解,结合代码会有更深理解,我在代码里会详细注释所有属性,以及方法。

但还有一点我要说明一下,对于最后一列的开点,查询,和更新我分别使用了函数add1,query1,update1,对于每一行的开点,查询和更新我使用函数add,query,update。

你可能觉得这有所冗余(因为它们之间差别很小),但这更易于理解,而且,如果只从编码速度方面考量,这样一般会更快(因为你为了消除add1,add函数差别你得很小心,而且,你还会使用额外变量)。

#include"bits/stdc++.h"

using namespace std;
typedef long long ll;

const int maxn = 3e5 + 10;

int root[maxn];//1-n行的线段树在root下标分别为1-n,最后一列的线段树特别为n+1,也即你从root[k]你可以找到第k课线段树
int sum[40 * maxn];
int ls[40 * maxn], rs[40 * maxn], cnt;//ls,rs为每个结点左儿子和右儿子,cnt为开点个数
ll ID[40 * maxn];//ID表示每个线段树叶节点上人的编号

int sz[maxn];//存储每科线段树(每行和最后一列)增加的人数

int n, m;

void add(int x, int &k, int l, int r) {//每行开点 x为哪一行//每个线段树右界限应为max(n,m)+q;
    k = ++cnt;

    //每个结点初始化的总和,应为在1到m-1范围内的点的个数
    sum[k] = l < m ? min(r, m - 1) - l + 1 : 0;

    //如果是叶节点,那么应该还初始化其ID,只在1到m-1范围内的结点才有ID,大于m-1范围的都是新插入的点
    if (r == l && r < m)ID[k] = 1ll * (x - 1) * m + r;
}

void update(ll id, int x, int y, int c, int &k, int l, int r) {
    //这里的id表示你要插入时放在叶节点上的id
    //x表示哪一行,y表示第y列,也即第y个人
    //c为1表示是插入,同时考察到的线段树的每个结点sum应加上c,-1表示删除结点

    if (!k) add(x, k, l, r);//如果没有就开点

    sum[k] += c;//结点总和加上c
    if (r == l) {
        if (c == 1)ID[k] = id;//如果为1,表示插入,那么应该把id放在这个叶节点上
        return;
    }
    int mid = (l + r) >> 1;
    if (y <= mid) update(id, x, y, c, ls[k], l, mid);
    else update(id, x, y, c, rs[k], mid + 1, r);
}

int query(ll &id, int x, int y, int &k, int l, int r) {
    //id为第y个人的位置上id,也即第k个人的id,函数返回第y个人的位置
    //x表示第x行
    if (!k)add(x, k, l, r);

    if (r == l) {
        id = ID[k];
        return l;
    }

    int mid = (l + r) >> 1;

    if (!ls[k])add(x, ls[k], l, mid);//因为你要考察左儿子的sum,你得确保左儿子被开了

    if (sum[ls[k]] >= y)return query(id, x, y, ls[k], l, mid);
    return query(id, x, y - sum[ls[k]], rs[k], mid + 1, r);
}

//以下函数为最后一列专属
//因为和上区别不大,我不在详述
void add1(int &k, int l, int r) {
    k = ++cnt;

    sum[k] = l <= n ? min(r, n) - l + 1 : 0;

    if (r == l && r <= n)ID[k] = 1ll * r * m;
}

void update1(ll id, int x, int c, int &k, int l, int r) {
    //x表示第x行位置
    if (!k)add1(k, l, r);

    sum[k] += c;
    if (r == l) {
        if (c == 1)ID[k] = id;
        return;
    }
    int mid = (l + r) >> 1;
    if (x <= mid)update1(id, x, c, ls[k], l, mid);
    else update1(id, x, c, rs[k], mid + 1, r);
}

int query1(ll &id, int x, int &k, int l, int r) {
    if (k == 0)add1(k, l, r);

    if (r == l) {
        id = ID[k];
        return l;
    }

    int mid = (l + r) >> 1;
    if (!ls[k])add1(ls[k], l, mid);

    if (sum[ls[k]] >= x)return query1(id, x, ls[k], l, mid);
    return query1(id, x - sum[ls[k]], rs[k], mid + 1, r);
}

int main() {
    int q;
    scanf("%d%d%d", &n, &m, &q);

    int L = 1, R = max(n, m - 1) + q;//线段树的左右边界

    while (q--) {
        int x, y;
        scanf("%d%d", &x, &y);
        if (y == m) {
            //如果是最后一列单独考察
            ll id;
            int pos = query1(id, x, root[n + 1], L, R);//查询的x个人的位置并取其id

            printf("%lld\n", id);

            update1(0, pos, -1, root[n + 1], L, R);//把pos位置的人删除
            ++sz[n + 1];//把删除的这个人插入最后,sz[n+1]++表示末尾插入的人数加1
            update1(id, sz[n + 1] + n, 1, root[n + 1], L, R);//在sz[n+1]+n的位置上插入这个人,并把id放在叶节点
        } else {
            //否则离队的不在最后一列
            ll id;
            int pos = query(id, x, y, root[x], L, R);//查询x行第y个人的位置并取其id

            printf("%lld\n", id);

            update(0, x, pos, -1, root[x], L, R);//删除第x行的pos位置的人

            ++sz[n + 1];//把删除的这个人插入到最后一列的线段树末尾
            update1(id, sz[n + 1] + n, 1, root[n + 1], L, R);

            pos = query1(id, x, root[n + 1], L, R);//取最后一列第x个人的id和其位置pos

            update1(id, pos, -1, root[n + 1], L, R);//删除

            ++sz[x];
            update(id, x, sz[x] + m - 1, 1, root[x], L, R);//插入第x行的线段树末尾
        }
    }
}

对于细节的第5点:如果你用了push_up(),那么你可能出现,左儿子或右儿子没有开点,导致当前结点的sum计算错误,除非你保证左右儿子一定开了。

最后如果你要优化代码,那就自己尝试一下吧。

以上就为这道题我的讲解,新人第一篇博客,难免有编排错误,望理解。如果你有时间,你可以在评论区指出,我尽量改正

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值