無損數據壓縮是一件奇妙的事情,想一想,一串任意的數據能夠根據一定的規則轉換成只有原來 1/2 - 1/5 長度的數據,并且能夠按照相應的規則還原到原來的樣子,聽起來真是很酷。

半 年前,苦熬過初學 vc 時那段艱難的學習曲線的我,對 MFC、SDK 開始失望和不滿,這些雖然不算易學,但和 DHTML 沒有實質上的區別,都是調用微軟提供的各種各樣的函數,不需要你自己去創建一個窗口,多線程編程時,也不需要你自己去分配 CPU 時間。我也做過驅動,同樣,有DDK(微軟驅動開發包),當然,也有 DDK 的“參考手冊”,連一個最簡單的數據結構都不需要你自己做,一切都是函數、函數……
微軟的高級程序員編寫了函數讓我們這些搞應用的去調用,我不想在這里貶低搞應用的人,正是這些應用工程師連接起了科學和社會之間的橋梁,將來可以做銷售,做管理,用自己逐漸積累起來的智慧和經驗在社會上打拼。
但 是,在技術上來說,誠實地說,這并不高深,不是嗎?第一流的公司如微軟、Sybase、Oracle 等總是面向社會大眾的,這樣才能有巨大的市場。但是他們往往也是站在社會的最頂層的:操作系統、編譯器、數據庫都值得一代代的專家去不斷研究。這些帝國般 的企業之所以偉大,恐怕不是“有經驗”、“能吃苦”這些中國特色的概念所能涵蓋的,艱深的技術體系、現代的管理哲學、強大的市場能力都是缺一不可的吧。我 們既然有志于技術,并且正在起步階段,何必急不可耐地要轉去做“管理”,做“青年才俊”,那些所謂的“成功人士”的根底能有幾何,這樣子浮躁,胸中的規模 和格局能有多大?

在我發現vc只是一個用途廣泛的編程工具,并不能代表“知識”、“技術”的時候,我有些失落,無所不能的不是我,而是 MFC、SDK、DDK,是微軟的工程師,他們做的,正是我想做的,或者說,我也想成為那種層次的人,現在我知道了,他們是專家,但這不會是一個夢,有一 天我會做到的,為什么不能說出我的想法呢。
那時公司做的系統里有一個壓縮模塊,領導找了一個 zlib 庫,不讓我自己做壓縮算法,站在公司的立場上,我很理解,真的很理解,自己做算法要多久啊。但那時自己心中隱藏的一份倔強驅使我去尋找壓縮原理的資料,我 完全沒有意識到,我即將打開一扇大門,進入一個神奇的“數據結構”的世界?!坝嬎銠C藝術”的第一線陽光,居然也照到了我這樣一個平凡的人的身上。

上面說到“計算機藝術”,或者進一步細化說“計算機編程藝術”,聽起來很深奧,很高雅,但是在將要進入專業的壓縮算法的研究時,我要請大家做的第一 件事情是:忘掉自己的年齡、學歷,忘掉自己的社會身份,忘掉編程語言,忘掉“面向對象”、“三層架構”等一切術語。把自己當作一個小孩,有一雙求知的眼 睛,對世界充滿不倦的、單純的好奇,唯一的前提是一個正常的具有人類理性思維能力的大腦。
下面就讓我們開始一段神奇的壓縮算法之旅吧:


1. 原理部分:
  有兩種形式的重復存在于計算機數據中,zip 就是對這兩種重復進行了壓縮。
  一種是短語形式的重復,即三個字節以上的重復,對于這種重復,zip用兩個數字:1.重復位置距當前壓縮位置的距離;2.重復的長度,來表示這個重復,假設這兩個數字各占一個字節,于是數據便得到了壓縮,這很容易理解。
   一個字節有 0 - 255 共 256 種可能的取值,三個字節有 256 * 256 * 256 共一千六百多萬種可能的情況,更長的短語取值的可能情況以指數方式增長,出現重復的概率似乎極低,實則不然,各種類型的數據都有出現重復的傾向,一篇論文 中,為數不多的術語傾向于重復出現;一篇小說,人名和地名會重復出現;一張上下漸變的背景圖片,水平方向上的像素會重復出現;程序的源文件中,語法關鍵字 會重復出現(我們寫程序時,多少次前后copy、paste?),以幾十 K 為單位的非壓縮格式的數據中,傾向于大量出現短語式的重復。經過上面提到的方式進行壓縮后,短語式重復的傾向被完全破壞,所以在壓縮的結果上進行第二次短 語式壓縮一般是沒有效果的。
  第二種重復為單字節的重復,一個字節只有256種可能的取值,所以這種重復是必然的。其中,某些字節出現次數可能 較多,另一些則較少,在統計上有分布不均勻的傾向,這是容易理解的,比如一個 ASCII 文本文件中,某些符號可能很少用到,而字母和數字則使用較多,各字母的使用頻率也是不一樣的,據說字母 e 的使用概率最高;許多圖片呈現深色調或淺色調,深色(或淺色)的像素使用較多(這里順便提一下:png 圖片格式是一種無損壓縮,其核心算法就是 zip 算法,它和 zip 格式的文件的主要區別在于:作為一種圖片格式,它在文件頭處存放了圖片的大小、使用的顏色數等信息);上面提到的短語式壓縮的結果也有這種傾向:重復傾向 于出現在離當前壓縮位置較近的地方,重復長度傾向于比較短(20字節以內)。這樣,就有了壓縮的可能:給 256 種字節取值重新編碼,使出現較多的字節使用較短的編碼,出現較少的字節使用較長的編碼,這樣一來,變短的字節相對于變長的字節更多,文件的總長度就會減 少,并且,字節使用比例越不均勻,壓縮比例就越大。
  在進一步討論編碼的要求以及辦法前,先提一下:編碼式壓縮必須在短語式壓縮之后進行,因為 編碼式壓縮后,原先八位二進制值的字節就被破壞了,這樣文件中短語式重復的傾向也會被破壞(除非先進行解碼)。另外,短語式壓縮后的結果:那些剩下的未被 匹配的單、雙字節和得到匹配的距離、長度值仍然具有取值分布不均勻性,因此,兩種壓縮方式的順序不能變。
  在編碼式壓縮后,以連續的八位作為一 個字節,原先未壓縮文件中所具有的字節取值不均勻的傾向被徹底破壞,成為隨機性取值,根據統計學知識,隨機性取值具有均勻性的傾向(比如拋硬幣試驗,拋一 千次,正反面朝上的次數都接近于 500 次)。因此,編碼式壓縮后的結果無法再進行編碼式壓縮。
  短語式壓縮和編碼式壓縮是目前計算機科學界研究出的僅有的兩種無損壓縮方法,它們都無法重復進行,所以,壓縮文件無法再次壓縮(實際上,能反復進行的壓縮算法是不可想象的,因為最終會壓縮到 0 字節)。
=====================================

(補充)

壓縮文件無法再次壓縮是因為:
1. 短語式壓縮去掉了三個字節以上的重復,壓縮后的結果中包含的是未匹配的單雙字節,和匹配距離、長度的組合。這個結果當然仍然可能包含三個字節以上的重復, 但是概率極低。因為三個字節有 256 * 256 * 256 共一千六百多萬種可能的情況,一千六百萬分之一的概率導致匹配的距離很長,需要二進制數24位來表示這個匹配距離,再加上匹配長度就超過了三個字節,得不 償失。所以只能壓縮掉原始文件中“自然存在的,并非隨機的短語式重復傾向”。
2.編碼式壓縮利用各個單字節使用頻率不一樣的傾向,使定長編碼變為 不定長編碼,給使用頻率高的字節更短的編碼,使用頻率低的字節更長的編碼,起到壓縮的效果。如果把編碼式壓縮的“結果”按照8位作為1字節,重新統計各字 節的使用頻率,應該是大致相等的。因為新的字節使用頻率是隨機的。相等的頻率再去變換字節長短是沒有意義的,因為變短的字節沒有比變長的字節更多。

=======================================

  短語式重復的傾向和字節取值分布不均勻的傾向是可以壓縮的基礎,兩種壓縮的順序不能互換的原因也說了,下面我們來看編碼式壓縮的要求及方法:

首先,為了使用不定長的編碼表示單個字符,編碼必須符合“前綴編碼”的要求,即較短的編碼決不能是較長編碼的前綴,反過來說就是,任何一個字符的編碼,都不是由另一個字符的編碼加上若干位 0 或 1 組成,否則解壓縮程序將無法解碼。
看一下前綴編碼的一個最簡單的例子:


符號 編碼
A 0
B 10
C 110
D 1110
E 11110

有了上面的碼表,你一定可以輕松地從下面這串二進制流中分辨出真正的信息內容了:

1110010101110110111100010 - DABBDCEAAB

要構造符合這一要求的二進制編碼體系,二叉樹是最理想的選擇??疾煜旅孢@棵二叉樹:

        根(root)
       0  |   1
       +-------+--------+
    0  | 1   0  |  1
    +-----+------+  +----+----+
    |     | |     |
    a      | d     e
     0  |  1
     +-----+-----+
     |     |
     b     c

要編碼的字符總是出現在樹葉上,假定從根向樹葉行走的過程中,左轉為0,右轉為1,則一個字符的編碼就是從根走到該字符所在樹葉的路徑。正因為字符只能出現在樹葉上,任何一個字符的路徑都不會是另一字符路徑的前綴路徑,符合要求的前綴編碼也就構造成功了:

a - 00 b - 010 c - 011 d - 10 e - 11


接下來來看編碼式壓縮的過程:
為了簡化問題,假定一個文件中只出現了 a,b,c,d ,e五種字符,它們的出現次數分別是
a : 6次
b : 15次
c : 2次
d : 9次
e : 1次
如果用定長的編碼方式為這四種字符編碼: a : 000 b : 001 c : 010 d : 011 e : 100
那么整個文件的長度是 3*6 + 3*15 + 3*2 + 3*9 + 3*1 = 99

用二叉樹表示這四種編碼(其中葉子節點上的數字是其使用次數,非葉子節點上的數字是其左右孩子使用次數之和):

          根
           |
      +---------33---------+
      |        |
   +----32---+      +----1---+
   |    |     |    |
+-21-+    +-11-+    +--1--+  
|   |   |   |    |   |
6   15  2  9    1   

(如果某個節點只有一個子節點,可以去掉這個子節點。)

         根
         |
        +------33------+
       |     |
    +-----32----+     1
    |      |
  +--21--+  +--11--+
  |   |  |   |
  6   15 2    9

現在的編碼是: a : 000 b : 001 c : 010 d : 011 e : 1 仍然符合“前綴編碼”的要求。

第一步:如果發現下層節點的數字大于上層節點的數字,就交換它們的位置,并重新計算非葉子節點的值。
先交換11和1,由于11個字節縮短了一位,1個字節增長了一位,總文件縮短了10位。

           根
            |
       +----------33---------+
       |        |
   +-----22----+     +----11----+
   |     |     |     |
+--21--+    1      2     9
|     |
6   15

再交換15和1、6和2,最終得到這樣的樹:

           根
            |
       +----------33---------+
       |        |
     +-----18----+    +----15----+
    |      |    |     |
  +--3--+    15   6     9
  |   |
  2   1

這時所有上層節點的數值都大于下層節點的數值,似乎無法再進一步壓縮了。但是我們把每一層的最小的兩個節點結合起來,常會發現仍有壓縮余地。

第二步:把每一層的最小的兩個節點結合起來,重新計算相關節點的值。

在上面的樹中,第一、二、四三層都只有一或二個節點,無法重新組合,但第三層上有四個節點,我們把最小的3和6結合起來,并重新計算相關節點的值,成為下面這棵樹。

           根
            |
       +----------33---------+
       |         |
    +------9-----+    +----24----+
    |      |    |     |
   +--3--+    6   15    9
   |   |
  2  1

然后,再重復做第一步。
這時第二層的9小于第三層的15,于是可以互換,有9個字節增長了一位,15個字節縮短了一位,文件總長度又縮短了6位。然后重新計算相關節點的值。

           根
            |
       +----------33---------+
       |        |
       15     +----18----+ 
            |    |
         +------9-----+   9
         |      |
         +--3--+   6
         |   |
         2  1

這時發現所有的上層節點都大于下層節點,每一層上最小的兩個節點被并在了一起,也不可能再產生比同層其他節點更小的父節點了。

這時整個文件的長度是 3*6 + 1*15 + 4*2 + 2*9 + 4*1 = 63

這時可以看出編碼式壓縮的一個基本前提:各節點之間的值要相差比較懸殊,以使某兩個節點的和小于同層或下層的另一個節點,這樣,交換節點才有利益。
所以歸根結底,原始文件中的字節使用頻率必須相差較大,否則將沒有兩個節點的頻率之和小于同層或下層其他節點的頻率,也就無法壓縮。反之,相差得越懸殊,兩個節點的頻率之和比同層或下層節點的頻率小得越多,交換節點之后的利益也越大。

在這個例子中,經過上面兩步不斷重復,得到了最優的二叉樹,但不能保證在所有情況下,都能通過這兩步的重復得到最優二叉樹,下面來看另一個例子:

                         根
                         |
              +---------19--------+
             ?。                  。?br />     ?。保玻           。?br />      |             ?。?br /> ?。担     。罚?br /> ?。      。     。      。?br />+-2-+  ?。常 。常  。矗?br />|   |  ?。  。 。  。  。  。?br />1  ?。薄  。薄  。病 。薄  。病  。病  。?br />
這個例子中,所有上層節點都大于等于下層節點,每一層最小的兩個節點結合在了一起,但仍然可以進一步優化:


                         根
                        ?。?br />             ?。保梗?br />             ?。                  。?br />     ?。保玻           。?br />     ?。             。?br /> ?。矗     。福?br /> ?。      。     。      。?br />+-2-+   +-2-+ ?。矗  。矗?br />|  ?。  。  。 。  。  。  。?br />1  ?。薄  。薄  。薄 。病  。病  。病  。?br />
通過最低一層的第4第5個節點對換,第3層的8大于第2層的7。
到這里,我們得出這樣一個結論:一棵最優二叉編碼樹(所有上層節點都無法和下層節點交換),必須符合這樣兩個條件:
1.所有上層節點都大于等于下層節點。
2.某節點,設其較大的子節點為m,較小的子節點為n,m下的任一層的所有節點都應大于等于n下的該層的所有節點。

當符合這兩個條件時,任一層都無法產生更小的節點去和下層節點交換,也無法產生更大的節點去和上層節點交換。

上 面的兩個例子是比較簡單的,實際的文件中,一個字節有256種可能的取值,所以二叉樹的葉子節點多達256個,需要不斷的調整樹形,最終的樹形可能非常復 雜,有一種非常精巧的算法可以快速地建起一棵最優二叉樹,這種算法由D.Huffman(戴·霍夫曼)提出,下面我們先來介紹霍夫曼算法的步驟,然后再來 證明通過這么簡單的步驟得出的樹形確實是一棵最優二叉樹。

霍夫曼算法的步驟是這樣的:

·從各個節點中找出最小的兩個節點,給它們建一個父節點,值為這兩個節點之和。
·然后從節點序列中去除這兩個節點,加入它們的父節點到序列中。

重復上面兩個步驟,直到節點序列中只剩下唯一一個節點。這時一棵最優二叉樹就已經建成了,它的根就是剩下的這個節點。

仍以上面的例子來看霍夫曼樹的建立過程。
最初的節點序列是這樣的:
a(6)  b(15)  c(2)  d(9)  e(1)

把最小的c和e結合起來
                   | (3)
a(6)   b(15)   d(9)    +------+------+
              |      |
              c     e

不斷重復,最終得到的樹是這樣的:

       根
        |
    +-----33-----+
   |     |
   15   +----18----+   
       |       |
       9  +------9-----+
          |       |
         6     +--3--+
              |   |
              2  1

這時各個字符的編碼長度和前面我們說過的方法得到的編碼長度是相同的,因而文件的總長度也是相同的: 3*6 + 1*15 + 4*2 + 2*9 + 4*1 = 63

考察霍夫曼樹的建立過程中的每一步的節點序列的變化:

6  15 2 9 1
6  15 9 3
15 9  9
15 18
33

下面我們用逆推法來證明對于各種不同的節點序列,用霍夫曼算法建立起來的樹總是一棵最優二叉樹:

對霍夫曼樹的建立過程運用逆推法:
當這個過程中的節點序列只有兩個節點時(比如前例中的15和18),肯定是一棵最優二叉樹,一個編碼為0,另一個編碼為1,無法再進一步優化。
然后往前步進,節點序列中不斷地減少一個節點,增加兩個節點,在步進過程中將始終保持是一棵最優二叉樹,這是因為:
1. 按照霍夫曼樹的建立過程,新增的兩個節點是當前節點序列中最小的兩個,其他的任何兩個節點的父節點都大于(或等于)這兩個節點的父節點,只要前一步是最優 二叉樹,其他的任何兩個節點的父節點就一定都處在它們的父節點的上層或同層,所以這兩個節點一定處在當前二叉樹的最低一層。
2.這兩個新增的節點是最小的,所以無法和其他上層節點對換。符合我們前面說的最優二叉樹的第一個條件。
3. 只要前一步是最優二叉樹,由于這兩個新增的節點是最小的,即使同層有其他節點,也無法和同層其他節點重新結合,產生比它們的父節點更小的上層節點來和同層 的其他節點對換。它們的父節點小于其他節點的父節點,它們又小于其他所有節點,只要前一步符合最優二叉樹的第二個條件,到這一步仍將符合。

這樣一步步逆推下去,在這個過程中霍夫曼樹每一步都始終保持著是一棵最優二叉樹。

由于每一步都從節點序列中刪除兩個節點,新增一個節點,霍夫曼樹的建立過程共需 (原始節點數 - 1) 步,所以霍夫曼算法不失為一種精巧的編碼式壓縮算法。


附:對于 huffman 樹,《計算機程序設計藝術》中有完全不同的證明,大意是這樣的:
1.二叉編碼樹的內部節點(非葉子節點)數等于外部節點(葉子節點)數減1。
2.二叉編碼樹的外部節點的加權路徑長度(值乘以路徑長度)之和,等于所有內部節點值之和。(這兩條都可以通過對節點數運用數學歸納法來證明,留給大家做練習。)
3.對 huffman 樹的建立過程運用逆推,當只有一個內部節點時,肯定是一棵最優二叉樹。
4. 往前步進,新增兩個最小的外部節點,它們結合在一起產生一個新的內部節點,當且僅當原先的內部節點集合是極小化的,加入這個新的內部節點后仍是極小化的。 (因為最小的兩個節點結合在一起,并處于最低層,相對于它們分別和其他同層或上層節點結合在一起,至少不會增加加權路徑長度。)
5.隨著內部節點數逐個增加,內部節點集合總維持極小化。




2.實現部分
   如果世界上從沒有一個壓縮程序,我們看了前面的壓縮原理,將有信心一定能作出一個可以壓縮大多數格式、內容的數據的程序,當我們著手要做這樣一個程序的 時候,會發現有很多的難題需要我們去一個個解決,下面將逐個描述這些難題,并詳細分析 zip 算法是如何解決這些難題的,其中很多問題帶有普遍意義,比如查找匹配,比如數組排序等等,這些都是說不盡的話題,讓我們深入其中,做一番思考。

我們前面說過,對于短語式重復,我們用“重復距當前位置的距離”和“重復的長度”這兩個數字來表示這一段重復,以實現壓縮,現在問題來了,一個字節 能表示的數字大小為 0 -255,然而重復出現的位置和重復的長度都可能超過 255,事實上,二進制數的位數確定下來后,所能表示的數字大小的范圍是有限的,n位的二進制數能表示的最大值是2的n次方減1,如果位數取得太大,對于 大量的短匹配,可能不但起不到壓縮作用,反而增大了最終的結果。針對這種情況,有兩種不同的算法來解決這個問題,它們是兩種不同的思路。一種稱為 lz77 算法,這是一種很自然的思路:限制這兩個數字的大小,以取得折衷的壓縮效果。例如距離取 15 位,長度取 8 位,這樣,距離的最大取值為 32 k - 1,長度的最大取值為 255,這兩個數字占 23 位,比三個字節少一位,是符合壓縮的要求的。讓我們在頭腦中想象一下 lz77 算法壓縮進行時的情況,會出現有意思的模型:

   最遠匹配位置->          當前處理位置->
───┸─────────────────╂─────────────>壓縮進行方向
   已壓縮部分             ┃    未壓縮部分

  在最遠匹配位置和當前處理位置之間是可以用來查找匹配的“字典”區域,隨著壓縮的進行,“字典”區域從待壓縮文件的頭部不斷地向后滑動,直到達到文件的尾部,短語式壓縮也就結束了。
  解壓縮也非常簡單:

         ┎────────拷貝────────┒
 匹配位置    ┃          當前處理位置  ┃
   ┃<──匹配長度──>┃       ┠─────∨────┨
───┸──────────┸───────╂──────────┸─>解壓進行方向
   已解壓部分              ┃    未解壓部分

  不斷地從壓縮文件中讀出匹配位置值和匹配長度值,把已解壓部分的匹配內容拷貝到解壓文件尾部,遇到壓縮文件中那些壓縮時未能得到匹配,而是直接保存的單、雙字節,解壓時只要直接拷貝到文件尾部即可,直到整個壓縮文件處理完畢。
  lz77算法模型也被稱為“滑動字典”模型或“滑動窗口”模型。
  另有一種lzw算法對待壓縮文件中存在大量簡單匹配的情況進行了完全不同的算法設計,它只用一個數字來表示一段短語,下面來描述一下lzw的壓縮解壓過程,然后來綜合比較兩者的適用情況。
  lzw的壓縮過程:
1) 初始化一個指定大小的字典,把 256 種字節取值加入字典。
2) 在待壓縮文件的當前處理位置尋找在字典中出現的最長匹配,輸出該匹配在字典中的序號。
3) 如果字典沒有達到最大容量,把該匹配加上它在待壓縮文件中的下一個字節加入字典。
4) 把當前處理位置移到該匹配后。
5) 重復 2、3、4 直到文件輸出完畢。

  lzw 的解壓過程:
1) 初始化一個指定大小的字典,把 256 種字節取值加入字典。
2) 從壓縮文件中順序讀出一個字典序號,根據該序號,把字典中相應的數據拷貝到解壓文件尾部。
3) 如果字典沒有達到最大容量,把前一個匹配內容加上當前匹配的第一個字節加入字典。
4) 重復 2、3 兩步直到壓縮文件處理完畢。

  從 lzw 的壓縮過程,我們可以歸納出它不同于 lz77 算法的一些主要特點:
1) 對于一段短語,它只輸出一個數字,即字典中的序號。(這個數字的位數決定了字典的最大容量,當它的位數取得太大時,比如 24 位以上,對于短匹配占多數的情況,壓縮率可能很低。取得太小時,比如 8 位,字典的容量受到限制。所以同樣需要取舍。)
2) 對于一個短語,比如 abcd ,當它在待壓縮文件中第一次出現時,ab 被加入字典,第二次出現時,abc 被加入字典,第三次出現時,abcd 才會被加入字典,對于一些長匹配,它必須高頻率地出現,并且字典有較大的容量,才會被最終完整地加入字典。相應地,lz77 只要匹配在“字典區域”中存在,馬上就可以直接使用。
3) 設 lzw 的“字典序號”取 n 位,它的最大長度可以達到 2 的 n 次方;設 lz77 的“匹配長度”取 n 位,“匹配距離”取 d 位,它的最大長度也是 2 的 n 次方,但還要多輸出 d 位(d 至少不小于 n),從理論上說 lzw 每輸出一個匹配只要 n 位,不管是長匹配還是短匹配,壓縮率要比 lz77 高至少一倍,但實際上,lzw 的字典中的匹配長度的增長由于各匹配互相打斷,很難達到最大值。而且雖然 lz77 每一個匹配都要多輸出 d 位,但 lzw 每一個匹配都要從單字節開始增長起,對于種類繁多的匹配,lzw 居于劣勢。
  可以看出,在多數情況下,lz77 擁有更高的壓縮率,而在待壓縮文件中占絕大多數的是些簡單的匹配時,lzw 更具優勢,GIF 就是采用了 lzw 算法來壓縮背景單一、圖形簡單的圖片。zip 是用來壓縮通用文件的,這就是它采用對大多數文件有更高壓縮率的 lz77 算法的原因。

  接下來 zip 算法將要解決在“字典區域”中如何高速查找最長匹配的問題。

(注:以下關于技術細節的描述是以 gzip 的公開源代碼為基礎的,如果需要完整的代碼,可以在 gzip 的官方網站 www.gzip.org 下載。下面提到的每一個問題,都首先介紹最直觀簡單的解決方法,然后指出這種方法的弊端所在,最后介紹 gzip 采用的做法,這樣也許能使讀者對 gzip 看似復雜、不直觀的做法的意義有更好的理解。)
最 直觀的搜索方式是順序搜索:以待壓縮部分的第一個字節與窗口中的每一個字節依次比較,當找到一個相等的字節時,再比較后續的字節…… 遍歷了窗口后得出最長匹配。gzip 用的是被稱作“哈希表”的方法來實現較高效的搜索?!肮#╤ash)”是分散的意思,把待搜索的數據按照字節值分散到一個個“桶”中,搜索時再根據字節 值到相應的“桶”中去尋找。短語式壓縮的最短匹配為 3 個字節,gzip 以 3 個字節的值作為哈希表的索引,但 3 個字節共有 2 的 24 次方種取值,需要 16M 個桶,桶里存放的是窗口中的位置值,窗口的大小為 32K,所以每個桶至少要有大于兩個字節的空間,哈希表將大于 32M,作為 90 年代開發的程序,這個要求是太大了,而且隨著窗口的移動,哈希表里的數據會不斷過時,維護這么大的表,會降低程序的效率,gzip 定義哈希表為 2 的 15 次方(32K)個桶,并設計了一個哈希函數把 16M 種取值對應到 32K 個桶中,不同的值被對應到相同的桶中是不可避免的,哈希函數的任務是 1.使各種取值盡可能均勻地分布到各個桶中,避免許多不同的值集中到某些桶中,而另一些是空桶,使搜索的效率降低。2.函數的計算盡可能地簡單,因為每次 “插入”和“搜尋”哈希表都要執行哈希函數,哈希函數的復雜度直接影響程序的執行效率,容易想到的哈希函數是取 3 個字節的左邊(或右邊)15 位二進制值,但這樣只要左邊(或右邊)2 個字節相同,就會被放到同一個桶中,而 2 個字節相同的概率是比較高的,不符合“平均分布”的要求。gzip 采用的算法是:A(4,5) + A(6,7,8) ^ B(1,2,3) + B(4,5) + B(6,7,8) ^ C(1,2,3) + C(4,5,6,7,8) (說明:A 指 3 個字節中的第 1 個字節,B 指第 2 個字節,C 指第 3 個字節,A(4,5) 指第一個字節的第 4,5 位二進制碼,“^”是二進制位的異或操作,“+”是“連接”而不是“加”,“^”優先于“+”)這樣使 3 個字節都盡量“參與”到最后的結果中來,而且每個結果值 h 都等于 ((前1個h << 5) ^ c)取右 15 位,計算也還簡單。
哈希表的具體實現也值得探討,因為無法預先知道每一個“桶”會存放多少個元素,所以最簡單的,會想到用鏈表來實現:哈希表里存放著每個桶的第一個 元素,每個元素除了存放著自身的值,還存放著一個指針,指向同一個桶中的下一個元素,可以順著指針鏈來遍歷該桶中的每一個元素,插入元素時,先用哈希函數 算出該放到第幾個桶中,再把它掛到相應鏈表的最后。這個方案的缺點是頻繁地申請和釋放內存會降低運行速度;內存指針的存放占據了額外的內存開銷。有更少內 存開銷和更快速的方法來實現哈希表,并且不需要頻繁的內存申請和釋放:gzip 在內存中申請了兩個數組,一個叫 head[],一個叫 pre[],大小都為 32K,根據當前位置 strstart 開始的 3 個字節,用哈希函數計算出在 head[] 中的位置 ins_h,然后把 head[ins_h] 中的值記入 pre[strstart],再把當前位置 strstart 記入 head[ins_h]。隨著壓縮的進行,head[]里記載著最近的可能的匹配的位置(如果有匹配的話,head[ins_h]不為 0),pre[]中的所有位置與原始數據的位置相對應,但每一個位置保存的值是前一個最近的可能的匹配的位置。(“可能的匹配”是指哈希函數計算出的 ins_h 相同。)順著 pre[] 中的指示找下去,直到遇到 0,可以得到所有匹配在原始數據中的位置,0 表示不再有更遠的匹配。
  接下來很自然地要觀察 gzip 具體是如何判斷哈希表中數據的過時,如何清理哈希表的,因為 pre[] 里只能存放 32K 個元素,所以這項工作是必須要做的。
   gzip 從原始文件中讀出兩個窗口大小的內容(共 64K 字節)到一塊內存中,這塊內存也是一個數組,稱作 Window[];申請 head[]、pre[] 并清零;strstart 置為 0。然后 gzip 邊搜索邊插入,搜索時通過計算 ins_h,檢查 head[] 中是否有匹配,如果有匹配,判斷 strstart 減 head[] 中的位置是否大于 1 個窗口的大小,如果大于 1 個窗口的大小,就不到 pre[] 中去搜索了,因為 pre[] 中保存的位置更遠了,如果不大于,就順著 pre[] 的指示到 Window[] 中逐個匹配位置開始,逐個字節與當前位置的數據比較,以找出最長匹配,pre[] 中的位置也要判斷是否超出一個窗口,如遇到超出一個窗口的位置或者 0 就不再找下去,找不到匹配就輸出當前位置的單個字節到另外的內存(輸出方法在后文中會介紹),并把 strstart 插入哈希表,strstart 遞增,如果找到了匹配,就輸出匹配位置和匹配長度這兩個數字到另外的內存中,并把 strstart 開始的,直到 strstart + 匹配長度 為止的所有位置都插入哈希表,strstart += 匹配長度。插入哈希表的方法為:
pre[strstart % 32K] = head[ins_h];
head[ins_h] = strstart;
可 以看出,pre[] 是循環利用的,所有的位置都在一個窗口以內,但每一個位置保存的值不一定是一個窗口以內的。在搜索時,head[] 和 pre[] 中的位置值對應到 pre[] 時也要 % 32K。當 Window[] 中的原始數據將要處理完畢時,要把 Window[] 中后一窗的數據復制到前一窗,再讀取 32K 字節的數據到后一窗,strstart -= 32K,遍歷 head[],值小于等于 32K 的,置為 0,大于 32K 的,-= 32K;pre[] 同 head[] 一樣處理。然后同前面一樣處理新一窗的數據。
  分析:現在可以 看到,雖然 3 個字節有 16M 種取值,但實際上一個窗口只有 32K 個取值需要插入哈希表,由于短語式重復的存在,實際只有 < 32K 種取值插入哈希表的 32K 個“桶”中,而且哈希函數又符合“平均分布”的要求,所以哈希表中實際存在的“沖突”一般不會多,對搜索效率的影響不大。可以預計,在“一般情況”下,每 個“桶”中存放的數據,正是我們要找的。哈希表在各種搜索算法中,實現相對的比較簡單,容易理解,“平均搜索速度”最快,哈希函數的設計是搜索速度的關 鍵,只要符合“平均分布”和“計算簡單”,就常常能成為諸種搜索算法中的首選,所以哈希表是最流行的一種搜索算法。但在某些特殊情況下,它也有缺點,比 如:1.當鍵碼 k 不存在時,要求找出小于 k 的最大鍵碼或大于 k 的最小鍵碼,哈希表無法有效率地滿足這種要求。2.哈希表的“平均搜索速度”是建立在概率論的基礎上的,因為事先不能預知待搜索的數據集合,我們只能“信 賴”搜索速度的“平均值”,而不能“保證”搜索速度的“上限”。在同人類性命攸關的應用中(如醫療或宇航領域),將是不合適的。這些情況及其他一些特殊情 況下,我們必須求助其他“平均速度”較低,但能滿足相應的特殊要求的算法。(見《計算機程序設計藝術》第3卷 排序與查找)。幸而“在窗口中搜索匹配字節串”不屬于特殊情況。

時間與壓縮率的平衡:
gzip 定義了幾種可供選擇的 level,越低的 level 壓縮時間越快但壓縮率越低,越高的 level 壓縮時間越慢但壓縮率越高。
不同的 level 對下面四個變量有不同的取值:

nice_length
max_chain
max_lazy
good_length

nice_length: 前面說過,搜索匹配時,順著 pre[] 的指示到 Window[] 中逐個匹配位置開始,找出最長匹配,但在這過程中,如果遇到一個匹配的長度達到或超過 nice_length,就不再試圖尋找更長的匹配。最低的 level 定義 nice_length 為 8,最高的 level 定義 nice_length 為 258(即一個字節能表示的最大短語匹配長度 3 + 255)。

max_chain:這個值規定了順著 pre[] 的指示往前回溯的最大次數。最低的 level 定義 max_chain 為 4,最高的 level 定義 max_chain 為 4096。當 max_chain 和 nice_length 有沖突時,以先達到的為準。

max_lazy:這里有一個懶惰匹配(lazy match)的概念,在輸出當前位置(strstart)的匹配之前,gzip 會去找下一個位置(strstart + 1)的匹配,如果下一個匹配的長度比當前匹配的長度更長,gzip 就放棄當前匹配,只輸出當前位置處的首個字節,然后再查找 strstart + 2 處的匹配,這樣的方式一直往后找,如果后一個匹配比前一個匹配更長,就只輸出前一個匹配的首字節,直到遇到前一個匹配長于后一個匹配,才輸出前一個匹配。
gzip 作者的思路是,如果后一個匹配比前一個匹配更長,就犧牲前一個匹配的首字節來換取后面的大于等于1的額外的匹配長度。
max_lazy 規定了,如果匹配的長度達到或超過了這個值,就直接輸出,不再管后一個匹配是否更長。最低的4級 level 不做懶惰匹配,第5級 level 定義 max_lazy 為 4,最高的 level 定義 max_lazy 為 258。

good_length: 這個值也和懶惰匹配有關,如果前一個匹配長度達到或超過 good_length,那在尋找當前的懶惰匹配時,回溯的最大次數減小到 max_chain 的 1/4,以減少當前的懶惰匹配花費的時間。第5級 level 定義 good_length 為 4(這一級等于忽略了 good_length),最高的 level 定義 good_length 為 32。

分析:懶惰匹配有必要嗎?可以改進嗎?
gzip 的作者是無損壓縮方面的專家,但是世界上沒有絕對的權威,吾愛吾師,更愛真理。我覺得 gzip 的作者對懶惰匹配的考慮確實不夠周詳。只要是進行了認真客觀的分析,誰都有權利提出自己的觀點。
采用懶惰匹配,需要對原始文件的更多的位置查找匹配,時間肯定增加了許多倍,但壓縮率的提高在總體上十分有限。在幾種情況下,它反而增長了短語壓縮的結果,所以如果一定要用懶惰匹配,也應該改進一下算法,下面是具體的分析。
1. 連續3次以上找到了更長的匹配,就不應該單個輸出前面的那些字節,而應該作為匹配輸出。
2. 于是,如果連續找到更長的匹配的次數大于第一個匹配的長度,對于第一個匹配,相當于沒有做懶惰匹配。
3. 如果小于第一個匹配的長度但大于2,就沒有必要作懶惰匹配,因為輸出的總是兩個匹配。
4. 所以找到一個匹配后,最多只需要向后做 2 次懶惰匹配,就可以決定是輸出第一個匹配,還是輸出1(或 2)個首字節加后面的匹配了。
5. 于是,對于一段原始字節串,如果不做懶惰匹配時輸出兩個匹配(對于每個匹配,距離占15位二進制數,長度占8位二進制數,加起來約占3字節,輸出兩個匹配 約需要6字節),做了懶惰匹配如果有改進的話,將是輸出1或2個單字節加上1個匹配(也就是約4或5字節)。這樣,懶惰匹配可以使某些短語壓縮的結果再縮 短1/3到1/6。
6. 再觀察這樣一個例子:
1232345145678[當前位置]12345678
不用懶惰匹配,約輸出6字節,用懶惰匹配,約輸出7字節,由于使用了懶惰匹配,把更后面的一個匹配拆成了兩個匹配。(如果 678 正好能歸入再后面的一個匹配,那懶惰匹配可能是有益的。)
7. 綜合考慮各種因素(匹配數和未匹配的單雙字節在原始文件中所占的比例,后一個匹配長度大于前一個匹配長度的概率,等等),經過改進的懶惰匹配算法,對總的 壓縮率即使有貢獻,也仍是很小的,而且也仍然很有可能會降低壓縮率。再考慮到時間的確定的明顯的增加與壓縮率的不確定的微弱的增益,也許最好的改進是果斷 地放棄懶惰匹配。

gzip 在完成短語式壓縮后,將轉入編碼式壓縮的階段。這個階段的實現是很復雜的,對最終的壓縮率至關重要,我會詳細解說 gzip 的做法。gzip 是開放源代碼的無損壓縮程序中最著名的,其中的種種技巧很有啟發意義,但是他是比較早期的程序,現在有很多的程序已經在壓縮率上超過了它,所以我會根據自 己對無損壓縮的基本規律的理解提出對它的改進。

編碼式壓縮的幾點考慮:
1. huffman 算法壓縮率的關鍵是各節點值的差異要大,這樣就要求分段編碼輸出。因為某些段落中某些節點的出現頻率較高,另一些段落中這些節點出現頻率較低,如果不分段輸出,頻率的差異會被彼此抵消,而不同段落中,節點的出現頻率不同是常有的。
  要決定分段的大小,必須解決一對矛盾:上面的分析似乎要求段落越小越好,但由于要保存碼表以對 huffman 壓縮結果進行解壓,每個段落都要保存一份不同的碼表,所以段落取得太小,保存了碼表后得不償失,這樣,似乎又要求段落要盡量大,使碼表的保存份數盡量少。
   gzip 采取了這樣的策略來確定段落的大小:lz77 壓縮每產生 4k(?。┑臄祿?,就判斷現在對未編碼部分進行編碼輸出是否合適,最多積壓到 32k(大)的時候,必定進行強制輸出,因為平庸的數據積壓得太多,后面即使有好的數據,頻率統計在一起,也會被平庸化。
  判斷當前輸出合適與 否的條件是:1)用預先設定好的各節點長度和各節點實際的出現次數,計算壓縮結果的大概值,看這個值是否小于未壓縮時的 1/2。2)看目前為止的匹配數是否小于未匹配的字節數,因為 lz77 壓縮產生的數據包括“匹配”和“未匹配的原始字節”,段落間的節點頻率差異主要體現在“未匹配的原始字節”中。
  上面的判斷只是一種“猜測”,真正的精確的計算需要花費更多的時間。
   我覺得 gzip 的策略可以改進,我的策略是:1)輸出的時機是壓縮率的關鍵之一,現在計算機的速度和九十年代時已經今非昔比,現在完全有條件采用真正的建 huffman 樹的方法得到各節點的碼長,作精確的判斷。2)不應該與未壓縮的原始數據比較,而應該與 lz77 輸出的數據比較,否則計算出的壓縮比很大一部分是短語式壓縮的功勞。3)由于采用了真正的建 huffman 樹的方法,不用再去做匹配數與未匹配的字節數的比較,因為那只是一種猜測。4)每 4k 的數據都單獨統計頻率,如果是合適的,就先輸出之前的積壓(如果有的話),再輸出當前的 4k,這樣可以避免當前的數據被積壓的數據平庸化。如果不合適,就把當前的頻率歸入到積壓的數據(如果有)的頻率中,再判斷是否合適,如仍不合適就暫緩輸 出,否則一起輸出,這和 gzip 的作法是一樣的。說明:幾段差的數據積壓到一起仍有可能成為好的數據,比如 01、 02、……積壓在一起,0 的頻率逐漸高出了其他字節。5)如果愿意付出更多的時間,在把當前的頻率歸入之前的頻率時,可以先和之前 4k 的頻率合并,如果不合適,和之前 8k 的頻率合并,這樣逐漸往前合并 4k,避免前面不好的數據拖累合并后的好的數據。6)有了前面的機制,32k 的強制輸出點可以取消。7)進一步的改進:當要輸出時,只輸出積壓的不好的部分,好的數據先留著,等后面的 4k,如果新的加入后,仍是好的數據,就再等,如果會降低壓縮率,才輸出好的部分。這樣,讓好的數據大段的輸出,可以減少碼表的保存份數。8)再進一步的 改進:壞的數據放在一起可能會提高壓縮率,好的數據放在一起也可能更好,當然,兩種情況也都有可能降低壓縮率,所以前面判斷“好”還是“不好”,“合適” 還是“不合適”的標準應該從某一個固定的壓縮率標準改變為:提高了壓縮率還是降低了壓縮率。(提高的幅度應該至少抵消多保存一份碼表的損失;降低的幅度也 應該至少抵消少保存一份碼表的得益)9)綜合前面的分析,確定分段大小的策略最終調整為:當新的數據和前面的未切分數據放在一起時,兩者中任何一方受到損 失,都應該設置切分點,積累了兩個分段后,通過計算,當切分帶來的收益大于少保存一份碼表時,才輸出前一段,否則取消它們之間的切分點。這個策略實際上可 以涵蓋前面提到的所有改進,因為每個實際的分段之中的數據或者相互促進,或者彼此稍有妨害,但好過多保存一份碼表;而每兩個相鄰的分段之間的數據彼此妨 害,抵消了少保存一份碼表的收益。這個策略簡單直觀地體現了我們設置分段的初衷:就是分段輸出必須能提高壓縮率。

2. 如果不考慮碼表,huffman 算法能得到最短的編碼式壓縮結果,但是這種算法必須保存碼表以便解壓縮,所以不能保證結果是最佳的。gzip 預先擬定了一套通用的靜態的編碼,當要輸出一個段落時,比較 huffman 壓縮結果加碼表的長度和靜態編碼的壓縮結果長度,再決定用哪種方法輸出這個段落。靜態編碼不需要建樹,計算壓縮結果長度時耗時很少。如果各節點的頻率的差 異很小,huffman 壓縮結果加碼表反而增大了結果,靜態編碼也不合適,同樣增大了結果,gzip 就直接保存 lz77 的原始輸出。由于輸出一個段落時,增加了靜態編碼的方案,使輸出的實際長度和之前確定分段點時計算的值可能不同,那么前面計算出的這個分段點是否仍是正確 的?前面的分段策略是否需要調整?
  分析:1)靜態編碼的各節點編碼是不變的,對于段落的合并是無所謂的,兩個連續段落即使都采用靜態編碼,也 不用合并,因為合并后結果長度是不會變的。2)所以只對一種情況可能有影響:一個段落中拆分出一些部分用 huffman 編碼,另一些部分用靜態編碼,壓縮結果更好。當這種情況發生時,則必有一些部分的優勢節點(頻率高的節點)與靜態編碼預先擬定的優勢節點相近,采用靜態編 碼后有稍許改善,其他部分則與靜態編碼預先擬定的優勢節點有一定分歧,采用靜態編碼后會有稍許不利。之所以說“稍許”,是因為我們已知同一個段落里的各部 分數據或者互相促進,或者僅有稍許妨害,說明它們的優勢節點是大致趨同的??紤]到拆分后可能要多保存幾份碼表,拆分帶來收益的可能性和程度是很小的,而且 計算的復雜度較大,所以前面的拆分策略可以不作調整。
  至于直接保存 lz77 的原始輸出,可以看作靜態編碼的一種特殊形式,只不過它假定各節點的頻率相近,沒有優勢節點。它可以套用靜態編碼的分析,來證明不影響前面已經制定的分段策略。

3.采用 huffman 編碼,必須深入研究碼表的保存方式。
  只要計算一下采用簡單的方式來保存碼表,需要多大的空間,就知道這是一個挑戰。
   簡單地保存碼表的方法是順序地保存每一個值的碼長和編碼。之所以要保存碼長,是因為編碼是不定長的,沒有碼長,解壓時無法正確讀取編碼。碼長必須是定長 的,也就是說必須限制 huffman 樹的最大層數,使碼長的位數能恰好表示這個層數。限制 huffman 樹的最大層數的方法是:如果規定的最大層數為 n,則在 n - 1 層找到一個葉子節點 a(如果 n - 1 層沒有葉子節點,就逐層地往上尋找,直到找到一個葉子節點),在節點 a 的位置放一個非葉子節點 A,使 a 成為 A 的子節點,把某個超過 n 層的葉子節點 b 提上來作為 A 的另一個子節點,此時 b 的父節點 B 只剩下一個子節點 c,取消 B,把 c 放在 B 的位置,重復這樣的過程,直到所有 n 層以下的節點都被提上來。之所以要從 n - 1 層開始逐層往上找,是因為下層的節點頻率小,碼長變化后的影響小。假設每一層節點的頻率相近,那么上層父節點的頻率是其下層子節點的兩倍,第 11 層節點的頻率只有第一層節點頻率的 1 / 1024,所以應該從下往上找。
  現在就開始計算碼表大小:
  對于 256 個原始字節值,限制它的 huffman 樹的層數為 0 - 15,碼長就需要 4 位,256 個碼長需要 4 bit * 256 = 128 字節;而 256 個新編碼需要至少 256 字節。(當二叉樹的所有葉子節點都放在第 8 層 —— 不算根節點一層,正好能放下 2 的 8 次方 = 256 個葉子節點,其中任何一個葉子節點往上升,至少造成兩個葉子節點往下降。換一個角度說,如果在第 8 層以上存在一個葉子節點 a,在節點 a 的位置放一個非葉子節點 A,使 a 成為 A 的子節點,把某個超過 8 層的葉子節點 b 提上來作為 A 的另一個子節點,此時 b 的父節點 B 只剩下一個子節點 c,取消 B,把 c 放在 B 的位置,此時 a 增長了一位,c 縮短了一位,b 縮短了至少一位,編碼的平均位長縮短。所以,當第 8 層以上不存在葉子節點,所有葉子節點都放在第 8 層時,編碼的平均位長達到最短 —— 8位。)這套碼表共需至少 128 + 256 = 384 字節。
  256 個“匹配長度”的情況與原始字節值相同,兩套碼表共需至少 384 * 2 = 768 字節。
   對于 32k 個“匹配距離”,如果限制該 huffman 樹的層數為 0 - 31,保存每個值的碼長需要 5 位,新編碼的平均長度超過 15 位。(因為所有葉子節點都放在第 15 層 —— 不算根節點一層,正好能放下 2 的 15 次方 = 32k 個葉子節點。)這套碼表要超過80k 字節( (5 + 15) * 32k / 8 = 80k )。
  前面討論分段策略時已經說過,為了避免個段落間節點頻率差異被互相抵消,要求段落劃分盡量細致、準確,最小的段落可以僅為 4k,而采用上面這種簡單的方式,碼表要超過 80k,顯然是無法接受的。
   對碼表的保存方式的深入研究,確實是個無法繞開的挑戰,如果不攻克這個難關,編碼式壓縮無法進行下去!挑戰會帶來樂趣,困難會激發豪情。我們所要做的 是:觀察 gzip 如何一步步地通過繁復但又巧妙的做法解決這個難題,對其中的做法的道理務求知其然、知其所以然,通過觀察、思考,把握無損壓縮內在的、深層的、本質的規 律!事實上,對 gzip 的這些做法進行閱讀(源代碼)、分析、挖掘其中的智慧,本身就是一個對智慧、耐力、乃至決心的長期的挑戰,我接受了這個挑戰,并把它描述、解釋出來,讀者 面對的挑戰是花費較長期的時間去閱讀、理解,希望讀者完全有耐力、豪情、興趣來接受這個挑戰,深化自己的技術層次、思維層次。

3.1 只保存碼長,并增加一些特殊的值。

3.1.1 把 huffman 樹的每一層上的葉子節點都換到該層的左邊,按照其原始值從小到大依次排列,非葉子節點則集中在該層右邊,這時仍是一棵二叉樹,得到的編碼仍符合前綴編碼的 要求。每個葉子節點的編碼長度不變,所以壓縮率也不變。僅需要按照原始值從小到大依次保存每個值的碼長,解壓時就可以還原這套編碼表,還原方法是:碼長為 n 的第一個值的編碼是碼長為 n - 1 的最后一個值的編碼加 1,并左移一位(也就是說,在編碼最后加個 0),而碼長為 n 的其他值的編碼是前一個碼長為 n 的值的編碼加 1。從上面所說的樹的角度來解釋,每一層的第一個葉子節點是其上層最后一個葉子節點的右邊一個節點的左子節點,所以它的編碼是上層最后一個葉子節點的編碼 加 1 并左移一位,而每一層上的葉子節點都緊密排列,所以除了第一個葉子節點外,其他葉子節點的編碼都是前一個葉子節點的編碼加 1。編程上的實現方法是:遍歷碼表,得到每個碼長(n)上有多少個值,計算出每個碼長上第一個值的編碼,放在數組 bit_len[]中,再次遍歷碼表,依次根據每個值的碼長(n),賦予它的編碼為該碼長上的前一個值的編碼 (bit_len[n]) 加 1,bit_len[n] ++。
  由于只需要保存碼長,現在碼表由超過 80k 字節減小到約 20k 字節。

3.1.2 如何只保存在段落中出現過的節點(有效節點)的編碼?
   一個 ASCⅡ文本,128 以后的值是不會在文件中出現的,按照 3.1.1 的方法,碼表中后半部分(都是 0)在解壓縮時是用不到的。為了避免這類浪費,只保存有效節點(碼長不為 0 的節點),一種方法是保存有效節點的原始值和新編碼的碼長,當有效節點超過所有節點的1/4,這種方法保存的碼表的大小會超過 3.1.1 的方法。
   gzip 采用的方法是:在 3.1.1 的基礎上,于若干種碼長之外,增加一些特殊的值,他們表示當前為之前一個碼長或 0 碼長(無效節點)的重復,遇到這種值,那后面的一個數字表示重復的次數。第一種值代表當前為之前一個碼長的重復 3 - 6 次,后面跟著 2 bit 為具體的重復次數;第二種值代表當前為 0 碼長的重復 3 - 10 次,后面跟著 3 bit 為具體的重復次數;第三種值代表當前為 0 碼長的重復 11 - 138 次,后面跟著 7 bit 為具體的重復次數。限制最小重復次數為 3,可以確保這種方法得到的碼表不會大過 3.1.1。第一種值限制最大重復次數為 6,是因為連續 6 個值以上的碼長相等(說明頻率十分接近)的情況不常見,做這個限制可以節省附加 bit;第二第三種值區分重復次數的范圍,也是為了節省附加 bit。在只有少數有效節點的情況下,這種方法只需要保存較少的數據,同時也具有簡單的去重復的作用。
  如果最大碼長是 15,0 - 15 共 16 種值,一個碼長需要 4 位,加上上面 3 種值,共 19 種值,需要 5 位,在重復不多時,加了這 3 種值,是不是會增大碼表?其實不用擔心,gzip 會對碼表再進行一次 huffman 壓縮,根據這 19 種值的頻率分配給它們可變碼長的編碼,不會造成浪費,由于涉及到一些其他情況,對碼表的再編碼壓縮在后面還會詳細介紹!

3.2 把原始字節值和匹配長度值建在一棵樹上。
  現在先考慮另一個問題:如何使解壓時能區分當前是一個未匹配的字節,還是一個匹配?未匹配字節值和匹配長度、匹配距離是三棵不同的 huffman 樹,它們的編碼互相不符合前綴編碼的要求,部分節點甚至可能編碼相同,解壓時如何區分?
  第一種方法是用標志位。輸出壓縮結果時,除了輸出每一段的碼表、重新編碼后的數據流,還要保存對應于這一段數據的標志位流,流中的每一位 0 或 1 表示當前是一個未匹配的字節,還是一個匹配。
  第二種方法是給原始字節值和匹配長度值不同的編碼,并符合前綴編碼的要求。最好的做法是把它們建在一棵樹上,以確保它們符合前綴編碼的要求,并由它們的頻率來確定各自的碼長。
  第一種方法相當于原始字節值和匹配長度值的編碼都增長一位。
  第二種方法中這兩套節點的碼長變化要根據具體節點各自的頻率而定。
  經過分析,第二種方法更好,因為第一種方法可以看作是第二種方法的變種,相當于簡單地在兩棵 huffman 樹的根節點上再加一個父節點,這樣顯然是不能保證最佳的結果的。

3.3 把匹配長度、匹配距離變為長度范圍、距離范圍,減少節點。
  經過上面對保存碼表的方法的改進后,現在碼表還有多大?
   由于有了上面介紹的去重復機制,碼表的實際大小和節點的重復情況有關,如果有很多連續 3 個以上節點的碼長相等的情況出現,或有很多連續 3 個以上的無效節點的情況出現,碼表可能是很小的,但作為通用的無損壓縮算法,必須考慮重復不多的情況。“匹配距離”是碼表中最主要的部分,我們來分析一下 它的重復情況,“匹配距離”共有 32k 個取值,如果一個段落不到 32k,“匹配距離”的有效節點數當然是不可能到 32k 的,思考一下,可以知道,它的有效節點數和這樣幾個因素有關:一段有多長,段落中匹配數和未匹配數的比例,決定了它有多少個值,再加上這些值的重復性,決 定了它有多少個有效節點。再分析一下這些值的重復性:不同于原始字節和“匹配長度”都只有 256 個取值,它有 32k 個取值,相同的匹配有相同的匹配長度但不一定有相同的匹配距離,所以它的去值范圍廣,重復率低,有效節點多。雖然實際的情況無法預測,但我們可以做一些 “大致合理”的假設,以便對碼表的大小有一個基本的概念,假如短語式壓縮的輸出段落的大小為 90k 字節,其中未匹配字節數和匹配數的比例為 3 : 1,每個未匹配字節占 8 位;每個匹配中,長度占 8 位,距離占 15 位,共 23 位,約為未匹配字節的 3 倍,所以匹配占了 90k 字節中的約 45k 字節,匹配數約 15k 個,也就是說有 15k 個距離值,假如距離值的平均節點頻率為 3,那么去掉重復后有 5k 個有效距離值節點,保存到碼表時每個碼長需要 5 位,保存 5k 個碼長需要 5k * 5 / 8 約 3k 字節,算上無效節點、碼長的重復的因素,原始字節值、匹配長度的保存,最終碼表約 5k 字節,為 90k 的 18 分之一。當段落減小時,有效節點趨于稀疏,無效節點容易連成片,去重復機制能發揮更大的作用;當段落增大時,無效節點密度減小,可能無法大片連接,去重復 機制的效用降低,碼表的比例可能會增大。一旦“匹配距離”需要保存的碼長數達到了 32k個,碼表達到最大,之后段落再增大也不會增大碼表,于是碼表的比例又會逐漸下降。當然段落通常不會達到這么大,使得“匹配距離”需要保存的碼長數能 有機會達到 32k。
  gzip 以犧牲壓縮率的代價來換取碼表的進一步的大幅度減小。我們先描述一下它的具體做法,再來分析其利弊。
   gzip 把匹配長度劃成 29 個范圍,把匹配距離劃成 30 個范圍,根據每個范圍中節點的總頻率,為 29 個長度范圍加 258 個字節值建 huffman 樹:l_tree,為 30 個距離范圍建 huffman 樹:d_tree。輸出一個值時先輸出該值所在范圍的編碼,再輸出附加碼,即它是該范圍中的第幾個值。這樣碼表中只需保存范圍的碼長。范圍的大小都是 2 的乘方,所以范圍大小和附加碼的位長是互相決定的。
29 個長度范圍的附加碼位長是:
{0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0};
30 個距離范圍的附加碼位長是:
{0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13};
可以看出:范圍的劃分是從小到大的。為什么不平均劃分呢?
   如果仍以單個節點的角度來看,被分到同一范圍的節點相當于被賦予了相同的碼長:范圍編碼的碼長加附加碼的碼長。若頻率差別很大的節點因劃分入同一個范圍 而擁有相同的碼長,就不符合 huffman 編碼的初衷,會對壓縮率產生不良影響。因此要求劃分后,范圍里的節點頻率相近,以盡量降低同一個范圍里不同節點間的相互影響。
  “匹配長度”從 短到長,頻率會逐漸衰減,而且衰減的幅度有從大到小的特點,這個特點是在大多數原始文件中“自然存在”的。比如在 google 上搜索,2 個字的短語和 22 個字的短語,搜到的結果數差別巨大,200 個字和 220 個字,搜到的結果數差別就沒有那么大。頻率大致上單向地逐步變化,所以劃分范圍后,范圍內節點的頻率較接近;變化速度由大到小,所以范圍的劃分應該從小到 大。
  “匹配距離”也有類似的特點,對大多數文件來說,匹配發生在 1k 以內比發生在 5k 左右的可能性要大得多,但發生在 28k 處附近的可能性和發生在 32k 處附近的可能性的差別就沒那么明顯。所以范圍劃分也應該是從小到大。
   “未匹配的原始字節”不具有頻率衰減或遞增的單向變化的規律,它們的頻率分布往往是參差不齊、難以預測的,不可能用預先設定的范圍表對它們進行大致合理 的劃分,就像“匹配長度”和“匹配距離”那樣。雖然也可以通過計算分析,對它們進行不設定范圍數量和大小的劃分,以求每個范圍中的各節點頻率大致相近,但 1) “匹配距離”的劃分已經大幅度地縮小了碼表的大小;2) 由于不具有頻率單向變化的趨向,要強行劃出節點頻率相近并且節點數是 2 的乘方的范圍太勉強,難度也大;3) 未匹配的字節數一般要大于“匹配數”(注意:不是“匹配字節數”),強行劃分造成的不良反應較大。所以 gzip 保留了這套節點,沒去拆分。
  長度范圍的最后一個附加碼位長是 0,是因為長度大于 258 的匹配都被截斷到 258,所以 258 的頻率可能會高出前面的節點,單獨劃為一個范圍。
  如果一個范圍里的節點頻率相同,節點數是 2 的乘方,且沒有無效節點,那么這個范圍可以看作 huffman 樹中的一棵子樹,范圍的編碼可以看作這棵子樹的根的編碼,這樣的劃分是不會影響壓縮率的。
  對壓縮率的損害來自頻率不一致,以及無效節點的存在。范圍里的有效節點如果沒有過半,“附加碼”的位數就至少有一位浪費了,也就是說,范圍里所有有效節點的碼長無端增長了一位,如果有效節點沒有過 1/4,至少就有 2 位附加碼浪費。
  劃分范圍的收益是使碼表減小到不足 0.2k,加上后面會介紹的對碼表的第二次壓縮,碼表的最終大小是微不足道的。
   現在我們來近似地估計一下劃分范圍在“一般情況”下對壓縮率的損害的情況,以便有一個大致的概念,仍舉前面的例子:段落大小為 90k,設其中未匹配字節數和匹配數的比例為 3:1,未匹配字節有 45k 個,匹配距離值和匹配長度值各 15k 個,有效距離值節點為 5k個(節點平均頻率為 3),無效距離值節點為 32k - 5k = 27k 個,有效距離值節點的平均密度為 5/32,不到 1/6。范圍的劃分是前小后大,有效節點頻率是前大后小,無效節點是前少后多。距離值有 15k 個,設前面有效節點頻率高、密度較大的部分占一半,約 7k 個值,這個部分中無效節點帶來的損害較小,而且范圍劃分細,節點間頻率不一致帶來的損害也小,姑且不去計算。后面的范圍劃分大、有效節點密度小的部分損害 較大,這個部分占了約 7k 個值,由于前面的部分有效節點密度大,所以假設這個部分有效節點密度為 1/8(也就是說,約一半的匹配發生在 1k 距離以內,且 1k 以內無效節點很少,那么 4k / 31k 約等于 1/8),附加碼浪費了 3 位,7k 個值浪費 3 位,共浪費了 21k bit 約等于 3k 字節。
  再看頻率不一致帶來的損害:huffman 編碼如果要達到 50% 的壓縮率,需要節點間頻率的差異達到幾百倍。讀者可以虛擬一些節點頻率,試著建一下 huffman 樹,會發現當節點頻率差異在幾十倍甚至只有幾倍的時候,壓縮率其實微乎其微。經過上面這樣合理地劃分范圍,范圍內的節點頻率差異一般不會那么大,所以我們 假設頻率不一致造成的損害為 1k - 2k。
  匹配長度值的取值范圍只有 258 個,而且匹配長度可能很少會超過 20 字節,而前 20 字節的范圍劃分是很細的,所以無效節點的損害和頻率不一致的損害都較小。
  這樣,在這個例子中,劃分范圍帶來的損害約在 5k - 6k,和不劃分范圍時碼表的大小非常相似,至少也是在一個數量級上。
   再來看看損害比例變化的趨勢:當段落很小時,范圍中的有效值稀疏,損害比例會加大。而不劃分范圍時,碼表的去重復機制會有更大作用,無效節點連成片,損 害比例減小。反之,段落增大,范圍里有效節點密度大,損害比例降低,而不劃分范圍時,無效節點可能無法大片連接,去重復機制的效用降低,損害比例增大。
  由于劃分范圍能使 huffman 樹的節點從最多 32k 減到不足 320 個,從而使壓縮速度顯著改善。綜上所述,段落?。ū热绮坏?10k),不宜劃分范圍,否則劃分范圍是有益的。

3.4 對碼表進行第二次壓縮。
  目前為止,碼表中只需要保存各個節點經過 huffman 編碼后的新編碼的碼長。共兩棵樹,l_tree: 256 個原始字節值加 29 個長度范圍值加 1 個段落中止符,共 286 個節點,段落中止符用來在解壓時標示一個段落的終結。d_tree: 30 個距離范圍值。也就是說,共需要保存 286 + 30 = 316 個編碼的碼長。gzip 限制 huffman 樹的最大層數為 15,這樣,碼長就有 0 - 15 共 16 種值,再加上前面介紹過的去重復機制使用的 3 種特殊值,共 19 種值,如果就這樣保存碼表的話,每個碼長都需要 5 位,才能表示 19 種值。我們觀察一下,316 個碼長,一共只有 19 種值,碼長值的重復是必然的,而且由于 huffman 樹上每層的節點數不同,所以各個碼長值的頻率也不一樣。所以還可以為這 19 種值再建 huffman 樹,進行第二次編碼。這棵樹只有 19 個節點,限制它的層數為 0 - 7,可以用 3 個 bit 表示這 19 個節點的“長度”。這樣,用新的“碼長的編碼”來保存 316 個碼長,另需額外保存 3 * 19 = 57 bit,就可以解壓出這 19 個“碼長的編碼”。(至于這 57 bit,就沒有必要再作第 3 次編碼了)


4. 解決了碼表的問題,現在再回過頭來看靜態編碼。
  靜態編碼是 gzip 預先設定的編碼方案,它的碼表是固定的。
   該如何合理設計這套編碼?作為 huffman 編碼的補助,它的耗時應盡量少,前面說過,lz77 輸出一個分段之前,要比較 huffman 編碼和靜態編碼的壓縮結果,為了直接利用 lz77 輸出時做的匹配長度范圍、匹配距離范圍的頻率的統計,靜態編碼采用了同樣的范圍-附加碼的方案,這樣可以快速得到靜態編碼的壓縮結果大小。
  靜 態編碼的碼長的分配是這樣的:29 個長度范圍中前 24 個范圍的碼長為 7,后 5 個范圍的碼長為8。原始字節值中 0 - 143 的碼長為 8,144 - 255 的碼長為 9。而 30 個距離范圍的碼長為 5。根據這些預先設定的碼長建立靜態的 l_tree 和 d_tree,編碼也就產生了。結合前面提到的附加碼位數的定義:
29 個長度范圍的附加碼位長:
{0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0};
30 個距離范圍的附加碼位長:
{0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13};
讀 者可以知道每一個值的實際碼長。長度范圍值和原始字節值建在一棵樹上,節點多所以碼長較長,30 個距離范圍值只需要 5 位二進制數表示。短匹配的長度范圍值位長較短,字節值 0 - 143 的位長中等,其他字節值和長匹配的長度范圍值較長。這樣的分配反映了 gzip 作者對“大多數”文件中各種值的頻率的粗略估計。作為一個通用的壓縮算法,無法預先知道一個文件的實際情況,不可能做精確的估計。
  進一步的思 考:靜態編碼有必要嗎?靜態編碼采用了和 huffman 編碼相同的范圍-附加碼的方案,在碼長的分配上不可能超過 huffman 編碼,如果能“獲勝”,那就是勝在不需要保存碼表上,而前面分析過,碼表是很小的,對壓縮率沒有多大影響,所以 gzip 設計的這個靜態編碼方案應該是可有可無的。

5. 關于堆排序算法。
  似乎已經解決了所有的難題,但是對于沒有學過數據結構的讀者,仍然有一個會對程序效率產生影響的問題需要關注,那就是“排序”。
  已經講過,huffman 算法就是從一個節點序列中,不斷找出兩個最小的節點,為它們建一個父節點,值為這兩個節點之和,然后從節點序列中去除這兩個節點,加入它們的父節點到序列中,不斷重復這樣的步驟,直到節點序列中只剩下一個節點。如何快速地找出最小的元素呢?
   在普通的線性羅列的數據結構中,從 N 個元素中找出最小的元素的時間和 N 成正比,如果數據以我們所要介紹的“堆”的結構存儲,時間和 lg N 成正比(注:lg 以 2 為底數,如 lg 256 = 8,lg 1024 = 10 ...)。 集合中的元素越多,堆排序算法的優勢越突出,而且堆排序非常適合于在數據序列中不斷地取走最小的元素并加入新的元素。

5.1 什么是堆?
  堆首先是一棵“完全二叉樹”,即所有的葉子節點都在樹的最低二層,最低一層的節點依次靠左排列的二叉樹。如圖:

                       完全二叉樹
                        ?。?br />              +----------○----------+
             ?。                    。?br />     ?。穑         。穑?br />     ?。             。         。      。?br /> ?。穑     。穑   。穑  。穑?br />  |      ?。     。      。   。  。  。  。?br />+-○-+  ?。穑 。穑  。穑  觥   觥   觥   ?br />|  ?。  。  。 。  。  。  。?br />■   ■   ■   ■  ■   ■   ■   ■


  堆分大根堆和小根堆,大根堆的所有子節點都小于它的父節點,小根堆的所有子節點都大于它的父節點。下面就是一個小根堆:

                         小根堆
                         ?。?br />              +-----------2----------+
              |                     ?。?br />     ?。常         。福?br />      |             ?。         。       。?br />  +---6---+      +---4---+   ?。保担  。保福?br /> ?。      。     。      。   。   。  。   。?br />+-8-+   +-9-+ ?。担  。担 。保丁 。玻啊  。保埂  。玻?br />|   |   |  ?。 。  。  。  。?br />9  ?。埂 。保薄 。保场 。丁  。浮  。丁  。?br />
5.2 堆如何在內存中存儲?
  堆存放在一個數組中,存放的順序是:從根開始,依次存放每一層從左至右的節點。
5.3 如何尋找任意節點的子節點和父節點?
  數組中第 k 個元素,它的左子節點是第 2k 個元素,右子節點是第 2k + 1 個元素。它的父節點是┖ k/2 ┚(注:┖ X ┚表示小于等于 X 的最大整數)。
5.4 如何建立堆?
   先把 n 個元素依次放入數組中,令變量 k = ┖ n/2 ┚,這時第 k 個元素是最后一個元素的父節點,從第 k 個元素的兩個子節點中找出較小的一個與 k 元素比較,如果小于 k 元素,就和 k 元素交換一下位置,換位后的原先的 k 元素再和新的子節點比較(如果有子節點的話),直到它不再小于新的子節點或沒有子節點。令 k = k - 1。再重復上面的做法直到 k < 1,一個堆就建成了。
5.5 如何從堆中找出第二個最小的元素?
  把堆中第一個元素(最小的元素)存放到其他地方,把第 n 個元素(最后一個)放到第一個的位置,再用前面的方法和下層節點交換直到它放到合適的位置,這時數組仍然是一個堆,第一個元素是最小的節點,數組的最后一個有效節點是第 n - 1 個元素。
  花費的時間和交換的次數成正比,最大的可能的交換次數是: 堆的層數 - 1 =┏ lg (元素數 + 1) ┒- 1(注:┏ X ┒表示大于等于 X 的最小整數)。
  現在可以看到,堆之所以采用完全二叉樹的形式,是為了樹的層數盡可能少。
  而抽出最后一個元素放到樹根,而不是抽出第二層的元素,是為了維持完全二叉樹的結構!
5.6 如何加入新的元素到堆中?
  把第一個元素存放到其他地方,把新的元素放到第一個的位置,再用前面的方法和下層節點交換,直到它被放到合適的位置,此時數組中仍然是一個堆。

6. 建 huffman 樹和編碼的算法:
  如果現在有 n 個待編碼的節點,按照原始數值從小到大存放在數組 tree[n] 中,那么,將要建立的 huffman 樹總共會有 2n -1 節點,包括葉子節點和非葉子節點。申請一塊內存,大小是能放下 huffman 樹的所有節點,先把 n 個待編碼節點放入這塊內存的左端,然后用“堆排序”算法先把它們建成一個堆。
  然后不斷用“堆排序”算法取出頻率最小 的節點,把它們從右到左、從小到大排放在內存塊的右端,每當取出兩個節點,給它們生成一個父節點,頻率等于它們之和,加入堆中。這樣直到堆中只剩下一個根 節點,這時,內存中從左到右存儲的是頻率從大到小的所有節點,一棵 huffman 樹其實也就建成了,層數小的節點在前,層數大的節點在后,每一層的節點又是按頻率從大到小依次排列。
  申請兩個數組:bl_count[], bl_base[]。置根節點的碼長為 0,從左至右,所有節點的碼長(len)為它的父節點的碼長 + 1,如果是葉子節點,bl_count[len]++,得到了每一層上的葉子節點數目。令變量 code = 0,然后根據 bl_count[] 生成 bl_base[]:碼長 len 從 1 開始遞增,bl_base[len] = code = (code + bl_count[len - 1]) << 1,得到了每一層上第一個葉子節點的編碼。
  現在所有待編碼節點都被賦予了碼長,遍歷待編碼節點,根據它們的碼長得到它們的編碼:序號 n 遞增,tree[n].code = bl_base[ tree[n].len ] ++。
  注意:我們前面討論碼表的時候說過,gzip 對 huffman 編碼進行了改進,只需要得到每一個葉子節點(待編碼節點)的碼長,就可以進行編碼,而不需要關心它的父節點的編碼是什么。而保存碼表時,只需要保存碼長。

動態 huffman 壓縮和解壓的整個流程:
壓縮:
  lz77 的壓縮過程中輸出未匹配的單雙字節,和匹配,并統計各字節值和匹配長度范圍、匹配距離范圍的頻率,根據這些頻率建立兩棵 huffman 樹:ltree、dtree,得到這兩棵樹上所有節點的長度和編碼。
  統計這兩棵樹節點長度的使用頻率,對各節點長度建立 huffman 樹:bl_tree,得到 bl_tree 的長度和編碼。
  存儲 bl_tree 的節點長度數組。
  再用 bl_tree 的編碼存儲 ltree、dtree 的節點長度數組。
  再用 ltree 的編碼存儲各字節值和匹配長度范圍(及附加碼)的流;用 dtree 的編碼存儲匹配距離范圍(及附加碼)的流。
解壓:
  先根據 bl_tree 的節點長度數組得到 bl_tree 的編碼。
  再用這些編碼得到 ltree、dtree 的節點長度數組,進而得到 ltree、dtree 的編碼。
  再根據 ltree、dtree 的編碼及附加碼的定義,得到 lz77 的輸出的原始結果:各字節值和匹配長度的流,匹配距離的流。




后記:
  寫作本文花費了超過一年的業余時間,其實看懂 gzip 源碼只用了一個半月的業余時間,等真正開始寫這篇文章的時候,發現深入分析無損壓縮算法要投入的心力會遠超過我原來的想象,不是光靠“毅然決然的態度”和“拼搏精神”就可以完成的。只有耐心地去付出。
  經過了一年多的時間,終于有了現在這樣質量的這篇文章。這期間,我的工作已經從應用工程師轉變到了研究員,應該說,寫作這篇文章對促成我把今后的工作轉變為搞研究是有影響的。所以這篇文章對我自己的人生道路當然是有重要的意義,我也希望它會促成讀者投身研究的決心。
  巴甫洛夫說:“科學研究需要的是偉大的熱情和艱苦的勞作”,從看到這句話起,我就一直很喜歡它,常常會想起這句話。希望這篇文章能使讀者聯想到“長久的熱情和耐心的勞作”,并在生活和工作中貫徹這種精神。
  一篇文章發布以后,它的全部價值就在于讀者的閱讀,感謝讀者諸君。