欢迎光临
我们一直在努力

主席树:从“区间求和”到“可持久化”的线段树进阶

引言

普通线段树能高效地维护一个数组的动态变化,但它有一个“致命缺陷”:修改会覆盖历史。当你更新了某个节点,之前版本的数据就永远丢失了。

如果问题是:给定一个序列,每次查询某个历史版本的状态怎么办?或者更经典一些——查询区间 [L, R] 中第 k 小的数,你会发现普通线段树完全无能为力。

主席树(可持久化线段树) 的出现就是为了解决这个问题。它的核心思想是:每次修改只新建被影响的那条链上的节点,其余节点与上一版本共用。这样,每个历史版本的根节点都被保存下来,你可以随时“回到过去”查询任意版本的数据。

如果把普通线段树比作“在黑板上写字,擦了就没了”,那么主席树就是 “每次修改都拍一张快照,只记录变化的部分”——你拥有了整条时间线,可以随时穿越回任意历史时刻。


前置知识

在学习主席树之前,你需要掌握以下内容:

  • 线段树基础:建树、单点修改、区间查询、懒标记。主席树是线段树的扩展,不理解线段树就无法理解主席树。

  • 权值线段树:线段树的下标不再是数组位置,而是值域。每个节点维护的是该值域区间内元素出现的次数。

  • 离散化:当值域很大时(如 1e9),需要将数据映射到连续的整数区间,否则无法建树。

  • 前缀和思想:主席树查询区间时,利用 [1, R] 版本减去 [1, L-1] 版本得到 [L, R] 的信息。

  • 动态开点:主席树的节点编号不是 2x 和 2x+1,而是用结构体数组记录左右儿子的下标。


  • 第一章:从“问题”到“思想”——主席树是什么?

    1.1 一个经典问题:静态区间第 k 小

    给定长度为 n 的序列,m 次询问,每次查询区间 [L, R] 中第 k 小的数。

    如果只查一次全局的第 k 小,排序即可。但每次查询的区间都不同,怎么办?

    朴素思路:对每个位置 i,建一棵线段树维护 [1, i] 的权值信息。查询 [L, R] 时,用第 R 棵树减去第 L-1 棵树,得到区间内的权值分布,然后在线段树上二分找第 k 小。

    但暴力建 n 棵完整的线段树,空间是 O(n²),不可行。

    1.2 主席树的“省空间”魔法

    仔细观察:从第 i-1 棵树到第 i 棵树,我们只插入了一个新元素。权值线段树中,这个修改只会影响从根到对应叶子节点的一条路径,共 O(log n) 个节点。

    主席树的策略是:

    • 新建一个根节点 root[i]。

    • 从 root[i] 开始,沿着插入值对应的路径,新建这条路径上的所有节点。

    • 路径之外的节点,直接指向上一版本 root[i-1] 中对应的节点。

    这样,每插入一个元素只新建 O(log n) 个节点,总空间 O(n log n)。

    可以把主席树想象成一棵“有很多根”的树。每个根对应一个版本,版本之间共享大部分节点。查询时从对应的根出发,就像进入了那个版本的“平行宇宙”。


    第二章:算法步骤与代码实现

    2.1 数据结构定义

    主席树采用动态开点,不能用堆式存储(2x、2x+1)。我们需要用数组记录每个节点的左右儿子和权值:

    const int MAXN = 200005;
    const int MAXT = MAXN * 40; // n log n 级别,通常开 n << 5

    int root[MAXN]; // 每个版本的根节点编号
    int ls[MAXT], rs[MAXT]; // 左右儿子
    int sum[MAXT]; // 该节点维护的权值出现次数
    int tot = 0; // 节点总数

    2.2 建树(空树)

    先建一棵空的权值线段树,所有节点的 sum 都为 0。这是所有版本的“基准”:

    int build(int l, int r) {
    int now = ++tot;
    if (l == r) return now;
    int mid = (l + r) >> 1;
    ls[now] = build(l, mid);
    rs[now] = build(mid + 1, r);
    return now;
    }

    2.3 修改(插入一个值)

    在上一版本 pre 的基础上,插入值 pos,新建一条链:

    int modify(int pre, int l, int r, int pos) {
    int now = ++tot;
    ls[now] = ls[pre];
    rs[now] = rs[pre];
    sum[now] = sum[pre] + 1; // 当前节点多了一个数

    if (l == r) return now;

    int mid = (l + r) >> 1;
    if (pos <= mid) ls[now] = modify(ls[pre], l, mid, pos);
    else rs[now] = modify(rs[pre], mid + 1, r, pos);

    return now;
    }

    关键点:

    • 新建节点 now 先拷贝上一版本 pre 的左右儿子和 sum。

    • sum[now] = sum[pre] + 1 表示当前值域区间多了一个数。

    • 递归修改对应子树,另一棵子树直接沿用 pre 的节点。

    • 每层只新建一个节点,共新建 O(log n) 个节点。

    2.4 查询(区间第 k 小)

    查询 [L, R] 的第 k 小,同时从 root[R] 和 root[L-1] 向下走:

    int query(int u, int v, int l, int r, int k) {
    // u = root[R], v = root[L-1]
    if (l == r) return l; // 找到第 k 小对应的离散化值

    int mid = (l + r) >> 1;
    // 左子树中 [L, R] 区间的数的个数
    int left_cnt = sum[ls[u]] – sum[ls[v]];

    if (k <= left_cnt) return query(ls[u], ls[v], l, mid, k);
    else return query(rs[u], rs[v], mid + 1, r, k – left_cnt);
    }

    关键点:

    • sum[ls[u]] – sum[ls[v]] 得到区间 [L, R] 内落在左值域区间的数的个数。

    • 如果 k <= left_cnt,第 k 小在左子树;否则在右子树找第 k – left_cnt 小。

    2.5 完整的主函数流程

    int main() {
    int n, m;
    cin >> n >> m;

    vector<int> a(n + 1);
    vector<int> vals;
    for (int i = 1; i <= n; i++) {
    cin >> a[i];
    vals.push_back(a[i]);
    }

    // 离散化
    sort(vals.begin(), vals.end());
    vals.erase(unique(vals.begin(), vals.end()), vals.end());

    root[0] = build(1, vals.size());

    for (int i = 1; i <= n; i++) {
    int pos = lower_bound(vals.begin(), vals.end(), a[i]) – vals.begin() + 1;
    root[i] = modify(root[i – 1], 1, vals.size(), pos);
    }

    while (m–) {
    int L, R, k;
    cin >> L >> R >> k;
    int ans_pos = query(root[R], root[L – 1], 1, vals.size(), k);
    cout << vals[ans_pos – 1] << '\\n';
    }
    return 0;
    }

    空间提醒:节点数上限一般为 n * (log2(n) + 1) + 4n,大约 n << 5 即可。如果 n 达到 2e5,MAXT = MAXN * 40 是安全值。


    第三章:复杂度与性质

    操作时间复杂度空间复杂度
    建树(空树) O(n) O(n)
    单点插入(修改) O(log V) O(log V) 新增节点
    区间查询 O(log V) O(1)
    总体 O((n+m) log V) O(n log V)

    其中 V 是值域大小(离散化后为元素个数)。

    关键性质:

    • 可持久化:每个历史版本的根节点 root[i] 都被保存,可以查询任意版本。

    • 可减性:主席树维护的信息满足区间减法,因此可以用两个版本“相减”得到任意区间的信息。

    • 静态 vs 动态:上述实现是静态主席树——所有数据预先知道,只查询不修改。如果需要支持修改,需要套上树状数组,即动态主席树。


    第四章:例题与详细解析

    例题1:【模板】可持久化线段树 1(主席树)—— 洛谷 P3834

    题目描述
    给定 n 个整数构成的序列,m 次查询,每次查询区间 [L, R] 内的第 k 小值。

    输入示例

    5 5
    25957 6405 15770 26287 26465
    2 2 1
    3 4 1
    4 5 1
    1 2 2
    4 4 1

    输出示例

    6405
    15770
    26287
    25957
    26287

    解题思路
    这就是主席树的模板题,直接套用上面的完整代码即可。

    详细解析

    第一步:理解数据
    序列长度为 5,值为 [25957, 6405, 15770, 26287, 26465]。离散化后,这些值分别对应 [3, 1, 2, 4, 5]。

    第二步:构建版本

    • root[0]:空树,所有节点 sum = 0。

    • root[1]:插入 25957(离散化值 3)。新建从根到叶子 3 的路径(共 log 5 ≈ 3 个节点)。

    • root[2]:在 root[1] 基础上插入 6405(离散化值 1)。新建从根到叶子 1 的路径,其他节点指向 root[1]。

    • 依此类推,得到 5 个版本的根节点。

    第三步:查询 [2, 2] 的第 1 小

    • 用 root[2] – root[1] 得到只包含第 2 个元素的权值线段树。

    • 在树上二分找第 1 小,得到离散化值 1,对应原值 6405。

    第四步:查询 [3, 4] 的第 1 小

    • 用 root[4] – root[2] 得到区间 [3, 4] 的权值分布:元素为 [15770, 26287]。

    • 第 1 小是 15770。

    复杂度:建树 O(n log n),每次查询 O(log n),总复杂度 O((n+m) log n)。

    代码要点:

    • 离散化时用 sort + unique + lower_bound。

    • root[0] = build(1, cnt) 建空树,所有 sum 默认为 0。

    • 查询时两个指针 u 和 v 同时移动。


    例题2:树上主席树 —— 洛谷 P2633 Count on a tree

    题目描述
    给定一棵 n 个节点的树,每个点有一个权值。m 次询问,每次给出 u, v, k,回答 u 到 v 路径上第 k 小的点权。

    输入示例

    8 5
    105 2 9 3 8 5 7 7
    1 2
    1 3
    1 4
    3 5
    3 6
    3 7
    4 8
    2 5 1
    2 5 2
    2 5 3
    2 5 4
    7 8 2

    输出示例

    2
    5
    8
    105
    7

    解题思路
    这道题将主席树从序列搬到了树上。

    关键在于:树上的一条路径可以拆成四个前缀。

    设 root[u] 表示从根节点到 u 的路径上所有点权构成的权值线段树。那么:

    • 从根到 u 的版本:root[u]

    • 从根到 v 的版本:root[v]

    • 从根到 LCA(u, v) 的版本:root[lca]

    • 从根到 LCA 的父节点的版本:root[fa[lca]]

    路径 u → v 上的权值分布 = root[u] + root[v] – root[lca] – root[fa[lca]]。

    查询第 k 小时,四个指针同时移动,用这个公式计算左子树的节点数。

    详细解析

    第一步:树上 DFS 建树
    从根节点 1 开始 DFS,每访问一个节点 u,就在父版本的基础上插入 a[u],得到 root[u]。

    void dfs(int u, int fa) {
    depth[u] = depth[fa] + 1;
    up[u][0] = fa;
    root[u] = modify(root[fa], 1, cnt, get_id(a[u]));
    for (int v : g[u]) {
    if (v == fa) continue;
    dfs(v, u);
    }
    }

    第二步:查询路径 [u, v] 的第 k 小

    int lca = get_lca(u, v);
    int fa_lca = up[lca][0];
    // 四个版本同时查
    int left_cnt = sum[ls[root[u]]] + sum[ls[root[v]]]
    – sum[ls[root[lca]]] – sum[ls[root[fa_lca]]];

    如果 k <= left_cnt,四个指针都走向左子树;否则走向右子树,k -= left_cnt。

    第三步:LCA 预处理
    用倍增法预处理 up[u][i],支持 O(log n) 查询 LCA。

    复杂度:预处理 O(n log n),每次查询 O(log n)。


    例题3:动态主席树(带修改)—— 洛谷 P2617 Dynamic Rankings

    题目描述
    给定一个序列,支持两种操作:

    • Q i j k:查询区间 [i, j] 中第 k 小的数。

    • C i t:将 a[i] 修改为 t。

    输入示例

    5 3
    3 2 1 4 7
    Q 1 4 3
    C 2 6
    Q 2 5 3

    输出示例

    3
    6

    解题思路
    静态主席树无法处理修改,因为修改一个位置会影响从该位置往后的所有版本(前缀和性质被破坏)。

    解决方案:树状数组套主席树。

    • 树状数组的每个节点 i 维护一棵权值线段树,表示 [i – lowbit(i) + 1, i] 区间内的元素分布。

    • 修改 a[pos] 时,更新树状数组上所有包含 pos 的节点(O(log n) 个),每个节点在权值线段树上修改(O(log n)),总复杂度 O(log² n)。

    • 查询 [L, R] 时,将 R 和 L-1 分别拆成 O(log n) 个树状数组节点,收集这些节点的根,同时计算左子树大小。

    详细解析

    第一步:离散化
    所有初始值和修改中出现的值都要参与离散化。

    第二步:树状数组维护线段树根

    int tree[MAXN]; // tree[i] 是树状数组节点 i 对应的线段树根
    void add(int idx, int pos, int delta) {
    for (int i = idx; i <= n; i += i & -i) {
    modify(tree[i], 1, tot, pos, delta);
    }
    }

    第三步:查询

    int query(int l, int r, int k) {
    // 收集 R 对应的节点到 vecR,L-1 对应的节点到 vecL
    for (int i = r; i > 0; i -= i & -i) vecR.push_back(tree[i]);
    for (int i = l – 1; i > 0; i -= i & -i) vecL.push_back(tree[i]);
    // 二分查找第 k 小
    while (l < r) {
    int left_sum = 0;
    for (int root : vecR) left_sum += sum[ls[root]];
    for (int root : vecL) left_sum -= sum[ls[root]];
    if (k <= left_sum) { /* 全部走向左子树 */ }
    else { /* 走向右子树,k -= left_sum */ }
    }
    }

    复杂度:修改 O(log² n),查询 O(log² n),空间 O((n+m) log² n)。


    例题4:主席树 + 二分答案 —— 洛谷 P4587 [FJOI2016] 神秘数

    题目描述
    给定序列,每次询问区间 [L, R],求最小的不能由区间内任意子集和表示的正整数。

    解题思路
    这是一个巧妙的转化题。

    核心性质:假设当前能表示的数的范围为 [1, x],那么下一个能扩展范围的数是所有 ≤ x+1 的数的和。如果这些数的和 > x,则范围可以扩展;否则 x+1 就是答案。

    主席树的作用:快速查询区间 [L, R] 中值域在 [1, x+1] 内的所有数的和。

    算法流程:

  • 初始化 ans = 1。

  • 循环:

    • 查询区间 [L, R] 内值域在 [1, ans] 的数的和 sum。

    • 如果 sum < ans,说明 ans 无法被表示,跳出。

    • 否则 ans = sum + 1,继续扩展。

  • 输出 ans。

  • 每次查询 O(log n),最多扩展 O(log n) 次(因为 ans 至少翻倍),总复杂度 O(log² n)。


    第五章:主席树的常见变形与应用场景

    变形核心思想典型例题
    静态区间第 k 小 前缀和版本相减 P3834
    树上路径第 k 小 四个版本相减(含 LCA) P2633
    动态主席树 树状数组套主席树 P2617
    可持久化并查集 主席树维护 fa 数组的历史版本
    区间不同数个数 主席树维护每个数最后一次出现位置 SPOJ DQUERY
    主席树 + 二分答案 利用主席树快速统计值域信息 P4587

    总结

    主席树(可持久化线段树)通过“每次修改只新建一条链,其余节点共用”的巧妙设计,将“多版本线段树”的空间从 O(n²) 降到了 O(n log n)。它利用前缀和的可减性,能在 O(log n) 内回答任意区间的权值统计问题。

    学习主席树的三个关键点:

  • 理解“共用节点”:版本之间不是独立的,而是共享大部分节点。

  • 掌握“两棵树相减”:区间信息 = 右端点版本 – 左端点前一版本。

  • 动态开点的写法:用 ls[]、rs[] 数组记录左右儿子,而不是 2x、2x+1。

  • 赞(0)
    未经允许不得转载:171主机测评 » 主席树:从“区间求和”到“可持久化”的线段树进阶
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址