《擦除序列》线性时间做法题解
(最近把早些年零散写的题解收集整理一下)
关于这篇题解的背景:
先前已经有人用树(堆)写了题解,但是时间复杂度还没有达到极致。于是我写了这篇时间复杂度为 O ( n ) O(n) O(n) 的做法题解。
梳理题意
题目链接。
每次求剩下未被擦除的所有数字中,最大连续子段和的值且不能包含任何被擦除的位置。
解题思路
每次调用一遍最大连续子段和时间复杂度为 O ( n ) O(n) O(n) ,那么总的时间复杂度为 O ( n 2 ) O(n^2) O(n2),如果常数小一些能勉强过掉 60 % 60\% 60% 的数据;但是该题与最大连续子段和模板题的最大区别在于 1 ≤ a i ≤ 1 0 9 1≤a_i≤10^9 1≤ai≤109 。是的, a i a_i ai均为正数!
那么很明显我们每一次得到的最大连续子段一定与两个’X’(被擦掉的位置)或开头或结尾相邻,即尽可能向外延伸。
这时候我们也就没必要每一个区间都打一个最大值,仅记录符合上文要求的区间最大值即可。
但是又遇到了问题:每一次擦掉数字,区间会改变:一个区间分裂为两个区间。既要消除前者的影响,也要将答案再和后者取最大值。消除前者的影响至少需要对数级别的复杂度(可以使用平衡树),但那样不如直接使用线段树了。或许每一次都再打一遍最大值?那么成了 O ( n 2 ) O(n^2) O(n2) 算法,刚刚的分析也就没意义了。此时,“正难则反”,要从反面来思考问题。
从后向前,由擦除数字改为每次加入数字,于是就变成:两个区间合并为一个区间。由于前面所提 a i > 0 a_i>0 ai>0 ,所以两个子区间分别的和必然小于合并后区间的和,所以不会对答案产生影响,自然无需消除影响(想一想,为什么?)。
于是只需将合并后区间的和与当前最大值比大小。这样一遍加数一边打擂台就可以考虑到所有的需要考虑的区间。但是又应该如何在 O ( 1 ) O(1) O(1) 的复杂度完成区间的求和?
考虑 c n t [ i ] cnt[i] cnt[i] 代表区间号为 i i i 的区间和; C N T CNT CNT 代表当前使用过的最大区间号,因此再加一就是一个空余区间号(类似于链式前向星的计数器); L E F T [ i ] LEFT[i] LEFT[i] 表示区间号为 i i i 的区间向左能扩展到的位置(个人程序实现时为方便而-1); R I G H T [ i ] RIGHT[i] RIGHT[i] 表示区间号为 i i i 的区间向右能扩展到的位置(个人程序实现时为方便而+1); l [ i ] l[i] l[i] 表示向左能扩展到位置 i i i 的区间的区间号; r [ i ] r[i] r[i] 表示向右能扩展到位置 i i i 的区间的区间号;而 a n s [ i ] ans[i] ans[i] 表示在 p [ i ] p[i] p[i] 被擦掉以后的答案。
或许你已经注意到: l l l 和 L E F T LEFT LEFT, r r r 和 R I G H T RIGHT RIGHT 是维护双射的数组。(在编写与调试此类问题时极需要耐心)
具体实现
于是逆序枚举:当每一个被擦掉的 p [ i ] p[i] p[i] “还原回来”,有以下操作:
1.更新 c n t cnt cnt 数组。每一个 p [ i ] p[i] p[i] 还原回来时都将原本两个独立的区间合并(可以为空),而 p [ i ] p[i] p[i] 则分别是这两个集合向左和向右能扩展到的位置,所以它们的区间号分别是 l [ p [ i ] ] l[p[i]] l[p[i]] 和 r [ p [ i ] ] r[p[i]] r[p[i]] ,再加上 a [ p [ i ] ] a[p[i]] a[p[i]],因此:
cnt[++CNT]=cnt[l[p[i]]]+cnt[r[p[i]]]+a[p[i]];
2.维护 L E F T LEFT LEFT 和 R I G H T RIGHT RIGHT 数组。在最开始时均初始化为 − 1 -1 −1 。如果 L E F T [ r [ p [ i ] ] ] LEFT[r[p[i]]] LEFT[r[p[i]]] 为-1,说明原左侧区间为空,此时应当将现区间能扩展到的最左位置更新为 p [ i ] − 1 p[i]-1 p[i]−1 ;否则不是空区间,那么最左端应该是左侧区间的左端即 L E F T [ r [ p [ i ] ] ] LEFT[r[p[i]]] LEFT[r[p[i]]]。 R I G H T RIGHT RIGHT 数组同理。于是得到:
if(LEFT[r[p[i]]]==-1)LEFT[CNT]=p[i]-1;
else LEFT[CNT]=LEFT[r[p[i]]];
if(RIGHT[l[p[i]]]==-1)RIGHT[CNT]=p[i]+1;
else RIGHT[CNT]=RIGHT[l[p[i]]];
3.维护 l l l 和 r r r 数组。一开始均为 0 0 0 (我的区间号从 1 1 1 标起, 0 0 0 默认为空区间),由于 l l l 和 L E F T LEFT LEFT, r r r 和 R I G H T RIGHT RIGHT 双向映射,而在第2步中 L E F T LEFT LEFT 和 R I G H T RIGHT RIGHT 已经更新,所以只需要维护双射即可(若解释理解也可以,但是这样思考起来更方便):
l[LEFT[CNT]]=r[RIGHT[CNT]]=CNT;
4.更新 a n s ans ans 数组:
ans[i-1]=max(ans[i],cnt[CNT]);
总结反思
我的方法时间复杂度为 O ( n ) O(n) O(n) ,利用 a i > 0 a_i>0 ai>0 的条件避免冗余比较,总计只会考虑 n n n 个区间。虽然它只能处理 a i > 0 a_i>0 ai>0 ,而树(堆)的做法似乎没有限制,然而我相信出题人既然给出了 a i > 0 a_i>0 ai>0 的条件,那必定有它存在的价值,值得我们去探究,成为我们做题乃至优化的依据之一。一旦数据加到百万甚至千万级别, O ( n l o g n ) O(n\ log\ n) O(n log n) 的做法就有可能超时。最后,希望同学们不选得分最高的,要选最适合自己的算法!
注:修改于2024年8月6日。
#include <bits/stdc++.h>
#define int long long
using namespace std;int a[100010],n,p[100010],ans[100010],l[100010],r[100010],cnt[100010],CNT,LEFT[100010],RIGHT[100010];signed main(){cin>>n;for(int i=1;i<=n;++i){cin>>a[i];}for(int i=1;i<=n;++i)cin>>p[i];memset(LEFT,-1,sizeof(LEFT));memset(RIGHT,-1,sizeof(RIGHT));for(int i=n;i>=1;--i){cnt[++CNT]=cnt[l[p[i]]]+cnt[r[p[i]]]+a[p[i]];if(LEFT[r[p[i]]]==-1)LEFT[CNT]=p[i]-1;else LEFT[CNT]=LEFT[r[p[i]]];if(RIGHT[l[p[i]]]==-1)RIGHT[CNT]=p[i]+1;else RIGHT[CNT]=RIGHT[l[p[i]]];l[LEFT[CNT]]=r[RIGHT[CNT]]=CNT;ans[i-1]=max(ans[i],cnt[CNT]);}for(int i=1;i<=n;++i)cout<<ans[i]<<" ";return 0;
}