数据结构知识整理——树

    这里打算整理一下数据结构中有关“树”的基本知识点,尽量做到全面,当然不可能完全做到啦,因此以后发现缺漏会进行补充。这里主要参考了《数据结构与算法分析:C语言描述》这本书,这篇笔记也算是对书中相应知识点的一个整理,下面说到的“书”都是指的这本书。

1.基本概念

    基本概念就不多写了,像子树根节点子节点叶子节点父节点兄弟节点这样的定义没必要进行赘述。
    节点的:一个节点含有的子节点的个数称为该节点的度。
    重点区别深度(depth)和(height)这两个概念。首先要明白路径(path)的概念。

路径:节点 n1 到 nk 的路径定义为节点 n1,n2,···,nk 的一个序列,使得对于 1≤i≤k ,节点 ni 是 ni+1 的父亲。这个路径的(length)为该路径上边的条数,即 k-1。

    注意,从每个节点到它自己有一条长为0的路径,在一棵树中从根到每个节点恰好存在一条路径。
    明白了路径及其长的定义,就可以来区别深度和高的概念了。

深度:对于任意节点 ni,其深度对从根节点到ni的唯一路径的长。
:ni 的高是从 ni 到一片树叶的最长路径的长。

    注意:根的深度为0。所有树叶的高度为0。一棵树的高等于它的根的高一棵树的深度等于它的最深的树叶的深度,该深度总是等于这棵树的高。

    例:根据定义,可以判断出下图中节点C的深度为1(A-C 的长),高为2(C-G-I 的长)。该树的高为3(根的高,即 A-C-G-I 的路径长为3)。
深度与高辨析
ps:以上定义皆遵从《数据结构与算法分析:C语言描述》中的描述。貌似有的书中定义的深度和高是从1开始算的,只能说要注意区分吧。

1.1 树的实现

    可以定义一个节点类型,节点除了保存该节点自己的数据外,还保存指向每个子节点的指针。然而注意这里讨论的是一般意义上的树,一般意义上的树每个节点的子节点数是不确定的,可能有好几个子节点。这样一来在节点类型中要保存全体指向子节点的指针就不太合适了,容易浪费空间。解决方法是利用链表的思想,每个节点的子节点形成一个链表,每个节点类型除了保存其自己的数据外,还保存指向其第一个子节点,和指向下一兄弟节点的指针。看具体的节点声明和图。

struct TreeNode {
	ElementType val;//自己的值
    TreeNode *FirstChild;//指向其第一个子节点的指针
    TreeNode *NextSibling;//指向下一兄弟节点的指针
};

    把上图所示的树用此处所描述的方法来进行实现,得到的结果可以直观地表示为下图右侧的情况。以节点 C 为例,其有一个指针指向子节点 F,另一个指针指向其兄弟节点 D。当然,图中没有画出空指针。
左孩子右兄弟

1.2 树的遍历及应用

    《数据结构与算法分析:C语言描述》在本部分介绍了一种树的流行的应用。这里直接引用书中的原话:

流行的用法之一是包括UNIX、VAX/VMS和DOS在内的许多常用操作系统中的目录结构。

    树的根即为目录的根,根下面的子节点或为目录,或为正规的文件,再往后也是这样。
ps:个人认为可以想象我们电脑中的文件目录。

    书中在此处介绍了两种遍历树的方法。

先序遍历:对节点的处理工作是在它的诸子节点被处理之前进行的。
后序遍历:对节点的处理工作是在它的诸子节点被处理之后进行的。

    书中亦在此处介绍了这两种方法的实际应用。将先序遍历的策略应用于目录中所有文件的名字的罗列;将后序遍历的策略应用于树中所有文件占用的磁盘区块的总数的计算。
    关于树的遍历还有一部分内容放在表达式树。

2. 二叉树

二叉树(binary tree)是一棵树,其中每个节点的子节点都不能多于两个。

    二叉树的一个性质是平均二叉树的深度要比N小很多,这个平均深度为O( N \sqrt N N )。对于后面要提到的二叉查找树,为O(logN)。不过二叉树的深度最大也可以到N-1。
    注意在计算机科学中,除非有特殊的声明,所有对数都是以2为底的。
ps:这也是《数据结构与算法分析:C语言描述》上写的。

2.1 实现

    由于二叉树的节点最多有两个子节点,因此在节点类型中可以保存全体指向子节点的指针。当然,可能有空指针。

struct TreeNode {
     ElementType Element;//自己的值
     TreeNode *Left;//指向左子节点的指针
     TreeNode *Right;//指向右子节点的指针
 };

2.2 表达式树

    表达式树的叶子节点是操作数(operand),比如常量或变量,其他节点为操作符(operator)。如果操作符皆为二元的,那么我们自然可以用二叉树来实现表达式树,如下图所示。通过递归计算左子树及右子树的值并应用在根处的操作符来算出整个表达式树的值。该树即为“(a-b)+((c+d)×e)”的表达式树。
表达式树示例

    如果我们想打印出中缀表达式——这里即为“(a-b)+((c+d)×e)”,对两个括号整体进行运算——我们可以使用中序遍历(打印顺序为:左、节点、右),通过递归产生一个带括号的左表达式,然后打印出根处的运算符,再递归地产生一个带括号的右表达式。
    如果想先递归打印左、右子树,再打印运算符,则可用后序遍历(打印顺序为:左、右、节点),输出“ab-cd+e××”,这便是后缀表达式
    当然也可以先打印运算符再打印左、右子树,即使用先序遍历(打印顺序为:节点、左、右),输出“+-ab×+cde”这样子得到的是前缀表达式

    如何构造一颗表达式树?书上写的将后缀表达式转换成表达式树的方法很直观清楚,这里直接引用书上的表达。

一次一个符号地读入表达式。如果符号是操作数,那么我们就建立一个单节点树并将一个指向它的指针推入栈中。如果符号是操作符,那么我们就从栈中弹出指向两棵树T1和T2的那两个指针(T1的先弹出)并形成一颗新的数,该树的根是操作符,他的两个指针分别指向T2和T1。然后将指向这棵新树的指针压入栈中。

    继续来看上例“ab-cd+e××”。首先读入a和b这两个操作数,依次入栈。然后读入+,因为是操作符,因此依次弹出b和a(a先入栈,因此后出),这里T1为b,T2为a,然后令+的左子树为T2(a),右子树为T1(b)。之后再将以+为根节点的新树压入栈。以此类推。注意这里出入栈的皆为指向具体对象的指针,上述过程只是我为了表述方便所以用具体对象进行说明。

ps:图就不画了,麻烦。
ps:遍历方法中还有一种层序遍历,顾名思义就是从上往下一层层遍历。前面的三种递归策略皆可用递归实现(隐式地使用了栈),层序递归可用队列实现。如果有空就整理一下各种遍历策略的编程细节。这里暂时先不管这个问题。

3. 二叉查找树

    二叉树的一个重要应用是它们在查找中的使用。令二叉树的每个节点都保存一个关键字,并且对于每个节点而言,其左子树中所有关键字值小于该节点的关键字值,而右子树中所有关键字值大于该节点的关键字值,这样一来,二叉树即成为二叉查找树。这里为了假设没有重复关键字。

3.1 几个常用操作

(1)初始化操作
    主要就是建立一颗空树,返回一个TreeNode *类型的空指针,代码略。
(2)几个查找操作
    二叉查找树的性质及没有重复关键字的假设让查找操作变得异常简单。查找操作主要有三种:查找特定值,查找最大值,查找最小值。注要这里找的都是位置,即指向节点的指针。
    查找特定值操作要求返回指向树中具有某个指定关键字的节点的指针,如果这样的节点不存在则返回空指针,因为是C语言描述,之后空指针直接用NULL代替了,若是C++的话就用nullptr。

//输入要查找的关键字及指向根节点的指针
TreeNode *Find(ElementType X,TreeNode *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;
 };

    找最大值位置,根据二叉查找树的原理,就是从根节点开始,只要有右子节点就向左找,找到终点。找最小值位置就是从根节点往左找。
(3)插入与删除
    插入操作,如果想插入某个关键字,则先使用查找特定关键字的原理进行查找,找到了相同关键字则什么都不做(前面假设了没有重复关键字),否则将该关键字插入到遍历的路径的最后一点上。注意插入的时候要创建一个节点。

TreeNode *Insert(ElementType X,TreeNode *T){
	if(T == NULL) {
		T = malloc(sizeof(struct TreeNode));
     	T->Element= X;
     	T->Left = T->Right = NULL;
     }
     else if(X < T->val) return Insert(X, T->Left);
     else if(X > T->val) return Insert(X, T->Right);
     return T;
 };

    删除操作可分为三种情况,即要删除的节点是叶子节点、有一个子节点和有两个子节点。具体的操作,书上的表述很清晰,直接引用了。

1)如果节点是一片树叶,那么可以立即删除。
2)如果节点有一个子节点,则该节点可以在其父节点调整指针绕过该节点后删除。
3)如果节点有两个子节点,一般的策略是用其右子树中最小的数据代替该节点的数据并递归地删除那个节点。

    下图显示节点6被删前后的情况。
在这里插入图片描述
    下图显示节点4被删前后的情况。
在这里插入图片描述
ps:代码就不贴了,麻烦。反正就是先找目标元素,之后看属于那种情况就进行对应的操作。
    如果删除的次数不多,可采用懒惰删除的策略,即要删除目标元素时仍将元素留在树中,仅对节点做个被删除的记号。

3.2 平均情形分析

    书中这一章主要是证明树的所有节点的平均深度为O(log N)。同时说明上述操作的平均运行时间都是O(log N)。在个别情况下,该结论与实际情形不符。这一节最后提出了一个问题,并给出了一个解决方案,引出后面的内容。

如果向一棵树输入预先排序的数据,那么一连串插入操作将花费二次时间,而用链表实现插入的代价会非常巨大,因为此时的树将只由那些没有左子节点的节点组成。一种解决办法就是要有一个称为平衡的附加结构条件:任何节点的深度均不得过深。

ps:以我的数学水平暂不足以对这一节的内容进行透彻的说明,因此就不详写了。

4. AVL数

    AVL(Adelson-Velskii和Landis)树是带有平衡条件的二叉查找树。具体而言:

一颗AVL树是其每个节点的左子树和右子树的高度最多差1的二叉查找树。(空树的高度定义为-1)

    每个节点都要保留高度信息。高度为h的AVL树中,最少节点数S(h)由S(h)=S(h-1)+S(h-2)+1给出。对于h=0,S(h)=1;h=1,S(h)=2。S(h)与斐波那契数列密切相关。除去可能的插入操作外(假设删除使用懒惰删除),所有的树操作都可以用时间O(log N)执行。

//AVL树节点声明
struct AvlNode {
     ElementType Element;//自己的值
     TreeNode *Left;//指向左子节点的指针
     TreeNode *Right;//指向右子节点的指针
     int Height;//高度信息
 };

    插入操作的麻烦之处在于其可能破坏AVL树的特性,因此在插入操作中要有一个性质恢复过程。可以通过旋转对树进行简单修正来做到。在插入过程中,沿着插入点到根节点的路径上行并更新平衡信息,当发现某个节点的新平衡破坏了AVL条件时,我们需要对它进行重新平衡。这种不平衡可能出现在以下4种情况中(都是已经插入了的情况)。每种情况都将导致A不平衡。
在这里插入图片描述
    图中1(左-左)和4(右-右)都是插入发生在外边的情况;2(左-右)和3(右-左)都是插入发生在内部的情况。对于1和4使用单旋转进行调整,2和3使用双旋转进行调整。

4.1 单旋转

    下图显示对于情形1如何进行单旋转。节点F为新插入的节点,从节点F上行对各节点的平衡条件进行分析,可以发现节点A的左子树高度比右子树高度高2,不满足平衡条件,需进行重新平衡。我们也很容易发现该情形属于情形1。
在这里插入图片描述
    如何调整?我们把A的左子树上移,相对而言,A及其右子树则下降了。由此则将B变成新的根节点,新树中A变成B的右子节点,再将B的右子树放到A的左子树位置。根据二叉树的性质,B<A,B的右子树中所有节点也都小于A,因此这种操作是合理的。这就是单旋转。
    再看一个情形4。
在这里插入图片描述

4.2 双旋转

    单旋转对情形2和3无效。可以看到下图显示对于情形2,进行单旋转后的树依旧不满足平衡条件,此时需进行双旋转。
在这里插入图片描述
    下图显示对于情形2如何进行双旋转。注意F和G有一个就够了,无论是因为F还是G都能让A不满足平衡条件。
在这里插入图片描述
    在双旋转中,想象把E往上拎,让E变成新的根,B作为其左子节点,A作为其右子节点。E原先的左子树变成B的右子树,E原先的右子树变成A的左子树。这就是双旋转。另外,其效果与先旋转A的“子”(B)和“孙”(E),再将A与新的“子”(E)旋转相同。
    再来看情形3的情况:
在这里插入图片描述

4.3 编程细节

    这里直接引用书上的内容。

为将关键字是X的一个新节点插入到一颗AVL树中,我们递归地将X插入到T的相应的子树(TLR)中。若TLR高度不变则插入完成。否则在T中出现高度不平衡,则根据X以及T和TLR中的关键字做适当的单旋转或双旋转,更新这些高度(并解决好与树的其他部分的连接),从而完成插入。

AvlNode *Insert(ElementType X,TreeNode *T){
	if(T == NULL) {
		T = malloc(sizeof(struct TreeNode));
     	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);
	}
	//更新各沿途节点的高度信息
	T -> Height = Max(Height(T ->Left), Height(T -> Right)) + 1;
	return T;
 };

    对AVL树的删除操作显然更为复杂,因此如果删除操作不多,那么用懒惰删除就可以了。
ps:具体旋转操作的代码有空就贴上。

5. 伸展树

    我们并不担心二叉查找树单次操作的最坏运行时间是O(N),只要其不经常发生就行。重要的是累积的运行时间。伸展树(splay tree)能够保证从空树开始任意连续M次对树的操作最多花费O(MlogN)的时间,这是一个令人满意的时间,即可保证不存在坏的操作序列
    这里有一个摊还(amortized)运行时间的概念:

当M次操作的序列总的最坏情形运行时间为O(MF(N))时,我们就说它的摊还运行时间为O(F(N))。

    伸展树每次的摊还代价是O(logN)。伸展树保证这一时间界的方式,就是在访问节点时对节点进行移动操作。因为任意特定操作可以有最坏的时间界O(N)。借用书上的例子:一旦发现一个深层节点,我们可能需要不断地对其进行Find,即查找,若这个节点不改变位置,而每次查找花费O(N),那么M次查找将花费O(MN)的时间。
    伸展树的基本想法是,当一个节点被访问后,它就要经过一系列AVL树的旋转后放到根上。这一系列的操作对树具有平衡的作用,这可使得原先较深的节点能够移动到浅层,对这些节点的访问时间将变少。同时,对于频繁访问的节点,我们将其移动到浅层可以节省访问时间。伸展树不要求保留高度或平衡信息。
    然而,如果从底部向上都采用AVL旋转操作,也会引发一个问题。如下图所示,我们对I进行了一次访问,通过一系列的旋转操作,I变成了新的根节点,然而,这一系列的操作也将节点G推到了深处。虽然I的访问时间将大大减少,但其他节点的状况并未有明显的好转。
在这里插入图片描述

5.1 展开

    展开(splaying)的思路类似旋转,具体如何旋转要分情况。
1)如果被访问节点X的父节点是树根,则直接旋转X和树根,方式很像单旋转。
2)如果被访问节点X有“父”(P)又有“祖”(G),三个节点呈之字形,则进行像AVL那样的双旋转。下图左侧显示了这种情形的操作方法。
3)如果被访问节点X有“父”(P)又有“祖”(G),三个节点呈一字形,则把被访问节点拎起来。下图右侧显示了这种情形的操作方法。
在这里插入图片描述
    再回到上面那个例子,使用此处所描述的展开方式进行操作,重构情况如下。可也看到展开操作不仅把访问的节点(此处为节点I)移动到根处,而且使得访问路径上的节点的深度也显著减少。
在这里插入图片描述

注意:当访问路径太长而导致超出正常查找时间时,展开对未来的操作有益。当访问耗时很少时,展开则不那么有益甚至有害。

    通过访问要删除的节点进行删除操作,把它上移到根节点再删除,则可得到两颗子树TL(左)和TR(右)。将TL中最大的元素旋转至根处,显然新的根没有右子树(如果有右子树则它必然不是最大的元素),我们让TR成为其右子树。

6. B树

    与前面的查找树不同,作为一种常用的查找树,B树(B-tree)不是二叉树。

阶为M的B树是一颗具有下列结构特性的树:
    树的根或者是一片树叶,或者其子节点数在2和M之间。
    除根外,所有非树叶节点的子节点数在⌈M/2⌉到M之间。
    所有的树叶都在相同的深度上。

    若某个内部节点的子树为P1,P2,…,PM。要求子树Pi的所有关键字皆小于Pj中的所有关键字(i<j)。该内部节点含有P2,…,PM 中的最小关键字以及指向 P1,P2,…,PM 的指针。叶子节点包含所有实际数据(关键字)。另外,还要求在(非根)树叶中关键字的个数也在⌈M/2⌉到M之间。B树有很多定义,这里的描述的是一种流行的结构,其他定义在次要细节上不同。
    看一个书上的例子了解B树的操作。下图是一个3阶B树,3阶B树也叫2-3树。
在这里插入图片描述
    查找操作,从根开始根据要查找的关键与存储在节点上的各个值的关系确定搜索的方向,往下搜索直到到达叶子节点。
    插入不重复的关键字时先按照查找操作找到叶子节点,这时:
1)如果节点中关键字数小于M,直接将关键字添加到叶子节点中。
2)如果节点中关键字数等于M,即节点容量已满,但父节点的子节点数小于M,则构造两个节点,每个节点分别有⌈(M+1)/2⌉和⌊(M+1)/2⌋个关键字,并更新父节点信息。
3)如果不仅节点容量已满,父节点的子节点数也到了最大值,那么就要将这个父节点分成两个节点,再更新祖先的信息。
    当然,在第3种情形下,父节点的数量也有可能已经达到极限,此时再往上进行分裂。可以在通向根的路径上一直分下去,如果根节点也被分裂,则创建一个新的根节点。
    书中介绍的实例是先插入18,此时满足情形1。之后插入1,此时满足情形2。之后再插入19,此时满足情形3。之后又插入了28,这时就要进行两次父节点的分裂并创建一个新的根节点。整个过程如下图所示。
在这里插入图片描述
ps:子节点数量过多还有其他处理方法,这里就不写了。

    对于删除操作,如果删除关键字后节点中只剩下1个关键字,则把这个节点与他的兄弟节点合并。如果兄弟已经有3个关键字了,那就拿出一个使两个节点各有两个关键字。如果兄弟有2个关键字则直接合并,合并后父节点失去一个子节点,这时要向上检查,如果根节点仅剩下一个子节点,则把根节点删除。
    B树的深度最多为⌈log⌈M/2⌉N⌉。查找需要O(logN)的时间,插入与删除最坏的运行时间是O(MlogMN)。书中给出从运行时间考虑,M的最好取值为3或4,M再增大则插入和删除的时间会增加。如果只关心主存的速度,则更高阶的B树没有优势。

ps:就这样了,其他的红黑树、treap树啥的不记在这里了,书上也没有放在同一章。头一次试着这样复习记笔记,还挺花时间的,不过温故知新也不是没有收获吧。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值