数据结构树论之赫夫曼树
本文,我们来讨论一个用于压缩、加密等场景的赫夫曼编码,而赫夫曼编码通过赫夫曼树生成出来。
假如有一个字符串“abcdeaaaaa”,请问如何用最小的存储空间来保存?
在不压缩的情况下, java中char类型用两个字节表示,那么上面这个串的话得用20个字节才能装下。
我们知道,计算机底层都是二进制的,对于上面这个字符串,我们发现不同的字符为5个,因此可以使用3个位来编码,3个位的编码空间为8,从而节省存储空间:
比如a:000, b:001,c:010, d:011, e:100
那么“abcdeaaaaa” 编码为 000 001 010 011 100 000 000 000 000 000 ,一共30个位就可以了,比起原来的20个字节=160位的话,压缩了5倍有余。在解压缩时,通过编码表,也非常容易完成解压缩过程,即每次读取3个位,查找编码表,反向得出字符。
仔细观察上面的这种编码,我们发现,字符a出现了很多次,如果将a的编码搞短一点,是不是最终编码会更加短了呢,我们来试上一试:
比如a就一位来编码,a:0,那么为了区别a和其他字符,因此其他字符不能用0开头进行编码,用1开始对其他几个字符编码为 b:100, c:101,d:110, e:111。
通过新的编码对“abcdeaaaaa” 编码为 0 100 101 110 111 0 0 0 0 0 一共18个位就可以解决问题了。在解码时,读取一位后去码表中找,找到了得出字符;找不到则往后拼一位再找。比如示例中,首先读取0,码表中找到0的解码字符为a;继续读取1,码表中没有1对应的编码,继续读一位出来,变为10,再去码表找,还是找不到,再读取一位出来,变为100,此时去码表中找字符为b;依次这样找下去,即可完成解码。
前面编码的时候讲到,a的编码为0后,其他的编码都不能以0开头,这是因为在解码的流程中,不能引起歧义。也就是说,每一个编码都不能是其他所有编码的前缀,否则解码就会出错,这种编码就叫赫夫曼编码,也叫前缀编码。
现在,我们知道了赫夫曼编码是怎么回事了,但是怎么把它编出来呢?不可能像上面示例一样,靠手工吧。这就回到今天的主题,赫夫曼树了。
从根到叶子节点的路径长度*叶子节点的权重,称之为带权路径长度。
那么对于图示中的三种树的组织方式,所有叶子节点的的带权路径长度之和为:
WPL(a):7*2+5*2+2*2+4*2=36()
WPL(b):7*3+5*3+2*1+4*2=46()
WPL©:7*1+5*2+2*3+4*3=35()
带权路径长度最小的树,就叫最优二叉树, 也就是赫夫曼树了。
因为是二叉树,如果将左孩子的路径编码为0,将右孩子的路径编码为1,那么从根到叶子节点的路径形成的编码就是赫夫曼编码了。
比如图示中c树的编码为:a:0, b:10, c:110, d:111。
到这儿,你可能有个疑问,为啥赫夫曼树(也就是最优二叉树)出来的编码,就满足前缀编码,没有一个编码是另外一个的前缀?这个想要通过数学来证明,我也不会,不过如果我们明白了,赫夫曼树是怎么构建的话,也许你会有自己的想法。
现在来看一看如何构建赫夫曼树,通过赫夫曼树的定义,其实我们明显可以得出,权重越大应该离根节点约近,权重越小应该离根节点越远。构建的思路还是比较巧妙的,倒着从叶子到根进行构建:
- 从节点列表中,选择权重最小的两个节点,组成一个子树,子树的根的权重为这两个节点的权重之和
- 从节点列表中,移除第一步选择的两个节点,同时加入新的子树的根节点
- 重复1、2,直到节点列表中只有一个节点为止(剩下的最后的节点就是整棵树的根节点)
我们可以通过画个图来理解下这个过程
首先有ABCDEFG这些节点
- 选择权重最小的两个节点,那么就是G和A,构建成一个子树,子树根权重为3(用x:3来表示)
- 新的节点列表去除G和A,并加入x:3节点后,剩下BCDEF以及x:3。
- 选择权重最小的两个节点,那么就是x:3和B,构建成一个子树,子树根权重为7(用x:7来表示)
- 节点列表去除x:3和B,并加入x:7节点后,剩下CDEF以及x:7
- 重复这样执行下去,直到列表中只有一个节点为止。
以上就是赫夫曼树的构建过程,理解了这个过程,用代码实现起来就不算复杂了:
/**
* 赫夫曼树
*
* 通过编码映射的方式,可用于对文本数据压缩、加密等场景应用,应用比较广泛。
*
* 赫夫曼树又叫最优二叉树,它也是这一种二叉树,它有一个特性就是
* 带权路径总和最小:
* 1. 叶子节点有权重
* 2. 根到叶子节点的路径*叶子节点的权重叫带权路径
* 3. 所有根到叶子节点的带权路径最小。
*
* 当我们对一个字符串进行压缩,首先想到的就是编码映射,比如字符串"abcdea",
* 原本占用的空间是12个字节(假如一个字符占用一个字节),也就是12*8=96个byte,
* 但如果我们通过3个byte的二级制对字符进行编码,
* a:000
* b:001
* c:010
* d:011
* e:100
* 那么对"abcdea"就可以通过000001010011100000来表示,缩短到18个byte,大大减少存储空间。
*
* 更进一步,假如字符串""abcdeaaaaa",我们发现a字符出现了若干次,如果使用上面的定长编码,还是存在压缩空间的。
* 这儿我们可以使用变长编码来实现,出现次数越多,用越短的编码,比如
* a:0
* b:101
* c:110
* d:101
* e:111
* 通过这种方式,bcde长度不变,但字符a的压缩空间进一步变低了。
* 需要注意的是,这种变长的编码,不能出现某个编码是另外一个编码的前缀,否则在解码的时候会产生歧义。因此也叫前缀编码。
*
* 这种编码也叫赫夫曼编码,那么如何来自动生成这样的编码表呢,可通过赫夫曼树来生成。
*
*/
public class HuffmanTree {
HuffmanNode root; //根节点
List<HuffmanNode> leafs; //所有叶子节点
public HuffmanTree() {
leafs = new ArrayList<>();
}
/**
* 通过字符权重表构建赫夫曼树
*
* 赫夫曼树的构建过程
* 1. 所有的节点通过权重排序
* 2. 取最小权重的两个节点为左右孩子,生成一个根,组成一个子树,子树的根的权重为两个孩子的权重之和
* 3. 新根加入权重排序
* 4. 重复2-3的步骤,直到剩下一个节点为止,这个节点就是最后的根节点
*
* @param charToWeightMap
* @return
*/
public static HuffmanTree buildTree(Map<Character, Integer> charToWeightMap){
HuffmanTree tree = new HuffmanTree();
//所有的叶子节点生成
charToWeightMap.entrySet().forEach((entry) -