本篇博文将介绍什么是哈夫曼树,并且如何在java语言中构建一棵哈夫曼树,怎么利用哈夫曼树实现对文件的压缩和解压。首先,先来了解下什么哈夫曼树。
一、哈夫曼树
哈夫曼树属于二叉树,即树的结点最多拥有2个孩子结点。若该二叉树带权路径长度达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree)。哈夫曼树是带权路径长度最短的树,权值较大的结点离根较近。
(一)树的相关概念
1.路径和路径长度
在一棵树中,从一个结点往下可以达到的孩子或孙子结点之间的通路,称为路径。通路中分支的数目称为路径长度。若规定根结点的层数为1,则从跟结点到第L层结点的路径长度为L-1。
2.结点的权和带权路径长度
若将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。结点的带权路径长度为:从根结点到该结点之间的路径长度与该结点的权的乘积。
3.树的带权路径长度
树的带权路径长度规定为所有叶子结点的带权路径长度之和,记为WPL。
(二)哈夫曼树的构造原理
假设有n个权值,则构造出的哈夫曼树有n个叶子结点。 n个权值分别设为 w1、w2、…、wn,则哈夫曼树的构造规则为:
(1) 将w1、w2、…,wn看成是有n 棵树的森林(每棵树仅有一个结点);
(2) 在森林中选出两个根结点的权值最小的树合并,作为一棵新树的左、右子树,且新树的根结点权值为其左、右子树根结点权值之和;
(3)从森林中删除选取的两棵树,并将新树加入森林;
(4)重复(2)、(3)步,直到森林中只剩一棵树为止,该树即为所求得的哈夫曼树。
(三)哈夫曼编码
在数据通信中,需要将传送的文字转换成二进制的字符串,用0,1码的不同排列来表示字符。例如,需传送的报文为“AFTER DATA EAR ARE ART AREA”,这里用到的字符集为“A,E,R,T,F,D”,各字母出现的次数为{8,4,5,3,1,1}。现要求为这些字母设计编码。要区别6个字母,最简单的二进制编码方式是等长编码,固定采用3位二进制,可分别用000、001、010、011、100、101对“A,E,R,T,F,D”进行编码发送,当对方接收报文时再按照三位一分进行译码。显然编码的长度取决报文中不同字符的个数。若报文中可能出现26个不同字符,则固定编码长度为5。然而,传送报文时总是希望总长度尽可能短。在实际应用中,各个字符的出现频度或使用次数是不相同的,如A、B、C的使用频率远远高于X、Y、Z,自然会想到设计编码时,让使用频率高的用短编码,使用频率低的用长编码,以优化整个报文编码。
为使不等长编码为前缀编码(即要求一个字符的编码不能是另一个字符编码的前缀),可用字符集中的每个字符作为叶子结点生成一棵编码二叉树,为了获得传送报文的最短长度,可将每个字符的出现频率作为字符结点的权值赋予该结点上,显然字使用频率越小权值越小,权值越小叶子就越靠下,于是频率小编码长,频率高编码短,这样就保证了此树的最小带权路径长度效果上就是传送报文的最短长度。因此,求传送报文的最短长度问题转化为求由字符集中的所有字符作为叶子结点,由字符出现频率作为其权值所产生的哈夫曼树的问题。利用哈夫曼树来设计二进制的前缀编码,既满足前缀编码的条件,又保证报文编码总长最短。
二、用Java实现哈夫曼树结构
(一)创建树的结点结构
首先要搞清楚,我们用哈夫曼树实现压缩的原理是要先统计好被压缩文件的每个字节的次数,以这个次数为依据来构建哈夫曼树,使得出现次数多字节对应的哈夫曼编码要短,而出现次数少的字节对应的哈夫曼编码要长一些,所以树结点结构中的要保存的数据就有文件中的字节(用byte类型),字节出现的次数(用int类型),表示是左孩子还是右孩子的数据,指向左孩子和右孩子的两个结点结构对象。同时,如果希望能够直接比较结点中的字节出现次数,可以重写一个比较方法。
/** * 二叉树结点元素结构 * * @author Bill56 * */ public class Node implements Comparable<Node> { // 元素内容 public int number; // 元素次数对应的字节 public byte by; // 表示结点是左结点还是右结点,0表示左,1表示右 public String type = ""; // 指向该结点的左孩子 public Node leftChild; // 指向该结点的右孩子 public Node rightChild; /** * 构造方法,需要将结点的值传入 * * @param number * 结点元素的值 */ public Node(int number) { this.number = number; } /** * 构造方法 * * @param by * 结点元素字节值 * @param number * 结点元素的字节出现次数 * */ public Node(byte by, int number) { super(); this.by = by; this.number = number; } @Override public int compareTo(Node o) { // TODO Auto-generated method stub return this.number - o.number; } }
(二)创建树结构类
树结构中主要包含一些列对结点对象的操作,如,通过一个队列生成一个map对象(用来存放字节对应的次数),通过队列生成一棵树,通过树的根结点对象生成一个哈夫曼map,获得哈夫曼编码等。
/** * 由结点元素构成的二叉树树结构,由结点作为树的根节点 * * @author Bill56 * */ public class Tree { /** * 根据map生成一个由Node组成的优先队列 * * @param map * 需要生成队列的map对象 * @return 优先队列对象 */ public PriorityQueue<Node> map2Queue(HashMap<Byte, Integer> map) { // 创建队列对象 PriorityQueue<Node> queue = new PriorityQueue<Node>(); if (map != null) { // 获取map的key Set<Byte> set = map.keySet(); for (Byte b : set) { // 将获取到的key中的值连同key一起保存到node结点中 Node node = new Node(b, map.get(b)); // 写入到优先队列 queue.add(node); } } return queue; } /** * 根据优先队列创建一颗哈夫曼树 * * @param queue * 优先队列 * @return 哈夫曼树的根结点 */ public Node queue2Tree(PriorityQueue<Node> queue) { // 当优先队列元素大于1的时候,取出最小的两个元素之和相加后再放回到优先队列,留下的最后一个元素便是根结点 while (queue.size() > 1) { // poll方法获取并移除此队列的头,如果此队列为空,则返回 null // 取出最小的元素 Node n1 = queue.poll(); // 取出第二小的元素 Node n2 = queue.poll(); // 将两个元素的字节次数值相加构成新的结点 Node newNode = new Node(n1.number + n2.number); // 将新结点的左孩子指向最小的,而右孩子指向第二小的 newNode.leftChild = n1; newNode.rightChild = n2; n1.type = "0"; n2.type = "1"; // 将新结点再放回队列 queue.add(newNode); } // 优先队列中留下的最后一个元素便是根结点,将其取出返回 return queue.poll(); } /** * 根据传入的结点遍历树 * * @param node * 遍历的起始结点 */ public void ergodicTree(Node node) { if (node != null) { System.out.println(node.number); // 递归遍历左孩子的次数 ergodicTree(node.leftChild); // 递归遍历右孩子的次数 ergodicTree(node.rightChild); } } /** * 根据哈夫曼树生成对应叶子结点的哈夫曼编码 * * @param root * 树的根结点 * @return 保存叶子结点的哈夫曼map */ public HashMap<Byte, String> tree2HfmMap(Node root) { HashMap<Byte, String> hfmMap = new HashMap<>(); getHufmanCode(root, "", hfmMap); return hfmMap; } /** * 根据输入的结点获得哈夫曼编码 * * @param node * 遍历的起始结点 * @param code * 传入结点的编码类型 * @param hfmMap * 用来保存字节对应的哈夫曼编码的map */ private void getHufmanCode(Node node, String code, HashMap<Byte, String> hfmMap) { if (node != null) { code += node.type; // 当node为叶子结点的时候 if (node.leftChild == null && node.rightChild == null) { hfmMap.put(node.by, code); } // 递归遍历左孩子的次数 getHufmanCode(node.leftChild, code, hfmMap); // 递归遍历右孩子的次数 getHufmanCode(node.rightChild, code, hfmMap); } } }
三、创建一个模型类,用来保存被压缩文件的相关信息,包括被压缩文件的路径和该文件的哈夫曼树编码(HashMap对象),如下FileConfig.java:
/** * 用来保存压缩时的文件路径和对应的字节哈夫曼编码映射 * * @author Bill56 * */ public class FileConfig { // 文件路径 private String filePath; // 文件字节的哈夫曼编码映射 private HashMap<Byte, String> hfmCodeMap; /** * 构造方法 * * @param filePath * 文件路径 * @param hfmCodeMap * 文件字节的哈夫曼编码映射 */ public FileConfig(String filePath, HashMap<Byte, String> hfmCodeMap) { super(); this.filePath = filePath; this.hfmCodeMap = hfmCodeMap; } public String getFilePath() { return filePath; } public void setFilePath(String filePath) { this.filePath = filePath; } public HashMap<Byte, String> getHfmCodeMap() { return hfmCodeMap; } public void setHfmCodeMap(HashMap<Byte, String> hfmCodeMap) { this.hfmCodeMap = hfmCodeMap; } @Override public String toString() { return "FileConfig [filePath=" + filePath + ", hfmCodeMap=" + hfmCodeMap + "]"; } }
四、实现哈夫曼编码对文件的压缩和解压
完成了第二部分后,接下来便可以实现对文件实现压缩了。首先,需要扫描被压缩的文件,统计好每个字节对应所出现的次数,然后生成哈夫曼树,进而得到哈夫曼编码。最后,哈夫曼编码代替文件中的字节。可以将本部分的代码全部封装到一个FileUtil.java类中。以下的每一个点都是这个类的一个静态方法。
(一)统计被压缩文件中的字节及其出现的次数,用HashMap对象保存。
/** * 根据指定的文件统计该文件中每个字节出现的次数,保存到一个HashMap对象中 * * @param f * 要统计的文件 * @return 保存次数的HashMap */ public static HashMap<Byte, Integer> countByte(File f) { // 判断文件是否存在 if (!f.exists()) { // 不存在,直接返回null return null; } // 执行到这表示文件存在 HashMap<Byte, Integer> byteCountMap = new HashMap<>(); FileInputStream fis = null; try { // 创建文件输入流 fis = new FileInputStream(f); // 保存每次读取的字节 byte[] buf = new byte[1024]; int size = 0; // 每次读取1024个字节 while ((size = fis.read(buf)) != -1) { // 循环每次读到的真正字节 for (int i = 0; i < size; i++) { // 获取缓冲区的字节 byte b = buf[i]; // 如果map中包含了这个字节,则取出对应的值,自增一次 if (byteCountMap.containsKey(b)) { // 获得原值 int old = byteCountMap.get(b); // 先自增后入 byteCountMap.put(b, ++old); } else { // map中不包含这个字节,则直接放入,且出现次数为1 byteCountMap.put(b, 1); } } } } 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 fis = null; } } } return byteCountMap; }(二)实现对文件的压缩
对文件的压缩,应该是根据一个文件的引用和对应的哈夫曼编码来实现,并且将字节和对应的编码一并写入压缩后的文件头数据,以便之后做解压来读取。在实现这个方法之前,我们需要两个方法,就是根据01字符串转换成对应的字节,和根据字节生成对应的01字符串的方法。
1.根据01字符串生成对应的字节
/** * 将字符串转成二进制字节的方法 * * @param bString * 待转换的字符串 * @return 二进制字节 */ private static byte bit2byte(String bString) { byte result = 0; for (int i = bString.length() - 1, j = 0; i >= 0; i--, j++) { result += (Byte.parseByte(bString.charAt(i) + "") * Math.pow(2, j)); } return result; }2.根据字节生成对应的01字符串
/** * 将二字节转成二进制的01字符串 * * @param b * 待转换的字节 * @return 01字符串 */ public static String byte2bits(byte b) { int z = b; z |= 256; String str = Integer.toBinaryString(z); int len = str.length(); return str.substring(len - 8, len); }3.实现压缩的方法
由于每8个01串生成一个字节,而被压缩文件最后的01串长度可能不是8的倍数,即不能被8整除,会出现不足8位的情况。这个时候,我们需要为其后面补0,补足8位,同时,还需要添加一个01串,该01串对应的字节应该是补0的次数(一定小于8)。
/** * 将文件中的字节右字节哈夫曼map进行转换 * * @param f * 待转换的文件 * @param byteHfmMap * 该文件的字节哈夫曼map */ public static FileConfig file2HfmCode(File f, HashMap<Byte, String> byteHfmMap) { // 声明文件输出流 FileInputStream fis = null; FileOutputStream fos = null; try { System.out.println("正在压缩~~~"); // 创建文件输入流 fis = new FileInputStream(f); // 获取文件后缀前的名称 String name = f.getName().substring(0, f.getName().indexOf(".")); File outF = new File(f.getParent() + "\\" + name + "-压缩.txt"); // 创建文件输出流 fos = new FileOutputStream(outF); DataOutputStream dos = new DataOutputStream(fos); // 将哈夫曼编码读入到文件头部,并记录哈夫曼编码所占的大小 Set<Byte> set = byteHfmMap.keySet(); long hfmSize = 0; for (Byte bi : set) { // 先统计哈夫曼编码总共的所占的大小 hfmSize += 1 + 4 + byteHfmMap.get(bi).length(); } // 先将长度写入 dos.writeLong(hfmSize); dos.flush(); for (Byte bi : set) { // // 测试是否正确 // System.out.println(bi + "\t" + byteHfmMap.get(bi)); // 写入哈夫曼编码对应的字节 dos.writeByte(bi); // 先将字符串长度写入 dos.writeInt(byteHfmMap.get(bi).length()); // 写入哈夫曼字节的编码 dos.writeBytes(byteHfmMap.get(bi)); dos.flush(); } // 保存一次读取文件的缓冲数组 byte[] buf = new byte[1024]; int size = 0; // 保存哈弗吗编码的StringBuilder StringBuilder strBuilder = new StringBuilder(); while ((size = fis.read(buf)) != -1) { // 循环每次读到的实际字节 for (int i = 0; i < size; i++) { // 获取字节 byte b = buf[i]; // 在字节哈夫曼映射中找到该值,获得其hfm编码 if (byteHfmMap.containsKey(b)) { String hfmCode = byteHfmMap.get(b); strBuilder.append(hfmCode); } } } // 将保存的文件哈夫曼编码按8个一字节进行压缩 int hfmLength = strBuilder.length(); // 获取需要循环的次数 int byteNumber = hfmLength / 8; // 不足8位的数 int restNumber = hfmLength % 8; for (int i = 0; i < byteNumber; i++) { String str = strBuilder.substring(i * 8, (i + 1) * 8); byte by = bit2byte(str); fos.write(by); fos.flush(); } int zeroNumber = 8 - restNumber; if (zeroNumber < 8) { String str = strBuilder.substring(hfmLength - restNumber); for (int i = 0; i < zeroNumber; i++) { // 补0操作 str += "0"; } byte by = bit2byte(str); fos.write(by); fos.flush(); } // 将补0的长度也记录下来保存到文件末尾 String zeroLenStr = Integer.toBinaryString(zeroNumber); // 将01串转成字节 byte zeroB = bit2byte(zeroLenStr); fos.write(zeroB); fos.flush(); System.out.println("压缩完毕~~~"); return new FileConfig(outF.getAbsolutePath(), byteHfmMap); } 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 fis = null; } } // 关闭流 if (fos != null) { try { fos.close(); } catch (IOException e) { // TODO Auto-generated catch block fos = null; } } } return null; }(三)实现对已压缩文件的解压
首先应该读取已压缩文件的头数据,以获取其哈夫曼编码,然后通过哈夫曼编码来还原该文件。与压缩一样,在解压的时候,需要获取全部字节对应的01串,并将其保存到一个字符串对象中(最后8位除外),同时检测在压缩时候补0的个数(通过最后8位来获取),然后在字符串对象中舍弃多添加的0的个数。
/** * 将已经压缩的文件进行解压,把哈夫曼编码重新转成对应的字节文件 * * @param f * 待解压的文件 * @param byteHfmMap * 保存字节的哈夫曼映射 */ public static void hfmCode2File(File f) { // 声明文件输出流 FileInputStream fis = null; FileOutputStream fos = null; try { System.out.println("正在解压~~~"); // 创建文件输入流 fis = new FileInputStream(f); // 获取文件后缀前的名称 String name = f.getName().substring(0, f.getName().indexOf(".")); // 创建文件输出流 fos = new FileOutputStream(f.getParent() + "\\" + name + "-解压.txt"); DataInputStream dis = new DataInputStream(fis); long hfmSize = dis.readLong(); // // 测试读取到的大小是否正确 // System.out.println(hfmSize); // 用来保存从文件读到的哈夫曼编码map HashMap<Byte, String> byteHfmMap = new HashMap<>(); for (int i = 0; i < hfmSize;) { byte b = dis.readByte(); int codeLength = dis.readInt(); byte[] bys = new byte[codeLength]; dis.read(bys); String code = new String(bys); byteHfmMap.put(b, code); i += 1 + 4 + codeLength; // // 测试读取是否正确 // System.out.println(b + "\t" + code + "\t" + i); } // 保存一次读取文件的缓冲数组 byte[] buf = new byte[1024]; int size = 0; // 保存哈弗吗编码的StringBuilder StringBuilder strBuilder = new StringBuilder(); // fis.skip(hfmSize); while ((size = fis.read(buf)) != -1) { // 循环每次读到的实际字节 for (int i = 0; i < size; i++) { // 获取字节 byte b = buf[i]; // 将其转成二进制01字符串 String strBin = byte2bits(b); // System.out.printf("字节为:%d,对应的01串为:%s\n",b,strBin); strBuilder.append(strBin); } } String strTotalCode = strBuilder.toString(); // 获取字符串总长度 int strLength = strTotalCode.length(); // 截取出最后八个之外的 String strFact1 = strTotalCode.substring(0, strLength - 8); // 获取最后八个,并且转成对应的字节 String lastEight = strTotalCode.substring(strLength - 8); // 得到补0的位数 byte zeroNumber = bit2byte(lastEight); // 将得到的fact1减去最后的0的位数 String strFact2 = strFact1.substring(0, strFact1.length() - zeroNumber); // 循环字节哈夫曼映射中的每一个哈夫曼值,然后在所有01串种进行匹配 Set<Byte> byteSet = byteHfmMap.keySet(); int index = 0; // 从第0位开始 String chs = strFact2.charAt(0) + ""; while (index < strFact2.length()) { // 计数器,用来判断是否匹配到了 int count = 0; for (Byte bi : byteSet) { // 如果匹配到了,则跳出循环 if (chs.equals(byteHfmMap.get(bi))) { fos.write(bi); fos.flush(); break; } // 没有匹配到则计数器累加一次 count++; } // 如果计数器值大于或鱼等map,说明没有匹配到 if (count >= byteSet.size()) { index++; chs += strFact2.charAt(index); } else { // 匹配到了,则匹配下一个字符串 if (++index < strFact2.length()) { chs = strFact2.charAt(index) + ""; } } } System.out.println("解压完毕~~~"); // for (Byte hfmByte : byteSet) { // String strHfmCode = byteHfmMap.get(hfmByte); // strFact2 = strFact2.replaceAll(strHfmCode, // String.valueOf(hfmByte)); // } // fos.write(strFact2.getBytes()); // fos.flush(); } 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 fis = null; } } // 关闭流 if (fos != null) { try { fos.close(); } catch (IOException e) { // TODO Auto-generated catch block fos = null; } } } }(四)将第(一)中得到的字节和次数map对象生成哈夫曼树编码,实现压缩,并且保存到FileConfig对象中
public static FileConfig yasuo(File f) { HashMap<Byte, Integer> map = FileUtil.countByte(f); Tree tree = new Tree(); // 构建优先队列 PriorityQueue<Node> queue = tree.map2Queue(map); // 构建树 Node root = tree.queue2Tree(queue); // 获得字节的哈夫曼编码map // tree.ergodicTree(root); HashMap<Byte, String> hfmMap = tree.tree2HfmMap(root); // Set<Byte> set = hfmMap.keySet(); // for (Byte b : set) { // System.out.printf("字节为:%d,哈夫曼编码为:%s\n", b, hfmMap.get(b)); // } FileConfig fc = FileUtil.file2HfmCode(f, hfmMap); return fc; }(五)实现解压的具体算法
public static void jieya(String filePath) { File f = new File(filePath); FileUtil.hfmCode2File(f); }
五、创建一个测试类,用来压缩一个文件,同时对被压缩的文件再次解压,查看耗时
/** * 测试一些算法的类 * * @author Bill56 * */ public class Test { public static void main(String[] args) { File f = new File("C:\\Users\\Bill56\\Desktop\\file.txt"); long startTime = System.currentTimeMillis(); FileConfig fc = ExeUtilFile.yasuo(f); ExeUtilFile.jieya(fc.getFilePath()); long endTime = System.currentTimeMillis(); System.out.println("压缩和解压共花费时间为:" + (endTime - startTime) + "ms"); } }运行结果: