前言
這篇博客主要是整理、記錄一下這次數據結構實訓的過程以及分享一些我個人的心得體會,當然,代碼我個人的項目代碼也會開源分享。先放鏈接:https://github.com/Melonl/FileCompress
相關資料以及開源代碼
在上面給的Github鏈接裏的Code&Ref文件夾下即是實訓參考文檔以及老師給的參考代碼,main函數入口在Demo1.cpp裏,Training Guide3.0.docx是實訓參考文檔,另外文件夾裏還給了一個txt和一個png用於測試。Core文件夾裏的是這次實訓我項目的核心代碼,Main.cpp是入口。FileCompress_qt_project文件夾裏的是我最終的Qt項目代碼,裏面包含Core文件夾下的代碼以及Qt相關的GUI代碼,是從Qt工程目錄直接打包的,可以直接使用Qt Creator 4.10.1及以上版本(截止至本文發佈時的最新Qt版本Qt 5.13.1)打開。另外,代碼裏我用的是Haffman這個單詞,是拼寫錯誤,因爲我一直以爲哈夫曼的英文直譯就是Haffman,誰知道其實是霍夫曼..
關於我的項目
這次實訓指導老師直接給了參考代碼(大概是老師爲了實訓不會太多人掛而放水..),就是鏈接裏Code&Ref文件夾下面的那份代碼。這樣就使得實訓的重心從實現Huffman壓縮算法轉到理解、修改、優化參考代碼上。所以,我給自己的要求是在理解的基礎上使用C++簡單封裝整個流程,然後對參考代碼做一些改進(即是Core文件夾下的代碼),最後再使用Qt做一個界面(即是上文開源的Qt工程)。
Qt工程實際截圖:
Huffman編碼過程總結
這部分先總體總結一下參考代碼的實現思路,然後我會以Q&A的形式來着重記錄一下實現中的一些難點。
首先要說明的是,參考代碼和參考文檔涉及到的Huffman編碼都是靜態的Huffman編碼,即需要在壓縮文件之前先掃描一遍文件得到所有字節對應的權重,然後再根據權重生成Huffman樹和各字節的Huffman編碼,最後一一對應地把原字節翻譯成Huffman編碼。靜態Huffman編碼只適合壓縮較小的文件或者有大量重複字符的文本文件,否則的話壓縮後文件的大小可能沒有變化或者更大。Huffman編碼的整個過程在參考文檔和參考代碼裏都解釋得很詳細了,我覺得我也總結不出什麼更好的內容了,如果有需要直接去閱讀源碼和文檔吧,下面直接以Q&A形式記錄一些重難點好了。
1.Huffman編碼只能用於壓縮文本文件(.txt)嗎?
可以壓縮任意類型的文件。在參考代碼的haffmantree.cpp的47行(在Statistics函數裏)可以看到,它是以每次讀入一個字節的形式來處理文件,然後再將這個字節“視爲” ASCII碼來統計權重(或者說“詞頻”,也就是這個字節在整個文件中出現的次數),所以它可以壓縮任意類型的文件。
2.用來存Huffman樹的數組的大小爲什麼要設爲511而不是510或者512?
因爲Huffman樹最多隻有511個結點,510少了,會出現問題,512多了,浪費空間。在上文有說到,這種壓縮方式是基於字符或者說字節的權重來壓縮的,是將字節“視爲”ASCII碼來統計權重的,一個字節剛好對應一個char類型,而char又與ASCII碼一一對應。ASCII碼最多隻有256個,Huffman樹又是一種二叉樹,那麼Huffman樹最大也就只有256*2-1=511個結點(即是所有ASCII碼的字符都在被壓縮的文件裏出現的情況)。這裏有涉及到一點數據結構二叉樹的相關內容。
3.爲什麼Huffman樹的根節點在下標爲bytes_count * 2 - 2的位置?
這個跟Huffman樹的構造過程有關。在按字節讀取源文件統計完權重之後,代碼裏是先對HaffTree數組根據權重進行降序排序,使所有葉子結點(共有byte_count個葉子結點)都“擠”到數組的頭部,然後再在剩下的空位裏順序地從葉子往根部構造非葉子結點,所以最後下標爲bytes_count * 2 - 2的位置就是整個Huffman樹最後一個結點的位置,也就是根結點所在位置。
4.writeCompressFile函數尾部的strcat(buff, "0000000")的作用?爲什麼是7個'0'而不是8個?
用於補齊最後一個字節的數據,補7個'0'的是因爲緩衝區中至少有一個 bit數據。在compressfile.cpp裏的148行可以看到,代碼中是先判斷一下buff裏是否還剩下有非'0'的字符,若有,則需要填充爲8個bit後再寫出,因爲不管是讀入還是寫出,最小單位都是一個字節,即是8個bit。而填充7個'0'是因爲此時buff中至少還有一個bit的數據,填充7個'0'後無論如何都能湊夠一個字節寫出,多出來的 ‘0’會被直接捨棄(因爲填充完後只寫出一個字節)。
5.爲什麼在解壓的時候只需要構建Huffman樹而不需要構建Huffman編碼?
因爲解壓的時候需要的是“編碼到字符”的關係,而這個關係已經包含在樹結構裏了。在構造Huffman樹的時候,我們規定編碼中的 0代表"往左孩子移動",1代表"往右孩子移動",所以每個葉子結點(也就是每個字節)對應的Huffman編碼其實就是從根結點到該葉子結點的“路徑”。我們在解壓的時候,已經有了對應的Huffman編碼,也就是已經有了“路徑”信息,我們就可以直接根據“路徑”在樹裏找對應的原字節,將Huffman編碼還原成對應的字節數據即可,不需要先給每個葉子結點生成編碼。但是在壓縮的時候,我們只有原字節,而樹的“路徑”對應的是Huffman編碼,我們無法通過原字節直接找到“路徑”,也就無法直接找到對應的Huffman編碼,只能在構造好每個字節的Huffman編碼後再通過數組層面的搜索來找到對應的結點,從而找到對應的Huffman編碼。
6.對參考代碼進行的優化或者改進?
都是一些小的效率上的改進。
- 在統計權重的部分,也就是haffmantree.cpp中60行的位置,這裏額外地使用了一個for來統計原文件出現的byte數量(即是葉子結點數量),其實完全可以在48行統計原文件字節數的時候一併統計,這樣可以節省一些時間。改進後的這部分代碼可以在Core文件夾下面的HaffCode.cpp裏143行看到。
- 在壓縮的根據字節找對應編碼的部分,也就是compressfile.cpp的getByteEncode函數,這裏使用了最樸素的O(n)複雜度的查找,而查找的數據結構是一個數組,那麼我們完全可以使用諸如二分查找之類的方法來優化查找速度。我的代碼中使用的就是二分查找(對應代碼在HaffCode.cpp裏的getCode函數中),當然,需要提前對數組根據字節的ASCII碼來排序,我使用的是STL中的sort函數。排序的時間複雜度大約爲O(nlogn),排完序後每次查找只需要O(logn)的複雜度,我個人認爲在被壓縮的文件比較大的情況,效率的提升是非常可觀的,因爲查找操作需要執行非常多(其實就是文件的字節數)次。其實這裏還有一種更加極限的優化方式,就是將數組裏的葉子結點的字節ASCII碼對應到數組下標,例如ASCII碼爲97的結點就讓它放在數組裏下標爲97的位置。這樣一來查找的時間複雜度可以直接優化到O(1),而這個使ASCII碼對應下標的操作也不過O(n)的複雜度。
- 寫出緩衝區的部分,也就是flushBuffer函數,這裏使用了pow函數來將8個char構造成一個寫出的char,因爲這是一個處理二進制位的問題,我們可以直接使用位運算來處理(優化後的代碼在我的代碼Compressor.cpp的flushBuffer函數中)。使用位運算代替pow函數可以極大地提升速度,並且省去了調用函數的空間開銷。
- 在根據葉子結點構造Huffman樹的過程中,需要每次查找兩個最小權重的結點來組成一個子樹,參考代碼中這裏使用的是兩重for構建的,一重for構建一重for查找,這樣算的話時間複雜度大概是在O(n²)。我們其實可以使用優先隊列來優化,爲了防止結點對象重複構造的問題,我們還可以使用指向結點的指針來代替結點本身push進優先隊列。但是使用優先隊列還需要考慮原數組下標的問題,因爲我們取到兩個最小結點後是需要根據它們的下標來操作的,而加入到優先隊列之後下標的信息就沒了,如果要儲存下標信息的話又得再定義一個結構來儲存下標...總之,我覺得太過繁瑣而且效率的提升不是特別高,所以這裏只給個思路,在我的代碼中是沒有做這個優化的。
QT相關的總結
在我的項目中QT的代碼其實不多,無非就是槽函數、QT簡單的多線程以及一些GUI相關的東西,都比較簡單,這裏就不展開說了,有需要的朋友直接翻我的QT工程便是。
踩的坑
主要踩了兩個大坑,一是文件編碼的坑,二是C++重定向讀取文件的坑。
先說說文件編碼的坑,老師給的參考代碼是GBK編碼格式的,而我一開始在寫我自己的cpp的時候使用的是UTF-8編碼,如果我正常一點一點敲代碼倒沒什麼我,問題就出在一旦我複製了參考代碼到我的cpp裏,我的cpp就會出問題,出問題之後雖然依舊可以編譯,也可以正常寫代碼,顯示也沒問題,但是我在有問題的cpp裏使用類似ifstream in("xx")這種需要傳一個字符串路徑的函數,傳過去的路徑永遠都是錯誤的,也就導致文件流總是打不開,無論怎麼調試都不行。最後的解決辦法是重新創建了一個UTF-8編碼的cpp,然後手動一行一行地照着寫代碼,不從GBK編碼文件裏複製任何代碼。
二是C++重定向讀取文件的坑,使用C++的重定向讀取char的時候,哪怕打開文件流的時候是使用的ios::binary的,它依舊會跳過一些字符,導致使用這種方式不能按字節處理完文件中所有的字節。正確的按字節讀取方式是使用ifstream::read,這個函數將會逐個字節讀取,而使用重定向讀取本身是一種格式化輸入,會跳過一些字節。
這次實訓就總結到這裏,感謝閱讀。