zkw 線段樹-原理及其擴充套件

Tmbcan發表於2024-11-15

前言

許多演算法的本質是統計。線段樹用於統計,是溝通原陣列與字首和的橋樑。

《統計的力量》清華大學-張昆瑋

關於線段樹

前置知識:線段樹 OIWiki

線段樹是一種專門維護區間問題的資料結構。
線段樹對資訊進行二進位制化處理並在樹形結構上維護,以此讓處理速度達到 \(O(\log{n})\) 級別。

線段樹的實現方式

由於線段樹的樹形結構特點,每次修改查詢可以從根節點向下二分查詢需要用到的節點,因此較為普遍且快捷的線段樹會使用遞迴實現。

但遞迴實現的線段樹由於每次要從根節點遞迴向下傳遞子樹資訊,導致常數較大,容易被卡常,所以出現了常數更小的遞推實現的線段樹(膜拜 zkw 大佬)。


zkw 線段樹

先來講一些小原理。

一、原理

由於遞迴實現的線段樹不是一棵滿二叉樹,其葉子節點位置不確定,導致每次操作都需要從根節點開始自上而下遞迴依次尋找葉子節點,回溯時進行維護,遞迴過程常數就比較大了。

所以 zkw 線段樹就直接建出一棵滿二叉樹,原序列資訊都維護在最底層。嚴格規定父子節點關係,同層節點的子樹大小相等。
這樣每個葉子節點都可以直接找到並修改,由於二叉樹父子節點的二進位制關係,就可以遞推直接找到對應節點的父親節點自下而上地維護節點關係。

二、初始化

1、建樹

對長度為 \(n\) 的序列建一棵 zkw 線段樹,其至少有 \(n+2\) 個葉子節點。其中有 2 個用來幫助維護區間資訊的虛點,有 \(n\) 個用來存原序列資訊的節點。
如圖(【模板】線段樹 1 的樣例為例,下同):

建樹時先求出虛點 \(P\) 位置,然後直接向其他葉子節點讀入資訊即可:

//先求虛點 P
  P = 1;
  while(P<=n+1) P<<=1;//節點深度每增加一層,當前層節點數量擴大一倍
  for(int i=1;i<=n;++i) read(tr[P+i]);

2、維護

根據上文所說,由於嚴格確定了父子關係,所以直接自下而上遍歷所有節點維護父子關係做初始化:

//push_up
  for(int i=P-1;i;--i){//i=(P+n)>>1
  	tr[i] = tr[i<<1|1]+tr[i<<1]; 
  	tr[i] = min(tr[i<<1|1],tr[i<<1]);
  	tr[i] = max(tr[i<<1|1],tr[i<<1]);
  	//...
  }

三、概念介紹

1、永久化懶標記

與遞迴線段樹的 \(lazy\) \(tag\) 不同,其每次向下遞迴時都需要先下放標記並清空以維護資訊。

但在維護存在結合律的運算時,zkw 線段樹的 \(lazy\) \(tag\) 只會累加,而不會在修改和查詢前下放清空。

2、“哨兵”節點

在區間操作時,引入兩個哨兵節點,分別在區間的左右兩側,把閉區間變成開區間進行處理

兩個哨兵節點到根有兩條鏈,與兩條鏈相鄰且在中間部分的節點,就是這次操作需要用到其資訊的所有節點。

如圖(沿用了第一個圖,節點中的數的為區間和):
例如:【模板】線段樹 1 第一個操作 \(query(2,4)\)

同時,這也解釋了為什麼建樹時葉子節點上會有 \(2\) 個虛點(當然是為了不越界)。

(1)為什麼可以確定需要用到哪些節點

操作時,只需要操作區間中單元素區間的公共祖先即可。
我們選取的兩條鏈,中間部分正好包含了與操作區間有關的所有節點,與兩條鏈相鄰的節點顯然的所有區間的公共祖先。

操作時只需要操作這些節點上的資訊就可以了。

(2)在遞推過程中怎麼判斷要用到哪些節點

觀察我們剛才手推出來的圖片,注意到:
對於左哨兵 \(S\),當它是左兒子時,其兄弟節點是需要用到的;
對於右哨兵 \(T\),當它是右兒子時,其兄弟節點是需要用到的。

每次操作完後 \(S\)\(T\) 向上走到自己的父親節點,然後維護父子關係,再進行新一輪操作。
\(S\)\(T\) 互為兄弟節點時(走到了兩條鏈的交點),就停止操作,然後向上維護資訊到根節點。

四、基於結合律的查詢與修改

1、區間修改

以區間加為例
類似遞迴線段樹操作,更新時需要知道當前節點的子樹大小
每次更新時,當前節點的值增加的是其標記乘子樹大小;其標記的值正常累加即可。
永久化懶標記減少了標記下放帶來的常數。

//
  inline void update_add(int l,int r,ll k){
  	l=P+l-1; r=P+r+1;//哨兵位置 
  	int siz = 1;//記錄當前子樹大小 
  	
  	while(l^1^r){//當l與r互為兄弟時,只有最後一位不同 
  		if(~l&1) tr[l^1]+=siz*k,sum[l^1]+=k;
  		if(r&1) tr[r^1]+=siz*k,sum[r^1]+=k;
  		//類似遞迴線段樹 tr[p] += tag[p]*(r-l+1) 
  		l>>=1; r>>=1; siz<<=1;
  		//每次向上走時子樹大小都會增加一倍 
  		tr[l] = tr[l<<1]+tr[l<<1|1]+sum[l]*siz;//維護父子關係 
  		tr[r] = tr[r<<1]+tr[r<<1|1]+sum[r]*siz;
  	}
  	for(l>>=1,siz<<=1;l;l>>=1,siz<<=1) tr[l] = tr[l<<1]+tr[l<<1|1]+sum[l]*siz;//更新上傳至根節點
  } 

2、區間查詢

由於我們需要查詢的區間被左右哨兵分為了兩個部分,但兩部分子樹大小不一定相等。
所以要分別維護左右哨兵到達的節點所包含查詢區間的子樹的大小。

//
  inline ll query_sum(int l,int r){
  	l=l+P-1; r=r+P+1;
  	ll res = 0;
  	int sizl = 0,sizr = 0,siz = 1;//分別維護左右兩側子樹大小

  	while(l^1^r){
  		if(~l&1) res+=tr[l^1],sizl+=siz;//更新答案及子樹大小 
  		if(r&1) res+=tr[r^1],sizr+=siz;
  		l>>=1; r>>=1; siz<<=1;
		
  		res += sum[l]*sizl+sum[r]*sizr;
  		//即使當前節點所存的區間和不需要用,但因為其是兩個哨兵的父親節點,且 tag 不會下傳,
  		//所以其 tag 會對答案有貢獻,所以需要加上 tag 的貢獻
  	}
  	for(l>>=1,sizl+=sizr;l;l>>=1) res+=sum[l]*sizl;//累加至根節點 
	return res;
  }

如果維護區間最大值也同理:

//
  inline void update_add(int l,int r,ll k){
  	l=P+l-1; r=P+r+1;
  	while(l^1^r){
  		if(~l&1) sum[l^1]+=k,maxn[l^1]+=d;
  		if(r&1) sum[r^1]+=k,maxn[r^1]+=d;
  		l>>=1; r>>=1;
        maxn[l] = max(maxn[l<<1],maxn[l<<1|1])+sum[l];
        maxn[r] = max(maxn[r<<1],maxn[r<<1|1])+sum[r];
  	}
  	for(l>>=1;l;l>>=1) maxn[l]=max(maxn[l<<1],maxn[l<<1|1])+sum[l];//更新上傳至根節點
  } 
  inline ll query_max(int l,int r){
  	l=l+P-1; r=r+P+1;
  	ll resl = 0,resr = 0;//分別記錄左右兩側最大值 
  	while(l^1^r){
  		if(~l&1) resl=max(resl,maxn[l^1]);
  		if(r&1) resr=max(resr,maxn[r^1]);
  		l>>=1; r>>=1;
  		resl += sum[l];//標記永久化,所以要累加標記值
  		resr += sum[r];
  	}
  	for(resl=max(resl,resr),l>>=1;l;l>>=1) res1+=sum[l];//累加至根節點
	return resl;
  }

某些時候,只會用到單點修改區間查詢和區間修改單點查詢,此時 zkw 線段樹碼量優勢很大。

3、單點修改下的區間查詢

修改:直接改葉子結點的值然後向上維護。
查詢:哨兵向上走時直接累加節點值。

//
  inline update(int x,ll k){
  	x += P; tr[x] = k;
  	for(x>>=1; x ;x>>=1) tr[x] = tr[x<<1]+tr[x<<1|1]; 
  }
  inline ll query(int l,int r){
  	l += P-1; r += P+1;
  	ll res = 0;
  	while(l^1^r){
  		if(~l&1) res+=tr[l^1];
  		if(r&1) res+=tr[r^1];
  		l>>=1; r>>=1;
  	}
  	return res;
  }

4、區間修改下的單點查詢

將賦初值的過程看作是在葉子節點上打標記,區間修改也是在節點上打標記。
由於 zkw 線段樹的標記是永久化的,所以此時將標記的值看作節點的真實值。
但這種做法顯然只對於單點查詢有效,在查詢時需要加上節點到根沿途的所有標記。

//
  inline void update_add(int l,int r,ll k){
  	l += P-1; r += P+1;
  	while(l^1^r){
  		if(~l&1) tr[l^1]+=k;
  		if(r&1) tr[r^1]+=k;
  		l>>=1; r>>=1;
  	}
  }
  inline ll query(int x){
  	ll res = 0;
  	for(x+=P; x ;x>>=1) res+=tr[x];
  	return res;
  }

5、標記永久化的侷限性

以上修改與查詢方式,全部基於運算具有結合律,所以標記可以永久化,以此減少標記下放增加的常數。

但如果運算存在優先順序,標記就不能再永久化了。考慮在更新時將先標記下放(類似遞迴線段樹)然後再從葉子節點向上更新。

但是如果像遞迴線段樹一樣從根開始逐次尋找子節點下放一遍的話,那最佳化等於沒有。
所以要考慮基於 zkw 線段樹的特點進行下放操作,而且要儘可能的簡潔方便。


So easy,搜一紫衣。

五、有運算優先順序的修改與查詢

1、標記去永久化

在進行區間修改時,我們會用到的節點只存在於哨兵節點到根的鏈上。
所以只考慮將這兩條鏈上的節點標記進行下放即可。

(1)如何得到有哪些需要下放標記的節點

考慮最暴力的方法:
每次從哨兵節點向上遞迴直至根節點,回溯時下放標記。
顯然這樣的方式常數最佳化約等於零。

考慮最佳化肯定是基於 zkw 線段樹的特點。
還是由於 zkw 線段樹是滿二叉樹結構,所以可以透過節點編號移位的方式找到其所有父子節點的編號。
顯然哨兵到根節點的鏈,是哨兵的所有父親組成的,所以只要讓哨兵編號移位就可以了。

(2)如何自上而下的傳遞標記

再記錄一下葉子節點的深度。
思考滿二叉樹的性質:當節點編號右移位節點深度時就指向根節點編號。
所以節點右移的位數,從節點深度依次遞減,就可以自上而下得到其所有父親節點的編號。

2、區間修改

先下放標記,然後正常做標記更新。
傳遞標記時可能要考慮子樹大小,直接透過深度計算就可以了。
以區間加及乘為例

//建樹時記錄葉子節點深度 
  P = 1;DEP = 0;
  while(P<=n+1) P<<=1,++DEP;
  //...
  //...
  //...
  inline void update_add(int l,int r,ll k){
  	l=P+l-1; r=P+r+1;
  	//先下放標記 
  	for(int i=DEP;i;--i) push_down(l>>i,1<<i),push_down(r>>i,1<<i); 
  	//push_dwon( 鏈上節點 , 當前子樹大小 );

  	int siz = 1;
  	while(l^1^r){
  		if(~l&1) tr[l^1]+=siz*k,sum[l^1]+=k;//正常更新
  		if(r&1) tr[r^1]+=siz*k,sum[r^1]+=k;
  		l>>=1; r>>=1; siz<<=1;

  		//維護父子關係 
  		tr[l] = tr[l<<1]+tr[l<<1|1];//由於標記已下放,所以維護時不再考慮累加標記 
  		tr[r] = tr[r<<1]+tr[r<<1|1];
  	}
  	for(l>>=1; l ;l>>=1) tr[l] = tr[l<<1]+tr[l<<1|1];//上傳至根節點 
  }
  //
  inline void update_mul(int l,int r,ll k){
  	l += P-1; r += P+1;
  	for(int i=DEP;i;--i) push_down(l>>i,1<<i),push_down(r>>i,1<<i);
  	while(l^1^r){
  		if(~l&1) tr[l^1]*=k,mul[l^1]*=k,sum[l^1]*=k;//標記覆蓋
  		if(r&1) tr[r^1]*=k,mul[r^1]*=k,sum[r^1]*=k;
  		l>>=1; r>>=1;
  		tr[l] = tr[l<<1]+tr[l<<1|1];
  		tr[r] = tr[r<<1]+tr[r<<1|1];
  	}
  	for(l>>=1; l ;l>>=1) tr[l] = tr[l<<1]+tr[l<<1|1];
  }

3、區間查詢

先下放標記。
由於標記已經去永久化,所以直接累加節點值即可。

//
  inline ll query(int l,int r){
  	l = l+P-1;r = r+P+1;
  	//先下放標記 
  	for(int i=DEP;i;--i) push_down(l>>i,1<<i),push_down(r>>i,1<<i); 
  	ll res = 0;
  	while(l^1^r){
  		if(~l&1) res+=tr[l^1]; 
  		if(r&1) res+=tr[r^1]; 
  		//由於標記已下放,所以無需再累加標記的貢獻 
  		l>>=1; r>>=1;
  	}
  	return res;
  }

六、最佳化效果

1、時間複雜度

開始的時候也提到了:遞迴線段樹常數大的瓶頸在於其需要對樹進行遞迴遍歷以找到目標節點,然後回溯進行資訊維護。
zkw 線段樹僅僅只是最佳化了遞迴尋找目標節點這樣的遍歷過程的常數。

如果是追求常數或者注重最佳化遍歷,那 zkw 線段樹的最佳化就比較明顯了;如果要維護較為複雜的資訊,那麼顯然這點常數並不是很夠看,此時就需要在其他地方上做改進了。

2、空間複雜度

zkw 線段樹需要開三倍空間,普通線段樹如果不使用動態開點需要開四倍空間。

相較於普通線段樹,zkw 線段樹程式碼好理解也比較簡潔,不會出現忘建樹忘終止遞迴的問題,而且滿二叉樹結構的確定性讓手造資料也比較方便。
對於一些維護資訊複雜的題目,zkw 線段樹的優勢在於手推時思路更加清晰。


如果性格比較內向,不敢用遞迴線段樹進行遞迴維護資訊。
想用 zkw 遞推實現更多更強的操作怎麼辦!

zkw 線段樹實現其他線段樹結構

一、引入

1、關於 zkw 線段樹

本人認為:狹義的 zkw 線段樹是指建立出滿二叉樹結構、節點間的父子關係嚴格規定、一切資訊從葉子節點開始向上維護、透過迴圈遞推實現維護過程。
另外:張昆瑋大佬的 PPT 中提到,為了減小遞迴帶來的常數,出現了彙編版的非遞迴線段樹。
所以本人的理解是:廣義的 zkw 線段樹指透過迴圈遞推而非遞迴實現的線段樹。

2、關於最佳化效果

基於多數線段樹結構的特點,導致大部分時候必須上下迴圈兩次維護資訊,所以此時 zkw 線段樹更多最佳化的是程式碼的簡潔程度理解難度(當然了,對常數也有一些最佳化)。

二、可持久化線段樹

1、介紹

可持久化線段樹與普通線段樹的區別在於,其支援修改和訪問任意版本
舉個例子:給定一個序列 \(a_N\),對它進行一百萬次操作,然後突然問你第十次操作後的序列資訊。

樸素的想法是對於每次操作都建一棵線段樹,空間複雜度是 \(O(3mn)\) 的。
可以發現:
修改後,大部分節點並沒有受到影響,所以考慮只對受影響的節點新建對應節點。其餘沒受影響的節點直接與原樹共用節點,就等同於新建了一棵修改後的線段樹。

2、單點修改單點查詢

每次單點修改後,只有葉子節點到根節點的那一條鏈上的點會受到影響。
所以我們只需要對受影響的這條鏈新建一條對應的鏈,其餘沒受影響的節點直接和待修改版本共用即可。

對於本次要修改的位置,在以原始序列 \(a_N\) 建立的初始線段樹中,其對應的葉子節點到根的鏈上的節點分別為 \(tl\),當前新節點為 \(now\),下一個新節點為 \(new\)
如果 \(tl\) 為左兒子,那麼 \(now\) 的左兒子為 \(new\),右兒子為 \(tl\) 對應在待修改樹上節點的兄弟節點;
如果 \(tl\) 為右兒子,那麼 \(now\) 的右兒子為 \(new\),左兒子為 \(tl\) 對應在待修改樹上節點的兄弟節點。

其實就是新建節點的位置與初始樹上的節點位置分別對應
看圖(節點內數字為節點編號):在原序列上修改一個位置:

第一次修改後的序列上,再修改一次:

繼續在第一次修改後的序列上做修改:

我們發現新建的鏈在新樹上的位置,與初始樹上的鏈在初始樹上的位置,是相同的。
所以我們新建節點時,新節點的位置跟隨對應的初始樹上的節點的位置進行移動

由於版本間需要以根節點做區分(因為使用葉子節點會非常麻煩),所以修改和查詢操作只能從根節點開始自上而下進行,防止不同版本的儲存出現問題。
所以我們需要多一個記錄:當前節點的左右兒子。

對於 \(tl\) 到根的鏈如何快速求得,我們前面講“哨兵”的時候已經講過實現,接下來就是模擬整個新建節點過程即可。
同時,新建節點的節點編號依次遞增,操作後進行自下而上維護資訊也很方便:

//建初始線段樹
  while(P<=n+1) P<<=1,++DEP; NOW = (1<<(DEP+1))-1;//最後一個節點的編號
  for(int i=1;i<=n;++i) read(tr[P+i]); rt[0] = 1;//初始樹根為1
  for(int i=P-1;i;--i) son[i][0]=i<<1,son[i][1]=i<<1|1;//記錄子節點 0為左兒子;1為右兒子
//...
//...
  inline void update(int i,int vi,int val,int l){
  	int tl = l+P;//在初始樹上對應的葉子節點編號
  	int v = rt[vi];//待修改線段樹的根
  	rt[i] = l = ++NOW;//新線段樹的根
  	for(int dep=DEP-1; dep>=0 ;--dep,l = NOW){
        //模擬節點更新過程
		if((tl>>dep)&1) son[l][0] = son[v][0],son[l][1] = ++NOW,v = son[v][1];
  		else son[l][0] = ++NOW,son[l][1] = son[v][1],v = son[v][0];
  	}
  	tr[l] = val;//更新最後的葉子節點

    //自下而上維護資訊(如果有需要的話)
    //for(int dep=1;dep<=DEP;++dep) tr[l-dep]=tr[son[l-dep][0]]+tr[son[l-dep][1]];
  }

版本查詢與修改相同,從根開始模擬子樹選取:

//
  inline int query(int vi,int l){
  	int tl = l+P;//在初始樹上對應的葉子節點編號
  	l = rt[vi];//當前版本的根
  	for(int dep=DEP-1; dep>=0 ;--dep) l=son[l][(tl>>dep)&1];
  	return tr[l];//返回葉子節點值
  }

3、區間修改區間查詢

目前我瞭解到的資訊是:只能做區間加
可持久化線段樹中有大量的公用節點,所以標記不能下放且修改要能夠用永久化標記維護,否則會對其他版本產生影響

那麼考慮如何做區間加。

  1. 標記永久化:省去標記下放以減小常數同時防止對其他版本產生影響;
  2. 預處理時記錄子樹大小,查訊時累加標記值。

不同的是:

  1. 需要對區間新建節點;
  2. 修改時對照初始樹上節點的軌跡進行移動;
  3. 修改需要自上而下進行,然後再自下而上做一遍維護(類似遞迴回溯)。

三、權值線段樹

1、介紹

普通線段樹維護的是資訊,權值線段樹維護的是資訊的個數
權值線段樹相當於在普通線段樹上開了一個,用於處理資訊個數,以單點修改和區間查詢實現動態全域性第 \(k\)

2、查詢全域性排名

在權值線段樹中,節點存資訊出現的次數:

//
  inline void update(int l,int k){
  	l += P; tr[l] += k;//k為資訊出現次數 
  	for(l>>=1; l ;l>>=1) tr[l] = tr[l<<1]+tr[l<<1|1];
  }

當前數字的相對大小位置向前的字首和,即為當前數字在全域性中的排名:

//
  inline int get_rank(int r){//查詢第r個數的全域性排名 
  	int l = 1+P-1;//做區間[1,r]的字首和 
  	r += P+1;
  	int res = 0;
  	while(l^1^r){
  		if(~l&1) res+=tr[l^1];
  		if(r&1) res+=tr[r^1];
  		l>>=1; r>>=1; 
  	}
  	return res;
  }

3、動態全域性第 \(k\)

基於線段樹的結構,第 \(k\) 大的二分實現其實就線上段樹上查詢左右子樹的過程。
查詢第 \(k\) 大時,藉助線段樹的結構,以左右子樹選取來模擬二分過程即可:

//
  inline int K_th(int k){
  	int l = 1,dep = 0;
  	while(dep<DEP){
  		if(tr[l<<1]>=k) l=l<<1;//模擬二分 
  		else k-=tr[l<<1],l=l<<1|1;
  		++dep;
  	}
  	return l-P;//減去虛點編號,得到原陣列中的編號 
  }

4、前驅與後繼

有時還需要查詢 \(k\) 的前驅和後繼。
\(k\) 的前驅為:最大的小於 \(k\) 的數;
\(k\) 的後繼為:最小的大於 \(k\) 的數。
\(k\) 的前驅可以看作:查與 \(k-1\) 的排名相同數;
\(k\) 的後繼可以看作:查比 \(k\) 的排名靠後一位的數。
結合一下 \(get\_rank\)\(K\_th\) 即可:

//
  inline int pre(int k){
  	int rk = get_rank(k-1);
  	return K_th(rk);
  } 
  inline int nex(int k){
  	int rk = get_rank(k)+1; 
  	return K-th(rk);
  }

四、可持久化權值線段樹(主席樹)

有人說 zkw 做不了主席樹,我急了。

1、介紹

顧名思義,就是可持久化線段樹和權值線段樹結合。
大部分情況下只需要支援區間查詢,常用於解決靜態區間第 \(k\),因為單獨的主席樹不太好進行修改操作。
當然,動態區間第 \(k\)的實現——樹套樹,可以直接跳到目錄五去看。

2、靜態區間第 \(k\)

主席樹對序列的每個位置都維護一棵線段樹,其節點值為對應序列上值的範圍。
在第 \(m\) 棵線段樹上,區間 \([L,R]\) 維護的是:序列上 \(a_i\sim a_m\) 中,有多少數字在 \([L,R]\) 範圍內。

我們對序列中每一個數的權值都開一棵線段樹,一共開 \(N\) 棵樹,存不下,所以使用可持久化線段樹。
由於權值線段樹存下了數的權值,每個節點上存的是字首和,資訊具有可加性。所以查 \([L,R]\) 等於查 \([1,R]-[1,L-1]\)
可持久化線段樹的新建書和權值線段樹的查詢結合一下就好了:

//可持久化線段樹的建新樹
  inline void update(int i,int vi,int l,int k){
  	int tl = l+P;
  	int v = rt[vi];
  	rt[i] = l = ++NOW;
  	for(int dep=DEP-1; dep>=0 ;--dep,l=NOW){
		if((tl>>dep)&1) son[l][0] = son[v][0],son[l][1] = ++NOW,v = son[v][1];
  		else son[l][1] = son[v][1],son[l][0] = ++NOW,v = son[v][0];
  	}
  	tr[l] = tr[v]+k;//需要維護字首和
    //向上維護資訊
  	for(int dep=1;dep<=DEP;++dep) tr[l-dep]=tr[son[l-dep][0]]+tr[son[l-dep][1]];
  }
//權值線段樹的查詢
  inline int query(int l,int r,int k){
    //查 [l,r] 相當於查 [1,r]-[1,l-1]
  	l = rt[l-1];r = rt[r];
    int tl = 1;//答案
  	for(int dep=0;dep<DEP;++dep){
  		int num = tr[son[r][0]]-tr[son[l][0]];//左子樹大小
		if(num>=k){//不比左子樹大,說明在左子樹中
			l = son[l][0];
			r = son[r][0];
            tl = tl<<1;
		}
		else{//比左子樹大,說明在右子樹中
			k -= num;
			l = son[l][1];
			r = son[r][1];
            tl = tl<<1|1;
		}
	}
	return tl-P;//當前權值為:對應在初始樹上位置減虛點編號
  }

五、樹狀陣列套權值線段樹

1、介紹

上文說,單獨的主席樹不方便維護動態區間第 \(k\),主要是因為主席樹修改時,對應的其他版本關係被破壞了。
實現動態第 \(k\) 大的樸素想法當然還是對序列的每個位置都開一棵權值線段樹,那麼難點就在於我們到底要對哪些樹做修改。

由於權值線段樹具有可加性的性質,所以我們可以拿一個樹狀陣列維護線段樹的字首和,用於求出要修改哪些樹。這個過程我們可以用 \(lowbit\) 來實現。

把要修改的樹編號存下來,然後做線段樹相加的操作,此時操作就從多棵線段樹變成了在一棵線段樹上操作。

2、初始化

對序列的每個點建一棵 zkw 線段樹的話,空間會變成 \(Q(3n^2)\) 的,所以我們需要動態開點,空間複雜度變成 \(O(n\log^2{n})\)
(存個節點而已,我們 zkw 也要動態開點,父子關係對應初始樹就可以了)。

為了保證修改和查詢時新樹節點與序列的對應關係,以及嚴格確定的樹形結構,所以我們先建一棵初始樹(不用真的建出來,因為我們只會用到編號),操作時新樹上的節點跟隨對應在初始樹上的節點進行移動。

//
  for(int i=1;i<=n;++i){
  	read(a[i]);
  	b[++idx] = a[i];
  }
  sort(b+1,b+1+idx);
  idx = unique(b+1,b+1+idx)-(b+1);//離散化
  while(P<=idx+1) P<<=1,++DEP;//求初始樹上節點編號備用
  for(int i=1;i<=n;++i){
  	a[i] = lower_bound(b+1,b+1+idx,a[i])-b;
  	add(i,1);//對每個位置建線段樹
  }
//...
  inline void update(int i,int l,int k){
  	int tl = l+P,stop = 0;
  	rt[i] ? 0 : rt[i]=++NOW;//動態開點
  	l = rt[i]; st[++stop] = l;tr[l] += k;
  	for(int dep=DEP-1;dep>=0;--dep,st[++stop]=l,tr[l]+=k){
  		if((tl>>dep)&1) son[l][1]?0:son[l][1]=++NOW,l=son[l][1];
  		else son[l][0]?0:son[l][0]=++NOW,l=son[l][0];
	}
    //為了方便也可以把鏈上的節點全存下來再做維護
  	//while(--stop) tr[st[stop]] = tr[son[st[stop]][0]]+tr[son[st[stop]][1]];
  }
  inline void add(int x,int k){//lowbit求需要用到的線段樹
  	for(int i=x;i<=n;i+=(i&-i)) update(i,a[x],k);
  }

3、單點修改

先把原來數的權值減一,再讓新的數權值加一。

//
  inline void change(int pos,int k){
  	add(pos,-1);
  	a[pos] = k;
  	add(pos,1);
  }

4、查詢區間排名

由於權值線段樹維護的是字首和,所以把區間 \([L,R]\) 的查詢看作查詢 \([1,R]-[1,L-1]\)
先用樹狀陣列求出需要用到的線段樹,然後做線段樹相加,求字首和即可。

//
  inline int query_rank(int l){
  	l += P;
  	int res = 0;
  	for(int dep=DEP-1;dep>=0;--dep){
  		if((l>>dep)&1){//做線段樹相加求字首和
  			for(int i=1;i<=tmp0;++i) res-=tr[son[tmp[i][0]][0]],tmp[i][0]=son[tmp[i][0]][1];
  			for(int i=1;i<=tmp1;++i) res+=tr[son[tmp[i][1]][0]],tmp[i][1]=son[tmp[i][1]][1];
  		}
  		else{
  			for(int i=1;i<=tmp0;++i) tmp[i][0]=son[tmp[i][0]][0];
  			for(int i=1;i<=tmp1;++i) tmp[i][1]=son[tmp[i][1]][0];
  		}
  	}
  	return res;
  }
  inline int get_rank(int l,int r,int k){
  	tmp0 = tmp1 = 0;
  	for(int i=l-1; i ;i-=(i&-i)) tmp[++tmp0][0] = rt[i];
  	for(int i=r; i ;i-=(i&-i)) tmp[++tmp1][1] = rt[i];
  	return query_rank(k)+1;
    //query_rank求的是小於等於k的數的個數,加一就是k的排名
  }

5、動態區間第 \(k\)

和查詢排名道理一樣:由於權值線段樹維護的是字首和,所以把區間 \([L,R]\) 的查詢看作查詢 \([1,R]-[1,L-1]\)
還是先用樹狀陣列求出需要用到的線段樹,查詢時做線段樹相加。然後模擬線段樹上二分就可以了。

//
  inline int query_num(int k){
  	int l = 1;
  	for(int dep=0,res=0;dep<DEP;++dep,res=0){
  		for(int i=1;i<=tmp0;++i) res-=tr[son[tmp[i][0]][0]];
  		for(int i=1;i<=tmp1;++i) res+=tr[son[tmp[i][1]][0]];//每棵樹的節點值都滿足可加
  		if(k>res){
  			k -= res;//做樹上二分
  			for(int i=1;i<=tmp0;++i) tmp[i][0]=son[tmp[i][0]][1];
  			for(int i=1;i<=tmp1;++i) tmp[i][1]=son[tmp[i][1]][1];
  			l = l<<1|1;
  		} 
  		else{
  			for(int i=1;i<=tmp0;++i) tmp[i][0]=son[tmp[i][0]][0];
  			for(int i=1;i<=tmp1;++i) tmp[i][1]=son[tmp[i][1]][0];
  			l = l<<1;
  		}
  	}
  	return l-P;//葉子節點對應編號
  }
  inline int get_num(int l,int r,int k){
  	tmp0 = tmp1 = 0;//先用lowbit求需要查詢的線段樹
  	for(int i=l-1; i ;i-=(i&-i)) tmp[++tmp0][0] = rt[i];
  	for(int i=r; i ;i-=(i&-i)) tmp[++tmp1][1] = rt[i];
  	return query_num(k);
  }

線段樹套線段樹與其原理相同。下層線段樹維護序列資訊,再用一棵上層線段樹來維護下層線段樹的字首和。
你可以看這張圖,我暫時先不多贅述了:(真的碼不動字了)

六、兔隊線段樹

(本人不是特別瞭解,所以暫時僅作資訊具有可加減性的解釋)
有人說 zkw 做不了兔隊線段樹,我急了。

1、介紹

兔隊線段樹是指一類:在資訊修改同時,以 \(O(\log{n})\) 複雜度做維護的線段樹。支援單點修改區間查詢,通常用來維護字首最大值的問題。
(粉兔在這篇文章中率先對其進行了說明)

2、處理與維護

其處理與維護資訊的大致方式可以看作:

  1. 首先修改資訊,然後從下到上做維護;
  2. 向上維護時每到達一個節點,都再次從下到上維護資訊;
  3. 第二次從下到上維護時,左子樹對答案貢獻不變,只考慮右子樹對答案的貢獻。

由於第一次向上維護時,需要從當前節點開始對其所有子樹進行第二次維護,所以遞迴線段樹常用的方法是二次遞迴處理右子樹資訊。

3、具體實現

考慮如何用 zkw 線段樹遞推處理右子樹資訊。
首先,對單點進行修改後,從下到上進行處理和維護,同時記錄節點深度,防止第二次維護時發生越界:

//單點修改後,每次上傳更新到根節點
  inline void update(int l,ll k){
  	l += P;int dep = DEP;
  	mx[l] = k;mn[l] = k;//...
  	for(l>>=1,--dep; l ;l>>=1,--dep) push_up(l,dep);
  }

然後,再次模擬標記上傳過程:

//
  inline void push_up(int l,int dep){
  	mx[l] = max(mx[l<<1],mx[l<<1|1]);
  	//...
  	ans[l] = ans[l<<1]+calc(l<<1|1,dep+1,mx[l<<1]); 
  }
  inline int calc(int l,int dep,ll mx){
  	int res = 0,tl = l;
  	while(dep<DEP){//模擬左右子樹選取過程
		if(mx[l]<=k) break;//剪枝之類的
		if(mx[l<<1]<=k) l = l<<1|1;
		else{
			res += len[l]-len[l<<1];//資訊有可減性,考慮左區間的覆蓋 
			l <<= 1;
		}
		++dep; 
	}
	if(dep==DEP) res += (mx[l]>k);//葉子節點特判
  }

七、Kinetic Tournamen Tree

(有讀者評論問能不能實現 KTT,我們討論研究後發現是可以的。)

1、介紹

KTT 最初在 2020 年集訓隊論文中由 EI 隊長提出。
KTT 用來維護動態區間最大值問題,其基本思想為將需要維護的資訊看作一次函式,所有修改都基於函式進行。同時設定閾值,表示維護的答案取值何時發生變化,當修改或查詢的資訊達到閾值時,暴力重構子樹維護答案。

筆者覺得學習 KTT 最好還是從一些具體問題入手。所以我們下文的內容,全部圍繞論文中提到的經典問題 P5693 EI 的第六分塊進行展開。

2、資訊處理

最大子段和要記錄四個資訊用線段樹維護,資訊合併時分類討論:

  • \(lmax = \max(lmax_{ls},sum_{ls}+lmax_{rs})\)
  • \(rmax = \max(rmax_{rs},sum_{rs}+rmax_{ls})\)
  • \(mx = \max(mx_{ls},mx_{rs},rmax_{ls}+lmax_{rs})\)

進行動態維護就要用 KTT 了,這是我們的重點內容。

現在每個資訊記錄的都不是一個具體值,而是一條一次函式\(f(x)=kx+b\)
其中 \(k\) 為最大子段的長度,\(x\) 為變化量,\(f(0)=b\) 為當前維護的具體值。
同時,對於兩條函式,記錄一個閾值 \(dx\),表示當前區間最大值是否在兩個函式間進行交替

3、關於交替閾值

前置知識:人教版八年級下冊 19.2.3一次函式與方程、不等式
在對兩條函式進行合併取最大值時,需要知道具體應該何時選取哪條函式。我們知道應該看函式的交點相對於區間的位置,來對取值情況分類討論。
交替閾值就幹了這樣一件事情,維護時記錄下何時應該對函式選取進行交替,並只在需要交替時交替,以此最佳化時間複雜度。

具體地,當區間加 \(q\) 時,函式向上進行了移動,函式的交點相對於區間進行了左右移動。此時我們令閾值 \(dx\) 減小,當 \(dx<0\) 時表示此時選取的函式要進行交替了。
具體減少多少呢,由於函式都滿足 \(k\ge 1\),所以至少要令 \(dx-=q\)(當然最好是這個數,減多了重構次數就太多了)。
由於同一個區間可能有兩個不同的函式進行維護,所以在合併區間時,閾值不僅要對左右區間取最小值,還需要包含當前兩條函式的交點。

4、區間及函式合併

筆者個人建議寫成過載運算子形式。
針對函式的操作,有求交點、函式合併、函式移動:

//struct Func
	inline Func operator + (const Func&G) const{//函式合併
		return Func(k+G.k,b+G.b);
	}
	inline ll operator & (const Func&G) const{//求交點
		return (G.b-b)/(k-G.k);
	}
	inline void operator += (const ll&G){//函式向上移動
		b += k*G;
	}

區間合併時,我們在函式操作的基礎上分類討論即可,注意同時維護閾值資訊:

//struct Tree
	inline bool operator < (const Func&G) const{
        //欽定兩條函式的相對位置,方便判斷有沒有交點
		return k==G.k && b<G.b || k<G.k;
	}
    inline void Merge_lx(Func x,Func y,Tree &tmp) const{//求lmax
		if(x<y) swap(x,y);
		if(x.b>=y.b) tmp.lx = x;//欽定過了函式位置,此時兩條函式沒有交點
		else tmp.lx = y,tmp.dx = Min(tmp.dx,x&y);
	}
    //...
	inline Tree operator + (const Tree&G) const{//區間合併
		Tree tmp;tmp.sum = sum+G.sum; tmp.dx = Min(dx,G.dx);//注意維護閾值資訊 
		Merge_lx(lx,sum+G.lx,tmp);Merge_rx(G.rx,G.sum+rx,tmp);
		Merge_mx(G.mx,mx,tmp);Merge_mx(tmp.mx,rx+G.lx,tmp);
		return tmp;
	}

5、修改與重構

區間加按照正常的方式來,唯一不同的是在修改後需要對節點子樹進行重構
首先第一步肯定是下放標記:

//struct Tree
  inline void operator += (const ll&G){//區間加
		lx += G; rx += G; mx += G; sum += G; dx -= G;
  }
//
  inline void push_down(int p){//正常push_down
     if(tag[p]){
  		tag[p<<1] += tag[p]; tr[p<<1] += tag[p];
  		tag[p<<1|1] += tag[p]; tr[p<<1|1] += tag[p];
		tag[p] = 0;
     }
  }

然後再正常做修改:

//
  inline void update(int l,int r,ll k){
  	l += P-1; r += P+1;//先push_down
  	for(int dep=DEP;dep;--dep) push_down(l>>dep),push_down(r>>dep);
  	while(l^1^r){
  		if(~l&1) tag[l^1]+=k,tr[l^1]+=k,rebuild(l^1);//別忘了重構
  		if(r&1) tag[r^1]+=k,tr[r^1]+=k,rebuild(r^1);
  		l>>=1;r>>=1;
  		tr[l] = tr[l<<1]+tr[l<<1|1];
  		tr[r] = tr[r<<1]+tr[r<<1|1];
  	}
  	for(l>>=1; l ;l>>=1) tr[l] = tr[l<<1]+tr[l<<1|1];
  }

對於重構,從當前子樹的根節點開始一層一層向下遞推,直到沒有節點需要重構為止:

//
  inline void rebuild(int p){
  	if(tr[p].dx>=0) return ;
  	int head = 1,tail = 0;
  	st[++tail] = p; push_down(p);
  	while(tail>=head){//模擬壓棧
		int ttail = tail;
		for(int j=tail,pos;j>=head;--j){
  			pos = st[j]; //看子節點的子樹是否需要更新
  			if(tr[pos<<1].dx<0) st[++tail]=pos<<1,push_down(pos<<1);//注意push_down
  			if(tr[pos<<1|1].dx<0) st[++tail]=pos<<1|1,push_down(pos<<1|1);
  		}
  		head = ttail+1;
  	}//重新維護
  	do{ tr[st[tail]]=tr[st[tail]<<1]+tr[st[tail]<<1|1]; } while(--tail); 
  }

6、查詢

正常做查詢就可以了。
需要注意一點,區間合併時要按照左右順序進行。

//
  inline ll query(int l,int r){
  	l += P-1; r += P+1;//先push_down
  	for(int dep=DEP;dep;--dep) push_down(l>>dep),push_down(r>>dep);
  	Tree resl,resr;
  	while(l^1^r){
        //注意左右區間的合併順序
  		if(~l&1) resl = resl+tr[l^1];
  		if(r&1) resr = tr[r^1]+resr;
  		l>>=1;r>>=1;
  	}
  	return (resl+resr).mx.b;
  }

KTT 的基本思路就是這樣,將資訊轉換為函式進行處理,同時維護閾值進行重構。這使得 KTT 有優於分塊的複雜度,但同時也對其使用產生了限制。


到現在,能肯定 zkw 線段樹基本可以實現遞迴線段樹能做的全部操作了。

一些模板題及程式碼

雲剪貼簿了,會跟隨文章更新。

後記

更新日誌

筆者目前學識過於淺薄,文章大部分內容是筆者自己的理解,可能有地方講得不是很清楚。等筆者再學會新東西,會先更新在此文章以及我的部落格,然後找時間統一更新。

同時,筆者會經常對文章內容細節和程式碼塊進行修改完善,如果您有什麼想法可以提出來,我們一起來解決。作者真的真的是活的!!!

期待您提出寶貴的建議。

鳴謝

《統計的力量》清華大學-張昆瑋 /hzwer整理
OIWiki
CSDN 偶耶XJX
Tifa's Blog【洛穀日報 #35】Tifa
洛穀日報 #4 皎月半灑花
NianFeng // EntropyIncreaser //

如需轉載,請註明出處。

相關文章