哈夫曼压缩原理及实现

哈夫曼算法是很经典的无损压缩算法,其变形——范式哈夫曼算法广泛用于图片领域,比如 jpeg 就用到了。也是因为要写 jpeg 的编解码器,我才会学习哈夫曼算法的。

原理

无损压缩,就是把一种信息表达换成另一种表达,其中尽可能去掉冗余信息。哈夫曼算法就是把高频字符用较短的编码表示,低频字符用较长编码表示,以此达到压缩数据的目的。

哈夫曼算法讲解网上有很多,这里只简单论述。

  1. 将字符从低频到高频排序得到一个数组。
  2. 构建最小权路径二叉树——哈夫曼树。
    1. 取数组前两个构建节点,低频为左子树,高频为右子树,频率相同,则深度小的在左。节点的频率为左右子节点的和,将新节点加入数组,按频率从低到高排序。
    2. 重复 2.1 步骤,完成树的构建。
  3. 生成编码表。从根节点开始,先序遍历,访问左子树得到 0,访问右子树得到 1。抵达叶子节点的时候会得到对应此条路径的 01 编码序列,这个编码就是叶子结点字符编码后的值。
  4. 根据编码表,翻译原数据。
  5. 保存编码表。这里的编码表和第 3 步不同。从根节点开始,先序遍历。非叶子节点,保存 0,叶子节点,保存 1 以及叶子结点字符(8 位二进制表示)。最终会保存一串 00111010001 这样的二进制序列。

解码则是对应的逆向操作。根据保存的编码表构建哈夫曼树,然后译码压缩后的数据。

注意,生成编码表的时候不能去掉前导 0。比如 0010 就是 0010,不是 10,压缩时保存的就是四位二进制。

此外,编码后数据在保存到 buffer 和读取 buffer 的时候要注意,如果字节内部索引是从右往左读取,保存和读取的时候需要反转二进制位。比如读取 10010,如果从右往左读取,尝试两位的时候是 01,而实际上应该是 10,因为生成的 code 是从根节点开始的:1 -> 0 -> 0 -> 1 -> 0。

实现

代码里按位读写二进制数据用到的 BitKit 代码在这里:ArrayBuffer 按位数存取数字

/**
 * 哈夫曼压缩
 **/
const Huffman = {
  decode(buf) {
    if (Object.prototype.toString.call(buf) !== '[object Uint8Array]') {
      throw new Error(`Huffman.decode 的参数需要是 Uint8Array`)
    }
    // 前两个字节是编码表字节长度
    const tableLength = (buf[0] << 8) + buf[1]
    // 构建哈夫曼树
    const trie = this._buildTrieReverse(buf.slice(2, tableLength + 2))
    // 生成编码表
    const table = this._getTable(trie)
    // 编码表值(code_bitLength)为 key, key 为值,方便解码时快速查询
    const reservedTable = {}
    for (let i = 0, len = table.length; i < len; i++) {
      if (table[i] !== undefined) {
        reservedTable[`${table[i].code}_${table[i].bitLength}`] = i
      }
    }
    // 解码
    // 原数据字节长度--数据前 4 个字节
    let dataLength = 0
    for (let i = 0; i < 4; i++) {
      if (dataLength) {
        dataLength <<= 8
      }
      dataLength += buf[tableLength + 2 + i]
    }
    // 原数据长度的二进制流
    const rawData = new Uint8Array(dataLength)
    // 压缩数据解码
    const compressedBuf = buf.slice(tableLength + 6)
    let decodeLength = 0
    let bitPos = 0
    let currBitLength = 0
    let currNum = undefined
    let detectiveCode = null
    while (decodeLength < dataLength) {
      currBitLength = 0
      currNum = undefined
      // 查找原数据
      while (currNum === undefined) {
        currBitLength++
        detectiveCode = BitKit.read(compressedBuf, bitPos, currBitLength)
        currNum = reservedTable[`${detectiveCode}_${currBitLength}`]
        if (currBitLength > 256) {
          throw new Error('解压错误')
        }
      }
      rawData[decodeLength] = currNum
      bitPos += currBitLength
      decodeLength++
    }
    return rawData
  },
  /**
   * 压缩
   * @param {Uint8Array} buf
   * @return Uint8Array
   **/
  encode(buf) {
    if (Object.prototype.toString.call(buf) !== '[object Uint8Array]') {
      throw new Error(`Huffman.encode 的参数需要是 Uint8Array`)
    }
    // 频率统计。
    // 统计一个字节共 256 种数值分别出现的次数
    let arr = new Array(256).fill(0)
    for (let i = 0, len = buf.length; i < len; i++) {
      arr[buf[i]]++
    }
    arr = arr.map((freq, num) => {
      return {
        num,
        freq
      }
    }).filter(v => v.freq)
    // 构建哈夫曼树
    const trie = this._buildTrie(arr)
    // 生成编码表
    const table = this._getTable(trie)
    // 编码--前面 4 个字节标识原数据字节长度
    let encodeBuf = new Uint8Array(buf.length + 4)
    for (let i = 0; i < 4; i++) {
      encodeBuf[i] = (buf.length >> ((3 - i) * 8)) & 0xff
    }
    let currBit = 32
    for (let i = 0, len = buf.length; i < len; i++) {
      // let bitLength = BitKit.getBitsByNum(table[buf[i]])
      let bitLength = table[buf[i]].bitLength
      if (currBit + bitLength > encodeBuf.length * 8) {
        const newBuf = new Uint8Array(encodeBuf.length + 1000)
        newBuf.set(encodeBuf)
        encodeBuf = newBuf
      }
      currBit = BitKit.write(encodeBuf, table[buf[i]].code, currBit, bitLength)
    }
    // encodeBuf 裁切
    encodeBuf = encodeBuf.slice(0, Math.ceil(currBit / 8))
    // 保存编码表
    const tableBuf = this._saveTable(trie)
    // 合并数据
    const finalData = new Uint8Array(encodeBuf.length + tableBuf.length)
    finalData.set(tableBuf)
    finalData.set(encodeBuf, tableBuf.length)
    return finalData
  },
  /**
   * 构建哈夫曼树
   * @param {array} arr
   * @returns trie
   **/
  _buildTrie(arr) {
    while (arr.length > 1) {
      // 升序排序
      arr.sort((a, b) => a.freq - b.freq)
      
      const node = {}
      node.left = arr.shift()
      node.right = arr.shift()

      // 权值相等,则深度较小的作为左子树
      if (node.left.freq === node.left.freq) {
        if (node.left.depth !== undefined && node.right.depth !== undefined && node.left.depth > node.right.depth) {
          const temp = node.left
          node.left = node.right
          node.right = temp
        }
      }
      
      node.freq = node.left.freq + node.right.freq
      node.depth = Math.max((node.left.depth || 0), (node.right.depth || 0)) + 1
      
      arr.push(node)
    }
    return arr[0]
  },
  /**
   * 生成编码表
   * @param {trie} trie
   * @returns array
   * 返回一个 256 长度的数组,下标为原数字,元素为对应的编码{ code: number, bitLength: number }——bitLength 用来避免 0b01 变成和 0b1。这是为了方便编码的时候直接通过下标查表。
   **/
  _getTable(trie) {
    const arr = new Array(256)
    
    getChildTrie(trie, 0, arr, 0)
    
    return arr
    
    function getChildTrie(trie, code, arr, bitLength) {
      if (!trie.left && !trie.right) {
        arr[trie.num] = {
          code,
          bitLength
        }
        return
      }
      if (trie.left) {
        getChildTrie(trie.left, code << 1, arr, bitLength + 1)
      }
      if (trie.right) {
        getChildTrie(trie.right, (code << 1) + 1, arr, bitLength + 1)
      }
    }
  },
  /**
   * 保存编码表
   * @param {trie} trie
   * @returns Uint8Array
   **/
  _saveTable(trie) {
    // 前两个字节标识编码表字节长度
    let buf = new Uint8Array(514)
    let currBit = 16
    
    recursive(trie, buf)
    
    // 编码表长度
    const length = Math.ceil(currBit / 8)
    buf[0] = length >> 8
    buf[1] = length & 0xff
    
    // 裁切
    return buf.slice(0, length + 2)
    
    function recursive(trie, buf) {
      // 非字符节点,保存 0
      if (trie.num === undefined) {
        currBit = BitKit.write(buf, 0, currBit, 1)
      } else {
        // 字符节点,保存 1 和字符(固定 8 位)
        currBit = BitKit.write(buf, 1, currBit, 1)
        currBit = BitKit.write(buf, trie.num, currBit, 8)
        return
      }
      // 左子树
      if (trie.left) {
        recursive(trie.left, buf)
      }
      // 右子树
      if (trie.right) {
        recursive(trie.right, buf)
      }
    }
  },
  /**
   * 根据编码表二进制逆向构建哈夫曼树
   * @param {Uint8Array} buf
   * @param {number} startBit 开始位
   * @returns trie
   **/
  _buildTrieReverse(buf, startBit = 0) {
    return build(buf)
    
    function build(buf) {
      const node = {}
      const flag = BitKit.read(buf, startBit)
      startBit++
      // flag有值,代表是字符节点——叶子节点
      if (flag) {
        node.num = BitKit.read(buf, startBit, 8)
        startBit += 8
        return node
      }
      // 左子树
      node.left = build(buf)
      // 右子树
      node.right = build(buf)
      return node
    }
  }
}

编码安全性和原数据大小限制

我这里是通过 Number 类型保存编码的。在 JavaScript 里,移位操作最多 32 位。因为哈夫曼树的每一层就是一位,所以不能超过 33 层。

如图所示。每次生成的新节点都不够大,导致平均每一层只有一个叶子结点。这就会导致编码可能超出 32 位的限制,毕竟原字符有 256 种(8位)。所以这里需要计算一下,原文本长度达到多少可能会导致哈夫曼树编码溢出。

设每一次新生成的节点频次为字符数组里剩下元素最小频次,第一层总共频次 1 + 1,第二层 2 + 2……。所以总共 2^32 次方字符数量,即 4,294,967,296 字节,即 4G 数据。

所以一般情况下,我这里的哈夫曼压缩实现还是安全的。实际情况下,也可以分块压缩。毕竟哈夫曼算法需要先遍历数据统计频次,一次性遍历太大的数据也不好。分成小块,比如 1M 一块,进行压缩更现实一些。

总结

哈夫曼算法的压缩率并不是很高,我试了一下,对《舞舞舞》这本小说的 txt 文本进行压缩,压缩率还不到 30%。lzw 也许更好。