线段树进阶笔记
update:
- 2024.10.23 整理了 Segment Tree Beat 与 历史最值操作
线段树是一个比较基础的数据结构,但其变形与拓展的难度依旧极高,仍有许多 人类の智慧 藏匿其中。
0. 线段树的双半群模型
一些群论的东西,但当维护信息 极多 时较为有用,具体可见
(写的很粗糙,详细请找群论内容)
形象的,线段树上每个节点都有 数据 与 标记 两种信息,称作
- 则需要存在
的转移,即 数据合并。 - 以及
,即 标记转移。 - 以及
,即 标记合并。
同时还需要满足 结合律 与 分配律,这是一个 半群,再存在一个 单位元
则我们可以维护两个结构体,将三种转移加入即可。
这里举个例子。
区间加,区间乘,区间查询。则有
, (乘法与加法标记)。
则有。
以及。
以及。
注意 顺序。
当然,这些数据也可以用 矩阵 维护,如果你感兴趣(常数稍大)
区间历史操作,从矩阵乘法到标记 - EnofTaiPeople
参考文章
线段树维护分治信息略解 - 铃悬
数据结构闲谈:范围分治的「双半群」模型 - Meatherm
1. 可持久化线段树
1.1 算法介绍
首先可持久化线段树是 动态开点 的,本质上是通过记录 原来的信息 实现 时间域 上的查询,每次插入需要以上一个版本的节点为基础,新建节点进行更新,复杂度是
1.2 扩展应用
1.3 例题
我们考虑二分
2. 李超线段树
李超树比较好些,凸包的细节太多。
貌似ktt可以代替,但我没学。
2.1 算法介绍
李超树是解决 直线插入,查询某一 横坐标 使纵坐标最大的直线。
对于值域在
一般李超树都用的是标记永久化,所以我们查询的时候需要找到包含
对于插入一个 线段,我们可以先把线段的值域拆分,然后再加入线段即可,复杂度
struct line{
db k,b = -inf;
int id;
line(db k,db b,int id):k(k),b(b),id(id){}
line(){}
db val(int x){return k * x + b;}
};
bool cmp(line a,line b,int x){return (fabs(a.val(x) - b.val(x)) < eps) ? a.id < b.id : a.val(x) > b.val(x);}
struct LiChao_tree{
line mx[N<<5];
line ask(line a,line b,int x){return cmp(a,b,x) ? a : b;}
void modify(int p,int l,int r,line x){
if(l > r)return;
int mid = l + r >> 1;
if(cmp(x,mx[p],mid))swap(mx[p],x);
if(cmp(x,mx[p],l))modify(p<<1,l,mid,x);
if(cmp(x,mx[p],r))modify(p<<1|1,mid+1,r,x);
}
void modify(int p,int l,int r,int L,int R,line x){
if(L <= l && r <= R)return modify(p,l,r,x),void();
int mid = l + r >> 1;
if(L <= mid)modify(p<<1,l,mid,L,R,x);
if(R > mid)modify(p<<1|1,mid+1,r,L,R,x);
}
line query(int p,int l,int r,int x){
if(l == r)return mx[p];
int mid = l + r >> 1;
if(x <= mid)return ask(query(p<<1,l,mid,x),mx[p],x);
else return ask(query(p<<1|1,mid+1,r,x),mx[p],x);
}
}t;
int main(){
scanf("%d",&n);
for(int i = 1;i <= n;i++){
int op,a,b,c,d;
scanf("%d%d",&op,&a);a = (a+las-1)%m1+1;
if(op == 0)printf("%d\n",las = t.query(1,1,m1,a).id);
else{
scanf("%d%d%d",&b,&c,&d);
b = (b+las-1)%m2+1,c = (c+las-1)%m1+1,d = (d+las-1)%m2+1;
if(a > c)swap(a,c),swap(b,d);
double k = 0,B = 0;
if(a == c)B = max(b,d);
else k = (double)(d - b) / (c - a),B = (double)b - a * k;
t.modify(1,1,m1,a,c,{k,B,++m});
}
}
return 0;
}
给出一个包装比较好的实现,不是很难写。
2.2 扩展应用
2.2.1 优化 DP(代替斜率优化)
斜率优化本质就是维护一些直线,通过建立凸包来寻找最大值。
而 李超树 可以直接维护直线,复杂度
但是有些性质会导致不可使用 李炒熟,注意甄别。
见例 I。
2.2.2 动态开点
当横坐标值域过大时,可以用 动态开点,空间复杂度甚至是
感性证明一下:设当前直线
在值域 中,若当前值域没有直线,则不会向下递归,空间复杂度增加 ,否则该线段会向下递归知道找到没有直线的区间,复杂度也是增加 。
2.2.3 李超线段树合并
与普通线段树合并类似。
将
因为直线的总数是
若我们用 动态开点 + 回收内存 则空间复杂度依旧是
2.2.4 可持久化李超树
动态开点,每加一条直线代表一个版本即可。
2.3 例题
考虑 DP,则
II P9020 [USACO23JAN] Mana Collection P
首先我们考虑如何求每个点的贡献,可以发现只有最后一次经过某点的时间是有用的,我们可以考虑 最少失去的法力值,设其为
其中
然后对于答案,即为
复杂度
III COGS 3922. 删除题目
首先我们假设两条边
但是这显然没完,上述所求的只是在一条链 从子树到祖先 上的路径,然后我们考虑在某个点合并两条链,假设合并两点
知周所众,dsu on tree 是可以代替一些淀粉质的,所以我们考虑用 dsu on tree,考虑每个点
IV CF1175G Yet Another Partiton Problem
神题
显然有 DP
这种东西一般决策单调性,但是没有。
对于内部的
然后对于所有段,答案即为
每加入一个新的决策,则单调栈中可能会合并一些区间,则我们建立的凸包需要合并,需要 启发式合并 保证复杂度为
总复杂度
傻逼凸包细节真多,要你李超树有何用?。
难写的一批
2.4 参考文章
3. 线段树分治
更像是一种利用线段树结构的思想。
3.1 算法思想
主要思想就是 离线按照时间轴 划分,类似的有 CDQ分治。
但线段树分治可以完成一个独有的操作 —— 删除,一些不可以支持删除的数据结构中可以使用,如 李超树,并查集等。
我们将时间轴按照线段树的结构分层,形成了
则离线之后我们遍历整颗线段树(按照时间顺序),假设在当前节点,则将该点内的集合进行修改,在退出该点区间时再将 所有操作撤销,撤销是好办的,只需将原节点数据存到栈内即可,但是 不可维护复杂度均摊的数据结构(没有撤销除外)。
注意,支持删除改撤销是线段树分治的 功能,不是线段树分治必须撤销。
3.2 扩展应用
3.2.1 询问时间区间
当询问在一段 时间区间 内时,我们也可以按照时间分治,将询问区间拆分,遍历一遍找出最优答案,若修改是单点的,则可以不需要撤销,清空即可。
见例 II。
3.3 例题
板子,我们可以用 扩展域 并查集实现二分图的判断,因为路径压缩是均摊的,我们需要按秩合并即可。
复杂度
int n,m,k;
struct node{int fa,siz;};
struct DSU{
node s[N];
void build(){for(int i = 1;i <= n + n;i++)s[i] = {i,1};}
int find(int x){return x == s[x].fa ? x : find(s[x].fa);}
void merge(int x,int y,vpi &st){
x = find(x),y = find(y);
if(x == y)return;
if(s[x].siz < s[y].siz)swap(x,y);
st.pb({x,s[x]}),st.pb({y,s[y]});
s[y].fa = x,s[x].siz += s[y].siz;
}
void del(vpi d){//撤销
for(int i = (int)d.size()-1;i >= 0;i--){
pi now = d[i];
s[now.fi] = now.se;
}
}
}U;
struct made{int x,y;};
struct segment{
vector<made>s[N<<2];
bool vi[N<<2];
void insert(int p,int l,int r,int L,int R,made x){
if(L > R)return;
if(L <= l && r <= R)return s[p].pb(x),void();
int mid = l + r >> 1;
if(L <= mid)insert(p<<1,l,mid,L,R,x);
if(R > mid)insert(p<<1|1,mid+1,r,L,R,x);
}
void ask(int p,int l,int r){
vpi de;
if(!vi[p]){
for(made now : s[p]){
int u = now.x,v = now.y;
if(U.find(u) == U.find(v)){vi[p] = 1;break;}
U.merge(u,v + n,de),U.merge(u + n,v,de);
}
}
if(l == r)return vi[p] ? printf("No\n") : printf("Yes\n"),U.del(de),void();
vi[p<<1] = vi[p<<1|1] = vi[p];
int mid = l + r >> 1;
ask(p<<1,l,mid),ask(p<<1|1,mid+1,r),U.del(de);
}
}t;
int main(){
n = read(),m = read(),k = read();
U.build();
for(int i = 1;i <= m;i++){
int x = read(),y = read(),l = read(),r = read();
t.insert(1,1,k,l+1,r,{x,y});
}
t.ask(1,1,k);
return 0;
}
题意很考验语文功底,是shit,但是题目很好。
题意:修改使一个商店在当天暂时(明天就没了)有一个价值为
首先子问题可以 可持久化 Trie 做,对于询问,我们拆分时间,而且不用撤销,直接分治即可。
最后是一棵树,即求当前时刻断开
删除,我们可以想到用线段树分治,加上可撤销并查集,对于每条边,即存在的区间即为 除去询问 的所有区间,总区间个数是
一道好题。
这是一个树形结构,我们可以先拍成
我们考虑斜率优化,在看这个式子
3.4 参考文章
4. Segment Tree Beats
又称吉司机线段树,简称 STB。
当存在一些形如 使区间
吉司机线段数是势能线段树,复杂度约
4.1 算法简介
上述操作我们称其为 区间最值操作。
我们考虑简单的操作,有区间求和与区间最值操作,我们以区间取
可以发现该操作只会影响
,则该操作不会对该区间有任何影响。 ,的最大值会改变,且区间和会减去 ,记下懒标记即可。 ,我们无法简单的处理,向下递归即可。
可以证明,该算法的复杂度是
- 证明: 先咕掉。
4.2 拓展操作
4.2.1 区间加法
加上区间加法操作,我们有两种方法:
-
运用多种懒标记:
表示先加上 在与 取 ,即 。考虑合并两个懒标记
,注意顺序,可以看 。 -
一个核心思想:将区间最值问题转化为对值域的区间加减问题。
我们可以发现
中我们只进行的对最大值的修改,其实就相当于在该区间对最大值进行减 的操作,所以我们可以将每个区间的数分为 最大值 与其他数,即划分数域,这样我们只需要维护两个不同的区间加懒标记即可。
两种方法都有用途,建议都学。
论文中证明了带有区间加法操作的总复杂度为
4.2.2 与历史最值问题的结合
详见
4.3 例题
这是模板题,这里没用划分数域的方法。
struct STB{
#define ls p<<1
#define rs p<<1|1
int mx[M],se[M],cn[M],la[M];//最大 次大 最大值个数 懒标记
ll sum[M];
void pushup(int p){
sum[p] = sum[ls] + sum[rs];
if(mx[ls] > mx[rs])mx[p] = mx[ls],cn[p] = cn[ls],se[p] = max(mx[rs],se[ls]);
else if(mx[ls] < mx[rs])mx[p] = mx[rs],cn[p] = cn[rs],se[p] = max(mx[ls],se[rs]);
else mx[p] = mx[ls],cn[p] = cn[ls] + cn[rs],se[p] = max(se[ls],se[rs]);
}
void update(int p,int k){
if(k < mx[p]){
sum[p] -= (ll)(mx[p] - k) * cn[p];
mx[p] = la[p] = k;
}
}
void pushdown(int p){
if(la[p] != inf)update(ls,la[p]),update(rs,la[p]);
la[p] = inf;
}
void build(int p,int l,int r){
la[p] = inf;
if(l == r)return sum[p] = mx[p] = a[l],se[p] = -inf,cn[p] = 1,void();
int mid = l + r >> 1;
build(ls,l,mid),build(rs,mid+1,r);
pushup(p);
}
void modify(int p,int l,int r,int L,int R,int k){
if(k >= mx[p])return;
if(L <= l && r <= R && k < mx[p] && k > se[p])return update(p,k),void();
int mid = l + r >> 1;
pushdown(p);
if(L <= mid)modify(ls,l,mid,L,R,k);
if(R > mid)modify(rs,mid+1,r,L,R,k);
pushup(p);
}//区间最值操作
int cal1(int p,int l,int r,int L,int R){
if(L <= l && r <= R)return mx[p];
int mid = l + r >> 1;
pushdown(p);
if(R <= mid)return cal1(ls,l,mid,L,R);
else if(L > mid)return cal1(rs,mid+1,r,L,R);
else return max(cal1(ls,l,mid,L,R),cal1(rs,mid+1,r,L,R));
}
ll cal2(int p,int l,int r,int L,int R){
if(L <= l && r <= R)return sum[p];
int mid = l + r >> 1;
pushdown(p);
if(R <= mid)return cal2(ls,l,mid,L,R);
else if(L > mid)return cal2(rs,mid+1,r,L,R);
else return cal2(ls,l,mid,L,R) + cal2(rs,mid+1,r,L,R);
}
#undef ls
#undef rs
}t;
给出一个实现。
操作及其的多,我们若用一坨懒标记则会麻烦的不得了,需要用到划分数域的方法。
我们将区间中的数分为 最大值、最小值以及其他值,将区间最值操作改为值域区间加减操作,我们维护三个懒标记,每个节点要存 最大最小值,次大次小值,最大最小值次数,在区间内只存在很少数时会存在数域重叠的情况,会多算答案,需要分类讨论一下,码量很大。
比如最大值等于最小值,次大值等于最小值等。
III P10638 BZOJ4355 Play with sequence
对于操作一,我们可以视为
对于操作三,我们只需要找到
复杂度
IV P9631 [ICPC2020 Nanjing R] Just Another Game of Stones
好题,首先操作
对于操作
即
- 证明:很好证,大于
的位数是不变的,若 的 位为 ,则异或后一定为 ,最后一定大于 。
这样我们拆下位,记录区间异或和,以及区间内各二进制位数的数目。
复杂度
4.4 参考文章:
吉司机线段树(Segment Tree Beats!)复杂度分析 - Charles Wu
区间最值操作与历史最值操作 - 吉如一 2016集训队论文
5. 历史最值问题
对于一个数列,在操作的途中,我们钦定另一个数列
5.1 算法介绍
- 历史最大值:每次操作后,我们都令
,称 为 的历史最大值数组。 - 历史最小值:每次操作后,我们都令
,称 为 的历史最小值数组。 - 历史和:每次操作后,我们都令
,称 为 的历史和数组。
我们继续从简单的懒标记方法引入。
例题:P4314 CPU 监控
- 查询区间最大值。
- 查询区间历史最大值。
- 区间加,区间赋值。
我们把通常的标记与数据都分为两类,最大值与历史最大值
考虑标记合并,我们定于顺序为先加后覆盖,所以对于加法标记,若该区间内已经有覆盖标记,可视为将覆盖标记增加。
对于历史最大值,有
对于历史标记合并,有:
。 。 。
第三类转移与所定义的标记顺序相关。
总复杂度
总结:其实本质上就是暴力的将标记与数据拆分为普通标记与另类标记,在进行合并的时候会更加麻烦。
5.2 扩展应用
5.2.1 与区间最值问题的结合
对于一些问题,不仅有历史操作,还有最值操作。
我们定义标记
然后在拆分为普通标记与历史最大标记,普通标记合并有
对于历史最大标记,我们可以把该标记作为一个分段函数,合并两个标记即合并连个函数。
如图有:
所以对于标记合并有
比较难写,建议用结构体维护标记,以及半群转移。
复杂度
5.2.2 划分数域方法。
一些情况下,历史标记无法合并,这时就不能用普通的懒标记维护,例如区间最值与所查询答案相反。
例题:P6242 【模板】线段树 3(区间最值操作、区间历史最值)
该题操作存在区间取最小值,但是询问的是历史最大值。
此时我们无法合并历史最大标记,考虑数域划分,将区间最值转化为值域加减问题,我们需要先将值域分为最大值与其他值,再拆分为加法标记与历史最大加法标记,所以我们需要维护
标记合并与上述类似,不在赘述,只需要分类下即可。
复杂度
另一个模板:#169. 【UR #11】元旦老人与数列
5.2.3 无区间最值的历史和
这里只讨论无区间最值的历史和。
有的不会
5.2.3.1 历史最小值的和
即求
我们定义辅助数组
当我们将
我们分别维护
5.2.3.2 历史最小值的和
即求
我们定义辅助数组
5.2.3.3 历史和的和
即求
我们定义辅助数组
5.2.4 有区间最值的历史和
咕咕咕....
5.3 例题
5.4 参考文章
区间最值操作与历史最值操作 - 吉如一 2016集训队论文
1
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!