線段樹(Segment Tree)
什麼是線段樹?
線段樹一些基礎概念
-
注意點
- 靜態線段樹[也就是我們的區間大小是固定的, 不會變化的] (這是我們學習的)
-
基本概念
- 線段樹每個節點表示一個區間
- 線段樹的根節點表示整個區間統計範圍, 如[1-N]
- 線段樹的每個葉子節點表示長度為1的元區間
- 線段樹的每個節點[l, r], 它的左子節點是[l, mid], 右子節點是[mid + 1, r], 其中 mid = (l + r) / 2
-
特性
- 每個區間的長度是區間內整數的個數
- 葉子節點為1, 不能再分
- 葉子節點的數目和根節點表示的區間長度相同
-
基本操作
- 區間更新
- 區間查詢
-
應用場景
- 比如給一組資料, 如: [1, 2, 3, 4], 有兩種操作, 操作1: 給第i個數加上x, 操作2: 陣列中最大的數是什麼?
上面的問題, 通過陣列可以很方便的查詢到最大值, 我們只需要遍歷這個空間[start, end]即可找出最大值。
對於更新一個數, 我們就在這個資料上加上x, 如果A[i] = A[i] + x。使用陣列實現該演算法的缺點是什麼呢?Q1: Query查詢最大值複雜度為O(n), 更新複雜度為O(1)
在有Q個query的情況下這樣總的複雜度為O(QN), 那麼對於查詢來說這樣的複雜度是不能接受的, 在沒學習線段樹之前可能沒有更好的方法進行優化。
- 下面兩個例子[區間染色, 區間查詢]
為什麼使用線段樹
對於一類問題, 我們關心的是線段(或者區間)。
比如一個經典例子: 區間染色(如圖1-1)
有一面牆, 長度為n, 每次選擇一段牆進行染色。
m次操作後, 我們可以看到多少種顏色?
m次操作後, 我們可以在[i...j]區間內看到多少種顏色?
在這裡, 染色操作就是我們的更新區間操作, 更新成了新的顏色。
我們在[i...j]這個區間檢視有多少種顏色, 這就是查詢操作。
上述問題可以通過陣列來完成, 但是更新和查詢的時間都是O(n)級別的, 顯然這個效能是不夠的。 由於我們只關注某一個區間, 此時線段樹就可以派上用場了。
[圖1-1]
另一類經典問題: 區間查詢(如圖1-2)
之前我們都是針對單個元素進行插入、刪除或者是更新操作。
但是, 我們有些時候希望對一個區間內的所有資料進行統計查詢。
查詢一個區間[i...j]的最大值, 最小值或者區間數字總和。(這就是基於一個區間的統計查詢)
如: 我們現在要統計2017年註冊使用者截止當前消費最高的使用者?消費最少的使用者?
上述問題也可以使用陣列來實現, 不過複雜度都是O(n)級別, 但是如果使用線段樹的話則是O(logn)
[圖1-2]
線段樹解決以下問題: 對於給定的區間[圖1-2]
- 更新: 更新區間中一個元素或者一個區間的值
- 查詢: 查詢一個區間[i...j]的最大值, 最小值, 或者區間數字和。
對於上面這些問題, 我們說都可以通過線段樹來現實會更加優秀快捷。 然鵝我們構建的線段樹是一個靜態的, 比如上面距離的區間染色, 我們就固定在0-15這個區間內進行染色更新, 而不會去考慮這個區間會新增(如0-20), 又比如說, 我們說2017年註冊使用者消費, 我們就設定定在2017年內固定這個區間值, 要更新也只是這個區間範圍內的值, 而不是更新這個區間的大小。
線段樹的表示
我們利用陣列來構建一個線段樹, 如圖[1-3]
[圖1-3]
以求和為例, 我們想查詢[2...5]這個區間的和, 我們需要來到A[2...3]和A[4...5]這兩個節點上, 並將這兩個節點的結果進行合併。如此, 當資料量非常大的時候我們依然可以通過線段樹非常快的找到我們關心的區間對應的一個或者多個節點進行操作, 而不需要對這個區間中所有元素進行遍歷。
線段樹形態
在上面, 我們瞭解線段樹大概是什麼樣子了, 但是從圖1-3中我們看到線段樹是一個滿二叉樹的形態, 只不過這個是最好的情況。
圖[1-4]
現在我們可以看到圖1-4中只有5個元素, 也就得出下面的結論
- 線段樹不一定是滿二叉樹
- 線段樹不是完全二叉樹
- 線段樹是平衡二叉樹(最大深度和最小深度之間差最多為1, 暫時先知道這個就可以)
線段樹空間開闢
如果區間有N個元素, 陣列表示需要多少節點?
對於一個滿二叉樹來說, 我們第0層有1個節點, 第1層有2個節點, 第2層有4個節點, 第3層有8個節點, 第h-1層有2的h次方減1的節點。
所以一顆滿二叉樹我們需要的空間是2的h次方減1, 當然直接2的h次方空間即可。 也就是說滿二叉樹中最後一層的節點數大致等於前面所有層節點之和。
有了上面的結論, 我們回到問題需要開闢多少空呢?
假設 N=8, 或者N為2的整數次冪, 只需要2N空間
但是, 不可能N都是整數次冪, 最壞的情況2的k次方加1, 2N是不夠的。如圖1-4, 這個時候我們的葉子節點就是倒數的2層裡面了。還需要在加一層, 所以需要4N的空間。
回到上面的概念, 線段樹是一個平衡二叉樹。深度不超過1。所以只會多加入一層。此時所需要的空間就是4N
上面我們也說了, 我們的線段樹是靜態的不考慮新增元素, 即區間固定。所以使用4N的靜態空間即可。
關於4N:
其實我也看了很多別人寫的4N的文章之類的, 說實話還是沒看懂, 基本上一上來就是個公式推導...讓我很尷尬...
對於4N我的總結:
- 當產生兩層葉子節點數時, 我們就需要開4N的空間
- 當產生一層葉子節點數時, 我們開2N的空間
也就是說最優的情況下開2N即可, 最差的情況下開4N, 那什麼是最優和最差呢? [參考圖1-3和圖1-4]。
但是由於基本不能確定有多少元素, 所以最終就會開4N以空間換時間。
當然了, 以上僅僅是我個人的一個理解, 不一定是對的。望指教。
線段樹實現
線段樹陣列表示
public class SegmentTree<E> {
private E[] data ; // 線段樹資料副本
private E[] tree; // 使用陣列實現線段樹
public SegmentTree(E[] arr) {
this.data = (E[]) new Object[arr.length];
for (int i = 0; i < arr.length; i++)
this.data[i] = arr[i];
// 開闢空間4倍, 形成滿二叉樹。空間換時間.
this.tree = (E[]) new Object[arr.length * 4];
}
public E get(int index) {
if (index < 0 || index >= data.length)
throw new IllegalArgumentException("請輸入正確的索引");
return this.data[index];
}
public int getSize() {
return data.length;
}
// 返回完全二叉樹的陣列表示左孩子的索引位置
public int leftChild(int index) {
return index * 2 + 1;
}
// 返回完全二叉樹的陣列表示右孩子的索引位置
public int rightChild(int index) {
return index * 2 + 2;
}
}
複製程式碼
將陣列轉換線段樹
上面我們也說了, 線段樹可以做區間的彙總, 查詢, 最大最小。那麼在構建線段樹的時候, 線段樹的節點儲存的是什麼? 葉子節點又儲存的是什麼?
- 葉子節點儲存的是實際具體的數字值
- 非葉子節點儲存什麼內容主要和我們的業務關聯比如我們是累加, 那麼非葉子節點儲存的就是l...r的總和, 如果是最大值或最小值則是l...r中最大值或者最小值資訊。
如何劃分線段樹的區間呢? 文章開頭已經說了, 不過會存在一個小問題
mid = L + (R - L) / 2
複製程式碼
之所以採用這種方法而非(l+r)/2主要是為了防止數太大, 進而出現整形溢位。
現在我們已經清楚的知道如何劃分線段樹區間, 以及通過線段樹陣列表示程式碼獲取到其左右孩子資訊。
接下來我們就看看如何通過遞迴的方式構建線段樹把。
public interface Merger<E> {
E merge(E a, E b) ;
}
複製程式碼
利用該介面, 可以動態實現線段樹最大|最小|聚合操作等。
private Merger<E> merger;
// ++ 這裡只有新增出來的部分
public SegmentTree(E[] arr, Merger<E> merger) {
this.merger = merger;
// 將陣列構建成線段樹
// 初始化從下標0開始, 區間從0到陣列末尾
buildSegmentTree(0, 0, this.data.length -1);
}
// treeIndex的位置建立區間[l...r]的值
private void buildSegmentTree(int treeIndex, int l, int r) {
// 如果陣列長度只有1的話, 直接賦值退出
if (l == r) {
this.tree[treeIndex] = this.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);
// 如果我們是區間累加就將左右孩子值相加儲存即可
// 然鵝直接累加是不行的, E是不知道滴。
// 不過如果直接進行相加了, 那麼我們想要做最大|最小或者其它操作豈不是沒法用了嗎?
// 我們建立一個介面, 用來實現我們我們想要的操作。
// this.tree[treeIndex] = this.tree[leftChildIndex] + this.tree[rightChildIndex];
this.tree[treeIndex] = merger.merge(this.tree[leftTreeIndex], this.tree[rightTreeIndex]);
}
@Override
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();
}
複製程式碼
線段樹查詢
圖[1-6]這個線段樹圖片中, 如果我們要查詢[2, 5]這個區間的的資訊。
如果我們的Merger實現的累加操作, 則就是查詢[2, 5]這個區間的總和。
如何查詢呢? 從什麼位置上開始查詢呢? 當然還是從我們的根節點上開始查詢。
-
根節點包含的是[0...7]這個區間相應的資訊, [2, 5]顯然是[0...7]區間的一個子集。相應的要向下從這個根節點的左右子樹種查詢。對於線段樹來說每一個節點他的左右子樹都是從中間分隔開的, 所以我們是知道這個分隔的位置的。所以對於根節點來說, 他的左孩子是[0...3]區間的內容, 右孩子是[4...7]區間的內容。
-
對於[2, 5]這個區間, 它有一部分落在[0...3]區間中, 另外一部分落在[4...7]區間中。所以我們要到根節點左右兩個節點查詢。具體是在左節點中查詢[2...3]這個子區間, 右節點查詢[4...5]這個區間。可以看到我們將[2, 5]拆成了兩部分[2...3]和[4...5]兩個子區間。分別到根節點左右兩個孩子中去查詢。
-
我們從[0...3]這個區間查詢[2, 3]這個子區間。我們知道[0...3]這個區間左孩子是[0...1]區間右孩子是[2...3]的區間, 由於[0...1]這個區間和我們查詢的區間沒有任何關係, 所以我們繼續在[0...3]的右孩子[2, 3]繼續查詢。同理, 在查詢[4...7]這個節點中查詢[4, 5]這個區間, 它的左孩子包含[4, 5]這個區間, 右孩子包含[6, 7]這個區間。所以相應的我們要查詢的[4, 5]區間和[6, 7]完全沒有重疊, 所以到[4...7]的左孩子查詢相應的資料就可以了。
-
當然, 在查詢到[2, 3]這個結果和[4, 5]這個結果的值時, 我們並不需要遍歷到葉子節點, 直接返回[2, 3]和[4, 5]的值。但是由於是兩個不同節點返回來的, 我們還需要對返回的結果進行組合。返回[2, 5]這個區間所對應的的結果。
-
可以看到我們並不需要從頭到尾遍歷我們要查詢[2, 5]的元素。我們只需要從我們根節點向下去找相應的子區間。在把我們查詢到的子區間綜合起來。查詢的時候是和我們樹的高度相關。而和我們查詢區間的長度是無關的。正因為如此, 我們線段樹是logn級別的查詢也是logn級別的。
public E query(int queryL, int queryR) {
if (queryL < 0 || queryL >= data.length ||
queryR < 0 || queryR >= data.length || queryL > queryR)
throw new IllegalArgumentException("請正確輸入區間值");
return query(0, 0, data.length -1, 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 + 1)
return query(rightTreeIndex, mid + 1, r, queryL, queryR);
else if (queryR <= mid) // 要查詢的區間在左子樹中
return query(leftTreeIndex, l, mid, queryL, queryR);
// 當要查詢的區間, 在左右子樹中
E leftQueryResult = query(leftTreeIndex, l, mid, queryL, mid);
E rightQueryResult = query(rightTreeIndex, mid + 1, r, mid + 1, queryR);
return merger.merge(leftQueryResult, rightQueryResult);
}
複製程式碼
圖[1-6]展示如何遞迴查詢區間值。
[圖1-6]
線段樹更新
基本操作中, 我們已經學習區間查詢, 但是區間如何更新呢? 更新需要注意哪些點呢?
線段樹儲存的是一個區間的值, 所以當我們更新葉子節點內容後, 還需要一級一級的往上推, 更新父節點的值。
所以, 更新操作分為兩部分:
- 查詢到對應的索引並更新為新資料
- 更新資料後, 依次更新父節點的值
public void set(int index, E e) {
if (index < 0 || index >= data.length)
throw new IllegalArgumentException("請正確輸入索引位置.");
data[index] = e;
// 初始化從根節點開始, 查詢到對應的葉子節點更新
set(0, 0, data.length - 1, index, e);
}
private void set(int treeIndex, int l, int r, int index, E e) {
if (l == r) {
tree[treeIndex] = e;
return ;
}
int mid = l + (r - l) / 2;
int leftIndexTree = leftChild(treeIndex);
int rightIndexTree = rightChild(treeIndex);
// 如果索引位置大於中間值, 則一定在樹的右側, 否則在樹的左側。
if (index >= mid + 1)
set(rightIndexTree, mid + 1, r, index, e);
else
set(leftIndexTree, l, mid, index, e);
// 最後不要忘記更新父節點的值
tree[treeIndex] = merger.merge(tree[leftIndexTree], tree[rightIndexTree]);
}
複製程式碼