字符串匹配问题
设 文本 为一个长度为 的数组 , 模式 是一个长度为 的数组 , 其中 , 进一步假设 和 的元素都来自一个有限字母集 , 例如 或 , 字符数组 和 通常称为字符串.
如果 , 并且 , 那么称模式 在 中出现, 且偏移为 .
为什么是 ?
算法假想文本 不动, 拿 模式 与它对比, 不断将 往右移动来寻找匹配的情况, 将 移动过的距离称作 偏移 , 因此, 如果匹配那么应该从 的 位开始算
如果 在 中以偏移 出现则称 为 有效偏移; 否则为 无效偏移. 字符串匹配问题就是找到所有的 有效偏移.
除了朴素算法以外, 其他的算法都基于 模式字符串 进行了预处理, 然后找到所有有效偏移. 所以字符串匹配问题可以抽象为两个步骤 :
- 预处理
- 匹配
| 算法 | 预处理时间 | 匹配时间 |
|---|---|---|
| 朴素 | 0 | |
| Rabin-Karp | ||
| 有限自动机 | ||
| KMP |
符号和术语
| 符号 | 含义 |
|---|---|
| 有限字母集 | |
| 由 中的字母组成的有限长度字符串集合 | |
| 空字符串 | |
| 字符串 的长度 | |
| 字符串 和 的连结 (concatenation) | |
| 等于 | |
| 对于某个字符串 有 , 则称 为字符串 的前缀, 记作 | |
| 为 的 后缀 | |
| 由 或 中 个字符组成的前缀 |
那么字符串匹配问题可以写成 : 求所有偏移 () 使得
朴素算法
枚举 的每一个位置进行比较.
Rabin-Karp
待阅读
有限自动机算法
待阅读
KMP 算法
线性时间的字符串匹配算法, 算法用辅助函数 , 并用 的时间根据 模式 预先计算出来并存储到数组 .
一般来说, 对于偏移 , 知道下列问题的答案将很有用 :
设模式字符 与 文本字符 匹配, 定义 和 , , 那么当 时满足条件 :
并且
的最小偏移 是多少 ?
( 这个条件限定了 为 的后缀 )
换句话说 :
因为已知 , 要求使得 且 的最大 . 给出 和 那么 , 在最好的情况下, , 得出 , 我们立刻能够知道偏移 , , ... , 为一定为 无效偏移, 从而跳过它们的判断. 而偏移 是否为 有效偏移 则还需要继续计算得出. 得出新的偏移之后只需要从 模式 的第 位开始对比.
那么该如何计算出这些必要的信息呢 ? 我们通过用模式与其自身进行比较来预先计算出这些信息.
要计算 已知 时, 使得 且 的最大 ,
即求 使得 的最大 值 (因为 ), 注意 也是可以的, 因为空串 是任何字符串的前后缀
记作
即 等于一个 "即是前缀 的前缀, 又是 的后缀的" 最长前缀 的长度
所以得到以下伪代码
KMP-MATCHER(T, P)
n = T.length
m = P.length
pi = PREPROCESS(P)
q = 0 # q 为当前询问的 q 长 P 前缀
for i = 1 to n
while q > 0 and P[q + 1] != T[i]
q = pi[q]
if (P[q + 1] == T[i])
q = q + 1
if (q == m)
print "pattern occurs with shift" i - m
q = pi[q]
PREPROCESS(P)
m = P.length
let pi[1...m] be a new array
# k = 0 即空串情况为最大的 k (k < q), 使得它即为 P[1] 的前缀又是 P[1] 的后缀
pi[1] = 0
k = 0 # 从空前缀开始检查
for q = 2 to m # q 为当前查询的 P 的 q 长前缀
# 跳出 while 循环时要么是 k = 0 要么是 P[k + 1] == P[q]
# 1. k = 0 的情况下, 这里判断 k + 1 即 1 长前缀是否为 q 长前缀的前后缀
# 2. P[k + 1] == P[q] 的情况下直接下面的 if 直接成立
while k > 0 and P[k + 1] != P[q]
k = pi[k]
if P[k + 1] == P[q]
k++
pi[q] = k
return pi
在 中
每轮 循环结束前都计算出正确的最大的 使得 为 的前后缀,
所以本轮用的 在计算结束之前对应的是上一轮循环的前缀, 即 , 例如
P : | 1 | 2 | .............................. |q-1| q | .............................
P : | 1 | 2 | ...................... | k |k+1| ...................................
也可以理解成计算结束之前的 代表着已经匹配好的字符数
所以在本轮循环的 中, 无需从 开始对比, 而是沿用上一轮循环结束时的 , 预处理算法能够高效且正确地在 的时间内计算出 数组.
循环 中, 当 且 时, 我们设 , 那么有
又因为 ( 因为 对应的是上一轮 循环的结果, 所以是 而不是 )
所以有 , 例如下面这种情景
P_{q-1} : | a | b | a | b | ...... | a | b | a | b |
由于 4 长前缀是 的前后缀, 2 长前缀又是 4 长前缀的前后缀, 所以, 2 长前缀是 的前后缀
因此, 只要满足循环条件, 我们就不断地让 , 这样就能够实现高效地匹配.
匹配过程中利用 数组便可以高效地在 时间内找出所有 有效偏移
下面是 KMP 的 java 实现, 其中基本的定义不变, 但是下标因为 java 字符串基于0的关系需要稍微调整
public class KMP {
/**
* O(n + m) Time Complexity, O(m) Space Complexity
* @param T for text string
* @param P for pattern string
* @return the founded indexes or so called "shifts"
*/
public List<Integer> solve(String T, String P) {
List<Integer> res = new ArrayList<>();
// 特殊情况判断
if (T == null || P == null) return res;
if (T.length() < P.length()) return res;
// 下面是 T 长大于等于 P 长的情况
if (T.isEmpty()) { res.add(0); return res; } // T 长为 0, 因为 T 长必然大于等于 P 长, 所以 P 长也一定为 0
if (P.isEmpty()) { res.add(0); return res; } // T 长不为 0 但 P 长为 0
int n = T.length(), m = P.length();
int[] pi = preprocess(P);
// 从 P 的 0 长前缀 (空串) 和 T 的 1 长前缀开始对比, q 可以视作已匹配字符数
for (int i = 1, q = 0; i <= n; i++) {
while (q > 0 && P.charAt(q) != T.charAt(i - 1)) q = pi[q];
if (P.charAt(q) == T.charAt(i - 1)) q++;
if (q == m) {
System.out.println("pattern occurs with shift " + (i - m));
q = pi[q];
res.add(i - m);
}
}
return res;
}
public int[] preprocess(String P) {
int m = P.length();
// k = pi[q] 表示 P 的 k 长前缀 是 P 的 q 长前缀的前后缀
// 并且是满足 k < q 的最大 k 值
// 所以数组大小要开到 m + 1
int[] pi = new int[m + 1];
// pi[0] 是没有意义的, 但 pi[1] 有意义
pi[1] = 0;
// 从 P 的 0 长前缀和 P 的 2 长前缀开始对比, k 可以视作已匹配的字符数
for (int q = 2, k = 0; q <= m; q++) {
// 因为 java 字符串基 0
// 所以 k + 1 长前缀的最后一个字符下标为 k, q 长前缀的最后一个字符下标为 q - 1
while (k > 0 && P.charAt(k) != P.charAt(q - 1)) k = pi[k];
if (P.charAt(k) == P.charAt(q - 1)) k++;
pi[q] = k;
}
return pi;
}
}