目录
实验要求
1.首先调试LZW的编码程序,以一个文本文件作为输入,得到输出的LZW编码文件。
2. 以实验步骤一得到的编码文件作为输入,编写LZW的解码程序。在写解码程序时需要对关键语句加上注释,并说明进行何操作。在实验报告中重点说明当前码字在词典中不存在时应如何处理并解释原因。
3. 选择至少十种不同格式类型的文件,使用LZW编码器进行压缩得到输出的压缩比特流文件。对各种不同格式的文件进行压缩效率的分析。
LZW编码原理
LZW(Lempel-Ziv-Welch)编码,属于第二类词典编码(dictionary encoding)。在介绍LZW之前,我想先简单介绍一下词典编码。
词典编码:人们根据”数据具有重复性“的特点,重新构造一个词典,将数据中反复出现的字符串用代号表示;解码时通过查找词典再将代号转换为字符串。通常在构造词典时,我们会统计字符出现的概率,根据概率设计词典,因此词典编码也属于统计编码类型。(浅了解一下:统计编码包括huffman、算数、词典、游程等)
而第二类词典编码是指企图从输入的数据中创造一个短语词典,这个短语可以是任意字符的组合,并将字符赋予索引号。在编码时,如果遇到词典中的字符,我们便可直接输出对应的索引号,而不是字符本身。利用索引号替代了重复字符,从而起到了压缩数据的作用。
LZW算法步骤
编码器:
Step 1:将词典初始化为包含所有可能的单字符,当前前缀串P初始化为空。
Step 2:当前字符C=字符流中的下一个字符
Step 3:判断P+C是否在词典中
· 如果“是”,则用C扩展P,即让P=P+C,返回Step 2
· 如果“否”,则输出与当前前缀P相对应的码字W,将P+C添加到词典中,令P=C,返回Step 2
先简单举个例子理解下思路:(事先将26个字母大小写存入词典中)
第一个字符a:P为空,C=a,P+C=a,a存在在词典中,令P=P+C(a);
第二个字符b:P=a,C=b,P+C=ab,输出P的索引号97,ab不在原词典中,将ab添加到词典,新索引号256,令P=C(b);
第三个字符b:P=b,C=b,P+C=bb,输出P的索引号98,bb不在原词典中,将bb添加到词典,新索引号257,令P=C(b);
第四个字符a:P=b,C=a,P+C=ba,输出P的索引号98,ba不在原词典中,将ba添加到词典,新索引号258,令P=C(a);
第五个字符b:P=a,C=b,P+C=ab,ab在原词典中,令P=P+C(ab);
第六个字符a:P=ab,C=a,P+C=aba,输出P的索引号256,aba不在原词典中,将aba添加到词典,新索引号259,令P=C(a);
第七个字符b:P=a,C=b,P+C=ab,ab在原词典中,令P=P+C(ab);
第八个字符a:P=ab,C=a,P+C=aba,aba在原词典中,令P=P+C(aba);
第九个字符c:P=aba,C=c,P+C=abac,abac不在原词典中,输出P的索引号259,abac不在原词典中,将abac添加到词典,新索引号260,令P=C(c);
解码器:
Step 1:在开始译码时词典包含所有可能的前缀根
Step 2:cW存放码字流中的第一个码字。(dictionary[cW]表示对应的符号)
Step 3:输出dictionary[cW]到码字流。
Step 4:先前码字pW:=当前码字cW。
Step 5:当前码字cW:=码字流的下一个码字。
Step 6:判断当前缀-字符串string.cW 是否在词典中。
· 如果”是”,则把当前缀-符串string.cW输出到字符流,当前前缀P:=先前缀-符串string.pW,当前字符C:=当前前缀-符串string.cW的第一个字符,把缀-符串P+C添加到词典。
· 如果”否”,则当前前缀P:=先前缀-符串string.pW,当前字符C:=当前缀-符串string.cW的第一个字符,输出缀-符串P+C到字符流,然后把它添加到词典中。
Step 7:判断码字流中是否还有码字要译。
如果”是”,返回步骤4
如果”否”,结束
用同样的例子理一下思路:
第一个码字97:pW为空,cW=97,cW在词典中,输出dictionary[cW](a)。P=dictionary[pW]为空,C=dictionary[cW](a),将P+C(a)放入词典(已有),令pW=cW(97)
第二个字符98:pW为97,cW=98,cW在词典中,输出dictionary[cW](b)。P=dictionary[pW]为a,C=dictionary[cW](b),将P+C(ab)放入词典,新索引号256,令pW=cW(98)
第三个字符98:pW为98,cW=98,cW在词典中,输出dictionary[cW](b)。P=dictionary[pW]为b,C=dictionary[cW](b),将P+C(bb)放入词典,新索引号257,令pW=cW(98)
第四个字符256:pW为98,cW=256,cW在词典中,输出dictionary[cW](ab)。P=dictionary[pW]为b,C=dictionary[cW](ab)的第一个字符a,将P+C(ba)放入词典,新索引号258,令pW=cW(256)
第五个字符259:pW为256,cW=259,cW不在词典中,P=dictionary[pW]为ab,C=dictionary[cW](ab)的第一个字符a,将P+C(aba)放入词典,新索引号259,输出P+C(aba),令pW=cW(256)
第五个字符99:pW为259,cW=99,cW在词典中,输出dictionary[cW](c)。P=dictionary[pW]为空,C=dictionary[cW](a),将P+C(a)放入词典(已有),令pW=cW(97)
具体代码分析
词典树
树的每个节点至少有一个字符和指向母节点的指针,因此我们构建词典数:
1、构造字典树结构体
struct {
int suffix; //表示新字符
int parent, firstchild, nextsibling; //分别表示当前节点对应的母节点、第一个孩子节点、下一个兄弟节点
} dictionary[MAX_CODE+1];
int next_code;//下一个字符
int d_stack[MAX_CODE]; //存储解码的字符
2、初始化字典(词典第一层——存放单个字母)
void InitDictionary() { //初始化词典
for (int i = 0; i < 256; i++) { //初始化词典数第一层(放单个字母)
dictionary[i].suffix = i; //尾缀字符(指自己)
dictionary[i].parent = -1; //母节点(没有)
dictionary[i].firstchild = -1; //第一个孩子节点(没有)
dictionary[i].nextsibling = i + 1; //下一个(右边的)兄弟节点
}
dictionary[255].nextsibling = -1; //第一层最后一个词的兄弟节点
next_code = 256; //新词条代号
string_code = -1;
}
因为树是动态建立的,树中每个节点可能存在多个子节点,我们在设计时每个节点可拥有任意个字节点且无需为其预留空间
3、查找词典中是否有字符串,如果有则返回对应索引号,否则返回-1
int InDictionary(int character, int string_code) {//查找字典中是否有字符串
int sibling;//索引号
if (0 > string_code) return character; //如果是单个字符,直接返回对应的字符(之前已经将单个字符的string_code设置为-1)
sibling = dictionary[string_code].firstchild;//找string_code的第一个孩子节点(没有返回-1)
while (-1 < sibling) {//如果找到孩子
//遍历寻找对应字符
if (character == dictionary[sibling].suffix)//如果第一个孩子节点就是当前的字符
return sibling; //返回索引号
sibling = dictionary[sibling].nextsibling; //遍历第一个孩子的兄弟节点
}
return -1; //如果没有找到就返回-1
}
4、增加新字符串
void AddToDictionary(int character, int string_code) {
int firstsibling, nextsibling;
if (0 > string_code) return; //如果是一个单字符,直接返回
//加入新字符、父母、兄弟情况
dictionary[next_code].suffix = character; //suffix表示当前的索引号
dictionary[next_code].parent = string_code; //新字符的父母
dictionary[next_code].nextsibling = -1; //这个尾缀的兄弟节点是-1(新加入没有兄弟节点)
dictionary[next_code].firstchild = -1; //这个尾缀的第一个孩子节点是-1(新加入还没有孩子)
firstsibling = dictionary[string_code].firstchild; //找父母的第一个孩子(最左边的哥哥)
if (-1 < firstsibling) { //父母有孩子
//遍历找到父母的最后一个孩子
nextsibling = firstsibling; //从第一个孩子开始
while (-1 < dictionary[nextsibling].nextsibling) //循环哥哥的下一个兄弟节点,如果有兄弟则进入while循环
nextsibling = dictionary[nextsibling].nextsibling; //更新最近的兄弟节点
dictionary[nextsibling].nextsibling = next_code; //没有兄弟节点的时候,将新字符作为这个兄弟节点的兄弟节点(新字符在最右边,为最小的孩子)
}
else {//新字符是父母的第一个孩子
dictionary[string_code].firstchild = next_code;
}
next_code++; //为下一个字符做准备
}
(1)LZW编码
void LZWEncode(FILE* fp, BITFILE* bf) {
int character; //新字符C
int string_code; //旧索引P
int index; //索引号
unsigned long file_length; //文件长度
fseek(fp, 0, SEEK_END); //寻找文件末尾
file_length = ftell(fp); //获取文件长度
fseek(fp, 0, SEEK_SET); //指针移动到fp文件开始
BitsOutput(bf, file_length, 4 * 8);
InitDictionary(); //初始化词典
string_code = -1; //初始值赋值为-1(单个字符设置为-1,从单个字符开始判断)
while (EOF != (character = fgetc(fp))) {//文件读取过程,
//fgetc是从指针指向的文件中读取一个字符,读取一个字节后,光标位置后移一个字节
index = InDictionary(character, string_code); //判断当前字符是否在词典中,index也就是P+C,如果词典中没有当前字符,则返回-1
if (0 <= index) { //当前字符在词典中
string_code = index; //令P=P+C
}
else {//如果当前字符不在词典中
output(bf, string_code); //输出P对应的索引号
if (MAX_CODE > next_code) { //如果词典中还有空间
AddToDictionary(character, string_code); //将新字符串添加到词典中
}
string_code = character; //令P=C
}
}
output(bf, string_code); //循环读完文件后输出最后一个旧字符
}
(2)LZW解码
1、补充DecodeString函数:
int DecodeString(int start,int code)将code字符从d_stack[start]开始倒序存入d_stack数组中,并返回要输出的字符串总长度
int DecodeString(int start, int code) {//在d_stack中存储解码字符
int count; //count标记数组下标
count = start; //从start处开始找
while (code >= 0) { //循环找code的父母,code=-1到达根节点
d_stack[count] = dictionary[code].suffix; //从d_stack[start]开始放code的尾缀字符(最后一个字母)
code = dictionary[code].parent; //找code的父母节点,倒序存进d_stack
count++; //存code的倒数第二个字母......(字符顺序和存储顺序相反,倒序存)
}
return count; //返回字符流长度
}
2、解码过程
void LZWDecode(BITFILE* bf, FILE* fp) {
int character;
int new_code, last_code;//new_code(cW),last_code(pW),cW是当前字符,pW是先前字符
int phrase_length; //解码字符串总长度
unsigned long file_length; //输出文件长度
file_length = BitsInput(bf, 4 * 8); //需要解码的文件大小
if (-1 == file_length) file_length = 0;//
InitDictionary(); //初始化字典,存单个字符
last_code = -1; //pW最开始设为-1
while (0 < file_length) { //如果文件还有内容没有读
new_code = input(bf); //读取新码字
if (new_code >= next_code) { //如果cW不在词典中,那他一定是pW+pW的第一个字符,输出的是pW+pW的第一个字符
d_stack[0] = character; //先将pW的第一个字符存到character里
phrase_length = DecodeString(1, last_code);//将pW从d_stack[1]开始倒序存入d_stack数组,并返回d_stack的总长度(也就是pW+pW第一个字母的总长度)
}
else { //如果cW在词典中,输出的是cW
phrase_length = DecodeString(0, new_code);//将cW从d_stack[0]开始倒序存入d_stack数组,并返回d_stack的总长度(也就是cW的长度)
}
character = d_stack[phrase_length - 1];//character存储当前输出字符的首字母
while (0 < phrase_length) {//当解码字符长度大于0
phrase_length--;//准备倒序输出字符
fputc(d_stack[phrase_length], fp);//输出字符
file_length--;//循环~一直到0
}
if (MAX_CODE > next_code) {//如果词典中还有存储空间
AddToDictionary(character, last_code);//存新字符,last_code(pW)理解为当前字符的父母节点,在遍历寻找pw最小的孩子,把character当作其兄弟节点存进去
}
last_code = new_code;//新编码变为旧编码
}
}
Q:为什么解码的时候会出现码字不存在的情况?
解码时如果出现码字不存在,那么必然出现了上一个新编码的码字,上一个新编码的码字和其首字母重新组成了新码字。(简单理解为:上一个字符在被编码后立即被使用,它必然就是pW+pW的第一个字符)
实例测试
补充实验用到的bitio.h文件
#include <stdio.h>
#pragma warning(disable:4703)
#pragma warning(disable:4996); //使得fopen在编译时可以通过
typedef struct {//文件结构体
FILE* fp;
unsigned char mask;//掩码
int rack;//每次输出的是rack
}BITFILE;
BITFILE* OpenBitFileInput(char* filename)//只读形式打开输入文件
{
BITFILE* bf;
bf = (BITFILE*)malloc(sizeof(BITFILE));
if (NULL == bf) return NULL;
if (NULL == filename) bf->fp = stdin;
else bf->fp = fopen(filename, "rb");
if (NULL == bf->fp) return NULL;
//初始化文件结构体
bf->mask = 0x80;
bf->rack = 0;
return bf;
}
BITFILE* OpenBitFileOutput(char* filename) {//创建打开输出文件
BITFILE* bf;
bf = (BITFILE*)malloc(sizeof(BITFILE));
if (NULL == bf) return NULL;
if (NULL == filename) bf->fp = stdout;
else bf->fp = fopen(filename, "wb");
if (NULL == bf->fp) return NULL;
//初始化文件结构体
bf->mask = 0x80;
bf->rack = 0;
return bf;
}
void CloseBitFileInput(BITFILE* bf) {//关闭读入文件
fclose(bf->fp);
free(bf);
}
void CloseBitFileOutput(BITFILE* bf) {//关闭输出文件
// Output the remaining bits
if (0x80 != bf->mask) fputc(bf->rack, bf->fp);
fclose(bf->fp);
free(bf);
}
int BitInput(BITFILE* bf) {
int value;
if (0x80 == bf->mask) {
bf->rack = fgetc(bf->fp);//前移
if (EOF == bf->rack) {//EOF:end of file,文件是否有数据?
fprintf(stderr, "Empty file\n");
exit(-1);
}
}
value = bf->mask & bf->rack;//取得mask所表示位置的二进制数字
bf->mask >>= 1;//mask右移
if (0 == bf->mask) bf->mask = 0x80;
return((0 == value) ? 0 : 1);//value为0时返回0,不为零时返回1
}
unsigned long BitsInput(BITFILE* bf, int count) {
unsigned long mask;
unsigned long value;
mask = 1L << (count - 1);
value = 0L;
while (0 != mask) {
if (1 == BitInput(bf))
value |= mask;
mask >>= 1;
}
return value;
}
void BitOutput(BITFILE* bf, int bit) {
if (0 != bit) bf->rack |= bf->mask;
bf->mask >>= 1;
if (0 == bf->mask) { // eight bits in rack
fputc(bf->rack, bf->fp);
bf->rack = 0;
bf->mask = 0x80;
}
}
void BitsOutput(BITFILE* bf, unsigned long code, int count) {//按位输出数据到输出文件
unsigned long mask;
mask = 1L << (count - 1);
while (0 != mask) {
BitOutput(bf, (int)(0 == (code & mask) ? 0 : 1));
mask >>= 1;
}
}
LZW编码
用上文分析过的abbababac作为例子:
设置命令行参数:
try.txt为源文件,result.txt为编码的输出文件(输出代号),de_result.txt为最终解码结果文件
int main(int argc, char** argv) {
FILE* fp = NULL;
BITFILE* bf;
int choice;
cout << "1表示编码,2表示解码,请输入:" << endl;
cin >> choice;
if (choice == 1) {
fopen_s(&fp, argv[1], "rb");//打开输入文件
bf = OpenBitFileOutput(argv[2]); //打开输出文件
if (NULL != fp && NULL != bf) { //如果两个文件均不为空
LZWEncode(fp, bf);
fclose(fp);
CloseBitFileOutput(bf);
fprintf(stdout, "编码成功!\n");
printf("encode dictionary:\n");
PrintDictionary();//输出编码词典
}
}
else if (choice == 2) {
bf = OpenBitFileInput(argv[2]); //打开输入文件,是压缩后的文件
fp = fopen(argv[3], "wb"); //以写的方式打开输出文件,是解压缩后的文件
if (NULL != fp && NULL != bf) { //如果两个文件不为空
LZWDecode(bf, fp); //对输入文件bf解码生成输出文件fp,fp就是解码后的文件
fclose(fp); //关闭输出文件
CloseBitFileInput(bf); //关闭输入文件
fprintf(stdout, "decoding done\n"); //输出解码成功
printf("decode dictionary:\n");
PrintDictionary();
}
}
return 0;
}
测试结果:(与之前分析的结果进行对比,结果完全一致~)
将编码输出的文件作为输入写出:
对比可知编解码端的词典相同,测试成功!
不同格式类型的文件编码测试
选用txt、doc、jpg、png、tif、md、bmp、xlsx、pdf、html进行测试:
文件类型 | 压缩前/字节 | 压缩后/字节 | 压缩比 | 排序 |
txt | 48 | 76 | -58.3% | 8 |
doc | 10,240 | 3,876 | 62.14% | 2 |
jpg | 389,285 | 487,794 | -25.3% | 5 |
png | 49,855 | 71,186 | -42.8% | 6 |
tif | 3,744 | 7,086 | -89.3% | 10 |
md | 18 | 28 | -55.6% | 7 |
bmp | 84,750 | 940 | 98.9% | 1 |
xlsx | 6,600 | 10,874 | -64.8% | 9 |
1,200 | 1,380 | -15% | 4 | |
html | 150,635 | 78,644 | 47.8% | 3 |
观察10组不同类型文件的压缩比,我们可以观察到在我所选取的各个类型的例子中,词典编码对bmp格式的文件压缩效果最好,其次是doc文档格式.....但是观察到压缩比会出现负数情况,即利用词典编码最终得到编码文件反而不如未压缩前的文件。
出现这类情况的原因:我推测是因为文件内文字重复率太少,词典编码主要是利用数据的重复性,如果词语重复次数太少,会影响文件编码效果。jpg、png、tif图片之前也介绍过都属于是压缩图片格式,图片本身已经带有压缩,压缩后文件的冗余度变低,再利用词典编码进行编码最终得到的空间反而更大。
实验总结
根据理论推导,LZW编码在对重复率高的内容压缩效果比较好,而且再传输文件时不需要再传输词典码表,解码方可边解码边构建码表,成功实现了边传输边解码的效果。