樹形問題選講

kokowork發表於2024-08-26

省流:本篇專供衝擊NOIP一等的人使用,座標HN

本文的洛谷連結

1.ST表&&倍增法求LCA

一對好兄弟。

1.1 ST表

ST表是一種資料結構,不帶修(悲),可以處理滿足可加性可重複貢獻兩條性質的區間資訊

\(e.g.\)

  • 區間最值(大/小)

  • 位運算(按位與/或/非/異或)

  • 區間\(\gcd\) or \(\operatorname{lcm}\)

而且碼量小(小到我還是背不下來(悲)),效率高

一般來說,ST表的預處理為\(O(n\log n)\),查詢直接一個\(O(1)\)震驚\(百萬OIer\),太快了。

我們就開始學習吧!!!

首先我們要知道一個常識,如何求對數……?

先把這道題切了,並且欣賞一下我和工程大佬搶最優解的搞笑過程(?)

然後,萬事開頭難,我們先建個額外的\(ST[maxn]\)陣列,開始啦!!

然後,我們思考如何做到這麼優秀的複雜度。

一般來說,給的資料都是一維的,我們的常規思維是加點維度,讓空間承擔更多可以提前確定的答案,以減少單次查詢的運算。那我們怎麼加呢?

誒,這個時候我們就要用到一種叫倍增的思想了

樸素的查詢肯定是一個個查,但是倍增就不一樣了,一個不行,跳一個再找,還不對,跳兩個,還不對,跳四個……還不對?!跳八個!!!(逼急了屬於是,\(OIer\)做題實錄)

所以一個個查肯定是查了\(O(n)\)次,而倍增可以只查\(O(\log_{2}n)\)次,\(good\)

很好的演算法,使你不知道怎樣用它減小查詢次數

那我們就可以考慮擴充\(ST\),把它的大小擴充\([maxn][\log_2 maxn]\)

它的第一維代表起點,第二維代表倍增查詢的指數,合起來,就表示起點為\(i\),長度為\(2^j\)這段區間的答案。

哦,真就這麼簡單嗎?

啊,是的(

接下來就是激動人心的初始化時間了。

因為\(2^0=1\),所以我們可以直接把原資料塞進\(ST[i][0]\)裡,以\(i\)為起點,長度為\(2^0=1\)

然後,我們怎麼把資料填滿整個表呢?

來,在紙上畫一條較長的線段,把左端點塗的明顯些,這就代表一個長度為\(2^j\)的區間,左端點為\(i\)

然後再複習一個初中知識:

\[2^{j}=2 \times 2^{j-1} \]

所以最開始的區間顯然可以分為兩個區間:\([i,i+2^{j-1}]\)\([i+2^{j-1}+1,i+2^{j-1}+2^{j-1}]\),我們\([i,i+2^{j}]\)的答案就從這兩個子區間轉移上來,比如說最大值,答案就是兩個子區間答案的最大值。

由於我們要填滿整個表,填一次\(O(1),\)所以時間就是\(O(n\log n)\),為表的大小,\(\therefore\) 這也是空間複雜度。

好,那接下來怎麼查詢呢?給定區間\([l,r]\),我們怎麼\(O(1)\)搞定答案呢?

首先,由於ST表的答案儲存依賴長度,我們先把區間長度\(len=r-l+1\)搞到手。然後設\(k=\log_2len\),方便查表,沒問題吧。

接下來就是重量級:

\[ans=ans(st[l][k],st[r-(1<<k)][k]) \]

其中\(ans()\)為你要求的資料的處理函式。

為什麼這樣可以呢?,首先,你需要讀懂那個位運算,這裡不做解釋。我們從這個區間中的兩個地方跳了\(2^k\)長度,總長度就是\(2^{log_2len}=len\),長度是沒問題的,不會遺漏。

但是,怎麼保證\(l+2^k\)一定等於\(r-2^k\)呢,證明就不放了。不要慌,因為這兩個值不需要相等,它們代表的是兩個區間的端點,只要兩個區間有交集,結果就不會錯,這就是可重複貢獻的好處,你可以拿最大值的例子套一套。

同樣的,區間和這種東西就無法用ST表維護,因為它不能遺漏,也不能有交集,我們在ST表中不關心交集的大小,但在區間和中,被加兩次是相當令人頭疼的。

因為查詢可以直接差分,所以就是\(O(1)\)的!

好了,講完了,接下來把模板題切了吧!

習題1.1.1

P3865 【模板】ST表 && RMQ問題

注意,在\(O(n\log n)\)的迴圈中,我們外迴圈是列舉長度指數,內迴圈則是列舉起點,和陣列定義是反過來的(當然,你可以試著把陣列反過來定義,可能沒這麼多問題,但我沒試過)而且終止條件是\(i+2^j-1\le n\),終止條件是顯然的,因為你不能越界。

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+7;
const int log2maxn=25;
#define endl '\n'

int st[maxn][log2maxn];//第一維為陣列個數,第二維為陣列個數的倍增指數
int logs[maxn];

inline int query(int l,int r)
{
	int k=logs[r-l+1];

	return max(st[l][k],st[r-(1<<k)+1][k]);
}

int main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);
	cout.tie(0);
	int n=0,m=0,l=0,r=0;
	cin>>n>>m;
	logs[1]=0;
	for(int i=2;i<=n;i++)
	{
		logs[i]=logs[i>>1]+1;
	}
	for(int i=1;i<=n;i++)
	{
		cin>>st[i][0];
	}
	//預處理
	for(int j=1;j<=logs[n];j++)//先列舉區間長度指數
	{
		for(int i=1;i<=n-(1<<j)+1;i++)//再列舉區間起點
		{
			st[i][j]=max(st[i][j-1],st[i+(1<<(j-1))][j-1]);
		}
	}
	for(int i=1;i<=m;i++)
	{
		cin>>l>>r;
		cout<<query(l,r);
		if(i<m)cout<<endl;
	}
	return 0;
}

好了,學會了這個有用但不是很有用的資料結構,我們來看它的一個重要應用。

1.2 倍增法求LCA

本文不涉及\(tarjan\)法,因為€€£的官方教材上估計是覺得CSP 2022 T3級別的資料\(tarjan\)都過不去,所以寫了一句“實際中幾乎完全被取代”。難繃。

LCA是最近公共祖先的英文縮寫(廢話),指的是樹中的兩個節點的最近的(距離最小的)公共祖先。比如說,你和你的兄弟姐妹的LCA肯定是爸爸媽媽,但是別的情況我們不涉及(

特別的,如果兩個點本身就是一條脈上的,即某個點是另外一個點的祖先,我們規定這兩個點的LCA為深度最小的那個點,這跟\(\gcd(2,4)=2,\operatorname{lcm}(2,4)=4\)是一個道理。

好,那怎麼求出同一棵樹上任兩個節點的LCA呢?

一個暴力的想法馬上就可以寫出來,用兩個變數當“指標”,指著這兩個點,然後依次跳到它們的父親,一直到這兩個指標指到相同節點為止。這個相同節點就是所求的LCA。

由於我們需要一直往上跳,遍歷的節點很多,對於\(n\)個結點的樹進行\(q\)次詢問的話,暴力做法的複雜度達到了\(O(nq)\),很多題目都無法承受。

那麼有什麼最佳化的方法嗎?答案是肯定的,我給你在上面暴力流程中畫一下關鍵詞:

依次跳到它們的父親

看到了吧,跟我們之前講的“一個個查”很類似,那我們也可以異想天開,看看這種樹上查詢能不能用ST表搞定。

這一回,我們的ST表有實際意義了。對於每個\(st[i][j]\),它表示:從節點\(i\)向上跳\(2^j\)層所到達的節點。我們這裡做個約定,由於儲存內容是下表的父親,我們把\(st\)暫時改名叫\(fa\),之前寫ST表的對數表用來給節點儲存深度,改叫\(dep\)。其餘不變。

首先搞定\(fa\)的初始化,由於這不是線性結構的問題了,我們在樹上的預處理受到樹節點連邊的限制。所以,我們把預處理放進樹的\(dfs()\)

我們首先把傳入節點的\(dep\)初始化為它父親的+1,然後,它往上跳一層便是它的父親,所以把\(fa[傳入節點][0]\)初始化為它的父親節點編號。

接下來,我們一直往上跳,直到跳到深度限制(對,就是這麼誇張),但是我們肯定不能一個一個跳,我們是列舉指數,就像初始化ST表那樣。那麼,我們怎麼做呢?\(fa[x][i]\)有什麼規律呢?

我們再回到那個初中等式:

\[2^{j}=2 \times 2^{j-1} \]

有沒有什麼啟發?直接看這個式子顯然沒有。

但是呢,我們可以將“從起點\(i\)\(2^j\)層”這個過程分成兩步,先從\(i\)\(2^{j-1}\)層,再從新起點跳\(2^{j-1}\)層。這樣,我們就跳了\(2\times 2^{j-1}=2^{j}\)層。成功把這個過程等價了。

如果你還沒什麼感觸的話,你應該想到一個式子:

\[fa[u][i]=fa[fa[u][i-1]][i-1] \]

其中,\(u\)是最開始的節點。

這下就明白怎麼向上更新了吧?但是……請問我們到現在為止,乾的事情……跟\(dfs()\)有什麼關係?

好吧,為了在形式上更像\(dfs()\),我們來考慮一下轉移問題。

……還能怎麼轉移?考慮完了自己的爹,肯定要考慮自己的子孫後代的爹了!(無隱喻

我們就遍歷傳入節點的子孫,但是如果你加的是雙向邊的話,那你就很麻煩,因為搞不好判兒子的時候會指父為子,哦,不敢想象……

我們就判定,對於點\(i\)的某條邊,如果它指向的不是父親節點,就以這條邊的另一個端點作為傳入節點,它的父親節點就是\(i\)。順便說一句,父親節點也是在\(dfs()\)的形參列表裡傳進去的。

好,這樣我們就完成了每個點的初始化,\(n\)個點跳了\(\log_2 n\)的高度,總時間複雜度為\(O(n\log_2 n)\),剛好和ST表的預處理一致。

接下來查詢,也沒什麼別的,就是讓\(dep\)大的那個點一路往上跳,這裡的跳是\(fa[u][i]\)。然後當兩個點\(dep\)一致的時候,就一起跳,只是它們倆的跳這下可以基於ST表(\(fa\)),越跳越高,樂。當跳到兩個節點的\(fa\)相同時,就不跳了。

然後,我們返回其中一個節點的\(fa[u][0]\),既然它們的父親相同了,那再跳一步就到了嘛。

如果第一階段的單獨跳結束後,兩個指標剛好重合,那就說明它們在一條脈上,由於已經跳完了,這時返回任意一個節點都可以。記得在一起跳之前判掉。

這樣做,你需要\(O(n\log_2 n)\)的預處理,\(q\)次查詢,每次最多查\(\log_2 n\)個點,那麼查詢就是\(O(q\log_2 n)\),總的就是\(O((n+q)\log_2 n)\)可能會超時,但是對於我們的目標要求已經夠用了

好了,模板題來襲!!!

習題1.2.1

P3379 【模板】最近公共祖先(LCA)

本題用了鄰接表存樹,你用鏈式前向星也可以。

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e7;
const int log2maxn=25;
#define endl '\n'

vector<int> tree[maxn];
int fa[maxn][30];
int dep[maxn];

inline void adde(int u,int v)
{
	tree[u].push_back(v);
}

void dfs(int u,int father)
{
	dep[u]=dep[father]+1;
	fa[u][0]=father;
	for(int i=1;i<=log2maxn;i++)
	{
		fa[u][i]=fa[fa[u][i-1]][i-1];
	}
	for(int i=0;i<tree[u].size();i++)
	{
		if(tree[u][i]!=father)dfs(tree[u][i],u);
	}
}

int lca(int u,int v)
{
	if(dep[u]<dep[v])
	{
		swap(u,v);
	}
	for(int i=log2maxn;i>=0;i--)
	{
		if(dep[fa[u][i]]>=dep[v])
		{
			u=fa[u][i];
		}
	}
	if(u==v)return v;
	for(int i=log2maxn;i>=0;i--)
	{
		if(fa[u][i]!=fa[v][i])
		{
			u=fa[u][i];
			v=fa[v][i];
		}
	}
	return fa[u][0];
}

int main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);
	cout.tie(0);
	int n=0,m=0,root=0,u=0,v=0,l=0,r=0;
	cin>>n>>m>>root;
	for(int i=1;i<=n-1;i++)
	{
		cin>>u>>v;
		adde(u,v);
		adde(v,u);
	}
	dfs(root,0);
	for(int i=1;i<=m;i++)
	{
		cin>>l>>r;
		cout<<lca(l,r);
		if(i<m)cout<<endl;
	}
	return 0;
}

2. 線段樹

眾所周知,ST表雖然能上樹,但是它終究是個靜態的東西,不帶修讓人很難受,我們現在就開始學習一種OIer根本無法繞開不學的資料結構——線段樹!!!(Segment Tree,SGT),它支援動態修改和點查/區查,並且全是\(O(\log_2 n)\)級別的,相當的快啊。

而且還有很多變種:

  • 按值域分的權值線段樹

  • 區間第k小的可持久化線段樹(主席樹)

  • 看上去好像是計算幾何的李超線段樹

  • 卡常大師\(zkw\)線段樹

  • 解決區間歷史問題的吉司機線段樹

  • 線段樹從隔壁平衡樹學的的合併與分裂

  • 在樹套樹的戰場共襄盛舉

但是這些東西本文基本上都不學……

權值線段樹會講,\(zkw\)本來想講,但是發現沒必要(

我們省選再見好吧。

2.1 普通線段樹

線段樹確實是樹形的,但是它維護的還是一個線性序列的資訊,它的節點事實上是維護一個區間的資訊。

所以,我們可以從根節點開始,一步步遞迴,分治,把每個節點所代表的區間端點傳遞下去。為什麼可以遞迴呢?這裡引入一個小結論,我們暫時不予證明:

對於一棵二叉樹,節點\(x\)的左兒子為\(2x\),右兒子為\(2x+1\),寫成位運算的形式為\(x<<1\)\((x<<1)|1\)

做了這麼多題,你應該對下表和資訊儲存之間的關係有些想法了。現在讓我們來看看具體怎麼遞迴。

首先,我們把資訊讀入原線性陣列\(arr\),設其長度為\(n\),然後進行線段樹的初始化。根節點所代表的區間自然是\([1,n]\),如果沒有到葉子節點,我們就別停,設當前傳遞到了區間\([l,r]\),我們取\(mid=\lfloor \frac{l+r}{2}\rfloor\),以\([l,mid]\)\([mid+1,r]\)兩個區間分別遞迴。

哦,什麼時候停呢,不是葉子節點就遞迴,那是葉子節點就停唄!什麼節點算葉子節點呢?當然是分無可分的時候,即\(l==r\),分不出來了。這個時候,我們就設\(tree[pos]=arr[l]\),代表\([l,l]\),當然就是\(arr[l]\)嘛!其中\(pos\)為當前遞迴位置,也可以認為是當前節點個數,不過就不利於理解下面的知識了(

當然,在遞迴建樹完成後,我們還要進行一些小操作,每個節點既然代表了區間資訊,那我們總得把資訊拉到上面的節點來吧,不能沉在底下的葉子吧,你在ST表那裡也幹了這種活。

比如說區間和,我們可以直接把節點\(i\)的左右兒子的答案加在一起,作為節點\(i\)的答案。為什麼呢?有沒有發現,我們遞迴建樹時,剛好把\([l,r]\)對半切開,無縫分成兩個兒子所代表的區間?所以我們統計該節點的答案時,只要把它兒子們的答案相加就可以了。我們把這個操作叫做\(pushup()\),很形象吧,把底下的答案拉上去。

在每個節點的兩個兒子的遞迴都跑完了後,我們就把答案\(pushup()\)上來,如果有目的的話,就是方便查詢)

初始化完了,我們來討論區修和區查,單點就是特殊情況嘛。

首先談談區間修改,我們怎麼做呢,就是把待修改區間傳遞下去,對於每個節點\(i\),如果它所代表的區間能夠被我們所要修改的區間包括的話,那我們肯定傳到足夠小的區間了,把這個節點的答案更新,然後直接返回,跑。

不然的話,那就說明區間還分的不夠小,我們取熟悉的\(mid\),但是是這個節點的左右端點。然後判斷一下,如果傳入的左端點在中間值左邊,那就說明在這個節點代表區間的左半邊有貢獻,左半邊怎麼表示?左兒子嘛!同理,右端點比中間值大的話,右半邊就也需更改,就是右兒子嘛!最後改完了,別忘了\(pushup()\)

查詢也是很簡單,在修改的基礎上改一下:

  • 如果傳入區間大於等於該節點區間,那就查到頭了,直接將該節點的內容返回即可。

  • 不然再計算一下該區間的\(mid\),傳入區間的左端點小的話,就去統計左兒子的貢獻,右端點比\(mid\)大的話,就統計右兒子的貢獻

  • 最後返回答案就可以了,不需要\(pushup()\)

關於這些區間左右兒子,怎麼體現呢?在修改和查詢的函式里都加個\(pos\)引數,直接傳兒子編號就行了,和初始化一樣。最初呼叫時,我們賦1就可以,從根開始搜嘛。

這就是線段樹的基本操作了,下面先來寫道模板題。

習題2.1.1

P3372 【模板】 線段樹 1

首先,線段樹的陣列要開4倍空間,嚴謹證明?從沒給過好吧

這裡再瞭解一個知識:懶標記(lazytag)

說是能延遲修改,但是我沒看出來(

反正如果要繼續向下修改/查詢的話,就要把左右兒子的懶標記加上父親的,同時改掉左右兒子的答案,最後把父親的懶標記清空(傳下去了,不要了)。這個操作叫\(pushdown()\),就是把懶標記傳下去。這個如果改到了葉子,那就直接加上懶標記就行了。

好了,你已經瞭解完了,可以先試試,我沒有能力搞來好圖片演示,靠幹講實在不太行。可以對著我的程式碼調(

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e6+7;
#define int long long

int arr[maxn];
struct node
{
	int l;
	int r;
	int sum;
	int lazy;
};
node tree[maxn*4];

inline int lson(int pos)
{
	return pos<<1;
}

inline int rson(int pos)
{
	return (pos<<1)|1;
}

inline void pushup(int pos)
{
	tree[pos].sum=tree[lson(pos)].sum+tree[rson(pos)].sum;
}

inline void pushdown(int pos)
{
	if(tree[pos].lazy!=0)
	{
		tree[lson(pos)].lazy+=tree[pos].lazy;
		tree[rson(pos)].lazy+=tree[pos].lazy;
		tree[lson(pos)].sum+=tree[pos].lazy*(tree[lson(pos)].r-tree[lson(pos)].l+1);
		tree[rson(pos)].sum+=tree[pos].lazy*(tree[rson(pos)].r-tree[rson(pos)].l+1);
		tree[pos].lazy=0;
	}
}

void build(int pos,int l,int r)
{
	tree[pos].l=l;
	tree[pos].r=r;
	tree[pos].lazy=0;
	if(l==r)
	{
		tree[pos].sum=arr[l];
		return;
	}
	int mid=(l+r)>>1;
	build(lson(pos),l,mid);
	build(rson(pos),mid+1,r);
	pushup(pos);
	return;
}

int query(int L,int R,int pos)
{
	if(tree[pos].l>=L&&tree[pos].r<=R)
	{
		return tree[pos].sum;
	}
	pushdown(pos);
	int mid=(tree[pos].l+tree[pos].r)>>1;
	int sum=0;
	if(L<=mid)sum+=query(L,R,lson(pos));
	if(R>mid)sum+=query(L,R,rson(pos));
	return sum;
}

void update(int L,int R,int pos,int val)
{
	if(tree[pos].l>=L&&tree[pos].r<=R)
	{
		tree[pos].sum+=val*(tree[pos].r-tree[pos].l+1);
		tree[pos].lazy+=val;
		return;
	}
	else
	{
		pushdown(pos);
		int mid=(tree[pos].l+tree[pos].r)>>1;
		if(L<=mid)update(L,R,lson(pos),val);
		if(R>mid)update(L,R,rson(pos),val);
		pushup(pos);
	}
}

signed main()
{
	int n=0,m=0,op=0,x=0,y=0,k=0;
	cin>>n>>m;
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
	}
	build(1,1,n);
	for(int i=1;i<=m;i++)
	{
		cin>>op>>x>>y;
		if(op==1)
		{
			cin>>k;
			update(x,y,1,k);
		}
		else
		{
			cout<<query(x,y,1);
			if(i<m)cout<<endl;
		}
	}
	return 0;
}

習題2.1.2

P3373 【模板】 線段樹 2

這裡有兩個懶標記那就開兩個唄(,傳遞時改區間答案,記得先乘上乘法的lztag,再加上加法的。主要是為了符合運算律。主要是加法tag更新時要乘上乘法的tag,這一點很麻煩。可以像下面一樣寫個專門的函式計算。

而且乘法懶標記要賦為\(1\)!賦\(0\)就改不動了!

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e6+7;
#define int long long

int arr[maxn];
struct node
{
	int l;
	int r;
	int sum;
	int add;
	int mul;
};
node tree[maxn*4];

inline int lson(int pos)
{
	return pos<<1;
}

inline int rson(int pos)
{
	return (pos<<1)|1;
}

inline void calc(int pos,int add,int mul,int mod)
{
	tree[pos].sum=(tree[pos].sum*mul+(add*(tree[pos].r-tree[pos].l+1)))%mod;
	tree[pos].mul=(mul*tree[pos].mul)%mod;
	tree[pos].add=(tree[pos].add*mul+add)%mod;
}

inline void pushup(int pos)
{
	tree[pos].sum=tree[lson(pos)].sum+tree[rson(pos)].sum;
}

inline void pushdown(int pos,int mod)
{
	calc(lson(pos),tree[pos].add,tree[pos].mul,mod);
	calc(rson(pos),tree[pos].add,tree[pos].mul,mod);
	tree[pos].add=0;
	tree[pos].mul=1;
}

void build(int pos,int l,int r)
{
	tree[pos].l=l;
	tree[pos].r=r;
	tree[pos].add=0;
	tree[pos].mul=1;
	if(l==r)
	{
		tree[pos].sum=arr[l];
		return;
	}
	int mid=(l+r)>>1;
	build(lson(pos),l,mid);
	build(rson(pos),mid+1,r);
	pushup(pos);
	return;
}

int query(int L,int R,int pos,int mod)
{
	if(tree[pos].l>=L&&tree[pos].r<=R)
	{
		return tree[pos].sum;
	}
	pushdown(pos,mod);
	int mid=(tree[pos].l+tree[pos].r)>>1;
	int sum=0;
	if(L<=mid)sum+=query(L,R,lson(pos),mod)%mod;
	if(R>mid)sum+=query(L,R,rson(pos),mod)%mod;
	return sum;
}

void update(int L,int R,int pos,int add,int mul,int mod)
{
	if(tree[pos].l>=L&&tree[pos].r<=R)
	{
		calc(pos,add,mul,mod);
		return;
	}
	else
	{
		pushdown(pos,mod);
		int mid=(tree[pos].l+tree[pos].r)>>1;
		if(L<=mid)update(L,R,lson(pos),add,mul,mod);
		if(R>mid)update(L,R,rson(pos),add,mul,mod);
		pushup(pos);
	}
}

signed main()
{
	int n=0,m=0,mod=0,op=0,x=0,y=0,k=0;
	cin>>n>>m>>mod;
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
	}
	build(1,1,n);
	for(int i=1;i<=m;i++)
	{
		cin>>op>>x>>y;
		if(op==1)
		{
			cin>>k;
			update(x,y,1,0,k,mod);
		}
		else if(op==2)
		{
			cin>>k;
			update(x,y,1,k,1,mod);
		}
		else
		{
			cout<<query(x,y,1,mod)%mod;
			if(i<m)cout<<endl;
		}
	}
	return 0;
}

習題2.1.3

P8856 [POI2002]火車線路

來看一道線段樹的初級應用,這裡也講解一下,線段樹如何解RMQ問題。

思路很簡單,把時間軸看成是原線性陣列,初始每個元素都為\(s\),,然後初始化。每次查詢在這段時間裡的座位數的最小值,如果大於需要的座位,那就可以買,整段區間減掉座位,不然就不可以總司令

用線段樹求這種動態RMQ問題和求區間和有什麼區別呢?

……

沒有什麼區別(

  • \(pushup()\)裡的加和改成\(\min\) or \(\max\)

  • 查詢函式里的累加改成\(\min\) or \(\max\)

結束。額……確實只有這種區別(

哦,資料表明,到第\(D\)天,人家下車了,不佔座位了,所以所有查詢和修改的區間都應為\([O,D)\) or \([O,D-1]\)

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+7;

struct node
{
	int l;
	int r;
	int val;
	int lazy;
};
node tree[maxn*4];

inline int lson(int pos)
{
	return pos<<1;
}

inline int rson(int pos)
{
	return (pos<<1)|1;
}

inline void pushup(int pos)
{
	tree[pos].val=min(tree[lson(pos)].val,tree[rson(pos)].val);
}

inline void pushdown(int pos)
{
	if(tree[pos].lazy)
	{
		tree[lson(pos)].lazy+=tree[pos].lazy;
		tree[rson(pos)].lazy+=tree[pos].lazy;
		tree[lson(pos)].val+=tree[pos].lazy;
		tree[rson(pos)].val+=tree[pos].lazy;
		tree[pos].lazy=0;
	}
}

void build(int pos,int l,int r,int s)
{
	tree[pos].l=l;
	tree[pos].r=r;
	tree[pos].lazy=0;
	if(l==r)
	{
		tree[pos].val=s;
		return;
	}
	int mid=(l+r)>>1;
	build(lson(pos),l,mid,s);
	build(rson(pos),mid+1,r,s);
	pushup(pos);
	return;
}

void change(int pos,int l,int r,int val)
{
	if(tree[pos].l>=l&&tree[pos].r<=r)
	{
		tree[pos].val+=val;
		tree[pos].lazy+=val;
		return;
	}
	else
	{
		pushdown(pos);
		int mid=(tree[pos].l+tree[pos].r)>>1;
		if(l<=mid)change(lson(pos),l,r,val);
		if(r>mid)change(rson(pos),l,r,val);
		pushup(pos);
	}
}

int query(int pos,int l,int r)
{
	if(tree[pos].l>=l&&tree[pos].r<=r)
	{
		return tree[pos].val;
	}
	pushdown(pos);
	int mid=(tree[pos].l+tree[pos].r)>>1;
	int res=0x3f3f3f3f;
	if(l<=mid)res=min(query(lson(pos),l,r),res);
	if(r>mid)res=min(query(rson(pos),l,r),res);
	return res;
}

int main()
{
	int n=0,num=0,m=0,l=0,r=0,k=0;
	cin>>n>>num>>m;
	build(1,1,n,num);
	for(int i=1;i<=m;i++)
	{
		cin>>l>>r>>k;
		if(query(1,l,r-1)>=k)
		{
			cout<<"T";
			change(1,l,r-1,-k);
		}
		else cout<<"N";
		if(i<m)cout<<endl;
	}
	return 0;
}

比分塊來說,SGT可能更優,但也更容易被卡(,而且確實沒有分塊好寫。

2.2 權值線段樹

權值線段樹肯定是沿襲了線段樹的基本框架,但是原線性陣列已被確定為題目所給值域,所以不用初始化了。

所以我們\(tree\)陣列就可以確定為\(int\)型別,表示每個數值出現的次數,當然你硬是要用\(node\)型別也可以,但值域不大,不用最佳化時,我們顯然可以偷懶,直接寫成\(int\),降低編碼難度。

如果不用初始化,那我們修改和查詢的區間傳什麼呢?你……肯定是在整個線性陣列範圍內查詢啊,之前線段樹是\([1,n]\),那我們現在變成以值域作為線性陣列,那最初傳入的區間就是值域嘛!即\([-maxn,maxn]\)。如果題目中沒有給負數,那就\([0,maxn]\),如果0也不給,那你照著寫唄,總不能都是我給結論吧(

好,接下來我們瞭解一下權值線段樹的功能:

  • 插入/刪除一個數

相當於線上性陣列上加減一個數,出現次數\(\pm 1\)

  • 查詢這個數在全域性範圍內多大

比它小的數有多少個,加上它自己1個。

  • 查詢現在第\(k\)小的數

因為線段樹的每個葉子都是線性序列上的節點,所以我們只要找到第\(k\)小的葉子就可以了。

區間第\(k\)小請轉向主席樹 or 樹套樹

  • 查全域性前驅後繼

查查自己多大,再查查自己前後面是誰,搞定。

現在我們來一步步實現這些操作。

首先,把前面線段樹的修改函式先拷過來。然後\(l==r\)的那個判葉子程式碼改成tree[pos]++或tree[pos]--,就表示我們在這個位置新增或者刪除一個數了,\(pushup()\)還是要的,畢竟還要把每個區間的資料逐級拉上來。

我們的\(mid\)取傳入區間的中間值,如果我們要修改的數比中間值小,顯然要找左兒子,否則就找右兒子。

關於查詢第\(k\)小的數呢,我們直接以\(k\)為基礎判斷,對於當前節點,如果\(k\)小於等於它的左邊界,那不行,找找左兒子,顯然傳這個區間的一半作為值域,\(mid\)是老朋友了。不然的話,就說明整個左子樹都小於\(k\),那麼我們就找右子樹,但是右子樹要減掉左子樹的大小,因為左子樹佔了名額嘛。如果找到合適的葉子了,我們就返回葉子的邊界(也就是第\(k\)小的數嘛)

那麼,我們怎麼進行這個逆操作,即查詢一個數是多大呢?先把剛寫的第\(k\)小拷過來,然後略作改動。具體就是如果這個數小於我們的老朋友\(mid\),那就查左子樹,反之,就查它在右子樹中的排名,然後加上左子樹的大小,左子樹也佔了名額嘛。

有了這兩個互逆操作,我們查前驅後繼就簡單多了,首先,看看自己要傳入的數是第幾小,然後再查第(幾-1)或(幾+1)的數就可以了。

事實上,這幾個功能跟我們接下來要講的平衡樹極為接近,所以我們可以找平衡樹的板子來練習權值線段樹。

習題2.2.1

P3369 【模板】普通平衡樹

當你學了平衡樹後,你肯定會覺得還是權值線段樹好寫,而且平衡樹不一定能跑得比權值線段樹快,只是權值線段樹的區間比較大(雖然也沒看到題目卡),比如說本題的值域就為\(\pm 1e7\),加上線段樹的四倍空間,我們可以把陣列開到\(8 \times 10^7\),要透過本題有點困難,我們來介紹權值線段樹(其實普通線段樹也可以)的一種空間最佳化:動態開點。

什麼是動態開點呢?我們給線段樹開四倍空間,這些空間不管你用不用都要開在那裡,很沒必要。動態開點就是需要修改(在權值線段樹裡體現在新增或刪除一個數)時才開這個點,如果有數從來沒出現過,我們就不開這個點了,這樣就可以把實際空間節省到點數\(\times 4\),當然你開4倍空間時還是要寫\(1e7\)這個值域的。

區別主要體現在插入和刪除上,我們設一個變數\(root\)表示根節點並賦0,將傳入的pos改為引用傳遞(在函式里的修改是改你傳進來的變數,所以不能傳一個數),然後先把pos傳進去。再設一個\(tot\)表示總點數。

在添刪的操作裡,如果傳入的pos為空,那我們就直接把\(tot\)加一,然後把root賦成新的tot。因為傳進來的都是左右兒子,如果他們為空,那肯定要新加點。

加了動態開點以後,節點的左右邊界會變得不確定(因為省了一些點),建議直接開個結構體存著。這裡的root是傳了個引用,一開始賦成0,後面會自動改的,即根節點的編號。

然後就沒了,沒了?沒了。

下面沒給樸素寫法(樸素寫法又不是AC Code),直接加了動態開點,以供對照。

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+7;//詢問數
const int maxn2=1e7+5;//值域

int tree[maxn*4];//每個節點儲存內容
struct interval
{
	int l;
	int r;
};
interval node[maxn*4];//每個點所代表的邊界
int tot=0;//點數

inline void pushup(int pos)
{
	tree[pos]=tree[node[pos].l]+tree[node[pos].r];
}

void ins(int &pos,int l,int r,int val)//插入
{
	if(!pos)pos=++tot;//新增一個點
	if(l==r)//只有葉子值域才會縮成一個點
	{
		tree[pos]++;//rp++
		return;//要是不返回你就死定了
	}
	int mid=(l+r)>>1;//二分
	if(val<=mid)ins(node[pos].l,l,mid,val);//插入的數比當前節點代表的區間的中間值還小,那就往左邊找
	else ins(node[pos].r,mid+1,r,val);//要不還是往右邊找吧
	pushup(pos);//別忘了把這個數彙報給上級
}

void del(int &pos,int l,int r,int val)//刪除,和插入基本一致
{
	if(!pos)pos=--tot;//關於這個點,它死了。別忘了把這個點銷戶
	if(l==r)
	{
		tree[pos]--;
		return;
	}
	int mid=(l+r)>>1;
	if(val<=mid)del(node[pos].l,l,mid,val);
	else del(node[pos].r,mid+1,r,val);
	pushup(pos);
}

int kth(int pos,int l,int r,int k)//全域性第k個
{
	if(l==r)return l;//找到合適的葉子了,很明顯葉子的邊界就是排名
	int mid=(l+r)>>1;//二分again
	if(k<=tree[node[pos].l])return kth(node[pos].l,l,mid,k);//你要找的數在左子樹上
	else return kth(node[pos].r,mid+1,r,k-tree[node[pos].l]);//你要找的數在右子樹上,同時,左子樹佔了名額,把它們都減去
}

int getrank(int pos,int l,int r,int val)//全域性排名查詢
{
	if(l==r)return 1;//找到了!
	int mid=(l+r)>>1;//二分again again
	if(val<=mid)return getrank(node[pos].l,l,mid,val);//去左邊那桌
	else return getrank(node[pos].r,mid+1,r,val)+tree[node[pos].l];//去右邊那桌,記得把左邊的名額帶過來
}

int main()
{
	int n=0,op=0,x=0,root=0;
	cin>>n;
	for(int i=1;i<=n;i++)
	{
		cin>>op>>x;
		if(op==1)
		{
			ins(root,-maxn2,maxn2,x);
		}
		if(op==2)
		{
			del(root,-maxn2,maxn2,x);
		}
		if(op==3)
		{
			cout<<getrank(root,-maxn2,maxn2,x);
			if(i<n)cout<<endl;
		}
		if(op==4)
		{
			cout<<kth(root,-maxn2,maxn2,x);
			if(i<n)cout<<endl;
		}
		if(op==5)
		{
			int num=getrank(root,-maxn2,maxn2,x);//前驅肯定是第k-1個數
			cout<<kth(root,-maxn2,maxn2,num-1);
			if(i<n)cout<<endl;
		}
		if(op==6)
		{
			int num=getrank(root,-maxn2,maxn2,x+1);//後繼肯定是第k+1個數
			cout<<kth(root,-maxn2,maxn2,num);
			if(i<n)cout<<endl;
		}
	}
	return 0;
}

2.3 樹狀陣列

學了線段樹,你可能會被它的碼量深深折服(

其實它還有種碼量小的對手(當然實際應用中不是這麼回事),叫樹狀陣列

二者的思維比較相近,都是把線性結構轉為樹形結構。但

  • 樹狀陣列的額外陣列不需要開四倍空間,而只有一倍空間

  • 能夠做到和線段樹一樣的修改與查詢的時間複雜度,即\(O(\log_2 n)\),相當優秀。

  • 當然,它的擴充性極差,而且區間修改和區間查詢放到一起就會極麻煩,所以我們就不講這種情況了(

它是怎麼做到的呢?這個問題比較抽象。簡單來說,它刪掉了一棵普通的二叉樹的一些節點,與線段樹一樣,原陣列沉在最底下,然後上面的輔助節點的編號進行二進位制展開後最後面的1屁股後面的零坨一樣多,零坨越多,轉化成樹的深度越淺。

似乎很抽象,我們舉個例子,假設原陣列長度為\(8\)

然後我們把1-8展開成二進位制:

\[\begin{aligned} 1=000\color{#66CCFF}{1}\\ 2=00\color{#EE0000}{10}\\ 3=001\color{#66CCFF}{1}\\ 4=0\color{#006666}{100}\\ 5=010\color{#66CCFF}{1}\\ 6=01\color{#EE0000}{10}\\ 7=011\color{#66CCFF}{1}\\ 8=1000 \end{aligned} \]

容易發現,相同顏色標記的數字都滿足這個特點。所以,對有\(8\)個元素的陣列,我們構建樹狀陣列後,應該是如下結構:

  • 第一層:\(tree[8]\)

  • 第二層:\(tree[4]\)

  • 第三層:\(tree[2],tree[6]\)

  • 第四層:\(tree[1],tree[3],tree[5],tree[7]\)

  • 第五層:原陣列。

每層有什麼節點清楚了……清楚了嗎?\(n\)個節點都這麼推嗎?,如何讓計算機去數最後面的零坨?

我們在這裡介紹一種運算:\(lowbit()\)

首先我們先給待求數按位取反,然後+1,由計算機基本原理可知,這個操作相當於直接取負,所以不要怕實現複雜。

然後,我們拿著這個數和原數進行按位與,C++就一個&,我們不管了。

比如說\(6\),我們展示它有內容的位元組(int有四個位元組嘛),演示一下這個過程:

  • 原數:\(00000110\)

  • 按位取反:\(11111001\)

  • 加一:\(11111010\)

  • 按位與:

\[\begin{aligned} 原數:00000110\\ 變換:11111010\\ 結果:00000010 \end{aligned} \]

你看,是不是就拿到了一個數最後一個1和後面的零坨?

數學證明就不要深究了……whk數學能拿多少分,還能看得懂這種證明。( (無惡意)

我們就規定,樹狀陣列\(tree[i]\)儲存的是原陣列的右端點為\(i\)的資訊。

???

那左端點呢?\(1\)嗎?nonono,那跟字首和陣列還有什麼區別

我們又在這裡定義,\(tree[i]\)的所儲存的區間的長度是\(lowbit(i)\),這樣的話,手模一下上面的例子,\(tree[i]\)的元素各自儲存的區間為:

\[\begin{aligned} tree[1]=[1,1]\\ tree[2]=[1,2]\\ tree[3]=[3,3]\\ tree[4]=[1,4]\\ tree[5]=[5,5]\\ tree[6]=[5,6]\\ tree[7]=[7,7]\\ tree[8]=[1,8] \end{aligned} \]

然後呢?這麼規定有什麼用?

別急啊,首先,我們思考一下線段樹的幾個功能

因為前面說了區修區查一起搞很複雜,所以,我們分點修+區查區修+點查兩種情況來討論。

  • 點修+區查

我們考慮對單點進行修改,比如說,我們改\(arr[1]\),那跟\(tree\)陣列有什麼關係呢?一看先前的表,哦,\(tree[1],tree[2],tree[4],tree[8]\)都涉及到了\(arr[1]\)這個位置,那都要改咯。

但是每一個點都會牽扯到這麼多區間嗎?我們怎麼進行\(tree[i]\)更改的轉移呢?

我們研究\(1,2,4,8\)這四個數的關係,這時,我們就請出我們的利器\(lowbit()\),既然都說了有關係,先拿它試試唄:

\[\begin{aligned} lowbit(1)=1\\ lowbit(2)=2\\ lowbit(4)=4 \end{aligned} \]

哦,似乎有點好玩的事情。

\[\begin{aligned} 1+lowbit(1)=2\\ 2+lowbit(2)=4\\ 4+lowbit(4)=8 \end{aligned} \]

哦!我們線上段樹裡就發現了,深度越淺的節點區間越大,所以當修改了一個點時,我們要把它的修改值傳上去,就可以一直給它的下標\(+lowbit(它的編號)\),這樣做可以不重不漏,好耶!

但這只是個孤證啊!沒事,我們再改個\(arr[5]\),查表,先改\(tree[5]\),加個\(lowbit(5)=1\),轉移到了\(tree[6]\),再加個\(lowbit(6)=2\),就到了\(tree[8]\)了!可以證明沒有哪個包含\(arr[5]\)的區間被落下。

那就這樣了,假如我們要修改\(arr[i]\),我們只要修改它對應的\(tree[i]\),然後一路加\(lowbit()\)轉移,轉移一次修改一次,直到\(tree[n]\)修改完成,因為\(tree[n]\)代表了\([1,n]\),這肯定是最大的,所以不用再修改了。我們就退出。

那區間查詢呢?好像有點不方便。比如我們要查詢\([1,6]\),查表,可以直接返回\(tree[4]+tree[6]\),那查詢\([3,6]\)呢?不行了吧。

但是我們可以觀察到,查詢\([1,i]\)這個區間,基本上就是修改的逆操作,我們可以直接減減減\(lowbit()\),減到\(tree[1]\)打止。

那我們有一個構想,對於可差分的資訊,比如區間和,\([l,r]\)的答案顯然就是\([1,r]\)的答案減去\([1,l-1]\)的答案,那麼我們只要查詢兩次,再把答案相減就可以了,那麼不可差分的資訊怎麼維護?對不起,不好搞,對於我們無法解決的問題,我們就把它扔進限制條件,讓後世\(OIer\)們受苦吧)

當然,存在一種\(O(\log^2n)\)(對你沒看錯)的做法可以這麼做,但我們用不上,所以鴿了,咕咕咕。

由於這種模式下查詢時\(tree\)下標往前走,修改時往後走,我們可以記成“向前查,向後修”

習題2.3.1

P3374 【模板】樹狀陣列 1

本題為\(div.\)點修+區查

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=5e5+7;

int treearr[maxn];

inline int lowbit(int x)
{
	return x&-x;
}

void change(int n,int pos,int val)
{
	while(pos<=n)
	{
		treearr[pos]+=val;
		pos+=lowbit(pos);
	}
}

int query(int pos)
{
	int t=0;
	while(pos>0)
	{
		t+=treearr[pos];
		pos-=lowbit(pos);
	}
	return t;
}

int main()
{
	int n=0,m=0,buf=0,op=0,x=0,val=0,l=0,r=0;
	cin>>n>>m;
	for(int i=1;i<=n;i++)
	{
		cin>>buf;
		change(n,i,buf);
	}
	for(int i=1;i<=m;i++)
	{
		cin>>op;
		if(op==1)
		{
			cin>>x>>val;
			change(n,x,val);
		}
		else
		{
			cin>>l>>r;
			cout<<query(r)-query(l-1);
			if(i<m)cout<<endl;
		}
		
	}
	return 0;
}
  • 區修+點查

我們前面點查修改一次要從樹上爬下去,是\(O(\log_2 n)\)的。如果暴力掃的話會是\(O(n\log_2 n)\),題目受不住。

我在這時看了一眼自己的程式碼……所以為什麼要暴力掃?

我們在區間查詢時提到了兩點:

一次查詢,查的是\([1,i]\)

查詢和修改剛好方向相反

那麼,我們也可以發現,區修實質上可以沿用點修的函式,點修影響了包含這個點的區間,可以認為,在它修改的區間內,最大的是\([i,n]\)(雖然實際上沒有改這麼多)如果這些區間中有的不能改,我們可以把它改回來就是了。

我們試試這個猜想,對於區間\([3,5]\),我們修改一下\(3\)

\[ 改tree[3],即[3,3]\\ lowbit(3)=1,3+1=4\\ 改tree[4],即[1,4]\\ lowbit[4]=4,4+4=8\\ 改tree[8],即[1,8] \]

我們發現,實質上,你可以認為修改對\([3,8]\)都產生了影響,但我們只要修改\([3,5]\),所以應該把\([6,8]\)的影響抵消掉

怎麼做?不難發現,以\(6\)為起點,數值相反,再做一次區修嘛!\(6\)是甚麼?右端點\(5\)再加一嘛!

好,因為只爬了兩次,我們依然只有\(O(\log_2 n)\)

我們居然把\(O(n\log_2 n)\)打成了\(O(\log_2 n)\),整整一個\(n\)啊!這就是演算法的力量!

接下來是最後一關了!點查怎麼寫?

由於樹狀陣列區修區查奇怪的不相容性質,搭配區修,點查也爬兩次會出現一點小錯誤怎麼都算不對。我們在這種情況下讀入資料時應直接把資料讀入原陣列\(arr\),然後點查\(i\)結果就是給\(i\)的區查函式的結果加上\(arr[i]\)。原理我們不予證明,因為樹狀陣列此時的性質已經變為了差分陣列。

習題2.3.2

P3368 【模板】樹狀陣列 2

本題為\(div.\)區修+點查

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=5e5+7;

int treearr[maxn];
int arr[maxn];

inline int lowbit(int x)
{
	return x&-x;
}

void change(int n,int pos,int val)
{
	while(pos<=n)
	{
		treearr[pos]+=val;
		pos+=lowbit(pos);
	}
}

int query(int pos)
{
	int t=0;
	while(pos>0)
	{
		t+=treearr[pos];
		pos-=lowbit(pos);
	}
	return t;
}

int main()
{
	int n=0,m=0,op=0,x=0,val=0,l=0,r=0;
	cin>>n>>m;
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
	}
	for(int i=1;i<=m;i++)
	{
		cin>>op;
		if(op==1)
		{
			cin>>l>>r>>val;
			change(n,l,val);
			change(n,r+1,-val);
		}
		else
		{
			cin>>x;
			cout<<query(x)+arr[x];
			if(i<m)cout<<endl;
		}
		
	}
	return 0;
}

樹狀陣列比線段樹的優點就是碼量巨小,好除錯,沒別的。

3.平衡樹選講

平衡樹是一種較為強勁但真的很麻煩的二叉搜尋樹。

一般平衡樹的時間都比權值線段樹長,但是空間只需一倍,而不是8倍,但有動態開點後還管什麼!平衡樹主要還能解決區間翻轉,比如所謂“文藝平衡樹”,這個權值線段樹比較困難,不好實現對應的操作。

多種平衡樹的主要作用都是防止二叉查詢樹退化成鏈。一般的,當插入的節點過多過密時,線段樹基本上就只有一條鏈有實際資訊了。比如想象一下插入\({1,4,5}\)三種元素的其中一個,插\(1e7\)次,那查詢時肯定有許多冗餘區間,而且會把樹高退化到\(O(n)\),更不利於查詢。平衡樹就是透過給每個點一個隨機權值,使樹高維持在\(O(\log_2 n)\),防止成鏈。

平衡樹基於這兩個指標維護樹高和查詢的操作也是多種多樣而且很抽象的,一般來說,平衡樹和它們維護樹高的操作如下:

  • \(有旋treap\):左旋,右旋

  • \(伸展樹/Splay\):旋轉

  • \(無旋treap/fhq-treap\):分裂,合併

是不是隻有最後一個最特殊?那就是我們要學的(

\(fhq-treap\)是範浩強大佬在\(treap\)的基礎上,基本上掀了加以改進得到的。所謂\(treap\),就是\(tree+heap\),前者是樹,後者是堆。

具體來說,對於每個點,我們賦兩個值:\(val\)\(randval\),前者就是你要新增/刪除的數,後者用\(rand()\)決定。對於\(val\),我們採用二叉搜尋樹來儲存,即左子樹每個節點的值都小於根節點,右子樹每個結點的值都大於根節點,但我們再根據\(randval\),把這棵樹稍作修改,使其符合小根堆的性質。堆結構就是用來維持樹高穩定的。所以叫平衡樹。

好,我們先來了解平衡樹結點的結構,它一般如下所示:

struct node
{
  int l;
  int r;
  int size;
  int val;
  int randval;
}
node tree[maxn];

在權值線段樹裡你也感受到了,每個結點的所有兒子並無一個穩定的規律,所以這裡的\(l\)\(r\)都是指向當前節點的左右兒子,其餘的前面已經提到過了。

新建一個節點就只要填表了。我們設一個\(idx\)表示當前總點數。

void newnode(int &x,int val)
{
  x=++idx;//直接更改新點數,不返回了
  tree[idx].val=val;
  tree[idx].size=1;
  tree[idx].randval=rand();
}

回到操作,普通\(treap\)維持堆結構的操作就是左旋和右旋,當\(randval\)不對勁時,根據\(randval\)的大小,把根節點和左右兒子對調。就像是旋轉了一樣。

這麼轉來轉去確實太容易把人轉糊塗了,所以有旋treap在賽場上的使用率最低(

\(範夶(\mathrm{b\check{i}})奆(\mathrm{ju\grave{a}n})\)不是怎麼想的,他提出了兩個新操作:分裂與合併

怎麼做呢?

分裂有兩種,要麼按節點值域將一棵樹分成兩棵,並刪掉一些邊,使其仍符合平衡樹的結構;要麼按子樹大小分。前者一般用在普通平衡樹,後者一般用在文藝平衡樹。受限於例題要求,本文只講按節點值域分裂。

按值域分裂,我們先確定引數,和線段樹差不多:

void split(int pos,int val,int &x,int &y);

它們分別表示當前位置、按什麼值分裂,新的兩棵樹的樹根,可以看成是對節點的左右兒子的關係的更改。根節點和左右兒子的關係就是樹的邊嘛,這樣就讓我們聯想到加邊和刪邊,也就是分裂和合並中必備的操作。

好,首先,如果當前位置\(pos\)為空,那我們可能走錯節點了,應該把傳進來的\(x\)\(y\)都改成\(0\),表示我們發現這兩個邊應該要刪掉。然後就撤。

\(pos\)不為空呢?還記得\(treap\)\(tree\)性質嗎?由於按值分裂,我們就考慮傳入的\(val\)和當前節點的\(val\)的關係。

如果當前節點的\(val\le\)傳入的\(val\),那根據\(tree\)性質,比當前節點\(val\)大的肯定是右兒子,我們就以右兒子作為\(pos\),並且別忘了我們還要分裂,當前節點和右兒子必須分開,也就是它們連的邊要刪掉。

分裂完以後,可能會出現新的樹,所以我們要及時把\(x\)\(y\)的引用改成對應的\(pos\),表示節點兒子的更新。(指指點點)(C++的引用太折磨人啦)

既然刪了邊,那也要考慮連上邊,但是好訊息是這裡不需要了,我們傳的\(x\)\(y\)都是引用,既然傳的是當前節點的左右兒子指標,那麼指標也會被自動修改成分裂後正確的兒子。

如果是\(<\),那麼我們顯然要找左子樹,而且也要刪掉當前節點和左子樹的邊。

最後,我們再更新下節點的\(size\)值,使其符合修改後的實際。也是從前幾天的線段樹copy一下\(pushup()\)就行了(但是要把根節點自己算進去,所以最後還要加1)。

最後,你應該寫出如下函式:

不管是分裂&&合併、還是左旋&&右旋、還是旋轉,都建議手模到懂了程式碼到底在幹什麼為止

void split(int pos,int val,int &x,int &y)
{
  if(!pos)
  {
    x=y=0;
    return;
  }
  if(tree[pos].val<=val)
  {
    x=pos;
    split(tree[x].r,val,tree[x].r,y);
    pushup(x);
  }
  else
  {
    y=pos;
    split(tree[y].l,val,x,tree[y].l);
    pushup(y);
  }
}

這裡給一組樣例,忽略了\(size\)的變化,你可以手模一下:

6 5
//邊的方向:u→v
7 5 8 3 6 13//每個點的大小
1 2
1 3
2 4
2 5
3 6

然後我們再來看看合併。先給出合併的函式原型:

int merge(int x,int y);

這裡的兩個引數就是要合併的兩棵樹的根節點,然後返回值就是新樹的根節點。還是很簡單的。接下來就開始實現

首先判一下,如果有任意一棵子樹為空,那我們就直接返回另一個,你問怎麼寫?x=0能不能推出x+y=y?對的,我們直接返回x+y

如果兩棵子樹都不為空,那肯定就要準備合併了,我們在這裡規定一個前提,兩棵子樹都必須是\(val\)有序的,且有一棵子樹的\(val\)必須全部小於另一棵樹的\(val\)。這樣,我們就只關心哪棵樹的根作為新樹的根,兩棵樹內部就不用調整邊了。這可是不能亂搞的,合併是分裂的逆操作,我們做完一次分裂以後,再做一次合併要能復原。所以分裂斷掉的邊,合併還要補上。

我們這時就要想起\(treap\)\(heap\)性質,如果我們隨意確定根節點的話,很可能會讓樹鏈化。所以這時,我們使用\(randval\)來判斷。\(randval\)小的放在上面,以維護小根堆的形態。

同樣的,合併也是遞迴的過程,由於只要把\(randval\)小的放上面,我們的遞迴就很簡單了。

你可以寫出如下程式碼:

int merge(int x,int y)
{
  if(!x||!y)return x+y;
  else
  {
    if(tree[x].key<tree[y].key)
    {
      tree[x].r=y;
      pushup(x);
      return x;
    }
    else
    {
      tree[y].l=x;
      pushup(y);
      return y;
    }
  }
}

砈氬,我們花了這麼長的篇幅講這兩種基本操作,接下來該講講怎麼用它們兩個實現那幾個具體功能了吧?

首先是新增/刪除數。我們平衡樹把相同的數作為不同的節點,因為開出來的\(randval\)很可能不相同。所以增刪數就相當於加減節點。那我們來思考,怎麼樣分裂合併會讓節點數發生改變呢?

首先是增加數,我們首先將原樹按要加的這個數分裂,這樣,分裂完的左子樹應該全部小於等於要加的數,右子樹就應該全部大於,為了維護堆的性質,如果把新節點和右子樹先合併,那左右子樹值域就可能有交叉,那不行。所以,我們先將新節點作為一棵樹和左子樹合併,然後新左子樹再和右子樹合併。

簡單說就是“分裂一次,合併兩次

刪掉一個數,就先按要刪的數將原數分開,左子樹肯定是小於等於這個數的,所以我們再對左子樹進行(傳入數-1)的分裂,第二次右子樹的根節點就存在第三個變數裡備用。這樣第三個變數為根的子樹,就是我們要刪的數的全部節點了!(為什麼是傳入數-1?應該不會有毒瘤題目絕世好題在資料結構出浮點數吧?那把多和少的剝掉了,不就剩等於的了?)

然後有一個很搞笑的操作(真的很搞笑):把第三個變數這棵子樹的左右兒子合併,也就是說,原來的根節點就變成了光桿司令,左右兒子抱團了嘛。那麼我們就相當於刪了根節點,所以我們就成功刪了一個點。也就刪了這個數。

最後再進行一次合併,也是一樣的,優先合併小的和等於的,再合併大的。完工!

同樣的,這個是“分裂兩次,合併三次

那我們再思考一下怎麼進行另一對互逆操作,根據排名查數根據數查排名

根據數查排名倒是相當好想,我們根據(傳入數-1)將樹分裂,那麼左子樹肯定都小於等於(傳入數-1),也就嚴格小於傳入數,我們就統計出了小於傳入數的數的個數,怎麼統計?就是左子樹的大小嘛!再+1就是排名了。完事別忘了把樹裝回去。

根據排名查數則略嫌麻煩,有逆操作的經驗,我們也考慮用左子樹的大小做文章,如果傳入排名剛好等於當前節點的左子樹大小+1,那我們就直接返回這個節點是哪個數就可以了,不然的話,我們就做遞迴。

如果傳入排名小於等於左子樹的大小(嚴格小於左子樹大小+1),我們就查左子樹的傳入排名。如果比左子樹大小+1還大,那我們就只能往右區間找了,記得減掉左子樹佔的名額(大小+1)。

習題3.1.1

P3369 【模板】普通平衡樹

有沒有發現我上面講的內容就是這道題?快動手吧!

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e7;
#define int long long

struct node
{
	int l;
	int r;
	int val;
	int sval;
	int size;
};
node tree[maxn];
int root=0;
int idx=0;

void newnode(int &x,int v)
{
	x=++idx;
	tree[idx].val=v;
	tree[idx].sval=rand();
	tree[idx].size=1;
}

void pushup(int pos)
{
	tree[pos].size=tree[tree[pos].l].size+tree[tree[pos].r].size+1;
}

void split(int pos,int val,int &x,int &y)//按值分裂,取決於樹的路徑長,O(log n)
{
	if(!pos)
	{
		x=y=0;
		return;
	}
	if(tree[pos].val<=val)
	{
		x=pos;
		split(tree[x].r,val,tree[x].r,y);
		pushup(x);
	}
	else
	{
		y=pos;
		split(tree[y].l,val,x,tree[y].l);
		pushup(y);
	}
}

int merge(int x,int y)//只需要考慮兩棵樹根節點的大小,因為它們內部都是有序的。返回值為新樹的根節點,O(log n)
{
	if(!x||!y)return x+y;
	if(tree[x].sval<tree[y].sval)
	{
		tree[x].r=merge(tree[x].r,y);
		pushup(x);
		return x;
	}
	else
	{
		tree[y].l=merge(x,tree[y].l);
		pushup(y);
		return y;
	}
}

void ins(int v)
{
	int x,y,z;
	split(root,v,x,y);
	newnode(z,v);
	root=merge(merge(x,z),y);
}

void del(int v)
{	
	int x,y,z;	
	split(root,v,x,z);
	split(x,v-1,x,y);
	y=merge(tree[y].l,tree[y].r);
	root=merge(merge(x,y),z);
}

int kth(int pos,int k)
{
	if(k==tree[tree[pos].l].size+1)
	{
		return tree[pos].val;
	}
	else if(k<=tree[tree[pos].l].size)
	{
		return kth(tree[pos].l,k);
	}
	else
	{
		return kth(tree[pos].r,k-tree[tree[pos].l].size-1);
	}
}

int pre(int val)
{
	int x,y;
	split(root,val-1,x,y);
	int ans=kth(x,tree[x].size);
	root=merge(x,y);
	return ans;
}

int suc(int val)
{
	int x,y;
	split(root,val,x,y);
	int ans=kth(y,1);
	root=merge(x,y);
	return ans;
}

int getrank(int val)
{
	int x,y;
	split(root,val-1,x,y);
	int ans=tree[x].size+1;
	root=merge(x,y);
	return ans;
}

signed main()
{
	int n=0,op=0,s=0;
	cin>>n;
	for(int i=1;i<=n;i++)
	{
		cin>>op>>s;
		if(op==1)
		{
			ins(s);
		}
		if(op==2)
		{
			del(s);
		}
		if(op==3)
		{
			cout<<getrank(s);
			if(i<n)cout<<endl;
		}
		if(op==4)
		{
			cout<<kth(root,s);
			if(i<n)cout<<endl;
		}
		if(op==5)
		{
			cout<<pre(s);
			if(i<n)cout<<endl;
		}
		if(op==6)
		{
			cout<<suc(s);
			if(i<n)cout<<endl;
		}
	}
	return 0;
}

按道理講,平衡樹有入墳入門五題,但是我太菜了,先只做這一道吧。

4.樹形結構的應用

有些資料結構,名字根本不帶“樹”,但事實上也是利用了樹形結構的思想,我們對目標要求範圍內的這種資料結構進行簡單的講解。

4.1 並查集

這個東西,我自己看了一年也沒看明白,但是這是雅苯教練給我講的第一個知識點,在此鳴謝!

設想有\(n\)個元素(理論上是任意的資料結構),我們只有兩種操作:

  • 把任兩個元素扔進一個大的“集合”裡

  • 查詢兩個元素是否在一個“集合”內

一個暴力的想法是直接開個\(set\)陣列。每個元素的初始值為自己的下標。對於“扔進一個大‘集合’”這個操作,我們可以把兩個元素對應下標的值改成一個一樣的數,“查詢”就直接查詢兩個元素的\(set\)值是否相等就可以了。這兩個實現的複雜度都肯定是\(O(1)\)的……

一定是\(O(1)\)嗎?設想下面一組資料:

5 4   ;五個元素,四個操作
1 1 3 ;合併1 3,下列"1"指令同理
1 2 5
1 3 5
2 1 2 ;查詢1 2是否在一個集合內。 

首先看看上文加粗的字,“一樣的數”怎麼定義?這是個好問題。我們姑且先定義“一樣的數”為指令的第一個運算元。

好,看看合併指令執行完後,\(set\)是個什麼情況:

1 2 1 4 2

執行最後一條指令,當然“FALSE啦”!

真FALSE?你手模一遍:

最後的情況本來是:

set 1: 1 2 3 5
set 2: 4

明明就是TRUE嘛!

那怎麼辦?肯定是“一樣的數”定義錯了!可惜,你除了改成“第二個運算元”,還能改成什麼?事實上,你線上性序列裡,根本無法保證查詢操作的正確,就算能做到,也不是\(O(1)\)的,搞不好並沒有多項式解法(

那我們怎麼做呢?作為\(AK\space IOI\)的種子選手,這種操作都實現不了?\(doge\)

一種想法是把集合不再侷限在“線性序列”的刻板印象裡,而是一棵樹,那就好判斷了,如果兩個點所在的“集合樹”的根節點相同,那肯定就在一個集合裡,反之亦然。

怎麼找根節點?不要講了吧?既然每個節點的父親一開始都定義成自己的編號了,如果編號依然等於自己,那就說明這個點掌握了自己的人生(霧),肯定是根節點啊!

一個口胡證明:兩棵有根樹不可能有公共點,比如考慮以下以鄰接表給出的樹,無向邊會成鏈,所以是有向邊:

5 4
1 2
1 3
4 3
4 5

顯然若把14當作兩個根節點,那這兩棵樹就可以有一個公共點3,那這樣,誰來做代表集合的樹根呢(

這樣我們就有了集合確定的唯一性,而且因為藉助樹的結構,我們要判根很容易,極限情況下是\(O(\log_2 n)\)(樹的最大高度),但是還有更優解,接下來介紹兩個並查集的常見最佳化:

  1. 路徑壓縮

每往上跳一層,我們就把當前節點的父親改成當前節點的父節點的父親,如此迴圈,最後原本在一條鏈上的節點都和根節點直接相連,下次查詢路徑就短了。

  1. 按秩合併/啟發式合併

因為你顯然不能保證兩棵待合併的“集合樹”都已經路徑壓縮至最優,所以它們的高度肯定是\(\ge 2\)的。那為了壓查詢的時間,我們就可以按樹的高度進行抉擇,把高度小的樹根節點的父節點設為高度大的樹的根節點。這種方法就叫按秩合併,要這麼做,需要開個\(rank\)陣列維護每個點所在高度,而且集合合併會變得較為複雜(

因此,我們在賽場上常使用另一種可能不那麼穩定,但是好寫的多的最佳化:啟發式合併。

講的很高深,事實上,直觀也可以感受到,樹的高度越高,節點就應該越多,所以我們可以按兩棵樹的大小決策,把節點少的樹加進節點多的樹。這麼做一下,可以讓單次合併複雜度直接降到\(O(\alpha(n))\)。這個數學證明是真的很複雜,所以就不放了QAQ

\(\alpha(n)\)是阿克曼函式的反函式,在OI的資料規模內,可以認為是一個不大於\(4\)的常數

為什麼說不穩定呢?因為如果其中有一棵樹路徑壓縮很徹底,而另一棵就壓縮的很少,顯然這時可能會將壓縮少的樹放去壓縮多的樹,從而跑更長的路。但是問題不大的

有時因為集合的邊維護了某種資訊,這時不能簡單的路徑壓縮,我們就只使用按秩合併或啟發式合併。

但是呢,一旦合併到一個集合裡,就很難在多項式時間裡把兩個點分開。只是提一嘴,沒考過(

關於這個“樹”怎麼存呢?事實上,只要開個對應的\(fa\)陣列,維護每個點對應的父親就可以了。初始化也和暴力一樣,初始化為每個元素的對應下標。再開個\(sz\)陣列維護大小,每個元素的\(sz\)初始為1,結束!

習題4.1.1

P3367 【模板】並查集

這就是模板題了。動手吧!

為什麼這麼多人不喜歡寫\(merge()\) (

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e4+5;

int fa[maxn];
int size[maxn];

void set_make(int len)
{
  for(int i=1;i<=len;i++)
  {
    fa[i]=i;
    size[i]=1;
  }
}

int find(int val)
{
  if(fa[val]==val)return val;
  return fa[val]=find(fa[val]);
}

void merge(int x,int y)
{
  x=find(x);
  y=find(y);
  if(size[x]>size[y])swap(x,y);
  fa[x]=y;
  size[x]+=size[y];
}

int main()
{
  int n=0,m=0,z=0,x=0,y=0;
  cin>>n>>m;
  set_make(n);
  for(int i=1;i<=m;i++)
  {
    cin>>z;
    if(z==1)
    {
      cin>>x>>y;
      //cout<<"Union set "<<x<<" with set "<<y<<endl;
      merge(x,y);
    }
    if(z==2)
    {
      cin>>x>>y;
      //cout<<x<<" is in set "<<find(x)<<endl;
      //cout<<y<<" is in set "<<find(y)<<endl;
      cout<<(find(x)==find(y)?"Y":"N");
      if(i<m)cout<<endl;
    }
  }
  return 0;
}

好像判集合寫醜了,汗。

習題4.1.2

P1551 親戚

看了半天回想,這實在找不出不是雙倍經驗的理由(

完了,這個判同祖先怎麼也寫得這麼醜(

哦,題目原因,沒事)

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e4+5;

int fa[maxn];
int size[maxn];

void set_make(int len)
{
  for(int i=1;i<=len;i++)
  {
    fa[i]=i;
    size[i]=1;
  }
}

int find(int val)
{
  if(fa[val]==val)return val;
  return fa[val]=find(fa[val]);
}

void merge(int x,int y)
{
  x=find(x);
  y=find(y);
  if(size[x]>size[y])swap(x,y);
  fa[x]=y;
  size[x]+=size[y];
}

int main()
{
  int n=0,m=0,p=0,mx=0,my=0,px=0,py=0;
  cin>>n>>m>>p;
  set_make(n);
  for(int i=1;i<=m;i++)
  {
    cin>>mx>>my;
    merge(mx,my);
  }
  for(int i=1;i<=p;i++)
  {
    cin>>px>>py;
    cout<<((find(px)==find(py))?"Yes":"No");
    if(i<p)cout<<endl;
  }
  return 0;
}

習題4.1.3

P1525 [NOIP2010 提高組] 關押罪犯

說了要把這道題作為並查集的典題的,差點忘了。

這一題代表一種常考題型,叫做“種類並查集”或者“種族並查集”,主要就是解決這種“敵人”問題

我們假想,對於每個元素\(i\)都有一個“共軛”兄弟\(i'\),它們名義上是一個點,但屬性截然相反。比如\(i\)\(j\)是敵人,那\(i\)\(j'\)\(i'\)\(j\)就是好朋友。

我們考慮將關係建模成節點的邊,那我們就可以採用簡單的貪心策略,將這些邊按邊權從大到小排序,從最小的開始選,每次選條邊,就嘗試將邊的兩個端點各自和對方的“共軛”兄弟合併,如果已經被合併過了,那就說明不能這麼安排,此時這條邊的邊權就是答案。

砈氬這個\(merge()\)寫的更醜了,有時間要把這些講解題目的遠古程式碼都重構一遍!!

事實上:咕值++

怎麼在程式中表示\(i'\)\(j'\)呢?我們可以直接開兩倍的\(fa\)\(sz\),然後,對於每個節點\(k\),直接令\(k+n\)\(k'\),好寫好記。

而且,既然使用了“邊”的概念,那麼這道題就可以用些圖論的演算法過,比如標籤裡的“tarjan”和“二分圖”,這些與本講無關,所以略去,作為思考。

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+7;

int n=0,m=0;
int fa[maxn];
int size[maxn];

struct relation
{
  int a,b,c;
  
  friend bool operator <(const relation &a,const relation &b)
  {
    if(a.c<b.c)return true;
    else return false;
  }

};


relation arr[maxn];

void set_make(int len)
{
  for(int i=1;i<=len;i++)
  {
    fa[i]=i;
    size[i]=1;
  }
}

int find(int val)
{
  if(fa[val]==val)return val;
  return fa[val]=find(fa[val]);
}

bool merge(int x,int y)
{
  int x1=find(x);
  int y1=find(y);
  int x2=find(x+n);
  int y2=find(y+n);
  if(x1==y1)return false;
  fa[x1]=y2;
  fa[y1]=x2;
  return true;
}

int main()
{
  cin>>n>>m;
  set_make(2*n);
  for(int i=1;i<=m;i++)
  {
    cin>>arr[i].a>>arr[i].b>>arr[i].c;
  }
  sort(arr,arr+m+1);
  /*for(int i=1;i<=m;i++)
  {
    cout<<arr[i].c<<' ';
  }
  cout<<endl;*/
  for(int i=m;i>=1;i--)
  {
    //cout<<arr[i].a<<' '<<arr[i].b<<endl;
    if(merge(arr[i].a,arr[i].b)==false)
    {
      cout<<arr[i].c;
      return 0;
    }
  }
  cout<<0;
  return 0;
}

習題4.1.4

P1892 [BOI2003]團伙

這題也是種類並查集的碘

對於這題,如果\(i\)\(j\)是敵人,我們就把\(i\)\(j'\)\(j\)\(i'\)兩對好朋友節點合併,如果\(i\)\(j\)本來就是朋友,那就直接合並。

最後統計有多少個集團,也就是集合的個數,輸出就可以了。

總算有個優美的\(merge()\)

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e3+5;
const int maxm=5e3+7;

int fa[maxn*2];
int sz[maxn*2];

void set_make(int n)
{
	for(int i=1;i<=n;i++)
	{
		fa[i]=i;
		sz[i]=1;
	}
}

int find(int x)
{
	if(fa[x]==x)return fa[x];
	else return fa[x]=find(fa[x]);
}

bool merge(int x,int y)
{
	x=find(x);
	y=find(y);
	if(x==y)return false;
	else
	{
		fa[x]=y;
		return true;
	}
}

int main()
{
	int n=0,m=0,u=0,v=0;
	int res=0;
	char order;
	cin>>n>>m;
	set_make(n*2);
	for(int i=1;i<=m;i++)
	{
		cin>>order;
		cin>>u>>v;
		if(order=='E')
		{
			merge(u+n,v);
			merge(v+n,u);
		}
		if(order=='F')
		{
			merge(u,v);
		}
	}
	for(int i=1;i<=n;i++)
	{
		if(fa[i]==i)res++;
	}
	cout<<res;
	return 0;
}

2024/8/24 23:32:00 初稿!完結撒花!!!(釋出於洛谷)

相關文章