数据结构(三):非线性逻辑结构-特殊的二叉树结构:堆、哈夫曼树、二叉搜索树、平衡二叉搜索树、红黑树、线索二叉树

来源:互联网 发布:淘宝无营业执照 编辑:程序博客网 时间:2024/05/16 12:17

在上一篇数据结构的博文《数据结构(三):非线性逻辑结构-二叉树》中已经对二叉树的概念、遍历等基本的概念和操作进行了介绍。本篇博文主要介绍几个特殊的二叉树,堆、哈夫曼树、二叉搜索树、平衡二叉搜索树、红黑树、线索二叉树,它们在解决实际问题中有着非常重要的应用。本文主要从概念和一些基本操作上进行分类和总结。

一、概念总揽

(1) 堆

堆(heap order)是一种特殊的表,如果将它看做是一颗完全二叉树的层次序列,那么它具有如下的性质:每个节点的值都不大于其孩子的值,或每个节点的值都不小于其孩子的值,前者为小根堆,后者为大根堆。


(2) 哈夫曼树

在应用中通常给树中的节点一个有意义的实数,称为该节点的权,该权可能是该节点的度或访问频率等。一个节点和根之间的路径长度与这个节点的权的乘积称为该节点的带权路径长度。树中所有叶子的带权路径长度之和称为树的带权路径长度(weighted path length of tree),通常记为WPL。

在叶子数目为n,其权为w1, w2, w3,....,wn的所有二叉树中树的带权路径长度最小的树称为最优二叉树,通常叫做哈弗曼树。

(3) 二叉搜索树

单向链表的搜索只能从表头开始,逐个扫描,效率低;双向链表通过前驱和后继指针,可以从当前节点向前或向后两个方向进行,但是搜索效率提高不大,因为还是逐个搜索。二叉搜索树(binary search tree)是改进的双向链表,其中每个节点的值不小于左孩子的值,不大于右孩子的值。二叉搜索树能显著改善搜索的性能。

(4) 平衡二叉搜索树

二叉搜索树的查找效率取决于树的形状,一颗形状均匀的二叉搜索树与节点插入的次序有关。如果二叉搜索树极度不均匀,则会退化为双向链表形式的线性逻辑结构,导致搜索效率极度退化。所以,需要一种动态平衡的方法,不依赖节点插入顺序,总是可以保证二叉树的形状均匀。

所谓形状均匀就是指任何节点的左右子树的高度最多相差为1,称之为平衡二叉搜索树。如果将一个节点的左右孩子子树的高度之差称为该节点的平衡因子的话,那么平衡二叉树的任意节点的平衡因子只能是-1,0,1。

(5) 红黑树

红黑树是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。它是在1972年由Rudolf Bayer发明的,他称之为"对称二叉B树",它现代的名字是在 Leo J. Guibas 和 Robert Sedgewick 于1978年写的一篇论文中获得的。它是复杂的,但它的操作有着良好的最坏情况运行时间,并且在实践中是高效的: 它可以在O(log n)时间内做查找,插入和删除,这里的n 是树中元素的数目。
红黑树是一种很有意思的平衡检索树。它的统计性能要好于平衡二叉树(有些书籍根据作者姓名,Adelson-Velskii和Landis,将其称为AVL-树),因此,红黑树在很多地方都有应用。在C++ STL中,很多部分(目前包括set, multiset, map, multimap)应用了红黑树的变体(SGI STL中的红黑树有一些变化,这些修改提供了更好的性能,以及对set操作的支持)。

(6) 线索二叉树

使用二叉链表无法直接找到每一个节点在某一种遍历序列中的前驱后继,而具有n个节点的二叉链表必定存在n+1个空链域,可以用来存放前驱和后继的指针,并称之为线索(thread),具体的作法:

若节点有左子树,则其左指针指向左孩子;否则另它指向前驱;

若节点有右子树,则其右指针指向右孩子;否则另它指向后继。

为此,二叉链表的节点结构需要增加两个标志域,指明左右链域中的指针是指向左右孩子还是指向前驱后继。

带有线索的二叉链表成为线索链表,相应的二叉树称之为线索二叉树。

二、数据结构之堆

对于学习计算机编程的人,都会听说过堆栈这个概念,但实际上堆栈是两种不同的数据结构:堆和栈。

1. 堆与栈的比较

在数据结构系列博文线性数据结构的《数据结构(二):线性表包括顺序存储结构(顺序表、顺序队列和顺序栈)和链式存储结构(链表、链队列和链栈)》已经对栈进行过分析,栈的典型特征就是先进后出,后进新出,压入栈和弹出栈等概念。类似于往箱子里存储物品,先放入的物品在最下面,而后放入的物品在上面,在取物品时,必须先取最上面的之后才能取下面的。

那么堆是完全二叉树的的层次序列,是一种经过排序的树形数据结构。常说的堆的数据结构指的就是二叉堆,常用来实现优先队列、堆排序等。比如在图书馆的书架上取书,虽然书的摆放是有序的,但是我们想取任意一本时不必像栈一样,先取出前面所有的书,书架这种机制不同于箱子,我们可以直接取出我们想要的书。

最为直接也最容易混淆的就是内存分配中的堆和栈,这里以C语言程序中的堆栈分配为例进行说明,借鉴了一个大神所写的一段程序,具体如下:

int a = 0; 全局初始化区 
char *p1; 全局未初始化区 
main() 

int b; //栈 
char s[] = "abc"; //栈 
char *p2; //栈 
char *p3 = "123456"; //123456\0在常量区,p3在栈上。 
static int c =0; //全局(静态)初始化区 
p1 = (char *)malloc(10);  //堆 
p2 = (char *)malloc(20);  //堆 
}

主要的区别就是:栈(stack)是系统自动分配的自动变量,而堆(heap)则是程序员根据需要自己申请的空间,如malloc等,栈空间是自动分配也是自动回收的,而堆则需要程序员自行维护的。另外,在函数调用中,第一个进栈的是主函数中函数调用后的下一条指令的地址,然后是函数的各个参数,在大多数的C编译器,参数是由右向左入栈的,然后是函数中的局部变量,而静态变量是不入栈的。在本次函数调用结束后,局部变量先出栈,然后是参数,最后是栈顶指针指向最开始存的地址,也就是主函数中函数调用后的吓一跳指令,程序由该点继续运行。

2. 堆的存储结构

与顺序表相同:

typedef struct 

{

DataType *data;

int max;

int size;

}Heap;

堆是一种非常有效的数据管理结构,例如每次都是访问最小元素,就可以使用小堆根,因为表中第一个元素总是最小的。

3. 堆的基本操作

堆是一颗完全二叉树,高度为O(lg n),其基本操作至多与树的高度成正比。

Void FilterUp(Heap *H);
Void FilterDown(Heap *H);
Void SetHeap(Heap *H,int n);
Void FreeHeap(Heap *H);
Void HeapInsert(Heap *H,DataType item);
DataType HeapDelete(Heap *H);

堆运算的特点是删除和插入(以小堆根为例)

(1) 删除

删除第一个元素,用最后一个元素填补
调整顺序,将根结点和其左右孩子较小的比,大交换,直到恢复秩序.

(2)插入

新元素插尾,
从尾开始和双亲比较,小于双亲交换.
比较或者移动的次数不超过完全二叉树的深度
利用堆排序相当于折半查,效率高.

4. 堆的应用

(1) 堆排序

堆排序(HeapSort)是一树形选择排序。
堆排序的特点是:在排序过程中,将R[1..n]看成是一棵完全二叉树的顺序存储结构,利用完全二叉树中双亲结点和孩子结点之间的内在关系,在当前无序区中选择关键字最大(或最小)的记录。
优 点
直接选择排序中,为了从R[1..n]中选出关键字最小的记录,必须进行n-1次比较,然后在R[2..n]中选出关键字最小的记录,又需要做n-2次比较。事实上,后面的n-2次比较中,有许多比较可能在前面的n-1次比较中已经做过,但由于前一趟排序时未保留这些比较结果,所以后一趟排序时又重复执行了这些比较操作。
堆排序可通过树形结构保存部分比较结果,可减少比较次数。

参考代码如下:


(2) 优先队列

优先队列是一种用来维护由一组元素构成的集合S的数据结构。在C++标准模板库中有priority_queue的实现,但是对于其数据结构还是需要进行分析:

优先队列和通常的栈和队列一样,只是每个元素有一个优先级,在处理时总是处理优先级最高的,如果两个元素具有相同的优先级,则按照它们插入到队列中的先后顺序处理。优先队列可以通过链表、数组、堆或者其它数据结构实现。

具体实现可以参考一下代码:


5. 总结

对于堆这样的数据结构,只要清楚两个基本的操作:删除和插入即可。为了维持大根堆与小根堆的结构,必须进行调整。采用队列的形式,从堆顶删除,从堆尾插入,这样就可以实现优先队列结构。另外,特别注意,堆在存储结构上虽然采用了线性表的形式,但是在逻辑结构上属于完全二叉树,因此其在线性表中的索引也就有了如下的关系式:

对于在线性表中的索引index,下表索引从0开始,如果有双亲和孩子结点,那么双亲结点在线性表中的索引为( index - 1) / 2,而孩子结点在线性表中的索引为左孩子为(index*2+1) ,而右孩子为(index*2+2)。知道了这些,对于调整heap结构就十分方便了。

三、数据结构之哈夫曼树

1. huffman 树

带权、路径长度最短的树
路径:从树中一个结点到另一个结点之间的分支构成这两个结点间的~
路径长度:路径上的分支数
树的路径长度:从树根到每一个结点的路径长度之和
在许多应用中,常常将树中结点赋予一个有某种意义的实数,称为该结点的权。
结点的带权路径长度:是该结点到树根之间的路径长度与结点上权的乘积。
树的带权路径长度:树中所有叶子结点(k)的带权路径长度ωk lk之和, 记做WPL.

下面通过一个实例进行说明:有4个结点,权值分别为7,5,2,4,构造有4个叶子结点的二叉树


设有n个权值{w1,w2,……wn},
构造一棵有n个叶子结点的二叉树,每个叶子权值为wi,则WPL最小的二叉树叫Huffman树
哈夫曼树中没有度为1的结点,称为严格的二叉树

2. huffman算法

构造Huffman树的方法:Huffman算法

1.根据给定的n个权值{w1,w2,……wn},构造n棵只有根结点的二叉树Tj,令其权值为wj
2.在森林中选取两棵根结点权值最小的树作左右子树,构造一棵新的二叉树,置新二叉树根结点权值为其左右子树根结点权值之和
3.在森林中删除这两棵树,同时将新得到的二叉树加入森林中
4.重复上述两步,直到只含一棵树为止,这棵树即哈夫曼树

w={7,5,2,4}

构建步骤如下图所示:


3. 采用堆来构造Huffman树

顺序表中是n个权,
1。每个权生成一个仅有根的二叉链表,将根指针入堆。
2。从堆中删除2个结点,权和生成双亲结点,根入堆。
重复直2步骤n-1次。返回根指针。

解释说明:将每个权生成的仅有root的二叉链表的根指针全部入堆,这样堆顶就是最小的权,然后从堆中删除两个节点,根据堆的性质,删除的两个节点刚好就是最小的两个权值,从这两个权值构造一个双亲节点,并将root入堆,然后重复该步骤,每次都选取两个最小的权去构造新的子树,并将入堆,这样重复n-1次之后就构成了Huffman树。算法中采用了堆这个具有排序结构(优先级)的数据结构,能够很好的解决生成Huffman树的问题。

4. huffman树节点的存储结构及代码实现



5. Huffman树的应用:Huffman编码

数据通信用的二进制编码

思想:根据字符出现频率编码,使电文总长最短编码:

根据字符出现频率构造Huffman树,然后将树中结点引向其左孩子的分支标 “0”,引向其右孩子的分支标“1”;每个字符的编码即为从根到每个叶子的路径上得到的0、1序列

举例说明:它们的出现频率依次为4、7、5、2、9,试画出对应的哈夫曼树(请按左子树根结点的权小于等于右子树根结点的权的次序构造),并求出每个字符的哈夫曼编码


具体的编码数组结构描述如下:

typedef char datatype;
typedef struct
{
    char bits[n];
    int start;
    datatype data;
}codetype;
codetype code[n];

编码算法的基本思想:

从叶子tree[i]出发,利用双亲地址找到双亲结点tree[p],再利用tree[p]的lchild和rchild指针域判断tree[i] 是tree[p]的左孩子还是右孩子,然后决定分配代码是“0”还是“1”,然后以tree[p]为出发点继续向上回溯,直到根结点为止。

Huffman编码实现如下:


6. 总结

Huffman树是一个最优二叉树,体现的是叶子节点的带权路径长度和最小。在应用中可以进行Huffman编码,先根据字符出现的频率大小,尽量让出现频率少的字符具有较长的编码,而出现频率大的字符具有较短的编码,这样就能使得编码的压缩效果最好。哈弗曼进行编码的过程包括两个,先根据字符出现频率大小来构建Huffman树,然后从叶子节点出发,到根节点或者从根节点出发到叶子节点,就可以得到该字符的二进制编码。这样的操作能够保证报文的编码长度最小。另外,哈弗曼树可以进行译码,与编码的过程相反,从哈弗曼树的根节点开始,如果遇见0则走左孩子子树,如果遇见1则走右孩子子树,然后一直到叶子节点。这样就得到了该二进制串的字符含义。

四、数据结构之二叉搜索树

1. 基本结构

二叉排序树或是一棵空树,或是具有下列性质的二叉树:

若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值
若它的右子树不空,则右子树上所有结点的值均大于或等于它的根结点的值
它的左、右子树也分别为二叉排序树

二叉搜索树的中序遍历序列是一个有序的序列,它能够显著的提升搜索性能,找到一个数据的路径最长不超过树的深度,搜索性能最优的是完全二叉树。二叉搜索树的存储结构如下:


2. 基本操作

(1) 插入

插入原则:若二叉排序树为空,则插入结点应为新的根结点;否则,继续在其左、右子树上查找,直至某个叶子结点的左子树或右子树为空为止,则插入结点应为该叶子结点的左孩子或右孩子。实现代码如下:


(2) 生成

二叉排序树生成:从空树出发,经过一系列的查找、插入操作之后,可生成一棵二叉排序树

举例说明:

{10, 18, 3, 8, 12, 2, 7, 3}


(3) 删除

要删除二叉排序树中的p结点,分三种情况

 p为叶子结点:

只需修改p双亲f的指针f->lchild=NULL f->rchild=NULL

p只有左子树或右子树

p只有左子树,用p的左孩子代替p        (1)(2)

p只有右子树,用p的右孩子代替p        (3)(4)

p左、右子树均非空

沿p左子树的根C的右子树分支找到S,S的右子树为空,将S的左子树成为S的双亲Q的右子树,用S取代p    (5)

令p的左子树为f(f为p的双亲)的左子树,而p的右子树为S的右子树 (6)


五、数据结构之平衡二叉搜索树

1. 基本概念

二叉搜索树的查找效率取决于树的形状,一颗形状均匀的二叉搜索树与节点插入的次序有关。如果二叉搜索树极度不均匀,则会退化为双向链表形式的线性逻辑结构,导致搜索效率极度退化。所以,需要一种动态平衡的方法,不依赖节点插入顺序,总是可以保证二叉树的形状均匀。

所谓形状均匀就是指任何节点的左右子树的高度最多相差为1,称之为平衡二叉搜索树。如果将一个节点的左右孩子子树的高度之差称为该节点的平衡因子的话,那么平衡二叉树的任意节点的平衡因子只能是-1,0,1。

Adelson-Velskii和Landis提出了一种动态平衡方法,可以实现平衡二叉搜索树,简称为AVL树。

2. 具体实现

在AVL树的实现中比较困难的在于删除和添加后的二叉树结构的动态调整,这里主要通过四个动态调整的方法,具体可以参考代码中的注释

存储结构的实现及基本操作函数声明:


基本操作函数的实现如下:


3. AVLTree的一个demo

实现了一个可用于展示动态平衡二叉树的演示程序,具体的效果见下图:


具体的工程见:动态平衡二叉树demo

六、数据结构之红黑树

1. 基本概念

红黑树是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。它是在1972年由Rudolf Bayer发明的,他称之为"对称二叉B树",它现代的名字是在 Leo J. Guibas 和 Robert Sedgewick 于1978年写的一篇论文中获得的。它是复杂的,但它的操作有着良好的最坏情况运行时间,并且在实践中是高效的: 它可以在O(log n)时间内做查找,插入和删除,这里的n 是树中元素的数目。
红黑树是一种很有意思的平衡检索树。它的统计性能要好于平衡二叉树(有些书籍根据作者姓名,Adelson-Velskii和Landis,将其称为AVL-树),因此,红黑树在很多地方都有应用。在C++ STL中,很多部分(目前包括set, multiset, map, multimap)应用了红黑树的变体(SGI STL中的红黑树有一些变化,这些修改提供了更好的性能,以及对set操作的支持)。

2. 红黑树的性质

首先红黑树是二叉查找树,红黑树确保没有一条路径会比其他路径长出两倍,因而是接近平衡的,所以是一种近似平衡二叉搜索树。

那么必须满足如下二叉查找树的性质:

(1).在一棵二叉查找树上,执行查找、插入、删除等操作,的时间复杂度为O(lgn)。
    因为,一棵由n个结点,随机构造的二叉查找树的高度为lgn,所以顺理成章,一般操作的执行时间为O(lgn)。
    //至于n个结点的二叉树高度为lgn的证明,可参考算法导论 第12章 二叉查找树 第12.4节。
(2).但若是一棵具有n个结点的线性链,则此些操作最坏情况运行时间为O(n)。

而红黑树能保证在最坏的情况下,基本动态几何操作时间均为O(lgn)

红黑树上每个结点内含五个域,color,key,left,right,p。如果相应的指针域没有,则设为NIL。
一般的,红黑树,满足以下性质,即只有满足以下全部性质的树,我们才称之为红黑树:
1)每个结点要么是红的,要么是黑的。
2)根结点是黑的。
3)每个叶结点,即空结点(NIL)是黑的。
4)如果一个结点是红的,那么它的俩个儿子都是黑的。
5)对每个结点,从该结点到其子孙结点的所有路径上包含相同数目的黑结点。

如图所示:


3. 树的旋转

在对红黑树进行插入和删除等操作时,对树做了修改,那么可能会违背红黑树的性质。
 
为了保持红黑树的性质,我们可以通过对树进行旋转,即修改树种某些结点的颜色及指针结构,以达到对红黑树进行
插入、删除结点等操作时,红黑树依然能保持它特有的性质(如上文所述的,五点性质)。
 
树的旋转,分为左旋和右旋,以下借助图来做形象的解释和介绍:

(1) 左旋


(2) 右旋


4. 红黑树的具体实现

参看如下代码,具体实现红黑树的左旋和右旋,以及删除、插入等操作:


七、数据结构之线索二叉树

1 基本概念

使用二叉链表无法直接找到每一个节点在某一种遍历序列中的前驱后继,而具有n个节点的二叉链表必定存在n+1个空链域,可以用来存放前驱和后继的指针,并称之为线索(thread),具体的作法:

若节点有左子树,则其左指针指向左孩子;否则另它指向前驱;

若节点有右子树,则其右指针指向右孩子;否则另它指向后继。

为此,二叉链表的节点结构需要增加两个标志域,指明左右链域中的指针是指向左右孩子还是指向前驱后继。

带有线索的二叉链表成为线索链表,相应的二叉树称之为线索二叉树。


2. 线索二叉树节点定义

如图所示:


对线索链表中结点的约定:在二叉链表的结点中增加两个标志域
若该结点的左子树不空,则Lchild域的指针指向其左子树且左标志域的值为“指针 Link”否则,Lchild域的指针指向其“前驱”,且左标志的值为“线索 Thread”
若该结点的右子树不空,则rchild域的指针指向其右子树,且右标志域的值为 “指针 Link”;否则,rchild域的指针指向其“后继”,且右标志的值为“线索 Thread”。
如此定义的二叉树的存储结构称作“线索链表”。

具体实现如下:


3. 如何建立线索链表?

在中序遍历过程中修改结点的左、右指针域,以保存当前访问结点的“前驱”和“后继”信息。遍历过程中,附设指针pre,  并始终保持指针pre指向当前访问的、指针p所指结点的前驱。形象化的描述如下图所示:


4. 遍历线索链表

由于在线索链表中添加了遍历中得到的前驱”和“后继”的信息,从而简化了遍历的算法
for ( p = firstNode(T); p; p = Succ(p) )
      Visit (p);

寻找某一节点的后继:

ThNode *InorderNext(ThNode *ptr)
{
          ThNode *q;
          If(prt->rtag==Thread) 
                  return(ptr->right);
          else {
    q=ptr->right;
   while(q->ltag==Link)
  q=q->left;
    return q;
}


}

遍历中序线索算法描述:

<1>.寻找中序序列的开始结点
<2>.访问当前结点然后寻找后继
<3>.重复2直到后继为空.


参考文献

1. 数据结构的栈和堆

2. 数据结构之“堆”

3. 数据结构:堆

4. 优先队列及最小堆最大堆

5. 教你透彻了解红黑树 

6. 《C/C++与数据结构》 王立柱

1 0