主席树入门讲解
是时候炒炒冷饭了
本人OIWiki自学如何输出"Hello World"也太强了吧
主席树入门讲解
前置知识:线段树
权值线段树
要想接触主席树首先应该解决的就应该是权值线段树。
神马是权值线段树?
顾名思义,权值线段树于权值有关。
权值(知道请跳过)
权值:表示的是一个数在数组中出现的次数
例子:
\(a={1,2,1,3,3,4}\)
\(1\) 的权值是多少?小学生都知道,是 \(2\) ( \(1\) 在 \(a\) 数组中出现了 \(2\) 次);
\(2\) 的权值是多少?小学生都知道,是 \(1\) ( \(2\) 在 \(a\) 数组中出现了 \(1\) 次);
\(3\) 的权值是多少?小学生都知道,是 \(2\) ( \(3\) 在 \(a\) 数组中出现了 \(2\) 次);
\(4\) 的权值是多少?小学生都知道,是 \(1\) ( \(4\) 在 \(a\) 数组中出现了 \(1\) 次);
现在还不知道权值是什么的可以师傅掉头去网吧了。
权值线段树(知道请跳过)
区别一下:权值线段树 \(\neq\) 带权线段树。
权值线段树:某个或某段节点里存的所有数的“出现次数”的总和的线段树;
感性理解:就是 \(l~r\) 的这个区间中有多少个数
假设一个数组 \(a\) = {1,1,2,4}
那么他的线段树的图应该长这样:

(这里就不过多解释了,没理解的欢迎来问我)
主席树(可持久化线段树)
还是上面那个图:

现在让你多了 \(100\) 个 \(3\) (翻译:把 \(3~3\) 这个区间的值从 \(0\) 改为 \(100\) ,就是表示 \(3\) 的权值多了 \(100\) )
思考:现在这个线段树会变成什么样?一共改变了几个节点?
显而易见,变一下 \([3~3]\) 的值,再往上递归变化,一共改变了 \(3\) 个节点
图:

冷知识:现在还没开始讲可持久化线段树哦,这只是最简单的线段树更新
OK,假设可爱的出题人让你更新了 \(n\) 次(就像上面的那种操作一样)。突然,出题人突发恶疾,竟然想让你又返回来,在很久之前的更新了 \(x\) 次的线段树上重新更新(这里我们设定 \(x < n\) )
现在想想怎么解决这样的操作?很简单,我们可以想到一种暴力:存 \(n\) 个线段树,每次访问第 \(x\) 次操作时就调用我们存的第 \(x\) 个线段树。换句话说,这种暴力就是更新一次就在上一个树的基础上建一颗新的。
想想这种方法的可行度:时间空间双炸,直接炸爽了。
主席树
然后就引出主角,主席树😍😍😍
为什么叫主席树呢?我也不知道,根据网上资料显示就是都不知道为什么会取这个名字。
前面说过,我们更新一棵树时只会更新一条链上的数值
图来~~~
更新区间 \(3~3\) 时的=需要更新的一条链:

有线段树基础的都知道,我们做线段树的更新操作时,一般最多只会更新 \(log(n)\) 个节点,这里也一样,我们在做更新操作时只更新这么多节点。那这样其他节点都空着不用更新了,怎么利用?
天才般的你想到,为何不直接在原树的基础上直接建图呢?这样不仅时间短,空间也比原来的暴力小了很多。
举例:
还是前面的例子复用一下,懒得重新画了
假设一个数组 \(a\) = {1,1,2,4}
更新区间 \(4~4\) 的值为100
在原树上新建了节点后的图:

红色线条为新建边,红色字符为新建节点
每次都在前面的基础上像这样一直建边,在查询第 \(x\) 次的线段树就可以直接访问了。
时间和空间分析
假设原来有 \(n\) 个元素。
每次新建节点时间开销为 \(log(n)\) ,\(m\) 次操作的时间开销为 \(m \times log(n)\)。
每次新建节点空间开销为 \(log(n)\) ,\(m\) 次操作的空间开销为 \(n \times log(n) + m \times log(n)\)。
细节(必看)
关于左右子节点
首先,不能按照以前的写法写 \(n \times 2\) 和 \(n \times 2 + 1\) 了。为什么?因为每次新建的点的编号不是这么编排号码的,他是挨个排序,不讲原来那套规律的。
未排新节点编号时的图:

原树的编号还是遵循原来的规律的。
更新一次后排了编号的图:

更新点就是从上往下排新编号,不遵循规律了。
所以现在就是要新建一个数组来记录每个节点的左子节点,右子节点。十分轮椅。这在建图中有所体现:
ll aaa(ll l, ll r, ll P, ll pos) {
ll xxx = ++cnt; // ① 新建节点
zb[xxx] = zb[P]; // ② ★ 先照抄旧节点的左儿子(没改的那半边)
yb[xxx] = yb[P]; // ③ ★ 先照抄旧节点的右儿子
sum[xxx] = sum[P] + 1; // ④ 这段值域多了一个数
if (l == r) return xxx; // ⑤ 到叶子(值 pos 的格子),结束
ll mid = (l + r) / 2; // ⑥ 一分为二
if (pos <= mid) // ⑦ 要插的值在左半边
zb[xxx] = aaa(l, mid, zb[xxx], pos); // ★ 只重建左儿子
else // ⑧ 否则在右半边
yb[xxx] = aaa(mid + 1, r, yb[xxx], pos); // ★ 只重建右儿子
return xxx; // ⑨ 把新节点编号返回给上一层
}
课后练习
习题:「洛谷 P3834」「模板」可持久化线段树2
题意
给定 \(n\) 个整数构成的序列,将对于指定的闭区间查询其区间内的第 \(k\) 小值。
分析
普通做法:
假设问你 \(l~r\) 的区间内的第 \(k\) 小的值 (\(1 \le l \le r \le n\) 并且 \(k \le r-l+1\))。
你需要建一个权值线段树很好理解吧,但是当前已经建的图是 \(1~n\) 的所有值的图,问你 \(l~r\) 的你还要先回溯到插入 \(r\) 的地方才能做,所以自然而然就可以想到用主席树。
主席树做法:
其实就是我们一直在阐述的东西,所以就不讲太多了。还是在插入时每插入一个数就更新一次,用主席树优化就是一次加 \(log(n)\) 个点,显然能过。
首先你要先明白命名:
ll n,m,a[N]; // 输入需要的变量和数组
ll b[N]; // b:离散化后的数组a (具体离散化我掌握的并不是很好)
ll zj[N]; //zj[i]表示为第 i 个版本的根的编号
// zb[i]:表示“左边”。在树上表示的就是第 i 个节点的左子节点编号
// yb[i]:表示“右边”。在树上表示的就是第 i 个节点的右子节点编号
ll zb[N],yb[N];
ll sum[N]; // sum[i]:节点 i 表示的值域中有多少个数,也就是节点 i 所表示的值域的权值
ll cnt; // 当前已经有多少个点了。在添加点时, 新点的编号则是 ++cnt(cnt还要自更新)
应该都理解命名吧。接下来看看建树操作:
// 建树操作
// 传入:
// l, r 当前节点所管的值域区间 [l, r],一开始是 [1, q]
// x 引用!要建的这个节点的编号会被写回到 x 里。
// 因为编号是动态分配的、算不出来,所以必须靠"写回"来挂到父节点的孩子指针上
// 返回:无(新编号通过引用 x 带出去)
void build(ll l, ll r, ll &x)
{
x = ++cnt; // 把新建的节点编号赋值
sum[x] = 0; // 初始化一下
if (l == r) return; // 如果是叶子节点,下面没有子节点就直接结束即可
// 下面是线段树的常规递归
ll mid = (l + r) / 2;
build(l, mid, zb[x]); // 建左儿子:编号写进 zb[x]
build(mid + 1, r, yb[x]); // 建右儿子:编号写进 yb[x]
}
这里的建树可以看成是原来最简单的那种建树操作,还没有到动态开点。
// 更新版本,也可以说是加点操作
// 传入:
// l, r 当前节点所管的值域区间 [l, r]
// P 旧版本里"当前这个位置"的节点编号(老节点),新节点要照抄它的左右儿子
// pos 要插入的数值,是离散化之后的编号(它会一路走到代表这个值的叶子)
// 返回:新长出来的这个节点的编号(给上一层当孩子用)
ll aaa(ll l, ll r, ll P, ll pos)
{
ll xxx = ++cnt; // 把新建的节点编号赋值
zb[xxx] = zb[P]; // 左节点先赋值为旧节点的左儿子
yb[xxx] = yb[P]; // 右节点先赋值为旧节点的右儿子
sum[xxx] = sum[P] + 1; // 节点数就是旧节点的节点数 + 1
if (l == r) return xxx; // 如果是叶子节点,下面没有子节点就直接结束即可
// 常规往下递归
ll mid = (l + r) / 2;
if (pos <= mid) zb[xxx] = aaa(l, mid, zb[xxx], pos); // 只重建左儿子
else yb[xxx] = aaa(mid + 1, r, yb[xxx], pos); // 只重建右儿子
return xxx;
}
这里是加点操作,还是比较好理解的。
// 找答案
// 传入:
// l, r 当前节点所管的值域区间 [l, r]
// L 靠前那个版本(第 l-1 个版本)在当前区间的节点编号
// R 靠后那个版本(第 r 个版本)在当前区间的节点编号
// k 要在这个区间的数里找第 k 小
// 返回:答案的离散化编号(最后还要用 b[编号] 换回真实数值)
ll getans(ll l, ll r, ll L, ll R, ll k)
{
if (l == r) return l; // 找到叶子节点,答案就是它本身
// 算 tot:左半边在"两个版本的差"里有几个数
// sum[zb[R]] = 版本 R 的左半边有几个数
// sum[zb[L]] = 版本 L 的左半边有几个数
ll mid = (l + r) / 2, tot = sum[zb[R]] - sum[zb[L]];
if (tot >= k) return getans(l, mid, zb[L], zb[R], k); // 左半边够装第 k 小 → 去左边
return getans(mid + 1, r, yb[L], yb[R], k - tot); // 不够 → 去右边,k 要减掉左边那 tot 个
}
这里是找答案,就是那种最普通的权值线段树找答案思路。
代码
#include <bits/stdc++.h>
using namespace std;
#define ll int // 用 int 就行,省内存(cnt/sum 不会超过 1e9)
const ll N = 5e6 + 5; // 节点池大小:n*(log2(n)+2) 再留余量
// n = 2e5 → 2e5*20 = 4e6,开 5e6 够用
ll n, m; // n 个数,m 次询问
ll a[N]; // 原数组 a[1..n](这里开得偏大,其实 2e5 就够)
ll b[N]; // 离散化用的排序数组
ll zj[N]; // ★ 版本根数组:zj[i] = 第 i 个版本的根节点编号
// 对应讲解里的 rt[],"zj" = 自己起的名字
ll zb[N]; // 左儿子编号 left son
ll yb[N]; // 右儿子编号 right son
ll sum[N]; // sum[x] = 节点 x 这段值域里装了几个数(权值/出现次数)
ll cnt = 0; // 已分配的节点数;每次 ++cnt 拿一个新编号
// ★ 0 号节点永远空着:zb[0]=yb[0]=sum[0]=0
// ============================================================
// build:建"版本 0"(一棵所有格子都是 0 的空树)
// l, r : 当前节点管的值域区间
// x : 引用!函数会把"新建的节点编号"写回这个变量
// —— 这就是"把新节点挂到父节点的孩子指针上"的写法
// ============================================================
void build(ll l, ll r, ll &x) {
x = ++cnt; // ① 新建节点,编号 cnt
sum[x] = 0; // ② 空树里一个数都没有
if (l == r) return; // ③ 到叶子,结束
ll mid = (l + r) / 2; // ④ 一分为二
build(l, mid, zb[x]); // ⑤ 递归建左儿子,编号写进 zb[x]
build(mid + 1, r, yb[x]); // ⑥ 递归建右儿子,编号写进 yb[x]
}
// 说明:版本 0 也可以像标准模板那样直接 zj[0] = 0(不建树),
// 这样能省下 q 个节点;你这种"老老实实建一棵空树"的写法也对,
// 只是会多花约 2q 个节点。
// ============================================================
// aaa:插入一个数,长出一条新链(对应讲解里的 update / modify)
// l, r : 当前节点的值域区间
// P : 旧版本里这个位置的节点编号("老节点")
// pos : 要插入的数值(离散化后的编号)
// 返回 : 新节点的编号
// ============================================================
ll aaa(ll l, ll r, ll P, ll pos) {
ll xxx = ++cnt; // ① 新建节点
zb[xxx] = zb[P]; // ② ★ 先照抄旧节点的左儿子(没改的那半边)
yb[xxx] = yb[P]; // ③ ★ 先照抄旧节点的右儿子
sum[xxx] = sum[P] + 1; // ④ 这段值域多了一个数
if (l == r) return xxx; // ⑤ 到叶子(值 pos 的格子),结束
ll mid = (l + r) / 2; // ⑥ 一分为二
if (pos <= mid) // ⑦ 要插的值在左半边
zb[xxx] = aaa(l, mid, zb[xxx], pos); // ★ 只重建左儿子
else // ⑧ 否则在右半边
yb[xxx] = aaa(mid + 1, r, yb[xxx], pos); // ★ 只重建右儿子
return xxx; // ⑨ 把新节点编号返回给上一层
}
// 注意 ⑦⑧ 里的 zb[xxx] 就是 ② 里照抄来的值 —— 拿它当"老节点"往下递归,
// 递归返回的新节点编号又写回 zb[xxx]。这两步合起来就是"一路复制,兄弟指旧"。
// ============================================================
// getans:在第 L 个版本和第 R 个版本的"差树"上找第 k 小
// l, r : 当前节点的值域区间
// L : 靠前版本的节点编号(从 zj[l-1] 一路走下来)
// R : 靠后版本的节点编号(从 zj[r] 一路走下来)
// k : 找第 k 小
// 返回 : 答案的离散化编号
// ============================================================
ll getans(ll l, ll r, ll L, ll R, ll k) {
if (l == r) return l; // ① 到叶子,叶子代表的值就是 l
ll mid = (l + r) / 2;
// ② 算 tot:左半边在"两个版本的差"里有几个数
// sum[zb[R]] = 版本 R 的左半边有几个数
// sum[zb[L]] = 版本 L 的左半边有几个数
ll tot = sum[zb[R]] - sum[zb[L]];
if (tot >= k) // ③ 左半边够装第 k 小 → 去左边
return getans(l, mid, zb[L], zb[R], k);
// 注意 L、R 两个指针【都】往左走
return getans(mid + 1, r, yb[L], yb[R], k - tot);
// ④ 不够 → 去右边,k 要减掉左边那 tot 个
}
int main() {
ios::sync_with_stdio(false); // 关掉 cin/cout 与 scanf/printf 的同步,加速
cin.tie(0); cout.tie(0); // 解绑 cin 和 cout
cin >> n >> m;
for (ll i = 1; i <= n; i++) {
cin >> a[i];
b[i] = a[i]; // b 用来做离散化
}
// ---------- 离散化:把大数值压成"第几小" ----------
sort(b + 1, b + n + 1); // 排序
ll q = unique(b + 1, b + n + 1) - b - 1; // 去重,q = 不同数值的个数 = 值域大小
// memset(sum,0,sizeof sum); ← 删掉!全局数组本来就是 0,这句白花时间
// ---------- 建版本 0(空树)----------
build(1, q, zj[0]); // zj[0] 拿到版本 0 的根编号
// ---------- 一个一个插入,生成版本 1..n ----------
for (ll i = 1; i <= n; i++) {
ll p = lower_bound(b + 1, b + q + 1, a[i]) - b; // 数值 → 离散化编号
zj[i] = aaa(1, q, zj[i - 1], p); // 从上一版本派生新版本
// ↑ 新根编号 ↑ 老根 ↑ 要插的值
}
// ---------- 回答询问 ----------
while (m--) {
ll l, r, k;
cin >> l >> r >> k;
// 在"版本 r 减版本 l-1"的差树上找第 k 小
ll po = getans(1, q, zj[l - 1], zj[r], k);
// ↑ 靠前版本 ↑ 靠后版本
cout << b[po] << endl; // 把离散化编号换回原来的数值
}
return 0;
}

浙公网安备 33010602011771号