預備知識:樹狀數組 。
與樹狀數組 (Binary Index Tree, BIT, aka "二叉索引樹") 類似,線段樹適用於以下場景:
給定數組
a[n]
, 並且要求w
次修改數組,現有q
次區間查詢,每次區間查詢包括[l, r]
2 個參數,要求返回sum(a[l, r])
的值。
如果沒有「修改元素」的要求,顯然用前綴和是最好的。既然樹狀數組能解決上述場景,那么線段樹比樹狀數組好在哪里呢?
- 線段樹可以在 \(O(\log{n})\) 的時間復雜度內實現單點修改、區間修改、區間查詢(區間求和,求區間最大值,求區間最小值)等操作。
- 樹狀數組適用於單點修改和區間查詢。
顯然,線段樹比樹狀數組能力更強,樹狀數組能做到的,線段樹同樣能做到。
結構
線段樹的本質是一棵基於數組表示的二叉樹。
假設有一個大小為 5 的數組 \(a[5] = \{10, 11, 12, 13, 14\}\) ,記線段樹為 \(d[n]\) ,下標均從 1 開始計數。那么該線段樹的形態如下:
[1, 5]
/ \
[1,3] [4,5]
/ \ / \
[1,2] [3,3] [4,4] [5,5]
/ \
[1,1] [2,2]
// 線段樹的性質:葉子節點是數組元素
每個節點表示一個區間(亦即所謂的「線段」),線段樹 \(d_i\) 記錄的是這一區間的和:
d[1] = sum([1, 5]) = 60
d[2] = sum([1, 3]) = 33
d[3] = sum([4, 5]) = 27
d[4] = sum([1, 2]) = 21
d[5] = sum([3, 3]) = 12
d[6] = sum([4, 4]) = 13
d[7] = sum([5, 5]) = 14
d[8] = sum([1, 1]) = 10
d[9] = sum([2, 2]) = 11
前面提到,線段樹的本質是基於數組表示的二叉樹,那么節點 \(d_i\) 的左右孩子節點分別為 \(d_{2i}, d_{2i+1}\), 且根據線段樹的形態,那么我們有:
根據這一遞推公式,顯然可以得知,線段樹建立和修改是「自底向上」的。
建樹
建立線段樹的第一個問題:給定數組 \(a[n]\) ,那么線段樹需要多大的空間?
分析
- 線段樹是一棵完全二叉樹,其高度為 \(h = \lceil \log{n} \rceil\),高度從 0 開始計數,這意味着該二叉樹有 \(h+1\) 層。
- 考慮最壞情況,線段樹是一棵滿二叉樹,那么有 \(2^{h+1} - 1\) 個節點。
\[2^{h+1} - 1 \le 2^{h+1} \le 2^{\log{n} + 2} \le 2^{\log{4n}} = 4n \]
- 因此,線段樹的空間一般開 \(4n\) 大小。當然,調數學庫精確算一下也是可以的 (if you want) 。
假設我們當前節點 \(d_i\) 表示的是區間 \([s, t]\) 之和,那么其左節點 \(d_{2i}\) 表示的是區間 \([s,\frac{s+t}{2}]\) ,其右節點 \(d_{2i+1}\) 表示的是區間 \([\frac{s+t}{2}+1, t]\) .
建樹操作如下:
const int n = 5;
int nums[n + 1] = {0, 10, 11, 12, 13, 14};
vector<int> d;
void build(int s, int t, int idx)
{
if (s == t)
{
d[idx] = nums[s];
return;
}
int m = s + (t - s) / 2, l = 2 * idx, r = 2 * idx + 1;
build(s, m, l), build(m + 1, t, r);
d[idx] = d[l] + d[r];
}
int main()
{
d.resize(4 * n, 0);
build(1, n, 1);
}
查詢
區間查詢,即求出區間 \([l,r]\) 之和,或者求出區間的最大值、最小值等操作。
依舊以這個樹為例子:
[1, 5]
/ \
[1,3] [4,5]
/ \ / \
[1,2] [3,3] [4,4] [5,5]
/ \
[1,1] [2,2]
如果區間 \([l, r]\) 是線段樹上的一個節點,那么這種查詢是簡單的,直接獲取 \(d_1 = 60\) 即可。那如果要查詢的區間不是對應於某個節點(即橫跨若干節點),查詢是怎么做到的呢?以查詢區間 \([3, 5]\) 為例,可以拆分為 \([3,3]\) 和 \([4, 5]\) 這 2 個節點。
一般地,查詢區間為 \([l, r]\) ,根據線段樹的性質,最多可拆分為 \(\log{n}\) 個子區間(這些子區間要求是一個極大的子區間,即 \([4,5]\) 不用再進一步拆分為 \([4,4]\) 和 \([5,5]\) )。
// [l, r] 為查詢區間, [s, t] 為當前節點包含的區間
// idx 代表線段樹的節點
int getsum(int l, int r, int s, int t, int idx)
{
if (l <= s && t <= r)
return d[idx];
int sum = 0;
int m = s + (t - s) / 2;
if (l <= m) // 如果 [s, m] 與目標區間 [l, r] 有交集
sum += getsum(l, r, s, m, 2 * idx);
if (m < r) // 如果 [m+1, t] 與目標區間 [l, r] 有交集
sum += getsum(l, r, m + 1, t, 2 * idx + 1);
return sum;
}
修改
如果要對 nums[i]
的值修改,那么線段樹需要做出調整,任何包含 nums[i]
的區間都需要修改,復雜度是 \(O(\log{n})\) .
如果要對區間 \([a, b]\) 內數組元素做修改,顯然,在線段樹中,任何包括了 nums[a ... b]
中某一元素的區間都要修改一次,這時候復雜度為 \(O((b-a+1) \cdot \log{n})\) ,這個復雜度屬實有點蚌埠住 😅 。
因此需要引入「惰性標記」,簡單來說,就是空間換時間。
懶惰標記,簡單來說,就是通過延遲對節點信息的更改,從而減少可能不必要的操作次數。每次執行修改時,我們通過打標記的方法表明該節點對應的區間在某一次操作中被更改,但不更新該節點的子節點的信息。實質性的修改則在下一次訪問帶有標記的節點時才進行。
如下圖所示,給線段樹的每個節點都加入一個標記值 t[i]
,表示這一區間的值的變化。

如果想要給區間 \([3,5]\) 的每個數都加上一個值 val = 5
,那么會找到子區間 \([3,3]\) 和 \([4, 5]\) ,修改它們的標記值。注意,下圖的線段樹 d[i]
修改為節點的值(即區間之和)。

* 注:此處應為 d[5] = 12 + 5 = 17, d[3] = 27 + 2 * 5 = 37
雖然節點 d[3]
節點被修改,但它的孩子節點並沒有修改(所謂的「延遲更改」)。同時需要注意,d[3]
真正的變化值為 5 * 2 = 10
.
接下來,如果我們需要查找區間 \([4,4]\) 之和,那么會遍歷到 \(d_3 = [3,4]\) 這一區間。當發現這一節點存在不為 0 的標記值時,此時需要真正地更新其孩子,將標記值「下放」,並重置標記值為 0 。

* 注:與上同理,此處應為 d[5] = 17, d[3] = 37, d[6] = 18, d[7] = 19
帶惰性標記的區間修改
// [l, r] 為修改的目標區間, [s, t] 為當前節點包含的區間
// idx 代表線段樹的節點
// val 為變化值
void update(int l, int r, int s, int t, int idx, int val)
{
// [s, t] 為修改區間 [l, r] 的子集時
// 直接修改當前節點的值, 然后打標記
if (l <= s && t <= r)
{
d[idx] += (t - s + 1) * val;
vals[idx] += val;
return;
}
int m = s + (t - s) / 2;
int lchild = 2 * idx, rchild = 2 * idx + 1;
// 如果不是葉子節點, 並且存在標記
if (vals[idx] && s != t)
{
// 標記下放並重置
// 注意標記下放使用 +=, 因為左右孩子可能存在非零標記
d[lchild] += vals[idx] * (m - s + 1), vals[lchild] += vals[idx];
d[rchild] += vals[idx] * (t - m), vals[rchild] += vals[idx];
vals[idx] = 0;
}
if (l <= m) update(l, r, s, m, lchild, val);
if (r > m) update(l, r, m + 1, t, rchild, val);
}
帶惰性標記的區間查詢
// [l, r] 為查詢區間, [s, t] 為當前節點包含的區間
// idx 代表線段樹的節點
int getsum(int l, int r, int s, int t, int idx)
{
if (l <= s && t <= r)
return d[idx];
int sum = 0;
int m = s + (t - s) / 2;
int lchild = 2 * idx, rchild = 2 * idx + 1;
// 如果存在非零標記
if (vals[idx])
{
d[lchild] += (m - s + 1) * vals[idx], vals[lchild] += vals[idx];
d[rchild] += (t - m) * vals[idx], vals[rchild] += vals[idx];
vals[idx] = 0;
}
if (l <= m) // 如果 [s, m] 與目標區間 [l, r] 有交集
sum += getsum(l, r, s, m, 2 * idx);
if (m < r) // 如果 [m+1, t] 與目標區間 [l, r] 有交集
sum += getsum(l, r, m + 1, t, 2 * idx + 1);
return sum;
}
模版例題: