《笨笨數據壓縮教程》是我在1998年因工作需要研究壓縮算法時寫的文章(算是一
種工作筆記吧,其中難免有許多疏漏),1999年初隨着項目變遷,就把壓縮技術的
研究暫時擱置了。從那以后,一是工作太忙,二是自己懶惰,總之是沒能把半部壓
縮教程補全。非常對不住大家。——王詠剛,2003年3月
目錄
前言
第一章:輕松一下:數據壓縮簡史
第二章:技術准備:概率、模型和編碼
第三章:奇妙的二叉樹:Huffman的貢獻
第四章:向極限挑戰:算術編碼
第五章:聰明的以色列人(上):LZ77
第六章:聰明的以色列人(下):LZ78 和 LZW
你一定看出了本書目錄中顯現出來的層次關系。是的,本書是按照前言中對壓縮技
術的分類編排的。假如你想系統地學習和掌握壓縮技術,最好按照章節順序依次閱
讀;當然,如果你僅僅把本書當作了解壓縮技術的窗口,或者將本書作為一本速查
手冊,那么,你完全可以根據你的需要進行跳躍式的瀏覽。
前言
大家好,我叫王笨笨。在過去的幾個月里,因為工作需要,我比較多的關注了數據
壓縮技術的現狀及其發展,並親自動手實現了幾個數據壓縮模塊。在這一過程中,
我發現這一領域的中文技術資料極其匱乏。為此,王笨笨決定編寫這本《數據壓縮
教程》,以便有一個總結記錄這幾個月學習過程的機會。
誰需要看這本書
如果你僅僅希望將你自己的一大堆霸占硬盤空間的大文件壓縮成單個的小文件,那
么不要看這本書,去看Winzip,ARJ,RAR 等應用程序的幫助好了;如果你僅僅想把
手中的精美圖片、語音信息、CD音軌乃至動畫、視頻壓縮保存,那么不要看這本書
,去學習和使用 Photoshop、MP3 Compress 等多媒體文件編輯壓縮工具就足夠了。
如果你對數據能被壓縮到如此之小感到驚訝和迷惑不解,如果你想知道上面提到的
這許多壓縮工具是如何工作的,如果你正要為自己的應用程序加入靈活的壓縮、解
壓縮模塊,如果你正在編寫自己的圖形圖像編輯工具……那么,這本書就是你的選
擇,這里有詳細的算法描述,有可供直接使用的源代碼,有Internet上關於壓縮技
術的資源介紹,有對你進一步學習壓縮技術的有效建議,快來吧!
不過記住,王笨笨比較笨,書中一定有不少缺點和錯誤,還望諸位高手指正。
壓縮技術概貌
首先大致了解一下壓縮技術的現狀吧,不懂沒有關系,了解一下而已。
壓縮技術大致可以按照以下的方法分類:
壓縮技術
│
┌────────┴────────┐
通用無損數據壓縮 多媒體數據壓縮(大多為有損壓縮)
│ │
┌────┴───┐ ┌────┬─┴───────────┐
基於統計 基於字典 音頻壓縮 圖像壓縮 視頻壓縮
模型的壓 模型的壓 │ │ │
縮技術 縮技術 MP3等 ┌┴────────┐ AVI
│ │ │ 二值 灰度 彩色 矢量 MPEG2等
┌┴──┐ ┌┴─────┤ 圖像 圖像 圖像 圖像
Huffman 算術 LZ77 LZ78 LZW │ │ │ │
編碼 編碼 └───┬──┘ 傳真機 FELICS GIF PostScript
│ │ │ 標准 JPEG等 JPEG等 Windows WMF等
UNIX下 接近無損 PKZIP、LHarc、ARJ、
的COMPACT 壓縮極限 UNIX下的COMPRESS
程序等 的高級應用 程序等
_____________________________________________________________
第一章 輕松一下:數據壓縮簡史
算起來,數據壓縮的起源要比計算機的起源早得多,有興趣的讀者可以翻閱一下任
何一本成語辭典,查查諸如“二桃三士”、“蕭規曹隨”之類的短語涵蓋了多少信
息內容。:-)
認真一點:數據壓縮技術在計算機技術的萌芽時期就已經被提上了議事日程,有關
信息如何被高效存儲和傳遞的話題不斷被軍事科學家、數學家、電子學家討論來、
討論去。終於,隨着信息論的產生和發展,數據壓縮也由熱門話題演變成了真正的
技術。
通用無損數據壓縮
科學家在研究中發現,大多數信息的表達都存在着一定的冗余度,通過采用一定的
模型和編碼方法,可以降低這種冗余度。貝爾實驗室的 Claude Shannon 和 MIT 的
R.M.Fano 幾乎同時提出了最早的對符號進行有效編碼從而實現數據壓縮的 Shann
on-Fano 編碼方法。
D.A.Huffman 於 1952 年第一次發表了他的論文“最小冗余度代碼的構造方法”(A
Method for the Construction of Minimum Redundancy Codes)。從此,數據壓縮
開始在商業程序中實現並被應用在許多技術領域。UNIX 系統上一個不太為現代人熟
知的壓縮程序 COMPACT 就是 Huffman 0 階自適應編碼的具體實現。80年代初,Hu
ffman 編碼又在 CP/M 和 DOS 系統中實現,其代表程序叫 SQ。在數據壓縮領域,
Huffman 的這一論文事實上開創了數據壓縮技術一個值得回憶的時代,60 年代、7
0 年代乃至 80 年代的早期,數據壓縮領域幾乎一直被 Huffman 編碼及其分支所壟
斷。如果不是后面將要提到的那兩個以色列人,也許我們今天還要在 Huffman 編碼
的 0 和 1 的組合中流連忘返。
讓我們沿着 Huffman 的軌跡再向后跳躍幾年,80 年代,數學家們不滿足於 Huffm
an 編碼中的某些致命弱點,他們從新的角度入手,遵循 Huffman 編碼的主導思想
,設計出另一種更為精確,更能接近信息論中“熵”極限的編碼方法——算術編碼
。憑借算術編碼的精妙設計和卓越表現,人們終於可以向着數據壓縮的極限前進了
。可以證明,算術編碼得到的壓縮效果可以最大地減小信息的冗余度,用最少量的
符號精確表達原始信息內容。當然,算術編碼同時也給程序員和計算機帶來了新的
挑戰:要實現和運行算術編碼,需要更為艱苦的編程勞動和更加快速的計算機系統
。也就是說,在同樣的計算機系統上,算術編碼雖然可以得到最
好的壓縮效果,但卻要消耗也許幾十倍的計算時間。這就是為什么算術編碼不能在
我們日常使用的壓縮工具中實現的主要原因。那么,能不能既在壓縮效果上超越 H
uffman,又不增加程序對系統資源和時間的需求呢?我們必須感謝下面將要介紹的
兩個以色列人。
直到 1977 年,數據壓縮的研究工作主要集中於熵、字符和單詞頻率以及統計模型
等方面,研究者們一直在絞盡腦汁為使用 Huffman 編碼的程序找出更快、更好的改
進方法。1977 年以后,一切都改變了。
1977 年,以色列人 Jacob Ziv 和 Abraham Lempel 發表了論文“順序數據壓縮的
一個通用算法”(A Universal Alogrithem for Sequential Data Compression)。
1978 年,他們發表了該論文的續篇“通過可變比率編碼的獨立序列的壓縮”(Comp
ression of Individual Sequences via Variable-Rate Coding)。
所有的一切都改變了,在這兩篇論文中提出的兩個壓縮技術被稱為 LZ77 和 LZ78
(不知為什么,作者名字的首字母被倒置了)。簡單地說,這兩種壓縮方法的思路完
全不同於從 Shannon 到 Huffman 到算術壓縮的傳統思路,倒是和本章開頭所舉的
成語辭典的例子頗為相似,因此,人們將基於這一思路的編碼方法稱作“字典”式
編碼。字典式編碼不但在壓縮效果上大大超過了 Huffman,而且,對於好的實現,
其壓縮和解壓縮的速度也異常驚人。1984 年,Terry Welch 發表了名為“高性能數
據壓縮技術”(A Technique for High-Performance Data Compression)的論文,描
述了他在 Sperry Research Center(現在是 Unisys 的一部分)的研究成果。他實現
了 LZ78 算法的一個變種 —— LZW。LZW 繼承了 LZ77 和 LZ78 壓縮效果好、速度
快的優點,而且在算法
描述上更容易被人們接受(有的研究者認為是由於 Welch 的論文比 Ziv 和 Lempe
l 的更容易理解),實現也比較簡單。不久,UNIX 上出現了使用 LZW 算法的 Com
press 程序,該程序性能優良,並有高水平的文檔,很快成為了 UNIX 世界的壓縮
程序標准。緊隨其后的是 MS-DOS 環境下的 ARC 程序( System Enhancement Asso
ciates, 1985 ),還有象 PKWare、PKARC 等仿制品。LZ78 和 LZW 一時間統治了
UNIX 和 DOS 兩大平台。
80 年代中期以后,人們對 LZ77 進行了改進,隨之誕生了一批我們今天還在大量使
用的壓縮程序。Haruyasu Yoshizaki(Yoshi) 的 LHarc 和 Robert Jung 的 ARJ 是
其中兩個著名的例子。LZ77 得以和LZ78、LZW 一起壟斷當今的通用數據壓縮領域。
目前,基於字典方式的壓縮已經有了一個被廣泛認可的標准,從古老的 PKZip 到現
在的 WinZip,特別是隨着 Internet 上文件傳輸的流行,ZIP 格式成為了事實上的
標准,沒有哪一種通用的文件壓縮、歸檔系統敢於不支持 ZIP 格式。
多媒體信息的壓縮
今天的程序員們和設計師們往往樂此不疲地為計算機更換更大的硬盤,增加更多的
內存,其主要目的是為了存放和處理越來越多的聲音、圖像和視頻數據。對聲音、
圖像、視頻等多媒體信息的壓縮有兩條思路,要么采用成熟的通用數據壓縮技術進
行壓縮,要么根據媒體信息的特性設計新的壓縮方法。事實上,人們在兩條道路上
都作了卓有成效的探索。
還記得 GIF 格式嗎?GIF 可以把原始圖形文件以非常小數據量存儲,可以在同一個
文件中存儲多幅圖像從而實現動畫效果。知道 GIF 中的圖像使用什么方法壓縮的嗎
?LZW! 原來如此啊。GIF 大概是使用通用壓縮技術壓縮圖像信息的最成功的例子,
當然,GIF 文件中除了經過 LZW 壓縮的像素信息以外,還保存有圖像的各種屬性信
息以及圖像所使用的調色板信息等。GIF 精確地保留了原始圖像的每一個像素信息
,是無損圖像壓縮的代表。
根據媒體特性量身定制的壓縮方法中,行程編碼(RLE: Run-Length Encoding)是最
為簡單、最容易被想到的一種。大多數計算機中產生的圖像(和現實世界的圖像例如
照片不同)都具有着大面積重復的顏色塊,為什么非要用無數個完全相同的顏色值來
表示某塊圖像呢?我們完全可以用一個顏色值加一個重復次數來表示這一塊圖像,
冗余度由此減小了,這就是 RLE 方法的基本思路。顯然,它不適於用來壓縮照片、
聲音等很少連續重復信息的數據。RLE 方法最有代表性的實現有 PCX 和 Targa 圖
形格式。
如果分別考察的話,只有黑白兩種顏色的二值圖像以及只有 256 級灰度變化的圖像
具有一些獨特的地方,可以被壓縮算法加以利用。我們知道,傳真圖像是一種典型
的二值圖像。國際電報電話咨詢委員會(CCITT )為此建立了一系列的壓縮標准,專
門用於壓縮傳遞二值圖像(可以是無損的或有損的)。對於灰度圖像,除了著名的
JPEG 標准以外,后文將要介紹的一種叫 FELICS 的算法可以實現效果非常好的無
損壓縮。
70 年代末 80 年代初,人們逐漸意識到,對到多數灰度或是彩色圖像乃至聲音文件
,沒有必要忠實地保留其所有信息,在允許一定的精度損失的情況下,可以實現更
為有效的壓縮方法。到 80 年代末,許多人已經在這一領域取得了不小的收獲,設
計出了一批在壓縮效果上讓人驚訝不已的聲音和圖像壓縮算法。在此基礎上,國際
標准化組織( ISO )和 CCITT 聯合組成了兩個委員會。委員會的名字我們大概都已
經非常熟悉了:靜態圖像聯合專家小組( JPEG )和動態圖像聯合專家小組( MPEG )
。JPEG 的壓縮目標是靜止圖像(灰度的和彩色的),MPEG 的目標則是聲音和視頻
。但他們的基本思路是完全一樣的,即保留媒體信息中最有規律、最能體現信息主
要特征的數據,而略去其他不重要的數據。他們都取得了令人贊嘆的成就。
你剛看完 VCD 嗎?那么你剛剛享用過他們為我們帶來的樂趣了。知道普通 VCD 每
一幀有多少彩色像素嗎?知道每秒鍾播放多少幀嗎?知道的話,算一算一部100 分
鍾的電影不壓縮的話需要多少空間。每張光盤的容量是 640M,那么,不壓縮的電影
需要多少張光盤來存放呢?你該知道 JPEG 或是 MPEG 的厲害了吧。
最后,必須簡單地提到與圖像壓縮領域相關的電子出版印刷領域中的一種叫做 Pos
tScript 的東西。PostScript是作為電子印刷業的標准頁面描述語言被設計出來的
,它起源於 1976 年的 Evans & Sutherland 計算機公司,當時的名字是 Design
System。1978 年,John Warnock 和 Martin Newel 將其演變為 JAM 語言。1982
年,John Warnock 和 Chuck Geschke 創建了著名的 Adobe System 公司,並第三
次設計和實現了這個語言,並將其稱為 PostScript。PostScript 的主要思路是存
儲和傳輸預先定義的命令來“畫”出圖像,而不是存儲和傳輸圖像的每一個像素,
這特別適用於在激光打印機上的輸出。采用類似“從(10, 10)到(100, 100)畫一條
紅色直線”或是“在(50,50)以 40 為半徑畫一個藍色的圓”之類的命令存儲圖
像顯然比直接存儲每個像素節省了不少地方。
所以,從壓縮技術的角度來看,PostScript 也可以算是壓縮方法的一種。根據類似
的原理,Windows 中的 WMF 格式、HP 的 HPGL 語言、AutoCAD 中使用的 DXF格式
等等,都可以對某種特定的圖像進行有效的壓縮。
_____________________________________________________________
第二章 技術准備:概率、模型和編碼
什么是熵
數據壓縮不僅起源於 40 年代由 Claude Shannon 首創的信息論,而且其基本原理
即信息究竟能被壓縮到多小,至今依然遵循信息論中的一條定理,這條定理借用了
熱力學中的名詞“熵”( Entropy )來表示一條信息中真正需要編碼的信息量:
考慮用 0 和 1 組成的二進制數碼為含有 n 個符號的某條信息編碼,假設符號 Fn
在整條信息中重復出現的概率為 Pn,則該符號的熵也即表示該符號所需的位,數
位為:
En = - log2( Pn )
整條信息的熵也即表示整條信息所需的位數為:E = ∑En
舉個例子,對下面這條只出現了 a b c 三個字符的字符串:
aabbaccbaa
字符串長度為 10,字符 a b c 分別出現了 5 3 2 次,則 a b c 在信息中出現的
概率分別為 0.5 0.3 0.2,他們的熵分別為:
Ea = -log2(0.5) = 1
Eb = -log2(0.3) = 1.737
Ec = -log2(0.2) = 2.322
整條信息的熵也即表達整個字符串需要的位數為:
E = Ea * 5 + Eb * 3 + Ec * 2 = 14.855 位
回想一下如果用計算機中常用的 ASCII 編碼,表示上面的字符串我們需要整整 80
位呢!現在知道信息為什么能被壓縮而不丟失原有的信息內容了吧。簡單地講,用
較少的位數表示較頻繁出現的符號,這就是數據壓縮的基本准則。
細心的讀者馬上會想到,我們該怎樣用 0 1 這樣的二進制數碼表示零點幾個二進制
位呢?確實很困難,但不是沒有辦法。一旦我們找到了准確表示零點幾個二進制位
的方法,我們就有權利向無損壓縮的極限挑戰了。不要着急,看到第四章就明白了
。
模型
從上面的描述,我們明白,要壓縮一條信息,首先要分析清楚信息中每個符號出現
的概率。不同的壓縮程序通過不同的方法確定符號的出現概率,對符號的概率計算
得越准確,也就越容易得到好的壓縮效果。在壓縮程序中,用來處理輸入信息,計
算符號的概率並決定輸出哪個或哪些代碼的模塊叫做模型。
難道對信息中字符的出現概率這么難以估計以至於有各種不同的壓縮模型嗎?對上
面的字符串我們不是很容易就知道每個字符的概率了嗎?是的是的,不過上面的字
符串僅有 10 個字符長呀,那只是例子而已。考慮我們現實中要壓縮的文件,大多
數可是有幾十 K 甚至幾百 K 長,幾 M 字節的文件不是也屢見不鮮嗎?
是的,我們可以預先掃描文件中的所有字符,統計出每個字符出現的概率,這種方
法在壓縮術語里叫做“靜態統計模型”。但是,不同的文件中,字符有不同的分布
概率,我們要么先花上大量的時間統計我們要壓縮的所有文件中的字符概率,要么
為每一個單獨的文件保存一份概率表以備解壓縮時需要。糟糕的是,不但掃描文件
要消耗大量時間,而且保存一份概率表也使壓縮后的文件增大了不少。所以,在實
際應用中,“靜態統計模型”應用的很少。
真正的壓縮程序中使用的大多是一種叫“自適應模型”的東西。自適應模型可以說
是一台具有學習功能的自動機。他在信息被輸入之前對信息內容一無所知並假定每
個字符的出現概率均等,隨着字符不斷被輸入和編碼,他統計並紀錄已經出現過的
字符的概率並將這些概率應用於對后續字符的編碼。也就是說,自適應模型在壓縮
開始時壓縮效果並不理想,但隨着壓縮的進行,他會越來越接近字符概率的准確值
,並達到理想的壓縮效果。自適應模型還可以適應輸入信息中字符分布的突然變化
,可以適應不同的文件中的字符分布而不需要保存概率表。
上面提到的模型可以統稱為“統計模型”,因為他們都是基於對每個字符出現次數
的統計得到字符概率的。另一大類模型叫做“字典模型”。實際上,當我們在生活
中提到“工行”這個詞的時候,我們都知道其意思是指“中國工商銀行”,類似的
例子還有不少,但共同的前提是我們心中都有一本約定俗成的縮寫字典。
字典模型也是如此,他並不直接計算字符出現的概率,而是使用一本字典,隨着輸
入信息的讀入,模型找出輸入信息在字典中匹配的最長的字符串,然后輸出該字符
串在字典中的索引信息。匹配越長,壓縮效果越好。事實上,字典模型本質上仍然
是基於對字符概率的計算的,只不過,字典模型使用整個字符串的匹配代替了對某
一字符重復次數的統計。可以證明,字典模型得到的壓縮效果仍然無法突破熵的極
限。
當然,對通用的壓縮程序來說,保存一本大字典所需的空間仍然是無法讓人忍受的
,況且,任何一本預先定義的字典都無法適應不同文件中數據的變化情況。對了,
字典模型也有相應的“自適應”方案。我們可以隨着信息的不斷輸入,從已經輸入
的信息中建立合適的字典,並不斷更新這本字典,以適應數據的不斷變化。
讓我們從另一個角度理解一下自適應模型。Cluade Shannon 曾試圖通過一個“聚會
游戲”(party game)來測定英語的真實信息容量。他每次向聽眾公布一條被他隱藏
起一個字符的消息,讓聽眾來猜下一個字符是什么,一次猜一個,直到猜對為止。
然后,Shannon 使用猜測次數來確定整個信息的熵。在這個實驗中,一種根據前面
出現過的字符估計下一個字符概率的模型就存在於聽眾的頭腦中,比計算機中使用
的自適應模型更為高級的是,聽眾除了根據字符出現過的次數外,還可以根據他們
對語言的經驗進行猜測。
編碼
通過模型,我們已經確定了對某一個符號該用多少位二進制數進行編碼。現在的問
題是,如何設計一種編碼方案,使其盡量精確地用模型計算出來的位數表示某個符
號。最先被考慮的問題是,如果對 a 用 3 個二進制位就可以表示,而對 b 用 4
個二進制位就可以表示,那么,在解碼時,面對一連串的二進制流,我怎么知道哪
三個位是 a,哪四個位是 b 呢?所以,必須設計出一種編碼方式,使得解碼程序可
以方便地分離每個字符的編碼部分。於是有了一種叫“前綴編碼”的技術。該技術
的主導思想是,任何一個字符的編碼,都不是另一個字符編碼的前綴。反過來說就
是,任何一個字符的編碼,都不是由另一個字符的編碼加上若干位 0 或 1 組成。
看一下前綴編碼的一個最簡單的例子:
符號 編碼
A 0
B 10
C 110
D 1110
E 11110
有了上面的碼表,你一定可以輕松地從下面這串二進制流中分辨出真正的信息內容
了:
1110010101110110111100010 – DABBDCEAAB
下一個問題是:象上面這樣的前綴編碼只能表示整數位的符號,對幾點幾位的符號
只能用近似的整數位輸出,那么怎樣輸出小數位數呢?科學家們用算術編碼解決了
這個問題,我們將在第四章對算術編碼作詳細的討論。
總結一下
不同的模型使用不同的方法計算字符的出現概率,由此概率可以得出字符的熵;然
后使用不同的編碼方法,盡量接近我們期望得到的熵值。所以,壓縮效果的好壞一
方面取決於模型能否准確地得到字符概率,另一方面也取決於編碼方法能否准確地
用期望的位數輸出字符代碼。換句話說,壓縮 = 模型 + 編碼。如下圖所示:
┌────┐ 符號 ┌────┐ 概率 ┌────┐ 代碼 ┌────┐
│ 輸入 │───-→│ 模型 │───-→│ 編碼 │───-→│ 輸出 │
└────┘ └────┘ └────┘ └────┘
資源
我們已經知道,編寫壓縮程序往往不能對數據的整個字節進行處理,而是要按照二
進制位來讀寫和處理數據,操作二進制位的函數也就成為了壓縮程序中使用最為普
遍的工具函數。我們在此提供兩組函數集,使用它們可以有效的進行文件或內存中
的二進制位操作。它們共有六個文件:bitio.h - 用於文件中二進制位操作的函數
說明。
bitio.cpp - 用於文件中二進制位操作的函數實現。
errhand.h 和 errhand.cpp - bitio.cpp 中使用的錯誤處理函數。
wm_bitio.h - 用於內存中二進制位操作的函數說明。
wm_bitio.cpp - 用於內存中二進制位操作的函數實現。
它們被共同包裝在文件
http://www.contextfree.net/wangyg/a/tutorial/benben/src/bitio.zip
中。
_____________________________________________________________
第三章 奇妙的二叉樹:Huffman的貢獻
提起 Huffman 這個名字,程序員們至少會聯想到二叉樹和二進制編碼。的確,我們
總以 Huffman 編碼來概括 D.A.Huffman 個人對計算機領域特別是數據壓縮領域的
傑出貢獻。我們知道,壓縮 = 模型 + 編碼,作為一種壓縮方法,我們必須全面考
慮其模型和編碼兩個模塊的功效;但同時,模型和編碼兩個模塊又相互具有獨立性
。舉例來說,一個使用 Huffman 編碼方法的程序,完全可以采用不同的模型來統計
字符在信息中出現的概率。因此,我們這一章將首先圍繞 Huffman 先生最為重要的
貢獻 —— Huffman 編碼展開討論,隨后,我們再具體介紹可以和 Huffman 聯合使
用的概率模型。
為什么是二叉樹
為什么壓縮領域中的編碼方法總和二叉樹聯系在一起呢?原因非常簡單,回憶一下
我們介紹過的“前綴編碼”:為了使用不固定的碼長表示單個字符,編碼必須符合
“前綴編碼”的要求,即較短的編碼決不能是較長編碼的前綴。要構造符合這一要
求的二進制編碼體系,二叉樹是最理想的選擇。考察下面這棵二叉樹:
根(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
Shannon-Fano 編碼進入 Huffman 先生構造的神奇二叉樹之前,我們先來看一下它
的前身,由 Claude Shannon 和 R.M.Fano 兩人提出的 Shannon-Fano 編碼。
討論之前,我們假定要編碼字符的出現概率已經由某一模型統計出來,例如,對下
面這串出現了五種字符的信息( 40 個字符長 ):
Cabcedeacacdeddaaabaababaaabbacdebaceada
五種字符的出現次數分別:a - 16,b - 7,c - 6,d - 6,e - 5。
Shannon-Fano 編碼的核心仍然是構造二叉樹,構造的方式非常簡單:
1) 將給定符號按照其頻率從大到小排序。對上面的例子,應該得到:
a - 16
b - 7
c - 6
d - 6
e - 5
2) 將序列分成上下兩部分,使得上部頻率總和盡可能接近下部頻率總和。我們有:
a - 16
b - 7
──────
c - 6
d - 6
e - 5
3) 我們把第二步中划分出的上部作為二叉樹的左子樹,記 0,下部作為二叉樹的右
子樹,記 1。
4) 分別對左右子樹重復 2 3 兩步,直到所有的符號都成為二叉樹的樹葉為止。現
在我們有如下的二叉樹:
根(root)
0 │ 1
┌───┴──┐
0 │ 1 0 │ 1
┌─┴──┐ ┌─┴──┐
│ │ │ │
a b c │
0 │ 1
┌──┴──┐
│ │
d e
於是我們得到了此信息的編碼表:
a - 00 b - 01 c - 10 d - 110 e – 111
可以將例子中的信息編碼為:
cabcedeacacdeddaaabaababaaabbacdebaceada
10 00 01 10 111 110 111 00 10 00 10 ......
碼長共 91 位。考慮用 ASCII 碼表示上述信息需要 8 * 40 = 240 位,我們確實實
現了數據壓縮。
Huffman 編碼
Huffman 編碼構造二叉樹的方法和 Shannon-Fano 正好相反,不是自上而下,而是
從樹葉到樹根生成二叉樹。現在,我們仍然使用上面的例子來學習 Huffman 編碼方
法。
1) 將各個符號及其出現頻率分別作為不同的小二叉樹(目前每棵樹只有根節點)。
a(16) b(7) c(6) d(6) e(5)
2) 在 1 中得到的樹林里找出頻率值最小的兩棵樹,將他們分別作為左、右子樹連
成一棵大一些的二叉樹
,該二叉樹的頻率值為兩棵子樹頻率值之和。對上面的例子,我們得到一個新的樹
林:
(11)
a(16) b(7) c(6) │
┌─┴─┐
│ │
d e
3) 對上面得到的樹林重復 2 的做法,直到所有符號都連入樹中為止。這一步完成
后,我們有這樣的二叉樹:
根(root)
0 │ 1
┌───┼───────┐
│ 0 │ 1
│ ┌────┼─────┐
│ 0 │ 1 0 │ 1
a ┌───┴──┐ ┌───┴───┐
│ │ │ │
b c d e
由此,我們可以建立和 Shannon-Fano 編碼略微不同的編碼表:
a - 0 b - 100 c - 101 d - 110 e - 111
對例子中信息的編碼為:
cabcedeacacdeddaaabaababaaabbacdebaceada
101 0 100 101 111 110 111 0 101 0 101 ......
碼長共 88 位。這比使用 Shannon-Fano 編碼要更短一點。
讓我們回顧一下熵的知識,使用我們在第二章學到的計算方法,上面的例子中,每
個字符的熵為:
Ea = - log2(16 / 40) = 1.322
Eb = - log2( 7 / 40) = 2.515
Ec = - log2( 6 / 40) = 2.737
Ed = - log2( 6 / 40) = 2.737
Ee = - log2( 5 / 40) = 3.000
信息的熵為:
E = Ea * 16 + Eb * 7 + Ec * 6 + Ed * 6 + Ee * 5 = 86.601
也就是說,表示該條信息最少需要 86.601 位。我們看到,Shannon-Fano 編碼和
Huffman 編碼都已經比較接近該信息的熵值了。同時,我們也看出,無論是Shanno
n-Fano 還是 Huffman,都只能用近似的整數位來表示單個符號,而不是理想的小數
位。我們可以將它們做一個對比:
符號 理想位數 S-F 編碼 Huffman 編碼
( 熵 ) 需要位數 需要位數
─────────────────────────
a 1.322 2 1
b 2.515 2 3
c 2.737 2 3
d 2.737 3 3
e 3.000 3 3
─────────────────────────
總 計 86。601 91 88
這就是象 Huffman 這樣的整數位編碼方式無法達到最理想的壓縮效果的原因。
為 Huffman 編碼選擇模型(附范式 Huffman 編碼)
最簡單,最容易被 Huffman 編碼利用的模型是“靜態統計模型”,也就是說在編碼
前統計要編碼的信息中所有字符的出現頻率,讓后根據統計出的信息建立編碼樹,
進行編碼。這種模型的缺點是顯而易見的:
首先,對數據量較大的信息,靜態統計要消耗大量的時間;其次,必須保存統計出
的結果以便解碼時構造相同的編碼樹,或者直接保存編碼樹本身,而且,對於每次
靜態統計,都有不同的結果,必須分別予以保存,這要消耗大量的空間(這意味着
壓縮效率的下降);再次,事實上,即使不將編碼樹計算在內,對通常含有 0 - 2
55 字符集的計算機文件來說,靜態統計模型統計出的頻率是字符在整個文件中的出
現頻率,往往反映不出字符在文件中不同局部出現頻率的變化情況,使用這一頻率
進行壓縮,大多數情況下得不到太好壓縮效果,文件有時甚至在壓縮后反而增大了
。所以,“靜態統計模型”一般僅作為復雜算法的某一部分出現,在信息的某一局
部完成壓縮功能。我們很難將其用於獨立的壓縮系統。
有一種有效的“靜態統計模型”的替代方案,如果我們要壓縮的所有信息具有某些
共同的特性,也即在分布上存在着共同的特征,比如我們要壓縮的是普通的英文文
本,那么,字母 a 或者字母 e 的出現頻率應當是大致穩定的。使用語言學家事先
已經建立好的字母頻率表來進行壓縮和解壓縮,不但不用保存多份統計信息,而且
一般說來對該類文件有着較好的壓縮效果。這種方案除了適應性不太強以外,偶爾
還會有一些尷尬的時候。讀一遍下面這段話:
If Youth,throughout all history, had had a champion to stand up for it
; to show a doubting world that a child can think;and, possibly, do
it practically; you wouldn't constantly run across folks today who clai
m that "a child don't know anything." - Gadsby by E.V.Wright, 1939.
發現什么問題了嗎?哦,整段話中竟沒有出現一次英文中出現頻率最高的字母 e !
真讓人驚訝,但沒有辦法,事先擬定的頻率分布總有意外的時候。對英文或中文文
本,有一種比較實用的靜態模型:不是把字符而是把英文單詞或中文詞語作為統計
頻率和編碼的單位進行壓縮。也就是說,每次編碼的不再是 a b c 這樣的單個符號
,而是 the look flower 這樣的單詞。這種壓縮方式可以達到相當不錯的壓縮效果
,並被廣泛地用於全文檢索系統。對基於詞的編碼方式,需要解決幾個技術難點。
首先是分詞的問題,英文單詞可以由詞間空格分隔,但中文怎么辦呢?其實,有很
多中文分詞算法可以解決這個問題,本文就不再詳細介紹了。一旦我們將詞語分離
出來,我們就可以對每個詞進行頻率統計,然后建立 Huffman 編碼樹,輸出編碼時
,一個編碼將代替一個詞語。但要注意,英文和漢語的單詞數量都在幾萬到十幾萬
左右,也就是說,我們的 Huffman 編碼樹將擁有十幾萬個葉子節點,這對於一棵樹
來說太大太大了,系統將無力承擔所需要的資源,這怎么辦呢?我們可以暫時拋開
樹結構,采用另一種構造 Huffman 編碼的方式——范式 Huffman 編碼。
范式 Huffman 編碼(Canonical Huffman Code)的基本思路是:並非只有使用二叉樹
建立的前綴編碼才是Huffman 編碼,只要符合(1)是前綴編碼(2)某一字符編碼長度
和使用二叉樹建立的該字符的編碼長度相同。這兩個條件的編碼都可以叫做 Huffm
an 編碼。考慮對下面六個單詞的編碼:
符號 出現次數 傳統 Huffman 編碼 范式 Huffman 編碼
─────────────────────────────
單詞1 10 000 000
單詞2 11 001 001
單詞3 12 100 010
單詞4 13 101 011
單詞5 22 01 10
單詞6 23 11 11
注意到范式 Huffman 編碼的獨特之處了嗎?你無法使用二叉樹來建立這組編碼,但
這組編碼確實能起到和 Huffman 編碼相同的作用。而且,范式 Huffman 編碼具有
一個明顯的特點:當我們把要編碼的符號按照其頻率從小到大排列時,如果把范式
Huffman 編碼本身作為單詞的話,也呈現出從小到大的字典順序。
構造范式 Huffman 編碼的方法大致是:
1) 統計每個要編碼符號的頻率。
2) 根據這些頻率信息求出該符號在傳統 Huffman 編碼樹中的深度(也就是表示該
符號所需要的位數 - 編碼長度)。因為我們關心的僅僅是該符號在樹中的深度,我
們完全沒有必要構造二叉樹,僅用一個數組就可以模擬二叉樹的創建過程並得到符
號的深度,具體方法這里就不詳述了。3) 分別統計從最大編碼長度 maxlength 到
1 的每個長度對應了多少個符號。根據這一信息從 maxlength 個 0 開始以遞增順
序為每個符號分配編碼。例如,編碼長度為 5 的符號有 4 個,長度為 3 的有 1
個,長度為 2 的有 3 個,則分配的編碼依次為: 00000 00001 00010 00011 001
01 10 11
4) 編碼輸出壓縮信息,並保存按照頻率順序排列的符號表,然后保存每組同樣長度
編碼中的最前一個編碼以及該組中的編碼個數。現在完全可以不依賴任何樹結構進
行高速解壓縮了。而且在整個壓縮、解壓縮過程中需要的空間比傳統Huffman 編碼
少得多。
最后要提到的是,Huffman 編碼可以采用自適應模型,根據已經編碼的符號頻率決
定下一個符號的編碼。這時,我們無需為解壓縮預先保存任何信息,整個編碼是在
壓縮和解壓縮過程中動態創建的,而且自適應編碼由於其符號頻率是根據信息內容
的變化動態得到的,更符合符號的局部分布規律,因此在壓縮效果上比靜態模型好
許多。但是,采用自適應模型必須考慮編碼表的動態特性,即編碼表必須可以隨時
更新以適應符號頻率的變化。對於 Huffman 編碼來說,我們很難建立能夠隨時更新
的二叉樹,使用范式 Huffman 編碼是個不錯的選擇,但依然存在不少技術上的難題
。幸好,如果願意的話,我們可以暫時不考慮自適應模型的 Huffman 編碼,因為對
於自適應模型我們還有許多更好的選擇,下面幾章將要談到的算術編碼、字典編碼
等更為適合采用自適應模型,我們將在其中深入探討自適應模型的各種實現方法。
____________________________________________________________
第四章 向極限挑戰:算術編碼
我們在上一章中已經明白,Huffman 編碼使用整數個二進制位對符號進行編碼,這
種方法在許多情況下無法得到最優的壓縮效果。假設某個字符的出現概率為 80%,
該字符事實上只需要 -log2(0.8) = 0.322 位編碼,但 Huffman 編碼一定會為其分
配一位 0 或一位 1 的編碼。可以想象,整個信息的 80% 在壓縮后都幾乎相當於理
想長度的 3 倍左右,壓縮效果可想而知。
難道真的能只輸出 0.322 個 0 或 0.322 個 1 嗎?是用剪刀把計算機存儲器中的
二進制位剪開嗎?計算機真有這樣的特異功能嗎?慢着慢着,我們不要被表面現象
所迷惑,其實,在這一問題上,我們只要換一換腦筋,從另一個角度……哎呀,還
是想不通,怎么能是半個呢?好了,不用費心了,數學家們也不過是在十幾年前才
想到了算術編碼這種神奇的方法,還是讓我們虛心地研究一下他們究竟是從哪個角
度找到突破口的吧。
輸出:一個小數更神奇的事情發生了,算術編碼對整條信息(無論信息有多么長)
,其輸出僅僅是一個數,而且是一個介於 0 和 1 之間的二進制小數。例如算術編
碼對某條信息的輸出為 1010001111,那么它表示小數0.1010001111,也即十進制數
0.64。
咦?怎么一會兒是表示半個二進制位,一會兒又是輸出一個小數,算術編碼怎么這
么古怪呀?不要着急,我們借助下面一個簡單的例子來闡釋算術編碼的基本原理。
為了表示上的清晰,我們暫時使用十進制表示算法中出現的小數,這絲毫不會影響
算法的可行性。
考慮某條信息中可能出現的字符僅有 a b c 三種,我們要壓縮保存的信息為 bccb
。在沒有開始壓縮進程之前,假設我們對 a b c 三者在信息中的出現概率一無所知
(我們采用的是自適應模型),沒辦法,我們暫時認為三者的出現概率相等,也就
是都為 1/3,我們將 0 - 1 區間按照概率的比例分配給三個字符,即 a 從 0.000
0 到 0.3333,b 從 0.3333 到 0.6667,c 從 0.6667 到 1.0000。
用圖形表示就是:
┌─1.0000
│
Pc=1/3│
│
├─0.6667
│
Pb=1/3│
│
├─0.3333
│
Pa=1/3│
│
└─0.0000
現在我們拿到第一個字符 b,讓我們把目光投向 b 對應的區間 0.3333 - 0.6667。
這時由於多了字符 b,三個字符的概率分布變成:Pa = 1/4,Pb = 2/4,Pc = 1/4
。好,讓我們按照新的概率分布比例划分0.3333 - 0.6667 這一區間,划分的結果
可以用圖形表示為:
┌─0.6667
Pc=1/4│
├─0.5834
│
│
Pb=2/4│
│
│
├─0.4167
Pa=1/4│
└─0.3333
接着我們拿到字符 c,我們現在要關注上一步中得到的 c 的區間 0.5834 - 0.666
7。新添了 c 以后,三個字符的概率分布變成 Pa = 1/5,Pb = 2/5,Pc = 2/5。我
們用這個概率分布划分區間 0.5834 - 0.6667:
┌─0.6667
│
Pc=2/5│
│
├─0.6334
│
Pb=2/5│
│
├─0.6001
Pa=1/5│
└─0.5834
現在輸入下一個字符 c,三個字符的概率分布為:Pa = 1/6,Pb = 2/6,Pc = 3/6
。我們來划分 c 的區間 0.6334 - 0.6667:
┌─0.6667
│
│
Pc=3/6│
│
│
├─0.6501
│
Pb=2/6│
│
├─0.6390
Pa=1/6│
└─0.6334
輸入最后一個字符 b,因為是最后一個字符,不用再做進一步的划分了,上一步中
得到的 b 的區間為0.6390 - 0.6501,好,讓我們在這個區間內隨便選擇一個容易
變成二進制的數,例如 0.64,將它變成二進制 0.1010001111,去掉前面沒有太多
意義的 0 和小數點,我們可以輸出 1010001111,這就是信息被壓縮后的結果,我
們完成了一次最簡單的算術壓縮過程。
怎么樣,不算很難吧?可如何解壓縮呢?那就更簡單了。解壓縮之前我們仍然假定
三個字符的概率相等,並得出上面的第一幅分布圖。解壓縮時我們面對的是二進制
流 1010001111,我們先在前面加上 0 和小數點把它變成小數 0.1010001111,也就
是十進制 0.64。這時我們發現 0.64 在分布圖中落入字符 b 的區間內,我們立即
輸出字符 b,並得出三個字符新的概率分布。類似壓縮時采用的方法,我們按照新
的概率分布划分字符 b 的區間。在新的划分中,我們發現 0.64 落入了字符 c 的
區間,我們可以輸出字符 c。同理,我們可以繼續輸出所有的字符,完成全部解壓
縮過程(注意,為了敘述方便,我們暫時回避了如何判斷解壓縮結束的問題,實際
應用中,這個問題並不難解決)。
現在把教程拋開,仔細回想一下,直到你理解了算術壓縮的基本原理,並產生了許
多新的問題為止。
真的能接近極限嗎?
現在你一定明白了一些東西,也一定有了不少新問題,沒有關系,讓我們一個一個
解決。
首先,我們曾反復強調,算術壓縮可以表示小數個二進制位,並由此可以接近無損
壓縮的熵極限,怎么從上面的描述中沒有看出來呢?
算術編碼實際上采用了化零為整的思想來表示小數個二進制位,我們確實無法精確
表示單個小數位字符,但我們可以將許多字符集中起來表示,僅僅允許在最后一位
有些許的誤差。
結合上面的簡單例子考慮,我們每輸入一個符號,都對概率的分布表做一下調整,
並將要輸出的小數限定在某個越來越小的區間范圍內。對輸出區間的限定是問題的
關鍵所在,例如,我們輸入第一個字符 b 時,輸出區間被限定在 0.3333 - 0.666
7 之間,我們無法決定輸出值得第一位是 3、4、5 還是 6,也就是說,b 的編碼長
度小於一個十進制位(注意我們用十進制講解,和二進制不完全相同),那么我們
暫時不決定輸出信息的任何位,繼續輸入下面的字符。直到輸入了第三個字符 c 以
后,我們的輸出區間被限定在 0.6334 - 0.6667 之間,我們終於知道輸出小數的第
一位(十進制)是 6,但仍然無法知道第二位是多少,也即前三個字符的編碼長度
在 1 和 2 之間。等到我們輸入了所有字符之后,我們的輸出區間為0.6390 - 0.6
501,我們始終沒有得到關於第二位的確切信息,現在我們明白,輸出所有的 4 個
字符,我們只需要 1 點幾個十進制位,我們唯一的選擇是輸出 2 個十進制位 0.6
4。這樣,我們在誤差不超過 1 個十進制位的情況下相當精確地輸出了所有信息,
很好地接近了熵值(需要注明的是,為了更好地和下面的課程接軌,上面的例子采
用的是 0 階自適應模型,其結果和真正的熵值還有一定的差距)。
小數有多長?
你一定已經想到,如果信息內容特別豐富,我們要輸出的小數將會很長很長,我們
該如何在內存中表示如此長的小數呢?其實,沒有任何必要在內存中存儲要輸出的
整個小數。我們從上面的例子可以知道,在編碼的進行中,我們會不斷地得到有關
要輸出小數的各種信息。具體地講,當我們將區間限定在 0.6390 - 0.6501 之間時
,我們已經知道要輸出的小數第一位(十進制)一定是 6,那么我們完全可以將 6
從內存中拿掉,接着在區間 0.390 - 0.501 之間繼續我們的壓縮進程。內存中始
終不會有非常長的小數存在。使用二進制時也是一樣的,我們會隨着壓縮的進行不
斷決定下一個要輸出的二進制位是 0 還是 1,然后輸出該位並減小內存中小數的長
度。
靜態模型如何實現?
我們知道上面的簡單例子采用的是自適應模型,那么如何實現靜態模型呢?其實很
簡單。對信息 bccb 我們統計出其中只有兩個字符,概率分布為 Pb = 0.5,Pc =
0.5。我們在壓縮過程中不必再更新此概率分布,每次對區間的划分都依照此分布即
可,對上例也就是每次都平分區間。這樣,我們的壓縮過程可以簡單表示為:
輸出區間的下限 輸出區間的上限
─────────────────────────
壓縮前 0.0 1.0
輸入 b 0.0 0.5
輸入 c 0.25 0.5
輸入 c 0.375 0.5
輸入 b 0.375 0.4375
我們看出,最后的輸出區間在 0.375 - 0.4375 之間,甚至連一個十進制位都沒有
確定,也就是說,整個信息根本用不了一個十進制位。如果我們改用二進制來表示
上述過程的話,我們會發現我們可以非常接近該信息的熵值(有的讀者可能已經算
出來了,該信息的熵值為 4 個二進制位)。
為什么用自適應模型?
既然我們使用靜態模型可以很好地接近熵值,為什么還要采用自適應模型呢?要知
道,靜態模型無法適應信息的多樣性,例如,我們上面得出的概率分布沒法在所有
待壓縮信息上使用,為了能正確解壓縮,我們必須再消耗一定的空間保存靜態模型
統計出的概率分布,保存模型所用的空間將使我們重新遠離熵值。其次,靜態模型
需要在壓縮前對信息內字符的分布進行統計,這一統計過程將消耗大量的時間,使
得本來就比較慢的算術編碼壓縮更加緩慢。另外還有最重要的一點,對較長的信息
,靜態模型統計出的符號概率是該符號在整個信息中的出現概率,而自適應模型可
以統計出某個符號在某一局部的出現概率或某個符號相對於某一上下文的出現概率
,換句話說,自適應模型得到的概率分布將有利於對信息的壓縮(可以說結合上下
文的自適應模型的信息熵建立在更高的概率層次上,其總熵值更小),好的基於上
下文的自適應模型得到的壓縮結果將遠遠超過靜態模型。
自適應模型的階
我們通常用“階”(order)這一術語區分不同的自適應模型。本章開頭的例子中采用
的是 0 階自適應模型,也就是說,該例子中統計的是符號在已輸入信息中的出現概
率,沒有考慮任何上下文信息。如果我們將模型變成統計符號在某個特定符號后的
出現概率,那么,模型就成為了 1 階上下文自適應模型。舉例來說,我們要對一篇
英文文本進行編碼,我們已經編碼了 10000 個英文字符,剛剛編碼的字符是 t,下
一個要編碼的字符是 h。我們在前面的編碼過程中已經統計出前 10000 個字符中出
現了 113 次字母 t,其中有 47 個 t 后面跟着字母 h。我們得出字符 h 在字符
t 后的出現頻率是 47/113,我們使用這一頻率對字符 h 進行編碼,需要 -log2(4
7/113) = 1.266 位。對比 0 階自適應模型,如果前 10000 個字符中 h 的出現次
數為 82 次,則字符 h 的概率是 82/10000,我們用此概率對 h 進行編碼,需要
-log2(82/10000) = 6.930 位。考慮上下文因素的優勢顯而易見。我們還可以進一
步擴大這一優勢,例如要編碼字符 h 的前兩個字符是 gt,而在已經編碼的文本中
gt 后面出現 h 的概率是 80%,那么我們只需要 0.322 位就可以編碼輸出字符 h
。此時,我們使用的模型叫做2 階上下文自適應模型。最理想的情況是采用 3 階自
適應模型。此時,如果結合算術編碼,對信息的壓縮效果將達到驚人的程度。采用
更高階的模型需要消耗的系統空間和時間至少在目前還無法讓人接受,使用算術壓
縮的應用程序大多數采用 2 階或 3 階的自適應模型。
轉義碼的作用
使用自適應模型的算術編碼算法必須考慮如何為從未出現過的上下文編碼。例如,
在 1 階上下文模型中,需要統計出現概率的上下文可能有 256 * 256 = 65536 種
,因為 0 - 255 的所有字符都有可能出現在 0 - 255 個字符中任何一個之后。當
我們面對一個從未出現過的上下文時(比如剛編碼過字符 b,要編碼字符 d,而在
此之前,d 從未出現在 b 的后面),該怎樣確定字符的概率呢?比較簡單的辦法是
在壓縮開始之前,為所有可能的上下文分配計數為 1 的出現次數,如果在壓縮中碰
到從未出現的 bd 組合,我們認為 d 出現在 b 之后的次數為 1,並可由此得到概
率進行正確的編碼。使用這種方法的問題是,在壓縮開始之前,在某上下文中的字
符已經具有了一個比較小的頻率。例如對 1 階上下文模型,壓縮前,任意字符的頻
率都被人為地設定為 1/65536,按照這個頻率,壓縮開始時每個字符要用 16 位編
碼,只有隨着壓縮的進行,出現較頻繁的字符在頻率分布圖上占據了較大的空間后
,壓縮效果才會逐漸好起來。對於 2 階或 3 階上下文模型,情況就更糟糕,我們
要為幾乎從不出現的大多數上下文浪費大量的空間。
我們通過引入“轉義碼”來解決這一問題。“轉義碼”是混在壓縮數據流中的特殊
的記號,用於通知解壓縮程序下一個上下文在此之前從未出現過,需要使用低階的
上下文進行編碼。舉例來講,在 3 階上下文模型中,我們剛編碼過 ght,下一個要
編碼的字符是 a,而在此之前,ght 后面從未出現過字符 a,這時,壓縮程序輸出
轉義碼,然后檢查 2 階的上下文表,看在此之前 ht 后面出現 a 的次數;如果 h
t 后面曾經出現過 a,那么就使用 2 階上下文表中的概率為 a 編碼,否則再輸出
轉義碼,檢查 1 階上下文表;如果仍未能查到,則輸出轉義碼,轉入最低的 0 階
上下文表,看以前是否出現過字符 a;如果以前根本沒有出現過 a,那么我們轉到
一個特殊的“轉義”上下文表,該表內包含 0 - 255 所有符號,每個符號的計數都
為 1,並且永遠不會被更新,任何在高階上下文中沒有出現的符號都可以退到這里
按照 1/256 的頻率進行編碼。
“轉義碼”的引入使我們擺脫了從未出現過的上下文的困擾,可以使模型根據輸入
數據的變化快速調整到最佳位置,並迅速減少對高概率符號編碼所需要的位數。
存儲空間問題
在算術編碼高階上下文模型的實現中,對內存的需求量是一個十分棘手的問題。因
為我們必須保持對已出現的上下文的計數,而高階上下文模型中可能出現的上下文
種類又是如此之多,數據結構的設計將直接影響到算法實現的成功與否。在 1 階上
下文模型中,使用數組來進行出現次數的統計是可行的,但對於 2 階或3 階上下文
模型,數組大小將依照指數規律增長,現有計算機的內存滿足不了我們的要求。比
較聰明的辦法是采用樹結構存儲所有出現過的上下文。利用高階上下文總是建立在
低階上下文的基礎上這一規律,我們將 0 階上下文表存儲在數組中,每個數組元素
包含了指向相應的 1 階上下文表的指針,1 階上下文表中又包含了指向 2 階上下
文表的指針……由此構成整個上下文樹。樹中只有出現過的上下文才擁有已分配的
節點,沒有出現過的上下文不必占用內存空間。在每個上下文表中,也無需保存所
有 256 個字符的計數,只有在該上下文后面出現過的字符才擁有計數值。由此,我
們可以最大限度地減少空間消耗。
資源
關於算術壓縮具體的設計和實現請參考下面給出的示例程序。
程序 Arith-N 由 League for Programming Freedom 的 Mark Nelson 提供,由王
笨笨在 Visual C++ 5.0 環境下編譯、調試通過。
Arith-N 包含 Visual C++ 工程 ArithN.dsp 和 ArithNExpand.dsp,分別對應了壓
縮和解壓縮程序 an.exe 與 ane.exe。
Arith-N 是可以在命令行指定階數的 N 階上下文自適應算術編碼通用壓縮、解壓縮
程序,由於是用作教程示例,為清晰起見,在某些地方並沒有刻意進行效率上的優
化。
所有源程序包裝在文件
http://www.contextfree.net/wangyg/a/tutorial/benben/src/arith-n.zip
中。
_____________________________________________________________
第五章 聰明的以色列人(上):LZ77
全新的思路
我們在第三和第四章中討論的壓縮模型都是基於對信息中單個字符出現頻率的統計
而設計的,直到 70 年代末期,這種思路在數據壓縮領域一直占據着統治地位。在
我們今天看來,這種情形在某種程度上顯得有些可笑,但事情就是這樣,一旦某項
技術在某一領域形成了慣例,人們就很難創造出在思路上與其大相徑庭的哪怕是更
簡單更實用的技術來。
我們敬佩那兩個在數據壓縮領域做出了傑出貢獻的以色列人,因為正是他們打破了
Huffman 編碼一統天下的格局,帶給了我們既高效又簡便的“字典模型”。至今,
幾乎我們日常使用的所有通用壓縮工具,象 ARJ,PKZip,WinZip,LHArc,RAR,G
Zip,ACE,ZOO,TurboZip,Compress,JAR……甚至許多硬件如網絡設備中內置的
壓縮算法,無一例外,都可以最終歸結為這兩個以色列人的傑出貢獻。
說起來,字典模型的思路相當簡單,我們日常生活中就經常在使用這種壓縮思想。
我們常常跟人說“奧運會”、“IBM”、“TCP”之類的詞匯,說者和聽者都明白它
們指的是“奧林匹克運動會”、“國際商業機器公司”和“傳輸控制協議”,這實
際就是信息的壓縮。我們之所以可以順利使用這種壓縮方式而不產生語義上的誤解
,是因為在說者和聽者的心中都有一個事先定義好的縮略語字典,我們在對信息進
行壓縮(說)和解壓縮(聽)的過程中都對字典進行了查詢操作。字典壓縮模型正
是基於這一思路設計實現的。最簡單的情況是,我們擁有一本預先定義好的字典。
例如,我們要對一篇中文文章進行壓縮,我們手中已經有一本《現代漢語詞典》。
那么,我們掃描要壓縮的文章,並對其中的句子進行分詞操作,對每一個獨立的詞
語,我們在《現代漢語詞典》查找它的出現位置,如果找到,我們就輸出頁碼和該
詞在該頁中的序號,如果沒有找到,我們就輸出一個新詞。這就是靜態字典模型的
基本算法了。
你一定可以發現,靜態字典模型並不是好的選擇。首先,靜態模型的適應性不強,
我們必須為每類不同的信息建立不同的字典;其次,對靜態模型,我們必須維護信
息量並不算小的字典,這一額外的信息量影響了最終的壓縮效果。所以,幾乎所有
通用的字典模型都使用了自適應的方式,也就是說,將已經編碼過的信息作為字典
,如果要編碼的字符串曾經出現過,就輸出該字符串的出現位置及長度,否則輸出
新的字符串。根據這一思路,你能從下面這幅圖中讀出其中包含的原始信息嗎?
┌─────┐
┌┬┼─┬┬──┼─┬┬──┬┐
↓↓↓ ↓↓ │ ││ ││
吃葡萄不吐■■皮,█□■■倒◇■■◆
↑ ↑ ↑ │ │ │
└───┼──┼──┘ │ │
└──┼──────┘ │
└─────────┘
啊,對了,是“吃葡萄不吐葡萄皮,不吃葡萄倒吐葡萄皮”。現在你該大致明白自
適應字典模型的梗概了吧。好了,下面就讓我們來深入學習字典模型的第一類實現
——LZ77 算法。滑動的窗口LZ77 算法在某種意義上又可以稱為“滑動窗口壓縮”
,這是由於該算法將一個虛擬的,可以跟隨壓縮進程滑動的窗口作為術語字典,要
壓縮的字符串如果在該窗口中出現,則輸出其出現位置和長度。使用固定大小窗口
進行術語匹配,而不是在所有已經編碼的信息中匹配,是因為匹配算法的時間消耗
往往很多,必須限制字典的大小才能保證算法的效率;隨着壓縮的進程滑動字典窗
口,使其中總包含最近編碼過的信息,是因為對大多數信息而言,要編碼的字符串
往往在最近的上下文中更容易找到匹配串。參照下圖,讓我們熟悉一下 LZ77 算法
的基本流程。
當前壓縮位置
↓
←已編碼數據│未編碼數據→
│
│← 滑動窗口(MAX WND SIZE) →│
───┼────┳━━━┳─────╋━━━┳┼────────
│ ┃ ┃ ┃ ┃│
───┼────╋━━━╋─────┻━━━┻┼────────
0 off ↑ off+lenth ↑ 下一個字符
└── 最長匹配 ──┘
1、從當前壓縮位置開始,考察未編碼的數據,並試圖在滑動窗口中找出最長的匹配
字符串,如果找到,則進行步驟 2,否則進行步驟 3。
2、輸出三元符號組 ( off, len, c )。其中 off 為窗口中匹配字符串相對窗口邊
界的偏移,len 為可匹配的長度,c 為下一個字符。然后將窗口向后滑動 len + 1
個字符,繼續步驟 1。
3、輸出三元符號組 ( 0, 0, c )。其中 c 為下一個字符。然后將窗口向后滑動 l
en + 1 個字符,繼續步驟 1。
我們結合實例來說明。假設窗口的大小為 10 個字符,我們剛編碼過的 10 個字符
是:abcdbbccaa,即將編碼的字符為:abaeaaabaee
我們首先發現,可以和要編碼字符匹配的最長串為 ab ( off = 0, len = 2 ), ab
的下一個字符為 a,我們輸出三元組:( 0, 2, a )。
現在窗口向后滑動 3 個字符,窗口中的內容為:dbbccaaaba。
下一個字符 e 在窗口中沒有匹配,我們輸出三元組:( 0, 0, e )。
窗口向后滑動 1 個字符,其中內容變為:bbccaaabae。
我們馬上發現,要編碼的 aaabae 在窗口中存在( off = 4, len = 6 ),其后的字
符為 e,我們可以輸出:( 4, 6, e )。
這樣,我們將可以匹配的字符串都變成了指向窗口內的指針,並由此完成了對上述
數據的壓縮。解壓縮的過程十分簡單,只要我們向壓縮時那樣維護好滑動的窗口,
隨着三元組的不斷輸入,我們在窗口中找到相應的匹配串,綴上后繼字符 c 輸出(
如果 off 和 len 都為 0 則只輸出后繼字符 c )即可還原出原始數據。
當然,真正實現 LZ77 算法時還有許多復雜的問題需要解決,下面我們就來對可能
碰到的問題逐一加以探討。
編碼方法
我們必須精心設計三元組中每個分量的表示方法,才能達到較好的壓縮效果。一般
來講,編碼的設計要根據待編碼的數值的分布情況而定。對於三元組的第一個分量
——窗口內的偏移,通常的經驗是,偏移接近窗口尾部的情況要多於接近窗口頭部
的情況,這是因為字符串在與其接近的位置較容易找到匹配串,但對於普通的窗口
大小(例如 4096 字節)來說,偏移值基本還是均勻分布的,我們完全可以用固定
的位數來表示它。編碼 off 需要的位數 bitnum = upper_bound( log2( MAX_WND_
SIZE ))由此,如果窗口大小為 4096,用 12 位就可以對偏移編碼。如果窗口大小
為 2048,用11 位就可以了。復雜一點的程序考慮到在壓縮開始時,窗口大小並沒
有達到 MAX_WND_SIZE,而是隨着壓縮的進行增長,因此可以根據窗口的當前大小動
態計算所需要的位數,這樣可以略微節省一點空間。對於第二個分量——字符串長
度,我們必須考慮到,它在大多數時候不會太大,少數情況下才會發生大字符串的
匹配。顯然可以使用一種變長的編碼方式來表示該長度值。在前面我們已經知道,
要輸出變長的編碼,該編碼必須滿足前綴編碼的條件。其實 Huffman 編碼也可以在
此處使用,但卻不是最好的選擇。適用於此處的好的編碼方案很多,我在這里介紹
其中兩種應用非常廣泛的編碼。第一種叫 Golomb 編碼。假設對正整數 x 進行 Go
lomb 編碼,選擇參數 m,令
b = 2m
q = INT((x - 1)/b)
r = x - qb - 1
則 x 可以被編碼為兩部分,第一部分是由 q 個 1 加 1 個 0 組成,第二部分為
m 位二進制數,其值為 r。我們將 m = 0, 1, 2, 3 時的 Golomb 編碼表列出:
值 x m = 0 m = 1 m = 2 m = 3
_________________________________________________________
1 0 0 0 0 00 0 000
2 10 0 1 0 01 0 001
3 110 10 0 0 10 0 010
4 1110 10 1 0 11 0 011
5 11110 110 0 10 00 0 100
6 111110 110 1 10 01 0 101
7 1111110 1110 0 10 10 0 110
8 11111110 1110 1 10 11 0 111
9 111111110 11110 0 110 00 10 000
從表中我們可以看出,Golomb 編碼不但符合前綴編碼的規律,而且可以用較少的位
表示較小的 x 值,而用較長的位表示較大的 x 值。這樣,如果 x 的取值傾向於比
較小的數值時,Golomb 編碼就可以有效地節省空間。當然,根據 x 的分布規律不
同,我們可以選取不同的 m 值以達到最好的壓縮效果。對我們上面討論的三元組
len 值,我們可以采用 Golomb 方式編碼。上面的討論中 len 可能取 0,我們只需
用 len + 1 的 Golomb 編碼即可。至於參數 m 的選擇,一般經驗是取 3 或 4 即
可。可以考慮的另一種變長前綴編碼叫做 γ 編碼。它也分作前后兩個部分,假設
對 x 編碼,令 q = int( log2x ),則編碼的前一部分是 q 個 1 加一個 0,后一
部分是 q 位長的二進制數,其值等於 x - 2q 。
γ編碼表如下:
值 x γ編碼
─────────
1 0
2 10 0
3 10 1
4 110 00
5 110 01
6 110 10
7 110 11
8 1110 000
9 1110 001
其實,如果對 off 值考慮其傾向於窗口后部的規律,我們也可以采用變長的編碼方
法。但這種方式對窗口較小的情況改善並不明顯,有時壓縮效果還不如固定長編碼
。對三元組的最后一個分量——字符 c,因為其分布並無規律可循,我們只能老老
實實地用 8 個二進制位對其編碼。根據上面的敘述,相信你一定也能寫出高效的編
碼和解碼程序了。
另一種輸出方式
LZ77 的原始算法采用三元組輸出每一個匹配串及其后續字符,即使沒有匹配,我們
仍然需要輸出一個 len = 0 的三元組來表示單個字符。試驗表明,這種方式對於某
些特殊情況(例如同一字符不斷重復的情形)有着較好的適應能力。但對於一般數
據,我們還可以設計出另外一種更為有效的輸出方式:將匹配串和不能匹配的單個
字符分別編碼、分別輸出,輸出匹配串時不同時輸出后續字符。我們將每一個輸出
分成匹配串和單個字符兩種類型,並首先輸出一個二進制位對其加以區分。例如,
輸出 0 表示下面是一個匹配串,輸出 1 表示下面是一個單個字符。之后,如果要
輸出的是單個字符,我們直接輸出該字符的字節值,這要用 8 個二進制位。也就是
說,我們輸出一個單個的字符共需要 9 個二進制位。如果要輸出的是匹配串,我們
按照前面的方法依次輸出 off 和 len。對 off,我們可以輸出定長編碼,也可以輸
出變長前綴碼,對 len 我們輸出變長前綴碼。有時候我們可以對匹配長度加以限制
,例如,我們可以限制最少匹配 3 個字符。因為,對於 2 個字符的匹配串,我們
使用匹配串的方式輸出並不一定比我們直接輸出 2 個單個字符(需要 18 位)節省
空間(是否節省取決於我們采用何種編碼輸出 off 和 len)。這種輸出方式的優點
是輸出單個字符的時候比較節省空間。另外,因為不強求每次都外帶一個后續字符
,可以適應一些較長匹配的情況。如何查找匹配串在滑動窗口中查找最長的匹配串
,大概是 LZ77 算法中的核心問題。容易知道,LZ77 算法中空間和時間的消耗集中
於對匹配串的查找算法。每次滑動窗口之后,都要進行下一個匹配串的查找,如果
查找算法的時間效率在 O(n2) 或者更高,總的算法時間效率就將達到 O(n3),這是
我們無法容忍的。正常的順序匹配算法顯然無法滿足我們的要求。事實上,我們有
以下幾種可選的方案。
1、限制可匹配字符串的最大長度(例如 20 個字節),將窗口中每一個 20 字節長
的串抽取出來,按照大小順序組織成二叉有序樹。在這樣的二叉有序樹中進行字符
串的查找,其效率是很高的。樹中每一個節點大小是 20(key) + 4(off) + 4(left
child) + 4(right child) = 32。樹中共有 MAX_WND_SIZE - 19 個節點,假如窗
口大小為 4096 字節,樹的大小大約是 130k 字節。空間消耗也不算多。這種方法
對匹配串長度的限制雖然影響了壓縮程序對一些特殊數據(又很長的匹配串)的壓
縮效果,但就平均性能而言,壓縮效果還是不錯的。
2、將窗口中每個長度為 3 (視情況也可取 2 或 4)的字符串建立索引,先在此索
引中匹配,之后對得出的每個可匹配位置進行順序查找,直到找到最長匹配字符串
。因為長度為 3 的字符串可以有 2563 種情況,我們不可能用靜態數組存儲該索引
結構。使用 Hash 表是一個明智的選擇。我們可以僅用 MAX_WND_SIZE - 1 的數組
存儲每個索引點,Hash 函數的參數當然是字符串本身的 3 個字符值了,Hash 函數
算法及 Hash 之后的散列函數很容易設計。每個索引點之后是該字符串出現的所有
位置,我們可以使用單鏈表來存儲每一個位置。值得注意的是,對一些特殊情況比
如 aaaaaa...之類的連續字串,字符串 aaa 有很多連續出現位置,但我們無需對其
中的每一個位置都進行匹配,只要對最左邊和最右邊的位置操作就可以了。解決的
辦法是在鏈表節點中紀錄相同字符連續出現的長度,對連續的出現位置不再建立新
的節點。這種方法可以匹配任意長度的字符串,壓縮效果要好一些,但缺點是查找
耗時多於第一種方法。
3、使用字符樹( trie )來對窗口內的字符串建立索引,因為字符的取值范圍是 0
- 255,字符樹本身的層次不可能太多,3 - 4 層之下就應該換用其他的數據結構例
如 Hash 表等。這種方法可以作為第二種方法的改進算法出現,可以提高查找速度
,但空間的消耗較多。如果對窗口中的數據進行索引,就必然帶來一個索引位置表
示的問題,即我們在索引結構中該往偏移項中存儲什么數據:首先,窗口是不斷向
后滑動的,我們每次將窗口向后滑動一個位置,索引結構就要作相應的更新,我們
必須刪除那些已經移動出窗口的數據,並增加新的索引信息。其次,窗口不斷向后
滑動的事實使我們無法用相對窗口左邊界的偏移來表示索引位置,因為隨着窗口的
滑動,每個被索引的字符串相對窗口左邊界的位置都在改變,我們無法承擔更新所
有索引位置的時間消耗。
解決這一問題的辦法是,使用一種可以環形滾動的偏移系統來建立索引,而輸出匹
配字符串時再將環形偏移還原為相對窗口左邊界的真正偏移。讓我們用圖形來說明
,窗口剛剛達到最大時,環形偏移和原始偏移系統相同:
偏移: 0 1 2 3 4 ...... Max
├───────────────────────────────┤
環形偏移: 0 1 2 3 4 ...... Max
窗口向后滑動一個字節后,滑出窗口左端的環形偏移 0 被補到了窗口右端:
偏移: 0 1 2 3 4 ...... Max
├───────────────────────────────┤
環形偏移: 1 2 3 4 5 ...... Max 0
窗口再滑動 3 個子節后,偏移系統的情況是:
偏移: 0 1 2 3 4 ...... Max
├───────────────────────────────┤
環形偏移: 4 5 6 7 8...... Max 0 1 2 3
依此類推。
我們在索引結構中保存環形偏移,但在查找到匹配字符串后,輸出的匹配位置 off
必須是原始偏移(相對窗口左邊),這樣才可以保證解碼程序的順利執行。我們用
下面的代碼將環形偏移還原為原始偏移:
// 由環形 off 得到真正的off(相對於窗口左邊)
// 其中 nLeftOff 為當前與窗口左邊對應的環形偏移值
int GetRealOff(int off)
{
if (off >= nLeftOff)
return off - nLeftOff;
else
return (_MAX_WINDOW_SIZE - (nLeftOff - off));
}
這樣,解碼程序無需考慮環形偏移系統就可以順利高速解碼了。
資源
結合上面的討論,典型的 LZ77 算法應當不難實現,我們本章給出的源碼是一個較
為特殊的實現。示例程序 lz77.exe 使用對匹配串和單個字符分類輸出的模型,輸
出匹配串時,off 采用定長編碼,len 采用γ編碼。索引結構采用 2 字節長字符串
的索引,使用 256 * 256 大小的靜態數組存儲索引點,每個索引點指向一個位置鏈
表。鏈表節點考慮了對 aaaaa... 之類的重復串的優化。示例程序的獨特之處在於
使用了 64k 大小的固定長度窗口,窗口不做滑動(因此不需要環形偏移系統,也節
省了刪除索引點的時間)。壓縮函數每次只對最多 64k 長的數據進行壓縮,主函數
將原始文件分成 64k 大小的塊逐個壓縮存儲。使用這種方法首先可以增大匹配的概
率,字符串可以在 64k 空間內任意尋找最大匹配串,以此提高壓縮效率。其次,這
種方法有利於實現解壓縮的同步。也就是說,利用這種方法分塊壓縮的數據,很容
易從原始文件中間的任何一個位置開始解壓縮,這尤其適用於全文檢索系統中全文
信息的保存和隨機讀取。
結合上述示例程序,王笨笨開發了可壓縮多個文件並可同步(隨機)解壓縮的文件
級接口,但此接口並非自由代碼(free code)。如果需要可以和王笨笨聯系。示例程
序 lz77 的所有源文件被包裝在文件
http://www.contextfree.net/wangyg/a/tutorial/benben/src/lz77.zip
中,由王笨笨編寫,在 Visual C++ 5.0 環境下編譯通過。其使用方法是:
壓縮: lz77 c 源文件名 壓縮文件名
解壓縮: lz77 d 壓縮文件名 源文件名
_____________________________________________________________
第六章 聰明的以色列人(下):LZ78 和 LZW
LZ78 的算法描述:
for (;;)
{
current_match = 1;
current_length = 0;
memset(test_string, '\0', MAX_STRING);
for (;;)
{
test_string[current_length ++] = getc(input);
new_match = find_match(test_string);
if (new_match) == -1)
break;
current_match = new_match;
}
output_code(current_match);
output_char(test_string[current_letgth - 1];
add_string_to_dictionary(test_string);
}
LZ78 示例:
輸入正文: "DAD DADA DADDY DADO..."
輸出短語 輸出字符 編碼后的串
0 'D' "D"
0 'A' "A"
1 ' ' "D"
1 'A' "DA"
4 ' ' "DA "
4 'D' "DAD"
1 'Y' "DY"
0 ' ' " "
6 'O' "DADO"
此時字典的情況:
0 ""
1 "D"
2 "A"
3 "D "
4 "DA"
5 "DA "
6 "DAD"
7 "DY"
8 " "
9 "DADO"