可持久化线段树
可持久化线段树(主席树)的原理与实现:历史版本查询与区间第 K 小。
一句话算法
可持久化线段树每次修改只复制从根到叶子的一条链,让新旧版本共享没有变化的节点。
问题模型
普通线段树修改后,旧状态会被覆盖。可持久化线段树解决的是“保留历史版本”的问题:
- 给定初始数组。
- 每次基于某个历史版本修改一个位置,生成新版本。
- 每次可以查询任意历史版本中的某个位置或区间信息。
以 luogu-P3919 为例,操作只有两类:
- 基于版本
v,把a[pos]改成value,生成新版本。 - 查询版本
v中a[pos]的值,同时复制出一个新版本。
核心直觉
一次单点修改只会影响线段树上从根到目标叶子的一条路径。路径之外的所有节点表示的区间完全没有变化,可以被新版本继续共享。
因此修改时:
- 复制旧根,得到新根。
- 沿着目标位置向下走,每经过一个节点就复制一个新节点。
- 没走到的另一个儿子直接指向旧版本的儿子。
- 到叶子后写入新值。
这样一次修改只新增
算法步骤
- 用普通线段树方式建立初始版本
root[0]。 update(old_root, pos, value):- 复制当前节点为新节点。
- 如果到达叶子,改值并返回新节点编号。
- 否则只递归修改包含
pos的那一边。 - 另一边直接复用旧节点。
query(root[v], pos):- 从版本
v的根节点出发。 - 按
pos所在区间向下走到叶子。 - 返回叶子保存的值。
- 从版本
算法证明
关键不变量: 对任意版本 v,从 root[v] 出发能访问到的节点,恰好组成这个版本对应的线段树。
- 初始版本:
build建出完整线段树,root[0]指向它,不变量成立。 - 修改路径: 单点修改只会改变包含
pos的区间节点;不包含pos的区间值不变,可以共享旧节点。 - 复制节点: 每次进入受影响节点时先复制,所以新版本的修改不会覆盖旧版本节点。
- 共享节点: 未被复制的子树没有任何内容变化,新旧版本指向同一棵子树不会改变查询结果。
- 查询正确: 查询只沿着版本根节点对应的树向下走,根据不变量,到达的叶子就是该版本中
pos的值。
复杂度分析
设数组长度为
- 建树时间复杂度:
。 - 单次修改时间复杂度:
,新增节点 。 - 单次查询时间复杂度:
。 - 总空间复杂度:
。
代码实现
下面模板对应 luogu-P3919 的可持久化数组。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#include <bits/stdc++.h>
using namespace std;
struct PersistentArray {
struct Node {
int left = 0;
int right = 0;
int value = 0;
};
vector<Node> tree;
explicit PersistentArray(int max_nodes) {
tree.reserve(max_nodes);
tree.push_back(Node{});
}
int clone(int p) {
tree.push_back(tree[p]);
return (int)tree.size() - 1;
}
int build(int l, int r, const vector<int>& a) {
int p = clone(0);
if (l == r) {
tree[p].value = a[l];
return p;
}
int mid = (l + r) >> 1;
tree[p].left = build(l, mid, a);
tree[p].right = build(mid + 1, r, a);
return p;
}
int update(int p, int l, int r, int pos, int value) {
int q = clone(p);
if (l == r) {
tree[q].value = value;
return q;
}
int mid = (l + r) >> 1;
if (pos <= mid) {
tree[q].left = update(tree[p].left, l, mid, pos, value);
} else {
tree[q].right = update(tree[p].right, mid + 1, r, pos, value);
}
return q;
}
int query(int p, int l, int r, int pos) const {
if (l == r) return tree[p].value;
int mid = (l + r) >> 1;
if (pos <= mid) return query(tree[p].left, l, mid, pos);
return query(tree[p].right, mid + 1, r, pos);
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
vector<int> a(n + 1);
for (int i = 1; i <= n; ++i) cin >> a[i];
int max_nodes = n + m * 20 + 5;
PersistentArray seg(max_nodes);
vector<int> root(m + 1);
root[0] = seg.build(1, n, a);
for (int i = 1; i <= m; ++i) {
int version, op, pos;
cin >> version >> op >> pos;
if (op == 1) {
int value;
cin >> value;
root[i] = seg.update(root[version], 1, n, pos, value);
} else {
root[i] = root[version];
cout << seg.query(root[version], 1, n, pos) << '\n';
}
}
return 0;
}
测试用例
输入:
5 5
1 2 3 4 5
0 2 1
0 1 3 9
1 2 3
2 2 3
3 2 1
输出:
1
3
9
1
第一条查询读取初始版本的 a[1]。第二次修改基于初始版本把 a[3] 改成 9,后续查询可以同时看到旧版本的 3 和新版本的 9。
应用分类详解
可持久化线段树的本质是“用少量新增节点保存历史版本差异”。
一、可持久化数组
典型模式: 单点修改、查询历史版本单点值。 识别信号: 题面明确给出版本号,每次操作基于旧版本生成新版本。 核心建模: 线段树叶子保存数组值,每次修改复制根到叶子的一条链。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 历史数组 | luogu-P3919 | 每个版本保存一个根节点 |
二、区间第 k 小
典型模式: 静态数组,多次询问 [l,r] 中第 k 小。
识别信号: 没有修改,只有大量区间排名查询。
核心建模: 建立前缀版本权值线段树,root[r] - root[l-1] 表示区间频次。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 主席树模板 | luogu-P3834 | 两个版本相减得到区间内每个值的出现次数 |
| K-th Number | POJ 2104 | 离散化后在权值线段树上二分答案 |
三、历史区间信息
典型模式: 操作需要回到过去版本查询区间和、区间最值或其他可合并信息。 识别信号: 历史版本很多,但每次修改影响范围较小。 核心建模: 节点维护可合并信息,单点修改复制路径;区间修改通常要更谨慎,常见做法是标记永久化。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 历史区间和 | HDU 4348 To the moon | 持久化线段树配合标记永久化 |
经典例题
-
luogu-P3919 可持久化数组模板题。先掌握“复制一条链,共享其他节点”的基本写法。
-
luogu-P3834 主席树最经典应用。重点理解两个前缀版本相减表示区间频次。
-
POJ 2104 K-th Number 区间第 k 小模板题,和 P3834 模型一致。