可持久化數據結構

可持久化數據結構

毒液哥 Fudan University

概論

本文假設讀者對數據結構有一定了解,對此概念不做贅述。

可持久化數據結構是一類數據結構的統稱。若我們能在某一時刻訪問一數據結構的任何歷史版本,則稱該數據結構爲可持久化數據結構。
若一個數據結構是可持久化的,則可以通過修改該數據結構,在各操作時間複雜度不改變的同時,使其成爲一個可持久化數據結構。

由定義可知,可持久化數據結構的任何一個歷史版本都要被保留,因此可持久化數據結構的任何操作都不能直接修改之前存在的節點,必須對每個需要修改的節點創建一個拷貝。因此只有當任何時間的任何操作創建節點拷貝的複雜度都嚴格小於等於其複雜度,這一數據結構才能可持久化。

下文會討論一些常見的數據結構的一般版本,及其可持久化版本,並分析一些不能可持久化的一般版本。


可持久化線段樹

一般線段樹

一般來說,線段樹有兩種實現方法,一種是基於數組的靜態開點的線段樹,另一種是使用指針儲存樹形結構的動態開點(也可靜態開點)版本。其中前者本質上是對後者的一種優化。在線段樹的座標範圍確定的情況下,整顆線段樹的節點數量以及每個節點表示的線段其實是確定的。若將該線段樹的結構完整建立,則它是一棵節點數不超過O(n)O(n)的正則二叉樹,其中nn爲座標範圍的長度。因此可以用數組儲存這棵二叉樹,從而省去了記錄左右兒子關係的空間。

因爲線段樹的每個操作會嚴格修改O(log n)O(\log\ n)個節點,所以線段樹可持久化。但由於前者用數組存儲了整棵線段樹,但拷貝數組需要O(n)O(n)的時間,因此前一個版本的線段樹不可持久化,而後一個版本的線段樹可持久化。

可持久化

uu表示一棵滿節點線段樹的一個節點,則:
left(u)left(u)表示uu的左兒子;right(u)right(u)表示uu的右兒子;data(u)data(u)表示uu上儲存的數據;L(u)L(u)表示uu(線段)的左端點;R(u)R(u)表示uu(線段)的右端點。

考慮一般線段樹的單點遞歸修改過程。
遞歸函數FF的參數u, x, yu,\ x,\ y分別表示線段樹的節點、要修改的座標、以及數據的修改量,沒有返回值:

  1. 判斷uu是否是葉子節點,若是,直接修改data(u)data(u)並返回。
  2. 判斷xx位於left(u)left(u)還是right(u)right(u). 不妨設是前者。
  3. 遞歸調用F(left(u), x, v)F(left(u),\ x,\ v).
  4. left(u)left(u)right(u)right(u)更新data(u)data(u).

我們發現,所有被訪問到的節點的信息都會被修改,因此在可持久化版本中,我們應該對所有訪問到的節點製作一份副本,並對副本進行修改。同時需要注意,在第三步中,由於我們爲修改過的left(u)left(u)創建了一份副本,我們應該將新節點的左兒子指向修改過後的left(u)left(u)副本。下面給出可持久化線段樹的單點遞歸修改過程。

遞歸函數GG的參數u, x, yu,\ x,\ y分別表示線段樹的節點、要修改的座標、以及數據的修改量,返回值爲修改過後的uu的副本(即vv):

  1. 創建uu的一個拷貝,設其爲vv.
  2. 判斷vv是否是葉子節點,若是,直接修改data(v)data(v)並返回vv
  3. 判斷xx位於left(u)left(u)還是right(u)right(u). 不妨設是前者。
  4. 遞歸調用G(left(u), x, v)G(left(u),\ x,\ v). 並令left(v)left(v)更新爲其返回值。
  5. left(v)left(v)right(v)right(v)更新data(v)data(v).
  6. 返回vv.

每次在外部調用函數GG都會返回一棵當前版本線段樹的根,並且不會對已有的節點進行任何的修改,即任何歷史版本的線段樹都是可以訪問的。修改操作的時間複雜度爲O(log n)O(\log\ n),和一般版本的線段樹一致。由於每次修改均要新建O(log n)O(\log\ n)個節點,所以修改操作的空間複雜度爲嚴格O(log n)O(\log\ n),而一般線段樹修改操作的空間複雜度爲O(1)O(1).

核心代碼如下:

Node* Insert(Node *u, int x, int y, int Left, int Right) // Left, Right分別表示L(u), R(u)
{
	Node *v = NewNode(u); // 創建一個u的拷貝
	if(Left == Right)
	{
		v -> Data += y; // 直接修改data(v)
		return v;
	}
	int Mid = Left + Right >> 1;
	if(x <= Mid) // 判斷x是否在left(u)中
		v -> Child[0] = Insert(left(u), x, y, Left, Mid); // 遞歸調用,Child[0]表示左兒子
	else
		v -> Child[1] = Insert(right(u), x, y, Mid + 1, Right);
	v -> Data += data(left(u)) + data(right(u)); // 更新data(v)
	return v;
}

區間修改的可持久化和單點修改的可持久化類似,在此略述。

例題

區間K大值

問題描述

給定一個長度爲nn的序列aann個詢問,每個詢問給出l, r, kl,\ r,\ k,問區間[l,r][l, r]中第kk大的數是多少。

解法

不妨設元素的大小在[1,m][1, m]中,我們將元素的大小作爲線段樹的下標;線段樹每個節點儲存大小在其區間內的數的個數。依次將序列中的每個元素插入到可持久化線段樹中,注意:將元素大小作爲下標,11作爲修改量,則我們可以得到n+1n + 1個歷史版本(一開始線段樹爲空),設爲T0,T1,...,TnT_0, T_1, ..., T_n. 設u0,u1,...,unu_0, u_1, ..., u_n分別爲T0,T1,...,TnT_0, T_1, ..., T_n上表示某相同線段[L,R][L, R]的節點,則data(ui)data(u_i)表示a1,a2,...,aia_1, a_2, ..., a_i中值位於[L,R][L, R]範圍內的點的個數,data(ui)data(uj)data(u_i) - data(u_j)表示aj+1,aj+2,...,aia_{j + 1}, a_{j + 2}, ..., a_i中值位於[L,R][L, R]範圍內的點的個數。

那麼,對於每個詢問,通過對TrT_rTl1T_{l - 1}作差(本質上是在訪問節點uu時對data(ur)data(u_r)data(ul1)data(u_{l - 1})作差)輕鬆得到al,al+1,...,ara_l, a_{l + 1}, ..., a_r這些元素加入後的線段樹。

在線段樹上,我們可以輕鬆知道第kk大的節點在左兒子還是右兒子中。那麼樹邊從線段樹的根一直走到葉子節點,其表示的單點即爲答案。時空複雜度均爲O(nlog m)O(n \cdot \log\ m). 注意:若要在uu中尋找第kk大的點,且發現其在right(u)right(u)中,則要在right(u)right(u)中尋找第kdata(left(u))k - data(left(u))大的點。


可持久化平衡樹

一般平衡樹

在算法競賽中,Splay Tree, 旋轉式Treap, 非旋轉式Treep, 替罪羊樹是幾種選手比較熟悉的平衡樹。
由於Splay Tree替罪羊樹的複雜度是均攤O(log n)O(\log\ n)的,因此對於某一次操作,其複雜度並不是嚴格爲O(log n)O(\log\ n),所以這兩種平衡樹是不可持久化的。

Treep是可持久化的。由於旋轉式Treap需要記錄節點的父親信息,將其操作改爲可持久化版本會使修改的節點達到O(n)個,因此我們在此討論非旋轉式Treep的可持久化版本。

可持久化

xx表示一棵Treap的一個節點,則:
left(x)left(x)表示xx的左兒子;right(x)right(x)表示xx的右兒子;data(x)data(x)表示xx上儲存的值;key(x)key(x)表示xx的隨機權值;size(x)size(x)表示以xx爲根的子樹的大小。

非旋轉式Treap有兩個核心操作:

  1. Merge(x,y)Merge(x, y)x,yx, y爲根的兩棵有序的樹合併成一棵樹,返回這棵樹。
  2. Split(x,k)Split(x, k)將以xx爲根的樹分裂爲兩棵有序的樹(即樹1中所有元素均不大於樹2中的任何元素),其中樹1的大小爲kk,返回這兩棵樹。

在執行MergeMerge操作時,因爲要維護Treap作爲堆(不妨設其爲大根堆)的性質,要對x,yx, y的隨機權值進行判斷,若key(x)&gt;key(y)key(x) &gt; key(y),則將right(x)right(x)yy合併作爲xx的右兒子;反之將xxleft(y)left(y)合併作爲yy的左兒子。由於Treap的期望高度爲O(log n)O(\log\ n),因此MergeMerge操作時間複雜度上限爲O(log n)O(\log\ n).

在執行SplitSplit操作時,若size(left(x))ksize(left(x)) \leq k, 我們將xxleft(x)left(x)斷開後遞歸地執行Split(left(x),k)Split(left(x), k), 並將返回的第二棵樹與xx合併作爲新的第二棵樹;反之我們將xxright(x)right(x)斷開後遞歸地執行Split(left(x),ksize(left(x))1)Split(left(x), k - size(left(x)) - 1),並將xx與返回的第一棵樹合併作爲新的第一棵樹。最後返回得到的兩棵樹。注意:在邊界情況,即k=0k = 0k=size(x)k = size(x)時有一個返回值爲NULL.

SplitSplit操作中執行的MergeMerge操作,由於合併點的隨機權值的大小關係是已知的,帶入MergeMerge函數中可以發現其複雜度爲O(1)O(1)。由於Treap的期望高度爲O(log n)O(\log\ n),因此SplitSplit操作時間複雜度複雜度上限爲O(log n)O(\log\ n).

這兩個操作的可持久化版本和線段樹的可持久化處理方式完全類似,對於每個要修改的節點,都新建一個拷貝,對拷貝進行修改,已有的節點不做任何修改以保存歷史版本,同時空間複雜度從O(1)O(1)上升到O(log n)O(\log\ n).

核心代碼如下:

Node* Merge(Node *x, Node *y)
{
	if(!x)
		return y;
	if(!y)
		return x;
	if(key(x) > key(y)) // 維護堆的性質
	{
		Node *New = NewNode(x); // 創建x的拷貝
		New -> Child[1] = Merge(right(x), y); // 合併right(x)和y
		New -> Update(); // 更新size(New)
		return New;
	}
	else
	{
		Node *New = NewNode(y);
		New -> Child[0] = Merge(x, left(y));
		New -> Update();
		return New;
	}
}

pair<Node*, Node*> Split(Node *x, int k)
{
	if(x -> Size == k) // 處理邊界情況
		return make_pair(x, (Node*)NULL);
	if(k == 0)
		return make_pair((Node*)NULL, x);
	Node *New = NewNode(x); // 創建x的拷貝
	if(size(left(x)) >= k)
	{
		auto ret = Split(left(x), k); // 將left(x)分裂
		New -> Child[0] = NULL; // 斷開與左子樹的連接
		return make_pair(ret.first, Merge(ret.second, New)); // 將返回的第二棵樹與New合併
	}
	else
	{
		auto ret = Split(right(x), k - size(left(x)) - 1);
		New -> Child[1] = NULL;
		return make_pair(Merge(New, ret.first), ret.second);
	}
}

區間K大值

問題描述

給定一個長度爲nn的序列aann個詢問,每個詢問給出l, r, kl,\ r,\ k,問區間[l,r][l, r]中第kk大的數是多少。

解法

不妨設元素的大小在[1,m][1, m]中.

非旋轉式Treap中的所有操作基本上是基於MergeMergeSplitSplit兩個操作的。要在其中插入一個權值爲vv的點,我們要先查詢樹中有多少個點權值小於vv(設爲Find(x,v)Find(x, v)操作), 再對原樹進行SplitSplit,之後將第一棵樹,新節點,第二棵樹這三棵樹合併後得到新的樹。

同可持久化線段樹的版本類似,依次將序列中的每個元素插入到可持久化線段樹中,我們可以得到n+1n + 1個歷史版本(一開始平衡樹爲空),設爲T0,T1,...,TnT_0, T_1, ..., T_n. 由於不能像可持久化線段樹一樣對兩棵樹作差,我們無法直接在樹內二分得到答案。因此我們只能先二分答案,再判斷可行性。設目前二分的答案爲vv,我們先執行Find(Tr,v)Find(T_r, v)Find(Tl1,v)Find(T_{l - 1}, v)操作再對返回值作差,從而得到al,al+1,...,ara_l, a_{l + 1}, ..., a_r中權值小於vv的數的個數,設爲Num(v)Num(v), 二分查找到的最大的vv, 使得Num(v)&lt;kNum(v) &lt; k, 即爲答案。

時間複雜度爲O(nlog nlog m)O(n \cdot \log\ n \cdot \log\ m), 空間複雜度爲O(nlog n)O(n \cdot \log\ n).

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章