此篇文章用於記錄《玩轉資料結構》課程的學習筆記

什麼是線段樹

線段樹也被稱為區間樹,英文名為

Segment Tree

或者

Interval tree

,是一種高階的資料結構。這種資料結構更多出現在競賽中,在常見的本科資料結構教材裡沒有介紹這種資料結構。但是,在面試中卻有可能碰到和線段樹相關的問題。那麼為什麼會產生線段樹這種資料結構,線段樹到底是為了解決什麼樣的一種問題呢?

其實這裡的

線段

可以理解為

區間

,線段樹就是為了解決區間問題的。

有一個很經典的線段樹問題是:區間染色。

假設有一面牆,長度為 n,每次選擇一段牆進行染色。

線段樹詳解與實現

在區間染色的過程中,每次選擇一段區間進行染色,這時新的顏色可能會覆蓋之前的顏色。

最後的問題是:

在經過 m 次染色操作後,我們可以在整個區間看見多少種顏色?

更加普遍的說法是:

在經過 m 次染色操作後,我們可以在區間

[i, j]

內看見多少種顏色?

由於第一個問題是第二個問題的一個特例,我們採用第二種問題來思考解決方法。

從上面可以看出,我們對於區間,有 2 種操作,分別是

染色操作

查詢區間的顏色

,使用更加一般的說法,

染色操作

就是

更新區間

查詢區間的顏色

就是

查詢區間

這類問題裡面,更加常見的的是區間查詢:一個數組存放的不再是顏色,而是具體的數字,查詢某個區間

[i, j]

統計值

。這裡的統計值是指:區間內最大值、最小值、或者這個區間的數字和。

線段樹詳解與實現

比如:

查詢 2018 年註冊的使用者中消費最高的使用者

查詢 2018 年註冊的使用者中消費最低的使用者

注意上面兩種情況都是動態查詢,我們查詢的消費資料

不只是 2018 的消費資料

如果我們想查詢 2018 年中消費最高的使用者,那麼 2018 年的資料已經固定了,我們直接在這一年的資料中進行統計分析就行了。

但是一個 2018 年註冊的使用者,在 2019 年、2020 年都可能會有消費。我們實際上查詢的是:2018 年註冊的使用者中,到現在為止,消費最高的使用者。

這種情況下,資料是在動態變化的, 也就是說:2017 年註冊的使用者中,每個使用者的消費額是會更新的,這就對應到

更新區間

的操作。

此時線段樹就是一種好的選擇。

按照通常的思路,使用陣列儲存上述的元素是比較好的,思考上面兩個操作的時間複雜度:

更新區間

:每次根據需要更新的區間的首尾索引,逐個遍歷區間中的元素進行更新,時間複雜度為

O(n)

查詢區間

:每次根據需要更新的區間的首尾索引,逐個遍歷區間種的元素進行查詢,時間複雜度為

O(n)

兩個操作的時間複雜度均為

O(n)

,對於需要多次動態使用的場景來說,效能可能是不夠好的。

在這類問題中,我們關注的是一個個區間內的元素的情況,線段樹就有用武之地了,線段樹的優點就是把兩個操作的

時間複雜度降到了O(logn)

操作使用陣列使用線段樹

更新O(n)O(logn)查詢O(n)O(logn)

這裡提一點,如果你看到一個演算法的時間複雜度是

O(logn)

,那這個演算法大多與

二叉樹

分治

演算法有關。

這裡也不例外,線段樹就是使用二叉樹來實現的。

那麼一個區間是如何被構建成為一個二叉樹的?

對於一個數組 A,如下所示:

線段樹詳解與實現

對應的線段樹就是:

線段樹詳解與實現

二叉樹中每個

非葉子節點

表示的是區間內元素的

統計值

葉子節點

儲存的就是

元素本身

。上面說了統計值是指:區間內最大值、最小值、或者這個區間的數字和。比如你要求區間的最大值,每個每個節點儲存的就是這個區間內元素的最大值。像下面這樣:

線段樹詳解與實現

假設你要查詢

[4,7]

區間內的最大值,那麼不用查到葉子節點,而是查到

A[4, 7]

這個節點就行了。

線段樹詳解與實現

當然,並不是所有的區間都恰好落在一個節點,比如你要求

[2, 5]

區間內的最大值。那麼就要分別找到

A[2, 3]

A[4,5]

的最大值,再進行比較。

線段樹詳解與實現

可以看出,線段樹的查詢區間操作不需要遍歷區間中的每一個元素,只要找到對應的樹節點就可以返回,時間複雜度為

O(logn)

總結

從更加抽象的角度來講,線段樹的使用場景就是,對於給定區間,進行更新區間和查詢區間操作:

更新區間:更新區間中的一個元素或者一個區間的值。

查詢區間:查詢一個區間

[i, j]

的最大值、最小值、或者區間的數字和。

注意,在大多數情況下,我們是不考慮區間裡新增元素和刪除元素的,我們假設區間的大小是固定的。

線段樹的表示

樹的一般表示方法是鏈式儲存,每個節點有兩個指標,一個指向左孩子,一個指向右孩子。但是滿二叉樹,和完全二叉樹,除了使用連結串列法來儲存,還可以使用陣列來表示。

在滿二叉樹和完全二叉樹的陣列表示中,假設一個節點的所以是

i

,那麼左孩子的索引就是

2 \times i +1

,右孩子的索引就是

2 \times i +2

那麼線段樹是不是滿二叉樹或者完全二叉樹呢? 能不能使用陣列來表示呢?

在上面的例子中,我們的二叉樹恰好是一棵滿二叉樹,這是因為我們的陣列大小恰好是 8,也就是

2^3

,只有陣列大小恰好是 2 的 n 次冪,所對應的線段樹才會是一個滿二叉樹。在大部分情況下,線段樹並不是一個滿二叉樹。如果一個數組的大小是 10 ,對應的線段樹如下圖所示。

線段樹詳解與實現

所以線段樹不是滿二叉樹,也不是完全二叉樹。但實際上:線段樹是平衡二叉樹,是可以保證

O(logn)

的時間複雜度的,這裡就不證明了。

其實平衡二叉樹可以看作特殊的滿二叉樹,進而使用陣列來表示。

線段樹詳解與實現

下一步就是確定:對於大小為 n 的陣列,需要多大的空間來儲存線段樹。

首先說一個結論,對於高為 h 層的滿二叉樹,一共有

2^{h}-1

個節點,而最後一層有

2^{h-1}

個節點。那麼:

除了最後一層的前面所有節點數 = 總的節點數 - 最後一層的節點數 = (2^{h}-1) - 2^{h-1} \approx 2^{h-1}-1

。也就是:前面所有層的節點數之和等於最後一層的節點數減 1。

線段樹詳解與實現

那麼線段樹所需要的節點數量,分兩種情況來討論:

如果 n 恰好是 2 的 k 次冪,由於線段樹最後一層的葉子節點儲存的是陣列元素本身,最後一層的節點數就是 n,而根據上面的結論,前面所有層的節點數之和是

n-1

,那麼總節點數就是

2 \times n-1

。為了方便起見,分配

 2 \times n

的空間。

如果 n 不是 2 的 k 次冪,最壞的情況就是

n=2^k+1

,那麼有一個元素需要開闢新的一層來儲存,需要

4 \times n-5

的大小。為了方便起見,我們分配

4 \times n

的空間,已經足夠了。

線段樹詳解與實現

綜上,首先需要判斷陣列的大小是否為

2^k

,是則使用

2 \times n

的空間,否則使用的

4 \times n

空間。下面線段樹的實現是基於陣列來實現的,不過為了簡便起見,下面的實現統一使用

4 \times n

空間來儲存線段樹。

使用陣列來儲存線段樹,會有一定的空間浪費,但是換來的時間複雜度的降低是可以接受的。同時,我也在最後會介紹鏈式儲存的實現方式。

線段樹的實現

首先需要兩個陣列,其中

data

存放原來的資料,

tree

就是存放線段樹。

基本的 API 有

getSize()

:返回陣列元素個數;

get(int index)

:根據索引獲取資料。

其中每個元素使用泛型

E

表示,這是為了考慮可擴充套件性:如果你的陣列元素不是數字,而是自定義的類,那麼使用泛型就是比較好的選擇。

public class SegmentTree {

private E[] tree; //線段樹

private E[] data; //資料

public SegmentTree(E[] arr) {

data = (E[]) new Object[arr。length];

tree = (E[]) new Object[arr。length * 4]; //大小為 4 * n

for (int i = 0; i < arr。length; i++) {

data[i] = arr[i];

}

}

// 返回陣列元素個數

public int getSize() {

return data。length;

}

// 根據索引獲取資料

public E get(int index) {

if (index < 0 || index > data。length)

throw new IllegalArgumentException(“Index is illegal”);

return data[index];

}

}

由於把線段樹看作一棵完全二叉樹,應該定義兩個 API,根據一個節點獲取到它的左孩子和右孩子。

// 根據一個節點的索引 index,返回這個節點的左孩子的索引

private int leftChild(int index) {

return 2 * index + 1;

}

// 根據一個節點的索引 index,返回這個節點的右孩子的索引

private int rightChild(int index) {

return 2 * index + 2;

}

線段樹的構建

下面考慮的就是構造線段樹的每個節點。這裡以求區間的最大值為例。

線段樹詳解與實現

根節點儲存的是整個

[0,7]

區間的最大值,左孩子儲存的是

[0,3]

區間內的最大值,右孩子儲存的是

[4,7]

區間內的最大值。

我們要求根節點的值,首先求得左右兩個孩子的值,再從左右兩個孩子中取出較大的值作為根節點的值。要求父節點區間的值,需要先求孩子節點區間的值,這是遞迴的性質。所以可以透過遞迴來建立線段樹。

那麼這個遞迴的終止條件,也就是 base case,是什麼呢?

base case:如果一個節點的區間長度為1,不能再劃分,也就是遞迴到底了,就返回這個元素本身。

明確了思路,那麼我們的遞迴函式需要幾個引數?

首先,既然是建立節點,那麼需要節點在

tree

陣列中的索引;其次,這個節點對應的區間的左邊界和右邊界。

總共需要 3 個引數,寫出的程式碼如下:

// 在 treeIndex 的位置建立表示區間 [l,r] 的線段樹

private void buildSegmentTree(int treeIndex, int l, int r) {

// base case:遞迴到葉子節點了

if (l == r) {

tree[treeIndex] = data[l];

return;

}

int leftTreeIndex = leftChild(treeIndex);

int rightTreeIndex = rightChild(treeIndex);

//劃分區間

int mid = l + (r - l) / 2;

// 求(左孩子)左邊區間的最大值

buildSegmentTree(leftTreeIndex, l, mid);

// 求(右孩子)右區間的最大值

buildSegmentTree(rightTreeIndex, mid + 1, r);

//合併左右區間,求左區間和右區間點的最大值

tree[treeIndex] = Math。max(tree[leftTreeIndex], tree[rightTreeIndex]);

}

當然這裡最後一句是會報錯的。因為

tree

的元素型別是泛型,不支援

Math。max()

函式。

還有一個問題是:如果你在這裡把

區間合併的邏輯

寫成了只取最大值,那麼這個線段樹就只能求某個區間的最大值,不能用於求取區間的最小值、或者區間的和,限制了線段樹的應用場景。

一個更好的方法是:使用者可以根據自己的業務場景,自由選擇合併區間的邏輯。

要達成這個目的,我們需要建立一個介面,使用者需要實現這個介面來實現自己的區間合併邏輯。

//融合器,表示如何合併兩個區間的統計值

public interface Merger {

// a 表示左區間的統計值,b 表示有區間的統計值

//返回整個[左區間+右區間] 的統計值

E merge(E a, E b);

}

線上段樹的建構函式中,新增一個

Merger

引數,並且呼叫

buildSegmentTree()

構建線段樹。

public class SegmentTree {

private E[] tree; //線段樹

private E[] data; //資料

private Merger merger;//融合器

public SegmentTree(E[] arr, Merger merger) {

this。merger = merger;

data = (E[]) new Object[arr。length];

tree = (E[]) new Object[arr。length * 4]; //大小為 4 * n

for (int i = 0; i < arr。length; i++) {

data[i] = arr[i];

}

//構建線段樹

buildSegmentTree(0, 0, data。length - 1);

}

}

然後,修改

buildSegmentTree()

方法的最後一行。

// 在 treeIndex 的位置建立表示區間 [l,r] 的線段樹

private void buildSegmentTree(int treeIndex, int l, int r) {

// base case:遞迴到葉子節點了

if (l == r) {

tree[treeIndex] = data[l];

return;

}

int leftTreeIndex = leftChild(treeIndex);

int rightTreeIndex = rightChild(treeIndex);

//劃分區間

int mid = l + (r - l) / 2;

// 求(左孩子)左區間的統計值

buildSegmentTree(leftTreeIndex, l, mid);

// 求(右孩子)右區間的統計值

buildSegmentTree(rightTreeIndex, mid + 1, r);

//求當前節點 [左區間+右區間] 的統計值

tree[treeIndex] = merger。merge(tree[leftTreeIndex], tree[rightTreeIndex]);

}

線段樹的查詢

我們用下面的陣列構建一棵線段樹,查詢區間

[2,5]

為例。

線段樹詳解與實現

我們首先從根節點開始查詢:在區間

[0,7]

中查詢

[2,5]

線段樹詳解與實現

將根節點的區間分為左孩子

[0,3]

和右孩子

[4,7]

,而我們的查詢的區間

[2,5]

不能其中一個孩子的區間完全包括

於是上述問題轉化為兩個子問題:

在區間

[0,3]

中查詢

[2,3]

在區間

[4,7]

中查詢

[4,5]

最後將

[2,3]

[4,5]

結果合併,得到區間

[2,5]

的結果。

線段樹詳解與實現

[0,3]

劃分為左孩子

[0,1]

和右孩子

[2,3]

,此時

孩子剛好和查詢的區間重合,返回結果。

[4,7]

劃分為左孩子

[4,5]

和右孩子

[6,7]

,此時

孩子剛好和查詢的區間重合,返回結果。

線段樹詳解與實現

從上面可以看出,線上段樹的區間查詢過程中,不需要遍歷區間中的每個元素,只需要線上段樹中找到對應的所有子區間,再將這些子區間的結果合併即可。而查詢所經過的節點數最多是樹的高度,時間複雜度為

O(logn)

而且上面的過程也是遞迴的過程。

遞迴終止條件就是:查詢區間的邊界和節點的邊界完全重合,就返回該節點的統計值。

如果不重合,那怎麼辦?

這時應該分 3 種情況:

如果查詢區間的

左邊界大於中間節點

,那麼就查詢右區間

線段樹詳解與實現

如果查詢區間的

右邊界小於等於中間節點

,那麼就查詢左區間

線段樹詳解與實現

如果不屬於上述兩種情況,那麼查詢的區間就要根據中間節點拆分

線段樹詳解與實現

遞迴函式的引數應該有 4 個,

當前節點所在的區間的左邊界和右邊界

使用者要查詢的的區間的左邊界和右邊界

程式碼如下:

//在以 treeIndex 為根的線段樹中 [l,r] 的範圍裡,搜尋區間 [queryL, queryR]

private E query(int treeIndex, int l, int r, int queryL, int queryR) {

if (l == queryL && r == queryR) {

return tree[treeIndex];

}

int mid = l + (r - l) / 2;

int leftTreeIndex = leftChild(treeIndex);

int rightTreeIndex = rightChild(treeIndex);

// 如果左邊界大於中間節點,則查詢右區間

if (queryL > mid)

return query(rightTreeIndex, mid + 1, r, queryL, queryR);

// 如果右邊界小於等於中間節點,則查詢左區間

if (queryR <= mid)

return query(leftTreeIndex, l, mid, queryL, queryR);

// 如果上述兩種情況都不是,則根據中間節點,拆分為兩個查詢區間

E leftResult = query(leftTreeIndex, l, mid, queryL, mid);

E rightResult = query(rightTreeIndex, mid + 1, r, mid + 1, queryR);

//合併左右區間的查詢結果

return merger。merge(leftResult, rightResult);

}

線段樹的更新

如下圖所示,更新

A[i]=100

,那麼將需要更新的索引

i

和區間的終點

mid

,分為兩種情況。

如果

i>mid

,那麼索引

i

落在右區間,更新右區間;

如果

i<=mid

,那麼索引

i

落在左區間,更新左區間;

那麼遞迴的終止條件是什麼呢?

當遞迴到葉子節點的時候,就值更新這個節點:葉子節點就是區間長度為 1 的節點。

線段樹詳解與實現

當更新完葉子節點後,還需要回溯,更新父節點區間的統計值。

線段樹詳解與實現

程式碼如下:

//將 index 位置的值,更新為 e

public void update(int index, E e) {

if (index < 0 || index >= data。length)

throw new IllegalArgumentException(“Index is illegal”);

data[index] = e;

//更新線段樹相應的節點

updateTree(0, 0, data。length - 1, index, e);

}

// 在以 treeIndex 為根的線段樹中,更新 index 的值為 e

private void updateTree(int treeIndex, int l, int r, int index, E e) {

//遞迴終止條件

if (l == r) {

tree[treeIndex] = e;

return;

}

int mid = l + (r - l) / 2;

int leftTreeIndex = leftChild(treeIndex);

int rightTreeIndex = rightChild(treeIndex);

if (index > mid)

updateTree(rightTreeIndex, mid + 1, r, index, e);

else //index <= mid

updateTree(leftTreeIndex, l, mid, index, e);

//更新當前節點

tree[treeIndex] = merger。merge(tree[leftTreeIndex], tree[rightTreeIndex]);

}

完整程式碼

public class SegmentTree {

private E[] tree; //線段樹

private E[] data; //資料

private Merger merger;//融合器

public SegmentTree(E[] arr, Merger merger) {

this。merger = merger;

data = (E[]) new Object[arr。length];

tree = (E[]) new Object[arr。length * 4]; //大小為 4 * n

for (int i = 0; i < arr。length; i++) {

data[i] = arr[i];

}

//構建線段樹

buildSegmentTree(0, 0, data。length - 1);

}

// 返回陣列元素個數

public int getSize() {

return data。length;

}

// 根據索引獲取資料

public E get(int index) {

if (index < 0 || index > data。length)

throw new IllegalArgumentException(“Index is illegal”);

return data[index];

}

//根據一個節點的索引 index,返回這個節點的左孩子的索引

private int leftChild(int index) {

return 2 * index + 1;

}

//根據一個節點的索引 index,返回這個節點的右孩子的索引

private int rightChild(int index) {

return 2 * index + 2;

}

// 在 treeIndex 的位置建立表示區間 [l,r] 的線段樹

private void buildSegmentTree(int treeIndex, int l, int r) {

// base case:遞迴到葉子節點了

if (l == r) {

tree[treeIndex] = data[l];

return;

}

int leftTreeIndex = leftChild(treeIndex);

int rightTreeIndex = rightChild(treeIndex);

//劃分區間

int mid = l + (r - l) / 2;

// 求(左孩子)左區間的統計值

buildSegmentTree(leftTreeIndex, l, mid);

// 求(右孩子)右區間的統計值

buildSegmentTree(rightTreeIndex, mid + 1, r);

//求當前節點 [左區間+右區間] 的統計值

tree[treeIndex] = merger。merge(tree[leftTreeIndex], tree[rightTreeIndex]);

}

//查詢區間,返回區間 [queryL, queryR] 的統計值

public E query(int queryL, int queryR) {

//首先進行邊界檢查

if (queryL < 0 || queryL > data。length || queryR < 0 || queryR > data。length || queryL > queryR) {

throw new IllegalArgumentException(“Index is illegal”);

}

return query(0, 0, data。length - 1, queryL, queryR);

}

//在以 treeIndex 為根的線段樹中 [l,r] 的範圍裡,搜尋區間 [queryL, queryR]

private E query(int treeIndex, int l, int r, int queryL, int queryR) {

if (l == queryL && r == queryR) {

return tree[treeIndex];

}

int mid = l + (r - l) / 2;

int leftTreeIndex = leftChild(treeIndex);

int rightTreeIndex = rightChild(treeIndex);

// 如果左邊界大於中間節點,則查詢右區間

if (queryL > mid)

return query(rightTreeIndex, mid + 1, r, queryL, queryR);

// 如果右邊界小於等於中間節點,則查詢左區間

if (queryR <= mid)

return query(leftTreeIndex, l, mid, queryL, queryR);

// 如果上述兩種情況都不是,則根據中間節點,拆分為兩個查詢區間

E leftResult = query(leftTreeIndex, l, mid, queryL, mid);

E rightResult = query(rightTreeIndex, mid + 1, r, mid + 1, queryR);

//合併左右區間的查詢結果

return merger。merge(leftResult, rightResult);

}

//將 index 位置的值,更新為 e

public void update(int index, E e) {

if (index < 0 || index >= data。length)

throw new IllegalArgumentException(“Index is illegal”);

data[index] = e;

//更新線段樹相應的節點

updateTree(0, 0, data。length - 1, index, e);

}

// 在以 treeIndex 為根的線段樹中,更新 index 的值為 e

private void updateTree(int treeIndex, int l, int r, int index, E e) {

//遞迴終止條件

if (l == r) {

tree[treeIndex] = e;

return;

}

int mid = l + (r - l) / 2;

int leftTreeIndex = leftChild(treeIndex);

int rightTreeIndex = rightChild(treeIndex);

if (index > mid)

updateTree(rightTreeIndex, mid + 1, r, index, e);

else //index <= mid

updateTree(leftTreeIndex, l, mid, index, e);

//更新當前節點

tree[treeIndex] = merger。merge(tree[leftTreeIndex], tree[rightTreeIndex]);

}

public String toString() {

StringBuffer res = new StringBuffer();

res。append(‘[’);

for (int i = 0; i < tree。length; i++) {

if (tree[i] != null)

res。append(tree[i]);

else res。append(“null”);

if (i != tree。length - 1)

res。append(“, ”);

}

res。append(‘]’);

return res。toString();

}

}

使用例子

定義一個求區間的最大值的線段樹,程式碼如下:

public class Main {

public static void main(String[] args) {

Integer[] nums = new Integer[]{34, 65, 8, 10, 21, 86, 79, 30};

SegmentTree segTree = new SegmentTree<>(nums, new Merger() {

@Override

public Integer merge(Integer a, Integer b) {

//返回 a 和 b 的最大值

return Math。max(a, b);

}

});

// 查詢區間 [2,5] 的最大值

System。out。println(segTree。query(4, 7));

}

}

當然,你也可以定義一個求區間內元素的和的線段樹,只需要修改

merge()

方法的實現即可:

public class Main {

public static void main(String[] args) {

Integer[] nums = new Integer[]{34, 65, 8, 10, 21, 86, 79, 30};

SegmentTree segTree = new SegmentTree<>(nums, new Merger() {

@Override

public Integer merge(Integer a, Integer b) {

//返回 a 和 b 的和

return a + b;

}

});

// 查詢區間 [2,5] 的和

System。out。println(segTree。query(4, 7));

}

}

LeetCode 上相關的題目

303. 區域檢索和-陣列不可變

題目連結:

303. 區域和檢索 - 陣列不可變

線段樹詳解與實現

線段樹求解

這道題是求取區間和,可以使用線段樹來實現。時間複雜度為

O(logn)

,空間複雜度為

O(n)

class NumArray {

private int[] tree;

private int[] data;

public NumArray(int[] nums) {

data = nums;

tree = new int[nums。length * 4];

//當陣列長度大於 0 時,才建立線段樹

if (nums。length > 0)

//建立線段樹

buildSegmentTree(0, 0, nums。length - 1);

}

//根據一個節點的索引 index,返回這個節點的左孩子的索引

private int leftChild(int index) {

return 2 * index + 1;

}

//根據一個節點的索引 index,返回這個節點的右孩子的索引

private int rightChild(int index) {

return 2 * index + 2;

}

// 在 treeIndex 的位置建立表示區間 [l,r] 的線段樹

private void buildSegmentTree(int treeIndex, int l, int r) {

//遞迴終止條件:區間長度為 1

if (l == r) {

tree[treeIndex] = data[l];

return;

}

int leftTreeIndex = leftChild(treeIndex);

int rightTreeIndex = rightChild(treeIndex);

int mid = l + (r - l) / 2;

//建立左區間(左孩子)的和

buildSegmentTree(leftTreeIndex, l, mid);

//建立右區間(右孩子)的和

buildSegmentTree(rightTreeIndex, mid + 1, r);

//合併做有區間的和

tree[treeIndex] = tree[leftTreeIndex] + tree[rightTreeIndex];

}

public int sumRange(int i, int j) {

//tree。length == 1 表示陣列沒有元素,直接返回 0

if (tree。length == 1)

return 0;

return queryRange(0, 0, data。length - 1, i, j);

}

//在以 treeIndex 為根的線段樹中 [l,r] 的範圍裡,搜尋區間 [queryL, queryR]

private int queryRange(int treeIndex, int l, int r, int queryL, int queryR) {

if (queryL == l && queryR == r)

return tree[treeIndex];

int mid = l + (r - l) / 2;

int leftTreeIndex = leftChild(treeIndex);

int rightTreeIndex = rightChild(treeIndex);

// 如果左邊界大於中間節點,則查詢右區間

if (queryL > mid)

return queryRange(rightTreeIndex, mid + 1, r, queryL, queryR);

// 如果右邊界小於等於中間節點,則查詢左區間

if (queryR <= mid)

return queryRange(leftTreeIndex, l, mid, queryL, queryR);

// 如果上述兩種情況都不是,則根據中間節點,拆分為兩個查詢區間

int leftResult = queryRange(leftTreeIndex, l, mid, queryL, mid);

int rightResult = queryRange(rightTreeIndex, mid + 1, r, mid + 1, queryR);

//合併左右區間的查詢結果

return leftResult + rightResult;

}

}

字首和求解

其實這道題有更加高效的解法,那就是

字首和

字首和的定義是:定義一個字首和陣列

sum

,每個元素

sum[i]

表示的是

nums[0。。。i]

區間中的元素的和。

線段樹詳解與實現

那麼我們要求

[i,j]

區間的和,就可以使用

sum[j]-sum[i-1]

得到。

線段樹詳解與實現

注意當

i=0

時,

i-1=-1

會溢位。因此

sums

陣列應該整體向後移動一位。

sum[0]=0

表示前面沒有元素,和應該是 0。

此時

[i,j]

區間的和應該是

sum[j+1]-sum[i]

線段樹詳解與實現

程式碼如下:

class NumArray {

//字首和陣列

private int[] sums;

public NumArray(int[] nums) {

//邊界條件判斷

if (nums == null || nums。length == 0) {

sums = new int[]{};

}

int n = nums。length;

//由於整體後移了一位,長度應該為 n+1

sums = new int[n + 1];

//構建字首和

for (int i = 0; i < n; i++) {

sums[i + 1] = sums[i] + nums[i];

}

}

public int sumRange(int i, int j) {

if (sums。length == 0)

return 0;

//直接返回字首和相減的結果

return sums[j + 1] - sums[i];

}

}

使用字首和陣列的空間複雜度依然是

O(n)

,但時間複雜度是

O(1)

,優於線段樹。

那既然這樣,區間問題為什麼還要用線段樹呢?

因為這道題目加了一個限制:

陣列不可變

,也就是說數組裡的元素是固定的。

如果陣列的內容是可變的,那麼每次更新索引

[i]

的資料,相應的

[i。。。n]

區間的字首和都需要更新。字首和陣列更新的時間時間複雜度是

O(n)

,而線段樹的更新複雜度是

O(logn)

因此,在陣列內容可變的情況下,線段樹依然是更優的選擇。

303. 區域檢索和-陣列可修改

題目連結:

307. 區域和檢索 - 陣列可修改

線段樹詳解與實現

字首和求解

根據上面字首和的做法,我們只需要新增更新資料和對應的字首和的邏輯即可。

class NumArray {

int[] sums;

int[] data;

public NumArray(int[] nums) {

//邊界條件判斷

if (nums == null || nums。length == 0) {

sums = new int[]{};

}

data = nums;

int n = nums。length;

//由於整體後移了一位,長度應該為 n+1

sums = new int[n + 1];

//構建字首和

for (int i = 0; i < n; i++) {

sums[i + 1] = sums[i] + nums[i];

}

}

public void update(int i, int val) {

// 更新陣列

data[i] = val;

//更新從 i 到 n 的字首和

for (int j = i; j < data。length; j++) {

sums[j + 1] = sums[j] + data[j];

}

}

public int sumRange(int i, int j) {

if (sums。length == 0)

return 0;

//直接返回字首和相減的結果

return sums[j + 1] - sums[i];

}

}

update()

方法的時間複雜度是

O(n)

sumRange()

方法的時間複雜度是

O(1)

線段樹求解

同理,這裡只需要在上面

303. 區域和檢索 - 陣列不可變

的線段樹解法上,新增更新的線段樹的邏輯即可。

class NumArray {

int[] tree;

int[] data;

public NumArray(int[] nums) {

data = nums;

int n = nums。length;

if (nums == null || nums。length == 0) {

tree = new int[]{};

return;

}

tree = new int[n * 4];

buildSegmentTree(0, 0, data。length - 1);

}

private void buildSegmentTree(int treeIndex, int l, int r) {

// base case:遞迴到葉子節點了

if (l == r) {

tree[treeIndex] = data[l];

return;

}

//劃分區間

int mid = l + (r - l) / 2;

int leftTreeIndex = leftChild(treeIndex);

int rightTreeIndex = rightChild(treeIndex);

// 求(左孩子)左區間的統計值

buildSegmentTree(leftTreeIndex, l, mid);

// 求(右孩子)右區間的統計值

buildSegmentTree(rightTreeIndex, mid + 1, r);

//求當前節點 [左區間+右區間] 的統計值

tree[treeIndex] = tree[leftTreeIndex] + tree[rightTreeIndex];

}

private int leftChild(int treeIndex) {

return 2 * treeIndex + 1;

}

private int rightChild(int treeIndex) {

return 2 * treeIndex + 2;

}

//將 index 位置的值,更新為 e

public void update(int i, int val) {

// 更新陣列

data[i] = val;

//更新線段樹

updateTree(0, i, 0, data。length - 1);

}

// 在以 treeIndex 為根的線段樹中,更新 index 的值為 e

private void updateTree(int treeIndex, int index, int l, int r) {

//遞迴終止條件

if (l == r) {

tree[treeIndex] = data[l];

return;

}

int mid = l + (r - l) / 2;

int leftTreeIndex = leftChild(treeIndex);

int rightTreeIndex = rightChild(treeIndex);

if (index <= mid)

updateTree(leftTreeIndex, index, l, mid);

else //index <= mid

updateTree(rightTreeIndex, index, mid + 1, r);

//更新當前節點

tree[treeIndex] = tree[leftTreeIndex] + tree[rightTreeIndex];

}

public int sumRange(int i, int j) {

if (data。length == 0)

return 0;

return queryRange(0, 0, data。length - 1, i, j);

}

private int queryRange(int treeIndex, int l, int r, int queryL, int queryR) {

if (l == queryL && r == queryR) {

return tree[treeIndex];

}

int mid = l + (r - l) / 2;

int leftTreeIndex = leftChild(treeIndex);

int rightTreeIndex = rightChild(treeIndex);

if (queryL > mid)

return queryRange(rightTreeIndex, mid + 1, r, queryL, queryR);

if (queryR <= mid)

return queryRange(leftTreeIndex, l, mid, queryL, queryR);

int leftResult = queryRange(leftTreeIndex, l, mid, queryL, mid);

int rightResult = queryRange(rightTreeIndex, mid + 1, r, mid + 1, queryR);

return leftResult + rightResult;

}

}

update()

方法和

sumRange()

方法的時間複雜度都是

O(n)

總結與擴充套件

線段樹,雖然不是滿二叉樹。但是我們卻可以把它看作一棵滿二叉樹,進而使用陣列來儲存這棵樹。

其次,線上段樹中,我們定義了每個節點,儲存的資料是這個節點對應區間的統計值(最大值、最小值、區間和)。透過這一點,你可以體會到,對於樹中的節點,你可以賦予它獨特的定義,進而可以高效地解決各種各樣的問題。因此,樹的使用範圍是非常廣泛的。

對於,線段樹的構建、更新區間、和查詢區間,這 3 個操作,都是先遞迴訪問葉子節點,然後再回溯訪問父節點,合併左右孩子區間的結果,這本質上是一種

後序遍歷

的思想。

對一個區間進行更新

在本文的例子中,每次更新都是對一個元素進行更新。現在考慮另一種更新:對某個區間內的所有元素進行更新。

比如:將

[2,5]

區間中的所有元素都加 3,就需要遍歷這個區間中的每個元素,區間更新的時間複雜度就變為了

O(n)

。為了降低區間更新的複雜度,有一種專門的方法:

懶惰更新

懶惰更新的思想是:在每次更新區間時,我們實際上先不更新實際的資料,而是使用另一個

lazy

陣列,來標記這些未更新的內容。

那麼什麼時候才會更新這些節點呢?

當我們下一次更新或者查詢到這些資料時,先查一下

lazy

陣列中是否有資料未更新,然後將未更新的內容進行更新,再訪問對應的資料。

例如:

第一次:將

[2,5]

區間中的所有元素都加 3,實際上

lazy

陣列就會標記

[2,5]

區間的資料未更新;

第二次:將

[4,7]

區間中的所有元素都減 5。這時,查詢

lazy

陣列中,發現

[4,5]

區間的資料未更新,那麼先更新

[4,5]

區間的內容,

[2,3]

區間的標記不變。然後標記

[4,7]

區間中的內容未更新。

透過懶惰更新,時間複雜度降為了

O(logn)

二維線段樹

在這篇文章中,我們處理的都是一維的線段樹,實際中還可以產生二位線段樹。

一維線段樹,就是資料都是一維陣列,每個節點記錄的區間只有左右兩個邊界。

線段樹詳解與實現

在二維線段樹中,資料是二維陣列,也就是一個矩陣,每個節點記錄的區間有上下左右兩個邊界。那麼每個節點就有 4 個孩子。

線段樹詳解與實現

進一步擴充套件,你也可以設計出 3 維的線段樹,甚至更高維的線段樹。

線段樹是一種設計思想,利用樹這種資料結構,如何把一個大的資料單元,遞迴地拆分為小的資料單元,同時,利用樹這種資料結構,可以高效地進行查詢、更新等操作。

動態線段樹

在這篇文章中,我使用了陣列這種資料結構來儲存樹,造成了空間的浪費。實際上,我們可以也使用鏈式儲存,可以更好地利用空間。

實際上,動態線段樹有一個更加重要的應用,。例如,我們要儲存 10000000 大小的線段樹,但是不會對這麼大一個區間中的每一個部分都進行訪問,可能只會對一種某個小部分進行訪問。

那麼我們可以不用一開始就建立這麼巨大的線段樹,而是隻建立一個根節點,等到實際訪問某個區間時,在根據區間的邊界,動態地建立線段樹。

線段樹詳解與實現

樹狀陣列

這篇文章講的線段樹,實際上是對區間操作的一種資料結構。與區間操作相關的資料結構,還有另外一個:樹狀陣列,英文名為

Binary Index Tree

。感興趣可以自行查閱。

如果你覺得這篇文章對你有幫助,不妨點個贊,讓我有更多動力寫出好文章。

我的文章會首發在公眾號上,歡迎掃碼關注我的公眾號

「張賢同學」

線段樹詳解與實現