Huffman编码

317 阅读12分钟

背景

  英文字母大小写总共就52个,一本英文书籍几十上百万的英文单词都是由这52个字符排列组合而成,不难看出这52个字符肯定是大量重复了。

  一本中文小说几百万字,也都是由常用的几千个汉字组合而成。如果是一本玄幻小说,那么在相近的章节中一定大量的重复出现人名,地名,功法境界,以及主角在一段时间内修炼的功法。至于主角名字更是贯穿整本小说一定大量重复。还有作者的写作风格是保持一致的,因此文章有时候在描写一些紧张氛围或者描写反面人物可能会使用相似的句式和词语来表达,这些可能会造成很高的重复率。

  图片是由像素点组成的而每个像素点是由rgb:三元素组成。这些都是由0~255的数字表示,因此可以将图片看作一堆数字。一张小的图片的像素点至少也有几千,如果是高清图片估计有上百万个像素点。每一个像素点都是三个0~255的数字,组成图片的像素点一定存在大量的重复数字;一些图片有很大范围的背景色这种情况下,数字更是会发生大量的重复。

  不管是中文,英文还是图片的像素点,如果数据量很大那么肯定存在大量重复字符。一个英文字符是8bit,一个中文字符使用UTF-8编码会占3个字节24bit;如果要压缩数据,从字符编码角度考虑应该怎么去压缩呢?可以思考三秒(PS:就这几段句子已经重复出现重复了很多次了)


Huffman编码

   现在有一段英文文本AAAAAAAAABBCCCDDCCCBBBAAAAABB,这段文本A 出现14次,B出现7次,C出现6次,D出现2次。正常编码,这个文本所占bit:(14+7+6+2)* 8 = 232bit。

  • A的编码是:0100 0001
  • B的编码是:0100 0010
  • C的编码是:0100 0011
  • D的编码是:0100 0100

  我们可以思考下,A,B,C,D这四个字符在这段文本中需要用8bit来表示吗?是不是可以用更少的字符来表示这几个字符?重新给这4个字符编码:

  • A-> 1
  • B-> 10
  • C-> 11
  • D-> 100

  重新编码之后,这段文本所占bit:14 * 1 + 2 * 7 + 2 * 6 + 2 * 3 = 46bit;重新编码之后的文本大小只占原文本的20%左右。在这个例子中,A编码最短,BC次之,D最长我们是按照字母顺序来编码的吗?显然不是的,在给字符编码时,是按照字符使用的频率来决定该如何编码。如果使用次数多也就是频率高的就尽量用短编码,使用次数少频率低就用长编码。这样才能尽可能的降低压缩后的字符长度。Huffman编码在对字符重新编码时的指导思想就是这样的。

  我们继续上面的例子,将A,B,C,D按照上面的方法编码并创建一个对照表。

ABCD
11011100

   根据重新编码后的字符,上面英文文本的二进制编码是 :111111111101011111110010011110111111101010111111010,根据这段编码我们该如何解码,将压缩后的二进制码还原成字符?

在这里插入图片描述   根据上面的编码我们可以看出,在解压时没法恢复源文本了。最开始的9个1,可以有多种解析方式:可以4个C 一个A,这种情况下A位置变化都有5种。还可以有3C,3A的组合。。。。等等其他组合根本没有办法确定这9个1该如何解码。

  那该如何编码呢?既要按照字符使用频率来编码,又要在解压时能够复原文本。这时候Huffman树就隆重出场了Huffman树的构建很简单,但是构建过程非常巧妙。Huffman树的构建规则:

  • Huffman树是一个二叉树
  • 所有字符节点都是叶子节点
  • 每次都用2个最少使用频率节点来构建新节点;

  下面会用图解来展示如何构建一颗Huffman树:


  •   最开始有 A,B,C,D四个节点,经过排序之后由较小的2个节点生成新的节点。因此选择C,D来生成新节点:8;生成新节点之后C,D节点不再参与后续的过程。

  •   参与第二次构建Huffman树的新节点:8,B(7),A(14),这3个节点再比较使用次数: 7 < 8 < 14 ;因此使用 B(7),8节点来构建新节点:15;

  •   第三次参与构建新节点的的节点: 15,A(14);直接用这2个节点生成root节点;Huffman树构建结束。
  •    上面的过程汇总图:


  可以看出来,使用频率最高的A,从根节点到A的路径是最短的,而使用频率最小的D,从根节点到D的距离是最长的。这路劲长短正好对应了使用频率的高低。如果一个节点链接left节点用 0 表示;链接 right 节点用 1表示;(反过来也可以)那么从root节点到叶子节点的路径就可以用01字符串来表示了;



  A,B,C,D构建的Huffman树形成了新的编码:

ABCD
101001000

  英文:AAAAAAAAABBCCCDDCCCBBBAAAAABB经过构建Huffman树重新编码之后的压缩文本是:1111111110101001001001000000001001001010101111110101一共52字符;只有原文的52/232 = 22%左右大小;

  回到关键问题,如何解压呢?

  也很简单,在解压时只需要对照Huffman树来解压就行。从root节点往下找,找到叶子节点就找到了对应的字符。用这种方式顺序读取二进制码对照Huffman树就可以解析文本了。

代码部分

  • Huffman树的构建:

   //Huffman树节点
    class HMNode{
        int val;
        HMNode left;
        HMNode right;
        boolean leaf;//是否叶子节点
        char str;

        public HMNode(int val){
            this.val = val;
        }
        public HMNode(int val,char str){
            this.val = val;
            leaf = true;
            this.str = str;
        }

        @Override
        public String toString() {
            return "val="+val+";char="+(str=='\0' ? '_':str);
        }
    }


//统计文本各个字符使用频率
    public Map<Character,Integer> count(String text){
        Map<Character,Integer> map = new HashMap<>();
        for (int i = 0; i <text.length() ; i++) {
            char t = text.charAt(i);
            if(map.containsKey(t)){
                map.compute(t,(key,val)->val+1);
            }else{
                map.computeIfAbsent(t,key->1);
            }
        }
        return map;
    }

//构建Huffman树
    public HMNode buildHuffManTree(String text){
        Map<Character,Integer> map = count(text);
        List<HMNode> nodes = new ArrayList<>(map.size()<<1);
        map.forEach((key,val)->nodes.add(new HMNode(val,key)));
        nodes.sort((n1, n2)-> n1.val-n2.val);
        int start = 0;
        while(nodes.size()-2>=start){
            HMNode root  = new HMNode(nodes.get(start).val+nodes.get(start+1).val);
            root.left = nodes.get(start);
            root.right = nodes.get(start+1);
            nodes.add(root);
            nodes.sort((n1, n2)-> n1.val-n2.val);
            start+=2;
        }
        return nodes.get(start);
    }
  • 测试
    @Test
    public void test(){
        String text = "aaabbbbbccccccddddee";
        HMNode root =buildHuffManTree(text);
        List nodes =  new ArrayList();
        nodes.add(root);
        print(nodes);

    }


    public void print(List<HMNode>nodes){

        List<HMNode> c = new ArrayList<>();
        System.out.println("\n");
        for (HMNode hmNode : nodes){
            if(hmNode.left != null)c.add(hmNode.left);
            if(hmNode.right != null)c.add(hmNode.right);
            System.out.print(hmNode+ "\t");
        }
        System.out.println("\n");
        if(!c.isEmpty())print(c);
    }
=========================================res=================================================
//结果:char=_	===> 表示新生成的节点
												val=20;char=_	
	


						val=9;char=_									val=11;char=_	



		val=4;char=d				val=5;char=b	 		val=5;char=_	   val=6;char=c	



													val=2;char=e	val=3;char=a	

  • 将Huffman树转化成编码

//左分支 + 0  ; 右分支 + 1

    public void huffmanCode(HMNode root,Map<String,Character> huffmanCode,String code){
        if(root.leaf){
            huffmanCode.put(code,root.str);
            return;
        }
        if(root.left != null){
            huffmanCode(root.left,huffmanCode,code+"0");
        }
        if(root.right != null){
            huffmanCode(root.right,huffmanCode,code+"1");
        }
     }

    @Test
    public void test(){
        String text = "aaabbbbbccccccddddee";
        Map<Character, Integer> count = count(text);
        HMNode root =buildHuffManTree(text);
        Map<String,Character>code = new HashMap<>();
        huffmanCode(root,code,"");
        code.forEach((key,val)->System.out.println(val+" -> "+key));

    }

=========================================res=================================================

d -> 00
c -> 11
b -> 01
e -> 100
a -> 101



字符的Huffman(bit)编码

   在上一节,将Huffman树转化成了01 构成的字符串,显然在实际应用中不是这种操作。我们实际想要的是01构成的一串bits;举个例子:字符"A" 编码:0100 0001(8bit),假设我们重新编码之后字符 "A" 的路径是01,只占2个bit,应该用 01(bit)表示,而不是字符类型的"01",如果用字符类型的01来重新编码,那经过Huffman编码之后的数据比原本的数据还大。

  其实最开始用01来表示左右分支,也是考虑到一个bit也只有01两种取值,刚好对应了Huffman树的左右分支。想象在Java中不能直接使用bit数组,只好用其他的数据类型来代替。可以使用byte,char,short,int,long来记录编码路径。

  使用单个的变量来记录编码路径可能会出现问题;比如要使用Huffman编码来重新给一本中文小说编码,一本中文小说可能会出现几千个不同的汉字,由这几千个汉字构成的Huffman树路径就会很长单个的变量就不能完整的记录字符的路径,这种情况只能使用数组来记录每一个字符的编码了。

未命名绘图009.drawio.png

代码:

    

void bitCode(HMNode cur,String path,Map<Character,byte[]> bitCode){
       if(cur.leaf){
//           System.out.println(cur.val+" -> "+path);
           bitCode.put(cur.val,bitHuffmanCode(path));
           return;
       }
       if(cur.left != null){
           bitCode(cur.left,path+0,bitCode);
       }

       if(cur.right != null){
           bitCode(cur.right,path+1,bitCode);
       }

    }

byte[] bitHuffmanCode(String code){
    int len = code.length()%8 ==0 ? code.length()/8 +1:code.length()/8+2;
    byte[] bitCode = new byte[len];
    int i=1,bitOffset = 7;
    bitCode[0]=(byte)(code.length()%8 == 0 ? 8 :code.length()%8);
    for (char c : code.toCharArray()){
        byte bit =(byte)(( c -'0') << bitOffset);
        bitCode[i] |=bit;
        bitOffset--;
        if(bitOffset == -1){
            bitOffset = 7;
            i++;
        }
    }
    return bitCode;
}
        

文本字符进行编码与解码

   有了Huffman编码之后,就只需要一个个读取文本字符,然后按顺序将每个字符的编码拼接起来。

public  enum Size{
    B(1),KB(1<<10),MB(1<<20),GB(1<<30);
    int val;
    public int getVal(){
        return val;
    }
    Size(int val){
        this.val = val;
    }
}
public  class EncodeInfo{
    long[] textCode;
    int index;//数据当前位置
    //数组中最后一个long的无效位,初始值是64;
    int bitOffset=8;//bit偏移位置:8 -> 0
    // 214 748 364 7 --> Integer.MAX_VAlUE
    //922 337 203 685 477 5807 --> lONG.MAX_VAlUE
    public EncodeInfo(int len,Size size){
        int textLen = len * size.getVal();
        if(textLen < 0)
            throw new ArithmeticException("超过限制。。");

        textCode  = new long[textLen];
    }
    public EncodeInfo(){
        this(4,Size.KB);
    }
    public EncodeInfo(int len){
        this(len,Size.KB);
    }
}
                       

  重新编码之后文本的编码保存到EncodeInfo 中。Size:可以控制EncodeInfo创建数组大小 ;

                       
           
private static final long CONVERT = (1<<8)-1;//byte -> long
   
public  void encode(String text,EncodeInfo info){
   HMNode root =  buildHuffManTree(text);
   Map<Character,byte[]> bitCode = new HashMap<>();
   bitCode(root,"",bitCode);//字符经过Huffman树重新编码;
   encodeText(info,text,bitCode);//将每个字符的char[]拼接起来保存到EncodeInfo的long[]数组textCode中。
   decode(info,root);//解码,将经过Huffman编码的字节数组,利用Huffman树还原成字符。
}
    

public void  encodeText(EncodeInfo info, String text, Map<Character,byte[]> map){
    for (char t : text.toCharArray()){
        encodeChar(info,map.get(t));
    }
}

void encodeChar(EncodeInfo info,byte[] charCode){
    for (int i = 1; i < charCode.length-1; i++) {
        operationBits(info,charCode,i,8);
    }
        operationBits(info,charCode,charCode.length-1,charCode[0]);
}
                                          
void operationBits(EncodeInfo info,byte[] charCode,int i,int validBitLen){
    long c = charCode[i] & CONVERT;//将byte转换成long
    if(info.bitOffset >= 8){
        info.textCode[info.index] |= (c << (info.bitOffset-8));
        info.bitOffset-=validBitLen;
    }else{
        info.textCode[info.index] |= (c>>>(8-info.bitOffset));
        if(info.bitOffset >= validBitLen){//剩余位置比byte的有效bit还要长,就可以直接将bit添加到剩余的空位置中 ;
            info.bitOffset-=validBitLen;
        }else{
            long next = (c << info.bitOffset) & CONVERT;//将后半部分顶到前面;
            long nextLen = validBitLen - info.bitOffset;//后半部分的长度
            nextPosition(info);//下一个变量来保存剩余的后半部分;
            info.textCode[info.index] |= (next <<(info.bitOffset -8));
            info.bitOffset-=nextLen;
        }
    }
    //当这个long没有空余位置,就要将指针往后移;
    if(info.bitOffset == 0){
        nextPosition(info);
    }
}
    
void  nextPosition(EncodeInfo info){
    if(info.index +1  == info.textCode.length)
        throw new RuntimeException("数据太多,数组装不下数据了。。");//后续改进,可以分批读取数据压缩。假如有一个很大的文本需要被压缩,比如10G这个时候不需要将本文全部读取到内存中一次性压缩,可以分批读取压缩保存到磁盘。
    info.index+=1;
    info.bitOffset=64;
}


    //解码      
public void decode(EncodeInfo info ,HMNode root){
    long[] textCode = info.textCode;
    System.out.println("\n------------------------\n");
    HMNode cur = root;
    for (int i = 0; i < info.index; i++) {
        cur = parse(cur,textCode[i],64,root);
    }
    parse(cur,textCode[info.index],64-info.bitOffset,root);
}
                                   
                                   
                                   
 HMNode parse(HMNode cur,long bits,int len,HMNode root){
    long curBit = 0;
    while(cur != null && len > 0){
        curBit = (bits>>>(8-1));
        if(curBit == 1 ){
            cur = cur.right;
        }else{
            cur = cur.left;
        }
        bits<<=1;
        len--;
        if(cur.leaf){
            System.out.print(cur.val);
            if(len ==0)
                return root;
            else
                break;
        }

    }
    if(len == 0)return cur;

    return parse(cur=root,bits,len,root);
}

    
    

测试


@Test
public void testEncode(){
    String text = "急急急uu  ,.;/.'*&^%$广东福建" +
            "的好的的德国费尔法的的发夫算法发生过" +
            "和323434234123吧vDVD产生的dgdfgrtgvdverterfddfewf" +
            "csbfgjuuoioipljl.k,mbnnvscawdqw12ewer3465678i98ok东方广场v" +
            "必然会与i一头金发不然" +
            "的复合体和v地方法规和肉体和国外荣" +
            "格认为他还好吧已的发表个人观点反复播放" +
            "功能肉体和人格杠登革热" +
            "而突然与欧i醍醐灌顶的vfgddr5tt14234567890-poiuwqwertjhsdghjkl,mnbzxcvbnmverhjk'[;" +
            "挂号费更多等待等同于合同部分欲哭嘉年华我惹人讨厌人文档分隔符返回给儿童会更好" +
            "个人人共和国合同也就一天放个假法国韩国功夫大他吞吞吐吐灌灌灌灌灌灌灌灌反反复" +
            "复烦烦烦方法嘎嘎嘎嘎嘎嘎嘎" +
            "酷酷酷酷酷酷酷酷哈哈哈哈哈哈哦哦哦哦哦哦怕" +
            "怕怕怕怕呸呸呸噗噗噗破坏有有有由于呃呃呃呃呃呃师v不错v相对方位";


    System.out.println(text);


    EncodeInfo info  = new EncodeInfo(1,Size.KB);
    encode(text,info);

    long[] tt = info.textCode;
    System.out.println("\n----------------\n");
    for (int i = 0; i <= info.index; i++) {
        System.out.println(tt[i]);
    }
    System.out.println("最后一个long的有效位:"+(64-info.bitOffset));

    System.out.println("++++++++++++++++++++++++++++++++++++++");
    System.out.println(text.getBytes().length);
    System.out.println((info.textCode.length-1)*8 + (64-info.bitOffset)/8);
}