持久化資料結構完全指南 — 路徑複製、Fat Node 與持久化線段樹 | 資料結構與演算法
持久化資料結構(Persistent Data Structures) 讓每次修改都產生一個新版本,而舊版本永遠完整保留。透過 路徑複製(Path Copying) 與 Fat Node 等核心技術,我們能以 O(log n) 的額外空間代價保存完整歷史,實現 持久化陣列、持久化線段樹(主席樹)、持久化 Trie 等強大結構。本文將從基礎概念出發,搭配 JavaScript/TypeScript 與 C++ 雙語言完整實作,帶你徹底掌握版本化資料結構的設計哲學。
前言
在前一篇文章中,我們探討了後綴結構(Suffix Structures)如何為字串建立強大的索引系統。本篇我們將進入一個全新的維度—— 持久化資料結構(Persistent Data Structures)。
想像你正在使用 Git 進行版本控制:每次 commit 都會產生一個快照,你可以隨時回到任何歷史版本查看當時的程式碼狀態,而這些歷史版本並不是每次都完整複製整個專案——Git 只記錄「改變的部分」,其餘檔案則與上一版本共享。這正是持久化資料結構的核心思想。
傳統的「可變」(Mutable)資料結構在每次修改時會覆蓋舊狀態。一旦你更新了陣列的某個元素、插入了一個節點,舊的值就永遠消失了。但在許多場景中,我們需要保留歷史:
- 區間第 K 小查詢:需要對比不同前綴版本的線段樹
- 版本控制系統:Git 的 Tree Object 本質上就是持久化樹
- 資料庫 MVCC:PostgreSQL 讓多個交易同時讀取不同版本的資料
- 函數式程式設計:Clojure、Haskell 的不可變集合預設就是持久化的
- 前端狀態管理:Redux 要求不可變更新,Immutable.js 底層就是持久化 Trie
學習本文後,你將能夠:
- 理解 部分持久化、完全持久化、匯合持久化 三種層級的差異
- 掌握 Fat Node 與 路徑複製 兩大實現技術
- 實作 持久化陣列、持久化鏈結串列、持久化線段樹、持久化 Treap 等結構
- 設計一個通用的 版本管理系統
- 解決 LeetCode 上與持久化思想相關的面試題目
核心概念
為什麼需要持久化?
在傳統的可變資料結構中,每次操作都是「破壞性」的:
普通陣列:
初始:[1, 2, 3, 4, 5]
更新 arr[2] = 99
結果:[1, 2, 99, 4, 5] ← 原本的 3 永遠消失了
而持久化資料結構的每次修改都「建立新版本」,舊版本仍可存取:
持久化陣列:
v0 = [1, 2, 3, 4, 5]
v1 = update(v0, 2, 99) → [1, 2, 99, 4, 5]
v0 仍然是 [1, 2, 3, 4, 5] ← 舊版本完整保留
v1 是 [1, 2, 99, 4, 5] ← 新版本
兩者共享未修改的元素 ← 空間高效
關鍵在於:持久化不是每次都做完整拷貝(那樣空間成本太高),而是透過 結構共享(Structural Sharing) 讓新舊版本共用未改變的部分。
Fat Node vs 路徑複製(Path Copying)
實現持久化有兩種主要技術:
Fat Node(胖節點):在每個節點內記錄所有歷史版本的值。每個節點變成一個「版本 → 值」的映射表。
Fat Node 方式:
節點 A = { v0: 3, v1: 99, v3: 42 }
查詢版本 v1 的值 → 在節點內查找 v1 → 回傳 99
查詢版本 v2 的值 → v2 不存在,找前一個版本 v1 → 回傳 99
優點:不需要建立新節點
缺點:查詢需要二分搜尋歷史,節點大小不固定
路徑複製(Path Copying):修改時只複製「從根到修改點路徑上的節點」,其餘節點與舊版本共享。
路徑複製方式(二元樹更新葉節點 E 的值):
版本 v0: 版本 v1(更新 E 的值):
[root_v0] [root_v1] ← 新建
/ \ / \
[A] [B] [A] [B'] ← 新建
/ \ / \ / \ / \
[C] [D] [E] [F] [C] [D] [E'] [F]
↑ 新建(新值)
共享節點:A、C、D、F(不在修改路徑上)
新建節點:root_v1、B'、E'(路徑複製)
每次更新的空間:O(路徑長度) = O(log n)(平衡樹)
路徑複製是競賽與實務中最常用的技術,因為它概念簡潔、實作直觀,且每次更新只增加 O(log n) 的空間。
部分持久化 vs 完全持久化
持久化資料結構依據「可修改哪些版本」分為三個層級:
| 層級 | 名稱 | 定義 | 範例 |
|---|---|---|---|
| 部分持久化 | Partial Persistence | 可查詢所有歷史版本,但只能修改最新版本 | 持久化線段樹(主席樹) |
| 完全持久化 | Full Persistence | 可查詢並修改任意歷史版本,產生新分支 | Git 的分支模型 |
| 匯合持久化 | Confluent Persistence | 可合併兩個版本產生新版本 | Git merge |
部分持久化(線性歷史):
v0 → v1 → v2 → v3(只能從最新版本產生新版本)
完全持久化(樹狀歷史):
v0 → v1 → v2
↘ v3 → v4(可以從任意版本產生分支)
匯合持久化(DAG 歷史):
v1 ─┐
├→ v3(合併 v1 和 v2 產生 v3)
v2 ─┘
在競技程式設計中,部分持久化 透過路徑複製實現是最常見的模式。完全持久化在實務系統(如 Git)中更常見。匯合持久化則是理論上最強的形式,實作難度也最高。
匯合持久化(Confluent Persistence)
匯合持久化允許將兩個獨立演化的版本合併為一個新版本。這在理論上非常強大,但也帶來了資料結構設計上的挑戰——合併操作的正確性和效率取決於底層結構的性質。
在實務中,Git 的 merge 操作就是匯合持久化的典型應用。但對大多數演算法競賽場景,部分持久化已經足夠。
JavaScript / TypeScript 實作
持久化陣列(Persistent Array via Path Copying)
持久化陣列的核心思想是:用一棵完全二元樹(Balanced Binary Tree)來表示陣列,每次更新透過路徑複製建立新版本。
// ─── 持久化陣列:基於完全二元樹的路徑複製 ──────────────────
class PersistentArrayNode {
left: PersistentArrayNode | null = null;
right: PersistentArrayNode | null = null;
value: number = 0; // 僅葉節點有值
}
class PersistentArray {
private roots: PersistentArrayNode[] = [];
private size: number;
constructor(arr: number[]) {
this.size = arr.length;
// 版本 0:從原始陣列建構完全二元樹
this.roots.push(this.build(arr, 0, this.size - 1));
}
// 建構完全二元樹,葉節點對應陣列元素
private build(
arr: number[], lo: number, hi: number
): PersistentArrayNode {
const node = new PersistentArrayNode();
if (lo === hi) {
node.value = arr[lo];
return node;
}
const mid = (lo + hi) >> 1;
node.left = this.build(arr, lo, mid);
node.right = this.build(arr, mid + 1, hi);
return node;
}
// 在指定版本上更新位置 pos 的值,回傳新版本號
update(version: number, pos: number, val: number): number {
const newRoot = this.updateNode(
this.roots[version], 0, this.size - 1, pos, val
);
this.roots.push(newRoot);
return this.roots.length - 1; // 回傳新版本號
}
// 路徑複製:只複製根到修改點的路徑
private updateNode(
node: PersistentArrayNode,
lo: number, hi: number,
pos: number, val: number
): PersistentArrayNode {
const newNode = new PersistentArrayNode();
if (lo === hi) {
newNode.value = val;
return newNode;
}
// 複製左右子節點的參照(結構共享)
newNode.left = node.left;
newNode.right = node.right;
const mid = (lo + hi) >> 1;
if (pos <= mid) {
// 修改點在左子樹,只複製左路徑
newNode.left = this.updateNode(node.left!, lo, mid, pos, val);
} else {
// 修改點在右子樹,只複製右路徑
newNode.right = this.updateNode(node.right!, mid + 1, hi, pos, val);
}
return newNode;
}
// 查詢指定版本、指定位置的值
query(version: number, pos: number): number {
return this.queryNode(this.roots[version], 0, this.size - 1, pos);
}
private queryNode(
node: PersistentArrayNode,
lo: number, hi: number, pos: number
): number {
if (lo === hi) return node.value;
const mid = (lo + hi) >> 1;
if (pos <= mid) {
return this.queryNode(node.left!, lo, mid, pos);
} else {
return this.queryNode(node.right!, mid + 1, hi, pos);
}
}
// 取得目前版本數
getVersionCount(): number {
return this.roots.length;
}
}
// ─── 使用範例 ──────────────────────────────────────────────
const parr = new PersistentArray([10, 20, 30, 40, 50]);
// 版本 0:[10, 20, 30, 40, 50]
console.log(parr.query(0, 2)); // 輸出:30
// 在版本 0 基礎上修改位置 2 的值為 99,產生版本 1
const v1 = parr.update(0, 2, 99);
console.log(parr.query(v1, 2)); // 輸出:99
console.log(parr.query(0, 2)); // 輸出:30(版本 0 不受影響)
// 在版本 0 基礎上修改位置 4 的值為 77,產生版本 2(分支!)
const v2 = parr.update(0, 4, 77);
console.log(parr.query(v2, 4)); // 輸出:77
console.log(parr.query(v1, 4)); // 輸出:50(版本 1 不受影響)
console.log(parr.query(0, 4)); // 輸出:50(版本 0 不受影響)
這裡展示了 完全持久化 的特性:我們可以從版本 0 分支出版本 1 和版本 2,每個版本都是獨立的。
持久化鏈結串列(Persistent Linked List)
持久化鏈結串列是函數式程式語言(如 Haskell、Clojure)中最基本的資料結構。在函數式語言中,List 天生就是持久化的——prepend 操作直接共享整個尾部。
// ─── 持久化鏈結串列(Persistent Linked List)──────────────
class PNode<T> {
constructor(
public readonly value: T,
public readonly next: PNode<T> | null
) {}
}
class PersistentList<T> {
private constructor(
private readonly head: PNode<T> | null,
public readonly size: number
) {}
// 建立空串列
static empty<T>(): PersistentList<T> {
return new PersistentList<T>(null, 0);
}
// 在頭部插入元素,回傳新版本(O(1) — 完美結構共享)
prepend(value: T): PersistentList<T> {
return new PersistentList(
new PNode(value, this.head), this.size + 1
);
}
// 取得頭部元素
getHead(): T | undefined {
return this.head?.value;
}
// 移除頭部元素,回傳新版本(O(1))
tail(): PersistentList<T> {
if (!this.head) throw new Error("空串列無法 tail");
return new PersistentList(this.head.next, this.size - 1);
}
// 取得第 k 個元素(O(k))
get(index: number): T | undefined {
let cur = this.head;
for (let i = 0; i < index && cur; i++) {
cur = cur.next;
}
return cur?.value;
}
// 轉為陣列(方便除錯)
toArray(): T[] {
const result: T[] = [];
let cur = this.head;
while (cur) {
result.push(cur.value);
cur = cur.next;
}
return result;
}
}
// ─── 使用範例 ──────────────────────────────────────────────
const list0 = PersistentList.empty<number>();
const list1 = list0.prepend(3).prepend(2).prepend(1);
const list2 = list1.prepend(0); // 在 list1 前面加 0
const list3 = list1.tail(); // 移除 list1 的頭部
console.log(list1.toArray()); // 輸出:[1, 2, 3]
console.log(list2.toArray()); // 輸出:[0, 1, 2, 3]
console.log(list3.toArray()); // 輸出:[2, 3]
// list1 完全不受 list2、list3 的影響
console.log(list1.toArray()); // 輸出:[1, 2, 3]
結構共享示意:
list1: [1] → [2] → [3] → null
↑
list2: [0] ─┘ (共享 list1 的全部節點)
list3: [2] → [3] → null
↑
直接指向 list1 的第二個節點
持久化線段樹(Persistent Segment Tree)— 主席樹
持久化線段樹是競技程式設計中最重要的持久化結構,在中文社群中又稱 主席樹(以提出者黃嘉泰命名)。它的核心應用是解決 靜態區間第 K 小 問題。
核心思想:對陣列的每個前綴 a[1..i] 建立一棵值域線段樹(統計每個值出現次數)。版本 i 的線段樹由版本 i-1 路徑複製更新而來。查詢 [l, r] 的第 K 小,等同於版本 r 的線段樹「減去」版本 l-1 的線段樹,在差值上找第 K 小。
值域 [1, 6],陣列 a = [3, 1, 4, 1, 5, 2]
版本 v0(空): 版本 v1(插入 3): 版本 v2(插入 1):
[1,6]=0 [1,6]=1 [1,6]=2
/ \ / \ / \
[1,3]=0 [4,6]=0 [1,3]=1 [4,6]=0 [1,3]=2 [4,6]=0
/ \ / \ / \
[1,2] [3,3] [1,2] [3,3]=1 [1,2]=1 [3,3]=1
0 0 0 ↑ 路徑複製
查詢 [2,5] 的第 2 小:
使用 version[5] 減去 version[1]
在差值線段樹上做第 K 小查找
// ─── 持久化線段樹(主席樹):區間第 K 小 ────────────────────
class PSegNode {
left: PSegNode | null = null;
right: PSegNode | null = null;
count: number = 0;
}
class PersistentSegmentTree {
private roots: PSegNode[] = [];
private sorted: number[];
private m: number; // 離散化後的值域大小
constructor(arr: number[]) {
// 步驟 1:離散化 — 將值映射到 [0, m-1]
this.sorted = [...new Set(arr)].sort((a, b) => a - b);
this.m = this.sorted.length;
// 步驟 2:建立 n+1 個版本(v0 為空版本)
let prev = new PSegNode();
this.roots.push(prev); // version 0:空線段樹
for (const val of arr) {
const idx = this.compress(val);
const newRoot = this.update(prev, 0, this.m - 1, idx);
this.roots.push(newRoot);
prev = newRoot;
}
}
// 離散化:二分搜尋找到值在 sorted 中的索引
private compress(val: number): number {
let lo = 0, hi = this.sorted.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (this.sorted[mid] < val) lo = mid + 1;
else hi = mid;
}
return lo;
}
// 路徑複製更新:在位置 pos 的計數 +1
private update(
node: PSegNode, lo: number, hi: number, pos: number
): PSegNode {
const newNode = new PSegNode();
newNode.left = node.left; // 結構共享
newNode.right = node.right; // 結構共享
newNode.count = node.count + 1;
if (lo === hi) return newNode;
const mid = (lo + hi) >> 1;
if (pos <= mid) {
newNode.left = this.update(
node.left ?? new PSegNode(), lo, mid, pos
);
} else {
newNode.right = this.update(
node.right ?? new PSegNode(), mid + 1, hi, pos
);
}
return newNode;
}
// 查詢 [l, r](1-indexed)區間第 k 小
query(l: number, r: number, k: number): number {
return this.kth(
this.roots[l - 1], this.roots[r],
0, this.m - 1, k
);
}
// 差值線段樹上的第 K 小查找
private kth(
leftRoot: PSegNode, rightRoot: PSegNode,
lo: number, hi: number, k: number
): number {
if (lo === hi) return this.sorted[lo];
const mid = (lo + hi) >> 1;
// 左子樹在 [l, r] 區間內的元素數量
const leftCount =
(rightRoot.left?.count ?? 0) - (leftRoot.left?.count ?? 0);
if (k <= leftCount) {
// 第 K 小在左子樹
return this.kth(
leftRoot.left ?? new PSegNode(),
rightRoot.left ?? new PSegNode(),
lo, mid, k
);
} else {
// 第 K 小在右子樹,k 要扣掉左子樹的數量
return this.kth(
leftRoot.right ?? new PSegNode(),
rightRoot.right ?? new PSegNode(),
mid + 1, hi, k - leftCount
);
}
}
}
// ─── 使用範例 ──────────────────────────────────────────────
const pstArr = [3, 1, 4, 1, 5, 2, 6, 5, 3, 5];
const pst = new PersistentSegmentTree(pstArr);
// 查詢 [2, 6] 的第 3 小(子陣列 [1, 4, 1, 5, 2])
console.log(pst.query(2, 6, 3)); // 輸出:2
// 查詢 [1, 10] 的第 5 小(全陣列第 5 小)
console.log(pst.query(1, 10, 5)); // 輸出:3
// 查詢 [3, 7] 的第 1 小(子陣列 [4, 1, 5, 2, 6])
console.log(pst.query(3, 7, 1)); // 輸出:1
持久化 Treap(Persistent Treap / BST)
Treap 是結合二元搜尋樹(BST)與堆(Heap)性質的隨機化資料結構。持久化 Treap 透過路徑複製,讓每次分裂(split)和合併(merge)都產生新版本。
// ─── 持久化 Treap ─────────────────────────────────────────
class TreapNode {
left: TreapNode | null = null;
right: TreapNode | null = null;
key: number;
priority: number;
size: number = 1;
constructor(key: number) {
this.key = key;
this.priority = Math.random(); // 隨機優先級
}
}
class PersistentTreap {
private roots: (TreapNode | null)[] = [null]; // 版本 0 為空樹
private getSize(node: TreapNode | null): number {
return node?.size ?? 0;
}
private updateSize(node: TreapNode): void {
node.size = 1 + this.getSize(node.left) + this.getSize(node.right);
}
// 複製節點(路徑複製的基礎)
private clone(node: TreapNode): TreapNode {
const newNode = new TreapNode(node.key);
newNode.left = node.left;
newNode.right = node.right;
newNode.priority = node.priority;
newNode.size = node.size;
return newNode;
}
// 分裂:將樹分成 key ≤ k 和 key > k 兩棵(持久化版本)
private split(
node: TreapNode | null, k: number
): [TreapNode | null, TreapNode | null] {
if (!node) return [null, null];
const cur = this.clone(node); // 路徑複製
if (cur.key <= k) {
const [l, r] = this.split(cur.right, k);
cur.right = l;
this.updateSize(cur);
return [cur, r];
} else {
const [l, r] = this.split(cur.left, k);
cur.left = r;
this.updateSize(cur);
return [l, cur];
}
}
// 合併兩棵 Treap(持久化版本)
private merge(
left: TreapNode | null, right: TreapNode | null
): TreapNode | null {
if (!left) return right;
if (!right) return left;
if (left.priority > right.priority) {
const cur = this.clone(left); // 路徑複製
cur.right = this.merge(cur.right, right);
this.updateSize(cur);
return cur;
} else {
const cur = this.clone(right); // 路徑複製
cur.left = this.merge(left, cur.left);
this.updateSize(cur);
return cur;
}
}
// 在指定版本插入值,回傳新版本號
insert(version: number, key: number): number {
const root = this.roots[version];
const [l, r] = this.split(root, key);
const node = new TreapNode(key);
const merged = this.merge(this.merge(l, node), r);
this.roots.push(merged);
return this.roots.length - 1;
}
// 在指定版本刪除值,回傳新版本號
remove(version: number, key: number): number {
const root = this.roots[version];
const [l, mr] = this.split(root, key - 1);
const [_, r] = this.split(mr, key); // _ 就是 key 節點(被丟棄)
const merged = this.merge(l, r);
this.roots.push(merged);
return this.roots.length - 1;
}
// 查詢指定版本中第 k 小的值
kth(version: number, k: number): number | undefined {
return this.kthNode(this.roots[version], k);
}
private kthNode(node: TreapNode | null, k: number): number | undefined {
if (!node) return undefined;
const leftSize = this.getSize(node.left);
if (k <= leftSize) return this.kthNode(node.left, k);
if (k === leftSize + 1) return node.key;
return this.kthNode(node.right, k - leftSize - 1);
}
// 取得指定版本的樹大小
size(version: number): number {
return this.getSize(this.roots[version]);
}
}
// ─── 使用範例 ──────────────────────────────────────────────
const treap = new PersistentTreap();
const tv1 = treap.insert(0, 5); // 版本 1:{5}
const tv2 = treap.insert(tv1, 3); // 版本 2:{3, 5}
const tv3 = treap.insert(tv2, 7); // 版本 3:{3, 5, 7}
const tv4 = treap.insert(tv1, 1); // 版本 4:從版本 1 分支 {1, 5}
console.log(treap.kth(tv3, 2)); // 輸出:5(版本 3 第 2 小)
console.log(treap.kth(tv4, 1)); // 輸出:1(版本 4 第 1 小)
console.log(treap.size(tv3)); // 輸出:3
console.log(treap.size(tv4)); // 輸出:2
版本管理系統(Version Management System)
讓我們把持久化的概念抽象成一個通用的版本管理系統:
// ─── 通用版本管理系統 ─────────────────────────────────────
interface Snapshot<T> {
id: number;
parentId: number | null;
timestamp: number;
label: string;
data: T;
}
class VersionManager<T> {
private snapshots: Map<number, Snapshot<T>> = new Map();
private nextId: number = 0;
private currentId: number = -1;
// 建立初始版本
init(data: T, label: string = "initial"): number {
const id = this.nextId++;
this.snapshots.set(id, {
id, parentId: null,
timestamp: Date.now(), label, data
});
this.currentId = id;
return id;
}
// 從指定版本建立新版本
commit(
parentId: number,
data: T,
label: string
): number {
if (!this.snapshots.has(parentId)) {
throw new Error(`版本 ${parentId} 不存在`);
}
const id = this.nextId++;
this.snapshots.set(id, {
id, parentId,
timestamp: Date.now(), label, data
});
this.currentId = id;
return id;
}
// 查詢指定版本的資料
checkout(versionId: number): T {
const snap = this.snapshots.get(versionId);
if (!snap) throw new Error(`版本 ${versionId} 不存在`);
this.currentId = versionId;
return snap.data;
}
// 取得版本歷史(從指定版本回溯到初始版本)
getHistory(versionId: number): Snapshot<T>[] {
const history: Snapshot<T>[] = [];
let current: number | null = versionId;
while (current !== null) {
const snap = this.snapshots.get(current);
if (!snap) break;
history.push(snap);
current = snap.parentId;
}
return history.reverse();
}
// 取得所有版本 ID
getAllVersions(): number[] {
return [...this.snapshots.keys()];
}
getCurrentId(): number {
return this.currentId;
}
}
// ─── 使用範例 ──────────────────────────────────────────────
const vm = new VersionManager<string[]>();
const vid0 = vm.init(["main.ts", "utils.ts"], "專案初始化");
const vid1 = vm.commit(vid0, ["main.ts", "utils.ts", "api.ts"], "新增 API 模組");
const vid2 = vm.commit(vid1, ["main.ts", "utils.ts", "api.ts", "test.ts"], "新增測試");
// 從版本 0 分支(模擬 git branch)
const vid3 = vm.commit(vid0, ["main.ts", "utils.ts", "config.ts"], "新增設定檔");
console.log(vm.checkout(vid2));
// 輸出:["main.ts", "utils.ts", "api.ts", "test.ts"]
console.log(vm.checkout(vid3));
// 輸出:["main.ts", "utils.ts", "config.ts"]
console.log(vm.getHistory(vid2).map(s => s.label));
// 輸出:["專案初始化", "新增 API 模組", "新增測試"]
console.log(vm.getHistory(vid3).map(s => s.label));
// 輸出:["專案初始化", "新增設定檔"]
C++ 對照實作
C++ 在競賽中實作持久化線段樹時,通常使用 靜態記憶體池(避免動態 new 的開銷),並以整數索引代替指標。
持久化線段樹:區間第 K 小
#include <bits/stdc++.h>
using namespace std;
// 靜態記憶體池:避免動態記憶體分配
const int MAXNODES = 20000005;
struct Node {
int left, right, cnt;
} tree[MAXNODES];
int tot = 0; // 全域節點計數器
// 建立新節點(路徑複製)
int newNode(int node) {
tree[++tot] = tree[node]; // 複製舊節點的所有欄位
return tot;
}
// 更新:在值域 [lo, hi] 的位置 pos 計數 +1
int update(int node, int lo, int hi, int pos) {
int cur = newNode(node); // 路徑複製
tree[cur].cnt++;
if (lo == hi) return cur;
int mid = (lo + hi) / 2;
if (pos <= mid)
tree[cur].left = update(tree[node].left, lo, mid, pos);
else
tree[cur].right = update(tree[node].right, mid + 1, hi, pos);
return cur;
}
// 在差值線段樹上查詢第 k 小
int kth(int lNode, int rNode, int lo, int hi, int k) {
if (lo == hi) return lo;
int mid = (lo + hi) / 2;
int leftCnt = tree[tree[rNode].left].cnt
- tree[tree[lNode].left].cnt;
if (k <= leftCnt)
return kth(tree[lNode].left, tree[rNode].left,
lo, mid, k);
else
return kth(tree[lNode].right, tree[rNode].right,
mid + 1, hi, k - leftCnt);
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, q;
cin >> n >> q;
vector<int> a(n + 1);
vector<int> sorted_a;
for (int i = 1; i <= n; i++) {
cin >> a[i];
sorted_a.push_back(a[i]);
}
// 離散化
sort(sorted_a.begin(), sorted_a.end());
sorted_a.erase(
unique(sorted_a.begin(), sorted_a.end()),
sorted_a.end()
);
int m = sorted_a.size();
auto compress = [&](int x) {
return lower_bound(
sorted_a.begin(), sorted_a.end(), x
) - sorted_a.begin();
};
// 建立 n+1 個版本
vector<int> roots(n + 1, 0);
for (int i = 1; i <= n; i++) {
roots[i] = update(roots[i - 1], 0, m - 1,
compress(a[i]));
}
// 處理查詢
while (q--) {
int l, r, k;
cin >> l >> r >> k;
int idx = kth(roots[l - 1], roots[r], 0, m - 1, k);
cout << sorted_a[idx] << "\n";
}
return 0;
}
TypeScript vs C++ 關鍵差異:
| 面向 | TypeScript | C++ |
|---|---|---|
| 節點建立 | new PSegNode()(動態物件) | 靜態陣列 tree[MAXNODES] |
| 參照方式 | 物件指標(reference) | 整數索引 |
| 記憶體管理 | GC 自動回收 | 手動估算記憶體池大小 |
| 效能 | 常數因子較大 | 常數因子小,適合競賽 |
| 空間估算 | 不需預估 | 需預留 n * log(m) * 2 |
複雜度分析
| 操作 | 時間複雜度 | 空間複雜度 | 說明 |
|---|---|---|---|
| 持久化陣列 — 單次查詢 | O(log n) | O(1) | 二元樹走到葉節點 |
| 持久化陣列 — 單次更新 | O(log n) | O(log n) | 路徑複製 log n 個節點 |
| 持久化線段樹 — 建構 | O(n log n) | O(n log n) | n 次更新各 O(log n) 節點 |
| 持久化線段樹 — 單次查詢 | O(log n) | O(1) | 差值線段樹走到葉節點 |
| 持久化 Treap — 插入/刪除 | O(log n) 期望 | O(log n) 期望 | 隨機化保證期望高度 |
| 持久化鏈結串列 — prepend | O(1) | O(1) | 直接加新頭節點 |
| Fat Node — 查詢 | O(log v) | — | v 為版本數,二分搜尋 |
空間估算實務:持久化線段樹中,n = 10^5、離散化後 m ≈ 10^5,每次更新複製 log₂(10^5) ≈ 17 個節點,n 次更新共 ≈ 1.7 × 10^6 個節點。每個節點約 12 bytes(左子、右子、計數),總記憶體約 20 MB。
變體與延伸
函數式資料結構(Functional Data Structures)
在函數式程式語言中,所有資料結構預設都是不可變的——這意味著它們天生就是持久化的。
- Clojure 的 PersistentVector:使用 32-ary Trie(32 叉樹),路徑長度 log₃₂ n ≤ 7(對 n ≤ 10^10),實際存取效率接近 O(1)
- Haskell 的 Data.Map:基於 Weight-Balanced Tree,所有操作自動保留舊版本
- Scala 的 Vector:與 Clojure 類似,使用 32-ary Trie 實現高效持久化向量
這些語言的成功說明了:持久化不僅是一種演算法技巧,更是一種程式設計範式。
持久化並查集(Persistent Union-Find)
持久化並查集允許在任意歷史版本上進行 find 和 union 操作。實作上使用持久化陣列(而非普通陣列)來儲存 parent 和 rank,但需要注意:路徑壓縮在持久化中不可用(因為它會修改非路徑上的節點),所以只能使用 按秩合併(Union by Rank)來保證效率。
// ─── 持久化並查集(概念骨架)────────────────────────────────
class PersistentUnionFind {
private parentArr: PersistentArray;
private rankArr: PersistentArray;
constructor(n: number) {
// 初始化:每個元素的 parent 指向自己,rank 為 0
const parents = Array.from({ length: n }, (_, i) => i);
const ranks = new Array(n).fill(0);
this.parentArr = new PersistentArray(parents);
this.rankArr = new PersistentArray(ranks);
}
// 在指定版本上查找根(不做路徑壓縮)
find(version: number, x: number): number {
let parent = this.parentArr.query(version, x);
while (parent !== x) {
x = parent;
parent = this.parentArr.query(version, x);
}
return x;
}
// 在指定版本上合併兩個集合,回傳新版本號
union(version: number, x: number, y: number): number {
const rootX = this.find(version, x);
const rootY = this.find(version, y);
if (rootX === rootY) return version; // 同一集合,不需合併
const rankX = this.rankArr.query(version, rootX);
const rankY = this.rankArr.query(version, rootY);
let newVersion: number;
if (rankX < rankY) {
newVersion = this.parentArr.update(version, rootX, rootY);
} else if (rankX > rankY) {
newVersion = this.parentArr.update(version, rootY, rootX);
} else {
newVersion = this.parentArr.update(version, rootY, rootX);
// 注意:這裡需要在新版本上更新 rank
// 實際實作中需要讓 parentArr 和 rankArr 共用版本號
// 此處為概念示意
}
return newVersion;
}
}
注意:完整的持久化並查集實作需要讓
parentArr和rankArr同步版本,通常會將兩者合併為一個持久化陣列或使用統一的版本管理器。
追溯式資料結構(Retroactive Data Structures)
追溯式資料結構比持久化更進一步:它允許你修改 過去的操作,並讓所有後續版本自動更新。
持久化 vs 追溯:
持久化:
v0 → v1(insert 5)→ v2(insert 3)→ v3(delete 5)
修改 v1 → 產生新分支 v1',不影響 v2、v3
追溯:
v0 → v1(insert 5)→ v2(insert 3)→ v3(delete 5)
修改 v1 的操作(改為 insert 7)→ v2、v3 自動連鎖更新
結果:v0 → v1'(insert 7)→ v2'(insert 3)→ v3'(delete 7)
追溯式資料結構在理論上非常有趣(由 Demaine 等人在 2004 年提出),但實作複雜度極高,目前主要存在於學術研究中。
面試考點
持久化資料結構在面試中的考察重點:
1. 概念理解
- 解釋持久化(Persistence)與不可變(Immutability)的區別
- 為什麼路徑複製的空間複雜度是 O(log n) 而非 O(n)?
- Fat Node 和 Path Copying 的優缺點比較
2. 實作能力
- 手寫持久化線段樹(主席樹)解區間第 K 小
- 實作持久化陣列的
update和query - 解釋離散化在持久化線段樹中的作用
3. 系統設計連結
- Git 的資料模型如何體現持久化思想?
- 資料庫 MVCC 與持久化的關係
- React 不可變狀態更新(Immutable Update)的原理
面試回答範本:
「持久化資料結構透過結構共享讓每次修改只產生 O(log n) 的額外空間。最常見的技術是路徑複製——修改時只複製根到修改點的路徑,其餘節點與舊版本共享。這讓我們能以 O(n log n) 的總空間保存 n 個版本,每個版本都可在 O(log n) 時間查詢。實務中,Git 的 Tree Object、資料庫的 MVCC、React 的狀態管理都運用了類似的思想。」
LeetCode 練習題
1. LeetCode 307 — Range Sum Query - Mutable(暖身)
難度:Medium
連結:https://leetcode.com/problems/range-sum-query-mutable/
雖然此題不需要持久化,但它是熟悉線段樹結構的最佳起點——持久化線段樹就是在此基礎上加上路徑複製。
// 線段樹實作(持久化線段樹的基礎)
class NumArray {
private tree: number[];
private n: number;
constructor(nums: number[]) {
this.n = nums.length;
this.tree = new Array(4 * this.n).fill(0);
this.build(nums, 1, 0, this.n - 1);
}
private build(
nums: number[], node: number, lo: number, hi: number
): void {
if (lo === hi) {
this.tree[node] = nums[lo];
return;
}
const mid = (lo + hi) >> 1;
this.build(nums, 2 * node, lo, mid);
this.build(nums, 2 * node + 1, mid + 1, hi);
this.tree[node] = this.tree[2 * node] + this.tree[2 * node + 1];
}
update(index: number, val: number): void {
this.updateHelper(1, 0, this.n - 1, index, val);
}
private updateHelper(
node: number, lo: number, hi: number,
pos: number, val: number
): void {
if (lo === hi) {
this.tree[node] = val;
return;
}
const mid = (lo + hi) >> 1;
if (pos <= mid)
this.updateHelper(2 * node, lo, mid, pos, val);
else
this.updateHelper(2 * node + 1, mid + 1, hi, pos, val);
this.tree[node] = this.tree[2 * node] + this.tree[2 * node + 1];
}
sumRange(left: number, right: number): number {
return this.queryHelper(1, 0, this.n - 1, left, right);
}
private queryHelper(
node: number, lo: number, hi: number,
l: number, r: number
): number {
if (r < lo || hi < l) return 0;
if (l <= lo && hi <= r) return this.tree[node];
const mid = (lo + hi) >> 1;
return this.queryHelper(2 * node, lo, mid, l, r) +
this.queryHelper(2 * node + 1, mid + 1, hi, l, r);
}
}
2. LeetCode 315 — Count of Smaller Numbers After Self
難度:Hard
連結:https://leetcode.com/problems/count-of-smaller-numbers-after-self/
持久化思路:從右到左掃描陣列,每次將當前元素插入值域線段樹。對位置 i 的答案就是插入 a[i] 之前,值域 [min, a[i]-1] 的計數。雖然此題不一定要用持久化(離線 BIT 也行),但持久化線段樹的版本差分思想在此處非常自然。
// 核心思路:值域線段樹 + 從右往左建版本
function countSmaller(nums: number[]): number[] {
const sorted = [...new Set(nums)].sort((a, b) => a - b);
const m = sorted.length;
const compress = (v: number) => {
let lo = 0, hi = sorted.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (sorted[mid] < v) lo = mid + 1;
else hi = mid;
}
return lo;
};
// 使用 BIT(樹狀陣列)更簡潔
const bit = new Array(m + 2).fill(0);
const bitUpdate = (i: number) => {
for (i++; i <= m; i += i & (-i)) bit[i]++;
};
const bitQuery = (i: number) => {
let s = 0;
for (i++; i > 0; i -= i & (-i)) s += bit[i];
return s;
};
const result = new Array(nums.length);
for (let i = nums.length - 1; i >= 0; i--) {
const idx = compress(nums[i]);
result[i] = bitQuery(idx - 1);
bitUpdate(idx);
}
return result;
}
console.log(countSmaller([5, 2, 6, 1]));
// 輸出:[2, 1, 1, 0]
3. LeetCode 327 — Count of Range Sum
難度:Hard
連結:https://leetcode.com/problems/count-of-range-sum/
與持久化的關聯:此題需要統計前綴和陣列中滿足 lower ≤ prefix[j] - prefix[i] ≤ upper 的配對數量。可以用持久化線段樹(或歸併排序)解決,核心是對前綴和的值域建立版本化計數。
4. LeetCode 699 — Falling Squares
難度:Hard
連結:https://leetcode.com/problems/falling-squares/
與持久化的關聯:每個方塊掉落後都會改變高度圖的狀態。如果我們想查詢「第 k 個方塊掉落後的高度圖」,就需要保留每個版本的線段樹。
5. LeetCode 218 — The Skyline Problem
難度:Hard
連結:https://leetcode.com/problems/the-skyline-problem/
與持久化的關聯:天際線問題需要追蹤建築物的出現與消失對高度輪廓的影響,本質上是一個版本化的最大值查詢問題。雖然通常用掃描線 + 堆解決,但持久化線段樹提供了另一種優雅的思路。
總結
本文深入探討了 持久化資料結構(Persistent Data Structures) 的核心概念與實作技術:
核心技術:路徑複製(Path Copying) 透過只複製修改路徑上的節點,以 O(log n) 的額外空間保存完整版本歷史。Fat Node 則在節點內記錄版本映射表,適合不同場景。
三種持久化層級:部分持久化(只能修改最新版本)、完全持久化(可修改任意版本產生分支)、匯合持久化(可合併版本)——由簡到繁,對應不同需求。
核心結構:持久化陣列、持久化鏈結串列、持久化線段樹(主席樹)、持久化 Treap——每一種都是在對應的可變結構上「加裝」路徑複製。
實務應用:Git 的 Tree Object、資料庫 MVCC、函數式語言的不可變集合、React/Redux 的狀態管理——持久化思想已經深深融入現代軟體工程的每個角落。
競賽核心:持久化線段樹(主席樹)是解決「靜態區間第 K 小」的經典工具,掌握離散化 + 前綴版本差分 + 路徑複製三個要素即可。
持久化資料結構的設計哲學——不修改,而是建立新版本——與函數式程式設計的不可變原則不謀而合。理解這個思想,不僅能幫你解決演算法競賽中的版本化查詢問題,更能讓你在系統設計中自然地運用版本控制、快照隔離等技術。
在下一篇文章中,我們將開始一個全新的系列—— 實戰應用系列。第 040 篇將探討 設計 Rate Limiter,帶你運用前面學到的資料結構知識解決真實系統設計問題。敬請期待!