线性数据结构(例如链表)在检索数据项时,最坏情况下可能需要遍历整个链表才能找到检索的数据项或者直接没有找到要检索的数据项,这样检索数据的时间复杂度为O(n),其中n为链表的大小(即链表中数据项的数目)。当需要处理的数据量很大时,这样的时间复杂度是不可接受的。而使用二叉查找树(binary search tree)这种数据结构,在特定的条件限制下可以保证每种操作的时间复杂度保持在O(logN)。为了研究二叉查找树,首先介绍更一般的树的概念和应用。
树的定义
树在计算机科学中有广泛的应用,有多种方式可以定义树,最常用的是递归定义的方式:一棵树是一些节点的集合,这个集合可以是空集;若非空,则一棵树由称作根(root)的节点r以及0个或者多个非空的(子)树T1,T2,T3。。。Tk组成,这些子树中每一棵的根都被来自根root的一条有向边所连接。(树可以看做一个特殊的图。)从定义可以看出,一棵树由N个节点和N-1条边组成,因为除了root节点之外所有的节点都有一条边指向父节点。
相关定义:没有子节点的节点称为叶节点(leaf);有相同父节点的节点称为兄弟节点;从节点n1到nk的路径(path)定义为节点n1,n2,。。。nk的一个序列,使得对于1<=i<k,节点n(i)是n(i+1)的父亲。这个路径的长为该路径上的边的条数,即k-1;对于任意节点ni,ni的深度为从根到ni的唯一路径的长,ni的高是从ni到一片树叶的最长路径的长。一棵树的高等于它的根的高。
树的实现
树的一个实现方式是在树节点中除了保存数据外还要保存所有指向其子节点的指针,但实际上由于树节点的子节点个数不确定,因此要保存的指针个数也是不确定的,因此不能使用这种做法。解决方法是将每个节点的所有儿子都放在树节点的链表中,称为第一儿子/下一兄弟表示法,定义如下:
typedef struct TreeNode * PtrToNode;
struct TreeNode
{
ElementType Element;
PtrToNode FirstChild; //指向第一个儿子
PtrToNode NextSibling;//指向兄弟节点
}
树的遍历与应用
树在各种流行的操作系统的文件系统中得到了广泛的应用,用于组织文件系统的目录结构。文件系统中包括目录和文件,其中目录也是一种文件,包括了其子目录与子文件信息。下面介绍两种文件系统中常见的操作的实现方式,分别是打印目录中所有文件的名字以及计算每个目录(文件)占用的磁盘区块的数目。
打印目录中的所有文件名,同时需要根据文件的深度打印相应数目的缩进。例程如下:
static void ListDir(DirectoryOrFile D,int Depth)
{
if(D is a legitimate entry)
{
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);
}
算法的核心是递归过程ListDir,其中depth是实现缩进显示的内部簿记变量,不是外部主调例程需要知道的参数,因此使用驱动例程ListDirectory将递归例程ListDir与外部主调例程连接起来。这里使用遍历策略是先序遍历,在先序遍历中,对节点的处理是在它的诸儿子被处理之前进行的。
文件系统中另一种常见的操作是计算文件(目录)的大小,目录本身也有大小,因此最直观的思路是将目录本身的大小加上目录中各个子目录(文件)的大小,这里采用后序遍历的算法来实现,在后序遍历中,在一个节点处的工作实在它的诸子节点被计算后进行的,代码如下:
static void SizeDirectory(DirectoryOrFile D)
{
int totalSize=0;
if(D is a legitimate entry)
{
totalSize=FileSize(D);
for each child c of D
totalSize+=SizeDirectory(c);
);
return totalSize;
}
在树的各种操作的实现方式中,利用递归的思想是非常常见的。树本身就可以利用递归方式来定义,因此可以很方便的将递归的思想利用到树的处理中。另外,很多树操作也可以使用非递归的思想实现,例如使用迭代的思想处理。