線段樹 (Segment Tree)

預備知識:樹狀數組

與樹狀數組 (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}\), 且根據線段樹的形態,那麼我們有:

\[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;
}

模版例題:

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章