数据结构(霍夫曼树)

1. Huffman编码

1.1 问题起源

假设在数据通信中,有一字串"ABABBCBBA"需要传送,一般会将这些字符进行编码,然后按编码后的二进制位进行传输,例如这些字母的ASCII码取值为:

A(65): 0100 0001

B(66): 0100 0010

C(67): 0100 0011

因此最"简单"的方式,就是将上述字串直接使用字符编码方式转换成如下01序列进行传输(总共72位):

01000001 01000010 01000001 01000010 01000010 01000011 01000010 01000010 01000001

1

很容易发现,上述字串中,虽然每个字母只用了一个字节来编码,但是每个字节的前面6位都是"0100 00",因此完全可以将这无效编码去掉,变成:

A: 01

B: 10

C: 11

这样一来,字串"ABABBCBBA"编码后就变成了:

0110 0110 1011 1010 01

整个编码长度缩减为18位。但这并未达到压缩的极致,仔细观察上述ABC三个字符的编码,会发现其实把A的编码从01换成0也可以,这又省掉了一位。字串"ABABBCBBA"编码后就变成了:

0100 1010 1110 100

二进制编码进一步缩短为15位。这里有一点要着重注意,将A的编码从01换成0是可以的,但是不能换成1,因为B和C的编码都是从1开始的,如果A的编码是1的话,在译码阶段就会出现二义性,比如当出现"11011..."时,就无法区分第一个1究竟是A还是跟后面的1结合形成C。

经过观察又会发现,字串中B出现的概率比A多,因此我们完全可以将性价比更高(即更短)的编码给B,变成:

A: 10

B: 0

C: 11

这样一来,字串"ABABBCBBA"编码后就进一步变成了:

1001 0001 1001 0

1

此时,字串的编码只有13位。并且注意到,由于做了谨慎的处理,逐个读取二进制位译码时,不会发生二义性。

1.2 Huffman编码

在上面的分析中,那种基于字符出现的频次,按频次给不同给不同字符分配长短不一的编码方式,就是Huffman编码的基本思路:

  1. 找出字串中各个字符的出现频次,如:[A3,B5,C1][A 3,B 5,C1]。
  2. 以频次作为叶子节点的权值,构造一棵Huffman树。
  3. 将Huffman树中所有的左子树的边标记为0,右子树的边标记为1,则每一个叶子节点的路径就是Huffman编码的数值。

以上述字串"ABABBCBBA"为例,这三个节点的频次构成的Huffman树是:

在此Huffman树中,按照上述编码的思路,将左子树路径标记为0,右子树路径标记为1:

那么[A3,B5,C1][A 3,B 5,C1]的路径分别是:

A3A 3: 10

B5B 5: 0

C1C1: 11

这刚好就是上述描述的最优长短编码。下面,来看Huffman树是怎么构建的。

2. Huffman树

2.1 基本概念

Huffman树一般称为霍夫曼树,又称为最优二叉树,为什么是最优呢?以下面的二叉树为例,先弄清几个概念:


  • 有时会用一个数值来表征一个节点,比如上述每个节点中的数字。这些权值具体到实际应用当中可以表达一种程度、某个标值等,比如Huffman编码中会提到的某个字符出现的频率。
  • 路径及路径的长度
    从根节点开始,到叶子节点的一条通路,比如上图中红色虚线所示的路径:3、2、8、13、2、8、1
    路径所包含的边的数目,称为路径的长度,比如上述从根节点3到叶子节点1的路径中,包含了3条边,因此该路径的长度为3。
  • 带权的路径长度
    如果在计算路径长度时,考虑到达某个节点所经过的边数目,将这些数据累加起来作为路径长度的话,这样的路径长度称为带权的路径长度。
    比如上述路径3、2、8、13、2、8、1,其带权路径是:

3∗0+2∗1+8∗2+1∗3=2+16+3=213∗0+2∗1+8∗2+1∗3=2+16+3=21

  • 树的带权路径长度
    容易理解,树的带权路径长度就是所有的叶子节点的带权路径长度之和,称为WPL (即W eighted P ath Length)。还是以上述二叉树为例,此处WPL应等于:

(1∗2+2∗6)+(1∗2+2∗8+3∗1)+(1∗5)=14+21+5=41(1∗2+2∗6)+(1∗2+2∗8+3∗1)+(1∗5)=14+21+5=41

很明显,如果变换一下各个带权节点的位置,则可以改变整棵树的带权路径的长度,例如做如下变换,让权值大的节点尽量往根部靠近(以减少路径长度):

此时树的带权路径长度为:

(1∗6+2∗1)+(1∗5+2∗2)+(1∗5+2∗3)=8+9+11=28(1∗6+2∗1)+(1∗5+2∗2)+(1∗5+2∗3)=8+9+11=28

2.2 Huffman树的定义

给定N个权值为N的叶子节点,构建一棵树,如果这棵树的WPL达到最小值,就称这样的树为最优树,或霍夫曼树。

如果构建的树是二叉树,那么这样的二叉树称为最优二叉树。

2.3 Huffman树的节点数量

抛出这么一个问题:当待编码的字符有m个时,最终构建出来的霍夫曼树的节点数总共有几个?

当一棵霍夫曼树的叶子节点数 n0=3n 0​=3 (此处的n0n0​就是上述代码中的m)时,整棵霍夫曼树的节点树是多少呢?答案是5。下面是简单的推导过程:

  1. 假设二叉树节点总数为nn ,度为0的节点数为n0n 0,度为1的节点数为n1n 1,度为2的节点数为n2n2,那么显然有:

n=n0+n1+n2n =n 0+n 1+n2

  1. 从另一个角度考察,二叉树的节点总数也等于所有节点的子节点个数之和:n0n 0节点的子节点的总数是0∗n00∗n 0,n1n 1节点的子节点总数是1∗n11∗n 1,n2n 2节点的子节点总数是2∗n22∗n2,又由于根节点不是任何节点的子节点,因此有:

n=0∗n0+1∗n1+2∗n2+1=n1+2∗n2+1n =0∗n 0+1∗n 1+2∗n 2+1=n 1+2∗n2+1

  1. 结合上述两个式子得到:

n2=n0−1n 2=n0−1

  1. 霍夫曼树中,所有的权值节点均是叶子结点,且不存在度为1的节点,即n1=0n1=0,因此有:

n=n0+n1+n2=n0+n0−1=2∗n0−1n =n 0+n 1+n 2=n 0+n 0−1=2∗n0−1

结论:

当权值节点数量为n0n 0​时,霍夫曼树的节点总数为2∗n0−12∗n0​−1

2.4 Huffman树构建的基本思路

从以上带权路径最小化基本思路出发,可以构建一棵Huffman树。仍然以字串"ABABBCBBA"为例,基本思路是:

  1. 计算各个字符所出现的频次:

"ABABBCBBA"

A:出现3次

B:出现5次

C:出现1次

不同字符的数量是:3

  1. 将这些频次作为节点的权值,放在一个数组中:

    int m = 3; // 3是子串所出现不同的字符数量[A, B, C]
    int huffmanTree[2*m-1] = {3, 5, 1, 0, 0}; // 此处伪代码,不可编译

这些权值节点就是霍夫曼树的叶子节点,如下图所示:

权值节点

  1. 从这些叶子节点出发,逐步构建整棵霍夫曼树:在已知的叶子中找到两个最小且无父节点的叶子,接他们权值相加并将和放入后续数组中,比如找到3和1之后,生长出节点4:

    huffmanTree[] = {3, 5, 1, 4, 0};

在上述操作过程中,关键是要标注节点之间的关系,必须指明3和1是4的子节点,4是它们的父节点,这样,在下一轮构建中3和1将不再参与,最终构建出霍夫曼树:

huffmanTree[] = {3, 5, 1, 4, 9};
  1. 从叶子节点出发,向上寻找根节点,将沿途经历过的路径加以编码(比如左路径为0、右路径为1),再反转,得到的就是改节点的霍夫曼编码,比如:从节点3开始,到根节点9,其经过的路径编码是:01,因此权值为3的节点A霍夫曼编码就是10。最终得到:

[A3,B5,C1][A 3,B 5,C1]的路径分别是:

A3A 3: 10

B5B 5: 0

C1C1: 11

2.5 Huffman树的构建步骤

  • 【步骤1】准备一个存储空间为 2∗m−12∗m −1 (即2∗n0−12∗n0−1)的数组来存储霍夫曼树:

    // 霍夫曼树节点
    typedef struct
    {
    char ch; // 字符
    int weight; // 权重,即字符出现的频次

      int parent; // 父节点位置
      int lchild; // 左子树位置
      int rchild; // 右子树位置
    

    }huffmanTreeNode;

    // 统计字符串s中的各个字符
    void initHuffmanTree(const char *s, huffmanTreeNode **ht, int pm)
    {
    // 统计s中不同字符的个数,放入
    pm中
    // 以及各个字符出现的频次,放入alphabet中
    *pm = 0;
    int alphabet[26] = {[0 ... 25]=0};
    for(int i=0; s[i]!='\0'; i++)
    alphabet[s[i]-'A']++;

      for(int i=0; i<26; i++)
      {
          if(alphabet[i] != 0)
              (*pm)++;
      }
    
      // 存储各个字符出现的频次
      // 所需的霍夫曼节点总数n = 2*m - 1
      // [a1, a2, a3, ..., am, 0, 0, ... , 0]
      // | <--  m个字符 --> |
    
      *ht = calloc((*pm)*2-1, sizeof(huffmanTreeNode));
      for(int i=0,k=0; i<26; i++)
      {
          if(alphabet[i] != 0)
          {
              (*ht)[k].ch = 'A' + i;
              (*ht)[k].weight = alphabet[i];
              k++;
          }
      }
    

    }

  • 【步骤2】根据权值节点数组,构建霍夫曼树:

    // 函数功能:在数组ht中找到两个最小值,分别用p1和p2标识其下标
    // 参数解析:
    // ht:霍夫曼树数组
    // end:数组边界下标
    // p1与p2:最小值位置下标
    void find(huffmanTreeNode *ht, int end, int *p1, int *p2)
    {
    int min1 = 0;
    int min2 = 0;

      for(int i=0; i<=end; i++)
      {
          if(ht[i].parent != 0)
              continue;
          
          if(min1 == 0)
          {
              min1 = ht[i].weight;
              *p1 = i;
              continue;
          }
          else if(min2 == 0)
          {
              if(min1 < ht[i].weight)
              {
                  min2 = ht[i].weight;
                  *p2 = i;
              }
              else
              {
                  min2 = min1;
                  min1 = ht[i].weight;
                  *p2 = *p1;
                  *p1 = i;
              }
              continue;
          }
          
          if(ht[i].weight < min1)
          {
              min1 = ht[i].weight;
              *p2 = *p1;
              *p1 = i;
          }
          else if(ht[i].weight < min2)
          {
              min2 = ht[i].weight;
              *p2 = i;
          }
      }
    

    }

    void createHuffmanTree(huffmanTreeNode ht, int m)
    {
    // 从叶子开始,构建霍夫曼树
    int p1, p2;
    for(int i=m; i<2
    m-1; i++)
    {
    // 在ht中找到两个最小且未有父节点的节点
    // 并将其用p1与p2标识出来
    find(ht, i-1, &p1, &p2);

          ht[p1].parent = i;
          ht[p2].parent = i;
    
          ht[i].weight = ht[*p1].weight + ht[*p2].weight;
          ht[i].lchild = *p1;
          ht[i].rchild = *p2;
      }
    

    }

至此,一棵Huffman树ht就创建完毕了,有了ht之后,可以设计一个函数来展示从外部命令行输入的字串对应的编码:

2.6 Huffman码表

由上述1.2小节知道,如果有了一棵Huffman树,那么叶子节点的编码就是从叶子到根的路径的翻转,比如下图:

上图中,如果约定左子树为0,右子树为1,那么叶子节点3到根的路径就是01,那么最终叶子节点3的编码就是10。

下述代码,实现输出ht中第i个节点的Huffman编码值:

void huffmanCode(huffmanTreeNode *ht, int i)
{
    char hcode[20];
    int k;
    for(k=0; ht[i].parent != 0; k++)
    {
        if(ht[ht[i].parent].lchild == i)
            hcode[k] = '0';
        else if(ht[ht[i].parent].rchild == i)
            hcode[k] = '1';
        
        i = ht[i].parent;
    }

    for(int m=k-1; m>=0; m--)
        printf("%c", hcode[m]);
    printf("\n");
}

将以上各个代码模块整合起来,可以得到Huffman编码的Demo程序:

int main(int argc, char const *argv[])
{
    int m;
    huffmanTreeNode *ht;

    // 初始化一棵霍夫曼树,将已统计的频次作为叶子
    // 放入树的前m项中:[3, 5, 1, 0, 0, ... , 0]
    initHuffmanTree(argv[1], &ht, &m);

    // 从叶子开始,构建霍夫曼树
    createHuffmanTree(ht, m);

    // 根据已构建的霍夫曼树,生成霍夫曼编码表
    printf("霍夫曼编码表:\n");
    for(int i=0; i<m; i++)
    {
        printf("%c:",  ht[i].ch);
        huffmanCode(ht, i);
    }

    return 0;
}

执行效果:

gec@ubuntu:~$ ./huffmanCode ABABBCBBA
霍夫曼编码表:
A:01
B:1
C:00
相关推荐
数据小小爬虫19 分钟前
利用Java爬虫获取义乌购店铺所有商品列表:技术探索与实践
java·开发语言·爬虫
Dong雨32 分钟前
Java的Stream流和Option类
java·新特性
!!!52534 分钟前
maven的生命周期
java·数据库·maven
大草原的小灰灰1 小时前
C++ STL之容器介绍(vector、list、set、map)
数据结构·c++·算法
Hello Dam1 小时前
基于 FastExcel 与消息队列高效生成及导入机构用户数据
java·数据库·spring boot·excel·easyexcel·fastexcel
ShyTan1 小时前
java项目启动时,执行某方法
java·开发语言
new一个对象_1 小时前
poi处理多选框进行勾选操作下载word以及多word文件压缩
java·word
小刘|1 小时前
数据结构的插入与删除
java·数据结构·算法
Clockwiseee2 小时前
JAVA多线程学习
java·开发语言·学习
w(゚Д゚)w吓洗宝宝了2 小时前
List详解 - 双向链表的操作
数据结构·c++·链表·list