问题背景
现在有一个问题或者需求:需要在一个极长的字符串中匹配一个短字符串,比如各种文本编辑器里的查找功能。
即在母串中找到第一次出现完整子串时的起始位置。
我们将母串写为T,模式串写为P
暴力算法
我们最容易想到的做法就是暴力算法,即一位一位地匹配母串T和模式串P。
我们设置一个i指针指向母串和一个j指针指向模式串,当i指针和j指针指向的字符相等时,就同时后移一位;当i指针和j指针指向的字符不相等时,就将i指针回溯到i-j+1的位置,j指针设置为0,重复匹配的过程,直到到达模式串的末尾,我们认为匹配成功。
#include <iostream>
using namespace std;
int bruteForce(string &t, string &p) {
//如果找到,返回在主串中第一个字符出现的下标,否则返回-1
int i = 0, j = 0;
while (i < t.size() && j < p.size()) {
if (t[i] == p[j]) {
i += 1;
j += 1;
} else {
i = i - j + 1; //i回溯
j = 0;
}
}
if (j == p.size())
return i - j;
else
return -1;
}
int main() {
string t, p;
t = "abcbcglx";
p = "bcgl";
int res = bruteForce(t, p);
cout << res << endl;
return 0;
}
//输出
3
在最坏的情况下,这个算法的时间复杂度为O(mn)
m为母串的长度,n为模式串的长度
显然,这是我们无法忍受的。所以我们引入KMP算法,可以将时间复杂度降为O(m+n)
KMP
我们来看下面的情况
当i指针和j指针指向的字符不匹配时,我们没有必要再让i指针回溯。
这是为什么呢?
因为我们已经比较过i和j前面的字符串abc了,而它们是匹配的,这就意味着当我们把i回溯到i-j+1的位置并把j置0时,结果一定是不匹配的。
如果你是一个善于观察的人,你一定会像下图这样做。
那么我们就发现了i指针的规律:i指针不会回溯
我们把i指针想象成一个意志坚定的士兵,它绝不后退。
我在这里引用一位大佬的博客内容
大牛们是无法忍受“暴力破解”这种低效的手段的,于是他们三个研究出了KMP算法。其思想就如同我们上边所看到的一样:“利用已经部分匹配这个有效信息,保持i指针不回溯,通过修改j指针,让模式串尽量地移动到有效的位置。”
既然我们已经知道i指针不回溯了,那j指针该如何移动呢?
我们还是从一个人的直觉出发来思考。
我们再看一个例子。
我们可能已经看出一点规律了。
当匹配失败时,我们将j指针移动到第k位,其中k的性质是:模式串P的第0位到第k-1位的串和母串T的i
指针前面的串尽可能地相等。
即 T[i-k ~ i-1] == P[0 ~ k-1]
如果我们仔细观察,又能发现 P[0 ~ k-1] == P[j-k ~ j-1]
也就是前缀和后缀相同。
好吧好吧,我们观察了这么多,又是模式串又是母串的,能不能把它们之间的关系联系起来呢?
所以当发生不匹配时,可以直接将指针j移动到k位置,再和母串进行比较。
我们先写一段不完整的代码。
int kmp(string &t, string &p) {
//如果找到,返回在主串中第一个字符出现的下标,否则返回-1
int i = 0, j = 0;
int next[p.size()];
getNext(p, next);
while (i < t.size() && j < p.size()) {
if (t[i] == p[j]) {
i += 1;
j += 1;
} else {
//i不回溯
//通过next数组找到不匹配时j回到的位置
if (j != 0)
j = next[j - 1];
else {
i++;
}
}
}
if (j == p.size())
return i - j;
else
return -1;
}
我们这里其实算是把上面分析的内容做了封装,而把next数组当做一个能正确返回指针j回退到的位置的黑盒。
那么我们就从指针j如何移动的问题暂时脱身出来,而转入到如何求k的问题上了。
next数组
为了能找到j指针回退的位置,我们预处理出来一个数组,称为next数组,也被称为部分匹配表(Partial Match Table)PMT。
next数组的值就表示:当主串与模式串的某一位字符不匹配时,模式串要回退的位置。
char: | a | b | a | b | a | b | c | a |
index: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
value: | 0 | 0 | 1 | 2 | 3 | 4 | 0 | 1 |
当主串和模式串不匹配时,如果指针j不等于0,我们就让指针j回退到next[j-1]的位置。
我们先来看前缀和后缀的定义。
这里引用一位大佬在知乎上的回答。
如果字符串A和B,存在A=BS,其中S是任意的非空字符串,那就称B为A的前缀。例如,"Harry"的前缀包括{“H”, “Ha”, “Har”, “Harr”},我们把所有前缀组成的集合,称为字符串的前缀集合。同样可以定义后缀A=SB, 其中S是任意的非空字符串,那就称B为A的后缀,例如,"Potter"的后缀包括{“otter”, “tter”, “ter”, “er”, “r”},然后把所有后缀组成的集合,称为字符串的后缀集合。要注意的是,字符串本身并不是自己的后缀。
有了这个定义,就可以说明PMT中的值的意义了。PMT中的值是字符串的前缀集合与后缀集合的交集中最长元素的长度。例如,对于”aba”,它的前缀集合为{”a”, ”ab”},后缀 集合为{”ba”, ”a”}。两个集合的交集为{”a”},那么长度最长的元素就是字符串”a”了,长 度为1,所以对于”aba”而言,它在PMT表中对应的值就是1。再比如,对于字符串”ababa”,它的前缀集合为{”a”, ”ab”, ”aba”, ”abab”},它的后缀集合为{”baba”, ”aba”, ”ba”, ”a”}, 两个集合的交集为{”a”, ”aba”},其中最长的元素为”aba”,长度为3。
我们现在来用代码求next数组。
有些大佬提供的思路是:
求next数组的过程完全可以看成字符串匹配的过程,即以模式字符串为主字符串,以模式字符串的前缀为目标字符串,一旦字符串匹配成功,那么当前的next值就是匹配成功的字符串的长度。
这个思路,感觉有点抽象。 而且不同版本的求next数组的方法也不尽相同。
这里我用的是一个印度小哥的视频讲解的方法。
思路:
next[0]初始化为0
首先设置两个指针x和y,一开始x指向模式串p的第0位,y指向模式串p的第1位。
- 当p[x]==p[y]时,我们把next[y]设置为x的下标+1,同时让x和y各自前进一步
- 当p[x]!=p[y]时,如果x等于0,那么我们把next[y]设置为0,并且让y前进一步
- 当p[x]!=p[y]并且x不等于0时,我们让x回退到x的前一位字符对应的next数组的值的位置,即x=next[x-1]
void getNext(string &p, int next[]) {
next[0] = 0;
int x = 0, y = 1;
while (y < p.size()) {
if (p[x] == p[y]) {
next[y] = x + 1;
x++;
y++;
} else if (p[x] != p[y] && x == 0) {
next[y] = 0;
y++;
} else {
//x移动到前一个字符对应的next数组的值的下标处
x = next[x - 1];
}
}
return;
}
完整版KMP
现在我们把前面的内容整合起来
#include <iostream>
using namespace std;
void getNext(string &p, int next[]) {
next[0] = 0;
int x = 0, y = 1;
while (y < p.size()) {
if (p[x] == p[y]) {
next[y] = x + 1;
x++;
y++;
} else if (p[x] != p[y] && x == 0) {
next[y] = 0;
y++;
} else {
//x移动到前一个字符对应的next数组的值的下标处
x = next[x - 1];
}
}
return;
}
int kmp(string &t, string &p) {
//如果找到,返回在主串中第一个字符出现的下标,否则返回-1
int i = 0, j = 0;
int next[p.size()];
getNext(p, next);
while (i < t.size() && j < p.size()) {
if (t[i] == p[j]) {
i += 1;
j += 1;
} else {
//i不回溯
//通过next数组找到不匹配时j回到的位置
if (j != 0)
j = next[j - 1];
else {
i++;
}
}
}
if (j == p.size())
return i - j;
else
return -1;
}
int main() {
string t, p;
t = "abcbcglx";
p = "bcglx";
int res = kmp(t, p);
cout << res << endl;
return 0;
}
//结果
3
来个java版本的助助兴
public class Demo {
public static void main(String[] args) {
String t="abcbcglx";
String p="bcglx";
int res=kmp(t,p);
System.out.println(res);
}
public static void getNext(char[] p,int[] next){
next[0]=0;
int x=0,y=1;
while(y<p.length){
if(p[x]==p[y]){
next[y]=x+1;
x++;
y++;
}else if(p[x]!=p[y] && x==0){
next[y]=0;
y++;
}else{
x=next[x-1];
}
}
}
public static int kmp(String st,String sp){
char[] t=st.toCharArray();
char[] p=sp.toCharArray();
int i=0,j=0;
int[] next=new int[p.length];
getNext(p,next);
while(i<t.length && j<p.length){
if(t[i]==p[j]){
i++;
j++;
}else{
if(j!=0) j=next[j-1];
else i++;
}
}
if(j==p.length) return i-j;
else return -1;
}
}
//结果
3
参考资料
【搬运】油管阿三哥讲KMP查找算法,中英文字幕,人工翻译,简单易懂