LZW壓縮算法原理解析與實現【轉載】

LZW算法原理--Wikipedia相關介紹

一個簡單的例子
ZW編碼 (Encoding) 的核心思想其實比較簡單,就是把出現過的字符串映射到記號上,這樣就可能用較短的編碼來表示長的字符串,實現壓縮,例如對於字符串:

ABABAB

可以看到子串AB在後面重復出現了,這樣我們可以用一個特殊記號表示AB,例如數字2,這樣原來的字符串就可以表示爲:

AB22

這裏我們稱2是字串AB的記號(Symbol)。那麼A和B有沒有記號來表示?當然有,例如我們規定數字0表示A,數字1表示B。實際上最後得到的壓縮後的數據應該是一個記號流 (Symbol Stream) :

0122

這樣我們就有一個記號和字符串的映射表,即字典 (Dictionary) :

Symbol String
0 A
1 B
2 C

有了壓縮後的編碼0122,結合字典,就能夠很輕鬆地解碼 (Decoding) 原字符串ABABAB。

當然在真正的LZW中A和B不會用數字0和1表示,而是它們的ASCII值。實際上LZW初始會有一個默認的字典,包含了所有256個8bit字符,單個字符的記號就是它自身,用數字表示就是ASCII值。在此基礎上,編碼過程中加入的新的記號的映射,從256開始,稱爲擴展表(Extended Dictionary)。在這個例子裏是爲了簡單起見,只有兩個基礎字符,所以規定0表示A,1表示B,從記號2開始就是擴展項了。

字典的生成

這裏有一個問題:爲什麼第一個AB不也用2表示?即表示爲222,這樣不又節省了一個記號?這個問題實際上引出的是LZW的一個核心思想,即壓縮後的編碼是自解釋 (self-explaining) 的。什麼意思?即字典是不會被寫進壓縮文件的,在解壓縮的時候,一開始字典裏除了默認的0->A和1->B之外並沒有其它映射,2->AB是在解壓縮的過程中一邊加入的。這就要求壓縮後的數據自己能告訴解碼器,完整的字典,例如2->AB是如何生成的,在解碼的過程中還原出編碼時用的字典。

用上面的例子來說明,我們可以想象ABABAB編碼的過程:

  1. 遇到A,用0表示,編碼爲0。
  2. 遇到B,用1表示,編碼爲1。
  3. 發現了一個子串AB,添加映射2->AB到字典裏。
  4. 後面又出現了AB子串,都用2來編碼。

以上過程只是一個概述,並非真正LZW編碼過程,只是爲了表示它的思想。可以看出最前面的A和B是用來生成表項2->AB的,所以它們必須被保留在壓縮編碼裏,作爲表項2->AB生成的“第一現場”。這樣在解碼0122的時候,解碼器首先通過01直接解析出最前面A和B,並且生成表項2->AB,這樣才能將後面出現的2都解析爲AB。實際上解碼器是自己還原出了編碼時2->AB生成的過程。

編碼和解碼都是從前往後步步推進的,同時生成字典,所以解碼的過程也是一個不斷還原編碼字典的過程。解碼器一邊解碼,向後推進,一邊在之前已經解出的原始數據上重現編碼的過程,構建出編碼時用的字典。

LZW算法詳解

下面給出完整的LZW編碼和解碼的過程,結合一個稍微複雜一點的例子,來說明LZW的原理,重點是理解解碼中的每一步是如何對應和還原編碼中的步驟,並恢復編碼字典的。

  • 編碼算法

編碼器從原字符串不斷地讀入新的字符,並試圖將單個字符或字符串編碼爲記號 (Symbol)。這裏我們維護兩個變量,一個是P (Previous),表示手頭已有的,還沒有被編碼的字符串,一個是C (current),表示當前新讀進來的字符

1.初始狀態,字典裏只有所有的默認項,例如0->a,1->b,2->c。此時P和C都是空的。
2.讀入新的字符C,與P合併形成字符串P+C。
3.在字典裏查找P+C,如果:
     - P+C在字典裏,P=P+C。
      - P+C不在字典裏,將P的記號輸出;在字典中爲P+C建立一個記號映射;更新P=C。
4.返回步驟2重複,直至讀完原字符串中所有字符。

以上表示的是編碼中間的一般過程,在收尾的時候有一些特殊的處理,即步驟2中,如果到達字符串尾部,沒有新的C讀入了,則將手頭的P對應的記號輸出,結束。

編碼過程的核心就在於第3步,我們需要理解P究竟是什麼。P是當前維護的,可以被編碼爲記號的子串。注意P是可以被編碼爲記號,但還並未輸出。新的字符C不斷被讀入並添加到P的尾部,只要P+C仍然能在字典裏找到,就不斷增長更新P=P+C,這樣就能將一個儘可能長的字串P編碼爲一個記號,這就是壓縮的實現。當新的P+C無法在字典裏找到時,我們沒有辦法,輸出已有的P的編碼記號,併爲新子串P+C建立字典表項。然後新的P從單字符C開始,重新增長,重複上述過程。

這裏用一個例子來說明編碼的過程,之所以用小寫的字符串是爲了和P,C區分。

ababcababac

初始狀態字典裏有三個默認的映射:

Symbol String
0 a
1 b
2 c

開始編碼:

Step P C P+C P+C in Dict ? Action Output
1 - a a Yes 更新P=a -
2 a b ab No 添加3->ab,更新P=b 0
3 b a ba No 添加4->ba ,更新P=a 1
4 a b ab Yes 更新P=ab -
5 ab c abc No 添加5->abc,更新P=c 3
6 c a ca No 添加6->ca,更新P=a 2
7 a b ab Yes 更新P=ab -
8 ab a aba No 添加7->aba,更新P=a 3
9 a b ab Yes 更新P=ab -
10 ab a aba Yes 更新P=aba -
11 aba c abac No 添加8->abac,更新P=c 7
12 c - - - - 2

注意編碼過程中的第3-4步,第7-8步以及8-10步,子串P發生了增長,直到新的P+C無法在字典中找到,則將當前的P輸出,P則更新爲單字符C,重新開始增長。

輸出的結果爲0132372,完整的字典爲:

Symbol String
0 a
1 b
2 c
3 ab
4 ba
5 abc
6 ca
7 aba
8 abac

這裏用一個圖來展示原字符串是如何對應到壓縮後的編碼的:
在這裏插入圖片描述

  • 解碼算法

解碼的過程比編碼複雜,其核心思想在於解碼需要還原出編碼時的用的字典。因此要理解解碼的原理,必須分析它是如何對應編碼的過程的。下面首先給出算法:

解碼器的輸入是壓縮後的數據,即記號流 (Symbol Stream)。類似於編碼,我們仍然維護兩個變量pW (previous word) 和cW (current word),後綴W的含義是word,實際上就是記號 (Symbol),一個記號就代表一個word,或者說子串。pW表示之前剛剛解碼的記號;cW表示當前新讀進來的記號。

注意cW和pW都是記號,我們用Str(cW)和Str(pW)表示它們解碼出來的原字符串。

  1. 初始狀態,字典裏只有所有的默認項,例如0->a,1->b,2->c。此時pW和cW都是空的。
  2. 讀入第一個的符號cW,解碼輸出。注意第一個cW肯定是能直接解碼的,而且一定是單個字符。
  3. 賦值pW=cW。
  4. 讀入下一個符號cW。
  5. 在字典裏查找cW,如果:
         a. cW在字典裏:
             (1) 解碼cW,即輸出 Str(cW)。
             (2) 令P=Str(pW),C=Str(cW)的第一個字符
             (3) 在字典中爲P+C添加新的記號映射。
        b. cW不在字典裏:
             (1) 令P=Str(pW),C=Str(pW)的第一個字符
              (2) 在字典中爲P+C添加新的記號映射,這個新的記號一定就是cW。
              (3) 輸出P+C。
  6. 返回步驟3重複,直至讀完所有記號。

顯然,最重要的是第5步,也是最難理解的。在這一步中解碼器不斷地在已經破譯出來的數據上,模擬編碼的過程,還原出字典。我們還是結合之前的例子來說明,我們需要從記號流

0 1 3 2 3 7 2

解碼出:

a b ab c ab aba c

這裏我用空格表示出了記號是如何依次對應解碼出來的子串的,當然在解碼開始時我們根本不知道這些,我們手裏的字典只有默認項,即:

Symbol String
0 a
1 b
2 c

解碼開始:
首先讀取第一個記號cW=0,解碼爲a,輸出,賦值pW=cW=0。然後開始循環,依此讀取後面的記號:

Step pW cW cW in Dict ? Action Output
1 0 1 Yes P=a,C=b,P+C=ab,添加3->ab b
2 1 3 Yes P=b,C=a,P+C=ba,添加4->ba ab
3 3 2 Yes P=ab,C=c,P+C=abc,添加5->abc c

好,先解碼到這裏,我們已經解出了前5個字符 a b ab c。一步一步走下來我們可以看出解碼的思想。首先直接解碼最前面的a和b,然後生成了3->ab這一映射,也就是說解碼器利用前面已經解出的字符,如實還原了編碼過程中字典的生成。這也是爲什麼第一個a和b必須保留下來,而不能直接用3來編碼,因爲解碼器一開始根本不知道3表示ab。而第二個以及以後的ab就可以用記號3破譯出來,因爲此時我們已經建立了3->ab的關係。

仔細觀察添加新映射的過程,就可以看出它是如何還原編碼過程的。解碼步驟5.a中,P=Str(pW),C=Str(cW)的第一個字符,我們可以用下圖來說明:

在這裏插入圖片描述
注意P+C構成的方式,取前一個符號pW,加上當前最新符號cW的第一個字符。這正好對應了編碼過程中遇到P+C不在字典中的情況:將P編碼爲pW輸出,並更新P=C,P從單字符C開始重新增長。

到目前爲止,我們只用到瞭解碼步驟5.a的情況,即每次新讀入的cW都能在字典裏找到,只有這樣我們才能直接解碼cW輸出,並拿到cW的第一個字符C,與P組成P+C。但實際上還有一種可能就是5.b中的cW不在字典裏。爲什麼cW會不在字典裏?回到例子,我們此時已經解出了5個字符,繼續往下走:

Step pW cW cW in Dict ? Action Output
4 2 3 Yes P=c,C=a,P+C=ca,添加6->ca ab
5 3 7 No P=ab,C=a,P+C=aba,添加7->aba aba
6 7 2 Yes P=aba,C=c,P+C=abac,添加8->abac c

好到此爲止,後面的 ab aba c 也解碼出來了,解碼過程結束。這裏最重要的就是Step-5,新讀入一個cW爲7,可7此時並不在字典裏。當然我們事實上知道7最終應該對應aba,可是解碼器應該如何反推出來?

爲什麼解碼進行到這一步7->aba還沒有被編入字典?因爲解碼比編碼有一步的延遲,實際上aba正是由當前的P=ab,和那個還未知的cw=7的第一個字符C組成的,所以cW映射的就是這個即將新加入的子串P+C,也因此cW的第一個字符就是pW的第一個字符a,cW就是aba。

總結

好了,LZW的編碼和解碼過程到此就講解完畢了。其實它的思想本身是簡單的,就是將原始數據中的子串用記號表示,類似於編一部字典。編碼過程中如何切割子串,建立映射的方式,其實並不是唯一的,但是LZW算法的嚴格之處在於,它提供了一種方式,使得壓縮後的編碼能夠唯一地反推出編碼過程中建立的字典,從而不必將字典本身寫入壓縮文件。試想,如果字典也需要寫入壓縮文件,那它佔據的體積本身就會很大,可能到最後起不到壓縮的效果。


LZW算法實現

編碼與解碼流程圖

基於C/C++語言的實現

/**
*	作者:戴文治
*	時間:2017年11月3日
*	描述:LZW編碼譯碼算法
*	特點:該代碼具有可移植性,可在Dev-C++、VC++6.0、VS2010等多種平臺完美運行
*		  該代碼具有可重用性,運行後可對多個測試用例進行順序測試而不受影響
*/
#include<iostream>
#include<cstring>
#define N 1000
using namespace std;
class LZW{ //LZW算法類
public:
	char encodeStr[N];		//要編碼的字符串
	int decodeList[N];		//要譯碼的數組
	int firstDictionaryNum; //先前詞典的大小 
	int length;				//當前詞典的大小 
	char dictionary[N][N];	//先前詞典
	
	
	LZW(){					//構造函數 
		encodeStr[0] = '\0';
		
		for(int i=0;i<N;i++){
			this->decodeList[i]=-INT_MAX;
		}
		
		for(int i=0;i<N;i++){
			this->dictionary[i][0] = '\0';
		}
		
		firstDictionaryNum = 0;
		length = 0;
	}
	
	
	bool initDictionary() 		//初始化先前詞典
	{	
		if(encodeStr[0]=='\0'){			//若沒有要編碼的字符串,則不能生成先前詞典 
			return false;
		}
		dictionary[0][0] = encodeStr[0];//將要編碼的字符串的第一個字符加入先前詞典 
		dictionary[0][1] = '\0';
		length = 1;
		int i,j; 
		for(i=1;encodeStr[i]!='\0';i++){//將要編碼的字符串中所有不同的字符加入先前詞典 
			for(j=0;dictionary[j][0]!='\0';j++){
				if(dictionary[j][0]==encodeStr[i]){
					break;
				}
			}
			if(dictionary[j][0]=='\0'){
				dictionary[j][0] = encodeStr[i];
				dictionary[j][1] = '\0';
				length++;
			}
		}
		firstDictionaryNum = length;			//先前詞典的大小
		return true;
	}
	
	
	void Encode() 	 		 	//編碼
	{
		for(int g=0;g<firstDictionaryNum;g++){	//先前詞典中的初始編碼沒有輸出編碼,故設置爲-1 
			decodeList[g]=-1;
		}
		int num = firstDictionaryNum;
		char *q,*p,*c;
		q =  encodeStr;						  	//q爲標誌指針,用來確認位置的 
		p =  encodeStr; 						//p指針作爲字符串匹配的首字符 
		c = p;									//通過不斷移動c指針實現匹配 
		while(p-q != strlen(encodeStr)){		//若還沒匹配完所有字符,則循環 
			int i,j;
			int index=0;
			for(i=0;dictionary[i][0]!='\0'&&c-q!=strlen(encodeStr);i++){//通過不斷向後移動c指針實現匹配
				char temp[N]; 
				strncpy(temp,p,c-p+1);			//每添加一個匹配字符,則已匹配字符串temp增加一個字符 
				temp[c-p+1]='\0';
				if(strcmp(temp,dictionary[i])==0){//字符匹配成功 
					c++;
					index = i;
				}
			}
			decodeList[num++]=index;			//遇到一個不匹配的字符或者已經沒有字符可以匹配,則輸出已匹配的字符串 
			if(c-q!=strlen(encodeStr)){			//若到一個不匹配的字符且還有字符未匹配,則說明出現了新的詞典字段,加入詞典 
				strncpy(dictionary[length],p,c-p+1);
				dictionary[length][c-p+1]='\0';
				length++;
			}
			p = c;								//匹配下一個時,p指向c的指向 
		}
 
	} 
	
	
	void Decode()    			//譯碼 
	{
		for(int i=1;decodeList[i]!=-INT_MAX;i++){	//根據輸入代碼來循環 
			if(decodeList[i]<=length){				//若出現輸入代碼在先前詞典可以找到,則輸出上一個輸出的全部+當前輸出的第一個 
				strcpy(dictionary[length],dictionary[decodeList[i-1]-1]);
				char temp[2];
				strncpy(temp,dictionary[decodeList[i]-1],1);
				temp[1]='\0';
				strcat(dictionary[length],temp);
			}
			else{									//若出現輸入代碼在先前詞典找不到,則輸出上一個輸出的全部+上一個輸出的第一個 
				strcpy(dictionary[length],dictionary[decodeList[i-1]-1]);
				char temp[2];
				strncpy(temp,dictionary[decodeList[i-1]-1],1);
				temp[1]='\0';
				strcat(dictionary[length],temp);
			}
			length++;
		}
	}	
};
 
 
int main(){
	while(true){
		cout<<"\n\t1.編碼\t\t2.譯碼\t\t3.退出\n\n";
		cout<<"請選擇:";
		char x;
		cin>>x;
		LZW lzw;
		if(x=='1'){
			cout<<"請輸入要編碼的字符串:"<<endl<<endl;
			cin>>lzw.encodeStr;
			if(lzw.initDictionary()==false){
				cout<<"請先設置要編碼的字符串encodeStr屬性"<<endl;
			}
			lzw.Encode();	//開始編碼
			cout<<endl<<"編碼過程爲:"<<endl<<endl;
			cout<<"\t索引號\t\t\t詞典\t\t\t輸出"<<endl;
			for(int i=0;i<lzw.length;i++){
				cout<<"\t"<<i+1<<"\t\t\t"<<lzw.dictionary[i]<<"\t\t\t";
				if(i>=lzw.firstDictionaryNum){
					cout<<lzw.decodeList[i]+1;
				}
				cout<<endl;
			}
			cout<<"\t-\t\t\t-\t\t\t"<<lzw.decodeList[lzw.length]+1<<endl<<endl<<endl;
		} 
		else if(x=='2'){
			cout<<"請按順序輸入初始先前詞典:(例:1 A)(輸入0結束)"<<endl;
			int tempNum;
			cin>>tempNum;
			int index = 1; 
			while(tempNum!=0){
				if(tempNum<0){
					cout<<"輸入序號錯誤,重新輸入該行"<<endl<<endl;
					getchar();//兩個getchar()是刪除掉該行已經輸入的字符 
					getchar();
					cin>>tempNum;
					continue;
				}
				if(tempNum!=index){
					cout<<"請以遞增順序輸入序號,重新輸入該行"<<endl<<endl;
					getchar();
					getchar();
					cin>>tempNum;
					continue;
				}
				cin>>lzw.dictionary[tempNum-1];
				cin>>tempNum;
				index++; 
			}
			lzw.firstDictionaryNum = index-1;
			lzw.length = lzw.firstDictionaryNum; 
			
			cout<<endl<<"請輸入要譯的編碼(輸入0結束):"<<endl<<endl;
			int temp;
			int j=0;
			cin>>temp;
			while(temp!=0){
				if(temp<0){
					cout<<"輸入要譯的編碼錯誤,重新輸入該編碼"<<endl<<endl;
					cin>>temp;
					continue;
				}
				lzw.decodeList[j] = temp;
				j++;
				cin>>temp;
			}
			lzw.Decode();	//開始譯碼 
			cout<<endl<<"譯碼過程爲:"<<endl<<endl;
			
			cout<<"    輸入代碼\t\t索引號\t\t詞典\t\t輸出"<<endl;
			for(int i=0;i<lzw.firstDictionaryNum;i++){
				cout<<"      \t\t\t   "<<i+1<<"  \t\t "<<lzw.dictionary[i]<<endl;
			}
			cout<<"\t"<<lzw.decodeList[0]<<"\t\t   -\t\t -\t\t  "<<lzw.dictionary[lzw.decodeList[0]-1]<<endl;
			for(int i=1;lzw.decodeList[i]!=-INT_MAX;i++){
				cout<<"\t"<<lzw.decodeList[i]<<"\t\t   "<<i+3<<"  \t\t "<<lzw.dictionary[i+3-1]<<"\t\t  "<<lzw.dictionary[lzw.decodeList[i]-1]<<endl;
			}
			cout<<endl<<endl;
		}
		else if(x=='3'){
			break;
		} 
		else{
			cout<<"請輸入正確的選擇!"<<endl<<endl<<endl;
		}
	}
	return 0;
}

使用上面的示例“ababcababac”進行測試:(注意: 初始字典設置爲1–a 2–b 3–c)
編碼:
在這裏插入圖片描述
解碼:
在這裏插入圖片描述
編程中的注意事項:

注意指針的使用。

要求熟練掌握字符串處理函數,對這些函數的功能和效果要有很明確的意識,否則會出現很多BUG。

最後,這次實驗我遇見許多細微的問題,最後還是通過DEBUG調試才發現的。事實證明Dev-C++和VC++非常地難以調試,很不直觀!而用VS2010調試程序的話,它會有一個程序變量的窗口,每執行一條語句,它會顯示出當前所有變量的值,還會把此次修改的變量用紅色標註出來,在此推薦通過VS2010來調試程序。


轉載自博文:

  1. 原理
  2. 流程圖(內附有編解碼不同的C++實現,以及實現對文件壓縮的思路)
  3. C/C++實現
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章