可持久化数据结构

\(\texttt{0x00}\) 简介

可持久化就是通过每次修改都创建新版本,来保留数据结构回滚访问历史版本的能力。

可持久化分为两类:

  1. 部分可持久化:所有版本都可以访问,但是只有最新版本可以修改;

  2. 完全可持久化:所有版本都既可以访问又可以修改。

注意:一个数据结构可持久化当且仅当它的拓补结构在使用的过程中保持不变。


\(\texttt{0x01}\) 核心思想

要想访问数据结构的历史版本,暴力做法就是在每次修改操作后将整个数据结构拷贝一份存储起来,若共有 \(M\) 次操作,就要多花费 \(M\) 倍的空间,直接导致 MLE 或 RE。

但是我们不妨思考一下,全部拷贝一遍是不是太浪费了?

比如更新一个游戏,只是下载新的内容,而不是把整个游戏重新下载一遍。

“可持久化”就提供了一个类似这样的思想,它并不是暴力地全部拷贝,而是在每次操作结束后,仅创建数据结构中发生改变的部分的副本,不拷贝其他内容

这样一来,维护数据结构的时间复杂度没有增加,空间复杂度仅增长为与时间同级的规模。

下面都是一些可持久化数据结构的实例。

\(\texttt{0x02}\) 可持久化 \(\texttt{Trie}\)

\(\texttt{Trie}\) 的节点一样,可持久化 \(\texttt{Trie}\) 的每个节点也有若干个字符指针指向子节点。

依次维护 \(\texttt{cat}\)\(\texttt{rat}\)\(\texttt{cab}\)\(\texttt{fry}\) 四个单词,原始的 \(\texttt{Trie}\) 长成这个样:

那么可持久化 \(\texttt{Trie}\) 呢?

先插入单词 \(\texttt{cat}\)

再插入单词 \(\texttt{rat}\),这个时候我们需要新开一个根节点作为版本 \(2\) 的入口。

接下来我们只需要把新的路径记录下来就可以了。

根据可持久化的核心思想,我们每次只用记录不一样的地方,相同的地方直接从上一个版本 \(\texttt{copy}\) 过来就行了。

所以先把根节点 \(v_1\) 的所有儿子信息 \(\texttt{copy}\)\(v_2\),再新建一个儿子指向 \(r\)

接下来,由于在上一个版本中,由根到 \(r\) 的节点是不存在的,它是一个全新的点,所以这时不应该从上一个版本复制任何信息,直接开辟一个新的节点 \(a\)

同理,\(a\) 也是个全新的点,所以直接开辟 \(t\)

插入完成后,这棵树就长成了这样:

绿框中的就是版本 \(2\)

该更新版本 \(3\) 了,先复制上一个版本的信息。

但是 \(c\) 点在要修改的路径上,所以 \(c\) 要新建一个点。

虽然这是一个新的点,但它其实是从原来的 \(c\) 点克隆过来的,它的信息只有 \(a\) 点。

但是 \(a\) 点也在要修改的路径上,所以同理克隆、裂开。

最后由于 \(t\) 不在要修改的路径上,所以 \(a\) 要指向 \(t\)

完成插入后结构如图:

绿框中的即是版本 \(3\)

注意我们每次都只更新了一条路径,每次更新至于上一个版本比较,复制信息也从上一个版本复制。

按照上面的方法,更新 \(\texttt{fry}\)

绿框中的即是版本 \(4\)

这就是可持久化 \(\texttt{Trie}\) 的过程了。

总之,只修改有关路径上的点。

例题:

P4735 最大异或和

类似于前缀和的思想,设 \(s[i]\) 为序列 \(a\)\(i\) 个数异或起来得到的值,其中 \(s[0] = 0\)

根据异或运算的性质:

\[a[p] \operatorname{xor} a[p + 1] \operatorname{xor} \cdots \operatorname{xor} a[N] = s[p - 1] \operatorname{xor} s[N] \]

对于每次添加操作,序列 \(s\) 很好维护。

对于每次询问操作,等价于:已知一个整数 \(c = s[N] \operatorname{xor} x\),求一个位置 \(p\),且 \(l - 1 \le p \le r - 1\),使得 \(s[p] \operatorname{xor} c\) 最大。

若先不考虑 \([l - 1,r - 1]\) 的限制,其实就是要求两个数的异或和最大。

这种问题可以用 \(\texttt{01Trie}\) 的方法求出。

若只有 \(p \le r - 1\) 的限制,那么只需要加上可持久化就可以解决。

因为可持久化 \(\texttt{Trie}\) 保存了所有在插入了数 \(i\) 后的 \(\texttt{01Trie}\),所以对于每次询问,从 \(root[r - 1]\) 出发,优先尝试访问与 \(c\) 的每一位相反的指针即可。

那么再加上 \(p \ge l - 1\) 的限制该怎么办呢?

其实这就是一个存在性问题,即询问在与 \(c\) 的某位相反的子树中是否存在一个数的编号 \(\ge l - 1\)

\(max\_id[x]\) 表示可持久化 \(\texttt{Trie}\) 中当前子树的编号最大值。

这样,从 \(root[r - 1]\) 出发检索 \(c\) 时,仅考虑 \(max_id\)\(\ge l - 1\) 的节点即可。

整个算法的时间复杂度为 \(O((n + m)\log 10^7)\)

\(\texttt{Code}\)

#include <iostream>
#include <cstring>

using namespace std;

const int N = 600010, M = N * 25;
int n, m;
int tr[M][2], max_id[M];
int root[N], idx;
int s[N];

void insert(int i, int k, int p, int q) {
	if(k < 0) {
		max_id[q] = i;
		return ;
	}
	int v = s[i] >> k & 1;
	if(p) tr[q][v ^ 1] = tr[p][v ^ 1];
	tr[q][v] = ++idx;
	insert(i, k - 1, tr[p][v], tr[q][v]);
	max_id[q] = max(max_id[tr[q][0]], max_id[tr[q][1]]);
}

int query(int root, int c, int l) {
	int p = root;
	for(int i = 23; i >= 0; i--) {
		int v = c >> i & 1;
		if(max_id[tr[p][v ^ 1]] >= l) p = tr[p][v ^ 1];
		else p = tr[p][v];
	}
	return c ^ s[max_id[p]];
}

int main() {
	scanf("%d%d", &n, &m);
	max_id[0] = -1;
	root[0] = ++idx;
	insert(0, 23, 0, root[0]);
	int x;
	for(int i = 1; i <= n; i++) {
		scanf("%d", &x);
		s[i] = s[i - 1] ^ x;
		root[i] = ++idx;
		insert(i, 23, root[i - 1], root[i]);
	}
	char op[2];
	int l, r;
	while(m--) {
		scanf("%s", op);
		if(op[0] == 'A') {
			scanf("%d", &x);
			root[++n] = ++idx;
			s[n] = s[n - 1] ^ x;
			insert(n, 23, root[n - 1], root[n]);
		}
		else {
			scanf("%d%d%d", &l, &r, &x);
			printf("%d\n", query(root[r - 1], s[n] ^ x, l - 1));
		}
	}
	return 0;
}

\(\texttt{0x03}\) 可持久化线段树

可持久化线段树又称主席树(据说是因为它的发明者的名字首字母是 hjt)。

基于可持久化 \(\texttt{Trie}\) 的思想,对于线段树的单点修改,每次只会使树上 \(O(\log n)\) 个节点被修改。

修改的时候与 \(\texttt{Trie}\) 也是一个道理,当某一次修改,一个节点的信息发生了改变,我们就克隆一个全新的节点出来承载修改并继承原节点的信息。

同时,由于可持久化线段树不再是一棵完全二叉树,所以就不满足“左儿子编号是父节点的 \(2\) 倍,右儿子编号是父节点的 \(2\)\(+1\)”的性质。

所以不能再用次序编号,而是改为直接记录每个节点的左、右子节点编号,与 \(\texttt{Trie}\) 的字符指针类似。

struct node{
	int ls, rs;
}tr[N << 5];

因为每次只会修改 \(O(\log n)\) 个节点,所以可持久化线段树的空间复杂度为 \(O(N + M\log N)\)

同时,我们不再记录每个节点代表的区间 \([l,r]\),而是在函数中作为参数传递。

可持久化线段树难以支持大部分区间修改操作。

因为当一个节点下传懒标记时,一旦我们创建它左右儿子 \(ls,rs\) 的副本 \(ls',rs'\) 并把标记更新,那么所有依赖 \(ls,rs\) 的线段树版本都要改为依赖 \(ls',rs'\),甚至有的还要自底向上重新计算某些信息。这样的后果是灾难性的。

在一些特殊的题目中,可以使用“标记永久化”代替标记下传,从而完成区间修改操作。

但这种做法局限性太大,实用性也不广。

例题:

P3919 【模板】可持久化线段树 1(可持久化数组)

历史版本单点修改模板题,只需要注意每次查询也要新建版本就行了。

\(\texttt{Code}\)

#include <iostream>

using namespace std;

const int N = 1000010;

int n, m;
int a[N];
struct node{
	int ls, rs;
	int val;
}tr[N << 5];
int root[N], idx;

int build(int l, int r) {
	int p = ++idx;
	if(l == r) {
		tr[p].val = a[l];
		return p;
	}
	int mid = l + r >> 1;
	tr[p].ls = build(l, mid);
	tr[p].rs = build(mid + 1, r);
	return p; 
}

int insert(int p, int x, int v, int l, int r) {
	int q = ++idx;
	tr[q] = tr[p];
	if(l == r) {
		tr[q].val = v;
		return q;
	}
	int mid = l + r >> 1;
	if(x <= mid) tr[q].ls = insert(tr[p].ls, x, v, l, mid);
	else tr[q].rs = insert(tr[p].rs, x, v, mid + 1, r);
	return q;
}

int query(int p, int x, int l, int r) {
	if(l == r) return tr[p].val;
	int mid = l + r >> 1;
	if(x <= mid) return query(tr[p].ls, x, l, mid);
	else return query(tr[p].rs, x, mid + 1, r);
}

int main() {
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= n; i++) scanf("%d", &a[i]);
	root[0] = build(1, n); 
	int p, op, pos, v;
	int tt = 0;
	while(m--) {
		scanf("%d%d%d", &p, &op, &pos);
		if(op == 1) {
			scanf("%d", &v);
			root[++tt] = insert(root[p], pos, v, 1, n);
		}
		else {
			printf("%d\n", query(root[p], pos, 1, n));
			root[++tt] = root[p]; //新建版本
		}
	}
	return 0;
}

P3834 【模板】可持久化线段树 2

静态区间求第 \(k\) 大数,一道很经典的题。

这道题解法多样,比如归并树、划分树、可持久化线段树、树套树。

这里讲的是可持久化线段树做法。

在线段树上维护”序列 \(A\) 有多少个数落在值域区间 \([l,r]\) 内”(记为 \(cnt_{l,r}\)),那么只需要比较 \(cnt_{l,mid}\)\(k_i\) 的大小关系,就可以确定 \(A\)\(k_i\) 小数是 \(\le mid\) 还是 \(> mid\),从而选择进入线段树的左儿子还是右儿子。

只考虑限制 \([1,r]\),那么直接用可持久化线段树就行了,但是加上限制 \([l,r]\),怎么办呢?

但是左范围不能像上一个题那样做,上个题由于是存在性问题,具有一定的特殊性。

不过,线段树有个很特殊的地方:它的结构是一模一样的,不像 \(\texttt{Trie}\) 那样会多很多节点。可持久化的节点都能与之前版本中的节点一一对应起来的。所以,区间之间是具有可减性的。

也就是说我们可以通过减法,计算出 \([L,R]\) 中位于值域区间 \([l,r]\) 中的数的个数的。然后套入权值线段树的求 \(k\) 小中即可。

同时由于这道题的值域范围很大,所以还需要做离散化。

该算法的时间复杂度为 \(O((N + M)\log N)\),空间复杂度为 \(O(N\log N)\)

若要拓展到带修动态区间第 \(k\) 小数问题,则需要最外层套一个树状数组或用树套树做。

\(\texttt{Code}\)

#include <iostream>
#include <algorithm>
#include <vector>
 
using namespace std;

const int N = 200010;

int n, m;
int a[N];
vector<int> nums;
struct node{
	int ls, rs;
	int cnt;
}tr[N * 25];
int root[N], idx;

int find(int x) { //求离散化之后的值
	return lower_bound(nums.begin(), nums.end(), x) - nums.begin();
}

int build(int l, int r) {
	int p = ++idx;
	if(l == r) return p;
	int mid = l + r >> 1;
	tr[p].ls = build(l, mid);
	tr[p].rs = build(mid + 1, r);
	return p;
}

int insert(int p, int l, int r, int x) {
	int q = ++idx;
	tr[q] = tr[p];
	if(l == r) {
		tr[q].cnt++;
		return q;
	}
	int mid = l + r >> 1;
	if(x <= mid) tr[q].ls = insert(tr[p].ls, l, mid, x);
	else tr[q].rs = insert(tr[p].rs, mid + 1, r, x);
	tr[q].cnt = tr[tr[q].ls].cnt + tr[tr[q].rs].cnt;
	return q;
}

int query(int q, int p, int l, int r, int k) {
	if(l == r) return l;
	int mid = l + r >> 1;
	int cnt = tr[tr[q].ls].cnt - tr[tr[p].ls].cnt; //有多少个数落在值域 [l, mid] 内
	if(k <= cnt) return query(tr[q].ls, tr[p].ls, l, mid, k); //左边找第 k 大
	else return query(tr[q].rs, tr[p].rs, mid + 1, r, k - cnt); //右边找第 k - cnt 大
}

int main() {
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= n; i++) {
		scanf("%d", &a[i]);
		nums.push_back(a[i]);
	}
	sort(nums.begin(), nums.end());
	nums.erase(unique(nums.begin(), nums.end()), nums.end()); //离散化
	
	root[0] = build(0, nums.size() - 1);
	for(int i = 1; i <= n; i++) root[i] = insert(root[i - 1], 0, nums.size() - 1, find(a[i]));
	int l, r, k;
	while(m--) {
		scanf("%d%d%d", &l, &r, &k);
		printf("%d\n", nums[query(root[r], root[l - 1], 0, nums.size() - 1, k)]); //由于 query 返回的是离散化之后的值,所以要再套一层 nums 才是原数
	}
	return 0;
}
posted @ 2024-07-23 14:35  Brilliant11001  阅读(2)  评论(0编辑  收藏  举报