1. 程式人生 > 實用技巧 >淺談 線段樹

淺談 線段樹

其實真不太想寫的。

前置芝士:可以先看看其他的 RMQ 演算法,如樹狀陣列,ST之類的。

畢竟線段樹這個東西很重要,而看懂了其他的會方便理解一些,早學不一定精。

當然,也歡迎翻翻我之前寫的。給個鏈。

0x01 基本概念

線段樹(Segment Tree)是一個基於分治の資料結構。

通常處理區間,序列中的查詢,更改問題。但因為其可維護變數的多樣性,所以常在各類題目中遇到。準確說,是各類優化中遇到。

線段樹是個有根二叉樹,我們記為 \(t\),其每個節點 \(t[p]\) 均儲存著一組關鍵資訊:\(l\)\(r\)。我通常將其稱為鑰匙資訊。(意會即可)

它們合在一起表示當前這個節點儲存的是哪一個區間的資訊。

比如,如果你的線段樹維護的有區間最值,那麼對於一個結點 \(t[p]\) 滿足 \(t[p].l = L, t[p].r = R\),則其維護的值就是 \(max\{a[L], a[L + 1]...a[R - 1], a[R]\}\),其中 \(a[i]\) 為給定區間的第 \(i\) 號元素。

而對於每個節點 \(t[p]\),我們定義 \(mid = \frac {(t[p].l + t[p].r)} {2}\) ,並定義,對於其左兒子節點 \(t[p \times 2]\),有 \(t[p \times 2].l = t[p].l\)\(t[p \times 2].r = mid\)

同理對於其右兒子 \(t[p \times 2 + 1]\),有 \(t[p \times 2 + 1].l = mid + 1\)\(t[p \times 2 + 1].r = t[p].r\)

這樣,在我們知道所有的葉子節點後就可以往上更新至全部的區間資訊,這就是線段樹的大體思想。

不過我們還需要完善最後一個定義。對於葉子節點 \(t[q]\),顯然擁有一個特性:沒有兒子。那也就是說它們無法找到合理的 \(mid\) 將其表示的區間再次分化,那就是 \(t[q].l = t[q].r\) 唄。我們在序列裡稱這樣的區間為元區間,它也是我們的邊界,與我們的答案來源。畢竟這是一開始題目就會給你的東西。

我不想畫圖。。。

但為了方便讀者理解,我還是扒一個下來吧。

上面這張圖中,線段樹起到了儲存區間最小值的作用。

其原序列 \(a\) 為:

1 3 5 7 9 10 2 4 8 6

節點上標註的 \([x, y]\) 表示 \(b_i(i \in [x, y], b_i = a_i)\) 這個序列,而 \(minv[p]\)\(Min Value\),表示節點 \(t[p]\) 儲存的 \(b\) 序列的最小值。

很容易理解了吧,接下來我們來看看實現。

0x02 程式碼實現

沒點基礎碼力還真不敢碰這玩意。

注:這裡的實現均以區間和為例。

step0.前言:關於空間

線段樹如果不看所有的葉子節點,它一定是顆滿二叉樹,這個毋庸置疑。

那麼我們設除去最下面一層以外的深度最深的一層有 \(x\) 個節點。

則上一層定有 \(\frac x 2\) 個節點,畢竟兩個兒子對應一個父親嘛。

如果我們設最下面一層的節點數為 \(y\),那麼整棵線段樹的節點總和為:\(y + x + \frac x 2 + \frac x 4 +...+ 1\)。等比數列求和,答案為 \(2 \times n - 1 + y\)。又因為最下層的節點均為元區間,且原序列中元區間個數為 \(n\) 個,那麼所以 \(y < n\)。所以總節點數一定小於 \(3 \times n\)

但是因為我們的存圖方式是有空點存在的,詳見上圖。且最後一行最多容納 \(2 \times n\) 個點,那麼我們線段樹的陣列就需要開足四倍空間。

這也是線段樹的一個小缺陷所在。

step1.建樹

首先,我們已經瞭解了線段樹的構造,那麼這一步就相當於是去模擬了。

再理一下。

  • 顯然,\(t[p].Sum = t[p \times 2].Sum + t[p \times 2 + 1].Sum\)
  • \(l = r\) 時,屬於元區間,來到葉子節點,直接更新,並返回。
  • 每次記得儲存每個節點的鑰匙資訊,即 \(l\)\(r\) 的值,並從 \(mid\) 開始繼續往下劃分。
void Make_Tree(int p, int l, int r) {
	t[p].l = l;
	t[p].r = r;
    // 記錄節點の鑰匙資訊。
	if(l == r) {
		t[p].Sum = a[l];
		return ;
	}
    // 葉子節點(元區間)
	int mid = (l + r) >> 1;
	Make_Tree(p << 1, l, mid);
	Make_Tree(p << 1 | 1, mid + 1, r);
	t[p].Sum = t[p << 1].Sum + t[p << 1 | 1].Sum; 
    // 遞迴建樹並維護區間和
}

step2.更新

這裡的例子中我們完成操作:單點修改。

即一次修改一個原序列中一個元素的值。記我們要改的元素為 \(a_index\),表示它在原序列 \(a\) 中的第 \(index\) 位。

那麼線上段樹中改一個點,其實就是找到其對應的元區間,更改元區間後再更新它的所有父親嘛。

首先明確,對於節點 \(t[p]\),如果 \(index <= mid\),則我們需要往左兒子找,因為根據定義,此時的 \(index\) 一定屬於區間 \([t[p \times 2].l, t[p \times 2].r]\) 中。那麼反之,如果 \(index > mid\),則需要往右兒子找。

始終記住,在大多情況下,元區間都是我們的邊界條件。

不就結了嗎。

void Update(int p, int index, int x) {
	if(t[p].l == t[p].r) {
		t[p].Sum += x;
		return ;
	}
    // 找到元區間,直接修改
	int mid = (t[p].l + t[p].r) >> 1;
	if(index <= mid)
		Update(p << 1, index, x);
	else
		Update(p << 1 | 1, index, x);
	t[p].Sum = t[p << 1].Sum + t[p << 1 | 1].Sum; 
    // 左右兒子依次訪問,並再次更新
}

step3.查詢

單點修改,單點查詢顯然沒必要對吧。那個不是有手就行嗎。

於是我們考慮單點修改,區間查詢。

首先對於我們想要的區間 \([L, R]\),如果 \([L, R]\) 完全覆蓋一個區間 \([t[p].l, t[p].r]\),則 \(t[p].Sum\) 一定會對我們想要的答案產生價值。

那麼這次我們就不需要再查詢 \(t[p]\) 的兒子節點了,因為它的兒子節點可以帶來的價值一定全部包含在 \(t[p].Sum\) 中。

但是同樣,對於 \(t[p]\),也完全有可能不完全覆蓋。 那麼我們就暫時不能累加價值,因為這時候 \(t[p]\) 的有部分價值可能是我們不想要的。

那這個時候,我們就應該去訪問 \(t[p]\) 的兒子節點了,必定在整棵線段樹中一定能找到完全覆蓋的情況。

真就往下搜唄。

不過不一定是兩個兒子都需要訪問。大多時候我們都是需要訪問才訪問。需要訪問也就是說要查的區間與某個兒子表示的區間擁有交集。那就直接比較 \(L\)\(R\)\(mid\) 的大小即可。如果 \(L <= mid\),即我們要查的區間有一丟丟在左兒子裡,那麼就去拜訪它。同理,如果 \(R > mid\),則還需去看看右兒子。當然也可能會出現兩邊都要訪問的情況,也就是 \(mid\) 剛好把要查區間從中截斷(兩邊各有一丟丟嘛)。

LL Query(int p, int l, int r) {
	if(l <= t[p].l && t[p].r <= r) 
		return t[p].Sum;
	// 完全覆蓋就直接返回
	int mid = (t[p].l + t[p].r) >> 1;
	LL val = 0;
	if(l <= mid)
		val += Query(p << 1, l, r);
	if(r > mid)
		val += Query(p << 1 | 1, l, r);
	// 看情況訪問左右兒子
	return val;
}

step4.完整程式碼

還是放一個吧。線段樹一定要慢慢調哦。

可以交交這道題。「線段樹」模板題1

#include <cstdio>

const int MAXN = 1e6 + 5;
const int MAXT = 1e6 * 4 + 5;
typedef long long LL;
struct Segment_Tree {
	int l, r;
	LL Sum;
	Segment_Tree() {}
	Segment_Tree(int L, int R, LL S) {
		l = L;
		r = R;
		Sum = S;
	}
} t[MAXT];
int a[MAXN];

void Make_Tree(int p, int l, int r) { // 建樹
	t[p].l = l;
	t[p].r = r;
	if(l == r) {
		t[p].Sum = a[l];
		return ;
	}
	int mid = (l + r) >> 1;
	Make_Tree(p << 1, l, mid);
	Make_Tree(p << 1 | 1, mid + 1, r);
	t[p].Sum = t[p << 1].Sum + t[p << 1 | 1].Sum; 
}

void Update(int p, int index, int x) { // 更新
	if(t[p].l == t[p].r) {
		t[p].Sum += x;
		return ;
	}
	int mid = (t[p].l + t[p].r) >> 1;
	if(index <= mid)
		Update(p << 1, index, x);
	else
		Update(p << 1 | 1, index, x);
	t[p].Sum = t[p << 1].Sum + t[p << 1 | 1].Sum; 
}

LL Query(int p, int l, int r) { // 詢問
	if(l <= t[p].l && t[p].r <= r) 
		return t[p].Sum;
	int mid = (t[p].l + t[p].r) >> 1;
	LL val = 0;
	if(l <= mid)
		val += Query(p << 1, l, r);
	if(r > mid)
		val += Query(p << 1 | 1, l, r);
	return val;
}

int main() {
	int n, q;
	scanf ("%d %d", &n, &q);
	for(int i = 1; i <= n; i++)
		scanf ("%d", &a[i]);
	Make_Tree(1, 1, n);
	for(int i = 1; i <= q; i++) {
		int flag;
		scanf ("%d", &flag);
		if(flag == 1) {
			int v, x;
			scanf ("%d %d", &v, &x);
			Update(1, v, x);
		}
		else {
			int l, r;
			scanf ("%d %d", &l, &r);
			printf("%lld\n", Query(1, l, r));
		}
	}
	return 0;
}

其實話說,整體把線段樹的程式碼拉出來還挺好看的。

上一個有這種感覺的是笛卡爾曲線方程:\(r = a(1 - \sin \Theta)\)。抱歉扯遠了。

0x03 推廣

我們在上一個版塊中只講到了單點修改。

那如果想要區間修改呢?之前知道的只有樹狀陣列有個非常麻煩的實現方法(霧。

於是引入:懶惰標記 (Lazy Tag),又叫延遲標記 (Daley Tag)。

首先,關於區間修改,我們可以按照剛剛的思路將這個區間修改改為很多個小的單點修改,但這顯然會超時。那麼考慮優化。

你會發現如果我們每次都跑到元區間其實是很不划算的,因為我們在查詢的時候並不是每次都查到了元區間。也就是說我們只需要將我們需要的點,即會對答案產生價值的點進行精確更改即可。

這個很顯然吧,一個小貪心。

那麼我們可以將每個節點儲存的資訊多加一個:\(add\)。這個 \(add\) 表示,之前區間修改時沒累加在當前節點但其實需要去累加的價值。

也就是說我們需要將所有之前的操作更改的價值累加起來,在我們需要查詢 \(t[p \times 2]\),我們再由標記在 \(t[p]\) 上的 \(add\) 去更新 \(t[p \times 2]\),求得實際的值。\(t[p \times 2 + 1]\) 同理。

好像很抽象?我還是扒個圖吧。

這裡面的綠點表示修改區間 \([3, 9]\) 時本來會改變的線段樹上的節點。

而我們每次 lazy 只標記黃點。

在我們下次要去查詢某個綠點時,我們再由黃點的 lazy tag 去更新綠點的資訊。

也就是說真實的綠點 \(t[q]\) 滿足:\(t[q].Sum = t[q].Sum + t[p].add * (t[q].r - t[q].l + 1)\)。其中 \(t[p]\)\(t[q]\) 的父親節點。而 \(t[p].add\) 需要乘上它表示的節點個數,因為我們存的是單個節點的 lazy tag。

啊,沒有智商了,那就結合程式碼再分析吧。

#include <cstdio>

typedef long long LL;
const int MAXN = 1e6 + 5;
const int MAXT = 1e6 * 4 + 5;
struct Segment_Tree {
	int l, r, len;
	LL Sum, add; // lazy tag
	Segment_Tree() {}
	Segment_Tree(int L, int R, LL S, LL A, int Len) {
		l = L;
		r = R;
		Sum = S;
		add = A;
		len = Len;
	}
} t[MAXT];
int a[MAXN];

void Spread(int p) { 
	// 從父親往兒子更新標記
	if(t[p].add) {
		t[p << 1].Sum += t[p].add * t[p << 1].len;
		t[p << 1 | 1].Sum += t[p].add * t[p << 1 | 1].len;
		t[p << 1].add += t[p].add;
		t[p << 1 | 1].add += t[p].add;
		t[p].add = 0; 
	}
}

void Make_Tree(int p, int l, int r) {
	t[p].l = l;
	t[p].r = r;
	t[p].len = r - l + 1;
	if(l == r) {
		t[p].Sum = a[l];
		return ;
	}
	int mid = (l + r) >> 1;
	Make_Tree(p << 1, l, mid);
	Make_Tree(p << 1 | 1, mid + 1, r);
	t[p].Sum = t[p << 1].Sum + t[p << 1 | 1].Sum; 
}

void Update(int p, int l, int r, int x) {
	if(l <= t[p].l && t[p].r <= r) {
		t[p].Sum += (LL)x * t[p].len;
		t[p].add += x;
		return ;
	}
	Spread(p);
	// 更新標記。
	int mid = (t[p].l + t[p].r) >> 1;
	if(l <= mid)
		Update(p << 1, l, r, x);
	if(r > mid)
		Update(p << 1 | 1, l, r, x);
	t[p].Sum = t[p << 1].Sum + t[p << 1 | 1].Sum;
}

LL Query(int p, int l, int r) {
	if(l <= t[p].l && t[p].r <= r) 
		return t[p].Sum;
	Spread(p);
	// 更新標記。
	int mid = (t[p].l + t[p].r) >> 1;
	LL val = 0;
	if(l <= mid)
		val += Query(p << 1, l, r);
	if(r > mid)
		val += Query(p << 1 | 1, l, r);	
	return val;
}

int main() {
	int n, q;
	scanf ("%d %d", &n, &q);
	for(int i = 1; i <= n; i++)
		scanf ("%d", &a[i]);
	Make_Tree(1, 1, n);
	for(int i = 1; i <= q; i++) {
		int flag;
		scanf ("%d", &flag);
		if(flag == 1) {
			int l, r, x;
			scanf ("%d %d %d", &l, &r, &x);
			Update(1, l, r, x);
		}
		else {
			int l, r;
			scanf ("%d %d", &l, &r);
			printf("%lld\n", Query(1, l, r));
		}
	}
	return 0;
}