線段樹 — Lazy Propagation 與區間查詢完整實作 | 資料結構與演算法
線段樹(Segment Tree) 是一種基於分治思想的二元樹型資料結構,能以 O(log n) 時間同時支援區間查詢與區間更新。搭配 Lazy Propagation(延遲傳播) 機制,它成為競技程式設計與系統工程中處理動態區間問題的首選工具。
前言
想像你管理一座圖書館,書架上有 10,000 本書,每本書都有一個分數。你需要頻繁回答以下問題:
- 第 100 到 500 號書架的總分是多少?
- 把第 200 到 300 號的所有書加 5 分。
- 再次查詢第 100 到 500 號的總分。
如果每次都從頭掃描,複雜度是 O(n),1000 次查詢就是百萬次運算。前綴和(Prefix Sum) 能讓查詢降到 O(1),但更新後必須重建,更新本身回到 O(n)。
線段樹 在這兩個操作之間取得最佳平衡:無論查詢還是更新,都穩定在 O(log n)。
學習本文後你將能夠:
- 理解線段樹的結構設計與節點對應關係
- 實作 build、query、update 三大核心操作
- 掌握 Lazy Propagation 的原理與 pushDown 機制
- 用 TypeScript 與 C++ 寫出完整的線段樹類別
- 解決 LeetCode 上常見的區間問題
為什麼需要線段樹
先看各種方法的時間複雜度比較:
| 操作 | 暴力法 | 前綴和 | 線段樹 |
|---|---|---|---|
| 單點更新 | O(1) | O(n) 重建 | O(log n) |
| 區間查詢 | O(n) | O(1) | O(log n) |
| 區間更新 | O(n) | O(n) 重建 | O(log n) |
前綴和(Prefix Sum)的弱點在於:一旦陣列元素被修改,整個前綴和陣列就必須重建,使得更新代價高昂。線段樹則以 O(log n) 完成所有操作,是動態修改場景的最佳選擇。
線段樹的結構設計
核心思想:遞迴分割區間
線段樹使用陣列模擬完全二元樹。對於原始陣列 [2, 1, 5, 3, 4]:
- 根節點(節點 1)管理整個區間
[0, 4],儲存總和 15 - 節點 2 管理左半
[0, 2],總和 8 - 節點 3 管理右半
[3, 4],總和 7 - 以此類推,直到葉節點只管理單一元素
原始陣列: [2, 1, 5, 3, 4]
[0,4] sum=15
/ \
[0,2] sum=8 [3,4] sum=7
/ \ / \
[0,1] sum=3 [2,2] [3,3] [4,4]
/ \ sum=5 sum=3 sum=4
[0,0] [1,1]
sum=2 sum=1
節點編號(1-indexed):
1 (sum=15)
/ \
2 3
/ \ / \
4 5 6 7
/ \
8 9
節點索引規則
線段樹採用 1-indexed 的陣列儲存:
- 節點
i的左子節點是2 * i - 節點
i的右子節點是2 * i + 1 - 節點
i的父節點是⌊i / 2⌋ - 對於長度 n 的陣列,需要 4n 的空間(預留充足避免越界)
再看一個更完整的例子(8 個元素):
陣列索引: 0 1 2 3 4 5 6 7
值: 3 1 4 1 5 9 2 6
線段樹(區間求和):
node[1]=[0,7]=31
/ \
node[2]=[0,3]=9 node[3]=[4,7]=22
/ \ / \
node[4]=[0,1]=4 [5]=[2,3]=5 [6]=[4,5]=14 [7]=[6,7]=8
/ \ / \ / \ / \
[0,0]=3 [1,1]=1 [2,2]=4 [3,3]=1 [4,4]=5 [5,5]=9 [6,6]=2 [7,7]=6
三大核心操作
Build(建樹)
自底向上建構:葉節點儲存原始值,內部節點合併左右子節點的結果。時間複雜度 O(n)。
build(node, start, end):
if start == end:
tree[node] = nums[start] // 葉節點
return
mid = (start + end) / 2
build(2*node, start, mid) // 建左子樹
build(2*node+1, mid+1, end) // 建右子樹
tree[node] = tree[2*node] + tree[2*node+1] // 合併
Query(區間查詢)
將查詢區間 [l, r] 與節點管理的區間 [start, end] 比對,分三種情況處理:
- 完全不重疊(
r < start或end < l):返回零元素(求和返回 0,求最大值返回-∞) - 完全覆蓋(
l <= start且end <= r):直接返回此節點的值 - 部分重疊:遞迴查詢左右子樹,合併結果
時間複雜度 O(log n)(最多訪問 4 log n 個節點)。
Update(單點更新)
沿著根到目標葉節點的路徑往下,更新葉節點後,回溯時重新計算每個祖先節點的聚合值。時間複雜度 O(log n)。
Lazy Propagation(延遲傳播)
問題:區間更新的瓶頸
若要對區間 [l, r] 的每個元素都加上常數 k,樸素做法是對每個葉節點個別更新,最壞情況下是 O(n log n)——等同於 n 次單點更新。
Lazy Propagation(懶標記) 解決了這個問題。
核心思想
更新時,不立即把「加 k」傳播到所有子孫節點,而是:
- 在當前節點打上懶標記(lazy tag):「此節點以下的所有子孫都需要加 k」
- 同時更新當前節點的聚合值(利用區間長度直接計算)
- 只有當真正需要進入子節點時(查詢或更新需要分裂區間),才將懶標記**下推(pushDown)**給兩個子節點
這樣每次區間更新只需訪問 O(log n) 個節點。
懶標記的視覺化
對 [1, 4] 所有元素加 3(陣列長度 8):
初始狀態:
[0,7] sum=31 lazy=0
/ \
[0,3] sum=9 lazy=0 [4,7] sum=22 lazy=0
更新過程:
步驟1: 根節點 [0,7] 部分重疊,往下分
步驟2: 左節點 [0,3] 部分重疊,往下分
步驟3: [0,0] 不重疊,跳過
步驟4: [1,3] 完全覆蓋 → sum += 3×3=9,打 lazy=3
步驟5: 右節點 [4,7] 部分重疊,往下分
步驟6: [4,4] 完全覆蓋 → sum += 3×1=3,打 lazy=3
步驟7: [5,7] 不重疊,跳過
之後如需訪問 [1,3] 的子節點,才執行 pushDown:
lazy[1,1] += 3,sum[1,1] += 3×1
lazy[2,3] += 3,sum[2,3] += 3×2
清除 [1,3] 的 lazy
pushDown 規則
pushDown(node, start, end):
if lazy[node] != 0:
mid = (start + end) / 2
leftLen = mid - start + 1
rightLen = end - mid
// 更新左子節點
tree[2*node] += lazy[node] * leftLen
lazy[2*node] += lazy[node]
// 更新右子節點
tree[2*node+1] += lazy[node] * rightLen
lazy[2*node+1] += lazy[node]
// 清除自身懶標記
lazy[node] = 0
TypeScript 完整實作
區間求和線段樹(含 Lazy Propagation)
class SegmentTree {
private n: number;
private tree: number[]; // 儲存區間聚合值
private lazy: number[]; // 儲存懶標記
constructor(nums: number[]) {
this.n = nums.length;
this.tree = new Array(4 * this.n).fill(0);
this.lazy = new Array(4 * this.n).fill(0);
this.build(nums, 1, 0, this.n - 1);
}
// 建樹:自底向上合併
private build(nums: number[], node: number, start: number, end: number): void {
if (start === end) {
this.tree[node] = nums[start]; // 葉節點儲存原始值
return;
}
const mid = (start + end) >> 1;
this.build(nums, 2 * node, start, mid);
this.build(nums, 2 * node + 1, mid + 1, end);
this.tree[node] = this.tree[2 * node] + this.tree[2 * node + 1]; // 合併
}
// 懶標記下推:訪問子節點前必須先呼叫
private pushDown(node: number, start: number, end: number): void {
if (this.lazy[node] !== 0) {
const mid = (start + end) >> 1;
const leftLen = mid - start + 1;
const rightLen = end - mid;
const left = 2 * node;
const right = 2 * node + 1;
// 將懶標記傳給左右子節點
this.tree[left] += this.lazy[node] * leftLen;
this.tree[right] += this.lazy[node] * rightLen;
this.lazy[left] += this.lazy[node];
this.lazy[right] += this.lazy[node];
this.lazy[node] = 0; // 清除自身懶標記
}
}
// 區間更新:對 [l, r] 所有元素加 val
update(l: number, r: number, val: number): void {
this.updateHelper(1, 0, this.n - 1, l, r, val);
}
private updateHelper(
node: number,
start: number,
end: number,
l: number,
r: number,
val: number
): void {
if (r < start || end < l) return; // 完全不重疊,跳過
if (l <= start && end <= r) {
// 完全覆蓋:直接更新並打懶標記
this.tree[node] += val * (end - start + 1);
this.lazy[node] += val;
return;
}
// 部分重疊:先下推懶標記,再遞迴左右子樹
this.pushDown(node, start, end);
const mid = (start + end) >> 1;
this.updateHelper(2 * node, start, mid, l, r, val);
this.updateHelper(2 * node + 1, mid + 1, end, l, r, val);
this.tree[node] = this.tree[2 * node] + this.tree[2 * node + 1];
}
// 區間查詢:返回 [l, r] 的總和
query(l: number, r: number): number {
return this.queryHelper(1, 0, this.n - 1, l, r);
}
private queryHelper(
node: number,
start: number,
end: number,
l: number,
r: number
): number {
if (r < start || end < l) return 0; // 完全不重疊
if (l <= start && end <= r) return this.tree[node]; // 完全覆蓋
this.pushDown(node, start, end); // 下推懶標記後再遞迴
const mid = (start + end) >> 1;
return (
this.queryHelper(2 * node, start, mid, l, r) +
this.queryHelper(2 * node + 1, mid + 1, end, l, r)
);
}
// 單點賦值:將 index 位置設為 val
set(index: number, val: number): void {
this.setHelper(1, 0, this.n - 1, index, val);
}
private setHelper(
node: number,
start: number,
end: number,
index: number,
val: number
): void {
if (start === end) {
this.tree[node] = val;
return;
}
this.pushDown(node, start, end);
const mid = (start + end) >> 1;
if (index <= mid) this.setHelper(2 * node, start, mid, index, val);
else this.setHelper(2 * node + 1, mid + 1, end, index, val);
this.tree[node] = this.tree[2 * node] + this.tree[2 * node + 1];
}
}
// 使用範例
const st = new SegmentTree([1, 3, 5, 7, 9, 11]);
console.log(st.query(1, 3)); // 輸出:15(3+5+7)
st.update(1, 3, 2); // 對索引 1~3 各加 2
console.log(st.query(1, 3)); // 輸出:21(5+7+9)
st.set(2, 100); // 將索引 2 設為 100
console.log(st.query(0, 5)); // 輸出:126
區間最大值線段樹
某些場景需要查詢區間最大值(如股票最高價、網路延遲峰值),只需把合併方式從加法換成 Math.max:
class SegmentTreeMax {
private n: number;
private tree: number[];
constructor(nums: number[]) {
this.n = nums.length;
this.tree = new Array(4 * this.n).fill(-Infinity);
this.build(nums, 1, 0, this.n - 1);
}
private build(nums: number[], node: number, start: number, end: number): void {
if (start === end) {
this.tree[node] = nums[start];
return;
}
const mid = (start + end) >> 1;
this.build(nums, 2 * node, start, mid);
this.build(nums, 2 * node + 1, mid + 1, end);
// 合併改為取最大值
this.tree[node] = Math.max(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, start: number, end: number, index: number, val: number): void {
if (start === end) {
this.tree[node] = val;
return;
}
const mid = (start + end) >> 1;
if (index <= mid) this.updateHelper(2 * node, start, mid, index, val);
else this.updateHelper(2 * node + 1, mid + 1, end, index, val);
this.tree[node] = Math.max(this.tree[2 * node], this.tree[2 * node + 1]);
}
// 區間最大值查詢
queryMax(l: number, r: number): number {
return this.queryHelper(1, 0, this.n - 1, l, r);
}
private queryHelper(node: number, start: number, end: number, l: number, r: number): number {
if (r < start || end < l) return -Infinity; // 不重疊返回負無窮
if (l <= start && end <= r) return this.tree[node];
const mid = (start + end) >> 1;
return Math.max(
this.queryHelper(2 * node, start, mid, l, r),
this.queryHelper(2 * node + 1, mid + 1, end, l, r)
);
}
}
// 使用範例
const maxTree = new SegmentTreeMax([3, 1, 4, 1, 5, 9, 2, 6]);
console.log(maxTree.queryMax(2, 5)); // 輸出:9(max(4,1,5,9))
maxTree.update(5, 0); // 將索引 5 改為 0
console.log(maxTree.queryMax(2, 5)); // 輸出:5(max(4,1,5,0))
C++ 對照實作
struct 封裝的通用線段樹
#include <bits/stdc++.h>
using namespace std;
struct SegmentTree {
int n;
vector<long long> tree, lazy;
SegmentTree(const vector<int>& nums)
: n(nums.size()),
tree(4 * nums.size(), 0),
lazy(4 * nums.size(), 0) {
build(nums, 1, 0, n - 1);
}
void build(const vector<int>& nums, int node, int start, int end) {
if (start == end) {
tree[node] = nums[start];
return;
}
int mid = (start + end) / 2;
build(nums, 2 * node, start, mid);
build(nums, 2 * node + 1, mid + 1, end);
tree[node] = tree[2 * node] + tree[2 * node + 1];
}
void pushDown(int node, int start, int end) {
if (lazy[node] != 0) {
int mid = (start + end) / 2;
int left = 2 * node, right = 2 * node + 1;
tree[left] += lazy[node] * (mid - start + 1);
tree[right] += lazy[node] * (end - mid);
lazy[left] += lazy[node];
lazy[right] += lazy[node];
lazy[node] = 0;
}
}
// 區間加法更新
void update(int l, int r, long long val, int node = 1, int start = -1, int end = -1) {
if (start == -1) start = 0, end = n - 1;
if (r < start || end < l) return;
if (l <= start && end <= r) {
tree[node] += val * (end - start + 1);
lazy[node] += val;
return;
}
pushDown(node, start, end);
int mid = (start + end) / 2;
update(l, r, val, 2 * node, start, mid);
update(l, r, val, 2 * node + 1, mid + 1, end);
tree[node] = tree[2 * node] + tree[2 * node + 1];
}
// 單點賦值
void set(int index, long long val, int node = 1, int start = -1, int end = -1) {
if (start == -1) start = 0, end = n - 1;
if (start == end) {
tree[node] = val;
lazy[node] = 0;
return;
}
pushDown(node, start, end);
int mid = (start + end) / 2;
if (index <= mid) set(index, val, 2 * node, start, mid);
else set(index, val, 2 * node + 1, mid + 1, end);
tree[node] = tree[2 * node] + tree[2 * node + 1];
}
// 區間求和查詢
long long query(int l, int r, int node = 1, int start = -1, int end = -1) {
if (start == -1) start = 0, end = n - 1;
if (r < start || end < l) return 0;
if (l <= start && end <= r) return tree[node];
pushDown(node, start, end);
int mid = (start + end) / 2;
return query(l, r, 2 * node, start, mid) +
query(l, r, 2 * node + 1, mid + 1, end);
}
};
int main() {
vector<int> nums = {1, 3, 5, 7, 9, 11};
SegmentTree st(nums);
cout << st.query(1, 3) << "\n"; // 輸出:15(3+5+7)
st.update(1, 3, 2); // 對索引 1~3 各加 2
cout << st.query(1, 3) << "\n"; // 輸出:21
return 0;
}
複雜度分析
| 操作 | 時間複雜度 | 空間複雜度 | 說明 |
|---|---|---|---|
| Build(建樹) | O(n) | O(n) | 每個節點只訪問一次 |
| 單點查詢 | O(log n) | — | 沿路徑到葉節點 |
| 單點更新 | O(log n) | — | 更新路徑上所有節點 |
| 區間查詢 | O(log n) | — | 最多訪問 4 log n 個節點 |
| 區間更新(無懶標記) | O(n log n) | — | 等同 n 次單點更新 |
| 區間更新(懶標記) | O(log n) | — | 攤平後每操作 O(log n) |
| 動態開點線段樹 | O(log n) | O(m log n) | m 次操作後的空間 |
空間說明:線段樹使用 4n 大小的陣列。雖然完全二元樹理論上只需 2n,但線段樹不一定是完全二元樹(n 不為 2 的冪次時),4n 是安全的通用上界。
變體與延伸
動態開點線段樹
當值域極大(如 10^9)但操作數少(如 10^5)時,靜態陣列會浪費大量空間。動態開點線段樹只在真正被訪問時才建立節點,空間從 O(N) 降到 O(m log N)(m 為操作次數)。
// 適用場景:值域 [1, 1e9],操作數 1e5 次
// 只建立被訪問到的節點,節省 99.99% 空間
struct DynamicSegTree {
struct Node {
long long sum = 0, lazy = 0;
int left = 0, right = 0; // 子節點 ID(0 表示未建立)
};
int N; // 值域上界
vector<Node> nodes;
int root = 1;
DynamicSegTree(int N) : N(N) {
nodes.emplace_back(); // nodes[0] 作為 null 節點
nodes.emplace_back(); // nodes[1] 作為根節點
}
int newNode() {
nodes.emplace_back();
return nodes.size() - 1;
}
void pushDown(int node, int len) {
if (nodes[node].lazy == 0) return;
auto pushChild = [&](int& child, int childLen) {
if (child == 0) child = newNode();
nodes[child].sum += nodes[node].lazy * childLen;
nodes[child].lazy += nodes[node].lazy;
};
int mid = len / 2;
pushChild(nodes[node].left, mid);
pushChild(nodes[node].right, len - mid);
nodes[node].lazy = 0;
}
void update(int l, int r, long long val) { update(l, r, val, root, 1, N); }
long long query(int l, int r) { return query(l, r, root, 1, N); }
void update(int l, int r, long long val, int node, int start, int end) {
if (l <= start && end <= r) {
nodes[node].sum += val * (end - start + 1);
nodes[node].lazy += val;
return;
}
pushDown(node, end - start + 1);
int mid = (start + end) / 2;
if (l <= mid) {
if (!nodes[node].left) nodes[node].left = newNode();
update(l, r, val, nodes[node].left, start, mid);
}
if (r > mid) {
if (!nodes[node].right) nodes[node].right = newNode();
update(l, r, val, nodes[node].right, mid + 1, end);
}
nodes[node].sum = nodes[nodes[node].left].sum + nodes[nodes[node].right].sum;
}
long long query(int l, int r, int node, int start, int end) {
if (!node) return 0;
if (l <= start && end <= r) return nodes[node].sum;
pushDown(node, end - start + 1);
int mid = (start + end) / 2;
long long res = 0;
if (l <= mid) res += query(l, r, nodes[node].left, start, mid);
if (r > mid) res += query(l, r, nodes[node].right, mid + 1, end);
return res;
}
};
持久化線段樹(主席樹)
持久化線段樹(Persistent Segment Tree),又稱主席樹(Chairman Tree),在每次更新時不修改原有節點,而是透過**路徑複製(Path Copying)**建立新版本,共享未修改的節點。每次操作只新增 O(log n) 個節點。
版本 0: 版本 1(更新後):
root0 root1
/ \ / \
A B A' B ← B 共享,A' 是新建的
/ \ / \ / \
C D E F C' D ← D 共享,C' 是新建的
典型應用:靜態區間第 K 小值——對陣列從左到右建立 n+1 個版本的線段樹,利用版本差分(root[r] - root[l-1])在 O(log n) 內查詢任意區間的第 K 小值。
線段樹合併
兩棵線段樹可以合併為一棵,透過遞迴合併對應節點的值。常見於「把子樹的答案合併到父節點」的樹型 DP 場景,時間複雜度 O(m log n)(m 為兩棵樹的節點總數)。
二維線段樹
將一維線段樹的每個節點再掛一棵線段樹,支援二維矩形區間的查詢與更新。時間複雜度 O(log n × log m),空間複雜度 O(nm)。實際競程中更常用二維 BIT(樹狀陣列),程式碼較簡單。
常見陷阱
在實作線段樹時,以下幾個坑特別容易踩到:
1. 陣列空間不足
線段樹陣列至少需要 4n 空間,若使用 2n 會在 n 不是 2 的冪時發生越界。安全做法是始終開 4n。
2. 忘記在訪問子節點前 pushDown
在 query 和 update 的「部分重疊」分支,遞迴進入子節點之前必須先呼叫 pushDown。漏掉這一步會導致子節點讀到過期的數據。
3. 區間加法 vs 區間賦值的懶標記語義不同
- 區間加法:
lazy[child] += lazy[parent](可以疊加) - 區間賦值:
lazy[child] = lazy[parent](直接覆蓋,舊值作廢)
兩者混用時需要分開維護兩種懶標記,處理優先級(賦值優先於加法)。
4. 1-indexed 與 0-indexed 混用
線段樹內部節點習慣 1-indexed(根節點為 1),但原始陣列通常是 0-indexed。確保邊界 [0, n-1] 的轉換正確,不要搞混。
5. 動態開點的節點池預分配
每次操作最多新建 2 log N 個節點,m 次操作需預分配 m × 2 × log(N) 個節點。若未預留足夠空間,vector 重新分配記憶體時會使舊指標失效,造成難以追蹤的 bug。
面試考點
題型一:區間最小值查詢
在面試中,考官可能會直接問:「如何設計一個支援區間最小值查詢與單點更新的資料結構?」
關鍵點:只需將合併函數從加法換成 Math.min,返回空區間時用 +Infinity 而非 0。
// 合併方式
this.tree[node] = Math.min(this.tree[2 * node], this.tree[2 * node + 1]);
// 不重疊時返回
if (r < start || end < l) return Infinity;
題型二:區間賦值更新
若更新操作是「將 [l, r] 內所有元素設為某個值」(而非加法),懶標記語義需要調整:
// 懶標記表示「設為此值」而非「加上此值」
// pushDown 時直接覆蓋子節點,不累加
private pushDown(node: number, start: number, end: number): void {
if (this.lazy[node] !== -1) { // -1 表示無標記
const mid = (start + end) >> 1;
const left = 2 * node, right = 2 * node + 1;
// 賦值:直接覆蓋
this.tree[left] = this.lazy[node] * (mid - start + 1);
this.tree[right] = this.lazy[node] * (end - mid);
this.lazy[left] = this.lazy[node];
this.lazy[right] = this.lazy[node];
this.lazy[node] = -1; // 清除
}
}
LeetCode 練習題
LeetCode 307 — Range Sum Query - Mutable(Medium)
題意:設計支援單點更新與區間求和查詢的資料結構。這是線段樹最直接的應用題。
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, start: number, end: number): void {
if (start === end) {
this.tree[node] = nums[start];
return;
}
const mid = (start + end) >> 1;
this.build(nums, 2 * node, start, mid);
this.build(nums, 2 * node + 1, mid + 1, end);
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, start: number, end: number, index: number, val: number): void {
if (start === end) {
this.tree[node] = val;
return;
}
const mid = (start + end) >> 1;
if (index <= mid) this.updateHelper(2 * node, start, mid, index, val);
else this.updateHelper(2 * node + 1, mid + 1, end, index, 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, start: number, end: number, l: number, r: number): number {
if (r < start || end < l) return 0;
if (l <= start && end <= r) return this.tree[node];
const mid = (start + end) >> 1;
return this.queryHelper(2 * node, start, mid, l, r) +
this.queryHelper(2 * node + 1, mid + 1, end, l, r);
}
}
// 複雜度:建樹 O(n),更新 O(log n),查詢 O(log n),空間 O(n)
LeetCode 303 — Range Sum Query - Immutable(Easy)
題意:陣列不變,只需多次查詢區間和。此題用前綴和即可,不需要線段樹。但可以用本題練習理解「當陣列不可變時,前綴和是更好的選擇」,加深對資料結構選擇的判斷力。
class NumArrayImmutable {
private prefix: number[];
constructor(nums: number[]) {
this.prefix = new Array(nums.length + 1).fill(0);
for (let i = 0; i < nums.length; i++) {
this.prefix[i + 1] = this.prefix[i] + nums[i];
}
}
sumRange(left: number, right: number): number {
return this.prefix[right + 1] - this.prefix[left];
}
}
// 複雜度:建表 O(n),查詢 O(1),空間 O(n)
// 此題不需線段樹,說明:選對資料結構比盲目用複雜結構更重要
LeetCode 315 — Count of Smaller Numbers After Self(Hard)
題意:對每個 nums[i],計算右側有多少個元素嚴格小於它。
關鍵洞察:從右往左掃描,用線段樹維護已見元素的計數(值域線段樹)。先離散化壓縮值域,再對每個元素查詢「比自己小的個數」,然後將自己插入線段樹。
function countSmaller(nums: number[]): number[] {
// 離散化:將值域壓縮到 [1, m]
const sorted = [...new Set(nums)].sort((a, b) => a - b);
const rank = new Map<number, number>();
sorted.forEach((v, i) => rank.set(v, i + 1));
const m = sorted.length;
// 值域線段樹:儲存每個離散值的出現次數
const tree = new Array(4 * (m + 1)).fill(0);
const update = (node: number, start: number, end: number, idx: number): void => {
if (start === end) { tree[node]++; return; }
const mid = (start + end) >> 1;
if (idx <= mid) update(2 * node, start, mid, idx);
else update(2 * node + 1, mid + 1, end, idx);
tree[node] = tree[2 * node] + tree[2 * node + 1];
};
const query = (node: number, start: number, end: number, l: number, r: number): number => {
if (r < l || r < start || end < l) return 0;
if (l <= start && end <= r) return tree[node];
const mid = (start + end) >> 1;
return query(2 * node, start, mid, l, r) +
query(2 * node + 1, mid + 1, end, l, r);
};
const result: number[] = new Array(nums.length);
for (let i = nums.length - 1; i >= 0; i--) {
const r = rank.get(nums[i])!;
result[i] = query(1, 1, m, 1, r - 1); // 查詢比自己小的個數
update(1, 1, m, r); // 將自己插入線段樹
}
return result;
}
// 複雜度:O(n log n) 時間,O(n) 空間
LeetCode 218 — The Skyline Problem(Hard)
題意:給定一組建築物的 [left, right, height],求天際線輪廓的關鍵點。
線段樹策略:將建築物的所有 x 座標離散化,以座標為索引建立線段樹(維護區間最大高度)。掃描線(sweep line)從左到右,遇到建築物左端加入高度,遇到右端移除高度,每次變化時查詢當前全局最大高度,高度變化點即為天際線關鍵點。時間複雜度 O(n log n)。
LeetCode 732 — My Calendar III(Hard)
題意:動態插入日程區間 [start, end),每次插入後回報最多幾個日程重疊(即區間最大重疊數)。
線段樹策略:用動態開點線段樹,對 [start, end-1] 執行區間加 1,然後查詢全局最大值。因為日期範圍可達 10^9,必須使用動態開點。
class MyCalendarThree {
private tree: Map<number, number> = new Map();
private lazy: Map<number, number> = new Map();
private get(map: Map<number, number>, key: number): number {
return map.get(key) ?? 0;
}
private pushDown(node: number): void {
const lz = this.get(this.lazy, node);
if (lz !== 0) {
const left = 2 * node, right = 2 * node + 1;
this.tree.set(left, (this.get(this.tree, left) + lz));
this.tree.set(right, (this.get(this.tree, right) + lz));
this.lazy.set(left, (this.get(this.lazy, left) + lz));
this.lazy.set(right, (this.get(this.lazy, right) + lz));
this.lazy.set(node, 0);
}
}
private update(node: number, start: number, end: number, l: number, r: number): void {
if (r < start || end < l) return;
if (l <= start && end <= r) {
this.tree.set(node, (this.get(this.tree, node) + 1));
this.lazy.set(node, (this.get(this.lazy, node) + 1));
return;
}
this.pushDown(node);
const mid = (start + end) >> 1;
this.update(2 * node, start, mid, l, r);
this.update(2 * node + 1, mid + 1, end, l, r);
this.tree.set(node, Math.max(
this.get(this.tree, 2 * node),
this.get(this.tree, 2 * node + 1)
));
}
book(start: number, end: number): number {
this.update(1, 0, 1e9, start, end - 1);
return this.get(this.tree, 1);
}
}
// 複雜度:每次 book O(log N),N = 1e9
LeetCode 推薦練習清單
| 題號 | 題目 | 難度 | 線段樹應用 |
|---|---|---|---|
| 303 | Range Sum Query - Immutable | Easy | 前綴和(對比用,理解選擇時機) |
| 307 | Range Sum Query - Mutable | Medium | 標準單點更新 + 區間查詢 |
| 315 | Count of Smaller Numbers After Self | Hard | 離散化 + 值域線段樹計數 |
| 218 | The Skyline Problem | Hard | 掃描線 + 區間最大值 |
| 732 | My Calendar III | Hard | 動態開點 + 區間加法 + 全域最大值 |
總結
線段樹是資料結構與演算法中「投資報酬率」極高的一個工具:
- 核心思想是分治遞迴,將區間問題分解到可以直接計算的最小單元
- 三大操作(build / query / update)各有清晰的邏輯:build 自底向上,query 分三種重疊情況,update 沿路徑修改
- Lazy Propagation 是線段樹處理區間更新的精髓,以「打標記延遲更新」的策略,將區間更新從 O(n) 優化到 O(log n)
- 靈活的聚合函數讓線段樹不只能求和,也能求最大值、最小值、GCD、最大公因數等任何滿足結合律的運算
掌握線段樹之後,你會發現許多看似複雜的區間問題都能迎刃而解。
希望這篇文章能幫助你扎實理解線段樹的每個細節。如果在練習過程中遇到問題,歡迎到 聯絡頁面 留言討論!
下一篇:我們將介紹 樹狀陣列(Fenwick Tree / BIT)——線段樹的精簡版,以更短的程式碼在前綴和問題上達到相同的 O(log n) 效能。敬請期待!