版本控制不是 Git 的專利。線段樹也能做到「改一個值,舊的那版還在」——而且省到讓人意外。

為什麼需要它?線段樹不是已經很強了嗎?

一般線段樹強在當下:區間和、區間最值,改一個值 O(log n)、查一段 O(log n),沒話說。但它有個致命的健忘症——你一改值,上一版就永遠消失了

問題是有整類題目要問的正是歷史:「陣列在第 5 次操作後、[3, 8] 這段的和是多少?」或者更經典的「區間 [L, R] 裡第 k 小的數是誰?」這些都需要你手上同時握著很多個版本的線段樹,隨時能翻回任何一版去查。

最直覺的作法是每改一次就把整棵樹拷貝一份。撞牆撞得很快:一棵 n 個葉子的線段樹有 ~2n 個節點,你做 m 次修改就是 O(nm) 空間。n 跟 m 都上到 10^5,這數字直接爆掉記憶體。

持久化線段樹的招數,是看穿一件事:改一個位置,其實只動到從根到那個葉子的一條路徑,深度 log n。 樹上其他絕大多數節點根本沒變——那幹嘛複製它們?

路徑複製:只複製被改到的那條路

核心技巧就一句話:每次更新,只新建「從根到修改點」這條路徑上的節點,路徑以外的子樹,新版本直接指回舊版本的節點共享。

版本 0(原始):
          [0,7]
         /      \
     [0,3]      [4,7]
     /    \
  [0,1]  [2,3]
 
更新位置 2 → 產生版本 1:
 
  root1                    root0(原封不動,仍可查)
    |                        |
  [0,7]'                   [0,7]
  /    \                   /    \
[0,3]'  \  ─────共享────► [4,7]        ← [4,7] 沒被動到,兩版共指同一節點
 /  \
[0,1] [2,3]'

  └── 共享舊版的 [0,1]
 
打了 ' 的 = 這次新建的節點([0,7]'、[0,3]'、[2,3]')
數量 = 這條 root→修改點路徑上的節點數,約 log n

看懂這張圖,整個資料結構就懂一半了。root1 是一棵看起來完整的線段樹,但它 90% 的節點是跟 root0 借的。新建的只有那條通往位置 2 的路徑。舊的 root0 一根汗毛都沒少,你之後拿它查任何區間,結果跟修改前完全一樣。

每個版本就存一個根節點,要哪一版就從哪個根走下去:

// 每個版本保留一個根節點
List<Node> roots = new ArrayList<>();
roots.add(build(0, n - 1));        // 版本 0:初始建構
 
// 更新 → 不覆蓋舊版,而是長出一個新版本
void update(int version, int pos, long delta) {
    Node newRoot = update(roots.get(version), pos, delta);
    roots.add(newRoot);            // 新根接在版本清單尾巴
}

真正的巧勁全在遞迴這一段——進到節點先複製自己,然後只對「有走到」的那一側遞迴,另一側原地共享

Node update(Node node, int pos, long delta) {
    Node newNode = node.copy();           // 先複製當前節點(連兩個子指標一起拷)
    if (node.left == node.right) {         // 到葉子了
        newNode.sum += delta;
        return newNode;
    }
    int mid = (node.left + node.right) / 2;
    if (pos <= mid)
        newNode.leftChild = update(node.leftChild, pos, delta);  // 只有左側重建
        // newNode.rightChild 還指著舊節點 —— 這就是「共享」
    else
        newNode.rightChild = update(node.rightChild, pos, delta); // 只有右側重建
    newNode.sum = newNode.leftChild.sum + newNode.rightChild.sum;
    return newNode;
}

node.copy() 那行同時拷貝了左右兩個子指標——關鍵在於只有被遞迴的那一側會被覆寫成新節點,另一側就這樣繼續指向舊版。這就是共享的全部祕密,沒有魔法。

查詢反而是最無聊的部分:跟一般線段樹一模一樣,只是入口從「the 那棵樹」換成「你指定版本的根」:

long query(int version, int L, int R) {
    return query(roots.get(version), L, R);  // 從第 version 版的根往下查
}

主席樹:區間第 k 小的標準答案

持久化線段樹最漂亮的應用,是解「靜態區間第 k 小」——這在中文競程圈幾乎跟「主席樹」畫上等號。

想法是對前綴各建一個版本。線段樹這次不存區間和,而是當值域上的計數桶:把每個數字丟進它所屬值域的葉子,記「這個值出現幾次」。

對前綴逐個插入,留下每一版:
  roots[0] = 空樹(什麼都還沒插)
  roots[1] = 插入 arr[0] 後
  roots[2] = 插入 arr[0], arr[1] 後
  ...
  roots[i] = 前 i 個元素的值域計數樹
 
關鍵觀察:roots[R] 減掉 roots[L-1],
逐節點相減,得到的正好是「只含 arr[L..R] 這段元素」的計數樹。
 
在這棵差分樹上找第 k 小:
  看左子樹(較小值域那半)裡裝了 cnt 個元素——
    cnt >= k  → 第 k 小落在左半,往左走
    cnt <  k  → 往右走,並把 k 扣掉 cnt(前 cnt 名不算了)
  一路走到葉子,那個值域就是答案

妙就妙在「兩棵版本相減」這步。因為 roots[R]roots[L-1] 大量共享節點,相減時同一結構位置的兩個節點一起往下走,計數 = 右版該節點 - 左版該節點,就把 [L, R] 這段的貢獻乾淨地剝出來了。整趟查詢只沿著一條 log n 的路徑走,不用真的把差分樹建出來。

沒有持久化,這題你得對每個區間重算,或搬出更重的離線做法;有了它,前綴版本一次建好,之後每次查詢 O(log n) 就吐答案。這就是為什麼它是這題的標準解,不是「其中一種解」。

複雜度:省在哪,一張表說完

操作時間空間
初始建構O(n)O(n)
更新一次(長一個新版本)O(log n)O(log n)
查詢任意版本O(log n)
m 次更新後總空間O(n + m log n)

重點就是那個粗體的 O(log n)。跟「每次整棵複製」的 O(n) 擺在一起看,差距是 log n 對 n——n 上到十萬,就是「多幾十個節點」跟「多二十萬個節點」的差別。持久化不是把版本控制變可能,是把它從奢侈品變成能上場的日常工具。

🎬 互動視覺化持久化線段樹動畫 — 一步步看更新時哪條路徑被複製、哪些節點跟舊版牽著同一條線共享,比盯著上面那幾張 ASCII 圖腦補快得多。


一般線段樹活在當下,改了就忘;持久化線段樹記得每一個昨天,而且記得很省——只把改過的那一頁抄下來,其餘的翻回舊本子接著看。

接下來往哪走

  • Segment Tree 線段樹 — 前置知識:先把「當下版本」的線段樹搞熟,再談怎麼留住歷史
  • Suffix Array 後綴陣列 — 同一個互動視覺化頁的鄰居,另一個把「預處理換查詢速度」玩到極致的結構