在上一篇数据结构的博文《数据结构(三):非线性逻辑结构-二叉树》中已经对二叉树的概念、遍历等基本的概念和操作进行了介绍。本篇博文主要介绍几个特殊的二叉树,堆、哈夫曼树、二叉搜索树、平衡二叉搜索树、红黑树、线索二叉树,它们在解决实际问题中有着非常重要的应用。本文主要从概念和一些基本操作上进行分类和总结。
一、概念总揽
(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 在常量区,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次比较中已经做过,但由于前一趟排序时未保留这些比较结果,所以后一趟排序时又重复执行了这些比较操作。
堆排序可通过树形结构保存部分比较结果,可减少比较次数。
参考代码如下:
//////////////////////////////////////////////////////////////////// //堆排序 templatevoid Sort::HeapSort(T arr[], int len){ int i; //建立子堆 for(i = len / 2; i >= 1; i--){ CreateHeap(arr, i, len); } for(i = len - 1; i >= 1; i--){ buff = arr[1]; arr[1] = arr[i + 1]; arr[i + 1] = buff; CreateHeap(arr, 1, i); } } //建立堆 template void Sort::CreateHeap(T arr[], int root, int len){ int j = 2 * root; //root's left child, right (2 * root + 1) T temp = arr[root]; bool flags = false; while(j <= len && !flags){ if(j < len){ if(arr[j] < arr[j + 1]){ // Left child is less then right child ++j; // Move the index to the right child } } if(temp < arr[j]){ arr[j / 2] = arr[j]; j *= 2; }else{ flags = true; } } arr[j / 2] = temp; }
(2) 优先队列
优先队列是一种用来维护由一组元素构成的集合S的数据结构。在C++标准模板库中有priority_queue的实现,但是对于其数据结构还是需要进行分析:
优先队列和通常的栈和队列一样,只是每个元素有一个优先级,在处理时总是处理优先级最高的,如果两个元素具有相同的优先级,则按照它们插入到队列中的先后顺序处理。优先队列可以通过链表、数组、堆或者其它数据结构实现。
具体实现可以参考一下代码:
#include#include using namespace std; class Heap { public: Heap(int iSize); ~Heap(); int Enqueue(int iVal); int Dequeue(int &iVal); int GetMin(int &iVal); void printQueue(); protected: int *m_pData; int m_iSize; int m_iAmount; }; Heap::Heap(int iSize = 100)//注意这里是从0开始,所以如果根是i,那么左孩子是2*i+1,右孩子是2*i+2 { m_pData = new int[iSize]; m_iSize = iSize; m_iAmount = 0; } Heap::~Heap() { delete []m_pData; } int Heap::Enqueue(int iVal)//进入堆 { if (m_iAmount == m_iSize) { return 0; } m_pData[m_iAmount ++] = iVal; int iIndex = m_iAmount - 1; while (m_pData[iIndex] < m_pData[(iIndex - 1) /2])//上浮,直到满足最小堆 { swap(m_pData[iIndex],m_pData[(iIndex - 1) /2]); iIndex = (iIndex - 1) /2; } return 1; } int Heap::Dequeue(int &iVal)//出堆 { if (m_iAmount == 0) { return 0; } iVal = m_pData[0];//出堆的数据 m_pData[0] = m_pData[m_iAmount - 1];//最后一个数据放到第一个根上面 -- m_iAmount;//总数减1 int rc = m_pData[0]; int s = 0; for (int j = 2*s +1; j < m_iAmount; j *= 2)//最后一个数放到第一个位置以后,开始下沉,来维持堆的性质 { if (j < m_iAmount - 1 && m_pData[j] > m_pData[j+1]) { ++ j; } if (rc < m_pData[j])//rc应该插入在s位置上 { break; } m_pData[s] = m_pData[j]; s = j; } m_pData[s] = rc; return 1; } int Heap::GetMin(int &iVal) { if (m_iAmount == 0) { return 0; } iVal = m_pData[0]; return 1; } void Heap::printQueue() { for (int i = 0; i < m_iAmount; ++ i) { cout << m_pData[i] << " "; } cout << endl; } int main() { Heap heap; heap.Enqueue(4); heap.Enqueue(1); heap.Enqueue(3); heap.Enqueue(2); heap.Enqueue(6); heap.Enqueue(5); heap.printQueue(); int iVal; heap.Dequeue(iVal); heap.Dequeue(iVal); heap.printQueue(); return 0; }
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树节点的存储结构及代码实现
#define n /*叶子数目*/ #define m 2*n-1 /*结点总数*/ typedef char datatype; typedef struct {float weight; datatype data; int lchild,rchild,parent; }hufmtree; hufmtree tree[m]; /* huffman树的静态三叉链表表示*/ HUFFMAN(hufmtree tree[]) { int i,j,p; char ch; float small1,small2,f; for(i=0;i
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编码实现如下:
#define n /*叶子数目*/ #define m 2*n-1 /*结点总数*/ typedef char datatype; typedef struct {float weight; datatype data; int lchild,rchild,parent; }hufmtree; hufmtree tree[m]; /* huffman树的静态三叉链表表示*/ typedef char datatype; typedef struct { char bits[n]; int start; datatype data; }codetype; codetype code[n]; HUFFMANCODE(codetype code[], hufmtree tree[]) /*code存放求出的哈夫曼编码的数组,tree为已知的哈夫曼树*/ { int i, c, p; codetype cd; /*cd为缓冲变量*/ for(i=0; i