compressApp
需求
1、能够选择任一文本文件,压缩,保存在本地。保存的压缩文件可自己命名。
2、能够解压压缩文件
3、压缩算法使用哈夫曼编码。
设计步骤
1、先写出核心压缩算法
2、再完成ui设计
核心压缩算法
压缩原理:
- 对于一个可读文件,可以读出来成为一个字符串。
统计所有字符的出现频率作为权值,权值大的编码短,权值小的编码长。将每个字符根据映射表替换成对应编码,得到01字符串,再把01字符串每8个为一个字节保存下来(有01字符串转整数的函数Integer.parseInt(s, 2);)。以此实现压缩。
- 解压时,读取压缩文件,把每个字节通过转换得到01字符串(有整数转为01字符串的函数Integer.toBinaryString(0);),再根据映射表替换成原来的字符串。
- 比如原来一个文件100个字符,一共100*2个字节。因为平均每个字符都是2个字节。
现在给每个字符重新编码,编码短的字符占多数,他们占的字节数少于2,所以整体上一个文件占的字节数就少了,就实现了压缩。
压缩解压流程:
压缩
- 读取文件得到字符串
- 统计每个字符的频率,以此构造节点,加入链表,根据所有节点,构造哈夫曼树
- 根据哈夫曼树,遍历叶节点,得到每个字符的哈夫曼编码,并储存在 (字符-哈夫曼编码) 映射表里
- 把所有字符替换成哈夫曼编码,得到01字符串,再把01字符串每8个为一个字节保存下来
- 保存映射表
解压
- 读取压缩文件,得到映射表,与字节数组。
- 把每个字节都转换成01字符串,把01字符串通过映射表转换成原字符串。
下面开始设计
读取可读文件
读取可读文件得到字符串
关键点在于缓冲流readLine()不能读取换行符,因此每读取一行就得追加一个换行符。
此外,可用字节流来读取数据,能够读取换行符。最后使用new String(byte[])把字节数组转化为字符串即可
//1.读取文件
public String readFile(String path)throws Exception { //读取文件必须抛出异常
//获取文件句柄
File file = new File(path);
//文件输入字节流,读取文件时数据
FileInputStream fis = new FileInputStream(file);
//文件字符流 ,一字符两字节,提高效率
FileReader fr = new FileReader(file);
//缓冲字符流
BufferedReader br = new BufferedReader(fr);
String str;
//怎么提高字符串的拼接效率, 使用StringBuilder的Append方法
StringBuilder msg = new StringBuilder("");
//readLine()不能读取换行符,只能自己加上。想要读取换行符就得用字节流
while((str = br.readLine()) != null) { //文件输入读取方法!
msg.append(str);
msg.append("\n");//追加换行符
}
msg.deleteCharAt(msg.length()-1); //把最后的换行符删掉
//记得关
fis.close();
fr.close();
br.close();
return msg.toString();
}
字符的频率
统计每个字符的频率
//利用Hash统计各字符频次
public HashMap<Character,Integer> countToList(String s) {
HashMap<Character,Integer> map = new HashMap<>();
for(int i=0;i<s.length();i++) {
map.put(s.charAt(i), map.getOrDefault(s.charAt(i), 0)+1); //统计各个字符频率
}
return map;
}
构建哈夫曼树
- 首先设计哈夫曼树的节点类
class Node { //节点类
char data; //字符
int weight; //权值,即频率
Node left;
Node right;
public Node(char data, int weight) {
this.data = data;
this.weight = weight;
}
}
2、接着构造哈夫曼树
三个基本属性保存构造哈夫曼树得到的一些数据。
private Node root; //树根
HashMap<Character,String> map = new HashMap<>() ;//记录字符与编码的映射
private String priString; //原始字符串
构建哈夫曼树步骤:
1.对节点对象进行排序
⒉取出该序列中权值最小的两个节点
让这两个节点的权值累加,作为这两个节点的父节点
3.把父节点放入序列中进行排序
重复上面三步
下面的构造代码有些关键点:
一个是遍历HashMap的代码,可以for循环,也可以用迭代器
一个是自定义排序方法Collections.sort,可以给容器里的对象排序。
/**
* 利用字符串构造哈夫曼树
*/
public void createTree(String s) {
priString = s; //保存原始字符串
System.out.println(s); //输出文件内容测试
HashMap<Character,Integer> map =countToList(s); //统计字符频数
ArrayList<Node> list = new ArrayList<>(); //存储Node
//用for循环遍历
for(Entry<Character,Integer> each :map.entrySet()) { //取出所有键对
Node n = new Node(each.getKey(),each.getValue());//生成node
list.add(n);
}
// 只要nodes数组中还有2个以上的节点
while (list.size() > 1) {
//quickSort(list);
Collections.sort(list,new Comparator<Node>() { //降序排序
@Override
public int compare(Node o1,Node o2) {
return o2.weight- o1.weight; //降序
}
});
//获取权值最小的两个节点
Node left = list.get(list.size()-1); //最小值在后,方便删除
Node right = list.get(list.size()-2);
//删除权值最小的两个节点
list.remove(list.size()-1);
list.remove(list.size()-1);
//生成新节点,新节点的权值为两个子节点的权值之和
Node parent = new Node(null, left.weight + right.weight);
//让新节点作为两个权值最小节点的父节点
parent.left = left;
parent.right = right;
//将新节点加入到集合中
list.add(parent);
}
root = list.get(0); //保存根节点
getTreeCode(root,"");//保存字符与编码映射
}
哈夫曼编码
为了得到每个字符的哈夫曼编码,使用前序遍历,只需要判断是叶节点就输出。
这里使用左0右1的编码方式,编码放在映射表里。
最终根据映射表得到01字符串
//前序遍历,判断是否为叶结点,遍历时左0右1,得到每个字符的哈夫曼编码
private void getTreeCode(Node root,String code){
if(root != null) {
if(root.left == null && root.right == null) {//没有孩子为叶节点
map.put((Character)root.data, code); //存储字符与编码的映射
// String m=root.data+"权值:"+root.weight+" 哈夫曼编码:"+code;
// System.out.println(m); //是叶子节点就输出编码
}
getTreeCode(root.left,code+"0");
getTreeCode(root.right,code+"1");
}
}
//输出原始字符串对应哈夫曼编码
public String printStringCode(){
StringBuilder s = new StringBuilder();
String x =priString;
int len = x.length();
for(int i=0;i<len;i++) {
s.append(map.get(x.charAt(i)));
}
return s.toString();
}
写入压缩文件
先把01字符串变成字节数组,最后把字节数组写入文件。
写入时用字节流写入字节数组,再用对象流写入映射表。两种数据间加入一个分隔符便于区分即可
分隔符用两个相同的字节0x00,0x00,防止前面的字节数组出现分隔符而导致分割出错,降低出错概率。
/**
* 二进制字符串转换为byte数组,每8个01串分成一个字符串,最后一个不足的后面补0,外加一个字符串记录补0个数
* 思路:把字符串转化为long,long转为byte
* 当然用Integer也行
* **/
public static byte[] conver2HexToByte(String hex2Str)
{
int len = hex2Str.length();
String [] temp = new String[len/8+2];
//分割成每8个字符一个字符串,外加一个字符串说明补0个数
for(int i=0;i<temp.length-1;i++) {
int begin = i*8;
int end = (begin+8)>len?len:begin+8; //越界判断
temp[i] = hex2Str.substring(begin,end);//左闭右开
}
int addzero = 8-temp[temp.length-2].length(); //最后一个字符串补0个数
StringBuilder astring = new StringBuilder(temp[temp.length-2]);
for(int i=0;i<addzero;i++) {
astring.append('0'); //这个补零在后面补,防止转为字节时在前面补零导致还原时出错
}
temp[temp.length-2] = astring.toString();
temp[temp.length-1]=Integer.toBinaryString(addzero);
//外加一个字符串记录补零个数,这里不用在前面补零,转为字节数组时会在前面自动补零
//下面开始将每个字符串变成一个字节
byte [] b = new byte[temp.length];
for(int i = 0;i<b.length;i++)
{
b[i] = Long.valueOf(temp[i], 2).byteValue();
//System.out.print(Long.valueOf(temp[i], 2)+",");
//Long.valueOf(temp[i], 2)把2进制形式的01字符串转化为long
//Long.byteValue(); 把long转化为byte
}
return b; //返回字节数组
}
//5.把01串每八个转成一个byte 写入文件,再把映射表写入
public void writeFile(String s,String path) throws IOException {
//byte [] b =s.getBytes(); //这样是不行的,把01当成字符串而不是二进制数。
//二进制字符串转化为byte[]
System.out.println(s);//输出01字符串测试
byte[] b = conver2HexToByte(s); //化成字节数组
File file = new File(path);
if(!file.exists()){
file.createNewFile();//如果文件不存在,就创建该文件
}
//字节流
FileOutputStream fos = null;
fos = new FileOutputStream(file);//首次写入获取
//用字节流开始写,可直接写入字节数组
fos.write(b);
byte sep[] = {0x00,0x00};//两个字节作为分隔符
fos.write(sep);
fos.flush(); //清空确保写入
fos.close();//关闭
//接着写入映射表
//字节输出流
fos = new FileOutputStream(file,true);//这里构造方法多了一个参数true,true表示在文件末尾追加写入
//对象流,直接存上映射表
ObjectOutputStream objectOut = new ObjectOutputStream(fos);
objectOut.writeObject(this.map);//直接把映射表写入
objectOut.close();
//写完记得关掉
fos.close();
}
读取压缩文件
readCompressFile读取时也用字节流读取即可得到字节数组,再用对象流读取到映射表
conver2HexSt八字节数组转为01字符串
restoreString把01字符串还原成原字符串
//实现读取压缩文件,恢复映射表,得到原01字符串
public String readCompressFile(String path)throws Exception { //读取文件必须抛出异常
File file = new File(path);
//文件输入字节流,读取文件时数据
FileInputStream fis = new FileInputStream(file);
// //文件输入字符流
// InputStreamReader fw = new InputStreamReader(fis,"gbk"); //这个流可以指定编码格式
byte[] buf = new byte[10000]; //先开辟10000个字节的空间吧储存读取的内容吧。
int len = 0; //记录当前接受字节数
byte by0,by1;
//当连续读到两个0x00,说明是分隔符,说明字节数组读完了
while(true) {
by0=(byte) fis.read();
by1=(byte) fis.read();
if(by0==0x00&&by1==0x00)break;
else {
buf[len++]=by0;
buf[len++]=by1;
}
}
//对象输入流
ObjectInputStream ois = new ObjectInputStream(fis);
this.map = (HashMap<Character, String>) ois.readObject();
//读取对象再强制转换,从而恢复映射表
System.out.println(this.mapString()); //映射表输出测试
ois.close();
//把字节数组恢复成01字符串
fis.close();
String ori = conver2HexStr(buf,len);//字节数组还原成01字符串
System.out.println(ori); //原字符串输出测试
return ori;
}
解压过程
把字节数组转为01字符串,再把01字符串转为原字符串即可。
/**
* byte数组转换为二进制字符串,
* 每个byte就是8位,用toString把位值转为为二进制字符串
* **/
public static String conver2HexStr(byte [] b,int length)
{
StringBuffer result = new StringBuffer(); //储存byte转成01串
for(int i = 0;i<length;i++)
{ //原先发现字节转为字符串时,把前导零给舍去了,导致还原时字符个数变少(一个字节原本8个)
//所以要补上前导零
StringBuilder tem = new StringBuilder();
tem.append(Long.toBinaryString(b[i] & 0xff));
while(tem.length()<8) { //补足前导0
tem.insert(0, '0');
}
result.append(tem);
//b[i] & 0xff将byte转化为long
//Long.toString(long,2) ,将数字转化成2进制形式的字符串
}
String cut = result.substring(result.length()-8);
int len = Integer.parseInt(cut, 2); //取出最后一字节,转化为整数,为切割长度
return result.substring(0,result.length()-8-len).toString();
//切割多余部分再返回
}
//01字符串还原成字符串
public String restoreString(String code) {
StringBuilder result = new StringBuilder();
int index = 0,len = code.length();//当前匹配替换下标,总长度
while(index<len) {
for(Entry<Character,String> each: map.entrySet()) { //开始匹配
if(code.startsWith(each.getValue(), index)) {
index+=each.getValue().length();
result.append(each.getKey());
break; //成功匹配第一个结束
}
}
}
return result.toString();
}
写入解压文件
写入字符串用缓冲流即可
//还原字符串写入文件
public static void writeString2File(String path,String content) throws IOException {
File file = new File(path);
FileOutputStream fi = new FileOutputStream(file); //字节流
OutputStreamWriter wri = new OutputStreamWriter(fi); //字符流
BufferedWriter writer = new BufferedWriter(wri); //缓冲流
writer.write(content);
writer.close();
}
字节数组与字符串相互转化
String s ="shabi";
System.out.println(s.getBytes()); //转化为字节数组
System.out.println(s);
System.out.println(new String(s.getBytes())); //字节数组还原成字符串
存在问题或优化方向
- 读取的路径怎么显示到窗口?
把文件显示组件引用传到监听类里
- 读取的文件出现乱码怎么办?
字节类数据用字节流,字符类数据用字节流或缓冲流,对象类数据用对象流,图片类数据用imageIO流。
同一个文件,用字节流读到中间,关闭,再用对象流继续往下读也是可以的。文件位置指针不变。
- 读取文件忽略了原来的换行符怎么办?
怎么把换行符也读取进去?
一个是用字节流读取,肯定能读到换行符。最后把字节数组强制类型转换为字符串即可。
另一个是用缓冲流读,每读完一行追加一个换行。
由于换行符的问题,原文件与经过压缩后还原的文件字节大小有一点差别。
- 仍存在分隔符的问题。
当压缩的字节数组中出现分隔符,可能导致读取字节数组不完整的问题。
现在的办法是用两个两相同的分隔符降低出错概率,但不能完全避免 - 当文件很小时,压缩反而会使文件变大,因为要存映射表