詳解Huffman壓縮原理和c++程式碼實現
寫在前面
Huffman壓縮原理其實挺好理解的,我用java很快就寫好了。然後用c++寫,一開始我是這麼想的:c++偏底層,應該對二進位制串檔案的讀寫會更簡單吧。
不涉及到檔案讀寫的部分確實很快就做好了,然後就被檔案讀寫折磨。
各種深夜痛哭... ...
但還是值得的,學習了更多底層的知識。我對Huffman壓縮基本掌握了。(本來想說完全掌握的,但,呵,生活。微笑)
寫這篇部落格花了我很長時間,我完全盡力了。我儘可能詳細地寫了三部分,分別是Huffman原理、坑和c++程式碼實現。我舉了一些例子,並且經過實際動手驗證,還自行繪製了幾幅圖幫助理解。
歡迎指錯和討論交流,也歡迎提問質疑,儘管能力有限,但我盡力解答。
目錄
一、Huffman壓縮
1、檔案在計算機中儲存形式
2、Huffman壓縮演算法原理
3、例子解析
二、需要特別注意的坑
1、windows的'\r\n'問題,c/c++可以用二進位制方式讀取來解決
2、檔案讀取末尾問題
三、Huffman的實現(上程式碼,本文c++版本,如果要java版本請私聊我)
編碼篇
1、統計頻率
2、建立Huffman樹
3、獲取Huffman編碼表
4、編碼
譯碼篇
1、獲取Huffman譯碼錶
2、譯碼
正文
一、Huffman壓縮
1、檔案在計算機中儲存形式
在開始實現之前,我們要了解下計算機底層的編解碼。
計算機只認0與1,一切檔案最終的儲存形式都是0、1串。文字、圖片、視訊等檔案都是通過一定的協議進行編解碼,而 這些協議及其轉化由各種各樣的軟體(音訊軟體、畫圖軟體、記事本等)實現。 下圖是一張png圖片檔案在計算機中儲存形式, 可以通過Binary Viewer 軟體進行檢視。
檔案的儲存有定長儲存和不定長儲存,定長儲存就是int、char、byte等型別會以固定的位數b進行儲存。舉個例子,我們新建txt檔案,寫入“4 中”然後敲回車換行,再寫入“16”,儲存。現在我們用Binary Viewer檢視它在計算機中的儲存。
因為我們是用文字檔案進行儲存,所以記事本的編解碼方式是將每一個字元對應的ASCII碼寫入文字檔案,如果是0-127的只有8位,如果是中文這樣的拓展字元則有16位。
我們看圖吧,第一個位元組(8位)“00110100”轉化為10進製為52, 查ASCII表為‘4’,接下來的第二個位元組“00100000”代表空格,接下來的兩個位元組是‘中’,然後依次是‘\r’,‘\n’,‘1’,6’。這裡特別強調下,Windows系統用‘\r\n’表示換行,而Liunx用‘\n’,Mac用‘\r’。為什麼會有如此差異呢?感興趣的自行百度,這個跟早期印表機有關,現在只是一個規則,並沒有實際含義,但要特別特別注意 。我寫Huffman的時候被這個坑了,具體我們後面再說。我強烈建議寫壓縮的時候用上檢視二進位制串的工具(比如Binary Viewer),有利於理解和debug。
2、Huffman壓縮演算法原理
我們上邊提到了計算機的定長儲存,其實我們也看到了儲存‘4’這樣的數字,計算機用了8位,那麼我們能不能減少二進位制串呢,從而來實現壓縮?有很多壓縮演算法,他們主要是用新一套的編解碼錶來實現。而每次所用的編碼表都是不同的,是依據壓縮的檔案來決定的。
那Huffman是怎麼做的呢?它先通過對要壓縮的檔案進行統計頻率,比如在“65da as 美65a”中a-3(表示a出現3次),d-1,s-1,6-2,5-2,美-1,空格-2。Huffman採用不定長進行儲存,頻率高的對應的編碼長度較短,頻率低的對應的編碼長度較長。但我們壓縮後是要能解壓的,假如有這樣的一組編碼“00101110”,Huffman壓縮演算法每次讀取一位,直至找到在Huffman編碼表中找到,然後去除這一串,接著重複以上操作,直至編碼讀取完畢。要實現這樣的結果,我們要怎麼創立Huffman編碼表呢?以下是具體做法:(邊看例子輔助理解)
(1)先對壓縮檔案的字元進行頻率統計,以“字元--頻率”的形式存入某容器m
(2) 在容器m中取出兩個頻率最小對應的字元,作為二叉樹的兩個葉子節點,並將頻率和作為它們的根節點,同時將新結點存入容器m,將舊的兩個結點踢出容器m。(容器m可以是優先佇列)
(3)重複(2),直到最後容器m中只有一個元素。
(4) 將形成的二叉樹的左節點標0,右節點標1。把從最上面的根節點到最下面的葉子節點途中遇到的0,1序列串起來,就得到了各個符號的編碼。
3、例子解析
例子:有一串“cdbedfaabca”,進行Huffman編碼和解碼。
編碼: (1)頻率統計 f:1 e:1 d:2 c:2 b:2 a:5
(2) f與 e作為葉子結點,其根節點為_2 。 此時,新的頻率表為:_2 d:2 c:2 b:2 a:5
d與_2作為葉子結點,其根節點為_4 。 此時,新的頻率表為:c:2 b:2 _4 a:5
b與 c作為葉子結點,其根節點為_4 。 此時,新的頻率表為:_4 _4 a:5
_4與_4作為葉子結點,其根節點為_8 。 此時,新的頻率表為: _8 a:5
_8與 a作為葉子結點,其根節點為_13 。
結束。
(3)左子樹標0,右子樹標1。如下圖所示:
Huffman編碼表 a:0 c:100b:101f:1100e:1101d:111
那麼“cdbedfaabca”的編碼為“10011110111011111100001011000”。
解碼: 讀取第一位‘1’,搜尋Huffman表,找不到。繼續讀下一位“10”,找不到,繼續讀下一位“100”,此時對應字元‘c’。
那麼清零,繼續讀第一位“1”,直至讀到“111”,對應‘d’。
繼續清零,繼續讀第一位‘0’,直至讀到“101”,對應‘b’。
... ...
讀到最後,得“cdbedfaabca”。
二、需要特別注意的坑
1、windows的'\r\n'問題
c++在windows系統中,在讀寫檔案時,有兩種格式,分別是文字檔案和二進位制檔案,它們的區別只有一點。
文字檔案表示換行會用'\r\n',上文我們已經證明了,在txt檔案換行後,用軟體檢視其二進位制儲存形式,發現換行是'\r\n'對應的Ascii碼。也就是,寫入時寫入'\r\n',然後在讀取時會把'\r\n'轉化成換行。
你應該沒意識到這對Huffman壓縮有什麼影響,那麼我們舉四個例子來進一步說明吧。
我用二進位制形式寫入‘\n’時,txt文字開啟沒有換行,也沒有其它改變。但用c++讀取時讀到‘\n’,會換行。
我用二進位制形式寫入‘\r’時,txt文字開啟沒有換行,也沒有其它改變。但用c++讀取時讀到‘\r’,沒有任何操作。
我用二進位制形式寫入‘\r\n’時,txt文字開啟換行。但用c++讀取時只讀到‘\n’,(沒有讀到'\r' !!!),會換行。
我用二進位制形式寫入‘\n\r’時,txt文字開啟沒有換行,也沒有其它改變。用c++讀取時讀到‘\n\r’,會換行('\n'實現的)。
為什麼要特意強調這點呢?
因為我們在進行Huffman壓縮的過程中,我們可能會儲存“11111111”(-1),“00001010”(\n),“00001101”(\r)這樣比較特殊的字元。-1經常被用於證明檔案讀到末尾,\n、\r與換行關係密切。
事實證明,“11111111”並沒有影響,即便程式讀到了-1,它依然會繼續讀取,所以不需要考慮。
而根據上面的四個例子,讀取到‘\n’,‘\r’,‘\n\r’時都不會有影響,但若是讀取到‘\r\n’,恭喜你,出錯了,這時候‘\r’不會被讀取到。後果就是譯碼時會出錯,譯碼到那裡時就開始跟原文不同了。
怎麼解決呢?
我給出的方案是:進行Huffman編碼和譯碼時,避免使用文字檔案來讀寫,採用二進位制檔案。在讀取原檔案和輸出譯碼後的檔案時,用文字檔案,而不用二進位制檔案,這樣才能保證檔案開啟時能正常顯示換行。
如圖:
2、檔案讀取末尾問題
讀取檔案判斷末尾,c++用eof()或fail()方法都行,“11111111”並不會造成影響。
ifstream in("E:\fin.txt");
char ch;
while(!in.eof())
{
in.get(ch);
cout<<ch;
//其它操作......
}
但是有點問題,就是最後一個字元會被讀取兩次,這是因為當檔案輸入流讀取不到時,它才會停止讀取。所以當讀取到最後一個字元時,in.eof()返回仍為false,所以會再執行in.get(ch),此時輸入流指標讀取不到,in.eof()才返回true。但ch未改變,導致檔案最後一個字元會被讀取兩遍。
最簡單的解決方案如下:
ifstream in("E:\fin.txt");
char ch;
while(!in.eof())
{
in.get(ch);
if(!in.eof()){ //再判斷一次
cout<<ch;
//其它操作......
}
}
二、Huffman的實現(上程式碼)
編碼篇
1、統計頻率
//讀取原檔案,統計頻率並加入map中 void read_count(const char* fin) //char * fin為檔案路徑 { char ch; string s; ifstream in(fin); if(!in.good()) { printf("Cannot open the file %s\n",fin ); return ; } while(!in.eof()) { in.get(ch); //該判斷用於避免讀取不存在的下一位 if(!in.eof()){ s=ch; //這種查詢會增添新元素 map1[s]=map1[s]+1; } } }
2、建立Huffman樹
以下部分為所有的全域性變數(兩條線以內)
============================================================================
#define MAX 100000
//huffman樹 結點
struct Huffman
{
Huffman(string c,int n):num(n),ch(c),lchild(NULL),rchild(NULL) {}
Huffman():ch(""),lchild(NULL),rchild(NULL) {}
int num; //儲存頻數
string ch=""; //儲存字元
Huffman *lchild; //左子樹
Huffman *rchild; //右子樹
};
typedef Huffman* Node;
//比較器,用於優先佇列
class Compare
{
public:
bool operator()(const Node& c1, const Node& c2) const
{
return (*c1).num > (*c2).num;
}
};
//map對映,用於key與value的相互轉化,進行編解密
map<string,int>map1;
map<string,string>map2;
map<string,string>map3;
map<string,int>::iterator l_it; //迭代器,用於map的遍歷
//優先佇列,輔助huffman樹的建立
priority_queue< Node, vector<Node>, Compare > pq;
string str="";
string result="";
============================================================================
//得到初始的優先佇列
void getArray()
{
for(l_it = map1.begin(); l_it != map1.end(); l_it++)
{
Node node=new Huffman(l_it->first,l_it->second);
pq.push(node);
}
}
//得到Huffman樹
void getTree()
{
while(pq.size()>1)
{
Node node1=pq.top(); //從優先佇列中彈出最小的數
pq.pop();
Node node2=pq.top(); //彈出最小的數
pq.pop();
string key=node1->ch+node2->ch;
int value=node1->num+node2->num; //新結點的頻數為兩個葉子結點的頻數和
Node node=new Huffman(key,value); //new新結點
node->lchild=node1; //左子樹
node->rchild=node2; //右子樹
pq.push(node); //將新結點加入優先佇列中
//printf("%s %d\n",node.ch.c_str(),node.num);
}
}
3、獲取Huffman編碼表
//獲取Huffman編碼表
void getMap(string code,Node node)
{
//當遍歷結束時,返回
if(!node||node->ch=="")
{
return;
}
//當遇到葉子結點時,獲取huffman編碼並放入map2
if(node->ch.length()==1)
{
map2[node->ch]=code;
}
if(node->rchild)
{
//右結點+‘1’
Node right=node->rchild;
getMap(code+"1",right);
}
if(node->lchild)
{
//左結點+‘0’
Node left=node->lchild;
getMap(code+"0",left);
}
}
4、編碼
void compress(const char* fin,const char* fout)
{
//以二進位制形式開啟輸出檔案,且如果檔案已存在,則清空後再寫入
ofstream file(fout,ios_base::trunc|ios_base::binary);
//判斷檔案是否正常開啟
if(!file.good())
{
printf("Cannot open the file%s\n",fout );
return ;
}
//開啟輸入檔案
ifstream in(fin);
//判斷檔案是否正常開啟
if(!in.good())
{
printf("Cannot open the file%s\n",fin );
return ;
}
//迭代器,用於map的遍歷
map<string,string>::iterator l_it1;
//寫入huffman編碼個數
file<<map2.size()<<" ";
//遍歷map2中huffman結點並寫入檔案
for(l_it1 = map2.begin(); l_it1 != map2.end(); l_it1++)
{
//假如編碼的key為‘\n’時做的額外處理。因為此時'\n'要作為字元,而非隔離變數的標誌,可以去掉此句自行測試,看看會怎麼樣
if(l_it1->first.c_str()[0]=='\n'){
file<<endl;
}
//將編碼的key與value寫入檔案
file<<l_it1->first.c_str()<<l_it1->second.c_str()<<" ";
}
char ch;
string s;
string cs="";
int length=cs.length();
string str="";
unsigned char byte;
unsigned long temp;
while(!in.eof())
{
// MAX 防止出現string長度超出限制。當檔案很大時,必須要有此句
while(length<MAX&&!in.eof()){
//通過map2的huffman編碼表將原文轉成相應的編碼
in.get(ch);
if(!in.eof()){//防止讀取檔案末尾不存在的一位
s=ch;
cs+=map2[s];
length=cs.length();
}
}
//將轉化後的二進位制編碼串寫入檔案
while(length>=8)
{
//取前8b
str=cs.substr(0,8);
bitset<8> bits(str);
temp=bits.to_ulong();//轉換為long型別
byte=temp;//轉換為char型別
file<<byte;//寫入檔案
//取出剩下的二進位制串
cs=cs.substr(8,length-8);
length=cs.length();
}
}
//假如剩餘不足8位時,補足0並寫入檔案
if(length!=0)
{
str=cs.substr(0,length);
int n=0;
while(n<8-length) //補0
{
str+='0';
n++;
}
bitset<8> bits(str);
temp=bits.to_ulong();//轉換為long型別
byte=temp;//轉換為char型別
file<<byte; //寫入檔案
}
//寫入補0的個數,如果沒有則寫入0
char p=(char)(8-length)%8;
file<<p;
file.close();//關閉檔案
in.close();//關閉檔案
}
//將char轉成string二進位制串
string turnachar(unsigned char c)
{
string k="";
int j=128; //後八位為 1000_0000
for(int i=0; i <8; i++)
{
//判斷原char該位數是0或1
k+=(unsigned char)(bool)(c&j)+'0';
j>>=1; //將1右移
}
//cout<<k;
return k;
}
譯碼篇
1、獲取Huffman譯碼錶
2、譯碼
//獲取Huffman譯碼錶並進行譯碼
void decompress(const char* fin,const char* fout)
{
//1、獲取Huffman譯碼錶
//以二進位制的形式開啟編碼後的檔案
ifstream in(fin,ios_base::binary);
//假如檔案開啟失敗
if (in.fail()){
cout<<"Fail to open the file1 !!"<<endl;
return;
}
// QFile file;
// file.remove(fout);
//要以文字檔案形式開啟並寫入輸出檔案,不然回車換行不能正常顯示
ofstream out(fout,ios_base::trunc);
//假如檔案開啟失敗
if (out.fail()){
cerr<<"Fail to open the file2 !!"<<endl;
return;
}
map<string,char>map4;
int size;
char key;
char h;
string value;
//如果只是>>這種的話,會讀取不到\n,然後會出錯
in>>size;
in.get(h); //讀取掉空格
while(size>0)
{
in.get(key); //讀取key
in>>value; //讀取value
in.get(h); //讀取掉空格
map4[value]=key; //將key與value寫入map4
size--;
//cout<<value<<">>"<<key<<endl;
}
//2、開始譯碼
char c;
unsigned char c1;
string sc="";
int length=sc.length();
// int end = fgetc(&in);
while(!in.fail())
{
sc.clear();
result.clear();
length=sc.length();
while(length<MAX&&(!in.eof())){
in.get(c); //讀取每一個char
if(!in.eof()){//讀取到檔案末尾時起效,保證讀取正常
c1 = (unsigned char)c; //轉成無符號的
sc+=turnachar(c1); //轉化成原本的二進位制串
length=sc.length();
}
// end = fgetc(in);
}
// cerr<<length<<endl<<sc<<endl;
//當檔案結束時,去除補的0及記錄個數的char
if(in.fail()){
int num=(int)c; //num代表補0個數
sc=sc.substr(0,sc.length()-8-num);
}
string ss="";
int i=1;
bool check=false;
while(sc.length()>0)
{
//開始解碼
ss=sc.substr(0,i);
while(map4.find(ss)==map4.end()) //假如在Huffman表中找不到,繼續讀取下一位
{
i++;
length=sc.length();
//判斷是否超過原字串大小,避免報錯
if(i>length){
check=true;
break;
}
ss=sc.substr(0,i);
}
//用於退出兩層迴圈
if(check==true){
break;
}
//解碼
result+=map4[ss];
//去除已解碼的部分,繼續解碼
sc=sc.substr(i,sc.length()-i);
i=1;
}
//將解碼後的結果寫入檔案
out<<result;
//cerr<<"已經進行一個階段"<<endl;
//end = fgetc(in);
}
//關閉檔案
in.close();
out.close();
}
解碼也要用到turnachar()函式,在編碼部分已給出。
最後
程式碼基本全了,不僅給出各個函式,全域性變數也已給出,而主函式只是呼叫他們。
普通字元和中文都能適用,假如想進一步精進,可以使用圖形化介面,真正弄成一個文字檔案壓縮的工具。