树,二叉树和二叉查找树
之前介绍过链表,栈与队列。这些数据结构都是线性且一维的。我们为了打破这种限制,创建一个新的数据结构-树。程序世界中的树与自然界中的树是倒过来的:根在顶,叶子在底。根节点只有子节点,没有父节点;而叶子节点没有子节点,或者子节点是空结构。
树的递归定义:
- 空结构是一棵空树;
- 如果t1,t2…tk是不相交的树,则以它们的根作为子节点的数据结构也是一棵树;
- 只有通过(1)(2)产生的数据结构才是树。
二叉树的实现
之前我们曾经探讨过查找算法及其相应的复杂度。就算是一维的链表,也有自组织链表以及跳跃链表等多种方式降低查找所需消耗。这里将要介绍的二叉树是一种常用的数据结构,如果按照特定排序,将有效减少查找开销。
根据二叉树的定义,每一个父节点都有两个子节点,即节点的层次是从根到该节点所经过的弧的个数,根的层数是1,其非空子节点的层数是2。如果除最后一个层次外所有节点都有两个子节点,那么该树称为完全二叉树(complete binary tree)。
对于非空二叉树,若其所有的非终端节点都有两个非空子节点,则叶节点数m与非终端节点数k的关系是:
m
=
k
+
1
m=k+1
m=k+1。
本文主要讨论查找二叉树,即有序二叉树。对于树中的某个非终端节点n,左子节点中的值小于n,右子节点中的值大于n。前提是避免将相同值的副本放在同一颗树中。这样的查找方式比链表更佳优化。
下面介绍通用二叉树的实现,假定节点由一个信息跟两个子节点指针组成:
#include <stack>
#include <queue>
#include <iostream>
using namespace std;
template<class T>
class Stack : public stack<T> {
public:
T pop() {
T tmp = stack<T>::top();
stack<T>::pop();
return tmp;
}
}; //重定义栈的弹出。使之可以返回栈顶元素。
template<class T>
class Queue : public std::queue<T> {
public:
T dequeue() {
T tmp = queue<T>::front();
queue<T>::pop();
return tmp;
}
void enqueue(const T& el) {
push(el);
}
}; //重定义队列的弹出。使之可以返回队首元素。
template<class T>
class BSTNode {
public:
BSTNode() {
left = right = 0;
}
BSTNode(const T& e, BSTNode<T> *l = 0, BSTNode<T> *r = 0) {
el = e; left = l; right = r;
}
T el; //节点信息
BSTNode<T> *left, *right; //左节点,右节点指针。
}; //二叉树节点类
template<class T>
class BST {
public:
BST() {
root = 0;
}
~BST() {
clear();
}
void clear() {
clear(root);
root = 0;
}
bool isEmpty() const {
return root == 0;
}
void preorder() {
preorder(root);
}
void inorder() {
inorder(root);
}
void postorder() {
postorder(root);
}
void insert(const T&);
void recursiveInsert(const T& el) {
recursiveInsert(root,el);
}
T* search(const T& el) const {
return search(root,el);
}
T* recursiveSearch(const T& el) const {
return recursiveSearch(root,el);
}
void deleteByCopying(BSTNode<T>*&);
void findAndDeleteByCopying(const T&);
void deleteByMerging(BSTNode<T>*&);
void findAndDeleteByMerging(const T&);
void iterativePreorder();
void iterativeInorder();
void iterativePostorder();
void breadthFirst();
void MorrisPreorder();
void MorrisInorder();
void MorrisPostorder();
void balance(T*,int,int);
protected:
BSTNode<T>* root; //根节点指针。
void clear(BSTNode<T>*); //清除节点分配的空间,在析构函数中运行。
void recursiveInsert(BSTNode<T>*&, const T&);
T* search(BSTNode<T>*, const T&) const;
T* recursiveSearch(BSTNode<T>*, const T&) const;
void preorder(BSTNode<T>*);
void inorder(BSTNode<T>*);
void postorder(BSTNode<T>*);
virtual void visit(BSTNode<T>* p) {
cout << p->el << ' ';
}
}; //二叉树实现的class。
二叉树的查找
本节直接基于上一节的代码实现二叉树查找。
template<class T>
T* BST<T>::search(BSTNode<T>* p, const T& el) const {
while (p != 0)
{
if (el == p->el)
return &p->el; //找到了目标。
else if (el < p->el)
p = p->left; //当前节点信息小于目标,查找左子节点。
else p = p->right; //当前节点信息小于目标,查找右子节点。
}
return 0; //没有找到节点。
}
单次查找的复杂度是到达该节点的路径长度+1,复杂度取决于树的形状以及节点在树中的位置。查找算法的复杂性是由查找过程中的比较次数来决定。
内部路径长度(Internal Path Length)是所有节点的所有路径长度的总和。计算公式:对于所有的层次i,计算
∑
(
i
−
1
)
l
i
\sum(i-1)l_i
∑(i−1)li,
l
i
l_i
li是层次为
i
i
i的节点数目,即节点数-1,即为该层路径数。平均路径长度(average path length)由
I
P
L
/
n
IPL/n
IPL/n得到,该值取决于树的形状。最坏情况下,退化为链表:
p
a
t
h
w
o
r
s
t
=
1
/
n
∑
i
=
1
n
(
i
−
1
)
=
(
n
−
1
)
/
2
=
O
(
n
)
path_{worst}=1/n\displaystyle\sum_{i=1}^{n}(i-1)=(n-1)/2=O(n)
pathworst=1/ni=1∑n(i−1)=(n−1)/2=O(n)
查找过程将耗费n个时间单位。
最好情况:所有叶子节点位于最多两个层次中,只有倒数第二层上的节点可以有一个子节点。我们在这里用高度为h完全二叉树来近似计算:
I
P
L
=
∑
i
=
1
h
−
1
i
2
i
IPL=\displaystyle\sum_{i=1}^{h-1}i2^i
IPL=i=1∑h−1i2i
∑
i
=
1
h
−
1
2
i
=
2
h
−
2
\displaystyle\sum_{i=1}^{h-1}2^i=2^h-2
i=1∑h−12i=2h−2
可以得到:
I
P
L
=
2
I
P
L
−
I
P
L
=
(
h
−
1
)
2
h
−
∑
i
=
1
h
−
1
2
i
=
(
h
−
2
)
2
h
+
2
IPL=2IPL-IPL=(h-1)2^h-\displaystyle\sum_{i=1}^{h-1}2^i=(h-2)2^h+2
IPL=2IPL−IPL=(h−1)2h−i=1∑h−12i=(h−2)2h+2
由于完整二叉树节点数为:
n
=
2
h
−
1
n=2^h-1
n=2h−1可得:
p
a
t
h
b
e
s
t
=
I
P
L
/
n
=
(
(
h
−
2
)
2
h
+
2
)
/
(
2
h
−
1
)
≈
h
−
2
path_{best}=IPL/n=((h-2)2^h+2)/(2^h-1)≈h-2
pathbest=IPL/n=((h−2)2h+2)/(2h−1)≈h−2
该树的高度 h = l g ( n + 1 ) h=lg(n+1) h=lg(n+1),因此 p a t h b e s t = l g ( n + 1 ) − 2 path_{best}=lg(n+1)-2 pathbest=lg(n+1)−2;在完全平衡树中平均路径长度 A P L = l g ( n + 1 ) − 2 = O ( l g n ) APL=lg(n+1)-2=O(lgn) APL=lg(n+1)−2=O(lgn)。
那么新的问题是,最终的结果一定是最好情况和最坏情况中间的某个值。关于一个随机二叉树来说,经过一个复杂的数学计算可以证明 ,最好情况下的平均路径长度和期望情况下的路径长度只相差27.85%,因此在大多数情况中,即便不进行平衡,在二叉树中的查找也十分高效。但不能忽略极端情况-----二叉树被拉成了链表,此时的复杂度O(n)是不能被接受的。【如果对该数学过程感兴趣,可以查阅相关资料,笔者使用《C++数据结构与算法第四版》】
树的遍历
遍历是把每一个节点访问一次,可以解释为把所有的节点放在一条线上,或者将树线性化。而遍历的方式繁多,有n个节点的树,就有n!种遍历方式。这里介绍两种常用的遍历方式:广度优先遍历和深度优先遍历。
广度优先遍历
这种方式从最底部到最高层,或反之;从左到右,或反之。这样就有4种访问方式了(上下,左右各两种)。如果采用从上到下,从左往右的广度优先遍历:上面的序列将会是:15,10,24,5,12,18,30。下面实现最典型的从上到下,从左往右的广度遍历:
template<typename T>
void BST<T>::breadthFirst()
{
Queue<BSTNode<T> *>queue;
BSTNode<T> *p = root;
if(p != 0)
{
queue.enqueue(p); //先把根节点放进队列。
while(!queue.empty())
{
p = dequeue();
visit(p); //弹出并访问。
if(p->left != 0)
queue.enqueue(p->left); //将左子节点放入队尾。
if(p->right != 0)
queue.enqueue(p->right); // 将右子节点放入队尾。
}
}
}
深度优先遍历
深度优先遍历将尽可能地向左或向右进行。这样的遍历有3个任务:
- V 访问节点
- L 遍历左子树
- R 遍历右子树
这3个任务在每个节点上都以相同的顺序执行。如果确定先左后右,那么又有3种遍历方式:
- VLR 先序树遍历
- LVR 中序树遍历
- LRV 后序树遍历
下面采用递归的方式来实现深度优先遍历:
template<class T>
void BST<T>::inorder(BSTNode<T> *p) {
if (p != 0) {
inorder(p->left);
visit(p);
inorder(p->right);
}
} //中序树遍历
template<class T>
void BST<T>::preorder(BSTNode<T> *p) {
if (p != 0) {
visit(p);
preorder(p->left);
preorder(p->right);
}
} // 前序树遍历
template<class T>
void BST<T>::postorder(BSTNode<T>* p) {
if (p != 0) {
postorder(p->left);
postorder(p->right);
visit(p);
}
} //后序树遍历
之前学习过递归在许多情况下效率远远低于迭代,尤其是在函数体中使用了两次递归。我们尝试去掉递归(实质上系统和硬件也会这么干,最终生成栈+迭代的形式)。
首先是前序树遍历的非递归实现:
template<class T>
void BST<T>::iterativePreorder() {
Stack<BSTNode<T>*> travStack;
BSTNode<T> *p = root;
if (p != 0) {
travStack.push(p);
while (!travStack.empty()) {
p = travStack.pop();
visit(p);
if (p->right != 0)
travStack.push(p->right);
if (p->left != 0) // 左子节点比右子节点后压入。
travStack.push(p->left);
}
}
}
与递归不同的是,这里不能单独通过调换api顺序的方式改变前,中,后序。因为读取的节点已经确定,无论如何变动都是前序。
后序非递归实现:
template<class T>
void BST<T>::iterativePostorder() {
Stack<BSTNode<T>*> travStack;
BSTNode<T>* p = root, *q = root;
while (p != 0) {
for ( ; p->left != 0; p = p->left)
travStack.push(p);
while (p->right == 0 || p->right == q) {//这里的q是为了标记已经识别过的右子节点,出栈的时候可以直接跳过。
visit(p);
q = p;
if (travStack.empty())
return;
p = travStack.pop();
}
travStack.push(p);
p = p->right;
}
}
中序的实现也很复杂,以下只给出一种可能。如果可能,请使用递归版本,逻辑清晰易于理解。
中序非递归实现:
template<class T>
void BST<T>::iterativeInorder() {
Stack<BSTNode<T>*> travStack;
BSTNode<T> *p = root;
while (p != 0) {
while (p != 0) { // stack the right child (if any)
if (p->right) // and the node itself when going
travStack.push(p->right); // to the left;
travStack.push(p);
p = p->left;
}
p = travStack.pop(); // pop a node with no left child
while (!travStack.empty() && p->right == 0) { // visit it and all nodes
visit(p); // with no right child;
p = travStack.pop();
}
visit(p); // visit also the first node with
if (!travStack.empty()) // a right child (if any);
p = travStack.pop();
else p = 0;
}
}
关于二叉树的非递归算法,这篇文章有较为详细的介绍,本篇限于篇幅引用例程,原理表过不提,也可以根据代码推导出思路。
不使用栈的深度优先遍历
线索树:
前面的内容介绍了递归与非递归实现的遍历函数,它们都使用了栈存储节点信息。极端情况下,栈将会占用极大的内存,因此可以在给定的节点中加入线索(thread) 来指示该节点的前驱和后继节点指针。使用这样节点的树称为线索树(thread tree) 。
完整的树,单个节点除了维护左右子节点指针外,还需要维护前驱节点和后继节点两个指针。这样一共4个指针会造成极大浪费。简单考虑,我们采用一个标志来重载。
template<class T>
class ThreadedNode {
public:
ThreadedNode() {
left = right = 0;
}
ThreadedNode(const T& el, ThreadedNode *l = 0, ThreadedNode *r = 0) {
key = el; left = l; right = r; successor = 0;
}
T key; //节点信息。可以是字符,数字等。
ThreadedNode *left, *right; //左右子节点指针。
unsigned int successor : 1; //标志位。
};
successor | * right |
---|---|
0 | * rightchild |
1 | * nextnode |
注:successor为1时,右指针重载为指向下一个要遍历的节点的指针。
首先扫描左子节点的终点,然后依次遍历回到上一个有右节点的位置,并相应设置好前驱节点(prev)指针。如果该节点的前驱节点successor为0,说明当前节点是一个右子节点。若successor为1,则* right指针指向后继节点。
遍历过程代码如下:
template<class T>
void ThreadedTree<T>::inorder() {
ThreadedNode<T> *prev, *p = root;
if (p != 0) { // process only non-empty trees;
while (p->left != 0) // go to the leftmost node;
p = p->left;
while (p != 0) {
visit(p);
prev = p;
p = p->right; // go to the right node and only
if (p != 0 && prev->successor == 0) // if it is a descendant
while (p->left != 0) // go to the leftmost node,
p = p->left; // otherwise visit the successor;
}
}
}
对于先序遍历,可以调整遍历和访问子节点的顺序。对于后序遍历会复杂一些。可以自行搜索相关资料。
通过树的转换进行遍历
线索树将栈转化为树的一部分,然而仍有一些算法不需要栈或者线索也可以遍历树,通常这些算法会暂时地修改树使之不具有树结构,在遍历结束前需要恢复。这里介绍一个中序遍历 – Morris算法。
该算法临时性地取消了左子树,从而在访问该节点后可以直接处理右子树,算法思路如下:
MorrisInorder()
{
while没有结束
if没有左子节点
访问该节点;
转向右子树;
else让该节点“连带之后的节点”成为其左子树节点中最右侧节点的右子节点;
转向该左子树;
}
我们必须保留某些信息以将树恢复到原有形式,可以保留被迁移的节点的左指针来完成。
template<class T>
void BST<T>::MorrisInorder() {
BSTNode<T> *p = root, *tmp;
while (p != 0)
if (p->left == 0) {
visit(p);
p = p->right;
}
else {
tmp = p->left;
while (tmp->right != 0 &&// go to the rightmost node of
tmp->right != p) // the left subtree or
tmp = tmp->right; // to the temporary parent of p;
if (tmp->right == 0) { // if 'true' rightmost node was
tmp->right = p; // reached, make it a temporary
p = p->left; // parent of the current root,
}
else { // else a temporary parent has been
visit(p); // found; visit node p and then cut
tmp->right = 0; // the right pointer of the current
p = p->right; // parent, whereby it ceases to be
} // a parent;
}
}
插入
查找操作不会修改树。它通过扫描访问树的某些或者所有键值,但不会发生变化。而某些操作,增删改,合并树,以及平衡树结构并降低其高度则不然。本节讨论插入。
插入可以先用查找的策略,然后比较新节点键值和当前检查点的大小,大则寻找右子节点,小则寻找左节点。如果当前节点的相应子节点为空,停止扫描,让新节点称为当前节点的对应子节点。
总结一下之前讨论过的遍历查找方法:
- 广度优先遍历(队列)
- 深度优先遍历(递归,栈,线索树,树转换)
首先是最简单的插入算法,它不需要遍历,而是每次选择一个单子树往下,发现空节点就插入。
普通的插入算法如下:
template<typename T>
void BST<T>::insert(const T& el)
{
BSTNode<T> *p = root, *prev = 0;
while(p!=0)
{
prev = p;
if(el < p->el) //已经进行过重载
p = p->left;
else
p = p->right;
}
if(root == 0)
root = el;
else if(el < prev->el)
prev->left = newBSTNode<T>(el);
else prev->right = newBSTNode<T>(el);
}
前面我们介绍过线索树,那么这里我们将之前的内容串联起来。因为线索树中有一个successor标志位。我们可以在创建(一个个节点插入)的过程中将successor标志位利用起来,代码如下:
template<class T>
void ThreadedTree<T>::insert(const T& el) {
ThreadedNode<T> *p, *prev = 0, *newNode;
newNode = new ThreadedNode<T>(el);
if (root == 0) { // tree is empty;
root = newNode;
return;
}
p = root; // find a place to insert newNode;
while (p != 0) {
prev = p;
if (p->key > el)
p = p->left;
else if (p->successor == 0) // go to the right node only if it
p = p->right; // is a descendant, not a successor;
else break; // don't follow successor link;
}
if (prev->key > el) { // if newNode is left child of
prev->left = newNode; // its parent, the parent becomes
newNode->successor = 1; // also its successor;
newNode->right = prev;
}
else if (prev->successor == 1) {// if the parent of newNode
newNode->successor = 1; // is not the right-most node,
prev->successor = 0; // make parent's successor
newNode->right = prev->right; // newNode's successor,
prev->right = newNode;
}
else prev->right = newNode; // otherwise it has no successor;
}
这段代码的核心思维是:从左节点开始,如果左子节点为空,那么将新节点插入,并将后继节点指向母节点。如果需要放到右节点,那么除了将新节点插入以外,将母节点的后继节点作为新节点的后继节点。
删除
删除操作的复杂度取决于要删除的节点在树中的位置。删除中间节点比删除叶子节点困难很多。删除共分为三种情况:
- 要删除的节点是一个叶子节点,这种情况最为简单,设置空指针,并回收空间即可。
- 要删除的节点有一个子节点,操作父节点指针指向新的子节点,并回收空间。
- 要删除的节点有两个子节点,本节主要讨论这种比较复杂的情形:这里讲两种不同的方法。
合并删除
该解决方案将被删除节点的两个子树整合为一个子树。
由于在该节点中左子树最大的节点仍然小于该节点的右子节点,所以可以将整个右子树转移到左子树中最大节点的右子节点后。
template<class T>
void BST<T>::findAndDeleteByMerging(const T& el) {
BSTNode<T> *node = root, *prev = 0;
while (node != 0)
{
if (node->el == el)
break;
prev = node;
if (el < node->el)
node = node->left;
else node = node->right;
} //这个循环是为了找到node节点,即要删除的节点。
if (node != 0 && node->el == el) //node不为空且找到了。
if (node == root)
deleteByMerging(root);
else if (prev->left == node)
deleteByMerging(prev->left);
else deleteByMerging(prev->right);
else if (root != 0) //该树不为空且,node为空或者node不等于要找的节点。
cout << "el " << el << " is not in the tree\n";
else cout << "the tree is empty\n"; //该树为空。
}
template<class T>
void BST<T>::deleteByMerging(BSTNode<T>*& node) //注意这个引用符号!!!
{
BSTNode<T> *tmp = node;
if (node != 0)
{
if (!node->right) //如果该节点没有右子树,就用左子节点替换(左子节点为空也可)
node = node->left;
else if (node->left == 0) //如果有右子节点且无左子节点,可以用右子节点代替。
node = node->right;
else //如果左右子节点都有,那么就寻找左子树中最右的节点,并将该节点的右子节点指针指向原节点的右子节点。最后用原节点的左子节点替换原节点。
{
tmp = node->left;
while (tmp->right != 0)
tmp = tmp->right;
tmp->right = node->right;
tmp = node;
node = node->left;
}
delete tmp;
}
}
合并删除可能会导致树的高度增加,也可能导致树的高度下降,且新树有可能非常不平衡。因此这种算法效率不一定低,但并不完美。
复制删除
如果该节点有两个子节点,可以效仿之前的做法,找到左子树中的最右侧节点,然后转化为两个更简单的问题之一:
- 要删除的节点是叶子节点。
- 要删除的节点只有一个子节点。
前面介绍了合并删除,是维持左子树不动的情况下将右子树嫁接到左子树最右侧节点之下。而这里介绍的复制删除则是将左子树下的最右侧节点替代原节点。这种方法降低了左子树的高度,右子树不受影响,但有一个缺陷:经历多次插入操作后,右子树比左子树繁茂,整个树将不再平衡。
针对上述问题,可以对算法进行改进——交替进行左右子树的复制删除操作。关于其复杂度的计算过于复杂(J.Culberson证明),这里直接给出结论:
复制删除的形式 | IPL(内部路径长度) | 平均查找时间(平均路径长度) |
---|---|---|
插入及非对称删除 | θ ( n n ) θ(n\sqrt{\smash{n}}) θ(nn) | θ ( n ) θ(\sqrt{\smash{n}}) θ(n) |
插入及对称删除 | θ ( n l g n ) θ(nlgn) θ(nlgn) | θ ( l g n ) θ(lgn) θ(lgn) |
树的平衡
之前强调过,通过树查找比通过链表查找快得多,但这并不总是成立。我们知道,树是可以退化为链表的,这样不仅浪费了额外的内存,还让查找过程变得繁琐。
高度平衡(height-balanced):如果任意节点两个子树的高度相差不超过1,或者简称为平衡的。并且一个典型的特征是,所有的叶子节点都处在一个或两个层次上,该树就是完全平衡的(perfectly balanced)。
关于树记住一个关系:
设树的高度为h,则一个完全二叉树(也是平衡的)有如下表示:
- 一层中的最大节点数: 2 h − 1 2^{h-1} 2h−1
- 该树包含的最大节点数: 2 h − 1 2^h-1 2h−1
很容易得出,在一个完全平衡树中如果节点数为n,那么树的高度就是
⌈
l
g
(
n
)
⌉
\lceil lg(n) \rceil
⌈lg(n)⌉,这个符号表示向上取整。
有很多方法可以对二叉树进行平衡,这里介绍重新创建和重新排序两类技术形成的树平衡算法。
当数据流到来时(这里以一个数组为例),指定中间元素为根。
这里用排序算法对数组进行排序之后,每次取中间的作为当前层数的节点,然后再以两边的中心元素为下一层节点…一直到无法再细分。这样的结果可以保证二叉树的完全平衡。
采用递归方式实现代码如下:
template<class T>
void BST<T>::balance (T data[], int first, int last)
{
if (first <= last)
{
int middle = (first + last)/2; //取中间元素
insert(data[middle]); //二叉树的插入操作
balance(data,first,middle-1); //左侧选取中间节点继续平衡
balance(data,middle+1,last); //右侧选取中间节点继续平衡
}
}
DSW算法
前边讨论的创建平衡树的算法效率比较低,因为需要使用额外的有序数组。这个算法在重建的时候需要通过中序遍历将元素取出,然后重建该树。下面介绍一种几乎不需要存储中间变量,也不需要排序的DSW算法。
旋转是该算法中非常重要的一部分:
伪代码如下:
rotateRight(GR, PAR, CH)
{
if PAR不是根节点 //GR非空
CH成为GR的子节点;
CH的右子树成为PAR的左子树;
PAR成为CH的右节点;
} //右旋转
旋转有左旋转和右旋转两种形式。这种操作不会影响树的主要性质,该树依然是查找树。一般来说,DSW算法将任意二叉树转化了链表树,成为主干或主链,然后围绕主链中第二个节点的父节点反复旋转,逐渐转化为完全平衡树。
创建主链的过程如下:
creatBackbone(root)
{
tmp = root;
while (tmp != 0)
if tmp有左子节点;
围绕tmp旋转该子节点; //即左子节点成为当前节点的父节点。
tmp设置为刚成为父节点的子节点;
else 将tmp设置为它的右子节点;
}
容易得到,此过程最好,最坏情况的时间复杂度都是O(N)。
通过此过程可以一步一步将作为左子树的节点整合进主链,形成一个只有右子树的查找树结构(单链)。此时进入第二个过程,将链转化为完全平衡树。
接下来需要对主链节点进行左旋转,仍然可以参考右旋转的图,只不过左旋转可以视作右旋转的逆向:
rotateLeft(GR, PAR, CH)
{
if PAR不是根节点 //GR非空
PAR成为CH的子节点;
CH的左子树成为PAR的右子树;
PAR成为CH的左节点;
} //左旋转
首先顺着主链向下操作,计算当前的节点数n与最接近的完全二叉树中的节点数
2
⌊
l
g
(
n
+
1
)
⌋
−
1
2^{\lfloor lg(n+1) \rfloor}-1
2⌊lg(n+1)⌋−1的差,这是为了计算最后一层多出来的节点。并单独进行处理。计算出节点数及其差值后,首先对主链进行预处理操作,之后再对预处理完成后的主链进行操作。
这里的操作是指:从主链的第二个节点开始,每隔一个节点进行一次旋转。操作的次数由伪代码中罗列的条件给出:
creatPerfectTree()
{
n = 节点数;
m = 最接近的完整二叉树的节点数;
从主链顶端进行n-m次旋转;//预处理。
while(m>1)
m = m/2;
从主链开始做m次旋转;//每间隔一个节点做一次。
}
对于第二阶段,可以通过追踪while中执行旋转的迭代次数进而求出旋转次数表示的复杂度:
( 2 l g ( m + 1 ) − 1 − 1 ) + . . . + 15 + 7 + 3 + 1 = ∑ i = 1 l g ( m + 1 ) − 1 ( 2 i − 1 ) = m − l g ( m + 1 ) (2^{lg(m+1)-1}-1)+...+15+7+3+1=\displaystyle\sum_{i=1}^{lg(m+1)-1}(2^i-1)=m-lg(m+1) (2lg(m+1)−1−1)+...+15+7+3+1=i=1∑lg(m+1)−1(2i−1)=m−lg(m+1)
所以总旋转次数如下:
n − m + ( m − l g ( m + 1 ) = n − l g ( m − 1 ) = n − ⌊ l g ( n + 1 ) ⌋ n-m+(m-lg(m+1)=n-lg(m-1)=n-\lfloor lg(n+1) \rfloor n−m+(m−lg(m+1)=n−lg(m−1)=n−⌊lg(n+1)⌋
所以旋转的次数是O(n)。因此通过DSW算法进行全局平衡所需的时间随n线性增长,并且不需要额外的存储空间(相对于栈,更加节省空间)。
AVL树
前面讨论了全局建立平衡树或者重建平衡树的办法。此处引进AVL树(也叫可容许树)的概念。它要求每个节点的左右子树高度差最大为1。
注意:AVL是高度平衡树,而完全平衡树还要求所有叶子节点必须处在一个或两个高度上。
设
A
V
L
h
AVL_h
AVLh是高度为h的AVL树的最小节点数,根据AVL树的定义,有:
A
V
L
h
=
A
V
L
h
−
1
+
A
V
L
h
−
2
+
1
AVL_h=AVL_{h-1}+AVL_{h-2}+1
AVLh=AVLh−1+AVLh−2+1
根据该公式可以推导出具有n个节点的AVL树高度h的上下限取决于节点数n(证明太难这里略过):
l g ( n + 1 ) ≤ h < 1.44 l g ( n + 2 ) − 0.328 lg(n+1)≤h<1.44lg(n+2)-0.328 lg(n+1)≤h<1.44lg(n+2)−0.328
注意:AVL树高度受限于O(lgn)最坏情况下的查找次数为O(lgn)次。而对于高度相同的完全平衡二叉树,其h= O ( ⌈ l g ( n + 1 ) ⌉ ) O(\lceil lg(n+1) \rceil ) O(⌈lg(n+1)⌉)所以经过实践,AVL在多数情况下比完全平衡二叉树效率更高。
插入操作:
如果出现节点左右子树差值大于1时,树就需要平衡。下面讨论2种常见的情况(关于左右子树对称的情形考虑在内,一共就有4种情况这里不再赘述)。
情况1:在右子节点的右子树插入一个节点,如果导致树失衡,可以通过一下操作进行平衡。
另一种情况是在右节点的左子树中插入节点。这样的处理过程略微麻烦一些。为了树再度平衡需要进行双重旋转。
那么,上面解决了树的平衡问题,都是找到一个平衡因子不符合要求的节点,并将它及以下的节点视为独立子树。并且,不需要对该节点的前驱进行额外操作,因为该子树的总高度没有发生改变,对于这个节点的父节点,一直到根节点都不会有任何影响。
下面讨论如何找到那个关键的蓝色节点:
可以从插入的节点往根节点的方向(向上)更新节点的平衡因子,如果之前平衡因子是±1,那么修改为±2。这样这个平衡因子为2的点就是关键的蓝色节点,所有的操作都在这个节点的子树上进行。这个节点以上的任何节点都不用操作。
更新平衡因子的伪代码如下:
updateBalanceFactors()
{
Q = 刚插入的节点;
P = Q的父节点;
if Q是P的左子节点
P->balanceFactor--;
else
P->balanceFactor++;
while(P非根节点且P的平衡因子不等于±2)
{
Q = P;
P = P的父节点;
if Q->(balanceFactor == 0)
return; //这里一定是从1变为0,也就是说该节点往上的节点都没有受到影响。
if Q是P的左子节点
P->balanceFactor--;
else p->balanceFactor++;
}
if (P->balanceFactor == ±2)
以P为根节点进行旋转平衡;//这里可以分别对应4种情况进行旋转操作
}
根据以上算法,如果往上一直到根节点,都没有出现平衡因子为正负2的情况,那么将这条路径上的平衡因子更新即可,不需要做旋转操作。
删除操作:
现在我们来讨论删除的情况,这个比插入操作略微复杂。之前介绍过两种删除操作,我们选用对维持树平衡更有利的拷贝删除 deleteByCopying()。从树中删除节点后,路径上的节点平衡因子都需要更新。对于±2的节点来说,必须执行旋转操作来恢复平衡。注意:插入操作遇到±2的节点就可以停止往上搜索了,但删除操作不行,在最坏情况下,该路径上的每一个节点都需要旋转,设该树有n个节点,则有可能最多旋转
O
(
l
g
n
)
O(lgn)
O(lgn)次才能重新实现树的平衡。
删除节点之后需要旋转的情况一共8种,考虑对称性我们考虑4种。
细节不表,我们直接上图:
前面提到过,进行插入和删除操作最多需要1.44lg(n+2)次查找。另外插入操作只需要一次单旋转或双重旋转即可,删除操作在最坏情况下需要1.44lg(n+2)次旋转。前面也提到,平均情况下需要lg(n)+0.25次查找,删除操作的旋转次数减小为这个数,插入操作只需要一次操作。室验还表明78%的情况下删除操作不需要重建平衡,而只有53%的插入操作不会使得树失衡。更复杂的删除操作,其对平衡性的影响反而比插入操作更小。
下面介绍一个扩展:允许AVL树的高度差∆>1,则此后最坏情况下树的高度增加为:
h = { 1.81 l g n − 0.71 ∆ = 2 2.15 l g n − 1.13 ∆ = 3 h= \begin{cases} 1.81lgn-0.71 &\text{} ∆=2 \\ 2.15lgn-1.13 &\text{} ∆=3 \end{cases} h={1.81lgn−0.712.15lgn−1.13∆=2∆=3
试验表明,与纯AVL树相比,扩展后的访问节点平均次数增加了一半,但重新构造的次数可以减少1/10。
自适应树
平衡树主要关心使树不要倾向任何一边,如果新节点威胁到树的平衡,之前介绍的AVL方法(局部重构),DSW(全局重构)纠正这一问题。平衡树可以提高效率,但并不是唯一可用的方法。
我们提出另一个办法,该方案取决于某个元素的常用程度(使用频率)。设计一种“优先树”,每个元素设置一个字段记录使用次数,扫描树之后将最长用的节点向根节点移动。长此以往,长期使用的元素将占据根节点下的前几层。
自重新构造树(self-restructuring tree)
介绍一种策略:
- 单一旋转:也就是前边提到的单次旋转的一种,将子节点围绕父节点旋转,并将父节点转变为子节点的子节点,子节点取代父节点。
- 移动到根部:重复单一旋转直到被访问的元素位于根部为止。
记住一个结论:已经确定将节点移动到根部的效率是在优化树中访问节点的2ln2倍,即复杂度为(2ln2)lgn。这一结果在任何概率分布下都成立。平均来说,单一旋转技术的平均查找时间为 Π ∗ n \sqrt{\varPi*n} Π∗n
张开策略(splaying)
之前“移动到根部”的一个改良版本为“张开策略”,该策略根据子节点、父节点和祖父节点之间的链接关系的顺序,成对地使用单一旋转。这里分为三种情况:
假设:被访问节点为R,其父节点为Q,祖父节点为P(如果有的话),
- 节点R的父节点Q是根节点。
- 同构配置(Homogeneous configuration),节点R,Q同时为其父节点的左子或右子节点。
- 异构配置(Heterogeneous configuration),节点R,Q分别为其父节点的左子和右子节点。
该算法的伪代码如下:
splaying()
{
while(R不是根节点)
{
if R的父节点是根节点;
进行单一张开操作,使R围绕其父节点进行旋转(下图a);
else if R与其前驱同构;
进行一次同构张开操作,先围绕P旋转Q,后围绕Q旋转R(下图b);
else //R与其前驱同构
进行一次异构张开操作,先围绕Q旋转R,后围绕P旋转R(下图c);
}
}
下面图解,对称情况自行考虑:
“张开策略”是两次旋转的结合(被访问节点是根节点的情况除外),但和自适应树不同的是,如果同构,“张开”策略会先旋转父节点与祖父节点,这样有利于维持树的平衡。
对“张开”技术访问节点的效率进行摊销分析:
假设有一个二叉查找树t,令根为x的子树中节点数目为
n
o
d
e
(
x
)
node(x)
node(x),
r
a
n
k
(
x
)
=
l
g
(
n
o
d
e
(
x
)
)
rank(x)=lg(node(x))
rank(x)=lg(node(x)),因此
r
a
n
k
(
r
o
o
t
(
t
)
)
=
l
g
(
n
)
rank(root(t))=lg(n)
rank(root(t))=lg(n),并且:
p o t e n t i a l ( t ) = ∑ x 是 t 的 一 个 节 点 r a n k ( x ) potential(t)=\sum_{x是t的一个节点}rank(x) potential(t)=∑x是t的一个节点rank(x)
又因为x为t的子树,所以很明显:
n o d e ( x ) + 1 ≤ n o d e s ( p a r e n t ( x ) ) node(x)+1≤nodes(parent(x)) node(x)+1≤nodes(parent(x)),因此, r a n k ( x ) < n o d e s ( p a r e n t ( x ) ) rank(x)<nodes(parent(x)) rank(x)<nodes(parent(x))
我们对于访问x节点的摊销成本定义为函数:
a m C o s t ( x ) = c o s t ( x ) + p o t e n t i a l s ( t ) + p o t e n t i a l o ( t ) amCost(x)=cost(x)+potential_s(t)+potential_o(t) amCost(x)=cost(x)+potentials(t)+potentialo(t)
该公式基于访问前后树的潜在成本的变化定义对x节点进行“张开”操作的摊销成本。因为每次访问,只会改变祖孙三个节点的rank,其他节点的复杂度不会改变。
访问引理(Sleator和Tarjan, 1985):对于在x处“张开”的摊销成本:
a m C o s t ( x ) < 3 ( l g ( n ) − r a n k ( x ) ) + 1 amCost(x)<3(lg(n)-rank(x))+1 amCost(x)<3(lg(n)−rank(x))+1
使用“张开”技术重新构造的树中访问节点的摊销成本等于O(lg(n)),与平衡树中最坏的情况相同。但是,单一访问,它的最坏情况仍可能是O(n),只有在足够多的次数m下,复杂度才可以视为O(mlg(n))与平衡树的效率相当。
“张开”关注元素本身胜过树的形状,在根部与底部频率相差不大的情况下“张开”可能不是最好的选择,我们可以改良“张开方法”。
“半张开(semisplaying)”是一个改进版,对于同构的“张开”,该策略单步只需要一次旋转,然后继续张开被访问节点的父节点,不张开节点本身。它并不要求将节点强行送上根部,仅仅向靠近根部的方向靠拢。【参见图b】