前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >《算法竞赛进阶指南》0x15 字符串

《算法竞赛进阶指南》0x15 字符串

作者头像
一只野生彩色铅笔
发布2022-10-31 11:03:20
7120
发布2022-10-31 11:03:20
举报
文章被收录于专栏:彩铅的随笔博客

本篇将大量摘自 OI-Wiki 本人当初学的时候也是看到这几篇博客才顿悟的,自觉这部分讲的不会比原作者更好 author: Ir1d, LeoJacob, Xeonacid, greyqz, StudyingFather, Marcythm, minghu6, Backl1ght

字符串匹配问题

又称模式匹配(pattern matching)。该问题可以概括为「给定字符串

S

T

,在主串

S

中寻找子串

T

」。字符

T

称为模式串 (pattern)。

类型

  • 单串匹配:给定一个模式串和一个待匹配串,找出前者在后者中的所有位置。
  • 多串匹配:给定多个模式串和一个待匹配串,找出这些模式串在后者中的所有位置。
    • 出现多个待匹配串时,将它们直接连起来便可作为一个待匹配串处理。
    • 可以直接当做单串匹配,但是效率不够高。
  • 其他类型:例如匹配一个串的任意后缀,匹配多个串的任意后缀……

暴力做法

简称 BF (Brute Force) 算法。该算法的基本思想是从主串

S

的第一个字符开始和模式串

T

的第一个字符进行比较,若相等,则继续比较二者的后续字符;否则,模式串

T

回退到第一个字符,重新和主串

S

的第二个字符进行比较。如此往复,直到

S

T

中所有字符比较完毕。

在最好情况下,BF 算法匹配成功时,时间复杂度为

O(n)

;匹配失败时,时间复杂度为

O(m)

在最坏情况下,每趟不成功的匹配都发生在模式串的最后一个字符,BF 算法要执行

m(n-m+1)

次比较,时间复杂度为

O(nm)

字符串哈希

字符串哈希是字符串模式匹配中的一个经典做法,具体概念在上一章 “0x14 哈希” 中讲过了

这里提一下字符串哈希的经典应用:

  • 字符串匹配
    • 直接哈希
    O(n)
  • 允许
k

次失配的字符串匹配

  • 哈希 + 二分
O(n + kn\log m)
  • 枚举所有子串,二分到第一个不同的位置,删掉前面部分,继续比较后面的哈希值

  • 最长回文子串
    • 正反哈希 + 二分
    O(n\log n)
    • 每个字符之间再填充一个字符,可以全部处理成奇数情况,具体上一章有介绍
    • 用马拉车可以在线性时间内求解
  • 最长公共子字符串
    • 二分答案转为判定
    O(n\log \frac{n}{m})
    • 二分出答案后,把第一个字符串所有该长度的子串都放入一个哈希表中,然后不断和后面的取交集
  • 确定字符串中不同子字符串的数量
    • 哈希 + 枚举
    O(n^2)
    • 枚举长度,然后对字符串的哈希值再哈希一下找相同值

前缀函数与 KMP 算法

前缀函数定义

给定一个长度为

n

的字符串

s

,其 前缀函数 被定义为一个长度为

n

的数组

\pi

其中

\pi[i]

的定义是:

  1. 如果子串
s[1\dots i]

有一对相等的真前缀与真后缀:

s[1\dots k-1]

s[i - (k-2) \dots i]

,那么

\pi[i]

就是这个相等的真前缀(或者真后缀,因为它们相等)的长度,也就是

\pi[i]=k

  1. 如果不止有一对相等的,那么
\pi[i]

就是其中最长的那一对的长度;

  1. 如果没有相等的,那么
\pi[i]=0

简单来说

\pi[i]

就是,子串

s[1\dots i]

最长的相等的真前缀与真后缀的长度。

用数学语言描述如下:

[ \pi[i] = \max_{k = 1 \dots i}\{k: s[1 \dots k - 1] = s[i - (k - 2) \dots i]\} ]

特别地,规定

\pi[1]=0

预处理前缀函数

朴素做法就是暴力枚举,对于当前求得前缀

\pi[i]

,让

j

i-1

0

枚举到第一个前后缀匹配的长度

时间复杂度为:

O(n^3)

第一个优化

观察易得 相邻的前缀函数值至多增加

1

当取一个尽可能大的

\pi[i+1]

时,必然要求新增的

s[i+1]

也与之对应的字符匹配,即

s[i+1]=s[\pi[i]]

, 此时

\pi[i+1] = \pi[i]+1
[ \underbrace{\overbrace{s_1 ~ s_2 ~ s_3}^{\pi[i] = 3} ~ s_4}_{\pi[i+1] = 4} ~ \dots ~ \underbrace{\overbrace{s_{i-2} ~ s_{i-1} ~ s_{i}}^{\pi[i] = 3} ~ s_{i+1}}_{\pi[i+1] = 4} ]

所以当移动到下一个位置时,前缀函数的值要么增加一,要么维持不变,要么减少。

最终比较次数会减少到

2n-3

次,总时间复杂度为

O(n^2)

第二个优化

第一个优化中讨论了计算

\pi[i+1]

时的最好情况

s[i + 1] = s[\pi[i]]

,此时

\pi[i + 1] = \pi[i] + 1

,现讨论当

s[i+1] \ne s[\pi[i]]

如何跳转?

失配时,我们希望找到对于子串

s[1\dots i]

,仅次于

\pi[i]

的第二长度

j

,使得在位置

i

的前缀性质仍得以保持,也即

s[1 \dots j - 1] = s[i - (j - 2) \dots i]

[ \overbrace{\underbrace{s_1 ~ s_2}_j ~ s_3 ~ s_4}^{\pi[i]} ~ \dots ~ \overbrace{s_{i-3} ~ s_{i-2} ~ \underbrace{s_{i-1} ~ s_{i}}_j}^{\pi[i]} ~ s_{i+1} ]

如果我们找到了这样的长度

j

,那么仅需要再次比较

s[i + 1]

s[j]

。如果它们相等,那么就有

\pi[i + 1] = j + 1

。否则,我们需要找到子串

s[0\dots i]

仅次于

j

的第二长度

j^{(2)}

,使得前缀性质得以保持,如此反复,直到

j = 0

。如果

s[i + 1] \neq s[0]

,则

\pi[i + 1] = 0

观察上图可以发现,因为

s[1\dots \pi[i]-1] = s[i-(\pi[i]-2)\dots i]

,所以对于

s[1\dots i]

的第二长度

j

,有这样的性质:

[ s[1 \dots j - 1] = s[i - (j - 2) \dots i]= s[\pi[i]-j\dots \pi[i]-1] ]

也就是说

j

等价于子串

s[\pi[i]-1]

的前缀函数值,即

j=\pi[\pi[i]-1]

。同理,次于

j

的第二长度等价于

s[j-1]

的前缀函数值,

j^{(2)}=\pi[j-1]

显然我们可以得到一个关于

j

的状态转移方程:

j^{(n)}=\pi[j^{(n-1)}-1], \ \ (j^{(n-1)}>0)

最终代码如下:

代码语言:javascript
复制
int ne[SIZE];
for (int i = 2, j = 0; i <= n; i ++ )
{
    while (j && a[j + 1] != a[i]) j = ne[j];
    if (a[i] == a[j + 1]) j ++ ;
    ne[i] = j;
}

前缀函数的经典应用

  • 应用于 KMP 算法
  • 字符串的周期
  • 统计每个前缀的出现次数
  • 一个字符串中本质不同子串的数目
  • 字符串压缩
  • 根据前缀函数构建一个自动机

在字符串中查找子串:Knuth-Morris-Pratt 算法

该任务是前缀函数的一个典型应用,利用计算好的前缀函数,我们可以快速完成字符串的模式匹配

因为定义的相似性,求解主串与模式串匹配的过程,就是求解当前主串枚举到的子串中后缀与匹配串前缀的最大匹配长度

如果此时长度刚好等于匹配串的长度,则说明匹配成功

代码语言:javascript
复制
for (int i = 1, j = 0; i <= m; i ++ )
{
    while (j && (j == n || b[i] != a[j + 1])) j = ne[j];
    if (b[i] == a[j + 1]) j ++ ;
    f[i] = j;
    // if (f[i] == n)   //此时就是 A 在 B 中的某一次出现
}

由于每个阶段,

j

变化的浮动为上一轮的

j

0

,且同时每个阶段

j

至多增加

1

,因此

j

在整个过程中,其 减少的次数小于等于增加的次数,算上增加和减少

j

的总变化次数至多为:

2(N + M)

,易得 KMP 的时间复杂度为

O(N + M)

前缀函数求字符串的周期

字符串周期的定义:

\forall i \in [1, n - p]

,都有

S[i] = S[i + p]

,则称

p

为字符串

S

的周期

字符串border的定义:

\forall r \in [0, n - 1)

,若

S[1, r + 1] = S[n - r, n]

,则称

S

长度为

r

的前缀是

S

的 border

显然

|S| - r

S

的周期,利用该性质以及前缀函数的定义,可以得到

S

的所有 border 长度,即:

[ \pi[n] \ ,\pi[\pi[n]] \ , \pi[\pi[\pi[n]]] \cdots ]

所以根据前缀函数可以在

O(n)

的时间内计算出

S

的所有周期

其中

\pi[n]

为最长的 border 长度,因此

n - \pi[n]

S

的最小周期

前缀函数统计每个前缀的出现次数

考虑位置

i

的前缀函数值

\pi[i]

。根据定义,其意味着字符串

s

一个长度为

\pi[i]

的前缀在位置

i

出现并以

i

为右端点,同时不存在一个更长的前缀满足前述定义。与此同时,更短的前缀可能以该位置为右端点。

容易看出,我们遇到了在计算前缀函数时已经回答过的问题:给定一个长度为

j

的前缀,同时其也是一个右端点位于

i

的后缀,下一个更小的前缀长度

k < j

是多少?该长度的前缀需同时也是一个右端点为

i

的后缀。

因此以位置

i

为右端点,有长度为

\pi[i]

的前缀,有长度为

\pi[\pi[i] - 1]

的前缀,有长度为

\pi[\pi[\pi[i] - 1] - 1]

的前缀,等等,直到长度变为

0

。故而我们可以通过下述方式计算答案。

代码语言:javascript
复制
vector<int> ans(n + 1);
for (int i = 1; i <= n; i ++ ) ans[pi[i]]++;
for (int i = n; i >= 1; i -- ) ans[pi[i - 1]] += ans[i];
for (int i = 1; i <= n; i ++ ) ans[i]++;

字符串最小表示法

字符串的最小表示法:最小表示法是求与某个字符串 循环同构 的所有字符串中,字典序最小的

如何在

O(N)

的时间内求出字符串的最小表示

类似 循环同构 的问题,第一时间想到 破环成链,将数组整体复制接到后面,然后寻找长度为

n

的最小字典序

先枚举起点,然后暴力挨个比较的做法,时间复杂度为

O(N^2)

,对于本题的数据规模完全是 ok 的

这里介绍一个线性的做法:

考虑对于一对字符串

A,B

, 它们在原字符串

S

中的起始位置分别为

i,j

, 且它们的前

k

个字符均相同,即

[ A[i \cdots i + k - 1] = B[j \cdots j + k - 1] ]

不妨先考虑

A[i+k] > B[j + k]

的情况,我们发现起始位置下标

l

满足

i \le l \le i + k

的字符串均不能成为答案。因为对于任意一个字符串

S_{i+p}

(表示以

i+p

为起始位置的字符串)一定存在字符串

S_{j+p}

比它更优。

所以我们比较时可以跳过下标

l\in[i,i+k]

, 直接比较

S_{i+k+l}

这样对于每个指针都只会向后位移,因此时间复杂度为

O(N)

算法流程

  1. 初始化指针
i

1

j

2
  1. 通过直接向后扫描的方法,比较
S[i]

S[j]

两个循环同构串

  1. 如果扫描了
n

个字符后仍然相等,说明

S

有更小的循环元,且该循环元已扫描完毕,

S[min(i,j) ... min(i,j) + n - 1]

即为最小表示

  1. 如果在
i+k

j+k

处发现不相等:

S[i+k] > S[j+k]

,令

i = i + k + 1

若此时

i=j

,则令

i=i+1
S[i+k] < S[j+k]

,令

j = j + k + 1

若此时

i=j

,则令

j=j+1

i > n

j > n

,则

S[min(i,j) ... min(i,j) + n - 1]

即为最小表示,否则重复第二步

代码语言:javascript
复制
int n = strlen(s + 1);
for (int i = 1; i <= n; i ++ ) s[n + i] = 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;  // 形如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 ++ ;
    }
}
ans = min(i, j);

习题

周期

题目描述

一个字符串的前缀是从第一个字符开始的连续若干个字符,例如

abaab

共有

5

个前缀,分别是

a

ab

aba

abaa

abaab

我们希望知道一个

N

位字符串

S

的前缀是否具有循环节。

换言之,对于每一个从头开始的长度为

i

i>1

)的前缀,是否由重复出现的子串

A

组成,即

AAA…A

A

重复出现

K

次,

K>1

)。

如果存在,请找出最短的循环节对应的

K

值(也就是这个前缀串的所有可能重复节中,最大的

K

值)。

输入格式

输入包括多组测试数据,每组测试数据包括两行。

第一行输入字符串

S

的长度

N

第二行输入字符串

S

输入数据以只包括一个

0

的行作为结尾。

输出格式

对于每组测试数据,第一行输出 Test case # 和测试数据的编号。

接下来的每一行,输出具有循环节的前缀的长度

i

和其对应

K

,中间用一个空格隔开。

前缀长度需要升序排列。

在每组测试数据的最后输出一个空行。

数据范围

2≤N≤1000000

输入样例

代码语言:javascript
复制
3
aaa
4
abcd
12
aabaabaabaab
0

输出样例

代码语言:javascript
复制
Test case #1
2 2
3 3

Test case #2

Test case #3
2 2
6 2
9 3
12 4

解析

根据我在上文中的有关 “前缀函数求字符串的周期” 内容继续

对于一个字符串border长度,其最长为

|r|_{max} = \pi[n]

,因此最小正周期为

|T|_{min} = n - \pi[n]

综上,如果当前字符串的最小正周期

\pi[i]

能够整除当前字符串的长度

i

,则他一定存在循环元

根据该理论,输出结果即可

代码语言:javascript
复制
void solve()
{
    scanf("%s", str + 1);
    for (int i = 2, j = 0; i <= n; i ++ )
    {
        while (j && str[i] != str[j + 1]) j = ne[j];
        if (str[i] == str[j + 1]) j ++ ;
        ne[i] = j;
    }
    for (int i = 2; i <= n; i ++ )
        if (i % (i - ne[i]) == 0 && i / (i - ne[i]) > 1)
            printf("%d %d\n", i, i / (i - ne[i]));
    puts("");
}
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2022-02-06,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 字符串匹配问题
  • 字符串哈希
  • 前缀函数与 KMP 算法
    • 前缀函数定义
      • 预处理前缀函数
        • 在字符串中查找子串:Knuth-Morris-Pratt 算法
          • 前缀函数求字符串的周期
            • 前缀函数统计每个前缀的出现次数
            • 字符串最小表示法
            • 习题
              • 周期
                • 题目描述
                • 解析
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档