- bitworld 的博客
子序列与子串问题
- @ 2025-8-18 17:55:34
子序列与子串问题
1. 序列与字符串:基本概念
在计算机科学中,一个序列 (Sequence) 是一个有序的元素集合。这些元素可以是数字、字符或任何其他数据类型。例如,数组 [10, 20, 5, 30] 就是一个整数序列。
字符串 (String) 是一种特殊的序列,它的元素是字符。例如,"hello" 就是一个由字符 'h', 'e', 'l', 'l', 'o' 组成的字符串。在 C++ 中,我们通常使用 std::string 类型或字符数组 char[] 来表示字符串。
对于一个长度为 的字符串 ,我们可以用 来表示其第 个字符,其中索引通常从 开始,即 。
2. 子串 (Substring)
2.1. 定义
一个字符串 如果是另一个字符串 的子串,那么 必须是 中连续的一部分。
换句话说,如果字符串 的长度为 ,那么它的任何子串都可以表示为 ,即从索引 开始到索引 结束的所有字符组成的连续段,其中 。
示例: 对于字符串 :
"ban"是一个子串。()"nan"是一个子串。()"a"是一个子串。"banana"本身也是一个子串。"bna"不是子串,因为字符 'b', 'n', 'a' 在原字符串中不是连续的。- 空字符串
""被认为是任何字符串的子串。
2.2. 子串的数量
一个长度为 的非空字符串,它有多少个不同的非空子串?
我们可以通过枚举子串的起始位置和结束位置来计算。
- 起始位置 可以从 到 。
- 对于每个起始位置 ,结束位置 可以从 到 。
总的子串数量(包括重复的)是所有可能的 组合数,即:
$$\sum_{i=0}^{n-1} (n-i) = n + (n-1) + \dots + 1 = \frac{n(n+1)}{2} $$这是所有子串的总数。例如,对于 "aba",子串有 "a", "ab", "aba", "b", "ba", "a"。总共 6 个,符合 。
如果问题是不同的非空子串数量,就需要去重。对于 "aba",不同的子串是 "a", "b", "ab", "ba", "aba",共 5 个。计算不同子串的数量通常需要更复杂的算法(如后缀自动机或后缀树),超出了基础笔试的范围,但理解总数的计算是必要的。
2.3. 经典问题:最长公共子串 (Longest Common Substring)
最长公共子串问题是寻找两个或多个字符串中,长度最长的共同子串。
题意概括:给定两个字符串 和 ,找出它们的最长公共子串的长度。
解法:动态规划 (Dynamic Programming)
我们可以使用一个二维数组 来解决这个问题。 表示以 的第 个字符 () 和 的第 个字符 () 结尾的最长公共子串的长度。
状态转移方程:
- 如果 ,说明这两个字符可以延续之前的公共子串。所以,以它们结尾的公共子串长度,等于以它们前一个字符结尾的公共子串长度加 1。
- 如果 ,那么以这两个字符结尾的公共子串不存在,因为子串必须是连续的。所以长度为 0。
在整个计算过程中,我们需要一个变量来记录所有 的最大值,这个最大值就是最终的答案。
C++ 代码示例
#include<bits/stdc++.h>
using namespace std;
using ll = long long;
int main() {
ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
string s1, s2;
cin >> s1 >> s2;
int n = s1.length();
int m = s2.length();
// dp[i][j]: 以 s1[i-1] 和 s2[j-1] 结尾的最长公共子串长度
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
int ans = 0;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
if (s1[i - 1] == s2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = 0;
}
// 记录所有dp值的最大值
ans = max(ans, dp[i][j]);
}
}
cout << ans << endl;
return 0;
}
// 复杂度分析:
// 时间复杂度: O(n * m),因为我们填充了一个 n*m 大小的二维数组。
// 空间复杂度: O(n * m),用于存储dp表。
// (空间可以优化到 O(min(n, m)),但这里为了清晰展示逻辑,使用二维数组)
3. 子序列 (Subsequence)
3.1. 定义
一个字符串 如果是另一个字符串 的子序列,那么 是通过从 中删除零个或多个字符,而不改变其余字符的相对顺序得到的。
子序列和子串的关键区别在于:子序列的字符不必连续。
示例: 对于字符串 :
"bna"是一个子序列。可以从"banana"中保留第 1、3、6 个字符得到。"bnn"是一个子序列。可以保留第 1、5、6 个字符。"aa"是一个子序列。"ban"同时是子串和子序列。"baan"不是子序列,因为第二个 'a' 出现在 'n' 的后面,这与原串中的相对顺序不符。- 空字符串
""被认为是任何字符串的子序列。
3.2. 子序列的数量
一个长度为 的字符串,它有多少个不同的子序列?
对于原字符串中的每个字符,我们都有两种选择:选入子序列,或者不选。由于有 个字符,每个字符都有 2 种独立的选项,根据乘法原理,总共的可能性是:
这个计算包含了空子序列(所有字符都不选的情况)。如果问题要求非空子序列的数量,那么答案是 。
这个公式计算的是所有可能的子序列,可能会有重复。例如,对于 "aba",子序列有:"", "a", "b", "a", "ab", "aa", "ba", "aba"。去重后为 "", "a", "b", "ab", "aa", "ba", "aba"。计算不同子序列的数量是一个更难的动态规划问题。
3.3. 经典问题:最长公共子序列 (Longest Common Subsequence)
最长公共子序列(LCS)是算法竞赛中的一个经典问题。
题意概括:给定两个字符串 和 ,找出它们的最长公共子序列的长度。
解法:动态规划 (Dynamic Programming)
同样,我们使用一个二维数组 。 的含义是:字符串 的前 个字符 () 和 的前 个字符 () 的最长公共子序列的长度。
状态转移方程: 我们考虑 的第 个字符 () 和 的第 个字符 ()。
-
如果 : 这两个字符相等,那么它们可以共同构成公共子序列的一部分。这个公共子序列的长度等于 前 个字符和 前 个字符的 LCS 长度加 1。
-
如果 : 这两个字符不相等,那么它们不能同时作为 LCS 的结尾。LCS 只能来自于以下两种情况之一,我们取其中的较大者:
- 的前 个字符与 的前 个字符的 LCS。 (相当于忽略 )
- 的前 个字符与 的前 个字符的 LCS。 (相当于忽略 )
最终的答案就是 ,其中 和 分别是 和 的长度。
C++ 代码示例
#include<bits/stdc++.h>
using namespace std;
using ll = long long;
int main() {
ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
string s1, s2;
cin >> s1 >> s2;
int n = s1.length();
int m = s2.length();
// dp[i][j]: s1的前i个字符和s2的前j个字符的LCS长度
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
if (s1[i - 1] == s2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
cout << dp[n][m] << endl;
return 0;
}
// 复杂度分析:
// 时间复杂度: O(n * m),因为我们填充了一个 n*m 大小的二维数组。
// 空间复杂度: O(n * m),用于存储dp表。
// (空间同样可以优化到 O(min(n, m)))
4. 题型解析
在笔试中,关于子串和子序列的题目通常以选择、判断、阅读程序和填空等形式出现。
4.1. 选择与判断题
这类题目主要考察对基本定义的理解。
例题 1 (判断题):
字符串
"ace"是字符串"abcde"的一个子串。
分析:错误。子串必须是连续的。"ace" 在 "abcde" 中不是连续的。它是 "abcde" 的一个子序列。
例题 2 (选择题):
对于字符串 ,以下哪个是它的子序列但不是子串? A.
"aba"B."aca"C."baca"D."ca"
分析:
- A.
"aba":是子串(从索引0开始)。 - B.
"aca":是子串(从索引2开始)。 - C.
"baca":是子串(从索引1开始)。 - D.
"ca":在原串中找不到连续的"ca"。但可以取第3个字符 'c' 和第5个字符 'a' 组成,保持了相对顺序,所以是子序列。 答案:D
例题 3 (选择题):
一个长度为 5 的字符串
s,最多有多少个非空子串? A. 5 B. 15 C. 25 D. 31
分析: 题目问的是“子串”的数量。套用公式 ,其中 。
这个公式计算的是所有子串(包括重复的)的数量。题目中的“最多”一词暗示了字符串中的字符各不相同,此时所有子串也都是不同的。 注意选项 D 的 31 是 ,这是非空“子序列”的数量。 答案:B
4.2. 程序阅读题
程序阅读题通常会给出一个实现子串或子序列相关算法的代码片段,要求你分析其功能或输出。
例题 4 (程序阅读):
阅读以下 C++ 代码,输入为
s1 = "abcf"和s2 = "acbf",请问输出是什么?#include<bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(0); cin.tie(0); cout.tie(0); string s1, s2; cin >> s1 >> s2; int n = s1.length(), m = s2.length(); vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0)); for (int i = 1; i <= n; ++i) { for (int j = 1; j <= m; ++j) { if (s1[i - 1] == s2[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]); } } } cout << dp[n][m] << endl; return 0; }
分析:
这段代码是典型的计算最长公共子序列(LCS)的模板。我们要找出 "abcf" 和 "acbf" 的 LCS。
手动模拟 DP 表的计算过程:
- : a b c f
- : a c b f
LCS 可能是:
"abf"(长度3)"acf"(长度3)
我们来追踪几个关键的 DP 值:
dp表大小为 5x5。dp[1][1]('a'vs'a'):dp[2][2]('b'vs'c'):dp[2][3]('b'vs'b'): . ("ab"vs"acb")dp[3][2]('c'vs'c'): . ("abc"vs"ac")- ...
- 最终
dp[4][4](比较'f'和'f'):此时s1[3] == s2[3]。所以dp[4][4] = dp[3][3] + 1。 而dp[3][3]是"abc"和"acb"的 LCS 长度。我们看到"ab"和"ac"都是长度为 2 的公共子序列。所以dp[3][3]=2。 因此dp[4][4] = 2 + 1 = 3。
输出:3
4.3. 程序填空题
这类题目会给出一个不完整的代码,要求你补全关键部分,通常是动态规划的状态转移方程。
例题 5 (程序填空):
下面的代码用于计算字符串
s1和s2的最长公共子串的长度。请补全// TODO部分。#include<bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(0); cin.tie(0); cout.tie(0); string s1, s2; cin >> s1 >> s2; int n = s1.length(), m = s2.length(); vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0)); int ans = 0; for (int i = 1; i <= n; ++i) { for (int j = 1; j <= m; ++j) { if (s1[i - 1] == s2[j - 1]) { // TODO } else { dp[i][j] = 0; } ans = max(ans, dp[i][j]); } } cout << ans << endl; return 0; }
分析: 该代码的框架是计算最长公共子串。回顾其 DP 定义: 是以 和 结尾的公共子串长度。当这两个字符相等时,它可以延续前一个位置的公共子串。所以,长度是在 的基础上加 1。
填空内容:dp[i][j] = dp[i-1][j-1] + 1;
5. 拓展与关联
5.1. 编辑距离 (Edit Distance)
编辑距离是另一个与 LCS 密切相关的经典问题。它衡量的是将一个字符串 转换为另一个字符串 所需的最少单字符编辑(插入、删除、替换)次数。
编辑距离的 DP 解法与 LCS 非常相似。令 为 的前 个字符变为 的前 个字符所需的最少操作次数。
- 如果 ,无需操作,。
- 如果 ,可以有三种操作:
- 替换:将 替换为 。代价是 。
- 删除:删除 。代价是 。
- 插入:在 后插入一个字符(等价于从 中删除 )。代价是 。 取三者中的最小值:
5.2. 回文子串/子序列
回文是指正读反读都一样的字符串,如 "level" 或 "madam"。
- 最长回文子串:可以在 时间内用 DP 解决,也可以用更高级的 Manacher 算法在 时间内解决。
- 最长回文子序列:这个问题可以转化为 LCS 问题。一个字符串 的最长回文子序列,就是 和其反转字符串 的最长公共子序列。例如,对于 ,其反转 。 是
"goog",长度为 4。
6. 总结
| 特性 | 子串 (Substring) | 子序列 (Subsequence) |
|---|---|---|
| 定义 | 原始字符串中连续的字符片段 | 从原始字符串中按原相对顺序选出的字符序列 |
| 连续性 | 必须连续 | 不必连续 |
示例 ("abcde") |
"bcd" 是 |
"ace" 是 |
| 数量 (长度为 n) | 总数为 | 总数为 (含空) |
| LCS DP 转移 (不等时) |