持久化資料結構完全指南 — 路徑複製、Fat Node 與持久化線段樹 | 資料結構與演算法

2026/07/22
持久化資料結構完全指南 — 路徑複製、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++ 關鍵差異

面向TypeScriptC++
節點建立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) 期望隨機化保證期望高度
持久化鏈結串列 — prependO(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)

持久化並查集允許在任意歷史版本上進行 findunion 操作。實作上使用持久化陣列(而非普通陣列)來儲存 parentrank,但需要注意:路徑壓縮在持久化中不可用(因為它會修改非路徑上的節點),所以只能使用 按秩合併(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;
  }
}

注意:完整的持久化並查集實作需要讓 parentArrrankArr 同步版本,通常會將兩者合併為一個持久化陣列或使用統一的版本管理器。

追溯式資料結構(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 小
  • 實作持久化陣列的 updatequery
  • 解釋離散化在持久化線段樹中的作用

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) 的核心概念與實作技術:

  1. 核心技術路徑複製(Path Copying) 透過只複製修改路徑上的節點,以 O(log n) 的額外空間保存完整版本歷史。Fat Node 則在節點內記錄版本映射表,適合不同場景。

  2. 三種持久化層級部分持久化(只能修改最新版本)、完全持久化(可修改任意版本產生分支)、匯合持久化(可合併版本)——由簡到繁,對應不同需求。

  3. 核心結構:持久化陣列、持久化鏈結串列、持久化線段樹(主席樹)、持久化 Treap——每一種都是在對應的可變結構上「加裝」路徑複製。

  4. 實務應用:Git 的 Tree Object、資料庫 MVCC、函數式語言的不可變集合、React/Redux 的狀態管理——持久化思想已經深深融入現代軟體工程的每個角落。

  5. 競賽核心:持久化線段樹(主席樹)是解決「靜態區間第 K 小」的經典工具,掌握離散化 + 前綴版本差分 + 路徑複製三個要素即可。

持久化資料結構的設計哲學——不修改,而是建立新版本——與函數式程式設計的不可變原則不謀而合。理解這個思想,不僅能幫你解決演算法競賽中的版本化查詢問題,更能讓你在系統設計中自然地運用版本控制、快照隔離等技術。

在下一篇文章中,我們將開始一個全新的系列—— 實戰應用系列。第 040 篇將探討 設計 Rate Limiter,帶你運用前面學到的資料結構知識解決真實系統設計問題。敬請期待!

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →