CC++ & Algorithm

可持久化线段树(主席树):时光倒流的区间查询

较难6
语言版本:C++
概述:可持久化线段树(主席树)可以保留每次修改的历史版本,让我们能随时查询过去某个时刻的区间信息,常用于求区间第K大等问题。

可持久化线段树(主席树):给数据装一台“时光机”

你有没有想过,如果写错了题目能回到之前的状态重来就好了?可持久化线段树就给了数据一种“后悔药”:每一次修改都会生成一个新版本,同时保留旧版本,你可以随时访问任意历史版本的数据。因为发明者黄嘉泰(HJT)名字缩写像“主席”,所以常被称为主席树。

生活中的“时间旅行”

想象一下,你正在用铅笔写数学作业,每做完一题就想拍张照片保存。如果后面写错了,你可以擦掉重写,但是还能翻出之前拍的照片看看原来是怎么写的。主席树就是给每个历史时刻拍了一张“快照”。

更贴近你的生活:打游戏时,你常常会在打Boss前手动存档,万一打不过就读取存档重来。主席树允许你在数组上做这样的操作——每次修改(比如把某个位置的值改掉)都会生成一个新版本,而旧版本依然存在。你可以随时查询任意版本中任意区间里的信息。

核心思想:只新增,不复用旧节点

普通的线段树一次修改需要修改从根到叶子的整条路径上所有节点,代价是O(log N)。如果每次修改都完整复制一棵树,那空间和时间都爆炸。主席树的高明之处在于:每次修改只新建路径上的 log N 个节点,其余节点继续延用旧版本的指针。这样一来,每修改一次只增加 O(log N) 的空间,总空间为 O(N log N)。

从生活看空间节省

假设你有N个同学,每个同学手里有一份全班成绩单(存着身高数据)。如果每个人都在自己的那份上修改一个数,普通做法是每人抄整份成绩单,成本太高。主席树的做法是:第一个人拿着原始成绩单,第二个人只把修改的那个数字所在的那一行重写一遍,然后指着第一份的其他行说“那些行还是原来的”。第三个人又在第二份的基础上只重写自己改的那一行……这样所有成绩单共用大部分行,省下了很多纸。

最常见应用:静态数组的区间第K小(大)值

主席树最经典的应用就是求一个固定数组的任意区间 [l, r] 中,第 k 小的数是多少。注意这里数组的值之后不会修改(称为“静态”),但我们有多次查询,每次区间和 k 都不同。

核心做法:前缀和 + 差分

  1. 将原数组的值离散化(把数值映射到 1~M,M 是不同数值个数)。
  2. 对数组的每个前缀 i(即前 i 个数)建立一棵值域线段树:这棵树存的是前 i 个数中,每个数值出现的次数(即桶)。树中每个节点 [L,R] 表示这个前缀里数值在 [L,R] 之间的数有多少个。
  3. 要查询区间 [l, r] 的信息,用前缀 r 的树减去前缀 l-1 的树,就能得到区间内每个数值的个数。这就是“差分”思想,像这样:sum_in_interval = tree[r] - tree[l-1]
  4. 然后在差值树上找第 k 小:如果左子树中的总数(区间内数值范围在左半边的数的个数)>= k,则答案在左半边;否则在右半边,此时 k 减去左子树的个数。

为什么叫“主席树”?

因为每个前缀一棵树,就像每个主席(校长)都有自己的档案柜;而树之间通过共享节点形成一种“可持久化”结构,名字便由此而来。

手把手理解代码:求区间第K小(离散化后)

下面这段代码实现了上面说的过程。请逐行看注释,理解每一步。

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 200005;  // 数组最大长度

struct Node {
    int l, r, sum;   // l:左孩子节点编号, r:右孩子节点编号, sum:该节点代表的区间内的数值个数
} t[MAXN * 20];      // 主席树节点数组,开20倍左右(通常N*4*logN够用)
int root[MAXN], cnt; // root[i]:第i个版本的树根节点编号, cnt:当前已创建的节点总数
int n, q;            // n:数组长度, q:查询次数
int arr[MAXN];       // 原数组(1-indexed)
vector<int> v;       // 用于离散化的有序向量(存放所有出现过的数值)

// 建立一棵空的值域线段树,返回根节点编号
int build(int l, int r) {
    int p = ++cnt;          // 生成新节点编号
    t[p].sum = 0;            // 初始个数为0
    if (l == r) return p;   // 叶子节点直接返回
    int mid = (l + r) / 2;
    t[p].l = build(l, mid);
    t[p].r = build(mid+1, r);
    return p;
}

// 更新:基于前一个版本pre,在位置pos上增加一个数(数值对应的离散化下标)
// 返回新版本根节点编号
int update(int pre, int l, int r, int pos) {
    int p = ++cnt;           // 新建当前节点
    t[p] = t[pre];           // 先复制旧节点的左右孩子指针和sum
    t[p].sum++;              // 当前区间多了一个数
    if (l == r) return p;    // 叶子节点更新完毕
    int mid = (l + r) / 2;
    if (pos <= mid)
        t[p].l = update(t[pre].l, l, mid, pos);  // 左子树新建,右子树继续沿用旧版本
    else
        t[p].r = update(t[pre].r, mid+1, r, pos); // 右子树新建
    return p;
}

// 查询区间第k小:u对应前l-1个数的版本,v对应前r个数的版本
// 在值域[l,r]内找第k小的数值的离散化下标
int query(int u, int v, int l, int r, int k) {
    if (l == r) return l;    // 找到了叶子,返回该数值的离散化下标
    int mid = (l + r) / 2;
    int leftSum = t[t[v].l].sum - t[t[u].l].sum;  // 左子树在区间内的个数 = v的左子树个数 - u的左子树个数
    if (k <= leftSum)
        return query(t[u].l, t[v].l, l, mid, k);
    else
        return query(t[u].r, t[v].r, mid+1, r, k - leftSum);
}

int main() {
    scanf("%d%d", &n, &q);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &arr[i]);
        v.push_back(arr[i]);  // 收集所有数值用于离散化
    }
    // 离散化:排序 + 去重
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());
    // 建立第0个版本(空树),所有值出现次数为0
    root[0] = build(1, v.size());
    // 依次插入每个数,生成第i个版本
    for (int i = 1; i <= n; i++) {
        // pos是arr[i]在离散化后的下标(从1开始)
        int pos = lower_bound(v.begin(), v.end(), arr[i]) - v.begin() + 1;
        root[i] = update(root[i-1], 1, v.size(), pos);
    }
    // 处理查询
    while (q--) {
        int l, r, k;
        scanf("%d%d%d", &l, &r, &k);
        int idx = query(root[l-1], root[r], 1, v.size(), k);
        printf("%d\n", v[idx-1]);  // 将离散化下标转回原数值
    }
    return 0;
}

代码中的常见“坑”和注意点

  1. 节点数组大小要开够:一般开 MAXN * 20MAXN * (log2(MAXN)+2)。如果忘了开大,会因数组越界导致奇怪错误(比如 RE 或 WA)。
  2. 离散化是必须的:值域线段树要求值域范围在可接受的区间内(比如 1~1e5)。如果直接用原数值,可能值域特别大(如 10^9),线段树节点会爆炸。
  3. 注意下标是1-indexed还是0-indexed:我们代码中用1-indexed,arr[1]~arr[n],root[0]表示空前缀。query中root[l-1]root[r]对应左闭右开区间。
  4. 查询时 k - leftSum 的位置:当进入右子树时,k必须减去左子树的总数,因为左子树已经包含了前leftSum个小的数。
  5. 空间回收?不需要:主席树创建的所有节点一直保留,因为每个历史版本都可能被查询。所以不用担心内存泄漏,只要节点数组开够就行。
  6. 多组数据时要重置cnt和root[0]:如果有多组测试数据,每次开始前记得 cnt=0; 并重新建空树。

扩展:可持久化线段树还能做什么?

  • 动态区间第K小:结合树状数组(BIT)或线段树套主席树,可以实现支持修改的区间第K小(树套树)。
  • 可持久化数组:直接用主席树实现一个可以回退版本的一维数组,每个叶子存一个值。每次修改只新建一条路径。
  • 可持久化并查集:把并查集的 fa 数组和 size 数组用可持久化数组实现,就能得到支持历史版本查询的并查集。
  • 统计区间内不同数的个数:用主席树维护每个数上一次出现的位置,也能巧妙求解。

相关知识点指引

  • 线段树(普通版):主席树的基础,必须先掌握普通线段树的建树、更新、查询。
  • 二分答案 + 值域线段树:如果把主席树换成每次二分值域,也可以求区间第K小,但会慢很多。
  • 树套树:如线段树套平衡树,也能解决动态区间第K小,但常数大、代码长。
  • 可持久化Trie(字典树):思路类似,常用于解决二进制异或相关的问题(如最大异或和)。

主席树让区间查询拥有了“时间旅行”的能力,是处理历史数据的经典方法。学会它,你就多了一把解决区间统计问题的利器。动手写代码,多跑几个样例试试吧!

例题精讲

1单选题

可持久化线段树(主席树)的核心特点是什么?

A支持区间修改操作
B可以查询任意历史版本
C只能用于静态数组
D空间复杂度与普通线段树相同
2判断题

在构建可持久化线段树时,每次插入一个数需要新建 O(log n) 个节点。

3填空题
以下为主席树查询区间第 k 小的核心递归代码,补全缺失部分。
int query(int u, int v, int l, int r, int k) {
    if (l == r) return l;
    int mid = (l + r) >> 1;
    int left = tree[tree[u].l].sum - tree[tree[v].l].sum;
    if (k <= left)
        return query(tree[u].l, tree[v].l, l, mid, k);
    else
        return query(tree[u].r, tree[v].r, mid + 1, r, ___);
}