【洛谷】kmp算法

文章目录


相关概念

【字符串】

用字符构成的序列就是字符串。在字符串匹配问题中,我们会让字符串的下标从 1 开始,这样便于我们处理一些边界问题。因此,在输入字符串时,我们一般会在前面加上一个空格,这样字符就从 1 开始计数了:

cpp 复制代码
string s; cin >> s;
int n = s.size();
s = ' ' + s;

【子串】

选取字符串中连续的一段。

【前缀】

从字符串的首端开始,到某一个位置结束的子串。那么,字符串长度为 i 的前缀,就是字符串 1,i 区间的子串。

【真前缀】

不包含字符串本身的前缀。

【后缀】

从字符串的某一个位置开始,到字符串末端的子串。那么,字符串长度为 i 的后缀,就是字符串 n−i+1,n 区间的子串。

【真后缀】

不包含字符串本身的后缀子串。

【真公共前后缀 - border 以及最长真公共前后缀的长度 - π】

字符串 s 的真公共前后缀为 s 的一个子串 t,满足 t 既是 s 的真前缀,又是 s 的真后缀,又称为字符串 s 的 border。

例如,字符串 aabaaba 的真公共前后缀有:a、aaba。

在一个字符串中,最长的真公共前后缀的长度用 π 表示。上述字符串的 π 值为 4。

练习:

abaaba:其中 border 有:a、aba,因此 π 值为 3;

aabbaa:其中 border 有:a、aa,因此 π 值为 2;

aaaaaa:其中 border 有:a、aa、aaa、aaaaa,因此 π 值为 5。

性质:

传递性:字符串 s 的 border 的 border 也是字符串 s 的 border。这个很显然,可以画图看一看。

例如下面字符串中所有短横线的字符串一定相同。

【字符串匹配】

字符串匹配又称模式匹配。给定两个字符串 S 和 T,需要在主串 S 中找到模式串 T。比如,主串 S= "abcdefcde",模式串 T= "cde"。如果下标从 1 开始计数,模式串会在主串 3,7 位置出现。

关于字符串匹配,大家肯定能想到暴力解法,那就是拿着模式串,在主串中一个位置一个位置判断。但是,暴力解法极限情况,时间开销会达到 O(n×m)。而接下来要学习的 kmp,能在 O(n+m) 的线性时间内,找到所有的匹配位置,已经维护出更多的信息。

前缀函数

字符串每一个前缀子串的 π 值。

即该函数能生成下图所示的表格:

以字符串 aabaab 为例,πi 表示:字符串 s 长度为 i 的前缀,最长的 border 长度(最长真公共前后缀)。

【前缀函数小用途】

从大到小枚举字符串 s 某个前缀的所有 border。

假设我们此时生成了一个字符串 s 的前缀函数表,我们可以利用这张表,从大到小拿到某个前缀所有的 border。原理就是 border 的传递性:字符串 border 的 border 还是 border。

pii是值既是长度为i的字符串的最长真公共前后缀的长度,也是长度为i的字符串最长真公共前缀的末尾字符的下标,由此可以根据上面的方法用前缀函数的表格从大到小拿到某个前缀所有的 border。

最后pipi\[pi\[pi\[i]]]的值是0, 这就是代码实现的循环终止条件。

证明:

正确性:会不会漏掉一些 border?显然是不会的:

首先 πi 存的是最长的 border 的长度,毋庸置疑;

其次,如果下一个 ππ\[i] 如果不是次长的,那么 ππ\[i] 就不是长度为 πi 的前缀的最长 border 的长度,与我们的定义相违背;

因此,整个过程一定能够不重不漏的将所有的 border 从大到小枚举出来。

代码实现:

cpp 复制代码
string s;
int pi[N]; //假设已经由前缀函数生成了一个表格

// 长度为 i 的前缀中,所有 border 的长度
void get_border(int i)
{
    int j = pi[i];
    while(j)
    {
        cout << j << endl;
        j = pi[j];
    }
}

前缀函数实现(即填表代码实现)

计算的过程包含了动态规划的思想,就是推导状态转移方程。

对于字符串 s:

状态表示:

πi 表示:字符串 s 长度为 i 的前缀,最长的 border 长度(最长真公共前后缀)。

状态转移方程:

a. 我们发现,如果将长度为 i 的前缀中的 border 删去最后一个字符,就变成了长度为 i−1 的前缀中的 border(因为字符串的真公共前后缀长的一模一样);

b. 那么,我们就可以从大到小枚举长度为 i−1 的前缀中所有的 border(因为当我们填pii时pi1到i - 1我们都已经填完了,所以可以从大到小枚举长度为 i−1 的前缀中所有的 border,用前面介绍的小用途实现),然后判断这个 border 的下一个字符是否和 si 相等:

i. 如果相等,说明这个就是最长的一个 border;

ii. 如果不相等,那就继续判断下一个 border,直到将所有的 border 验证完毕。

正确性?

毋庸置疑。因为我们只从大到小枚举 1,i−1 所有的 border;

当第一次判断成功时,一定是 1,i 最长的 border。

代码实现:

cpp 复制代码
string s;
int pi[N];

void get_pi()
{
    cin >> s;
    int n = s.size();
    s = ' ' + s;
    // pi[1] = 0;
    for(int i = 2; i <= n; i++)
    {
        int j = pi[i - 1];
        while(j && s[i] != s[j + 1]) j = pi[j];
        if(s[i] == s[j + 1]) j++;
        pi[i] = j;
    }

    // 优化
    // 我们注意到,i++ 之后,pi[i - 1] 依旧是上一次的 j,所以代码也可以这样写
    // 更能体现到 j 指针基本上不回退
    for(int i = 2, j = 0; i <= n; i++)
    {
        while(j && s[i] != s[j + 1]) j = pi[j];
        //判断j是否为0,若为0则长为i的字符串没有真公共前缀
        if(s[i] == s[j + 1]) j++; 
        //j++后值即为长为i的字符串的最长真公共前缀长度
        pi[i] = j;
    }
}

时间复杂度:

模拟一遍过程会发现,i 指针每次向后移动一位后,j 指针最多会向后移动一位,然后继续往前跳。因此两个指针最差情况下会遍历字符串两遍,时间复杂度为 O(2n)=O(n)。(本质:i - j 的差值不会变小)

用前缀函数解决字符串匹配

设主串 S= "abcabaaaba",模式串 T= "aba",主串的长度为 n,模式串的长度为 m。

将两个字符串拼起来:S=T+′#′+S= "aba#abcabaaaba"(#为主串和模式串中都不会出现的字符),对于新的字符串,可以在 O(n+m) 时间内生成前缀函数:

前缀函数等于模式串长度的位置 i,就是能够匹配的末端。在主串中,出现的起始位置就是 i−2×m。

那么,有了前缀函数之后,不仅能知道匹配了几次,还能知道每次匹配的起始位置。

(这其实已经是 kmp 算法了。因为 kmp 算法的本质,就是利用模式串的前缀函数的信息,实现快速匹配。只不过大家经常遇到的 next 数组的形式,是把上述过程拆成两部分而已。)

模板KMP

题目描述

代码

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

const int N = 2e6 + 10; //!两个字符串拼一起!1e6->2e6 

string s1, s2;
//pi[i]表示以i为结尾的字符串的最长真公共前缀长度 
int pi[N]; 

int main()
{
	cin >> s1 >> s2;
	int n1 = s1.size();
	int n2 = s2.size();
	//拼接字符串
	string s = " " + s2 + "#" + s1;  // ! " "
	int n = s.size();
	//利用前缀函数填表
	for(int i = 2; i <= n; i++)
	{
		int k = pi[i - 1]; 
		while(k && s[k + 1] != s[i])  // ! s1->s 
		{
			//k一直往前跑匹配正确的字符串 
			k = pi[k];
		}
		if(s[k + 1] == s[i])
		{
			k++;
		}
		pi[i] = k;
		
		if(pi[i] == n2)
		{
			//匹配成功
			cout <<  i - 2 * n2 << endl; // ! n1->i
		}
	}
	
	for(int i = 1; i <= n2; i++)
	{
		cout << pi[i] << " ";
	}
	
	return 0;
} 

next 数组版本

大多数教材中的 next 数组版本,其实是把「用前缀函数解决字符串匹配问题」的过程拆成两部分:

先预处理模式串 t 的前缀函数 - next 数组(next 数组本质就是前缀函数计算出的结果);

在暴力匹配的过程中,用生成的 next 数组,加速匹配。

接下来,用一个实际的例子模拟一下匹配的过程,大家会发现,next 数组版本和求前缀函数的本质是一样的。

当我们发现字符串不匹配时,例如上图模式串末尾字符e和主串b不匹配时,模式串首字符会直接跳到模式串真公共后缀的首字符开始往后匹配,依据是真公共前后缀的特性,这里的跳跃过程不就是前缀函数实现中:"判断这个 border 的下一个字符是否和 si 相等:ii. 如果不相等,那就继续判断下一个 border" 这一步的反过程吗!

cpp 复制代码
#include <iostream>
using namespace std;

const int N = 1e6 + 10;

string s, t;
int n, m;
int ne[N];

int main()
{
    cin >> s >> t;
	n = s.size();
	m = t.size();
	s = " " + s;
	t = " " + t;
   //预处理t的next数组
   //这里的逻辑和之前求解前缀函数相同
	for(int i = 2, j = 0; i <= m; i++)
	{
		while(j && t[j + 1] != t[i])
		{
			j = ne[j];
		}
		if(t[j + 1] == t[i])
		{
			j++;
		}
		ne[i] = j;
	} 
	//利用next加速匹配字符串
	//j指向next数组 
	//注意利用next数组实现kmp匹配字符串时i要从1开始 
    //因为是i==1是可能匹配成功的 
    //而预处理前缀函数不能从1开始,
	//因为要满足真公共前后缀的条件 
	for(int i = 1, j = 0; i <= n; i++) 
	{
		//注意这里是s[i] != t[j + 1]
		//不是s[j + 1] != s[i],是子串和模式串进行比较 
		while(j && s[i] != t[j + 1]) 
		{
			j = ne[j];
		}
		if(s[i] == t[j + 1])
		{
			j++;
		}
		if(j == m)
		{
			cout << i - m + 1 << "\n";
		}
	}
	for(int i = 1; i <= m; i++) 
	{
		cout << ne[i] << " ";
	}
    return 0;
}
相关推荐
禹凕1 小时前
Dijkstra算法详解与应用
python·算法
传奇开心果编程1 小时前
【Rust入门知识点学与练】第4课:条件判断 if / else
开发语言·学习·rust
m0_380743871 小时前
给 Claude API 调用补上超时重试和错误分类
开发语言·人工智能·php
Draw Stars2 小时前
Git BASH安装教程
开发语言·git·bash
vortex52 小时前
一文讲透 Zsh 与 Bash 的区别
开发语言·bash
千谦阙听2 小时前
C++类和对象(中):默认成员函数、构造与析构、拷贝构造、运算符重载
开发语言·c++·学习
YaraMemo2 小时前
元启发式算法框架
人工智能·算法·5g·信息与通信·启发式算法·信号处理
weixin_307779132 小时前
一维无粘 Burgers 方程的激波形成问题:MacCormack 格式求解
c++·算法·matlab
HugoStudio_SWAN3 小时前
洛谷 B4500 / B4449 / B3843 凯撒密码、密码强度与密码合规——加密与安全的三道门
c++·学习·程序人生·算法·安全