笨笨數據壓縮教程--第四章 向極限挑戰:算術編碼

 我們在上一章中已經明白,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.0000 到 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.6667。新添了 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.6667 之間,我們無法決定輸出值得第一位是 3、4、5 還是 6,也就是說,b 的編碼長度小於一個十進制位(注意我們用十進制講解,和二進制不完全相同),那麼我們暫時不決定輸出信息的任何位,繼續輸入下面的字符。直到輸入了第三個 字符 c 以後,我們的輸出區間被限定在 0.6334 - 0.6667 之間,我們終於知道輸出小數的第一位(十進制)是 6,但仍然無法知道第二位是多少,也即前三個字符的編碼長度在 1 和 2 之間。等到我們輸入了所有字符之後,我們的輸出區間爲 0.6390 - 0.6501,我們始終沒有得到關於第二位的確切信息,現在我們明白,輸出所有的 4 個字符,我們只需要 1 點幾個十進制位,我們唯一的選擇是輸出 2 個十進制位 0.64。這樣,我們在誤差不超過 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(47/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 的次數;如果 ht 後面曾經出現過 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 階上下文自適應算術編碼通用壓縮、解壓縮程序,由於是用作教程示例,爲清晰起見,在某些地方並沒有刻意進行效率上的優化。

  所有源程序包裝在文件 Arith-N.zip 中。

發佈了86 篇原創文章 · 獲贊 15 · 訪問量 19萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章