数据结构&算法/字符串/字符串匹配 —— 算法导论阅读总结

281 阅读4分钟

字符串匹配问题

文本 为一个长度为 nn 的数组 T[1...n]T[1...n], 模式 是一个长度为 mm 的数组 P[1...m]P[1...m], 其中 mnm \le n, 进一步假设 PPTT 的元素都来自一个有限字母集 Σ\Sigma, 例如 Σ={0,1}\Sigma=\{0, 1\}Σ={a,b,...,z}\Sigma = \{ a, b, ... , z \}, 字符数组 TTPP 通常称为字符串.

如果 0snm0 \le s \le n - m, 并且 T[s+1...s+m]=P[1...m]T[s + 1...s + m] = P[1...m], 那么称模式 PPTT 中出现, 且偏移为 ss.

为什么是 s+1s + 1 ?

算法假想文本 TT 不动, 拿 模式 PP 与它对比, 不断将 PP 往右移动来寻找匹配的情况, 将 PP 移动过的距离称作 偏移 ss, 因此, 如果匹配那么应该从 TTs+1s + 1 位开始算

如果 PPTT 中以偏移 ss 出现则称 ss有效偏移; 否则为 无效偏移. 字符串匹配问题就是找到所有的 有效偏移.

除了朴素算法以外, 其他的算法都基于 模式字符串 进行了预处理, 然后找到所有有效偏移. 所以字符串匹配问题可以抽象为两个步骤 :

  1. 预处理
  2. 匹配
算法预处理时间匹配时间
朴素0O((nm+1)m)O((n - m + 1)m)
Rabin-KarpΘ(m)\Theta(m)O((nm+1)m)O((n - m + 1)m)
有限自动机O(mΣ)O(m\|\Sigma\|)Θ(n)\Theta(n)
KMPO(m)O(m)Θ(n)\Theta(n)

符号和术语

符号含义
Σ\Sigma有限字母集
Σ\Sigma*Σ\Sigma 中的字母组成的有限长度字符串集合
ε\varepsilon空字符串
x\|x\|字符串 xx 的长度
xyxy字符串 xxyy连结 (concatenation)
xy\|xy\|等于 x+y\|x\| + \|y\|
ωx\omega \sqsubset x对于某个字符串 yyx=ωyx = \omega y, 则称 ω\omega 为字符串 xx前缀, 记作 ωx\omega \sqsubset x
ωx\omega \sqsupset xω\omegaxx后缀
TkT_k PkP_kTTPPkk 个字符组成的前缀

那么字符串匹配问题可以写成 : 求所有偏移 ss (0snm0 \le s \le n - m) 使得 PTs+mP \sqsupset T_{s + m}

朴素算法

枚举 TT 的每一个位置进行比较.

Rabin-Karp

待阅读

有限自动机算法

待阅读

KMP 算法

线性时间的字符串匹配算法, 算法用辅助函数 π\pi, 并用 O(m)O(m) 的时间根据 模式 预先计算出来并存储到数组 π[1...m]\pi[1...m].

一般来说, 对于偏移 ss, 知道下列问题的答案将很有用 :

设模式字符 P[1...q]P[1...q] 与 文本字符 T[s+1...s+q]T[s + 1...s + q] 匹配, 定义 ss'kk, s>ss' \gt s, 那么当 k<qk \lt q 时满足条件 :

P[1...k]=T[s+1...s+k]P[1...k] = T[s' + 1...s' + k] 并且 s+k=s+qs' + k = s + q

的最小偏移 ss' 是多少 ?

( s+k=s+qs' + k = s + q 这个条件限定了 T[s+1...s+k]T[s' + 1...s' + k]T[s+1...s+q]T[s + 1...s + q] 的后缀 )

换句话说 :

因为已知 PqTs+qP_q \sqsupset T_{s + q}, 要求使得 PkTs+qP_k \sqsupset T_{s + q}k<qk \lt q 的最大 kk. 给出 ssqq 那么 s=s+qks' = s + q - k, 在最好的情况下, k=0k = 0, 得出 s=s+qs' = s + q, 我们立刻能够知道偏移 s+1s + 1, s+2s + 2, ... , s+q1s + q - 1 为一定为 无效偏移, 从而跳过它们的判断. 而偏移 ss' 是否为 有效偏移 则还需要继续计算得出. 得出新的偏移之后只需要从 模式 的第 k+1k + 1 位开始对比.

那么该如何计算出这些必要的信息呢 ? 我们通过用模式与其自身进行比较来预先计算出这些信息.

要计算 已知 PqTs+qP_q \sqsupset T_{s + q} 时, 使得 PkTs+qP_k \sqsupset T_{s + q}k<qk \lt q 的最大 kk,

即求 使得 PkPqP_k \sqsupset P_q 的最大 kk 值 (因为 k<qk < q ), 注意 k=0k = 0 也是可以的, 因为空串 ε\varepsilon 是任何字符串的前后缀

记作 π[q]=max{k:k<q 且 PkPq}\pi [q] = max\{ k: k \lt q \ 且 \ P_k \sqsupset P_q \}

π[q]\pi [q] 等于一个 "即是前缀 PqP_q 的前缀, 又是 PqP_q 的后缀的" 最长前缀 的长度

所以得到以下伪代码

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

PREPROCESSPREPROCESS

每轮 forfor 循环结束前都计算出正确的最大的 kk 使得 PkP_kPqP_q 的前后缀,

所以本轮用的 kk 在计算结束之前对应的是上一轮循环的前缀, 即 Pq1P_{q - 1}, 例如

P : | 1 | 2 | .............................. |q-1| q | .............................
P :         | 1 | 2 | ...................... | k |k+1| ...................................

也可以理解成计算结束之前的 kk 代表着已经匹配好的字符数

所以在本轮循环的 PqP_q 中, 无需从 k=0k = 0 开始对比, 而是沿用上一轮循环结束时的 kk, 预处理算法能够高效且正确地在 O(m)O(m) 的时间内计算出 π\pi 数组.

whilewhile 循环 中, 当 k>0k > 0P[k+1]P[q]P[k + 1] \ne P[q] 时, 我们设 k=π[k]k' = \pi[k], 那么有 PkPkPkPkP_{k'} \sqsupset P_k 且 P_{k'} \sqsubset P_k

又因为 PkPq1PkPq1P_k \sqsupset P_{q - 1} 且 P_k \sqsubset P_{q - 1} ( 因为 kk 对应的是上一轮 forfor 循环的结果, 所以是 q1q - 1 而不是 qq)

所以有 PkPq1PkPq1P_{k'} \sqsupset P_{q - 1} 且 P_{k'} \sqsubset P_{q - 1}, 例如下面这种情景

P_{q-1} : | a | b | a | b | ...... | a | b | a | b |

由于 4 长前缀是 Pq1P_{q - 1} 的前后缀, 2 长前缀又是 4 长前缀的前后缀, 所以, 2 长前缀是 Pq1P_{q - 1} 的前后缀

因此, 只要满足循环条件, 我们就不断地让 k=k=π[k]k = k' = \pi[k], 这样就能够实现高效地匹配.

匹配过程中利用 π\pi 数组便可以高效地在 O(n)O(n) 时间内找出所有 有效偏移

下面是 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;
    }
}