字符串 | String
已经有两个月没有写了吗
字符串 | String
前言 | Preface
学的肯定是字符串
先学 KMP ,再学 z Function,然后是 Manacher,然后搞忘了
规定与记号 | Conventions and Notations
记号 | Notation
- \(\Sigma\) :字符集
- \(\Sigma^*\):由 \(\Sigma\) 中字符组成的字符串的集合
- \(\epsilon\):空串
- 有 \(\forall \Sigma^*,\epsilon \in \Sigma^*\)
- \(|·|\):字符串长度
- \(s.t.\)
such that,表示满足后面条件
定义 | Definition
-
连结 Concatenation 两个字符串 \(s, t\) 的连结为 \(st\) ,长度为 \(|st| = |s|+|t|\)
-
幂 Exponentiation 多个相同字符串依次连结:\(s^n = \underbrace{ss\ldots s}_{n 个 s}\)
-
前缀 Prefix 对于字符串 \(w \in \Sigma^*\),称其是字符串 \(x \in \Sigma^*\) 的前缀,若 \(\exist y \in \Sigma^*,wy=x\),记作 \(w \sqsubseteq x\)
-
后缀 Suffix 对于字符串 \(w \in \Sigma^*\),称其是字符串 \(x \in \Sigma^*\) 的前缀,若 \(\exist y \in \Sigma^*,yw=x\),记作 \(w \sqsupseteq x\)
- 真·前缀 字符串 \(w \in \Sigma^*\),\(w \sqsubseteq x ~ \land ~ w \not= x\),记作 \(w \sqsubset x\)
- 真·后缀 字符串 \(w \in \Sigma^*\), \(w \sqsupseteq x ~ \land ~ w \not= x\),记作 \(w \sqsupset x\)
-
子串 Substring 连续的一段,对于 \(x \in \Sigma^*\) ,其 \([i,j]\) 对应的子串表示为 \(x[i\ldots j]\)
-
子序列 Subsequence 对于 \(\{p_i\}_{i=1}^{k} \subseteq \{1,2,\ldots,n\}\) ,\(x[p_i], i=1,2,\ldots,k\)
-
前缀函数 Prefix Function \(\pi[i]\) 定义为最长的使得 \(u \sqsubset x \land u \sqsupset x\) 且 \(u = v\) 的 \(u\) 的长度
- 形式化的:
\[\pi[i] = \max_{\forall u \in x[1\ldots i] ~ s.t. ~ u\sqsubset x \land u \sqsupset x} {|u|} \tag{Definition 1} \] -
翻转 Reverse 一个字符串 \(s\) 的翻转 \(s^R\) 表示为:\(s^R = s[|s|]\ldots s[i]\ldots s[1],i=1,2,\ldots,|s|\)
-
Z 函数 Z-Function \(z[i]\) 定义为 \(s\) 与 \(s[i+1\ldots |s|]\) 的 LCP (最长公共前缀)
前缀函数 | Prefix Function
定义见 #规定与记号
朴素求法 | Simple Method
按照定义式 \(\text{Def 1}\) 根据子串的改写:
可以根据 \(\text{Var 1.1}\) 写出以下代码:
void prefix_function(string s) {
int n = s.length();
s = "#" + s;
for (int i = 2; i <= n; i++)
for (int j = i - 1; j >= 0; j--)
if (s.substr(1, j) == s.substr(i - j + 1, j)) {
pi[i] = j;
break;
}
}
很明显算法的复杂度高达 \(\mathcal O(n^3)\),太高了
优化算法 | Optimization Algorithm
优化一 | Opt Algorithm 1
观察前缀函数,不难发现,\(\pi[i]\) 的值顶多比 \(\pi[i-1]\) 的值多 \(1\)
因为相较于上一次的匹配,这次最多多匹配到一个
void prefix_function(string s) {
int n = s.length();
s = "#" + s;
for (int i = 2; i <= n; i++)
for (int j = pi[i - 1] + 1; j >= 0; j--)
if (s.substr(1, j) == s.substr(i - j + 1, j)) {
pi[i] = j;
break;
}
}
观察代码,发现似乎没有变化
优化一:复杂度分析 | Opt Algorithm 1:Complexity Analysis
观察到的时间复杂度的贡献最主要在 \(j\) 身上,所以我们要统计 \(j\) 的总共修改次数
注意到顶多有 \(n-2\) 次 \(+1\) ,所以至多 \(j\) 会减去 \(n - 2\),所以至多有 \(n-2\) 次比较
再加上进入循环时的 \(n-1\) 次,总共会有 \(2n - 3\) 次比较,每一次比较复杂度 \(\mathcal O(n)\)
所以时间复杂度为 \(\mathcal O(n^2)\)
优化二 | Opt Algorithm 2
记 \(\text{suf}[\pi[i]]\) 表示 \(\pi[i]\) 找到的真后缀,同理,前缀用 \(\text{pre}[\pi[i]]\) 表示
所以一定有 \(\text{pre}[\pi[i]] = \text{suf}[\pi[\pi[i]]] = \text{suf}[\pi[i]]\)
利用这个性质,我们将比较位 \(j\),按照 \(j = \pi[j]\) 的规则跳跃,直到 \(\pi[j] = \pi[i + 1]\) 为止
void getPi(const string &str) {
int length = str.length();
for (int i = 2; i <= length; i++) {
int j = pi[i - 1];
while (j > 0 && str[i] != str[j + 1]) j = pi[j];
if (str[i] == str[j + 1]) j++;
pi[i] = j;
}
}
优化二:复杂度分析 | Opt Algorithm 2:Complexity Analysis
同上,对 \(j\) 进行分析,在丢掉耗时的字符串比较操作后,复杂度来到了 \(\mathcal O(n)\)
KMP 算法 | Knuth-Morris-Pratt Algorithm
由 Knuth、Morris、Pratt 在1977年发布
实现方式 1 | Implementation Method 1
将模式串 Pattern string \(P\) 与 文本串 Text String \(T\) 拼接在一起,具体可以写作“#” + P + "#" + T
第一个 # 用来占位,第二个 # 则用以分隔两个字符串,使得 \(\pi[i]\) 的大小不会超过 \(|P|\)
而当我们将 \(\pi[i]\) 列出来,就会发现,那些使得 \(\pi[j] = |P|\) 的就是与 \(P\) 匹配上的点
vector<int> KMP(const string &partten, const string &text) {
vector<int> answer;
string current = " " + partten + "#" + text;
int n = partten.length(), m = text.length();
getPi(current);
for (int i = n + 2; i <= n + m + 1; i++)
if (pi[i] == n)
answer.push_back(i - 2 * n);
return answer;
}
实现方式 2 | Implementation Method 2
不将模式串与文本串连结,而是直接拿着模式串去匹配
当匹配时,将匹配的 \(j\) 跳到 \(\pi[j]\),直接跳到与其前缀匹配的后缀上,从而跳过了很多无用的东西
vector<int> KMP(const string &partten, const string &text) {
int j = 0;
vector<int> answer;
int n = partten.length() - 1, m = text.length() - 1;
for (int i = 1; i <= m; i++) {
while (j > 0 && partten[i] != text[j + 1]) j = pi[j];
if (partten[i] == text[j + 1]) j++;
if (j == n) {
answer.push_back(j - n + 1);
j = pi[j];
}
}
return answer;
}
时间复杂度 | Time complexity
两种实现的复杂度是一样的
仅需要考虑第一种
预处理 \(\mathcal O(n+m)\),匹配 \(\mathcal O(m)\),总的就是 \(\mathcal O(n+m)\)
应用 | Applications
循环节 | Cycle Section
没想到吧,\(\pi\) 居然可以用来求循环节的长度
先从周期 Period 讲起:(这里周期默认为正
对于 \(p \in \N\),若 \(x[i] = x[i +p]\) ,则称 \(p\) 是 \(x\) 的一个周期,记作 \(x[:p]\)
从定义里不难看出周期 \(p\) 可以不整除 \(|x|\)
我们需要找到最小的周期
为了求出周期的最小,我们引入 Border 这个概念
字符串的 border 是指该字符串的 一个既是其前缀又是其后缀的真子串。
有了 border ,就不难发现,\(x\) 中有长度为 \(r\) 的 border,则 \(|x| - r\) 是 \(x\) 的一个周期
观察 border 的定义,发现找到最长的 border 的过程就是 \(\pi[|x|]\)
所以最小周期为 \(|x| - \pi[|x|]\)
好,现在我们来讨论循环节
循环节的长度是肯定会整除 \(|x|\)
假设最小正周期 \(|x| - \pi[|x|] ~ | ~ \left|x\right|\),此时最小正周期就是循环节
接下来我们要证明当 \(|x| - \pi[|x|] ~ \not|~~ ~ |x|\)时,最小循环节就是 \(n\)
即证明不存在一个 \(x[:p]\) 使得 \(p > |x| - \pi[|x|]\) 时,\(p\) 是 \(x\) 的循环节
引理 1.1 | Lemma 1.1
对于字符串 \(x\),有两个周期 \(x[:p]\),\(x[:q]\),则 \(x[:\gcd(p,q)]\) 也是 \(x\) 的一周期
用 Bezout 定理易证
那此时 \(\gcd(|x|-\pi[|x|],p)\) 也是周期了
而一定有 \(\gcd(|x|-\pi[|x|], p) \le |x|-\pi[|x|] \lt p\)
冲突,假设不成立
统计不同子串的个数
懒得翻译了
以现在的所学,只能够做到 \(\mathcal O(n^2)\)
直接 Hash 是过不了 \(5000\) 的数据的,复杂度足足有 \(\mathcal O(n^2 \log n)\),还有一个两倍的常数
有一个小trick,可以有效的减少常数,空间占用也可以大大提升
就是 set 或者 map 不用存下所有的后缀
就是像这样:
for (...){
set<int> hash;
for (...)
answer += hash.size();
}
字典树的做法只需要将后缀存入然后输出节点数
for (int i = 1; i <= n; i++) trie.insert(str.substr(n - i + 1, i));
cout << trie.totalSubstring() << "\n";
但是空间复杂度很不友好
所以我们想一下如何使用 \(\pi\) 函数
我们使用增量法求解
假设现在的字符串是 \(s\),新增进去了一个字符 \(c\),所以就是在 \(sc\) 找到以 \(c\) 为结尾且未出现过的后缀数量
因为前缀函数对于后缀的无力,所以就需要将整个字符串翻转
然后对于 \((sc)^R\) 求一遍它的 \(\pi\) 函数,求出最大值 \(\pi_{\max}\)
\(\pi_{\max}\) 的含义:最长的会与先前的串冲突的以 \(c\) 为结尾的后缀长度
所以答案的增量就是 \(|sc| - \pi_{\max} = |s| +1 - \pi_{\max}\)
string current = "";
int length = 0;
int answer = 0;
for (int i = 0; i < n; i++) {
current = str[i] + current;
answer += (++length) - getPi("#" + current);
}
复杂度明显是 \(\mathcal O(n^2)\),空间复杂度就是 \(\mathcal \Theta(n)\)
例题 | Example
P3375【模板】KMP
就是一道模版题
void solve() {
cin >> text;
cin >> partten;
vector<int> answer = KMP(partten, text);
for (int posi : answer) cout << posi << "\n";
int length = partten.length();
for (int i = 1; i <= length; i++) cout << pi[i] << " \n"[i == length];
}
POJ2406 Power Strings [幂字符串]
找循环节
void solve() {
if (str == ".") exit(0);
int n = str.length();
str = "#" + str;
getPi(str);
if (n % (n - pi[n]) == 0) cout << n / (n - pi[n]) << "\n";
else cout << 1 << "\n";
}
int main() {
while (cin >> str) solve();
return 0;
}
P10475 [USACO03FALL] Milking Grid
注意到求的是周期,如果是循环节的话会更加的简单
我们可以将每一行看做一个字符,然后整个二维字符串可以看成字符串,然后跑 KMP 找出周期
将每一列看成字符,作为字符串使用,然后再跑 KMP 找周期
int m, n;
cin >> m >> n;
for (int i = 1; i <= m; i++) cin >> str[i];
getPow(max(n, m));
for (int i = 1; i <= m; i++) {
str[i] = "#" + str[i];
getHash1(i, str[i]);
}
for (int i = 1; i <= n; i++) {
string current = "#";
for (int j = 1; j <= m; j++) current += str[j][i];
getHash2(i, current);
}
getPi1(m);
int pR = m - pi[m];
getPi2(n);
int pC = n - pi[n];
cout << pR * 1ll * pC << "\n";
但是这样子会发生 hash 冲突,冲突概率高达 \(1\%\),然后就获得了 \(90\) 分的高分
其实可以不用 hash 的,hash 并没有降低复杂度
总体的复杂度 \(\mathcal O(RC)\),在 \(R \in[1,10000],C \in [1, 75]\) 时绰绰有余
getPi 中就只需要将比较的字符改成字符串或者 Hash 值就行了
P2375 [NOI2014] 动物园
求的 border 不能重叠怎么办
将这个转换一下就是 \(|\text{border}_x|_i \le \frac i2\)
已经有了一个限制条件了,现在来想数量怎么求
这个时候,仅凭我们现有的知识就不行了,所以我们引入 Border 树这个概念
Border 树 | Border Tree
又称 Fail 树
定义为 \(i\) 与 \(\pi[i]\) 的连边构成的树
容易发现,节点 \(i\) 的深度就是 border 的个数
其实有了 border 树过后,我们就可以在上面套数据结构了
像倍增、树剖都可以做
但是将 KMP 小改一下更好写
ll getAnswer(const string &str) {
ll answer = 1;
int length = str.length();
getDepth(str);
string current = "#" + str;
int j = 0;
for (int i = 2; i <= length; i++) {
while (j > 0 && current[i] != current[j + 1]) j = pi[j];
if (current[i] == current[j + 1]) j++;
while (j * 2 > i) j = pi[j];
answer = ((answer * (depth[j] + 1) % MOD + MOD) % MOD + MOD) % MOD;
}
return answer;
}
其实 Border 树是 AC 自动机的关键
Z 函数 | Z-Function
又叫做扩展KMP | Ex-KMP
形式化的定义为:
朴素求法 | Simple Method
和前缀函数一样,按照定义式模拟便是
for (size_t i = 1; i < str.length(); i++)
while (i + z[i] + 1 <= str.length() &&
str[z[i] + 1] == str[i + z[i] + 1])
z[i]++;
复杂度有 \(\mathcal O(n^2)\)
优化 | Optimization
性质 | Property
从性质入手会更加简单的优化
性质一 | Property 1
由定义易发现
由此我们可以写出优化后的代码:
for (size_t i = 2; i <= length; i++) {
if (i <= r && z[i - l + 1] < r - i + 1) z[i] = z[i - l + 1];
else {
z[i] = max(0, r - i + 1);
while (i + z[i] - 1 <= length && str[z[i] + 1] == str[i + z[i]]) z[i]++; // 只能够老老实实地拓展
if (r < i + z[i] - 1) l = r, i = i + z[i] - 1;
}
}
应用 | Application
字符串匹配 | String Matching
和 KMP 一样
循环节 | Cycle Section
利用有长度为 \(r\) 的 border 就有 \(|s| - r\) 为其周期的性质
所以就和前缀函数一样
将 \(i\) 从后往前扫,然后就会发现第一个使得 \(i + z[i] = |s|\) 的 \(i\) 对应的周期就是最小正周期
马拉车算法 | Manacher Algorithm
居然先讲的 manacher,写肯定是同步的
回文串 | Palindrome
一个字符串 \(s\) 是回文串,当且仅当 \(s\) 关于中心点 \(\frac{|s|}{2}\) 对称
肯定会有奇偶之分
对于奇回文串和偶回文串,肯定不会大费周章写两份十分相似的代码去增加错误率的
所以我们想到在任意两个字符之间加入没有出现过的字符,比如 #
这样子字符串就肯定是奇回文串了
这里我们定义 \(d[i]\) 表示以 \(i\) 为中心,向外拓展的最长可构成回文串的半径
观察易知 \(d[i] - 1\) 恰好是该回文串在原串中的长度

浙公网安备 33010602011771号