1. 程式人生 > >線段樹的擴充套件之淺談zkw線段樹

線段樹的擴充套件之淺談zkw線段樹

線段樹的擴充套件之淺談zkw線段樹

轉自:https://khong-biet.blog.luogu.org/Introduction-of-zkwSegmentTree


2018-08-07 upd:

  1. 更新了線段樹測試(聽說資料加強了,所以把老記錄換掉)
  2. 更新了圖片
  3. 修改了一些文字

2018-08-08 upd:

  1. 補全了線段樹測試(順便加了樹狀陣列)
  2. 修改了一些文字
  3. 填坑了

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]
}

大家可以和遞迴版線段樹做一下對比

有細心的讀者可能發現了:上例計算出的N16而不是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指向的節點的父節點相同時停止

在這期間,如果:

  1. s指向的節點是左兒子,那麼ans += 右兒子的值

  2. 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∑⌊log2​i⌋​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 主要參考資料