替罪羊樹詳解


splay 太難了!

我直接懵逼,但是,

我不是會被輕易打倒的,*這題這么難的嗎,看題解看題解

不過我偶然遇到了替罪羊樹,發現可以諤諤一下。

替罪羊樹也(怎么是“也”呢?其實比較的暴力數據結構還是挺多的,比如莫隊)是一種優雅的暴力,但是,

在大多情況下珂以踩正解!

暴力碾標算不是夢啊。

替罪羊樹的核心思想是:

將不平衡的子樹拍扁然后重構。

這樣,查找的次數大大減少。

那么如何判斷是否不平衡呢?

我們給平衡一個定義:

一棵樹的左子樹或右子樹的節點過多,就是不平衡的。

那如何判斷節點多呢?

我們記錄a[k].size 為節點 \(k\) 的節點總數,a[k].sh 為剩下的節點總數,嘗試玄學一些:

定義一個 \(\alpha\) 為平衡權值(一般取 \(\alpha\in[0.7,0.8]\)),那么當 \(\max\{\text{左兒子的節點數},\text{右兒子的節點數}\}>\alpha×\text{總節點數}\)時,就判斷為失衡。

當然還有一種情況比較難想,這里提出來,可以當結論記住。

當該子樹被刪除的節點過多時,在下面的搜索中會浪費時間,對於是否失衡的判斷也會受到一些影響,所以借助拍扁全部刪掉刪除。

當然如果這個節點不存在直接跳過就好了。

我們可以模擬一下判斷是否需要失衡重構的過程:

bool judge(int k)
{
	return (a[k].wn&&(alpha*(double)a[k].size<(double)max(a[a[k].ls].size,a[a[k].rs].size)||(double)a[k].sh<alpha*(double)a[k].size));
}

有點長啊。

那么,如何重構呢?

前面有提到,核心是拍扁:

比如有這樣一棵二叉查找樹:

顯然十分不平衡,我們用中序遍歷(左兒子,根,右兒子)的方式壓成數組(直鏈):


代碼實現十分 naive;

void unfold(int k)
{
	if(!k) return;
	unfold(a[k].ls);
	if(a[k].wn) rt[++crt]=k;
	unfold(a[k].rs);
	return;
}

最后的 rt[] 數組存的就是前序遍歷的點的編號。

其中 a[k].wn 代表 \(k\) 節點上有幾個點(也就是說有幾個點和 \(k\) 點權值相同),你會發現這樣也把沒有存在的節點直接踢掉了。

然后我們用中序遍歷把他展成一棵樹:

具體方法有點像線段樹啊:

int rebuild(int l,int r)//返回根的編號
{
	if(l==r) return 0;
	int mid=(l+r)>>1;
	a[rt[mid]].ls=rebuild(l,mid);
	a[rt[mid]].rs=rebuild(mid+1,r);
	update(rt[mid]);
	return rt[mid];
}

那怎么update呢?

我們發現只需要處理總的節點數和剩下的節點數。
其他的除了只與自身相關的變量就已經更新或根本不用更新。

簡單來說就是這樣的:

void update(int k)
{
	a[k].size=a[a[k].ls].size+a[a[k].rs].size+a[k].wn;
	a[k].sh=a[a[k].ls].sh+a[a[k].rs].sh+a[k].wn;
	return;
}

總的維護平衡的操作也就是這這樣了:

void bal(int& k){crt=0,unfold(k),k=rebuild(1,crt+1);}

我們發現,這不就是 \(dfs\) 嗎?

確實,不過為什么能保證復雜度呢?

其實前面已經提到過,在修改很多次后才會失衡。

其實,只需要這樣的無腦爆搜操作就可以與什么難以理解的“雙旋”媲美了。

如果你理解了,那么你只需要會一般二叉搜索樹操作就好了。

不過可能很多人(?)直接跳過了,所以不講是不可能的。

我們來看例題吧:P3369 【模板】普通平衡樹

\(ps:\)下面指的“根”絕大部分都是正搜到的子樹的根,要變通。

第一個操作:插入節點。

由於二叉搜索樹的性質:左兒子 \(<\)\(<\) 右兒子,直接搜索即可。

分類討論:
\(1.\) 存在與這個數一樣的節點,直接加一下出現次數不斷向上更新即可。
\(2.\)不存在和這個數一樣的節點。

那怎么辦呢?
我們在符合條件(即二叉搜索數性質)的位置插入一個節點即可。

由於我們要修改找到最后一個點的左兒子或右兒子,需要在函數取地址,否則會死。

代碼中 a[k].val 代表 \(k\) 點的權值,a[k].wn\(k\) 節點出現的次數,cnt是存在或存在過的節點總數,可以當編號來使喚。

void insert(int& k,int x)
{
	if(!k)//由於不存在的兒子為0,這是情況二
	{
		k=++cnt;
		if(!root) root=1;//如果還沒有根,那么就把他設成根
		//這樣便於以下操作從根開始往下搜
		a[k].val=x,a[k].ls=a[k].rs=0;
		a[k].wn=a[k].size=a[k].sh=1;
	}
	else
	{
		if(a[k].val==x) a[k].wn++;//情況一
		//按二叉搜索樹性質向下找
		else if(x<a[k].val) insert(a[k].ls,x);
		else insert(a[k].rs,x);
		//記得更新與不斷判斷是否失衡
		update(k);
		if(judge(k)) bal(k);
	}
}

你又會問了:
這樣失衡次數如果很多不會垮嗎?

答案是否定的,因為只插入一個節點不可能次次改變樹的平衡度。

這個問題說了三遍了......

操作二:刪除節點。

理論上的套路與插入節點異曲同工。

這里只需要處理更新剩下的節點數和節點自身次數,由於操作很少,所以被稱為“惰性刪除”。

先看代碼:

void del(int& k,int x)
{
	//if(!k) return;
	a[k].sh--;
	if(a[k].val==x)  a[k].wn--;//可以判斷a[k].wn>0?
	else
	{
		if(a[k].val>x) del(a[k].ls,x);
		else del(a[k].rs,x);
	}
	update(k);
	if(judge(k)) bal(k);
}

這是保證合法的操作,判斷不合法的已被注釋。

很簡單吧?

下面先不看操作三。

操作(詢問?)四:查詢第 \(k\) 大。

和權值線段樹類似,如果左兒子不夠用就在右邊,如果夠用就在左邊。

還有一種比較特殊的情況,如果這個排名正好是根呢?

也就是這種情況:

x>a[a[k].ls].sh&&a[a[k].ls].sh+a[k].wn>=x

注意等號的取與否。

就是無腦模擬一下了:

int at(int k,int x)
{
	if(a[k].ls==a[k].rs) return a[k].val;
	if(x<=a[a[k].ls].sh) return at(a[k].ls,x);
	else if(x>a[a[k].ls].sh&&a[a[k].ls].sh+a[k].wn>=x) return a[k].val;
	else return at(a[k].rs,x-a[a[k].ls].sh-a[k].wn); 
}

注意用的是剩下的,即 a[k].sh 而非 a[k].size

下面考慮操作(詢問?)三:查詢一個數的排名。

我們考慮找到比 \(x\) 小的數的個數 \(+1\) 即可。

我們這里有一個二叉搜索樹(假設每個點只出現 \(1\) 次):

假設我們要查找比 \(13\) 小的有幾個數。

那么就有這樣的回溯路徑:

我們注意在空節點處返回 \(0\)(顯而易見)。

然后其他的情況分類即可:

\(1.\) 查找的值小於該節點的值,那么就返回他在左子樹中的排名。

\(2.\) 查找的值等於該節點的值,那么就返回左子樹剩余節點的個數。

\(3.\) 查找的值大於該節點的值,那么返回左子樹剩余節點數,該節點出現次數與這個數在右子樹的排名的和。

代碼實現:

int rkdown(int k,int x)
{
	if(!k) return 0;
	if(a[k].wn&&a[k].val==x) return a[a[k].ls].sh;
	else if(x<a[k].val) return rkdown(a[k].ls,x);
	else return a[a[k].ls].sh+a[k].wn+rkdown(a[k].rs,x);	
}

下面是操作五:詢問一個數的前驅。

就是他前面那個數的值,也就是排名為比他小的數的個數的數。
也就是:

at(root,rkdown(root,x))

操作六(詢問一個數的后繼)怎么辦呢?

我們珂以得出這個數最后的排名(指並列時的最后一個的排名),就是這樣的:

int rkup(int k,int x)
{
	if(!k) return 0;
	if(a[k].wn&&x==a[k].val) return a[k].wn+a[a[k].ls].sh;
	else if(x<a[k].val) return rkup(a[k].ls,x);
	else return a[a[k].ls].sh+a[k].wn+rkup(a[k].rs,x); 
}

你發現只有一個地方,就是當查找的值等於該節點的值時,要把他自身出現的次數算上。

查詢后繼只需:

at(root,rkup(root,x)+1)

可能 \(+1\) 容易忘,在函數中加上即可:

int rkup(int k,int x)
{
	if(!k) return 1;
	if(a[k].wn&&x==a[k].val) return 1+a[k].wn+a[a[k].ls].sh;
	else if(x<a[k].val) return rkup(a[k].ls,x);
	else return a[a[k].ls].sh+a[k].wn+rkup(a[k].rs,x); 
}

這樣能保證只加了一個 \(1\),想想為什么。
太簡單了,因為搜到底或者正好碰到只可能出現一次。(看什么,自己分析)

那么回答詢問時:

at(root,rkup(root,x))

這樣,所有的替罪羊樹相關知識就講完了。

是不是豁然開朗呢?細細理解,發現這個東西太好理解了,我的 ds 生涯有救啦。

為了方便 debug ,給出 \(AC\) 代碼(全篇):

#include"iostream"
#include"cstdio"
#include"cmath"
#include"cstring"
using namespace std;

#define read(x) scanf("%d",&x)
#define MAXN 100005

const double alpha=0.75;//這個值隨心就好了
int n;
int t,x;
struct node
{
	int ls,rs;
	int size,sh;
	int val;
	int wn;
	node()
	{
		ls=rs=0;
		size=sh=0;
		val=0;
		wn=0;
	}
}a[MAXN];
int root=0;
int rt[MAXN],crt=0;
int cnt=0;

bool judge(int k){return (a[k].wn&&(alpha*(double)a[k].size<(double)max(a[a[k].ls].size,a[a[k].rs].size)||(double)a[k].sh<alpha*(double)a[k].size));}

void update(int k)
{
	a[k].size=a[a[k].ls].size+a[a[k].rs].size+a[k].wn;
	a[k].sh=a[a[k].ls].sh+a[a[k].rs].sh+a[k].wn;
	return;
}

void unfold(int k)
{
	if(!k) return;
	unfold(a[k].ls);
	if(a[k].wn) rt[++crt]=k;
	unfold(a[k].rs);
	return;
}

int rebuild(int l,int r)
{
	if(l==r) return 0;
	int mid=(l+r)>>1;
	a[rt[mid]].ls=rebuild(l,mid);
	a[rt[mid]].rs=rebuild(mid+1,r);
	update(rt[mid]);
	return rt[mid];
	
}

void bal(int& k){crt=0,unfold(k),k=rebuild(1,crt+1);}

void insert(int& k,int x)
{
	if(!k)
	{
		k=++cnt;
		if(!root) root=1;
		a[k].val=x,a[k].ls=a[k].rs=0;
		a[k].wn=a[k].size=a[k].sh=1;
	}
	else
	{
		if(a[k].val==x) a[k].wn++;
		else if(x<a[k].val) insert(a[k].ls,x);
		else insert(a[k].rs,x);
		update(k);
		if(judge(k)) bal(k);
	}
}

void del(int& k,int x)
{
	a[k].sh--;
	if(a[k].val==x) a[k].wn--;
	else
	{
		if(a[k].val>x) del(a[k].ls,x);
		else del(a[k].rs,x);
	}
	update(k);
	if(judge(k)) bal(k);
}

int rkup(int k,int x)
{
	if(!k) return 1;
	if(a[k].wn&&x==a[k].val) return 1+a[k].wn+a[a[k].ls].sh;
	else if(x<a[k].val) return rkup(a[k].ls,x);
	else return a[a[k].ls].sh+a[k].wn+rkup(a[k].rs,x); 
}

int rkdown(int k,int x)
{
	if(!k) return 0;
	if(a[k].wn&&a[k].val==x) return a[a[k].ls].sh;
	else if(x<a[k].val) return rkdown(a[k].ls,x);
	else return a[a[k].ls].sh+a[k].wn+rkdown(a[k].rs,x);	
}

int at(int k,int x)
{
	if(a[k].ls==a[k].rs) return a[k].val;
	if(x<=a[a[k].ls].sh) return at(a[k].ls,x);
	else if(x>a[a[k].ls].sh&&a[a[k].ls].sh+a[k].wn>=x) return a[k].val;
	else return at(a[k].rs,x-a[a[k].ls].sh-a[k].wn); 
}

int main()
{
	read(n);
	while(n--)
	{
		read(t),read(x);
		if(t==1) insert(root,x);
		else if(t==2) del(root,x);
		else if(t==3) printf("%d\n",rkdown(root,x)+1);
		else if(t==4) printf("%d\n",at(root,x));
		else if(t==5) printf("%d\n",at(root,rkdown(root,x)));
		else printf("%d\n",at(root,rkup(root,x)));
	}
	return 0;
}

那么替罪羊樹的時間復雜度是多少呢?

在最壞情況下的均攤復雜度是 \(\mathcal O(m\log n)\),是十分優秀的算法,這就是“暴力碾標算”的由來。

講的夠詳細了吧......

行,點贊吧(<--不要臉)。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM