splay树 (树ADT连载 5/13)
可在作者的 github仓库 中获取本文和其他文章的 markdown 源文件及相关代码。
欢迎评论或仓库 PR 指出文章错漏或与我讨论相关问题,我将长期维护所有文章。
所有文章均用 Typora 完成写作,可使用 Typora 打开文章 md 文件,以获得最佳阅读体验。
🌲🌲🌲 一起手写一棵完整的 splay 树。
❗️ 【NEW】 ❗️
这是小白 yuki 推出的「树ADT」系列文章的第 5 篇 (5/13) 。
k e y w o r d s keywords k ey w or d s :
splay树 / 展开 / 自顶向下展开 / 摊还时间上的平衡
本文介绍一种易于实现的平衡二叉查找树 –– splay树。实际上本文是为了后续讲解多种 平衡二叉树 的前置文章,最终目的是讲解 「红黑树」 (已推出,红黑树从入门到看开 )。
本文重点在于展现如何设计一个 splay树 类,分析主要方法的代码实现,并给出该类的完整实现代码。读者学习之后 可以自己写出一个方法较为完备的splay树类 。
yuki的其他文章如下,欢迎阅读指正!
如下所有文章同时也在我的 github 仓库 中维护。
[TOC]
splay树
伸展树是解决基本 BST 在非平衡状态下主要操作无法达到 O ( l o g n ) O(logn) O ( l o g n ) 复杂度的另一种解决方案。相比 AVL 树,伸展树并不严格地使 BST 每次操作后保持平衡,因此伸展树的单次操作可能为 O ( n ) O(n) O ( n ) 复杂度,但伸展树保证 m m m 次操作的最坏时间复杂度为 m O ( l o g n ) mO(logn) m O ( l o g n ) ,也就是说其主要操作的 均摊时间复杂度 为 O ( l o g n ) O(logn) O ( l o g n ) 。伸展树相对 AVL 树的优点是 无需维护树的高度信息 ,从而能够节省一部分空间,且编程相对简单。伸展树的主要操作均基于 「展开 (splay)」 ,展开操作的基础是我们已在 AVL 中讲解过的旋转, 「展开」动作将目标结点旋转至根结点处,并在这个过程中调整沿途上的结点 。本节将介绍一种只需 O ( 1 ) O(1) O ( 1 ) 辅助空间的「自顶向下」展开的伸展树。
伸展树由 Daniel Sleator 和 Robert Tarjan 共同发明,发表于1985年的 论文 中。
本文内容为Mark Allen Weiss所著 数据结构与算法分析:Java语言描述 相关章节的整理和总结。代码亦来自该书,略作改动。
展开
伸展树「展开」的想法基于这样一个事实:当一个结点被访问时 (查询最小/最大/是否存在/插入/删除等),它很可能将在不久之后被再次访问。因此为了提高总体访问效率,在访问一个结点后,考虑将其移动到根结点处, 移动过程即「展开」 。在 AVL 树一节中我们已经掌握如何通过旋转向一个方向移动目标结点,很容易想到通过有限次的「单旋转」将目标结点展开至根结点处 (使其成为根结点)。
以下图为例,通过简单旋转将左侧树的结点 1 展开至根处得到右侧树。同时我们希望在展开过程中能够降低树高,而下图单旋转方式显然没有改变树高。
为了使得展开同时具有降低树高的效果,伸展树采用 双旋转与单旋转结合 的方式展开。主要为以下三种情形,每种情形有对称的两种子情形 (不再列出)。
1 2 3 单旋转: 与 AVL 树中介绍的单旋转相同,分为左单旋状和右单旋转。 之字形双旋转:与 AVL 树中介绍的之字形双旋转相同,实际上就是连续两次不同向的单旋转,分为左右双旋转和右左双旋转。 一字形双旋转:实际上就是连续两次同向的单旋转,分为左左双旋转和右右双旋转。
如下图左侧是我们在 AVL 树一节中已经熟悉的之字形左右双旋转 (先左旋 P − X P-X P − X ,再右旋 X − G X-G X − G ),右侧是一字形右右双旋转 (先右旋 G − P G-P G − P ,再右旋 P − X P-X P − X )。
伸展树中的「展开」操作要求 在能够双旋转时采用双旋转,只能单旋转时才执行单旋转 。现在我们利用此规则展开前面给出的 1 到 6 的链状树,如下,展开过程为两次一字形的双旋转和一次单旋转。尽管由于结点树较少而无法很好地看出树高减小的效果,但总体上,相比简单的单旋转,我们确实能够看到树高减小的效果。
自顶向下展开
展开操作的前提是找到目标结点,因此需要一次遍历来找到目标结点,然后再沿着此路径将目标结点旋转至根结点,这显然需要保存路径信息。在 AVL 树中我们以递归调用的方式,借助隐式保存了路径信息的递归栈实现上述做法。这里我们将介绍一种 无需栈空间的「自顶向下」展开 ,即无需遍历找到目标后再展开,而是在自顶向下寻找目标的过程中实时地展开。
在前面的例子中我们没有展示之字形旋转的过程,实际上,本节给出的「自顶向下」的展开实现中, 对之字形的情况按照「单旋转」处理 。此外,展开的过程中始终维护树 L L L 、树 M M M 以及树 R R R ,使得旋转操作得到简化,其定义如下。
整棵树中小于中间树 M M M 的根结点但不在 M M M 中的结点存储在 L L L 树中,大于中间树 M M M 的根结点但不在 M M M 中的结点存储在 R R R 树中。初始时 M M M 即为原树,L , R L, R L , R 为空树。
下图从上到下表示 单旋转、之字形旋转 (同单旋转) 以及一字形旋转 。可以看到,与 AVL 的旋转相比,这里的「单旋转」实际上只是修改了一些链接关系 ( AVL 单旋转本质也是修改一些链接关系,但重点在于旋转主体目标结点相对关系的改变,例如下图的单旋转,若为 AVL 中的单旋转,则 X X X 到 Y Y Y 的链接「右旋」后将变为 Y Y Y 到 X X X 的链接),而「一字形旋转」也只是执行了一次 Y − X Y-X Y − X 右旋后修改了一些链接关系。因此相比 AVL 树的实现,「自顶向下」伸展树的代码十分易于编写。
实际编程中应用了一些技巧,例如利用 n u l l N o d e nullNode n u llN o d e 来简化程序并提高了程序效率。通过设置 h e a d e r header h e a d er 来保存 L L L 和 R R R 首次不为空树时的树根,以便于最后的 组装 操作。下图表现了展开操作最后的组装动作,同样只是简单地修改一些链接关系。具体实现以及提到的一些实现技巧在初看时较难理解,因此我在本文「主要方法」以及「类的实现代码」中提供了详细注释,请读者务必仔细阅读。
伸展树类架构
以下是伸展树 SplayTree 架构。
类成员/方法
描述
private BinaryNode<E> root
字段,splay树的根结点
private BinaryNode<E> newNode
字段,插入结点
private BinaryNode<E> nullNode
字段,为方便编程而设置的 n u l l null n u ll 结点
public SplayTree()
构造器
public void makeEmpty()
树置空
public boolean isEmpty()
树判空
public void insert(E e)
插入结点方法
public void remove(E e)
删除结点方法
public E findMin()
查找最小结点驱动方法
public E findMax()
查找最大结点驱动方法
public boolean contains(E e)
判断是否包含指定元素方法
public void printTree()
按中序遍历打印树的驱动方法
public int size()
求树的结点个数驱动方法
private BinaryNode<E> splay(E e, BinaryNode<E> t)
展开方法
private BinaryNode<E> findMin(BinaryNode<E> t)
返回树的最小结点
private BinaryNode<E> findMax(BinaryNode<E> t)
返回树的最大结点
private void printTree(BinaryNode<E> t)
中序遍历打印树
private int size(BinaryNode<E> t)
递归地遍历所有结点,返回结点总数
private BinaryNode<E> rotateRight(BinaryNode<E> k2)
右单旋转
private BinaryNode<E> rotateLeft(BinaryNode<E> k1)
左单旋转
以下是二叉树结点嵌套类 BinaryNode 的架构。
类成员/方法
描述
public E element
字段,本结点数据
public BinaryNode<E> left
字段,本结点的左子结点
public BinaryNode<E> right
字段,本结点的右子结点
public BinaryNode()
构造器
public BinaryNode(E element)
构造器
public BinaryNode(E element, BinaryNode<E> left, BinaryNode<E> right)
构造器
主要方法
splay
展开。伸展树中最主要的操作,传入目标结点值 e e e 和当前根结点 t t t (中间树 M M M 的根结点) , 在寻找目标结点的过程中完成展开 。首先新建树 L L L 与树 R R R ,初始为空树,方法中并不直接声明这两棵树,而是通过 h e a d e r , l e f t T r e e M a x , r i g h t T r e e M i n header, leftTreeMax, rightTreeMin h e a d er , l e f tT ree M a x , r i g h tT ree M in 来动态维护。动态维护主要体现在 L L L 中的 l e f t T r e e M a x leftTreeMax l e f tT ree M a x 以及 R R R 中的 r i g h t T r e e M i n rightTreeMin r i g h tT ree M in ,一开始令此二者为 h e a d e r header h e a d er ,该变量仅用于保存 L L L 和 R R R 初次不为空树时的根结点,用于最后 M , L , R M, L, R M , L , R 的组装。如其名,在方法的整个过程中,此二者始终代表 L L L 中最大结点以及 R R R 中最小结点。
首次执行 rightTreeMin.left = t
时,即 header.left = t
,且此后 header.left
引用不变, leftTreeMax.right = t
同理。
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 private BinaryNode<E> splay (E e, BinaryNode<E> t) { BinaryNode<E> leftTreeMax, rightTreeMin; header.left = header.right = nullNode; leftTreeMax = rightTreeMin = header; nullNode.element = e; while (true ) { int compareRes = e.compareTo(t.element); if (compareRes < 0 ) { if (e.compareTo(t.left.element) < 0 ) t = rotateRight(t); if (t.left == nullNode) break ; rightTreeMin.left = t; rightTreeMin = t; t = t.left; } else if (compareRes > 0 ) { if (e.compareTo(t.right.element) > 0 ) t = rotateLeft(t); if (t.right == nullNode) break ; leftTreeMax.right = t; leftTreeMax = t; t = t.right; } else break ; } leftTreeMax.right = t.left; rightTreeMin.left = t.right; t.left = header.right; t.right = header.left; return t; }
insert
插入值为 e e e 的结点。当前树空时,将待插入的结点作为根结点,否则 将目标结点旋转至根处 ,再根据该结点值与 e e e 的大小调整链接关系。
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 public void insert (E e) { if (newNode == null ) newNode = new BinaryNode <>(e); if (root == nullNode) { newNode.left = newNode.right = nullNode; root = newNode; } else { root = splay(e, root); int compareRes = e.compareTo(root.element); if (compareRes < 0 ) { newNode.left = root.left; newNode.right = root; root.left = nullNode; root = newNode; } else if (compareRes > 0 ) { newNode.right = root.right; newNode.left = root; root.right = nullNode; root = newNode; } else return ; } newNode = null ; }
findMin/findMax
查找具有最小/最大数据的结点的操作由驱动方法和具体方法实现。与 AVL 树中对应方法的不同仅在于此处的具体方法在找到目标结点后要对其执行一次 s p l a y splay s pl a y 操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public E findMin () { if (isEmpty()) throw new NoSuchElementException (); return findMin(root).element; } private BinaryNode<E> findMin (BinaryNode<E> t) { while (t.left != nullNode) t = t.left; root = splay(t.element, root); return t; } public E findMax () { if (isEmpty()) throw new NoSuchElementException (); return findMax(root).element; } private BinaryNode<E> findMax (BinaryNode<E> t) { while (t.right != nullNode) t = t.right; root = splay(t.element, root); return t; }
remove
调用 c o n t a i n s contains co n t ain s 方法查找是否存在值为 e e e 的结点,若不存在直接返回,若存在则通过 c o n t a i n s contains co n t ain s 方法,已将该结点展开为根结点,根据此时 r o o t root roo t 有无左儿子来执行具体的删除动作。若无左儿子,可直接删除 r o o t root roo t ,即让删除后的根结点为 r o o t . r i g h t root.right roo t . r i g h t ,否则通过 splay(e, newRoot)
将此时根的左子树中具有最大值的根结点 (必小于 e e e ) 展开至 n e w R o o t newRoot n e wR oo t ,以其为删除后的根结点,并执行 newRoot.right = root.right
。此删除为 懒惰删除 ,即只改变链接关系来达到删除的效果。
1 2 3 4 5 6 7 8 9 10 11 public void remove (E e) { if (!contains(e)) return ; BinaryNode<E> newRoot; if (root.left == nullNode) newRoot = root.right; else { newRoot = root.left; newRoot = splay(e, newRoot); newRoot.right = root.right; } root = newRoot; }
contains
若为空树直接返回 f a l s e false f a l se ,否则以值为 e e e 的结点为目标执行 s p l a y splay s pl a y ,最后根据 s p l a y splay s pl a y 的目标结点值是否等于 e e e 来返回 t r u e true t r u e 或 f a l s e false f a l se 。
1 2 3 4 5 public boolean contains (E e) { if (isEmpty()) return false ; root = splay(e, root); return root.element.compareTo(e) == 0 ; }
rotateRight
右单旋转,见前述说明。
rotateLeft
左单旋转,见前述说明。
类的实现代码
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 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 class SplayTree <E extends Comparable <? super E>> { private BinaryNode<E> root, newNode; private final BinaryNode<E> nullNode; public SplayTree () { this .nullNode = new BinaryNode <>(); this .root = this .nullNode.left = this .nullNode.right = this .nullNode; } public void insert (E e) { if (newNode == null ) newNode = new BinaryNode <>(e); if (root == nullNode) { newNode.left = newNode.right = nullNode; root = newNode; } else { root = splay(e, root); int compareRes = e.compareTo(root.element); if (compareRes < 0 ) { newNode.left = root.left; newNode.right = root; root.left = nullNode; root = newNode; } else if (compareRes > 0 ) { newNode.right = root.right; newNode.left = root; root.right = nullNode; root = newNode; } else return ; } newNode = null ; } public void remove (E e) { if (!contains(e)) return ; BinaryNode<E> newRoot; if (root.left == nullNode) newRoot = root.right; else { newRoot = root.left; newRoot = splay(e, newRoot); newRoot.right = root.right; } root = newRoot; } public E findMin () { if (isEmpty()) throw new NoSuchElementException (); return findMin(root).element; } public E findMax () { if (isEmpty()) throw new NoSuchElementException (); return findMax(root).element; } public boolean contains (E e) { if (isEmpty()) return false ; root = splay(e, root); return root.element.compareTo(e) == 0 ; } public void printTree () { if (isEmpty()) System.out.println("Empty tree" ); else printTree(root); } public int size () { return size(root); } public void makeEmpty () { root = nullNode; } public boolean isEmpty () { return root == nullNode; } private final BinaryNode<E> header = new BinaryNode <>(null ); private BinaryNode<E> splay (E e, BinaryNode<E> t) { BinaryNode<E> leftTreeMax, rightTreeMin; header.left = header.right = nullNode; leftTreeMax = rightTreeMin = header; nullNode.element = e; while (true ) { int compareRes = e.compareTo(t.element); if (compareRes < 0 ) { if (e.compareTo(t.left.element) < 0 ) t = rotateRight(t); if (t.left == nullNode) break ; rightTreeMin.left = t; rightTreeMin = t; t = t.left; } else if (compareRes > 0 ) { if (e.compareTo(t.right.element) > 0 ) t = rotateLeft(t); if (t.right == nullNode) break ; leftTreeMax.right = t; leftTreeMax = t; t = t.right; } else break ; } leftTreeMax.right = t.left; rightTreeMin.left = t.right; t.left = header.right; t.right = header.left; return t; } private BinaryNode<E> rotateRight (BinaryNode<E> k2) { BinaryNode<E> k1 = k2.left; k2.left = k1.right; k1.right = k2; return k1; } private BinaryNode<E> rotateLeft (BinaryNode<E> k1) { BinaryNode<E> k2 = k1.right; k1.right = k2.left; k2.left = k1; return k2; } private BinaryNode<E> findMin (BinaryNode<E> t) { while (t.left != nullNode) t = t.left; root = splay(t.element, root); return t; } private BinaryNode<E> findMax (BinaryNode<E> t) { while (t.right != nullNode) t = t.right; root = splay(t.element, root); return t; } private void printTree (BinaryNode<E> t) { if (t != nullNode) { printTree(t.left); System.out.println(t.element); printTree(t.right); } } private int size (BinaryNode<E> t) { if (t != nullNode) { if (t.left != nullNode && t.right != nullNode) { return 1 + size(t.left) + size(t.right); } else { return t.left != nullNode ? 1 + size(t.left) : 1 + size(t.right); } } return 0 ; } private static class BinaryNode <E>{ public E element; public BinaryNode<E> left, right; @SuppressWarnings("unused") public BinaryNode () { this (null , null , null ); } public BinaryNode (E element) { this (element, null , null ); } public BinaryNode (E element, BinaryNode<E> left, BinaryNode<E> right) { this .element = element; this .left = left; this .right = right; } } }
测试代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package com.yukiyama;import java.util.NoSuchElementException;public class SplayTreeDemo { public static void main (String[] args) { SplayTree<Integer> t = new SplayTree <>( ); int [] elements = {3 ,2 ,1 ,4 ,5 ,6 ,7 ,16 ,15 ,14 ,13 ,12 ,11 ,10 ,8 ,9 }; for (int e : elements) t.insert(e); t.printTree(); System.out.printf("size: %d, min: %d, max: %d\n" , t.size(), t.findMin(), t.findMax()); System.out.printf("has 10? %s, has 100? %s\n" , t.contains(10 ), t.contains(100 )); t.remove(1 ); t.remove(10 ); t.remove(16 ); t.printTree(); System.out.printf("size: %d\n" , t.size()); System.out.println("is empty: " + t.isEmpty()); t.makeEmpty(); System.out.println("is empty: " + t.isEmpty()); } }