前言
哈夫曼編碼是一種貪心算法和二叉樹結合的字符編碼方式,具有廣泛的應用背景,最直觀的是文件壓縮。本文主要講述如何用哈夫曼編解碼實現文件的壓縮和解壓,並給出代碼實現。
哈夫曼編碼的概念
哈夫曼樹又稱作最優樹,是一種帶權路徑長度最短的樹,而通過哈夫曼樹構造出的編碼方式稱作哈夫曼編碼。
也就是說哈夫曼編碼是一個通過哈夫曼樹進行的一種編碼,一般情況下,以字符 “0” 與 “1” 表示。編碼的實現過程很簡單,只要實現哈夫曼樹,通過遍歷哈夫曼樹,這里我們從根節點開始向下遍歷,如果下個節點是左孩子,則在字符串后面追加 “0”,如果為其右孩子,則在字符串后追加 “1”。結束條件為當前節點為葉子節點,得到的字符串就是葉子節點對應的字符的編碼。
哈夫曼樹實現
根據貪心算法的思想實現,把字符出現頻率較多的字符用稍微短一點的編碼,而出現頻率較少的字符用稍微長一點的編碼。哈夫曼樹就是按照這種思想實現,下面將舉例分析創建哈夫曼樹的具體過程。下面表格的每一行分別對應字符及出現頻率,根據這些信息就能創建一棵哈夫曼樹。
字符 | 出現頻率 | 編碼 | 總二進制位數 |
a | 500 | 1 | 500 |
b | 250 | 01 | 500 |
c | 120 | 001 | 360 |
d | 60 | 0001 | 240 |
e | 30 | 00001 | 150 |
f | 20 | 00000 | 100 |
如下圖,將每個字符看作一個節點,將帶有頻率的字符全部放到優先隊列中,每次從隊列中取頻率最小的兩個節點 a 和 b(這里頻率最小的 a 作為左子樹),然后新建一個節點R,把節點設置為兩個節點的頻率之和,然后把這個新節點R作為節點A和B的父親節點。最后再把R放到優先隊列中。重復這個過程,直到隊列中只有一個元素,即為哈夫曼樹的根節點。
由上分析可得,哈夫曼編碼的需要的總二進制位數為 500 + 500 + 360 + 240 + 150 + 100 = 1850。上面的例子如果用等長的編碼對字符進行壓縮,實現起來更簡單,6 個字符必須要 3 位二進制位表示,解壓縮的時候每次從文本中讀取 3 位二進制碼就能翻譯成對應的字符,如 000,001,010,011,100,101 分別表示 a,b,c,d,e,f。則需要總的二進制位數為 (500 + 250 + 120 + 60 + 30 + 20)* 3 = 2940。對比非常明顯哈夫曼編碼需要的總二進制位數比等長編碼需要的要少很很多,這里的壓縮率為 1850 / 2940 = 62%。哈夫曼編碼的壓縮率通常在 20% ~90% 之間。
下面代碼是借助標准庫的優先隊列 std::priority_queque 實現哈夫曼樹的代碼簡單實現,構造函數需要接受 afMap 入參,huffmanCode 函數是對象的唯一對外方法,哈夫曼編碼的結果會寫在 codeMap 里面。這部分是創建哈夫曼樹的核心代碼,為方便調試,我還實現了打印二叉樹樹形結構的功能,這里就補貼代碼,有興趣的同學可以到文末給出的 github 倉庫中下載。
using uchar = unsigned char; struct Node { uchar c; int freq; Node *left; Node *right; Node(uchar _c, int f, Node *l = nullptr, Node *r = nullptr) : c(_c), freq(f), left(l), right(r) {} bool operator<(const Node &node) const { //重載,優先隊列的底層數據結構std::heap是最大堆 return freq > node.freq; } }; class huffTree { public: huffTree(const std::map<uchar, int>& afMap) { for (auto i : afMap) { Node n(i.first, i.second); q.push(n); } _makehuffTree(); } ~huffTree() { Node node = q.top(); _deleteTree(node.left); _deleteTree(node.right); } void huffmanCode(std::map<uchar, std::string>& codeMap) { Node node = q.top(); std::string prefix; _huffmanCode(&node, prefix, codeMap); } private: static bool _isLeaf(Node* n) { return n->left == nullptr && n->right == nullptr; } void _deleteTree(Node* n) { if (!n) return ; _deleteTree(n->left); _deleteTree(n->right); delete n; } void _makehuffTree() { while (q.size() != 1) { Node *left = new Node(q.top()); q.pop(); Node *right = new Node(q.top()); q.pop(); Node node('R', left->freq + right->freq, left, right); q.push(node); } } void _huffmanCode(Node *root, std::string& prefix, std::map<uchar, std::string>& codeMap) { std::string tmp = prefix; if (root->left != nullptr) { prefix += '0'; if (_isLeaf(root->left)) { codeMap[root->left->c] = prefix; } else { _huffmanCode(root->left, prefix, codeMap); } } if (root->right != nullptr) { prefix = tmp; prefix += '1'; if (_isLeaf(root->right)) { codeMap[root->right->c] = prefix; } else { _huffmanCode(root->right, prefix, codeMap); } } } private: std::priority_queue<Node> q; };
文件壓縮實現
首先需要給出文件壓縮和下面將要提到的文件解壓縮的公共頭文件,如下:
//得到index位的值,若index位為0,則GET_BYTE值為假,否則為真 #define GET_BYTE(vbyte, index) (((vbyte) & (1 << ((index) ^ 7))) != 0) //index位置1 #define SET_BYTE(vbyte, index) ((vbyte) |= (1 << ((index) ^ 7))) //index位置0 #define CLR_BYTE(vbyte, index) ((vbyte) &= (~(1 << ((index) ^ 7)))) using uchar = unsigned char; struct fileHead { char flag[4]; //壓縮二進制文件頭部標志 ycy uchar alphaVariety; //字符種類 uchar lastValidBit; //最后一個字節的有效位數 char unused[10]; //待用空間 }; //這個結構體總共占用16個字節的空間 struct alphaFreq { uchar alpha; //字符,考慮到文件中有漢字,所以定義成uchar int freq; //字符出現的頻度 alphaFreq() {} alphaFreq(const std::pair<char, int>& x) : alpha(x.first), freq(x.second) {} };
下面是文件壓縮的代碼具體實現。過程其實相對簡單,理解起來不難。首先需要讀取文件信息,統計每一個字符出現的次數,這里實現是從 std::map 容器以字符為 key 累加統計字符出現的次數。然后,用統計的結果 _afMap 創建哈夫曼樹,得到相應的每個字符的哈夫曼編碼 _codeMap。最后,就是將數據寫入壓縮文件,該過程需要先寫入文件頭部信息, 即結構體 fileHead 的內容,這部分解壓縮的時候進行格式校驗等需要用到。接着將 _afMap 的字符及頻率數據依次寫入文件中,這部分是解壓縮時重新創建哈夫曼樹用來譯碼。到這一步就依次讀取源文件的每一個字符,將其對應的哈夫曼編碼寫進文件中去。至此壓縮文件的過程結束。下面的代碼不是很難,我就不加注釋了。
class huffEncode { public: bool encode(const char* srcFilename, const char* destFilename) { if (!_getAlphaFreq(srcFilename)) return false; huffTree htree(_afMap); htree.huffmanCode(_codeMap); return _encode(srcFilename, destFilename); } private: int _getLastValidBit() { int sum = 0; for (auto it : _codeMap) { sum += it.second.size() * _afMap.at(it.first); sum &= 0xFF; } sum &= 0x7; return sum == 0 ? 8 : sum; } bool _getAlphaFreq(const char* filename) { uchar ch; std::ifstream is(filename, std::ios::binary); if (!is.is_open()) { printf("read file failed! filename: %s", filename); return false; } is.read((char*)&ch, sizeof(uchar)); while (!is.eof()) { _afMap[ch]++; is.read((char*)&ch, sizeof(uchar)); } is.close(); return true; } bool _encode(const char* srcFilename, const char* destFilename) { uchar ch; uchar value; int bitIndex = 0; fileHead filehead = {'e', 'v', 'e', 'n'}; filehead.alphaVariety = (uchar) _afMap.size(); filehead.lastValidBit = _getLastValidBit(); std::ifstream is(srcFilename, std::ios::binary); if (!is.is_open()) { printf("read file failed! filename: %s", srcFilename); return false; } std::ofstream io(destFilename, std::ios::binary); if (!io.is_open()) { printf("read file failed! filename: %s", destFilename); return false; } io.write((char*)&filehead, sizeof(fileHead)); for (auto i : _afMap) { alphaFreq af(i); io.write((char*)&af, sizeof(alphaFreq)); } is.read((char*)&ch, sizeof(uchar)); while (!is.eof()) { std::string code = _codeMap.at(ch); for (auto c : code) { if ('0' == c) { CLR_BYTE(value, bitIndex); } else { SET_BYTE(value, bitIndex); } ++bitIndex; if (bitIndex >= 8) { bitIndex = 0; io.write((char*)&value, sizeof(uchar)); } } is.read((char*)&ch, sizeof(uchar)); } if (bitIndex) { io.write((char*)&value, sizeof(uchar)); } is.close(); io.close(); return true; } private: std::map<uchar, int> _afMap; std::map<uchar, std::string> _codeMap; };
文件解壓縮實現
文件解壓縮其實就是哈夫曼編碼的譯碼過程,處理過程相對於壓縮過程來說相對復雜一點,但其實就是將文件編碼按照哈夫曼編碼的既定規則翻譯出原來對應的字符,並將字符寫到文件中的過程。較為詳細的過程是先讀取文件頭部信息,校驗文件格式是否是上面壓縮文件的格式(這里是flag的四個字符為even),不是則返回錯誤。然后根據頭部信息字符種類 alphaVariety(即字符的個數)依次讀取字符及其頻率,並將讀取的內容放到 _afMap 中,然后創建哈夫曼樹,得到相應的每個字符的哈夫曼編碼 _codeMap,並遍歷 _codeMap 創建以字符編碼為 key 的譯碼器 _decodeMap,主要方便是后面譯碼的時候根據編碼獲取其對應的字符。然后讀取壓縮文件剩余的內容,每次讀取一個字節即 8 個二進制位,獲取哈夫曼樹根節點,用一個樹節點指針pNode指向根節點,然后逐個讀取二進制,每次根據二進制位的值,當值為 0 指針走左子樹,當值為 1 指針走右子樹,並將值添加到 std::string 類型的字符串 code 后面,直到走到葉子結點位置為止。用 code 作為 key 可在譯碼器 _decodeMap 中取得對應的字符,將字符寫到新文件中去。然后清空 code,pNode重新指向根節點,繼續走上面的流程,直到讀完文件內容。文件最后一個字節的處理和描述有點不一樣,需根據文件頭信息的最后一位有效位 lastValidBit 進行特殊處理,這里特別提醒一下。
class huffDecode { public: huffDecode() : _fileHead(nullptr), _htree(nullptr) { _fileHead = new fileHead(); } ~huffDecode() { if (!_fileHead) delete _fileHead; if (!_htree) delete _htree; } private: static bool _isLeaf(Node* n) { return n->left == nullptr && n->right == nullptr; } long _getFileSize(const char* strFileName) { std::ifstream in(strFileName); if (!in.is_open()) return 0; in.seekg(0, std::ios_base::end); std::streampos sp = in.tellg(); in.close(); return sp; } bool _getAlphaFreq(const char* filename) { std::ifstream is(filename, std::ios::binary); if (!is) { printf("read file failed! filename: %s", filename); return false; } is.read((char*)_fileHead, sizeof(fileHead)); if (!(_fileHead->flag[0] == 'e' && _fileHead->flag[1] == 'v' && _fileHead->flag[2] == 'e' && _fileHead->flag[3] == 'n')) { printf("not support this file format! filename: %s\n", filename); return false; } for (int i = 0; i < static_cast<int>(_fileHead->alphaVariety); ++i) { alphaFreq af; is.read((char*)&af, sizeof(af)); _afMap.insert(std::pair<char, int>(af.alpha, af.freq)); } is.close(); return true; } bool _decode(const char* srcFilename, const char* destFilename) { long fileSize = _getFileSize(srcFilename); std::ifstream is(srcFilename, std::ios::binary); if (!is) { printf("read file failed! filename: %s", srcFilename); return false; } is.seekg(sizeof(fileHead) + sizeof(alphaFreq) * _fileHead->alphaVariety); Node node = _htree->getHuffTree(); Node* pNode = &node; std::ofstream io(destFilename, std::ios::binary); if (!io) { printf("create file failed! filename: %s", destFilename); return false; } uchar value; std::string code; int index = 0; long curLocation = is.tellg(); is.read((char*)&value, sizeof(uchar)); while (1) { if (_isLeaf(pNode)) { uchar alpha = _decodeMap[code]; io.write((char*)&alpha, sizeof(uchar)); if (curLocation >= fileSize && index >= _fileHead->lastValidBit) { break; } code.clear(); pNode = &node; } if (GET_BYTE(value, index)) { code += '1'; pNode = pNode->right; } else { pNode = pNode->left; code += '0'; } if (++index >= 8) { index = 0; is.read((char*)&value, sizeof(uchar)); curLocation = is.tellg(); } } is.close(); io.close(); return true; } public: bool decode(const char* srcFilename, const char* destFilename) { if (!_getAlphaFreq(srcFilename)) return false; long fileSize = _getFileSize(srcFilename); _htree = new huffTree(_afMap); _htree->watch(); _htree->huffmanCode(_codeMap); for (auto it : _codeMap) { _decodeMap.insert(std::pair<std::string, uchar>(it.second, it.first)); } return _decode(srcFilename, destFilename); } private: fileHead *_fileHead; huffTree *_htree; std::map<uchar, int> _afMap; std::map<uchar, std::string> _codeMap; std::map<std::string, uchar> _decodeMap; };
總結
利用哈夫曼編解碼實現文件的解壓縮其實原理不是很難,但其需要用的編程知識其實相對較多,有優先隊列、位運算、滿二叉樹、容器及文件操作等,想要實現的優雅其實不是很容易。而我在網上查到的 C++ 實現都不甚滿意,所以決定自己實現,個人覺得還算比較滿意,但因個人水平有限肯定會存在某些問題,請發現的朋友留言探討。我覺得這個過程還是比較非常能鍛煉自己的編程能力,作為一個小項目來練手再合適不過,不僅能夠加深自己對位運算、C++標准庫、二叉樹及文件操作的理解,而且能夠鍛煉面向對象的編程思維。對了,不能忘記了,我代碼實現的主要思想主要參考這位兄弟的文章,他是用 C 語言實現的,其實已經非常優雅,文章鏈接:https://blog.csdn.net/weixin_38214171/article/details/81626498。
最后給出實現的源碼鏈接:https://github.com/evenleo/huffman