可持久化线段树

创建时间:2025-05-08


可持久化数据结构简介

可持久化数据结构(Persistent data structure),它的特点在于它的名字中可持久化(当然,代码如同它名字一样长也算它的特点),说的直白点就是保存了每个历史版本的数据结构。

当然也可以直接开若干个完整的数据结构来存储历史版本,实现可持久化,但 OI 赛场上这么做的时间和空间复杂度早炸了。为了解决时间和空间问题,可持久化数据结构诞生了。

最先诞生的就是可持久化线段树,从可持久化线段树中也衍生出了主席树、可持久化并查集、可持久化字典树等算法。

可持久化线段树

P3919 【模板】可持久化线段树 1(可持久化数组)
为例,题目要求维护长度为 \(N\) 的序列 \(a\) 并进行 \(M\) 次操作,分为两种:

  1. 在某个历史版本(记初始序列为0号版本)上单点修改并生成这个新版本
  2. 查询某个历史版本的某个元素的值并复制为新版本

显然,我们无法直接通过数组解决这个问题。

首先想到直接开 \(M\) 棵线段树存储 \(M\) 个版本,修改操作直接复制并修改,查询操作直接输出并复制。


事实上,观察左图我们可以发现,只有 \(\log n\) 个点发生了改变,于是我们便天才地想到了将新的1号节点的右儿子直接接在旧的3号节点上,将新的2号节点直接接在旧的5号节点上,将新的4号节点直接接在旧的9号节点上,正如右图!

尽管这么做需要动态开点,也使得“线段树”的一个节点可能有多个父节点,不再是一颗传统意义上的树,但线段树的遍历是从上往下的,故这对修改、查询操作丝毫没有影响,我们只要记录下每个版本的根节点编号即可。并且,每次修改时生成新版本的时间复杂度从 \(O(n)\) 降了为 \(O(\log n)\),查询时新版本的复杂度从 \(O(n)\) 降为了 \(O(1)\),整体空间复杂度从 \(O(mn)\) 降为了 \(O(n+m \log n)\)

首此启发,我们完善一下解题思路:使用数组 \(root\) 记录第 \(i\) 次操作生成的版本的根节点编号(\(root_0\) 表示初始线段树的根节点编号),记当前正在版本 \(v\) 的基础上操作。执行修改操作从 \(root_v\) 开始递归:先新建节点,将要递归修改的儿子并更新为新的节点,将不要修改的儿子直接继承旧的,或者说,直接克隆当前节点并递归、修改涉及修改的儿子节点,修改结束后记下 \(root_i\)。查询操作就更加简单了,只需要在从 \(root_v\) 开始递归找到查询的元素并令 \(root_i=root_v\) 即可。

完整代码如下:

#include <bits/stdc++.h>

using namespace std;

const int MAX_N = 1e6 + 50;

int N, M, tot, root[MAX_N], a[MAX_N];
struct Node {
	int lc, rc, val;
} tr[MAX_N * 24];

int build(int l, int r) {
	int cur = ++tot;	
	if (l == r) {
		tr[cur].val = a[l];
		return cur;
	}
	int mid = l + r >> 1;
	tr[cur].lc = build(l, mid);
	tr[cur].rc = build(mid + 1, r);
	return cur;
}

int modify(int pos, int l, int r, int idx, int val) {
	int cur = ++tot;
	tr[cur] = tr[pos];
	if (l == r) {
		tr[cur].val = val;
		return cur;
	}
	int mid = l + r >> 1;
	if (idx <= mid)
		tr[cur].lc = modify(tr[cur].lc, l, mid, idx, val);
	else
		tr[cur].rc = modify(tr[cur].rc, mid + 1, r, idx, val);
	return cur;
} 

int query(int cur, int l, int r, int idx) {
	if (l == r)
		return tr[cur].val;
	int mid = l + r >> 1;
	if (idx <= mid)
		return query(tr[cur].lc, l, mid, idx);
	else
		return query(tr[cur].rc, mid + 1, r, idx); 
}

int main() {
	ios::sync_with_stdio(false);
	cin.tie(0), cout.tie(0); 
	
	cin >> N >> M;
	for (int i = 1; i <= N; i++)
		cin >> a[i];
	root[0] = build(1, N);
	for (int i = 1, v, opt, p, c; i <= M; i++) {
		cin >> v >> opt >> p;
		if (opt == 1) {
			cin >> c;
			root[i] = modify(root[v], 1, N, p, c);
		} else {
			cout << query(root[v], 1, N, p) << '\n';
			root[i] = root[v];
		}
	}
	return 0;
}

主席树

前面的题目只是可持久化线段树的入门,考试时的可持久化线段树绝没有这么简单。可持久化线段是最多的应用是利用两个版本作差,或者主席树。

主席树的全称应该是可持久化值域线段树,主席树是它的小名。至于为什么叫主席树这个高大上的名字就不得而知了

P3834 【模板】可持久化线段树 2为例,题目要多次查询静态序列的区间第 \(k\) 小值。

记查询区间为 \([a,b]\),值域线段树 \(T\) 表示值 \([l,r]\)\([a,b]\) 中的出现次数总和。很容易类比二叉搜索树的 \(k\) 小值查询方法:记左子树的大小为 \(lsiz\),当 \(k \le lsiz\) 时,\(k\) 小值一定在左子树中,递归左子树即可;否则 \(k\) 小值一定在右子树中,\(k \leftarrow k - lsiz\) 并递归右子树。直到 \(l=r\) 时,\(l\) 就是答案!

那么 \(T\) 的节点的信息如何维护呢?每次查询都建一棵树时不可能的,参考前缀和的思路,记 \(x\)\([l,r]\)\([1,b]\) 中的出现次数,\(y\)\([l,r]\)\([1,a-1]\) 中的出现次数,\(T[a \sim b]\) 就是 \(x-y\)。故我们只需要维护 \(n\) 棵权值线段树,第 \(i\) 棵表示 \([l,r]\)\([1,i]\) 中的出现次数即可,很容易想到使用一颗主席树(可持久化值域线段树)!记 \(root_i\) 为第 \(i\) 棵值域线段树的根,我们只需要按照之前单点修改的方法将序列 \(a\) 逐渐插入值域线段树并记录下 \(root\) 即可。

参考代码:

#include <bits/stdc++.h>

using namespace std;

const int MAX_N = 2e5 + 50;

int n, m, a[MAX_N];
vector<int> d;
int tot, root[MAX_N];
struct Node {
	int lc, rc, cnt;
} tr[MAX_N * 24];

inline void pushup(int cur) {
	tr[cur].cnt = tr[tr[cur].lc].cnt + tr[tr[cur].rc].cnt;
}

int build(int l, int r) {
	int cur = ++tot;
	if (l == r) {
		tr[cur].cnt = 0;
		return cur;
	}
	int mid = l + r >> 1;
	tr[cur].lc = build(l, mid);
	tr[cur].rc = build(mid + 1, r);
	pushup(cur);
	return cur;
}

int add(int pos, int l, int r, int idx, int cnt) {
	int cur = ++tot;
	tr[cur] = tr[pos];
	if (l == r) {
		tr[cur].cnt += cnt;
		return cur;
	}
	int mid = l + r >> 1;
	if (idx <= mid)
		tr[cur].lc = add(tr[cur].lc, l, mid, idx, cnt);
	else
		tr[cur].rc = add(tr[cur].rc, mid + 1, r, idx, cnt);
	pushup(cur);
	return cur;
}

int query(int u, int v, int l, int r, int rank) {
	if (l == r)
		return l;
	int mid = l + r >> 1;
	int lcnt = tr[tr[v].lc].cnt - tr[tr[u].lc].cnt;
	if (rank <= lcnt)
		return query(tr[u].lc, tr[v].lc, l, mid, rank);
	return query(tr[u].rc, tr[v].rc, mid + 1, r, rank - lcnt);
}

int main() {
	cin >> n >> m;
	for (int i = 1; i <= n; i++) {
		cin >> a[i];
		d.emplace_back(a[i]);
	}
	sort(d.begin(), d.end());
	d.resize(unique(d.begin(), d.end()) - d.begin());
	root[0] = build(1, n);
	for (int i = 1; i <= n; i++) {
		int mp = lower_bound(d.begin(), d.end(), a[i]) - d.begin();
		root[i] = add(root[i - 1], 1, n, mp + 1, 1);
	}
	for (int l, r, k; m--; ) {
		cin >> l >> r >> k;
		cout << d[query(root[l - 1], root[r], 1, n, k) - 1] << '\n';
	}
	return 0;
}

可持久化线段树的其它练习

P1972 [SDOI2009] HH的项链(抵消贡献)
P4587 [FJOI2016] 神秘数(迭代计算)
P3168 [CQOI2015] 任务查询系统(K值查询)
P2839 [国家集训队] middle(二分答案+最大前后缀和)
CF1422F Boring Queries(抵消贡献+卡空间)

posted @ 2026-05-19 12:31  xubaichuan  阅读(10)  评论(0)    收藏  举报