哈夫曼压缩与解压缩的实现
开始之前,务必要看!看了能更好的理解代码一、整体的布局二、模块功能实现1、压缩2、解压缩三、尾记-主函数的详细介绍开始之前,务必要看!看了能更好的理解代码
为了伙伴们更好的理解我们这个代码的实现,笔者先手动计算一个压缩过程,这样我们就能对整体的代码有一个了解了:
这一部分请真心想要做出哈夫曼压缩解压缩的伙伴们一定认真看,因为这里的逻辑就是代码的逻辑:
假设我们有一个字符串是:acefkmffmkfafkaffakaeef
:
那么它的哈夫曼树如图:
之后,我们进行计算:
这里我们说存入参数,其实就是存入了哈夫曼编码,实际上压缩是压缩了,但是参数比较多,就导致压缩后的文件反而比原文件大。
一、整体的布局
我们已经对整体的逻辑实现有了一个了解,现在就来看看具体的代码吧:
我们想要做到功能分模块,也就是我可以自主选择是压缩还是解压缩,并且查看情况,那么我们就把业务逻辑分离:
那么我们首先看看最简单的头文件huffman.h书写了什么东西吧!
#pragma once#define _CRT_SECURE_NO_WARNINGS#include <stdlib.h>#include <stdio.h>#include <string.h>#include<time.h>#include<Windows.h>struct HuffmanNode{int letter; //保存字符的映射long count; //文件中该字符出现的次数long parent, lchild, rchild; //构建二叉树所需的数据char bits[256];//对应的哈夫曼编码};struct HuffmanNode TreeNode[512], tmp, temp; //节点树,TreeNode.count用来记录数据出现的次数//函数compress(),读取文件内容并加以压缩,将压缩内容写入另一个文档int compress(const char* filename, const char* outputfile);//函数:uncompress(),作用:解压缩文件,并将解压后的内容写入新文件int uncompress(const char* filename, const char* outputfile);
头文件主要就写了一些定义,并包含了其他可能需要的头文件。
现在来看看test.c文件
为了我们更好的互动,在测试文件中书写一个菜单,供选择,那么这个代码很简单:
#include"huffman.h"//注意头文件的包含void menu(){printf("\n1,压缩文件\n");printf("2,解压缩文件\n");printf("3,读取解压缩文件\n");printf("4,查看哈夫曼编码\n");printf("输入'q'退出程序\n");printf("请选择需要的操作:\n");}
至于功能的实现,我们自然还是选择swtich语句,这里我们就需要考虑可能要写的功能了,比如,压缩文件,我们书写一个compress()
函数,解压缩文件,我们书写一个uncompress()
函数,读取文件的话,其实不需要再写函数,可以直接在uncompree()函数里实现解压缩文件的读取,而查看哈夫曼代码,我们既可以在编写哈夫曼树的时候去书写,也可以在解压缩的时候书写。
所以,简单的讲,我们只需要书写两个模块,就可以实现我们想要的功能,那么我们先看看逻辑主函数里都写了什么吧!
当然,这里的主函数会在尾记的时候详细的说一遍,这里可以选择不去理解。
void main(){clock_t begin, end;double cost;FILE* fp;char ch, temparray[30];int ret1, ret2;int choice, count = 0, n;long f;memset(&TreeNode, 0, sizeof(TreeNode));memset(&tmp, 0, sizeof(tmp));menu();while (scanf("%d", &choice) == 1){system("cls");switch (choice){case 1:ret1 = compress("data.txt", "data.zip.txt");menu();break;case 2://开始记录begin = clock();ret2 = uncompress("data.zip.txt", "data.unzip.txt");//结束记录end = clock();cost = (double)(end - begin) / CLOCKS_PER_SEC;printf("解压缩耗时: %lf secs\n", cost);printf("压缩比率是%.3lf%%\n", ((double)ret1 / ret2) * 100);menu();break;case 3:printf("读取解压后的文件:\n");if ((fp = fopen("data.unzip.txt", "rt")) == NULL) {printf("读取失败!\n");exit(1);}ch = fgetc(fp);while (ch != EOF) {putchar(ch);ch = fgetc(fp);}fclose(fp);menu();break;case 4:if ((fp = fopen("data.zip.txt", "rb")) == NULL){printf("读取失败!\n");exit(1);}fseek(fp, 4, SEEK_SET);fread(&f, sizeof(long), 1, fp);fseek(fp, f, SEEK_SET);fread(&n, sizeof(long), 1, fp);//读取字符种类总数for (int i = 0; i < n; i++){printf("字符:%-5c频数:%-6ld哈夫曼编码:%-20s", TreeNode[i].letter, TreeNode[i].count, TreeNode[i].bits);count++;if (count % 4 == 0)printf("\n");}menu();break;case 6:printf("欢迎再次使用!\n");default:break;}}}
我们每次实现一个功能,再去选择另一个功能,就清理一下屏幕,所以这里书写了一个system("cls")
,以便屏幕看着整洁一点。当然,可以根据个人的需要去更改。
由于我们希望计算压缩比率,那么我们书写模块的时候,还需要注意需要返回字节数。也就是书写一个有返回值的函数。
现在要看懂这些代码还是不行的,因为这里的功能的选择依赖于书写的模块的内容。所以我们还得继续看看模块都写了什么,才能理解这个main()函数的含义(并且在尾记里还会再介绍的,保证让每个人能有所收获)。放心,笔者会一一解释所有的可能的难点。
二、模块功能实现
1、压缩
压缩的功能就是读取原文件,进行压缩并写入新文件。这里几乎每一行代码我都给了注释,小伙伴们应该都可以看懂的,所以只挑几个必要的说一说了。
压缩文件的难点就在于写入压缩文件,也就是说,我们需要写入什么东西?因为SEEK_SET可以快速找到文件开头,这里我们在文件开头就写入原文件字节数和压缩文件字节数,为了防止丢失数据(如果不写在开头,就读不到这些数据)
之后我们就存入压缩文件的容器(char类型,个数也就是存储的压缩文件字节数,所以现在理解为什么要存储原文件长和压缩文件长了吗?是为了不多读取,也不少读取)
然后我们在末尾再存入自己编写的哈夫曼编码(实际上更像一个自己写的ASCII编码表),这些参数的个数我们也需要记录,不然不好读取,也就是记录一共统计了多少个字符。
#include"huffman.h"//务必注意包含头文件int compress(const char* filename, const char* outputfile){char temparray[256];unsigned char c;long i, j, m, n, f;long lowest, pt, filelength;FILE* inputfilep, * outputfilep;int k, q;inputfilep = fopen(filename, "rb");//打开文件夹的原始文件if (inputfilep == NULL){printf("打开文件失败\n");return 0;}outputfilep = fopen(outputfile, "wb");//打开压缩后存储信息的文件,如果没有就创建if (outputfilep == NULL){printf("打开文件失败\n");return 0;}filelength = 0;while (!feof(inputfilep))//功能是检测流上的文件结束符,如果文件结束,则返回非0值,否则返回0{fread(&c, 1, 1, inputfilep);//从给定文件inputfilep 读取数据到cTreeNode[c].count++;//读文件,统计每个字符出现次数,并且使用是ASCII码filelength++;//记录文件的字符总数}filelength--;//去掉结束符TreeNode[c].count--;for (i = 0; i < 512; i++) //huffman算法中初始节点的设置{if (TreeNode[i].count != 0)//512个不一定都用了TreeNode[i].letter = i;//映射成ASCII码else //赋值-1是为了避免哈夫曼编码时使用0出现冲突TreeNode[i].letter = -1;TreeNode[i].parent = -1;TreeNode[i].lchild = TreeNode[i].rchild = -1;}//将节点按出现次数排序(选择排序)for (i = 0; i < 256; i++)//每次都向后完成一次{k = i;//k用来记录变化的位置for (q = i + 1; q < 256; q++)//每次都在没有排序的数里进行循环if (TreeNode[k].count < TreeNode[q].count)k = q;//挑没排序的数里数据最大的temp = TreeNode[i];//用t来暂时存储数据(为了与后面的小数据交换)TreeNode[i] = TreeNode[k];//把数据最大的赋值TreeNode[k] = temp;//把暂存的数据赋值给原来值大的,实现交换}for (i = 0; i < 256; i++)//统计不同字符的数量{if (TreeNode[i].count == 0)break;}//统计出i的数值,以便下面使用n = i;m = 2 * n - 1;//m是总结点数for (i = n; i < m; i++)//使用空结点{lowest = 999999999;//保证后面的if一定开始执行for (j = 0; j < i; j++){if (TreeNode[j].parent != -1) continue;if (lowest > TreeNode[j].count){lowest = TreeNode[j].count;pt = j;}}TreeNode[i].count = TreeNode[pt].count;TreeNode[pt].parent = i;TreeNode[i].lchild = pt;lowest = 999999999;for (j = 0; j < i; j++){if (TreeNode[j].parent != -1) continue;if (lowest > TreeNode[j].count){lowest = TreeNode[j].count;pt = j;}}TreeNode[i].count += TreeNode[pt].count;TreeNode[i].rchild = pt;TreeNode[pt].parent = i;}for (i = 0; i < n; i++)//设置字符的编码{f = i;//从构造数的第一个结点开始,依次对结点进行编排TreeNode[i].bits[0] = 0;//初始化while (TreeNode[f].parent != -1)//只要有父节点,就进行编排,第一次没有父节点就是根节点{j = f;f = TreeNode[f].parent;if (TreeNode[f].lchild == j)//左孩子编0{j = strlen(TreeNode[i].bits);memmove(TreeNode[i].bits + 1, TreeNode[i].bits, j + 1);TreeNode[i].bits[0] = '0';}else//右孩子编1{j = strlen(TreeNode[i].bits);memmove(TreeNode[i].bits + 1, TreeNode[i].bits, j + 1);TreeNode[i].bits[0] = '1';}}}//下面的就是读原文件的每一个字符,按照设置好的编码替换文件中的字符fseek(inputfilep, 0, SEEK_SET); //将指针定在文件起始位置,没有任何偏移fseek(outputfilep, 8, SEEK_SET); //以8位二进制数为单位,每次移动偏移进行读取temparray[0] = 0;pt = 8;//后面写入了一共八字节的filelength和ptprintf("读取将要压缩的文件:%s\n", filename);printf("当前文件有:%d字符\n", filelength);//压缩文件while (!feof(inputfilep)){c = fgetc(inputfilep);//获取下一个字符(一个无符号字符)for (i = 0; i < n; i++)//n是字符种类总数{if (c == TreeNode[i].letter) //如果读取的字符与我们字符种类映射的ACSCII某一个对应break;}strcat(temparray, TreeNode[i].bits);//那么就把此字符种类的编码追加给temparray来暂存j = strlen(temparray);//j用来暂存初始编码总长c = 0;//二进制是00000000if (j >= 8)//只要存的编码位数大于8,就执行操作写入文件{for (i = 0; i < 8; i++)//按照八位二进制数转化写入文件一次进行压缩{if (temparray[i] == '1') {c = c << 1;c = c + 1;}//c的二进制数左移并加1else c = c << 1;//c的二进制数字左移}//实现二进制的读取和记录fwrite(&c, sizeof(char), 1, outputfilep);//printf("%s",temparray);//temparray数组存储的是char保存的二进制内容pt++;//pt使用的初始值是8,用于记录字节数strcpy(temparray, temparray + 8);//temparray的值重新覆盖}}if (j > 0)//当剩余字符数量少于8个时{strcat(temparray, "00000000");for (i = 0; i < 8; i++){if (temparray[i] == '1'){c = c << 1;c += 1;//位运算}else c = c << 1;//对不足的位数进行补零}fwrite(&c, 1, 1, outputfilep);pt++;}fseek(outputfilep, 0, SEEK_SET);//偏移量为零,重新找到开头位置写入参数//使用outputfilep指针来确定文件的开头,将编码信息写入存储文件fwrite(&filelength, 4, 1, outputfilep);//1是写入的元素大小,sizeof(filelength)是需要写入的长度。这些内容写入ouputfilepfwrite(&pt, 4, 1, outputfilep);//写入四字节fseek(outputfilep, pt, SEEK_SET);//从给定的开头位置,pt大小(压缩之后的位置),在outputfile里读取fwrite(&n, 4, 1, outputfilep);//写一个四字节的数据,要注意pt的累加//以上是写入压缩的各类参数:字符种类总数,字符总数for (i = 0; i < n; i++){tmp = TreeNode[i];fwrite(&(TreeNode[i].letter), 1, 1, outputfilep);//写入各类字符pt++;c = strlen(TreeNode[i].bits);//映射fwrite(&c, 1, 1, outputfilep);pt++;j = strlen(TreeNode[i].bits);if (j % 8 != 0)//当位数不满8时,对该数进行补零操作(为了一字节八位数的保存){for (f = j % 8; f < 8; f++)strcat(TreeNode[i].bits, "0");}while (TreeNode[i].bits[0] != 0){c = 0;//赋值00000000for (j = 0; j < 8; j++){if (TreeNode[i].bits[j] == '1'){c = c << 1;c += 1;//进行位运算}else c = c << 1;//不是1就补0}strcpy(TreeNode[i].bits, TreeNode[i].bits + 8);fwrite(&c, 1, 1, outputfilep);//将所得的编码信息写入文件pt++;}//以上写入各字符种类哈夫曼编码的数据TreeNode[i] = tmp;}fclose(inputfilep);fclose(outputfilep);//关闭文件printf("\n压缩后文件为:%s\n", outputfile);printf("压缩后文件有:%d字符\n", pt + 4);//之前写入了一个四字节的n,所以需要加4return pt + 4;//返回压缩成功信息}
至于位运算,注释给的也很详细,哈夫曼树的编写,就读者自己看看书喽,我们日后再写一篇文章讲一讲哈夫曼树的编写。不然这篇文章就太长啦!
2、解压缩
至于解压缩,也就是读取压缩文件,并把读取结果写入新文件,所以压缩的时候,我们主要使用fseek和fwrite,解压缩的时候,我们就主要使用fseek和fread了。
同样的,几乎每一行代码都给了注释,认真看一看都能看懂。
现在就挑几个不好理解的说一说:
读取的时候,我们需要注意先读取原文件长和压缩文件长,这样我们才能让程序知道,它在读压缩文件的时候应该读多少个,不能读多了,读多了就读到参数了(就是后面存储的哈夫曼编码的数据,那就读多了)
之后是读取参数的数据(获取解压缩的数据,不然没有编码也解压不了)
最后一步就是利用读取到的参数进行转译了,这一步就是写入新文件的过程,还是需要使用fwrite函数的。
至此,我们的哈夫曼解压缩也就实现了。
int uncompress(const char* filename, const char* outputfile){char temparray[256], bx[256];unsigned char c;long i, j, m, n, k, f, p, l;long filelength;int len = 0;FILE* inputfilep, * outputfilep;char cname[512] = {0 };inputfilep = fopen(filename, "rb");//打开压缩的文件if (inputfilep == NULL){printf("文件打开失败!");return 0;//若打开失败,则输出错误信息}outputfilep = fopen(outputfile, "wb");//打开新文件,用于解压缩if (outputfilep == NULL){return 0;}fseek(inputfilep, 0, SEEK_END);//定下指针位置len = ftell(inputfilep);//记录的是inputfilep的位置,也就是字节数fseek(inputfilep, 0, SEEK_SET);printf("将要读取解压的文件:%s\n", filename);printf("当前文件有:%d字符\n", len);fread(&filelength, sizeof(long), 1, inputfilep);//读取原文件长fread(&f, sizeof(long), 1, inputfilep);//f读取的是需要压缩文件的长度,不包括参数fseek(inputfilep, f, SEEK_SET);//从文件的f位置后开始读fread(&n, sizeof(long), 1, inputfilep);//读取字符种类总数for (i = 0; i < n; i++)//读取压缩文件内容并转换成二进制码{fread(&TreeNode[i].letter, 1, 1, inputfilep);//读取字符(ASCII码)fread(&c, 1, 1, inputfilep);//读取字符编码的数据strlen(TreeNode[i].bits))p = (long)c;//读的是编码的长度,编码的长度与count反相关TreeNode[i].count = p;TreeNode[i].bits[0] = 0;//赋初值if (p % 8 > 0) //判断字节m = p / 8 + 1;else m = p / 8;for (j = 0; j < m; j++){fread(&c, 1, 1, inputfilep);//此步读取的是单个字符类的二进制码f = c;_itoa(f, temparray, 2);//将f的值转为2进制存入temparray,此时temparray存的是二进制f = strlen(temparray);for (l = 8; l > f; l--){strcat(TreeNode[i].bits, "0");//位数不足,执行补零操作}strcat(TreeNode[i].bits, temparray);}TreeNode[i].bits[p] = 0;}//接受解压所需要的参数,读取必要的文件for (i = 0; i < n; i++)//每次都向后完成一次{k = i;//k用来记录变化的位置for (j = i + 1; j < n; j++)//每次都在没有排序的组里进行循环if (strlen(TreeNode[k].bits) > strlen(TreeNode[j].bits))//长度最小的是出现次数最多的k = j;//取短的tmp = TreeNode[i];//用t来暂时存储数据TreeNode[i] = TreeNode[k];//k是略长的值,换到后面TreeNode[k] = tmp;//实现交换}//排序的目的是快速找到读取解压对应的字母p = strlen(TreeNode[n - 1].bits);//p取了一个长度最长的fseek(inputfilep, 8, SEEK_SET);//前八位存了两个四字节数据m = 0;bx[0] = 0;while (1){while (strlen(bx) < (unsigned int)p){fread(&c, 1, 1, inputfilep);//读取压缩的文件f = c;//f是一个数,转换十进制再转换二进制丢失了0_itoa(f, temparray, 2);//f转换成二进制暂存入temparrayf = strlen(temparray);for (l = 8; l > f; l--){strcat(bx, "0");}//如果不足八位,补零strcat(bx, temparray);}for (i = 0; i < n; i++){if (memcmp(TreeNode[i].bits, bx, TreeNode[i].count) == 0)break;//比较字节数是为了保证数组保存的二进制数字相同}strcpy(bx, bx + TreeNode[i].count);//进行赋值覆盖c = TreeNode[i].letter;//写入ASCII码fwrite(&c, 1, 1, outputfilep);//写入解码的文件m++;if (m == filelength)//限定写入只写到原文件的内容,再往后就是存储的参数break;}//读取压缩文件内容,解压缩的过程fclose(inputfilep);fclose(outputfilep);printf("解压后文件为:%s\n", outputfile);printf("解压后文件有:%d字符\n", filelength);return filelength;//输出成功信息}
把压缩的过程理解了,解压缩其实就没什么不能理解的了。
三、尾记-主函数的详细介绍
为什么写尾记呢?因为我们要在这个地方再看一看主函数的功能实现,这样就能让读者们真正的了解这些代码的运行了。
初始化内存就没什么好说的,不操作的话,可能运行不起来
memset(&TreeNode, 0, sizeof(TreeNode));memset(&tmp, 0, sizeof(tmp));
这个代码while (scanf("%d", &choice) == 1)
while (scanf("%d", &choice) == 1){......}
则实现了输入字母就退出,当然,我们也可以定向的作其他修改,看读者的需求了。
scanf函数的返回值是正确读取的个数,所以只要我们输入字母,就会退出循环,从而实现想要的功能
swtich语句的case1和case2没什么好讲的,就是基本的模块直接调用。
case3,我们是要实现读取解压缩的文件:
case 3:printf("读取解压后的文件:\n");if ((fp = fopen("data.unzip.txt", "rt")) == NULL) {printf("读取失败!\n");exit(1);}ch = fgetc(fp);while (ch != EOF) {putchar(ch);ch = fgetc(fp);}fclose(fp);menu();break;
那么这里我们就需要打开文件,并且使用 end of file 结束标志。这一个读取还是很简单,不需要itoa转换二进制,因为解压缩的文件已经是字符文件。
case4,我们是要读取哈夫曼编码的一些数据,如图:
那么由于我们书写的文件里已经有参数的数据,我们直接找到压缩的文件,读取一下有多少个字符种类,然后用一个循环把结构体打印出来就可以了。
(当然,笔者认为自己使用的这种方法是一个笨方法,我们也可以直接定义一个全局变量,在压缩的时候就存储一下字符种类个数)
这里的找到文件的操作,需要使用SEEK_SET的一些知识,上面的代码已经都给出了使用的说明和注释。
case 4:if ((fp = fopen("data.zip.txt", "rb")) == NULL){printf("读取失败!\n");exit(1);}fseek(fp, 4, SEEK_SET);fread(&f, sizeof(long), 1, fp);fseek(fp, f, SEEK_SET);fread(&n, sizeof(long), 1, fp);//读取字符种类总数for (int i = 0; i < n; i++){printf("字符:%-5c频数:%-6ld哈夫曼编码:%-20s", TreeNode[i].letter, TreeNode[i].count, TreeNode[i].bits);count++;if (count % 4 == 0)printf("\n");}menu();break;
那么,现在,我们要实现的功能就都已经书写了,如果有小伙伴说,我想看到二进制的数据,怎么才能看到呢?
二进制文件,我们是打不开的,看到的都是乱码,不过我们可以使用itoa函数外加一个辅助数组,把写入到压缩文件里的容器一个一个读出来,这样得到的也是一个可以被我们看到的二进制的一些数字(但注意,这不是二进制哦)。有兴趣的小伙伴可以自己实现一下。
今天的分享就到这里了,希望老铁们有所收获,我们下次再见。