哈夫曼编码实训:使用Qt构建界面和简单的效率优化

前言

这篇博客主要是整理、记录一下这次数据结构实训的过程以及分享一些我个人的心得体会,当然,代码我个人的项目代码也会开源分享。先放链接:https://github.com/Melonl/FileCompress

 

相关资料以及开源代码

在上面给的Github链接里的Code&Ref文件夹下即是实训参考文档以及老师给的参考代码,main函数入口在Demo1.cpp里,Training Guide3.0.docx是实训参考文档,另外文件夹里还给了一个txt和一个png用于测试。Core文件夹里的是这次实训我项目的核心代码,Main.cpp是入口。FileCompress_qt_project文件夹里的是我最终的Qt项目代码,里面包含Core文件夹下的代码以及Qt相关的GUI代码,是从Qt工程目录直接打包的,可以直接使用Qt Creator 4.10.1及以上版本(截止至本文发布时的最新Qt版本Qt 5.13.1)打开。另外,代码里我用的是Haffman这个单词,是拼写错误,因为我一直以为哈夫曼的英文直译就是Haffman,谁知道其实是霍夫曼..

 

关于我的项目

这次实训指导老师直接给了参考代码(大概是老师为了实训不会太多人挂而放水..),就是链接里Code&Ref文件夹下面的那份代码。这样就使得实训的重心从实现Huffman压缩算法转到理解、修改、优化参考代码上。所以,我给自己的要求是在理解的基础上使用C++简单封装整个流程,然后对参考代码做一些改进(即是Core文件夹下的代码),最后再使用Qt做一个界面(即是上文开源的Qt工程)。

Qt工程实际截图:

 

 

Huffman编码过程总结

这部分先总体总结一下参考代码的实现思路,然后我会以Q&A的形式来着重记录一下实现中的一些难点。

 首先要说明的是,参考代码和参考文档涉及到的Huffman编码都是静态的Huffman编码,即需要在压缩文件之前先扫描一遍文件得到所有字节对应的权重,然后再根据权重生成Huffman树和各字节的Huffman编码,最后一一对应地把原字节翻译成Huffman编码。静态Huffman编码只适合压缩较小的文件或者有大量重复字符的文本文件,否则的话压缩后文件的大小可能没有变化或者更大。Huffman编码的整个过程在参考文档和参考代码里都解释得很详细了,我觉得我也总结不出什么更好的内容了,如果有需要直接去阅读源码和文档吧,下面直接以Q&A形式记录一些重难点好了。

 

1.Huffman编码只能用于压缩文本文件(.txt)吗?

可以压缩任意类型的文件。在参考代码的haffmantree.cpp的47行(在Statistics函数里)可以看到,它是以每次读入一个字节的形式来处理文件,然后再将这个字节“视为” ASCII码来统计权重(或者说“词频”,也就是这个字节在整个文件中出现的次数),所以它可以压缩任意类型的文件。

 

2.用来存Huffman树的数组的大小为什么要设为511而不是510或者512?

因为Huffman树最多只有511个结点,510少了,会出现问题,512多了,浪费空间。在上文有说到,这种压缩方式是基于字符或者说字节的权重来压缩的,是将字节“视为”ASCII码来统计权重的,一个字节刚好对应一个char类型,而char又与ASCII码一一对应。ASCII码最多只有256个,Huffman树又是一种二叉树,那么Huffman树最大也就只有256*2-1=511个结点(即是所有ASCII码的字符都在被压缩的文件里出现的情况)。这里有涉及到一点数据结构二叉树的相关内容。

 

3.为什么Huffman树的根节点在下标为bytes_count * 2 - 2的位置?

这个跟Huffman树的构造过程有关。在按字节读取源文件统计完权重之后,代码里是先对HaffTree数组根据权重进行降序排序,使所有叶子结点(共有byte_count个叶子结点)都“挤”到数组的头部,然后再在剩下的空位里顺序地从叶子往根部构造非叶子结点,所以最后下标为bytes_count * 2 - 2的位置就是整个Huffman树最后一个结点的位置,也就是根结点所在位置。

 

4.writeCompressFile函数尾部的strcat(buff, "0000000")的作用?为什么是7个'0'而不是8个?

用于补齐最后一个字节的数据,补7个'0'的是因为缓冲区中至少有一个 bit数据。在compressfile.cpp里的148行可以看到,代码中是先判断一下buff里是否还剩下有非'0'的字符,若有,则需要填充为8个bit后再写出,因为不管是读入还是写出,最小单位都是一个字节,即是8个bit。而填充7个'0'是因为此时buff中至少还有一个bit的数据,填充7个'0'后无论如何都能凑够一个字节写出,多出来的 ‘0’会被直接舍弃(因为填充完后只写出一个字节)。

 

5.为什么在解压的时候只需要构建Huffman树而不需要构建Huffman编码?

因为解压的时候需要的是“编码到字符”的关系,而这个关系已经包含在树结构里了。在构造Huffman树的时候,我们规定编码中的 0代表"往左孩子移动",1代表"往右孩子移动",所以每个叶子结点(也就是每个字节)对应的Huffman编码其实就是从根结点到该叶子结点的“路径”。我们在解压的时候,已经有了对应的Huffman编码,也就是已经有了“路径”信息,我们就可以直接根据“路径”在树里找对应的原字节,将Huffman编码还原成对应的字节数据即可,不需要先给每个叶子结点生成编码。但是在压缩的时候,我们只有原字节,而树的“路径”对应的是Huffman编码,我们无法通过原字节直接找到“路径”,也就无法直接找到对应的Huffman编码,只能在构造好每个字节的Huffman编码后再通过数组层面的搜索来找到对应的结点,从而找到对应的Huffman编码。

 

6.对参考代码进行的优化或者改进?

都是一些小的效率上的改进。

  1. 在统计权重的部分,也就是haffmantree.cpp中60行的位置,这里额外地使用了一个for来统计原文件出现的byte数量(即是叶子结点数量),其实完全可以在48行统计原文件字节数的时候一并统计,这样可以节省一些时间。改进后的这部分代码可以在Core文件夹下面的HaffCode.cpp里143行看到。
  2. 在压缩的根据字节找对应编码的部分,也就是compressfile.cpp的getByteEncode函数,这里使用了最朴素的O(n)复杂度的查找,而查找的数据结构是一个数组,那么我们完全可以使用诸如二分查找之类的方法来优化查找速度。我的代码中使用的就是二分查找(对应代码在HaffCode.cpp里的getCode函数中),当然,需要提前对数组根据字节的ASCII码来排序,我使用的是STL中的sort函数。排序的时间复杂度大约为O(nlogn),排完序后每次查找只需要O(logn)的复杂度,我个人认为在被压缩的文件比较大的情况,效率的提升是非常可观的,因为查找操作需要执行非常多(其实就是文件的字节数)次。其实这里还有一种更加极限的优化方式,就是将数组里的叶子结点的字节ASCII码对应到数组下标,例如ASCII码为97的结点就让它放在数组里下标为97的位置。这样一来查找的时间复杂度可以直接优化到O(1),而这个使ASCII码对应下标的操作也不过O(n)的复杂度。
  3. 写出缓冲区的部分,也就是flushBuffer函数,这里使用了pow函数来将8个char构造成一个写出的char,因为这是一个处理二进制位的问题,我们可以直接使用位运算来处理(优化后的代码在我的代码Compressor.cpp的flushBuffer函数中)。使用位运算代替pow函数可以极大地提升速度,并且省去了调用函数的空间开销。
  4. 在根据叶子结点构造Huffman树的过程中,需要每次查找两个最小权重的结点来组成一个子树,参考代码中这里使用的是两重for构建的,一重for构建一重for查找,这样算的话时间复杂度大概是在O(n²)。我们其实可以使用优先队列来优化,为了防止结点对象重复构造的问题,我们还可以使用指向结点的指针来代替结点本身push进优先队列。但是使用优先队列还需要考虑原数组下标的问题,因为我们取到两个最小结点后是需要根据它们的下标来操作的,而加入到优先队列之后下标的信息就没了,如果要储存下标信息的话又得再定义一个结构来储存下标...总之,我觉得太过繁琐而且效率的提升不是特别高,所以这里只给个思路,在我的代码中是没有做这个优化的。

 

QT相关的总结

在我的项目中QT的代码其实不多,无非就是槽函数、QT简单的多线程以及一些GUI相关的东西,都比较简单,这里就不展开说了,有需要的朋友直接翻我的QT工程便是。

 

踩的坑

主要踩了两个大坑,一是文件编码的坑,二是C++重定向读取文件的坑。

先说说文件编码的坑,老师给的参考代码是GBK编码格式的,而我一开始在写我自己的cpp的时候使用的是UTF-8编码,如果我正常一点一点敲代码倒没什么我,问题就出在一旦我复制了参考代码到我的cpp里,我的cpp就会出问题,出问题之后虽然依旧可以编译,也可以正常写代码,显示也没问题,但是我在有问题的cpp里使用类似ifstream in("xx")这种需要传一个字符串路径的函数,传过去的路径永远都是错误的,也就导致文件流总是打不开,无论怎么调试都不行。最后的解决办法是重新创建了一个UTF-8编码的cpp,然后手动一行一行地照着写代码,不从GBK编码文件里复制任何代码。

二是C++重定向读取文件的坑,使用C++的重定向读取char的时候,哪怕打开文件流的时候是使用的ios::binary的,它依旧会跳过一些字符,导致使用这种方式不能按字节处理完文件中所有的字节。正确的按字节读取方式是使用ifstream::read,这个函数将会逐个字节读取,而使用重定向读取本身是一种格式化输入,会跳过一些字节。

 

 

这次实训就总结到这里,感谢阅读。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章