这个是代码是昨天写完的,一开始的时候还出了点小bug,这个bug在晚上去吃饭的路上想明白的,回来更改之后运行立刻完成最后一步,大获成功。
简单说下huffman编码和文件压缩主要的技术。
Huffman编码,解码:
I 创建Huffman树
II 根据Huffman树实现编码,并将编码结果和要编码的数据建立映射关系。
III Huffman解码,也就是根据获取的Huffman码来逆向获取解码信息,而且你从解压文件中一次性获取的数据是一个很长的字符串,没有预处理好的成段字符串式Huffman码。1
I 首先,如何创建Huffman树?
在这个我在前天的那篇文章中简单的提了一下,现在好好说一下。如果你不知道什么是Huffman树,请google之~
对于获取到的文件,首先要做的就是,建立一个长度为256的int数组,全部置零,然后以字节流的形式读取文件,并对字节流中的字节出现次数进行统计,方法就是以字节数值为数组偏移地址,对应的数组元素进行+1操作。另外这里需要提一下的就是,用于存储文件字节流的缓冲区最好是unsigned char类型,因为这样能直接使用,如果是char的,在转化为int类型的时候,一旦数值大于127,因为补码问题,你就直接乘上了通往未知数值的高铁~
完成统计之后,将这个数组中出现次数不为0的元素添加对应大小的二叉树节点数组中,然后以出现次数为Key值,进行排序。
在排序完成之后,就能开始构建Huffman树了。操作如下:
1 如果数组中元素个数不为1,将前两个元素构造为一个临时节点的子树,此时临时节点的Key值为两个元素Key值之和,然后删除数组中的第一个元素(从数组中删除),再将临时节点赋值给当前数组的第一个元素。
(其实就是将前两个元素添加到一个临时节点的左右根节点,然后在原数组中删除这两个元素,接着再将这个临时节点插入到数组头部,充当新的节点。上面的那段描述我觉得说的不是很清楚,但是那个是我在代码中发现的一个可以优化的地方,减少了一个元素的删除操作)
2 此时数组依据key值的排序很有可能已经不再有序,而又因为仅有一个乱序元素,所以专门设计了一个函数,一次完成排序,效率,应该是最高的了。重复1
这样当数组中只有1个元素的时候,就是Huffman树的根节点了。
这样,Huffman树的构造就完成了。我上面说的可能不是很清楚,你看了之后可能会有疑问,所以我在这贴下部分代码,你可以看一下,就是这么简单,而且很巧妙。
Huffman树节点,一开始就是一个Struct,但是因为涉及到了STL,所以添加了方法
1 struct HaffmanStruct 2 { 3 //a small structure 4 HaffmanStruct():val(0),ncounts(0),lNext(NULL),rNext(NULL){} 5 bool operator < (HaffmanStruct &); 6 bool operator > (HaffmanStruct &); 7 void Reset(); 8 unsigned char val; 9 unsigned int ncounts; 10 char HuffmanCode[254]; 11 //used for tree 12 HaffmanStruct * lNext; 13 HaffmanStruct * rNext; 14 };
给他一个数组,他给你一颗Huffman树
1 void HuffManEncode(vector<HaffmanStruct> & vecValidNumberArray) 2 { 3 HaffmanStruct ValidStruct;//temporary struct 4 //Analysis 5 while(vecValidNumberArray.size() != 1) 6 { 7 ValidStruct.Reset(); 8 ValidStruct.ncounts = vecValidNumberArray[0].ncounts + vecValidNumberArray[1].ncounts; 9 ValidStruct.lNext = new HaffmanStruct; 10 *ValidStruct.lNext = vecValidNumberArray[0]; 11 ValidStruct.rNext = new HaffmanStruct; 12 *ValidStruct.rNext = vecValidNumberArray[1]; 13 vecValidNumberArray.erase(vecValidNumberArray.begin()); 14 vecValidNumberArray[0] = ValidStruct; 15 SingleSort(&vecValidNumberArray[0], vecValidNumberArray.size(), 0); 16 } 17 }
以上就是Huffman树构造的全部过程。
II 根据Huffman树获取Huffman编码
对树最有效的访问方式就是遍历,而遍历有两种方式:深度优先遍历和广度优先遍历。不过学过Huffman编码的人都知道,Huffman的编码,必须使用深度优先遍历,你懂得~
我在此默认的模式是,左树为0,右树为1.而这个遍历函数需要使用一个编码缓冲区和输出目标,以及深度探测。于是乎,一个参数好多的递归函数新鲜出炉了,昨天才被我正式造出来。
1 template <class T> 2 void ErgodicTree(T & Root, char * szStr, int nDeep, string pStrArray[]) 3 { 4 if(Root.lNext == NULL && Root.rNext == NULL) 5 pStrArray[Root.val] = szStr; 6 szStr[nDeep] = '0'; 7 if(Root.lNext != NULL) 8 ErgodicTree(*Root.lNext, szStr, nDeep + 1, pStrArray); 9 szStr[nDeep] = '1'; 10 if(Root.rNext != NULL) 11 ErgodicTree(*Root.rNext, szStr, nDeep + 1, pStrArray); 12 szStr[nDeep] = 0; 13 }
需要注意的是,编码和解码的递归函数是不一样的,在这专门提一下,因为编码是一次性遍历完成全部的节点,而解码是每次只遍历到叶子节点。
可以看到,每次向下传递参数的时候,左树就置'0',右树就置'1',返回的时候必须清零。这样下一级函数会获取的结果,并且根据Deep的值对应置位,上级函数函数的乞讨递归也不会受到影响。代码写的很简单,但是其实很细致。
一旦访问到了叶子节点,就直接输出,这里写的也很巧妙,也就是在这里,获取到了Huffman编码,输出到对应的string数组中。
这样,就完成了Huffman编码。
III Huffman解码
用Huffman解码之前,你获取到的是一个很长的,内容是'0'和'1'的字符串。在我的代码中,这个字符串的长度是1024.
其实Huffman的解码实现起来也很简单,但是,存在细节性问题。
比如:从递归函数中获取返回值、下次解码的偏移地址、字符串访问已经到头了,但是解码失败(你想想这个问题出现的圆心),此时字符串中还剩下几个未解码的字符。
这些都是相当细节性的问题,另外文件中一般有n多个1024长度的以上的字节数,如何承上启下也是问题。
这一切,都在下面这段代码中解决:
1 char Buffer[128]; 2 char DecodeBuffer[1056];//增加了八个缓冲字节 3 DWORD dwReadByte; 4 DWORD dwFlag = 1; 5 DWORD dwDeep = 0; 6 char tmpchar; 7 int EffectiveBufferSize = 0; 8 int nLeftNumberInBuffer = 0; 9 char szSmallBuffer[2] = {0}; 10 11 //创建解压文件 12 HANDLE hDeCompressionFile = QuickCreateFile("C:\\DCRecord.txt"); 13 assert(hDeCompressionFile != INVALID_HANDLE_VALUE); 14 15 while(1) 16 { 17 int i = 0; 18 dwReadByte = ReadHuffCodeFromFile(hHuffFile, Buffer, 128); 19 if(dwReadByte == 0) 20 break; 21 EffectiveBufferSize = ReadBitToBuffer(Buffer, (int)dwReadByte, DecodeBuffer + nLeftNumberInBuffer, 1024); 22 EffectiveBufferSize += nLeftNumberInBuffer; 23 //TextFileFunction(DecodeBuffer, 1024); 24 for(i = 0;(i + dwDeep) < EffectiveBufferSize;i += dwDeep) 25 { 26 dwDeep = 0; 27 tmpchar = DecodeHuffman(&vecHuffmanArray[0], DecodeBuffer + i, EffectiveBufferSize - i, dwDeep, dwFlag); 28 if(dwFlag == 1) 29 { 30 szSmallBuffer[0] = tmpchar; 31 WriteBufferIntoFileNormally(hDeCompressionFile, szSmallBuffer, 1); 32 } 33 else 34 { 35 dwFlag = 1; 36 break; 37 } 38 } 39 nLeftNumberInBuffer = EffectiveBufferSize - i; 40 memcpy(DecodeBuffer, DecodeBuffer + i, nLeftNumberInBuffer); 41 }
这段代码中对于这种问题完成的很好,我上面说的在晚上去吃饭的路上就是想明白了实现承上启下那个问题的。
大致步骤如下:
要注意到参数Deep是引用值,是会修改原值的。这个值同时是递归时使用的字符串偏移地址,这个地址所在的值,决定了下一级是向左子树走还是向右走的方向。也就是根据字符串数据来访问Huffman树,一旦访问到叶子节点,就表明此次的解码完成了,返回这个对应值。虽然是递归调用,但是每一级递归调用只有一条通路选择,所以返回值具有可传递性。
完成一段字符串的解码之后,此时的Deep参数就已经是访问过的字符串个数了,就能用于下一次解码的地址偏移,能够用于循环代码操作。
另外还有个问题就是,我从获取的huffman解码整条字符串,都是8的倍数(因为下级函数时将一个字节的8位数据按位解读,写入字符串),所以到最后的一段字符串解码失败很正常,因为这段字符串码不完全。此时就需要将这段字符码移到首部,然后与下一次读出来的字符码进行拼接。你可以注意到,我的代码中,一般都是在for循环中直接声明int i,但是在这里却是在while循环外声明的i,就是为了实现拼接,使得解码操作能够传递下去。如果一次性创建一个很大很大的缓冲区把整个文件都读进来,我只能说:图样图森破。
具体的操作就看代码吧,我写的时候是有点小纠结的,但是写完了一看,呵呵,就这么简单。
这样,解码也就完成了。
最后说一下文件操作。
首先写文件有一块很重要的就是,需要写一个文件头。而且是一个变长的文件头。
文件头主要内容:(不涉及文件夹)
1 文件原名,以及后缀
2 需要编码的数据的数据个数
3 存在的字符和该字符出现的次数
上面的2 3其实就是把构造Huffman树最基础的数组存入文件中,这样解压文件就能根据文件头来构造Huffman树,从而实现解码了。为此我专门写了一个负责文件头的函数。而且这里有个注意事项就是,写文件的时候,要把排好序的数组写进去,这样解压文件就不需要再次进行排序了,能省则省嘛。
查看文件头数据:
这个是我以前水平还很烂很烂的时候写的一个查看程序,最可笑的就是,我这缓冲区用的是一个CString,现在看看,真是荒唐可笑。
不过现在对于小文件,还是能看一看的。你能看到,前几个都是1的,就是int数据,只有出现1次的字符。后面出现的次数逐渐增加。
再往下拉的话,就是各种乱码了,都是按字节解释起来乱七八糟的东西了。
其实编码解码的文件操作这块,我觉得应该算是计算机中,对最小数据单元的操作了,绝对没有比这更小的了。因为要做的是根据编码结果一位一位的将数据写到char变量中,而在读文件这块,也是整块的读内存,然后按位解析字节,获取到以字节为单位的解码数据。
这块我就贴这几个函数,用于从字节到位,和从位到字节的操作。这活,还真的是挺细致的。记得我昨天调代码的时候,还专门调试这几个函数,因为当时解码出来的是乱码,然后我对照写文件前的Huffman码,还真的找到了问题所在。
Byte to Bit
1 void SetByteBit(char * ByteAddr, int Val, int BitAddr) 2 { 3 int TmpVal = -1; 4 int tmpval = 1; 5 tmpval <<= BitAddr; 6 if(Val == 0) 7 { 8 TmpVal ^= tmpval; 9 *ByteAddr &= TmpVal; 10 } 11 else 12 { 13 *ByteAddr |= tmpval; 14 } 15 } 16 /* 17 将huffman码按位写入文件 18 */ 19 void WriteByteToFile(HANDLE hFile, const char * lpszHuffCode, int Mode) 20 { 21 if(hFile == INVALID_HANDLE_VALUE) 22 return; 23 24 static int snBytePointer = 0; 25 static int snBitPointer = 0; 26 static char WriteBuffer[512]; 27 DWORD wfCounter = 0;//写缓冲区指针 28 int nLength = 0; 29 if(lpszHuffCode != NULL) 30 nLength = strlen(lpszHuffCode); 31 32 if(Mode == 1) 33 { 34 WriteFile(hFile, WriteBuffer, snBytePointer + !!snBitPointer, &wfCounter, NULL); 35 snBytePointer = 0; 36 snBitPointer = 0; 37 wfCounter = 0; 38 return; 39 } 40 41 for(int i = 0;i < nLength;\ 42 ++snBitPointer >= 8?(snBytePointer ++,snBitPointer = 0):snBitPointer,i ++) 43 { 44 if(snBytePointer > 511) 45 { 46 WriteFile(hFile, WriteBuffer, 512, &wfCounter, NULL); 47 snBytePointer = 0; 48 } 49 SetByteBit(&WriteBuffer[snBytePointer], lpszHuffCode[i] - '0',snBitPointer); 50 } 51 }
Bit to Byte
1 /* 2 ReadByteBit Function 3 2013 10 05 4 */ 5 6 void ReadBitFromByte(const char Byte, char * buf = NULL) 7 { 8 if(buf == NULL) 9 return; 10 int nTmpVal = 1; 11 int nAndResult; 12 for(int i = 0;i < 8;++ i) 13 { 14 nTmpVal <<= i; 15 nAndResult = nTmpVal & Byte; 16 buf[i] = '0' + !!nAndResult; 17 nTmpVal = 1;//this is prrety important 18 } 19 } 20 /* 21 还是写中文注释吧 22 这个函数是用于将从文件读出来的二进制信息取出来,并存放到字符串中。 23 返回值就是读出来的位数的长度 24 */ 25 26 int ReadBitToBuffer(const char * ReadBuf, int nByteNumber,char * OutputBuf, int nOBufLength) 27 { 28 if(nOBufLength < nByteNumber * 8) 29 return 0; 30 for(int i = 0;i < nByteNumber;i ++) 31 { 32 ReadBitFromByte(ReadBuf[i], OutputBuf + 8 * i); 33 } 34 return nByteNumber * 8; 35 }
刚才看了看自己写的一部分代码,感觉,还真的感觉到了代码中有不少自己的努力和智慧。
代码就不全贴了,内容就这么多,都是最基础的操作。不过此番之后,我觉得,我已经有能力编写压缩文件程序了,至少数据压缩存储这一块,我有了最基础的技术。
看下解码效果:
简单提下:
我的解压文件字符没问题,但是为什么有些符号却不一样?你可以看到,最后的输出的结果是有些许不同的,这令我很费解~
源文件:
压缩后的文件,这感觉,用三个字母表示:WTF……
解压文件:
至此完成