和KMP算法的孽缘
这玩意我在准备考研的时候就接触过,当时咸鱼老师的说法是,算法设计有难度,手操起来很简单,当时对于这个算法就没多深究。结果没想到还是跑不掉,由于找工作的时候发现KMP算法也是考察的重点,我曾经花一天的时间,啥也不干,就搁那学KMP算法,结果后来也是过几天就忘完了,前面两次经历,让KMP算法给我留下了很深的阴影,现在马上我又要去准备秋招了,又碰着这家伙了,哎,上辈子欠他的,再来重新看看吧
首先来了解一下:KMP算法用来解决的问题是什么?
最常见的问题就是,给你一个长字符串haystack和一个短字符串needle(当然实际应用中长短不一定,这么说只是为了便于理解),让你确定这个长字符串是否包含短字符串,如果包含,则返回haystack中出现的needle第一个字符的下标。
KMP算法的优化思路
有的同学一听前面的问题,就会感觉这个问题不难啊,两个for循环不就写好了吗?没错,是这样的,但是这样的代码效率是很低的,假设haystack字符串长度是n,needle字符串长度是m,那常规解法的时间复杂度就是O(m×n),科学家之所以提出KMP算法,就是想要优化这个查找的过程,在经过科学家的优化之后,使用KMP算法解决字符串查找问题,其时间复杂度可以降为O(m+n),直接乘法变加法,同志们这太猛了啊,还得是科学家。KMP算法的核心思想就是:当出现字符串不匹配时,可以记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配。这在本质上也是用空间换时间的做法,那下面我们就来重点看看,这个空间换时间,怎么换最划算
KMP算法的设计
由于KMP算法的核心是用空间换时间,所以它申请的空间里面存什么,存的数据怎么用,这个就是算法设计的核心。
KMP算法中,额外申请了一块空间,用来存放一个数组,我们把它称之为next数组。KMP算法就是用这个数组,来记录匹配的信息,避免每次匹配错误时都从头重新匹配。这个next数组的长度和needle字符串一般大,我们很多时候也叫他前缀表,里面存的数据的作用就是告诉程序:模式串与主串(文本串)某一次匹配不成功时,模式串应该从哪里开始重新匹配(注意,KMP算法前后,主串上的指针是不动的,仅仅是模式串的指针在动,相当于主串不动,模式串向右滑动一段距离)。这个我们匹配规则其实也不陌生,我们手操的时候经常这么干,现在关键问题就在于,这个前缀表是怎么求出来的? 模式串在匹配到某一个字符时发现不匹配了,我是怎么知道它应该从哪开始重新匹配的呢?
求next数组
在我们手操的时候,由于一般题目出的模式串都不是很难,我们可以用瞪眼法凭感觉去观察,但是在写程序的时候,我们就必须将这个过程用严格的代码语言去描述,这个属实让人非常头大。当然这个工作数学家已经帮我们做了,数学家告诉我们,你想求next[i]等于多少,这个问题可以转化为求needle模式串从0到i下标构成的子串的最长公共前后缀的长度。好了,那我的问题又来了,一个字符串的最长公共前后缀咋求? 想解决这个问题,首先你得知道最长公共前后缀是啥吧?这里容易出错的是后缀的理解,举个例子,请列举出abba这个字符串的所有后缀,有些同学给出的答案是a,ab,abb,abba,但正确答案是a,ba,bba,abba,看出来其中的易错点了吗?后缀读的时候,是从前往后的。
好了,到现在为止,前后缀我能理解,最长我也能理解,公共这个词儿我就不太理解了,然后看了卡哥的文章,我就有点明白了,下面请听听我的理解:比如说abab这个字符串,它的前缀有a,ab,aba,它的后缀有b,ab,bab,你会发现ab子串即是abcdba的前缀,也是abcdba的后缀(符合这样特征的子串,大众说法叫公共前后缀,卡哥的说法是相同的前缀后缀)其中ab的长度最长,于是ab就是字符串abcdba的最长公共前后缀,ab的长度是2,于是字符串abcdba的最长公共前后缀长度就是2。
好了,到现在为止,给你一个字符串,你就知道应该如何去求它的next数组了,但是实际上求next数组的时候,并不是说把当前子串的前后缀都列出来然后求交集,他是那种递进式的,每次进入一个新的循环时,都有可能会用到前面已经求好的next数组,即当发现不匹配时,会将j缩小为next[j],缩小之后再来查看s[i] 是否等于 s[j + 1]。以下就是求next数组的标准代码
void getNext(int* next, const string& s) {
cout << s<< endl;
int j = 0;
next[0] = 0;
// 注意,在这个代码实际跑起来的时候,i永远走在j的前面,即在寻找公共前后缀的时候,j是前缀指针,i是后缀指针
for(int i = 1; i < s.size(); i++) {
cout << "i=" << i<< "j=" << j<< endl;
while (j > 0 && s[i] != s[j]) {
j = next[j - 1];
}
if (s[i] == s[j]) {
j++;
}
// 一轮循环确定一个next[i]
next[i] = j;
}
}
深入理解getNext中的while循环: while (j > 0 && s[i] != s[j]) { j = next[j - 1]; }
看完上面的代码之后,肯定有人还是迷的,理论里面说好的求最长公共前后缀长度呢,我咋没看出来这咋求出来的?按照我们前面的思维,最长公共前后缀长度肯定是要先列出所有的前缀和后缀,然后找出公共前后缀,再从公共前后缀中找出最长的那个。然后你一看卡哥的代码,发现这哪找前后缀了,我分不清啊。我也是这么想的,可能作者一开始也是按照我们的思路去写的,只不过后面他发现,可以用Next数组简化求公共前后缀的步骤,于是他从这个角度出发,最终写成了上面的代码。现在我再看这个代码的视线思路,我的理解大致如下:
- 首先如果前面子串s[0,…,i-1]最长公共前后缀长度不为0,比如看下面的图,我已经知道前面那仨a 和后面那仨a相同了,那我是不是只要再比较一下那个b和c,如果他俩还相同,那我不就轻而易举找出了当前s[0,…,i]的一个公共前后缀了吗,至于这个公共前后缀为啥是最长,肯定有科学家能证明,这个我不知道,反正代码里也不用体现

2.当然如果他们不相同,那我就得缩小前后缀的长度,当然你每次减一也是缩,但是代码中用next数组缩,就缩的更快,缩了之后重新比,如果匹配,俩指针就同步向右接着比,如果不匹配就接着往左缩,缩了之后再重新比,缩到0没法缩了,还是不匹配,那就next[i]=0
以下是更进一步的理解
- 注意,求next[i],其实就是 求s[0]~s[i]构成子串的最长公共前后缀长度
- 如果不提醒这点,读者很可能理解成,s[i]是后缀的起点,其实s[i]是s[0~i]这个子字符串的最长公共后缀的终点
- s[j]也不是前缀的起点,而是s[0~i]这个子字符串的最长公共前缀的终点
- 下面这个while循环是啥意思呢,举个例子,比如说aaabaaaa这个字符串
- i=7时,刚进入循环,j=3,这时候函数就会去进行比较前缀aaab的最后一个字符b和后缀aaac的最后一个字符a,对应代码是 while (j > 0 && s[i] != s[j]) ,其中j>0是表示next[i-1]的值不为0,即s[0~i-1]这个子串的最长公共前后缀长度不是0,该子串有公共前后缀
- 然后发现不相等,怎么办呢,此时j就会进行回退,执行j = next[j-1]。这时候有人就会问,为什么执行的是next[j-1]而不是next[j]呢? 因为我们本次循环就是想求next[j],你next[j]现在还不知道呢,你怎么用?所以就用next[j-1]
- 然后又有的人问,next[j-1]的含义是下标为j-1的元素匹配失败时,下次应该从哪里开始匹配,你这前面比的不是下标为j的元素吗,不是只能说j匹配失败了吗,那为什么你现在直接默认标为j-1的元素也匹配失败了呢,你看在"aaabaaac"这个例子中,aaab和aaaa匹配失败之后,如果按我们手操,肯定是前缀aaab和aaaa长度缩小1,然后去看前缀aaa和后缀aaa是否匹配啊
- 当然没问题,代码也可以这样写,但是如果你前后缀长度一次减少一个,那不就相当于线性遍历吗,假如说我一直到最后都没能匹配成功,那你不就得减n次吗,而KMP算法的精髓就在于,它可以利用前面已经计算好的next数组的结果,对过程进行优化,请你试想一下,假如说next[i]最终的结果就是0,那你的j是每次减一减到0快,还是按照next[j-1]隧道穿梭减到0快?答案显然是后者。再考虑中间又匹配成功了的情况,即使我next[j-1]可以一下子减的比较多,但是只要你后面是真的匹配的,我按照这个循环的逻辑,还是能够一步步加上来,得到最终的结果
根据next数组,用模式串去匹配文本串
求完next数组之后,我们要做的事情,就是根据next数组,用模式串去匹配文本串。当主串和模式串匹配到一半发现不再匹配时,此时肯定有个指针要移动,那是到底是主串上的指针(假设记为i)移动,还是模式串上的指针(假设记为j)移动呢?答案是模式串中的指针 移动。应该移动到哪里呢?答案是:模式串中的指针(以前指向模式串中下标为j的元素),现在指向模式串中下标为next[j]的元素,上面的过程可以描述为执行指令:j=next[j]。然后继续比较haystack[i]和 needle[j]的元素是否相同
int j = -1; // 因为next数组里记录的起始位置为-1
for (int i = 0; i < s.size(); i++) { // 注意i就从0开始
while(j >= 0 && s[i] != t[j + 1]) { // 不匹配
j = next[j]; // j 寻找之前匹配的位置
}
// 这个循环出来时,要不然j在经过几次next[j]的缩小后,终于有一次成功实现了s[i] == t[j + 1])
// 要不然就是j一直减到0,s[i] 始终都不等于 t[j + 1])
if (s[i] == t[j + 1]) { // 匹配,j和i同时向后移动
j++; // i的增加在for循环里
}
if (j == (t.size() - 1) ) { // 文本串s里出现了模式串t
return (i - t.size() + 1);
}
}
把两份代码合一起,就是一份完整的KMP算法,对我现在来说,复现这个代码还是做不到,因为没有完全理解,所以下面的工作,就是进一步理解卡哥的代码
class Solution {
public:
void getNext(int* next, const string& s) {
int j = 0;
next[0] = 0;
for(int i = 1; i < s.size(); i++) {
while (j > 0 && s[i] != s[j]) {
j = next[j - 1];
}
// j>0说明前面一次匹配是成功的,这次接着上次继续匹配
// j=0表示前面一次匹配不成功,这次从头开始匹配
if (s[i] == s[j]) {
j++;
}
next[i] = j;
}
}
int strStr(string haystack, string needle) {
if (needle.size() == 0) {
return 0;
}
vector<int> next(needle.size());
getNext(&next[0], needle);
int j = 0;
for (int i = 0; i < haystack.size(); i++) {
while(j > 0 && haystack[i] != needle[j]) {
j = next[j - 1];
}
if (haystack[i] == needle[j]) {
j++;
}
if (j == needle.size() ) {
return (i - needle.size() + 1);
}
}
return -1;
}
};
24万+

被折叠的 条评论
为什么被折叠?



