KMP 算法详解

释放双眼,带上耳机,听听看~!

KMP 算法(Knuth-Morris-Pratt 算法)是一个著名的字符串匹配算法,效率很高,但是确实有点复杂。

很多读者抱怨 KMP 算法无法理解,这很正常,想到大学教材上关于 KMP 算法的讲解,也不知道有多少未来的 Knuth、Morris、Pratt 被提前劝退了。有一些优秀的同学通过手推 KMP 算法的过程来辅助理解该算法,这是一种办法,不过本文要从逻辑层面帮助读者理解算法的原理。十行代码之间,KMP 灰飞烟灭。

先在开头约定,本文用 pat 表示模式串,长度为 M,txt 表示文本串,长度为 N。KMP 算法是在 txt 中查找子串 pat,如果存在,返回这个子串的起始索引,否则返回 -1

为什么我认为 KMP 算法就是个动态规划问题呢,等会再解释。对于动态规划,之前多次强调了要明确 dp 数组的含义,而且同一个问题可能有不止一种定义 dp 数组含义的方法,不同的定义会有不同的解法。

读者见过的 KMP 算法应该是,一波诡异的操作处理 pat 后形成一个一维的数组 next,然后根据这个数组经过又一波复杂操作去匹配 txt。时间复杂度 O(N),空间复杂度 O(M)。其实它这个 next 数组就相当于 dp 数组,其中元素的含义跟 pat 的前缀和后缀有关,判定规则比较复杂,不好理解。本文则用一个二维的 dp 数组(但空间复杂度还是 O(M)),重新定义其中元素的含义,使得代码长度大大减少,可解释性大大提高

一、KMP 算法概述

首先还是简单介绍一下 KMP 算法和暴力匹配算法的不同在哪里,难点在哪里,和动态规划有啥关系。

暴力的字符串匹配算法很容易写,看一下它的运行逻辑:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1// 暴力匹配(伪码)
2int search(String pat, String txt) {
3    int M = pat.length;
4    int N = txt.length;
5    for (int i = 0; i < N - M; i++) {
6        int j;
7        for (j = 0; j < M; j++) {
8            if (pat[j] != txt[i+j])
9                break;
10        }
11        // pat 全都匹配了
12        if (j == M) return i;
13    }
14    // txt 中不存在 pat 子串
15    return -1;
16}
17
18

对于暴力算法,如果出现不匹配字符,同时回退 txt 和 pat 的指针,嵌套 for 循环,时间复杂度 $O(MN)$,空间复杂度$O(1)$。最主要的问题是,如果字符串中重复的字符比较多,该算法就显得很蠢。

比如 txt = “aaacaaab” pat = “aaab”:

很明显,pat 中根本没有字符 c,根本没必要回退指针 i,暴力解法明显多做了很多不必要的操作。

KMP 算法的不同之处在于,它会花费空间来记录一些信息,在上述情况中就会显得很聪明:

再比如类似的 txt = “aaaaaaab” pat = “aaab”,暴力解法还会和上面那个例子一样蠢蠢地回退指针 i,而 KMP 算法又会耍聪明:

因为 KMP 算法知道字符 b 之前的字符 a 都是匹配的,所以每次只需要比较字符 b 是否被匹配就行了。

KMP 算法永不回退 txt 的指针 i,不走回头路(不会重复扫描 txt),而是借助 dp 数组中储存的信息把 pat 移到正确的位置继续匹配,时间复杂度只需 O(N),用空间换时间,所以我认为它是一种动态规划算法。

KMP 算法的难点在于,如何计算 dp 数组中的信息?如何根据这些信息正确地移动 pat 的指针?这个就需要确定有限状态自动机来辅助了,别怕这种高大上的文学词汇,其实和动态规划的 dp 数组如出一辙,等你学会了也可以拿这个词去吓唬别人。

还有一点需要明确的是:计算这个 dp 数组,只和 pat 串有关。意思是说,只要给我个 pat,我就能通过这个模式串计算出 dp 数组,然后你可以给我不同的 txt,我都不怕,利用这个 dp 数组我都能在 O(N) 时间完成字符串匹配。

具体来说,比如上文举的两个例子:


1
2
3
4
5
6
1txt1 = "aaacaaab"
2pat = "aaab"
3txt2 = "aaaaaaab"
4pat = "aaab"
5
6

我们的 txt 不同,但是 pat 是一样的,所以 KMP 算法使用的 dp 数组是同一个。

只不过对于 txt1 的下面这个即将出现的未匹配情况:

dp 数组指示 pat 这样移动:

PS:这个j 不要理解为索引,它的含义更准确地说应该是状态(state),所以它会出现这个奇怪的位置,后文会详述。

而对于 txt2 的下面这个即将出现的未匹配情况:

dp 数组指示 pat 这样移动:

明白了 dp 数组只和 pat 有关,那么我们这样设计 KMP 算法就会比较漂亮:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1public class KMP {
2    private int[][] dp;
3    private String pat;
4
5    public KMP(String pat) {
6        this.pat = pat;
7        // 通过 pat 构建 dp 数组
8        // 需要 O(M) 时间
9    }
10
11    public int search(String txt) {
12        // 借助 dp 数组去匹配 txt
13        // 需要 O(N) 时间
14    }
15}
16
17

这样,当我们需要用同一 pat 去匹配不同 txt 时,就不需要浪费时间构造 dp 数组了:


1
2
3
4
5
1KMP kmp = new KMP("aaab");
2int pos1 = kmp.search("aaacaaab"); //4
3int pos2 = kmp.search("aaaaaaab"); //4
4
5

二、状态机概述

为什么说 KMP 算法和状态机有关呢?是这样的,我们可以认为 pat 的匹配就是状态的转移。比如当 pat = “ABABC”:

如上图,圆圈内的数字就是状态,状态 0 是起始状态,状态 5(pat.length)是终止状态。开始匹配时 pat 处于起始状态,一旦转移到终止状态,就说明在 txt 中找到了 pat。比如说当前处于状态 2,就说明字符 “AB” 被匹配:

另外,处于不同状态时,pat 状态转移的行为也不同。比如说假设现在匹配到了状态 4,如果遇到字符 A 就应该转移到状态 3,遇到字符 C 就应该转移到状态 5,如果遇到字符 B 就应该转移到状态 0:

具体什么意思呢,我们来一个个举例看看。用变量 j 表示指向当前状态的指针,当前 pat 匹配到了状态 4:

如果遇到了字符 “A”,根据箭头指示,转移到状态 3 是最聪明的:

如果遇到了字符 “B”,根据箭头指示,只能转移到状态 0(一夜回到解放前):

如果遇到了字符 “C”,根据箭头指示,应该转移到终止状态 5,这也就意味着匹配完成:

KMP 算法详解

当然了,还可能遇到其他字符,比如 Z,但是显然应该转移到起始状态 0,因为 pat 中根本都没有字符 Z:

这里为了清晰起见,我们画状态图时就把其他字符转移到状态 0 的箭头省略,只画 pat 中出现的字符的状态转移:

KMP 算法最关键的步骤就是构造这个状态转移图。要确定状态转移的行为,得明确两个变量,一个是当前的匹配状态,另一个是遇到的字符;确定了这两个变量后,就可以知道这个情况下应该转移到哪个状态。

下面看一下 KMP 算法根据这幅状态转移图匹配字符串 txt 的过程:

请记住这个 GIF 的匹配过程,这就是 KMP 算法的核心逻辑

为了描述状态转移图,我们定义一个二维 dp 数组,它的含义如下:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
1dp[j][c] = next
20 <= j < M,代表当前的状态
30 <= c < 256,代表遇到的字符(ASCII 码)
40 <= next <= M,代表下一个状态
5
6dp[4]['A'] = 3 表示:
7当前是状态 4,如果遇到字符 A,
8pat 应该转移到状态 3
9
10dp[1]['B'] = 2 表示:
11当前是状态 1,如果遇到字符 B,
12pat 应该转移到状态 2
13
14

根据我们这个 dp 数组的定义和刚才状态转移的过程,我们可以先写出 KMP 算法的 search 函数代码:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1public int search(String txt) {
2    int M = pat.length();
3    int N = txt.length();
4    // pat 的初始态为 0
5    int j = 0;
6    for (int i = 0; i < N; i++) {
7        // 当前是状态 j,遇到字符 txt[i],
8        // pat 应该转移到哪个状态?
9        j = dp[j][txt.charAt(i)];
10        // 如果达到终止态,返回匹配开头的索引
11        if (j == M) return i - M + 1;
12    }
13    // 没到达终止态,匹配失败
14    return -1;
15}
16
17

到这里,应该还是很好理解的吧,dp 数组就是我们刚才画的那幅状态转移图,如果不清楚的话回去看下 GIF 的算法演进过程。下面讲解:如何通过 pat 构建这个 dp 数组?

三、构建状态转移图

回想刚才说的:要确定状态转移的行为,必须明确两个变量,一个是当前的匹配状态,另一个是遇到的字符,而且我们已经根据这个逻辑确定了 dp 数组的含义,那么构造 dp 数组的框架就是这样:


1
2
3
4
5
1for 0 <= j < M: # 状态
2    for 0 <= c < 256: # 字符
3        dp[j][c] = next
4
5

这个 next 状态应该怎么求呢?显然,如果遇到的字符 c 和 pat[j] 匹配的话,状态就应该向前推进一个,也就是说 next = j + 1,我们不妨称这种情况为状态推进

KMP 算法详解

如果字符 c 和 pat[j] 不匹配的话,状态就要回退(或者原地不动),我们不妨称这种情况为状态重启

那么,如何得知在哪个状态重启呢?解答这个问题之前,我们再定义一个名字:影子状态(我编的名字),用变量 X 表示。所谓影子状态,就是和当前状态具有相同的前缀。比如下面这种情况:

KMP 算法详解

当前状态 j = 4,其影子状态为 X = 2,它们都有相同的前缀 “AB”。因为状态 X 和状态 j 存在相同的前缀,所以当状态 j 准备进行状态重启的时候(遇到的字符 c 和 pat[j] 不匹配),可以通过 X 的状态转移图来获得最近的重启位置

比如说刚才的情况,如果状态 j 遇到一个字符 “A”,应该转移到哪里呢?首先只有遇到 “C” 才能推进状态,遇到 “A” 显然只能进行状态重启。状态 j 会把这个字符委托给状态 X 处理,也就是 dp[j]['A'] = dp[X]['A']

为什么这样可以呢?因为:既然 j 这边已经确定字符 “A” 无法推进状态,只能回退,而且 KMP 就是要尽可能少的回退,以免多余的计算。那么 j 就可以去问问和自己具有相同前缀的 X,如果 X 遇见 “A” 可以进行「状态推进」,那就转移过去,因为这样回退最少。

当然,如果遇到的字符是 “B”,状态 X 也不能进行「状态推进」,只能回退,j 只要跟着 X 指引的方向回退就行了:

你也许会问,这个 X 怎么知道遇到字符 “B” 要回退到状态 0 呢?因为 X 永远跟在 j 的身后,状态 X 如何转移,在之前就已经算出来了。动态规划算法不就是利用过去的结果解决现在的问题吗?

这样,我们就细化一下刚才的框架代码:


1
2
3
4
5
6
7
8
9
10
11
12
1int X # 影子状态
2for 0 <= j < M:
3    for 0 <= c < 256:
4        if c == pat[j]:
5            # 状态推进
6            dp[j][c] = j + 1
7        else:
8            # 状态重启
9            # 委托 X 计算重启位置
10            dp[j][c] = dp[X][c]
11
12

四、代码实现

如果之前的内容你都能理解,恭喜你,现在就剩下一个问题:影子状态 X 是如何得到的呢?下面先直接看完整代码吧。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
1public class KMP {
2    private int[][] dp;
3    private String pat;
4
5    public KMP(String pat) {
6        this.pat = pat;
7        int M = pat.length();
8        // dp[状态][字符] = 下个状态
9        dp = new int[M][256];
10        // base case
11        dp[0][pat.charAt(0)] = 1;
12        // 影子状态 X 初始为 0
13        int X = 0;
14        // 当前状态 j 从 1 开始
15        for (int j = 1; j < M; j++) {
16            for (int c = 0; c < 256; c++) {
17                if (pat.charAt(j) == c)
18                    dp[j][c] = j + 1;
19                else
20                    dp[j][c] = dp[X][c];
21            }
22            // 更新影子状态
23            X = dp[X][pat.charAt(j)];
24        }
25    }
26
27    public int search(String txt) {...}
28}
29
30

先解释一下这一行代码:


1
2
3
4
1// base case
2dp[0][pat.charAt(0)] = 1;
3
4

这行代码是 base case,只有遇到 pat[0] 这个字符才能使状态从 0 转移到 1,遇到其它字符的话还是停留在状态 0(Java 默认初始化数组全为 0)。

影子状态 X 是先初始化为 0,然后随着 j 的前进而不断更新的。下面看看到底应该如何更新影子状态 X


1
2
3
4
5
6
7
8
9
10
1int X = 0;
2for (int j = 1; j < M; j++) {
3    ...
4    // 更新影子状态
5    // 当前是状态 X,遇到字符 pat[j],
6    // pat 应该转移到哪个状态?
7    X = dp[X][pat.charAt(j)];
8}
9
10

更新 X 其实和 search 函数中更新状态 j 的过程是非常相似的:


1
2
3
4
5
6
7
8
9
1int j = 0;
2for (int i = 0; i < N; i++) {
3    // 当前是状态 j,遇到字符 txt[i],
4    // pat 应该转移到哪个状态?
5    j = dp[j][txt.charAt(i)];
6    ...
7}
8
9

其中的原理非常微妙,注意代码中 for 循环的变量初始值,可以这样理解:后者是在 txt 中匹配 pat,前者是在 pat 中匹配 pat[1..end],状态 X 总是落后状态 j 一个状态,与 j 具有最长的相同前缀。所以我把 X 比喻为影子状态,似乎也有一点贴切。

另外,构建 dp 数组是根据 base case dp[0][..] 向后推演。这就是我认为 KMP 算法就是一种动态规划算法的原因。

下面来看一下状态转移图的完整构造过程,你就能理解状态 X 作用之精妙了:

至此,KMP 算法的核心终于写完啦啦啦啦!看下 KMP 算法的完整代码吧:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
1public class KMP {
2    private int[][] dp;
3    private String pat;
4
5    public KMP(String pat) {
6        this.pat = pat;
7        int M = pat.length();
8        // dp[状态][字符] = 下个状态
9        dp = new int[M][256];
10        // base case
11        dp[0][pat.charAt(0)] = 1;
12        // 影子状态 X 初始为 0
13        int X = 0;
14        // 构建状态转移图(稍改的更紧凑了)
15        for (int j = 1; j < M; j++) {
16            for (int c = 0; c < 256; c++) {
17                dp[j][c] = dp[X][c];
18            dp[j][pat.charAt(j)] = j + 1;
19            // 更新影子状态
20            X = dp[X][pat.charAt(j)];
21        }
22    }
23
24    public int search(String txt) {
25        int M = pat.length();
26        int N = txt.length();
27        // pat 的初始态为 0
28        int j = 0;
29        for (int i = 0; i < N; i++) {
30            // 计算 pat 的下一个状态
31            j = dp[j][txt.charAt(i)];
32            // 到达终止态,返回结果
33            if (j == M) return i - M + 1;
34        }
35        // 没到达终止态,匹配失败
36        return -1;
37    }
38}
39
40

经过之前的详细举例讲解,你应该可以理解这段代码的含义了,当然你也可以把 KMP 算法写成一个函数。核心代码也就是两个函数中 for 循环的部分,数一下有超过十行吗?

五、最后总结

传统的 KMP 算法是使用一个一维数组 next 记录前缀信息,而本文是使用一个二维数组 dp 以状态转移的角度解决字符匹配问题,但是空间复杂度仍然是 O(256M) = O(M)。

在 pat 匹配 txt 的过程中,只要明确了「当前处在哪个状态」和「遇到的字符是什么」这两个问题,就可以确定应该转移到哪个状态(推进或回退)。

对于一个模式串 pat,其总共就有 M 个状态,对于 ASCII 字符,总共不会超过 256 种。所以我们就构造一个数组 dp[M][256] 来包含所有情况,并且明确 dp 数组的含义:

dp[j][c] = next 表示,当前是状态 j,遇到了字符 c,应该转移到状态 next。

明确了其含义,就可以很容易写出 search 函数的代码。

对于如何构建这个 dp 数组,需要一个辅助状态 X,它永远比当前状态 j 落后一个状态,拥有和 j 最长的相同前缀,我们给它起了个名字叫「影子状态」。

在构建当前状态 j 的转移方向时,只有字符 pat[j] 才能使状态推进(dp[j][pat[j]] = j+1);而对于其他字符只能进行状态回退,应该去请教影子状态 X 应该回退到哪里(dp[j][other] = dp[X][other],其中 other 是除了 pat[j] 之外所有字符)。

对于影子状态 X,我们把它初始化为 0,并且随着 j 的前进进行更新,更新的方式和 search 过程更新 j 的过程非常相似(X = dp[X][pat[j]])。

给TA打赏
共{{data.count}}人
人已打赏
安全运维

MySQL到MongoDB的数据同步方法!

2021-12-11 11:36:11

安全运维

Ubuntu上NFS的安装配置

2021-12-19 17:36:11

个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索