哈夫曼編碼 —— Lisp 與 Python 實現

雨神姥爺發表於2019-03-04

支援一下掘金的原創功能~

原文地址:哈夫曼編碼 —— Lisp 與 Python 實現

SICP 第二章主講對資料的抽象,可以簡單地理解為 Lisp 語言的資料結構。當然這樣理解只可作為一種過渡,因為 Lisp 中並不存在資料與過程的差別,也就完全不需要一個額外的概念來描述這種對資料抽象的過程。2.3.4 以哈弗曼編碼為例展示瞭如何在 Lisp 中實現哈夫曼二叉樹資料結構的表示與操作,本文在完成該小節習題(完整的哈夫曼編碼樹生成、解碼與編碼)的基礎上,將 Lisp(這裡用的是 DrRacket 的 #lang sicp 模式,即近似於 Scheme)與 Python 版本的程式進行對比。

1. 哈夫曼樹

哈夫曼編碼基於加權二叉樹,在 Python 中表示哈夫曼樹:

class Node(object):
  def __init__(self, symbol='', weight=0):
    self.left = None
    self.right = None
    self.symbol = symbol # 符號
    self.weight = weight # 權重複製程式碼

Lisp 通過列表(List)表示:

(define (make-leaf symbol weight) (list 'leaf symbol weight))
(define (make-tree left right)
  (list left
        right
        (append (symbols left) (symbols right))  ; 將葉子的字元快取在根節點
        (+ (weight left) (weight right)))
)

;; methods
(define (leaf? object) (eq? (car object) 'leaf))
(define (symbol-leaf leaf) (cadr leaf))
(define (weight-leaf leaf) (caddr leaf))

(define (symbols tree)
  (if (leaf? tree)
        (list (symbol-leaf tree))
        (caddr tree)))
(define (weight tree)
  (if (leaf? tree)
      (weight-leaf tree)
      (cadddr tree)))複製程式碼

2. 哈夫曼演算法

為了生成最優二叉樹,哈夫曼編碼以字元出現的頻率作為權重,每次選擇目前權重最小的兩個節點作為生成二叉樹的左右分支,並將權重之和作為根節點的權重,按照這一貪婪演算法,自底向上生成一棵帶權路徑長度最短的二叉樹。

依據這一演算法,可以從“字元-頻率”的統計資訊中建立一棵哈夫曼樹,在 Python 實現中,只需要每次對所有節點重新按照權重排序即可:

# frequency = {'a': 1, 'b': 3, 'c': 2}
def huffman_tree(frequency):
  SIZE = len(frequency)
  nodes = [Node(char, frequency.get(char)) for char in frequency.keys()]
  for _ in range(SIZE - 1):
    nodes.sort(key=lambda n: n.weight) # 對所有節點按照權重重新排序
    left = nodes.pop(0)
    right = nodes.pop(0)
    parent = Node('', left.weight + right.weight)
    parent.left = left
    parent.right = right
    nodes.append(parent)
  return nodes.pop()複製程式碼

Lisp 通常採用遞迴的過程完成迴圈操作,一種類似插入排序的有序集合實現如下:

(define (adjoin-set x set)
  (cond ((null? set) (list x))
        ((< (weight x) (weight (car set))) (cons x set))
        (else (cons (car set) (adjoin-set x (cdr set))))))複製程式碼

下面這段與 Python 列表推斷功能相似的過程,這也許可以讓你更加感受到 Python 的簡潔與美妙:

(define (make-leaf-set pairs)
  (if (null? pairs)
      '()
      (let ((pair (car pairs)))
        (adjoin-set (make-leaf (car pair)
                               (cadr pair))
                    (make-leaf-set (cdr pairs))))))複製程式碼

最後,基於“字元-頻率”的有序集合生成哈夫曼編碼樹:

(define (generate-huffman-tree pairs)
  (define (successive-merge leaf-set)
    (cond ((null? leaf-set) '())
          ((null? (cdr leaf-set)) (car leaf-set))
          (else (successive-merge (adjoin-set 
            (make-code-tree (car leaf-set)
                            (cadr leaf-set))
            (cddr leaf-set))))
    ))
  (successive-merge (make-leaf-set pairs)))複製程式碼

3. 編碼與解碼

有了哈夫曼樹之後,可以進行編碼和解碼。編碼過程需要找到字元所在的葉子節點,以及從根節點到該葉子節點的路徑,每次經過左子樹搜尋記作"0",經過右子樹記作"1",因此可以利用二叉樹的先序遍歷,遞迴地找到葉子節點和路徑:

def encode(symbol, tree):
  bits = None

  def preOrder(tree, path):
    if tree.left:
      preOrder(tree.left, path + "0")
    if tree.right:
      preOrder(tree.right, path + "1")
    if tree.isLeaf() and tree.symbol == symbol:
      nonlocal bits
      bits = path

  preOrder(tree, "")
  return bits複製程式碼

Lisp 的二叉樹中每層根節點快取了所有子樹中的出現的字元,以空間換取時間:

(define (left-branch tree) (car tree))
(define (right-branch tree) (cadr tree))

(define (encode-symbol char tree)
  (cond ((null? char) '())
        ((leaf? tree) '())
        ((memq char (symbols (left-branch tree))) 
          (cons 0 
            (encode-symbol char (left-branch tree))))
        ((memq char (symbols (right-branch tree)))
          (cons 1
            (encode-symbol char (right-branch tree))))
        (else (display "Error encoding char "))))複製程式碼

解碼過程更直接一些,只要遵循遇到"0"取左子樹,遇到"1"取右子樹的規則即可:

def decode(bits, tree):
  result = ""
  root = tree
  for bit in bits:
    if bit == "0":
      node = root.left
    elif bit == "1":
      node = root.right
    else:
      return "Code error: {}".format(bit)
  if node.isLeaf():
    result += node.symbol
      root = tree
  else:
    root = node
  return result複製程式碼

(define (choose-branch bit branch)
  (cond ((= bit 0) (left-branch branch))
        ((= bit 1) (right-branch branch))
        (else (display "bat bit: CHOOSE-BRANCH" bit))))

(define (decode bits tree)
  (define (decode-1 bits current-branch)
    (if (null? bits)
        '()
        (let ((next-branch
               (choose-branch (car bits) current-branch)))
          (if (leaf? next-branch)
              (cons (symbol-leaf next-branch)
                    (decode-1 (cdr bits) tree))
              (decode-1 (cdr bits) next-branch)))))
  (decode-1 bits tree))複製程式碼

需要注意的是上面的演算法並不能保證每次生成的哈夫曼樹都是唯一的,因為可能出現權值相等以及左右子樹分配的問題,但是對於同一棵樹,編碼和解碼的結果是互通的。

4. 總結

雖然未必會用到 Lisp 作為開發語言,但並不妨礙我們學習、吸收其中優秀的思想。SICP 前兩章分別介紹了對過程的抽象和對資料的抽象,其中一個重要的思想就是編碼的本質是對計算過程的描述,而這種描述並不拘泥於某種特定的語法或資料結構;對過程的抽象(例如(define (add a b) (+ a b)))與對資料的抽象(例如(define (make-leaf symbol weight) (list 'leaf symbol weight)))之間並沒有本質的差異。

Python 在確保簡介、優雅的同時也擁有驚人的靈活性,我們甚至可以模仿 Scheme 的語法來完成上面的所有程式:

def make-leaf(symbol, weight):
  return ['leaf', symbol, weight]
def leaf?(obj):
  return obj[0] == 'leaf'複製程式碼

雖然像上面這樣做毫無意義,但是將對過程的描述抽象到函式層面,然後對函式進行操作的思想在 Python 中同樣非常重要。

上面的程式碼大部分是在旅途中的火車或汽車上完成的,少有這樣的機會體驗一下離線程式設計的“樂趣”,sortnonlocal 的用法還要多虧寫 PyTips 時的總結,因此還是希望有時間可以寫滿 0xFF

相關文章