优先级队列应用之(哈夫曼树哈夫曼编码)

Posted 董哥的黑板报

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了优先级队列应用之(哈夫曼树哈夫曼编码)相关的知识,希望对你有一定的参考价值。

一、判定树/判别树的概念

  • 现在假设有下面这样的一个题目

  • 如果根据题目构建下面这样的一棵树,那么这个树就是判定树(或判别树)

  • 判定树(或判别树):用于描述分类过程的二叉树

什么是哈夫曼树呢?

  • 假设每次的输入量很大,假设现有10000个学生的成绩,其中E的学生占5%、D的学生占15%、C的学生占40%、B的学生占30%、A的学生占10%
  • 假设以下面的方式构建一棵判定树,则1000个数据的比较次数为:10000*(1*5%+2*15%+3*40%+4*40%)=31500次

  • 假设以下面的方式构建一棵判定树,则1000个数据的比较次数为:10000*(3*20%+2*80%)=22000次

  • 显然,上面的两种判别树的效率是不一样,现问:有没有一种效率最高的判别树呢?其中本文讨论的哈夫曼树就是一种最优二叉树

二、一些相关术语

路径

  • 路径:从树中一个结点到另一个结点之间的分支构成这两个结点间的路径

结点的路径长度

  • 结点的路径长度:两结点间路径上的分支数
  • 例如,下面从A到B、C、D、E、F、G、H、I的路径长度分别为1、1、2、2、3、3、4、4

  • 例如,下面从A到B、C、D、E、F、G、H、I的路径长度分别为1、1、2、2、2、2、3、3

树的路径长度(TL)

  • 树的路径长度(TL):从树根到每一个结点的路径长度之和,记作:TL
  • 例如,下面树的TL=0+1+1+2+2+3+3+4+4=20

  • 例如,下面树的TL=0+1+1+2+2+2+2+3+3=16

  • 注意:结点数目相同的二叉树中,完全二叉树是路径长度最短的二叉树

权(weight)

  • 权(weight):将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权

结点的带权路径长度

  • 结点的带权路径长度:从根节点到该节点之间的路径长度与该节点的权的乘积

树的带权路径长度(WPL)

  • 树的带权路径长度(WPL):树中所有叶子结点的带权路径长度之和,记作WPL
  • 其中WPL为:

演示案例

  • 现有有4个节点a、b、c、d,权值分别为7、5、2、4。现在构建了下面的两棵二叉树

  • 上面两棵二叉树的WPL分别为:
    • WPL(a)=7*2+5*2+2*2+4*2=36
    • WPL(b)=7*3+5*3+2*1+4*2=46

三、哈夫曼树

  • 哈夫曼树:对于具有相同节点的二叉树,其中构造出来的"带权路径长度最短"的二叉树称为哈夫曼树

演示说明

  • 现在有4个节点a、b、c、d,各个结点的权值分别为7、5、2、4
  • 下面构造出的二叉树WPL=7*2+5*2+2*2+4*2=36

  • 下面构造出的二叉树WPL=7*3+5*3+2*1+4*2=46

  • 下面构造出的二叉树WPL=7*1+5*2+2*3+4*3=35。在所有的树中其WPL最小,因此其是一棵哈夫曼树

  • 下面构造出的二叉树WPL=7*1+5*2+2*3+4*3=35。在所有的树中其WPL最小,因此其是一棵哈夫曼树

  • 总结:
    • 满二叉树不一定是哈夫曼树
    • 具有相同权结点的哈夫曼树不唯一
    • 哈夫曼树中权越大的叶子离根越近,权越小的叶子离根越远

四、哈夫曼树的构建

哈夫曼树的构造方法(哈夫曼算法)

  • ①构造森林全是根:根据n个给定的权值W1、W2、...、Wn构成n棵二叉树的森林F=T1、T2、...、Tn,其中Ti只有一个带权为Wi的根节点
  • ②选用两小构新树:在F中选取两棵根节点的权值最小的树作为左右子树,构造一棵新的二叉树,且设置新的二叉树的根节点的权值为其左右子树上根节点的权值之和
  • ③删除两小添新人:在F中删除这两棵树,同时将新得到的二叉树加入森林中
  • ④重复②③剩余单根:重复②和③,直到森林中只有一棵树为止,这棵树即为哈夫曼树
  • 贪心算法:构造哈夫曼树时首先选择权值小的叶子节点

演示案例1

  • 假设现在有下面4个节点a、b、c、d、e,权值分别为7、5、2、4

  • 第一步:选取最小的两个根节点c、d构造新树,然后剩余a、b,最终如下所示

  • 第二步:再选取最小的两个根节点b和6构造新树,然后剩余a,最终如下所示

  • 第三步:最后只剩下a了,因此将其与11合并,最终构造出来的哈夫曼树如下所示

演示案例2

  • 假设现在有下面5个节点a、b、c、d、e,权值分别为7、5、5、2、4

  • 第一步:选取最小的两个根节点d、e构造新树,然后剩余a、b、c,最终如下所示

  • 第二步:再选取最小的两个根节点b和c构造新树,然后剩余a,最终如下所示

  • 第三步:再选取最小的两个根节点a和6构造新树,最终如下所示

  • 第四步:最后将它们合并即可,最终构造出的哈夫曼树如下所示

总结

  • ①在哈夫曼算法中,初始时有n棵二叉树,要经过n-1次合并并最终形成哈夫曼树
  • ②经过n-1次合并产生n-1个新结点,且这n-1个新结点都是具有两个孩子的分支结点
  • 可见:哈夫曼树中共有n+n-1=2n-1个节点,且其所有的分支结点的度为0或2,没有度为1的结点

五、哈夫曼树的编码实现(数组实现)

  • 下面我们使用顺序存储结构(一维数组)来实现哈夫曼树
  • 结点类型定义如下:
typedef struct 
    int weight;           // 该结点的权值
    int parent, lch, rch; // 该结点的左右孩子
HTNode, *HuffmanTree;
  • 因此,可以使用下面的数组来表示哈夫曼树。其中对于n个结点构成的哈夫曼树一共有2n-1个结点,因此我们可以使用一个大小为2n的数组来存储哈夫曼树,其中0下标不存储元素

图示说明

  • 假设现在有n=8个结点,权值分别为W=7、19、2、6、32、3、21、10,现在让你用数组来构造一棵哈夫曼树
  • 第一步:创建一个大小为16的数组,索引0不存储元素,如下所示

  • 第二步:选取最小的两个元素2、3,然后构造出一个新根5,并初始化相关信息,最终如下所示

  • 第三步:选取最小的两个元素5、6,然后构造出一个新根11,并初始化相关信息,最终如下所示

  • 第四步:选取最小的两个元素7、10,然后构造出一个新根17,并初始化相关信息,最终如下所示

  • 第四步:选取最小的两个元素11、17,然后构造出一个新根28,并初始化相关信息,最终如下所示

  • 第四步:选取最小的两个元素19、21,然后构造出一个新根40,并初始化相关信息,最终如下所示

  • 第五步:选取最小的两个元素28、32,然后构造出一个新根60,并初始化相关信息,最终如下所示

  • 第六步:选取最小的两个元素40、60,然后构造出一个新根100,并初始化相关信息,最终构造出一颗哈夫曼树,如下所示

编码实现

  • ①初始化HT[1......2n-1]:lch=rch=parent=0
  • ②输入初始n个叶子节点,置HT[1......n]的weight的值
  • ③进行以下n-1次合并,依次产生n-1个结点HT[i],i=n+1......2n-1
    • 3.1在HT[1......i-1]中选两个未被选过(从parewnt==0的结点中选)的weight最小的两个节点HT[s1]和HT[s2],s1、s2为两个最小结点下标
    • 3.2修改HT[s1]和HT[s2]的parent值:HT[s1].parent=i; HT[s2].parent=i
    • 3.3修改新产生的HT[i]
      • HT[i].weight=HT[s1].weight+HT[s2].weight
      • HT[i].lch=s1;HT[i].rch=s2;
  • 代码如下
#include <iostream>
#include <map>

using namespace std;

typedef struct 
    int weight;           // 该结点的权值
    int parent, lch, rch; // 该结点的左右孩子
HTNode, *HuffmanTree;

// 初始化哈夫曼数组(不构造)
HuffmanTree initHuffmanTree(int n)

    if(n <= 1)
        return nullptr;

    // 哈夫曼树一共有2n-1个元素
    int m = 2 * n - 1;

    // 创建一个大小为2n的数组存储哈夫曼树(索引0不存储元素)
    HuffmanTree HT = new HTNode[m + 1];

    // 先初始化整个数组
    for(int i = 1; i <= m; ++i)
    
        HT[i].weight = 0;
        HT[i].lch = 0;
        HT[i].rch = 0;
        HT[i].parent = 0;
    
        
    // 输入n个节点的权值
    std::cout << "请输入" << n << "个初始化结点: ";
    for(int i = 1; i <= n; ++i)
        cin >> HT[i].weight;
    
    return HT;


// 根据传入的HT, 构造哈夫曼树
void Select(HuffmanTree HT, int length, int &s1, int &s2);

void createHuffmanTree(HuffmanTree HT, int n)

    // 哈夫曼树一共有2n-1个元素
    int m = 2 * n - 1;

    // 合并产生n-1个结点
    for(int i = n + 1; i <= m; ++i)
    
        int s1;
        int s2;
        // 在HT[k](1 <= k <= i -1)中选择两个其双亲域为0且权值最小的结点
        Select(HT, i - 1, s1, s2);

        // 从F中删除s1、s2
        HT[s1].parent = i;
        HT[s2].parent = i;

        // s1、s2分别作为i的左右孩子
        HT[i].lch = s1;
        HT[i].rch = s2;

        // i的权值分别为左右孩子权值之和
        HT[i].weight = HT[s1].weight + HT[s2].weight;
    


void Select(HuffmanTree HT, int length, int &s1, int &s2)

    // key: 权值. value: 索引
    // map的key默认升序排序
    std::map<int, int> Map;

    // 把parent为0的节点的信息存储到Map中
    for(int i = 1; i <= length; ++i)
    
        if(HT[i].parent == 0)
            Map.insert(HT[i].weight, i);
    

    // 选取权值最小的两个索引, 赋值给s1和s2即可
    auto iter = Map.begin();
    s1 = iter->second;
    iter++;
    s2 = iter->second;


// 打印哈夫曼树
void printHuffmanTree(HuffmanTree HT, int n)

    std::cout << "index\\t" << "weight\\t" << "parent\\t" << "lch\\t" <<"rch\\t" << std::endl;

    for(int i = 1; i < (2 * n); ++i)
    
        std::cout << i << "\\t" << HT[i].weight << "\\t" << HT[i].parent << "\\t" << \\
            HT[i].lch << "\\t" << HT[i].rch << std::endl;
    


int main()

    // 初始化哈夫曼树组
    HuffmanTree HT = initHuffmanTree(8);

    // 构造哈夫曼树
    createHuffmanTree(HT, 8);

    // 打印哈夫曼树
    printHuffmanTree(HT, 8);

    return 0;
  • 程序的运行效果如下(输入的数据是上面"图示说明"中的数据,结果一致):

六、哈夫曼编码

来看一个例子

  • 在远程通讯工程中,要将待传字符转换成由二进制的字符串
  • 例如,将A编码为00、B编码为01、C编码为10、D编码为11,则"ABACCDA"被编码为"00010010101100",如下图所示

  • 解码的时候只需要读取编码二进制字符串,然后进行解析即可
  • 但是上面的编码方式有一个缺点,如果字符较多的话,那么字符串会十分的长,我们可以将待传字符串中较多的字符采用尽可能短的编码,则转换的二进制字符串便可能减少,例如将A编码为0、B编码为00、C编码为1、D编码为01,则"ABACCDA"被编码为"00011010",如下图所示

  • 但是上面的编码方式仍然有一个缺点,那就是会出现重码的问题,我们在解码的时候不知道如何解码。例如,"0000"可以解码为"AAAA",也可以解码为"ABA",也可以解码为"BB"

  • 出现上面问题的原因是,A编码是B编码的一个前缀

前缀编码的概念

  • 在上面设计长度不等的编码时,则必须使任一字符的编码都不是另一个字符的编码的前缀,这种编码称为前缀编码

哈夫曼编码

  • 什么样的前缀码能使得电文总长最短?哈夫曼编码
  • 哈夫曼编码的规则为:
    • ①统计字符集中每个字符在电文中出现的平均概率(概率越大,要求编码越短)
    • ②利用哈夫曼树的特点:权越大的叶子离根越近;将每个字符的概率值作为权值,构造哈夫曼树。则概率越大的结点,路径越短
    • ③在哈夫曼树的每个分支上标上0或1(结点的左分支标0,右分支表1),把从根到每个叶子的路径上的标号连接起来,作为该叶子代表的字符的编码

演示案例1

  • 假设要传输的字符集D=C、A、S、T、;,字符出现的频率W=2、4、2、3、3
  • 第一步:首先根据字符出现的频率将它们构造成一棵哈夫曼树,如下所示

  • 因此,各个字符被编码为:

  • 第二步:现在假设有一个电文为CAS;CAT;SAT;AT,将其编码之后变为"11010111011101000011111000011000"
  • 第三步:现在假设有一个二进制编码为"1101000",则解码之后为"CAT"

演示案例2

  • 假设一个电文的字符集D=A、B、C、D、E、F、G,各个字符的频率分别为W=0.40、0.30、0.15、0.05、0.04、0.03、0.03
  • 第一步:我们根据字符集可以构建出下面的哈夫曼树

  • 第二步:因此各个字符被编码为

两个问题

  • ①为什么哈夫曼编码能够保证是前缀编码?因为没有一片树叶是另一片树叶的祖先,所以每个叶结点的编码就不可能是其他叶结点编码的前缀
  • ②为什么哈夫曼编码能够保证字符编码总长最短?因为哈夫曼树的带权路径长度最短,故字符编码的总长最短
  • 总结:
    • ①哈夫曼编码是前缀码
    • ②哈夫曼编码是最优前缀码

七、哈夫曼编码的编码实现(数组实现)

  • 现在有下面的哈夫曼树

  • 根据上面的哈夫曼树,我们使用数组存储哈夫曼树,最终的结果如下所示

  • 我们使用下面的结构存储哈夫曼结点编码
typedef char **HuffmanCode;

  • 定义一个临时数组来存放一次编码的结果,因为n个节点,最长的编码一定为5,然后编码字符串以"\\0"结尾,因此下面定义的cd数组大小为6

编码实现

  • 哈弗曼编码核心函数为createHuffmanCode()
#include <iostream>
#include <string.h>
#include <map>

using namespace std;

typedef struct 
    double weight;           // 该结点的权值
    int parent, lch, rch; // 该结点的左右孩子
HTNode, *HuffmanTree;

typedef char **HuffmanCode;

// 初始化哈夫曼数组(不构造)
HuffmanTree initHuffmanTree(int n)

    if(n <= 1)
        return nullptr;

    // 哈夫曼树一共有2n-1个元素
    int m = 2 * n - 1;

    // 创建一个大小为2n的数组存储哈夫曼树(索引0不存储元素)
    HuffmanTree HT = new HTNode[m + 1];

    // 先初始化整个数组
    for(int i = 1; i <= m; ++i)
    
        HT[i].weight = 0.0;
        HT[i].lch = 0;
        HT[i].rch = 0;
        HT[i].parent = 0;
    
        
    // 输入n个节点的权值
    std::cout << "请输入" << n << "个初始化结点: ";
    for(int i = 1; i <= n; ++i)
        cin >> HT[i].weight;
    
    return HT;


// 根据传入的HT, 构造哈夫曼树
void Select(HuffmanTree HT, int length, int &s1, int &s2);

void createHuffmanTree(HuffmanTree HT, int n)

    // 哈夫曼树一共有2n-1个元素
    int m = 2 * n - 1;

    // 合并产生n-1个结点
    for(int i = n + 1; i <= m; ++i)
    
        int s1;
        int s2;
        // 在HT[k](1 <= k <= i -1)中选择两个其双亲域为0且权值最小的结点
        Select(HT, i - 1, s1, s2);

        // 从F中删除s1、s2
        HT[s1].parent = i;
        HT[s2].parent = i;

        // s1、s2分别作为i的左右孩子
        HT[i].lch = s1;
        HT[i].rch = s2;

        // i的权值分别为左右孩子权值之和
        HT[i].weight = HT[s1].weight + HT[s2].weight;
    


void Select(HuffmanTree HT, int length, int &s1, int &s2)

    // key: 权值. value: 索引
    // map的key默认升序排序
    std::multimap<double, int> Map;

    // 把parent为0的节点的信息存储到Map中
    for(int i = 1; i <= length; ++i)
    
        if(HT[i].parent == 0)
            Map.insert(HT[i].weight, i);
    

    // 选取权值最小的两个索引, 赋值给s1和s2即可
    auto iter = Map.begin();
    s1 = iter->second;
    iter++;
    s2 = iter->second;


// 打印哈夫曼树
void printHuffmanTree(HuffmanTree HT, int n)

    std::cout << "index\\t" << "weight\\t" << "parent\\t" << "lch\\t" <<"rch\\t" << std::endl;

    for(int i = 1; i < (2 * n); ++i)
    
        std::cout << i << "\\t" << HT[i].weight << "\\t" << HT[i].parent << "\\t" << \\
            HT[i].lch << "\\t" << HT[i].rch << std::endl;
    


// 进行哈弗曼编码
void createHuffmanCode(HuffmanTree HT, HuffmanCode &HC, int n)

    HC = new char *[n + 1];
    
    // 存放一次编码的临时字符数组
    char* cd = new char[n];
    cd[n - 1] = '\\0';

    int i;
    int start;
    int c;
    int f;

    // 逐个字符求哈夫曼编码
    for(i = 1; i <= n; ++i)
    
        start = n -1;
        c = i;
        f = HT[i].parent;

        // 从叶子节点开始向上回溯,直到根节点
        while(f != 0)
        
            --start;              // 回溯1次, start向前移动一位
            if(HT[f].lch == c)    // 结点c是f的左孩子,则生成代码0
                cd[start] = '0';
            else                  // 结点c是f的右孩子,则生成代码1
                cd[start] = '1';
            
            // 继续向上回溯
            c = f;
            f = HT[f].parent;
        

        // 为第i个字符串编码分配空间
        HC[i] = new char[n - start];
        // 将临时编码存放到HC中
        strcpy(HC[i], &cd[start]);
    

    // 释放临时空间
    delete cd;


int main()

    // 初始化哈夫曼树组
    HuffmanTree HT = initHuffmanTree(7);

    // 构造哈夫曼树
    createHuffmanTree(HT, 7);

    // 打印哈夫曼树
    printHuffmanTree(HT, 7);

    // 进行哈夫曼编码
    HuffmanCode HC;
    createHuffmanCode(HT, HC, 7);
    // 打印哈夫曼编码结果
    std::cout << std::endl << "HuffmanCode: " << std::endl;
    for(int i = 1; i <= 7; ++i)
        std::cout << "\\t" << HC[i] << std::endl;
    
    return 0;
  • 运行效果如下 

  • 备注:可能与上面的图片不一样,是因为哈夫曼在构造的时候,左右顺序不一致,但是结果也是正确的

八、文件的编码和解码

使用哈夫曼编码压缩传输文件

  • 假设现在有下面的明文,其一共有378个字符,如果使用ASCII进行编码,每个字符占8位,则下面的文件大小一共为378*8bit=3024bit

  • 现在将其使用Huffman编码进行编码,得到的密文如下,一共为1596bit,因此文件的压缩比为1596/3024=52.8%

编码步骤

  • ①输入各字符及其权值
  • ②构造哈夫曼树——HT[i]
  • ③进行哈夫曼编码——HC[i]
  • ④查HC[i],得到各字符的哈弗曼编码

解码步骤

  • ①构造哈夫曼树
  • ②依次读入二进制码
  • ③读入0,则走向左孩子;读入1,则走向右孩子
  • ④一旦到达某叶子时,即可译出字符
  • ⑤然后再从根触发继续译码,直到结束

演示案例

  • 假设现在接收到如下的加密内容,且字符频度表W=(u,5)、(v,6)、(w,2)、(x,9)、(y,7),现在让你对下面的内容进行解密

  • 第一步:构造哈夫曼树,如下所示

  • 第二步:依次读入二进制码,得到的内容最终如下

以上是关于优先级队列应用之(哈夫曼树哈夫曼编码)的主要内容,如果未能解决你的问题,请参考以下文章

数据结构===哈夫曼编码实现/C或者C++

使用优先队列构建赫夫曼树

霍夫曼编码求节省空间

哈夫曼编码问题再续(下篇)——优先队列求解

SSL 1407哈夫曼树哈夫曼树(哈夫曼树知识)

我们有个数据结构的哈夫曼编码解码的课程设计,你能帮帮我吗