Java利用最优二叉树实现哈夫曼编码的压缩和解压

什么是哈夫曼编码

哈夫曼编码(Huffman Coding),又称霍夫曼编码,是一种编码方式,哈夫曼编码是可变字长编码(VLC)的一种。Huffman于1952年提出一种编码方法,该方法完全依据字符出现概率来构造异字头的平均长度最短的码字,有时称之为最佳编码,一般就叫做Huffman编码(有时也称为霍夫曼编码)

为什么哈夫曼编码能够实现文件的压缩

如果我们使用的定长编码方例如ASCII码,8-bit定长编码,使用8位(一个字节)代表一个字符,就会出现很多的byte出现了相同,所以我们考虑一种新的编码方式,变长编码的方式,原理很简单,以前是使用的8位表示一个字符,现在该用不固定长度,确定长度的依据是每个字符出现的次数,越是高频的字符其所用的bit(二进制位数)越短,这样就实现了整个文本的变短,

//举例我们有这么一个字符串:
String content = "abbcccddddeeeee";

当我们把这段文字发送给别人时,会转换成一段字节数组长成下面这个样子

字节数组:
[97, 98, 98, 99, 99, 99, 100, 100, 100, 100, 101, 101, 101, 101, 101]
二进制:
110000111000101100010110001111000111100011110010011001001100100110010011001011100101110010111001011100101

使用哈夫曼编码过后
先统计文本每个字符的 a : 1 b:2  c:3  d:4  e:5
每个字符对应的二进制 a->1110  b->1111  c->110   d->10 e->0
二进制:
010011011000000101010101111111111
字节数组:
[77, -127, 85, -1, 1] 

要想学习哈夫曼编码先了解哈夫曼树的基本概念

  • 路径和路径长度:在一棵树中,从节点往下可以达到的孩子或者孙子节点之间的通路,从树根节点到叶子节点的长度称为路径长度,从从根节点到第L层节点到路径长度为L-1
  • 节点的权及带权路径长度:给节点值我们就叫做节点的权值,节点带权路径的为: 路径长度 * 权值
  • 一整个树的带权路径长度会等于所有叶子节点的带权路径长度之和,称为WPL(weighted path length)
  • WPL最小时就叫做哈夫曼树,因此我们构建哈夫曼树时应该将最大权值的节点放在根节点附近
非最优二叉树1.png

非最优二叉树2
哈夫曼树.png

1、压缩算法实现部分

基本思路

  • 构造树节点node
  • 用生成的节点node构造哈夫曼树
  • 生成哈夫曼编码表
  • 用哈夫曼编码表生成压缩后的字节数组

构造树节点node

这是node类实现了Comparable借口,并且重写了compareTo可以后续用于所有node根据权值排序



public class Node implements Comparable<Node>{
    Byte data;    //存放数据本身
    int weight;  //权值
    Node left;  //指向左子节点
    Node right; //指向右子节点
    public Node(Byte data, int weight) {
        this.data = data;
        this.weight = weight;
    }
    @Override
    public int compareTo(Node o) {
        // TODO Auto-generated method stub
        return this.weight-o.weight;
    }
    @Override
    public String toString() {
        return "Node [data=" + data + ", weight=" + weight + "]";
    }
}

根据文本信息转换成Byte数组,再创建一个HashMap 遍历整个byte数组key保存当前的byte的值,value用于统计相同字节出现的频率,再把这个HashMap中的节点转换成Node节点

public static void main(String[] args) {
       // TODO Auto-generated method stub
       String content = "abbcccddddeeeee";
       byte[] contentBytes = content.getBytes();
   List<Node> nodes = getNodes(bytes);
   }

private static List<Node> getNodes(byte[] bytes) {
       // 创建Arrlist
       ArrayList<Node> nodes = new ArrayList<Node>();
       // 遍历bytes,统计byte出现的次数->map
       Map<Byte, Integer> counts = new HashMap();
       for (byte b : bytes) {
           if (!counts.containsKey(b)) {
               counts.put(b, 1);
           } else {
               counts.put(b, counts.get(b) + 1);
           }
       }
       // 把每一个键对转成一个Node对象,并加入到nodes集合中
       for (Map.Entry<Byte, Integer> entry : counts.entrySet()) {
           nodes.add(new Node(entry.getKey(), entry.getValue()));
       }
       return nodes;
   }

用生成的节点node构造哈夫曼树

  • 权值越大,带权路径长度应该最短越接近root节点,原因是用0和1分别表示二叉树的左右字节点可达路径,
  • 对传入的list中的node从小到大进行排序,拿出第一个最小的两个元素,构成新的节点放入list中
  • 对新的节点不赋值,权重是两个,把它的左右节点初始化为最小的两个元素,权重是两个是两个子节点的和
  • 重复以上的步骤直到list中的元素小2时候停止
示例 String content = "abbcccddddeeeee";
a : 1 b:2  c:3  d:4  e:5
步骤1
步骤2
步骤3
步骤4

具体代码如下:

    // 可以通过list创建哈夫曼树
    /**
     * @param nodes 要处理的nodes
     * @return 返回root节点
     */
    public static Node createHuffmanTree(List<Node> nodes) {
        // 传入的list中大于1个节点时候才构建二叉树
        while (nodes.size() > 1) {
            // 对集合里面的元素进行排序
            Collections.sort(nodes);
            // 取出权值最小的节点
            Node left = nodes.get(0);
            Node right = nodes.get(1);
            // 构建一个新的二叉树节点,并且初始化它的值为null
            Node parent = new Node(null, left.weight + right.weight);
            // 把两个节点挂在一个非子节点上
            parent.left = left;
            parent.right = right;
            // 移除左右两个节点
            nodes.remove(left);
            nodes.remove(right);
            // 把这个非叶子节点加入到存放到nodes中
            nodes.add(parent);
        }
        return nodes.get(0);
    }

构建哈夫曼树的输出结果

Node [data=null, weight=15]
Node [data=null, weight=6]
Node [data=99, weight=3]
Node [data=null, weight=3]
Node [data=97, weight=1]
Node [data=98, weight=2]
Node [data=null, weight=9]
Node [data=100, weight=4]
Node [data=101, weight=5]

生成哈夫曼编码表

首先要创建huffmanCodes哈夫曼编码表 ,表的key - node.data(a,b,c) value - 0101,重载getCodes函数定义一个StringBuilder用于字符串010101的连接,递归处理之前生成好的哈夫曼树,往左走拼接 '0' ,往右走拼接'1'只有当我们遍历到叶子节点才把它加入huffmanCodes中

    // 声明哈夫曼编码表,static修饰,在多个方法中共同使用
    public static Map<Byte, String> huffmanCodes = new HashMap<Byte, String>(); 

    // 生成哈夫曼树对应的哈夫曼编码
    // 思路:
    // 将哈夫曼编码存放在map中比较合适 Map<Byte,String>
    // e->0  d->10 c->110 b->1111 a->1110 

    // 为了调用方便,重载getCodes
    private static Map<Byte, String> getCodes(Node root) {
        if (root == null) {
            return null;
        }
    //处理只有root节点的特殊情况
        if (root.left == null && root.right == null) {
            huffmanCodes.put(root.data, "0");
        }
        // 在生成哈夫曼编码表时,需要去拼接路径,定义一个StringBuilder 存储叶子节点的路径
        StringBuilder builder = new StringBuilder();
        // 处理左子树
        getCodes(root.left, "0", builder);
        // 处理右子树
        getCodes(root.right, "1", builder);
        return huffmanCodes;
    }

    /**
     * 功能:将传入的node节点的所有叶子节点的哈夫曼得到,并放入huffmanCodes中
     * 
     * @param node    //传入节点
     * @param code    //路径:左子节点是0 右子节点1
     * @param builder //用于拼接路径
     */
    private static void getCodes(Node node, String code, StringBuilder builder) {
        // 重建StringBuilder是为了防治地址引用一值,保持生成字符不共用同一字符串
        StringBuilder builder2 = new StringBuilder(builder);
        // 将code加入到StringBuilder
        builder2.append(code);
        if (node != null) { // 如果为空不进行处理
            // 判断当前是叶子还是非叶子节点
            if (node.data == null) {
                // 递归处理
                // 左边递归
                getCodes(node.left, "0", builder2);
                // 右边递归
                getCodes(node.right, "1", builder2);
            } else {
                // 找到了叶子节点
                huffmanCodes.put(node.data, builder2.toString());
            }
        }
    }

用哈夫曼编码表生成压缩后的字节数组

最后一步就是用我们生成好的哈夫曼编码表对我们的全部文本进行生成处理过后的字节数组,遍历整个字节数组用字节数组的每个数据去hashmap中匹配010101生成二进制字符串,因为哈夫曼编码表对应的二进制是可变长的,所以最后字符串的长度可能就不是8的倍数,要进行特殊处理多加一个字节,最后一个字节如果不满8bit,我们在这里要用一个static int endLen记录下它的长度,在后面逆向解压中将要用到 , 再遍历整个字符串数组用Byte数组来封装好整个字符串,1Byte=8bit, 具体的代码如下:

    // 编写一个方法,将字符串对应的byte[]数组,通过生成哈夫曼编码表,返回一个哈夫曼编码处理后的byte[]数组
    /**
     * @param bytes       原始数据要处理的byte数组
     * @param huffmanCode //哈夫曼编码表
     * @return //返回处理后的byte[]
     */
    public static byte[] zip(byte[] bytes, Map<Byte, String> huffmanCode) {
        // 利用huffmanCodes将byte转成哈夫曼对应的字符串
        StringBuilder builder = new StringBuilder("");
        // 遍历数组
        for (byte b : bytes) {
            builder.append(huffmanCodes.get(b));
        }
        // 将"10101000..." 转成byte[]
        // 统计返回的哈夫曼编码有多长
        int len = (builder.length() % 8) == 0 ? builder.length() / 8 : builder.length() / 8 + 1;
        endLen = builder.length() % 8;
        // 创建 存储后的bytes压缩数组
        byte[] huffmanCodeBytes = new byte[len];
        int index = 0;// 记录是第几个byte
        for (int i = 0; i < builder.length(); i += 8) {// 因为是每8位对应一个byte,所以步长+8
            String strByte;
            // 两种情况i+8超过最后位置和不超过的分别赋值

            strByte = i + 8 > builder.length() ? builder.substring(i) : builder.substring(i, i + 8);
            // 后面一个参数2表示转换成二进制
            huffmanCodeBytes[index++] = (byte) Integer.parseInt(strByte, 2);
        }
        return huffmanCodeBytes;
    }

处理之后的结果

处理前的字节数组:[97, 98, 98, 99, 99, 99, 100, 100, 100, 100, 101, 101, 101, 101, 101] 长度=15
处理后的字节数组:[77, -127, 85, -1, 1] 长度=5
当重复字符较多时,压缩效率还是相当可观的

对整个压缩流程创建一个接口函数方便调用

    

    public static void main(String[] args) {
        // TODO Auto-generated method stub
        String content = "abbcccddddeeeee";
        byte[] contentBytes = content.getBytes();
        byte[] huffmanCodeBytes = huffmanZip(contentBytes);
  }

    // 创建一个接口函数封装好实现的细节
    /**
     * 
     * @param bytes 原始字符串对应的字节数组
     * @return 返回处理后的字节数组
     */
    public static byte[] huffmanZip(byte[] bytes) {
        System.out.println("处理前的字节数组:"+Arrays.toString(bytes)+" 长度="+bytes.length);
        List<Node> nodes = getNodes(bytes);
        // 创建哈夫曼树
        Node huffmanTreeRoot = createHuffmanTree(nodes);
        // 对应的哈夫曼编码
        Map<Byte, String> huffmanCodes = getCodes(huffmanTreeRoot);
        // 根据生成的哈夫曼编码,得到压缩后的数组
        byte[] huffmanCodeBytes = zip(bytes, huffmanCodes);
        System.out.println("处理后的字节数组:"+Arrays.toString(huffmanCodeBytes)+" 长度="+huffmanCodeBytes.length);
        preOrder(huffmanTreeRoot);
        return huffmanCodeBytes;
    }

2、解压算法实现部分

基本思路

  • 字节数组转换成二进制字符串
  • 逆向处理生成好的哈夫曼编码表
  • 根据逆向生成的哈夫曼表查询生成原来的字节数组

字节数组转换成二进制字符串

思路就是用flag来标识是否时最后一位,通过之前的endLen得到最后一位如果不满八位就进行特殊处理

代码处理主要用二进制的操作,byte的取值范围为-128-127,如果出现负数则要进行补高位

    // 完成数据转成对应的二进制字符串'10101000...'
    /**
     * 将一个byte 转成一个二进制的字符串
     * 
     * @param b    传入的是一个字节b
     * @param flag 标志是否为最后一个字节,true表示不是最后一个字节,false表示是最后一个字节
     * @return 是该b对应对二进制对字符串,(注意是按照补码返回)
     */
    public static String byteToBitString(boolean flag, byte b) {
        // 使用一个变量保持b
        int temp = b; // 将b转成int
        temp |= 256;
        String str = Integer.toBinaryString(temp);// 返回的是temp对应的二进制补码
        if (flag || (flag == false && endLen == 0)) {
      //字符串的截取,只拿后八位
            return str.substring(str.length() - 8);
        } else {
      //不满8bit有多少位拿多少位
            return str.substring(str.length() - endLen);
        }

逆向处理生成好的哈夫曼编码表

用循环把编码表key(a,b,c)-value(0101) 倒转过来

        // 把哈夫曼编码表进行调换,因为要进行反向查询
        Map<String, Byte> map = new HashMap<String, Byte>();
        for (Map.Entry<Byte, String> entry : huffmanCodes.entrySet()) {
            map.put(entry.getValue(), entry.getKey());
        }

根据逆向生成的哈夫曼表查询生成原来的字节数组

取生成好的010101二进制字符串,根据哈夫曼编码表查出原来的每个字节,采用双指针移动的方式去hashmap中查询,查到了对应的字节,吧它加入到我们的list中,再把list转换成byte数组进行返回

    // 编写一个方法,完成对压缩数据对解码
    /**
     * @param huffmanCodes 哈夫曼编码表
     * @param huffmanBytes 哈夫曼编码得到对字节数组
     * @return 就是原来对字符串对应对数组
     */
    public static byte[] decode(Map<Byte, String> huffmanCodes, byte[] huffmanBytes) {
        // 1.先得到 huffmanBytes 对应对二进制字符串,形式10101000...
        StringBuilder builder = new StringBuilder();
        // 2.将byte数组转成二进制的字符串
        for (int i = 0; i < huffmanBytes.length; i++) {
            byte b = huffmanBytes[i];
            // 判断是不是最后一个字节
            boolean flag = (i == huffmanBytes.length - 1);
            builder.append(byteToBitString(!flag, b));
        }
        // 创建集合,存放byte
        List<Byte> list = new ArrayList<>();
        for (int i = 0; i < builder.length();) {
            int count = 1; // 小的计数器
            boolean flag = true;
            Byte b = null;
            while (flag) {
                // 取出一个bit '1'或者'0'
                String key = builder.substring(i, i + count); // i 不动 让count移动,直到匹配到一个字符
                b = map.get(key);
                if (b == null) {// 没有匹配到
                    count++;
                } else {
                    flag = false;
                }
            }
            list.add(b);
            i = i + count;
        }
        // 当for循环结束以后,list中存放了所有当字符
        // 把list中的数据放入到byte[] 并返回
        byte b[] = new byte[list.size()];
        for (int i = 0; i < b.length; i++) {
            b[i] = list.get(i);
        }
        return b;
    }

所有调用过程main函数

    public static void main(String[] args) {
        // TODO Auto-generated method stub
        String content = "abbcccddddeeeee";
        byte[] contentBytes = content.getBytes();
        byte[] huffmanCodeBytes = huffmanZip(contentBytes);
        byte[] source = decode(huffmanCodes, huffmanCodeBytes);
        System.out.println("原来的字符串=" + new String(source));
    }

压缩-解压的处理结果

处理前的字节数组:[97, 98, 98, 99, 99, 99, 100, 100, 100, 100, 101, 101, 101, 101, 101] 长度=15
处理后的字节数组:[77, -127, 85, -1, 1] 长度=5
原来的字符串=abbcccddddeeeee

总结

以上就就是关于所有哈java实现夫曼编码的所有细节,希望你对此有更清晰的的了解
完整源代码移步github

声明

1、如果在文章当中发现有描述错误的地方,还请您不吝指出,万分感谢!
2、此文章系本人原创作品,转发请注明出处!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,172评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,346评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,788评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,299评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,409评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,467评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,476评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,262评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,699评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,994评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,167评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,827评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,499评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,149评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,387评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,028评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,055评论 2 352

推荐阅读更多精彩内容