跳到主要內容

資料結構

資料怎麼排,決定了哪些操作便宜

主題 · 線段樹與樹狀陣列

線段樹與樹狀陣列

區間總和或最小值要能隨時查、也要能隨時改:前綴和陣列每改一格就得重算 O(n),線段樹和樹狀陣列(Fenwick tree)把查和改都壓到 O(log n),而且都存在一個陣列裡。

結構
操作
2
6
1/12
陣列
  1. 5
    0
  2. 3
    1
  3. 7
    2
  4. 9
    3
  5. 6
    4
  6. 4
    5
  7. 1
    6
  8. 2
    7
  9. 8
    8
  10. 6
    9
51[0, 9]30[0, 4]15[0, 2]8[0, 1]5[0]3[1]7[2]15[3, 4]9[3]6[4]21[5, 9]7[5, 7]5[5, 6]4[5]1[6]2[7]14[8, 9]8[8]6[9]
正在看整塊拿來用/被改已經算進去跳過

節點 [0, 9] 只有一部分在 [2, 6] 裡:往下看兩個子節點。

同一個操作,四種做法(碰到的格子或節點數)
逐格掃描5
前綴和陣列2
線段樹11
樹狀陣列4
亮起來的是這一步執行的程式碼
class SegmentTree {
private tree: number[];
constructor(private a: number[]) {
this.tree = new Array(4 * Math.max(1, a.length)).fill(0);
if (a.length) this.build(1, 0, a.length - 1);
}
private build(node: number, lo: number, hi: number): void {
if (lo === hi) { this.tree[node] = this.a[lo]; return; }
const mid = (lo + hi) >> 1;
this.build(2 * node, lo, mid);
this.build(2 * node + 1, mid + 1, hi);
this.tree[node] = this.tree[2 * node] + this.tree[2 * node + 1];
}
update(i: number, value: number, node = 1, lo = 0, hi = this.a.length - 1): void {
if (lo === hi) { this.a[i] = value; this.tree[node] = value; return; }
const mid = (lo + hi) >> 1;
if (i <= mid) this.update(i, value, 2 * node, lo, mid);
else this.update(i, value, 2 * node + 1, mid + 1, hi);
this.tree[node] = this.tree[2 * node] + this.tree[2 * node + 1];
}
query(l: number, r: number, node = 1, lo = 0, hi = this.a.length - 1): number {
if (r < lo || hi < l) return 0;
if (l <= lo && hi <= r) return this.tree[node];
const mid = (lo + hi) >> 1;
return this.query(l, r, 2 * node, lo, mid)
+ this.query(l, r, 2 * node + 1, mid + 1, hi);
}
}
class FenwickTree {
private t: number[];
constructor(n: number) {
this.t = new Array(n + 1).fill(0);
}
// Add delta at 0-based index i: climb by the lowest set bit.
add(i: number, delta: number): void {
for (let j = i + 1; j < this.t.length; j += j & -j) this.t[j] += delta;
}
// Sum of the first count elements: descend by the lowest set bit.
prefix(count: number): number {
let sum = 0;
for (let j = count; j > 0; j -= j & -j) sum += this.t[j];
return sum;
}
rangeSum(l: number, r: number): number {
return this.prefix(r + 1) - this.prefix(l);
}
}

模型假設與範圍

  • Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。

什麼時候用

  • 資料會一直改,又要不斷查某一段的總和、最小值、最大值:例如即時排行榜的分數區間、股價的區間最高點。只查不改用前綴和就夠了;只改不查用普通陣列就夠了。
  • 只要「和」這類可以相減的運算,用樹狀陣列(Fenwick tree,又稱 BIT,Binary Indexed Tree):程式短、記憶體只要 n + 1 格。要最小值、最大值或更複雜的合併,用線段樹。

和其他主題的關係

由這些組成
動態陣列

時間與空間複雜度(Big O)

操作平均最差
區間查詢(線段樹)
每一層最多碰到 4 個節點
O(log n)O(log n)
單點更新(線段樹)
從根走到葉子再往回更新
O(log n)O(log n)
區間查詢(樹狀陣列)
兩次前綴和,每次每個位元最多一步
O(log n)O(log n)
單點更新(樹狀陣列)O(log n)O(log n)
建立
樹狀陣列逐一加入是 O(n log n),也有 O(n) 的建法
O(n)O(n)

空間:O(n),線段樹約 4n 格;樹狀陣列剛好 n + 1 格

Big O 實測:n 變大時步數怎麼長

數的是:每次操作平均碰到的節點或格子數(400 次隨機操作)

Big On = 1,000n = 8,000n = 64,000成長倍數:實測(理論)
線段樹:區間查詢O(log n)32.744.956.9×1.7 (×1.6)
樹狀陣列:區間查詢O(log n)9.812.815.7×1.6 (×1.6)
樹狀陣列:單點更新O(log n)5.16.78.2×1.6 (×1.6)
逐格掃描:區間查詢O(n)3322,69422,181×67 (×64)

和其他做法比

查詢(n = 1,000)查詢(n = 64,000)更新(n = 1,000)更新(n = 64,000)
逐格掃描331.822,180.711
前綴和陣列22545.632,306.8
線段樹32.756.91117
樹狀陣列9.815.75.18.2

每種大小各做 400 次隨機區間查詢和單點更新,數平均碰到幾格或幾個節點。逐格掃描查詢很慢、更新很快;前綴和陣列剛好相反(n = 64,000 時改一格平均要重寫 32,307 格)。兩種樹都把查詢和更新同時壓到幾十步以內,樹狀陣列又只要線段樹的三分之一到一半。

真實世界裡的它

  • 競賽和面試題的「逆序數」「右邊比自己小的個數」:把值當索引,用樹狀陣列計數。
  • 資料庫和時間序列系統對時間區間做彙總時,也常用類似的分層彙總結構。

取捨與陷阱

  • 樹狀陣列是 1-based:t[0] 不用,lowbit(0) = 0 會讓迴圈停不下來。
  • 線段樹陣列要開 4n 格才保證夠用;開 2n 在 n 不是 2 的冪次時會越界。
  • 要「整段一起加」時,單點更新的線段樹會變成 O(n log n):那要用延遲標記(lazy propagation),或改用差分陣列配合樹狀陣列。

LeetCode 練習