最近自己實現了一個ZIP壓縮資料的解壓程式,覺得有必要把ZIP壓縮格式進行一下詳細總結,資料壓縮是一門通訊原理和電腦科學都會涉及到的學科,在通訊原理中,一般稱為信源編碼,在電腦科學裡,一般稱為資料壓縮,兩者本質上沒啥區別,在數學家看來,都是對映。
一方面在進行通訊的時候,有必要將待傳輸的資料進行壓縮,以減少頻寬需求;另一方面,計算機儲存資料的時候,為了減少磁碟容量需求,也會將檔案進行壓縮,儘管現在的網路頻寬越來越高,壓縮已經不像90年代初那個時候那麼迫切,但在很多場合下仍然需要,其中一個原因是壓縮後的資料容量減小後,磁碟訪問IO的時間也縮短,儘管壓縮和解壓縮過程會消耗CPU資源,但是CPU計算資源增長得很快,但是磁碟IO資源卻變化得很慢,比如目前主流的SATA硬碟仍然是7200轉,如果把磁碟的IO壓力轉化到CPU上,總體上能夠提升系統執行速度。
壓縮作為一種非常典型的技術,會應用到很多很多場合下,比如檔案系統、資料庫、訊息傳輸、網頁傳輸等等各類場合。儘管壓縮裡面會涉及到很多術語和技術,但無需擔心,博主儘量將其描述得通俗易懂。另外,本文涉及的壓縮演算法非常主流並且十分精巧,理解了ZIP的壓縮過程,對理解其它相關的壓縮演算法應該就比較容易了。
1、引子
壓縮可以分為無失真壓縮和有失真壓縮,有損,指的是壓縮之後就無法完整還原原始資訊,但是壓縮率可以很高,主要應用於視訊、話音等資料的壓縮,因為損失了一點資訊,人是很難察覺的,或者說,也沒必要那麼清晰照樣可以看可以聽;無失真壓縮則用於檔案等等必須完整還原資訊的場合,ZIP自然就是一種無失真壓縮,在通訊原理中介紹資料壓縮的時候,往往是從資訊理論的角度出發,引出夏農所定義的熵的概念,這方面的介紹實在太多,這裡換一種思路,從最原始的思想出發,為了達到壓縮的目的,需要怎麼去設計演算法。而ZIP為我們提供了相當好的案例。
儘管我們不去探討資訊理論裡面那些複雜的概念,不過我們首先還是要從兩位資訊理論大牛談起。因為是他們奠基了今天大多數無損資料壓縮的核心,包括ZIP、RAR、GZIP、GIF、PNG等等大部分無失真壓縮格式。這兩位大牛的名字分別是Jacob Ziv和Abraham Lempel,是兩位以色列人,在1977年的時候發表了一篇論文《A Universal Algorithm for Sequential Data Compression》,從名字可以看出,這是一種通用壓縮演算法,所謂通用壓縮演算法,指的是這種壓縮演算法沒有對資料的型別有什麼限定。不過論文我覺得不用仔細看了,因為博主作為一名通訊專業的PHD,看起來也焦頭爛額,不過我們後面可以看到,它的思想還是很簡單的,之所以看起來複雜,主要是因為IEEE的某些雜誌就是這個特點,需要從數學上去證明,這種壓縮演算法到底有多優,比如針對一個各態歷經的隨機序列(不用追究什麼叫各態歷經隨機序列),經過這樣的壓縮演算法後,是否可以接近資訊理論裡面的極限(也就是前面說的熵的概念)等等,不過在理解其思想之前,個人認為沒必要深究這些東西,除非你要發論文。這兩位大牛提出的這個演算法稱為LZ77,兩位大牛過了一年又提了一個類似的演算法,稱為LZ78,思想類似,ZIP這個演算法就是基於LZ77的思想演變過來的,但ZIP對LZ77編碼之後的結果又繼續進行壓縮,直到難以壓縮為止。除了LZ77、LZ78,還有很多變種的演算法,基本都以LZ開頭,如LZW、LZO、LZMA、LZSS、LZR、LZB、LZH、LZC、LZT、LZMW、LZJ、LZFG等等,非常多,LZW也比較流行,GIF那個動畫格式記得用了LZW。我也寫過解碼程式,以後有時間可以再寫一篇,但感覺跟LZ77這些類似,寫的必要性不大。
ZIP的作者是一個叫Phil Katz的人,這個人算是開源界的一個具有悲劇色彩的傳奇人物。雖然二三十年前,開源這個詞還沒有現在這樣風起雲湧,但是總有一些具有黑客精神的牛人,內心裡面充滿了自由,無論他處於哪個時代。Phil Katz這個人是個牛逼程式設計師,成名於DOS時代,我個人也沒有經歷過那個時代,我是從Windows98開始接觸電腦的,只是從書籍中得知,那個時代網速很慢,撥號使用的是隻有幾十Kb(位元不是位元組)的貓,56Kb實際上是這種貓的最高速度,在ADSL出現之後,這種技術被迅速淘汰。當時記錄檔案的也是硬碟,但是在電腦之間拷貝檔案的是軟盤,這個東西我大一還用過,最高容量記得是1.44MB,這還是200X年的軟盤,以前的軟盤容量具體多大就不知道了,Phil Katz上網的時候還不到1990年,WWW實際上就沒出現,瀏覽器當然是沒有的,當時上網幹嘛呢?基本就是類似於網管敲各種命令,這樣實際上也可以聊天、上論壇不是嗎,傳個檔案不壓縮的話肯定死慢死慢的,所以壓縮在那個時代很重要。當時有個商業公司提供了一種稱為ARC的壓縮軟體,可以讓你在那個時代聊天更快,當然是要付費的,Phil Katz就感覺到不爽,於是寫了一個PKARC,免費的,看名字知道是相容ARC的,於是網友都用PKARC了,ARC那個公司自然就不爽,把哥們告上了法庭,說牽涉了智慧財產權等等,結果Phil Katz坐牢了。。。牛人就是牛人, 在牢裡面冥思苦想,決定整一個超越ARC的牛逼演算法出來,牢裡面就是適合思考,用了兩週就整出來的,稱為PKZIP,不僅免費,而且這次還開源了,直接公佈原始碼,因為演算法都不一樣了,也就不涉及到智慧財產權了,於是ZIP流行開來,不過Phil Katz這個人沒有從裡面賺到一分錢,還是窮困潦倒,因為喝酒過多等眾多原因,2000年的時候死在一個汽車旅館裡。英雄逝去,精神永存,現在我們用UE開啟ZIP檔案,我們能看到開頭的兩個位元組就是PK兩個字元的ASCII碼。
2、一個案例的入門思考
好了,Phil Katz在牢裡面到底思考了什麼?用什麼樣的演算法來壓縮資料呢?我們想一個簡單的例子:
生,容易。活,容易。生活,不容易。
上面這句話假如不壓縮,如果使用Unicode編碼,每個字會用2個位元組表示。為什麼是兩個位元組呢?Unicode是一種國際標準,把常見各國的字元,比如英文字元、日文字元、韓文字元、中文字元、拉丁字元等等全部制定了一個標準,顯然,用2個位元組可以最多表示2^16=65536個字元,那麼65536就夠了嗎?生僻字其實是很多的,比如光康熙字典裡面收錄的漢字就好幾萬,所以實際上是不夠的,那麼是不是擴到4個位元組?也可以,這樣空間倒是變大了,可以收錄更多字元,但一方面擴到4個位元組就一定保證夠嗎?另一方面,4個位元組是不是太浪費空間了,就為了那些一般情況都不會出現的生僻字?所以,一般情況下,使用2個位元組表示,當出現生僻字的時候,再使用4個位元組表示。這實際上就體現了資訊理論中資料壓縮基本思想,出現頻繁的那些字元,表示得短一些;出現稀少的,可以表示得長些(反正一般情況下也不會出現),這樣整體長度就會減小。除了Unicode,ASCII編碼是針對英文字元的編碼方案,用1個位元組即可,除了這兩種編碼方案,還有很多地區性的編碼方案,比如GB2312可以對中文簡體字進行編碼,Big5可以對中文繁體字進行編碼。兩個檔案如果都使用一種編碼方案,那是沒有問題的,不過考慮到國際化,還是儘量使用Unicode這種國際標準吧。不過這個跟ZIP沒啥關係,純屬背景介紹。
好了,回到我們前面說的例子,一共有17個字元(包括標點符號),如果用普通Unicode表示,一共是17*2=34位元組。可不可以壓縮呢?所有人一眼都可以看出裡面出現了很多重複的字元,比如裡面出現了好多次容易(實際上是容易加句號三個字元)這個詞,第一次出現的時候用普通的Unicode,第二次出現的“容易。”則可以用(距離、長度)表示,距離的意思是接下來的字元離前面重複的字元隔了幾個,長度則表示有幾個重複字元,上面的例子的第二個“容易。”就表示為(5,3),就是距離為5個字元,長度是3,在解壓縮的時候,解到這個地方的時候,往前跳5個字元,把這個位置的連續3個字元拷貝過來就完成了解壓縮,這實際上不就是指標的概念?沒有錯,跟指標很類似,不過在資料壓縮領域,一般稱為字典編碼,為什麼叫字典呢,當我們去查一個字的時候,我們總是先去目錄查詢這個字在哪一頁,再翻到那一頁去看,指標不也是這樣,指標不就是記憶體的地址,要對一個記憶體進行操作,我們先拿到指標,然後去那塊記憶體去操作。所謂的指標、字典、索引、目錄等等術語,不同的背景可能稱呼不同,但我們要理解他們的本質。如果使用(5,3)這種表示方法,原來需要用6個位元組表示,現在只需要記錄5和3即可。那麼,5和3怎麼記錄呢?一種方法自然還是可以用Unicode,那麼就相當於節省了2個位元組,但是有兩個問題,第一個問題是解壓縮的時候怎麼知道是正常的5和3這兩個字元,還是這只是一個特殊標記呢?所以前面還得加一個標誌來區分一下,到底接下來的Unicode碼是指普通字元,還是指距離和長度,如果是普通Unicode,則直接查Unicode碼錶,如果是距離和長度,則往前面移動一段距離,拷貝即可。第二個問題,還是壓縮程度不行,這麼一弄,感覺壓縮不了多少,如果重複字元比較長那倒是比較划算,因為反正“距離+長度”就夠了,但比如這個例子,如果5和3前面加一個特殊位元組,豈不是又是3個位元組,那還不如不壓縮。咋辦呢?能不能對(5,3)這種整數進行再次壓縮?這裡就利用了我們前面說的一個基本原則:出現的少的整數多編一些位元,出現的多的整數少編一些位元。那麼,比如3、4、5、6、7、8、9這些距離誰出現得多?誰出現的少呢?誰知道?
壓縮之前當然不知道,不過掃描一遍不就知道了?比如,後面那個重複的字串“容易。”按照前面的規則可以表示為(7,3),即離前面重複的字串距離為7,長度為3。(7,3)指著前面跟自己一樣那個字串。那麼,為什麼不指著第一個“容易。”要指著第二個“容易。”呢?如果指著第一個,那就不是(7,3)了,就是(12,3)了。當然,表示為(12,3)也可以解壓縮,但是有一個問題,就是12這個值比7大,大了又怎麼了?我們在生活中會發現一些普遍規律,重複現象往往具有區域性性。比如,你跟一個人說話,你說了一句話以後,往往很快會重複一遍,但是你不會隔了5個小時又重複這句話,這種特點在檔案裡面也存在著,到處都是這種例子,比如你在程式設計的時候,你定義了一個變數int nCount,這個nCount一般你很快就會用到,不會離得很遠。我們前面所說的距離代表了你隔了多久再說這句話,這個距離一般不大,既然如此,應該以離當前字串距離最近的那個作為記錄的依據(也就是指向離自己最近那個重複字串),這樣的話,所有的標記都是一些短距離,比如都是3、4、5、6、7而不會是3、5、78、965等等,如果大多數都是一些短距離,那麼這些短距離就可以用短一些的位元表示,長一些的距離不太常見,則用一些長一些的位元表示。這樣, 總體的表示長度就會減少。好了,我們前面得到了(5,3)、(7、3)這種記錄重複的表示,距離有兩種:5、7;長度只有1種:3。咋編碼?越短越好。
既然表示的位元越短越好,3表示為0、5表示為10、7表示為11,行不行?這樣(5,3),(7,3)就只需要表示為100、110,這樣豈不是很短?貌似可以,貌似很高效。
但解壓縮遇到10這兩個位元的時候,怎麼知道10表示5呢?這種表示方法是一個對映表,稱為碼錶。我們設計的上面這個例子的碼錶如下:
3–>0
5–>10
7–>11
這個碼錶也得傳過去或者記錄在壓縮檔案裡才行啊,否則無法解壓縮,但會不會記錄了碼錶以後整體空間又變大了,會不會起不到壓縮的作用?而且一個碼錶怎麼記錄?碼錶記錄下來也是一堆資料,是不是也需要編碼?碼錶是否可以繼續壓縮?那豈不是又需要新的碼錶?壓縮會不會是一個永無止境的過程?作為一個入門級的同學,大概想到這兒就不容易想下去了。
3、ZIP中的LZ編碼思想
上面我們說的重複字串用指標標記記錄下來,這種方法就是LZ這兩個人提出來的,理解起來比較簡單。後面分析(5,3)這種指標標記應該怎麼編碼的時候,就涉及到一種非常廣泛的編碼方式,Huffman編碼,Huffman大致和夏農是一個時代的人,這種編碼方式是他在MIT讀書的時候提出來的。接下來,我們來看看ZIP是怎麼做的。
以上面的例子,一個很簡單的示意圖如下:
可以看出,ZIP中使用的LZ77演算法和前面分析的類似,當然,如果仔細對比的話,ZIP中使用的演算法和LZ提出來的LZ77演算法其實還是有差異的,不過我建議不用仔細去扣裡面的差異,思想基本是相同的,我們後面會簡要分析一下兩者的差異。LZ77演算法一般稱為“滑動視窗壓縮”,我們前面說過,該演算法的核心是在前面的歷史資料中尋找重複字串,但如果要壓縮的檔案有100MB,是不是從檔案頭開始找?不是,這裡就涉及前面提過的一個規律,重複現象是具有區域性性的,它的基本假設是,如果一個字串要重複,那麼也是在附近重複,遠的地方就不用找了,因此設定了一個滑動視窗,ZIP中設定的滑動視窗是32KB,那麼就是往前面32KB的資料中去找,這個32KB隨著編碼不斷進行而往前滑動。當然,理論上講,把滑動視窗設定得很大,那樣就有更大的概率找到重複的字串,壓縮率不就更高了?初看起來如此,找的範圍越大,重複概率越大,不過仔細想想,可能會有問題,一方面,找的範圍越大,計算量會增大,不顧一切地增大滑動視窗,甚至不設定滑動視窗,那樣的軟體可能不可用,你想想,現在這種方式,我們在壓縮一個大檔案的時候,速度都已經很慢了,如果增大滑動視窗,速度就更慢,從工程實現角度來說,設定滑動視窗是必須的;另一方面,找的範圍越大,距離越遠,出現的距離很多,也不利於對距離進行進一步壓縮吧,我們前面說過,距離和長度最好出現的值越少越好,那樣更好壓縮,如果出現的很多,如何記錄距離和長度可能也存在問題。不過,我相信滑動視窗設定得越大,最終的結果應該越好一些,不過應該不會起到特別大的作用,比如壓縮率提高了5%,但計算量增加了10倍,這顯然有些得不償失。
在第一個圖中,“容易。”是一個重複字串,距離distance=5,字串長度length=3。當對這三個字元壓縮完畢後,接下來滑動視窗向前移動3個字元,要壓縮的是“我…”這個字串,但這個串在滑動視窗內沒找到,所以無法使用distance+length的方式記錄。這種結果稱為literal。literal的中文含義是原義的意思,表示沒有使用distance+length的方式記錄的那些普通字元。literal是不是就用原始的編碼方式,比如Unicode方式表示?ZIP裡不是這麼做的,ZIP把literal認為也是一個數,儘管不能用distance+length表示,但不代表不可以繼續壓縮。另外,如果“我”出現在了滑動視窗內,是不是就可以用distance+length的方式表示?也不是,因為一個字出現重複,不值得用這種方式表示,兩個字呢?distance+length就是兩個整數,看起來也不一定值得,ZIP中確實認為2個位元組如果在滑動視窗內找到重複,也不管,只有3個位元組以上的重複字串,才會用distance+length表示,重複字串的長度越長越好,因為不管多長,都用distance+length表示就行了。
這樣的話,一段字串最終就可以表示成literal、distance+length這兩種形式了。LZ系列演算法的作用到此為止,下面,Phil Katz考慮使用Huffman對上面的這些LZ壓縮後的結果進行二次壓縮。個人認為接下來的過程才是ZIP的核心,所以我們要熟悉一下Huffman編碼。
4、ZIP中的Huffman編碼思想
上面LZ壓縮結果有三類(literal、distance、length),我們拿出distance一類來舉例。distance代表重複字串離前一個一模一樣的字串之間的距離,是一個大於0的整數。如何對一個整數進行編碼呢?一種方法是直接用固定長度表示,比如採用計算機裡面描述一個4位元組整數那樣去記錄,這也是可以的,主要問題當然是浪費儲存空間,在ZIP中,distance這個數表示的是重複字串之間的距離,顯然,一般而言,越小的距離,出現的數量可能越多,而越大的距離,出現的數量可能越少,那麼,按照我們前面所說的原則,小的值就用較少位元表示,大的值就用較多位元表示,在我們這個場景裡,distance當然也不會無限大,比如不會超過滑動視窗的最大長度,假如對一個檔案進行LZ壓縮後,得到的distance值為:
3、6、4、3、4、3、4、3、5
這個例子裡,3出現了4次,4出現了3次,5出現了1次,6出現了1次。當然,不同的檔案得到的結果不同,這裡只是舉一個典型的例子,因為只有4種值,所以我們沒有必要對其它整數編碼。只需要對這4個整數進行編碼即可。
那麼,怎麼設計一個演算法,符合3的編碼長度最短?6的編碼長度最長這種直觀上可行的原則(我們並沒有說這是理論上最優的方式)呢?
看起來似乎很難想出來。我們先來簡化一下,用固定長度表示。這裡有4個整數,只要使用2個位元表示即可。於是這樣表示就很簡單:
00–>3; 01–>4; 10–>5; 11–>6。
00、01這種編碼結果稱為碼字,碼字的平均長度是2。上面這個對應關係即為碼錶,在壓縮時,需要將碼錶放在最前面,後面的數字就用碼字表示,解碼時,先把碼錶記錄在記憶體裡,比如用一個雜湊表記錄下來,解壓縮時,遇到00,就解碼為3等等。
因為出現了9個數,所以全部碼字總長度為18個位元。(我們暫時不考慮記錄碼錶到底要佔多少空間)
想要編碼結果更短,因為3出現的最多,所以考慮把3的碼字縮短點,比如3是不是可以用1個位元表示,這樣才算縮短吧,因為0和1只是二進位制的一個標誌,所以用0還是1沒有本質區別,那麼,我們暫定把3用位元0表示。那麼,4、5、6還能用0開頭的碼字表示呢?
這樣會存在問題,因為4、5、6的編碼結果如果以0開頭,那麼,在解壓縮的時候,遇到位元0,就不知道是表示3還是表示4、5、6了,就無法解碼,當然,似乎理論上也不是不可以,比如可以往後解解看,比如假定0表示3的條件下往後解,如果無效則說明這個假設不對,但這種方式很容易出現兩個字串的編碼結果是一樣的,這個誰來保證?所以,4、5、6都得以1開頭才行,那麼,按照這個原則,4用1個位元也不行,因為5、6要麼以0開頭,要麼以1開頭,就無法編碼了,所以我們將4的碼字增加至2個位元,比如10,於是我們得到了部分碼錶:
0–>3;10–>4。
按照這個道理,5、6既不能以0開頭,也不能以10開頭了,因為同樣存在無法解碼的問題,所以5應該以11開頭,就定為11行不行呢?也不行,因為6就不知道怎麼編碼了,6也不能以0開頭,也不能以10、11開頭,那就無法表示了,所以,迫不得已,我們必須把5擴充套件一位,比如110,那麼,6顯然就可以用111表示了,反正也沒有其他數了。於是我們得到了最終的碼錶:
0–>3;10–>4;110–>5;111–>6。
看起來,編碼結果只能是這樣了,我們來算一下,碼字的總長度減少了沒有,原來的9個數是3、6、4、3、4、3、4、3、5,分別對應的碼字是:
0、111、10、0、10、0、10、0、110
算一下,總共16個位元,果然比前面那種方式變短了。我們在前面的設計過程中,是按照這些值出現次數由高到底的順序去找碼字的,比如先確定3,再確定4、5、6等等。按照一個碼字不能是另一個碼字的字首這一規則,逐步獲得所有的碼字。這種編碼規則有一個專用術語,稱為字首碼。Huffman編碼基本上就是這麼做的,把出現頻率排個序,然後逐個去找,這個逐個去找的過程,就引入了二叉樹。不過Huffman的演算法一般是從頻率由低到高排序,從樹的下面依次往上合併,不過本質上沒區別,理解思想即可。上面的結果可以用一顆二叉樹表示為下圖:
這棵樹也稱為碼樹,其實就是碼錶的一種形式化描述,每個節點(除了葉子節點)都會分出兩個分支,左分支代表位元0,右邊分支代表1,從根節點到葉子節點的一個位元序列就是碼字。因為只有葉子節點可以是碼字,所以這樣也符合一個碼字不能是另一個碼字的字首這一原則。說到這裡,可以說一下另一個話題,就是一個對映表map在記憶體中怎麼儲存,沒有相關經驗的可以跳過,map實現的是key–>value這樣的一個表,map的儲存一般有雜湊表和樹形儲存兩類,樹形儲存就可以採用上面這棵樹,樹的中間節點並沒有什麼含義,葉子節點的值表示value,從根節點到葉子節點上分支的值就是key,這樣比較適合儲存那些key由多個不等長字元組成的場合,比如key如果是字串,那麼把二叉樹的分支擴充套件很多,成為多叉樹,每個分支就是a,b,c,d這種字元,這棵樹也就是Trie樹,是一種很好使的資料結構。利用樹的遍歷演算法,就實現了一個有序Map。
好了,我們理解了Huffman編碼的思想,我們來看看distance的實際情況。ZIP中滑動視窗大小固定為32KB,也就是說,distance的值範圍是1-32768。那麼,通過上面的方式,統計頻率後,就得到32768個碼字,按照上面這種方式可以構建出來。於是我們會遇到一個最大的問題,那就是這棵樹太大了,怎麼記錄呢?
好了,個人認為到了ZIP的核心了,那就是碼樹應該怎麼縮小,以及碼樹怎麼記錄的問題。
5、ZIP中Huffman碼樹的記錄方式
分析上面的例子,看看這個碼錶:
0–>3;10–>4;110–>5;111–>6。
我們之前提過,0和1就是二進位制的一個標誌,互換一下其實根本不影響編碼長度,所以,下面的碼錶其實是一樣的:
1–>3;00–>4;010–>5;011–>6。
1–>3;01–>4;000–>5;001–>6。
0–>3;11–>4;100–>5;101–>6。
。。。。。
這些都可以,而且編碼長度完全一樣,只是碼字不同而已。
對比一下第一個和第二個例子,對應的碼樹是這個樣子:
也就是說,我們把碼樹的任意節點的左右分支旋轉(0、1互換),也可以稱為樹的左右互換,其實不影響編碼長度,也就是說,這些碼錶其實都是一樣好的,使用哪個都可以。
這個規律暗示了什麼資訊呢?暗示了碼錶可以怎麼記錄呢?Phil Katz當年在牢裡也深深地思考了這一問題。
為了體會Phil Katz當時的心情,我們有必要盯著這兩棵樹思考幾分鐘:怎麼把一顆樹用最少的位元記錄下來?
Phil Katz當時思考的邏輯我猜是這樣的,既然這些樹的壓縮程度都一樣,那乾脆使用最特殊的那棵樹,反正壓縮程度都一樣,只要ZIP規定了這棵樹的特殊性,那麼我記錄的資訊就可以最少,這種特殊化的思想在後面還會看到。不同的樹當然有不同的特點,比如資料結構裡面常見的平衡樹也是一類特殊的樹,他選的樹就是左邊那棵,這棵樹有一個特點,越靠左邊越淺,越往右邊越深,是這些樹中最不平衡的樹。ZIP裡的壓縮演算法稱為Deflate演算法,這棵樹也稱為Deflate樹,對應的解壓縮演算法稱為Inflate,Deflate的大致意思是把輪胎放氣了,意為壓縮;Inflate是給輪胎打氣的意思,意為解壓。那麼,Deflate樹的特殊性又帶來什麼了?
揭曉答案吧,Phil Katz認為換來換去只有碼字長度不變,如果規定了一類特殊的樹,那麼就只需要記錄碼字長度即可。比如,一個有效的碼錶是0–>3;10–>4;110–>5;111–>6。但只需要記錄這個對應關係即可:
3 4 5 6
1 2 3 3
也就是說,把1、2、3、3記錄下來,解壓一邊照著左邊那棵樹的形狀構造一顆樹,然後只需要1、2、3、3這個資訊自然就知道是0、10、110、111。這就是Phil Katz想出來的ZIP最核心的一點:這棵碼樹用碼字長度序列記錄下來即可。
當然,只把1、2、3、3這個序列記錄下來還不行,比如不知道111對應5還是對應6?
所以,構造出樹來只是知道了有哪些碼字了,但是這些碼字到底對應哪些整數還是不知道。
Phil Katz於是又出現了一個想法:記錄1、2、3、3還是記錄1、3、2、3,或者3、3、2、1,其實都能構造出這棵樹來,那麼,為什麼不按照一個特殊的順序記錄呢?這個順序就是整數的大小順序,比如上面的3、4、5、6是整數大小順序排列的,那麼,記錄的順序就是1、2、3、3。而不是2、3、3、1。
好了,根據1、2、3、3這個資訊構造出了碼字,這些碼字對應的整數一個比一個大,假如我們知道編碼前的整數就是3、4、5、6這四個數,那就能對應起來了,不過到底是哪四個還是不知道啊?這個整數可以表示距離啊,距離不知道怎麼去解碼LZ?
Phil Katz又想了,既然distance的範圍是1-32768,那麼就按照這個順序記錄。上面的例子1和2沒有,那就記錄長度0。所以記錄下來的碼字長度序列為:
0、0、1、2、3、3、0、0、0、0、0、。。。。。。。。。。。。
這樣就知道構造出來的碼字對應哪個整數了吧,但因為distance可能的值很多(32768個),但實際出現的往往不多,中間會出現很多0(也就是根本就沒出現這個距離),不過這個問題倒是可以對連續的0做個特殊標記,這樣是不是就行了呢?還有什麼問題?
我們還是要站在時代的高度來看待這個問題。我們明白,每個distance肯定對應唯一一個碼字,使用Huffman編碼可以得到所有碼字,但是因為distance可能非常多,雖然一般不會有32768這麼多,但對一個大些的檔案進行LZ編碼,distance上千還是很正常的,所以這棵樹很大,計算量、消耗的記憶體都容易超越了那個時代的硬體條件,那麼怎麼辦呢?這裡再次體現了Phil Katz對Huffman編碼掌握的深度,他把distance劃分成多個區間,每個區間當做一個整數來看,這個整數稱為Distance Code。當一個distance落到某個區間,則相當於是出現了那個Code,多個distance對應於一個Distance Code,Distance雖然很多,但Distance Code可以劃分得很少,只要我們對Code進行Huffman編碼,得到Code的編碼後,Distance Code再根據一定規則擴充套件出來。那麼,劃分多少個區間?怎麼劃分割槽間呢?我們分析過,越小的距離,出現的越多;越大的距離,出現的越少,所以這種區間劃分不是等間隔的,而是越來越稀疏的,類似於下面的劃分:
1、2、3、4這四個特殊distance不劃分,或者說1個Distance就是1個區間;5,6作為一個區間;7、8作為一個區間等等,基本上,區間的大小都是1、2、4、8、16、32這麼遞增的,越往後,涵蓋的距離越多。為什麼這麼分呢?首先自然是距離越小出現頻率越高,所以距離值小的時候,劃分密一些,這樣相當於一個放大鏡,可以對小的距離進行更精細地編碼,使得其編碼長度與其出現次數儘量匹配;對於距離較大那些,因為出現頻率低,所以可以適當放寬一些。另一個原因是,只要知道這個區間Code的碼字,那麼對於這個區間裡面的所有distance,後面追加相應的多個位元即可,比如,17-24這個區間的Huffman碼字是110,因為17-24這個區間有8個整數,於是按照下面的規則即可獲得其distance對應的碼字:
17–>110 000
18–>110 001
19–>110 010
20–>110 011
21–>110 100
22–>110 101
23–>110 110
24–>110 111
這樣計算複雜度和記憶體消耗是不是很小了,因為需要進行Huffman編碼的整數一下字變少了,這棵樹不會多大,計算起來時間和空間複雜度降低,擴充套件起來也比較簡單。當然,從理論上來說,這樣的編碼方式實際上將編碼過程分為了兩級,並不是理論上最優的,把所有distance當作一個大空間去編碼才可能得到最優結果,不過還是那句話,工程實現的限制,在壓縮軟體實現上,我們不能用壓縮率作為衡量一個演算法優劣的唯一指標,其實耗費的時間和空間同樣是指標,所以需要看綜合指標。很多其他軟體也一樣,擴充套件性、時間空間複雜度、穩定性、移植性、維護的方便性等等是工程上很重要的東西。我沒有看過RAR是如何壓縮的,有可能是在類似的地方進行了改進,如果如此,那也是站在巨人的肩膀上,而且硬體條件不同,進行一些改進也並不奇怪。
具體來說,Phil Katz把distance劃分為30個區間,如下圖:
這個圖是我從David Salomon的《Data Compression The Complete Reference》這本書(第四版)中拷貝出來的,下面的有些圖也是,如果需要對資料壓縮排行全面的瞭解,這本書幾乎是最全的了,強烈推薦。
當然,你要問為什麼是30個區間,我也沒分析過,也許是複雜度和壓縮率經過試驗之後的一種折中吧。
其中,左邊的Code表示區間的編號,是0-29,共30個區間,這只是個編號,沒有特別的含義,但Huffman就是對0-29這30個Code進行編碼的,得到區間的碼字;
bits表示distance的碼字需要在Code的碼字基礎上擴充套件幾位,比如0就表示不擴充套件,最大的13表示要擴充套件13位,因此,最大的區間包含的distance數量為8192個。
Distance一列則表示這個區間涵蓋的distance範圍。
理解了碼樹如何有效記錄,以及如何縮小碼樹的過程,我覺得就理解了ZIP的精髓。
6、ZIP中literal和length的壓縮方式
說完了distance,LZ編碼結果還有兩類:literal和length。這兩類也利用了類似於distance的方式進行壓縮。
前面分析過,literal表示未匹配的字元,我們前面之所以拿漢字來舉例,完全是為了便於理解,ZIP之所以是通用壓縮,它實際上是針對位元組作為基本字元來編碼的,所以一個literal至多有256種可能。
length表示重複字串長度,length=1當然不會出現,因為一個字元不值得用distance+length去記錄,重複字串當然越長越好,Phil Katz(下面還是簡稱PK了,拷貝太麻煩)認為,length=2也不值得用這種方式記錄,還是太短了,所以PK把length最小值認為是3,必須3個以上字元的字串出現重複才用distance+length記錄。那麼,最大的length是多少呢?理論上當然可以很長很長,比如一個檔案就是連續的0,這個重複字串長度其實接近於這個檔案的實際長度。但是PK把length的範圍做了限制,限定length的個數跟literal一樣,也只有256個,因為PK認為,一個重複字串達到了256個已經很長了,概率非常小;另外,其實哪怕超過了256,我還是認為是一段256再加上另外一段,增加一個distance+length就行了嘛,並不影響結果。而且這樣做,我想同樣也考慮了硬體條件吧。
初看有點奇怪的在於,將literal和length二者合二為一,什麼意思呢?就是對這兩種整數(literal本質上是一個位元組)共用一個Huffman碼錶,一會兒解釋為什麼。PK對Huffman的理解我覺得達到了爐火純青的地步,前面已經看到,後面還會看到。他認為Huffman編碼的輸入反正說白了就是一個集合的元素就行,無論這個元素是啥,所以多個集合看做一個集合當作Huffman編碼的輸入沒啥問題。literal用整數0-255表示,256是一個結束標誌,解碼以後結果是256表示解碼結束;從257開始表示length,所以257這個數表示length=3,258這個數表示length=4等等,但PK也不是一直這麼一一對應,和distance一樣,也是把length(總共256個值)劃分為29個區間,其結果如下圖:
其中的含義和distance類似,不再贅述,所以literal/length這個Huffman編碼的輸入元素一共285個,其中256表示解碼結束標誌。為什麼要把二者合二為一呢?因為當解碼器接收到一個位元流的時候,首先可以按照literal/length這個碼錶來解碼,如果解出來是0-255,就表示未匹配字元,如果是256,那自然就結束,如果是257-285之間,則表示length,把後面擴充套件位元加上形成length後,後面的位元流肯定就表示distance,因此,實際上通過一個Huffman碼錶,對各類情況進行了統一,而不是通過加一個什麼標誌來區分到底是literal還是重複字串。
好了,理解了上面的過程,就理解了ZIP壓縮的第二步,第一步是LZ編碼,第二步是對LZ編碼後結果(literal、distance、length)進行的再編碼,因為literal/length是一個碼錶,我稱其為Huffman碼錶1,distance那個碼錶稱為Huffman碼錶2。前面我們已經分析了,Huffman碼樹用一個碼字長度序列表示,稱為CL(Code Length),記錄兩個碼錶的碼字長度序列分別記為CL1、CL2。碼樹記錄下來,對literal/length的編碼位元流稱為LIT位元流;對distance的編碼位元流稱為DIST位元流。
按照上面的方法,LZ的編碼結果就變成四塊:CL1、CL2、LIT位元流、DIST位元流。CL1、CL2是碼字長度的序列,這個序列說白了就是一堆正整數,因此,PK繼續深挖,認為這個序列還應該繼續壓縮,也就是說,對碼錶進行壓縮。
7、ZIP中對CL進行再次壓縮的方法
這裡仍然沿用Huffman的想法,因為CL也是一堆整數,那麼當然可以再次應用Huffman編碼。不過在這之前,PK對CL序列進行了一點處理。這個處理也是很精巧的。
CL序列表示一系列整數對應的碼字長度,對於literal/length來說,總共有0-285這麼多符號,所以這個序列長度為286,每個符號都有一個碼字長度,當然,這裡面可能會出現大段連續的0,因為某些字元或長度不存在,尤其是對英文文字編碼的時候,非ASCII字元就根本不會出現,length較大的值出現概率也很小,所以出現大段的0是很正常的;對於distance也類似,也可能出現大段的0。PK於是先進行了一下游程編碼。在說什麼是遊程編碼之前,我們談談PK對CL序列的認識。
literal/length的編碼符號總共286個(回憶:256個Literal+1個結束標誌+29個length區間),distance的編碼符號總共30個(回憶:30個區間),所以這顆碼樹不會特別深,Huffman編碼後的碼字長度不會特別長,PK認為最長不會超過15,也就是樹的深度不會超過15,這個是否是理論證明我還沒有分析,有興趣的同學可以分析一下。因此,CL1和CL2這兩個序列的任意整數值的範圍是0-15。0表示某個整數沒有出現(比如literal=0x12, length Code=8, distance Code=15等等)。
什麼叫遊程呢?就是一段完全相同的數的序列。什麼叫遊程編碼呢?說起來原理更簡單,就是對一段連續相同的數,記錄這個數一次,緊接著記錄出現了多少個即可。David的書中舉了這個例子,比如CL序列如下:
4, 4, 4, 4, 4, 3, 3, 3, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 0, 0, 0, 0, 0, 0, 2, 2, 2, 2
那麼,遊程編碼的結果為:
4, 16, 01(二進位制), 3, 3, 3, 6, 16, 11(二進位制), 16, 00(二進位制), 17,011(二進位制), 2, 16, 00(二進位制)
這是什麼意思呢?因為CL的範圍是0-15,PK認為重複出現2次太短就不用遊程編碼了,所以遊程長度從3開始。用16這個特殊的數表示重複出現3、4、5、6個這樣一個遊程,分別後面跟著00、01、10、11表示(實際儲存的時候需要低位元優先儲存,需要把位元倒序來存,博文的一些例子有時候會忽略這點,實際寫程式的時候一定要注意,否則會得到錯誤結果)。於是4,4,4,4,4,這段遊程記錄為4,16,01,也就是說,4這個數,後面還會連續出現了4次。6,16,11,16,00表示6後面還連續跟著6個6,再跟著3個6;因為連續的0出現的可能很多,所以用17、18這兩個特殊的數專門表示0遊程,17後面跟著3個位元分別記錄長度為3-10(總共8種可能)的遊程;18後面跟著7個位元表示11-138(總共128種可能)的遊程。17,011(二進位制)表示連續出現6個0;18,0111110(二進位制)表示連續出現62個0。總之記住,0-15是CL可能出現的值,16表示除了0以外的其它遊程;17、18表示0遊程。因為二進位制實際上也是個整數,所以上面的序列用整數表示為:
4, 16, 1, 3, 3, 3, 6, 16, 3, 16, 0, 17, 3, 2, 16, 0
我們又看到了一串整數,這串整數的值的範圍是0-18。這個序列稱為SQ(Sequence的意思)。因為有兩個CL1、CL2,所以對應的有兩個SQ1、SQ2。
針對SQ1、SQ2,PK用了第三個Huffman碼錶來對這兩個序列進行編碼。通過統計各個整數(0-18範圍內)的出現次數,按照相同的思路,對SQ1和SQ2進行了Huffman編碼,得到的碼流記為SQ1 bits和SQ2 bits。同時,這裡又需要記錄第三個碼錶,稱為Huffman碼錶3。同理,這個碼錶也用相同的方法記錄,也等效為一個碼長序列,稱為CCL,因為至多有0-18個,PK認為樹的深度至多為7,於是CCL的範圍是0-7。
當得到了CCL序列後,PK決定不再折騰,對這個序列用普通的3位元定長編碼記錄下來即可,即000代表0,111代表7。但實際上還有一點小折騰,就是最後這個序列如果全部記錄,那就需要19*3=57個位元,PK認為CL序列裡面CL範圍為0-15,特殊的幾個值是16、17、18,如果把CCL序列位置置換一下,把16、17、18這些放前面,那麼這個CCL序列就很可能最後面跟著一串0(因為CL=14,15這些很可能沒有),所以最後還引入了一個置換,其示意圖如下,分別表示置換前的CCL序列和置換後的CCL。可以看出,16、17、18對應的CCL被放到了前面,這樣如果尾部出現了一些0,就只需要記錄CCL長度即可,後面的0不記錄。可以繼續節省一些位元,不過這個例子尾部置換後只有1個0:
不過粗看起來,這個置換效果並不好,我一開始接觸這個置換的時候,我覺得應該按照16、17、18、0、1、2、3、。。。這樣的順序來儲存,如果按照我理解的,那麼置換後的結果如下:
2、4、0、4、5、5、1、5、0、6、0、0、0、0、0、0、0、0、0
這樣後面的一大串0直接截斷,比PK的方法更短。但PK卻按照上面的順序。我總是認為,我覺得牛人可能出錯了的時候,往往是我自己錯了,所以我又仔細想了一下,上面的順序特點比較明顯,直觀上看,PK認為CL為0和中間的值出現得比較多(放在了前面),但CL比較小的和比較大的出現得比較少(1、15、2、14這些放在了後面,你看,後面交叉著放),在檔案比較小的時候,這種方法效果不算好,上面就是一個典型的例子,但檔案比較大了以後,CL1、CL2碼樹比較大,碼字長度普遍比較長,大部分很可能接近於中間值,那麼這個時候PK的方法可能就體現出優勢了。不得不說,對一個演算法或者資料結構的優化程度,簡直完全取決於程式設計師對那個東西細節的理解的深度。當我仔細研究了ZIP壓縮演算法的過程之後,我對PK這種深夜埋頭冥思苦想的大牛佩服得五體投地。
到此為止,ZIP壓縮演算法的結果已經完畢。這個演算法命名為Deflate演算法。總結一下其編碼流程為:
8、Deflate壓縮資料格式
ZIP的格式實際上就是Deflate壓縮碼流外面套了一層檔案相關的資訊,這裡先介紹Deflate壓縮碼流格式。其格式為:
Header:3個位元,第一個位元如果是1,表示此部分為最後一個壓縮資料塊;否則表示這是.ZIP檔案的某個中間壓縮資料塊,但後面還有其他資料塊。這是ZIP中使用分塊壓縮的標誌之一;第2、3位元表示3個選擇:壓縮資料中沒有使用Huffman、使用靜態Huffman、使用動態Huffman,這是對LZ77編碼後的literal/length/distance進行進一步編碼的標誌。我們前面分析的都是動態Huffman,其實Deflate也支援靜態Huffman編碼,靜態Huffman編碼原理更為簡單,無需記錄碼錶(因為PK自己定義了一個固定的碼錶),但壓縮率不高,所以大多數情況下都是動態Huffman。
HLIT:5位元,記錄literal/length碼樹中碼長序列(CL1)個數的一個變數。後面CL1個數等於HLIT+257(因為至少有0-255總共256個literal,還有一個256表示解碼結束,但length的個數不定)。
HDIST:5位元,記錄distance碼樹中碼長序列(CL2)個數的一個變數。後面CL2個數等於HDIST+1。哪怕沒有1個重複字串,distance都為0也是一個CL。
HCLEN:4位元,記錄Huffman碼錶3中碼長序列(CCL)個數的一個變數。後面CCL個數等於HCLEN+4。PK認為CCL個數不會低於4個,即使對於整個檔案只有1個字元的情況。
接下來是3位元編碼的CCL,一共HCLEN+4個,用以構造Huffman碼錶3;
接下來是對CL1(碼長)序列經過遊程編碼(SQ1:縮短的整數序列)後,並對SQ1繼續用Huffman編碼後的位元流。包含HLIT+257個CL1,其解碼碼錶為Huffman碼錶3,用以構造Huffman碼錶1;
接下來是對CL2(碼長)序列經過遊程編碼(SQ2:縮短的整數序列)後,並對SQ2繼續用Huffman編碼後的位元流。包含HDIST+1個CL2,其解碼碼錶為Huffman碼錶3,用於構造Huffman碼錶2;
總之,上面的資料都是為了構造LZ解碼需要的2個Huffman碼錶。
接下來才是經過Huffman編碼的壓縮資料,解碼碼錶為Huffman碼錶1和碼錶2。
最後是資料塊結束標誌,即literal/length這個碼錶輸入符號位256的編碼位元。
對倒數第1、2內容塊進行解碼時,首先利用Huffman碼錶1進行解碼,如果解碼所得整數位於0-255之間,表示literal未匹配字元,接下來仍然利用Huffman碼錶1解碼;如果位於257-285之間,表示length匹配長度,之後需要利用Huffman碼錶2進行解碼得到distance偏移距離;如果等於256,表示資料塊解碼結束。
9、ZIP檔案格式解析
上面各節對ZIP的原理進行了分析,這一節我們來看一個實際的例子,為了更好地描述,我們儘量把這個例子舉得簡單一些。下面是我隨便從一本書拷貝出來的一段較短的待壓縮的英文文字資料:
As mentioned above,there are many kinds of wireless systems other than cellular.
這段英文文字長度為80位元組。經過ZIP壓縮後,其內容如下:
可以看到,第1、2位元組就是PK。看著怎麼比原文還長,這怎麼叫壓縮?實際上,這裡面大部分內容是ZIP的檔案標記開銷,真正壓縮的內容(也就是我們前面提到的Deflate資料,劃線部分都是ZIP檔案開銷)其實肯定要比原文短(否則ZIP不會啟用壓縮),我們這個例子是個短文字,但對於更長的文字而言,那ZIP檔案總體長度肯定是要短於原始文字的。上面的這個ZIP檔案,可以看到好幾個以PK開頭的區域,也就是不同顏色的劃線區域,這些其實都是ZIP檔案本身的開銷。
所以,我們首先來看一看ZIP的格式,其格式定義為:
[local file header 1]
[file data 1]
[data descriptor 1]
……….
[local file header n]
[file data n]
[data descriptor n]
[archive decryption header]
[archive extra data record]
[central directory]
[zip64 end of central directory record]
[zip64 end of central directory locator]
[end of central directory record]
local file header+file data+data descriptor這是一段ZIP壓縮資料,在一個ZIP檔案裡,至少有一段,至多那就不好說了,假如你要壓縮的檔案一共有10個,那這個地方至少會有10段,ZIP對每個檔案進行了獨立壓縮,RAR在此進行了改進,將多個檔案聯合起來進行壓縮,提高了壓縮率。local file header的格式如下:
可見,起始的4個位元組就是0x50(P)、0x4B(K)、0x03、0x04,因為是低位元組優先,所以Signature=0x03044B50.接下來的內容按照上面的格式解析,十分簡單,這個區域在上面ZIP資料的那個圖裡面是紅色劃線區域,之後則是壓縮後的Deflate資料。在檔案的尾部,還有ZIP尾部資料,上面這個例子包含了central directory和end of central directory record,一般這兩部分也是必須的。central directory以0x50、0x4B、0x01、0x02開頭;end of central directory record以0x50、0x4B、0x05、0x06開頭,其含義比較簡單,分別對應於上面ZIP資料那個圖的藍色和綠色部分,下面是兩者的格式:
end of central directory record格式:
這幾張圖是我從網上找的,寫得比較清晰。對於其中的含義,解釋起來也比較簡單,我分析的結果如下:注意ZIP採用的低位元組優先,在一個位元組裡面低位優先,需要反過來看。
Local File Header: (38B,304b)
00001010110100101100000000100000 (signature)
0000000000010100 (version:20)
0000000000000000 (generalBitFlag)
0000000000001000 (compressionMethod:8)
0100110110001110 (lastModTime:19854)
0100010100100101 (lastModDate:17701)
01010100101011010100001100111100 (CRC32)
00000000000000000000000001001000 (compressedSize:72)
00000000000000000000000001010000 (uncompressedSize:80)
0000000000001000 (filenameLength:8)
0000000000000000 (extraFieldLength:0)
0010101010100110110011100010111001110100001011100001111000101110 (fileName:Test.txt)
(extraField)
Central File Header: (54B,432b)
00001010110100101000000001000000 (signature)
0000000000010100 (versionMadeBy:20)
0000000000010100 (versionNeeded:20)
0000000000000000 (generalBitFlag)
0000000000001000 (compressionMethod:8)
0100110110001110 (lastModTime:19854)
0100010100100101 (lastModDate:17701)
01010100101011010100001100111100 (CRC32)
00000000000000000000000001001000 (compressedSize:72)
00000000000000000000000001010000 (uncompressedSize:80)
0000000000001000 (filenameLength:8)
0000000000000000 (extraFieldLength:0)
0000000000000000 (fileCommenLength:0)
0000000000000000 (diskNumberStart)
0000000000000001 (internalFileAttr)
10000001100000000000000000100000 (externalFileAttr)
00000000000000000000000000000000 (relativeOffsetLocalHeader)
0010101010100110110011100010111001110100001011100001111000101110 (fileName:Test.txt)
(extraField)
(fileComment)
end of Central Directory Record: (22B,176b)
00001010110100101010000001100000 (signature)
0000000000000000 (numberOfThisDisk:0)
0000000000000000 (numberDiskCentralDirectory:0)
0000000000000001 (EntriesCentralDirectDisk:1)
0000000000000001 (EntriesCentralDirect:1)
00000000000000000000000000110110 (sizeCentralDirectory:54)
00000000000000000000000001101110 (offsetStartCentralDirectory:110)
0000000000000000 (fileCommentLength:0)
(fileComment)
Local File Header Length:304
Central File Header Length:432
End Central Directory Record Length:176
可見,開銷總的長度為38+54+22=114位元組,整個檔案長度為186位元組,因此Deflate壓縮資料長度為72位元組(576位元)。儘管這裡看起來只是從80位元組壓縮到72位元組,那是因為這是一段短文字,重複字串出現較少,但如果文字較長,那壓縮率就會增加,這裡只是舉個例子。
下面對其中的關鍵部分,也就是Deflate壓縮資料進行解析。
10、Deflate解碼過程例項分析
我們按照ZIP格式把Deflate壓縮資料(72位元組)提取出來,如下(每行8位元組):
1010100001010011100010111011000000000001000001000011000010100010
1000101110101010011110110000000001100011101110000011100010100101
0101001111001100000010001101001010010010000101101010101100001101
1011110100011111100011101111111001110010011101110110011100010101
0010110100010100101100110001100100000100110111101101111000011101
0010001001100110111001000010011001101010101000110110000001110101
0100011010010011100010110111001000111101101001011100101010010111
0111000011111000011110000011010111001011011111111100100010001001
1010001100001110000010101010111101101010100101111101011111100000
Deflate格式除了上面的介紹,也可以參考RFC1951,解析如下:
Header:101, 第一個位元是1,表示此部分為最後一個壓縮資料塊;後面的兩個位元01表示採用動態哈夫曼、靜態哈夫曼、或者沒有編碼的標誌,01表示採用動態Huffman;在RFC1951裡面是這麼說明的:
00 – no compression
01 – compressed with fixed Huffman codes
10 – compressed with dynamic Huffman codes
11 – reserved (error)
注意,這裡需要按照低位元在先的方式去看,否則會誤以為是靜態Huffman。
接下來:
HLIT:01000,記錄literal/length碼樹中碼長序列個數的一個變數,表示HLIT=2(低位在前),說明後面存在HLIT + 257=259個CL1,CL1即0-258被編碼後的長度,其中0-255表示Literal,256表示無效符號,257、258分別表示Length=3、4(length從3開始)。因此,這裡實際上只出現了兩種重複字串的長度,即3和4。回顧這個圖可以更清楚:
繼續:
HDIST:01010,記錄distance碼樹中碼長序列個數的一個變數,表示HDIST=10,說明後面存在HDIST+1=11個CL2,CL2即Distance Code=0-10被編碼的長度。
繼續:
HCLEN:0111,記錄Huffman碼樹3中碼長序列個數的一個變數,表示HCLEN=14(1110二進位制),即說明緊接著跟著HCLEN+4=18個CCL,前面已經分析過,CCL記錄了一個Huffman碼錶,這個碼錶可以用一個碼長序列表示,根據這個碼長序列可以得到碼錶。於是接下來我們把後面的18*3=54個位元拷貝出來,上面的碼流目前解析為下面的結果:
101(Header) 01000(HLIT) 01010(HDIST) 0111(HCLEN)
000 101 110 110 000 000 000 010 000 010 000 110 000 101 000 101 000 101 (CCL)
110101010011110110000000001100011101110000011100010100101
0101001111001100000010001101001010010010000101101010101100001101
1011110100011111100011101111111001110010011101110110011100010101
0010110100010100101100110001100100000100110111101101111000011101
0010001001100110111001000010011001101010101000110110000001110101
0100011010010011100010110111001000111101101001011100101010010111
0111000011111000011110000011010111001011011111111100100010001001
1010001100001110000010101010111101101010100101111101011111100000
標準的CCL長度為19(回憶一下:CCL範圍為0-18,按照整數大小排序記錄各自的碼字長度),因此最後一個補0。得到序列:
000 101 110 110 000 000 000 010 000 010 000 110 000 101 000 101 000 101 000
其長度分別為(低位在前):
0、5、3、3、0、0、0、2、0、2、0、3、0、5、0、5、0、5、0
前面已經分析過,這個CCL序列實際上是經過一次置換操作得到的,需要進行相反的置換,置換後為:
3、5、5、5、3、2、2、0、0、0、0、0、0、0、0、0、0、5、3
這個就是對應於0-18的碼字長度序列。
根據Deflate樹的構造方式,得到下面的碼錶(Huffman碼錶3):
00 <–> 5
01 <–> 6
100 <–> 0
101 <–> 4
110 <–> 18
11100 <–>1
11101 <–>2
11110 <–>3
11111 <–>17
接下來就是CL1序列,按照前面的指示,一共有259個,分別對應於literal/length:0-258對應的碼字長度序列,我們隊跟著CCL後面的位元按照上面獲得的碼錶進行逐步解碼,在解碼之前,實際上並不知道CL1的位元流長度有多少,需要根據259這個數字來判定,解完了259個整數,表明解析CL1完畢:
101(Header) 01000(HLIT) 01010(HDIST) 0111(HCLEN)
000 101 110 110 000 000 000 010 000 010 000 110 000 101 000 101 000 101 (CCL)
110(18)1010100(7位元,記錄連續的11-138個0,此處一共0010101b=21,即記錄21+11=32個0)
11110(3)110(18)0000000(7位元,記錄連續的11-138個0,此處為全0,即記錄0+11=11個0)
01(6)100(0)01(6)110(18)1110000(7位元,記錄連續的11-138個0,此處為111b=7,即記錄7+11=18個0)
01(6)110(18)0010100(7位元,記錄連續的11-138個0,此處為10100b=20,即記錄20+11=31個0)
101(4)01(6)01(6)00(5)11110(3)01(6)100(0)00(5)00(5)100(0)01(6)101(4)
00(5)101(4)00(5)100(0)100(0)00(5)101(4)101(4)01(6)01(6)01(6)100(0)
00(5)110(18)1101111(7位元,記錄連續的11-138個0,此處為1111011b=123,即記錄123+11=134個0)
統計一下,上面已經解了32+11+18+31+134+30=256個數了,因為總共259個,還差三個:
01(6)00(5)01(6)
好了,CL1位元流解析完畢了,得到的CL1碼長序列為:
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 3 0 0 0 0 0 0 0
0 0 0 0 6 0 6 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 6 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 6 6 5 3 6 0 5 5 0 6 4 5 4 5 0 0 5 4 4 6 6 6
0 5 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 6 5 6
總共259個,每行40個。根據這個序列,同樣按照Deflate樹構造方法,得到literal/length碼錶(Huffman碼錶1)為:
000 –> (System.Char)(看前面的CL1序列,空格對應的ASCII為0x20=32,碼字長度3,即上面序列中第一個3)
001 –>e(System.Char)
0100 –>a(System.Char)
0101 –>l(System.Char)
0110 –>n(System.Char)
0111 –>s(System.Char)
1000 –>t(System.Char)
10010 –>d(System.Char)
10011 –>h(System.Char)
10100 –>i(System.Char)
10101 –>m(System.Char)
10110 –>o(System.Char)
10111 –>r(System.Char)
11000 –>y(System.Char)
11001 –>3(System.Int32)(看前面的CL1序列,對應257,碼字長度5)
110100 –>,(System.Char)
110101 –>.(System.Char)
110110 –>A(System.Char)
110111 –>b(System.Char)
111000 –>c(System.Char)
111001 –>f(System.Char)
111010 –>k(System.Char)
111011 –>u(System.Char)
111100 –>v(System.Char)
111101 –>w(System.Char)
111110 –>-1(System.Int32)(看前面的CL1序列,對應256,碼字長度6)
111111 –>4(System.Int32)(看前面的CL1序列,對應258,碼字長度6)
可以看出,碼錶裡存在兩個重複字串長度3和4,當解碼結果為-1(上面進行了處理,即256),或者說遇到111110的時候,表示Deflate碼流結束。
按照同樣的道理,對CL2序列進行解析,前面已經知道HDIST=10,即有11個CL2整數序列:
11111(17)000(3位元,記錄連續的3-10個0,此處為0,即記錄3個0)
11101(2)11111(17)100(3位元,記錄連續的3-10個0,此處為001b=1,即記錄4個0)
11100(1)100(0)11101(2)
已經結束,總共11個。
於是CL2序列為:
0 0 0 2 0 0 0 0 1 0 2
分別記錄的是distance碼為0-10的碼字長度,根據下面的對應關係,需要進行擴充套件:
比如,第1個碼長2記錄的是Code=3的長度,即Distance=4對應的碼字為:
10 –>4(System.Int32)
第1個碼長1記錄的是Code=8的長度(碼字為0,擴充套件三位000-111),即Distance=17-24對應的碼字為(注意,低位元優先):
0 000 –>17(System.Int32)
0 100 –>18(System.Int32)
0 010 –>19(System.Int32)
0 110 –>20(System.Int32)
0 001 –>21(System.Int32)
0 101 –>22(System.Int32)
0 011 –>23(System.Int32)
0 111 –>24(System.Int32)
注意,擴充套件的時候還是低位元優先。
最後1個碼長2記錄的是Code=10的長度(其實是碼字:11,擴充套件四位0000-1111),即Distance=33-48對應的碼字為:
11 0000 –>33(System.Int32)
11 1000 –>34(System.Int32)
11 0100 –>35(System.Int32)
11 1100 –>36(System.Int32)
11 0010 –>37(System.Int32)
11 1010 –>38(System.Int32)
11 0110 –>39(System.Int32)
11 1110 –>40(System.Int32)
11 0001 –>41(System.Int32)
11 1001 –>42(System.Int32)
11 0101 –>43(System.Int32)
11 1101 –>44(System.Int32)
11 0011 –>45(System.Int32)
11 1011 –>46(System.Int32)
11 0111 –>47(System.Int32)
11 1111 –>48(System.Int32)
至此為止,Huffman碼錶1、Huffman碼錶2已經還原出來,接下來是對LZ壓縮所得到的literal、distance、length進行解碼,目前剩餘的位元流如下,先按照Huffman碼錶1解碼,如果解碼結果是長度(>256),則接下來按照Huffman碼錶2解碼,逐步解碼即可:
[As ]:110110(A)0111(s)000(空格)
[mentioned ]:10101(m)001(e)0110(n)1000(t)10100(i)10110(o)0110(n)001(e)10010(d)000(空格)
[above,]:0100(a)110111(b)10110(o)111100(v)001(e)110100(,)
[there ]:1000(t)10011(h)001(e)10111(r)001(e)000(空格)
[are ]:0100(a)11001(長度3,表示下一個需要用Huffman解碼)10(Distance=4,即重複字串為re空格)
[many ]:10101(m)0100(a)0110(n)11000(y)000(空格)
[kinds ]:111010(k)10100(i)0110(n)10010(d)0111(s)000(空格)
[of ]:10110(o)111001(f)000(空格)
[wireless ]:111101(w)10100(i)10111(r)001(e)0101(l)001(e)0111(s)0111(s)000(空格)
[systems o]:0111(s)11000(y)0111(s)1000(t)001(e)10101(m)11001(長度指示=3,接下來根據distance解碼)0110(Distance=20,即重複字串為s o)
[ther ]:111111(長度指示=4,接下來根據distance解碼)111001(Distance=42,即重複字串為ther)000(空格)
[than ]:1000(t)10011(h)0100(a)0110(n)000(空格)
[cellular.]:111000(c)001(e)0101(l)0101(l)111011(u)0101(l)0100(a)10111(r)110101(.)
[256,結束標誌]111110(結束標誌)0000(位元組補齊的0)
於是解壓縮結果為:
As mentioned above,there are many kinds of wireless systems other than cellular.
再來回顧我們的解碼過程:
譯碼過程:
1、根據HCLEN得到截尾資訊,並參照固定置換表,根據CCL位元流得到CCL整數序列;
2、根據CCL整數序列構造出等價於CCL的二級Huffman碼錶3;
3、根據二級Huffman碼錶3對CL1、CL2位元流進行解碼,得到SQ1整數序列,SQ2整數序列;
4、根據SQ1整數序列,SQ2整數序列,利用遊程編碼規則得到等價的CL1整數序列、CL2整數序列;
5、根據CL1整數序列、CL2整數序列分別構造兩個一級Huffman碼錶:literal/length碼錶、distance碼錶;
6、根據兩個一級Huffman碼錶對後面的LZ壓縮資料進行解碼得到literal/length/distance流;
7、根據literal/length/distance流按照LZ規則進行解碼。
Deflate碼流長度總共為72位元組=576位元,其中:
3位元Header;
5位元HLIT;
5位元HDIST;
4位元HCLEN;
54位元CCL序列碼流;
133位元CL1序列碼流;
34位元CL2序列碼流;
338位元LZ壓縮後的literal/length/distance碼流。
11、ZIP的其它說明
上面各個環節已經詳細分析了ZIP壓縮的過程以及解碼流程,通過對一個例項的解壓縮過程分析,可以徹底地掌握ZIP壓縮和解壓縮的原理和過程。還有一些情況需要說明:
(1)上面的演算法複雜度主要在於壓縮一端,因為需要統計literal/length/distance,建立動態Huffman碼錶,相反解壓只需要還原碼錶後,逐位元解析即可,這也是壓縮軟體的一個典型特點,解壓速度遠快於壓縮速度。
(2)上面我們分析了動態Huffman,對於LZ壓縮後的literal/length/distance,也可以採用靜態Huffman編碼,這主要取決於ZIP在壓縮中看哪種方式更節省空間,靜態Huffman編碼不需要記錄碼錶,因為這個碼錶是固定的,在RFC1951裡面也有說明。對於literal/length碼錶來說,需要對0-285進行編碼,其碼錶為:
對於Distance來說,需要對Code=0-29的數進行編碼,則直接採用5位元表示。Distance和動態Huffman一樣,在此基礎上進行擴充套件。
(3)ZIP中使用的LZ77演算法是一種改進的LZ77。主要區別有兩點:
1)標準LZ77在找到重複字串時輸出三元組(length, distance, 下一個未匹配的字元)(有興趣可以關注LZ77那篇論文);Deflate在找到重複字串時僅輸出雙元組(length, distance)。
2)標準LZ77使用”貪婪“的方式解析,尋找的都是最長匹配字串。Deflate中不完全如此。David Salomon的書裡給了一個例子:
對於上面這個例子,標準LZ77在滑動視窗中查詢最長匹配字串,找到的是”the”與前面的there的前三個字元匹配,這種貪婪解析方式邏輯簡單,但編碼效率不一定最高。Deflate則不急於輸出,跳過t繼續往後檢視,發現”th ne”這5個字元存在重複字串,因此,Deflate演算法會選擇將t作為未匹配字元輸出,而對後面的匹配字串用(length, distance)編碼輸出。顯然,這樣就提高了壓縮效率,因為標準的LZ77找到的重複字串長度為3,而Deflate找到的是5。換句話說,Deflate演算法並不是簡單的尋找最長匹配後輸出,而是會權衡幾種可行的編碼方式,用其中最高效的方式輸出。
12、總結
本篇博文對ZIP中使用的壓縮演算法進行了詳細分析,從一個簡單地例子出發,一步步地分析了PK設計Deflate演算法的思路。最後,通過一個實際例子,分析了其解壓縮流程。總的來看,ZIP的核心在於如何對LZ壓縮後的literal、length、distance進行Huffman編碼,以及如何以最小空間記錄Huffman碼錶。整個過程充滿了對資料結構尤其是樹的深入優化利用。按照上面的分析,如果要對ZIP進行進一步改進,可以考慮的地方也有不少,典型的有:
(1)擴大LZ編碼的滑動視窗的大小;
(2)將Huffman編碼改進為算術編碼等壓縮率更高的方法,畢竟,Huffman的碼字長度必須為整數,這就從理論上限制了它的壓縮率只能接近於理論極限,但難以達到。我記得在JPEG影像編碼領域,以前的JPEG採用了DCT變換編碼+Huffman的方式,現在JPEG2000將其改為小波變換+算數編碼,所以資料壓縮也可以嘗試類似的思路;
(3)將多個檔案進行合併壓縮,ZIP中,不同的檔案壓縮過程沒有關係,獨立進行,如果將它們合併起來一起進行壓縮,壓縮率可以得到進一步提高。
描述分析有誤的地方,敬請指正。針對資料壓縮相關的話題,後續會對HBase列壓縮等等進行分析,看看ZIP這種檔案壓縮和HBase這種資料庫資料壓縮的區別和聯絡。