線段樹 (Segment Tree) 和樹狀數組是兩種常用的數據結構。他們用來維護一個區間內的操作,可以在 \(logN\) 的復雜度上進行查詢和修改。
線段樹可以維護對一個區間的查詢和修改,可以對區間進行分塊查詢,而樹狀數組是線段樹的閹割版,經常用來區間查詢,但修改只能進行單點修改,經過改造之后可以區間修改,區間樹本身就可以支持區間修改。使用樹狀數組的原因是因為樹狀數組比較好寫。
兩個數據結構的樣子
樹狀數組:
區間樹:
可以看到區間樹是父親節點維護子節點的信息,到了葉子結點才是具體的某一個值。
樹狀數組的構建則很獨特,他的結點維護信息的數量是由其結點轉化成二進制之后最左邊的1的個數之后的零的個數決定的。
c8 = c4 + c6 + c7 + a8
c4 = c2 + c3 + a4
c6 = c5 + a6
c7 = a7
c2 = c1 + a2
c3 = a3
c5 = a5
c1 = a1
而
\(8_{10} = 1000_{2}\) \(6_{10} = {110}_2\)
所以我們可以看到,\(c8\) 結點維護了 \(2^3\) 個結點信息,\(c_6\) 結點維護了 \(2^1\) 個結點信息。
樹狀數組的操作
構建
樹狀數組不需用進行構建,可以把樹的構建當作是樹的修改。
修改操作(單點修改)
樹狀數組的修改即修改維護這個節點信息的所有節點,更新與這個節點有關的所有節點即完成了節點的修改。
那么問題變成了如何尋找到與這個節點有關的上一個節點,只要找到上一個結點,那么我們就可以進行遞歸修改,從而完成對所有相關節點的修改。
而結點上尋可以看成對原來的數以二進制的形式加上除去除了最低位的 1 以外的二進制數后得到的結果。
若 \(a_1a_2a_3...a_i..a_j\) 為原來數的二進制表達形式,\(a_i\) 為最低位的1,那么其上尋節點應該為\(a_1a_2a_3...a_i..a_j + a_i..a_j\)。
比如從圖中我們可以看到,\(c4\)的上一個結點是\(c8\),根據這個上尋規則 \(100_2\) 加上 \(100_2\) 得到\(1000_2\)。
\(c6\) 的上一個結點是\(c8\),根據上尋規則 \(110_2\) 加上 \(10_2\),得到\(1000_2\)。
\(c7\) 的上一個結點是\(c8\),根據規則 \(111_2\) 加上 \(1_2\) 得到\(1000_2\)。
那么問題就變成了如何保留某一個數的最低位的1,把其余所有的1去除。
這里,我們可以引入一個函數 lowbit(x)
int lowbit(x) {
return x & (-x)
}
這個函數的作用可以只保留 x 的最低位的1,將其高位的1去除,具體原因是計算機中的數據存儲按照補碼規則進行存儲,可以推出。
比如
\(lowbit(4) = lowbit((100)_2) = 100_2 = 4_{10}\)
\(lowbit(5) = lowbit((101)_2) = 1_2\)
\(lowbit(6) = lowbit((110)_2) = 10_2 = 2_{10}\)
這樣,我們就完成了整個的修改操作
int add(int x, int k){
while(x <= n){
tree[x] += k;
x += lowbit(x);
}
}
這里的 n 表示整個數組的長度。
查詢操作 區間查詢
樹狀數組的父親節點維護的信息是一段前綴和的信息,如果需要進行區間查詢,那么利用前綴和也可以求出區間查詢的結果。
查詢操作和修改操作相反,我們需要不斷查詢子節點,直到子節點為0。
如何求解子節點可以看成如何查找父親節點的逆操作,父親節點為加上lowbit值,那么子節點即為減去lowbit的值。
int sum(int x){
int ans = 0;
while (x != 0) {
ans += tree[x]
x -= lowbit(x);
}
return ans;
}
區間修改+單點查詢
區間修改的操作,可以把樹狀數組維護的前i項和看成第i個數,那么對 \([x,y]\) 的區間修改,可以看成對第x個位置和第 y+1 的位置進行修改。
這樣之后進行單點查詢,即詢問某個位置的值。如果查詢范圍在 \([0, x]\) 之間 或 \([y, +\infty]\),即為原來的數。如果查詢范圍在 \([x, y]\) 之間,因為對第x個位置進行了更改,所以前綴和之后即可滿足條件。
區間樹的操作
構建
區間樹需要我們進行建樹操作,我們可以觀察一下區間樹的構成。
可以看到區間樹的父親節點維護的區間是左右兒子區間的並集,左右兒子節點的划分是父親節點的中間元素,所以,我們可以采用這種方式進行遞歸建樹。區間樹的信息是由兩個子節點的信息決定的,所以,我們需要在兩個子節點建好之后,維護父親節點的信息。
先定義兩個函數,獲取父親節點的兩個兒子節點
int leftChild(int p){
return p << 1;
}
int rightChild(int p) {
return p << 1 + 1;
}
void build(int p, int left, int right) {
if (left == right) {
ans[p] = a[left];
return ;
}
int mid = (left + right) / 2;
build(leftChild(p), left, mid);
build(rightChild(p), mid+1, right);
push_up(p);
}
上面的 push_up()
操作是用來維護父親節點信息,這個信息可以是由兩個子區間決定的區間和,區間最值等信息。但這個信息必須滿足結合律。這里我們使用維護區間和。
void push_up(int p) {
ans[p] = ans[leftChild(p)] + ans[rightChild(p)];
return ;
}
區間修改操作
對於線段樹而言,單點修改和區間修改沒有什么具體的差別,無非是區間長度不一樣而已,對於線段樹,我們可以引入懶標記的操作,沒有懶標記之前,我們需要進行區間維護需要先遞歸到葉子節點,然后向上依次維護父親節點。而懶標記的意義在於他不是更新到每一個具體的葉子節點,而是先記錄在部分區間的公共父親節點上。然后需要更新的時候再更新。需要更新的時機主要是在什么時候會用到子節點信息,如果需要用到子節點信息,那么我們需要進行將 lazy tag
進行下放,保證了子節點信息的一致。
所以,如果當前區間已經被更新區間完全覆蓋,那么我們不用對這個區間繼續深入到各個子節點,可以直接在父親節點完成對區間維護,如果當前區間被更新區間部分覆蓋,那么我們就對父親節點的兩個子節點進行部分維護即可,在維護兩個子節點的時候,因為父親節點的 lazy tag
記錄着上次的更新信息,所以,我們需要將父親節點的 lazy tag
下降到兩個子節點,更新兩個子節點的信息后,才能對兩個子節點進行這次的更新操作。不然,可能會出現數據不一致問題。
void push_down(int p, int left, int right) {
int mid = (left + right) >> 1;
tag[leftChild(p)] = tag[leftChild(p)] + tag[p];
ans[leftChild(p)] = ans[leftChild(p)] + tag[p] * (mid - left + 1);
tag[rightChild(p)] = tag[rightChild(p)] + tag[p];
ans[rightChild(p)] = ans[rightChild(p)] + tag[p] * (right - (mid + 1) +1);
//將父親節點的 lazy tag 下降后,父親節點的 lazy tag 清零
tag[p] = 0;
}
void update(int updateL, int updateR, int left, int right, int p, int k) {
//updateL, updateR 表示更新區間的范圍
//left, right,p 表示當前區間的范圍
//k 表示更新的值
if (updateL <= left && updateR >= right) {
//更新的區間完全覆蓋了當前區間
//可以直接使用這個區間的懶標記
ans[p] += k*(left - right + 1);
tag[p] += k;
return;
}
//此時被更新的區間部分覆蓋當前區間
push_down(p, l, r); // 因為要對子節點進行更新,所以把當前的父親節點的 lazy tag 向下進行傳遞
int mid = (left + right) / 2;
if (updateL <= mid){
//更新的區間部分覆蓋左兒子區間
update(updateL, updateR, left, mid, leftChild(p), k);
}
if (updateR > mid) {
//更新的區間部分覆蓋右兒子區間
update(updateL, updateR, mid + 1, childChild(p), k);
}
//重新維護父親節點
push_up(p);
}
區間查詢
區間查詢就是對指定的區間進行查詢,如果指定的區間完全覆蓋了當前父親節點的區間,就可以直接返回父親節點的信息,避免進一步的查詢。而如果查詢區間部分覆蓋了當前父親節點,那么我們需要查詢的就是子節點信息,需要把父親節點的 lazy tag
進行下放,更新子節點的信息。然后進行子節點查詢
int query(int queryLeft, int queryRight, int p, int left, int right) {
int res = 0;
if (queryLeft <= left && queryRight >= right) {
return ans[p];
}
int mid = (left + mid) / 2;
push_down(p, left, right);
if (queryLeft <= mid)
//需要查詢左兒子節點
res += query(queryLeft, queryRight, leftChild(p), left, mid);
if (queryRight > mid)
//需要查詢右兒子節點
res += query(queryLeft, queryRight, rightChild(p), mid+1, right);
return res;
}
以上就是樹狀數組和線段樹的兩個操作,他們都可以在 \(NlogN\) 的時間下完成區間的查詢。