字符串匹配算法:KMP
情景
现在有如下字符串 S 和 P,判断 P 是否为 S 的子串。
我们仍然按照原来的方式进行比较,比较到 P 的末尾时,我们发现了不匹配的字符。
注意,按照原来的思路,我们下一步应将字符串 P 的开头,与字符串 S 的第二位 C 重新进行比较。而 KMP 算法告诉我们,我们只需将字符串 P 需要比较的位置重置到图中 j 的位置,S 保持 i 的位置不变,接下来即可从 i,j 位置继续进行比较。
为什么?我们发现字符串 P 有子串 ACT 和 ACY,当 T 和 Y 不匹配时,我们就确定了 S 中的蓝色 AC 并不匹配 P 右侧的 AC,但是可能匹配左侧的 AC,所以我们从位置 i 和 j 继续比较。
换句话说,Y 对应下标 2,表示下一步要重新开始的地方。
既然如此,如果每次不匹配的时候,我们都能立刻知道 P 中不匹配的元素,下一步应该从哪个下标重新开始,这样不就能大大简化匹配过程了吗?这就是 KMP 的核心思想。
KMP 算法中,使用一个数组 next 来保存 P 中元素不匹配时,下一步应该重新开始的下标。由于计算机不能像我们人类一样,通过视觉来得出结论,因此这里有一种适合计算机的构造 next 数组的方法。
以上内容来自LeetCode。
next数组
现有字符串数组S,模板字符串P, next数组保存的是P 中元素不匹配时,下一步应该重新开始的下标。 假定当前位置为i,next数组用N表示。
next数组中
N
[
i
]
=
t
N[i] = t
N[i]=t 表示模板从0位置开始,i位置的字符首次出现不匹配的情况时,t位置之前的字符是不需要匹配的,因为字符串当前位置之前t个字符与模板前t个字符一致,借助next数组可以直接省去这部分过程。
首先假定在字符串S的j位置时,与之正在匹配的是模板字符串数组P中i位置的字符。此时有:
S
[
j
−
i
,
j
−
1
]
=
P
[
0
:
i
−
1
]
,
i
≥
1
(1)
S[j-i,j-1] = P[0:i-1] ,i\ge1 \tag{1}
S[j−i,j−1]=P[0:i−1],i≥1(1)
此时又有:
N
[
i
]
=
t
N[i]=t
N[i]=t
这表示假如
S
[
j
]
≠
P
[
i
]
S[j]\neq P[i]
S[j]=P[i],那么再比较
S
[
j
]
S[j]
S[j]和
P
[
t
]
P[t]
P[t]的值。那如果
S
[
j
]
≠
P
[
t
]
S[j]\neq P[t]
S[j]=P[t]呢?
N
[
t
]
N[t]
N[t]给出了新的开始位置,即在不匹配时,可以不断地使用
t
=
N
[
t
]
t=N[t]
t=N[t]来更新重新匹配的位置。
构造next数组
在对字符串数组S和模板P进行字符匹配时,假如已经匹配到模板P的i位置,有公式(1)。如果有:
N
[
i
]
=
t
,
t
<
i
N[i]=t,t<i
N[i]=t,t<i
不仅意味着S[j]之前t个字符与P前t个字符相同:
S
[
j
−
t
:
j
−
1
]
=
P
[
0
:
t
−
1
]
S[j-t:j-1]=P[0:t-1]
S[j−t:j−1]=P[0:t−1]
由于公式(1)表示的两个子段完全相同,说明P[i]之前t个字符与自身前t个字符也相同:
P
[
i
−
t
:
i
−
1
]
=
P
[
0
:
t
−
1
]
P[i-t:i-1]=P[0:t-1]
P[i−t:i−1]=P[0:t−1]
我们希望的
N
[
i
]
=
t
N[i]=t
N[i]=t表示
S
[
i
]
S[i]
S[i]之前与
P
P
P前t个字符相同,但我们可以借助模板本身,即假如S[i]正在与P[i]匹配,那P[i]前面t个字符与P前t个字符相同的话,等效于
S
[
i
]
S[i]
S[i]之前与
P
P
P前t个字符相同。这一性质表明完全可以脱离字符串S,只通过P就可以构造一个可以对任何字符串S快速匹配的next数组。也就是说构造next数组的过程是next数组i位置字符前面子段与相对应的next数组0位置开始的子段的比较的过程,而且KMP告诉我们构造next数组的过程与借助next数组匹配字符串的过程类似,不需要很麻烦的去比较。
假如对i已经有:
N
[
i
]
=
t
N[i]=t
N[i]=t
那么:
P
[
i
−
t
:
i
−
1
]
=
P
[
0
:
t
−
1
]
,
t
<
i
P[i-t:i-1]=P[0:t-1], t<i
P[i−t:i−1]=P[0:t−1],t<i
假如:
P
[
i
]
=
P
[
t
]
P[i]=P[t]
P[i]=P[t]
意味着:
P
[
i
−
t
:
i
]
=
P
[
0
:
t
]
P[i-t:i]=P[0:t]
P[i−t:i]=P[0:t]
也就是:
N
[
i
+
1
]
=
t
+
1
N[i+1]=t+1
N[i+1]=t+1
同样的问题来到了模板P上,也就是如果
P
[
i
]
≠
P
[
t
]
P[i] \neq P[t]
P[i]=P[t]呢?与之前描述的一样,
N
[
t
]
N[t]
N[t]给了答案:
t
←
N
[
t
]
t←N[t]
t←N[t]
性质已经给出,最后梳理下构造next数组的大致过程:
1. 初始化数组N,N.size()=P.size(),i初始化为1
2. 取
t
=
N
[
i
]
t=N[i]
t=N[i]比较
P
[
i
]
P[i]
P[i]和
P
[
t
]
P[t]
P[t],如果相等,则
N
[
i
+
1
]
=
t
+
1
N[i+1]=t+1
N[i+1]=t+1,否则
t
=
N
[
t
]
t=N[t]
t=N[t]
对next数组的构造过程实际上是通过当前N[i]计算N[i+1],那么N[0]呢?
显然,i=0时如果不匹配当然要对S下一位置开始,与模板0位置字符开始重新匹配。如果N[0]=0,会引入一个问题,因为P[i=0]=P[t=0],所以N[1]=1。即当S[j]与P[1]位置不匹配时,继续匹配S[j]和P[1],这显然不合理,因为
i
=
0
,
1
i=0,1
i=0,1时
N
[
i
]
=
t
N[i]=t
N[i]=t都不满足
t
<
i
t<i
t<i。先说下我一开始的构造方法,我选择了对i=0做特殊对待:
1. 构造next数组时指定N[0] = 0,N[1] = 1。
2. 从i=1开始循环。
int* buildNext1(const char* P) {
int str_len = strlen(P);
int* next_array = new int[str_len];
int i = 1, t = 0;
next_array[0] = 0;
next_array[1] = 0;
while (i < str_len - 1) {
if (P[i] == P[t]) {
++i;
++t;
next_array[i] = t;
}
else if (t == 0) {
++i;
next_array[i] = t;
}
else
t = next_array[t];
}
return next_array;
}
比如模板P为
aabbabcabcdd
的字符串,其对应的next数组为:
0 0 1 0 0 1 0 0
由于构造next数组时有了特殊对待,所以在匹配过程中也要有相应的特殊对待:
int match1(const char* P, const char* S) {
int* next = buildNext(P);
int s_len = strlen(S), p_len = strlen(P);
int i = 0, j = 0;
while (i < p_len && j < s_len) {
if (S[j] == P[i]) {
++i;
++j;
}
else if (i == 0) //i = 0时,S上字符要向后移动一位再与P[0]对比
++j;
else
i = next[i];
}
return j - i;
}
LeetCode这部分给的代码使用我觉得更好的处理方式:既然
N
[
i
=
0
]
=
0
N[i=0]=0
N[i=0]=0不满足
t
<
i
t<i
t<i,那么可以令
N
[
0
]
=
−
1
N[0]=-1
N[0]=−1,这样一来有以下好处:
1. N[1]自然而然地等于0,循环可以直接从i=0开始
2. 所有的
N
[
i
]
=
t
N[i]=t
N[i]=t都满足
t
<
i
t<i
t<i
3. 代码更简洁
以下是对应的构造代码和匹配代码:
int* buildNext(const char* P) { // 构造模式串 P 的 next 表
int m = strlen(P), j = 0; // “主”串指针
int* N = new int[m]; // next 表
int t = N[0] = -1; // 模式串指针
while (j < m - 1)
if (0 > t || P[j] == P[t]) { // 匹配
j++; t++;
N[j] = t; // 此句可改进为 N[j] = (P[j] != P[t] ? t : N[t]);
}
else // 失配
t = N[t];
return N;
}
int match(const char* P, const char* S) { // KMP 算法
int* next = buildNext(P); // 构造 next 表
int m = (int)strlen(S), i = 0; // 文本串指针
int n = (int)strlen(P), j = 0; //模式串指针
while (j < n && i < m) // 自左向右逐个比对字符
if (0 > j || S[i] == P[j]) // 若匹配,或 P 已移除最左侧
{
i++; j++;
} // 则转到下一字符
else
j = next[j]; // 模式串右移(注意:文本串不用回退)
delete[] next; // 释放 next 表
return i - j;
}
对N[0]做特殊对待,体现在代码上是当i==0时,只移动j;而S[j]==P[i]时,同时移动i和j,因此会额外多出一部分不太一样的操作。但当N[0]=-1时,只要j<0或者S[j]==P[i],都可以移动i和j(注意两组代码之间i和j指向的字符串数组不同)。
为了验证两种方法是否一致,作如下测试:
int main()
{
const char *P = "ACTGPACY";
const char *S = "ACTGPACTGKACTGPACY";
int* N = buildNext(P);
int* N1 = buildNext1(P);
for (int i = 0; i < 8; ++i)
cout << N[i] << ' ';
cout << endl;
for (int i = 0; i < 8; ++i)
cout << N1[i] << ' ';
cout << endl;
cout << "my result: " << match1(P, S) << " " << "result: " << match(P, S) << endl;
system("Pause");
输出结果为:
my next array:
-1 0 0 0 0 0 1 2
next array:
0 0 0 0 0 0 1 2
my result: 10
result: 10