题解:P13963 [ICPC 2023 Nanjing R] 接雨水

· · 题解

Update

2025.11.9

格式问题已更正,感谢管理员的指正。

前言

本篇题解注重于呈现本人从暴力到正解的思维过程,如果只想学习正解的话可直接跳过前半部分。

而为了帮助大家更好的看清这个过程,我自己大概测了一下本题划分的数据阶梯。

数据范围

本题共 33 个测试点。

对于 1 - 5 号测点:nq \leq 10^3(subtask1)。

对于 6 - 10 号测试点:nq \leq 5\times10^4 且数据随机(subtask2)。

对于 11 - 19 号测试点:nq \leq 5\times10^4(subtask3)。

对于 20 - 33 号测试点:nq \leq 10^5(subtask4)。

时间限制 5 s。

空间限制 1024 MB。

(注:本题原题在 ICPC 赛制下不存在部分分,所以【数据范围】这部分是我自己用程序测试出来的,并不保证准确,仅仅方便教学而已)

正文

解题过程

首先考虑把原公式拆开,发现可以把所有 a_i 的和单独拿出来存着,这部分每次修改仅用加上 v 即可,复杂度 O(1)

接下来我们只需要看公式的前半部分即可,就是对于每个点将他的前缀最大值和后缀最大值中较小者作为贡献加入

这里可以想到最天真的,不带任何优化的暴力,即每次修改完之后 n^2 暴力计算每个点的前后缀最大值,时间复杂度 O(n^3),期望通过 subtask1。

代码实现

(非正解代码均只展示部分)

        cin >> n;
        sumf=0;
        for (int i = 1;i <= n;i++)
        {
            cin >> a[i];
            sumf+=a[i];
        }
        cin >> q;
        for (int tiat=1;tiat <= q;tiat++)
        {
            cin >> x >> v;
            a[x]+=v;
            sumf+=v;
            long long ans=0;
            for (int i = 1;i <= n;i++)
            {
                int maxn1=a[i],maxn2=a[i];
                for (int j = i-1;j >= 1;j--)
                {
                    maxn1=max(maxn1,a[j]);
                }
                for (int j = i+1;j <= n;j++)
                {
                    maxn2=max(maxn2,a[j]);
                }
                ans+=min(maxn1,maxn2);
            }
            cout << ans-sumf << endl;
        }

这个显然有点过于暴力了,考虑进一步的优化。

经过浅浅的思考,发现在每次更新完 a_i 的值后统计单点的前后缀最大值可以做到 O(n),只需要开个数组依次递推即可。

代码实现

            f[1] = a[1];
            for (int j = 2;j <= n;j++)
            {
                f[j] = max(f[j-1],a[j]);
            }

            g[n] = a[n];
            for (int j = n-1;j >= 1;j--)
            {
                g[j] = max(g[j+1],a[j]);
            }

这个优化经实测无法直接获得比朴素暴力更高的分数,但这个让我们可以注意到一个性质,即前缀最大值这个数组单调不降,后缀最大值数组单调不增

进一步思考可发现,对于每次单点修改可分为两种情况:

(后续讨论均以修改前缀最大值为例,后缀最大值同理)

对于第二种情况,由于前缀最大值序列单调不增,所以某些点也一定在序列上属于一段连续的区间。

那么对于每次修改操作的最大值序列维护,只需要将以被修改的点为左端点,其右边最后一个小于 a_i+v(即修改后那个点的权值)的点为右端点的区间段全部赋值为 a_i+v 即可。

到此我们可以将题目的要求转为两个操作:

到这里可能一眼会想到用线段树维护两个序列,但考虑到用线段树好像还得套一层二分,还会添上一层 \log,并且还是两个序列,感觉代码复杂度会炸,而且线段树二分本蒟蒻并不是特别熟悉,所以这里我果断采用珂朵莉树进行维护。

(好吧我承认我就是想写珂朵莉)

这样我们每次修改后就先去对应的树上确定起始修改点的位置,然后也不需要二分,直接暴力扫到终止点所在的区间段,然后进行推平即可。

但紧接着又会面临一个问题,我们每次要加的是单个点的前后缀最大值中的较小值,而珂朵莉树只分别存了前后缀最大值序列,该怎么合并呢?

这里我想到过一个暴力写法,可以顺序遍历第一棵树(假设是后缀最大值),依次取出该树的颜色段作为询问区间去第二棵树上比较。

由于这样做在每次查询中询问的区间段的后缀最大值是一定的,所以对于第二棵树只需要与这个值进行比较,然后按照珂朵莉树正常的统计方式即可。

(一些注释写代码里了)。

代码实现

#include <bits/stdc++.h>
using namespace std;
#define IT set<node>::iterator
#define int long long
inline void read(long long &a)
{//省略
}
inline void write(long long x)
{//省略
}
struct node
{
    int l,r;
    mutable int v;
    node(int L,int R=-1,int V=0):l(L),r(R),v(V){}
    bool friend operator<(node a,node b)
    {
        return a.l < b.l;
    }
};
set<node> tree;
set<node> tree2;
long long a[100005];
IT cut(int pos)
{
    IT it=tree.lower_bound(node(pos));
    if (it != tree.end() and it->l == pos)
    {
        return it;
    }
    it--;
    int l=it->l,r=it->r,v=it->v;
    tree.erase(it);
    tree.insert(node(l,pos-1,v));
    return tree.insert(node(pos,r,v)).first;
}
IT cut2(int pos)
{
    //同cut,只不过针对tree2
}
IT xun(int pos)
{
    IT it=tree.lower_bound(node(pos));
    if (it != tree.end() and it->l == pos)
    {
        return it;
    }
    it--;
    return it;
}
IT xun2(int pos)
{
    //同xun,针对tree2
}
bool gai(int pos)
{
    //基本同2,只是遍历顺序不一样,这里拿2演示
}
bool gai2(int pos)//维护前缀最大值序列
{
    IT fz=xun2(pos);
    //这里判断一下要不要修改,如果不需要就跳过了,不进行切割
    //有对这种优化感兴趣的可以看我主页的一篇文章 (无耻推荐~~
    if (fz->v > a[pos])
    {
        return 0;
    }
    IT it=cut2(pos);
    IT fit=it;
    while (it->v <= a[pos] and it != tree2.end())
    //暴力扫就行,期望块数不是很多
    {
        it++;
    }
    it--;
    int r=it->r;
    it++;
    tree2.erase(fit,it);
    tree2.insert(node(pos,r,a[pos]));
    return 1;
}
int cha(int l,int r,int v)//v是传入的比较值,即从tree中获取的最大值
{
    IT itr=cut2(r+1),itl=cut2(l);
    int sum=0;
    for (;itl != itr;itl++)
    {
        sum+=min(itl->v,v)*(itl->r-itl->l+1);
    }
    return sum;
}

signed main()
{
    long long T,n,q,x,v;
    long long sumf=0;
    read(T);
    for (int chtholly=1;chtholly <= T;chtholly++)
    {
        //建树和读入先省略了,sumf是统计的所有a[i]的和
        for (int i = 1;i <= q;i++)
        {
            read(x),read(v);
            a[x]+=v;
            sumf+=v;
            lstans-=v;
            bool flag1=gai(x);
            bool flag2=gai2(x);
            //这里做个优化,如果本次没触发任何修改直接输出上次答案即可
            if (flag1 or flag2 or lstans < 0)
            {
                long long ans=0;
                for (IT it = tree.begin();it != tree.end();it++)
                {
                    ans+=cha(it->l,it->r,it->v);
                }
                ans-=sumf;
                lstans=ans;
            }
            write(lstans);
            putchar('\n');
        }
    }
    return 0;
}

上述做法可以成功通过 subtask2 的部分分(其实好像卡卡能卡到19号点,但这里不展开讲了),但面对后续的数据还是显得有些无能为力,但只看代码确实无法继续优化了,思考陷入僵局。。。

既然代码上卡住了,不妨再来推推性质。

我们最终答案要求的是这个柿子:

\sum\limits_{i = 1}^n \left(\min(f_i, g_i)\right) - \sum\limits_{i = 1}^n \left(a_i\right) $$ \min(x, y) = x + y - \max(x, y) $$ 于是,可以把原柿子变成: $$ \sum\limits_{i = 1}^n \left(f_i + g_i - \max(f_i,g_i)\right) - \sum\limits_{i = 1}^n \left(a_i\right) $$ 很显然,$f_i$ 和 $g_i$ 是我们已经维护出来的东西,现在唯一的不速之客就是 $\max( f_i , g_i )$ 了,回想一下 $f$,$g$ 数组的含义分别是什么? **每个数的前后缀最大值**。 那对于他们俩之间选个最大值,不就等效于全局最大值(设为 $M$)吗? 对于 $M$,我们显然可以每次 $O(1)$ 维护出来。 于是,柿子最终可以写成: $$ \sum\limits_{i = 1}^n \left(f_i + g_i \right) - \sum\limits_{i = 1}^n \left(a_i\right) - n\times M $$ 那这下很好办了,每次直接把两个序列维护的值加起来,可以避免刚刚暴力写法中那个查询带来的大量无意义分割和复杂度。 单次修改复杂度期望双 $\log$,查询期望双 $\log$,总复杂度$O(q\times \log^2 n)$,问题到此结束————了吗? 实测可以发现,这个写法可以通过 subtask3 这个数据范围稍微小一点的点,subtask4 依旧 TLE。 **(这个是出于珂朵莉树的历史遗留问题,容易被卡,如果采用线段树写法应该确实可以到此收工)** **下文为各位珂学家准备** ~~(bushi)~~ 接下来还得考虑进一步优化,发现这题有个很良心的点,就是**每次询问的区间是固定的**(即整个序列),那必然是可以在这里动点手脚的。 这里我们可以开个全局变量 $sum$,实时记录两个最值序列的总和,然后对于每次修改操作,我们在暴力扫颜色段的时候顺便对其做出修改,因为能被扫到的色段后面也一定会被推平成某个值,可以根据这个值来改。 这样子复杂度就相对有保证了,简单证明一下: - 若本次推平区间较小:那么本次暴力遍历的复杂度近似 $O(1)$,$set$ 自带 $\log$,总复杂度 $O(\log n)$。 - 若本次推平区间较大(接近原序列长度):那么本次暴力遍历最劣来到 $O(n \log n)$,但此后的一波推平可以迅速减少珂朵莉树的区块数,使后续操作复杂度近似 $\log$。 查询因为已维护为 $sum$ 所以是 $O(1)$,总复杂度均摊一下大约是稳定的 $O(q\times \log^2 n)$,可通过此题。 那么,完整代码在此献上。 ```cpp #include <bits/stdc++.h> using namespace std; #define IT set<node>::iterator #define int long long //-----------快读快写板子----------- inline void read(long long &a) { int s=0,w=1; char ch=getchar(); while (ch < '0' or ch > '9') { if (ch == '-') { w=-1; } ch=getchar(); } while (ch >= '0' and ch <= '9') { s=(s<<3)+(s<<1)+(ch^48); ch=getchar(); } a=s*w; } inline void write(long long x) { if (x < 0) { putchar('-'); x=-x; } if (x > 9) { write(x/10); } putchar('0'+x%10); return; } //-------------可爱的珂朵莉--------------- struct node { int l,r; mutable int v; node(int L,int R=-1,int V=0):l(L),r(R),v(V){} bool friend operator<(node a,node b) { return a.l < b.l; } }; set<node> tree; set<node> tree2; long long sum1,sum2;//这里把全局sum拆成俩维护了,但思路一样的 long long a[100005]; IT cut(int pos) { IT it=tree.lower_bound(node(pos)); if (it != tree.end() and it->l == pos) { return it; } it--; int l=it->l,r=it->r,v=it->v; tree.erase(it); tree.insert(node(l,pos-1,v)); return tree.insert(node(pos,r,v)).first; } IT cut2(int pos) { IT it=tree2.lower_bound(node(pos)); if (it != tree2.end() and it->l == pos) { return it; } it--; int l=it->l,r=it->r,v=it->v; tree2.erase(it); tree2.insert(node(l,pos-1,v)); return tree2.insert(node(pos,r,v)).first; } IT xun(int pos) { IT it=tree.lower_bound(node(pos)); if (it != tree.end() and it->l == pos) { return it; } it--; return it; } IT xun2(int pos) { IT it=tree2.lower_bound(node(pos)); if (it != tree2.end() and it->l == pos) { return it; } it--; return it; } void gai(int pos) { IT fz=xun(pos); if (fz->v >= a[pos]) { return; } IT it=cut(pos+1); IT fit=it; it--; while (it != tree.begin() and it->v < a[pos] ) { sum1+=(it->r-it->l+1)*(a[pos]-it->v); //暴力遍历时对sum做出修改 it--; } if (it->v >= a[pos]) { it++; } else { sum1+=(it->r-it->l+1)*(a[pos]-it->v); } int l=it->l; tree.erase(it,fit); tree.insert(node(l,pos,a[pos])); return; } void gai2(int pos) { IT fz=xun2(pos); if (fz->v >= a[pos]) { return; } IT it=cut2(pos); IT fit=it; while (it != tree2.end() and it->v < a[pos] ) { sum2+=(it->r-it->l+1)*(a[pos]-it->v); it++; } it--; int r=it->r; it++; tree2.erase(fit,it); tree2.insert(node(pos,r,a[pos])); return; } int cha(int l,int r,int v) { IT itr=xun2(r),itl=xun2(l); //神秘查询优化,参见本人一篇文章( itr++; IT fz=itr; itr--; int sum=0; if (itl == itr) { sum+=min(itl->v,v)*(r-l+1); } else { for (IT it=itl;it != fz;it++) { if (it == itl) { sum+=min(it->v,v)*(it->r-l+1); } else if (it == itr) { sum+=min(it->v,v)*(r-it->l+1); } else { sum+=min(it->v,v)*(it->r-it->l+1); } } } return sum; } signed main() { long long T,n,q,x,v; long long sumf=0; read(T); for (int chtholly=1;chtholly <= T;chtholly++) { tree.clear(); tree2.clear(); read(n); sumf=0; int qmax=0; for (int i = 1;i <= n;i++) { read(a[i]); qmax=max(qmax,a[i]);//维护全局最大值 sumf+=a[i];//维护所以a的和 } //-------建树优化-------- int fl=n,fr=n; int maxn=a[n]; while (fl >= 1) { if (a[fl] > maxn) { tree.insert(node(fl+1,fr,maxn)); maxn=a[fl]; fr=fl; } fl--; } if (fr > fl) { tree.insert(node(fl+1,fr,maxn)); } fl=1,fr=1; maxn=a[1]; while (fr <= n) { if (a[fr] > maxn) { tree2.insert(node(fl,fr-1,maxn)); maxn=a[fr]; fl=fr; } fr++; } if (fr > fl) { tree2.insert(node(fl,fr-1,maxn)); } //-------正片开始-------- read(q); sum1=0,sum2=0; for (IT it=tree.begin();it != tree.end();it++) { sum1+=(it->r-it->l+1)*(it->v); } for (IT it=tree2.begin();it != tree2.end();it++) { sum2+=(it->r-it->l+1)*(it->v); } for (int i = 1;i <= q;i++) { read(x),read(v); a[x]+=v; qmax=max(qmax,a[x]); sumf+=v; gai(x); gai2(x); write(sum1+sum2-n*qmax-sumf); putchar('\n'); } } return 0; } ```