学过数据结构的小伙伴,一定都知道二叉查找树,也叫二叉排序树,英文缩写是BST。
为了维持二叉查找树的高效率查找,就需要对二叉查找树进行平衡调整。在数据结构当中大名鼎鼎的红黑树、AVL,就是典型的自平衡二叉查找树。
今天,我们来介绍一种更有意思的自平衡二叉树:伸展树。它的英文名字是Splay Tree。
Part 1 为什么要伸展
我们来回顾一下,二叉搜索树满足:
左子结点 < 当前结点 < 右子结点
为什么要有平衡树呢?因为当二叉搜索树如下图“瘸腿”时,搜索左侧的结点,原来的速度 会掉到 ,与链表一个速度,失去了价值。
为了避免树瘸腿,我们可以通过适当的旋转来调整树的结构。
伸展树的核心,是通过不断的将结点旋转到根结点(即为splay
操作),来保证整棵树不会跟链表一样。它由 Daniel Sleator 和 Robert Tarjan 发明 。
1.1 结点
node中记录的信息:
-
parent
:父结点的指针。 -
child[0/1]
:child[0]
为左子结点的指针,child[1]
为右子结点的指针。 -
value
:这个结点存了啥。 -
count
:二叉树中不存在两个值相同的结点,如果需要记录多个就需要一个变量来记录这个数值出现了多少次。(count
就是来干这个活的) -
size
:这个结点为根结点的子树中有多少个结点。
基础操作:
-
maintain
:更新结点的size
。(更新要自底向上) -
get
:获取自己的类型,0为左子结点,1为右子结点。 -
connect
: 连接两个结点。
class node {
public:
node *parent; // 父结点的指针
node *child[2]; // child[0]为左子结点的指针,child[1]为右子结点的指针。
int value, count, size; // 数据,出现次数,子树大小
node(int _value) {
value = _value;
parent = child[0] = child[1] = NULL;
count = size = 1;
}
};
我们把对指针是否为NULL提到了两个基础操作中,所以只能放在伸展树的类中。
destroy
:销毁整个树。因为结点使用的是堆空间(new出来的),所以必须要销毁(delete),否则会内存泄漏。
class splayTree {
public:
node *root;
splayTree() {
root = NULL;
}
~splayTree() {
destroy(root); // 从root开始销毁
}
void destroy(node *current) { // 销毁结点
if (current) {
destroy(current->child[0]); // 后序遍历
destroy(current->child[1]);
delete current;
}
}
void maintain(node *current) { // 更新size
if (current == NULL) { // 可能传入的是一个空指针
return;
}
current->size = current->count; // 先将自己加上
if (current->child[0]) { // 防止没儿子,NULL,报错
current->size += current->child[0]->size; // 加上儿子
}
if (current->child[1]) {
current->size += current->child[1]->size;
}
}
int get(node *current) { // 获得结点是左结点还是右结点,0左1右
if (current == NULL || current->parent == NULL) { // 传入空指针;根结点没有父亲,特判一下
return -1;
}
return current->parent->child[1] == current; // 父亲的右子结点 是不是 自己
}
void connect(node *parent, node *current, int type) { // 父结点指针,当前结点指针,类型(0左1右)
if (parent) { // parent 可能为NULL
parent->child[type] = current;
}
if (current) { // current 也可能为NULL
current->parent = parent;
}
}
};
可能会有读者好奇:这个size
是用来干什么的呢?别急,等到查询排名时就会用到。
1.2 左旋 & 右旋
通过旋转,我们能在保证旋转可以保证左子结点 < 当前结点 < 右子结点
的情况下调整结点之间的关系。
旋转有两种定义:
-
对以x为根的子树进行旋转。
-
把x向上旋转。
1.2.1 以x为根的子树进行旋转
1.2.2 把x向上旋转
上面的动画使用文字叙述即为:
左旋
将被旋转结点的左结点变为父结点的右结点。
父结点变为被旋转结点的左子结点
原父结点的父结点(爷爷)变为被旋转结点的父结点。
右旋
将被旋转结点的右结点变为父结点的左结点。
父结点变为被旋转结点的右子结点
原父结点的父结点(爷爷)变为被旋转结点的父结点。
虽然1、2两种旋转定义不同,但是只看移动这分明就是一种嘛。
有细心的读者发现:左右旋的方式与AVL、红黑树等其他二叉树相同。
因为这是唯一一种不改变中序遍历的旋转方式。
左旋与右旋都不会改变中序遍历的结果,如上方动图,中序遍历始终为1 y 2 x 3
除了举例论证,你也可以这样理解:
这是因为左旋和右旋会保证旋转后的二叉树
左子结点 < 当前结点 < 右子结点
,因为旋转没有插入或删除结点,所以二叉树的中序遍历没变。
1.2.3 合并左右旋
想要合并左右旋,只能使用定义二:把x向上旋转。(原因见下)
左旋和右旋虽然属于两种操作,但是细想想:
一个结点不是左子结点,就是右子结点。因为我们要将结点向上旋转,所以每一个结点(除了根结点),只能朝一个方向旋转,也就是父结点的方向。
所以可以将两种操作整合为一种:
当被旋转的结点的类型为左子结点时,进行右旋。
当被旋转的结点的类型为右子结点时,进行左旋<