huffman树_逻辑结构.树.Huffman树

数据压缩

Not all bits have equal value. -Carl Sagan

计算机通过01构造出了丰富多彩的世界,图片、文本、视频……无论是何种形式的呈现,都是将01按照某种特定的规范排列。

但是将数据展示给我们看是一回事,数据的保存和传输又是另一回事:展示给人们的数据需要满足人的视觉体验,而保存和传输的数据并不需要满足计算机的视觉体验~计算机要做的就是更多地存储数据、更快地传输数据,故用一种新的规范来重新组合01,使得原本的数据占用空间更小,更利于传输,这就是一种数据压缩的思想。而将数据从压缩的规范转换回其原始的规范表示,就是解压缩了。

59c42e9c7936f867c6bb9a84c31e6ccd.png

编码问题

在计算机中数据的每一个元素的01串表示就可以看作是该元素的编码,例如文本'A'的ASCII码为65,那么其在计算机中的编码就是100 0001

要定义一个新的规范来实现数据的压缩,其实就是对数据中的每一个元素重新编码。反之,解压缩就是恢复编码(解码)的过程。

40051deb0fa944bcfcaf08928ad1a17b.png

例如:对于一串文本ABC其ascii码表示为1000001 1000010 1000011,假设为了压缩这段文本,我重新令A的编码为00、B的编码为01C的编码为10,那么按照这个新的规范重新表示ABC就是000110。这样就是一个简单的压缩了。

但是编码需要注意一个问题:编码间不混淆。试想如果A的编码为110B的编码为1101,那么在解码时遇到这样1101...的一串编码,我是读出一个A呢,还是一个B呢?

数据重新编码压缩后,必须能够重新解码,否则这种压缩和直接删除文件没有什么区别~为了解决这种混淆的问题,可以有三种解决方法:

  1. 定长编码。

    ascii码就是定长编码,每一个字符长度为1byte,所以我们在解析的时候一个byte一个byte解析就好。但是定长的编码不够灵活,很难达到对整体压缩的效果

  2. 不定长编码,每一个编码的结束使用特定的标识符。

    这种方式需要给标识符一个特定的01编码,那么每一个编码之后都需要再加上一串标识符的编码,这种情况下还要实现压缩似乎就有些任重而道远……

  3. 不定长编码,使用原生不会混淆的编码

    即不会有任一个编码为另一个编码的前缀(prefix-free)。跟前几种比较起来,这种就是空间利用率最大的,也正是我们想要的。

获取prefix-free编码

为了获取这种有助于我们压缩的编码,大神前辈从二叉树中获取了灵感:

  • 将数据元素存储在二叉树的叶子结点
  • 编码为根结点到该叶子结点的路径。由于二叉树非左即右,恰好切合计算机存储的非0即1,如果令左0右1,就可以用01表示根到叶子结点的路径作为编码。且叶子结点没有子结点,故这种编码不会存在前缀问题。
a25d22679d1bb05eae196354f9eea31c.png

接下来的问题就是如何构建一棵二叉树,能够实现重新编码后的数据得到压缩。

Huffman树

一个文本就是256个字符的组合,这之中必然有部分字符出现的频率更高,一部分出现的频率更低。对于不定长的编码,一种压缩的思路就是出现频率高的字符编码尽量短,反之编码可以长一些。简而言之:非均匀地更加合理地分配空间

对应到树上为频率高的字符更靠近根,频率低的字符更远离根。

哈夫曼树就是能够实现这种思路的一种满二叉树

哈夫曼树的构建

哈夫曼树采用一种贪心的思想,自底向上构建。树上的每一个结点有一个频率属性,用于比较;仅在叶子结点存储字符。假设当前文本的所有字符的集合为S,那么构建的过程如下:

  • 每一次从S中选出两个频率最低的字符,分别创建成两个树上结点,接着将这两个字符的频率相加衍生出一个父结点,父结点再作为一个字符放回集合S中。
  • 重复以上操作,直至S集合中只有一个结点。这个结点就是,保存了所有字符出现频率的总和。
5c2daf2cbf9391ab6321dd7cad760ea4.png
5bd173bb59d6122fff9f4f1c643ca349.png
5085551092f93fb6292fcbe9a72f8f03.png
169bcf3b073d8807e6df87fb14ae4cb1.png
4bbef1612202db3168aa2b30137e9df5.png

可以发现哈夫曼的构建是一个逐步合二为一的过程,所以每一个结点要么没有子结点、要么有两个子结点,故哈夫曼树是一棵满二叉树;同时由于每一次都是取出频率最小的两个结点,自底向上,故频率越小的结点会在越下边,这样就能够实现频率小编码长、频率高编码短的目的。

/**
* 哈夫曼树结点
*/
private static class Node implements Comparable<Node> {

  private final char ch;      // 字符,仅用于叶子结点
  private final int freq;     // 字符出现频率,仅用于叶子结点
  private Node left, right;

  public Node(char ch, int freq, Node left, Node right) {
    this.ch = ch;
    this.freq = freq;
    this.left = left;
    this.right = right;
  }

  public Node(char ch, int freq) {
    this.ch = ch;
    this.freq = freq;
  }

  public boolean isLeaf() {
    return this.left == null && this.right == null;
  }

  @Override
  public int compareTo(Node o) {
    return this.freq - o.freq;  // 通过freq(频率)来比较
  }
}
/**
* @param freq 统计字符出现频率的数组
* @return huffman树的根
*/
private Node buildTrie(int[] freq) {
  Queue pq = new PriorityQueue<>();
  Node a, b, parent;for (char c = 0; c // R=256,总的字符集大小if (freq[c] > 0) {
      pq.add(new Node(c, freq[c]));
    }
  }while (pq.size() > 1) {
    a = pq.remove();
    b = pq.remove();
    parent = new Node('\0', a.freq + b.freq, a, b);
    pq.add(parent);
  }return pq.remove();
}

Huffman编码

构建完Huffman树之后,编码的获取就容易多了,只需遍历整个二叉树,一路上左0右1,到达叶子结点时构成的01串就是该叶子结点字符的编码。

// 构建字符-编码表
private Map buildCode(Node r, String code) {
  Map map = new HashMap<>();
  buildCode(map, r, code);return map;
}private void buildCode(Map codeMap, Node n, String code) {if (n == null) {
  } else if (!n.isLeaf()) {
    buildCode(codeMap, n.left, code + '0');
    buildCode(codeMap, n.right, code + '1');
  } else
    codeMap.put(n.ch, code);
}

数据传输

有了哈夫曼编码表之后,原先的文件可以通过编码表一一转换成哈夫曼编码后传输。但是光传编码出去是不够的,别人对着你转换后一大串0101只能一脸蒙圈,所以还需要将解码的方法传出去(这也是一种难以避免的空间开销),两种方式:

  1. 传编码表。

    对于这种方式我所能想到的是(字符ascii,编码)这样一对对地传,这样的话由于编码是不定长的,所以每一对的界限不清晰,对方并不能解析;

    当然你也可以(字符ascii,频率)这样传,但是这样传频率至少需要一个int类型的大小来存,空间消耗有些大。并且频率的具体值并不是我们所关心的,我们关心的是频率的大小关系。所以我更趋向于第二种传输方式。

  2. 传哈夫曼树。

    树不是一种线性的结构,我们需要把其转换成线性的结构才能够用01串传输出去。将树以线性形式表示就是遍历了,所以传输的是哈夫曼树遍历的序列

    假设规定好传输前序遍历序列,以0表示分支结点,以1表示叶子结点,一旦遇到叶子结点就补上叶子结点的字符信息(8bit)。这样之后在解码的时候,只需要按照前序遍历的逻辑来分析01串:一旦遇到1,则后边8bit表示的是该叶子结点处的字符。

35c771857351d01e2eb99cbd4efed9b5.png
/**
* 前序遍历将Trie树输出
* 分支结点0/false,叶子结点1/true
*
* @param n 当前遍历到的结点
*/
private void writeTrie(Node n) {
  if (n.isLeaf()) {
    BinaryOutputUtil.write(true);  // 此工具类将boolean类型以一个bit的形式写出
    BinaryOutputUtil.write(n.ch, 8);
    return;
  }
  BinaryOutputUtil.write(false);
  writeTrie(n.left);
  writeTrie(n.right);
}
private Node readTrie() {
  boolean isLeaf = BinaryInputUtil.readBoolean();
  if (isLeaf)
    return new Node(BinaryInputUtil.readChar(), -1, null, null);
  else
    return new Node('\0', -1, readTrie(), readTrie());
}

实现了哈夫曼对文件的重新编码后,再加上对文件的读入输出,就可以实现一个简单的压缩软件了。大体的思路如下:

定义压缩文本的构成:哈夫曼树 编码后的文本

  • 压缩:
    • 读入文件内容
    • 统计每一个字符的出现频率
    • 根据频率构建哈夫曼树,获得哈夫曼编码
    • 将哈夫曼树前序序列写入新的目标文件
    • 根据编码表,将文本逐个字符重新编码,写入目标文件
    • 压缩完成
  • 解压缩:
    • 读入哈夫曼树
    • 读入重新编码后的文本
    • 根据哈夫曼树进行解码,解码结果输出
    • 解压缩完成

小总结

  • 数据压缩就是用一种新的规范重新组织01串。
  • 哈夫曼树是一棵最优满二叉树,哈夫曼编码是从根到存储数据的叶子结点的路径
  • 哈夫曼树的构建是贪心的过程,自底向上,每一次选出两个频率最小的结点衍生出一个父节点。
  • 因此频率越小的结点,总是越远离根,使得频率低编码长,频率高编码短。
  • 本质就是非均匀的更加合理地分配空间

完整代码

https://gitee.com/bankarian/data-structure/tree/master/src/main/java/com/beney/ds/trees/compress

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值