前言
首先举一个例子: 如果要求编写一个程序将百分制的考试转化为不及格(1),通过(2),一般(3),良好(4)和优秀(5),5个等级。
通过分析可以得到下面一种图1情况:
但是如果需要转换的学生成绩很多,用此判定树的程序效率问题就比较突出了。主要原因是因为学生成绩分布在上述五个分数段中是不均匀的。
假设实际学生的成绩分布情况入下表所示:
如果按上面图1这种情况,则80%以上的数据需要进行三次及三次以上的数据比较才能得到对应的等级。假设现在有10000个输入数据,需要进行的比较次数是比较多的。但是如果将比例较高的分数段放在判定树的前面,明显这样的比较次数会明显降低。如图2所示。
那么我们怎样可以得到一颗最好的比较判断逻辑,是运算效率最高的判定树的?这就是"最优树"(哈夫曼树)要解决的问题。
哈夫曼树的定义
首先介绍一些概念:
- 路径:从根节点开始沿某个分支到达该节点的节点序列。
- 节点路径长度L:路径所含分支数(节点个数减1)。
- 树的路径长度:从树根到各节点路径之和。
- 权值W:出现频率或者出现次数。
- 节点带权值的路径长度:从根节点到该节点的路径长度与该节点权值的乘积。
- 每个叶节点的带权值路径长度之和:叶节点权值与叶节点路径乘积之和。可以表示为WPL=ΣW*L (W为叶节点权值,L为叶节点路径长度)
叶节点带权值路径长度之和是判断效率的一个主要依据,值越小,效率越高。
哈夫曼树的定义:假设有n个权值{W1,W2,W3,......,Wn},构造有n个叶子节点的二叉树,每个叶子节点的权值是n个叶子节点权值之一,这样的二叉树有很多,其中必定有一个叶子节点带权路径长度最小的,这样的二叉树就是最优二叉树或者哈夫曼树。
注意:有n个数,各有权值,但是都是作为叶子节点。
哈夫曼树的构造
由哈夫曼树的定义可知,一颗二叉树要使WPL值最小,必须使权值越大的叶节点越靠近根节点,而权值越小的叶节点越远离根节点。
哈夫曼就是依据这一特点提出了一种算法,它是一种贪心算法。该算法在初始状态下将每一个节点看成一颗独立的树,每一步执行两棵树的合并,而选择合并对象的原则是"贪心"的,即每次将权值最小的两棵树合并。具体过程如下:
- 由给定的n个权值{W1,W2,W3,......,Wn}构造n个只有一个叶节点的二叉树,从而得到一个二叉树的集合F={T1,T2,......,Tn}。
- 在F中选择最小和次小的两颗树作为左右子树,构造一颗新的二叉树,新二叉树根节点的权值为左右子树权值之和。
- 在集合F中去掉2中的左右子树的两颗二叉树,将新的二叉树加入F中。
- 重复2,3动作,当二叉树只剩下一颗二叉树时,这颗二叉树就是要建立的哈夫曼树。
拿5个权值为{1,2,3,4,5}举例来构建一颗哈夫曼树帮助理解:
首先构造5棵树:
将权值最小和次小的两棵树作为左右子树,构造成新的树,新树根节点值为左右子树新权值之和。
重复上面动作
最后得到的就是哈夫曼树,仔细观察可以发现给定的权值,都作为了叶节点。并且,对于同一组给定权值得到的哈夫曼树形状可能不同。就上给定的权值,也可能得到如下哈夫曼树:
哈夫曼树编码实现
哈夫曼树的编码实现主要要考虑的是怎样得到最小和次小的权值。
一种方法可以通过排序,
另外一种办法就是可以通过对来实现,建立最小堆,取出堆顶元素,作为左右子树,在将新建立的树插入堆中。这里会用到最小堆的删除与插入操作。想要了解堆,可看博客https://blog.csdn.net/weixin_57023347/article/details/118088956?spm=1001.2014.3001.5501
显然建立堆的方式效率更高。
建立哈夫曼树的函数:
#include"Head.h"
typedef struct HTtree{
int weight;
struct HTtree *left;
struct HTtree *right;
}Htree;
Htree *HuffmanTreeCreate(HD *head){
int n = head->size-1;//元素个数,为什么要减1
for (int i = 0; i < n; i++){//循环从0开始
Htree *t = (Htree *)malloc(sizeof(Htree));
//建立的小根堆,堆顶元为最小值
t->left = HeadPop(head);//取出堆顶元素,作为左右子树
t->right = HeadPop(head);
//新树根为左右子树权值之和
t->weight = (t->left->weight) + (t->right->weight);
//将新树放入堆中
HeadPush(head, t);
}
//留下的最后一颗树为哈夫曼树
return HeadPop(head);
}
用到堆的主要函数(插入,删除)
#include"Head.h"
static void Swap(HDdatatype *px, HDdatatype *py){
HDdatatype temp = *px;
*px = *py;
*py = temp;
}
//从下往上调整。得到大根堆
static void AdjustDown(HDdatatype a[], int size, int parent){
int child = parent * 2 + 1;//先假设左边的数比根数大
while (child <= size){//没有孩子结点退出
if (child + 1 <= size&&a[child]->weight > a[child + 1]->weight){//如果右边的数大,child++就得到右边数的下标
child++;
}
if (a[child]->weight < a[parent]->weight){//如果孩子结点比父节点大就就交换
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else{//直到不大就退出
break;
}
}
}
static void AdjustUp(HDdatatype a[], int child){//向上调整
while (child > 0){//孩子节点等于0,没有父节点退出
int parent = (child - 1) / 2;//得到父节点
if (a[child]->weight<a[parent]->weight){//孩子节点比父节点大交换
Swap(&a[child], &a[parent]);
child = parent;
}
else{//直到不大退出
break;
}
}
}
//将数组b[],调整成堆,
void HeadInit(HD *head, HDdatatype b[],int n){
head->a = (HDdatatype *)malloc(sizeof(HDdatatype)*n);
if (head->a == NULL){
printf("malloc fail!\n");
exit(-1);
}
for (int i = 0; i < n; i++){
head->a[i] = b[i];
}
head->size = n;
head->capacity = n;
for (int i=(head->size-1-1)/2;i>=0;i--)
{
AdjustDown(head->a, head->size-1, i);
}
}
//在最后一个位置插入,再向上调整成堆
void HeadPush(HD *head, HDdatatype x){
assert(head);
if (HeadIsFull(head)){
HDdatatype *temp = (HDdatatype *)realloc(head->a, sizeof(HDdatatype)*head->capacity * 2);
if (temp == NULL){
printf("realloc fail\n");
HeadDestroy(head);
exit(-1);
}
head->a = temp;
head->capacity *= 2;
}
head->a[head->size] = x;
head->size++;
AdjustUp(head->a, head->size - 1);
}
//假设有N各元素,将第一个元素(0)与最后一个元(N-1)素交换,再将前面N-1个元素向下调整成堆
HDdatatype HeadPop(HD *head){
assert(head);
if (HeadIsEmpty(head)){
return NULL;
}
HDdatatype min = head->a[0];
Swap(&head->a[0], &head->a[head->size - 1]);
head->size--;
AdjustDown(head->a, head->size-1, 0);
return min;
}
bool HeadIsEmpty(HD *head){
if (head->size == 0){
return true;
}
return false;
}
头文件:
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>
#include<Windows.h>
#include<assert.h>
typedef struct HTtree{
int weight;
struct HTtree *left;
struct HTtree *right;
}Htree;
typedef Htree* HDdatatype;//将树类型作为数组元素类型
typedef struct Head{
HDdatatype *a;
int size;
int capacity;
}HD;
void HeadInit(HD *head, HDdatatype b[], int n);//堆初始化
void HeadDestroy(HD *head);//释放堆
void HeadPush(HD *head, HDdatatype x);//插入元素并保持堆的性质
HDdatatype HeadPop(HD *head);//弹出堆的首元素(优先级最高的)
bool HeadIsEmpty(HD *head);//判断堆是否为空
Htree *HuffmanTreeCreate(HD *head);
验证:
#include"Head.h"
int main(){
int weight[] = { 10, 15, 12, 3, 4, 13, 1 };//权值
int n = sizeof(weight) / sizeof(weight[0]);
HDdatatype *a = (HDdatatype *)malloc(sizeof(HDdatatype)*n);
for (int i = 0; i < n; i++){ //将权值作为叶节点,都单独作为独立的树
Htree *node = (Htree *)malloc(sizeof(Htree));
node->weight = weight[i];
node->left = NULL;
node->right = NULL;
a[i] = node;
}
HD head;
HeadInit(&head, a, n); //将树建立堆
Htree *ass = HuffmanTreeCreate(&head); //得到哈夫曼树
system("pause");
return 0;
}
哈夫曼树的特点:
- 没有度为1的节点。将叶节点两两合成,构造新树,所以没有度为1的节点
- n个叶子节点的哈夫曼树共有2n-1个节点。
推论:N0叶节点总数,N1只有一个儿子的节点,N2右两个儿子的节点。
节点总数N=N0+N1+N2,
而边的总数有:N-1=N0+N1+N2-1=0*N0+N1+2*N2 ——> N0=N2+1
由于哈夫曼树没有度为1的节点,所以节点总数n=n0+n1+n2=n0+n0-1=2*n0-1。(n0为叶子节点个数)
- 任意非叶节点左右子树仍然是哈夫曼树。
- 权值一样构成的哈夫曼树不同,但是优化值一样
哈夫曼树编码
给定一个字符串,如何对其中字符进行编码,使得该字符串编码的存储空间最小?
例如:有一串文本,包含58个字符,经统计其中只有7个字符不同,他们是:a,e,i,s,t,空格(sp),换行(nl)。其中出现频率为:
- 利用一般储存字符的方法,用8位来存储一个字符,则该文本需要58*8=464位存储。
- 但是其中只有7个字符是不同的,我们完全可以用3位来表示,3位可以表示8个字符。即a=000,e=001,i=010,s=011,t=100,sp=101,nl=110。这时该文本需要58*3=174位来存储。
- 但是如果使用哈夫曼树,上述文本通过频率可以生成的哈夫曼树可以是:
从图中,我们将左分支记为0,右分支记为1.某字符编码可以通过组合从根节点到某字符(叶节点)路径上的0,1可以得到。
即a=111,e=10,i=00,s=11011,t=1100,sp=01,nl=11010
存储该文本需要的位数为:3*10+15*2+12*2+3*5+4*4+13*2+1*5=146位
哈夫曼编码是文件压缩的有效方法,器压缩比通常在20%~90%之间。