Bootstrap

哈夫曼、算术、LZ编码

LZ 编码

基础知识

LZ编码(Lempel-Ziv Code)巧妙地将字典技术应用于通用数据压缩领域,而且可以从理论上证明LZ系列算法可以逼近信息熵的极限。

设信源符号集 A = { a 1 , a 2 , . . . , a K } A=\{a_1,a_2,...,a_K\} A={a1,a2,...,aK} K K K个符号,设输入信源符号序列为 u = ( u 1 , u 2 , . . . , u L ) u=(u_1,u_2,...,u_L) u=(u1,u2,...,uL);编码时将此序列分成不同的段。分段规则为:尽可能取最少个相连的信源符号,保证各段都不同。

开始时,先取一个符号作为第一段,然后继续分段。若出现与前面相同的符号时,就再取紧跟后面的一个符号一起组成一个段,使之与前面的段不同。这些分段构成字典。

编码的码字由段号加后面一个符号组成。设 u u u构成的字典中的短语共有 M ( u ) M(u) M(u)个。若编为二元码,段号所需码长 n = ⌈ log ⁡ M ( u ) ⌉ n=\lceil \log M(u)\rceil n=logM(u)⌉,每个符号需要的码长为 ⌈ log ⁡ K ⌉ \lceil \log K\rceil logK,单符号的码字段号为 0 0 0

例子

U = { a 1 , a 2 , a 3 , a 4 } U=\{a_1,a_2,a_3,a_4\} U={a1,a2,a3,a4},信源符号为 a 1 a 2 a 1 a 3 a 2 a 4 a 2 a 4 a 3 a 1 a 1 a 4 a_1a_2a_1a_3a_2a_4a_2a_4a_3a_1a_1a_4 a1a2a1a3a2a4a2a4a3a1a1a4

首先一共有4个符号,那么每个符号需要的码长为 log ⁡ 4 = 2 \log 4=2 log4=2

a 1 , a 2 , a 3 , a 4 a_1,a_2,a_3,a_4 a1,a2,a3,a4 可以分别编为 00 , 01 , 10 , 11 00,01,10,11 00,01,10,11

然后按照分段规则,可以分为: a 1 , a 2 , a 1 a 3 , a 2 a 4 , a 2 a 4 a 3 , a 1 a 1 , a 4 a_1,a_2,a_1a_3,a_2a_4,a_2a_4a_3,a_1a_1,a_4 a1,a2,a1a3,a2a4,a2a4a3,a1a1,a4 这7段;

那么段号需要的码长为 ⌈ log ⁡ 7 ⌉ = 3 \lceil \log 7\rceil=3 log7=3;所以一个短语需要使用5个比特;

然后开始编码(注意段号从1开始,单字符段号为0)

a 1 a_1 a1为单字符,段号为0,编为 00000 00000 00000

a 2 a_2 a2为单字符,段号为0,编为 00001 00001 00001

a 1 a 3 a_1a_3 a1a3,前缀 a 1 a_1 a1段号为1,字符 a 3 a_3 a3,编为 00110 00110 00110

a 2 a 4 a_2a_4 a2a4,前缀 a 2 a_2 a2段号为2,字符 a 4 a_4 a4,编为 01011 01011 01011

a 2 a 4 a 3 a_2a_4a_3 a2a4a3,前缀 a 2 a 4 a_2a_4 a2a4段号为 4 4 4,字符 a 3 a_3 a3,编为 10010 10010 10010

a 1 a 1 a_1a_1 a1a1,前缀 a 1 a_1 a1段号为 1 1 1,字符 a 1 a_1 a1,编为 00100 00100 00100

a 4 a_4 a4为单字符,段号为 0 0 0,编为 00011 00011 00011

那么最终编码结果为 00000000010011001011100100010000011 00000000010011001011100100010000011 00000000010011001011100100010000011

实现

编码:先根据字符集确定符号码长 n 1 n_1 n1,对每个字符先编码;然后扫描符号序列,创建好字典(为了方便后续编码,可以先记录下所有短语的位置)确定段号码长 n 2 n_2 n2;再对所有短语进行编码为段号+字符编码;最后返回编码结果、字符全集、字典长度方便译码。

译码:同样先确定短语码长 n 1 + n 2 n_1+n_2 n1+n2;然后每次截取一个短语的编码,得到段号和字符,译为对应的短语;并且一边译码一遍建立字典(所以并不需要传输字典本身)

from math import log2, ceil


def LZ_Compress(message):  # LZ编码,先构建完字典并记录短语位置;再分别对每个短语编码(段号+字符编码)
    U = sorted(list(set(message)))  # 字符全集U,按字典序来排列
    n1 = ceil(log2(len(U)))  # 每个符号需要的码长,log向上取整
    char = {U[i]: bin(i)[2:].zfill(n1) for i in range(len(U))}  # 对单个符号编码
    dict = {}  # 短语字典
    word_index = []  # 在创建字典过程中,记录编码短语的位置
    i = 0
    count = 1  # 段号,从1开始
    while i < len(message):
        for j in range(1, len(message) - i + 1):
            word = message[i:i + j]  # 截取一个短语
            if word not in dict:  # 不在字典,则添加进去
                dict[word] = count
                word_index.append((i, i + j))  # 记录下每个短语的位置
                count += 1  # 段号+1
                i = i + j  # 更新位置
                break
        else:
            i += 1
    n2 = ceil(log2(len(dict)))  # 段号需要的码长,log向上取整
    message_code = ''  # 最终编码结果
    for ind in word_index:
        i, j = ind
        if i + 1 == j:  # 短语长度为1
            message_code = message_code + '0' * n2 + char[message[i]]  # 段号+字符编码 段号为0
        else:
            message_code = message_code + bin(dict[message[i:j - 1]])[2:].zfill(n2) + char[message[j - 1]]  # 段号+字符编码
    return U, len(dict), message_code  # 返回字符全集,字典长度,编码结果;方便译码


def LZ_UnCompress(U, dict_len, code):  # 根据编码规则,边译码边重构字典(只需字典长度用来确定段号,以及字符全集即可)
    n1 = ceil(log2(len(U)))  # 字符码长
    n2 = ceil(log2(dict_len))  # 段号码长
    chars = {bin(i)[2:].zfill(n1): U[i] for i in range(len(U))}  # 字符编码集
    dict = {}  # 字典
    message = ''  # 最终译码结果
    count_ = 1  # 段号
    for i in range(0, len(code), n1 + n2):  # 每次截取一个短语编码的结果
        count = code[i:i + n2]  # 取出段号
        char = code[i + n2:i + n2 + n1]  # 取出字符编码

        if int(count, 2) == 0:  # 段号为0,直接译为单个字符
            decode_word = chars[char]
            message += decode_word
            dict[bin(count_)[2:].zfill(n2)] = decode_word  # 添加进字典
        else:
            decode_word = dict[count] + chars[char]  # 否则译为前缀短语+字符
            message = message + decode_word
            dict[bin(count_)[2:].zfill(n2)] = decode_word  # 添加进字典
        count_ += 1  # 段号自增
    return message


message = 'abacbdbdcaad'
U, n2, message_code = LZ_Compress(message)  # 编码
print(message_code)
# 00000000010011001011100100010000011
message_ = LZ_UnCompress(U, n2, message_code)  # 译码
print(message_)
print(message_ == message)  
# True

算术编码

基础知识

算术编码的主要思想是计算输入信源符号序列所对应的区间,然后在区间中任取一点,以其二进制表示适当截断作为序列的编码结果。

设信源符号集 A = { a 1 , a 2 , . . . , a K } A=\{a_1,a_2,...,a_K\} A={a1,a2,...,aK},其相应的概率分布为 P ( a k ) P(a_k) P(ak)​;定义信源符号的分布函数为
F ( a k ) = ∑ i = 1 k − 1 P ( a i ) F(a_k)=\sum_{i=1}^{k-1}P(a_i) F(ak)=i=1k1P(ai)其中 F ( a 1 ) = 0 F(a_1)=0 F(a1)=0;将 F ( u ) F(u) F(u)写成二进制小数,取小数后 n n n位,如有尾数就进位到第 n n n位,其中 n = ⌈ log ⁡ 1 P ( u ) ⌉ n=\lceil \log \frac{1}{P(u)}\rceil n=logP(u)1;得到的数就作为编码结果;(看例子更为直观一些)

例子

设信源 U = { a 1 , a 2 , a 3 , a 4 } U=\{a_1,a_2,a_3,a_4\} U={a1,a2,a3,a4},其概率分布 P ( a 1 ) = 0.5 , P ( a 2 ) = 0.25 , P ( a 3 ) = 0.125 , P ( a 4 ) = 0.125 P(a_1)=0.5,P(a_2)=0.25,P(a_3)=0.125,P(a_4)=0.125 P(a1)=0.5,P(a2)=0.25,P(a3)=0.125,P(a4)=0.125对信源序列 u = a 2 a 1 a 1 a 3 a 4 a 1 a 2 a 1 u=a_2a_1a_1a_3a_4a_1a_2a_1 u=a2a1a1a3a4a1a2a1 做算术编码。

按照编码规则: P ( u ) = ( 0.5 ) 4 ( 0.25 ) 2 ( 0.125 ) 2 = 2 − 14 P(u)=(0.5)^4(0.25)^2(0.125)^2=2^{-14} P(u)=(0.5)4(0.25)2(0.125)2=214;码长 n = ⌈ log ⁡ 1 P ( u ) ⌉ = 14 n=\lceil \log \frac{1}{P(u)}\rceil =14 n=logP(u)1=14

首先初始概率区间为
a 1 : [ 0 , 0.5 ] , a 2 : [ 0.5 , 0.75 ] , a 3 : [ 0.75 , 0.875 ] , a 4 : [ 0.875 : 1 ] a_1:[0,0.5],a_2:[0.5,0.75],a_3:[0.75,0.875],a_4:[0.875:1] a1:[0,0.5],a2:[0.5,0.75],a3:[0.75,0.875],a4:[0.875:1]序列第一个字符为 a 2 a_2 a2,那么选定第二个区间为目标区间,再按概率分布划分该区间
a 1 : [ 0.5 , 0.625 ] , a 2 : [ 0.625 , 0.6875 ] , a 3 : [ 0.6875 , 0.71875 ] , a 4 : [ 0.71875 , 0.75 ] a_1:[0.5,0.625],a_2:[0.625,0.6875],a_3:[0.6875,0.71875],a_4:[0.71875,0.75] a1:[0.5,0.625],a2:[0.625,0.6875],a3:[0.6875,0.71875],a4:[0.71875,0.75]序列第二个字符为 a 1 a_1 a1,那么选定第一个区间为目标区间,再按概率分布划分该区间
a 1 : [ 0.5 , 0.5625 ] , a 2 : [ 0.5625 , 0.59375 ] , a 3 : [ 0.59375 , 0.609375 ] , a 4 : [ 0.609375 , 0.625 ] a_1:[0.5,0.5625],a_2:[0.5625,0.59375],a_3:[0.59375,0.609375],a_4:[0.609375,0.625] a1:[0.5,0.5625],a2:[0.5625,0.59375],a3:[0.59375,0.609375],a4:[0.609375,0.625]以此类推不再列举,当到序列最后一个字符 a 1 a_1 a1时,确定最终序列 u u u所处的区间为:
[ F ( u ) , F ( u ) + P ( u ) ] = [ 0.553955078125 , 0.553985595703125 ] [F(u),F(u)+P(u)]=[0.553955078125,0.553985595703125] [F(u),F(u)+P(u)]=[0.553955078125,0.553985595703125]此时可取区间任一小数,比如取左端点;转换为二进制再取前 n = 14 n=14 n=14位:10001101110100,没有尾数无需进位;所以最后的编码结果为 10001101110100。

译码过程就是一系列的比较过程,将二进制编码结果转换为小数,比较其位于哪个区间就译为哪个字符;再按同样的方式进行划分,直到译码结束。

实现

编码:先确定码长,再按上述方式不断划分区间;最后取左端点小数的二进制作为编码,如果有尾数则进位;难点在于实现高精度计算,这里在Python使用的是decimal高精度库,当编码规模很大时,计算过高精度会比较慢(实现的问题)

译码:译码需要找到小数所处的区间,可以将每个区间的端点存在一个有序列表,然后采用二分查找加速这个查找过程,找到所处区间就可以译为相应字符,然后同样划分区间;直到译码结束。

from decimal import Decimal, getcontext  # 高精度库
from numpy import prod, ceil


# getcontext().prec = 10000  # 设置精度,根据编码规模来设置

def Arithmetic_Compress(message):
    U = sorted(list(set(message)))  # 字符全集U,按字典序排列
    P = {m: Decimal(message.count(m) / len(message)) for m in U}  # 字符概率分布,高精度表示

    Ps = Decimal(prod([Decimal(P[m] ** message.count(m)) for m in U]))  # P(u),高精度表示
    n = ceil(-Decimal.log10(Ps) / Decimal.log10(Decimal(2)))  # 码长n,log10换底公式,高精度表示
    pre = 0  # 初始lower
    lower_upper = {}  # 初始区间
    for m in P:
        lower_upper[m] = (pre, pre + P[m])  # 字符i概率所在区间(lower,upper)
        pre = pre + P[m]  # 上一个字符的upper

    for m in message:  # 每读取一个字符更新区间
        lower, upper = lower_upper[m]
        pre = lower
        for m_ in P:
            new_lower = pre  # 新的lower
            new_upper = pre + (upper - lower) * P[m_]  # 新的upper
            pre = new_upper
            lower_upper[m_] = (new_lower, new_upper)  # 更新区间
    flag = lower_upper[message[-1]][0]
    message_code = ''
    for _ in range(n):  # 小数转二进制,每次输出整数位
        message_code += str(int(flag * 2))
        flag = flag * 2 % 1
    if flag != 0:  # 有尾数,进位
        message_code = int(message_code, 2) + 1
        return len(message), U, P, bin(message_code)[2:].zfill(n)  # 进位后返回二进制,并给出字符全集和对应的概率分布
    return len(message), U, P, message_code  # 无尾数,直接返回


def binary_search(lower_upper, flag):  # 二分查找概率所在的区间,确定译码字符;
    i = 0
    j = len(lower_upper) - 1
    while i <= j:
        mid = (i + j) // 2
        if lower_upper[mid] > flag:
            j = mid - 1
        elif lower_upper[mid] < flag:
            i = mid + 1
        else:
            return mid
    return j


def Arithmetic_UnCompress(le, message_code, U, P):
    flag = Decimal(0)
    exp = Decimal(0.5)
    for c in message_code:
        if c == '1':
            flag = Decimal(Decimal(flag) + Decimal(exp))
        exp = Decimal(exp * Decimal(0.5))

    old_lower = 0
    old_upper = 1
    message = ''
    for _ in range(le):
        lower_upper = []
        pre = old_lower
        for m in P:
            lower = pre
            upper = pre + (old_upper - old_lower) * P[m]
            lower_upper.append(lower)
            pre = upper
        lower_upper.append(upper)
        ind = binary_search(lower_upper, flag)
        message += U[ind]
        old_lower = lower_upper[ind]
        old_upper = lower_upper[ind + 1]
    return message


message = 'baacdaba'
le, U, P, message_code = Arithmetic_Compress(message)
print(message_code)
# 10001101110100
message_ = Arithmetic_UnCompress(le, message_code, U, P)
print(message_)
print(message_ == message)
# True

哈夫曼编码

基础知识

哈夫曼编码得到的码是异字头码,其平均长度最短,是一种最佳码;给定信源 { U , P k } \{U,P_k\} {U,Pk},不失一般性可将各消息按概率由小到大进行排列如下: a 1 , a 2 , . . . , a K a_1,a_2,...,a_K a1,a2,...,aK,相应概率 P 1 ≤ P 2 ≤ P K P_1\le P_2\le P_K P1P2PK;每次合并概率最小的两个节点构成哈夫曼树,再进行编码;过程比较简单也很熟悉(数据结构,离散对数等课程都会学到);就不再举例关键在于其实现过程。

实现

编码:先根据概率分布创建一棵哈夫曼树,每次从集合选取概率最小的两个节点,合并为一个父节点再插入集合中,直到集合只剩下根节点;这里为了方便可以使用堆结构,动态调整集合;然后获取根到每一个叶子节点(字符)的路径,左0右1,将01串路径作为字符的编码结果;最后编码只需将字符编成相应结果即可。

译码:扫描编码结果,从根游走到叶子节点,然后译成其对应的字符,再返回根;直到译码结束;所以译码方需要重构哈夫曼树或者得到相应的对应关系。

import heapq  # 使用堆结构


class TreeNode:  # 定义哈夫曼树节点
    def __init__(self, char, freq):
        self.char = char  # 节点携带字符
        self.freq = freq  # 频率
        self.lchild = None  # 左孩子
        self.rchild = None  # 右孩子

    def __lt__(self, other):  # 自定义比较函数,节点之间比较频率
        return self.freq < other.freq


def Create_HuffmanTree(message):  # 创建哈夫曼树,每次合并概率最小的两个节点
    U = sorted(set(message))  # 字符全集
    P = [TreeNode(i, message.count(i) / len(message)) for i in U]  # 概率分布,创建节点(char:freq)
    heapq.heapify(P)  # 原地组织成堆
    while len(P) > 1:  # 直到堆中只剩根节点,构建完成
        l = heapq.heappop(P)
        r = heapq.heappop(P)  # 依次弹出概率最小的两个节点
        father_node = TreeNode(None, l.freq + r.freq)  # 合并
        father_node.lchild = l  # 左孩子
        father_node.rchild = r  # 右孩子
        heapq.heappush(P, father_node)  # 加入堆
    return P[0]  # 返回根节点


def Huffman_Codes(T):  # 编码,左0右1,确定根节点到叶子节点的一条路径
    def dfs(node, code, result):  # 根左右,中序遍历
        if node:  # 不为空
            if node.char:  # 找到叶子节点
                result[node.char] = code  # 对应的路径作为编码
            dfs(node.lchild, code + '0', result)  # 左0
            dfs(node.rchild, code + '1', result)  # 右1

    huffman_codes = {}  # 字符与编码对应关系
    dfs(T, '', huffman_codes)  # dfs
    return huffman_codes


def Huffman_Compress(message, huffman_codes):  # 哈夫曼编码,根据前面得到的编码规则来直接编码
    message_code = ''
    for m in message:
        message_code += huffman_codes[m]  # 对应的编码
    return message_code


def Huffman_UnCompress(message_code, T):  # 哈夫曼解码
    message = ''
    i = 0
    while i < len(message_code):  # 扫一遍
        t = T
        while t.char == None:  # 根据路径来找,0左右1直到叶子节点
            if message_code[i] == '0':
                t = t.lchild  # 向左
            else:
                t = t.rchild  # 向右
            i += 1
        message += t.char  # 找到,译为叶子节点的字符
    return message


message = 'abacbdbdcaad'
T = Create_HuffmanTree(message)  # 构建哈夫曼树
huffman_codes = Huffman_Codes(T)  # 编码规则

message_code = Huffman_Compress(message, huffman_codes)  # 编码
print(message_code)
# 111011001001100100111101
message_ = Huffman_UnCompress(message_code, T)  # 解码
print(message_)
print(message_ == message)
# True
;