字符串基础算法详解(kmp,扩展kmp,最小表示法,manachar)

由kuangbin专题十六习题总结此文-->都是水题

目录

kmp 

扩展kmp/z函数 

 最小表示法/最大表示法

 manachar


kmp 

最开始,我们需要求解一个模式串在目标串中是否出现,出现了多少次,循环出现多少次之类的问题。(此时的串要求是子串而不是子序列,即要求为连续的字符串)

很容易想到的暴力方法是,对于目标串的每一位,遍历与模式串匹配,不相同则移至下一位进行匹配,时间复杂度O(nm),显然会超时,而kmp算法可以以优化的时间处理顺而解决这个问题。

在这个算法中,最重要的一个点便是长度为n的next数组的解法(n为模式串的长度),后文用nest代替(因为next不能被定义成变量名)。nest数组中每一位表示的内容是到模式串第i位为止,最长的前后缀相同的长度。

所谓前缀后缀,对于字符串abdcab来说,前缀为‘a’ ‘ab’ ‘abd’ ‘abdc’ ‘abdca’ ‘abdcab’,最后一位的后缀为‘b’ ‘ab’ ‘cab’ ‘dcab’ ...,倒数第二位的后缀为‘a’ ‘ca’ ‘dca’ ...,后略。

假设:模式串A为abcabd,目标串B为abcadabcabd

对于模式串,我们先处理到一个nest数组中记录到每一位为止的最长与前缀相同的后缀长度,此时的数组为nest[1] = 0, nest[2] = 0, nest[3] = 0, nest[4] = 1(最长相同前后缀为‘a’), nest[5] = 2(最长相同前后缀为‘ab’), nest[6] = 0。

定义两个指针i = 1,j = 0,即从目标串B的第1位开始与模式串A的j + 1位进行比较。

此时遍历对比模式串与目标串时对比到第四位时,我们发现B[i] != A[j + 1]了,如果是在之前,那么我们是不是就要返回到模式串的第一位重新与目标串的第二位进行重新匹配了?然而这个bcad...是不能和模式串的前边进行匹配成功的,第三位开始的cada...也是不能成功的,只有从第四位adab...开始的目标串才可能与模式串的前边成功匹配,细心点的话就会发现,这正好是已经匹配成功的 目标串内容的后缀 与 模式串的前缀 有一个相同的 ‘a’   的位置,(其余的中间位置不论从哪开始都是不能成功匹配的),又因为已经匹配好的目标串和模式串的是完全相同的,那么真正需要被匹配的第一个位置便变成了模式串最长公共前后缀长度的下一位,即我们nest数组求的内容,将j回溯到nest[j]的位置,让i继续往后匹配即可。

这是通过暴力的方式发现的规律,此时的匹配过程只有j是在不断回溯,而i完全不需要回溯(要么是与 j 已经确定匹配好的前缀的下一位比较,要么是已经匹配好的部分无法与模式串的任何位置成功匹配,则从下一位开始比较),时间复杂度便减小到了O(m),目标串的长度。

1. 匹配成功时继续匹配下一位,并更新到第i位的最长公共前后缀为j(后缀与模式串的前几个是相同的)

2.下一位匹配要失败时,j回溯到nest[j]的位置,即有可能匹配成功的位置,不断回溯至下一位能与i匹配成功。

代码实现:

//b为模式串,n是b的长度;a是目标串,m是a的长度

//求nest数组
for (int i = 2, j = 0; i < n; i++) {
	while (j && b[i] != b[j + 1]) {
		j = nest[j];
	}
	if (b[i] == b[j + 1]) {
		j++;
    }
	nest[i] = j;
}

//查询b是否是a的子串
int ans = 0;
for (int i = 1, j = 0; i <= m; i++) {
	while (j&&a[i] != b[j + 1]) {
		j = nest[j];
	}
	if (a[i] == b[j + 1]) {
		j++;
	}
	if (j == n) {
		ans = 1;
		break;
	}
}
if (ans == 1) 
    cout<<"YES";
else 
    cout<<"NO";

 kmp得到的nest数组可以运用的地方非常多,下面列举一下我遇到的一些情况和解法。

规律1:如果模式串总长度n % (n - nest[n]) == 0时,这个字符串是一个完全循环串,循环节是末n - nest[n]位的字符。

规律2: 匹配时j == n时,模式串在目标串中出现了。

1. 求模式串是否在目标串中出现,出现了几次?

        同规律2,并计算cnt

2. 求模式串是否在目标串中不重叠地出现了几次。

        匹配时每次j == n使j回溯到0,从第一位开始匹配

3. 求添加多少个字符使模式串变成完全循环的串

        nest[n]为末多少位与前缀相同,n - nest[n]得到最短的循环串长度,用最短循环长度 - 末尾不满足这个循环长度的长度就是需要补充的长度以达到循环,n - nest[n] - n % (n - nest[n])

4. 求目标串的完全循环子串即循环长度(同规律1)

5. 求不完整模式串的最小循环节可能长度

        n - nest[n]

6. 末位的所有相同前后缀长度

        将最长前后缀长度转化为所有的,比如字符串ababcababababcabab,n = 18,nest[18] = 9,说明后九位与前九位是完全相同的,那么后九位的后缀便是前九位的后缀,问题就转变成了查找前九位的最长相同前后缀即nest[9],以此类推直至nest[i] = 0,说明不再有相同前后缀了。

7. 查找一定量字符串的最长公共子串

        取出最短串,暴力枚举它的所有子串,kmp处理,遍历匹配变成问题1的情况(仅对于小范围的字串长度,否则会超时)

8. 查找两个字符串相同前后缀长度

        将两个字串连接后kmp处理,注意答案小于min(n, m)。(还可以用exkmp解)

等等。

扩展kmp/z函数 

 和kmp数组不同的地方在于扩展kmp求的是对于每一位,从它开始的字符串与整个字符串的公共前缀是多少,意思很好理解,代码也不难,明白一点就理解其义了。

开始我们需要定义两个指针 l = 1, r = 0,表示已经枚举过的位数的公共前缀的最大的l和r(如果是第j位,则l = j,r = j + z[j] - 1),初始表示空段。又z[1] = n,n位字符串的总长度,也是显而易见的,对于第1位,和字符串的公共前缀长度是整个串。

前提:l到r表示的子串是已经枚举过的,与整个字符串相同的前缀长度,即字符串s的 [ s[l], s[r] ]等于[ s[1], s[r - l + 1] ]

从第二位开始枚举时,目前枚举到的位置i只有两种情况:

        ① i < r,则对于目前位置i来说,必然有一个位置k(k = i - l + 1)与之对应,并且从k到(r - l + 1)的字串内容与从i到r的内容相同(图示如下 

-------l----------i----r----------

1----------k----(r - l + 1)-----

又i与k对应,则z[i]的下界便是min(z[k], r - i + 1),(与k对应但相同部分只有r - i + 1那么长)

        ② i > r,暴力枚举即可

代码实现:

//n为字符串s的长度后,s = " " + s使之下标从1开始

void exkmp()
{
    z[1] = n;
    int l, r = 0;
    for (int i = 2; i <= n; i++) {
        if (i > r) {
            z[i] = 0;
        } else {
            z[i] = min(z[i - l + 1], r - i + 1);
        }
        while (i + z[i] <= n&&s[z[i] + 1] == s[i + z[i]]) {
            z[i]++;
        }
        if (i + z[i] - 1) > r) {
            l = i;
            r = i + z[i] - 1;
        }
    }
}

至于用法,貌似跟kmp差不多并且都能用普通kmp解决...但多学一个算法总没坏处。 

 最小表示法/最大表示法

最小表示法是对于一个环形字符串,找到字典序最小的长度为环长的表示方法。

eg:abcde

它可以表示为abcdebcdeacdeabdeabceabcd 。

其中字典序最小的(最小表示法)是abcde,最大的(最大表示法)是eabcd

这里就有一个很优美的方法(时间复杂度O(n))可以快速找到这个字符串的最小表示位置。

对于一个字符串A,长度为n,首先要求如果可以循环,那就将A延长一倍即可。此时使用两个指针i和j表示从i开始和从j开始长度为n的字符串,初始化i = 1,j = 2让两个字符串错开,我们可以比较他们后边的每一位,

1. 相等就继续往后匹配

2. 如果遇到A[i + k] > A[j + k](最大表示法是 < ),那么说明从i到k之间的所有起点 i' 都不会是最小表示法,因为不论从之间的哪一个点开始,都会存在对应的j到k间的一个位置 j' 使得 j' 表示的字符串是小于 i' 的。此时只需要让i移至i + k + 1的位置重新和j比较即可

3. 如果移动后i等于j了,让j++保证两个串是错开的就好了。

4.如果匹配到第n位依然i与j是相同的,说明整个字符串是一个完全循环串,循环节就是max(i, j) - min(i, j),直接结束查找就可以了,其中i或者j都是最小表示法的起始位置

代码实现:

//最小表示法
int i = 0, j = 1;
while (i < n&&j < n) {
	int k = 0;
	while (k < n&&s[i + k] == s[j + k]) {
		k++;
	}
	if (k == n) {
		break;
	}
	if (s[i + k] > s[j + k]) {
		i += k + 1;
	} else {
		j += k + 1;
	}
	if (i == j) {
		j++;
	}
}

//最大表示法
i = 0, j = 1;
while (i < n&&j < n) {
	int k = 0;
	while (k < n&&s[i + k] == s[j + k]) {
		k++;
	}
	if (k == n) {
		flag = 1;
		break;
	}
	if (s[i + k] < s[j + k]) {
		i += k + 1;
	} else {
		j += k + 1;
	}
	if (i == j) {
		j++;
	}
}

 一道考察板子很全面的例题:Problem - 3374 (hdu.edu.cn)

 题目问最小表示法和最大表示法的循环节起始位置和循环次数,和板子几乎一样,多加了个是否是循环串的判断。

ac代码:

#include<iostream>
#include<fstream>
#include<stdio.h>
#include<math.h>
#include<algorithm>
#include<string.h>
#define ll long long
using namespace std;
const int maxn = 2e6 + 10;
signed main()
{
	ios::sync_with_stdio(false);
	cin.tie();
	cout.tie();
	string s;
	while (cin>>s) {
		int n = s.length();
		s = s + s;
		int i = 0, j = 1, flag = 0;
		while (i < n&&j < n) {
			int k = 0;
			while (k < n&&s[i + k] == s[j + k]) {
				k++;
			}
			if (k == n) {
				flag = 1;
				break;
			}
			if (s[i + k] > s[j + k]) {
				i += k + 1;
			} else {
				j += k + 1;
			}
			if (i == j) {
				j++;
			}
		}
		int ans = 0, re = 0;
		if (flag) {
			re = min(i, j);
			ans = n / (max(i, j) - min(i, j));
		} else {
			re = min(i, j);
			ans = 1;
		}
		cout<<re + 1<<" "<<ans<<" ";
		i = 0, j = 1, flag = 0;
		while (i < n&&j < n) {
			int k = 0;
			while (k < n&&s[i + k] == s[j + k]) {
				k++;
			}
			if (k == n) {
				flag = 1;
				break;
			}
			if (s[i + k] < s[j + k]) {
				i += k + 1;
			} else {
				j += k + 1;
			}
			if (i == j) {
				j++;
			}
		}
		ans = 0, re = 0;
		if (flag) {
			re = min(i, j);
			ans = n / (max(i, j) - min(i, j));
		} else {
			re = min(i, j);
			ans = 1;
		}
		cout<<re + 1<<" "<<ans<<"\n";
	}
	return 0;
}

想要求一堆字符串里有多少个不同种类的字符串(循环起来相同即种类相同)也可以使用最小表示法解决,存所有字符串的最小表示字符串,最后查找有几种不同的最小表示法即可

是一个 很没有技术含量的简单算法...(很适合fw我来学习)

 manachar

马拉车也是一个很简单且使用极其片面的算法,仅能用于求回文串。首先对于不管是奇数长度还是偶数长度的回文串,我们都可以把他转换成一个奇数长度的串,方法也很简单,在从头到尾的每两个元素间加上一个完全不会出现的字符,开始和结尾加上两个又不同的表示起始。 

例如:abbacbc 转换后变成 $#a#b#b#a#c#b#c#^

 那么要求的数组p表示的就是以转换后每一个位置为中心的最长回文串的半径长度,视$为第0位的话,那么对于样例从p[1]开始的数组元素便为1,2,1,2,5,2,1,2,1,2,1,4,1,2

可见对于每一个大于2长度的回文串,原串的长度便是p[i] - 1。 

代码实现:

//n是宏定义的字符串长度
int k = 0;
b[k++] = '$', b[k++] = '#';
for (int i = 0; i < n; i++) {
	b[k++] = s[i];
	b[k++] = '#';
}
b[k++] = '^';
n = k;

如何求解这个p数组呢?我们只需定义两个指针,一个指向最右回文串的中点mid,一个指向最右回文串的右端点midright(初始化0)。

1. 当目前位置i < midright时,(i一定是在mid后边的,mid是前边枚举过的的中心位置),此时可以找到一个i关于mid的对称点j使得p[i] >= p[j],但如果j - p[j] < midleft的话,i却不能大于他的对称位置,因为若大于则代表这个以mid为中点的回文串的半径是错误的,是小的,所以应当取p[j] 和 midright - i的最小值为p[i]的下界。

2. 如果i >= midright时,只能从1开始枚举,即p[i]的下界是1.

代码实现:

int mid, midright = 0;
for (int i = 1; i < n; i++) {
	if (i < midright) {
		p[i] = min(p[mid * 2 - i], midright - i);
	} else {
		p[i] = 1;
	}
    //找到i位置的p[i]
	while (b[i - p[i]] == b[i + p[i]]) {
		p[i]++;
	}
    //不断更新最靠右的mid
	if (i + p[i] > midright) {
		midright = i + p[i];
		mid = i;
	}
}

这是一道稍有变形的板子题:Problem - 4513 (hdu.edu.cn)

要求不只是回文还要是丘形的回文,所以只需在计算半径是多加一个限制判断即可。

ac代码:

#include <iostream>
#include <string.h>
#include <algorithm>
#include <math.h>
#include <stdlib.h>
using namespace std;

const int maxn = 2e6 + 10;
int n;
int s[maxn], b[maxn];
int p[maxn];

void init()
{
	int k = 0;
	b[k++] = -1, b[k++] = 0;
	for (int i = 0; i < n; i++) {
		b[k++] = s[i];
		b[k++] = 0;
	}
	b[k++] = -2;
	n = k;
}

void manachar()
{
	int mid, midright = 0;
	for (int i = 1; i < n; i++) {
		if (i < midright) {
			p[i] = min(p[mid * 2 - i], midright - i);
		} else {
			p[i] = 1;
		}
		while (b[i - p[i]] == b[i + p[i]]&&(!b[i + p[i]] || b[i + p[i]] <= b[i + p[i] - 2])) {
			p[i]++;
		}
		if (i + p[i] > midright) {
			midright = i + p[i];
			mid = i;
		}
	}
}

int main()
{
	ios::sync_with_stdio(false);
	cin.tie();
	cout.tie();
	int t;
	cin>>t;
	while (t--) {
		cin>>n;
		for (int i = 0; i < n; i++) {
			cin>>s[i];
		}
		init();
		manachar();
		int ans = 0;
		for (int i = 0; i < n; i++) {
			ans = max(ans, p[i] - 1);
		}
		cout<<ans<<"\n";
	}
	return 0;
}

by yq

--------------------

        一个人能能走的多远不在于他在顺境时能走的多快,而在于他在逆境时多久能找到曾经的自己。

——KMP

  • 12
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 12
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

超高校级のDreamer

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值