1. 线段树与树状数组的进阶用法
数据结构1-Ducati
(2024.7.9)
1. 树状数组
设 \(b_i=\sum _{j=i-lowbit(i)+1}^i a_j\),此时 \(b_i\) 为树状数组,有以下性质:
-
每个点的支配区间要么包含,要么不交。
这说明其可以抽象为树形结构。
-
每个点的父节点为 \(i+lowbit(i)\)
特别的,建树可以优化为 \(O(n)\),先将 \(b_i=a_i\),枚举 \(i=1...n\),若 \(i+lowbit(i)\le n\),则使 \(b_{i+lowbit(i)}\) 加上 \(b_i\) 即可,类似树形 dp。
二维树状数组
直接二层循环遍历即可,严格好写于树套树。
树状数组与倍增
对于【单点修改,查询全局第 \(k\) 小】有经典线段树二分做法,如何用树状数组实现?如果考虑二分的话,并没有线段树一样完美契合的结构,复杂度 \(O(\log^2 n)\)。
考虑用倍增代替二分,因为树状数组实际上是在二进制上的操作,且 lowbit
和倍增非常契合。
具体的,我们维护 \(x=0,sum=0\),从大到小枚举二进制位 \(i\):
- 查询区间 \([x+1,x+2^i]\) 的区间和 \(t\);
- 如果 \(sum+t<k\),则拓展成功,\(x=x+2^i\),否则继续枚举 \(i\)。
这样得到的是 \([1...x]<k\) 的最大的 \(x\),因此 \(x+1\) 为答案。
这样的好处是,增量 \([x+1,.x+2^i]\) 的值一定是 \(t[x+2^i]\),因为 \(lowbit(x+2^i)=2^i\),结合树状数组定义即可。
例题1-P6225 [eJOI2019] 异或橙子
给定序列 \(a\),支持单点修改,查询一段区间所有子区间异或和的异或和。
考虑当前区间为 \([l,r]\),考虑位置 \(i\) 被计算了多少次?答案为 \((i-l+1)(r-i+1)\),即将区间长度分为两段。注意到如果区间长度为奇数时,只能是奇数加偶数,故奇数乘偶数为一偶数。若区间长度为偶数,则奇数乘奇数,偶数乘偶数需要分开看。发现相当于要么全是奇数位置贡献,要么全是偶数位置贡献。用两个树状数组分别维护即可。
为什么树状数组能维护异或和?满足可减性。
例题2 -【模板】线段树 1
重新推了一遍树状数组做法,但做麻烦了,没有将问题弱化完全:需要解决的问题实际上是查询 \((1,r)\) 的区间和,而不是一上来就做 \((l,r)\) 的区间和,后者显然可以通过前者差分得到,但直接推后者是比较麻烦的。
首先,树状数组直接处理区间加是几乎无法实现的,因此必须使用差分。只需解决区间和的问题,可以弱化为解决前缀和 \((1,r)\) 的问题。
即 \(\sum_{i\le r}\sum _{j\le i} b_j\),不难得到为 \(r\times b_1+(r-1)\times b_2+...+b_r\),只需分别维护 \(b_i,b_i\cdot i\),\(\sum b_i\times (r+1)-\sum b_i\cdot i\) 即为答案。
注意最后别忘了再差分一步。
补充例题1-二维树状数组: P4514 上帝造题的七分钟
维护矩阵加,矩阵和。
根据二维前缀和,我们有二维差分:\(b[i][j]=a[i][j]-a[i-1][j]-a[i][j-1]+a[i-1][j-1]\)。
先考虑求和:
同样的,将问题弱化为 \((1,1)\) 到 \((x,y)\) 的前缀和:
依旧考虑每个 \(b\) 被贡献多少次,即为其右下方点的个数:\((x-i+1)(y-j+1)\) 。则原式可整理为:
分别维护 \(b_{i,j},i\cdot b_{i,j},j\cdot b_{i,j},i\cdot j\cdot b_{i,j}\) 即可。
对于修改,依旧拆成四个点分别处理。
总结:对于拓展到区间查询的情况,实际上仅需仿照【线段树1】考虑每个点被贡献多少次,树状数组本身不需修改,式子都在外部呈现。
struct fenwick{
#define lowbit(x) (x&-x)
int t[N][N];
void upd(int x,int y,int k) {
for(int i=x;i<=n;i+=lowbit(i)) {
for(int j=y;j<=m;j+=lowbit(j)) {
t[i][j]+=k;
}
}
}
int query(int x,int y) {
int ans=0;朱文杰
for(int i=x;i;i-=lowbit(i)) {
for(int j=y;j;j-=lowbit(j)) {
ans+=t[i][j];
}
}
return ans;
}
}t1,t2,t3,t4;
inline void modify(int x,int y,int k) {
t1.upd(x,y,k);t2.upd(x,y,k*x);t3.upd(x,y,k*y),t4.upd(x,y,k*x*y);
}
inline int query(int x,int y) {
return t1.query(x,y)*(x*y+x+y+1)-t2.query(x,y)*(y+1)-t3.query(x,y)*(x+1)+t4.query(x,y);
}
int main(){
//fil();
n=read(),m=read();
char t;
while(~scanf(" %c",&t)) {
int a=read(),b=read(),c=read(),d=read();
if(t=='L') {
int k=read();
modify(a,b,k),modify(c+1,d+1,k);
modify(a,d+1,-k),modify(c+1,b,-k);
}
else{
printf("%d\n",query(a-1,b-1)+query(c,d)-query(a-1,d)-query(c,b-1));
}
}
return 0;
}
例题3-树状数组倍增:冰火战士
有若干个冰火战士,每个人有三个属性值 \(typ,a,b\),你需要给出一个最大的 \(T\),设 \(s1=\sum b_i,a_i\le T,typ=0\ ,s2=\sum b_i,a_i\ge T,typ=1\),使得 \(\min(s1,s2)\) 最大。
需要支持增加一个战士和删除一个战士的操作。每次操作完成后都输出 \(T\) 和 \(\min(s1,s2)\)。\(n\le 10^6\)。
设 \(T_1\) 为最大的 \(T\) 满足 \(s1<s2\),\(T_2\) 为最小的 \(T\) 满足 \(s1>s2\),答案肯定是 \(T1\) 和 \(T2\) 其中之一,再计算一遍即可判断。
容易想到 $O(n\log^2 n) $ 的二分+BIT维护前缀和的做法,如何优化到 \(O(n\log n)\) ?
用线段树常数太大,过不了 \(10^6\),使用树状数组上倍增即可。
如何判断倍增 \(r\to r+2^d\) 合法?维护 \(s1\) 和 \(s2\),看是否满足 \(s1<s2\) 的关系即可。
对于 \(T2\) 倒过来做即可。
太恶心了!因为两个都是非严格偏序关系,所以很多细节要处理。。调了两个小时/tuu。
好的方法是在:离散化之后,将火战士的位置向后移动一格,同时 \(n=tot+1\)。
补充例题2-优化dp: P3287 [SCOI2014] 方伯伯的玉米田
给定序列 \(a\),你有 \(k\) 次机会,将序列的一个区间加 \(1\),求结束时的最长不下降子序列的长度最大是多少。\(n\le 10^4,k\le 500,a_i\le 5000\)。
首先关键结论是:每次操作的右端点为 \(n\) 一定不劣。
设 \(f[i][j]\) 表示前 \(i\) 位,使用 \(j\) 次操作,以 \(i\) 结尾的最长上升子序列:
后面类似一个矩阵的前缀最大值,但是参差不齐。加入一个结尾为 \(k\) 的状态即可:将使用 \(j\) 次操作改为结尾为 \(j\)。
第一维可以滚掉。洛谷题解写的都是什么玩意(急)。
2. 线段树
线段树三问
-
Q:对于一个操作含区间修改、区间查询的问题,什么情况可使用线段树?
-
A:
- 对于一次区间修改,若对节点打上标记,能快速给一个节点更新信息(down)
- 标记在 知晓前后顺序 的时候可以合并(push_down)
- 能够根据子节点的信息推出父节点的信息(push_up)
例如经典的区间加,区间最大子段和就难以维护 down。
-
Q:什么时候可以使用 标记永久化?
-
A:
-
有些时候是可以的,例如:【区间加,区间和】,对于一次查询 \([nl,nr]\),当前节点 \([l,r]\) 有一个标记 \(v\),则增加 \([l,r]\cap[nl,nr]\times v\) 即可。
-
有些时候是不行的,例如:
- 【区间赋值,区间和】
- 【区间×矩阵,区间向量和】
由此看出,线段树能够标记永久化,需要满足的条件是懒标记的合并具有交换律。赋值和矩阵乘法都是没有交换律的。
-
-
Q:线段树的空间到底要开多大?
-
A:普遍说法是开 \(4\) 倍,这仅是保险做法,实际上:
-
如果动态开点,则只需要开两倍。
-
如果按照普通编号方式,设 \(m\) 为大于 \(n\) 的最小的二的次幂,则 \(f(n)\le f(m)=2m-1\) 。
例如 \(n\le 5\times10^5,f(n)<1048576\)。
-
例题1-复杂信息合并:P3401 洛谷树
单点修改,求区间所有子区间 异或和的和,然后搬到树上去。
好题!之前不知道怎么维护异或和的和,学之后某一天的abcE用到了。
先考虑序列上怎么做,二进制想到按位考虑,确定某一位会被贡献多少次,即这一位在多少个子区间有 \(1\) 的贡献。
如何用线段树维护?考虑分治,父节点的答案为左儿子答案加右儿子答案,再加上跨过中点的所有子区间答案。
后者只有两种情况,即左儿子后缀异或和为 \(0\),右儿子前缀异或和为 \(1\),或者相反。
用类似最大子段和的方式维护起来即可。即再维护一个区间前缀/后缀异或和为 \(1\) 的个数,\(0\) 的个数可以算出来。
太糖了!你如果维护前缀异或和的话,就相当于任选两个点再异或。所以只需统计前缀异或和每一位 \(0/1\) 的个数即可。答案即为 \(\sum cnt1\times(cnt0+1)\times 2^j\),加一的原因是 \(i=0\) 也是前缀和。修改的话,相当于对后面全部异或一下,对每一个为 \(1\) 的位置交换 \(0/1\) 个数即可。
搬到树上也是好做的,序列上的 \(i=0\) 就相当于 \(lca\)。再加一个子树异或(翻转 \(0/1\))。复杂度 \(O(n\log^3)\)。[提交记录](记录详情 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn))
例题2- 前缀最值线段树:楼房重建
支持单点修改,区间上升子序列长度。(这里指的是能选必须选)
只需考虑合并 push_up
。
父节点的答案为左儿子的答案加上右儿子以左儿子最大值为起点的答案,考虑后者如何处理:
设左儿子最大值为 \(mx\),若 $mx\le $ 右儿子的左儿子最小值,则答案为右儿子答案。
若 \(mx\le\) 左儿子最大值,则递归到左儿子处理,再加上(右儿子答案-右儿子左儿子答案)
若 \(mx>\) 左儿子最大值,则递归到右儿子处理。
即为单侧递归线段树,push_up
复杂度 \(O(\log n)\)。
补充例题: [[CodeForces 671E]Organizing a Race](https://www.luogu.com.cn/problem/CF671E)
小粉兔论文的第二道题目。
例题3- 矩阵与线段树:[THUSCH2017] 大魔法师
维护一个序列,每个元素是三元组 \((a,b,c)\),支持七种操作,取模:
- 区间 \(a=a+b\)
- 区间 \(b=b+c\)
- 区间 \(c=c+a\)
- 区间 \(a=a+v\)
- 区间 \(b=b\times v\)
- 区间 \(c=v\)
- 求区间 \(\sum a,\sum b,\sum c\)。
像轮换这样普通线段树不是很好维护的东西,可以考虑矩阵乘法线段树,实际上直接打 \(tag\) 非常麻烦,其本质相当于优化常数后的矩阵乘法,关键仍在于矩阵乘法具有结合律。
对于有区间加常数的操作,我们需要矩阵额外记录一个常数单位元 \(1\),即 \([a,b,c,1]\)。
此时六种修改操作的矩阵转移就简单明了了。
不过我们仍需要有一些矩阵卡常的素养:
- 不要
define int long long
; - 矩阵乘法最内层展开。
- 加法取模改为减法。
注意:常规矩阵乘法 push_down
之后懒标记的清空,需要先 clear()
全为 \(0\) 再 init()
使得对角线为 \(1\)。
例题4-线段树与区间ddp:P7576 「PMOI-3」期望乘积
定义一个序列的价值为其乘积。
定义 \(A\) 可到达序列 \(B\),当且仅当能够做恰好 \(k\) 次操作,每次选择 \(A\) 的一个子区间 \(+1\),使得 \(A=B\)。
给你一个长度为 \(n=10^5\) 的序列 \(A\),以及参数 \(k\le 3\),\(q\) 次询问一个区间的所有可到达序列的价值和。
神仙题。
加法对乘法有分配率是此题的关键。题解没看懂、
3.历史和与历史最值
把之前写的回顾一下再搬过来了,还有两三道省集/sdsc的题。
例题1-历史最值:P4314 CPU 监控
区间加,赋值,查询区间最大值与历史最大值
整理一下线段树思路:
对于一个很长的标记队列来讲,我们可以将其化简为 \((+a,=b,+c,=d)\) 的形式。因为在第一个赋值操作之后,任何的加法和赋值都可以看做赋值。前两项是普通的tag,后两项是历史最大加法/赋值 tag。
标记的顺序?根据刚才所说,应该先做加法,再做赋值。
-
信息的合并?直接做即可
-
信息的下传:
历史最大值 \(mval=\max(mval,val+madd)\) ;\(mval=\max(mval,mupd)\) 。
最大值 \(val\) 的顺序应该在 \(mval\) 后。
-
标记的下传:对于一个 \((+a,=b,+c,=d)\) 。
为了方便,我们将加法和赋值分 开写,先进行加法:
-
如果 \(p\) 未进行赋值操作:
\(mtag1=\max(mtag1,tag1+c),tag1+=a\) 。注意顺序;
-
否则,我们认为这是一个赋值操作:
\(mtag2=\max(mtag2,tag2+c),tag2+=a\) 。同样注意顺序。
然后是赋值,因为赋值的特殊性,我们用 \(vis\) 记录这个点是否用赋值标记还未下放,即这个标记的“生存周期”:
- 如果 \(p\) 未进行赋值操作:\(mtag2=d,tag2=b\) 。
- 否则:\(mtag2=\max(mtag2,d),tag2=b\) 。
这里分类讨论其实是没必要的,直接将 \(mtag2\) 初始化为 \(-inf\) 可以避免。
-
核心代码,使用结构体封装,挺方便的。
void adddown(int p,int add,int madd) {
/*information*/
t[p].mval=max(t[p].mval,t[p].val+madd);
t[p].val+=add;
/*tag*/
if(t[p].vis) {
t[p].mt2=max(t[p].mt2,t[p].t2+madd);
t[p].t2+=add;
}else{
t[p].mt1=max(t[p].mt1,t[p].t1+madd);
t[p].t1+=add;
}
}
void upddown(int p,int upd,int mupd) {
/*information*/
t[p].mval=max(t[p].mval,mupd);
t[p].val=upd;
/*tag*/
if(t[p].vis) {
t[p].mt2=max(t[p].mt2,mupd);
t[p].t2=upd;
}else{
t[p].vis=1;
t[p].mt2=mupd;
t[p].t2=upd;
}
}
void down(int p,tree tag) {
adddown(p,tag.t1,tag.mt1);
if(!tag.vis) return ;
upddown(p,tag.t2,tag.mt2);
t[p].vis=1;
}
void push_down(int p) {
down(ls(p),t[p]);
down(rs(p),t[p]);
t[p].vis=0;
t[p].t1=t[p].mt1=0;
}
4. Segment-beats
P6242 【模板】线段树 3(区间最值操作、区间历史最值)
区间加,区间取 \(min\) ,区间和,区间最大值,区间历史最大值。
没有赋值操作便简单很多,这里主要讲区间取 \(min\) 。
区间取 \(min(t)\) ,转化为将一个区间的最大值赋值为 \(t\) ,并且将所有大于 \(t\) 的都减去 \(a_i-t\) ,这段区间的和就减去了 \(\sum a_i-t\) 。
问题是如何找到每一个 \(>t\) 的数,我们希望这些数只有一种,即 \(se\) 为区间严格次大值,\(m\) 为区间最大值, \(se<t\le m\) 。这样我们可以直接将 \(sum-=cnt\times (m-t)\) 。其中 \(cnt\) 代表最大值个数。
但是情况并非常常如此,我们可以递归进行,如果 \(m<t\) 就跳出,如果 \(se<t\le m\) 就进行上述操作,否则就继续递归。势能分析复杂度 \(O(\log^2n)\) 。
对于查询历史最大值操作,我们则分别需要记录 区间最大值的加法标记的历史最大值 \(maddtag\) ,和区间非最大值的 加法标记的历史最大值 \(maddtag'\) 。这样就和操作二契合了。
5. 可持久化线段树
例题1-动态开点:CF915E Physical Education Lessons
支持区间赋值为 \(0/1\),求区间和。区间长度 \(10^9\)。\(q=3\cdot 10^5\)
对于这种值域极大的,如果强制在线,则可以使用动态开点线段树维护。
注意计算空间!其中我的 push_down
是这么写的:
inline void push_down(int p,int l,int r) {
if(t[p].tag==-1) {
return ;
}
if(!ls(p)) ls(p)=++cnt;
if(!rs(p)) rs(p)=++cnt;
t[ls(p)].tag=t[p].tag,t[rs(p)].tag=t[p].tag;
if(t[p].tag==0) t[ls(p)].val=t[rs(p)].val=0;
else t[ls(p)].val=mid-l+1,t[rs(p)].val=r-mid;
t[p].tag=-1;
return ;
}
空间复杂度为 \(O(q\log q \times 2)\),开到 \(32n\) 是不够的。
例题2- 静态区间mex
可持久化线段树上维护每个权值最后一次出现的位置,这是主席树上数颜色的套路,那么对于询问 \([l,r]\),答案即为第 \(r\) 棵线段树上第一个 \(<l\) 的位置,线段树维护区间 \(\min\) 再二分即可。
例题3:CF464E The Classic Problem
给定一张 \(n\) 个点,\(m\) 条边的无向图,每条边的边权为 \(2^{x_i}\) ,求 s 到 t 的最短路,结果对 \(1e9+7\) 取模。
最短路题一般只能用最短路算法。
设比较复杂度为 \(O(a)\) ,加法复杂度为 \(O(b)\) ,则总复杂度为 \(O(a(n+m)\log m+bm)\) 。问题在于如何优化高精度加法和比大小。
显然题目希望我们用二进制表示,两个数比大小可以用hash+二分做到 \(O(\log)\) 。
但是还有一个加法操作,即 d[u]=d[v]+w
,发现 \(w=2^k\) ,所以一定是从 \(k\) 往高位找到第一个 \(0\) 置为 \(1\) ,并把后面的 \(1\) 都置为 \(0\) 。这就很像一个线段树区间赋值。但是空间存不下,所以用主席树。找第一个 0 线段树上二分即可。
另外进制哈希满足结合律,可以直接放在主席树上。这样比较两个数大小也是在两颗主席树上完成的,同样是线段树二分,不过是自顶向下二分的。
另外,区间赋值需要懒标记,但是区间赋值为 \(0\) 在主席树上可以将其连到一颗永远为空的主席树上。
细节非常之多:
-
修改的时候无论如何一定要新建一棵完整的树,即
void update(int &p,...) {p=++cnt;...}
-
外层 \(tot\) 表示根的数量,一定要和内层 \(cnt\) 表示节点数量对上,别乱了。
-
用最短路 \(d_x\) 表示 根的编号
-
线段树上哈希,
push_up
的时候左儿子要乘右儿子的区间长度。(以前怎么写对的?)void push_up(int p,int l,int r) { t[p].hs2=((ll)t[ls(p)].hs2*m2[r-mid]%mod+(ll)t[rs(p)].hs2)%mod; }
-
由于 dij 实现过于拉跨,我习惯在出队的时候判断 \(vis\) ,但这会导致重复进队多次。所以空间至少得再多开一倍。。
补充例题: P8860 动态图连通性
给定一张 有向图,多次询问一条边 \((u,v)\) ,问如果删掉它 \(1\) 和 \(n\) 能否联通,如果能则删掉。询问间相互影响。\(n\le10^5\) 。
注意到一条边如果被删多次则是没有意义的,因为只有删边没有加边。所以只要一开始能被删去就会被删去,那么我们只需要保留第一次询问。
在线做是经典困难问题,我们肯定需要离线。不妨假设所有边都被询问了一遍,考虑最后剩下的 这条路径是什么。
发现一定是最后被询问的若干边,构成了一条从 1 到 n 的链。
这启发我们考虑跟删边顺序的关系,给每条边赋值为 初次访问的时间,从 1 开始走,走字典序最大的路径到 n ,即为答案。
字典序最大路径怎么做?结合经典问题,如果我们赋值为 \(2^{V-w}\) 次方就是主席树优化 0/1 高精度最短路问题了。
例题4-树上主席树:P2633 Count on a tree
求树上链权值 kth,强制在线。
每个节点继承其父亲的信息,每次询问即是在 \(u\) 节点主席树加 \(v\) 节点主席树减去 \(lca\) 主席树的树上线段树二分。
补充例题 :[SDOI2013] 森林
在P2633基础上加入 连接两棵树的操作
在上一题基础上考虑启发式合并,复杂度多一个 \(\log\)。
至于询问的时候要求 lca,用在线的倍增就行了。
例题5-子树深度主席树 CF893E:Subtree Minimum Query
求节点 \(x\) 子树内距离小于 \(k\) 的最小点权,强制在线。权值可能相同。
看到子树和强制在线可以想到线段树合并做,但还有一个绝妙的树上主席树做法。
具体的,我们见到和深度有关的树上问题,可以按 深度为时间轴,dfn为下标建立主席树。注意到这样会导致同一行的节点用相同的一棵树。
但是这样没有关系,我们查询只需要找到时间为 dep[x]+k
的主席树,在下标区间 (dfn[x],dfn[x]+siz[x]-1)
找区间最小值即可。
这样的正确性在于,深度小于x的节点不会处在这个下标区间里。
提交 *7,详细请见 那些调了一年的题,原因是共用同一版本乱修改会影响上一版本。
补充例题 [BZOJ4771] 七彩树
求节点 \(x\) 子树内距离小于 \(k\) 的节点颜色数,强制在线。
bzoj好题!
我甚至不知道子树数颜色的第三种求法(
如果不考虑深度限制的话,我们知道序列上区间数颜色,按照先序遍历后,即为 (dfn[x],dfn[x]+siz[x]-1)
区间颜色数。
更具体的,我们加入点 \(x\),将 \(x\) 时间戳为下标的值在线段树上加一,\(x\) 与其同颜色在树上dfn前驱的 \(lca\) 减一,求区间和。
加入深度的限制,和例题一样,bfs按照深度建主席树。但因为 bfn和dfn完全不同,加入点 \(x\) 时,会改变原本的前驱后继关系。
我们需要先还原 原本前驱后继的限制,如何找到其按照 bfs 遍历的dfn前驱后继?用 set
维护即可。
查询依旧找到时间为 dep[x]+k
的主席树,查区间 (dfn[x],dfn[x]+siz[x]-1)
的和即可。
tree[lca(next, prev)]++
tree[lca(x, next)]--
tree[lca(x, prev)]--
tree[x]++
6. 线段树合并
线段树合并常见于树上问题,维护子树的信息,可以与其他抽象树形结构结合(SAM的endpos集合),也可以优化树形 DP。
如果强制在线,则需要新建节点,空间复杂度极大,否则都是合并到父亲上。
int merge(int l, int r, int x, int y) {
if(!x || !y) return x | y;
int m = l + r >> 1, z = ++node;
if(l == r) return /* 合并叶子 x 和 y */, z;
ls[z] = merge(l, m, ls[x], ls[y]);
rs[z] = merge(m + 1, r, rs[x], rs[y]);
return /* 合并左右儿子 */, z;
}
void dfs(int x,int fa) {
for(int y:s[x]) {
if(y==fa) continue;
dfs(y,x);
rt[x]=t1.merge(rt[x],rt[y],1,3*n);
}
}
注意到如果 rt[x]=0
是不需要特判的。
注意: 合并左右儿子 push_up
和合并两棵树的叶子操作并不相同。例如叶子相加,左右儿子取 \(\max\)。
- 易错点:如果使用可持久化线段树合并,且在所有子树合并完之后再加入当前点信息,则该步修改也要可持久化。
- 检查线段树合并是否适用,只需考察能否快速合并两个叶子以及快速
push_up
,而不需要快速合并两个区间的信息。这是笔者在初学线段树合并时常犯的错误,即因无法快速合并两个有交区间的信息而认为无法线段树合并。注意这不同于push_up
,因为push_up
合并的两个区间无交。 - 当线段树合并涉及区间修改时,情况就变得麻烦了。因为 线段树合并(叶子合并)的方式和信息与标记合并的方式不一定相同,例如区间加法,区间求和,但线段树合并时对应叶子取 \(\max\)。
对于标记,常见的处理套路有:
- 标记永久化
- 称一个结点是空心的,当且仅当它的子树内只有它自己,即该结点是标记下传得到的结点,也即该结点维护的所有位置受到相同标记的作用。支持合并空心结点和普通结点,以及合并两个空心结点。这样空间常数较大,但时间、空间复杂度仍然正确。
模型与套路:
- 关于深度的信息(子树距离,\(k\) 级儿子):线段树下标 \(i\) 存 \(dep_x=i\) 的点的数量。例如:天天爱跑步。
- 代替
set
启发式合并,配合并查集实时维护连通块所有节点。 - 树上整体 DP,一类经典问题是具有祖先后代关系的路径覆盖,我们只关心上端点最浅深度从而设计状态。
分析线段树合并的复杂度
根据代码,一次线段树合并的复杂度为 重叠点数的复杂度,因此其复杂度 不会超过较小一棵线段树的大小。
可以分析出,合并的复杂度计算上是有结合律的。因此相当于不断合并一棵小子树上去。
每个小子树的大小都是 \(O(\log n)\) 的,因此总复杂度 \(O(n\log n)\)。
另一种理解方式是:重叠点数相当于删去的点数,初始总共有 \(O(n\log n)\) ,结束时只有 \(O(n)\)。
例题1:[NOIP2016 提高组] 天天爱跑步
给定一棵树,有 \(m\) 个人会同时从 \(u\) 沿最短路径跑到 \(v\)。对于每个点有一个参数 \(t_i\),求出有多少人会在 \(t_i\) 时刻跑到 \(i\) 号节点。
按照线段树合并的一般套路,我们可以用下标 \(i\) 存 \(dep_x=i\) 的起点/终点个数,在 \(lca(u,v)\) 处把这个点删掉就行吧。
属于是智商不够,数据结构来凑了。
注意这里的线段树是 单点修改,单点查询,实际上是相当于少一个 \(\log\) 的 set
启发式合并。
例题2-线段树合并优化整体dp:[PKUWC2018] Minimax
给定一棵二叉树,叶子节点有权值,权值两两不同。定义一个非叶子节点的权值为:
\(p_x\) 的概率为子节点权值最大值,\(1-p_x\) 的概率为子节点权值最小值。
求根节点所有可能的权值: \(\sum i \times v_i \times D_i^2\) ,其中 \(v_i\) 是第 \(i\) 小的权值大小, \(D_i\) 的其概率。
\(n\le10^5\) 。
显然可以离散化叶子的权值,而且二叉树的性质保证了每个叶子的权值都有可能被取到。
设 \(f[x][i]\) 表示 节点 \(x\) 取到 \(i\) 的概率,不难可以列出转移方程:
其中 \(l,r\) 分别表示左右儿子。如果没有则忽略。
至此我们已经有了 \(O(n^3)\) 的多项式做法,直观想法是看看能否用数据结构优化。
四个 \(\sum\) 均是前缀后缀和的形式,很符合数据结构特色,我们尝试把其扔到线段树上。
注意到一个关键信息,叶子权值两两不同,也就是说左儿子有的权值,右儿子一定没有。我们现在要处理的区间是 \([1,n]\) ,一定可以划分为若干段 \([l_i,r_i]\) 满足 \([l_i,r_i]\) 都还是左儿子特有或者右儿子特有,仔细一想发现这不就是 线段树合并 吗,每个叶子都是只有一条链的线段树,所以复杂度是正确的。
对于一个区间 \([l,r]\) ,不妨假设只有左儿子有,那么看起来处理这个式子非常麻烦。但是注意到 \(\sum f[r][j]\) 是对于所有的 \([l,r]\) 相同的,其他的也同理。不过这样再用线段树算 \(\sum\) 多一个 \(\log\) ,但这是经典套路,扔参数里面就可以省掉了。
Debug
线段树合并时,判断是否只有一个儿子有不能用 if(!p||!q)
呀 (哭笑)
未知来源补充例题
给定一棵二叉树和 \(p\) ,初始给定每个点 \((a_i,b_i)\) ,你可以执行以下两种操作:
- 选择 \((x,k)\) ,使 \(a_x=(a_x+k)\bmod p,b_x=(b_x+k)\bmod k\) ;
- 选择 \((x,k)\) ,使 \(y\in T(x),a_y=(a_y+k)\bmod p,b_y=(b_y+k)\bmod k\) ;
问至少多少次操作可以使每个点的 \(a_i\ge b_i\) 。\(n\le10^5,k\le10^9\) 。
看似非常不好做,但答案上界显然是 \(n\) ,因为进行一次操作一一定可以使得一个点满足条件。
最优化问题考虑树形 \(dp\) ,这里就需要用到树形 DP 的一定技巧:设计状态时可以设计子树外的信息,即:
\(dp[x][i]\) 表示 \(x\) 的祖先给 \(x\) 的贡献(操作二)总和是 \(i\),\(T(x)\) 每个点都满足条件的最小操作数。
如果这个点在算上祖先链上的贡献后依然不满足条件,那么必然需要在这一步做一次单点加操作,考虑这一步是否再额外进行一次子树加操作,如果没有:
如果这一步进行了一次子树加操作,
就是说因为 \(k\) 是我们自行控制的,所以如果这一步打算进行子树加,则祖先链的影响就无所谓了。
所以这个操作可以理解为每个 \(dp[x][i]\) 先做第一个式子,然后查询全局最小值 \(mn\) ,每个点在和 \(mn+1\) checkmin 一下。
对于 \([a_x+i<b_x+i\mod k]\) 这个式子,发现至多只有两个段是 \(1\) ,区间修改用线段树维护即可。
对于后面的求和式子,就是线段树合并的基本操作。同时仍要维护全局最小值。
然后是取 \(min\) 的操作,根据 segbeats
,考虑差分的和 \(\sum dp[x][i]-dp[x][i-1]\) ,这个和是 \(O(n)\) 的,因为线段树区间加对它的贡献是 \(O(1)\) 的,每次取min都会减少这个和,所以总共会进行 \(O(n)\) 次有效的取 min。实现的话,记录一个最大值,然后对 最大值和最小值相等的区间整体加即可。
因为 \(k\) 很大,而且要线段树合并。同时又有区间加法,懒标记不好做。我们用标记永久化即可,但并没有写过很多次。
被艾伦赵打败了我去
Summary
- 树上最优化即使非常复杂,也应该想到树形dp
- 如果状态不能仅限制在子树内,则可以尝试加入子树外状态。
- 分析操作特殊性质,对取 min 等操作应该会有证明复杂度的突破点。
- 高级算法指北——浅谈整体dp - 烟山嘉鸿 - 博客园 (cnblogs.com)
补充例题:P6773 [NOI2020] 命运 【整体dp】
给定一棵树,每一条边你都可以赋值为 \(0/1\) ,有 \(m\) 条限制 \((u,v)\) ,其中 \(u,v\) 为祖先关系。表示这条路径上至少有一个 \(1\) ,问满足所有限制的方案数。
如果 \(m\le 15\) ,可以想到利用并查集+容斥 解决。这或许是pyyz经典问题。
给出非比寻常的条件,则应想到观察题目性质,不难想到,对于一个 \(u\) ,\(v\) 是其祖先。如果 \((u,v_1)\) 满足限制,则 \(\forall v_i,dep[v_i]\ge dep[v_1]\) ,这样的 \((u,v_i)\) 都满足限制。
方案数问题,即使非常复杂,也应想到树形 dp。
\(dp[u][i]\) 表示以 \(u\) 为根的子树内,未满足的限制中上端点最深的深度是 \(i\) 。答案即为 \(dp[1][0]\) 。
前缀和优化,则有:
按照【Minimax】的套路,维护一个 区间和,区间乘法的线段树,用线段树合并的方式快速转移。
对于一个点 \(u\) ,设这个点为下端点,上端点最深的点深度为 \(dep\) ,则 \(dp[u][dep]=1\) 。显然这样做权值线段树时空复杂度均正确。
具体的,当前一个线段树区间 \([l,r]\) 有一方 \(v\) 为空,则对于这个区间所有 \(dp[u][i]\) ,\((g[v][dep[u]]+g[v][i])\) 的值是相等的,按照套路我们放在参数 \(g1\) 内(初始化为 \(g[v][dep[u]]\) ) ,每次进行一次区间乘法。
有一方 \(u\) 为空是类似的。
而如果是一个叶子 \([l,l]\) 双方都非空。则需要额外加上后面的 \(dp[v][i]\times g[u][i-1]\) ,发现我们需要额外维护 \(g2=g[u][i-1]\) ,在进行操作之后更新 \(g2\) 就能达到 \([i-1]\) 的效果。
有一些细节问题,就是 \(g2\) 加的时候应该是修改前的 \(dp[u][i]\) ,需要提前存一下。
对于区间操作的线段树合并问题,常见的处理方式是标记永久化,见上题。但是注意到,对于乘法操作,如果直接下传tag也无妨。因为空儿子本身就是 \(0\) ,乘以 任何数 仍然是 \(0\) 。
思考
线段树合并时,什么时候用 push_down()
和 push_up()
,什么时候判定为 if(!p||!q)
,什么时候判定为 if(!t[p].val)
。
Code
int merge(int u,int v,int l,int r,int &g1,int &g2) {
//g1: g[v][sum]+\sum dp[v][i] ,g2: g[u][i-1]
if(!u&&!v) return 0;
if(!u) {
g1=(g1+t[v].val)%mod;
down(v,g2);
return v;
}
if(!v) {
g2=(g2+t[u].val)%mod;
down(u,g1);
return u;
}
if(l==r) {
int tt=t[u].val;
g1=(g1+t[v].val)%mod;
down(u,g1);
t[u].val=(t[u].val+t[v].val*g2%mod)%mod;
g2=(g2+tt)%mod;
return u;
}
push_down(u);push_down(v);
t[u].ls=merge(t[u].ls,t[v].ls,l,mid,g1,g2);
t[u].rs=merge(t[u].rs,t[v].rs,mid+1,r,g1,g2);
push_up(u);push_up(v);
return u;
}
vector<int>lim[N],s[N];
int n,m;
void dfs(int x,int fa) {
dep[x]=dep[fa]+1;
int maxdep=0;
for(int z:lim[x]) maxdep=max(maxdep,dep[z]);
update(rt[x],0,n,maxdep,1);
for(int y:s[x]) {
if(y==fa) continue;
dfs(y,x);
int g1=query(rt[y],0,n,0,dep[x]),g2=0;
rt[x]=merge(rt[x],rt[y],0,n,g1,g2);
}
}
7. 线段树分裂
同 fhq-treap,大体分为按排名分裂和权值分裂。
排名分裂
将前 \(k\) 小的数划分到 \(T1\),剩下的划分到 \(T2\)。我们只需管那些左右子树分别属于 \(T1,T2\) 的节点即可。而这些节点个数是 \(O(\log n)\) 的。
具体的,类似 fhq-treap 分裂:函数 \(\text{split(x,y,k)}\) 表示分裂 \(x\) 为根的线段树,另一棵为 \(y\),定义 \(v=val_{ls(x)}\)。
- 如果 \(v<k\),继续递归右子树,\(k-v\to k\)。
- 如果 \(v=k\),左边归 \(x\),右边归 \(y\)。
- 如果 \(v>k\),右边归 \(y\),左边递归 \(x\)。
void split(int x,int &y,int k) {
if(!x) return y=0,void();
y=++cnt;
int p=t[ls(x)].v;
if(k>p) split(rs(x),rs(y),k-p);
else swap(rs(x),rs(y));
if(k<p) split(ls(x),ls(y),k);
t[y].v=t[x].v-k;
t[x].v=k;
return ;
}
权值分裂
注意特判的 k=r
即为叶子。
void split(int x,int &y,int l,int r,int k) {
if(!x||k==r) return y=0,void();
y=++cnt;
if(k<=mid) swap(rs(x),rs(y)),split(ls(x),ls(y),l,mid,k);
else {
split(rs(x),rs(y),mid+1,r,k);
}
push_up(x),push_up(y);
}
例题1:P5494 【模板】线段树分裂
维护若干可重集合,支持:
- 将一个集合中 \([x,y]\) 移动到新集合里。
- 将一个集合中的所有数移动到另一集合里。
- 将一个集合中加入 \(k\) 个 \(x\)。
- 查询一个集合中 \([x,y]\) 元素个数。
- 查询一个集合中 kth。
例题2-区间排序:P2824 [HEOI2016/TJOI2016] 排序
支持区间升序/降序排序,最后输出 \(p\) 位置上的数。
首先这题有一代码极短的离线 \(O(m\log^2n)\) 做法,考虑如果序列是 \(01\) 串的话排序只需要线段树区间修改就行了,那我们仿照类似中位数的二分答案 trick:将 \(\ge a_i\) 赋为 \(1\),否则赋为 \(0\),发现放在这题依旧满足单调性:合法的必要条件为 \(a_p=1\),且此时可以尝试更大一些。
在线单log则是线段树分裂做法。
8. 线段树优化建图
例题1:CF798B Legacy
补充例题:P6348 [PA2011] Journeys
9. 线段树分治
对于一类题目,其同时含有两种操作:加入及删除。然而,加入容易维护,而删除不易维护。此时,我们可以将操作统一离线下来处理,并使用线段树分治。