3461 Oulipo:这可能是我见过最详细的KMP教程

题目大意

传送门
给定单词W和文本T,计算W在T中出现的次数:W的所有连续字符必须与T的连续字符完全匹配。可能会发生重叠

思路分析

  • 最暴力的方法,依次遍历T的每个元素,一旦首个元素和W【0】匹配,就检查之后的字符串能不能完全和W匹配。复杂度 O ( W T ) O(|W||T|) ,卡死。
  • 稍微进阶一点:二分查找 W [ 0 ] W[0] T T 中的位置,找到就检查与之后是否完全匹配,找不到就退出。似乎复杂度降低了,但是容易被卡死,如果 T T 中包含了许多 W [ 0 ] W[0] 也就是重叠部分很多,依旧会TLE。

借用别人的图:

我们的匹配任务表示为:
在这里插入图片描述
第一种方法就是:一旦匹配错误,移动i指针到下一个位置,但是我们的BC元素明明已经匹配过了,这里并没有使用已知的信息。
在这里插入图片描述
第二种方法是:一旦匹配错误,不会再把i移动回第1位,因为主串匹配失败的位置前面除了第一个A之外再也没有A了,我们为什么能知道主串前面只有一个A?因为我们已经知道前面三个字符都是匹配的!(这很重要)。移动过去肯定也是不匹配的!所以i可以不动,我们只需要移动j即可
在这里插入图片描述
上面的这种情况还是比较理想的情况,我们最多也就多比较了2次。但假如是在主串“SSSSSSSSSSSSSA”中查找“SSSSB”,比较到最后一个才知道不匹配,然后i回溯,这个的效率是显然是最低的。

大牛们是无法忍受“暴力破解”这种低效的手段的,于是他们三个研究出了KMP算法。其思想是:“利用已经部分匹配这个有效信息,保持i指针不回溯,通过修改j指针,让模式串尽量地移动到有效的位置。”

所以,整个KMP的重点就在于当某一个字符与主串不匹配时,我们应该知道j指针要移动到哪?

接下来我们自己来发现j的移动规律:

在这里插入图片描述
如图:C和D不匹配了,我们要把j移动到哪?显然是第1位。为什么?因为前面有一个A相同啊:

在这里插入图片描述
如下图也是一样的情况:

在这里插入图片描述
可以把j指针移动到第2位,因为前面有两个字母是一样的:

在这里插入图片描述
至此我们可以大概看出一点端倪,当匹配失败时,j要移动的下一个位置k。存在着这样的性质:最前面的k个字符和j之前的最后k个字符是一样的

如果用数学公式来表示是这样的

P [ 0 , k 1 ] = = P [ j k , j 1 ] P[0 , k-1] == P[j-k , j-1]

这个相当重要,如果觉得不好记的话,可以通过下图来理解:

在这里插入图片描述
弄明白了这个就应该可能明白为什么可以直接将j移动到k位置了。

T [ i ] ! = P [ j ] 当T[i] != P[j]时 (T在i位置,P在j位置不匹配)

T [ i j , i 1 ] = = P [ 0 , j 1 ] 有T[i-j , i-1] == P[0 , j-1] (前j-1个位置都是匹配的)

P [ 0 , k 1 ] = = P [ j k , j 1 ] 由P[0 , k-1] == P[j-k , j-1] (KMP的关键,从j位置开始前k个元素,和从头开始k个元素相同)

T [ i k   i 1 ] = = P [ 0   k 1 ] 必然:T[i-k ~ i-1] == P[0 ~ k-1] (所以i指针不用移动,j指针移动到第k位,保证了i和j的前k-1位肯定匹配)

好,接下来就是重点了,怎么求这个(这些)k呢?因为在P的每一个位置都可能发生不匹配,也就是说我们要计算每一个位置j对应的k,所以用一个数组next来保存,next[j] = k,表示当T[i] != P[j]时,j指针的下一个位置。(显然我们提前处理出来下一个位置会省时很多,否则程序调用时会求多次)

很多教材或博文在这个地方都是讲得比较含糊或是根本就一笔带过,甚至就是贴一段代码上来,为什么是这样求?怎么可以这样求?根本就没有说清楚。而这里恰恰是整个算法最关键的地方。

public static int[] getNext(String ps) {
    char[] p = ps.toCharArray();
    int[] next = new int[p.length];
    next[0] = -1;
    int j = 0;
    int k = -1;
    while (j < p.length - 1) {
       if (k == -1 || p[j] == p[k]) {
           next[++j] = ++k;
       } else {
           k = next[k];
       }
    }
    return next;
}

我们自己来推导思路,现在要始终记住一点,next[j]的值(也就是k)表示,当P[j] != T[i]时,j指针的下一步移动位置。

先来看第一个:当j为0时,如果这时候不匹配,怎么办?

在这里插入图片描述
像上图这种情况,j已经在最左边了,不可能再移动了,这时候要应该是i指针后移。所以会有next[0] = -1;这个初始化。

显然,j指针一定是后移到0位置的。因为它前面也就只有这一个位置了~~~

下面这个是最重要的,请看如下图:

在这里插入图片描述
请仔细对比这两个图。

我们发现一个规律:

P [ k ] = = P [ j ] 当P[k] == P[j]时,

n e x t [ j + 1 ] = = n e x t [ j ] + 1 有next[j+1] == next[j] + 1

其实这个是可以证明的:

P [ j ] P [ 0 , k 1 ] = = p [ j k , j 1 ] n e x t [ j ] = = k 因为在P[j]之前已经有P[0 , k-1] == p[j-k , j-1]。(next[j] == k)

P [ k ] = = P [ j ] P [ 0 , k 1 ] + P [ k ] = = p [ j k , j 1 ] + P [ j ] 这时候现有P[k] == P[j],我们是不是可以得到P[0 , k-1] + P[k] == p[j-k , j-1] + P[j]。

P [ 0 , k ] = = P [ j k , j ] n e x t [ j + 1 ] = = k + 1 = = n e x t [ j ] + 1 即:P[0 , k] == P[j-k , j],即next[j+1] == k + 1 == next[j] + 1。

这里的公式不是很好懂,还是看图会容易理解些。

那如果P[k] != P[j]呢?比如下图所示:

在这里插入图片描述
像这种情况,你从代码上看应该是这一句:k = next[k];为什么是这样子?你看下面应该就明白了。

在这里插入图片描述
现在你应该知道为什么要k = next[k]了吧!像上边的例子,我们已经不可能找到[ A,B,A,B ]这个最长的后缀串了,但我们还是可能找到[ A,B ]、[ B ]这样的前缀串的。所以这个过程像不像在定位[ A,B,A,C ]这个串,当C和主串不一样了(也就是k位置不一样了),那当然是把指针移动到next[k]啦。

总结一下:如果 P [ k ] = = P [ j ] P[k]==P[j] ,那么 [ 0 , k 1 ] , [ j k , j ] [0,k-1],[j-k,j] 都是匹配的,那么我们只需要 P [ j + 1 ] = P [ k + 1 ] P[j+1]=P[k+1] ,如果不想等,那么 [ k 1 , j ] [k-1,j] 已经匹配的元素都是要不得的,我们让 k k 不断的向前迭代,直到找到一个 P [ k ] = = P [ j ] P[k]==P[j] ,或者直到 k = = 1 k==-1 ,此时再执行 P [ j + 1 ] = P [ k + 1 ] P[j+1]=P[k+1]

有了next数组之后就一切好办了,我们可以动手写KMP算法了:

public static int KMP(String ts, String ps) {
    char[] t = ts.toCharArray();
    char[] p = ps.toCharArray();
    int i = 0; // 主串的位置
    int j = 0; // 模式串的位置
    int[] next = getNext(ps);
    while (i < t.length && j < p.length) {
       if (j == -1 || t[i] == p[j]) { // 当j为-1时,要移动的是i,当然j也要归0
           i++;
           j++;
       } else {
           // i不需要回溯了
           // i = i - j + 1;
           j = next[j]; // j回到指定位置
       }
    }
    if (j == p.length) {
       return i - j;
    } else {
       return -1;
    }

}

和暴力破解相比,就改动了4个地方。其中最主要的一点就是,i不需要回溯了。

最后,来看一下上边的算法存在的缺陷。来看第一个例子:

在这里插入图片描述
显然,当我们上边的算法得到的next数组应该是[ -1,0,0,1 ]

所以下一步我们应该是把j移动到第1个元素咯:
在这里插入图片描述
不难发现,这一步是完全没有意义的。因为后面的B已经不匹配了,那前面的B也一定是不匹配的,同样的情况其实还发生在第2个元素A上。

显然,发生问题的原因在于P[j] == P[next[j]]

所以我们也只需要在计算next时添加一个判断条件即可:

public static int[] getNext(String ps) {
    char[] p = ps.toCharArray();
    int[] next = new int[p.length];
    next[0] = -1;
    int j = 0;
    int k = -1;
    while (j < p.length - 1) {
       if (k == -1 || p[j] == p[k]) {
           if (p[++j] == p[++k]) { // 当两个字符相等时要跳过
              next[j] = next[k];
           } else {
              next[j] = k;
           }
       } else {
           k = next[k];
       }
    }
    return next;
}

好了写了一早上,终于把KMP理解的差不多了,现在开始写题了,显然对于3461这个题,我们要的不是一个字符串匹配的位置,而是有几个匹配的字符串,因此需要稍加改动:

特别注意next数组必须处理next[len(W)]的情况,这是当他匹配成功后我们需要将j值调整到这里。

#include<iostream>
#include<string.h>
#include<string>
#include<vector>
#include<algorithm>
#include<cstdio>
#include<queue>
using namespace std;

#define MAX 10005
#define ll int
#define p pair<int,int>
#define inf 1111111111

string W, T;
ll nextArr[MAX];

// s1,s2:W,T各自的size
void getNext(ll s1, ll s2) {
	memset(nextArr, 0, sizeof(nextArr));
	ll k = -1, j = 0; nextArr[0] = -1;
	while (j < s1) {//next[s1]是匹配成功之后的位置
		if (k == -1 || W[j] == W[k]) {
			if (W[++j] == W[++k]) nextArr[j] = nextArr[k];
			else nextArr[j] = k;
		}
		else k = nextArr[k];
	}
}

ll KMP(ll s1, ll s2) {
	ll i = 0, j = 0, res = 0;

	while (i < s2) {
		if (j == -1 || T[i] == W[j]) {
			i++; j++;
		}
		else {
			j = nextArr[j];
		}
		if (j == s1) {//匹配成功了
			res++; 
		}
	}
	return res;
}

int main() {
	ll N, res = 0, sign = 1;
	cin >> N;
	for (int i = 0; i < N; i++) {
		cin >> W >> T; res = 0;
		ll s1 = W.size(), s2 = T.size(), k = 0;
		W += ' '; //W加一个元素
		getNext(s1, s2);
		cout << KMP(s1, s2) << endl;
	}
}
发布了211 篇原创文章 · 获赞 14 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/csyifanZhang/article/details/105357601