數據結構


數據結構

哈希表、樹狀數組、線段樹......

由於這些知識點較為基礎,相信各位神仙都會,因此不再贅述(斜眼笑)

可持久化數據結抅之主席樹

就是可持久化權值線段樹

利用前綴和思想,每個位置的那棵樹比前一個位置的那棵樹多一個值

那么每次查詢在兩棵線段樹上二分即可

P3834 【模板】可持久化線段樹 2(主席樹)

void add(int &p,int q,int l,int r,int x)
{
	p = ++ cnt;	tr[p] = tr[q];	tr[p].siz ++;
	if(l == r)	return;/**/
	int mid = (l + r) >> 1;
	if(x <= mid)	add(tr[p].ls,tr[q].ls,l,mid,x);
	else	add(tr[p].rs,tr[q].rs,mid + 1,r,x);
}
int ask(int p,int q,int l,int r,int x)
{
	if(l == r)	return b[l];
	int mid = (l + r) >> 1;
	int L = tr[tr[q].ls].siz - tr[tr[p].ls].siz;
	if(L >= x)	return ask(tr[p].ls,tr[q].ls,l,mid,x);
	else	return ask(tr[p].rs,tr[q].rs,mid + 1,r,x - L);
}
int main()
{
	n = read();m = read();
	for(int i = 1;i <= n;i ++){a[i] = read();b[i] = a[i];}
	sort(b + 1,b + n + 1);
	for(int i = 1;i <= n;i ++)
	{
		int d = lower_bound(b + 1,b + n + 1,a[i]) - b;
		add(rt[i],rt[i - 1],1,n,d);
	}
	for(int i = 1,k,l,r;i <= m;i ++)
	{
		l = read();r = read();k = read();
		printf("%d\n",ask(rt[l - 1],rt[r],1,n,k));
	}
}

例題

1.P2633 Count on a tree

這道題怎么做呢?你把主席樹給它推廣一下不就行了嘛。

我們以前求一段區間是右端點減去左端點。

現在放到樹上怎么辦呢?

樹上差分唄。

又考慮到點權,所以將u和v的主席樹加起來再減去\(lca\)\(fa[lca]\)的主席樹。

在考慮往左邊走還是往右邊走。

2.P3066 [USACO12DEC]逃跑的BarnRunning Away From…

既然是講主席樹,我們顯然也可以用主席樹來維護,只要在\(dfn\)序上建主席樹就行了。

那么\(dfn\)序在\(dfn[x]\)\(dfn[x]+size[x]-1\)范圍內的即為\(x\)的子樹。

其實這道題還有一個方法,倍增加樹上差分即可,有興趣的同學自己嘗試哈

3.P3567 [POI2014]KUR-Couriers

我們先建立一下主席樹,然后我們怎么找到這個數是誰呢?

考慮,這個數如果大於一半,那么他所在的那個區間也是一定大於一半的。

那么每次在主席樹上二分查找即可。

4.P3168 [CQOI2015]任務查詢系統

這道題顯然是區間修改,單點查詢。

貌似很熟悉,像極了樹狀數組......

因此考慮差分,在\(s\)處加,在\(e + 1\)處減去即可。

除此之外,用主席樹對\(n\)棵權值線段樹做前綴和即可。

左偏樹

為什么它叫做左偏樹呢?

因為它的左子樹比右子樹節點多,也就是它左偏。

咳咳,進入正題——

我們引入一個概念:外結點。

一顆左偏樹中的外結點為左子樹或右子樹為空的節點。

此外,我們定義一個節點\(i\)的距離為\(dis[i]\),表示從\(i\)到它的子樹內最近的外結點經過的邊數。

由於左偏樹左偏,因此可得:任意節點的左子節點的距離不小於右子節點的距離。

詳見圖片:

左偏樹距離.png

那么怎么合並呢,詳再見圖片:

左偏樹合並圖解1.png

左偏樹合並圖解2.png

左偏樹合並圖解3.png

根據左偏性質,我們還可以得到左偏樹定理:若一棵左偏樹有\(n\)個節點,則該左偏樹的距離不超過\(log_2(n + 1) - 1\)\(why\)?

當一棵左偏樹的距離\(k\)一定時,當且僅當該左偏樹是完全二叉樹時,節點數目最少(證明顯然)。

好吧還是證明一下:

當一棵樹是完全二叉樹時,我們隨便去掉一個葉子結點,它的距離都會變小。

而在下面增加節點時,它的距離不變。

因此它的結點總數\(n\)至少是\(2 ^ {(k + 1)} - 1\),即\(n \ge 2 ^ {(k + 1)} - 1\)

所以\(k \le log_2(n + 1) - 1\)

這樣我們就可以保證它合並的時間復雜度為\(O(log \space n)\)

【模板】左偏樹(可並堆)

int find(int x) {return x == fa[x] ? x : fa[x] = find(fa[x]);}
int merge(int x,int y)
{
	if(!x || !y) return x + y;
	if(val[x] > val[y] || (val[x] == val[y] && x > y)) swap(x,y);
	rs[x] = merge(rs[x],y);
	fa[ls[x]] = fa[rs[x]] = fa[x] = x;
	if(dis[ls[x]] < dis[rs[x]]) swap(ls[x],rs[x]);
	dis[x] = dis[rs[x]] + 1;
	return x;
}
void Union(int x,int y)
{
	int xx = find(x),yy = find(y);
	if(vis[x] || vis[y] || xx == yy) return;
	fa[xx] = fa[yy] = merge(xx,yy);
}
void Delete(int x)
{
	vis[x] = 1;
	fa[ls[x]] = ls[x]; fa[rs[x]] = rs[x];
	fa[x] = merge(ls[x],rs[x]);
}
int get_ans(int x)
{
	if(vis[x]) return -1;
	int xx = find(x);
	Delete(xx);
	return val[xx];
}
void work()
{
	n = read();m = read();
	for(int i = 1;i <= n;i ++) val[i] = read();
	for(int i = 1;i <= n;i ++) fa[i] = i;
	for(int i = 1,opt,x,y;i <= m;i ++)
	{
		opt = read();x = read();
		if(opt == 1) {y = read(); Union(x,y);}
		else printf("%d\n",get_ans(x));
	}
}
int main() {return work(),0;}

例題

1.P2713 羅馬游戲

跟模板題一毛一樣滴。。。

2.P1456 Monkey King

其實跟模板題也是一毛一樣滴,拿出來一個數減半,然后放回去合並。。。

3.P3261 [JLOI2015]城池攻占

首先顯然的是我們要從葉子結點\(dfs\)向上推,維護目前存活的騎士。

由於攻擊力\(\leq h[i]\)的騎士會在該節點\(i\)死亡, 因此我們維護一個最小堆。

初始時將到達此節點的所有騎士放進去,每次一直\(pop\),更新在此死去的騎士\(and\)該騎士攻占的城池數量,直至堆頂騎士攻擊力\(\geq h[i]\)

那么考慮如何更新騎士的攻擊力呢?(總不能\(O(n)\)掃一遍堆)

我們在根節點打上乘法\(tag \space and\)加法\(tag\)不就好了么...

4.P1552 [APIO2012]派遣

首先我們對每個節點維護一個大根堆,\(why?\)

因為當費用超過總預算時,貪心思想,我們顯然要將費用最高的忍者依次彈出,直至總費用不超過總預算(保證領導力相同時,忍者個數最多)

同時維護堆內忍者個數和費用和

接着我們\(dfs\)從下往上合並即可

別忘了\(long \space long\)

此外扔幾個練習題:

1.P4331 [BalticOI 2004]Sequence 數字序列

2.P4359 [CQOI2016]偽光滑數

3.P4971 斷罪者

平衡樹

據某宋同學說,你們平衡樹掌握的挺好的,因此我就不多講了,基礎你們來,習題我們一起上,沖鴨——

Splay

時間復雜度均攤\(O(n logn)\)(證明別問,問就是不會)

首先來一波旋轉

rotate

(以右旋為例)

rotate.png

我們要將\(x\)轉上去,那么為了維護\(BST\)的性質,\(y\)需要成為\(x\)的右兒子,那么原來\(x\)的右兒子怎么辦呢?

此時\(y\)的左兒子其實已經空了,因此我們可以將原來\(x\)的右兒子放到\(y\)的左兒子上。

而且此時仍然滿足\(BST\)性質。

左旋同理。

splay

伸展操作,我們需要將\(x\)這個節點轉到指定節點(一般是根)

那么我們直接將它\(rotate\)到根嗎?

答案當然是否定的(如果一直旋轉的話,那么考慮一條鏈時它將一直都是一條鏈,可以自己手畫嘗試一下)

於是為了應付這種情況,我們采用雙旋方式,先轉它的父親,再轉它。

這樣樹的期望高度是\(log \space n\)(具體證明,還是不會)

這樣,基本操作就完了。

此外,普通平衡樹的6種操作:插入,刪除,求排名,求第k大,求前驅,求后繼。

在每次操作之后都要把對應的節點轉到根。

對此,百度百科是這樣解釋的。

假設想要對一個二叉查找樹執行一系列的查找操作。為了使整個查找時間更小,被查頻率高的那些條目就應當經常處於靠近樹根的位置。於是想到設計一個簡單方法, 在每次查找之后對樹進行重構,把被查找的條目搬移到離樹根近一些的地方。splay tree應運而生。splay tree是一種自調整形式的二叉查找樹,它會沿着從某個節點到樹根之間的路徑,通過一系列的旋轉把這個節點搬移到樹根去。

一些人說這跟\(Splay\)的均攤時間復雜度有關,每次把節點轉到根可以攤還一定的時間復雜度。記住就好

insert

如果插入時,書中沒有任何節點,那么他就是根節點

否則不斷跳節點將該點插入對應的位置即可

delete

參考\(Treap\)我們用一個簡便的方法

將該節點轉到根節點處,然后合並左右子樹即可

rank

直接向下找即可,別忘了\(rank\)是關鍵詞,考試千萬別用

kth

和求排名差不多,不多說

pre

\(x\)對應的排名減一即為它的前驅

next

求后繼,\(x + 1\)的排名對應的數就是它的后繼

\(next\)也是關鍵詞,千萬別用

下面貼個代碼(忘了的同學可以適當借鑒)

#include<iostream>
#include<cstdio>
using namespace std;
const int N = 100005;
int n,root,sta[N * 30],tail,cnt,v[N * 30],tr[N * 30][2],fa[N * 30],size[N * 30];
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 << 3) + (x << 1) + (ch ^ 48);ch = getchar();}
	return x * f;
}
void up(int x) {size[x] = size[tr[x][0]] + size[tr[x][1]] + 1;}//+1
bool isr(int x) {return tr[fa[x]][1] == x;}
int em(int x) {return size[tr[x][0]] + 1;}
void rot(int x)
{
	int k = isr(x),y = fa[x],z = fa[y],w = tr[x][!k];
	if(y == root) root = x;
	else tr[z][isr(y)] = x;
	fa[x] = z; fa[y] = x; tr[x][!k] = y; tr[y][k] = w;
	if(w) fa[w] = y;//
	up(y); up(x);
}
void splay(int x)
{
	while(x != root)
	{
		if(fa[x] != root) rot((isr(x) == isr(fa[x])) ? fa[x] : x);
		rot(x);
	}
}
void Insert(int val)
{
	if(!root)
	{
		root = tail ? sta[tail --] : ++ cnt;
		v[root] = val; size[root] = 1;//size
		return;
	}
	int x = root,last = 0;
	while(x)
	{
		last = x;//
		x = tr[x][val > v[x]];
	}
	x = tail ? sta[tail --] : ++ cnt;
	tr[last][val > v[last]] = x; fa[x] = last;
	v[x] = val; size[x] = 1;//size
	splay(x);
}
int merge(int x,int y,int f)
{
	if(x) fa[x] = f;
	if(y) fa[y] = f;
	if(!x || !y) return x + y;
	tr[x][1] = merge(tr[x][1],y,x);
	up(x); return x;
}
void Delete(int val)
{
	int x = root;
	while(x && v[x] != val) x = tr[x][val > v[x]];
	if(!x) return;
	splay(x); root = merge(tr[x][0],tr[x][1],0);
	sta[++ tail] = x;
	tr[x][0] = tr[x][1] = size[x] = fa[x] = v[x] = 0;
}
int rnk(int val)
{
	int res = 0;
	int x = root,last = root;
	while(x)
	{
		last = x;
		if(v[x] >= val) x = tr[x][0];
		else res += em(x),x = tr[x][1];
	}
	
	splay(last); return res + 1;
}
int kth(int k)
{
	int x = root;
	while(x && em(x) != k)
	{
		if(em(x) > k) x = tr[x][0];
		else k -= em(x),x = tr[x][1];
	}
	splay(x); return v[x];
}
int pre(int val)
{
	int x = root,last = root;
	while(x)
	{
		if(v[x] < val) last = x,x = tr[x][1];//
		else x = tr[x][0];
	}
	return splay(last),v[last];
}
int nex(int val)
{
	int x = root,last = root;
	while(x)
	{
		if(v[x] > val) last = x,x = tr[x][0];
		else x = tr[x][1];
	}
	return splay(last),v[last];
}
void work()
{
	n = read();
	for(int i = 1,opt,x;i <= n;i ++)
	{
		opt = read();x = read();
		if(opt == 1) Insert(x);
		if(opt == 2) Delete(x);
		if(opt == 3) printf("%d\n",rnk(x));
		if(opt == 4) printf("%d\n",kth(x));
		if(opt == 5) printf("%d\n",pre(x));
		if(opt == 6) printf("%d\n",nex(x));
	}
}
int main() {return work(),0;}

例題

【模板】文藝平衡樹

在此只講述\(Splay\)做法(主要是我只用\(Splay\)寫了這個題)

之前的\(Splay\)我們維護的是權值,現在我們要維護一下位置。

考慮區間翻轉怎么辦呢?借鑒一下線段樹的思想:打標記。

那么這一段區間在樹上可能不連續怎么辦呢?

我們可以將區間旋轉出來:對於一段區間\([l,r]\),我們將\(l-1\)轉到根,將\(r +1\)轉到根的右兒子,那么根的右兒子的左節點即為這段區間。

然后我們給該節點打上標記,代表將區間翻轉了,之后用到的時候再翻轉即可。

為了保證翻轉$1 \(~\) n$這段區間的時候不出鍋,我們插入一個極大值,一個極小值即可。

FHQ Treap

\(FHQ \space Treap\)是什么呢?它也叫無旋\(Treap\),是\(Treap\)的加強版,它不依靠旋轉來平衡,但是常數也是較大的...(總比BST​強)

但是不依靠旋轉來平衡我們怎么辦呢?

我們用\(rand\)——隨機數據下,樹的期望高度是\(logn\)的,那么我們構造的樹就會很平衡。

也就是說,我們隊每個節點維護一個鍵值,而這個鍵值就是\(rand\)得來的,我們依靠它來平衡。

同時我們也要保證整棵樹的鍵值是個小根堆。

split

分裂操作,將一棵\(Treap\)分裂為兩棵\(Treap\)

我們將以\(x\)為根的子樹遞歸下去,分為\(l\)\(r\)兩部分(依據權值划分)

如果\(x\)的左子樹的權值比\(val\)小,我們可以直接\(x\)的左子樹給\(l\),然后遞歸建立右子樹

否則將\(x\)的右子樹給\(r\),然后遞歸建立左子樹

最后別忘了\(up\)

merge

合並操作,將兩棵子樹合並到一起

別忘了保證左子樹的權值都小於右子樹的

因此我們按照鍵值維護小根堆來合並

\(l\)的鍵值小,就將\(l\)的左子樹給\(x\),遞歸建右子樹

否則將\(r\)的右子樹給\(x\),遞歸建左子樹

kth

就是看這個點在子樹內的排名是不是等於\(k\)

若大於\(k\)去左邊找;反之將左邊的貢獻減掉,去右邊找

insert

特判沒有節點的情況,直接將\(root\)賦過去

然后按照\(val\)進行\(split\),最后將三棵子樹按照上述規則兩兩合並

delete

通過\(split\)將權值為\(val\)的點分裂出來

然后只刪除一個,將左右子樹合並

rank

將小於\(val\)的子樹\(split\)出來,看看該子樹里面有多少節點,加一即為排名

kth​

和上面一樣直接求即可

pre

將小於\(val\)的子樹分離出來

然后找子樹中最大的那個,最后別忘了合並

next

將大於\(val\)的子樹分離出來

然后找子樹中最小的那個,最后別忘了合並

#include <iostream>
#include <cstdio>
#include <cstdlib>
using namespace std;
const int N = 100005;
int n, cnt, root;
struct node
{
	int ch[2], siz, val , key;
	inline void init() {ch[0] = ch[1] = val = 0; siz = 1; key = rand();}
}tr[N * 30];
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 << 3) + (x << 1) + (ch ^ 48); ch = getchar(); }
	return x * f;
}
void up(int x) {tr[x].siz = tr[tr[x].ch[0]].siz + tr[tr[x].ch[1]].siz + 1; }
int em(int x) {return tr[tr[x].ch[0]].siz + 1; }
void split(int o, int &l, int &r, int val)
{
	if(!o) {l = r = 0; return; }
	if(tr[o].val <= val) return l = o, split(tr[o].ch[1], tr[l].ch[1], r, val), up(l);
	else return r = o, split(tr[o].ch[0], l, tr[r].ch[0], val), up(r);
}
void merge(int &o,int l,int r)
{
	if(!l || !r) {o = l + r; return; }
	if(tr[l].key <= tr[r].key) return o = l, merge(tr[o].ch[1], tr[l].ch[1], r), up(o);
	else return o = r, merge(tr[o].ch[0], l, tr[r].ch[0]), up(o);
}
void Insert(int val)
{
	if(!root) {root = ++ cnt; tr[root].init(); tr[root].val = val; return; }
	int x = 0, y = 0, z = ++ cnt; tr[z].init(); tr[z].val = val;
	split(root, x, y, val);
	merge(x, x, z);
	merge(root, x, y);
}
void Delete(int val)
{
	int x = 0, y = 0, z = 0;
	split(root, x, y, val);
	split(x, x, z, val - 1);
	merge(z, tr[z].ch[0], tr[z].ch[1]);
	merge(x, x, z);
	merge(root, x, y);
}
int rnk(int val)
{
	int x = 0, y = 0;
	split(root, x, y, val - 1);
	int res = tr[x].siz + 1;
	merge(root, x, y);
	return res;
}
int kth(int x, int k)
{
	int now = x;
	while(em(now) != k)
	{
		if(em(now) > k) now = tr[now].ch[0];
		else k -= em(now), now = tr[now].ch[1];
	}
	return now;
}
int kth(int k) 
{
	return tr[kth(root,k)].val;
}
int pre(int val)
{
	int x = 0, y = 0;
	split(root, x, y, val - 1);
	int res = tr[kth(x, tr[x].siz)].val;
	merge(root, x, y);
	return res;
}
int nex(int val)
{
	int x = 0, y = 0;
	split(root, x, y, val);
	int res = tr[kth(y, 1)].val;
	merge(root, x, y);
	return res;
}
void work()
{
	n = read();
	for(int i = 1, opt, x; i <= n; i ++)
	{
		opt = read(); x = read();
		if(opt == 1) Insert(x);
		if(opt == 2) Delete(x);
		if(opt == 3) printf("%d\n",rnk(x));
		if(opt == 4) printf("%d\n",kth(x));
		if(opt == 5) printf("%d\n",pre(x));
		if(opt == 6) printf("%d\n",nex(x));
	}
}
int main() {return work(), 0; }
替罪羊樹

替罪羊樹是一種優雅的數據結構,想問\(why?\)

暴力即優雅...

而替罪羊樹完美體現了這一點

\(Sunny\)_\(r\):當你的樹不平衡了怎么

辦?

\(splay\):我旋轉

\(FHQ \space Treap\):我\(rand\)

替罪羊樹:都讓讓,老子拍扁重建

\(splay \space and \space FHQ \space Treap\):...

紅黑樹

有興趣的同學,請自學...(主要是我不會)

例題

1.P2234 [HNOI2002]營業額統計

我們考慮這一天的值是固定的,那么我們顯然就是要去找一個最接近這個值的數。

那么我們考慮兩種情況。

  1. 要找的數比這個值大,我們要求這個數盡可能地小
  2. 要找的數比這個值小,我們要求這個數盡可能地大

等等。。。這不就一個前驅,一個后繼嘛。

套上平衡樹,沒了。

2.P2286 [HNOI2004]寵物收養場

這個題有一個很好的性質。就是顧客和寵物不會同時存在,那就比較省事了,不然還需要維護兩顆\(Splay\)。。。然后又是一堆東西。

考慮這道題怎么做,有了上一道題的經驗。可以很快發現其實也就是前驅和后繼。然后就是再加個插入和刪除就行了。

只需要知道當前是顧客多還是寵物多

然后相應的建出寵物樹或者顧客樹,相應查詢即可

3.P1486 [NOI2004]郁悶的出納員

整體加

難道我們需要一個一個加?那不\(TLE\)\(dog\)了嘛...

其實吧,基本上所有整體加的題目,我們都是只需要維護一個整體標記就行了。

那么這道題顯然也可以。

那么一個員工的實際工資,加加減減搞一搞就行了。

還有一個問題就是,新來的員工,人家並沒有經歷過工資的變化,其實就是標記對他是不對的。

那怎么辦呢?我們得想辦法讓他也適用啊。(不然,你還能再維護標記?)

那你直接先給他減去加標記即可。

4.P2596 [ZJOI2006]書架

其實這種題就是平衡樹的另一種用途的應用。就是用平衡樹維護序列,而這種情況維護用Splay。

那么我們來考慮考慮怎么維護。

  1. 操作1,我們需要把x放到最高的地方,也就是讓它在平衡樹上處在最左節點,然后我們可以把它直接轉到根,把左子樹放到x的后繼的左子樹就行了。
  2. 操作2,我們需要把x放到最低的地方,也就是讓它在平衡樹上處在最右節點,然后我們也是把它轉到根,把右子樹放到x的前驅的右子樹就行了。
  3. 操作3,-1的話,就是讓我們和它的前驅換一下地,我們可以求出前驅,然后把兩個節點的信息交換就行了,0的話,不用動,1的話,就是和后繼交換信息。
  4. 操作4,其實就是求它的排名就行了。
  5. 操作5,其實就是第k大。

一些習題自己寫吧:

1.P3224 [HNOI2012]永無鄉

2.P2464 [SDOI2008]郁悶的小J

3.P2042 [NOI2005]維護數列

4.SP1043 GSS1 - Can you answer these queries I

樹套樹

線段樹套線段樹
平衡樹套線段樹
樹狀數組套主席樹
線段樹套平衡樹...

P3380 【模板】二逼平衡樹(樹套樹)

樹狀數組套權值線段樹、線段樹套平衡樹、分塊(暴力數據結構)、線段樹套\(vector\)等等都可以過掉這道題(汗)

例題

1.P2617 Dynamic Rankings

根據二逼平衡樹的經驗

用樹狀數組套動態開點權值線段樹即可

2.P3157 [CQOI2011]動態逆序對

方法一:像上面那個題一樣,樹狀數組套動態開點權值線段樹,只不過把查詢第\(k\)小改為查詢比一個數大的數目即可

方法二:三維偏序\(CDQ\)即可

3.CF1093E Intersection of Permutations

一眼看上去仿佛並沒有什么思路

按照這道題,我們不僅需要找集合,還需要求出並集(難~)

因此我們換一種方法

我們考慮一個元素是不是都在這兩個范圍之內

那么我們把每一個數抽象為二維平面上的一個點,設\(P_{a_i}\)作為\(i\)這個元素在\(a\)排列中的位置,\(p_{b_i}\)\(i\)這個元素在\(b\)這個排列中的位置,那么這個點的坐標即為\((p_{a_i},p_{b_i})\)

操作一也就變成了一個二維數點問題

操作二就是將兩個點的縱坐標互換

而兩個維度的話一個放在內層,另一個放在外層維護即可

所以可以樹狀數組套權值線段樹

樹狀數組維護的是前綴和,那么對於樹狀數組的每一個位置,我們都維護一個動態開點權值線段樹,用來維護前綴里面所有出現的數。

修改的時候,跳\(lowbit\),然后在對應的線段樹上修改。

查詢的時候,先讓區間左端點跳\(lowbit\),跳的過程中不斷減去在權值線段樹上對應區間出現的數的個數。

區間右端點也是跳\(lowbit\),只不過是跳的過程中是加而不是減。

(和P3759差不多)

4.P4175 [CTSC2008]網絡管理

這道題目無非兩個操作:

1.單點修改

2.查詢樹上兩點路徑第\(k\)

三種方法,時間復雜度依次遞減:

1.對於一條鏈,我們用樹剖+\(dfn\)序將它轉化為多個區間第\(k\)大,求區間第\(k\)大,我們可以采用線段樹套平衡樹,需要二分答案+區間排名

\(O(n \space log^4n)\)

2.我們考慮方法一的弊端,就是線段樹套平衡樹沒辦法將區間第\(k\)大直接合並,必須二分答案

所以我們想將這個過程去掉,像上面一樣

因此我們可以樹剖+帶修主席樹

\(O(n\space log^3n)\)

3.還能不能再優化了呢?\(Of \space course\),上面的方法時間復雜度瓶頸在於樹鏈剖分,那能不能不樹剖了呢?

當然了,我們考慮這樣的一個問題,之前講主席樹的時候講到了這樣的一道題目,P2633 Count on a tree,我們也是求的鏈上第\(k\)大,這道題中,我們用的主席樹+樹上差分的思想,那這道題能不能也這樣呢?

當然也是可以啦。我們可以用\(u\)的主席樹+\(v\)的主席樹-\(lca\)的主席樹-\(fa[lca]\)的主席樹得到這段區間出現的數的個數,也就是說我們要維護每個點的主席樹。

考慮到修改的時候,我們對一個點修改,實際上是對整個以這個點為根的子樹產生了影響,而一顆子樹的\(dfs\)序是連續的,我們轉化成\(dfs\)序上的區間修改和單點查詢問題。

我們再聯系之前的樹狀數組,我們把這類問題變成了差分,同樣,我們把這道題變成了\(dfs\)序上的差分主席樹,也就是在\(dfn[x]\)加,在\(dfn[x]+size[x]\)減。

然后查詢時\(O(\log n)\)地跳主席樹來統計一個點的答案,再加上不斷二分區間是\(O(\log n)\)的,總共的時間復雜度是\(O(n\log^2n)\)

5.[ZJOI2013]K大數查詢

從題面發現很顯然是一道樹套樹的題(這不是廢話嗎)

那么我們用哪種樹套樹呢?

線段樹套平衡樹?

我們還需要二分答案,時間復雜度\(O(nlog^3n)\),瞬間爆炸。。。

區間線段樹套權值線段樹?

這不是跟上面那個一模一樣嘛。。。

我們考慮一下我們的時間復雜度為什么會這個高?

樹套樹的基本時間復雜度一般都是\(O(nlog^2n)\),這個一般是優化不了的,那么我們發現,好像多二分了一個答案。

那么我們怎么把二分答案這個環節去掉呢?考慮二分答案實際上是對權值進行二分。

那么二分權值是不是線段樹也可以做到?

於是我們選擇用權值線段樹套區間線段樹,就免去了二分答案這個環節。。

那么之前在權值線段樹上作為二分依據的\(size\),我們現在用在區間線段樹上查詢來獲得。

時間復雜度為\(O(nlog^2n)\),這里為了空間能夠開下,我們里層的區間線段樹選擇動態開點。

動態樹問題:維護森林的連通性。

而LCT就是解決動態樹問題的一種方法。

其實LCT就是我們平時說的實鏈剖分。

重鏈剖分你們肯定都透徹。實鏈剖分學起來也不是很困難的。

那實鏈剖分就是把重鏈剖分的重邊變成實邊,輕邊變成虛邊。

由於操作需求,實虛鏈之間是會不斷變化的,所以我們需要一個靈活的數據結構來維護。

當然是靈活的Splay了啊!!!

我們需要用Splay來維護實路徑。

也就是說,我們會把所有用實邊相連的點都放在同一顆Splay里面,這樣我們就會有若干顆Splay Tree。

由於一個節點所連出去的實邊只有一條,那么一顆Splay里面不同節點的深度也是不同的。

所以深度就取代了我們在普通平衡樹里面的val來作為平衡的標准。

這里,我們的LCT時間復雜度是均攤\(O(nlogn)\)的。主要是取決於Splay的時間復雜度。

那么LCT有以下三個比較重要的性質:

  1. 每一顆Splay里面維護的都是一條在原樹中從上到下嚴格遞增的路徑上的節點,中序遍歷的深度必須嚴格遞增。

  2. 每個節點都只能被包括在一顆Splay里面,即不存在兩顆Splay存在相同的節點。

  3. 邊分為實邊和虛邊,實邊是會被包括在一棵Splay里面的,虛邊是從一棵Splay指向另一顆Splay(這條邊是后者Splay樹中中序遍歷最靠前的點和它在原樹中的父親相連的邊)

    對於實邊,父親兒子都互相認。而對於虛邊,父不認子而子認父。

    LCT1.png

比如說一開始的實虛邊是這樣划分的。

那么對應到Splay上是怎么樣的呢? 是這樣的:

LCT2.png

每一個綠框里面的節點在同一顆Splay里面。

Access

LCT最核心的操作,也是最讓人難懂的操作了吧。

\(access(x)\),就是打通從x到原樹的根節點的實路徑,其實也就是把x到根上的所有節點都放到同一顆Splay里面,並且x節點必須是這顆Splay里面節點深度最大的,也就是x不能和它的任何一個兒子連實邊。

那么現在舉個例子來更好地理解一下:

我們現在要\(access(N)\),那么我們希望把實虛邊划分成這樣:

LCT3.png

那么怎么實現呢?

我們需要一步步的往上拉。

首先,先\(splay(N)\),讓它成為Splay的根,因為不能有深度比N更大的節點了,所以我們需要把節點深度比它大的右子樹置為空,也就是\(N-O\)之間的邊要變成虛邊。

變成這樣:

LCT4.png

接着就類似於重鏈剖分的跳重鏈一樣,我們不斷的通過虛邊來跳Splay。

對於現在來說的話,我們要跳到\(I\)這個節點,然后把它轉到根,再把它的右子樹置為N所在的Splay,就是\(I-N\)這條邊變成實邊。

然后變成這樣:

LCT5.png

接着,我們跳到H這個節點,進行類似於上面的操作。

再變成這樣:

LCT6.png

再跳到A,也是同樣的操作,最后變成了這樣:

LCT7.png

大功告成!!!

怎么樣,是不是感覺很麻煩,但是代碼只有一行。。。

其實就是splay,然后換右兒子,更新信息,跳虛邊。。。

Makeroot

其實有的時候,我們需要拉出一條兩個節點之間的路徑,但是如果兩個節點都不是根節點的話,就不滿足性質1了。

所以我們有了makeroot這個操作,\(makeroot(x)\)就是把x變成原樹的根了,那么就可以從另一個節點拉邊了。

怎么實現呢?

我們考慮,如果把這個節點變成根節點的話,為了滿足性質1,從x到根的路徑上的深度需要全部反過來。

所以,我們先打通x到根的路徑,再把x splay到根,再把整顆Splay翻轉過來(翻轉的話,可以參考文藝平衡樹)。

我們這里的splay是有些不同的,我們需要在splay之前把會經過的節點的標記全部下放。

這里的話,我們把節點都用棧存起來,再按深度從下到上一個個下放標記。

Findroot

\(findroot(x)\)就是找到x所在的原樹的根節點,一般是用來判斷兩個點是不是在同一顆原樹中。

先打通路徑,然后把x splay到根,然后找最左節點,就是原樹的根節點(因為深度最小嘛)。

Split

\(split(x,y)\)拉出一條從x到y的路徑,很簡單吧。

把其中一個變成根,讓另一個access就行了。再splay上去,就可以統計一些信息了。

Link

LCT怎么能沒有link操作呢,就是連一條邊。

我們需要先判斷一下是否已經連邊了。

如果沒有的話,讓一個節點成為所在原樹的根節點,然后直接認爹就行了(因為不在同一顆Splay里面,不需要父親認兒子)。

Cut

刪掉一條邊。

我們也需要判斷一下是否本來就沒有邊。

如果有的話,可以先拉出來一條從x到y的路徑,然后判斷x和y是否直接相連,如果直接相連,直接斷邊,父子不相認。

例題

1.[SDOI2008]洞穴勘測

甚至連板子都不如。。。(還是寫寫練練手感吧。)

2.[國家集訓隊]Tree II

這個題還有點意思。我們還需要區間加和區間乘。想到了什么呢?

想一下我們曾經在哪道題上維護過類似的操作。

沒錯,就是線段樹2啊。

我們還是采用和線段樹2相同的套路。對於乘法和加法,我們讓乘法優先,這樣可以讓精度損失降到最小。

我們現在要維護的標記除了翻轉標記,還有加法標記和乘法標記,可以一起下放,對於時間復雜度沒有什么太大的影響。然后就是一些細節問題了。

3.[HNOI2010]彈飛綿羊

這不LCT裸題嘛

我知道大家之前都是用分塊寫的,其實這題也可以用\(LCT\)寫,LCT被分塊攆爆了

大家好好觀察一下就很容易發現怎么用\(LCT\)來維護。

我們考慮,添加一個虛擬節點,這個節點就表示綿羊被彈飛了,那么所有跳之后會超出邊界的點都要向這個節點連邊。剩下的該向誰連就向誰連就行了。

對於查詢操作,我們怎么辦?

我們發現我們查詢的實質就是這個點到虛擬節點中一共經過了多少條邊,所以我們拉出一條路徑,然后統計一下這個路徑上一共有多少個節點,然后再減一就行了。

那么修改操作呢?

其實就是斷了一條邊,然后又連了一條邊。

然后就沒了。

4.P3703 [SDOI2017]樹點塗色

那我們先看看這些操作吧。看能不能發現些什么。

  1. 操作1,把\(x\)到根染上同一種顏色,怎么跟\(access\)那么像啊。莫非?我們靈機一動,決定用\(Splay\)來維護同一個顏色的集合。
  2. 操作2,\(x\)\(y\)的權值,莫非直接\(split\)?然而,你的\(Splay\)是維護同一個顏色,你的\(LCT\)現在根本就不支持除了\(access\)之外的任何東西了。那怎么辦呢?

不慌,我們考慮他是樹上一條路徑,我們考慮之前求兩點間距離是怎么求的,是不是樹上差分?那這個操作我們也可以用樹上差分來實現,具體就是:我們設\(f[x]\)表示x這個節點到根節點的權值,那么可以得到\(val[x][y] = f[x] + f[y] - 2 * f[lca(x,y)] + 1\),加一是因為lca這個點的顏色會被多減一次。
3. 操作3,眾所周知,LCT擅長維護一條鏈,對於子樹內的操作它幾乎是無能為力的。我們考慮一顆子樹內的\(dfs\)序是連續的,所以我們可以用\(dfs\)序上建線段樹來維護。

那么我們怎么維護這個\(f\)值呢?

我們考慮\(f\)值只有在進行\(access\)時會發生變化,連邊時\(f\)值會減一,斷邊時\(f\)值會加一。

留個有意思的小習題:

P4332 [SHOI2014]三叉神經樹

其他

融合樹&划分樹&支配樹

有興趣的同學請自學...

K-DTree&珂朵莉樹(ODT)&Kruskal重構樹&虛樹&李超線段樹

由於時間問題,我選擇——甩鍋,詳見\(wljss\)博客園


免責聲明!

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



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