哈夫曼树和哈夫曼编码的实现和应用实例(压缩和解压)

哈夫曼树的实现和应用实例(压缩文件)

哈夫曼树

基本介绍

  • 给定n个权值作为n个叶子结点,构造一棵二叉树,若该树的带权路径长度(wpl)达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree)
  • 赫夫曼树是带权路径长度最短的树,权值较大的结点离根较近

名词解释

  • 路径和路径长度:在一棵树中,从一个结点往下可以达到的孩子或孙子结点之间的通路,称为路径。通路中分支的数目称为路径长度。若规定根结点的层数为1,则从根结点到第L层结点的路径长度为L-1
  • 结点的权及带权路径长度:若将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。结点的带权路径长度为:从根结点到该结点之间的路径长度与该结点的权的乘积
  • 树的带权路径长度:树的带权路径长度规定为所有叶子结点的带权路径长度之和,记为WPL(weighted path length) ,权值越大的结点离根结点越近的二叉树才是最优二叉树

如何创建

  1. 将一个序列从小到大进行排序, 将每一个数据,每个数据都是一个节点 , 每个节点可以看成是一颗最简单的二叉树
  2. 取出根节点权值最小的两颗二叉树
  3. 组成一颗新的二叉树, 该新的二叉树的根节点的权值是前面两颗二叉树根节点权值的和
  4. 再将这颗新的二叉树,以根节点的权值大小 再次排序与剩余的待处理的根节点排序
  5. 不断重复 1-2-3-4 的步骤,直到数列中,所有的数据都被处理,此时数列中只有一个节点,该节点就是得到一颗赫夫曼树的根节点;

图解

在这里插入图片描述

代码实现

节点类Node的创建

package edu.hebeu.huffman.tree;

/**
 * 实现Comparable接口,保证该类能够通过Collections的sort()方法进行排序
 * @author 13651
 *
 */
public class Node implements Comparable<Node> {
	
	/**
	 * 左子节点
	 */
	private Node left;
	
	/**
	 * 右子节点
	 */
	private Node right;

	/**
	 * 该节点的权重
	 */
	private int weight;
	
	/**
	 * 构造器
	 * @param weight 当前节点的权重
	 */
	public Node(int weight) {
		this.weight = weight;
	}
	
	/**
	 * 实现Comparable接口的方法,决定该节点在Collection集合排序时是升序 还是 降序
	 */
	@Override
	public int compareTo(Node o) {
		// 表示从小到大排序
		return this.weight - o.weight;
		// 表示从大到小排序
//		return -(this.weight - o.weight);
	}
	
	/**
	 * 前序遍历
	 */
	public void preOrder() {
		System.out.println(this);
		if (left != null) {
			left.preOrder();
		}
		if (right != null) {
			right.preOrder();
		}
	}

	public Node getLeft() {
		return left;
	}

	public void setLeft(Node left) {
		this.left = left;
	}

	public Node getRight() {
		return right;
	}

	public void setRight(Node right) {
		this.right = right;
	}

	public int getWeight() {
		return weight;
	}

	public void setWeight(int weight) {
		this.weight = weight;
	}

	@Override
	public String toString() {
		return "Node [weight=" + weight + "]";
	}
	
}

哈夫曼树类HuffmanTree的创建

package edu.hebeu.huffman.tree;

import java.util.ArrayList;
import java.util.Collections;

/**
 * 这个类用来创建哈夫曼树
 * 基本介绍:
 	1. 给定n个权值作为n个叶子结点,构造一棵二叉树,若该树的带权路径长度(wpl)达到最小,称这样的二叉树为最优二
 	叉树,也称为哈夫曼树(Huffman Tree), 还有的书翻译为霍夫曼树
 	2. 赫夫曼树是带权路径长度最短的树,权值较大的结点离根较近
 	
 * 几个重要的概念:
 	1. 路径和路径长度:在一棵树中,从一个结点往下可以达到的孩子或孙子结点之间的通路,称为路径。通路中分支的
 	数目称为路径长度。若规定根结点的层数为1,则从根结点到第L层结点的路径长度为L-1;
 	2. 结点的权及带权路径长度:若将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。结点的带权
 	路径长度为:从根结点到该结点之间的路径长度与该结点的权的乘积;
 	3. 树的带权路径长度:树的带权路径长度规定为所有叶子结点的带权路径长度之和,记为WPL(weighted path length) ,
 	权值越大的结点离根结点越近的二叉树才是最优二叉树;
 	4. WPL最小的就是赫夫曼树
 	
 * 构成哈夫曼树的步骤:
 	1. 从小到大进行排序, 将每一个数据,每个数据都是一个节点 , 每个节点可以看成是一颗最简单的二叉树
 	2. 取出根节点权值最小的两颗二叉树 
 	3. 组成一颗新的二叉树, 该新的二叉树的根节点的权值是前面两颗二叉树根节点权值的和
 	4. 再将这颗新的二叉树,以根节点的权值大小 再次排序与剩余的待处理的根节点排序
 	5. 不断重复  1-2-3-4 的步骤,直到数列中,所有的数据都被处理,此时数列中只有一个节点,该节点就是得到一
 	颗赫夫曼树的根节点

 * @author 13651
 *
 */
public class HuffmanTree {
	
	/**
	 * 哈夫曼树的根节点
	 */
	private Node root;
	
	/**
	 * 
	 * @param weights 进行创建哈夫曼树使用的数组(数组内的元素对应的是节点的权重)
	 */
	public HuffmanTree(int[] weights) {
		if (weights.length <= 0) {
			System.err.println("请保证有节点来创建哈夫曼树!");
			return;
		}
		root = createHufffmanTree(weights); // 获取创建好的哈夫曼树的根节点
	}
	
	/**
	 * 该方法用来创建哈夫曼树
	 * @param weights 进行创建哈夫曼树使用的数组(数组内的元素对应的是节点的权重)
	 * @return 返回创建好后的哈夫曼数的根节点
	 */
	private static Node createHufffmanTree(int[] weights) {
		ArrayList<Node> weightList = new ArrayList<Node>();
		
		// TODO 通过数组的元素创建对应节点并加入到ArrayList集合中
		for (int weight : weights) {
			weightList.add(new Node(weight));
		}
		// TODO 当ArrayList中的元素大于一个(即还没有创建好哈夫曼树)
		while (weightList.size() > 1) {
			// TODO 先将当前的ArrayList集合内的元素排序
			Collections.sort(weightList);
			// TODO 取出最小和次小的元素节点
			Node minNode = weightList.get(0);
			Node subMinNode = weightList.get(1);
			// TODO 通过最小和次小元素节点创建新的节点,并将新的节点加入到ArrayList集合,然后将这两个元素删除
			Node newNode = new Node(minNode.getWeight() + subMinNode.getWeight());
			newNode.setLeft(minNode);
			newNode.setRight(subMinNode);
			weightList.remove(minNode);
			weightList.remove(subMinNode);
			weightList.add(newNode);
			
//			System.out.println(weightList);
		}
		// TODO 程序执行到此,说明ArrayList集合中只有一个元素(即此元素为构建的哈夫曼树的根节点)
		return weightList.get(0);
	}
	
	/**
	 * 前序遍历哈夫曼树
	 * @param root
	 */
	public void preOrder() {
		if (root == null) {
			System.err.println("哈夫曼树为空!");
			return;
		}
		root.preOrder();
	}
	
}

测试类Test的创建

package edu.hebeu.huffman.tree;

/**
 * 测试创建哈夫曼树
 * @author 13651
 *
 */
public class Test {
	
	public static void main(String[] args) {
		int data[] = { 13, 7, 8, 3, 29, 6, 1, 5, 9, 9, 78, 56, 89, 102 };
		HuffmanTree huffmanTree = new HuffmanTree(data);
		System.out.println("-----------------------------");
		huffmanTree.preOrder();
	}
}

测试

在这里插入图片描述

哈夫曼编码

基本介绍

  • 是一种编码方式, 属于一种程序算法;
  • 是哈夫曼树在电讯通信中的经典的应用之一
  • 广泛地用于数据文件压缩。其压缩率通常在20%~90%之间
  • 可变字长编码(VLC)的一种

前缀编码

如一个编码表如下:
a: 1 I:10 m: 0 t: 010 y: 01001 o: 01 n: 011 g: 001 : 111
如果我们此时发送一个I am tyong字符串,其对应的编码就是:1011100100100101011001(10 111 1 0 010 01001 01 011 001)
我们如果在解码时,会发现解析100111、…等编码可能会出现歧义(10可能对应1(a)0(m),也有可能是10(I)、…),此时我们会发现会匹配到重复的编码,如果不加以处理就会导致解码出现了歧义,如果我们能避免上述的问题的编码就是前缀编码,即不能匹配到重复的编码,哈夫曼编码就是前缀编码,因此处理哈夫曼编码解析原数据会更加简单(因为不存在重复匹配问题,即匹配到什么,就是什么)

如何创建哈夫曼编码?
在创建好哈夫曼树后,从根节点开始,每条路径左0右1,会得到每个节点对应的哈夫曼编码
如上述图解的1节点对应的哈夫曼编码就是00105节点就是1006节点就是101,…;

哈夫曼编码的应用实例——字符串的压缩和压缩

我们通过将一个字符串转换为哈夫曼编码的实例来体会哈夫曼编码的应用;

一、如何通过字符串生成哈夫曼编码?
分析:生成哈夫曼编码一定需要对应的哈夫曼树,而哈夫曼树的创建依赖于各个节点的权重,那权重就必须能够体现在节点上,所以我们这里用Byte类型的遍历做为节点的权重,因此我们就可以将字符串转换成字节数组,字节数组的每个元素就当作构建哈夫曼树的初始节点,以这些节点够成哈夫曼树,并且将来这些节点也一定在哈夫曼树的叶子节点上,由此得到哈夫曼树的叶子节点(内存储了字节)对应的哈夫曼编码(即哈夫曼编码表),完成字符串转换成哈夫曼编码

二、如何通过哈夫曼编码转换成其对应的字节数组(压缩)
分析:此时我们遍历字符串对应的字节数组,将每个元素(字节)通过查询哈夫曼编码表得到其对应的哈夫曼编码,然后将依次进行拼接,此时我们就得到了字符串通过哈夫曼编码处理后的01串,然后将这些01串每8位做为一个二进制的数进行转换为10进制的,这里有个小细节(因为位数可能不是8的位数,就导致了再后序的解压时最后一组01串在前后有0时可能出现缺失,因此我们将直接最后一位保存起来,再解压时直接与其最后一组进行替换来保证编码的完整性),此时得到的10进制位我们将其做为一个byte类型的数据(即字节),然后将它们依次保存到一个字节数组中,此数组就是压缩之后的数组(因为此时数组内的字节明显比原先的少了

三、如何将压缩后的字节数组解压为字符串
分析:我们可以通过每个字节获取其对应的二进制数(细节:对应每一个得到的二进制字符,我们应该将其与256进行按位或(因为如果是负数,得到的就是其补码,我们就需要截取后8位;如果是正数,我们可能还需要进行补位操作!),当是获取最后一个字节的二进制编码时,我们直接将上一步保存的的最后一组字符串赋值给它(省去了判断是否补0,补几个0的麻烦操作)),此时就得到了一长串的01,它的每几位就是哈夫曼表中的VALUE,我们可以通过查询哈夫曼编码表,依次得到字节,然后将它们保存到一个字节数组中,该数组就是字符串对应的字节数组,然后通过new String(byte[] bytes)方法就可以得到原先的字符串,此时就完成了解压

代码实现:
我们创建一个节点类Node

package edu.hebeu.huffman.code;

public class Node implements Comparable<Node>{
	
	/**
	 * 存放每个字符对应的ASCII码
	 */
	private Byte data;
	
	/**
	 * 左子节点
	 */
	private Node left;
	
	/**
	 * 右子节点
	 */
	private Node right;
	
	/**
	 * 该节点的权重(这里对应的就是data在压缩的字符串中出现的次数)
	 */
	private int weight;
	
	public Node(Byte data, int weight) {
		this.data = data;
		this.weight = weight;
	}
	
	/**
	 * 前序遍历节点
	 */
	public void preOrder() {
		System.out.println(this);
		if (left != null) {
			left.preOrder();
		}
		if (right != null) {
			right.preOrder();
		}
	}

	public Byte getData() {
		return data;
	}

	public void setData(Byte data) {
		this.data = data;
	}

	public Node getLeft() {
		return left;
	}

	public void setLeft(Node left) {
		this.left = left;
	}

	public Node getRight() {
		return right;
	}

	public void setRight(Node right) {
		this.right = right;
	}

	public int getWeight() {
		return weight;
	}

	public void setWeight(int weight) {
		this.weight = weight;
	}

	@Override
	public String toString() {
		return "Node [data=" + data + ", weight=" + weight + "]";
	}

	@Override
	public int compareTo(Node o) {
		// TODO 表示Node对象升序排列
		return weight - o.weight;
	}
	
}

创建哈夫曼树类HuffmanTreeCompress完成字符串的压缩和解压

package edu.hebeu.huffman.code;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 哈夫曼编码:
 * 	是一种编码方式, 属于一种程序算法
 * 	是哈夫曼树在电讯通信中的经典的应用之一
 * 	广泛地用于数据文件压缩。其压缩率通常在20%~90%之间
 * 	是可变字长编码(VLC)的一种
 * 
 * 该类是哈夫曼编码的应用:完成数据的压缩和解压
 * @author 13651
 *
 */
public class HuffmanTreeCompress {
	
	/**
	 * 哈夫曼树的根节点
	 */
	private static Node ROOT;
	
	/**
	 * 哈夫曼树的编码表
	 */
	private static Map<Byte, String> HUFFMAN_CODES_TABLE;
	
	/**
	 * 用来记录 压缩后的最后一个字节对应的 二进制编码
	 */
	private static String LAST_CODE;
	
	/**
	 * 私有化构造器
	 */
	private HuffmanTreeCompress() {}
	
	/**
	 * 外部调用的压缩方法
	 * @param content待压缩的字符串
	 * @return 压缩字符串完成的字节数组
	 */
	public static byte[] zip(String content) {
		long start = System.currentTimeMillis();
		if ("".equals(content)) {
			System.err.println("请保证有内容!");
			return null;
		}
		byte[] contentBytes = content.getBytes(); // 获取待压缩字符串的字节数组
		// 获取压缩的字符串的字节数组中的每个元素 生成Node节点的List集合
		List<Node> nodes = getNodeList(contentBytes);
		// 创建通过上面的Node节点集合生成哈夫曼树
		ROOT =  createHuffmanTree(nodes);
		// 生成该哈夫曼树的哈夫曼编码表
		generateHuffmanCodeTable();
		// 进行压缩
		byte[] zipContentBytes = huffmanZip(contentBytes);
		
		System.out.println("压缩前:" + contentBytes.length + "字节,压缩后:" +
		zipContentBytes.length + "字节,压缩时间:" + (System.currentTimeMillis() - start) + "ms,压缩率:" + 
		((((float) contentBytes.length - zipContentBytes.length) / contentBytes.length) * 100) + "%");
		return zipContentBytes;
	}
	
	/**
	 * 实际的压缩方法
	 * @param contentBytes 待压缩字符串的字节数组
	 * @return 将字符串对应对应的字节数组 通过哈夫曼编码 压缩为对应的字节数组
	 */
	private static byte[] huffmanZip(byte[] contentBytes) {
		// TODO 利用哈夫曼编码表生成待压缩字符串的字节数组 对应的哈夫曼字符串(100110010.... 、0100110011001111...)
		StringBuilder contentHuffman = new StringBuilder();
		for (byte contentByte : contentBytes) {
			contentHuffman.append(HUFFMAN_CODES_TABLE.get(contentByte));
		}
		System.out.println("zip:" + contentHuffman + "; length = " + contentHuffman.length());
		// TODO 将上面获取到的哈夫曼字符串(01串)每8位对应一个byte进行转换
		int zipByteCount = 0; // 保存上面获取到的哈夫曼字符串 能转换成多少个 byte
		if (contentHuffman.length() % 8 == 0) { // 如果正好8的倍数
			zipByteCount = contentHuffman.length() / 8; // 让压缩后的byte字节个数为 除8
		} else { // 否则,即不是8的倍数
			zipByteCount = contentHuffman.length() / 8 + 1; // 让压缩后的byte字节个数为 除8再加1
		}
		byte[] zipContentBytes = new byte[zipByteCount]; //创建zipByteCount长度的字节数组来存放压缩后的字节
		int index = 0; // 记录当前是zipContentBytes字节数组的第几个元素
		for (int i = 0; i < contentHuffman.length(); i = i + 8) { // 因为每8位哈夫曼字符串(01串) 对应一个byte,所以步长为8
			String subContentHuffman;
			if (contentHuffman.length() < i + 8) { // 如果此时的要截取的哈夫曼字符串长度不够8位
				subContentHuffman = contentHuffman.substring(i); // 截取当前位后面的全部字符
			} else { // 否则,即够8位
				subContentHuffman = contentHuffman.substring(i, i + 8); // 每次截取当前位后面的8个字符
			}
//			System.out.print("$" + (byte) Integer.parseInt(subContentHuffman, 2) + ", --" + subContentHuffman + "\n");
			zipContentBytes[index] = (byte) Integer.parseInt(subContentHuffman, 2); // 将截取到的字符串(01串)通过2进制 转换为Integer数字(10进制),然后将该数字做为byte加入到压缩的字节数组中
			
			if (contentHuffman.length() < i + 8) { // 如果此时为最后一次循环(即最后一个字节对应的哈夫曼编码)
//				System.err.println("--------------------------");
				LAST_CODE= subContentHuffman; // 保存压缩后的最后一个字节对应的哈夫曼编码(最后一个字节的编码可能会出现0位不足,这里我们直接保存,待解压缩时与解压缩的最后一个字节的哈夫曼编码进行替换)
			}
			index++;
		} // System.out.println();
		// 将压缩后的字节数组返回
		return zipContentBytes;
	}
	
	/**
	 * 外部调用的解压方法
	 * @param zipBytes 需要解压的字节数组
	 * @return 解压后的字符串
	 */
	public static String unzip(byte[] zipBytes) {
		long start = System.currentTimeMillis();
		if (zipBytes == null || zipBytes.length <= 0) {
			System.err.println("没有解压的数据!");
			return null;
		}
		String content = new String(huffmanUnzip(zipBytes));
		System.out.println("解压时间:" + (System.currentTimeMillis() - start) + "ms");
		return content; // 进行解压并返回
	}
	
	/**
	 * 实际的解压方法
	 * @param zipBytes 待解压的字节数组
	 * @return 解压后的字节数组
	 */
	private static byte[] huffmanUnzip(byte[] zipBytes) {
		StringBuilder contentHuffman = new StringBuilder(); // 用来存放经过压缩的字节数组 生成的哈夫曼字符串(100110010.... 、0100110011001111...)
		for (int i = 0; i < zipBytes.length; i++) {
			// 表示获取字节数组中当前元素对应的二进制字符串(除去字节数组的最后一个元素,其他的元素都需要进行补位操作);然后将该串拼接到contentHuffman
			contentHuffman.append(byteToBitStr(!(i == zipBytes.length - 1), zipBytes[i]));
		}
		System.out.println("unzip:" + contentHuffman + "; length:" + contentHuffman.length());
		// 存放反转后的哈夫曼编码表(方便下面 使用哈夫曼字符串 通过哈夫曼编码表 转换成对应的(原始的,压缩之前的)字节数组)
		Map<String, Byte> reverseHuffmanCodeTable = new HashMap<>();
		for (Map.Entry<Byte, String> entry : HUFFMAN_CODES_TABLE.entrySet()) {
			reverseHuffmanCodeTable.put(entry.getValue(), entry.getKey());
		}
		// 存放转换之后解压的字节数组转换之后(解压)的字节
		List<Byte> list = new ArrayList<>();
		for (int codeStart = 0; codeStart < contentHuffman.length();) {
			int codeEnd = 1; // 标识当前到扫描的二进制串的位置
			boolean isByte = false; // 标识当前扫描到的二进制串(i到j)
			Byte b = null; // 存放当前解析到的字节
			while(!isByte) {
				// 取出codeStart位置到codeEnd位置的二进制串的一个子串
//				System.out.println("codeStart = " + codeStart + "; codeEnd = " + codeEnd);
				
				String byteKey = null;
				
				byteKey = contentHuffman.substring(codeStart, codeStart + codeEnd);
				b = reverseHuffmanCodeTable.get(byteKey); // 通过截取到的子串获取其对应的Byte
				if (b != null) { // 如果不为空,即找到了该字串对应字节
					isByte = true;
				} else {
					codeEnd++;
				}
			}
			// 程序执行到此,说明已经找到了一个字节
			list.add(b); // 将该字节加入到list
			codeStart += codeEnd; // 直接让起始的扫描位置移动到结束的扫描位置
		}
		
		// 程序执行到此,list集合中就存放了解压之后的所有字节了,此时我们应该将它们取出来放到字节数组中
		byte[] contentBytes = new byte[list.size()];
		for (int i = 0; i < contentBytes.length; i++) {
			contentBytes[i] = list.get(i);
		}
		return contentBytes;
	}
	
	/**
	 * 该方法用来将一个byte转换成 位字符串(01串),
	 * 需要注意:
	 * 	如果byte是负数,在转换成int后,那么转换成的 位字符串就是其对应的补码;
	 * 	如果byte是正数,在转换成int后,那么转换成的 位字符串就是其对应的原码,此时我们还有可能需要进行补位;
	 * @param isRepair 是否需要补高位
	 * @param b 进行转换的byte值
	 * @return 转换后的二进制字符串
	 */
	private static String byteToBitStr(boolean isRepair, byte b) {
		int tmp = b; // 将byte转换成int
		String binaryStr; // 存放转换之后的二进制字符串
		if (isRepair) { // 如果需要进行补位 或者 为正数 时就进行补位
			tmp |= 256; // 进行 按位或操作,如 2对应的二进制就是 00000010,按位或256(256的二进制为100000000),即 00000010 | 10000000 = 100000010
			binaryStr = Integer.toBinaryString(tmp); // 转换为二进制字符串
//			System.out.println("binaryStr = " + binaryStr.substring(binaryStr.length() - 8));
			return binaryStr.substring(binaryStr.length() - 8); // 从第8位截取全部二进制字符串并返回
		}
		// 程序执行到此,说明不需要补位(即是最后一位字节的哈夫曼编码)
		binaryStr = Integer.toBinaryString(tmp); // 转换二进制字符串
//		System.out.println("1------" + binaryStr);
		binaryStr = LAST_CODE; // 将压缩过程中保存的最后一位字节对应的哈夫曼编码直接 赋值给该变量(不用进行判断是否添0)
//		System.out.println("2------" + binaryStr);
		return binaryStr; // 将整个二进制字符串返回
	}
	
	/**
	 * 通过传入的字节数组将其转换成对应Node节点集合
	 * @param bs 字节数组
	 * @return 将字节数组每个元素转换成Node的List集合
	 */
	private static List<Node> getNodeList(byte[] bs) {
		
		// 存储压缩信息对应的byte数组中每个元素对应的节点
		List<Node> nodes = new ArrayList<Node>();;
		
		// 这个Map用来存储统计每个byte字节出现的次数
		Map<Byte, Integer> tmpMap = new HashMap<Byte, Integer>();
		for (byte b : bs) {
			if (tmpMap.get(b) == null) { // 如果该字节没有在tmpMap,即第一次出现该字节
				tmpMap.put(b, 1); // 将这个Byte字节加入到Map
			} else { // 否则,即该字节在tmpMap中已经存在(即已经至少出现了一次)
				Integer count = tmpMap.get(b); // 获取该字节在Map中的Value(即出现的次数)
				tmpMap.put(b, count + 1); // 将该字节的值+1
			}
		}
		
		// 将上面的Map中的信息转换成Node并加入到nodes中
		for (Map.Entry<Byte, Integer> node : tmpMap.entrySet()) {
			nodes.add(new Node(node.getKey(), node.getValue())); // 将该KEY, VALUE对转换成Node并添加到nodes中
		}
		return nodes;
	}
	
	/**
	 * 创建哈夫曼树的方法
	 * @return
	 */
	private static Node createHuffmanTree(List<Node> nodes) {
		// 当nodes中的元素大于1,即哈夫曼树还没有构建完毕
		while (nodes.size() > 1) {
			// TODO 将nodes内的元素排序(有Node类实现Compareable接口,可以看出是从小到大排序的)
			Collections.sort(nodes);
			// TODO 取出最小和次小的元素节点
			Node minNode = nodes.get(0);
			Node subMinNode = nodes.get(1);
			// TODO 通过最小和次小元素节点创建新的节点,并将新的节点加入到ArrayList集合,然后将这两个元素删除
			Node newNode = new Node(null, minNode.getWeight() + subMinNode.getWeight());
			newNode.setLeft(minNode);
			newNode.setRight(subMinNode);
			nodes.remove(minNode);
			nodes.remove(subMinNode);
			nodes.add(newNode);
		}
		
		return nodes.get(0); // 将nodes中的0索引位置的元素(此时一定有且仅有这一个元素了)返回
	}
	
	/**
	 * 生成哈夫曼编码表
	 */
	private static void generateHuffmanCodeTable() {
		if (ROOT == null) {
			System.err.println("root节点为空,生成哈夫曼编码表失败!");
			return;
		}
		// 实例化存储哈夫曼编码的编码表
		HUFFMAN_CODES_TABLE = new HashMap<Byte, String>();
		// 处理root的左子树
		generateHuffmanCodeTable(ROOT.getLeft(), "0", new StringBuilder());
//		// 处理root的右子树
		generateHuffmanCodeTable(ROOT.getRight(), "1", new StringBuilder());
		// {32=100, 97=101, 33=1100, 101=1101, 118=1110, 105=000, 73=1111, 74=001, 107=010, 108=011}
		// 或者直接写成如下方式
//		generateHuffmanCodeTable(root, "", new StringBuilder());
		// {32=100, 97=101, 33=1100, 101=1101, 118=1110, 105=000, 73=1111, 74=001, 107=010, 108=011}
	}
	
	/**
	 * 生成哈夫曼表的方法,通过分析,传入的字符串对应的所有节点一定是哈夫曼树的叶子节点
	 * @param node 当前节点
	 * @param path 路径(左0右1)
	 * @param codeItem 用来拼接当前节点的上一条路径path,来生成相应叶子节点的哈夫曼编码
	 */
	private static void generateHuffmanCodeTable(Node node, String path, StringBuilder codeItem) {
		StringBuilder code = new StringBuilder(codeItem); // 接收传来的codeItem
		code.append(path); // 将path路径拼接到code上
		if (node != null) {
			if (node.getData() == null) { // 如果node的data为空,即该节点为非叶子节点(因为在创建哈夫曼树时,我们只有通过Byte字节生成的Node才有data,其他的节点data都为null)
				// 向左递归
				generateHuffmanCodeTable(node.getLeft(), "0", code);
				// 向右递归
				generateHuffmanCodeTable(node.getRight(), "1", code);
			} else { // 否则即node的data不为空,该节点为叶子节点
				HUFFMAN_CODES_TABLE.put(node.getData(), code.toString()); // 将该节点的data(即Byte)和其对应的编码code存入哈夫曼编码表
			}
		}
	}

	public static Node getRoot() {
		return ROOT;
	}

	public static Map<Byte, String> getCodeTable() {
		return HUFFMAN_CODES_TABLE;
	}
	
}

测试类的编写

public class Test {
	public static void main(String[] args) {
		String content = "你好,哈夫曼编码";
		byte[] zipContentBytes = HuffmanTreeCompress.zip(content);
		
		System.out.println("原始字节数组:" + Arrays.toString(content.getBytes()));
		System.out.println("压缩后的字节数组:" + Arrays.toString(zipContentBytes));
		
		System.out.println("解压之后:" + HuffmanTreeCompress.unzip(zipContentBytes));
	}
}

测试

在这里插入图片描述

文件压缩和解压的实例

通过上面的实例,我们已经看到里哈夫曼编码的强大之处,那么如何进行文件的压缩呢?无非就是:
压缩时:将待压缩的文件通过字节输入流读入,将压缩后的内容和其对应的哈夫曼表以及最后一组01串通过对象输出流输出(序列化)
解压时将待解压的文件通过对象输入流读入(反序列化),得到待解压文件的字节数组、哈夫曼编码表、最后一组01串得到,然后将其解压后的内容通过字节输出流输出

我们在上面的类上进行改写,Node节点类不变,只改变HuffmanTreeCompress类实现文件压缩和解压的实例,如下:

package edu.hebeu.huffman.code;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 哈夫曼编码:
 * 	是一种编码方式, 属于一种程序算法
 * 	是哈夫曼树在电讯通信中的经典的应用之一
 * 	广泛地用于数据文件压缩。其压缩率通常在20%~90%之间
 * 	是可变字长编码(VLC)的一种
 * 
 * 该类是哈夫曼编码的应用:完成数据的压缩和解压
 * @author 13651
 *
 */
public class HuffmanTreeCompress {
	
	/**
	 * 哈夫曼树的根节点
	 */
	private static Node ROOT;
	
	/**
	 * 哈夫曼树的编码表
	 */
	private static Map<Byte, String> HUFFMAN_CODES_TABLE;
	
	/**
	 * 用来记录 压缩后的最后一个字节对应的 二进制编码
	 */
	private static String LAST_CODE;
	
	/**
	 * 私有化构造器
	 */
	private HuffmanTreeCompress() {}
	
	/**
	 * 外部调用的压缩方法
	 * @param content待压缩的字符串
	 * @return 压缩字符串完成的字节数组
	 */
	public static byte[] zip(String content) {
		long start = System.currentTimeMillis();
		if ("".equals(content)) {
			System.err.println("请保证有内容!");
			return null;
		}
		byte[] contentBytes = content.getBytes(); // 获取待压缩字符串的字节数组
		// 获取压缩的字符串的字节数组中的每个元素 生成Node节点的List集合
		List<Node> nodes = getNodeList(contentBytes);
		// 创建通过上面的Node节点集合生成哈夫曼树
		ROOT =  createHuffmanTree(nodes);
		// 生成该哈夫曼树的哈夫曼编码表
		generateHuffmanCodeTable();
		// 进行压缩
		byte[] zipContentBytes = huffmanZip(contentBytes);
		
		System.out.println("压缩前:" + contentBytes.length + "字节,压缩后:" +
		zipContentBytes.length + "字节,压缩时间:" + (System.currentTimeMillis() - start) + "ms,压缩率:" + 
		((((float) contentBytes.length - zipContentBytes.length) / contentBytes.length) * 100) + "%");
		return zipContentBytes;
	}
	
	/**
	 * 压缩文件的方法
	 * @param sourcePath 压缩的文件路径
	 * @param targetFile 压缩后的文件存储路径
	 */
	public static void zipFile(String sourcePath, String targetFile) {
		long start = System.currentTimeMillis();
		
		FileInputStream fis = null;
		FileOutputStream fos = null;
		ObjectOutputStream oos = null;
		
		try {
			fis = new FileInputStream(sourcePath);
			byte[] sourceFileBytes = new byte[fis.available()];
			fis.read(sourceFileBytes); // 将源文件读取到sourceFileBytes字节数组中
//			System.out.println("--" + Arrays.toString(sourceFileBytes));
			
			// 获取压缩的文件对应的字节数组中的每个元素 生成Node节点的List集合
			List<Node> nodes = getNodeList(sourceFileBytes);
			// 创建通过上面的Node节点集合生成哈夫曼树
			ROOT =  createHuffmanTree(nodes);
			// 生成该哈夫曼树的哈夫曼编码表
			generateHuffmanCodeTable();
			
			// 进行压缩
			byte[] zipFileBytes = huffmanZip(sourceFileBytes);
			fos = new FileOutputStream(targetFile); // 文件输出流对象,将文件输出到targetFile
			// 将压缩的文件进行序列化
			oos = new ObjectOutputStream(fos);
			oos.writeObject(zipFileBytes); // 将压缩后的文件字节数组序列化
			oos.writeObject(HUFFMAN_CODES_TABLE); // 将该文件压缩和解压需要的哈夫曼表序列化
			oos.writeObject(LAST_CODE); // 将压缩的字节数组 的最后一个原始对应的二进制编码值序列化
			
			System.out.println("压缩前:" + sourceFileBytes.length + "字节,压缩后:" +
					zipFileBytes.length + "字节,压缩时间:" + (System.currentTimeMillis() - start) + "ms,压缩率:" + 
					((((float) sourceFileBytes.length - zipFileBytes.length) / sourceFileBytes.length) * 100) + "%");
		} catch (FileNotFoundException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} catch (IOException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} finally {
			if (fis != null) {
				try {
					fis.close();
				} catch (IOException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
			}
			if (fos != null) {
				try {
					fos.close();
				} catch (IOException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
			}
			if (oos != null) {
				try {
					oos.close();
				} catch (IOException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
			}
		}
	}
	
	/**
	 * 实际的压缩方法
	 * @param contentBytes 待压缩字符串的字节数组
	 * @return 将字符串对应对应的字节数组 通过哈夫曼编码 压缩为对应的字节数组
	 */
	private static byte[] huffmanZip(byte[] contentBytes) {
		// TODO 利用哈夫曼编码表生成待压缩字符串的字节数组 对应的哈夫曼字符串(100110010.... 、0100110011001111...)
		StringBuilder contentHuffman = new StringBuilder();
		for (byte contentByte : contentBytes) {
			contentHuffman.append(HUFFMAN_CODES_TABLE.get(contentByte));
		}
//		System.out.println("zip:" + contentHuffman + "; length = " + contentHuffman.length());
		// TODO 将上面获取到的哈夫曼字符串(01串)每8位对应一个byte进行转换
		int zipByteCount = 0; // 保存上面获取到的哈夫曼字符串 能转换成多少个 byte
		if (contentHuffman.length() % 8 == 0) { // 如果正好8的倍数
			zipByteCount = contentHuffman.length() / 8; // 让压缩后的byte字节个数为 除8
		} else { // 否则,即不是8的倍数
			zipByteCount = contentHuffman.length() / 8 + 1; // 让压缩后的byte字节个数为 除8再加1
		}
		byte[] zipContentBytes = new byte[zipByteCount]; //创建zipByteCount长度的字节数组来存放压缩后的字节
		int index = 0; // 记录当前是zipContentBytes字节数组的第几个元素
		for (int i = 0; i < contentHuffman.length(); i = i + 8) { // 因为每8位哈夫曼字符串(01串) 对应一个byte,所以步长为8
			String subContentHuffman;
			if (contentHuffman.length() < i + 8) { // 如果此时的要截取的哈夫曼字符串长度不够8位
				subContentHuffman = contentHuffman.substring(i); // 截取当前位后面的全部字符
			} else { // 否则,即够8位
				subContentHuffman = contentHuffman.substring(i, i + 8); // 每次截取当前位后面的8个字符
			}
//			System.out.print("$" + (byte) Integer.parseInt(subContentHuffman, 2) + ", --" + subContentHuffman + "\n");
			zipContentBytes[index] = (byte) Integer.parseInt(subContentHuffman, 2); // 将截取到的字符串(01串)通过2进制 转换为Integer数字(10进制),然后将该数字做为byte加入到压缩的字节数组中
			
			if (contentHuffman.length() < i + 8) { // 如果此时为最后一次循环(即最后一个字节对应的哈夫曼编码)
//				System.err.println("--------------------------");
				LAST_CODE= subContentHuffman; // 保存压缩后的最后一个字节对应的哈夫曼编码(最后一个字节的编码可能会出现0位不足,这里我们直接保存,待解压缩时与解压缩的最后一个字节的哈夫曼编码进行替换)
			}
			index++;
		} // System.out.println();
		// 将压缩后的字节数组返回
		return zipContentBytes;
	}
	
	/**
	 * 外部调用的解压方法
	 * @param zipBytes 需要解压的字节数组
	 * @return 解压后的字符串
	 */
	public static String unzip(byte[] zipBytes) {
		long start = System.currentTimeMillis();
		if (zipBytes == null || zipBytes.length <= 0) {
			System.err.println("没有解压的数据!");
			return null;
		}
		String content = new String(huffmanUnzip(zipBytes));
		System.out.println("解压时间:" + (System.currentTimeMillis() - start) + "ms");
		return content; // 进行解压并返回
	}
	
	/**
	 * 进行解压文件的方法
	 * @param sourceFile 源文件路径(待压缩文件路径)
	 * @param targetFile 目标文件路径(压缩到哪个路径)
	 */
	public static void UnzipFile(String sourceFile, String targetFile) {
		long start = System.currentTimeMillis();
		
		FileInputStream fis = null;
		FileOutputStream fos = null;
		ObjectInputStream ois = null;
		
		try {
			fis = new FileInputStream(sourceFile);
			ois = new ObjectInputStream(fis);
			
			byte[] zipBytes = (byte[]) ois.readObject(); // 反序列化得到压缩的字节数组
			HUFFMAN_CODES_TABLE = (Map<Byte, String>) ois.readObject(); // 反序列化得到哈夫曼编码表
			LAST_CODE = (String) ois.readObject(); // 反序列化得到压缩的字节数组中最后一个元素对应的二进制编码串
			
			// 进行解压
			byte[] contentBytes = huffmanUnzip(zipBytes);
			
			fos = new FileOutputStream(targetFile);
			fos.write(contentBytes); // 将解压得到的字节流写到指定位置
			System.out.println("解压时间:" + (System.currentTimeMillis() - start) + "ms");
		} catch (FileNotFoundException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} catch (IOException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} catch (ClassNotFoundException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} finally {
			if (fis != null) {
				try {
					fis.close();
				} catch (IOException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
			}
			if (fos != null) {
				try {
					fos.close();
				} catch (IOException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
			}
			if (ois != null) {
				try {
					ois.close();
				} catch (IOException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
			}
		}
	}
	
	/**
	 * 实际的解压方法
	 * @param zipBytes 待解压的字节数组
	 * @return 解压后的字节数组
	 */
	private static byte[] huffmanUnzip(byte[] zipBytes) {
		StringBuilder contentHuffman = new StringBuilder(); // 用来存放经过压缩的字节数组 生成的哈夫曼字符串(100110010.... 、0100110011001111...)
		for (int i = 0; i < zipBytes.length; i++) {
			// 表示获取字节数组中当前元素对应的二进制字符串(除去字节数组的最后一个元素,其他的元素都需要进行补位操作);然后将该串拼接到contentHuffman
			contentHuffman.append(byteToBitStr(!(i == zipBytes.length - 1), zipBytes[i]));
		}
//		System.out.println("unzip:" + contentHuffman + "; length:" + contentHuffman.length());
		// 存放反转后的哈夫曼编码表(方便下面 使用哈夫曼字符串 通过哈夫曼编码表 转换成对应的(原始的,压缩之前的)字节数组)
		Map<String, Byte> reverseHuffmanCodeTable = new HashMap<>();
		for (Map.Entry<Byte, String> entry : HUFFMAN_CODES_TABLE.entrySet()) {
			reverseHuffmanCodeTable.put(entry.getValue(), entry.getKey());
		}
		// 存放转换之后解压的字节数组转换之后(解压)的字节
		List<Byte> list = new ArrayList<>();
		for (int codeStart = 0; codeStart < contentHuffman.length();) {
			int codeEnd = 1; // 标识当前到扫描的二进制串的位置
			boolean isByte = false; // 标识当前扫描到的二进制串(i到j)
			Byte b = null; // 存放当前解析到的字节
			while(!isByte) {
				// 取出codeStart位置到codeEnd位置的二进制串的一个子串
//				System.out.println("codeStart = " + codeStart + "; codeEnd = " + codeEnd);
				
				String byteKey = null;
				
				byteKey = contentHuffman.substring(codeStart, codeStart + codeEnd);
				b = reverseHuffmanCodeTable.get(byteKey); // 通过截取到的子串获取其对应的Byte
				if (b != null) { // 如果不为空,即找到了该字串对应字节
					isByte = true;
				} else {
					codeEnd++;
				}
			}
			// 程序执行到此,说明已经找到了一个字节
			list.add(b); // 将该字节加入到list
			codeStart += codeEnd; // 直接让起始的扫描位置移动到结束的扫描位置
		}
		
		// 程序执行到此,list集合中就存放了解压之后的所有字节了,此时我们应该将它们取出来放到字节数组中
		byte[] contentBytes = new byte[list.size()];
		for (int i = 0; i < contentBytes.length; i++) {
			contentBytes[i] = list.get(i);
		}
		return contentBytes;
	}
	
	/**
	 * 该方法用来将一个byte转换成 位字符串(01串),
	 * 需要注意:
	 * 	如果byte是负数,在转换成int后,那么转换成的 位字符串就是其对应的补码;
	 * 	如果byte是正数,在转换成int后,那么转换成的 位字符串就是其对应的原码,此时我们还有可能需要进行补位;
	 * @param isRepair 是否需要补高位
	 * @param b 进行转换的byte值
	 * @return 转换后的二进制字符串
	 */
	private static String byteToBitStr(boolean isRepair, byte b) {
		int tmp = b; // 将byte转换成int
		String binaryStr; // 存放转换之后的二进制字符串
		if (isRepair) { // 如果需要进行补位 或者 为正数 时就进行补位
			tmp |= 256; // 进行 按位或操作,如 2对应的二进制就是 00000010,按位或256(256的二进制为100000000),即 00000010 | 10000000 = 100000010
			binaryStr = Integer.toBinaryString(tmp); // 转换为二进制字符串
//			System.out.println("binaryStr = " + binaryStr.substring(binaryStr.length() - 8));
			return binaryStr.substring(binaryStr.length() - 8); // 从第8位截取全部二进制字符串并返回
		}
		// 程序执行到此,说明不需要补位(即是最后一位字节的哈夫曼编码)
		binaryStr = Integer.toBinaryString(tmp); // 转换二进制字符串
//		System.out.println("1------" + binaryStr);
		binaryStr = LAST_CODE; // 将压缩过程中保存的最后一位字节对应的哈夫曼编码直接 赋值给该变量(不用进行判断是否添0)
//		System.out.println("2------" + binaryStr);
		return binaryStr; // 将整个二进制字符串返回
	}
	
	/**
	 * 通过传入的字节数组将其转换成对应Node节点集合
	 * @param bs 字节数组
	 * @return 将字节数组每个元素转换成Node的List集合
	 */
	private static List<Node> getNodeList(byte[] bs) {
		
		// 存储压缩信息对应的byte数组中每个元素对应的节点
		List<Node> nodes = new ArrayList<Node>();;
		
		// 这个Map用来存储统计每个byte字节出现的次数
		Map<Byte, Integer> tmpMap = new HashMap<Byte, Integer>();
		for (byte b : bs) {
			if (tmpMap.get(b) == null) { // 如果该字节没有在tmpMap,即第一次出现该字节
				tmpMap.put(b, 1); // 将这个Byte字节加入到Map
			} else { // 否则,即该字节在tmpMap中已经存在(即已经至少出现了一次)
				Integer count = tmpMap.get(b); // 获取该字节在Map中的Value(即出现的次数)
				tmpMap.put(b, count + 1); // 将该字节的值+1
			}
		}
		
		// 将上面的Map中的信息转换成Node并加入到nodes中
		for (Map.Entry<Byte, Integer> node : tmpMap.entrySet()) {
			nodes.add(new Node(node.getKey(), node.getValue())); // 将该KEY, VALUE对转换成Node并添加到nodes中
		}
		return nodes;
	}
	
	/**
	 * 创建哈夫曼树的方法
	 * @return
	 */
	private static Node createHuffmanTree(List<Node> nodes) {
		// 当nodes中的元素大于1,即哈夫曼树还没有构建完毕
		while (nodes.size() > 1) {
			// TODO 将nodes内的元素排序(有Node类实现Compareable接口,可以看出是从小到大排序的)
			Collections.sort(nodes);
			// TODO 取出最小和次小的元素节点
			Node minNode = nodes.get(0);
			Node subMinNode = nodes.get(1);
			// TODO 通过最小和次小元素节点创建新的节点,并将新的节点加入到ArrayList集合,然后将这两个元素删除
			Node newNode = new Node(null, minNode.getWeight() + subMinNode.getWeight());
			newNode.setLeft(minNode);
			newNode.setRight(subMinNode);
			nodes.remove(minNode);
			nodes.remove(subMinNode);
			nodes.add(newNode);
		}
		
		return nodes.get(0); // 将nodes中的0索引位置的元素(此时一定有且仅有这一个元素了)返回
	}
	
	/**
	 * 生成哈夫曼编码表
	 */
	private static void generateHuffmanCodeTable() {
		if (ROOT == null) {
			System.err.println("root节点为空,生成哈夫曼编码表失败!");
			return;
		}
		// 实例化存储哈夫曼编码的编码表
		HUFFMAN_CODES_TABLE = new HashMap<Byte, String>();
		// 处理root的左子树
		generateHuffmanCodeTable(ROOT.getLeft(), "0", new StringBuilder());
//		// 处理root的右子树
		generateHuffmanCodeTable(ROOT.getRight(), "1", new StringBuilder());
		// {32=100, 97=101, 33=1100, 101=1101, 118=1110, 105=000, 73=1111, 74=001, 107=010, 108=011}
		// 或者直接写成如下方式
//		generateHuffmanCodeTable(root, "", new StringBuilder());
		// {32=100, 97=101, 33=1100, 101=1101, 118=1110, 105=000, 73=1111, 74=001, 107=010, 108=011}
	}
	
	/**
	 * 生成哈夫曼表的方法,通过分析,传入的字符串对应的所有节点一定是哈夫曼树的叶子节点
	 * @param node 当前节点
	 * @param path 路径(左0右1)
	 * @param codeItem 用来拼接当前节点的上一条路径path,来生成相应叶子节点的哈夫曼编码
	 */
	private static void generateHuffmanCodeTable(Node node, String path, StringBuilder codeItem) {
		StringBuilder code = new StringBuilder(codeItem); // 接收传来的codeItem
		code.append(path); // 将path路径拼接到code上
		if (node != null) {
			if (node.getData() == null) { // 如果node的data为空,即该节点为非叶子节点(因为在创建哈夫曼树时,我们只有通过Byte字节生成的Node才有data,其他的节点data都为null)
				// 向左递归
				generateHuffmanCodeTable(node.getLeft(), "0", code);
				// 向右递归
				generateHuffmanCodeTable(node.getRight(), "1", code);
			} else { // 否则即node的data不为空,该节点为叶子节点
				HUFFMAN_CODES_TABLE.put(node.getData(), code.toString()); // 将该节点的data(即Byte)和其对应的编码code存入哈夫曼编码表
			}
		}
	}
	
	/**
	 * 前序遍历哈夫曼树的方法
	 */
	public static void preOrder() {
		if (ROOT == null) {
			System.err.println("哈夫曼树为空!");
		} else {
			ROOT.preOrder();
		}
	}

	public static Node getRoot() {
		return ROOT;
	}

	public static Map<Byte, String> getCodeTable() {
		return HUFFMAN_CODES_TABLE;
	}
	
}

测试类的编写

package edu.hebeu.huffman.code;

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.util.Arrays;

public class Test {
	public static void main(String[] args) {
		testZipFile(); // 压缩使用
		// testUnzipFile(); // 解压使用
	}
	
	/**
	 * 测试压缩文件
	 */
	public static void testZipFile() {
		HuffmanTreeCompress.zipFile("data/dataFile", "data/zipFile.hufm");
	}
	
	/**
	 * 测试解压文件
	 */
	public static void testUnzipFile() {
		HuffmanTreeCompress.UnzipFile("data/zipFile.hufm", "data/unzipFile");
	}
	
}

测试

压缩:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
解压:
在这里插入图片描述
在这里插入图片描述

  • 3
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值