尝试理解KMP算法

问题背景

现在有一个问题或者需求:需要在一个极长的字符串中匹配一个短字符串,比如各种文本编辑器里的查找功能。

即在母串中找到第一次出现完整子串时的起始位置。

我们将母串写为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位。

  1. 当p[x]==p[y]时,我们把next[y]设置为x的下标+1,同时让x和y各自前进一步
  2. 当p[x]!=p[y]时,如果x等于0,那么我们把next[y]设置为0,并且让y前进一步
  3. 当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算法

【搬运】油管阿三哥讲KMP查找算法,中英文字幕,人工翻译,简单易懂

如何更好地理解和掌握 KMP 算法? 作者:海纳  来源:知乎

KMP算法next数组的一种理解思路

Github上的参考代码

猜你喜欢

转载自blog.csdn.net/maets906/article/details/121348849