線段樹的擴充套件之淺談zkw線段樹
線段樹的擴充套件之淺談zkw線段樹
轉自:https://khong-biet.blog.luogu.org/Introduction-of-zkwSegmentTree
2018-08-07 upd:
- 更新了線段樹測試(聽說資料加強了,所以把老記錄換掉)
- 更新了圖片
- 修改了一些文字
2018-08-08 upd:
- 補全了線段樹測試(順便加了樹狀陣列)
- 修改了一些文字
- 填坑了
0 閱讀本文前請先閱讀:
本文主要是上面文章的延伸,所以上文有講的東西本文就不詳細講了QwQ
筆者的測試程式碼可能寫醜了,所以如果慢請自行卡常QwQ
這裡還是以區間求和(RSQ)為例
1 zkw線段樹簡介
什麼是zkw線段樹?
簡單來說,就是非遞迴式線段樹
眾所周知,遞迴式線段樹的常數很大,經常被卡,而zkw線段樹的常數很小
這裡用洛谷P3372做一個演示(更詳細的補充見文末)
遞迴式線段樹R9389075
zkw線段樹R9388963
前者執行時間是後者執行時間的2.05倍!Σ(°Д°;
測試用程式碼變數全部是unsigned long long型別的,如果按需調整速度還會更快
更詳細的測試見3
其實zkw線段樹不僅快,而且碼量小(遞迴1.8KB,zkw1.48KB)、佔用空間小(遞迴6.31MB,zkw4.94MB)、好除錯吊打遞迴式線段樹orz
而遞迴式線段樹的優點則是方便理解與學習,並且適用範圍更廣一些(zkw線段樹不能處理有運算優先順序的問題(加法乘法混合處理)例如洛谷P3373 )
2 zkw線段樹的實現
我們觀察一下遞迴式線段樹的程式碼,很容易就會發現:無論是建樹、修改還是查詢,都是自頂向下的。
zkw線段樹則正好反過來,即自底向上
具體來說,就是先把線段樹填充成滿二叉樹(堆式儲存),之後就可以直接找到葉節點,然後回溯上去了
聽起來好像很簡單QwQ
其實真的很簡單QwQ
2.1 先來看看怎麼建樹
首先是定義:
#define MAXN 200005 int tree[MAXN<<2]; //tree是線段樹陣列 int n, N=1; //n是原陣列實際長度,N下面會解釋
我們以下圖為例
(由visualgo生成為了便於講解,筆者做了一些改動QwQ)
如圖,下面的黃圈是原資料,黃圈下面的紅色數字是原陣列的下標
上面的樹就是線段樹了,每一個節點內部都是節點下方標明的區間中所有元素的總和,上邊的黑色數字就是線段樹的下標
visualgo生成的陣列下標預設是從0開始的,所以線段樹下的區間和原陣列有錯位,請注意區分(筆者懶得改了
通過觀察,我們發現一個規律:線段樹對應葉子節點的下標和原陣列的下標的差值是恆定的(8-1=9-2=...=15-8=78−1=9−2=...=15−8=7)
這個差值就是一個和N
很接近的數了(N
是葉子節點數)
實際上,
N=2^{\lceil\log_2{(n+1)}\rceil}N=2⌈log2(n+1)⌉
根據這一點,我們可以這樣建樹:
#define fp(i,l,r) for(register int i=(l);i<=(r);++i)
#define fd(i,r,l) for(register int i=(r);i>=(l);--i)
#define il inline
//這個根據自己習慣調整,筆者習慣這麼寫了QwQ
il void build() {
scanf("%d", &n);
for(; N <= n+1; N <<= 1);
//這個N當然可以直接算,不過為了算一個數就加一個#include<cmath>有點不划算
fp(i, N+1, N+n) scanf("%d", tree+i);
//這個等價於scanf("%d", &tree[i])
fd(i, N-1, 1) tree[i] = tree[i << 1] + tree[i << 1 | 1];
//這個等價於tree[i] = tree[i*2] + tree[i*2 + 1]
}
大家可以和遞迴版線段樹做一下對比
有細心的讀者可能發現了:上例計算出的N
是16
而不是8
!
還有,原陣列線上段樹對應的為止整體向後平移了1位!
其實這都是為了方便查詢
後面再詳細解釋
2.2 接下來需要分成兩個版本(單點修改+區間查詢 和 區間修改+區間查詢)
2.2.1 先說說單點修改+區間查詢吧(Easy)
2.2.1.1 看看單點修改
實現很簡單,所以直接放程式碼
il void modify(int x, int k) {
for(x += N; x; x >>= 1) tree[x] += k;
}
完了?Σ(°Д°;
完了!
單點查詢更簡單,相信各位讀者都能想到QwQ
2.2.1.2 再看看單點修改下的區間查詢
我們以查詢[2,6]
為例(線段樹上的,下同)
ans=\color{#b5e61d}[2,2]+[3,3]+[4,4]+[5,5]+[6,6]ans=[2,2]+[3,3]+[4,4]+[5,5]+[6,6]
觀察上圖可以發現,因為線上段樹上我們可以直接找到\color{#00a2e8}[2,3][2,3]和\color{#00a2e8}[4,5][4,5],所以我們只需要用\color{#00a2e8}[2,3][2,3]代替\color{#b5e61d}[2,2][2,2]和\color{#b5e61d}[3,3][3,3];用\color{#00a2e8}[4,5][4,5]代替\color{#b5e61d}[4,4][4,4]和\color{#b5e61d}[5,5][5,5]
於是
ans=\color{#00a2e8}[2,3]+[4,5]\color{#b5e61d}+[6,6]ans=[2,3]+[4,5]+[6,6]
自頂向下求和很簡單,怎麼實現自底向上的求和呢?
我們分別在區間左端點-1和右端點+1的位置放兩個指標(令其為s,t
),就像這樣:
接著不斷將s,t
移動到對應節點的父節點處,直到s,t
指向的節點的父節點相同時停止
在這期間,如果:
-
s
指向的節點是左兒子,那麼ans += 右兒子的值
-
t
指向的節點是右兒子,那麼ans += 左兒子的值
如果不能理解就看看上圖,多看幾遍就懂了QwQ
下面是程式碼
il int query(int s, int t) {
int ans = 0;
for(s = N + s - 1, r = N + r + 1; s ^ r ^ 1; s >>= 1, r >>= 1) {
//這個for包含的資訊量好像有點大,不過不要緊
//第一個分號前面就是將s和t初始化
//s ^ r ^ 1就是判斷對應節點的父節點是否相同
//很容易看出來當對應節點互為左右兒子時,s^t = 1,再^1之後就是0
//而其他情況時,s^t大於1,^1後當然不是0
//第二個分號後面就是s,t上移
if(~s&1) ans += tree[s^1];
if(r&1) ans += tree[r^1];
//這兩句的含義對照上面的實現過程看就能明白
}
return ans;
}
上面的那兩個疑問現在可以解釋了
仔細觀察上述流程可以發現:我們只能查詢[1,n-1]
範圍(這裡還是線段樹上標的)內的資料
如果我們想要查詢[0,m]
範圍內(0\leq m\leq n0≤m≤n)的呢?
將陣列整體平移!
如果我們想要查詢[m,n]
範圍內(0\leq m\leq n0≤m≤n)的呢?
把N
直接擴大2倍!
zkw:就是這麼狠
到目前為止zkw線段樹還是比較簡短的
可能有人覺得這個和樹狀陣列有點像,這就對了
zkw:樹狀陣列究竟是什麼?就是省掉一半空間後的線段樹加上中序遍歷
orz
單點修改+區間查詢完結,整理一下程式碼:
//單點修改+區間查詢
#include<cstdio>
#define MAXN 200005
#define fp(i,l,r) for(register int i=(l);i<=(r);++i)
#define fd(i,r,l) for(register int i=(r);i>=(l);--i)
#define il inline
int tree[MAXN<<2];
int n, N=1;
il void build() {
scanf("%d", &n);
for(; N <= n+1; N <<= 1);
fp(i, N+1, N+n) scanf("%d", tree+i);
fd(i, N-1, 1) tree[i] = tree[i << 1] + tree[i << 1 | 1];
}
il void modify(int x, int k) {
for(x += N; x; x >>= 1) tree[x] += k;
}
il int query(int s, int t) {
int ans = 0;
for(s = N + s - 1, r = N + r + 1; s ^ r ^ 1; s >>= 1, r >>= 1) {
if(~s&1) ans += tree[s^1];
if(r&1) ans += tree[r^1];
}
return ans;
}
int main() {
//...自己按需補充吧
}
2.2.2 區間修改+區間查詢(A little bit hard)
2.2.2.1 區間修改——噩夢的開始
很顯然,我們不能用上面的方法暴力修改(還不如遞迴式線段樹)
其實堆式儲存也可以自頂向下訪問
就是上下各走一次而已
但是我們有更好的辦法 zkw:使勁想想就知道了
這裡我們採用標記永久化的思想(就是不下推lazy tag讓他徹底lazy下去QwQ)
int add[MAXN<<2]; //這個lazy tag表示當前節點已經更新完,需要更新子節點
我們需要在自底向上時更新節點的值,所以我們還需要一個變數記錄該節點包含元素的個數
另外要注意修改某個節點的標記時要更新上面的值
舉個例子;我們換一棵樹
以修改[2,10]
為例
當s
到了[2,2]
節點時,[3,3]
節點的add加k
,那麼接下來[2,3]
、[0,3]
節點的值都要加上k*1
,而到了[0,7]
節點時,因為[4,7]
節點的add加了k
,所以[0,7]
節點的值要加上k*(1+4)=k*5
,自然k
要乘的係數又需要一個變數來記錄
需要注意的是,這次的修改要上推到根節點
下面是程式碼
il void update(int s, int t, int k) {
int lNum=0, rNum=0, nNum=1;
//lNum: s一路走來已經包含了幾個數
//rNum: t一路走來已經包含了幾個數
//nNum: 本層每個節點包含幾個數
for(s = N+s-1, t = N+t+1; s^t^1; s >>= 1, t >>= 1, nNum <<= 1) {
//更新tree
tree[s] += k*lNum;
tree[t] += k*rNum;
//處理add
if(~s&1) {add[s^1] += k; tree[s^1] += k*nNum; lNum += nNum;}
if(t&1) {add[t^1] += k; tree[t^1] += k*nNum; rNum += nNum;}
}
//更新上層tree
for(; s; s >>= 1, t >>= 1) {
tree[s] += k*lNum;
tree[t] += k*rNum;
}
}
2.2.2.2 區間查詢
我們以查詢[2,10]
為例沒錯筆者我就是用一張圖
過程類似,要注意s,t
每次上推時都要根據當前所在節點的標記和lNum / rNum
更新ans
(ans += add[s]*lNum
)
可能有些難懂,多讀兩遍或者看看程式碼或者自己手推一下就好了QwQ
同樣,這個也需要上推到根節點
il int query(int s, int t){
int lNum=0, rNum=0, nNum=1;
int ans=0;
for(s = N+s-1, t = N+t+1; s^t^1; s >>= 1, t >>= 1, nNum <<= 1) {
//根據標記更新
if(add[s]) ans += add[s]*lNum;
if(add[t]) ans += add[t]*rNum;
//常規求和
if(~s&1) {ans += tree[s^1]; lNum += nNum;}
if(t&1) {ans += tree[t^1]; rNum += nNum;}
}
//處理上層標記
for(; s; s >>= 1, t >>= 1) {
ans += add[s]*lNum;
ans += add[t]*rNum;
}
return ans;
}
區間修改+區間查詢到這裡先告一段落,整理一下程式碼:
//區間修改+區間查詢1
#include<cstdio>
#define MAXN 200005
#define fp(i,l,r) for(register int i=(l);i<=(r);++i)
#define fd(i,r,l) for(register int i=(r);i>=(l);--i)
#define il inline
int tree[MAXN<<2], add[MAXN<<2];
int n, N=1;
il void build() {
scanf("%d", &n);
for(; N <= n+1; N <<= 1);
fp(i, N+1, N+n) scanf("%d", tree+i);
fd(i, N-1, 1) tree[i] = tree[i << 1] + tree[i << 1 | 1];
}
il void update(int s, int t, int k) {
int lNum=0, rNum=0, nNum=1;
for(s = N+s-1, t = N+t+1; s^t^1; s >>= 1, t >>= 1, nNum <<= 1) {
tree[s] += k*lNum;
tree[t] += k*rNum;
if(~s&1) {add[s^1] += k; tree[s^1] += k*nNum; lNum += nNum;}
if(t&1) {add[t^1] += k; tree[t^1] += k*nNum; rNum += nNum;}
}
for(; s; s >>= 1, t >>= 1) {
tree[s] += k*lNum;
tree[t] += k*rNum;
}
}
il int query(int s, int t){
int lNum=0, rNum=0, nNum=1;
int ans=0;
for(s = N+s-1, t = N+t+1; s^t^1; s >>= 1, t >>= 1, nNum <<= 1) {
if(add[s]) ans += add[s]*lNum;
if(add[t]) ans += add[t]*rNum;
if(~s&1) {ans += tree[s^1]; lNum += nNum;}
if(t&1) {ans += tree[t^1]; rNum += nNum;}
}
for(; s; s >>= 1, t >>= 1) {
ans += add[s]*lNum;
ans += add[t]*rNum;
}
return ans;
}
int main() {
//還是按需編寫
}
2.2.3 對區間修改+區間查詢進行空間優化(A Little hard)
也許有的讀者發現了:標記和值好像可以看成一個東西
所以,我們可不可以不存值,只存標記?
當然可以!
zkw:永久化的標記就是值!
zkw:狗拿耗子,貓下崗了
那麼,怎麼實現呢?
下面是區間最值(RMQ)版本的(以最小值為例)
在這裡,我們不存總和了,存
tree[i]=sum[i]-sum[i>>1] //sum[i]對應上述兩個版本程式碼中的tree[i]
(即為子節點-父節點)區間修改就直接改
tree[i]
查詢就從當前節點一直加到根(
tree[i]+tree[i>>1]+...+tree[1]
)
或者數學一點
\displaystyle\sum_{\text{j}=0}^{\lfloor\log_2\text{i}\rfloor}\text{tree[i>>j]}j=0∑⌊log2i⌋tree[i>>j]
(修改時的
s,t
)遇到節點x
,則
A=min(tree[x>>1],tree[x>>1|1]), tree[x]+=A, tree[x>>1]-=A, tree[x>>1|1]-=A
//這一步可能有一些難懂,就是修改了一個區間,可能會導致父節點儲存的最值(普通情況下)發生改變,所以用這一步來修正
為什麼筆者沒有放區間求和(RSQ)版本的呢?
因為筆者發現區間求和版本的依然要維護兩棵樹(一棵存tree[i]-tree[i-1]
,另一棵存i*(tree[i]-tree[i-1])
,類似樹狀陣列),也就是沒有優化(可能是筆者太弱了,沒有想到別的方法)
當然,這個版本也是可以單點修改/單點查詢的,不過沒有上述程式碼實用,所以這裡就不討論了
直接放程式碼
void build() {
for(N=1;N<=n+1;N<<=1);
fp(i,N+1,N+n) scanf("%d",tree+i);
fd(i,N-1,1) {
tree[i]=min(tree[i<<1],tree[i<<1|1]);
tree[i<<1]-=tree[i]; tree[i<<1|1]-=tree[i];
}
}
void update(int s, int t, int k) {
int tmp;
for(s += N-1, t += N+1; s^t^1; s>>=1, t>>=1) {
if(~s&1) tree[s^1]+=k;
if(t&1) tree[t^1]+=k;
tmp = min(tree[s], tree[s^1]);
tree[s] -= tmp; tree[s^1] -= tmp; tree[s>>1] += tmp;
tmp = min(tree[t], tree[t^1]);
tree[t] -= tmp; tree[t^1] -= tmp; tree[t>>1] += tmp;
}
for (;s!=1;s>>=1) { //記得要上推到根節點
tmp = min(tree[s],tree[s^1]);
tree[s] -= tmp; tree[s^1] -= tmp; tree[s>>1] += tmp;
}
}
int query(int s, int t) { //閉區間
int sAns = 0, tAns = 0;
s+=N, t+=N;
if(s != t) { //防止查詢單點時死迴圈
for(; s^t^1; s>>=1, t>>=1) {
sAns += tree[s]; tAns += tree[t];
if(~s&1) sAns = min(sAns, tree[s^1]);
if(t&1) tAns = min(tAns, tree[t^1]);
}
}
int ans = min(sAns+tree[s], tAns+tree[t]);
while(s > 1) ans += tree[s>>=1];
return ans;
}
3 大資料測試
當然,說好的大資料測試可不能忘
先來看一看參賽選手:
1號:遞迴線段樹
2號:zkw線段樹(非差分版本,差分版本的常數略大,就不測了)
3號:樹狀陣列
zkw線段樹:說好的我的主場呢?
先以洛谷P3372做一個熱身
因為圖太多,所以不貼出來了,有興趣的讀者可以檢視提交記錄
讀入優化
1號:遞迴線段樹 412ms / 6.31MB (R9424058)
2號:zkw線段樹 208ms / 4.74MB (R9424567)
3號:樹狀陣列 196ms / 3.71MB (R9424624)
讀入優化+O2
1號:遞迴線段樹 220ms / 6.21MB (R9424921)
2號:zkw線段樹 160ms / 4.86MB (R9424805)
3號:樹狀陣列 96ms / 3.74MB (R9424762)
可以看到,沒有O2時2號和3號相差無幾,有了O2之後3號吊打全場可能是筆者寫的zkw線段樹常數太大QwQ
為了防止zkw線段樹被吊打得太慘反應演算法真實水平以及模擬NOIp競賽環境,下面就不開O2了
在這裡先放一下結果,測試程式碼和大資料放在另一篇文章裡
保證所有輸入資料在unsigned long long 範圍內,結果對2^{64}264取模,表格中的時間為平均值
測試環境:
系統:noilinux-1.4.1(當然是虛擬機器啦)
記憶體:2GB
CPU:AMD Athlon(tm) II X4 631 Quad-Core Processor 2600 MHz(就用了一個核)
請不要吐槽這個渣配置QwQ(話說這個配置和CCF老爺機的配置應該差不多吧)
順便吐槽那個系統自帶的辣雞評測軟體,一評測就閃退
測試#1:
資料規模 | 遞迴線段樹(ms) | zkw線段樹(ms) | 樹狀陣列(ms) |
---|---|---|---|
5e5(5組) | 3554.359 | 2067.978 | 1968.074 |
1e6(5組) | 7327.344 | 4922.725 | 4359.272 |
5e6(3組) | 49416.196 | 34078.837 | 26782.107 |
1e7(3組) | 126192.820 | 74198.015 | 57485.430 |
測試#2(稍微卡卡常):
資料規模 | 遞迴線段樹(ms) | zkw線段樹(ms) | 樹狀陣列(ms) |
---|---|---|---|
5e5(5組) | 3985.435 | 2085.221 | 1981.154 |
1e6(5組) | 6995.611 | 4268.988 | 3991.724 |
5e6(3組) | 45401.981 | 29582.957 | 25179.336 |
1e7(3組) | 99805.488 | 67543.985 | 54304.283 |
耗時:\text{樹狀陣列}\thickapprox\text{zkw線段樹}<\text{遞迴線段樹}樹狀陣列≈zkw線段樹<遞迴線段樹
程式碼長度:\text{樹狀陣列(1.57 KB)}<\text{zkw線段樹(2.20 KB)}<\text{遞迴線段樹(2.47 KB)}樹狀陣列(1.57 KB)<zkw線段樹(2.20 KB)<遞迴線段樹(2.47 KB) (當然這個參考意義不大)
結論:不考慮有運算優先順序的情況下,樹狀陣列吊打全場(zkw線段樹哭暈在廁所
4 後記
這篇文章筆者寫了將近一天整整三天。通過寫這篇文章,筆者對zkw線段樹的理解更加深刻了順便還學到了差分這個騷操作,並讓樹狀陣列吊打全場
當然,因為筆者是個蒟蒻,所以這篇文章難免會有錯誤,在此希望各位dalao批評的時候別把筆者噴得太慘QwQ
另外,zkw julao在他的ppt中還講了許多高階操作,希望各位有興趣讀者能夠看一看膜拜orz
關於線段樹,還可以擴展出很多東西,比如多維線段樹、多叉線段樹、可持久化線段樹……不過因為筆者是個蒟蒻,所以這些就先不寫了
5 主要參考資料
-
統計的力量——線段樹全接觸(膜拜zkw julao)