compressApp

需求

1、能够选择任一文本文件,压缩,保存在本地。保存的压缩文件可自己命名。

2、能够解压压缩文件

3、压缩算法使用哈夫曼编码。

设计步骤

1、先写出核心压缩算法

2、再完成ui设计

核心压缩算法

压缩原理:

  1. 对于一个可读文件,可以读出来成为一个字符串。

统计所有字符的出现频率作为权值,权值大的编码短,权值小的编码长。将每个字符根据映射表替换成对应编码,得到01字符串,再把01字符串每8个为一个字节保存下来(有01字符串转整数的函数Integer.parseInt(s, 2);)。以此实现压缩。

  1. 解压时,读取压缩文件,把每个字节通过转换得到01字符串(有整数转为01字符串的函数Integer.toBinaryString(0);),再根据映射表替换成原来的字符串。
  2. 比如原来一个文件100个字符,一共100*2个字节。因为平均每个字符都是2个字节。
    现在给每个字符重新编码,编码短的字符占多数,他们占的字节数少于2,所以整体上一个文件占的字节数就少了,就实现了压缩。

压缩解压流程:

压缩

  1. 读取文件得到字符串
  2. 统计每个字符的频率,以此构造节点,加入链表,根据所有节点,构造哈夫曼树
  3. 根据哈夫曼树,遍历叶节点,得到每个字符的哈夫曼编码,并储存在 (字符-哈夫曼编码) 映射表里
  4. 把所有字符替换成哈夫曼编码,得到01字符串,再把01字符串每8个为一个字节保存下来
  5. 保存映射表

解压

  1. 读取压缩文件,得到映射表,与字节数组。
  2. 把每个字节都转换成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;		
	}

构建哈夫曼树

  1. 首先设计哈夫曼树的节点类
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流。
同一个文件,用字节流读到中间,关闭,再用对象流继续往下读也是可以的。文件位置指针不变。

  • 读取文件忽略了原来的换行符怎么办?

怎么把换行符也读取进去?

一个是用字节流读取,肯定能读到换行符。最后把字节数组强制类型转换为字符串即可。

另一个是用缓冲流读,每读完一行追加一个换行。

由于换行符的问题,原文件与经过压缩后还原的文件字节大小有一点差别。

  • 仍存在分隔符的问题。
    当压缩的字节数组中出现分隔符,可能导致读取字节数组不完整的问题。
    现在的办法是用两个两相同的分隔符降低出错概率,但不能完全避免
  • 当文件很小时,压缩反而会使文件变大,因为要存映射表