本博客由Rcchio原創
我了解到很多壓縮文件的程序是基於哈夫曼編碼來實現的,所以產生了自己用哈夫曼編碼寫一個壓縮軟件的想法,經過查閱資料和自己的思考,我用c++語言寫出了該程序,並通過這篇文章來記錄一下自己寫該程序學到的東西。因為本人寫的程序在壓縮率上,還有提升的空間,所以本文將不定期更新,但程序整體的思路不會有較大的改動。
一、基於哈夫曼編碼可實現壓縮文件的原理分析
在計算機中,數據的存儲都是二進制的,並且以字節作為基本的存儲單位,像英文字母在文本中占一個字節,漢字占兩個字節,我們把這種每一個字符都通過相同的字節數來表達的編碼形式稱為定長編碼。哈夫曼編碼是一種不定長編碼方式,即每個字符的編碼的長度可以相等也可以不相等。在哈夫曼編碼中,出現的頻率越高的字符,其哈夫曼編碼的長度就越短,相反,出現頻率低的字符,哈夫曼編碼的長度就越長,因此字符的哈夫曼編碼的平均長度是最小的,這就是基於哈夫曼編碼可實現文件壓縮的基本原理。
二、程序的功能描述
該程序可以用來壓縮任何格式的文件生成壓縮文件,也可以解壓任何由本程序生成的壓縮格式的文件。
三、完成該程序所需要的知識
1、哈夫曼樹,哈夫曼編碼
2、c++的文件操作
3、c++的位操作
四、壓縮解壓文件的過程詳解
眾所周知,壓縮文件的目的是使該文件在不需要使用時占用的存儲空間減小。當使用到該文件時,將壓縮文件解壓為原文件。因此該程序應具有壓縮和解壓這兩個基本功能。
1、壓縮過程:
(1)對原文件中出現的所有字符進行哈夫曼編碼。
為了實現對各類型文件的壓縮,我們要以二進制的方式而不是文本文件的方式打開文件。文件中數據的存儲以字節為單位。一個字節有八位,因此該字節中0和1的排列方式共有2的八次方——256種,每一種排列方式都有一種字符與之對應,即一個字節最多可以表示256類字符,也就是說,原文件中字符的種類數最多只有256種 。將原文件以二進制的方式打開以后,統計原文件中字符的種類及每種字符出現的頻度(次數)。將每一種字符作為一個葉節點,字符的頻度作為葉節點的權值,就可以構造哈夫曼樹,對原文件中出現的所有字符進行哈夫曼編碼。將字符和字符對應的哈夫曼編碼保存在一個結構體數組中(暫且將它稱之為編碼表),為下面的向壓縮文件輸入哈夫曼編碼做准備。
如果對於二進制文件和文本文件的概念和區別不了解,可以點擊這里
如果對怎樣構造哈夫曼樹生成哈夫曼編碼不清楚可以參考該ppt:點擊這里
這里我講一下如何統計原文件中字符的種類及頻度:可以定義一個unsigned char 的變量x,該變量占用一個字節,用變量x依次讀取原文件中的字符。定義一個大小為256的結構體數組a,結構體有兩個成員unsigned char name和unsigned int weight, name用來存儲字符,weight用來存儲字符對應的頻度。以字符的值為數組下標,例如變量x讀取到字符'b',此時x的值為'b','b'的ascall碼為98,因此a[x].weight++就相當於a[98].weight++。x讀取一個字符,就執行a[x].name=x;a[x].weight++。a[x].weight的值記錄了字符x已經出現的頻率,當原文件的所有字符被讀取過一遍以后,字符的種類及對應的頻率也就統計完畢。
當然,原文件中有可能256種字符沒有完全出現,構造哈夫曼樹時只需要將權值(頻度)不為0的字符作為葉節點就可以了。構造好哈夫曼樹以后將結構體數組占用的內存釋放掉。
(2)向壓縮文件中輸入解壓必需的信息
這就需要考慮如何由壓縮文件還原為原文件。整個還原的思路是這樣的:將壓縮文件的結構分為三部分。第一部分,存儲原文件中字符的種類數。第二部分,存儲原文件中的字符及對應的頻度。第三部分,按照原文件中字符出現的順序依次輸入字符對應的哈夫曼編碼。解壓時,通過讀取壓縮文件的第一部分和第二部分的內容,得到原文件中字符的種類和頻度,可以重建哈夫曼樹。然后讀取原文件中的字符對應的哈夫曼編碼(具體的實現過程在下面的五-4詳述了)。根據字符對應的哈夫曼編碼(若干個0-1序列),從重建的哈夫曼樹的根節點出發向葉結點的方向移動,0代表向節點的左孩子移動,1代表向節點的右孩子移動。當移動到哈夫曼樹的葉節點時,我們就找到了該字符,然后將它輸入到解壓以后的文件中去。然后再根據下一個字符對應的哈夫曼編碼重復上述操作,直到原文件所有的字符都已經嚴格按順序輸入到解壓的文件中去。
因此,我們要向壓縮文件中輸入三部分信息。第一部分就是原文件中字符的種類數,第二部分就是原文件中的出現的字符及對應的頻度,第三部分就是嚴格根據原文件中字符的順序輸入字符對應的哈夫曼編碼。
第一部分和第二部分的內容的輸入是很簡單的,這里便不再詳述。向壓縮文件中輸入第三部分的內容時,需要再次用unsigned char 類型的變量將原文件中的字符依次讀取一遍,每讀取到一個字符,就在上面求得的編碼表中找到其對應的哈夫曼編碼,然后將該字符的哈夫曼編碼存到一個string類型的變量unit中,若unit中0和1的個數大於等於8時,就將unit中的前八位0-1序列通過位操作輸入到一個unsigned char變量中(具體的實現過程在下面的五-3詳述了),再將該變量輸入到壓縮文件中。這里需要注意,因為哈夫曼編碼的0和1都是用字符保存的,如果直接存入壓縮文件中,這樣會使得壓縮文件的存儲空間比原文件的還要大,這就違背的壓縮的含義。因此,需要通過通過上面的方式來存儲哈夫曼編碼。
2、解壓過程
整個解壓的思路已經在上面提到過了。下面我提一個細節問題。
根據某個字符的哈夫曼編碼序列從重構的哈夫曼樹的根節點出發向葉節點移動的過程中,如何判斷已經到達葉節點?是這樣的,在構造哈夫曼樹 時,我們使用一個結構體數組來保存葉節點和其他雙親結點的,對於有n個葉節點的哈夫曼樹,需要大小為2n-1的結構體數組來保存葉節點和其他結點。其中a[0]—a[n-1]都是存放葉節點,a[n]-a[2n-2]存放的是其他節點。所以當從哈夫曼樹的根節點出發向葉節點移動時,當某節點的下標在范圍0-n-1中時,就說明該結點已經是葉節點了。
五、一些需要注意的細節問題在編碼時可能需要注意或者需要參考,我將自己想到的情況舉例出來並通過代碼來增強博客的可讀性(給出的代碼只是為了說明某一個知識,並不嚴格的遵從c++的編程風格,請讀者注意)。
1、向壓縮文件輸入字符種類數,字符及對應的頻度的細節問題
向壓縮文件中輸入字符的種類數,字符及對應的頻度時,建議用ofstream類的write函數操作。相對應的,讀取壓縮文件的前兩部分信息,即原文件的字符種類數,字符及其頻度時,用ifsream類的read函數操作。而不是通過文件的流來實現。我舉一個例子,假設壓縮文件的名字為"1.txt",保存字符種類數的變量為H_number。
ofstream out("1.txt");
int H_number=34;
unsigned char c='5';
out<<H_number<<c;
ifstream in("1.txt");in>>H_number;
按我們希望的,H_number的值應為34,但通過流讀取文件,H_number的值為345,這樣一來,壓縮文件就不能得到正確還原了。
如果代碼改成這樣就能避免上述問題:
ofstream out("1.txt");
int H_number=34;
unsigned char c='5';
out.write((char*)&H_number,sizeof(int));
out.write((char*)&c,sizeof(char)); ifstream in("1.txt");in.read((char*)&H_number,sizeof(int));
2、c++中如何判斷文件結束
在c++中,判斷文件的結束是用ifstream類的成員函數eof()。用一個unsigned char x變量讀取文件的字符,當沒有讀到文件的結尾時,eof()函數的值為false,當讀取文件的最后一個字符時,由於eof()並不知道這是文件的最后一個字符,所以eof()的值仍為false。此時如果繼續讀取文件中的字符,由於到達了文件的結尾,沒有新的字符可以讀取,eof()函數的值變為true,而變量x中保存的仍然是文件的最后一個字符。因此如果在統計原文件的字符種類及頻度時,代碼這樣寫:
ifstream in("file.txt");
while(in.eof()) { x=in.get(); a[x].name=x; a[x].weight++; }
那么最后一個字符的頻度就被多統計了一次,這顯然錯誤的讀取了原文件的信息,那么解壓出來的文件的內容也會和原文件有出入。
該問題可以這樣解決:
ifstream in("file.txt");while(true) { x=in.get(); if(in.eof()) break; a[x].name=x; a[x].weight++; }
先讀取字符再判斷是否到達文件結尾,就避免了將最后一個字符的頻度多統計依次。
3、如何將8位的0-1序列通過位操作存入到一個字節的unsigned char變量中
假設有一個string類型的變量unit,存儲了8位的0-1序列,現將該序列通過|操作符存入unsigned char變量x中
for(int i=0;i<8;i++) { x=x<<1; if(unit[i]==1) x=x|1; }
經過上述代碼,變量x便存儲了8位0-1序列。
4、如何讀取占一個字節的字符對應的二進制比特流(0-1)序列
假設用變量unsigned char x讀取了壓縮文件中的第三部分的某個字符,現在要獲取該字符載有的比特流(8位0-1序列),並保存在一個string 類型的變量s中
for(int i=0;i<8;++i) { if(x&128) { s[i]=1; } else { s[i]=0; } x=x<<1; }
5、解壓文件時如何判斷壓縮文件的第三部分—哈夫曼編碼 已經讀完了?
可以通過計算原文件中的字符的頻度之和,即所有字符的總個數,作為原文件的長度(假設用int length來保存)。解壓文件時每次向解壓文件中輸出一個解壓出的字符,就將length--。當length為0時,表示解壓結束。
六、代碼實現
下面我給出程序主類的聲明,有助於理解程序的整體結構。
class Hnode //哈夫曼樹的存儲結構 { public: unsigned char name; //8位的存儲單元來存儲字符(256種) unsigned weight; //存儲字符的頻度 int p; //雙親節點 int lchild; //左孩子 int rchild; //右孩子 int Hnodeindex; //節點索引 Hnode() //初始化數據成員 { name = '\0'; weight = 0; p = 0; Hnodeindex = 0; lchild = rchild = 0; } class Huffmancode_node //字符的哈夫曼編碼的存儲結構 { public: unsigned char name; //字符的名稱 vector<int> code; //用vector容器存儲哈夫曼編碼 Huffmancode_node() { name = '\0'; } };
//主類的聲明 class ComAndEx //壓縮解壓類的聲明 { public: void Compress(); //壓縮文件的函數 void Extract(); //解壓文件的函數 string ScanCharacter();//統計源文件種字符的種類及個數的函數 void CreateHuffmanTree();//建立哈夫曼樹的函數 void CreateHuffmanCode();//生成哈夫曼編碼的函數 protected: vector<Hnode> HuffmanTree; //存儲哈夫曼樹的數組 vector<Huffmancode_node>Huffmancode; //存儲哈夫曼編碼的數組 int H_number; //字符的種類數 };
這里講一下為什么將構造哈夫曼樹和生成哈夫曼編碼分成兩個函數實現。因為在壓縮過程中需要構造哈夫曼樹,生成原文件中出現的字符的哈夫曼編碼,而在解壓過程中只需要重建哈夫曼樹,不需要編碼。因此將這兩個過程分為兩個函數實 現。
由於程序是在哈夫曼編碼的基礎上實現的,是學習哈夫曼編碼的一個很好的實踐機會。所以希望不懂哈夫曼編碼的同學查閱相關的資料或下載我前面給的有關哈夫曼編碼的ppt來自己學習一下,爭取會手動實現這一部分的代碼。其他部分的代碼如有關文件操作或位操作的,大家可以有選擇的借鑒一下。如有高見也請大家不吝指教。
七、程序的局限
上面所實現的程序,雖然能夠壓縮任何格式的文件,如.txt文件,.docx文件,.ppt文件,.jpg文件,.exe文件等,但是在壓縮效率上,往往是.txt文件最高,其他類型的文件壓縮效果不明顯,甚至原文件和壓縮文件的大小比例是1:1的情況也時常見到。究其原因,原文件中字符的種類數越多,哈夫曼樹的葉節點個數越多,整個哈夫曼樹的深度也越大,哈夫曼編碼的平均長度也越長。一些文件中字符的種類數接近或達到256,哈夫曼編碼的平均長度接近定長碼的長度,故壓縮效果不明顯。對於在此基礎上如何提高文件的壓縮率,若大家有高見請不吝賜教。我也將在此基礎上繼續尋找提高文件壓縮率的方法。