線段樹與樹狀陣列
區間總和或最小值要能隨時查、也要能隨時改:前綴和陣列每改一格就得重算 O(n),線段樹和樹狀陣列(Fenwick tree)把查和改都壓到 O(log n),而且都存在一個陣列裡。
結構
操作
2
6
1/12
陣列
- 50
- 31
- 72
- 93
- 64
- 45
- 16
- 27
- 88
- 69
正在看整塊拿來用/被改已經算進去跳過
節點 [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 O | n = 1,000 | n = 8,000 | n = 64,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 線段樹:區間查詢 | O(log n) | 32.7 | 44.9 | 56.9 | ×1.7 (×1.6) |
| 樹狀陣列:區間查詢 | O(log n) | 9.8 | 12.8 | 15.7 | ×1.6 (×1.6) |
| 樹狀陣列:單點更新 | O(log n) | 5.1 | 6.7 | 8.2 | ×1.6 (×1.6) |
| 逐格掃描:區間查詢 | O(n) | 332 | 2,694 | 22,181 | ×67 (×64) |
和其他做法比
| 查詢(n = 1,000) | 查詢(n = 64,000) | 更新(n = 1,000) | 更新(n = 64,000) | |
|---|---|---|---|---|
| 逐格掃描 | 331.8 | 22,180.7 | 1 | 1 |
| 前綴和陣列 | 2 | 2 | 545.6 | 32,306.8 |
| 線段樹 | 32.7 | 56.9 | 11 | 17 |
| 樹狀陣列 | 9.8 | 15.7 | 5.1 | 8.2 |
每種大小各做 400 次隨機區間查詢和單點更新,數平均碰到幾格或幾個節點。逐格掃描查詢很慢、更新很快;前綴和陣列剛好相反(n = 64,000 時改一格平均要重寫 32,307 格)。兩種樹都把查詢和更新同時壓到幾十步以內,樹狀陣列又只要線段樹的三分之一到一半。
真實世界裡的它
- 競賽和面試題的「逆序數」「右邊比自己小的個數」:把值當索引,用樹狀陣列計數。
- 資料庫和時間序列系統對時間區間做彙總時,也常用類似的分層彙總結構。
取捨與陷阱
- 樹狀陣列是 1-based:t[0] 不用,lowbit(0) = 0 會讓迴圈停不下來。
- 線段樹陣列要開 4n 格才保證夠用;開 2n 在 n 不是 2 的冪次時會越界。
- 要「整段一起加」時,單點更新的線段樹會變成 O(n log n):那要用延遲標記(lazy propagation),或改用差分陣列配合樹狀陣列。