算法竞赛进阶指南 基本数据结构 0x15 字符串

KMP模式匹配

KMP算法,又称 模式匹配算法,能够在线性时间内判定字符串A[1 ~ N]是否为字符串B[1 ~ M]的子串,并求出字符串A在字符串B中各次出现的位置

首先,一个O(NM)的朴素做法是,尝试枚举字符串B中的每个位置i,把字符串A与字符串B的后缀B[i ~ M]对齐,向后扫描逐一比较A[1]与B[i],A[2]与B[i + 1]…是否相等,我们把这种比较过程称为A与B尝试进行“匹配”

其次,这个问题使用字符串Hash也能在线性时间内求姐。通过上一节中的“兔子与兔子”这道例题,我们已经知道:可以在O(N)时间内与处理一个字符串的所有前缀Hash值,并在O(1)时间内查询该字符串任意一个子串的Hash值。所以,一个很直接的想法是:枚举字符串B中的每个位置 i ( N ≤ i ≤ M ) i(N \leq i \leq M) i(NiM),检查字符串A的Hash值与字符串B的子串A[i - N + 1 ~ i]的Hash值是否相同

KMP算法能更高效处理这个问题,并且能为我们提供一些额外的信息。详细地讲,KMP算法分为两步:
1、对字符串A进行自我“匹配”,求出一个数组next,其中next[i]表示"A中以i结尾的非前缀子串“与”A的前缀“能够匹配的最长长度,即:
n e x t [ i ] = m a x next[i]=max next[i]=max { j j j },其中 j < i j<i j<i并且 A [ i − j + 1 A[i-j+1 A[ij+1 ~ i ] = A [ 1 i]=A[1 i]=A[1 ~ j ] j] j]

特别地,当不存在这样的j时,令 n e x t [ i ] = 0 next[i]=0 next[i]=0

2、对字符串A与B进行匹配,求出一个数组f其中 f [ i ] f[i] f[i]表示“B中以i结尾的子串”与“A的前缀”能够匹配的最长长度,即:
f [ i ] = m a x f[i]=max f[i]=max { j j j },其中 j ≤ i j \leq i ji并且 B [ i − j + 1 B[i-j+1 B[ij+1 ~ i ] = A [ 1 i]=A[1 i]=A[1 ~ j ] j] j]

下面讨论next数组的计算方法。根据定义, n e x t [ 1 ] = 0 next[1]=0 next[1]=0。接下来我们按照i = 2 ~ N的顺序依次计算next[i]
假设 n e x t [ 1 next[1 next[1 ~ i − 1 ] i-1] i1]已经计算完毕,当计算next[i]时,根据定义,我们需要找出所有满足 j < i j<i j<i A [ i − j + 1 A[i-j+1 A[ij+1 ~ i ] = A [ 1 i]=A[1 i]=A[1 ~ j ] j] j]的整数j并取最大值。为了叙述方便,我们称满足这两个条件的j为next[i]的“候选项“

使用朴素算法计算next数组:
该算法对每个i枚举了i-1个非前缀子串,并检查与对应前缀的匹配情况,时间复杂度不会低于 O ( N 2 ) O(N^2) O(N2)

引理:
j 0 j_0 j0是next[i]的一个“候选项”,即 j 0 < i j_0<i j0<i并且 A [ i − j 0 + 1 A[i-j_0+1 A[ij0+1 ~ i ] = A [ 1 i]=A[1 i]=A[1 ~ j 0 ] j_0] j0],则小于 j 0 j_0 j0的最大的next[i]的“候选项”是 n e x t [ j 0 ] next[j_0] next[j0]。换言之, n e x t [ j 0 ] + 1 next[j_0]+1 next[j0]+1 ~ j 0 − 1 j_0-1 j01之间的数都不是next[i]的“候选项”。

使用优化的算法计算next数组:
根据引理,当next[i - 1]计算完毕时,我们即可得知next[i - 1]的所有“候选项”从大到小依次是next[i - 1], next[next[i - 1]], … 而如果一个整数j是next[i]的“候选项“,那么j - 1显然也必须是next[i - 1]的“候选项”。因此,在计算next[i]时,只需把next[i - 1] + 1, next[next[i - 1]] + 1, …作为j的选项即可

这就是KMP模式匹配算法。在上面代码的while循环中,j的值不断减小,j = next[j]的执行次数不会超过每层for循环开始时j的值与while循环结束时j的值之差。而在每层for循环中,j的值至多增加1.因为j始终非负,所以在整个计算过程中,j减小的幅度总和不会超过j增加的幅度总和。故j的总变化次数至多为 2 ( N + M ) 2(N+M) 2(N+M),整个算法的时间复杂度为 O ( N + M ) O(N+M) O(N+M)

0、AcWing 831. KMP字符串

题意 :

  • 给定一个字符串 S,以及一个模式串 P,所有字符串中只包含大小写英文字母以及阿拉伯数字。
  • 模式串 P 在字符串 S 中多次作为子串出现。
  • 求出模式串 P 在字符串 S 中所有出现的位置的起始下标。
  • 共一行,输出所有出现位置的起始下标(下标从 0 开始计数),整数之间用空格隔开。

思路 :

  • KMP算法一般推荐字符串下标从1开始!
  • ne[i]表示字符串p中以i结尾的非前缀子串与p串的前缀 能否匹配的最大长度
  • 先考虑假设已知ne数组的情况下,i表示在s串中现在要匹配的位置(因此i从1开始)(且注意到i永远不会回退!这就是kmp的特征),j表示在p串中现在已经被成功匹配的位置(因此从0开始);那么,i每前进一个,都考虑当前这个i和下一个要被匹配的j是否能匹配,如果不能,那么j就一直缩为ne[j],直到j = 0或者当前这个i和下一个要被匹配的j匹配了;j跳出循环后,如果j是p串的最后一个,说明p串完全被匹配上了,注意看这个时候j要变成什么呢?j还是要变成ne[j]
  • 那么我们如何来求这个ne数组呢?注意到求ne数组的过程就是p串自己和自己匹配;不过注意i是从2开始的(因为ne[1] = 0),每一个i都可以得到一个ne[i] = j来求ne数组的值
#include <iostream>
#include <cstring>
using namespace std;
const int N = 1e5 + 10, M = 1e6 + 10;

char p[N], s[M];
int ne[N];
int n, m;

int main() {
    scanf("%d%s", &n, p + 1);
    scanf("%d%s", &m, s + 1);
    
    for (int i = 2, j = 0; i <= n; ++ i) {
        while (j && p[i] != p[j + 1]) j = ne[j];
        if (p[i] == p[j + 1]) ++ j;
        ne[i] = j;
    }
    for (int i = 1, j = 0; i <= m; ++ i) {
        while (j && s[i] != p[j + 1]) j = ne[j];
        if (s[i] == p[j + 1]) ++ j;
        if (j == n) {
            printf("%d ", i - n);
            j = ne[j];
        }
    }
}

1、AcWing 141. 周期

题意 :

  • 一个字符串的前缀是从第一个字符开始的连续若干个字符,例如 abaab 共有 5 个前缀,分别是 a,ab,aba,abaa,abaab。
  • 我们希望知道一个 N 位字符串 S 的前缀是否具有循环节。
  • 换言之,对于每一个从头开始的长度为 i(i>1)的前缀,是否由重复出现的子串 A 组成,即 AAA…A (A 重复出现 K 次,K>1)。
  • 如果存在,请找出最短的循环节对应的 K 值(也就是这个前缀串的所有可能重复节中,最大的 K 值)。

思路 :

  • 引理:S[1 ~ i]具有长度len < i的循环元 的充要条件 是 len能整数i 并且 S[len + 1 ~ i] = S[1 ~ i - len](即i - len是next[i]的“候选项”)
  • 证明:
    先证必要性。设S[1 ~ i]具有长度为len的循环元,显然len能整除i,并且S[1 ~ i - len]和S[len + 1 ~ i]都是由 i / len - 1个循环元构成的。故S[1 ~ i - len] = S[len + 1 ~ i]。
    再证充分性。设len能整除i并且S[len + 1 ~ i] = S[1 ~ i - len]。因为len < i,所以S[1 ~ i - len]和S[len + 1 ~ i]的长度不小于len且是len的背书。二者各取前len个字符,有S[1 ~ len] = S[len + 1 ~ 2 * len]。以此类推,不断向后取len个字符,可以发现S[1 ~ i - len]和S[len + 1 ~ i]是以len为间隔错位对齐的,故S[1 ~ len]是S的循环元。
  • 根据引理,当i - next[i]能整除i时,S[1 ~ i - next[i]]就是S[1 ~ i]的最小循环元。(因为next[i]是最大的“候选项”)。它的最大循环次数就是 i / (i - next[i])。其中i - next[i]能整除i的条件是为了保证循环元每次重复的完整性
  • 进一步地,如果i - next[next[i]]能整除i,那么S[1 ~ i - next[next[i]]就是S[1 ~ i]的小循环元。以此类推,我们还可以找出S[1 ~ i]所有可能的循环元。
  • 值得指出的一个性质是,一个字符串的任意循环元的长度必然是最小循环元长度的倍数。
#include <iostream>
using namespace std;
const int N = 1e6 + 10;

int n;
char s[N];
int ne[N];

void get_next() {
    for (int i = 2, j = 0; i <= n; ++ i) {
        while (j && s[i] != s[j + 1]) j = ne[j];
        if (s[i] == s[j + 1]) ++ j;
        ne[i] = j;
    }
}

int main() {
    int cnt = 0;
    while (scanf("%d", &n) && n) {
        scanf("%s", s + 1);
        printf("Test case #%d\n", ++ cnt);
        get_next();
        for (int i = 2; i <= n; ++ i) {
            int t = i - ne[i];
            if (i > t  && i % t == 0) printf("%d %d\n", i, i / t);
        }
        puts("");
    }
}

最小表示法

给定一个字符串S[1 ~ n],如果我们不断把它的最后一个字符放到开头,最终会得到n个字符串,称这n个字符串是循环同构的。这些字符串中字典序最小的一个,称为字符串S的最小表示

例如S = “abca”,那么它的4个循环同构字符串为"abca",“aabc”,“caab”,“bcaa”,S的最小表示为"aabc"。与S循环同构的字符串可以用该字符串在S中的起始下标表示,因此我们用B[i]来表示从i开始的循环同构字符串,即 S[i ~ n] + S[1 ~ i - 1]

如何求出一个字符串的最小表示?最朴素的方法是:按照定义,依次比较这n个循环同构的字符串,找到其中字典序最小的一个。比较两个循环同构字符串B[i]与B[j]时,我们也采用直接向后扫描的方式,依次取k = 0,1,2,…,比较B[i + k]与B[j + k]是否相等,直至找到一个不相等的位置,从而确定B[i]与B[j]的大小关系。

实际上,一个字符串的最小表示可以再O(N)的线性时间内求出。我们首先把S复制一份接在它的结尾,得到的字符串记为SS。显然,B[i] = SS[i ~ i + n - 1]

对于任意的i,j,我们仔细观察B[i]与B[j]的比较过程:
如果在i + k与j + k处发现不相等,假设SS[i + k] > SS[j + k],那么我们当然可以得知B[i]不是S的最小表示。除此之外,我们还可以得知B[i + 1], B[i + 2], … ,B[i + k]也都不是S的最小表示。这是因为对于 1 ≤ p ≤ k 1 \leq p \leq k 1pk,存在一个比B[i + p]更晓得循环同构串B[j + p]。

同理,如果SS[i + k] < SS[j + k],那么B[j], B[j + 1], …,B[j + k]都不是S的最小同构串,直接跳过这些位置,一定不会遗漏最小表示。

最小表示法:
1、初始化i = 1, j = 2
2、通过直接向后扫描的方法,比较B[i]与B[j]两个循环同构串
(1)如果扫描了n个字符后仍然相等,说明S有更小的循环元(例如catcat有循环元cat),并且该循环元已扫描完成,B[min(i, j)]即为最小表示,算法结束。
(2)如果在i + k与j + k处发现不相等:
(i)若SS[i + k] > SS[j + k],令i = i + k + 1,若此时i = j,再令i = i + 1
(ii)若SS[i + k] < SS[j + k],令j = j + k + 1,若此时i = j,再令j = j + 1
3、若i > n或j > n,则B[min(i, j)]为最小表示;否则重复第2步

该算法通过两个指针不断向后移动的形式,尝试比较每两个循环同构串的大小。利用上面的性质,及时排除掉不可能的选项。当其中一个移动到结尾时,就考虑过了所有可能的二元组(B[i], B[j]),从而得到了最小表示。

如果每次比较向后扫描了k的长度,则i或j二者之一会向后移动k,而i和j合计一共最多向后移动2n的长度,因此该算法的复杂度为O(N)

#include <iostream>
#include <cstring>
using namespace std;
const int N = 2e6 + 10;

int n;
char s[N];

int main() {
    scanf("%s", s + 1);
    n = strlen(s + 1);
    for (int i = 1; i <= n; ++ i) s[i + n] = s[i];
    int i = 1, j = 2, k;
    while (i <= n && j <= n) {
        for (k = 0; k < n && s[i + k] == s[j + k]; ++ k);
        if (k == n) break; // s形如"catcat",它的循环元已扫描完成
        if (s[i + k] > s[j + k]) {
            i = i + k + 1;
            if (i == j) ++ i;
        } else {
            j = j + k + 1;
            if (i == j) ++ j;
        }
    }
    int ans = min(i, j); // B[ans]是最小表示
    printf("%d", ans);
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值