#5. 可持久化线段树

请先学习线段树的相关内容喵。线段树博客待填

可持久化线段树

0x01. 简介

OI Wiki上的神秘定义:

函数式线段树 是指使用函数式编程思想的线段树。在函数式编程思想中,将计算机运算视为数学函数,并避免可改变的状态或变量。不难发现,函数式线段树是 完全可持久化 的。

可持久化线段树,顾名思义就是支持维护每次修改前历史版本,可以相同时间复杂度查询的线段树。

0x02. 如何变得‘持久’

一个朴素的想法是对于每次修改新建一棵线段树去存,那么就变成了维护线段树森林。

空间复杂度 \(O(qn\log\ n)\) ,炸飞了。


重新考虑实际中每次修改,从根节点递归到叶子节点实际上只修改了树上的一条链,如图:

那么要想维护历史版本上所有信息,每次就会增加 \(O(\log\ n)\) 个点。
而对于每次没有修改的节点,我们可以直接把修改过的节点直接'嫁接'其上,通过重复利用的方式最大限度节省空间,即:

可以发现,从每个根节点开始遍历形成的二叉树都与上文朴素想法中的二叉树完全一致,但是长得好毒瘤啊

0x03. 具体实现与代码

对于每个新版本,我们进行以下操作:

  1. 新建一个根节点,先继承上个版本根节点的所有信息
继承根节点实现
int clone(int u) {
    tr[++root] = tr[u];
    ls[root] = ls[u];
    rs[root] = rs[u];
    return root;
}
u = clone(u);
  1. 递归修改,修改的左、右儿子信息,记录到新开的节点上
递归修改实现
int mid = l + r >> 1;
if(loc <= mid) ls[u] = upd(ls[u], l, mid, loc, val);    
else rs[u] = upd(rs[u], mid + 1, r, loc, val); 

对于查询操作,直接在指定的版本递归向下找就完了。

注意注意注意:由于可持久化线段树的结构整体上不再是二叉树,而是一棵有很多很多根节点的奇形怪状的树,这意味着你不再能用形如 u<<1u<<1|1u*2u*2+1 来访问 u 的左儿子和右儿子。可以再开两个数组 lsrs 分别存左右儿子来解决。实际上,这种方式即为'动态开点'

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

就是板子题,维护支持单点修改和历史单点查询的可持久化线段树。

AC代码:

#include<bits/stdc++.h>
using namespace std;

inline int read() {
	int f = 1, otto = 0;
	char a = getchar();
	while(!isdigit(a)) {
		if(a == '-') f = -1;
		a = getchar();
	}
	while(isdigit(a)) {
		otto = (otto << 1) + (otto <<3) + (a ^ 48);
		a = getchar(); 
	}
	return f * otto;
}

const int maxn = 1e6 + 10;
int a[maxn], rt[maxn], ls[maxn << 5], rs[maxn << 5], tr[maxn << 5];

int root;
int build(int u, int l, int r) {
	u = ++root;
	if(l == r) {
		tr[u] = a[l];
		return u;
	}
	int mid = l + r >> 1;
	ls[u] = build(ls[u], l, mid);
	rs[u] = build(rs[u], mid + 1, r);
	return u;
}

int clone(int u) {
	tr[++root] = tr[u];
	ls[root] = ls[u];
	rs[root] = rs[u];
	return root;
}

int upd(int u, int l, int r, int loc, int val) {
	u = clone(u);
	if(l == r) {
		tr[u] = val;
		return u;
	}
	int mid = l + r >> 1;
	if(loc <= mid) ls[u] = upd(ls[u], l, mid, loc, val);
	else rs[u] = upd(rs[u], mid + 1, r, loc, val); 
	return u;
}

int ask(int u, int l, int r, int loc) {
	if(l == r) return tr[u];
	int mid = l + r >> 1;
	if(loc <= mid) return ask(ls[u], l, mid, loc);
	else return ask(rs[u], mid + 1, r, loc);
}

int main() {
	int n = read(), m = read();
	for(int i = 1; i <= n; i++) a[i] = read();
	rt[0] = build(1, 1, n);
	
	for(int i = 1; i <= m; i++) {
		int ver = read(), op = read(), loc = read();
		if(op == 1) {
			int val = read();
			rt[i] = upd(rt[ver], 1, n, loc, val);
		}
		if(op == 2) {
			printf("%d\n", ask(rt[ver], 1, n, loc));
			rt[i] = rt[ver];	//克隆指定版本 
		}
	}
} 

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

给定序列和 \(m\) 次询问,求静态区间第 \(k\) 小,也就是传说中的'主席树'。
简单来说:

  1. 维护一棵在值域上的可持久化线段树,每个节点维护区间值域内序列中的数的个数,序列中的数需要离散化。
  2. 直接查询前缀 \(r\) 减去 \(l - 1\) 的第 \(k\) 小即为答案。

AC代码:

#include<bits/stdc++.h>
using namespace std;

inline int read() {
	int f = 1, otto = 0;
	char a = getchar();
	while(!isdigit(a)) {
		if(a == '-') f = -1;
		a = getchar();
	}
	while(isdigit(a)) {
		otto = (otto << 1) + (otto <<3) + (a ^ 48);
		a = getchar(); 
	}
	return f * otto;
}

const int maxn = 2e5 + 10;
int n, m, num, ver;
int a[maxn], b[maxn], rk[maxn];
int rt[maxn], tr[maxn << 5], ls[maxn << 5], rs[maxn << 5];

void upd(int &u1, int u2, int l, int r, int loc) {
	u1 = ++ver, tr[u1] = tr[u2] + 1;
	if(l == r) return;
	int mid = l + r >> 1;
	if(loc <= mid) rs[u1] = rs[u2], upd(ls[u1], ls[u2], l, mid, loc);
	else ls[u1] = ls[u2], upd(rs[u1], rs[u2], mid + 1, r, loc);
}

int ask(int u1, int u2, int l, int r, int k) {
	if(l == r) return l;
	int mid = l + r >> 1;
	int res = tr[ls[u1]] - tr[ls[u2]];
	if(res >= k) return ask(ls[u1], ls[u2], l, mid, k);
	return ask(rs[u1], rs[u2], mid + 1, r, k - res);
}

int main() {
	n = read(), m = read();
	for(int i = 1; i <= n; i++) a[i] = read(), b[i] = a[i];
	sort(b + 1, b + n + 1);
	num = unique(b + 1, b + n + 1) - b - 1;
	for(int i = 1; i <= n; i++) rk[i] = lower_bound(b + 1, b + num + 1, a[i]) - b, upd(rt[i], rt[i - 1], 1, num, rk[i]);
	for(int i = 1; i <= m; i++) {
		int l = read(), r = read(), k = read();
		printf("%d\n", b[ask(rt[r], rt[l - 1], 1, num, k)]);
	}
	return 0;
}

???为什么这样是正确的
细想一下:

  1. Q:为什么要用可持久化线段树?
    A:相当于对序列上的每个前缀维护一棵值域线段树,每次 upd() 在上一个版本的基础上更新。这样一来维护的信息就是前缀和的形式,也就可以直接用 \(r\) 版本减去 \(l-1\) 版本来查询了。
  2. Q:查询时为什么直接用 int res = tr[ls[u1]] - tr[ls[u2]]; ,即左儿子值域区间内的数的个数之差来作为二分的依据?
    A:相当于二分时先判断 midl 的关系。求区间第 \(k\) 大,即要找到一个值域区间刚好有 \(k\) 个数的最右端。所以 res 大于等于 k 时,接着二分左儿子值域区间; res 小于 k 时,把 k 减去 res 再二分右儿子值域区间。这里同样是在前缀上进行的查询。

0x04. 小结

可持久化线段树作为线段树的升级版,的确具有很强的启发作用。像类似主席树的'前缀和'思想,和可持久化线段树本身重复利用线段树上同一部分的思想,也揭示了数据结构'无穷无尽'的可能性。

\(THE\ \ \ END\)

posted @ 2024-11-14 17:30  Ydoc770  阅读(1)  评论(0编辑  收藏  举报