原理:一个文本文件的内容本质上是一个长字符串,如果根据每个字符在总串中的出现次数构造一棵哈夫曼树,得到每个字符的哈夫曼编码,再把每个字符换成其对应的哈夫曼编码,就可以得到一串长二进制串,出现次数越多的字符其哈夫曼编码越短,占用位数越少,以此达到压缩的效果。最后把该二进制串转换成十进制字节写入文件,就可以得到一个压缩文件。
对于后续的解压操作,解压时读取文件并进行字节转换会获得一个长二进制字符串,为了还原成原字符串,我们需知道每个字符对应的哈夫曼编码,因此我们还需要一个码表记录每个字符对应的哈夫曼编码,并在写入压缩文件时把码表对象也一起写入。
代码实现:
1.为了获得哈夫曼编码,需要有:节点类Node,createHFMTree()方法构造哈夫曼树,getHFMCode()方法为每个节点设置哈夫曼编码。构造哈夫曼树过程中会出现权值比较,因此还可以写一个快排fastSort()。
//节点类
class Node{
public String str; //存储的字符,也可用char
public int times;//字符在字符串中出现次数
public String code="";//节点的哈夫曼编码,初始值要设为""
public Node left,right;
//构造方法
public Node() {
}
public Node(int times) {
this.times = times;
}
public Node(String str,int times) {
this.str = str;
this.times = times;
}
}
public class Compress {
//private List<Node> nodeList;//节点数组
private HashMap<String,String> codeMap = new HashMap<>();//码表<字符,哈夫曼编码>
//根据节点的times值快排,从小到大
public void fastSort(List<Node> nodeList,int low,int high) {
if (low >= high) return;
int i = low, j = high;
Node t;
Node tmp = nodeList.get(low);
while (i < j) {
while (i < j && nodeList.get(j).times >= tmp.times) j--;
while (i < j && nodeList.get(i).times <= tmp.times) i++;
if (i < j) {
t = nodeList.get(i);
nodeList.set(i, nodeList.get(j));
nodeList.set(j, t);
}
}
nodeList.set(low, nodeList.get(i));
nodeList.set(i, tmp);
fastSort(nodeList, low, i - 1);
fastSort(nodeList, i + 1, high);
}
//创建哈夫曼树
public Node createHFMTree(List<Node> nodeList) {
while(nodeList.size()>1) {
fastSort(nodeList,0,nodeList.size()-1);
//每次取出节点前对数组快排,确保取出的头两个节点一直为times最小节点
Node node1 = nodeList.remove(0);
Node node2 = nodeList.remove(0);
//创建新的父节点并把其压入队列
Node node = new Node(node1.times + node2.times);
node.left = node1;
node.right = node2;
nodeList.add(node);
}//循环结束时,队列中仅剩一个节点,为整棵哈夫曼树的根节点
return nodeList.remove(0);//返回哈夫曼树的根节点
}
//为每个节点计算哈夫曼编码,左节点+0,右节点+1
public void getHFMCode(Node root) {
if(root.left!=null) {
root.left.code = root.code + '0';
getHFMCode(root.left);
}
if(root.right!=null) {
root.right.code = root.code + '1';
getHFMCode(root.right);
}
}
由样例图可以看出哈夫曼树的根节点实际上并没有对应字符,只用来辅助建树,因此没必要为其设置哈夫曼编码;把每个节点的默认编码code设为空串(不是null),从第二层开始为每个节点设置哈夫曼编码。
2.上述操作后,每个字符都有对应的编码,创建码表进行存储。
//编写码表,key为字符,value为哈夫曼编码
public void createcodeMap(Node node) {
if(node!=null) {
//遍历整棵树,只有叶子节点才需要写入码表,非叶子节点只是构造树时的支点
if(node.left==null && node.right==null) {
codeMap.put(node.str, node.code);
}
createcodeMap(node.left);
createcodeMap(node.right);
}
}
3.准备工作做完后,接下来是压缩部分,首先需要读取文本文件,获得文本文件的字符整串
//读取文件所有字符,返回一串长字符串
public String readFile(String path) throws Exception{
File file = new File(path);
FileReader fr = new FileReader(file);//获取文件的字符输入流
BufferedReader br = new BufferedReader(fr);//用缓冲输入流读取,提高效率
StringBuilder sb = new StringBuilder();//用于拼接读取后的总字符串
String curstr = "";//读取字符串
//逐行读取,若返回字符串curstr不为空,则拼接到总字符串
while((curstr=br.readLine()) != null) {
sb.append(curstr);
}
return sb.toString();
}
4.构造哈夫曼树需要先创建一个节点数组nodeList,需要为每个字符创建一个节点,且统计每个字符在整串中的出现次数,统计次数可以用一个哈希表实现,注意此处的哈希表不是码表。
//统计每个字符出现次数
public HashMap<String,Integer> countTimes(String str) {
HashMap<String,Integer> hm = new HashMap<>();//key为字符,value为出现次数
for(int i=0;i<str.length();i++) {
//containKey()判断哈希表中是否有目标字符
//hm的key值为String类型,charAt()返回char型,char+"" 会转成String
if(hm.containsKey(str.charAt(i)+"")) {//如果表中已有,数值+1
hm.put(str.charAt(i)+"", hm.get(str.charAt(i)+"")+1);
}else {//如果表中没有,存入
hm.put(str.charAt(i)+"", 1);
}
}
return hm;
}
//构造节点数组
public List<Node> createnodeList(HashMap<String,Integer> hm) {
List<Node> nodeList = new ArrayList<Node>();//全局变量中的nodeList只是声明变量,此处为创建
//遍历哈希表key值
for(String str:hm.keySet()) {
//为每个字符创建节点,存入字符和出现次数
Node node = new Node(str,hm.get(str));
nodeList.add(node);
}
return nodeList;
}
5.接下来是创建哈夫曼树,为每个字符设置哈夫曼编码,然后把字符整串按照哈夫曼编码转换得到一个哈夫曼编码串,也就是要写入文件的二进制串。
//把长字符串的每个字符替换成哈夫曼编码,得到一串二进制码
public String getHFMStr(String str) {
//给一串字符串,把其转换成二进制码
//str-->nodeList
HashMap<String,Integer> hm = countTimes(str);
List<Node> nodeList = createnodeList(hm);
//nodeList-->HFMTree
Node root = createHFMTree(nodeList);
getHFMCode(root);
//HFMTree-->codeMap
createcodeMap(root);
//依次取出每个字符,把其哈夫曼码存入新字符串
StringBuilder sb = new StringBuilder();
for(int i=0;i<str.length();i++) {
sb.append(codeMap.get(str.charAt(i)+""));
}
return sb.toString();
}
6.最后是把二进制串写入文件,由于是以字节流写入,所以需要把二进制串转换成字节再写入。字节的大小是8位比特,所以每8位二进制串转成1个字节,这样就不会出现位数丢失的情况。对于最后不足8位的二进制串,我们需手动补零补成8位,同时把补零个数也写入文件,这样解压的时候才知道要删除几个零。别忘记把码表也写入文件。
//把二进制码写入文件
public void writeIn(String codestr) throws Exception{
File file = new File("D:\\java\\teach\\receive.txt");//用于接收压缩内容的文件
//获取文件的输出流
FileOutputStream fos = new FileOutputStream(file);
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(codeMap);//先写入码表,便于解压时读取
//fos是按字节流写入,因此编码每8位转成一个int,而int在写入时会自动转型成byte,8位int转8位byte,不会出现位数丢失的情况
int num = codestr.length()/8; //能完整写入的编码串数量
int lastnum = codestr.length()%8; //剩余不足8位编码数,需手动补0
int data;//二进制编码转成的int值
for(int i=0;i<num;i++) {
//切割出8位字符串并转成整型,写入文件
data = Integer.parseInt(codestr.substring(i*8,(i+1)*8),2);
fos.write(data);
fos.flush();
}
String zero = ""; //补零字符串
for(int i=0;i<8-lastnum;i++) {
zero += '0';
}
//把剩余字符串补成8位,写入文件
String laststr = codestr.substring(num*8, num*8+lastnum) + zero;
data = Integer.parseInt(laststr,2);
fos.write(data);
//把最后的补零个数也写入文件,便于解压
fos.write(8-lastnum);
fos.flush();
}
7.最后是对上述方法包装,方便一次性调用。
//压缩,把所给文件内容压缩到指定文件
public void compress(String path) throws Exception{
String str = readFile(path);//获取所给文件要压缩的总字符串
String codestr = getHFMStr(str);//把总字符串转成二进制串
System.out.println("压缩时转换成的二进制编码串为:"+codestr);
writeIn(codestr);//写入文件
}
}
运行结果:
压缩时转换成的二进制编码串为:1101111111010100000
8.为了方便使用,可以写一个可视化界面。
public class UI {
public void create() {
JFrame jf = new JFrame("文本压缩");//窗体
jf.setSize(450,500);//设置大小
jf.setLocationRelativeTo(null);//居中显示
jf.setDefaultCloseOperation(3);//自动关闭后台
jf.setLayout(new FlowLayout());//流动式布局
JTextField jtf = new JTextField();//文本框,用于显示可选择文件
jtf.setPreferredSize(new Dimension(330,30));//设置大小
jf.add(jtf);//添加到窗体
JButton jbu1 = new JButton("选择文件");//按钮
JButton jbu2 = new JButton("确认压缩");
ButtonListener listener = new ButtonListener(jf,jtf);//监听器
jbu1.addActionListener(listener);//为按钮添加监听器
jbu2.addActionListener(listener);
jf.add(jbu1);//添加到窗体
jf.add(jbu2);
jf.setVisible(true);//设置窗体可见
}
public static void main(String[]args) {
new UI().create();
}
}
//监听器类
class ButtonListener implements ActionListener{
private JFrame jf; //压缩成功后程序停止,退出窗体
private JTextField jtf;//选定文件时,在文本框上显示该文件路径
private String path="";//选定文件的路径
//构造方法
public ButtonListener(JFrame jf,JTextField jtf) {
this.jf = jf;
this.jtf = jtf;
}
public void actionPerformed(ActionEvent e) {
String s = e.getActionCommand();//获取选择按钮的内容
switch(s) {
case "选择文件":
JFileChooser jfc = new JFileChooser();//文件选择器
int returnValue = jfc.showOpenDialog(null);
//showOpenDialog()会打开界面框,选择不同按钮会返回不同int值,APPROVE_OPTION是选择"确定"的返回值
if(returnValue == jfc.APPROVE_OPTION) {
File file = jfc.getSelectedFile();//获取选定的文件
path = file.toString();//获取文件路径
jtf.setText(path);//在文本框上显示选定文件的路径
}
break;
case "确认压缩":
try {
new Compress().compress(path);
} catch (Exception e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
}//压缩
jf.dispose();//退出窗体,程序结束
break;
}
}
}
以上就是压缩软件的实现代码,接下来是解压。
1.首先需要读取压缩文件,获得一个个字节,把字节转成二进制编码就得到了哈夫曼编码串。注意字节转哈夫曼编码时系统可能会自动消除前缀的0, 当字节转成的二进制码不足8位时要在前缀补零到8位。最后获得整二进制串要在后缀消除压缩时补的零。
public class Decompress {
private HashMap<String,String> codeMap;//码表
//读取文件中的字节,并转成二进制字符串
public String read(File file) throws Exception{
FileInputStream fis = new FileInputStream(file);//字节输入流
ObjectInputStream ois = new ObjectInputStream(fis);//对象输入流
List<Integer> dataList = new ArrayList<>();//字节数组,存储读取文件中的所有字节
int data;//每次读出的字节
//压缩时是先写入码表,因此解压时也是先读取码表对象
codeMap = (HashMap<String,String>)ois.readObject();
//读取字节
while((data=fis.read())!=-1) {//如果到达文件末尾会返回-1,代表读取完毕
dataList.add(data);
}
StringBuilder sb = new StringBuilder();
//压缩时最后写入了后缀补零个数,因此解压时记得取出
int lastzero = dataList.get(dataList.size()-1);
for(int i=0;i<dataList.size()-1;i++) {
/*Integer.toBinaryString会把整型转成二进制码,但可能会出现前缀的0被自动消除的情况,
因此转换后的二进制码若不足8位,要手动在前缀补零
*/
String s = Integer.toBinaryString(dataList.get(i));//整型转成二进制码
if(s.length()!=8) {
StringBuilder forezero = new StringBuilder();
for(int j=0;j<8-s.length();j++) {
forezero.append("0");
}
s = forezero + s;//补成8位二进制码
}
sb.append(s);
}
//不要忘记后缀消零
String str = sb.substring(0, sb.length()-lastzero);
return str;
}
2.把二进制串转成对应的字符串。对于二进制串,从一位开始往后取子串,每取出一位,就判断码表中是否有对应的编码值,若有,则根据码表把对应字符加入新字符串中。为了方便操作,可以新建一个反转码表,便于比较编码。
//把二进制码根据码表转成字符
public String codetoStr(String codestr) {
//为了方便操作,创建一个反转码表,新码表的key是字符的哈夫曼编码,value是字符值
HashMap<String,String> reMap = new HashMap<>();
for(String s:codeMap.keySet()) {//原码表的key是字符,value是哈夫曼编码
reMap.put(codeMap.get(s),s);
}
int i=0,index=0;//i和index用于移动切割二进制码串
StringBuilder sb = new StringBuilder();//转换后的字符串
while(i<codestr.length()) {
index++;//从一位二进制码开始
String key = codestr.substring(i,index);
if(reMap.containsKey(key)) {//若二进制等于码表的哈夫曼码
sb.append(reMap.get(key));//拼接对应字符
i = index;//更新流动下标
}
}
return sb.toString();
}
}
这样解压部分也完成了,后续也可以跟解压一样写一个可视化界面,这里不再重复。