数据结构与算法分析笔记(二):树
文章为《数据结构与算法分析:C语言描述》的学习笔记,手边有这本书籍看起来会比较轻松,因为在书中的一些图片示例并没有摘录。需要参照书籍的地方,笔记里均有标出,因此没有书籍也可以将此笔记作为学习参考。
4.1 预备知识
树可以由多种方法定义,其中一种是递归的方法:
一棵树是一些节点的集合。集合可以是空集;若集合非空,则一棵树由称作根(root)的节点r以及0个或多个非空的子树T1, T2, …, Tk组成,这些子树中每一棵的根,都被来自根r的一条有向边连接。
每一棵子树的根叫做根r的儿子(child),而r是每一棵子树的根的父亲(parent)。
没有儿子的节点称为树叶(leaf);具有相同父亲的节点为兄弟(sibling)。用类似的方法可以定义grandparent和grandchildren。
从节点n1到nk的路径(path)定义为节点n1,…,nk的一个序列。在一棵树中,从根到每个节点恰好存在一条路径。路径的长(length)为这条路径上的边(edge)的条数,每一个节点到自己有一条长为0的路径。
深度(depth)指的是根到节点的长度;高度(height)指的是根到树叶的最长长度。
4.1.1 树的实现
以下是一个典型的树节点声明,一个节点存放一个数据,并且指向自己的第一儿子与下一兄弟。
typedef struct TreeNode *PtrToNode;
struct TreeNode
{
ElementType Element;
PtrToNode FirstChild;
PtrToNode NextSibling;
}
4.1.2 树的遍历及应用
类UNIX系统的目录和树非常类似。此处建议看一下书上的图。
以下是遍历树的伪代码:
static void ListDir(DirectoryOrFile D, int Depth)
{
PrintName(D, Depth);
if(D is a directory)
for each child, C, of D
ListDir(C, Depth + 1);
}
void ListDirectory(DirectoryOrFile D)
{
ListDir(D, 0);
}
这属于前序遍历。
4.2 二叉树
二叉树(binary tree)是一棵树,其中每个节点的儿子都不能多于两个。
4.2.1 实现
树节点的声明有些类似于双链表,我们可以直接用指针指向某个节点的两个儿子。
增加和删除节点同样需要使用malloc和free,以下是二叉树的节点声明。
typedef struct TreeNode *PtrToNode;
typedef struct PtrToNode Tree;
struct TreeNode
{
ElementType Element;
Tree Left;
Tree Right;
};
二叉树有许多与搜索无关的重要应用,其主要用处之一是在编译器的设计领域。
4.2.2 表达式树
表达式树的树叶是操作数(operand),其他的节点是操作符(operator)。
通过递归的方法,打印出左表达式,再打印根部的运算符,最后打印出右侧表达式的方法称为中序遍历(inorder traversal)。很容易看出来,打印的结果是中缀表达式(fix expression)。
通过递归的方法,递归打印出左子树、右子树,然后打印运算符的方法称为后序遍历(postorder traversal)。打印的结果是后缀表达式,在学习栈的时候,我们遇到过这一类表达式。
还有一种遍历策略,先打印出运算符,然后递归地打印出右子树和左子树,为先序遍历(preorder traversal)。打印结果是不常见的前缀计法。
构造表达式树,需要使用到栈,书中阐释非常详细易懂,此处不再赘述。
查找树ADT——二叉查找树
使二叉树成为二叉查找数的性质是,对于树中每个节点X,它的左子树中所有节点的元素均小于X的元素,它的右子树中所有节点的元素均大于X的元素。
现在给出简要描述。
#ifndef _Tree_H
struct TreeNode;
typedef struct TreeNode* Position;
typedef struct TreeNode* SearchTree;
typedef int ElementType;
SearchTree MakeEmpty(SearchTree T);
Position Find(ElementType X, SearchTree T);
Position FindMin(SearchTree T);
Position FindMax(SearchTree T);
SearchTree Insert(ElementType X, SearchTree T);
SearchTree Delete(ElementType X, SearchTree T);
ElementType Retrieve(Position P);
#endif
struct TreeNode
{
ElementType Element;
SearchTree Left;
SearchTree Right;
};
以上是二叉查找树的声明。
4.3.1 MakeEmpty
这个操作主要用于初始化。我们的实现方法更紧密地遵循树的递归定义。
SearchTree MakeEmpty(SearchTree T)
{
if(T != NULL)
{
MakeEmpty(T->Left);
MakeEmpty(T->Right);
free(T);
}
return NULL;
}
4.3.2 Find
一般需要返回指向树T中具有关键字X的节点的指针,如果节点不存在则返回NULL。
关键的问题在于首先要对是否为空树进行测试;其次,尾递归在这里的运用是合理的,我们使用的栈空间只不过是O(logN)。
Position Find(ElementType X, SearchTree T)
{
if(T == NULL)
return NULL;
if(X < T->Element)
return Find(X, T->Left);
else
if(X > T->Element)
return Find(X, T->Right);
else
return T;
}
4.3.3 FindMin和FindMax
分别返回树中最小元和最大元的位置,这两个函数的操作是极其相似的,因此,此处使用递归编写FindMin,使用非递归编写FindMax。
Position FindMin(SearchTree T)
{
if(T == NULL)
return NULL;
else
if(T->Left == NULL)
return T;
else
return FindMin(T->Left);
}
Position FindMax(SearchTree T)
{
if(T != NULL)
while (T->Right != NULL)
T = T->Right;
return T;
}
4.3.4 Insert
插入例程需要符合二叉查找树的定义,其设计思想有些类似于Find。注意代码里面递归的运用,一层层深入,再一层层返回。
SearchTree Insert(ElementType X, SearchTree T)
{
if(T == NULL)
{
T = malloc( sizeof( struct TreeNode ) );
if(T == NULL)
{
printf("Out of Space!!!!");
exit;
}
else
{
T->Element = X;
T->Left = NULL;
T->Right = NULL;
}
}
else
if (X < T->Element)
T->Left = Insert(X, T->Left);
else
if (X > T->Element)
T->Right = Insert(X, T->Right);
return T;
}
4.3.5 Delete
接下来是最困难的操作,一旦发现需要删除的节点,我们就需要考虑几种可能的情况。
如果节点是一片树叶,那么可以立即删除。如果节点有一个儿子,则该节点可以在其父节点调整指针,绕过该节点,然后删除。
更复杂的情况是处理具有两个儿子的节点。一般的删除策略是,用需要删除的节点的右子树中的最小的节点,代替该节点的数据。接下来,我们只需要删除那个右子树中最小的节点即可,不需要删掉有两个儿子的节点。
SearchTree Delete(ElementType X, SearchTree T)
{
Position TmpCell;
if(T == NULL)
{
printf("ELement Not Found");
exit;
}
else if(X < T->Element)
T->Left = Delete(X, T->Left);
else if(X > T->Element)
T->Right = Delete(X, T->Right);
//至此已找到节点,这之后就开始删除(两个情况,即节点有 2个孩子 和 0或1个孩子 )
else if(T->Left && T->Right)
{
TmpCell = FindMin(T->Right);
T->Element = TmpCell->Element;
T->Right = Delete(T->Element, T->Right);
}
else
{
TmpCell = T;
if(T->Left == NULL)
T = T->Right;
else if(T->Right == NULL)
T = T->Left;
free(TmpCell);
}
return T;
}
删除的次数不多时,常采用懒惰删除(lazy deletion):当一个元素被删除时,它仍然留在树中,只是做了个被删除的记号。
4.3.6 平均情形分析
除了MakeEmpty函数,我们期望之前提到的所有操作都花费O(logN)的时间。我们又易知,那些操作是O(d)的,d是节点深度。在本节要证明,如果所有的树出现的机会均等,则所有节点的平均深度为O(logN)。
一棵树所有节点深度的和称为 内部路径长(internal path length) 。我们找的是它的平均。
令 D(N) 是具有 N 个节点的某棵树 T 的内部路径长,D(1) = 0。一棵N节点数是由一棵 i 节点左子树和一棵( N - i - 1)个节点的右子树以及深度为0的根节点组成。但是在原树中,所有这些节点都要加深一度。同样的结论对于右子树也是成立的,因此我们得到递归关系。
D
(
N
)
=
D
(
i
)
+
D
(
N
−
i
−
1
)
+
N
−
1
D(N) = D(i)+D(N-i-1)+N-1
D(N)=D(i)+D(N−i−1)+N−1
设所有子树的大小都等可能的出现,这对于二叉查找树是成立的,但对于二叉树则不成立。易得:
D
(
N
)
=
2
N
[
∑
j
=
0
n
−
1
D
(
j
)
]
+
N
−
1
D(N)= \dfrac{2}{N} [\sum^{n-1}_{j=0}D(j)]+N-1
D(N)=N2[j=0∑n−1D(j)]+N−1
事实上,明确“平均”意味着声明一般是极其困难的,可能需要一些假设。没有经过删除操作的所有查找二叉树是等可能出现的,如果经过了删除,就不一定了。
如果向一棵树输入预先排序的数据,那么一连串insert操作将花费二次时间,用链表实现insert的代价也十分巨大,因为这时候树根本不会存在左儿子。其中一种解决办法是有一个称为平衡(balance)的附加的结构条件:任何节点的深度均不能过深。
下面介绍一种平衡查找树:AVL(Adelson-Velskii and Landis)树。
4.4 AVL树
我们希望AVL树是带有平衡条件的二叉查找树,要保证树的深度是O(logN)。
因此我们给出如下条件:一棵AVL树是其每个节点的左子树和右子树的高度最多差1的二叉查找树。空树的高度定义为-1。
此处建议去看一下AVL树大致的图例。
由此总结得到,一棵AVL树的高度最多为:
1.44
l
o
g
(
N
+
2
)
−
1.328
1.44\,log(N+2)-1.328
1.44log(N+2)−1.328
在高度为h的AVL树中,最少节点数S(h)满足的关系式为:
S
(
h
)
=
S
(
h
−
1
)
+
S
(
h
−
2
)
+
1
S(h) = S(h-1)+S(h-2)+1
S(h)=S(h−1)+S(h−2)+1
h = 0, S(h) = 1; h = 1, S(h) = 2
可知最少节点数与斐波那契数列密切相关,由这个递推式可以推出上面提到的AVL的高度的界。
当进行插入操作时,插入的节点可能会破坏AVL树的特性,如果发生这种情况,我们需要把性质恢复才认为这一步插入完成。事实上这总可以通过对树进行简单的修正来完成,我们称其为旋转(rotation)。
我们将在最深的节点(第一个被破坏平衡的节点)去重新平衡这棵树。
我们的不平衡会有四种情况:
- 对节点左儿子的左子树进行一次插入
- 对节点左儿子的右子树进行一次插入
- 对节点右儿子的左子树进行一次插入
- 对节点右儿子的右子树进行一次插入
根据情形不同,采用的旋转方式也不同。
4.4.1 单旋转
强烈建议参照图片或动图理解。此处不进行笔记记录。
4.4.2 双旋转
强烈建议参照图片或动图理解。此处不进行笔记记录。
以下是代码实现:
#include <stdio.h>
#include <stdlib.h>
#ifndef _AVLTREE_H
struct AvlNode;
typedef struct AvlNode* Position;
typedef struct AvlNode* AvlTree;
typedef double ElementType;
AvlTree MakeEmpty(AvlTree T);
Position Find(ElementType X, AvlTree T);
Position FindMin(AvlTree T);
Position FindMax(AvlTree T);
AvlTree Insert(ElementType X, AvlTree T);
AvlTree Delete(ElementType X, AvlTree T);
ElementType Retrieve(Position P);
#endif
struct AvlNode
{
ElementType Element;
AvlTree Left;
AvlTree Right;
int Height;
};
static int Height(Position P)
{
if(P == NULL)
return -1;
else
return P->Height;
}
int Max(int a, int b)
{
return a > b ? a : b;
}
static Position SingleRotateWithLeft(Position K2)
{
//该函数只有当K2有左孩子的时候才能用
//对K2和它的左孩子进行一次旋转
//更新树的高度,然后返回一个新的根
Position K1;
K1 = K2->Left;
K2->Left = K1->Right;
K1->Right = K2;
K2->Height = Max(Height(K2->Left), Height(K2->Right)) + 1;
K1->Height = Max(Height(K1->Left), Height(K1->Right)) + 1;
return K1;
}
static Position SingleRotateWithRight( Position K2 )
{
Position K1;
K1 = K2->Right;
K2->Right = K1->Left;
K1->Left = K2;
K2->Height = Max(Height(K2->Left), Height(K2->Right)) + 1;
K1->Height = Max(Height(K1->Left), Height(K1->Right)) + 1;
return K1;
}
static Position DoubleRotateWithLeft(Position K3)
{
//先对K1和K2进行一次右旋转
K3->Left = SingleRotateWithRight( K3->Left );
//再对K2和K3进行一次左旋转
return SingleRotateWithLeft( K3 );
}
static Position DoubleRotateWithRight( Position K3 )
{
K3->Right = SingleRotateWithLeft( K3->Right );
return SingleRotateWithRight(K3);
}
AvlTree Insert( ElementType X, AvlTree T )
{
if( T == NULL )
{
//create and return a one-node Tree.
T = malloc( sizeof( struct AvlNode) );
if( T == NULL )
exit;//out of space
else
{
T->Element = X;
T->Height = 0;
T->Left = T->Right = NULL;
}
}
else if( X < T->Element )
{
T->Left = Insert( X, T->Left );
if( Height(T->Left) - Height(T->Right) == 2 )
if( X < T->Left->Element)
T = SingleRotateWithLeft( T );
else
T = DoubleRotateWithLeft( T );
}
else if( X > T->Element )
{
T->Right = Insert( X, T->Right);
if( Height(T->Right) - Height(T->Left) == 2 )
if( X < T->Right->Element )
T = SingleRotateWithRight( T );
else
T = DoubleRotateWithRight( T );
}
//还有一种剩余的情况是X已经在树中了,我们就不做任何处理
T->Height = Max( Height(T->Left), Height(T->Right) ) + 1;
return T;
}
对于AVL树的理解,建议画图和看代码结合,光看文字描述很难有直观形象的感受。
4.5 伸展树
伸展树(splay tree),保证从空树开始任意连续M次对树的操作最多花费O(M log N)时间。
一般来说,当M次操作的序列总的最坏时间是O(M F(N) )时,我们说它摊还(amortized)运行时间为O( F(N) )。
伸展树的基本想法是,当一个节点被访问后,它就要经过一系列AVL树的旋转后放到根上。这一种结构往往有实际的效用,因为在许多应用中,当一个节点被访问时,它就很可能不久之后再次被访问,我们会把访问该节点的时间通过重新构造而变少。
4.5.1 一个简单的想法
此处建议参照书籍图片。
如果我们单纯地对某个在深处的节点不断施加旋转,直到这个节点成为树的根,但是这种操作没有改变访问其它节点的情况,会把其他的某个节点推向我们施加操作的节点原先在的深度。
所以这个想法还不够好。
4.5.2 展开
建议配合图片形象理解。
如果是之字形,使用标准的双旋转;
如果是一字形,使用一字形旋转。
展开操作不仅将访问的节点移动到根处,而且还有把访问路径上的大部分节点的深度大致减少一半的效果。
当访问路径太长而导致超出正常查找时间的时候,旋转操作对于未来的操作有益,但当访问耗时很少的时候,这些旋转是有害的。
我们还可以通过访问要删除的节点实现删除操作,这种操作将节点上推到根处,一旦删除则得到两棵子树TL和TR,找到TL的最大元素,将这个元素旋转到TL的根下,此时TL将有一个没有右儿子的根,将TR作为右儿子而结束删除。
4.6 树的遍历
void PrintTree( SearchTree T )
{
if( T != NULL )
{
PrintTree(T->Left);
printf("%d",T->Element);
PrintTree(T->Left);
}
}
这是中序遍历,首先遍历左子树,之后是当前的节点,最后遍历右子树。
int Height( SearchTree T )
{
if( T == NULL )
return -1;
else
return 1 + Max(Height(T->Left), Height(T->Right));
}
这是后序遍历。计算树的高度。
所有这些例程有一个共有的想法,就是先处理NULL的情况。
4.7 B树
还有一种常用的查找树,它不是二叉树,叫做B树(B-Tree)。
阶为M的B树具有以下结构特性:
- 树的根要么是一片树叶,要么其儿子数在2到M之间。
- 除根以外,所有非树叶节点的儿子数在 M/2(向上取整) 和 M 之间。
- 所有的树叶都在相同深度上。
所有的数据都储存在树叶上,在每一个内部的节点上皆有指向该节点各儿子的指针P1,P2,……,PM和分别代表在子树P2,……,PM中发现的最小关键字k1,k2,…, km-1的值。
对于一般的M阶B树,当插入一个关键字是,唯一的困难在于接收该关键字的节点已经有M个关键字了。插入这个关键字使得该节点具有M+1个关键字,我们可以把它分裂成两个节点,它们分别具有
⌈
(
M
+
1
)
/
2
⌉
\lceil (M+1)/2 \rceil
⌈(M+1)/2⌉个和
⌊
(
M
+
1
)
/
2
⌋
\lfloor (M+1)/2 \rfloor
⌊(M+1)/2⌋个关键字。
又由于多出一个儿子,因此我们必须检查这个节点是否可被父节点接受,如果父节点已经具有M个儿子,那么这个父节点就要被分裂成两个节点。我们重复这个过程直到找到一个具有少于M个儿子的父节点。如果分裂根节点,则需要创建一个新的根,这个根有两个儿子。
B树的深度最多是 ⌈ l o g ⌈ M / 2 ⌉ N ⌉ \lceil log_{\lceil M/2 \rceil}N \rceil ⌈log⌈M/2⌉N⌉
B树实际用于数据库系统,在那里树被储存在物理的磁盘上而不是主存中。