HuffmanTree
定义
哈弗曼树是一种优化的二叉树,称为最优二叉树,是加权路径长度最小的二叉树。所谓权值在这里指的是节点中的数据。本文的哈弗曼树用数组提供数据,例如:arr[]={1,2,3,4,5,6}创建的哈弗曼树见下图:
图中蓝色的节点值是数组arr提供,红色节点值是两个孩子节点值相加得到。
名词解释
节点的权值:权就相当于重要程度,通过一个具体的数字来表示
路径:在树中从一个节点到另一个节点的分支。
路径长度:一条路径上的分支数量。
树的路径长度:从树的根节点到每个节点的路径长度之和。
树的带权路径长度:树中各个叶子节点的路径长度*该叶子节点的权的和。
性质
1、根节点值是所有的叶子节点值相加得到
2、创建哈弗曼树的节点值全部在叶子节点上,而且只在叶子节点出现
3、哈夫曼树的加权路径长度是最小的,这个是因为权值大的节点离根节点近,权值小的节点离根节点远。
创建HuffmanTree
1、取出数组中最小的两个值,组成最小的一个分支,用两者的和作为两者的父亲节点并在原数组中替换两者。
2、依次循环直到数组所有符合要求的值被使用。
使用示例:arr[] = {1,2,3,4,5,6};
节点定义
哈弗曼树节点采用二叉链结构,包含关键值_val;指向左节点的指针_left;指向右节点的指针_right。
template <class T>
struct HuffmanTreeNode{
T _val;
HuffmanTreeNode<T>* _left;
HuffmanTreeNode<T>* _right;
HuffmanTreeNode(const T& val)
:_val(val)
,_left(NULL)
,_right(NULL)
{}
};
构造函数
构造函数提供了两个,一个是空的构造函数,防止因为没有默认构造函数产生错误。另一个是带参的构造函数。
参数解释
arr是一个数组名,用来提供创建哈弗曼树的数值来源;size是数组的大小,这个大小在构建最小堆的时候会用到;invalid指的是非法值,如果数组中有一些特定的值,不希望插入到哈弗曼树中,用这个值来判断。一般来说,所有的非法值是相同的。
构建的思想
构建哈夫曼树的方式在上面已经提到,但是如何用代码来实现呢?这里可以借用最小堆来实现。如果不太熟悉最小堆可以看这个传送门。每次取最小堆的堆顶元素第一次是作为左节点,第二次作为右节点。同时将两者出堆,接着用两者的关键值创建一个父亲节点入堆。利用循环就可以创建出来哈弗曼树了。至于为什么要用最小堆,根据前述的方法,我们需要每次取数组中的最小的两个值来构建新的节点,用最小堆的话可以分两次取堆顶数据,然后让创建的新的节点再入堆。最小堆会自动调整,不需要人为参与修改。而且堆的存储机制就是动态增长的数组,天然符合这里利用数组传参的要求。其中这里传入给最小堆的数据类型是节点指针,为什么是节点指针很好理解。如果是节点本身,那么我们就没有办法将之前创建的内容链接在一起,见下图:
为什么不用节点的引用呢?引用的话不太好,如果是引用,可能出现节点结构太大的情况,这样开销有点大,不划算。这里还需要解释一下,需要自己创建一个比较仿函数,因为Node是一个自定义类型,在创建最小堆的时候是没有办法知道比较方式的,仿函数如下:(其中为了书写方便,在HuffmanTree类中定义了节点Node)
typedef HuffmanTreeNode<T> Node;
struct NodeCompare{
//const Node* left, const Node* right
//wrong
bool operator()(Node* left, Node* right){
return left->_val < right->_val;
}
};
这里需要注意的是,仿函数的参数不能够定义为const对象。需要修改。
构建的代码如下:注意循环结束的条件是最小堆中只有一个元素,因为此时已经构建完成。
HuffmanTree()
:_root(NULL)
{}
HuffmanTree(T* arr, size_t size, const T& invalid){
//注意这里使用的是Node*
Heap<Node*, NodeCompare> minheap;
for(size_t i = 0; i < size; ++i){
if(arr[i] != invalid){
//Node*
minheap.Push(new Node(arr[i]));
}
}
//minheap.Size()>1
while(minheap.Size() > 1){
Node* left = minheap.Top();
minheap.Pop();
Node* right = minheap.Top();
minheap.Pop();
Node* parent = new Node(left->_val + right->_val);
parent->_left = left;
parent->_right = right;
minheap.Push(parent);
}
_root = minheap.Top();
}
析构函数
析构函数通过递归实现,内部调用Destroy实现。
//destructor
~HuffmanTree(){
Destroy(_root);
_root = NULL;
}
void Destroy(Node* root){
if(root){
Destroy(root->_left);
Destroy(root->_right);
delete root;
}
}
获取根节点函数
//GetRoot
Node* GetRoot(){
return _root;
}
补充
因为没有实现拷贝构造和赋值运算符的重载,所以将他们都声明为私有成员变量。防止错误使用导致未知错误
文件压缩及解压缩
有了哈弗曼树,我们就可以利用哈弗曼树创建哈夫曼编码,通过哈夫曼编码可以实现文件压缩的功能。
哈夫曼编码
所谓哈夫曼编码是在哈弗曼树的基础上,定义向左的路径为零,向右的路径为一。这样定义以后,每一个叶子节点都会有一个唯一的编码值。而重要的是,在哈弗曼树中,所有叶子节点就是我们用来构建哈弗曼树的原值。使用示例:arr[] = {1,2,3,4,5,6};如下图:
需要注意的是,哈夫曼编码不是唯一的,就算是同样的原值,因为插入的顺序不一样会导致不同的编码,但是毋庸置疑的是,每一个叶子节点一定会有唯一的编码值。
压缩思路
1、扫描文件内容,统计文件中字符出现的次数。
2、利用文件出现次数构建哈弗曼树。
3、创建哈夫曼编码。
4、将创建哈弗曼树的字符及其对应出现次数写入压缩文件中,用于解压使用。
5、将哈夫曼编码替换原子符写入压缩文件。
压缩原理
举例:文件test.txt中内容:aaaaaabbbbbccccdddeef
其中字符出现的次数统计:a->6;b->5;c->4;d->3;e->2;f->1。利用次数构建的哈弗曼树和编码如下:
图中黑色字体是出现的字符,蓝色数字是对应出现的次数,红色字体是构建时两字符出现次数相加之和。为什么通过哈夫曼编码可以压缩文件?我们通过编码将原字符替换,只有当编码的长度超过一个字节的时候(也就是八个比特位)才会比替换之前的字符大小要大。但是出现次数多的字符的编码都十分的短,出现次数少的字符编码才会超过一个字节。这样抵消之下,肯定是相比于压缩之前文件大小要小。上例子中,写入压缩文件的压缩编码如下:
压缩与解压代码分析
结构体分析
结构体CharInfo是用来存储字符、字符出现的次数、字符的编码三者的结构类型。其中将_count定义为long long类型,防止字符出现的次数超过了整型表示范围。_code用string类型;通过这个结构体可以将三者紧紧地联系在一起。其中对该结构体重载了!=、+、< 这三个运算符。在代码中都会用到。
typedef long long LongType;
struct CharInfo{
char _ch;
LongType _count;
string _code;
//for invalid
bool