1、 概述
二叉查找樹(Binary Search Tree,也叫二叉排序樹,即Binary Sort Tree)能夠支持多種動態集合操作,它可以用來表示有序集合、建立索引等,因而在實際應用中,二叉排序樹是一種非常重要的數據結構。
從算法複雜度角度考慮,我們知道,作用於二叉查找樹上的基本操作(如查找,插入等)的時間複雜度與樹的高度成正比。對一個含n個節點的完全二叉樹,這些操作的最壞情況運行時間爲O(log n)。但如果因爲頻繁的刪除和插入操作,導致樹退化成一個n個節點的線性鏈(此時即爲一個單鏈表),則這些操作的最壞情況運行時間爲O(n)。爲了克服以上缺點,很多二叉查找樹的變形出現了,如紅黑樹、AVL樹,Treap樹等。
本文介紹了二叉查找樹的一種改進數據結構–伸展樹(Splay Tree)。它的主要特點是不會保證樹一直是平衡的,但各種操作的平攤時間複雜度是O(log n),因而,從平攤複雜度上看,二叉查找樹也是一種平衡二叉樹。另外,相比於其他樹狀數據結構(如紅黑樹,AVL樹等),伸展樹的空間要求與編程複雜度要小得多。
2、 基本操作
伸展樹的出發點是這樣的:考慮到局部性原理(剛被訪問的內容下次可能仍會被訪問,查找次數多的內容可能下一次會被訪問),爲了使整個查找時間更小,被查頻率高的那些節點應當經常處於靠近樹根的位置。這樣,很容易得想到以下這個方案:每次查找節點之後對樹進行重構,把被查找的節點搬移到樹根,這種自調整形式的二叉查找樹就是伸展樹。每次對伸展樹進行操作後,它均會通過旋轉的方法把被訪問節點旋轉到樹根的位置。
爲了將當前被訪問節點旋轉到樹根,我們通常將節點自底向上旋轉,直至該節點成爲樹根爲止。“旋轉”的巧妙之處就是在不打亂數列中數據大小關係(指中序遍歷結果是全序的)情況下,所有基本操作的平攤複雜度仍爲O(log n)。
伸展樹主要有三種旋轉操作,分別爲單旋轉,一字形旋轉和之字形旋轉。爲了便於解釋,我們假設當前被訪問節點爲X,X的父親節點爲Y(如果X的父親節點存在),X的祖父節點爲Z(如果X的祖父節點存在)。
(1) 單旋轉
節點X的父節點Y是根節點。這時,如果X是Y的左孩子,我們進行一次右旋操作;如果X 是Y 的右孩子,則我們進行一次左旋操作。經過旋轉,X成爲二叉查找樹T的根節點,調整結束。
(2) 一字型旋轉
節點X 的父節點Y不是根節點,Y 的父節點爲Z,且X與Y同時是各自父節點的左孩子或者同時是各自父節點的右孩子。這時,我們進行一次左左旋轉操作或者右右旋轉操作。
(3) 之字形旋轉
節點X的父節點Y不是根節點,Y的父節點爲Z,X與Y中一個是其父節點的左孩子而另一個是其父節點的右孩子。這時,我們進行一次左右旋轉操作或者右左旋轉操作。
3、伸展樹區間操作
在實際應用中,伸展樹的中序遍歷即爲我們維護的數列,這就引出一個問題,怎麼在伸展樹中表示某個區間?比如我們要提取區間[a,b],那麼我們將a前面一個數對應的結點轉到樹根,將b 後面一個結點對應的結點轉到樹根的右邊,那麼根右邊的左子樹就對應了區間[a,b]。原因很簡單,將a 前面一個數對應的結點轉到樹根後, a 及a 後面的數就在根的右子樹上,然後又將b後面一個結點對應的結點轉到樹根的右邊,那麼[a,b]這個區間就是下圖中B所示的子樹。
利用區間操作我們可以實現線段樹的一些功能,比如回答對區間的詢問(最大值,最小值等)。具體可以這樣實現,在每個結點記錄關於以這個結點爲根的子樹的信息,然後詢問時先提取區間,再直接讀取子樹的相關信息。還可以對區間進行整體修改,這也要用到與線段樹類似的延遲標記技術,即對於每個結點,額外記錄一個或多個標記,表示以這個結點爲根的子樹是否被進行了某種操作,並且這種操作影響其子結點的信息值,當進行旋轉和其他一些操作時相應地將標記向下傳遞。
與線段樹相比,伸展樹功能更強大,它能解決以下兩個線段樹不能解決的問題:
(1) 在a後面插入一些數。方法是:首先利用要插入的數構造一棵伸展樹,接着,將a 轉到根,並將a 後面一個數對應的結點轉到根結點的右邊,最後將這棵新的子樹掛到根右子結點的左子結點上。
(2) 刪除區間[a,b]內的數。首先提取[a,b]區間,直接刪除即可。
4、實現
代碼全部來自【參考資料2】。
(1)旋轉操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
// node 爲結點類型,其中ch[0]表示左結點指針,ch[1]表示右結點指針 // pre 表示指向父親的指針 // Rotate函數用於(左/右)旋轉x->pre void
Rotate(node *x, int
d) // 旋轉操作,d=0 表示左旋,d=1 表示右旋 { node *y = x->pre; Push_Down(y), Push_Down(x); // 先將Y 結點的標記向下傳遞(因爲Y 在上面),再把X 的標記向下傳遞 y->ch[! d] = x->ch[d]; if
(x->ch[d] != Null) x->ch[d]->pre = y; x->pre = y->pre; if
(y->pre != Null) if
(y->pre->ch[0] == y) y->pre->ch[0] = x; else
y->pre->ch[1] = x; x->ch[r] = y, y->pre = x, Update(y);
// 維護Y 結點 if
(y == root) root = x; // root 表示整棵樹的根結點 } |
(2)splay操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
void
Splay(node *x, node *f) // Splay 操作,表示把結點x 轉到結點f 的下面 { for
(Push_Down(x) ; x->pre != f; ) // 一開始就將X 的標記下傳 if
(x->pre->pre == f) // 父結點的父親即爲f,執行單旋轉 if
(x->pre->ch[0] == x) Rotate(x, 1); else
Rotate(x, 0); else { node *y = x->pre, *z = y->pre; if
(z->ch[0] == y) if
(y->ch[0] == x) Rotate(y, 1), Rotate(x, 1);
// 一字形旋轉 else Rotate(x, 0), Rotate(x, 1);
// 之字形旋轉 else if
(y->ch[1] == x) Rotate(y, 0), Rotate(x, 0);
// 一字形旋轉 else Rotate(x, 1), Rotate(x, 0);
// 之字形旋轉 } Update(x);
// 最後再維護X 結點 } |
(3)將第k個數轉到要求的位置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
// 找到處在中序遍歷第k 個結點,並將其旋轉到結點f 的下面 void
Select( int k, node *f) { int
tmp; node *t; for
(t = root; ; ) // 從根結點開始 { Push_Down(t);
// 由於要訪問t 的子結點,將標記下傳 tmp = t->ch[0]->size;
// 得到t 左子樹的大小 if
(k == tmp + 1) break ;
// 得出t 即爲查找結點,退出循環 if
(k <= tmp) // 第k 個結點在t 左邊,向左走 t = t->ch[0]; else
// 否則在右邊,而且在右子樹中,這個結點不再是第k 個 k -= tmp + 1, t = t->ch[1]; } Splay(t, f);
// 執行旋轉 } |
5、 應用
(1) 數列維護問題
題目:維護一個數列,支持以下幾種操作:
1. 插入:在當前數列第posi 個數字後面插入tot 個數字;若在數列首位插入,則posi 爲0。
2. 刪除:從當前數列第posi 個數字開始連續刪除tot 個數字。
3. 修改:從當前數列第posi 個數字開始連續tot 個數字統一修改爲c 。
4. 翻轉:取出從當前數列第posi 個數字開始的tot 個數字,翻轉後放入原來的位置。
5. 求和:計算從當前數列第posi 個數字開始連續tot 個數字的和並輸出。
6. 求和最大子序列:求出當前數列中和最大的一段子序列,並輸出最大和。
(2) 輕量級web服務器lighttpd中用到數據結構splay tree.
6、 參考資料
(1) 楊思雨《伸展樹的基本操作與應用》
(2) Crash《運用伸展樹解決數列維護問題》