1. 线段树与树状数组的进阶用法
数据结构1-Ducati
(2024.7.9)
1. 树状数组
设
-
每个点的支配区间要么包含,要么不交。
这说明其可以抽象为树形结构。
-
每个点的父节点为
特别的,建树可以优化为
二维树状数组
直接二层循环遍历即可,严格好写于树套树。
树状数组与倍增
对于【单点修改,查询全局第
考虑用倍增代替二分,因为树状数组实际上是在二进制上的操作,且 lowbit
和倍增非常契合。
具体的,我们维护
- 查询区间
的区间和 ; - 如果
,则拓展成功, ,否则继续枚举 。
这样得到的是
这样的好处是,增量
例题1-P6225 [eJOI2019] 异或橙子
给定序列
,支持单点修改,查询一段区间所有子区间异或和的异或和。
考虑当前区间为
为什么树状数组能维护异或和?满足可减性。
例题2 -【模板】线段树 1
重新推了一遍树状数组做法,但做麻烦了,没有将问题弱化完全:需要解决的问题实际上是查询
首先,树状数组直接处理区间加是几乎无法实现的,因此必须使用差分。只需解决区间和的问题,可以弱化为解决前缀和
即
注意最后别忘了再差分一步。
补充例题1-二维树状数组: P4514 上帝造题的七分钟
维护矩阵加,矩阵和。
根据二维前缀和,我们有二维差分:
先考虑求和:
同样的,将问题弱化为
依旧考虑每个
分别维护
对于修改,依旧拆成四个点分别处理。
总结:对于拓展到区间查询的情况,实际上仅需仿照【线段树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-树状数组倍增:冰火战士
有若干个冰火战士,每个人有三个属性值
,你需要给出一个最大的 ,设 ,使得 最大。 需要支持增加一个战士和删除一个战士的操作。每次操作完成后都输出
和 。 。
设
容易想到
用线段树常数太大,过不了
如何判断倍增
对于
太恶心了!因为两个都是非严格偏序关系,所以很多细节要处理。。调了两个小时/tuu。
好的方法是在:离散化之后,将火战士的位置向后移动一格,同时
补充例题2-优化dp: P3287 [SCOI2014] 方伯伯的玉米田
给定序列
,你有 次机会,将序列的一个区间加 ,求结束时的最长不下降子序列的长度最大是多少。 。
首先关键结论是:每次操作的右端点为
设
后面类似一个矩阵的前缀最大值,但是参差不齐。加入一个结尾为
第一维可以滚掉。洛谷题解写的都是什么玩意(急)。
2. 线段树
线段树三问
-
Q:对于一个操作含区间修改、区间查询的问题,什么情况可使用线段树?
-
A:
- 对于一次区间修改,若对节点打上标记,能快速给一个节点更新信息(down)
- 标记在 知晓前后顺序 的时候可以合并(push_down)
- 能够根据子节点的信息推出父节点的信息(push_up)
例如经典的区间加,区间最大子段和就难以维护 down。
-
Q:什么时候可以使用 标记永久化?
-
A:
-
有些时候是可以的,例如:【区间加,区间和】,对于一次查询
,当前节点 有一个标记 ,则增加 即可。 -
有些时候是不行的,例如:
- 【区间赋值,区间和】
- 【区间×矩阵,区间向量和】
由此看出,线段树能够标记永久化,需要满足的条件是懒标记的合并具有交换律。赋值和矩阵乘法都是没有交换律的。
-
-
Q:线段树的空间到底要开多大?
-
A:普遍说法是开
倍,这仅是保险做法,实际上:-
如果动态开点,则只需要开两倍。
-
如果按照普通编号方式,设
为大于 的最小的二的次幂,则 。例如
。
-
例题1-复杂信息合并:P3401 洛谷树
单点修改,求区间所有子区间 异或和的和,然后搬到树上去。
好题!之前不知道怎么维护异或和的和,学之后某一天的abcE用到了。
先考虑序列上怎么做,二进制想到按位考虑,确定某一位会被贡献多少次,即这一位在多少个子区间有
如何用线段树维护?考虑分治,父节点的答案为左儿子答案加右儿子答案,再加上跨过中点的所有子区间答案。
后者只有两种情况,即左儿子后缀异或和为
用类似最大子段和的方式维护起来即可。即再维护一个区间前缀/后缀异或和为
太糖了!你如果维护前缀异或和的话,就相当于任选两个点再异或。所以只需统计前缀异或和每一位
搬到树上也是好做的,序列上的
例题2- 前缀最值线段树:楼房重建
支持单点修改,区间上升子序列长度。(这里指的是能选必须选)
只需考虑合并 push_up
。
父节点的答案为左儿子的答案加上右儿子以左儿子最大值为起点的答案,考虑后者如何处理:
设左儿子最大值为
若
若
即为单侧递归线段树,push_up
复杂度
补充例题: [[CodeForces 671E]Organizing a Race](https://www.luogu.com.cn/problem/CF671E)
小粉兔论文的第二道题目。
例题3- 矩阵与线段树:[THUSCH2017] 大魔法师
维护一个序列,每个元素是三元组
,支持七种操作,取模:
- 区间
- 区间
- 区间
- 区间
- 区间
- 区间
- 求区间
。
像轮换这样普通线段树不是很好维护的东西,可以考虑矩阵乘法线段树,实际上直接打
对于有区间加常数的操作,我们需要矩阵额外记录一个常数单位元
此时六种修改操作的矩阵转移就简单明了了。
不过我们仍需要有一些矩阵卡常的素养:
- 不要
define int long long
; - 矩阵乘法最内层展开。
- 加法取模改为减法。
注意:常规矩阵乘法 push_down
之后懒标记的清空,需要先 clear()
全为 init()
使得对角线为
例题4-线段树与区间ddp:P7576 「PMOI-3」期望乘积
定义一个序列的价值为其乘积。
定义
可到达序列 ,当且仅当能够做恰好 次操作,每次选择 的一个子区间 ,使得 。 给你一个长度为
的序列 ,以及参数 , 次询问一个区间的所有可到达序列的价值和。
神仙题。
加法对乘法有分配率是此题的关键。题解没看懂、
3.历史和与历史最值
把之前写的回顾一下再搬过来了,还有两三道省集/sdsc的题。
例题1-历史最值:P4314 CPU 监控
区间加,赋值,查询区间最大值与历史最大值
整理一下线段树思路:
对于一个很长的标记队列来讲,我们可以将其化简为
标记的顺序?根据刚才所说,应该先做加法,再做赋值。
-
信息的合并?直接做即可
-
信息的下传:
历史最大值
; 。最大值
的顺序应该在 后。 -
标记的下传:对于一个
。为了方便,我们将加法和赋值分 开写,先进行加法:
-
如果
未进行赋值操作: 。注意顺序; -
否则,我们认为这是一个赋值操作:
。同样注意顺序。
然后是赋值,因为赋值的特殊性,我们用
记录这个点是否用赋值标记还未下放,即这个标记的“生存周期”:- 如果
未进行赋值操作: 。 - 否则:
。
这里分类讨论其实是没必要的,直接将
初始化为 可以避免。 -
核心代码,使用结构体封装,挺方便的。
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(区间最值操作、区间历史最值)
区间加,区间取
,区间和,区间最大值,区间历史最大值。
没有赋值操作便简单很多,这里主要讲区间取
区间取
问题是如何找到每一个
但是情况并非常常如此,我们可以递归进行,如果
对于查询历史最大值操作,我们则分别需要记录 区间最大值的加法标记的历史最大值
5. 可持久化线段树
例题1-动态开点:CF915E Physical Education Lessons
支持区间赋值为
,求区间和。区间长度 。
对于这种值域极大的,如果强制在线,则可以使用动态开点线段树维护。
注意计算空间!其中我的 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 ;
}
空间复杂度为
例题2- 静态区间mex
可持久化线段树上维护每个权值最后一次出现的位置,这是主席树上数颜色的套路,那么对于询问
例题3:CF464E The Classic Problem
给定一张
个点, 条边的无向图,每条边的边权为 ,求 s 到 t 的最短路,结果对 取模。
最短路题一般只能用最短路算法。
设比较复杂度为
显然题目希望我们用二进制表示,两个数比大小可以用hash+二分做到
但是还有一个加法操作,即 d[u]=d[v]+w
,发现
另外进制哈希满足结合律,可以直接放在主席树上。这样比较两个数大小也是在两颗主席树上完成的,同样是线段树二分,不过是自顶向下二分的。
另外,区间赋值需要懒标记,但是区间赋值为
细节非常之多:
-
修改的时候无论如何一定要新建一棵完整的树,即
void update(int &p,...) {p=++cnt;...}
-
外层
表示根的数量,一定要和内层 表示节点数量对上,别乱了。 -
用最短路
表示 根的编号 -
线段树上哈希,
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 实现过于拉跨,我习惯在出队的时候判断
,但这会导致重复进队多次。所以空间至少得再多开一倍。。
补充例题: P8860 动态图连通性
给定一张 有向图,多次询问一条边
,问如果删掉它 和 能否联通,如果能则删掉。询问间相互影响。 。
注意到一条边如果被删多次则是没有意义的,因为只有删边没有加边。所以只要一开始能被删去就会被删去,那么我们只需要保留第一次询问。
在线做是经典困难问题,我们肯定需要离线。不妨假设所有边都被询问了一遍,考虑最后剩下的 这条路径是什么。
发现一定是最后被询问的若干边,构成了一条从 1 到 n 的链。
这启发我们考虑跟删边顺序的关系,给每条边赋值为 初次访问的时间,从 1 开始走,走字典序最大的路径到 n ,即为答案。
字典序最大路径怎么做?结合经典问题,如果我们赋值为
例题4-树上主席树:P2633 Count on a tree
求树上链权值 kth,强制在线。
每个节点继承其父亲的信息,每次询问即是在
补充例题 :[SDOI2013] 森林
在P2633基础上加入 连接两棵树的操作
在上一题基础上考虑启发式合并,复杂度多一个
至于询问的时候要求 lca,用在线的倍增就行了。
例题5-子树深度主席树 CF893E:Subtree Minimum Query
求节点
子树内距离小于 的最小点权,强制在线。权值可能相同。
看到子树和强制在线可以想到线段树合并做,但还有一个绝妙的树上主席树做法。
具体的,我们见到和深度有关的树上问题,可以按 深度为时间轴,dfn为下标建立主席树。注意到这样会导致同一行的节点用相同的一棵树。
但是这样没有关系,我们查询只需要找到时间为 dep[x]+k
的主席树,在下标区间 (dfn[x],dfn[x]+siz[x]-1)
找区间最小值即可。
这样的正确性在于,深度小于x的节点不会处在这个下标区间里。
提交 *7,详细请见 那些调了一年的题,原因是共用同一版本乱修改会影响上一版本。
补充例题 [BZOJ4771] 七彩树
求节点
子树内距离小于 的节点颜色数,强制在线。
bzoj好题!
我甚至不知道子树数颜色的第三种求法(
如果不考虑深度限制的话,我们知道序列上区间数颜色,按照先序遍历后,即为 (dfn[x],dfn[x]+siz[x]-1)
区间颜色数。
更具体的,我们加入点
加入深度的限制,和例题一样,bfs按照深度建主席树。但因为 bfn和dfn完全不同,加入点
我们需要先还原 原本前驱后继的限制,如何找到其按照 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
和合并两棵树的叶子操作并不相同。例如叶子相加,左右儿子取
- 易错点:如果使用可持久化线段树合并,且在所有子树合并完之后再加入当前点信息,则该步修改也要可持久化。
- 检查线段树合并是否适用,只需考察能否快速合并两个叶子以及快速
push_up
,而不需要快速合并两个区间的信息。这是笔者在初学线段树合并时常犯的错误,即因无法快速合并两个有交区间的信息而认为无法线段树合并。注意这不同于push_up
,因为push_up
合并的两个区间无交。 - 当线段树合并涉及区间修改时,情况就变得麻烦了。因为 线段树合并(叶子合并)的方式和信息与标记合并的方式不一定相同,例如区间加法,区间求和,但线段树合并时对应叶子取
。
对于标记,常见的处理套路有:
- 标记永久化
- 称一个结点是空心的,当且仅当它的子树内只有它自己,即该结点是标记下传得到的结点,也即该结点维护的所有位置受到相同标记的作用。支持合并空心结点和普通结点,以及合并两个空心结点。这样空间常数较大,但时间、空间复杂度仍然正确。
模型与套路:
- 关于深度的信息(子树距离,
级儿子):线段树下标 存 的点的数量。例如:天天爱跑步。 - 代替
set
启发式合并,配合并查集实时维护连通块所有节点。 - 树上整体 DP,一类经典问题是具有祖先后代关系的路径覆盖,我们只关心上端点最浅深度从而设计状态。
分析线段树合并的复杂度
根据代码,一次线段树合并的复杂度为 重叠点数的复杂度,因此其复杂度 不会超过较小一棵线段树的大小。
可以分析出,合并的复杂度计算上是有结合律的。因此相当于不断合并一棵小子树上去。
每个小子树的大小都是
的,因此总复杂度 。 另一种理解方式是:重叠点数相当于删去的点数,初始总共有
,结束时只有 。
例题1:[NOIP2016 提高组] 天天爱跑步
给定一棵树,有
个人会同时从 沿最短路径跑到 。对于每个点有一个参数 ,求出有多少人会在 时刻跑到 号节点。
按照线段树合并的一般套路,我们可以用下标
属于是智商不够,数据结构来凑了。
注意这里的线段树是 单点修改,单点查询,实际上是相当于少一个 set
启发式合并。
例题2-线段树合并优化整体dp:[PKUWC2018] Minimax
给定一棵二叉树,叶子节点有权值,权值两两不同。定义一个非叶子节点的权值为:
的概率为子节点权值最大值, 的概率为子节点权值最小值。 求根节点所有可能的权值:
,其中 是第 小的权值大小, 的其概率。
。
显然可以离散化叶子的权值,而且二叉树的性质保证了每个叶子的权值都有可能被取到。
设
其中
至此我们已经有了
四个
注意到一个关键信息,叶子权值两两不同,也就是说左儿子有的权值,右儿子一定没有。我们现在要处理的区间是
对于一个区间
Debug
线段树合并时,判断是否只有一个儿子有不能用 if(!p||!q)
呀 (哭笑)
未知来源补充例题
给定一棵二叉树和
,初始给定每个点 ,你可以执行以下两种操作:
- 选择
,使 ; - 选择
,使 ; 问至少多少次操作可以使每个点的
。 。
看似非常不好做,但答案上界显然是
最优化问题考虑树形
如果这个点在算上祖先链上的贡献后依然不满足条件,那么必然需要在这一步做一次单点加操作,考虑这一步是否再额外进行一次子树加操作,如果没有:
如果这一步进行了一次子树加操作,
就是说因为
所以这个操作可以理解为每个
对于
对于后面的求和式子,就是线段树合并的基本操作。同时仍要维护全局最小值。
然后是取 segbeats
,考虑差分的和
因为
被艾伦赵打败了我去
Summary
- 树上最优化即使非常复杂,也应该想到树形dp
- 如果状态不能仅限制在子树内,则可以尝试加入子树外状态。
- 分析操作特殊性质,对取 min 等操作应该会有证明复杂度的突破点。
- 高级算法指北——浅谈整体dp - 烟山嘉鸿 - 博客园 (cnblogs.com)
补充例题:P6773 [NOI2020] 命运 【整体dp】
给定一棵树,每一条边你都可以赋值为
,有 条限制 ,其中 为祖先关系。表示这条路径上至少有一个 ,问满足所有限制的方案数。
如果
给出非比寻常的条件,则应想到观察题目性质,不难想到,对于一个
方案数问题,即使非常复杂,也应想到树形 dp。
前缀和优化,则有:
按照【Minimax】的套路,维护一个 区间和,区间乘法的线段树,用线段树合并的方式快速转移。
对于一个点
具体的,当前一个线段树区间
有一方
而如果是一个叶子
有一些细节问题,就是
对于区间操作的线段树合并问题,常见的处理方式是标记永久化,见上题。但是注意到,对于乘法操作,如果直接下传tag也无妨。因为空儿子本身就是
思考
线段树合并时,什么时候用 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,大体分为按排名分裂和权值分裂。
排名分裂
将前
具体的,类似 fhq-treap 分裂:函数
- 如果
,继续递归右子树, 。 - 如果
,左边归 ,右边归 。 - 如果
,右边归 ,左边递归 。
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 【模板】线段树分裂
维护若干可重集合,支持:
- 将一个集合中
移动到新集合里。 - 将一个集合中的所有数移动到另一集合里。
- 将一个集合中加入
个 。 - 查询一个集合中
元素个数。 - 查询一个集合中 kth。
例题2-区间排序:P2824 [HEOI2016/TJOI2016] 排序
支持区间升序/降序排序,最后输出
位置上的数。
首先这题有一代码极短的离线
在线单log则是线段树分裂做法。
8. 线段树优化建图
例题1:CF798B Legacy
补充例题:P6348 [PA2011] Journeys
9. 线段树分治
对于一类题目,其同时含有两种操作:加入及删除。然而,加入容易维护,而删除不易维护。此时,我们可以将操作统一离线下来处理,并使用线段树分治。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!