線段樹 - 多組圖帶你從頭到尾徹底理解線段樹

RioTian發表於2020-07-31

線段樹是演算法競賽中常用的用來維護 區間資訊 的資料結構。

相關閱讀:樹狀陣列 - 尚未釋出

線段樹可以在 \(O(\log N)\) 的時間複雜度內實現單點修改、區間修改、區間查詢(區間求和,求區間最大值,求區間最小值)等操作。

線段樹維護的資訊,需要滿足可加性,即能以可以接受的速度合併資訊和修改資訊,包括在使用懶惰標記時,標記也要滿足可加性(例如取模就不滿足可加性,對 \(4\) 取模然後對 \(3\) 取模,兩個操作就不能合併在一起做)。

線段樹

線段樹的基本結構與建樹

線段樹將每個長度不為 \(1\) 的區間劃分成左右兩個區間遞迴求解,把整個線段劃分為一個樹形結構,通過合併左右兩區間資訊來求得該區間的資訊。這種資料結構可以方便的進行大部分的區間操作。

有個大小為 \(5\) 的陣列 \(a=\{10,11,12,13,14\}\) ,要將其轉化為線段樹,有以下做法:設線段樹的根節點編號為 \(1\) ,用陣列 \(d\) 來儲存我們的線段樹, \(d_i\) 用來儲存線段樹上編號為 \(i\) 的節點的值(這裡每個節點所維護的值就是這個節點所表示的區間總和),如圖所示:

圖中 \(d_1\) 表示根節點,紫色方框是陣列 \(a\) ,紅色方框是陣列 \(d\) ,紅色方框中的括號中的黃色數字表示它所在的那個紅色方框表示的線段樹節點所表示的區間,如 \(d_1\) 所表示的區間就是 \([1,5]\)\(a_1,a_2, \cdots ,a_5\) ),即 \(d_1\) 所儲存的值是 \(a_1+a_2+ \cdots +a_5\)\(d_1=60\) 表示的是 \(a_1+a_2+ \cdots +a_5=60\)

通過觀察不難發現, \(d_i\) 的左兒子節點就是 \(d_{2\times i}\)\(d_i\) 的右兒子節點就是 \(d_{2\times i+1}\) 。如果 \(d_i\) 表示的是區間 \([s,t]\) (即 \(d_i=a_s+a_{s+1}+ \cdots +a_t\) ) 的話,那麼 \(d_i\) 的左兒子節點表示的是區間 \([ s, \frac{s+t}{2} ]\)\(d_i\) 的右兒子表示的是區間 \([ \frac{s+t}{2} +1,t ]\)

具體要怎麼用程式碼實現呢?

我們繼續觀察,有沒有發現如果 \(d_i\) 表示的區間大小等於 \(1\) 的話(區間大小指的是區間包含的元素的個數,即 \(a\) 的個數。設 \(d_j\) 表示區間 \([s,t]\) ,它的區間大小就是 \(t-s+1\) ),那麼 \(d_i\) 所表示的區間 \([s,t]\) 中肯定有 \(s=t\) ,且 \(d_i=a_s=a_t\) 。這就是線段樹的遞迴邊界。

思路如下:

此處給出 C++ 的程式碼實現,可參考註釋理解:

void build(int s, int t, int p) {
  // 對 [s,t] 區間建立線段樹,當前根的編號為 p
  if (s == t) {
    d[p] = a[s];
    return;
  }
  int m = (s + t) / 2;
  build(s, m, p * 2), build(m + 1, t, p * 2 + 1);//遞迴到左右子樹
  // 遞迴對左右區間建樹
  d[p] = d[p * 2] + d[(p * 2) + 1];
}

關於線段樹的空間:如果採用堆式儲存( \(2p\)\(p\) 的左兒子, \(2p+1\)\(p\) 的右兒子),若有 \(n\) 個葉子結點,則 d 陣列的範圍最大為 \(2^{\left\lceil\log{n}\right\rceil+1}\)

分析:容易知道線段樹的深度是 \(\left\lceil\log{n}\right\rceil\) 的,則在堆式儲存情況下葉子節點(包括無用的葉子節點)數量為 \(2^{\left\lceil\log{n}\right\rceil}\) 個,又由於其為一棵完全二叉樹,則其總節點個數 \(2^{\left\lceil\log{n}\right\rceil+1}-1\) 。當然如果你懶得計算的話可以直接把陣列長度設為 \(4n\) ,因為 \(\frac{2^{\left\lceil\log{n}\right\rceil+1}-1}{n}\) 的最大值在 \(n=2^{x}+1(x\in N_{+})\) 時取到,此時節點數為 \(2^{\left\lceil\log{n}\right\rceil+1}-1=2^{x+2}-1=4n-5\)

線段樹的區間查詢

區間查詢,比如求區間 \([l,r]\) 的總和(即 \(a_l+a_{l+1}+ \cdots +a_r\) )、求區間最大值/最小值等操作。

以上面這張圖為例,如果要查詢區間 \([1,5]\) 的和,那直接獲取 \(d_1\) 的值( \(60\) )即可。

如果要查詢的區間為 \([3,5]\) ,此時就不能直接獲取區間的值,但是 \([3,5]\) 可以拆成 \([3,3]\)\([4,5]\) ,可以通過合併這兩個區間的答案來求得這個區間的答案。

一般地,如果要查詢的區間是 \([l,r]\) ,則可以將其拆成最多為 \(O(\log n)\)極大 的區間,合併這些區間即可求出 \([l,r]\) 的答案。

此處給出 C++ 的程式碼實現,可參考註釋理解:

int getsum(int l, int r, int s, int t, int p) {
  // [l,r] 為查詢區間,[s,t] 為當前節點包含的區間,p 為當前節點的編號
  if (l <= s && t <= r)
    return d[p];  // 當前區間為詢問區間的子集時直接返回當前區間的和
  int m = (s + t) / 2, sum = 0;
  if (l <= m) sum += getsum(l, r, s, m, p * 2);
  // 如果左兒子代表的區間 [l,m] 與詢問區間有交集,則遞迴查詢左兒子
  if (r > m) sum += getsum(l, r, m + 1, t, p * 2 + 1);
  // 如果右兒子代表的區間 [m+1,r] 與詢問區間有交集,則遞迴查詢右兒子
  return sum;
}

線段樹的區間修改與懶惰標記

如果要求修改區間 \([l,r]\) ,把所有包含在區間 \([l,r]\) 中的節點都遍歷一次、修改一次,時間複雜度無法承受。我們這裡要引入一個叫做 「懶惰標記」 的東西。

我們設一個陣列 \(b\)\(b_i\) 表示編號為 \(i\) 的節點的懶惰標記值。為了加強對懶惰標記的理解,此處舉個例子:

A 有兩個兒子,一個是 B,一個是 C。

有一天 A 要建一個新房子,沒錢。剛好過年嘛,有人要給 B 和 C 紅包,兩個紅包的錢數相同都是 \(1\) 元,然而因為 A 是父親所以紅包肯定是先塞給 A 咯~

理論上來講 A 應該把兩個紅包分別給 B 和 C,但是……缺錢嘛,A 就把紅包偷偷收到自己口袋裡了。

A 高興地說:「我現在有 \(2\) 份紅包了!我又多了 \(2\times 1=2\) 元了!哈哈哈~」

但是 A 知道,如果他不把紅包給 B 和 C,那 B 和 C 肯定會不爽然後導致家庭矛盾最後崩潰,所以 A 對兒子 B 和 C 說:「我欠你們每人 \(1\)\(1\) 元的紅包,下次有新紅包給過來的時候再給你們!這裡我先做下記錄……嗯……我欠你們各 \(1\) 元……」

兒子 B、C 有點惱怒:「可是如果有同學問起我們我們收到了多少紅包咋辦?你把我們的紅包都收了,我們還怎麼裝?」

父親 A 趕忙說:「有同學問起來我就會給你們的!我欠條都寫好了不會不算話的!」

這樣 B、C 才放了心。

在這個故事中我們不難看出,A 就是父親節點,B 和 C 是 A 的兒子節點,而且 B 和 C 是葉子節點,分別對應一個陣列中的值(就是之前講的陣列 \(a\) ),我們假設節點 A 表示區間 \([1,2]\) (即 \(a_1+a_2\) ),節點 B 表示區間 \([1,1]\) (即 \(a_1\) ),節點 C 表示區間 \([2,2]\) (即 \(a_2\) ),它們的初始值都為 \(0\) (現在才剛開始呢,還沒拿到紅包,所以都沒錢)。

如圖:

注:這裡 D 表示當前節點的值(即所表示區間的區間和)。
為什麼節點 A 的 D 是 \(2\times 1=2\) 呢?原因很簡單:節點 A 表示的區間是 \([1,2]\) ,一共包含 \(2\) 個元素。我們是讓 \([1,2]\) 這個區間的每個元素都加上 \(1\) ,所以節點 A 的值就加上了 \(2\times 1=2\) 咯。

如果這時候我們要查詢區間 \([1,1]\) (即節點 B 的值),A 就把它欠的還給 B,此時的操作稱為 下傳懶惰標記

具體是這樣操作(如圖):

注:為什麼是加上 \(1\times 1=1\) 呢?因為 B 和 C 表示的區間中只有 \(1\) 個元素。

由此我們可以得到,區間 \([1,1]\) 的區間和就是 \(1\)

區間修改(區間加上某個值):

void update(int l, int r, int c, int s, int t, int p) {
  // [l,r] 為修改區間,c 為被修改的元素的變化量,[s,t] 為當前節點包含的區間,p
  // 為當前節點的編號
  if (l <= s && t <= r) {
    d[p] += (t - s + 1) * c, b[p] += c;
    return;
  }  // 當前區間為修改區間的子集時直接修改當前節點的值,然後打標記,結束脩改
  int m = (s + t) / 2;
  if (b[p] && s != t) {
    // 如果當前節點的懶標記非空,則更新當前節點兩個子節點的值和懶標記值
    d[p * 2] += b[p] * (m - s + 1), d[p * 2 + 1] += b[p] * (t - m);
    b[p * 2] += b[p], b[p * 2 + 1] += b[p];  // 將標記下傳給子節點
    b[p] = 0;                                // 清空當前節點的標記
  }
  if (l <= m) update(l, r, c, s, m, p * 2);
  if (r > m) update(l, r, c, m + 1, t, p * 2 + 1);
  d[p] = d[p * 2] + d[p * 2 + 1];
}

區間查詢(區間求和):

int getsum(int l, int r, int s, int t, int p) {
  // [l,r] 為查詢區間,[s,t] 為當前節點包含的區間,p為當前節點的編號
  if (l <= s && t <= r) return d[p];
  // 當前區間為詢問區間的子集時直接返回當前區間的和
  int m = (s + t) / 2;
  if (b[p]) {
    // 如果當前節點的懶標記非空,則更新當前節點兩個子節點的值和懶標記值
    d[p * 2] += b[p] * (m - s + 1), d[p * 2 + 1] += b[p] * (t - m),
        b[p * 2] += b[p], b[p * 2 + 1] += b[p];  // 將標記下傳給子節點
    b[p] = 0;                                    // 清空當前節點的標記
  }
  int sum = 0;
  if (l <= m) sum = getsum(l, r, s, m, p * 2);
  if (r > m) sum += getsum(l, r, m + 1, t, p * 2 + 1);
  return sum;
}

如果你是要實現區間修改為某一個值而不是加上某一個值的話,程式碼如下:

void update(int l, int r, int c, int s, int t, int p) {
  if (l <= s && t <= r) {
    d[p] = (t - s + 1) * c, b[p] = c;
    return;
  }
  int m = (s + t) / 2;
  if (b[p]) {
    d[p * 2] = b[p] * (m - s + 1), d[p * 2 + 1] = b[p] * (t - m),
          b[p * 2] = b[p * 2 + 1] = b[p];
    b[p] = 0;
  }
  if (l <= m) update(l, r, c, s, m, p * 2);
  if (r > m) update(l, r, c, m + 1, t, p * 2 + 1);
  d[p] = d[p * 2] + d[p * 2 + 1];
}
int getsum(int l, int r, int s, int t, int p) {
  if (l <= s && t <= r) return d[p];
  int m = (s + t) / 2;
  if (b[p]) {
    d[p * 2] = b[p] * (m - s + 1), d[p * 2 + 1] = b[p] * (t - m),
          b[p * 2] = b[p * 2 + 1] = b[p];
    b[p] = 0;
  }
  int sum = 0;
  if (l <= m) sum = getsum(l, r, s, m, p * 2);
  if (r > m) sum += getsum(l, r, m + 1, t, p * 2 + 1);
  return sum;
}

一些優化

這裡總結幾個線段樹的優化:

  • 在葉子節點處無需下放懶惰標記,所以懶惰標記可以不下傳到葉子節點。

  • 下放懶惰標記可以寫一個專門的函式 pushdown ,從兒子節點更新當前節點也可以寫一個專門的函式 maintain (或者對稱地用 pushup ),降低程式碼編寫難度。

  • 標記永久化,如果確定懶惰標記不會在中途被加到溢位(即超過了該型別資料所能表示的最大範圍),那麼就可以將標記永久化。標記永久化可以避免下傳懶惰標記,只需在進行詢問時把標記的影響加到答案當中,從而降低程式常數。具體如何處理與題目特性相關,需結合題目來寫。這也是樹套樹和可持久化資料結構中會用到的一種技巧。

線段樹基礎題推薦

<<| 是位運算,n << 1 == n * 2n << 1 | 1 == n * 2 + 1(再具體可以檢視 blog )。

luogu P3372【模板】線段樹 1

https://www.luogu.com.cn/problem/P3372

#include <iostream>
typedef long long LL;
LL n, a[100005], d[270000], b[270000];
void build(LL l, LL r, LL p) {
    if (l == r) {
        d[p] = a[l];
        return;
    }
    LL m = (l + r) >> 1;
    build(l, m, p << 1), build(m + 1, r, (p << 1) | 1);
    d[p] = d[p << 1] + d[(p << 1) | 1];
}
void update(LL l, LL r, LL c, LL s, LL t, LL p) {
    if (l <= s && t <= r) {
        d[p] += (t - s + 1) * c, b[p] += c;
        return;
    }
    LL m = (s + t) >> 1;
    if (b[p])
        d[p << 1] += b[p] * (m - s + 1), d[(p << 1) | 1] += b[p] * (t - m),
        b[p << 1] += b[p], b[(p << 1) | 1] += b[p];
    b[p] = 0;
    if (l <= m) update(l, r, c, s, m, p << 1);
    if (r > m) update(l, r, c, m + 1, t, (p << 1) | 1);
    d[p] = d[p << 1] + d[(p << 1) | 1];
}
LL getsum(LL l, LL r, LL s, LL t, LL p) {
    if (l <= s && t <= r) return d[p];
    LL m = (s + t) >> 1;
    if (b[p])
        d[p << 1] += b[p] * (m - s + 1), d[(p << 1) | 1] += b[p] * (t - m),
        b[p << 1] += b[p], b[(p << 1) | 1] += b[p];
    b[p] = 0;
    LL sum = 0;
    if (l <= m) sum = getsum(l, r, s, m, p << 1);
    if (r > m) sum += getsum(l, r, m + 1, t, (p << 1) | 1);
    return sum;
}
int main() {
    std::ios::sync_with_stdio(0);
    LL q, i1, i2, i3, i4;
    std::cin >> n >> q;
    for (LL i = 1; i <= n; i++) std::cin >> a[i];
    build(1, n, 1);
    while (q--) {
        std::cin >> i1 >> i2 >> i3;
        if (i1 == 2)
            std::cout << getsum(i2, i3, 1, n, 1) << std::endl;
        else
            std::cin >> i4, update(i2, i3, i4, 1, n, 1);
    }
    return 0;
}

luogu P3373【模板】線段樹 2

https://www.luogu.com.cn/problem/P3373

相比較於 P3372 ,此題多了個區間乘法。

一個 tag 似乎應付不了了,那麼來兩個 tag 啊: addmul

1. 區間加法

還是一樣。

s[pos].add = (s[pos].add + k) % mod;
s[pos].sum = (s[pos].sum + k * (s[pos].r - s[pos].l + 1)) % mod;

2. 區間乘法

這裡就有點不一樣了。

先把 mulsum 乘上 k

對於之前已經有的 add ,把它乘上 k 即可。在這裡,我們把乘之後的值直接更新add的值。

你想, add 其實應該加到 sum 裡面,所有乘上 k 後,運用乘法分配律, (sum + add) * k == sum * k + add * k

這樣來實現 addsum 有序進行。

s[pos].add = (s[pos].add * k) % mod;
s[pos].mul = (s[pos].mul * k) % mod;
s[pos].sum = (s[pos].sum * k) % mod;

3. pushdown的維護

現在要下傳兩個標記: addmul

sum :因為 add 之前已經乘過,所以在子孩子乘過 mul 後直接加就行。

mul :直接乘。

add :因為 add 的值是要包括乘之後的值,所以子孩子要先乘上 mul

s[pos << 1].sum = (s[pos << 1].sum * s[pos].mul + s[pos].add * (s[pos << 1].r - s[pos << 1].l + 1)) % mod;

s[pos << 1].mul = (s[pos << 1].mul * s[pos].mul) % mod;

s[pos << 1].add = (s[pos << 1].add * s[pos].mul + s[pos].add) % mod;

程式碼

#include<bits/stdc++.h>
using namespace std;
#define maxn 100010
typedef long long ll;
ll n, m, mod;
int a[maxn];
struct Segment_Tree {
	ll sum, add, mul;
	int l, r;
}s[maxn << 2];

void update(int pos) {
	s[pos].sum = (s[pos << 1].sum + s[(pos << 1) | 1].sum) % mod;
	return;
}

void pushdown(int pos) {//pushdown的維護
	s[pos << 1].sum = (s[pos << 1].sum * s[pos].mul + s[pos].add * (s[pos << 1].r - s[pos << 1].l + 1)) % mod;
	s[pos << 1 | 1].sum = (s[pos << 1 | 1].sum * s[pos].mul + s[pos].add * (s[pos << 1 | 1].r - s[pos << 1 | 1].l + 1)) % mod;

	s[pos << 1].mul = (s[pos << 1].mul * s[pos].mul) % mod;
	s[pos << 1 | 1].mul = (s[pos << 1 | 1].mul * s[pos].mul) % mod;

	s[pos << 1].add = (s[pos << 1].add * s[pos].mul + s[pos].add) % mod;
	s[pos << 1 | 1].add = (s[pos << 1 | 1].add * s[pos].mul + s[pos].add) % mod;

	s[pos].add = 0;
	s[pos].mul = 1;
	return;
}

void build_tree(int pos, int l, int r) { //建樹
	s[pos].l = l;
	s[pos].r = r;
	s[pos].mul = 1;
	if (l == r) {
		s[pos].sum = a[l] % mod;
		return;
	}
	int mid = (l + r) >> 1;
	build_tree(pos << 1, l, mid);
	build_tree(pos << 1 | 1, mid + 1, r);
	update(pos);
	return;
}

void ChangeMul(int pos, int x, int y, int k) { //區間乘法
	if (x <= s[pos].l && s[pos].r <= y) {
		s[pos].add = (s[pos].add * k) % mod;
		s[pos].mul = (s[pos].mul * k) % mod;
		s[pos].sum = (s[pos].sum * k) % mod;
		return;
	}
	pushdown(pos);
	int mid = (s[pos].l + s[pos].r) >> 1;
	if (x <= mid) ChangeMul(pos << 1, x, y, k);
	if (y > mid) ChangeMul(pos << 1 | 1, x, y, k);
	update(pos);
	return;
}

void ChangeAdd(int pos, int x, int y, int k) { //區間加法
	if (x <= s[pos].l && s[pos].r <= y) {
		s[pos].add = (s[pos].add + k) % mod;
		s[pos].sum = (s[pos].sum + k * (s[pos].r - s[pos].l + 1)) % mod;
		return;
	}
	pushdown(pos);
	int mid = (s[pos].l + s[pos].r) >> 1;
	if (x <= mid) ChangeAdd(pos << 1, x, y, k);
	if (y > mid) ChangeAdd(pos << 1 | 1, x, y, k);
	update(pos);
	return;
}

ll AskRange(int pos, int x, int y) { //區間詢問
	if (x <= s[pos].l && s[pos].r <= y) {
		return s[pos].sum;
	}
	pushdown(pos);
	ll val = 0;
	int mid = (s[pos].l + s[pos].r) >> 1;
	if (x <= mid) val = (val + AskRange(pos << 1, x, y)) % mod;
	if (y > mid) val = (val + AskRange(pos << 1 | 1, x, y)) % mod;
	return val;
}

int main() {
	//freopen("in.txt","r",stdin);
	ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);
	ll q, i1, i2, i3, i4;
	cin >> n >> m >> mod;
	for (int i = 1; i <= n; ++i)cin >> a[i];
	build_tree(1, 1, n);
	for (int i = 1; i <= m; ++i) {
		cin >> i1 >> i2 >> i3;
		if (i1 == 1) 
			cin>>i4, ChangeMul(1, i2, i3, i4);
		if (i1 == 2)
			cin >> i4, ChangeAdd(1, i2, i3, i4);
		if (i1 == 3)
			cout << AskRange(1, i2, i3) << endl;
	}
	return 0;	
}

HihoCoder 1078 線段樹的區間修改

https://cn.vjudge.net/problem/HihoCoder-1078

#include <iostream>

int n, a[100005], d[270000], b[270000];
void build(int l, int r, int p) {
    if (l == r) {
        d[p] = a[l];
        return;
    }
    int m = (l + r) >> 1;
    build(l, m, p << 1), build(m + 1, r, (p << 1) | 1);
    d[p] = d[p << 1] + d[(p << 1) | 1];
}
void update(int l, int r, int c, int s, int t, int p) {
    if (l <= s && t <= r) {
        d[p] = (t - s + 1) * c, b[p] = c;
        return;
    }
    int m = (s + t) >> 1;
    if (b[p]) {
        d[p << 1] = b[p] * (m - s + 1), d[(p << 1) | 1] = b[p] * (t - m);
        b[p << 1] = b[(p << 1) | 1] = b[p];
        b[p] = 0;
    }
    if (l <= m) update(l, r, c, s, m, p << 1);
    if (r > m) update(l, r, c, m + 1, t, (p << 1) | 1);
    d[p] = d[p << 1] + d[(p << 1) | 1];
}
int getsum(int l, int r, int s, int t, int p) {
    if (l <= s && t <= r) return d[p];
    int m = (s + t) >> 1;
    if (b[p]) {
        d[p << 1] = b[p] * (m - s + 1), d[(p << 1) | 1] = b[p] * (t - m);
        b[p << 1] = b[(p << 1) | 1] = b[p];
        b[p] = 0;
    }
    int sum = 0;
    if (l <= m) sum = getsum(l, r, s, m, p << 1);
    if (r > m) sum += getsum(l, r, m + 1, t, (p << 1) | 1);
    return sum;
}
int main() {
    std::ios::sync_with_stdio(0);
    std::cin >> n;
    for (int i = 1; i <= n; i++) std::cin >> a[i];
    build(1, n, 1);
    int q, i1, i2, i3, i4;
    std::cin >> q;
    while (q--) {
        std::cin >> i1 >> i2 >> i3;
        if (i1 == 0)
            std::cout << getsum(i2, i3, 1, n, 1) << endl;
        else
            std::cin >> i4, update(i2, i3, i4, 1, n, 1);
    }
    return 0;
}

2018 Multi-University Training Contest 5 Problem G. Glad You Came

http://acm.hdu.edu.cn/showproblem.php?pid=6356

維護一下每個區間的永久標記就可以了,最後線上段樹上跑一邊 dfs 統計結果即可。注意打標記的時候加個剪枝優化,否則會 T。

擴充 - 貓樹

眾所周知線段樹可以支援高速查詢某一段區間的資訊和,比如區間最大子段和,區間和,區間矩陣的連乘積等等。

但是有一個問題在於普通線段樹的區間詢問在某些毒瘤的眼裡可能還是有些慢了。

簡單來說就是線段樹建樹的時候需要做 \(O(n)\) 次合併操作,而每一次區間詢問需要做 \(O(\log{n})\) 次合併操作,詢問區間和這種東西的時候還可以忍受,但是當我們需要詢問區間線性基這種合併複雜度高達 \(O(\log^2{w})\) 的資訊的話,此時就算是做 \(O(\log{n})\) 次合併有些時候在時間上也是不可接受的。

而所謂 "貓樹" 就是一種不支援修改,僅僅支援快速區間詢問的一種靜態線段樹。

構造一棵這樣的靜態線段樹需要 \(O(n\log{n})\) 次合併操作,但是此時的查詢複雜度被加速至 \(O(1)\) 次合併操作。

在處理線性基這樣特殊的資訊的時候甚至可以將複雜度降至 \(O(n\log^2{w})\)

原理

在查詢 \([l,r]\) 這段區間的資訊和的時候,將線段樹樹上代表 \([l,l]\) 的節點和代表 \([r,r]\) 這段區間的節點線上段樹上的 lca 求出來,設這個節點 \(p\) 代表的區間為 \([L,R]\) ,我們會發現一些非常有趣的性質:

  1. \([L,R]\) 這個區間一定包含 \([l,r]\)

顯然,因為它既是 \(l\) 的祖先又是 \(r\) 的祖先。

  1. 這個區間一定跨越 \([L,R]\) 的中點

由於 \(p\)\(l\)\(r\)lca,這意味著 \(p\) 的左兒子是 \(l\) 的祖先而不是 \(r\) 的祖先, \(p\) 的右兒子是 \(r\) 的祖先而不是 \(l\) 的祖先。

因此 \(l\) 一定在 \([L,MID]\) 這個區間內, \(r\) 一定在 \((MID,R]\) 這個區間內。

有了這兩個性質,我們就可以將詢問的複雜度降至 \(O(1)\) 了。

實現

具體來講我們建樹的時候對於線段樹樹上的一個節點,設它代表的區間為 \((l,r]\)

不同於傳統線段樹在這個節點裡只保留 \([l,r]\) 的和,我們在這個節點裡面額外儲存 \((l,mid]\) 的字尾和陣列和 \((mid,r]\) 的字首和陣列。

這樣的話建樹的複雜度為 \(T(n)=2T(n/2)+O(n)=O(n\log{n})\) 同理空間複雜度也從原來的 \(O(n)\) 變成了 \(O(n\log{n})\)

下面是最關鍵的詢問了~

如果我們詢問的區間是 \([l,r]\) 那麼我們把代表 \([l,l]\) 的節點和代表 \([r,r]\) 的節點的 lca 求出來,記為 \(p\)

根據剛才的兩個性質, \(l,r\)\(p\) 所包含的區間之內並且一定跨越了 \(p\) 的中點。

這意味這一個非常關鍵的事實是我們可以使用 \(p\) 裡面的字首和陣列和字尾和陣列,將 \([l,r]\) 拆成 \([l,mid]+(mid,r]\) 從而拼出來 \([l,r]\) 這個區間。

而這個過程僅僅需要 \(O(1)\) 次合併操作!

不過我們好像忽略了點什麼?

似乎求 lca 的複雜度似乎還不是 \(O(1)\) ,暴力求是 \(O(\log{n})\) 的,倍增法則是 \(O(\log{\log{n}})\) 的,轉 ST 表的代價又太大……

堆式建樹

具體來將我們將這個序列補成 2 的整次冪,然後建線段樹。

此時我們發現線段樹上兩個節點的 lca 編號,就是兩個節點二進位制編號的 lcp

稍作思考即可發現發現在 \(x\)\(y\) 的二進位制下 lcp(x,y)=x>>log[x^y]

所以我們預處理一個 log 陣列即可輕鬆完成求 lca 的工作。

這樣我們就完成了一個貓樹。

由於建樹的時候涉及到求字首和和求字尾和,所以對於線性基這種雖然合併是 \(O(\log^2{w})\) 但是求字首和卻是 \(O(n\log{n})\) 的資訊,使用貓樹可以將靜態區間線性基從 \(O(n\log^2{w}+m\log^2{w}\log{n})\) 優化至 \(O(n\log{n}\log{w}+m\log^2{w})\) 的複雜度。

應用

經典問題:區間最大子段和

給出一個序列,多次詢問區間的最大子段和。

這是一個經典的模型。不同於經典做法,我們只需要記錄 prelprel、prerprer 為對應前(後)綴的最大子段和、最大前(後)綴和即可。

複雜度:\(O(nlog⁡n)+O(1)+O(nlog⁡n)\)。實測在 \(n=m=200000,a_i≤10^9\) 的情況下,此做法的執行時間接近經典做法(非遞迴線段樹實現)的 \(2/{3}\)

經典問題:NAND

給出一個序列,多次詢問一個 xx 對一個區間中所有數按順序依次 NAND 的結果。

NAND 沒有結合律,因此我們要用一些經典的處理方式。NAND 是按位獨立的,因此我們可以對每一位維護資訊:如果這一位剛開始是 0,那麼按順序 NAND 了這個區間中的所有數後會變成什麼;如果這一位是 1 那麼會變成多少。用位運算可以優化為 \(O(1)\) 的資訊合併。

使用貓樹,即可直接支援 \(O(nlog⁡n)+O(1)+O(nlog⁡n)\)

經典問題:區間凸包

參考

immortalCO 大爺的部落格

線段樹(segment tree)

線段樹教程

相關文章