线段树的高级用法
关于本博客的最新修订,见 线段树进阶
CHANGE LOG
- 2022.1.27 重构李超树部分。其它部分的施工计划已加入 to do list。
- 2022.3.10 重构线段树合并。
- 2022.4.1 重构线段树分裂。
- 2022.4.2 重构树套树。
0. 常见技巧
0.1 动态开点线段树
当线段树数量过多或维护的下标值域过大时,我们就没有办法再使用左儿子两倍,右儿子两倍加一的编号方法了,因为空间不允许我们开
好办,对于每个节点,维护其左儿子和右儿子的编号,向下传递时若对应儿子为空就新建一个节点,可以通过传递引用值方便地实现。查询时如果走到空节点就直接返回。
下面是一个单点修改,区间求和的动态开点线段树实现。可以发现相比于普通线段树,动态开点线段树仅仅是加了一个引用,且向下递归儿子时不再是 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; // 如果节点是空的,那就新建一个
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 || !x) 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;
}
0.2 标记永久化
众所周知,线段树的区间修改需要打懒标记。但对于部分特殊的线段树而言,它不支持懒标记下传。例如可持久化线段树和动态开点线段树,如果下传懒标记,则需要新建节点,使空间常数过大。
此时可以考虑使用标记永久化的技巧。简单地说就是按着懒标记不下传,查询时考虑从根到目标区间的路径上每个节点的懒标记。
以区间取最大值,区间求最大值为例,对每个区间我们维护两个信息,一是
自然,我们希望知道满足哪些条件的信息可以标记永久化。
首先是信息在区间修改后必须快速更新。例如区间取最大值后,当前区间的最大值只需和修改值取
其次,修改必须 顺序无关。这是因为在查询过程中,我们按照从根到当前区间的顺序合并懒标记信息,与这些标记被打上的时刻顺序不同。举个例子,三次修改
一般地,只要正常下传懒标记能做且满足第二个条件,那么均可以标记永久化。
一个特例是区间赋值。区间赋值的修改顺序相关,但可以在懒标记中再维护时间戳转化为求时间戳最值。严格说是通过转化使得满足第二个条件。
1. 线段树合并与分裂
前置知识:动态开点线段树。
线段树合并算是一个基础算法,经常见于树上问题,因为 合并过程本身就形成了一棵树。由于它比较模板化,所以可以和多种算法相结合,有很大的应用空间,如维护 SAM 的 endpos 集合,优化树形 DP 等等。
相比而言,线段树分裂的用处并不多。它可以和 set 维护连续段(ODT)的技巧一并使用,支持区间排序操作。
1.1 线段树合并
考虑合并两棵线段树,分别记为
综上,我们得到以下算法:首先从
- 如果
至少有一个为空,则返回另一个。 - 否则 新开节点
,继续递归当前区间的左子区间和右子区间,令 的左儿子为 的左儿子和 的左儿子合并的结果,右儿子同理。然后合并 的左右儿子的信息,并返回 。 - 注意,当递归到叶子节点时需要直接合并。大部分情况下叶子节点的合并是容易的,因为这只涉及两个长度为
的区间的信息。但 合并叶子处的信息与合并左右儿子的信息 是两个不同种类的合并操作,需要分来开考虑。例如可以是叶子处相加,左右儿子取 。
int merge(int l, int r, int x, int y) {
if(!x || !y) return x | y;
int m = l + r >> 1, z = ++node; // 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;
}
时间复杂度分析:每次合并的复杂度为两棵线段树 重合 的节点个数,也就是 删去 的节点个数。因此,线段树合并的总复杂度为所有线段树的节点个数之和。若一开始有
- 若线段树合并不新建节点,则整个过程会破环原有线段树的结构。如果我们将
的信息合并到 上,则对于 所有 包含节点 的线段树,其存储的信息均会改变。但我们只希望更新 当前 线段树在节点 对应的下标区间处的信息。这和 可持久化 数据结构需要新开节点的原因相同,因此也可以称作 可持久化线段树合并。打个比方,借了同学的笔记,就不应该在上面乱涂乱画(将别的同学的笔记抄在上面)。 - 如果被合并的线段树
的信息在合并后不会用到(询问离线后及时查询),那么我们可以不新建节点而直接将每个节点 的信息合并到节点 上,即在上述步骤中用 代替 。可以有效减少空间开销(空间常数砍半)。具体写法见例 I. - 一般来说
merge
的时候最好带上 ,因为我们得知道什么时候递归到了叶子节点,从而合并叶子节点处的信息,否则叶子结点就会从两个空节点上传信息。若合并的过程中,所有叶子节点最多在一棵线段树中出现,此时就可以不下传 ,因为根据算法,当我们递归到叶子节点时, 至少有一个为空,直接返回了。当区间信息可以快速合并时也不需要下传,因为我们不从子节点中合并东西上来,自然不用担心叶节点合并两个空节点。一个满足前者的例子是线段树合并维护 SAM 的 endpos 集合,此时一个叶子节点最多在一棵线段树上有值。
1.2 应用
检查线段树合并是否适用,我们只需检查能否快速合并两个叶子节点,以及快速 pushup,而不需要支持 快速合并两个区间的信息(这是笔者在初学线段树合并时常犯的错误,即因为无法快速合并两个有交区间的信息而认为无法线段树合并)。注意这不同于 pushup,因为 pushup 合并的两个区间 无交。由于几乎所有线段树题目均满足这些条件,所以我们断言,只要能用线段树维护的信息,线段树合并就能做。
- 线段树合并(在大部分情况下)可以代替复杂度更高的启发式合并,例如并查集在合并时需要维护连通块内每个节点的信息。由于前者的大常数,两者效率其实并没有太大差别。
- 线段树合并可以优化 DP,见 DP 优化方法 II 整体 DP 部分(暂未施工)。
- 线段树合并可以求出 SAM 的每个节点的
集合。 - 线段树常用于求解深度有关的树上问题,如多次查询某个节点的
级儿子的信息,就可以线段树合并预处理出每个节点的所有儿子以深度作为下标的信息。长链剖分 + 数据结构也可以维护。
1.3 线段树分裂
和 FHQ Treap 分裂一样,线段树分裂有按值分裂和按排名分裂两种方法。按排名分裂的流程如下。
设当前区间为
令
- 若
,说明 的右子树要全部给 ,并向左子树分裂。 - 若
,则将 的右子树全部给 后返回(这一步和上一步合并起来也可以,不过单独拎出来判一下可以减小时间和空间常数)。 - 若
,则向右子树分裂,并令 减去 。
对于按值分裂,类似上述过程即可。设需要保留
- 若
,说明 的右子树要全部给 ,并向左子树分裂。 - 若
,则将 的右子树全部给 后返回。 - 若
,则向右子树分裂。
显然,一次分裂会新建
1.4 例题
线段树合并:I. ~ VIII.
线段树分裂:IX. ~ XI.
I. P4556 [Vani有约会]雨天的尾巴 /【模板】线段树合并
将链修改转化为树上差分,最后线段树合并。注意每合并完一个节点就直接查询该节点的答案,不然需要可持久化,空间开销过大。
#include <bits/stdc++.h>
using namespace std;
const int K = 17, N = 1e5 + 5;
vector <int> e[N];
int n, m, dn, dfn[N], ans[N], fa[N], dep[N], lg[N], mi[K][N];
void dfs1(int id, int f) {
dep[id] = dep[fa[id] = f] + 1, mi[0][dfn[id] = ++dn] = id;
for(int it : e[id]) if(it != f) dfs1(it, id);
}
int get(int x, int y) {return dep[x] < dep[y] ? x : y;}
int lca(int u, int v) {
if(u == v) return u;
if((u = dfn[u]) > (v = dfn[v])) swap(u, v);
int d = lg[v - (++u) + 1];
return fa[get(mi[d][u], mi[d][v - (1 << d) + 1])];
}
int node, R[N], val[N << 6], mx[N << 6], ls[N << 6], rs[N << 6];
void push(int x) {
val[x] = max(val[ls[x]], val[rs[x]]);
mx[x] = val[x] == val[ls[x]] ? mx[ls[x]] : mx[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, mx[x] = p, 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);
push(x);
}
int merge(int l, int r, int x, int y) {
if(!x || !y) return x | y;
if(l == r) return val[x] += val[y], mx[x] = l, x;
int m = l + r >> 1;
ls[x] = merge(l, m, ls[x], ls[y]), rs[x] = merge(m + 1, r, rs[x], rs[y]);
return push(x), x;
}
void dfs2(int id, int f) {
for(int it : e[id]) if(it != f) dfs2(it, id), R[id] = merge(1, N, R[id], R[it]);
ans[id] = val[R[id]] ? mx[R[id]] : 0;
}
int main() {
cin >> n >> m;
for(int i = 2; i <= n; i++) lg[i] = lg[i >> 1] + 1;
for(int i = 1, u, v; i < n; i++) cin >> u >> v, e[u].push_back(v), e[v].push_back(u);
dfs1(1, 0);
for(int i = 1; i <= lg[n]; i++) // forget to initialize mi
for(int j = 1; j + (1 << i) - 1 <= n; j++)
mi[i][j] = get(mi[i - 1][j], mi[i - 1][j + (1 << i - 1)]);
for(int i = 1; i <= m; i++) {
int u, v, w, d; cin >> u >> v >> w, d = lca(u, v);
modify(1, N, w, R[u], 1);
modify(1, N, w, R[v], 1);
modify(1, N, w, R[d], -1);
if(fa[d]) modify(1, N, w, R[fa[d]], -1);
}
dfs2(1, 0);
for(int i = 1; i <= n; i++) cout << ans[i] << "\n";
return 0;
}
*II. P3521 [POI2011]ROT-Tree Rotations
对于每一个节点,是否交换左右子树不影响它的祖先,于是我们可以直接贪心。
用权值线段树维护落在各个区间的数的个数。合并时考虑从大到小遍历每个区间,优先递归右子树(实际上先递归左子树也没问题,因为要求左儿子对右儿子的顺序对和逆序对,两者在计算贡献的形式上是对称的)。左儿子线段树上的区间记为
在
线段树合并时,若
最终答案只需将左儿子和右儿子的答案相加,并加上顺序对和逆序对数量的较小值即可。时空复杂度线性对数。代码。
III. P3224 [HNOI2012]永无乡
比较裸的线段树合并,用线段树维护并查集每个连通块内部所有元素是常见套路。
IV. P3899 [湖南集训]谈笑风生
这题啊,exciting!
因为
若
若
直接树上线段树合并维护每个节点
注意合并时要新建节点,因为每个节点的线段树形态均需保留(除非把询问离线下来回答)。否则会破坏线段树结构。
时空复杂度均为线性对数。
#include <bits/stdc++.h>
using namespace std;
const int N = 3e5 + 5, K = N * 40;
int n, q, node, dep[N], sz[N], R[N], ls[K], rs[K];
long long val[K];
vector <int> e[N];
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);
}
long long query(int l, int r, int ql, int qr, int x) {
if(ql > qr) return 0;
if(ql <= l && r <= qr) return val[x];
int m = l + r >> 1;
long long 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;
}
int merge(int x, int y) {
if(!x || !y) return x | y;
int z = ++node;
ls[z] = merge(ls[x], ls[y]), rs[z] = merge(rs[x], rs[y]);
return val[z] = val[x] + val[y], z;
}
void dfs(int id, int fa) {
sz[id] = 1, dep[id] = dep[fa] + 1;
for(int it : e[id]) if(it != fa) dfs(it, id), sz[id] += sz[it], R[id] = merge(R[id], R[it]);
modify(1, n, dep[id], R[id], sz[id] - 1);
}
int main() {
cin >> n >> q;
for(int i = 1, u, v; i < n; i++) scanf("%d %d", &u, &v), e[u].push_back(v), e[v].push_back(u);
dfs(1, 0);
while(q--) {
int p, k;
scanf("%d %d", &p, &k);
printf("%lld\n", 1ll * min(dep[p] - 1, k) * (sz[p] - 1) + query(1, n, dep[p] + 1, min(n, dep[p] + k), R[p]));
}
return 0;
}
V. CF600E Lomsat gelral
一道线段树合并简单题。代码。
VI. CF208E Blood Cousins
一道线段树合并简单题。代码。
VII. CF570D Tree Requests
一道线段树合并简单题。代码。
VIII. CF1051G Distinctification
注意到一个数左移和右移可以抵消,故可以将一段连续的
- 左移贡献:对每一段
维护 , 和 ,则贡献为 。 - 右移贡献:用线段树维护
,其中 表示 在这一段从大到小的排名 。
具体地,并查集维护每一段
- 若当前
大小 为 ,则若左 / 右存在连续段,连通之。 - 否则,将
和 强制连通(因为右移过程最大位置可以达到 ),同时若 存在连续段,连通之。
合并时也需要先减去每个连续段的贡献,线段树合并后再加上。代码。
IX. P5494 【模板】线段树分裂
对于操作 0,将
对于操作 1,将
剩下来都是权值线段树的基本操作。时空复杂度线性对数。
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 5;
long long val[N << 5];
int n, m, cnt, node, a[N], R[N], ls[N << 5], rs[N << 5];
void build(int l, int r, int &x) {
x = ++node;
if(l == r) return val[x] = a[l], void();
int m = l + r >> 1;
build(l, m, ls[x]), build(m + 1, r, rs[x]);
val[x] = val[ls[x]] + val[rs[x]];
}
int merge(int x, int y) {
if(!x || !y) return x | y;
ls[x] = merge(ls[x], ls[y]), rs[x] = merge(rs[x], rs[y]);
return val[x] += val[y], x;
}
void modify(int l, int r, int p, int v, int &x) {
if(!x) x = ++node;
val[x] += v;
if(l == r) return;
int m = l + r >> 1;
if(p <= m) modify(l, m, p, v, ls[x]);
else modify(m + 1, r, p, v, rs[x]);
}
int query(int l, int r, long long k, int x) {
if(l == r) return l;
int m = l + r >> 1;
if(k <= val[ls[x]]) return query(l, m, k, ls[x]);
return query(m + 1, r, k - val[ls[x]], rs[x]);
}
long long query(int l, int r, int ql, int qr, int x) {
if(ql <= l && r <= qr) return val[x];
int m = l + r >> 1;
long long 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 split(int l, int r, int x, int &y, int v) {
if(!x) return;
y = ++node;
int m = l + r >> 1;
if(v < m) swap(rs[x], rs[y]), split(l, m, ls[x], ls[y], v);
else if(v == m) swap(rs[x], rs[y]);
else split(m + 1, r, rs[x], rs[y], v);
val[x] = val[ls[x]] + val[rs[x]];
val[y] = val[ls[y]] + val[rs[y]];
}
int main() {
cin >> n >> m;
for(int i = 1; i <= n; i++) cin >> a[i];
build(1, n, R[cnt = 1]);
for(int i = 1; i <= m; i++) {
int op, a, c, tmp;
long long b;
cin >> op >> a >> b;
if(op == 0) {
cin >> c;
split(1, n, R[a], R[++cnt], b - 1);
split(1, n, R[cnt], tmp, c);
R[a] = merge(R[a], tmp);
}
if(op == 1) R[a] = merge(R[a], R[b]);
if(op == 2) cin >> c, modify(1, n, c, b, R[a]);
if(op == 3) cin >> c, cout << query(1, n, b, c, R[a]) << "\n";
if(op == 4) cout << (val[R[a]] < b ? -1 : query(1, n, b, R[a])) << "\n";
}
return 0;
}
X. P2824 [HEOI2016/TJOI2016]排序
使用 set 维护极长已知有序段,排序时需要对端点所在的有序段进行分裂,然后再将所有覆盖到的有序段合并成一个大段。通过线段树分裂与合并实现,注意区间升序或降序会影响分裂时的方法,需要讨论。时间复杂度线性对数。代码。
XI. CF558E A Simple Task
和上一题是双倍经验。
2. 树套树
前置知识:动态开点线段树。
树套树就是在树形数据结构的每个节点内部再套一层树形数据结构。为方便说明,下文分别记为外层 ds(data structure,数据结构) 和内层 ds。常见形式有线段树套线段树,树状数组套线段树,以及线段树套平衡树。
- 若内层 ds 为线段树则需动态开点。若每个内层线段树均开满,则空间复杂度平方级别,无法接受。
- 当外层值域不大时,可将线段树换成树状数组以减小常数。
2.1 树状数组套线段树
BIT 套线段树可以解决带修二维数点或动态第
下面给出一些 BIT 套线段树的应用。
2.1.1 带修二维数点
支持修改一个点的点权,查询矩形点权和。假设
对于单点修改操作,对
对于矩形查询操作,可以看成在 BIT 上执行区间查询,查询的内容是一段区间
因为每次操作均需
- 更详细的说明:在查询时,对于直接求和的信息,例如满足条件的数的个数,可以在遍历 BIT 时直接查询。但如果内层 ds 查询的形式为 线段树二分(如 3.1.3 动态区间第
小),就要把遍历到的所有位置对应的内层 ds 的根节点编号先记录下来,递归时一并考虑,因为递归方向(向左子树还是右子树)由这些位置上的信息同时确定。注意向下递归的过程中需实时更新当前所有位置对应的内层 ds 的编号。
2.1.2 动态逆序对
动态逆序对 本质上是三维偏序 / 带修点权的二维数点问题。
首先转化问题,删除位置
如果从二维数点的角度出发,那就是将
两种方法时间复杂度均为线性对数平方。前者需要离线(因为要知道每个点被删除的时间)但空间复杂度线性,后者可以强制在线但空间复杂度多了两个
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5, K = N << 8;
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;
}
2.1.3 动态第 小
主席树带修是什么概念?区间第
回顾使用主席树求解静态区间第
带修的(线段树)前缀和问题自然考虑树状数组。因为 BIT 将信息的前缀和摊到了它的
因此,设
显然,时空复杂度均为
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5, 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;
}
2.2 线段树套线段树
线段树套线段树俗称二维线段树,是当信息不具有可减性时 BIT 套线段树的替代品,例如查询矩形内部所有点的权值的最大值。
使用方法就是把外层 ds 从 BIT 换成线段树,本质没有太大区别,这里说一个注意点。
二维线段树的外层线段树不支持 push_up
。因此,对于外层单点修改,需要在从根到目标叶子区间的路径上的所有区间均插入内层修改。对于外层区间修改,必须且只能标记永久化,这意味着我们在更新路径上需要在所有区间的
2.3 例题
*I. P3437 [POI2006]TET-Tetris 3D
使用标记永久化的二维线段树维护矩形取最大值矩形求最大值,时空复杂度
#include <bits/stdc++.h>
using namespace std;
const 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;
}
3. 线段树分治
线段树分治更像一种技巧而非算法。
3.1 算法介绍
线段树分治是一种很有用的 离线 的按时间分治的技巧。它的分治体现在线段树的分治结构上。它适用于仅支持插入,不支持删除的数据结构(如果支持删除还要啥线段树分治啊),例如线性基,李超线段树,并查集。更广义地说,任何难以快速删除的操作(从若干次操作带来的影响当中撤销某一次操作,得到新的影响)均可以通过线段树分治将删除转化为撤销。
只要加入操作时修改的复杂度有保障,撤销就是简单的:用栈记录数组层面的修改操作,撤销时回退。
线段树分治的核心在于用线段树维护 时间轴,对时间轴进行分值。对于一个操作,我们在离线后可以知道它的影响时间段
时间复杂度为
- 注意点:递归到叶子节点回答完询问后 不要忘记撤销修改。否则叶子节点处的修改会一直保留并且对数据结构存储的信息有很大影响。
除了修改对询问的影响有时间范围(体现为删除某次修改),当询问对修改有时间范围要求(体现为查询某个时刻范围的修改)时,也可以使用线段树分治。
类似地,我们将询问的时间范围要求区间在线段树上分解成
时间复杂度仍然是
-
当 询问 而非修改有时间范围要求时,我们可以 在线地 回答询问。对线段树上每个时间区间均维护对应的所有修改信息(加入修改时直接在线加,而不是先把修改存起来,最后遍历),那么询问每个定位区间即可立刻得到答案。这样做的代价是 空间开销太大,相当于在原本空间复杂度上乘了一个线段树的
(平衡树好啊!平衡树线性空间!乘一个 也还能接受)。相比而言,先把所有修改保存起来,询问离线下来,递归到每个区间的时候再构建对应所有修改形成的数据结构形态,然后更新所有时间范围定位区间包含当前区间的询问的答案,可以去掉这个
,代价是 必须离线。对每个区间加入修改前需要清空数据结构,注意该部分的时间复杂度。
3.2 例题
I. P5787 二分图 /【模板】线段树分治
判断二分图可以用扩展域并查集,由于要支持撤销所以不能路径压缩,而应按秩合并(和可持久化并查集差不多的注意点)。时间复杂度
const int N = 1e5 + 5;
int n, m, k, chk[N << 2], fa[N << 1], sz[N << 1];
int find(int x) {return fa[x] == x ? x : find(fa[x]);}
vpii val[N << 2];
void modify(int l, int r, int ql, int qr, int x, pii v) {
if(ql > qr) return;
if(ql <= l && r <= qr) return val[x].pb(v), void();
int m = l + r >> 1;
if(ql <= m) modify(l, m, ql, qr, x << 1, v);
if(m < qr) modify(m + 1, r, ql, qr, x << 1 | 1, v);
}
void undo(vpii &x) {for(auto it : x) sz[it.fi] -= sz[it.se], fa[it.se] = it.se;}
void query(int l, int r, int x) {
vpii upd;
if(chk[x]) {
for(auto it : val[x]) {
int u = it.fi, v = it.se, uu = u + n, vv = v + n;
u = find(u), v = find(v), uu = find(uu), vv = find(vv);
if(u == v) {chk[x] = 0; break;}
if(sz[u] < sz[vv]) swap(u, vv);
if(sz[v] < sz[uu]) swap(v, uu);
upd.pb(u, vv), upd.pb(v, uu);
sz[u] += sz[vv], fa[vv] = u, sz[v] += sz[uu], fa[uu] = v;
}
}
if(l == r) return puts(chk[x] ? "Yes" : "No"), undo(upd), void(); // 这里不要忘记 undo
chk[x << 1] = chk[x << 1 | 1] = chk[x];
int m = l + r >> 1;
query(l, m, x << 1), query(m + 1, r, x << 1 | 1), undo(upd);
}
int main(){
cin >> n >> m >> k, chk[1] = 1;
for(int i = 1; i <= n << 1; i++) fa[i] = i, sz[i] = 1;
for(int i = 1; i <= m; i++) {
int x, y, l, r; read(x), read(y), read(l), read(r);
modify(1, k, l + 1, r, 1, {x, y});
} query(1, k, 1);
return flush(), 0;
}
*II. P4585 [FJOI2015]火星商店问题
一道还算有趣的题目。区间求异或一个数的最大值不难想到可持久化 Trie。把修改和询问都挂到线段树上面,然后对于每个区间的所有修改,建出其可持久化 trie 然后更新所有以该区间为定位区间的询问的答案即可。时间复杂度
const int N = 1e5 + 5;
const int inf = 1e9 + 7;
int n, m, a[N], ans[N];
struct Trie {
int node, R[N], son[N << 5][2], sz[N << 5];
void clear() {mem(son, 0, node + 5), mem(sz, 0, node + 5), node = 0;}
void modify(int p, int &x, int v, int bit = 16) {
cpy(son[x = ++node], son[p], 2), sz[x] = sz[p] + 1;
if(bit == -1) return;
int b = v >> bit & 1;
modify(son[p][b], son[x][b], v, bit - 1);
}
int query(int x, int y, int v, int bit = 16) {
if(bit == -1 || !(sz[y] - sz[x])) return 0;
int b = v >> bit & 1, c = b ^ 1;
if(sz[son[y][c]] - sz[son[x][c]]) return (1 << bit) +
query(son[x][c], son[y][c], v, bit - 1);
return query(son[x][b], son[y][b], v, bit - 1);
}
} seq, tr;
struct Query {
int l, r, v, id;
};
vector <Query> qu[N << 2];
vpii upd[N << 2];
void modify(int l, int r, int p, int x, pii v) {
upd[x].pb(v);
if(l == r) return;
int m = l + r >> 1;
if(p <= m) modify(l, m, p, x << 1, v);
else modify(m + 1, r, p, x << 1 | 1, v);
}
void query(int l, int r, int ql, int qr, int x, Query v) {
if(ql <= l && r <= qr) return qu[x].pb(v), void();
int m = l + r >> 1;
if(ql <= m) query(l, m, ql, qr, x << 1, v);
if(m < qr) query(m + 1, r, ql, qr, x << 1 | 1, v);
}
void calc(vpii u, vector <Query> q) {
tr.clear(), sor(u);
int pre = 0;
for(pii it : u)
tr.modify(tr.R[pre], tr.R[it.fi], it.se), pre = it.fi;
for(auto it : q) {
int l = lower_bound(all(u), (pii){it.l, 0}) - u.begin();
int r = upper_bound(all(u), (pii){it.r, inf}) - u.begin() - 1;
if(r < 0) continue;
l = l ? u[l - 1].fi : 0, r = u[r].fi;
cmax(ans[it.id], tr.query(tr.R[l], tr.R[r], it.v));
}
}
void solve(int l, int r, int x) {
calc(upd[x], qu[x]);
if(l == r) return;
int m = l + r >> 1;
solve(l, m, x << 1), solve(m + 1, r, x << 1 | 1);
}
int main() {
cin >> n >> m, mem(ans, -1, N);
for(int i = 1; i <= n; i++)
seq.modify(seq.R[i - 1], seq.R[i], a[i] = read());
for(int i = 1, day = 0; i <= m; i++) {
int op = read(); day += !op;
if(op == 0) {
int s = read(), v = read();
modify(0, m, day, 1, {s, v});
} else {
int l = read(), r = read(), x = read(), d = read();
ans[i] = seq.query(seq.R[l - 1], seq.R[r], x);
if(d) query(0, m, max(0, day - d + 1), day, 1, {l, r, x, i});
}
} solve(0, m, 1);
for(int i = 1; i <= m; i++) if(~ans[i]) cout << ans[i] << "\n";
return 0;
}
3.3 参考资料
- foreverlasting,线段树分治总结。
4. 李超线段树
李超线段树,简称李超树。它支持插入 直线 或 线段,并查询某个横坐标处的 最值。
4.1 算法简介
李超线段树可以 在线 解决下述问题:两种操作,添加直线
它的核心思想是维护 区间最优直线,及时排除不可能成为答案的直线。具体地,维护
上述过程中,我们利用了这样一条性质:对于区间
因此,考虑插入一条直线
查询
综上,我们在
注意 初始化,根据最值类型确定第
- 一般来说,实现李超树的方法为 动态开点。不同于普通动态开点线段树,动态开点李超树的时间复杂度和插入直线个数 线性 相关。这是因为一条直线最多占用一个区间,而每个区间都有自己的最优直线,因此新建节点个数等于直线条数。这是它的优点:省空间。
4.2 Extension
- 扩展 1:注意到普通李超线段树 全局 插入直线。对于插入 线段 的 局部 问题,我们先找到线段横坐标区间在李超树上的 拆分区间,再进行对于每个拆分区间的全局插入。时间复杂度线性对数平方,空间复杂度线性对数。
- 扩展 2:李超树 不支持 删除直线。因此我们只能 离线线段树分治。时间复杂度
。空间复杂度线性对数,瓶颈在于对每个区间存储时间范围定位区间包含该区间的所有修改,而非李超树。 - 扩展 3:当横坐标过大时,离线离散化或在线动态开点。因为李超树本质仍是线段树,所以支持动态开点。时间复杂度同普通李超树。空间复杂度 线性!
- 扩展 4:李超树支持可持久化。
- 扩展 5:李超线段树常与 斜率优化 同时使用。简单地说,斜率优化是维护若干个点
,多次求斜率为 时 轴上的截距最值,即 。当 与 都不单调时,无法使用单调队列维护。不妨将每个点看做直线 ,并求出 时的最值。这是李超线段树的拿手好戏。具体见例 II. & IV. 与 DP 优化 II 斜率优化部分例 XI. & XII.
4.3 李超树合并
李超树合并与线段树合并有相似之处。
当节点
注意到总共只有
使用空间回收(即时刻保证每个区间均有最优直线,每条直线最多在一个区间内。对于湮灭的区间,清空所有信息 如左右儿子和最优直线,并丢进垃圾桶)时,空间复杂度为线性。
若合并需要可持久化(不能破坏原有李超树的结构,类似线段树合并)或不使用空间回收,则空间复杂度最坏可达到线性对数。
4.4 例题
I. P4097 [HEOI2013]Segment
模板题。
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5, X = 39989, Y = 1e9;
int n, cnt, mx[X << 2];
double k[N], b[N];
double get(int x, int id) {return k[id] * x + b[id];}
int get(int a, int c, int x) {
double b = get(x, a), d = get(x, c);
return b == d ? min(a, c) : b > d ? a : c;
}
void modify(int l, int r, int x, int v) {
int m = l + r >> 1;
if(get(m, v) > get(m, mx[x])) swap(mx[x], v);
if(get(l, v) > get(l, mx[x])) modify(l, m, x << 1, v);
else if(get(r, v) > get(r, mx[x])) modify(m + 1, r, x << 1 | 1, v);
}
void modify(int l, int r, int ql, int qr, int x, int v) {
if(ql <= l && r <= qr) return modify(l, r, x, v);
int m = l + r >> 1;
if(ql <= m) modify(l, m, ql, qr, x << 1, v);
if(m < qr) modify(m + 1, r, ql, qr, x << 1 | 1, v);
}
int query(int l, int r, int x, int p) {
if(l == r) return mx[x];
int m = l + r >> 1;
if(p <= m) return get(query(l, m, x << 1, p), mx[x], p);
return get(query(m + 1, r, x << 1 | 1, p), mx[x], p);
}
int main() {
cin >> n;
for(int i = 1, las = 0; i <= n; i++) {
int op, c, d, e, f;
scanf("%d %d", &op, &c), c = (c + las - 1) % X + 1;
if(op == 0) printf("%d\n", las = query(1, X, 1, c));
else {
scanf("%d %d %d", &d, &e, &f), e = (e + las - 1) % X + 1;
d = (d + las - 1) % Y + 1, f = (f + las - 1) % Y + 1;
if(c > e) swap(c, e), swap(d, f);
if(c == e) b[++cnt] = max(d, f);
else k[++cnt] = (double)(f - d) / (e - c), b[cnt] = d - c * k[cnt];
modify(1, X, c, e, 1, cnt);
}
}
return 0;
}
*II. CF1175G Yet Another Partiton Problem
题解。
III. P4254 [JSOI2008]Blue Mary 开公司
板子题了属于是。
IV. BZOJ1767 [CEOI2009]Harbingers
本题是 NOI 2014 购票(可撤销 BIT 维护凸包斜率优化,见 DP 优化 II 斜率优化部分例题 VIII.)的弱化版原题。考虑转移方程
其中
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· .NET10 - 预览版1新功能体验(一)