字符串 | 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}\) 根据子串的改写:

\[\pi[i] = \max_{k=1}^{i-1}\left\{k ~ \ s.t.~ \ x[1\ldots k] = x[i -k +1 \ldots i] \right\} \tag{Variant 1.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

形式化的定义为:

\[z[i] = \max_{u \sqsubseteq s ~ \land ~ u\sqsubseteq s[i+1\ldots |s|]} |u| \tag{Definition 2} \]

朴素求法 | 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

\[(\forall l, r \text{ s.t. } l, r \in [i, i+z[i]-1])(s[l\ldots r] = s[l-i+1\ldots r-i+1]) \tag{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\) 恰好是该回文串在原串中的长度

实现 | Method

posted @ 2026-05-03 20:45  Yangyihao  阅读(21)  评论(0)    收藏  举报