来源:力扣(LeetCode)
链接:
给定一个字符串 s,计算 s 的 不同非空子序列 的个数。因为结果可能很大,所以返回答案需要对 109 + 7 取余 。
字符串的 子序列 是经由原字符串删除一些(也可能不删除)字符但不改变剩余字符相对位置的一个新字符串。
- 例如,“ace” 是 “abcde” 的一个子序列,但 “aec” 不是。
示例 1:
输入:s = "abc"
输出:7
解释:7 个不同的子序列分别是 "a", "b", "c", "ab", "ac", "bc", 以及 "abc"。
示例 2:
输入:s = "aba"
输出:6
解释:6 个不同的子序列分别是 "a", "b", "ab", "ba", "aa" 以及 "aba"。
示例 3:
输入:s = "aaa"
输出:3
解释:3 个不同的子序列分别是 "a", "aa" 以及 "aaa"。
提示:
- 1 <= s.length <= 2000
- s 仅由小写英文字母组成
方法一:动态规划
思路与算法
我们用 f[i] 表示以 s[i] 为最后一个字符的子序列的数目。
-
如果子序列中只有 s[i] 这一个字符,那么有一种方案;
-
如果子序列中至少有两个字符,那么我们可以枚举倒数第二个字符来进行状态转移。容易想到的是:倒数第二个字符可以选择 s[0], s[1] , ⋯ , s[i−1] 中的某一个,这样就可以得到如下的状态转移方程:
然而这样做会产生重复计数。如果 s[j0] 和 s[j1] 这两个字符不相同,那么 f[j0] 和 f[j1] 对应的子序列是两个不相交的集合;但如果 s[j0] 和 s[j1] 这两个字符相同,那么 f[j0] 和 f[j1] 对应的子序列会包含重复的项。最简单的一个重复项就是只含有一个字符的子序列 s[j0] 和 s[j1] 本身。
那么我们该如何防止重复计数呢?可以发现,如果 j0 < j1,那么 f[j0] 一定是 f[j1] 的一个真子集。这是因为:
每一个以 s[j0] 为最后一个字符的子序列,都可以把这个字符改成完全相同的 s[j1] ,计入到 f[j1] 中。
因此,对于每一种字符,我们只需要找到其最后一次出现的位置(并且在位置 i 之前),并将对应的 f 值累加进 f[i] 即可。由于本题中字符串只包含小写字母,我们可以用 ]last[k] 记录第 k (0 ≤ k < 26) 个小写字母最后一次出现的位置。如果它还没有出现过,那么 last[k] = −1。这样我们就可以写出正确的状态转移方程:
将这两种情况合并在一起,最终的状态转移方程即为:
在计算完 f[i] 后,我们需要记得更新对应的 last 项。最终的答案即为:
代码:
class Solution {
public:
int distinctSubseqII(string s) {
vector<int> last(26, -1);
int n = s.size();
vector<int> f(n, 1);
for (int i = 0; i < n; ++i) {
for (int j = 0; j < 26; ++j) {
if (last[j] != -1) {
f[i] = (f[i] + f[last[j]]) % mod;
}
}
last[s[i] - 'a'] = i;
}
int ans = 0;
for (int i = 0; i < 26; ++i) {
if (last[i] != -1) {
ans = (ans + f[last[i]]) % mod;
}
}
return ans;
}
private:
static constexpr int mod = 1000000007;
};
执行用时:16 ms, 在所有 C++ 提交中击败了12.45%的用户
内存消耗:6.6 MB, 在所有 C++ 提交中击败了35.85%的用户
复杂度分析
时间复杂度: O(n∣Σ∣),其中 n 是字符串 s 的长度, Σ 是字符集,在本题中 ∣Σ∣=26。即为动态规划需要的时间。
空间复杂度: O(n+∣Σ∣)。即为数组 f 和 last 需要的空间。
方法二:优化的动态规划
思路与算法
观察方法一中的状态转移方程:
我们可以考虑使用一个长度为 Σ∣=26 的数组 g 来进行动态规划,其中 g[k] 就表示上述状态转移方程中的 f[last[k]]。记 oi 表示 s[i] 是第 oi 个字母,我们可以在遍历到 s[i] 时,更新 g[oi] 的值为:
即可。当 last[k] = −1 时我们无需进行转移,那么只要将数组 g 的初始值设为 0,在累加时就可以达到相同的效果。
代码:
class Solution {
public:
int distinctSubseqII(string s) {
vector<int> g(26, 0);
int n = s.size();
for (int i = 0; i < n; ++i) {
int total = 1;
for (int j = 0; j < 26; ++j) {
total = (total + g[j]) % mod;
}
g[s[i] - 'a'] = total;
}
int ans = 0;
for (int i = 0; i < 26; ++i) {
ans = (ans + g[i]) % mod;
}
return ans;
}
private:
static constexpr int mod = 1000000007;
};
执行用时:8 ms, 在所有 C++ 提交中击败了30.19%的用户
内存消耗:6.3 MB, 在所有 C++ 提交中击败了78.11%的用户
复杂度分析
时间复杂度: O(n∣Σ∣),其中 n 是字符串 s 的长度, Σ 是字符集,在本题中 ∣Σ∣=26。即为动态规划需要的时间。
空间复杂度: O(∣Σ∣)。即为数组 g 和 last 需要的空间。
方法三:继续优化的动态规划
思路与算法
观察方法二中的状态转移方程:
由于我们的答案是数组 g 的和,而遍历 s[i] 后只有 g[oi] 的值发生了变化。因此我们可以使用一个变量 total 直接维护数组 g 的和,每次将 g[oi] 的值更新为 1 + total,再将 total 的值增加 g[oi] 的变化量即可。
代码:
class Solution {
public:
int distinctSubseqII(string s) {
vector<int> g(26, 0);
int n = s.size(), total = 0;
for (int i = 0; i < n; ++i) {
int oi = s[i] - 'a';
int prev = g[oi];
g[oi] = (total + 1) % mod;
total = ((total + g[oi] - prev) % mod + mod) % mod;
}
return total;
}
private:
static constexpr int mod = 1000000007;
};
执行用时:4 ms, 在所有 C++ 提交中击败了78.49%的用户
内存消耗:6.2 MB, 在所有 C++ 提交中击败了94.72%的用户
复杂度分析
时间复杂度: O(n+∣Σ∣)。其中 n 是字符串 s 的长度, Σ 是字符集,在本题中 ∣Σ∣=26。初始化需要的时间为 O(∣Σ∣),动态规划需要的时间的为 O(n)。
空间复杂度: O(∣Σ∣)。即为数组 g 需要的空间。
author:LeetCode-Solution