1. 程式人生 > 實用技巧 >樹鏈剖分學習筆記

樹鏈剖分學習筆記

前言

書上只講了重鏈剖分,菜雞也只會這一種,想看其他的是別想了。

點權樹剖

要會樹鏈剖分,首先你需要了解一些概念。我們把一個節點的所有兒子節點中子樹節點數最大的稱為重兒子,也就是size最大的子節點。size的定義我在講換根DP時說過,因此不再贅述。對於每個節點的重兒子,我們用 \(son[x]\) 來記錄它,父親節點到重兒子的那條邊稱為重邊,而連向其他兒子的邊則稱為輕邊。

可以試著想象一下,或者手畫一棵樹,就會發現,許多重邊連在一起,構成了一條鏈,這條邊的頂端節點,我們用 \(top[x]\) 來記錄,其中 \(x\) 是這條鏈上的任意一個節點,也就是說,對於同一條鏈上的節點,它們的top的值都是一樣的。

還可以發現,每條鏈的頂點,隔著一條輕邊,就到了另一條鏈的節點上,也就是說,我們只要記錄每個節點的父節點,就可以讓它們轉移到另一條鏈上去。

我們可以想到,如果可以一次跳一條鏈,這樣求LCA豈不比倍增要快得多?當兩個點跳到一條鏈上的時候,深度更小的那個自然就是LCA了,而這需要的只是簡單的預處理罷了。

但是還有問題,我們不能一直跳,而且我們一次只能跳一個點,選哪個好呢?

這時top陣列就有用了,兩個點的top值不相等,說明兩個點肯定還沒有跳到同一條鏈上,而我們只要在每次跳之前判斷一下x節點在的這條鏈的頂點和y這條鏈的頂點那個深度更大,每次跳深度更大的那個就是了。

樹鏈剖分的基本原理就是如此,通過將一條條重鏈剖分出來,以達到快速維護樹上資訊的目的。

你可能還有疑問,不就是求個LCA,怎麼又可以維護樹上資訊了呢?

別急,先讓我們看一道模板題:樹的統計

沒有什麼花裡胡哨的東西,講的很明白,就是要你維護樹上的資訊,然而資料規模之大,是普通的演算法所承受不了的。這時候,樹鏈剖分就會發揮它的奇效了。

首先看到這些操作,有沒有覺得很熟悉?沒錯,這正是線段樹裡面的慣常操作,只是現在不要求你維護序列,而要求你維護一棵樹,那我們是否可以把樹上的資訊轉換到一段序列上呢?

當然可以,我們只要以dfs序來構造一個線段樹就可以了。dfs序,簡單點說,就是用dfs遍歷整棵樹時,節點出現的順序,我們只要在進入遞迴時和回溯時分別記錄一次就好了,放到樹鏈剖分裡就只要記錄進入遞迴時的順序即可。

還記得我們剖分出來的那些重鏈嗎?我們只需要在每次對一個節點進行遍歷時,優先遍歷它的重兒子,這樣這條鏈在dfs序中就是一段連續的序列,我們到時候就可以非常方便的線上段樹上進行操作。

說說做法吧,首先我們要用一個seg陣列代表所有節點線上段樹中的編號,也就是序列的下標,同時還要用一個rev陣列,相反的記錄序列中的每個下標對應的是樹中的哪個點,這樣方便我們在建樹時可以方便的將樹上資訊轉移到線段樹上。

接下來的就很簡單了,操作以模板題為例,我們可以仿造上面求LCA的方法,讓兩個一次一次的跳,每次利用線段樹來查詢一條鏈上的資訊,最終迴圈結束,兩個點已經在同一條鏈上,那麼這時我們只要再查詢u點到v點的資訊,就可以了涵蓋兩個點間路徑上的所有節點。當然,記得先判斷一下u和v兩點的深度,小的下標更小,查詢時放前面。

說了這麼多,就來看看程式碼吧。

先是預處理的

void dfs1(int x,int fa)
{
	father[x]=fa;
	dep[x]=dep[fa]+1;
	size[x]=1;
	for(int i=head[x];i;i=Next[i])
	{
		int y=ver[i];
		if(y==fa)continue;
		dfs1(y,x);
		size[x]+=size[y];
		if(size[y]>size[son[x]])son[x]=y;
	}
}
void dfs2(int x,int fa)
{
	if(son[x])
	{
		seg[son[x]]=++cnt;
		top[son[x]]=top[x];
		rev[cnt]=son[x];
		dfs2(son[x],x);
	}
	for(int i=head[x];i;i=Next[i])
	{
		int y=ver[i];
		if(top[y])continue;
		top[y]=y;//跳過一條輕邊,這個點就是新重鏈的頂節點
		seg[y]=++cnt;
		rev[cnt]=y;
		dfs2(y,x);
	}
}

要預處理的資訊有很多,在寫的時候一定要仔細,不要寫錯或寫漏,樹鏈剖分碼量一般都很大,寫錯一個小細節可能就要花費你大量的除錯時間。

接下來是詢問的程式碼

void ask(int x,int y)
{
	while(top[x]!=top[y])
	{
		if(dep[top[x]]<dep[top[y]])swap(x,y);
		query(1,1,n,seg[top[x]],seg[x]);
		x=father[top[x]];
	}
	if(dep[x]>dep[y])swap(x,y);
	query(1,1,n,seg[x],seg[y]);
}

為了方便,我的query函式沒有返回值,用全域性變數summ和maxn,放在query函式內,每次詢問時一起更新。

建樹部分

void build(int k,int l,int r)
{
	if(l==r)
	{
		sum[k]=Max[k]=num[rev[l]];
		return;
	}
	int mid=(l+r)>>1;
	build(k<<1,l,mid);
	build(k<<1|1,mid+1,r);
	sum[k]=sum[k<<1]+sum[k<<1|1];
	Max[k]=max(Max[k<<1],Max[k<<1|1]);
}

num記錄的是每個點的點權。

線段樹修改和查詢的程式碼就不在此列出來了,有需要可以看我這道題的完整AC程式碼。

程式碼
#include<cstdio>
#include<algorithm>
#include<iostream>
#include<cstring>
using namespace std;
const int N=3e4+10,M=1e5+24000;
int n,q,num[N];
int top[N],rev[M],seg[M],size[N],son[N];
int dep[N],father[N],sum[M],Max[M];
int Next[N<<1],ver[N<<1],tot,head[N<<1];
int summ,maxn;
void add(int x,int y)
{
	ver[++tot]=y;Next[tot]=head[x];head[x]=tot;
} 
inline void query(int k,int l,int r,int L,int R)
{
	if(L>r||R<l)return;
	if(L<=l&&r<=R)
	{
		summ+=sum[k];
		maxn=max(maxn,Max[k]);
		return;
	}
	int mid=l+r>>1;
	if(L<=mid)query(k<<1,l,mid,L,R);
	if(mid<R)query(k<<1|1,mid+1,r,L,R);
	//sum[k]=sum[k<<1]+sum[k<<1|1];
	//Max[k]=max(Max[k<<1],Max[k<<1|1]);
}
inline void change(int k,int l,int r,int v,int pos)
{
	if(pos<l||pos>r)return;
	if(l==r&&r==pos)
	{
		Max[k]=sum[k]=v;return;
	}
	int mid=l+r>>1;
	if(pos<=mid)change(k<<1,l,mid,v,pos);
	if(mid<pos)change(k<<1|1,mid+1,r,v,pos);
	sum[k]=sum[k<<1]+sum[k<<1|1];
	Max[k]=max(Max[k<<1],Max[k<<1|1]);
}
void build(int k,int l,int r)
{
	if(l==r)
	{
		sum[k]=Max[k]=num[rev[l]];
		return;
	}
	int mid=l+r>>1;
	build(k<<1,l,mid);
	build(k<<1|1,mid+1,r);
	sum[k]=sum[k<<1]+sum[k<<1|1];
	Max[k]=max(Max[k<<1],Max[k<<1|1]);
}
void dfs1(int x,int fa)
{
	size[x]=1;
	dep[x]=dep[fa]+1;
	father[x]=fa;
	for(int i=head[x];i;i=Next[i])
	{
		int y=ver[i];
		if(y==fa)continue;
		dfs1(y,x);
		size[x]+=size[y];
		if(size[y]>size[son[x]])son[x]=y;
	}
}
void dfs2(int x,int fa)
{
	if(son[x])
	{
		seg[son[x]]=++seg[0];
		top[son[x]]=top[x];
		rev[seg[0]]=son[x];
		dfs2(son[x],x);
	}
	for(int i=head[x];i;i=Next[i])
	{
		int v=ver[i];
		if(top[v])continue;
		seg[v]=++seg[0];
		top[v]=v;
		rev[seg[0]]=v;
		dfs2(v,x);
	}
}
inline void ask(int x,int y)
{
	int fx=top[x],fy=top[y];
	while(fx!=fy)
	{
		if(dep[fx]<dep[fy])swap(fx,fy),swap(x,y);
		query(1,1,seg[0],seg[fx],seg[x]);
		x=father[fx];fx=top[x];
	}
	if(dep[x]>dep[y])swap(x,y);
	query(1,1,seg[0],seg[x],seg[y]);
}
inline int read()
{
	int x=0,f=1;
	char ch=getchar();
	while(ch<'0'||ch>'9'){if(ch=='-')f=-1;ch=getchar();}
	while(ch>='0'&&ch<='9'){x=(x<<1)+(x<<3)+(ch^48);ch=getchar();}
	return x*f;
}
int main()
{
	n=read();
	for(int i=1;i<n;++i)
	{
		int x,y;
		x=read();y=read();
		add(x,y);add(y,x);
	}
	for(int i=1;i<=n;++i)num[i]=read();
	dfs1(1,0);
	seg[0]=seg[1]=top[1]=rev[1]=1;
	dfs2(1,0);
	build(1,1,seg[0]);
	//for(int i=1;i<=seg[0];i++)printf("%d\n",Max[i]);
	q=read();
	while(q--)
	{
		char s[12];
		scanf("%s",s+1);
		int u,v;
		u=read();v=read();
		if(s[1]=='C')
		{
			change(1,1,seg[0],v,seg[u]);
		}
		else
		{
			summ=0;
			maxn=-1e9;
			ask(u,v);
			if(s[2]=='M')
			{
				printf("%d\n",maxn);
			}
			else printf("%d\n",summ);
		}
	}
	return 0;
}

總之,樹鏈剖分就是這麼簡單了。這裡還有一些樹上操作,以及一些注意事項,都是我做題時遇到的,一一列下。

  1. 要知道一個節點在進行遞迴時,這棵子樹上的所有節點在dfs序中都是連續的,也就是說線上段樹中下標連續,因此如果要你一次修改以x為根的子樹上的所有節點,只要把l和r定為 \(seg[x]\)\(seg[x]+size[x]-1\) 即可。

  2. 最短路徑和路徑是一樣的,樹的邊是無向的,總共就n-1條,從x點到y點只有一條路徑,哪有什麼最短路與次短路之分……

  3. 修改一條鏈上的資訊和查詢一條鏈上的資訊方法是一樣的,換個函式名而已,應該不會有人不會吧……

  4. 略……

關於樹的操作太多了,每道題都不一樣,記住一些慣常操作就行了,其他的靠你自己推也應該可以推出來。畢竟資料結構題還是考你程式碼實現能力,思維能力要求不會太高。

邊權樹剖

我也不知道為什麼又要弄一個大標題,其實也沒什麼好講的……

進入正題,樹鏈剖分中有些題它並不給你點權,而是給你邊權,其他的跟點權的題一樣,沒什麼區別。

那麼怎麼把邊權轉點權呢?我們用的辦法一般是讓邊權變成子節點的點權。沒錯,也就是說對於一條邊 \((u,v)\) ,假如u是v的父節點,我們就把這條邊的邊權當做v點的點權,反之亦然,也就是說,根節點無權。

這事其實很簡單,只要在dfs1函式中的迴圈里加上這麼一條語句num[y]=w[i],一切就水到渠成了。

還有一個要修改的地方就是當兩點跳到同一條鏈上時,迴圈外的語句變成這樣query(1,1,n,seg[x]+1,seg[y])

這其實也很容易想明白,因為x這個點是深度最小的那個點,而它的權是它的父節點連向它那條邊的權,顯然不在我們的路徑內。畢竟假設我們查詢時路徑上的點有t個,那麼自然也就只有t-1條邊要查詢/修改。

還有一點要注意的就是,如果題目要求你修改的是輸入的第k條邊,那麼你就要記錄下輸入的點,並在查詢時判斷一下哪個節點是子節點,因為我們第k條邊的邊權轉移到的是當中的兩點中的子節點上,不能修改錯誤。判斷兩點深度大小就可以實現了。

練手題:Grass Planting G

最後給出一道碼量極大的boss題:旅遊