文本处理 (1) 模式匹配 KMP 算法与文本压缩 Huffman 编码
文本处理 (1) 模式匹配 KMP 算法与文本压缩 Huffman 编码
本章介绍常见的文本处理算法:模式匹配问题的 KMP 算法、文本压缩的 Huffman 编码树。
根据教材《数据结构与算法 Python 实现》整理,代码和笔记可前往我的 Github 库下载 isKage/dsa-notes 。【持续更新中,建议 star !】
1 模式匹配
设字符串 P 的长度为 m 。P 的一个子串(substring)P[i:j+1]
是 P 中从第 i 个到第 j 个字符构成的字符串
- P 的一个前缀(prefix)为:
P[0:i]
- P 的一个后缀(suffix)为:
P[i:m-1]
文本的模式匹配(pattern matching)问题:给定一个文本(text)字符串 T
和一个模式(pattern)字符串 P
,在 T 中找到一个与 P 一致的子串。
1.1 穷举
蛮力法/穷举法(brute-force)即列举所有可能的情况,并检查是否有符合要求的情况或寻找最优情况。模式匹配中,蛮力法即考虑所有 P 与 T 的子串可能匹配上的情况。
- 具体操作:将 P 与 T 从头部对齐开始,每一步把 P 相对于 T 向后移动一位,直到找到一个匹配的位置或所有 P 与 T 的相对位置均被探索过。
- 蛮力法的时间复杂度为 O(nm) ,其中 n 为 T 的长度,m 为模式 P 的长度。
- 蛮力法的最坏情况例如:
T = "aaaaaaaah"
P = "aaah"
代码实现
1 | def find_brute(P, T): |
1.2 KMP 算法
KMP(Knuth-Morris-Pratt )算法:预先计算模式部分之间的自重叠,从而当不匹配发生在一个位置时,我们在继续搜寻之前就能立刻知道移动模式的最大数目。
- 先对模式字符串进行预处理,以寻找其前缀与其本身匹配的位置:定义失败函数(failure function)
F(j)
为最长的既为P[0:j+1]
前缀又为P[1:j+1]
后缀的子串长度。 - KMP 算法在蛮力法上进行的改进为:匹配失败发生在
P[j] != T[i]
时,令j = F(j-1)
继续尝试匹配。
KMP 算法示例:现有文本字符串 abaababaabababaca
和模式串 ababac
。请求出模式串的失败函数,画出用 KMP 算法进行匹配的过程,并统计出比较的次数。
模式串 ababac
的失败函数为:F(j)
为最长的既为 P[0: j]
前缀又为 P[1: j]
后缀的子串长度
j |
0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
P[j] |
a |
b |
a |
b |
a |
c |
F[j] |
0 | 0 | 1 | 2 | 3 | 0 |
KMP 算法总共比较了 $22$ 次(若匹配成功就退出)
代码实现
1 | def compute_kmp_fail(pattern): |
性能分析
失败函数:匹配成功
i
加一,匹配失败则i or j
至少加一,最多 2m 次操作,复杂度为 O(m) ,m 为模式长度。KMP 算法:匹配成功
i
加一,匹配失败则i or j
至少加一,最多 2n 次操作,加之失败函数的复杂度,KMP 算法的复杂度为 O(n + m) ,m 为模式长度,n 为被匹配字符串长度。
2 文本压缩与贪心算法
文本压缩(text compression)问题:将给定字符串 X 压缩为一个更小的二进制字符串 Y 。
霍夫曼编码(Huffman encoding)是一种变长编码方式,可以得到字符串的最优二进制表示,其思路如下:
- 对字符串中的每个字符
c
,计算其出现频率f(c)
- 用长度较短的码表示高频字符,所有码字均不为其他码字的前缀
- 使用一棵最优编码树决定编码方式
2.1 霍夫曼编码 Huffman
前缀码(prefix code):所有码字均不为其他码字的前缀的一种编码方式
- 一棵编码树(encoding tree)表示一种前缀码
- 每个叶子节点存储一个被编码的字符
- 字符的码字由从根节点到该字符所在的叶子节点的路径确定(向左即编码为0,向右即编码为1)
霍夫曼编码步骤:
- 将所有字符用一棵单节点的树表示,节点权重为字符出现的频率
- 在每一步,将两棵权重最小的树合并为一棵,计算其权重
- 重复以上步骤直到只有一棵树
霍夫曼编码得到的编码树为霍夫曼树(Huffman tree)。
2.2 Huffman 代码实现和算法分析
2.2.1 代码实现
霍夫曼编码可以使用堆实现的优先级队列 code13_2_huffman.py
1 | from utils import HeapPriorityQueue |
例子:
1 | if __name__ == "__main__": |
结果:
1 | Code Map: |
2.2.2 算法分析
长度为 n 的文本,不同字符(包括各种特殊字符)的个数为 d ,构造最优编码/霍夫曼编码的时间复杂度为:
- 统计字符出现频率:O(n)
- 优先级队列的插入与删除: O(d log d)
总运行时间: O(n + d log d)
2.2.3 Huffman 编码最优性证明
Proof 原始论文 A Method for the Construction of Minimum-Redundancy Codes (David A, Huffman, et al. 1952) 。
对于字符集合 $C = { c_1,\ c_2,\ \cdots,\ c_n }$ 每个字符出现的频率为 $f(c_i)$ 。构造编码树 $T$ 是一个二叉树,叶子节点代表字符,路径代表 0 (Left) or 1 (Right) 。则字符 $c_i$ 的编码长度 $l(c_i)$ 为 $c_i$ 所在节点在 $T$ 中的深度。那么对这个字符集 $C$ 的编码树 $T$ 带来的期望编码总长为:
现在要证明:Huffman 编码树 $T_H$ 就是最优的,它使得 $L(T_H)$ 最小。
引理 1 在最优编码树 $T^*$ 中,频率最低的 2 个字符 $x$ 和 $y$ 一定位于最深的节点中,且他们拥有相同的父节点。
证明 引理 1 若字符 $z$ 比 $x$ 更深,但 $x$ 频率更低 $f(x) \leq f(z)$ 。那么交换 $x$ 和 $z$ 的节点位置,总编码期望长度 $L(T)$ 不增(因为频率低的字符变得更深)。这与 $L(T^*)$ 最优矛盾。同理,存在 2 个频率最低的字符 $x,\ y$ 那么他们肯定在同一层,否则将频率低的换到更深,长度不增,矛盾。$\square$
引理 2 将频率最低的两个字符 $x$ 和 $y$ 合并为一个新字符 $z$ ,新问题与原问题等价。
证明 引理 2 对于新问题:字符集 $C’ = C - {x,\ y} + {z}$ 编码树为 $T’$ 。得到的最优解为 $T’^$ 和最小编码长度 $L(T’^)$ 。由引理 1 知,$x,\ y$ 位于同一节点下的兄弟节点,则有 $l(z) = l(x) + 1 = l(y) + 1$ ,而显然 $z$ 的频率是二者的和 $f(z) = f(x) + f(y)$ 。所以有:
所以,当新问题 $B(T’^)$ 最优时,原问题 $B(T^)$ 也最优。$\square$
下面通过归纳法证明:Huffman 编码树 $T_H$ 是最优的
- 当 $|C| = n = 2$ 时,显然 $T_H$ 最优,只有 $0$ 和 $1$ 两种情况;
- 假设 $|C| = n$ 时,$T_H$ 是最优的;
- 对于 $|C| = n + 1$ 时,我们选择频率最低的 2 个字符 $x$ 和 $y$ ,合并为 $z$ 组成新问题,此时字符集 $C’$ 满足 $|C’| = n$ ,通过归纳假设,新问题是最优的。
再由引理 2 知,新问题与原问题等价,故 Huffman 编码树 $T_H$ 是最优的。$\square$
2.3 贪心算法
贪心算法(greedy method)是一种通用算法设计范式,贪心算法在问题有贪心选择(greedy-choice)性质时表现最佳。
- 贪心选择性质:全局最优解可以通过解决一系列局部最优问题来解决
贪心算法常按以下步骤设计:
- 将最优化问题转化为这样的形式:对其作出一次选择后,只剩下一个子问题需要求解
- 证明作出贪心选择后,原问题总是存在最优解,即贪心选择是安全的
- 证明作出贪心选择后,剩余子问题的最优解与贪心选择组合即可得到原问题的最优解
贪心算法例:
- Prim 算法
- Kruskal 算法
- Dijkstra 算法
- Huffman 编码