主席树

\(\text{luogu-3919}\)

如题,你需要维护这样的一个长度为 \(n\) 的数组,支持如下两种操作:

  1. 在某个历史版本上修改某一个位置上的值。
  2. 访问某个历史版本上的某一位置的值。

具体来说:

  1. 对于操作 \(1\),格式为 v 1 p c,即为在版本 $ v $ 的基础上,将 $ a_{p} $ 修改为 \(c\)
  2. 对于操作 \(2\),格式为 v 2 p,即访问版本 $ v $ 中的 $ a_{p} $ 的值,注意:生成一样版本的对象应为 \(v\)

每进行一次操作,就会生成一个新的版本。版本编号即为当前操作的编号(从 \(1\) 开始编号,版本 \(0\) 表示初始状态数组)。

对于操作 \(2\),即为生成一个完全一样的版本,不作任何改动。即,询问生成的版本是询问所访问的那个版本的复制。

\(1 \le n,m \le 10^5\)\(-10^9 \le a_i, c \le 10^9\)


可持久化数组(主席树)板子。

以下部分题解来自于 题解:P3919 【模板】可持久化线段树 1(可持久化数组) - 洛谷专栏

一种简单,容易想到的暴力做法就是每一次创建一个新版本就复制一个新数组,时间和空间复杂度都是 \(\mathcal{O}(mn)\)\(m\) 次操作,每次创建一个大小为 \(n\) 的数组,从原数组复制要进行 \(n\) 次操作)。本题 \(1 \le n,m \le 10^5\),这种做法显然不可接受。

我们观察需要创建新版本的场景,可以发现每次最多只会修改一个数,有时甚至不会修改,这些不修改的数字如果每一次都复制的话代价太高了。很容易想到每一次只记录修改的部分。

我们可以使用一种叫做可持久化线段树的数据结构。(没学过线段树的请右转学习线段树。)

我们现在先将版本 \(0\) 到版本 \(2\) 的线段树单独画出来:

我们发现版本 \(0\) 和版本 \(1\) 是重复的,我们可以直接将这些节点合并(将第二颗树的根节点的儿子设成第一颗树的根节点的儿子):

对于版本 \(2\),它只修改了一个数,我们可以想到只记录根节点到修改部分的一条“链”,其余的按照上面的方法操作:

总结:先将版本 \(0\) 的树完整的记录下来,接着之后的版本只记录修改的部分。

现在我们考虑复杂度:

空间复杂度:版本 \(0\) 要记录所有的数,会有 \(2n-1\) 个节点。总共有 \(m\) 个版本。假设每一个版本都有修改,那么每一次都要记录长为 \(\lceil \log_2 n \rceil+1\) 的“链”,空间复杂度约为 \(\mathcal{O}(n+m\log_2 n)\)。在此题中 \(n\)\(m\) 最大为 \(10^6\),内存限制为 \(1\) GB,可以接受。

时间复杂度:建第一棵树复杂度为 \(\mathcal{O}(n)\),后面每一次查找复杂度都是 \(\mathcal{O}(\log n)\) 的,合起来就是 \(\mathcal{O}(n+m \log n)\)

#include<iostream>
#include<cstdio>
using namespace std;
#define MAXN 1000005

long long read() {
	long long x = 0, f = 1;
	char c = getchar();
	while(c > 57 || c < 48) { if(c == 45) f = -1; c = getchar(); }
	while(c >= 48 && c <= 57) { x = (x << 1) + (x << 3) + (c - 48); c = getchar(); }
	return x * f;
}

struct node { long long l, r, w; } t[MAXN << 5];
long long n, m, rt[MAXN], np, cnt;

long long build(long long l, long long r) {
	long long p = (++ cnt), mid = (l + r) >> 1;
	if(l == r) { t[p].w = read(); return p; }
	t[p].l = build(l, mid), t[p].r = build(mid + 1, r);
	return p;
}

long long upd(long long x, long long k, long long l, long long r, long long v) {
	long long p = (++ cnt), mid = (l + r) >> 1;
	t[p].l = t[v].l, t[p].r = t[v].r;
	if(l == r) { t[p].w = k; return p; }
	if(x <= mid) t[p].l = upd(x, k, l, mid, t[v].l);
	else t[p].r = upd(x, k, mid + 1, r, t[v].r);
	return p;
}

long long qry(long long x, long long l, long long r, long long p) {
	if(l == r) return t[p].w;
	long long mid = (l + r) >> 1;
	if(x <= mid) return qry(x, l, mid, t[p].l);
	return qry(x, mid + 1, r, t[p].r);
}

int main() {
	n = read(), m = read(), rt[np ++] = build(1, n);
	while(m --) {
		long long v = read(), op = read(), x = read(), k;
		if(op == 1) k = read(), rt[np ++] = upd(x, k, 1, n, rt[v]);
		else cout << qry(x, 1, n, rt[np ++] = rt[v]) << "\n";
	}
	return 0;
}

\(\text{luogu-3834}\)

这是个非常经典的可持久化权值线段树入门题——静态区间第 \(k\) 小。

给定 \(n\) 个整数构成的序列 \(a\),将对于指定的闭区间 \([l, r]\) 查询其区间内的第 \(k\) 小值。

\(1 \leq n,m \leq 2\times 10^5\)\(0\le a_i \leq 10^9\)\(1 \leq l \leq r \leq n\)\(1 \leq k \leq r - l + 1\)


可持久化线段树/主席树模板题。

以下部分题解来自于 主席树 - 洛谷专栏

模板题就是主席树的典型例题,询问区间第 \(k\) 大。先不说区间 \([l,r]\) 吧,就说说 \([1,r]\) 怎么做。

模板题的 \([1,r]\) 情况

由题意知道我们肯定要对区间进行排序,但是我们的排序不是每次询问才排序,是初始化就排序并离散化 —— 针对数字较大但数据不大的情况下(具体见方法)。排序离散化完毕后,以离散化数组建主席树,设 \(i\) 属于区间 \([1,n]\),对原数组的 \([1,i]\) 区间的数做统计(例如下图,区间中按离散化数组顺序统计 \(1\) 的个数、\(2\) 的个数、\(3\) 的个数、\(4\) 的个数、\(8\) 的个数、\(9\) 的个数),有序地插入节点到离散化数组的主席树中,记录好原数组每个节点对应的线段树起点,针对样例有几个示意图。注意,这里的橙色节点是新节点,与之前出现的那个图不一样。

  • \([1,1]\) 的情况

图示1

  • \([1,4]\) 的情况

图示2

情况以此类推。

我们按照上面的做法构建的主席树是为了方便我们查找第 \(k\) 小值。因为我们是以离散数组构建的主席树,那么从根节点出发,左子树部分的数必定不大于右子树部分的数。于是就可以将左儿子的节点个数 \(x\)\(k\) 做比较,若 \(k\leq x\),则第 \(k\) 小值一定在左子树里面,若 \(x\leq k\),则第 \(k\) 小值一定在右子树里面,然后递归往下走,缩小范围。值得注意的是,前者递归时,\(k\) 直接传下去即可,后者递归时,需要将 \(k\) 减去左子树的数的个数再传递这个 \(k\) 值。

例如我们查找 \([1,4]\) 中第 \(2\) 小的值,图示如下,绿色节点为该值存在的区间位置。

图示3

需要注意的是,第二个绿色节点才是绿色根节点的左子树,因为左子树表示的区间是靠前的那一半

方法总结如下:

  1. 将原始数组复制一份,然后排序好,然后去掉多余的数,即将数据离散化。
  2. 离散化数组为基础,建一个全 \(0\) 的线段树,称作基础主席树;
  3. 对原数据中每一个 \([1,i]\) 区间统计,有序地插入新节点(题目中 \(i\) 每增加 \(1\) 就会多一个数,仅需对主席树对应的节点增加 \(1\) 即可);
  4. 对于查询 \([1,r]\) 中第 \(k\) 小值的操作,找到 \([1,r]\) 对应的根节点,我们按照线段树的方法操作即可(这个根节点及其子孙构成的必定是一颗线段树)。

模板题的解决

现在我们真正来解决区间询问 \([l,r]\) 的问题。

构建主席树的方法是没有问题的,问题正在于区间询问怎么写。其实,解决方案就是将主席树 \([1,r]\) 减去主席树 \([1,l-1]\) 就行了。其实这个原因并不难想,首先看到主席树的底层,全部是对数的统计。当主席树 \([1,r]\) 减去主席树 \([1,l-1]\) 时,统计也跟着减了,也就是说,现在统计记录的是 \([l,r]\) 区间。

而我们不需要单独减,只需要边递归查询边减,具体见查询部分代码。

// 初始的 x 和 y 分别代表的是点 l-1 和点 r,l 和 r 分别表示线段树点代表的区间,初始的 k 如题
ll qry(ll x, ll y, ll l, ll r, ll k) {
	ll res, mid = (l + r) >> 1, t = sum[ls[y]] - sum[ls[x]];
    // 因为主席树是区间统计好了的,只要减一下即可,无需递归到叶子再处理
	if(l == r) return l; // 找到目标位置
	if(t >= k) res = qry(ls[x], ls[y], l, mid, k);
	else res = qry(rs[x], rs[y], mid + 1, r, k - t); // 右子树记得改变 k 的值
	return res;
}

题目复杂度分析

题目一开始的离散化复杂度为 \(O(n\lg n)\),构建基础主席树复杂度为 \(O(n\lg n)\),统计并插入的复杂度是 \(O(n\lg n + n\lg n)=O(n\lg n)\),询问的复杂度是 \(O(m\lg n)\)

复杂度总和就是 \(O((m+n)\lg n)\)

#include<iostream>
#include<cstdio>
#include<algorithm> 
using namespace std;
#define MAXN 200005

long long read() {
	long long x = 0, f = 1;
	char c = getchar();
	while(c > 57 || c < 48) { if(c == 45) f = -1; c = getchar(); }
	while(c >= 48 && c <= 57) { x = (x << 1) + (x << 3) + (c - 48); c = getchar(); }
	return x * f;
}

long long n, m, q, cnt, a[MAXN], b[MAXN], sum[MAXN << 5];
long long rt[MAXN], ls[MAXN << 5], rs[MAXN << 5];

void build(long long &p, long long l, long long r) {
	p = (++ cnt); if(l == r) return;
	long long mid = (l + r) >> 1;
	build(ls[p], l, mid), build(rs[p], mid + 1, r);
	return;
}

long long upd(long long t, long long l, long long r, long long k) {
	long long p = (++ cnt);
	ls[p] = ls[t], rs[p] = rs[t], sum[p] = sum[t] + 1;
	if(l == r) return p;
	long long mid = (l + r) >> 1;
	if(k <= mid) ls[p] = upd(ls[p], l, mid, k);
	else rs[p] = upd(rs[p], mid + 1, r, k);
	return p;
}

long long qry(long long x, long long y, long long l, long long r, long long k) {
	long long res, mid = (l + r) >> 1, t = sum[ls[y]] - sum[ls[x]];
	if(l == r) return l;
	if(t >= k) res = qry(ls[x], ls[y], l, mid, k);
	else res = qry(rs[x], rs[y], mid + 1, r, k - t);
	return res;
}

int main() {
	n = read(), m = read();
	for(int i = 1; i <= n; i ++) b[i] = a[i] = read();
	sort(b + 1, b + n + 1);
	q = unique(b + 1, b + n + 1) - b - 1;
	build(rt[0], 1, q);
	for(int i = 1; i <= n; i ++) {
		long long t = lower_bound(b + 1, b + q + 1, a[i]) - b;
		rt[i] = upd(rt[i - 1], 1, q, t);
	}
	while(m --) {
		long long l = read(), r = read(), k = read();
		cout << b[qry(rt[l - 1], rt[r], 1, q, k)] << "\n";
	}
	return 0;
}

\(\text{luogu-4559}\)

作为一名大学生,九条可怜在去年参加了她人生中的最后一次军训。

军训中的一个重要项目是练习列队,为了训练学生,教官给每一个学生分配了一个休息位置。每次训练开始前,所有学生都在各自的休息位置休息,但是当教官发出集合命令后,被点到的学生必须要到指定位置集合。

为了简化问题,我们把休息位置和集合位置抽象成一根数轴。一共有 \(n\) 个学生,第 \(i\) 个学生的休息位置是 \(a_i\)。每一次命令,教官会指定一个区间 \([l,r]\) 和集合点 \(K\) ,所有编号在 \([l,r]\) 内的学生都必须赶到集合点列队。在列队时,每一个学生需要选择 \([K,K+r-l]\) 中的一个整数坐标站定且不能有任何两个学生选择的坐标相同。学生从坐标 \(x\) 跑到坐标 \(y\) 需要耗费体力 \(\vert y-x \vert\)

在一天的训练中,教官一共发布了 \(m\) 条命令 \((l,r,K)\) ,现在你需要计算对于每一条命令,在所有可能的列队方案中,消耗的体力值总和最小是多少。

以下是对题意的一些补充:

  1. 任何两条命令是无关的,即在一条集合命令结束后,所有学生都会回到自己的休息位置,然后教官才会发出下一条命令。

  2. 在集合的时候,可能有编号不在 \([l,r]\) 内的学生处在区间 \([K,K+r-l]\) 中,这时他会自己跑开,且跑动的距离不记在消耗的体力值总和中。

\(n,m \leq 5 \times 10^5,1 \leq a_i,K \leq 10^6\),学生休息的位置两两不同。


以下部分题解来自于 题解 P4559 【JSOI2018列队】 - 洛谷专栏

不难证明,学生跑到最终的位置时,他们的相对位置不改变至少是最优解之一,这可以脑补一下。

所以我们只需要求最终相对位置不变时的答案即可。

因为学生两两位置不同,所以最终有一部分学生向右跑,有一部分学生向左跑。

向右跑的学生对答案的贡献是 \(k+rk_i-1-a_i\)\(rk_i\) 表示他的位置在这个编号区间中的学生是第 \(rk_i\) 小的。

向左跑的学生对答案的贡献是 \(a_i-k-rk_i+1\)

显然左边一部分学生向右跑,右边一部分学生向左跑。

考虑使用主席树处理这个问题。

对权值线段树进行可持久化,则编号区间内的学生就是两个线段树相减。

考虑递归进一个区间 \([l,r]\),有 \(4\) 种情况。

  1. 这个区间中没有学生。直接返回 \(0\)

  2. 这个区间中的学生全部往右跑。返回 \((\sum k+rk_i-1)-(\sum a_i)\),左边是等差数列求和的形式,右边可以直接记。

  3. 这个区间中的学生全部往左跑。返回 \((\sum a_i)-(\sum k+rk_i-1)\)

  4. 不能确定这个区间中的学生的方向,递归到子树处理。

直接在主席树上实现即可。

时间复杂度 \(O(n\log n+m\log n\times\text{wys})\),因为我不会分析递归的复杂度,可能是 \(O(m\log n)\) 的。

#include<iostream>
#include<cstdio>
using namespace std;
#define MAXN 500005
#define MAXM 11000005
#define ll long long 

long long read() {
	long long x = 0, f = 1;
	char c = getchar();
	while(c > 57 || c < 48) { if(c == 45) f = -1; c = getchar(); }
	while(c >= 48 && c <= 57) { x = (x << 1) + (x << 3) + (c - 48); c = getchar(); }
	return x * f;
}

ll n, m, rt[MAXN], ls[MAXM], rs[MAXM], cnt, sum[MAXM], sz[MAXM];

void upd(ll &p, ll l, ll r, ll x) {
	ls[++ cnt] = ls[p], rs[cnt] = rs[p];
	sz[cnt] = sz[p] + 1, sum[cnt] = sum[p] + x;
	p = cnt; if(l == r) return;
	long long mid = (l + r) >> 1;
	if(x <= mid) upd(ls[p], l, mid, x);
	else upd(rs[p], mid + 1, r, x);
	return;
}

ll qry(ll x, ll y, ll l, ll r, ll s, ll k) {
	if(!(sz[x] - sz[y])) return 0;
	long long si = sz[x] - sz[y], sm = sum[x] - sum[y];
	if(l >= s + k) return sm - (2 * k + 2 * s + si - 1) * si / 2;
	if(r <= s + k + si - 1) return (2 * k + 2 * s + si - 1) * si / 2 - sm;
	ll mid = (l + r) >> 1, t = sz[ls[x]] - sz[ls[y]];
	return qry(ls[x], ls[y], l, mid, s, k) + 
		   qry(rs[x], rs[y], mid + 1, r, s + t, k);
}

int main() {
	n = read(), m = read();
	for(int i = 1; i <= n; i ++) 
		upd(rt[i] = rt[i - 1], 1, 1e6, read());
	while(m --) {
		ll l = read(), r = read(), k = read();
		cout << qry(rt[r], rt[l - 1], 1, 1e6, 0, k) << "\n";
	}
	return 0;
}

\(\text{luogu-2633}\)

给定一棵 \(n\) 个节点的树,每个点有一个权值。有 \(m\) 个询问,每次给你 \(u,v,k\),你需要回答 \(u \text{ xor last}\)\(v\) 这两个节点间第 \(k\) 小的点权。

其中 \(\text{last}\) 是上一个询问的答案,定义其初始为 \(0\),即第一个询问的 \(u\) 是明文。

\(1\le n,m \le 10^5\),点权在 \([1, 2 ^ {31} - 1]\) 之间。


实际上主席树就是一个类似前缀的东西。

对于这题只需要从根节点建主席树,每次询问就是把两个点路径上的主席树提取出来。

然后求区间第 \(k\) 小就好了,实际上是四个主席树加减。

下面是比较形式化的描述。

定义 \(s_x\) 为从根节点到 \(x\) 节点的“前缀主席树”,那么,包含 \(x \to y\) 的所有信息的主席树就是:

\[\Large{s_x + s_y - s_{\text{lca}(x, y)} - s_{fa_{\text{lca(x,y)}}}} \]

注意离散化和主席树的代码细节。

#include<iostream>
#include<cstdio>
#include<vector>
#include<algorithm>
#include<cmath>
using namespace std;
#define MAXN 100005
#define ll long long 

long long read() {
	long long x = 0, f = 1;
	char c = getchar();
	while(c > 57 || c < 48) { if(c == 45) f = -1; c = getchar(); }
	while(c >= 48 && c <= 57) { x = (x << 1) + (x << 3) + (c - 48); c = getchar(); }
	return x * f;
}

ll n, m, q, cnt, rt[MAXN], lst, a[MAXN], b[MAXN], fa[MAXN][20], dep[MAXN], id[MAXN];
struct node { ll l, r, sum; } t[MAXN << 5];
vector<ll> v[MAXN];

void build(node &p, ll l, ll r) {
	p.sum = 0; if(l == r) return;
	ll mid = (l + r) >> 1;
	build(t[p.l = (++ cnt)], l, mid);
	build(t[p.r = (++ cnt)], mid + 1, r);
	return;
}

void insert(node s, node &p, ll l, ll r, ll x) {
	p.sum = s.sum + 1; if(l == r) return;
	ll mid = (l + r) >> 1;
	if(x <= mid) insert(t[s.l], t[p.l = (++ cnt)], l, mid, x), p.r = s.r;
	else insert(t[s.r], t[p.r = (++ cnt)], mid + 1, r, x), p.l = s.l;
	return;
}

void dfs(ll x, ll f) {
	insert(t[rt[f]], t[rt[x] = (++ cnt)], 1, q, id[x]);
	fa[x][0] = f, dep[x] = dep[f] + 1;
	for(int i = 1; i <= 19; i ++) fa[x][i] = fa[fa[x][i - 1]][i - 1];
	for(auto y : v[x]) if(y != f) dfs(y, x);
	return;
}

ll lca(ll x, ll y) {
	if(dep[x] < dep[y]) swap(x, y);
	for(int i = 19; i >= 0; i --)
		if(dep[fa[x][i]] >= dep[y]) x = fa[x][i];
	if(x == y) return x;
	for(int i = 19; i >= 0; i --)
		if(fa[x][i] != fa[y][i]) x = fa[x][i], y = fa[y][i];
	return fa[x][0];
}

ll qry(node x, node y, node z, node w, ll l, ll r, ll k) {
	if(l == r) return l;
	ll sum = t[x.l].sum + t[y.l].sum - t[z.l].sum - t[w.l].sum;
	ll mid = (l + r) >> 1;
	if(k <= sum) return qry(t[x.l], t[y.l], t[z.l], t[w.l], l, mid, k);
	return qry(t[x.r], t[y.r], t[z.r], t[w.r], mid + 1, r, k - sum);
}

ll query(ll x, ll y, ll k) {
	ll la = lca(x, y);
	return b[qry(t[rt[x]], t[rt[y]], t[rt[la]], 
			     t[rt[fa[la][0]]], 1, q, k)];
}

int main() {
	n = read(), m = read();
	for(int i = 1; i <= n; i ++) b[i] = a[i] = read();
	for(int i = 1; i < n; i ++) {
		ll x = read(), y = read();
		v[x].push_back(y), v[y].push_back(x);
	}
	sort(b + 1, b + n + 1);
	q = unique(b + 1, b + n + 1) - b - 1;
	for(int i = 1; i <= n; i ++) 
		id[i] = lower_bound(b + 1, b + q + 1, a[i]) - b;
	build(t[rt[0] = (++ cnt)], 1, q), dfs(1, 0);
	while(m --) {
		ll x = read(), y = read(), k = read();
		ll res = query(x ^ lst, y, k);
		cout << res << "\n"; lst = res;
	}
	return 0;
}
posted @ 2026-02-06 16:51  So_noSlack  阅读(23)  评论(0)    收藏  举报