哈夫曼树(HuffmanTree)
给定N个权值作为N个叶子结点,构造一棵二叉树,若该树的带权路径长度达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree)。哈夫曼树是带权路径长度最短的树,权值较大的结点离根较近。
简介
在计算机数据处理中,哈夫曼编码使用变长编码表对源符号(如文件中的一个字母)进行编码,其中变长编码表是通过一种评估来源符号出现机率的方法得到的,出现机率高的字母使用较短的编码,反之出现机率低的则使用较长的编码,这便使编码之后的字符串的平均长度、期望值降低,从而达到无损压缩数据的目的。
例如,在英文中,e的出现机率最高,而z的出现概率则最低。当利用哈夫曼编码对一篇英文进行压缩时,e极有可能用一个比特来表示,而z则可能花去25个比特(不是26)。用普通的表示方法时,每个英文字母均占用一个字节,即8个比特。二者相比,e使用了一般编码的1/8的长度,z则使用了3倍多。倘若我们能实现对于英文中各个字母出现概率的较准确的估算,就可以大幅度提高无损压缩的比例。
哈夫曼树又称最优二叉树,是一种带权路径长度最短的二叉树。所谓树的带权路径长度,就是树中所有的叶结点的权值乘上其到根结点的路径长度(若根结点为0层,叶结点到根结点的路径长度为叶结点的层数)。树的路径长度是从树根到每一结点的路径长度之和,记为WPL=(W1L1+W2L2+W3L3+…+WnLn),N个权值Wi(i=1,2,…n)构成一棵有N个叶结点的二叉树,相应的叶结点的路径长度为Li(i=1,2,…n)。可以证明哈夫曼树的WPL是最小的。
术语
哈夫曼树又称为最优树.
- 路径和路径长度
在一棵树中,从一个结点往下可以达到的孩子或孙子结点之间的通路,称为路径。通路中分支的数目称为路径长度。若规定根结点的层数为1,则从根结点到第L层结点的路径长度为L-1。 - 结点的权及带权路径长度
若将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。结点的带权路径长度为:从根结点到该结点之间的路径长度与该结点的权的乘积。 - 树的带权路径长度
树的带权路径长度规定为所有叶子结点的带权路径长度之和,记为WPL。
构建哈夫曼树
- 创建一个包含所有字符的节点的优先队列(或最小堆),其中每个节点的权重是对应字符的频率。
- 从队列中选择两个具有最小权重的节点,并创建一个新节点作为它们的父节点。新节点的权重是这两个节点的权重之和。
- 将新节点插入队列。
- 重复步骤2和步骤3,直到队列中只剩下一个节点,这个节点就是哈夫曼树的根节点。
- 通过遍历哈夫曼树,给每个字符分配一个唯一的编码。通常,向左走表示添加一个"0",向右走表示添加一个"1"。
多叉哈夫曼树
哈夫曼树也可以是k叉的,只是在构造k叉哈夫曼树时需要先进行一些调整。构造哈夫曼树的思想是每次选k个权重最小的元素来合成一个新的元素,该元素权重为k个元素权重之和。但是当k大于2时,按照这个步骤做下去可能到最后剩下的元素少于k个。解决这个问题的办法是假设已经有了一棵哈夫曼树(且为一棵满k叉树),则可以计算出其叶节点数目为(k-1)nk+1,式子中的nk表示子节点数目为k的节点数目。于是对给定的n个权值构造k叉哈夫曼树时,可以先考虑增加一些权值为0的叶子节点,使得叶子节点总数为(k-1)nk+1这种形式,然后再按照哈夫曼树的方法进行构造即可。
实现
实现哈夫曼树的方式有很多种,可以使用优先队列(Priority Queue)简单达成这个过程,给与权重较低的符号较高的优先级(Priority),算法如下:
- 把n个终端节点加入优先队列,则n个节点都有一个优先权Pi,1 ≤ i ≤ n
- 如果队列内的节点数>1,则:
⑴从队列中移除两个最小的Pi节点,即连续做两次remove(min(Pi), Priority_Queue)
⑵产生一个新节点,此节点为(1)之移除节点之父节点,而此节点的权重值为(1)两节点之权重和
⑶把(2)产生之节点加入优先队列中 - 最后在优先队列里的点为树的根节点(root)
而此算法的时间复杂度(Time Complexity)为O(n log n);因为有n个终端节点,所以树总共有2n-1个节点,使用优先队列每个循环须O(log n)。
实现代码:(以cpp为例)
#include <iostream>
#include <queue>
#include <unordered_map>
using namespace std;
// 哈夫曼树节点的定义
struct Node {
char data;
int frequency;
Node* left;
Node* right;
Node(char data, int frequency) : data(data), frequency(frequency), left(nullptr), right(nullptr) {}
// 用于 priority_queue 中比较节点的大小
bool operator>(const Node& other) const {
return frequency > other.frequency;
}
};
// 构建哈夫曼树的函数
Node* buildHuffmanTree(const unordered_map<char, int>& frequencies) {
// 优先队列,用于存储节点,并按照频率从小到大排列
priority_queue<Node, vector<Node>, greater<Node>> pq;
// 将字符频率转换为节点,并加入优先队列
for (const auto& entry : frequencies) {
pq.push(Node(entry.first, entry.second));
}
// 构建哈夫曼树
while (pq.size() > 1) {
// 取出两个最小频率的节点
Node* left = new Node(pq.top().data, pq.top().frequency);
pq.pop();
Node* right = new Node(pq.top().data, pq.top().frequency);
pq.pop();
// 创建一个新节点作为它们的父节点,并将新节点加入优先队列
Node* internalNode = new Node('\0', left->frequency + right->frequency);
internalNode->left = left;
internalNode->right = right;
pq.push(*internalNode);
}
// 返回哈夫曼树的根节点
return new Node('\0', pq.top().frequency);
}
// 生成哈夫曼编码的递归辅助函数
void generateHuffmanCodes(Node* root, string currentCode, unordered_map<char, string>& codes) {
if (root) {
if (root->data != '\0') {
codes[root->data] = currentCode;
}
generateHuffmanCodes(root->left, currentCode + "0", codes);
generateHuffmanCodes(root->right, currentCode + "1", codes);
}
}
// 生成哈夫曼编码的函数
unordered_map<char, string> getHuffmanCodes(Node* root) {
unordered_map<char, string> codes;
generateHuffmanCodes(root, "", codes);
return codes;
}
int main() {
// 示例字符频率字典
unordered_map<char, int> frequencies = {{'a', 5}, {'b', 9}, {'c', 12}, {'d', 13}, {'e', 16}, {'f', 45}};
// 构建哈夫曼树
Node* root = buildHuffmanTree(frequencies);
// 生成哈夫曼编码
unordered_map<char, string> codes = getHuffmanCodes(root);
// 打印字符和对应的哈夫曼编码
for (const auto& entry : codes) {
cout << entry.first << ": " << entry.second << endl;
}
return 0;
}
此外,有一个更快的方式使时间复杂度降至线性时间(Linear Time)O(n),就是使用两个队列(Queue)创建哈夫曼树。第一个队列用来存储n个符号(即n个终端节点)的权重,第二个队列用来存储两两权重的合(即非终端节点)。此法可保证第二个队列的前端(Front)权重永远都是最小值,且方法如下:
4. 把n个终端节点加入第一个队列(依照权重大小排列,最小在前端)
5. 如果队列内的节点数>1,则:
⑴从队列前端移除两个最低权重的节点
⑵将(1)中移除的两个节点权重相加合成一个新节点
⑶加入第二个队列
6. 最后在第一个队列的节点为根节点
虽然使用此方法比使用优先队列的时间复杂度还低,但是注意此法的第1项,节点必须依照权重大小加入队列中,如果节点加入顺序不按大小,则需要经过排序,则至少花了O(n log n)的时间复杂度计算。
但是在不同的状况考量下,时间复杂度并非是最重要的,如果我们考虑英文字母的出现频率,变量n就是英文字母的26个字母,则使用哪一种算法时间复杂度都不会影响很大,因为n不是一笔庞大的数字。
实现代码:(以cpp为例)
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
// 哈夫曼树节点的定义
struct Node {
char data;
int frequency;
Node* left;
Node* right;
Node(char data, int frequency) : data(data), frequency(frequency), left(nullptr), right(nullptr) {}
// 用于 priority_queue 中比较节点的大小
bool operator>(const Node& other) const {
return frequency > other.frequency;
}
};
// 构建哈夫曼树的函数
Node* buildHuffmanTree(const vector<int>& weights) {
priority_queue<Node*, vector<Node*>, greater<Node*>> minHeap; // 存储最小权重的节点
for (int i = 0; i < weights.size(); ++i) {
minHeap.push(new Node(char('a' + i), weights[i]));
}
while (minHeap.size() > 1) {
// 取出两个最小权重的节点
Node* left = minHeap.top();
minHeap.pop();
Node* right = minHeap.top();
minHeap.pop();
// 创建一个新节点作为它们的父节点,并将新节点加入最小堆
Node* internalNode = new Node('\0', left->frequency + right->frequency);
internalNode->left = left;
internalNode->right = right;
minHeap.push(internalNode);
}
// 返回哈夫曼树的根节点
return minHeap.top();
}
// 生成哈夫曼编码的递归辅助函数
void generateHuffmanCodes(Node* root, string currentCode, unordered_map<char, string>& codes) {
if (root) {
if (root->data != '\0') {
codes[root->data] = currentCode;
}
generateHuffmanCodes(root->left, currentCode + "0", codes);
generateHuffmanCodes(root->right, currentCode + "1", codes);
}
}
// 生成哈夫曼编码的函数
unordered_map<char, string> getHuffmanCodes(Node* root) {
unordered_map<char, string> codes;
generateHuffmanCodes(root, "", codes);
return codes;
}
int main() {
// 示例权重数组
vector<int> weights = {5, 9, 12, 13, 16, 45};
// 构建哈夫曼树
Node* root = buildHuffmanTree(weights);
// 生成哈夫曼编码
unordered_map<char, string> codes = getHuffmanCodes(root);
// 打印字符和对应的哈夫曼编码
for (const auto& entry : codes) {
cout << entry.first << ": " << entry.second << endl;
}
return 0;
}
综合题
【问题描述】在数据压缩问题中,需要将数据文件转换成由二进制字符0、1组成的二进制串,称之为编码,已知待压缩的数据中包含若干字母(A-Z),为获得更好的空间效率,请设计有效的用于数据压缩的二进制编码,使数据文件压缩后编码总长度最小,并输出这个最小长度值。
【输入形式】待压缩的数据(长度不大于100的大写字母)
【输出形式】编码的最小总长度值
【样例输入】ABACCDA
【样例输出】13
【样例说明】A编码0,B编码110,C编码10,D编码111,ABACCDA的编码为0110010101110
代码实现
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
const int MAX_CHAR = 26; // 大写字母的个数
// 定义节点结构
struct Node {
char data;
unsigned freq;
Node* left, *right;
Node(char data, unsigned freq) : data(data), freq(freq), left(nullptr), right(nullptr) {}
};
// 比较节点的频率
struct compare {
bool operator()(Node* left, Node* right) {
return (left->freq > right->freq);
}
};
//构建最小堆时用作比较函数
// 生成哈夫曼树
Node* buildHuffmanTree(const string& data) {
priority_queue<Node*, vector<Node*>, compare> minHeap;
// 统计字符频率
int freq[MAX_CHAR] = {0};
for (char c : data) {
freq[c - 'A']++;
}
// 创建节点并加入最小堆
for (int i = 0; i < MAX_CHAR; ++i) {
if (freq[i] > 0) {
minHeap.push(new Node('A' + i, freq[i]));
}
}
// 构建哈夫曼树
while (minHeap.size() > 1) {
Node* left = minHeap.top();
minHeap.pop();
Node* right = minHeap.top();
minHeap.pop();
Node* newNode = new Node('$', left->freq + right->freq);//'$'用作内部节点数据,没有实际意义
newNode->left = left;
newNode->right = right;
minHeap.push(newNode);
}
return minHeap.top();
}
// 计算哈夫曼编码长度
unsigned calculateHuffmanCodeLength(Node* root, unsigned depth = 0) {
if (!root)
return 0;
if (!root->left && !root->right)
return root->freq * depth;
//递归计算哈夫曼树中每个叶子节点的编码长度,即路径长度乘以叶子节点的频率。
return calculateHuffmanCodeLength(root->left, depth + 1) + calculateHuffmanCodeLength(root->right, depth + 1);
}
// 主函数
int main() {
string input;
cin >> input;
if (input.size() > 100) {
return 1;
}
// 生成哈夫曼树
Node* root = buildHuffmanTree(input);
// 计算最小编码长度
unsigned minLength = calculateHuffmanCodeLength(root);
cout << minLength << endl;
return 0;
}
上面代码使用了优先队列实现,使用数组统计频率,实现基于哈夫曼编码的数据压缩。