本文参考:《大话数据结构》 —— 程杰
在进行字符串匹配时,KMP算法与朴素算法最大的区别就在于KMP算法省去了主串与子串不必要的回溯,这也是KMP算法(在主串有较多重复时)更加高效的关键。
例一:
主串:a b c d e f g a b ...
子串:a b c d e x
若用朴素算法进行匹配:
第一次匹配:
0 1 2 3 4 5 6 7 8
a b c d e f g a b ...
a b c d e x
第 0-4 位相等,第 5 位不等,比较了 6 次
第二次匹配:
0 1 2 3 4 5 6 7 8
a b c d e f g a b ...
a b c d e x
主串从第 5 位回溯至第 1 位,子串从第 5 位回溯至第 0 位,第 1 位不等,比较了 1 次
第三次匹配:
0 1 2 3 4 5 6 7 8
a b c d e f g a b ...
a b c d e x
......
第四次匹配:
0 1 2 3 4 5 6 7 8
a b c d e f g a b ...
a b c d e x
......
可以看出,用朴素算法进行匹配时,第二、三、四、五次匹配均为没有必要的,因为子串自身无重复,且子串与主串的 0-4 位相等,所以子串的第 0 位必定与主串的第 1、2、3、4位不等。
若用KMP算法进行匹配:
第一次匹配:
0 1 2 3 4 5 6 7 8
a b c d e f g a b ...
a b c d e x
第 0-4 位相等,第 5 位不等,比较了 6 次
第二次匹配:
0 1 2 3 4 5 6 7 8
a b c d e f g a b ...
a b c d e x
主串不回溯,子串从第 5 位回溯至第 0 位,第 1 位不等,比较了 1 次
从上述例子可以看出KMP算法的第一个优点:避免了主串不必要的回溯。事实上,主串的任何回溯都是不必要的,所以在KMP算法中,任何情况下主串都不回溯。
例二:
主串:a b c a b c a b x ...
子串:a b c a b x
若用朴素算法进行匹配:
第一次匹配:
0 1 2 3 4 5 6 7 8
a b c a b c a b x ...
a b c a b x
第 0-4 位相等,第 5 位不等,比较了 6 次
第二次匹配:
0 1 2 3 4 5 6 7 8
a b c a b c a b x ...
a b c a b x
主串从第 5 位回溯至第 1 位,子串从第 5 位回溯至第 0 位,第 1 位不等,比较了 1 次
第三次匹配:
0 1 2 3 4 5 6 7 8
a b c a b c a b x ...
a b c a b x
第 2 位不等,比较了 1 次
第四次匹配:
0 1 2 3 4 5 6 7 8
a b c a b c a b x ...
a b c a b x
相等,比较了 6 次
这一次,子串自身出现了重复,即第 0-1 位的 ab 和第 3-4 位的 ab 相等,所以若继续按照例一的方式避免子串的回溯,就会出现下面的情况:
第二次匹配:
0 1 2 3 4 5 6 7 8
a b c a b c a b x ...
a b c a b x
我们错过了正确答案。所以第四次匹配是必要的,不能跳过。但第二、三次匹配也是必要的吗?不难看出,答案是否定的。
若用KMP算法进行匹配:
第一次匹配:
0 1 2 3 4 5 6 7 8
a b c a b c a b x ...
a b c a b x
第 0-4 位相等,第 5 位不等,比较了 6 次
第二次匹配:
0 1 2 3 4 5 6 7 8
a b c a b c a b x ...
a b c a b x
子串从第 5 位回溯至第 3 位,相等,比较了 1 次
由于在进行第一次匹配时,我们已经知道主串的 3-4 位为ab,所以在第二次匹配中,我们完全可以直接让主串的第 5 位与子串的第 3 位进行比较,来避免子串不必要的回溯,减少比较次数,这也是KMP算法的第二个优点。
以上就是KMP算法的基本思想,但在具体实现中,我们不可能人工推算这些数值,因此我们需要一个数组来记录子串应回溯到的位置。
方便起见,对任意字符串变量str,本文中的非代码部分统一用 str[x] 表示 str.charAt(x)
因为主串不回溯,所以KMP算法的实现靠的是主串的累加和子串的回溯。在朴素算法中,每次比较后,若不相等,子串都会回溯至开头,但并不是每次都需要这么做(如例二)。为了避免这种不必要的回溯,我们需要一个next数组来记录子串应回溯至哪一位。
注意:每个子串都对应一个next数组,而且该数组的值与主串无关,因为子串回溯的位置只与子串自身的重复程度有关。
假设主串为String S
,子串为String T
,在匹配时,S
的当前下标为i
,T
的当前下标为j
,那么next[]
中记录的就是,在比较时,若T[i]
与S[x]
(x表示任意位置)不等时,T
应该回溯到的位置next[i]
。
以T:abcabx
为例:
j: 0 1 2 3 4 5
T: a b c a b x
若T[0]与S[x]不等,T应回溯至T[0],故next[0] == -1
若T[1]与S[x]不等,T应回溯至T[1],故next[1] == 0
若T[2]与S[x]不等,T应回溯至T[1],故next[2] == 0
若T[3]与S[x]不等,T应回溯至T[0],故next[3] == -1
若T[4]与S[x]不等,T应回溯至T[1],故next[4] == 0
若T[5]与S[x]不等,T应回溯至T[3],故next[5] == 2
至于为什么减一,在接下来的代码中可以看出
根据上述例子可以看出,在给next[]
赋值时,主要依据是T[0]
到T[j-1]
这段字符的前缀和后缀的重复程度。当T[j]
前这段字符的前缀(T[0]
)和后缀(T[j-1]
)不等时,T
应由T[j]
回溯至T[0]
;若相等,再比较T[0+1]
和T[j-1+1]
。
说了这么多,让我们结合具体实现来看:
/* 为避免和匹配时的i、j混淆,这里使用m、n */
public static int[] getNext(String T) {
int next[] = new int[T.length()];
int m = 0;
int n = -1;
next[0] = -1;
while (m < T.length() - 1) {
if (n == -1 || T.charAt(m) == T.charAt(n)) {
m++;
n++;
if (T.charAt(m) != T.charAt(n)) {
next[m] = n;
} else {
next[m] = next[n];
}
} else {
n = next[n];
}
}
return next;
}
第 3 行:声明数组next[]
,长度与T
的长度相同。
第 4 行:声明变量m=0
。变量m
用来控制循环次数,同时用来表示后缀最后一位的下标。
第 5 行:声明变量n=-1
。变量n
用来表示前缀最后一位的下标,n==-1
表示令前缀回溯至T[0]
。
第 6 行:令next[0]=-1
。这里的-1
可以看作一个标记,表示“准备”状态,即T
将回溯至T[0]
。
接下来就是第 7-19 行的循环。该循环的主要思想与上述相同,即T[n]
表示当前前缀最后一位,T[m]
表示当前后缀最后一位,起始时n=-1
,m=0
。进入循环后,先进行判断,若n==-1
(即n
在起始位置),或当前前缀和后缀相等,需要继续比较下一位,则令m
和n
自增,即前缀和后缀均向后增加一位,再次判断自增后是否满足条件;若不满足,即n
不在起始位置,当前前缀和后缀也不相等(更直观地说,就是在某位置的前缀和后缀的前x位相等,最后一位不等),则令n
回溯至与前缀中重复的那一位相同的位置(若存在,不存在则回溯至起始位置),即令n=next[n]
。
当然,只比较是不行的,还要把比较的结果赋给next[]
,由于next[]
对应T
的每一位的值,所以每当m
自增,都要给next[m]
赋值。赋值前我们要先判断自增后的T[m]
与T[n]
是否相等:
1、不等。此时有两种情况,一是因为n=-1
进入外层判断,这种情况为m
、n
自增后T
回溯至T[0]
,即从头开始比较,若不等,则下次比较时T
仍应从T[0]
开始比较,故将n
赋给next[m]
(这种情况下n
始终为0
);二是因为T[m]==T[n]
进入外层判断,这种情况为前缀与后缀相等后m
、n
自增,例如例二中的T:abcabx
,匹配时,若T[5]!=S[5]
,T
应回溯至T[2]
,即应有next[5]==2
(此时m==5
,n==2
,由m==3
,n==0
自增而来),故将n
赋给next[m]
。综合这两种情况可以得出,若不等,则有next[m] = n
。
2、相等。例如T:abcabx
,匹配时,若T[4]!=S[x]
,此时j==4
,因为T[1]==T[4]
,所以T
应回溯至T[next[1]]
而不是T[next[4]]
,即当后缀与前缀有重复时,匹配不等时可以直接回溯至与前缀中重复的那一位相同的位置。所以,若相等,则有next[m] = next[n]
。
循环结束后返回next[]
。
注意:1、这里的前缀与后缀不一定是单个字符,如T:abcabx
,当i==4
时,前缀为ab
,后缀为ab
,只不过比较时比较的是前缀和后缀的最后一位。
2、n=-1
时,主串的下一位与T[0]
比较;n=0
时,主串的当前位与T[0]
比较。
3、m
、n
仅仅是生成next[]
时的中间变量,该方法中只有next[]
与字符串匹配直接相关。
4、只有当前前缀与后缀相等,或T
回溯至T[0]
时,m
、n
才自增。
直接上代码:
public static int KMP(String S, String T, int pos) {
int i = pos - 1;
int j = -1;
int next[] = getNext(T);
while (i < S.length() && j < T.length()) {
if (j == -1 || S.charAt(i) == T.charAt(j)) {
i++;
j++;
} else {
j = next[j];
}
}
if (j == T.length()) {
return i - T.length();
} else {
return -1;
}
}
知道了next[]
的生成原理,应用就很简单了。
第 2 行:声明变量i=pos-1
。pos
表示从第几位开始匹配,一般为0
,即从头开始匹配。同时用来作为下标表示后缀。
第 3 行:声明变量j=-1
。与getNext()
中的n
相同,变量j
用来表示前缀最后一位的下标,j==-1
表示令前缀回溯至T[0]
。
第 4 行:从getNext()
接收next[]
。
第 5-12 行的循环与getNext()
中的基本相同,只是不需要给next[]
赋值了。循环条件是i
i=S.length()
,表示S
中不存在T
,若j=T.length()
,表示已匹配成功;若都符合条件,表示正在匹配,进入循环。进入循环后进行判断,若j==-1
,即从头开始匹配,或当前前缀与后缀相等,则令i
、j
自增;若不满足条件,则令j
按照next[]
回溯。
循环结束后判断j
的值,若与T
的长度相等,即比较过T
的最后一位后仍相等,j
再次自增,说明匹配成功,返回i - T.length()
,即用匹配成功时的后缀减去T
的长度,得到的S
中T
的位置;若不相等,则说明在S
中未匹配到T
,返回-1
。
至此,KMP模式匹配算法就讲解完毕了。最后再强调一下需要注意的地方:
1、KMP算法的核心就是避免所有不必要的回溯,并用next[]
数组记录应该回溯到的位置,用指定回溯的位置代替遍历式地回溯,以此提高效率。可以看出,next[]
的生成是KMP算法的核心,应重点理解。
2、每个子串都对应一个唯一的next[]
数组,与要匹配的主串无关,因为在KMP算法中,主串不回溯,只自增,next[]
记录的是子串要回溯到的位置。
3、getNext()
与KMP()
的结构十分相似,但思想有很大不同,要注意区分:getNext()
中只有一个字符串,进行的是前缀和后缀的比较;KMP()
中有两个字符串,进行的是字符串和字符串的比较,比较后子串根据next[]
进行回溯。
4、如果不理解,可以带入一个子串推导一遍,这样能更直观地理解其中的过程。
5、不难看出,KMP算法仅在子串自身重复度较高时更高效,若子串重复度不高,则与朴素算法差别不大。
最后附上完整代码:
public class Main {
public static void main(String[] args) {
System.out.println(KMP("abcabcabx", "abcabx", 0));
}
public static int KMP(String S, String T, int pos) {
int i = pos - 1;
int j = -1;
int next[] = getNext(T);
while (i < S.length() && j < T.length()) {
if (j == -1 || S.charAt(i) == T.charAt(j)) {
i++;
j++;
} else {
j = next[j];
}
}
if (j == T.length()) {
return i - T.length();
} else {
return -1;
}
}
public static int[] getNext(String T) {
int next[] = new int[T.length()];
int m = 0;
int n = -1;
next[0] = -1;
while (m < T.length() - 1) {
if (n == -1 || T.charAt(m) == T.charAt(n)) {
m++;
n++;
if (T.charAt(m) != T.charAt(n)) {
next[m] = n;
} else {
next[m] = next[n];
}
} else {
n = next[n];
}
}
return next;
}
}
以下是朴素算法作为对比:
public class Main {
public static void main(String[] args) {
System.out.println(KMP("abcabcabx", "abcabx", 0));
}
public static int KMP(String S, String T, int pos) {
int i = pos;
int j = 0;
while (i < S.length() && j < T.length()) {
if (S.charAt(i) == T.charAt(j)) {
i++;
j++;
} else {
i = i - j + 1;
j = 0;
}
}
if (j == T.length()) {
return i - T.length();
} else {
return 0;
}
}
}