Persistent Segment Tree

共 17 题
📑 题目列表 17 题
#
★★★

1. Persistent Segment Tree 在区间历史版本回溯的工程实现。

请详细说明可持久化线段树(主席树)在区间历史版本回溯场景下的工程实现原理,包括建树、版本更新与回溯查询的具体做法?

  • 可持久化的核心思想:路径复制(Path Copying)与节点共享
  • 版本管理:每个版本对应一个根节点,历史版本可随时回溯
  • 时间与空间复杂度:O(log n) 单次操作,O(n log n) 总空间

可持久化线段树的核心是"路径复制":每次更新时,只新建从根到被修改叶子这条路径上的 O(log n) 个节点,其余节点与旧版本共享。因此每个版本用一个指针(根节点)唯一标识,历史根节点被保留,随时可以沿着它查询当时的区间状态。工程上节点通常用数组 + 下标来模拟指针(避免动态指针的分配开销),每个节点存左右孩子下标与该节点维护的聚合信息。建树时先建一棵空树(版本 0),在此基础上每次修改生成新版本并把新根保存到 roots 数组。查询时直接传入对应版本的根即可,与版本无关的节点天然被共享,从而保证总空间 O(n log n)。

之所以能压缩空间,是因为单次修改只影响从根到叶的一条路径,其余子树完全不变。路径复制让新旧版本共享那些不变子树,既保证了不可变性(旧版本数据不被破坏),又实现了数据复用。这是所有可持久化(persistent)数据结构统一的设计范式,也是主席树能处理"询问历史区间"问题的空间基础。

class PersistentSegTree {
    static class Node { int l, r, sum; } // 左/右孩子下标,聚合和
    Node[] tr; int tot;
    int build(int l, int r) { // 建空树
        int p = ++tot;
        if (l != r) {
            int m = (l + r) >> 1;
            tr[p].l = build(l, m);
            tr[p].r = build(m + 1, r);
        }
        return p;
    }
    int update(int prev, int l, int r, int pos, int val) { // 路径复制
        int p = ++tot;
        tr[p].l = tr[prev].l; tr[p].r = tr[prev].r; tr[p].sum = tr[prev].sum;
        if (l == r) { tr[p].sum += val; return p; }
        int m = (l + r) >> 1;
        if (pos <= m) tr[p].l = update(tr[prev].l, l, m, pos, val);
        else          tr[p].r = update(tr[prev].r, m + 1, r, pos, val);
        tr[p].sum = tr[tr[p].l].sum + tr[tr[p].r].sum;
        return p;
    }
    int query(int l, int r, int L, int R) { /* 区间和查询 */ }
}
#
★★

2. Persistent Red-black Tree 在增删路径复制的工程实现。

红黑树如何实现可持久化?说明在插入和删除时沿路径复制节点以保留历史版本的具体工程做法?

  • 红黑树左旋/右旋/变色重平衡时路径复制的应用
  • 路径复制与旋转结合时对节点引用的处理
  • 平衡树持久化与线段树持久化的异同

可持久化红黑树(如函数式语言中常见的实现)在每次插入/删除时,从根到操作位置沿路径复制节点,并在复制节点上执行变色与旋转,从而产生一棵新树并保留旧树。由于旋转会改变若干节点的父子关系,需要保证复制的节点覆盖所有被修改的节点,且新树中未改变的子树继续与旧树共享。红黑树作为平衡树,插入/删除的旋转与变色呈 O(1) 的均摊次数,但路径复制使每次操作复制 O(log n) 个节点,因此单次持久化操作空间 O(log n),总空间 O(n log n)。工程上通常用函数式指针(不可变引用)以支持共享与历史保存。

红黑树持久化的难点在于旋转会同时修改多个节点,且变色可能沿路径向上传播;路径复制必须保证"被修改的节点一定是新复制出来的节点",否则会破坏旧版本。与线段树相比,红黑树的平衡性保证树高 O(log n),因此复制路径代价可控。Java 的 Immutable Collections(如 Guava 的 ImmutableSortedMap)与 Clojure 的持久化红黑树都采用类似思路。

#
★★

3. Persistent Segment Tree 在并查集按秩合并的持久化拓展。

说明如何用可持久化线段树(可持久化数组)加上按秩合并来实现并查集的历史版本回退,并分析复杂度?

  • 可持久化数组模拟并查集 parent 与 rank 数组
  • 按秩合并保证单次查找 O(log n)
  • 历史版本回退与离线查询

可持久化并查集的核心是将并查集的 parent 数组和 rank(或 size)数组用可持久化数组(通常用可持久化线段树实现)存储,每次 union 操作生成新的数组版本。由于带按秩合并的并查集树高为 O(log n),单次 find 复杂度 O(log n),而数组的每次单点修改又是 O(log n),因此单次 union 总复杂度 O(log² n)。查询时只需传入对应历史版本的根(数组的版本),即可在历史快照上做 find。这样既支持普通并查集的合并查询,又能在任意历史版本上回退操作。

普通并查集 O(1) 摊还的复杂度依赖路径压缩,但路径压缩会大量修改数组,破坏可持久化的空间效率(每次压缩可能改 O(n) 个点)。因此可持久化场景改用按秩合并(rank 合并、不打路径压缩),牺牲一点查找常数换取确定性的 O(log n) 树高,从而保证每次 union 只修改 O(log n) 个数组位置。这是持久化并查集的标准工程取舍。

// 建立在可持久化数组上:union 时 find 两次再合并 parent 与 rank
// 每次 find 不压缩路径,只沿 parent 向上跳,单次 O(log n)
// union(a,b): 取两个根,深度小者指向深度大者,更新 rank 版本
#
★★

4. Persistent Treap 在 implicit key 区间翻转的版本切换工程实现。

说明可持久化无旋 Treap(FHQ Treap,implicit key 按位置)如何实现区间翻转,并支持版本切换(撤销/历史回退)?

  • 无旋 Treap 按位置分裂 split / 合并 merge
  • 区间翻转懒标记与路径复制结合
  • 版本切换与历史回退

可持久化无旋 Treap 以子树大小作为隐式下标,分裂 split(根, k) 按前 k 个元素切分,合并 merge 按优先级合并。区间翻转时,先分裂出目标区间,给该子树打翻转懒标记,再合并回。由于每次 split/merge 都会沿路径复制 O(log n) 个节点,天然产生一个新版本,旧版本仍完整保留,因此可以保存历史根实现任意版本切换。懒标记在复制节点时同样被复制,从而不影响旧版本。每操作 O(log n) 时间与空间,n 次操作总空间 O(n log n)。

无旋 Treap 的唯一"修改"就是 split 和 merge,而这两者天然是沿路径构造新节点的过程,因此持久化实现非常简单——只需在新建节点时保留旧节点即可。相比有旋 Treap 或 Splay,无旋 Treap 的不可变性让版本切换变得顺理成章,这也是文本编辑器撤销/重做和历史版本回退场景中 FHQ Treap 倍受青睐的原因。

#
★★

5. Persistent Union-Find 在离线与持久化的 O(log² n) 工程实现。

给出可持久化并查集在离线和持久化场景下的 O(log² n) 工程实现思路,说明如何离线处理或在线回退历史版本?

  • 可持久化数组 + 按秩合并的 O(log² n) 单次操作
  • 离线(整体二分 / 分治 / 时间分治)与在线(持久化)两种路线
  • 两种方案在时间与空间上的取舍

可持久化并查集的 O(log² n) 来自两个因子:find 因按秩合并树高 O(log n),数组单点修改因可持久化线段树 O(log n)。在线场景直接保存每次 union 产生的数组版本,历史查询 O(log² n)。离线场景则常用"时间分治 / 线段树分治(按时间建立线段树,把边挂到其存活区间)"结合可撤销并查集(栈记录修改,用于回滚),由于只需撤销而非真正持久化开历史版本,常数与空间都更优。选择哪种取决于问题是否允许离线:在线强制要用持久化,离线可用分治 + 可撤销并查集得到更省空间的实现。

"持久化"记录的是所有历史版本,空间 O(n log n);而"可撤销"只需记录当前递归栈上的修改,空间 O(log n)。离线分治把"版本"隐含在分治区间中,从而避免显式保存全部历史。这是面试中常考的分辨点:持久化适合在线、可撤销适合离线。

#

6. 主席树(可持久化线段树)的建树与查询中静态区间第 k 小问题中,版本按前缀建立、查询时两棵树的差分如何工作?

在静态区间第 k 小问题中,说明主席树如何按前缀建树,以及查询 [l, r] 时如何用两棵树的节点差值定位第 k 小?

  • 值域离散化 + 按前缀建立版本(版本 i 表示前 i 个数)
  • 差分思想:区间 [l,r] 的统计 = 版本 r − 版本 l−1
  • 二分定位第 k 小

主席树按值域建树,版本 i 表示插入前 i 个元素后的线段树(每个位置代表一个值域区间,维护该值域内元素个数)。查询区间 [l, r] 的第 k 小时,同时从版本 r 的根和版本 l−1 的根出发,两个根的左子树节点数之差就是 [l,r] 内落于左值域的元素个数 cnt;若 k ≤ cnt 则向左递归,否则 k−=cnt 向右递归。这样每次下降一层,O(log n) 定位到第 k 小。离线预处理:先对原数组离散化,再从左到右依次插入每个元素生成每个版本。

"版本前缀 + 差分"是主席树最核心的建模:由于版本 i 记录了前缀 [1..i] 的信息,两个版本对应节点之差恰好是区间 [l,r] 的信息,从而把"区间查询"转化为"两棵树的减法"。配合值域二分,即可高效求出任意区间第 k 小,这是主席树最经典的应用。

// 查询 [l,r] 第 k 小
int query(int u, int v, int l, int r, int k) { // u=版本r, v=版本l-1
    if (l == r) return l;
    int m = (l + r) >> 1;
    int cnt = tr[tr[u].l].sum - tr[tr[v].l].sum; // 左值域内元素个数
    if (k <= cnt) return query(tr[u].l, tr[v].l, l, m, k);
    else          return query(tr[u].r, tr[v].r, m + 1, r, k - cnt);
}
#

7. 主席树求区间不同元素个数中用上一次出现位置建树,查询 [l,r] 时如何统计?

说明主席树如何统计区间内不同元素个数,为什么用"上一次出现位置"建树,以及查询 [l,r] 的统计方式?

  • 用"上一次出现位置"作为键值建树
  • 把不同元素个数转化为位置上的计数
  • 树状数组/主席树两种实现思路

统计区间 [l,r] 内不同元素个数的方法是:顺序扫描,用 map 记录每个值上一次出现的位置;当在第 i 个位置遇到值 x 且它上次出现在 pos 时,把主席树在位置 pos 处减 1、在位置 i 处加 1,建立版本 i。这样版本 i 中,每个出现过的值只在其"最后一次出现位置"上计数为 1。查询 [l,r] 时,用版本 r 查询区间 [l,r] 的和,即为不同元素个数。原理是:区间 [l,r] 内某个值若存在,则其最后一次出现位置必然落在 [l,r] 内且被计数;若其最后一次出现位置落在区间外,则区间内的那次不是最后一次,因而该值在区间内不贡献计数。故版本 r 在 [l,r] 上的区间和恰为不同元素个数。

关键洞察是"把不同元素计数转化为位置计数":版本 r 只保留每个值最后一次出现的位置。这样区间 [l,r] 的和恰好等于值域内不同元素在该区间出现的次数,因为每个值在区间的最后一次出现若在 [l,r] 内则贡献 1,否则不贡献。这一技巧也可以用树状数组离线实现,但主席树支持在线任意区间查询。

#

8. 可持久化数据结构的空间分析中每次更新新建 O(log n) 个节点,n 次更新总节点数为何是 O(n log n)?

分析可持久化数据结构的空间:为什么每次更新只新建 O(log n) 个节点,而 n 次更新后总节点数是 O(n log n)?

  • 路径复制只复制受影响的 O(log n) 个节点
  • 初始树 + n 次更新 × O(log n) 的总空间
  • 与朴素全复制 O(n²) 的对比

可持久化更新时,从根到被修改叶子/位置的路径上的节点需要变更,其余子树与旧版本共享,因此每次更新只新建与"树高"成正比的新节点。对线段树、平衡树等高度 O(log n) 的结构,一次更新新建 O(log n) 个节点。初始建树需要 O(n) 个节点,n 次更新共新建 n × O(log n) 个节点,加上初始的 O(n),总节点数 = O(n + n log n) = O(n log n)。相比之下,若每次更新把整棵树都复制一遍,总空间将是 O(n²),不可接受。

空间分析的关键是"节点共享":同一节点可被多个版本引用,因此总节点数不是"版本数 × 树大小",而是"初始树 + 每版本新增路径数"。只要树高是 O(log n),每版本只新增 O(log n) 个节点,总数就控制在 O(n log n)。这也是持久化结构能落地的前提。

#

9. 可持久化并查集中如何用可持久化数组 + 按秩合并实现历史版本回退?

说明如何用可持久化数组配合按秩合并实现可持久化并查集,使其支持历史版本回退?

  • 可持久化数组存储 parent 与 rank
  • 按秩合并(不打路径压缩)保证 O(log n)
  • 历史版本回退的查询方式

可持久化并查集把 parent 数组和 rank(或 size)数组保存在可持久化数组(可用可持久化线段树实现)中。union 时先 find 出两个根,比较 rank,把 rank 小的根指向 rank 大的根,并更新被改点的 parent 与根的 rank,每次修改都产生新版本。find 不打路径压缩,只沿 parent 向上走,树高因按秩合并为 O(log n)。每个历史版本对应一组可持久化数组的"根",查询时传入对应版本即可在该历史快照上做 find,实现版本回退。单次操作 O(log² n)。

路径压缩会破坏可持久化(一次 find 可能修改整条路径,导致大量节点复制),因此持久化版本退而求其次用按秩合并换取确定性的 O(log n) 树高。可持久化数组本身把"数组的每次修改"变成新版本,天然支持"数组在任意历史时刻的值"的查询,这正是并查集回退所需的全部能力。

#

10. 可持久化 Trie 的应用中如何支持历史版本的异或最大值查询,与可持久化线段树的空间差异?

说明可持久化 Trie 如何支持历史版本的异或最大值查询,以及它与可持久化线段树在空间上的差异?

  • 可持久化二进制 Trie 按前缀建版本
  • 异或最大值查询的贪心匹配
  • 与可持久化线段树的空间差异(二进制定长 vs 值域动态)

可持久化二进制 Trie 把数值按二进制位(如 31 位)建立 Trie,版本 i 表示前 i 个数的插入结果。查询异或最大值时,给定 x,从最高位开始贪心:若当前位存在与 x 当前位相反的路径(用版本差分判断该子树在前缀内非空),就沿它走,使异或结果该位为 1,否则走相同位。这样 O(位数) 得到最大异或值。空间上,可持久化 Trie 每个数需要插入 位数 个节点,n 个数总节点 O(n × 位数);可持久化线段树值域大小为 V 时单点更新 O(log V)、总 O(n log V)。两者本质都是 O(n × 深度),只差"深度"的维数:Trie 深度是位数(固定),线段树深度是值域对数的 log。

可持久化 Trie 与线段树在空间结构上高度相似(都是"每版本沿路径复制 O(深度) 个节点"),只是 Trie 的深度是二进制位数而线段树是值域段。异或最大值的贪心正确性来自二进制位从高到低的权重:高位优先决定结果大小,因此逐位贪心能保证全局最优。版本差分判断某子树在 [l,r] 区间内是否非空,与主席树"两版本相减"异曲同工。

#

11. 主席树在动态 K-th 区间树状数组套主席树工程实现。

当区间第 k 小问题带点修改(动态)时,如何用树状数组套主席树实现,并说明复杂度?

  • 树状数组作为外层位置下标,每节点一棵主席树
  • 修改点的影响范围与更新
  • 查询复杂度 O(log² n)

动态 K-th 问题用"树状数组套主席树":外层树状数组按位置下标组织,内层每棵主席树维护值域信息。修改位置 p 的值时,它影响树状数组中所有包含 p 的 O(log n) 个节点,对每个节点对应主席树做一次改值(O(log n)),因此单次修改 O(log² n)。查询区间 [l,r] 第 k 小时,用树状数组收集 O(log n) 个前缀节点(对应 r 和 l−1),在所有节点上同步进行值域二分,O(log² n) 定位。整体复杂度 O((n+m)log² n)。

外层树状数组负责"位置维度"的差分与更新,内层主席树负责"值域维度"的计数与二分。树状数组天然支持点更新、前缀查询,恰好匹配动态区间问题;主席树负责值域统计。两层 log 相乘得到 O(log² n)。这是"树套树"解决动态区间问题的经典范式,把静态主席树的前缀版本替换为"树状数组分块的多棵主席树"来支持修改。

#

12. Persistent Link-Cut Tree 在动态树路径查询的版本切换工程实现。

说明可持久化 Link-Cut Tree(LCT)如何实现动态树路径查询的版本切换,及其难点?

  • LCT 由多棵 Splay 辅助树组成
  • 可持久化 Splay 与虚实切换的矛盾
  • 版本切换与历史查询

可持久化 LCT 把 LCT 中的每棵辅助树(Splay)做可持久化处理,使每次 access/makeroot 产生的形态变化都生成新版本,从而支持历史路径查询。难点在于 LCT 的虚实切换(access)会大量改变 Splay 的父子关系,且涉及旋转,路径复制需要覆盖所有被改节点;同时可持久化 Splay 的旋转会破坏历史版本,需配合路径复制。工程上实现复杂,常借助函数式 Treap 或持久化旋转替代。实际中可持久化 LCT 很少直接使用,多用于"可撤销/可退化的树上动态查询"理论问题。

LCT 最核心的 access 操作把实链不断变换,涉及大量旋转与父子切换,这与可持久化的"不可变"诉求冲突。因此可持久化 LCT 比线段树式持久化难得多,需要精细的路径复制。这类问题在面试中通常考察其难点与取舍,而非完整实现。

#

13. Persistent Union-Find 在 Clojure STM 事务的实现原理。

说明 Clojure 的 STM(软件事务内存)中可持久化数据结构扮演的角色,以及可持久化并查集在函数式事务中的实现原理?

  • Clojure 持久化数据结构 + STM 原子性
  • 不可变引用与事务提交
  • 函数式并查集的作用

Clojure 的 STM 基于"持久化数据结构 + 事务重试":事务所修改的引用(ref)在事务内持有不可变快照,提交时通过提交点(commit point)校验并原子地指向新版本。由于数据结构不可变,并发事务各自操作自己的快照而互不干扰,冲突时靠重试解决。可持久化并查集(不可变 parent 数组)在事务中表现为:每次合并产生新的不可变版本,事务结束时把 ref 指向最终版本,实现原子提交。这依赖持久化结构"旧版本永远可用、修改生成新版本"的性质,使 STM 无需加锁即可保证一致性。

持久化数据结构是函数式语言 STM 的基石:不可变性让每个事务天然拥有独立快照,无需复制或加锁;事务冲突时回滚到旧快照重试,成本很低。可持久化并查集在此充当"可回退的集合状态",其版本化特性与事务的提交/回滚语义完美契合。这也是 Clojure 把 persistent collections 与 STM 结合设计的根本原因。

#

14. Persistent Treap 在操作日志回放的工程实现。

说明如何用可持久化 Treap 实现操作日志回放(撤销/重做),并给出工程思路?

  • 每次操作保存新版本根,形成版本链
  • 撤销=回到上一版本,重做=前进到下一版本
  • 操作日志与持久化版本的对应

可持久化 Treap 的每次修改(split/merge)都产生新版本,因此可以把每次操作后的根节点保存到历史栈中。撤销时只需把当前指针指回上一版本根,重做时指回下一版本根即可,无需重新执行操作。由于旧版本数据完整保留,回退是 O(1) 的指针切换。这构成"操作日志"的一种实现:日志记录的是每个版本对应的根,配合操作命令记录即可实现撤销/重做,且能支持任意历史跳转(如文档历史版本)。

持久化数据结构天然支持"时间旅行":版本即历史,根指针即时间戳。撤销/重做本质是把当前指针在版本链上移动,而不需要破坏性地反向执行操作。相比"命令模式 + 逆操作"的方案,持久化版本回放更简单、更可靠,且支持非线性历史(如分支)。

#

15. 主席树在静态 K-th 区间离线持久化工程实现。

说明主席树在静态区间第 k 小问题上的离线持久化工程实现,从建树到查询的完整流程?

  • 值域离散化预处理
  • 离线按前缀建版本
  • 两版本差分查询第 k 小

静态 K-th 离线实现流程:先读入整个数组,对值域做离散化(去重排序后映射到 1..m);建立版本 0 的空树;然后从左到右依次插入每个元素,每次插入生成新版本并保存到 roots 数组;对每个查询 [l,r,k],用 roots[r] 与 roots[l−1] 两棵树的节点做差分,沿值域二分定位第 k 小。整体 O((n+q)log n)。由于所有查询都用已建好的版本,无需在线修改,属于离线持久化。

离散化把值域压缩到 n 以内,使线段树深度为 O(log n),同时把"第 k 小"转化为值域上的计数二分。前缀版本 + 差分是主席树在静态区间问题上的统一范式,先离线建好所有版本,再对每个查询 O(log n) 回答。

#

16. 可持久化 Treap 与 rope 中文本编辑器撤销/重做场景的数据结构选型?

在文本编辑器撤销/重做场景下,比较可持久化 Treap 与 rope 的数据结构选型?

  • rope 的块状链表/平衡树结构
  • 可持久化 Treap 的撤销/重做与任意区间操作
  • 撤销/重做、插入删除、随机访问的平衡

文本编辑器的核心操作是插入、删除、随机访问(按字符定位)以及撤销/重做。可持久化无旋 Treap(作为 rope 的一种经典实现)以子树大小定位位置,支持 O(log n) 的插入/删除/区间翻转,且每次操作生成新版本,天然支持 O(1) 撤销/重做(回退根指针)。rope 通常指"块状链表 + 平衡树"思想的字符串容器(如 SGI rope),把字符串切块以提升缓存与拼接效率。选型上:若需要任意区间操作与完备的撤销/重做,可持久化 Treap 更合适;若主要追求大字符串的拼接与缓存局部性,块状 rope 常数更小。二者本质上都可用平衡树实现,可持久化 Treap 把"历史版本"这一需求直接内建。

撤销/重做的两种范式:一是可持久化(版本即历史,回退 O(1)),二是命令模式逆操作(记录操作日志,反向执行)。可持久化 Treap 与"文本文档不可变"的函数式模型契合,且无需记录逆操作,更适合编辑器内核。实际工程(如一些编辑器)常用平衡树(rope)实现,撤销则用命令栈,二者可结合。

#

17. 主席树的空间优化中值域离散化与节点池(静态数组)的工程实现?

说明主席树的空间优化手段:值域离散化与节点池(静态数组)分别如何降低空间,如何实现?

  • 值域离散化压缩线段树深度
  • 节点池用静态数组预分配避免动态分配
  • 空间上界估算与扩容

主席树空间优化有两层。第一是值域离散化:把原数组值映射到 1..n 的稠密下标,线段树值域从"真实值域"压缩到 n,深度从 O(log V) 降到 O(log n),节点数从 O(n log V) 降到 O(n log n)。第二是节点池:用静态数组(如 Node[] tr)模拟指针,预分配足够空间(估算上界,如 n 次插入每次 O(log n) 个节点,预留 (n+1) × (log n + 2) 个节点),避免每次动态 new 的开销与内存碎片;同时用下标代替指针,减少内存占用并提升缓存局部性。工程上还会对"复制空节点时共用"做优化,减少冗余节点。

离散化直击空间瓶颈(值域维度),节点池优化实现层面的分配与缓存。静态数组模拟指针是竞赛与高性能库的通用做法:既省去指针/对象开销,又因预分配避免反复 new。两者结合把主席树空间控制在可接受的 O(n log n) 且常数小。