为了伙伴们更好的理解我们这个代码的实现,笔者先手动计算一个压缩过程,这样我们就能对整体的代码有一个了解了:
这一部分请真心想要做出哈夫曼压缩解压缩的伙伴们一定认真看,因为这里的逻辑就是代码的逻辑:
假设我们有一个字符串是:acefkmffmkfafkaffakaeef
:
那么它的哈夫曼树如图:
之后,我们进行计算:
这里我们说存入参数,其实就是存入了哈夫曼编码,实际上压缩是压缩了,但是参数比较多,就导致压缩后的文件反而比原文件大。
我们已经对整体的逻辑实现有了一个了解,现在就来看看具体的代码吧:
我们想要做到功能分模块,也就是我可以自主选择是压缩还是解压缩,并且查看情况,那么我们就把业务逻辑分离:
那么我们首先看看最简单的头文件huffman.h书写了什么东西吧!
#pragma once
#define _CRT_SECURE_NO_WARNINGS
#include
#include
#include
#include
#include
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()函数的含义(并且在尾记里还会再介绍的,保证让每个人能有所收获)。放心,笔者会一一解释所有的可能的难点。
压缩的功能就是读取原文件,进行压缩并写入新文件。这里几乎每一行代码我都给了注释,小伙伴们应该都可以看懂的,所以只挑几个必要的说一说了。
压缩文件的难点就在于写入压缩文件,也就是说,我们需要写入什么东西?因为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 读取数据到c
TreeNode[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和pt
printf("读取将要压缩的文件:%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;//二进制是00000000
if (j >= 8)//只要存的编码位数大于8,就执行操作写入文件
{
for (i = 0; i < 8; i++)//按照八位二进制数转化写入文件一次进行压缩
{
if (temparray[i] == '1')
{
c = c << 1;
c = c + 1;
}//c的二进制数左移并加1
else
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)是需要写入的长度。这些内容写入ouputfilep
fwrite(&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;//赋值00000000
for (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,所以需要加4
return pt + 4;//返回压缩成功信息
}
至于位运算,注释给的也很详细,哈夫曼树的编写,就读者自己看看书喽,我们日后再写一篇文章讲一讲哈夫曼树的编写。不然这篇文章就太长啦!
至于解压缩,也就是读取压缩文件,并把读取结果写入新文件,所以压缩的时候,我们主要使用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转换成二进制暂存入temparray
f = 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函数外加一个辅助数组,把写入到压缩文件里的容器一个一个读出来,这样得到的也是一个可以被我们看到的二进制的一些数字(但注意,这不是二进制哦)。有兴趣的小伙伴可以自己实现一下。
今天的分享就到这里了,希望老铁们有所收获,我们下次再见。