ZIP 算法詳解 (轉!)


zip 的壓縮原理與實現(lz77 算法壓縮)

無損數據壓縮是一件奇妙的事情,想一想,一串任意的數據能夠根據一定的規則轉換成只有原來 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 字節)。
  短語式重復的傾向和字節取值分布不均勻的傾向是可以壓縮的基礎,兩種壓縮的順序不能互換的原因也說了,下面我們來看編碼式壓縮的要求及方法:

首先,為了使用不定長的編碼表示單個字符,編碼必須符合“前綴編碼”的要求,即較短的編碼決不能是較長編碼的前綴,反過來說就是,任何一個字符的編碼,都不是由另一個字符的編碼加上若干位 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.隨着內部節點數逐個增加,內部節點集合總維持極小化。




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) 一個長匹配被加入字典的過程,是從兩個字節開始,逐次增長一個字節,確定了字典的最大容量,也就間接確定了匹配的可能的最大長度。相對於 lz77 用兩個數字來表示一個短語,lzw 只用一個數字來表示一個短語,因此,“字典序號”的位數可以取得多一點(二進制數多一位,意味着數值大一倍),也就是說最長匹配可以比 lz77 更長,當某些超長匹配高頻率地出現,直到被完整地加入字典后,lzw將開始彌補初期的低效,逐漸顯出自己的優勢。
  可以看出,在多數情況下,lz77 擁有更高的壓縮率,而在待壓縮文件中占絕大多數的是些超長匹配,並且相同的超長匹配高頻率地反復出現時,lzw 更具優勢,GIF 就是采用了 lzw 算法來壓縮背景單一、圖形簡單的圖片。zip 是用來壓縮通用文件的,這就是它采用對大多數文件有更高壓縮率的 lz77 算法的原因。

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM