【原創】zip 的壓縮原理與實現

來源:互聯網
上載者:User
無損資料壓縮是一件奇妙的事情,想一想,一串任意的資料能夠根據一定的規則轉換成只有原來 1/2 - 1/5 長度的資料,並且能夠按照相應的規則還原到原來的樣子,聽起來真是很酷。
半年前,苦熬過初學 vc 時那段艱難的學習曲線的我,對 MFC、SDK 開始失望和不滿,這些雖然不算易學,但和 DHTML 沒有實質上的區別,都是調用微軟提供的各種各樣的函數,不需要你自己去建立一個視窗,多線程編程時,也不需要你自己去分配 CPU 時間。我也做過驅動,同樣,有DDK(微軟碟機動開發包),當然,也有 DDK 的“參考手冊”,連一個最簡單的資料結構都不需要你自己做,一切都是函數、函數……
微軟的進階程式員編寫了函數讓我們這些搞應用的去調用,我不想在這裡貶低搞應用的人,正是這些應用工程師串連起了科學和社會之間的橋樑,將來可以做銷售,做管理,用自己逐漸積累起來的智慧和經驗在社會上打拚。
但是,在技術上來說,誠實地說,這並不高深,不是嗎?第一流的公司如微軟、Sybase、Oracle 等總是面向社會福士的,這樣才能有巨大的市場。但是他們往往也是站在社會的最頂層的:作業系統、編譯器、資料庫都值得一代代的專家去不斷研究。這些帝國般的企業之所以偉大,恐怕不是“有經驗”、“能吃苦”這些中國特色的概念所能涵蓋的,艱深的技術體系、現代的管理哲學、強大的市場能力都是缺一不可的吧。我們既然有志於技術,並且正在起步階段,何必急不可耐地要轉去做“管理”,做“青年才俊”,那些所謂的“成功人士”的根底能有幾何,這樣子浮躁,胸中的規模和格局能有多大?

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

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


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--------+
              |                   |
      +------12------+            7
      |              |
  +---5---+      +---7---+
  |       |      |       |
+-2-+   +-3-+  +-3-+   +-4-+
|   |   |   |  |   |   |   |
1   1   1   2  1   2   2   2

這個例子中,所有上層節點都大於等於下層節點,每一層最小的兩個節點結合在了一起,但仍然可以進一步最佳化:


                         根
                         |
              +---------19--------+
              |                   |
      +------12------+            7
      |              |
  +---4---+      +---8---+
  |       |      |       |
+-2-+   +-2-+  +-4-+   +-4-+
|   |   |   |  |   |   |   |
1   1   1   1  2   2   2   2

通過最低一層的第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.隨著內部節點數逐個增加,內部節點集合總維持極小化。

[ 本帖由 ncs 最後編輯於 2006-3-3 14:54 ]
ncs
小蟲




UID 521
精華 0
積分 34
文章 10
威望 29
閱讀許可權 10
註冊 2003-2-24
狀態 離線
#2  大 中 小 使用道具  發表於 2006-3-3 14:49  資料  個人空間  短訊息  加為好友   
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 用的是被稱作“雜湊表”的方法來實現較高效的搜尋。“雜湊(hash)”是分散的意思,把待搜尋的資料按照位元組值分散到一個個“桶”中,搜尋時再根據位元組值到相應的“桶”中去尋找。短語式壓縮的最短匹配為 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 位,計算也還簡單。
雜湊表的具體實現也值得探討,因為無法預Crowdsourced Security Testing道每一個“桶”會存放多少個元素,所以最簡單的,會想到用鏈表來實現:雜湊表裡存放著每個桶的第一個元素,每個元素除了存放著自身的值,還存放著一個指標,指向同一個桶中的下一個元素,可以順著指標鏈來遍曆該桶中的每一個元素,插入元素時,先用雜湊函數算出該放到第幾個桶中,再把它掛到相應鏈表的最後。這個方案的缺點是頻繁地申請和釋放記憶體會降低運行速度;記憶體指標的存放佔據了額外的記憶體開銷。有更少記憶體開銷和更快速的方法來實現雜湊表,並且不需要頻繁的記憶體申請和釋放:gzip 在記憶體中申請了兩個數組,一個叫 head[],一個叫 pre[],大小都為 32K,根據當前位置 strstart 開始的 3 個位元組,用雜湊Function Compute出在 head[] 中的位置 ins_h,然後把 head[ins_h] 中的值記入 pre[strstart],再把當前位置 strstart 記入 head[ins_h]。隨著壓縮的進行,head[]裡記載著最近的可能的匹配的位置(如果有匹配的話,head[ins_h]不為 0),pre[]中的所有位置與未經處理資料的位置相對應,但每一個位置儲存的值是前一個最近的可能的匹配的位置。(“可能的匹配”是指雜湊Function Compute出的 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. 綜合考慮各種因素(匹配數和未匹配的單雙位元組在原始檔案中所佔的比例,後一個匹配長度大於前一個匹配長度的機率,等等),經過改進的懶惰匹配演算法,對總的壓縮率即使有貢獻,也仍是很小的,而且也仍然很有可能會降低壓縮率。再考慮到時間的確定的明顯的增加與壓縮率的不確定的微弱的增益,也許最好的改進是果斷地放棄懶惰匹配。


相關文章

Beyond APAC's No.1 Cloud

19.6% IaaS Market Share in Asia Pacific - Gartner IT Service report, 2018

Learn more >

Apsara Conference 2019

The Rise of Data Intelligence, September 25th - 27th, Hangzhou, China

Learn more >

Alibaba Cloud Free Trial

Learn and experience the power of Alibaba Cloud with a free trial worth $300-1200 USD

Learn more >

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。