夜深人静写算法(十五)- 霍夫曼编码

一、概述

1、算法简述

  • 众所周知,计算机中数据的存储和传输的最小单位是字节(byte),一个ASCII 码占用 1 个字节, 每个字节为 8 个比特位(Bit);例如,字符 ‘e’ 的二进制表示为 01100101;
  • 进程间通信传输字节流的过程中,为了节省带宽,往往会对传输的数据进行压缩。
  • 压缩算法有很多,今天介绍一种比较好理解的贪心算法 - 霍夫曼编码;
  • 霍夫曼编码的本质就是对每个出现过的 ASCII 字符,通过一个压缩字典,映射成另一个字符,映射后的字符是二进制比特串:001、0101、00 等等;
  • 解压缩就是这个过程的逆过程;

2、引例

源字节流

  • 首先,一个字符串 “HelloWorld”,在没有进行压缩的情况下采用 ASCII 编码,占用字节总数为 10,即 10 * 8 = 80 个比特位。

压缩字典

  • 然后,通过霍夫曼算法生成压缩字典如下(具体生成过程是霍夫曼树的构造过程,下文会详细讲述):
字符 压缩前编码(ASCII) 压缩后编码
d 01100100 001
e 01100101 010
l 01101100 10
o 01101111 111
r 01110010 011
H 01001000 1101
W 01010111 000

编码

  • 继而,遍历源字节流,对对应的字符进行编码替换,得到新的比特流;
H e l l o W o r l d
1101 010 10 10 111 000 111 011 10 001

规范化

  • 最后,将比特流再转换成字节流,由于编码后比特流长度不一定是8的倍数,所以最后的 XXXX 是补足位(补足位具体的值下文会接着探讨);
第1字节 第2字节 第3字节 第4字节
11010101 01011100 01110111 0001XXXX
  • 观察一下就会发现,每个压缩后的编码长度都是小于8的,所以压缩后的总位数一定是会小于压缩前的;压缩前 80 个比特位,压缩后 32 个比特位,压缩率为 40%;

解码

  • 解码过程是霍夫曼树的路径查找过程,了解霍夫曼树的构造过程,这个解码过程就一目了然了,下文也会详细介绍;

二、概念

1、变长编码

  • 每个 ASCII 字符占用字节数都为1,所以它是一种定长编码;区别于定长编码,霍夫曼编码是一种变长编码,即每个 ASCII 码进行编码映射后的二进制比特串的长度不相等;它是根据字符出现的概率来构造平均长度最短的编码,换言之,如果一个字符在文档中出现的次数多,那么它的映射后的二进制比特串就应该相对较短;反正,如果出现的次数少,那么它的映射后的二进制比特串就应该相对较长;

2、前缀编码

  • 前缀编码的含义是:编码集合中,任意两个编码 A 和 B,A 不能是 B 的前缀;
  • 这是为了保证在传输过程中,不用增加分隔符来区分两个编码值;
  • 霍夫曼编码是一种前缀编码,为什么呢?下文继续讲;

3、比特串

  • 简单认为就是数组,数组的最小单位是二进制比特位,也就是只有两种值:0或1;

4、压缩率

  • 压缩率 = 压缩后字节数 / 压缩前字节数;
  • 压缩率的值越小越好;
  • 压缩率取决于字符集合,字符集合越大,压缩率越大(压缩率越小越好),256个字符都有的话,等于不压缩,因为基本所有的编码后的字符长度都是8,和原字符一致,甚至有可能更高;
  • 如果压缩字符里面存在中文,不适合采用 霍夫曼压缩 (还是基于字符集合,中文会占用负数的 ASCII 码);
  • 了解算法以后再来看压缩率,会更加直观;
  • 如果所有字符都平均分布,则字符个数和压缩率的实测结果如下;
字符个数 压缩率(越低越好)
1 12.50%
2 18.77%
4 28.14%
8 39.08%
16 50.80%
32 62.91%
64 75.21%
128 87.61%
256 100.00%

三、算法详解

1、算法要求

  • 1、编码后的码值是二进制的比特串;
  • 2、编码后的任意两个比特串 A 和 B, A 不能是 B 的前缀,这是为了保证在传输过程中,不用增加分隔符来区分两个编码值;
  • 3、出现频率高的字符,编码后的比特串相对较短;出现频率低的字符,编码后的比特串相对较长;
  • 4、编码完要保证编码总长度最小;

2、算法简述

  • 1、统计字节流中出现的字符次数(频率);
  • 2、按照字符频率放入一个小顶优先队列中(小顶堆);
  • 3、如果优先队列中只剩一个结点,则这个结点为霍夫曼树的根结点;否则,取出频率最小和次小的两个,进行合并,产生一个新的结点再塞回优先队列中;其中,这个新的结点的左右子树分别是频率最小和次小的那两个结点,新的结点的频率值为两个子树频率值的和;
  • 4、重复3的过程,构造出来的树就是霍夫曼树;

压缩前的串

    HelloWorld

频次统计

字符 压缩前编码 出现频次
d 01100100 1
e 01100101 1
l 01101100 3
o 01101111 2
r 01110010 1
H 01001000 1
W 01010111 1
  • 启发:出现频次多的字符,编码后的比特串应该尽量短;

霍夫曼树构造

0
1
0
0
0
1
1
0
1
1
0
0
1
1
3
4
6
10
l
2
W
d
2
e
r
1
......
H
o
  • 观察可得:
    1. 叶子结点为每个未编码的 ASCII 字符,非叶子结点上的数字代表的是该结点为根的子树下所有字符的频率和;
    1. 树上任何一个非叶子结点一定有左右两棵子树(这是由构造算法决定的);
    1. 左子树的边权为0,右子树的边权为1;
    1. 根结点到叶子结点路径上的边权集合就是对应字符的编码后的比特串,比如字符 r 编码后的比特串为 011;
    1. 图中的 … 代表其他字符(总共 256 个字符集合),这棵树深度还很深,因为其它字符没有出现,所以频率都为0;

霍夫曼编码

  • 根结点到叶子结点的树边上的路径,就是对应字符的编码值,也正是上文提到的压缩字典;
字符 压缩前编码 出现频次 压缩后编码
d 01100100 1 001
e 01100101 1 010
l 01101100 3 10
o 01101111 2 111
r 01110010 1 011
H 01001000 1 1101
W 01010111 1 000
其它 - 0 -

霍夫曼压缩

  • 然后就比较好理解了,通过映射关系将每个字符替换为二进制比特串;由于计算机中存储和传输的基本单位是字节,所以替换过程是通过位运算来完成的,具体实现方式下文会详细介绍;
H e l l o W o r l d
1101 010 10 10 111 000 111 011 10 001
第1字节 第2字节 第3字节 第4字节
11010101 01011100 01110111 0001XXXX

不可达字符

  • 然后来分析下上文提到的 XXXX,也就是最后一个字节的补齐字符;
  • XXXX 必定是不会出现在压缩后编码集合中的字符,仔细想一下,编码集合256个字符,霍夫曼树中深度最深的那个叶子结点,它的编码长度一定是超过 8 的(想想为什么?),所以只要取这个比特串的高 8 位作为不可达字符即可;
  • 根本原因就是 前缀编码,只要是一个编码的前缀,必定不在编码集合中,它的前缀可以放心用来做 不可达字符;

霍夫曼解压缩

  • 1、准备一个指向霍夫曼树根结点的指针 searchNode;

  • 2、遍历被压缩串的每个比特位,如果是 0 ,则将 searchNode 指向它的左子树;如果是1,则将 searchNode 指向它的右子树;判断3;

  • 3、当 searchNode 为叶子结点,则说明这是一个完整的字符,提取出结点上的字符(ASCII 码值)写入到解压缩缓冲区,searchNode 继续指向 霍夫曼树的根结点,重复步骤2;

  • 如图,待解压串 110101010…,通过下面的霍夫曼树从根结点一直往下找 1101 后遇到叶子结点,所以解出来的第一个字符为 ‘H’;然后再回到根结点,周而复始,直到整个待解压串遍历完毕就获得了完整的源字节流;

1
1
0
1
3
4
6
10
l
2
W
d
2
e
r
1
......
H
o

四、数据结构和接口设计

1、压缩结构

typedef struct CompressdData
{
	char* compress_result;                 
	unsigned int capacity;
	unsigned int size;
	unsigned char _cache_bit;
 	unsigned char _cache_bit_index;
} CompressdData;
  • i. compress_result 用于存储压缩或者解压缩的字节流,支持动态扩展;

  • ii. capacity 代表了 compress_result 实际在堆上的空间;

  • iii. size 代表压缩、或者解压缩的字节流的实际大小;

  • iv. _cache_bit 表示某次编码(压缩)过程中,没有写入 compress_result 的剩余部分(高位);

  • v. _cache_bit_index 代表 _cache_bit 的位数;

  • 例如,图中 11010101 组成一个字节,这时候 _cache_bit = 010, _cache_bit_index = 3;

H e l l
1101 010 10 10
  • 当加入一个字符o以后,_cache_bit = 010111,_cache_bit_index = 6;
H e l l o
1101 010 10 10 111

2、编码字典

struct huffman_encoded_data
{
	unsigned long long value;
	unsigned char length;
};
  • 编码字典 huffman_encoded_data[256] 存储了每个字符编码后的值,以及长度;
    比如 ASCII 值为 200 的字符,编码以后变成 17,二进制表示为 10001;
    那么 value 的值就是 17, length 的值为 5
  • 令当前需要编码的字符 i;
  • 编码后的值 V = huffman_encoded_data[i].value ;
  • 编码后的值 V 的长度 L = huffman_encoded_data[i].length;
  • 则需要写入 compress_result 的数据需要按照下标进行;
长度范围 写入数据类型
0< L <=8 L个Bit
8< L <=16 L-8个Bit、1个byte
16< L <=24 L-16个Bit、1个short
24< L <=32 L-24个Bit、1个char、1个short
32< L <=40 L-32个Bit、1个int
40< L <=48 L-40个Bit、1个char,1个int
48< L <=56 L-48个Bit、1个short、1个int
56< L <=64 L-56个Bit、1个char、1个short,1个int

3、编码写入

i. 写入完整 Bit

void writeChar_Internal(unsigned char value)
{
	if (size + 1 > capacity)
	{
		expand(capacity);
	}
	*(compress_result + size) = value;
	size += 1;
}
  • writeChar_Internal 这个接口表示写入完整的一个字节 value;
  • 并且在写入的过程中判断容量,超过容量就进行倍增;
  • expand 函数就是在堆上分配 2*capacity 的内存,然后将 compress_result 原来的数据拷贝到新的内存,再删掉原来的内存;

ii. 写入非完整 Bit

void writeBit(unsigned char value, int fromBit, int bitSize)
{
	int leftBitIndex = 8 - _cache_bit_index;
	if (bitSize < leftBitIndex)
	{
		_cache_bit |= (((unsigned char)((value << fromBit) & _MINI_BIT_MASK[bitSize])) >> _cache_bit_index);
		_cache_bit_index += bitSize;
	}
	else
	{
		_cache_bit |= (((unsigned char)(value << fromBit)) >> _cache_bit_index);

		this->writeChar_Internal(_cache_bit);
		fromBit += leftBitIndex;
		bitSize -= leftBitIndex;
		_cache_bit_index = bitSize;
		_cache_bit = (((unsigned char)(value << fromBit)) & _MINI_BIT_MASK[bitSize]);
	}
}
  • writeBit 表示写入的比特位不一定是8位,而是 bitSize 位;
  • _MINI_BIT_MASK 是个二进制掩码,用来作 位与(&),去掉低位的0,如下;
下标 二进制值 十进制值
0 00000000 0
1 10000000 128
2 11000000 192
3 11100000 224
4 11110000 240
5 11111000 248
6 11111100 252
7 11111110 254
8 11111111 255
  • leftBitIndex 代表本次写入还可以写多少个Bit,这里的 left 是剩余的意思,不是,如果没有理解,很容易理解成,那后面就更难理解了;
直接写入
  • 需要写入的位数 bitSize 小于 剩余位数 leftBitIndex,则直接写入 _cache_bit,并且更新 _cache_bit_index;
  • 为了更加容易理解,举个例子,某种情况下的变量值如下:
变量类型 变量名 含义
成员 _cache_bit_index 2 当前字节的2个位被占用了
成员 _cache_bit 11000000 当前字节的2个被占用的位都是1
传参 bitSize 3 写入的字节只需要3个位
传参 value 00000101 需要写入的字节的三个位是 101
传参 fromBit 5 8 减去写入的位数
  • 那么我们的目的就是要把 value 的3个位‘101’放到_cache_bit的2个‘11’后面;
操作 功能
a=(value << fromBit) 10100000 将101提到高位
b = a & _MINI_BIT_MASK[bitSize] 10100000 剔除101以外的所有位
c = b >> _cache_bit_index 00101000 空出两个位给_cache_bit
_cache_bit = _cache_bit 位或 c 11101000 11和101合体
超出字节
  • 需要写入的位数 bitSize 大于等于 剩余位数 leftBitIndex,则必须先写一个字节,然后再缓存 _cache_bit;
  • 还是举例说明吧;
变量类型 变量名 含义
成员 _cache_bit_index 2 当前字节的2个位被占用了
成员 _cache_bit 11000000 当前字节的2个被占用的位都是1
传参 bitSize 7 写入的字节需要7个位
传参 value 00100101 需要写入的字节的7个位是 0100101
传参 fromBit 1 8 减去写入的位数
  • 操作完的结果是 11010010 写入压缩数据,剩下的 10000000 写入_cache_bit;
操作 功能
a=(value << fromBit) 01001010 将0100101提到高位
b = a>>_cache_bit_index 00010010 空出两个位给_cache_bit
_cache_bit = _cache_bit 位或 b 11010010 11和00010010合体
  • 写入前半个字节:writeChar_Internal(_cache_bit);
操作
fromBit = fromBit + leftBitIndex 7
bitSize = bitSize - leftBitIndex 1
_cache_bit_index = bitSize 1
a = (value << fromBit) 001001010000000
_cache_bit = a & _MINI_BIT_MASK[bitSize] 10000000

iii. 写入 byte

  • 写入 byte,其实就是写入8个位,当然,举一反三,写 short 和 int 也雷同;
void writeChar(unsigned char value)
{
	writeBit(value, 0, 8);
}

iv. 写入 short

void writeShort(unsigned short value)
{
	unsigned char hValue = (value >> 8) & 0xff;
	unsigned char lValue = (value) & 0xff;
	
	writeChar(hValue);
	writeChar(lValue);
}

iv. 写入 int

void writeInt(unsigned int value)
{
	unsigned char value1 = (value >> 24) & 0xff;
	unsigned char value2 = (value >> 16) & 0xff;
	unsigned char value3 = (value >> 8) & 0xff;
	unsigned char value4 = value & 0xff;

	writeChar(value1);
	writeChar(value2);
	writeChar(value3);
	writeChar(value4);
}

猜你喜欢

转载自blog.csdn.net/WhereIsHeroFrom/article/details/106435229