线段树进阶 Part 1
线段树是信息学竞赛最常见的数据结构。本篇笔记总结技巧和应用,不介绍基本线段树算法。
1. 常见技巧
1.1 信息设计
用线段树解决问题,首先得考虑维护哪些信息。若不带修,任何 满足结合律且封闭 的信息(称为半群)都是可维护的。结合律一般都有,封闭性帮助我们设计信息。
例如区间最大子段和,显然要维护最大子段和。封闭性要求从
那么最大前后缀和怎么从子区间合并?考虑到前缀是左子区间的某个前缀,或者整个左子区间接上右子区间的前缀,所以还要维护整个区间的和
从上例感受设计信息的流程:从要求的答案开始,考虑答案如何从子区间的答案合并。为此,可能需要维护一些辅助信息。再考虑辅助信息如何由子区间的辅助信息合并。重复该过程直到信息封闭。这种略显机械性的方法比一下子想出所有要维护的信息更简单。
对于懒标记,同样需要满足结合律和封闭性。不用满足交换律,因为及时 push_down
保证每次打懒标记都是将当前懒标记对应的操作序列接在原懒标记对应的操作序列之后,即我们按时间顺序处理所有懒标记。
区间修改时,不仅标记和信息各自封闭,还要求标记和信息之间相互配合,使得原信息根据懒标记能够快速计算新的信息。因此,区间修改相较单点修改可能需要维护更多信息。
1.2 抽象线段树
当所维护信息太多时,用结构体会更有条理。此时一个结构体就是半群上的一个元素,我们只需考虑如何合并两个结构体。同样地,对于区间修改,可以用另一类结构体表示标记。
这其实抽象出了线段树的运作框架:用线段树解决区间修改的区间半群和,就是考虑时间相邻的标记如何合并,下标相邻的信息如何合并,以及标记如何作用在信息上,最终得到一棵 “抽象线段树”(第一次听到这个概念是在 APIO2022 的讲课上,感兴趣的读者可以翻翻 lxl 的课件),而标记和信息长成什么样和具体问题有关。从这个角度考虑线段树,可以让我们思考问题更有条理,尤其是当问题很复杂的时候,例如下一小节的历史和。
1.3 维护历史信息
线段树维护区间修改的区间历史信息涉及复杂的标记与信息的设计和合并。
1.3.1 历史最值
我们以经典老题 CPU 监控 为例,分析历史最值问题的一般思路。题目要求支持区间加区间赋值,查询区间当前最大值和历史最大值。
第一步:信息设计
显然维护当前最大值
这一步讨论了信息的合并。
第二步:标记设计
首先少不了区间加区间赋值的经典标记。如果被赋值过,那么维护赋的值
维护
我们要求一段操作之后的历史最大值。以第一次赋值为分界线,之前的操作都是加法,之后的操作都是赋值。这两种操作需要分开考虑,因为加的值和赋的值显然不能混为一谈。于是维护加法操作前缀和(加法累计,而赋值不累计)的历史最大值
这一步讨论了标记如何作用在信息上。
第三步:标记合并
不用动脑子的方法是分成两个具有先后关系的标记是否有
如果两个都没有
如果只有
如果只有
如果两个都有
动脑子的方法是通过合理设置空值避免分类讨论。这样虽然代码量少,但是容易出错。
这一步讨论了标记的合并。过程中可能会为了标记的封闭性要求维护更多标记。
把讨论好的东西套在线段树的框架上实现即可。
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
constexpr int N = 1e5 + 5;
constexpr ll inf = 1e18;
ll n, m, a[N];
struct tag {
ll ad, as, had, has;
tag operator + (const tag &z) const { // 标记合并
tag res;
if(as == -inf && z.as == -inf) {
res.ad = ad + z.ad;
res.as = -inf;
res.had = max(had, ad + z.had);
res.has = -inf;
}
if(as == -inf && z.as != -inf) {
res.ad = 0;
res.as = z.as;
res.had = max(had, ad + z.had);
res.has = z.has;
}
if(as != -inf && z.as == -inf) {
res.ad = 0;
res.as = as + z.ad;
res.had = had;
res.has = max(has, as + z.had);
}
if(as != -inf && z.as != -inf) {
res.ad = 0;
res.as = z.as;
res.had = had;
res.has = max(has, max(as + z.had, z.has));
}
return res;
}
} laz[N << 2];
struct dat {
ll his, mx;
dat operator + (const dat &z) const { // 信息合并
return {max(his, z.his), max(mx, z.mx)};
}
dat operator + (const tag &z) const { // 标记作用在信息上
return {max(his, max(mx + z.had, z.has)), z.as == -inf ? mx + z.ad : z.as};
}
} val[N << 2];
void build(int l, int r, int x) {
if(l == r) {
val[x] = {a[l], a[l]};
return;
}
int m = l + r >> 1;
build(l, m, x << 1);
build(m + 1, r, x << 1 | 1);
val[x] = val[x << 1] + val[x << 1 | 1];
laz[x] = {0, -inf, 0, -inf};
}
void adtag(int x, tag v) {
val[x] = val[x] + v;
laz[x] = laz[x] + v;
}
void down(int x) {
adtag(x << 1, laz[x]);
adtag(x << 1 | 1, laz[x]);
laz[x] = {0, -inf, 0, -inf}; // 注意清空标记
}
void modify(int l, int r, int ql, int qr, int x, tag v) {
if(ql <= l && r <= qr) return adtag(x, v);
int m = l + r >> 1;
down(x);
if(ql <= m) modify(l, m, ql, qr, x << 1, v);
if(m < qr) modify(m + 1, r, ql, qr, x << 1 | 1, v);
val[x] = val[x << 1] + val[x << 1 | 1];
}
dat query(int l, int r, int ql, int qr, int x) {
if(ql <= l && r <= qr) return val[x];
int m = l + r >> 1;
down(x);
dat res = {-inf, -inf};
if(ql <= m) res = res + query(l, m, ql, qr, x << 1);
if(m < qr) res = res + query(m + 1, r, ql, qr, x << 1 | 1);
return res;
}
int main() {
cin >> n;
for(int i = 1; i <= n; i++) cin >> a[i];
build(1, n, 1);
cin >> m;
for(int i = 1; i <= m; i++) {
char op;
cin >> op;
int x, y, z;
if(op == 'A') cin >> x >> y, cout << query(1, n, x, y, 1).his << "\n";
if(op == 'Q') cin >> x >> y, cout << query(1, n, x, y, 1).mx << "\n";
if(op == 'P') cin >> x >> y >> z, modify(1, n, x, y, 1, {z, -inf, max(0, z), -inf});
if(op == 'C') cin >> x >> y >> z, modify(1, n, x, y, 1, {0, z, 0, z});
}
return 0;
}
1.3.2 历史和
历史和问题和历史最值问题的求解思路类似,需要在标记里多维护一个求历史和轮数。
考虑区间加区间历史和。信息维护区间长度
设每次求历史和的时候加的值分别为
于是标记维护 每个位置 加的值的历史和
根据实际意义合并标记:
例题:P8868。
1.3.3 矩阵乘法与历史和
前置知识:矩阵乘法。
设区间长度
将信息写成行向量
求
矩阵乘法满足结合律,可以用线段树维护区间向量乘矩阵,区间向量和。模拟矩阵乘法可知任何标记的主对角线元素为
与上一小节
1.4 动态开点
当线段树数量太多(线段树合并)或下标值域太大(权值线段树)时,由于空间限制,无法使用左儿子两倍,右儿子两倍加一的编号方法。
解决方法是 动态开点:对于每个结点,维护其左右儿子的编号,向下递归时,若当前结点为空则新建结点。查询时走到空结点就返回。
一般通过 传递引用 或 返回结点编号 的方式快速更新子结点编号。
以下是一个单点修改,区间求和的动态开点线段树实现。相比普通线段树,动态开点线段树向下递归时不再是 x << 1 / x << 1 + 1
而是 ls[x] / rs[x]
。
void modify(int l, int r, int p, int &x, int v) {
if(!x) x = ++node; // 如果结点为空,就新建一个
if(l == r) return val[x] = v, void();
int m = l + r >> 1;
if(p <= m) modify(l, m, p, ls[x], v);
else modify(m + 1, r, p, rs[x], v);
val[x] = val[ls[x]] + val[rs[x]];
}
int query(int l, int r, int ql, int qr, int x) {
if(!x || ql <= l && r <= qr) return val[x]; // 走到空结点返回 val[0] = 0
int m = l + r >> 1, ans = 0;
if(ql <= m) ans += query(l, m, ql, qr, ls[x]);
if(m < qr) ans += query(m + 1, r, ql, qr, rs[x]);
return ans;
}
1.5 标记永久化
众所周知,线段树的区间修改需要打懒标记。但部分线段树下传懒标记的代价过大,或不支持下传懒标记。例如可持久化线段树和动态开点线段树,下传懒标记需要新建结点,空间常数大。又例如树套树,无法下传懒标记。此时有 标记永久化 的技巧:按着懒标记不下传,查询时,考虑从根到当前区间的路径上每个点的懒标记。
以区间取最大值,区间最大值为例,对每个区间维护两个信息,一是
注意:如果使用 push_up
的方式维护
我们探究什么样的 “信息-标记” 二元组可标记永久化。
在查询过程中,我们按照从根到当前区间的顺序合并懒标记信息,与这些标记被打上的时刻顺序不同。例如三次修改
在原线段树的基础上满足这个条件,结合 push_up
,就可以用标记永久化做区间修改,区间查询了。
- 区间赋值和修改顺序相关,可维护时间戳转化为求时间戳最值。
- 如果无法
push_up
,则需要更强的条件。见 4.2 小节线段树套线段树。
例题:SP11470,CF960H。
1.6 线段树二分
线段树的分治结构帮助我们将二分和查询的过程合并在一起做到
1.6.1 全局查询
考虑这样一个问题:单点修改,全局查询前缀和大于
考察二分过程,第一次查询
我们发现,在不断递归的过程中,每次向右走,都需要将左儿子的权值累计入前缀和
当前二分区间就是当前结点对应的区间,在线段树上从根往叶子走的过程就是不断缩小二分区间的过程。
1.6.2 区间查询
尝试将
考虑
先找拆分区间再二分太麻烦了,考虑将这两个步骤结合在一起,即先按
设查询区间为
- 若当前区间包含于查询区间(子树内二分),即
:- 若
加上当前区间的值不大于 ,说明答案不在当前区间内,也说明答案大于 。我们令 加上当前区间的值,然后返回 。 - 否则,若
,返回 表示答案。 - 否则,先递归左儿子
,若返回值不为 ,则返回答案。 - 否则,返回
的返回值。
- 若
- 否则(寻找拆分区间),若
和 有交,则查询 。若返回值不为 ,说明答案在左子区间的拆分区间内,返回答案。 - 否则,若
和 有交,则查询 。若返回值不为 ,说明答案在右子区间的拆分区间内,返回答案。 - 否则,答案不在当前区间内,返回
。
上述做法可以判断无解。
1.6.3 具体实现
在第三步中,没有必要判断返回值是否为
此外,第二、三步和第一步的第三、四小步的形式相同,没有必要写两遍。
经过简化,可以写出如下代码:
int binary(int l, int r, int ql, int qr, int x, int &cur, int lim) {
if(ql <= l && r <= qr) {
if(cur + val[x] <= lim) return cur += val[x], -1; // 目标位置不在当前区间,返回
if(l == r) return l; // 目标位置在当前区间且长度为 1,找到目标位置
// 否则继续二分
}
int m = l + r >> 1;
if(ql <= m) {
int res = binary(l, m, ql, qr, x << 1, cur, lim);
if(res != -1) return res;
}
if(m < qr) return binary(m + 1, r, ql, qr, x << 1 | 1, cur, lim);
return -1;
}
实际上,如果查询信息单调且和左端点无关(信息和查询的左端点无关,如全局前缀和),代码还可以简化为:
int binary(int l, int r, int ql, int qr, int x, int lim) {
if(val[x] <= lim) return -1; // 目标位置不在当前区间
if(l == r) return l;
int m = l + r >> 1;
if(ql <= m) {
int res = binary(l, m, ql, qr, x << 1, lim);
if(res != -1) return res;
}
if(m < qr) return binary(m + 1, r, ql, qr, x << 1 | 1, lim);
return -1;
}
例题:CF241B,CF407E,CF773E,CF765F,CF671E。
1.7 例题
SP11470 TTM - To the moon
可持久化线段树 + 标记永久化板子题。
时空复杂度
CF960H Santa's Gift
将贡献式展开,得到 push_down
时新开结点,或者使用标记永久化。
时空复杂度
CF241B Friends
异或粽子 的加强版,复杂度稍劣。
考虑二分求出第
建出所有
将二分和 01 Trie 结合起来可做到
异或和之和较难处理,考虑统计每一位的答案。维护子树内每一位为
时空复杂度
将
CF407E k-d-sequence
注意到区间合法当且仅当
将判断式变形为
从左往右扫描线
时间复杂度
CF773E Blog Post Rating
首先可以证明
根据
问题变成了求解拐点
线段树二分实现上述过程,时间复杂度
P8868 [NOIP2022] 比赛
设
扫描线,考虑
询问
问题转化为
- 对于懒标记,维护
分别表示 , ,求和轮数,每轮求和的 之和即 历史和, 历史和,以及 历史和。 - 对于信息,维护
分别表示区间长度,区间 ,区间 ,区间 和区间 历史和。
时间复杂度
*CF765F Souvenirs
首先规定
按
但这样复杂度依然无法承受。进一步考察性质,我们发现,如果
进一步离线
时间复杂度
*CF671E Organizing a Race
设
设
那么
给
考虑从左往右开,发现当遇到
设
求出
对于剩余操作,直接加在
但令人惊讶的是,我们可以检查一段区间
于是我们又可以二分了。每次二分需要查询
不妨认为
2. 可持久化线段树
前置知识:动态开点线段树。
可持久化线段树在 NOI 大纲里是 8 级算法,也称主席树。它用于描述平面上的可减信息,支持矩形查询,常见于强制在线的二维数点问题。
2.1 算法简介
对一棵线段树
注意到单次修改只会改变
以下是维护单点修改,区间求和的线段树的可持久化版本的修改部分。
void modify(int pre, int &x, int l, int r, int p, int v) {
val[x = ++node] = val[pre];
ls[x] = ls[pre], rs[x] = rs[pre]; // 继承原来的结点.
if(l == r) return val[x] = v, void();
int m = l + r >> 1;
if(p <= m) modify(ls[pre], ls[x], l, m, p, v);
else modify(rs[pre], rs[x], m + 1, r, p, v);
val[x] = val[ls[x]] + val[rs[x]];
}
若可持久化线段树涉及区间修改,下传懒标记时必须新建结点,空间常数非常大。若修改性质较好,可以选择标记永久化而不下传懒标记。
2.2 应用
可持久化线段树刻画了 只含
例如,平面上有若干点,每个点有权值,多次查询一个矩形范围内所有点的权值之和。对横坐标扫描线,用可持久化线段树维护每个纵坐标的点权关于横坐标的前缀和,即下标为
如果不强制在线,则可以将询问离线,拆成两个对应横坐标的询问,一次扫描线解决。当问题强制在线时,我们需要保存扫描到每个横坐标时线段树的形态,必须可持久化。一般地,对于二维数点问题,线段树和可持久化线段树之间差一个强制在线。
再例如区间第
此外,可持久化线段树还可以维护扫描线的过程中,每个时刻的线段树形态。这样的问题一般可以抽象成以下形式:有
2.3 扩展
可持久化线段树维护静态平面信息。动态维护平面需要树套树。
可持久化线段树扩展到树上:每个结点继承并修改其父亲的线段树,序列前缀和变成树上前缀和。
2.3.1 可持久化数组
我们知道,线段树可以维护序列。自然地,可持久化线段树可以维护 可持久化序列,相当于将单点修改单点查询的线段树可持久化。
特别地,如果支持离线,则可以对版本之间的依赖关系建树,最后 DFS,用数据结构维护当前版本的答案(EI)。
2.3.2 可持久化树状结构
容易将可持久化线段树的思想扩展到其它树形数据结构上,如 01-Trie,李超线段树,平衡树等。
可持久化 01-Trie 支持查询某个值和一段区间内所有数的异或最大值(也可以查询第
可持久化平衡树将在平衡树文章中专门介绍。
2.3.3 可持久化并查集
合并两个元素时,修改其中一个代表元指向的父结点,用可持久化数组维护。注意不能使用基于均摊的路径压缩,必须按秩合并,所以既要维护
可持久化并查集可以维护边权有大小限制(不能两端都有限制)时,所有合法的边形成的连通块形态。按边权从小到大的顺序将每条边加入并查集,每加入一条边就记录一个版本的
2.4 例题
P3834【模板】可持久化线段树 2
区间第
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 2e5 + 5;
constexpr int K = N << 5;
int n, m, a[N], b[N];
int node, R[N], ls[K], rs[K], val[K];
void modify(int pre, int &x, int l, int r, int p) {
val[x = ++node] = val[pre] + 1;
ls[x] = ls[pre], rs[x] = rs[pre];
if(l == r) return;
int m = l + r >> 1;
if(p <= m) modify(ls[pre], ls[x], l, m, p);
else modify(rs[pre], rs[x], m + 1, r, p);
}
int query(int l, int r, int x, int y, int k) {
if(l == r) return b[l];
int m = l + r >> 1, v = val[ls[y]] - val[ls[x]];
if(k <= v) return query(l, m, ls[x], ls[y], k);
return query(m + 1, r, rs[x], rs[y], k - v);
}
int main() {
cin >> n >> m;
for(int i = 1; i <= n; i++) cin >> a[i], b[i] = a[i];
sort(b + 1, b + n + 1);
for(int i = 1; i <= n; i++) a[i] = lower_bound(b + 1, b + n + 1, a[i]) - b;
for(int i = 1; i <= n; i++) modify(R[i - 1], R[i], 1, n, a[i]);
for(int i = 1; i <= m; i++) {
int l, r, k;
cin >> l >> r >> k;
cout << query(1, n, R[l - 1], R[r], k) << "\n";
}
return 0;
}
P4735 最大异或和
可持久化 01-Trie 模板题。注意一开始要加入一个
时间复杂度
P3402 可持久化并查集
启发式合并,用可持久化数组维护
时间复杂度
P4592 [TJOI2018] 异或
对于询问 1,在树上可持久化 01-Trie 上二分。对于询问 2,用 DFS 序拍平后相当于区间询问与给定值的最大异或和,序列可持久化 01-Trie 即可。
时间复杂度
P3168 [CQOI2015] 任务查询系统
对时间扫描线,维护每个时间的以优先级为下标的权值线段树,记录每个优先级有多少个任务,就是主席树板子了。注意查询时叶子结点产生贡献的任务数量要和
时间复杂度线性对数。代码。
P2633 Count on a tree
树上可持久化线段树板子题。
代码。
P4137 Rmq Problem / mex
设
主席树维护
时间复杂度
P3293 [SCOI2016] 美味
从高位往低位贪心。设已经选择的所有位的数字为
时间复杂度
CF840D Destiny
如果一个数出现了不小于
主席树维护区间第
时间复杂度
P2048 [NOI2010] 超级钢琴
做一遍前缀和。
用优先队列维护每个左端点对应权值最大的右端点产生的贡献。每次取出权值最大的左端点,然后计算该左端点对应权值次大的右端点产生的贡献,加入优先队列,以此类推。取出
静态区间第
时间复杂度
双倍经验:异或粽子。
P4559 [JSOI2018] 列队
考虑一次询问,设学生的位置分别为
因为
线段树二分找到
对于多组询问,套可持久化线段树即可。
时间复杂度
P4098 [HEOI2013] ALO
对于每个 非最大值 元素
时间复杂度
*P2839 [国家集训队] middle
要求中位数恰好等于某个值是困难的,但 要求中位数不小于某个值是容易的。检查中位数是否不小于
因此,我们二分答案,检查 “答案是否不小于
但是我们不能对每个二分值都建线段树。注意到每个数在二分值不小于它的线段树上是
时间复杂度
P7518 [省选联考 2021 A/B 卷] 宝石
宝石一定是能吃就吃,先吃一定不比后吃劣(调整法易证)。
设
对于
注意到对于每个
对于
倍增优化跳
时间复杂度线性对数平方。代码。
P6071 『MdOI R1』Treequery
若
任选结点为根,设
- 若
全部在子树内,则答案为 。 - 若
全部在子树外:- 若
不在 子树内,则答案为 。 - 否则,答案为
,其中 表示 的最近的子树内有 某点的祖先,则 一定有 中 关于时间戳的前驱或后继。
- 若
- 否则答案为
。
用主席树支持上述查询。
时间复杂度
CF464E The Classic Problem
值域过大,考虑用线段树维护从
给第
使用可持久化线段树存每个点的
时间复杂度
*CF453E Little Pony and Lord Tirek
区间覆盖考虑珂朵莉树(set 维护连续段),对于每个询问,找出其覆盖的所有时间连续段
二维数点,可持久化线段树即可。
时间复杂度
3. 线段树合并
前置知识:动态开点线段树。
线段树合并常见于树上问题,合并若干儿子子树的线段树得到当前点的线段树。它可以和多种算法相结合,有很大的应用空间,例如维护 SAM 的 endpos 集合,优化树形 DP。
相比而言,线段树分裂的用处不多。它可以和 set 维护连续段(ODT)的技巧一起使用,支持区间排序。
3.1 算法介绍
合并两棵 下标范围相同 的线段树,分别记为
因为一棵树与空结点合并的结果为它本身,所以若两棵树的当前区间
综上,得以下步骤:从
- 若
至少有一个为空,则返回另一个。 - 否则新建结点
,继续递归当前区间的左子区间和右子区间。令 的左儿子为 的左儿子和 的左儿子合并的结果,右儿子同理。合并 的左右儿子的信息,并返回 。 - 递归到叶子时直接合并。这是容易的,因为只涉及两个长度为
的区间的信息。但 注意:合并叶子与合并左右儿子可能是不同种类的合并操作。例如叶子相加,左右儿子取 。
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;
}
复杂度分析:每次合并的复杂度为两棵线段树 重合 的结点个数,也就是 删去 的结点个数。因此,线段树合并的总复杂度为所有线段树的结点个数之和。常见情况是初始有
笔者总结的注意点与技巧:
- 若线段树合并不新建结点,则整个过程会破坏原有线段树的结构。如果我们将
的信息合并到 上,则对于 所有 包含结点 的线段树,其存储的信息均会改变。但我们希望只更新 当前 线段树在结点 对应的下标区间的信息。这和 可持久化 数据结构新建结点的原因相同,也称可持久化线段树合并。打个比方,借了同学的笔记,就不应在上面乱涂乱画,将其他同学的笔记抄在上面,而是拿一本新笔记本,将这个同学和其他同学的笔记抄在上面,除非同学已经用不上他的笔记了(下一条)。 - 如果被合并的线段树
的信息在合并后不会用到(询问离线后及时查询),那么可不新建结点而直接将 的信息合并到 上,即在上述步骤中用 代替 ,从而有效减少空间开销。具体写法见 P3224。 merge
时尽量下传 ,因为需要判断是否递归到叶子,否则叶子将从两个空结点合并信息。若初始所有叶子至多在一棵线段树上出现,就可以不下传,因为递归到叶子时 至少一个为空,直接返回了。当区间信息可快速合并且时也不需要下传,因为不从子结点合并东西上来,自然不用担心叶子合并两个空结点。一个满足前者的例子是线段树合并维护 SAM 的 endpos 集合。- 易错点:如果使用可持久化线段树合并,且在所有子树合并完之后再加入当前点信息,则该步修改也要可持久化。
- 检查线段树合并是否适用,只需考察能否快速合并两个叶子以及快速
push_up
,而不需要快速合并两个区间的信息。这是笔者在初学线段树合并时常犯的错误,即因无法快速合并两个有交区间的信息而认为无法线段树合并。注意这不同于push_up
,因为push_up
合并的两个区间无交。 - 线段树合并的方式适用于 01-Trie 等其它本质上是动态开点线段树的结构的合并。
当线段树合并涉及区间修改时,情况就变得麻烦了。因为 线段树合并(叶子合并)的方式和信息与标记合并的方式不一定相同,所以需要具体问题具体分析,没有一般化的套路。例如区间加法,区间求和,但线段树合并时对应叶子取
此外,为了避免标记破坏线段树合并复杂度分析的条件,我们不能无条件地下传标记,否则将一直合并到叶子。解决方法有:
- 标记永久化。
- 称一个结点是空心的,当且仅当它的子树内只有它自己,即该结点是标记下传得到的结点,也即该结点维护的所有位置受到相同标记的作用。支持合并空心结点和普通结点,以及合并两个空心结点。这样空间常数较大,但时间、空间复杂度仍然正确。
3.2 应用
线段树合并描述了 从若干子结构合并成大结构的合并过程中所有出现过的结构的完整信息,所以它常用于实时维护连通块信息,或求出树上每个结点的子树信息。一个结点的子树信息等于该结点的信息合并其所有儿子的子树信息。
- 代替复杂度更高的
set
启发式合并。例如配合并查集实时维护连通块内所有结点。 - 求出后缀自动机的每个结点的 endpos 集合,因为一个结点的 endpos 集合等于它在 link 树上的子树内所有叶子的 endpos 集合的并。
- 求解深度相关的树上问题。如多次查询
级儿子的信息,用线段树合并预处理出每个结点所有后代以深度作为下标的信息。这个例子也可以看成树上整体 DP。 - 其它树状数据结构也可以类似线段树一样合并,如 01-Trie。
此外,线段树合并还可以维护 树上整体 DP,即子结点向父结点转移的二维 DP,但大部分转移形如子结点对应位置进行运算,只有很少的特殊转移。一类经典问题是树上具有祖先后代关系的路径覆盖,对于下端在结点
3.3 线段树分裂
和 FHQ Treap 分裂一样,线段树分裂有按值分裂和按排名分裂两种方法。
按排名分裂的流程如下:设当前区间为
- 若
,则将 的右子树给 ,并向左子树分裂。 - 若
,则将 的右子树给 后返回。这一步可以和上一步合并,单独拎出来判断可减小时间和空间常数。 - 若
,则向右子树分裂,并令 减去 。
和线段树合并一样,线段树分裂需要特殊考虑叶子。使用一些技巧避免麻烦的判断。先看代码。
void split(int x, int &y, int k) {
if(!x) return y = 0, void();
y = ++node;
if(k < val[ls[x]]) swap(rs[x], rs[y]), split(ls[x], ls[y], k);
else if(k == val[ls[x]]) swap(rs[x], rs[y]);
else split(rs[x], rs[y], k - val[ls[x]]);
val[y] = val[x] - k, val[x] = k;
}
核心在第七行,我们不从子结点合并信息上来,而是直接通过原来的信息计算新的信息。当叶子出现
对于按值分裂,与上述过程类似。设需要保留
- 若
,则将 的右子树给 ,并向左子树分裂。 - 若
,则向右子树分裂。
特判若
void split(int l, int r, int x, int &y, int k) {
if(!x || k == r) return y = 0, void();
y = ++node;
int m = l + r >> 1;
if(k <= m) swap(rs[x], rs[y]), split(l, m, ls[x], ls[y], k);
else split(m + 1, r, rs[x], rs[y], k);
val[x] = val[ls[x]] + val[rs[x]];
val[y] = val[ls[y]] + val[rs[y]];
}
一次分裂新建
3.4 例题
P3224 [HNOI2012] 永无乡
用线段树维护并查集每个连通块内部所有结点的信息。
时空复杂度线性对数。代码。
CF600E Lomsat gelral
以颜色编号为下标建线段树,维护区间出现次数最多的颜色编号和,线段树合并即可。代码。
其它板子题:Blood Cousins(查询
复杂度都是线性对数。
P4556 [Vani 有约会] 雨天的尾巴 /【模板】线段树合并
将链修改转化为树上差分,则一个房子的救济粮信息由差分后其子树所有信息之和得到,考虑线段树合并。注意每合并得到一个结点的真实信息就查询其答案,不然需要可持久化,空间开销过大。
时空复杂度线性对数。代码。
P5494 【模板】线段树分裂
对于操作 0,将
对于操作 1,将
剩下都是权值线段树的基本操作。
时空复杂度线性对数。代码。
P2824 [HEOI2016/TJOI2016] 排序
使用 set 维护极长有序段,排序时对端点所在有序段进行分裂,再将所有覆盖到的有序段合并成一大段。通过线段树分裂与合并实现。
注意区间升序或降序会影响分裂时的细节,需要讨论。
时空复杂度
双倍经验:A Simple Task。
P3899 [湖南集训] 更为厉害
因为
若
若
树上线段树合并维护每个结点
注意合并时可持久化,或者离线回答询问。
时空复杂度线性对数。代码。
P6623 [省选联考 2020 A 卷] 树
将 01 Trie 倒过来建实现 01 Trie 全局加 1。套 01 Trie 合并即可。
时间复杂度
*P3521 [POI2011] ROT-Tree Rotations
对于每个结点,是否交换左右子树不影响它的祖先,可贪心确定。
用权值线段树维护子树内所有叶子权值的桶。合并时求出
在
本题不需要可持久化。因为每个结点的线段树合并完毕时,所需信息也已经统计完毕。
当前结点的答案等于左儿子和右儿子的答案相加,加上顺序对和逆序对数量的较小值。最终答案即根结点答案。
时空复杂度
P8907 [USACO22DEC] Making Friends P
答案等于总朋友关系数减去
因为奶牛离开的顺序按编号从小到大,所以互相连边等价于编号最小的朋友向其它朋友连边。考虑到
时间复杂度
CF490F Treeland Tour
整体 DP 经典题。
先离散化。
随便选个点定根,考虑在每条路径的 LCA 处统计答案。为此,设
对于任意两个儿子
- 若经过
,则用 更新答案。 - 若不经过
,则对每个 ,用 更新答案。等价于对每个 ,用 更新答案。线段树合并时维护 前缀和即可。
每次合并一个儿子之前,算出它和已经合并的所有儿子之间的贡献,即可考虑到所有儿子之间的贡献。
时间复杂度
[模拟赛] 博弈问题
给定一棵以
为根的树,点有点权 。对每个结点 求 。
, 。2s,512MB。
异或最小值考虑 01 Trie。
尝试对 01 Trie 上的每个区间维护答案式。注意到对于当前区间,若左右子树大小均大于
一次 push_up
的时间复杂度为
P4577 [FJOI2018] 领导集团问题
设
合并完儿子后,还要令
考虑对上述转移方程使用整体 DP,但无法合并:
时间复杂度
CF1051G Distinctification
注意到一个数左移和右移贡献抵消,故可以将一段连续的
- 左移贡献:对每一段连续的
维护 , 和 ,则贡献为 。 - 右移贡献:用线段树维护
,其中 表示 在这一段 从大到小 的排名 。
用并查集维护每个连续段。新加入
- 若
处存在连续段,连通之。 - 强制连通
与所在连续段的右边界 ,因为右移过程最大位置可以达到 。 - 若
处存在连续段,连通之。
如果不判断
合并时也是先减去每个连续段的贡献,合并后再加上。注意
时空复杂度线性对数,代码。
CF671D Roads in Yusland
考虑树形 DP,设
具体的覆盖方案并不重要,只有路径顶部深度最小值有影响,因此再记录一维
考虑合并
若
设
- 若
在当前区间有值, 没有值,则给该区间加 。 - 若
在当前区间有值, 没有值,则给该区间加 。 - 若
和 在当前区间均有值,根据线段树合并的性质,只需考虑叶子(非叶子会继续递归合并),因此 更新为 。
合并结束后考虑所有以
时间复杂度
P5298 [PKUWC2018] Minimax
设
对于叶子,
对于只有子结点
对于同时拥有两个子结点
考虑直接枚举
线段树合并维护转移,每个区间维护对应
时间复杂度
*P6773 [NOI2020] 命运
假设
对于
因此,设
初始化
合并
- 若
不选,有 贡献至 ,即 。 - 若
选,则有 贡献至 。
线段树合并时记录
最后令
下推标记不需要新建结点,因为结点为空说明对应区间 DP 值为
时间复杂度
*P5327 [ZJOI2019] 语言
对每个城市,考虑可以与之贸易的城市的形态,发现为经过该城市的路径的并。因为所有路径均经过该城市,所以路径并等价于路径两端形成的虚树。
虚树大小(边数)为所有关键点按时间戳排序并排成环,相邻两结点距离之和的一半。
每个
时间复杂度
*P7963 [NOIP2021] 棋局
破烂码农卡常屑题。
普通道路和直行道路的可达范围很容易刻画,而互通道路形成若干连通块。加入棋子会让互通道路的连通性变得稀碎,很麻烦。考虑将整个过程倒过来,那就是删除棋子,然后连通它周围的互通道路。
考虑只有互通道路的情况。对两种颜色,以等级为下标用线段树维护每个连通块边界上对应颜色的所有棋子,以查询边界上某种颜色的等级不大于某个值的棋子数量。但这样不同棋子可能占用同一个下标,而同一棋子在线段树合并时也在同一个下标。为区分,将每个棋子的等级变成它在所有棋子中以等级为第一关键字,加入时间为第二关键字(同等级,后加入的棋子可以吃前加入的棋子)从小到大的排名。
删除棋子时,其占用的位置变成空地,四周若有空地则连通,若有棋子则加入边界。一开始处理好每个棋子的这些信息。
加入直行道路,对每个位置求出上下左右通过直行道路可达的边界。对每行每列用 set
维护所有棋子的位置,可求出各个方向上走直行道碰到的第一个棋子(或不存在)。
然后减掉直行道路和互通道路同时可达的位置,即查询某个棋子是否在某个互通道路连通块的边界上,以及查询某个
普通道路是平凡的。
设
卡常方向:将合并过程离线建树,在树上 DFS 并用 BIT 维护。不想写了,详见 djq_cpp 的代码。
*CF1344E Train Tracks
若结点
找出所有这样的时刻区间
问题转化为如何求得这样的
因此,可以直接 LCT 求出所有
总时间复杂度
4. 树套树
前置知识:动态开点线段树。
树套树是在树形数据结构的结点内部套一层树形数据结构。为方便说明,分别记为外层结构和内层结构。
树套树的常见形式有线段树套线段树,树状数组套线段树,以及线段树套平衡树。
4.1 树状数组套线段树
BIT 套动态开点线段树可解决带修二维数点等经典问题。注意当值域过大时需将外层结构换成动态开点线段树。
4.1.1 带修二维数点
修改点权,查询矩形点权和,强制在线。
设
对于单点修改,
对于矩形求和,视为在 BIT 上执行区间
一般地,二维平面可视为若干直线排成一排。将平面剖成
每次操作需查询或修改
- 查询时,对于直接相加的信息,例如满足条件的数的个数或权值之和,可以在遍历 BIT 时直接查询。但如果内层结构查询的形式为 线段树二分,如动态区间第
小,就需要把在 BIT 上遍历到的所有位置对应的内层结构的根结点编号 记录下来,递归时一并考虑,因为递归方向由这些结点上的信息共同确定。注意递归过程中需实时更新当前查询区间在所有遍历到的内层结构上的对应编号,即令所有 变为 或 。 - 若非强制在线,可使用 cdq 分治做到更优秀的复杂度。
4.1.2 动态逆序对
动态逆序对 本质上是三维偏序 / 带修点权的二维数点问题。
删除位置
从二维数点的角度出发,就是将
两种方法的时间复杂度均为
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 1e5 + 5;
constexpr int K = N << 9;
int n, m, node, a[N], rev[N], R[N], ls[K], rs[K], val[K];
long long ans;
void modify(int l, int r, int p, int &x, int v) {
if(!x) x = ++node;
val[x] += v;
if(l == r) return;
int m = l + r >> 1;
if(p <= m) modify(l, m, p, ls[x], v);
else modify(m + 1, r, p, rs[x], v);
}
int query(int l, int r, int ql, int qr, int x) {
if(ql <= l && r <= qr) return val[x];
int m = l + r >> 1, ans = 0;
if(ql <= m) ans = query(l, m, ql, qr, ls[x]);
if(m < qr) ans += query(m + 1, r, ql, qr, rs[x]);
return ans;
}
void add(int x, int y, int v) {while(x <= n) modify(1, n, y, R[x], v), x += x & -x;}
int query(int x, int yd, int yu) {int s = 0; while(x) s += query(1, n, yd, yu, R[x]), x -= x & -x; return s;}
int query(int xl, int xr, int yd, int yu) {return yd > yu ? 0 : query(xr, yd, yu) - query(xl - 1, yd, yu);}
int main() {
cin >> n >> m;
for(int i = 1; i <= n; i++) cin >> a[i], rev[a[i]] = i, add(i, a[i], 1);
for(int i = 2; i <= n; i++) ans += query(1, i - 1, a[i] + 1, n);
for(int i = 1, p; i <= m; i++) {
cin >> p, p = rev[p], cout << ans << "\n";
ans -= query(1, p - 1, a[p] + 1, n) + query(p + 1, n, 1, a[p] - 1);
add(p, a[p], -1);
}
return 0;
}
4.1.3 区间动态第 小
主席树带修是什么概念?区间第
回忆使用主席树求静态区间第
带修前缀和自然考虑树状数组。树状数组每个位置上存储的值变成线段树。因为 BIT 将信息前缀和摊到了它的
因此,设
设
时间、空间复杂度均为
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 1e5 + 5;
constexpr int K = N << 9;
int n, m, node, a[N], R[N], ls[K], rs[K], val[K];
void modify(int l, int r, int p, int &x, int v) {
if(!x) x = ++node;
val[x] += v;
if(l == r) return;
int m = l + r >> 1;
if(p <= m) modify(l, m, p, ls[x], v);
else modify(m + 1, r, p, rs[x], v);
}
vector<int> Add, Sub;
int query(int l, int r, int k) {
if(l == r) return l;
int m = l + r >> 1, v = 0;
for(int it : Add) v += val[ls[it]];
for(int it : Sub) v -= val[ls[it]];
for(int &it : Add) it = k <= v ? ls[it] : rs[it];
for(int &it : Sub) it = k <= v ? ls[it] : rs[it];
if(k <= v) return query(l, m, k);
return query(m + 1, r, k - v);
}
void add(int x, int y, int v) {
while(x <= n) modify(0, 1e9, y, R[x], v), x += x & -x;
}
int main() {
cin >> n >> m;
for(int i = 1; i <= n; i++) cin >> a[i], add(i, a[i], 1);
for(int i = 1, l, r, k; i <= m; i++) {
char op;
cin >> op >> l >> r;
if(op == 'C') add(l, a[l], -1), add(l, a[l] = r, 1);
else {
cin >> k, Add.clear(), Sub.clear();
int x = r;
while(x) Add.push_back(R[x]), x -= x & -x;
x = l - 1;
while(x) Sub.push_back(R[x]), x -= x & -x;
cout << query(0, 1e9, k) << "\n";
}
}
return 0;
}
4.2 线段树套线段树
线段树套线段树也称二维线段树,是信息不具有可减性时树状数组套线段树的替代品,如查询矩形内点权最值。使用方法是把外层结构从树状数组换成线段树。
二维线段树的外层线段树不支持 push_up
。因此,修改外层线段树的时候必须快速计算一个不一定包含当前区间的修改对当前区间的影响,即 不 push_up
,而是根据修改立刻计算新的信息,此时修改区间不一定完全包含当前区间。这对信息和修改的性质要求极高。
例如矩形
注:该算法出题的概率极小,所以接下来的内容不看或看不懂也没关系。
对于外层的区间修改,因为不能 push_down
,所以必须标记永久化。
对于矩形修改矩形查询,我们往外层 递归路径上所有区间 的信息
这要求先合并所有位置上的信息和所有位置上的标记,再合并信息和标记,等价于先合并每个位置的信息和对应的标记,再将所有位置的结果合并,因为实际结果是后者,而我们只能做到前者。例如,
例如矩形取
4.3 例题
P4390 [BOI2007] Mokia 摩基亚
二维数点板子题,使用树套树或 CDQ 分治解决。
时间复杂度
*P3437 [POI2006] TET-Tetris 3D
使用标记永久化的线段树套线段树维护矩形取最大值矩形求最大值,时空复杂度
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 1e3 + 5;
int n, D, S;
namespace ST {
int node, val[N << 11], laz[N << 11], ls[N << 11], rs[N << 11];
void modify(int l, int r, int ql, int qr, int &x, int v) {
if(!x) x = ++node;
laz[x] = max(laz[x], v);
if(ql <= l && r <= qr) return val[x] = max(val[x], v), void();
int m = l + r >> 1;
if(ql <= m) modify(l, m, ql, qr, ls[x], v);
if(m < qr) modify(m + 1, r, ql, qr, rs[x], v);
}
int query(int l, int r, int ql, int qr, int x) {
if(!x) return 0;
if(ql <= l && r <= qr) return max(laz[x], val[x]);
int m = l + r >> 1, ans = val[x];
if(ql <= m) ans = max(ans, query(l, m, ql, qr, ls[x]));
if(m < qr) ans = max(ans, query(m + 1, r, ql, qr, rs[x]));
return ans;
}
}
int laz[N << 2], val[N << 2];
void modify(int l, int r, int ql, int qr, int u, int d, int x, int v) {
ST::modify(1, S, u, d, laz[x], v);
if(ql <= l && r <= qr) return ST::modify(1, S, u, d, val[x], v), void();
int m = l + r >> 1;
if(ql <= m) modify(l, m, ql, qr, u, d, x << 1, v);
if(m < qr) modify(m + 1, r, ql, qr, u, d, x << 1 | 1, v);
}
int query(int l, int r, int ql, int qr, int u, int d, int x) {
int ans = ST::query(1, S, u, d, val[x]);
if(ql <= l && r <= qr) return max(ans, ST::query(1, S, u, d, laz[x]));
int m = l + r >> 1;
if(ql <= m) ans = max(ans, query(l, m, ql, qr, u, d, x << 1));
if(m < qr) ans = max(ans, query(m + 1, r, ql, qr, u, d, x << 1 | 1));
return ans;
}
int main() {
cin >> D >> S >> n;
for(int i = 1; i <= n; i++) {
int d, s, w, x, y; cin >> d >> s >> w >> x >> y;
int ht = query(1, D, x + 1, x + d, y + 1, y + s, 1);
modify(1, D, x + 1, x + d, y + 1, y + s, 1, ht + w);
}
cout << query(1, D, 1, D, 1, S, 1) << endl;
return 0;
}
P5445 [APIO2019] 路灯
考虑点亮路灯
对于一个点对,它的贡献为所有可行的时刻连续段
点对问题转化为平面问题,矩形加单点查询。
时空复杂度
P3688 [ZJOI2017] 树状数组
题目给出的代码维护的是序列后缀和,因此结果正确当且仅当
设
- 对于
,有 的概率翻转 。因此,将 变成 。 - 对于
,有 的概率翻转 或 之一。 - 对于
,有 的概率翻转 。
将
时空复杂度
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· .NET10 - 预览版1新功能体验(一)