主席树入门讲解

是时候炒炒冷饭了

本人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}

那么他的线段树的图应该长这样:

111

(这里就不过多解释了,没理解的欢迎来问我)

主席树(可持久化线段树)

还是上面那个图:

111

现在让你多了 \(100\)\(3\) (翻译:把 \(3~3\) 这个区间的值从 \(0\) 改为 \(100\) ,就是表示 \(3\) 的权值多了 \(100\)

思考:现在这个线段树会变成什么样?一共改变了几个节点?

显而易见,变一下 \([3~3]\) 的值,再往上递归变化,一共改变了 \(3\) 个节点

图:

222

冷知识:现在还没开始讲可持久化线段树哦,这只是最简单的线段树更新

OK,假设可爱的出题人让你更新了 \(n\) 次(就像上面的那种操作一样)。突然,出题人突发恶疾,竟然想让你又返回来,在很久之前的更新了 \(x\) 次的线段树上重新更新(这里我们设定 \(x < n\)

现在想想怎么解决这样的操作?很简单,我们可以想到一种暴力:存 \(n\) 个线段树,每次访问第 \(x\) 次操作时就调用我们存的第 \(x\) 个线段树。换句话说,这种暴力就是更新一次就在上一个树的基础上建一颗新的。

想想这种方法的可行度:时间空间双炸,直接炸爽了。

主席树

然后就引出主角,主席树😍😍😍

为什么叫主席树呢?我也不知道,根据网上资料显示就是都不知道为什么会取这个名字。

前面说过,我们更新一棵树时只会更新一条链上的数值

图来~~~

更新区间 \(3~3\) 时的=需要更新的一条链:

111

有线段树基础的都知道,我们做线段树的更新操作时,一般最多只会更新 \(log(n)\) 个节点,这里也一样,我们在做更新操作时只更新这么多节点。那这样其他节点都空着不用更新了,怎么利用?

天才般的你想到,为何不直接在原树的基础上直接建图呢?这样不仅时间短,空间也比原来的暴力小了很多。

举例:

还是前面的例子复用一下,懒得重新画了

假设一个数组 \(a\) = {1,1,2,4}

更新区间 \(4~4\) 的值为100

在原树上新建了节点后的图:

333

红色线条为新建边,红色字符为新建节点

每次都在前面的基础上像这样一直建边,在查询第 \(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\) 了。为什么?因为每次新建的点的编号不是这么编排号码的,他是挨个排序,不讲原来那套规律的。

未排新节点编号时的图:

555

原树的编号还是遵循原来的规律的。

更新一次后排了编号的图:

666

更新点就是从上往下排新编号,不遵循规律了。

所以现在就是要新建一个数组来记录每个节点的左子节点,右子节点。十分轮椅。这在建图中有所体现:

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;
}
posted @ 2026-09-19 08:49  头像是美食  阅读(4)  评论(0)    收藏  举报