蓝桥杯“本质上升序列”题解:动态规划与去重技巧详解
1. 问题引入:从一个看似简单的字符串计数问题说起
最近在复盘蓝桥杯国赛的真题,翻到了C++ B组的这道“本质上升序列”。题目名字听起来有点唬人,什么“本质上升”,乍一看像是动态规划或者字符串处理的变种。很多同学第一次看到这个题,可能会有点懵,不知道从何下手。其实,这道题的核心,是要求我们从一个给定的字符串中,找出所有“本质不同”的“上升子序列”的个数。这里面的两个关键词——“本质不同”和“上升子序列”——就是解题的全部关键。
我们先抛开代码,用最直白的话来理解一下题意。假设给你一个字符串,比如 “lanqiao”。题目问的是:从这个字符串里,按顺序挑出一些字符(可以跳着挑,但顺序不能乱),使得挑出来的这些字符,从左到右是严格递增的(‘a’ < ‘b’ < ‘c’ …)。并且,即使挑出来的字符在字符串中的位置不同,只要最终组成的序列字符串一模一样,那就算同一种。这就是“本质不同”的含义。我们的任务就是数一数,总共有多少种不同的、严格递增的序列。
举个例子会清晰很多。我们用一个更短的字符串 “abc” 来试一下。它的所有“本质上升序列”有哪些呢?
- 单个字符: ‘a’, ‘b’, ‘c’。这有3种。
- 两个字符: ‘ab’, ‘ac’, ‘bc’。这有3种。
- 三个字符: ‘abc’。这有1种。 所以总共是 3 + 3 + 1 = 7 种。注意,‘ba’ 不是,因为 b > a,不满足递增;‘aa’ 也不是,因为 a = a,不满足严格递增。
那么,如果字符串里有重复字母呢?比如 “aab”。它的本质上升序列:
- 单个字符: 第一个 ‘a’, 第二个 ‘a’, ‘b’。但是,两个 ‘a’ 组成的序列都是 “a”,在“本质不同”的规则下,它们算同一种。所以单个字符只有 ‘a’ 和 ‘b’ 2种。
- 两个字符: 序列 “ab” (用第一个a和b),序列 “ab” (用第二个a和b)。看,又出现了!它们都是 “ab”,所以算1种。没有 “aa”,因为不严格递增。
- 三个字符: 没有,因为有两个a,无法构成严格递增。 所以总数是 2 + 1 = 3 种。
看到这里,你应该明白了题目的意思。它不是一个简单的求所有子序列的问题,而是在此基础上加了两重约束:1. 序列必须严格递增;2. 要去重。蓝桥杯把它放在国赛B组,显然不是让我们用暴力枚举所有子序列(2^n复杂度)再去重那么简单,字符串长度稍微大点就超时了。这背后考察的是动态规划(DP)的思想和去重的技巧。
2. 核心思路拆解:动态规划与去重逻辑的融合
面对这种计数问题,并且有“按顺序”、“递增”的条件,动态规划是一个很自然的思路。我们需要设计一个状态,以及状态之间如何转移。
一个最直接的想法是:定义dp[i]表示以字符串中第i个字符结尾的、严格递增的子序列有多少种。那么,最终答案就是所有dp[i]的和。
状态转移怎么想?对于第i个字符s[i],哪些子序列能以它结尾呢?所有在它之前出现的、并且字符比它小的位置j(j < i 且 s[j] < s[i]),这些位置j结尾的所有子序列,后面接上s[i],就构成了新的、以s[i]结尾的递增子序列。所以,dp[i]应该等于所有满足条件的dp[j]之和。
但是,这里有一个巨大的陷阱,也是这道题最精妙的地方:重复计数问题。考虑字符串 “abac”。我们来计算以最后一个字符 ‘c’ 结尾的子序列数。
- 位置1: ‘a’, 比 ‘c’ 小,
dp[1]代表以 ‘a’ 结尾的序列数,假设我们算出来是1(即序列 “a”)。 - 位置2: ‘b’, 比 ‘c’ 小,
dp[2]代表以 ‘b’ 结尾的序列数,它可能包括 “b” 和 “ab”。 - 位置3: ‘a’, 比 ‘c’ 小,
dp[3]代表以第二个 ‘a’ 结尾的序列数。
如果我们简单地把dp[1] + dp[2] + dp[3]加起来作为dp[4],会出问题。因为以第一个 ‘a’ (位置1) 结尾的序列有 “a”,以第二个 ‘a’ (位置3) 结尾的序列也有 “a”。当我们用 “a” + “c” 得到 “ac” 时,这个 “ac” 会被计算两次!一次是通过位置1的 ‘a’,一次是通过位置3的 ‘a’。然而,根据“本质不同”的定义,“ac” 只应该被算作一种。
所以,直接累加dp[j]会导致对于相同的字符,产生重复的转移计数。问题的根源在于,对于相同的字符,它们能形成的、以该字符结尾的“本质不同”序列集合可能是完全一样的。在上例中,以第一个 ‘a’ 和第二个 ‘a’ 结尾的本质不同序列都只有 {“a”}。
因此,我们需要修正我们的DP策略。一个关键洞察是:对于相同的字符,我们只关心“最后一次”出现时,它所承载的序列种类数。因为更早出现的相同字符,它能构成的所有序列,在后续的转移中,都会被后出现的同一个字符“代表”或“覆盖”。
基于这个想法,我们调整状态定义和转移方程:
状态定义:
dp[i]表示以字符s[i]结尾的、本质不同的严格递增子序列的个数。注意,这里强调的是以“这个位置的字符”结尾,但计数的已经是去重后的结果。状态转移:
dp[i] = 1 + sum(dp[j]),其中j满足j < i且s[j] < s[i]。这个1代表序列只包含s[i]自身的情况。求和sum(dp[j])表示把所有以比s[i]小的字符结尾的序列,后面添上s[i],形成新的序列。去重关键操作:在计算
dp[i]时,如果存在k < i且s[k] == s[i],那么我们需要将之前计算的dp[k]清零(或减去)。为什么?因为以s[k]结尾的所有序列,与现在以s[i]结尾的、由更早字符转移而来的序列,会产生重复。更准确地说,当我们遇到一个新的、与前面相同的字符时,我们应该认为,以这个字符结尾的序列的“所有权”或“代表性”转移到了这个新的位置。旧位置k的dp值不应该再参与后续任何比s[k]大的字符的转移计算,否则就会重复。一种清晰的实现方式是:我们维护一个辅助数组
last[26],记录每个小写字母最后一次出现时的dp值(或者其索引)。当我们在位置i遇到字符c时:- 首先,正常计算
dp[i] = 1 + sum(dp[j] for j where s[j] < s[i])。 - 然后,检查
last[c]是否存在(即字符c之前是否出现过)。 - 如果存在,假设之前出现的位置是
p,那么我们就让dp[p] = 0。这样,在后续计算比c大的字符的dp值时,就不会再累加到来自旧位置p的、已经由新位置i“代表”了的序列。
另一种等价的、在计算过程中更简洁的思路是:在累加
sum(dp[j])时,对于每一个字符,我们只累加它“最后一次出现”时的dp值。我们可以维护一个长度为26的数组sumDp[26],sumDp[ch]表示以字符ch结尾的所有本质不同序列的总数(即该字符当前最新的dp值)。那么对于当前位置i的字符cur:dp[i] = 1 + sum(sumDp[ch]),其中ch取所有比cur小的字符。- 然后,更新
sumDp[cur] = dp[i]。注意,这里是直接赋值,而不是累加。这就天然实现了“用新的覆盖旧的”,完成了去重。
- 首先,正常计算
第二种思路在编码上更简洁,也是解决此题的标准方法。它把去重的逻辑完美地融合到了状态转移的过程中。
3. 算法实现详解:从理论到C++代码
理解了上面的核心思路,我们就可以着手编写代码了。我们采用第二种思路,使用sumDp[26]数组来记录每个字符“当前”的代表性序列总数。
假设字符串s的长度为n,且只包含小写字母。算法步骤如下:
- 初始化一个长度为26的数组
sumDp,所有元素为0。sumDp[ch]表示以字符ch(‘a’对应0, ‘b’对应1, …) 结尾的本质不同上升序列的个数。 - 遍历字符串
s的每一个字符s[i]: a. 计算当前字符cur = s[i] - ‘a’。 b. 计算dp_i = 1。这个1代表序列只包含s[i]自身。 c. 遍历所有比cur小的字符prev(从0到cur-1),将sumDp[prev]累加到dp_i上。这表示所有以更小字符结尾的序列,后面接上s[i],构成新的序列。 d. 将sumDp[cur]更新为dp_i。注意,这里是赋值,不是+=。这就意味着,对于同一个字符,我们只保留最后一次计算出的、以它结尾的序列总数。之前的值被覆盖,相当于“旧位置”的贡献被移除了。 - 遍历结束后,答案就是数组
sumDp中所有元素的和。因为sumDp[ch]存储的就是以字符ch结尾的所有本质不同序列数,涵盖了所有可能的结尾字符。
让我们用字符串 “abac” 来手动模拟一下,验证去重是否生效:
- 初始化
sumDp[26] = {0} - i=0, s[0]=’a’, cur=0。
dp_i = 1 + sum(sumDp[0…-1])= 1。 (没有比 ‘a’ 小的字符)sumDp[0] = 1。 (现在,以’a’结尾的序列有1种:”a”)
- i=1, s[1]=’b’, cur=1。
dp_i = 1 + sumDp[0]= 1 + 1 = 2。 (比’b’小的字符是’a’,其sumDp为1)sumDp[1] = 2。 (以’b’结尾的序列有2种:”b”, “ab”)
- i=2, s[2]=’a’, cur=0。
dp_i = 1 + sum(sumDp[0…-1])= 1。 (注意,此时sumDp[0]还是1,但我们累加的是比’a’小的字符,没有,所以和为0)sumDp[0] = 1。 (这里直接覆盖了旧值。虽然值没变,但意义是:现在,以’a’结尾的序列,其“代表权”属于位置2的这个’a’。)
- i=3, s[3]=’c’, cur=2。
dp_i = 1 + sumDp[0] + sumDp[1]= 1 + 1 + 2 = 4。 (比’c’小的字符有’a’和’b’)sumDp[2] = 4。 (以’c’结尾的序列有4种:”c”, “ac”, “bc”, “abc”。注意,这里的”ac”只被计算了一次,因为sumDp[0]是1,它代表的是以最后一个’a’结尾的序列数。)
- 最终答案 =
sumDp[0] + sumDp[1] + sumDp[2]= 1 + 2 + 4 = 7。
我们验证一下 “abac” 的所有本质上升序列:
- 以 ‘a’ 结尾: “a”
- 以 ‘b’ 结尾: “b”, “ab”
- 以 ‘c’ 结尾: “c”, “ac”, “bc”, “abc” 总共 1 + 2 + 4 = 7 种。正确!
下面是完整的C++实现代码:
#include <iostream> #include <string> #include <vector> using namespace std; int main() { string s = "abac"; // 这里可以替换成题目给的字符串,比如蓝桥杯真题中的长字符串 vector<long long> sumDp(26, 0); // 使用long long防止大数溢出 for (char ch : s) { int cur = ch - 'a'; long long dp_i = 1; // 序列只包含当前字符自身 // 累加所有比当前字符小的字符的 sumDp 值 for (int prev = 0; prev < cur; ++prev) { dp_i += sumDp[prev]; } // 关键:更新(覆盖)当前字符的 sumDp 值 sumDp[cur] = dp_i; } long long ans = 0; for (long long num : sumDp) { ans += num; } cout << "本质上升序列的个数为: " << ans << endl; return 0; }代码要点与注意事项:
- 数据类型:由于答案可能非常大,远超
int范围,务必使用long long来存储sumDp和ans。这是竞赛题中非常常见的坑点。 - 去重的核心:
sumDp[cur] = dp_i;这一行是灵魂。它是赋值操作,确保了对于每个字符,我们只保留其最新(最后一次出现)的序列总数。 - 时间复杂度:O(26 * n),其中n是字符串长度。内层循环最多遍历26次(字母表大小),对于长度几十万的字符串也完全可行。
- 空间复杂度:O(26),只需要一个固定大小的数组,非常高效。
4. 真题实战与边界情况分析
蓝桥杯国赛真题中给出的字符串通常很长,比如可能是由某个单词或句子重复构成的。我们的算法可以轻松处理。我们拿一个更复杂的例子来测试一下,比如字符串 “abcabc”。
按照我们的算法:
- 遍历过程会动态更新每个字符的
sumDp。 - 最终,
sumDp[‘a’]将只记录最后一个 ‘a’ 的贡献,sumDp[‘b’]和sumDp[‘c’]同理。 - 计算以第二个 ‘c’ 结尾的序列时,它所累加的
sumDp[‘a’]和sumDp[‘b’]已经是考虑了所有 ‘a’ 和 ‘b’ 的最新、最全的序列集合,并且避免了因前面 ‘a’, ‘b’ 重复出现而导致的重复计数。
我们可以手动推导或编写小程序验证。对于 “abcabc”,其本质上升序列与 “abc” 是一样的吗?并不是。因为字符串变长了,虽然字符集还是 {a, b, c},但字符出现的顺序和次数增加了,能构成的新序列也变多了。例如,“a” 可以从第一个或第二个位置取,但本质都是 “a”,算一种。但序列 “ac” 呢?第一个 ‘a’ 和第二个 ‘c’,第一个 ‘a’ 和第三个 ‘c’,第二个 ‘a’ 和第三个 ‘c’… 实际上,根据我们的定义和算法,它会正确地计算出所有不重复的递增序列。
边界情况考虑:
- 空字符串:题目通常不会给空串,但如果遇到,按定义应该是0个序列(没有字符可选)。我们的算法中,
for循环不会执行,ans初始为0,结果正确。 - 单字符字符串:如 “a”。算法中,
dp_i = 1,然后sumDp[0]=1,最终ans=1。正确(只有序列 “a”)。 - 所有字符相同:如 “aaaa”。严格递增要求序列内字符不同,所以只能有单个字符的序列。我们的算法:第一个 ‘a’,
dp=1,sumDp[‘a’]=1;后续的 ‘a’,dp始终等于1(因为prev循环为空),并不断覆盖sumDp[‘a’]为1。最终ans=1。正确(只有序列 “a”)。 - 严格递减字符串:如 “cba”。只有单个字符的序列。算法会正确计算:每个字符的
dp_i都是1(因为前面没有更小的字符),最终ans=3。正确(“c”, “b”, “a”)。 - 大数处理:再次强调用
long long。如果字符串很长且字符分布均匀,答案是指数级增长的,int肯定会溢出。
注意:在蓝桥杯等竞赛的填空题中,答案可能是一个巨大的整数,需要直接输出这个数。我们的代码输出
ans即可。如果是编程题,可能要求对结果取模,那就在累加和赋值每一步都进行取模操作。
5. 算法对比与思维延伸:为什么不是其他方法?
在思考这道题时,可能会想到其他方法,我们来分析一下为什么DP是更优解。
1. 暴力DFS回溯枚举:这是最直观的方法:生成字符串的所有子序列,检查每个子序列是否严格递增,再用一个集合(如set<string>)去重。时间复杂度是 O(2^n * n),其中 n 是字符串长度。生成所有子序列是 O(2^n),检查递增和插入集合是 O(n) 或 O(L log L)(L是子序列长度)。当 n 超过20时,计算量就难以承受了。蓝桥杯国赛的数据规模,n 上百是常事,此法不可行。
2. 基于位置的传统子序列DP:定义dp[i]为考虑前 i 个字符,能形成的本质不同上升子序列个数。这个状态很难转移,因为新增一个字符s[i]时,它不仅可以接在以前面字符结尾的序列后面,还可以自己作为起点,更麻烦的是,它还会和前面相同的字符产生重复序列。状态定义没有聚焦于“以谁结尾”,导致去重异常复杂。相比之下,我们采用的“以字符结尾”的状态定义,配合sumDp数组,巧妙地将去重转化为“覆盖更新”,简化了问题。
3. 基于字符集的DP:我们的方法其实就是一种基于字符集的DP。状态是“以某个字符结尾”,而不是“以某个位置结尾”。因为题目只关心序列的字符内容(严格递增)和是否重复,不关心这些字符具体来自原字符串的哪些位置(只要顺序正确)。这种视角转换,是降低问题复杂度的关键。它利用了字母表只有26个的小范围特性,将复杂度从 O(n^2) 降到了 O(26*n)。
思维延伸:如果字符集很大呢?如果字符串不是小写字母,而是任意ASCII字符甚至Unicode,我们的sumDp数组大小就不再是26了。此时,一种方法是使用有序映射(如C++的map<char, long long>)来动态维护比当前字符小的所有字符的dp值之和。在遍历每个字符时,我们需要快速求出所有键小于当前字符的值的和,并更新当前字符的键值。这可以通过树状数组(Fenwick Tree)或线段树来实现,将字符离散化后,在值域上维护前缀和。这样时间复杂度可以做到 O(n log C),其中C是字符集大小。这体现了该DP模型良好的可扩展性。
6. 常见错误与调试技巧
在实现和调试这道题时,初学者容易遇到以下几个坑:
1. 忘记使用 long long:这是最致命的错误。因为本质上升序列的数量可能增长得非常快。例如,一个完全递增的字符串 “abcdefghijklmnopqrstuvwxyz”,其本质上升序列的数量等于所有非空子集的数量,即 2^26 - 1,大约是6.7亿,还在int范围内。但如果字符串更长,或者字符排列方式特殊,数量很容易超过 2^31。在竞赛中,一旦溢出,结果就完全错误了。养成习惯:在不确定范围时,对于计数类DP,优先使用long long。
2. 去重逻辑写错,写成了累加:错误的代码:sumDp[cur] += dp_i;这会导致重复计数。例如 “aa”,正确答案是1(只有”a”),但累加会得到2(第一个’a’算1,第二个’a’又加了1)。务必记住是赋值:sumDp[cur] = dp_i;
3. 内层循环的边界弄错:计算dp_i时,累加的是prev从 0 到cur-1,即所有严格小于当前字符的字符。如果写成prev <= cur或者prev < cur但起始值不对,都会导致错误。可以画一个字母表来帮助理解。
4. 初始化问题:sumDp数组应初始化为0。dp_i每次要初始化为1(代表自身)。这些细节在纸上演算时是清晰的,但写代码时可能遗漏。
调试技巧:
- 小数据测试:用短字符串如 “a”, “ab”, “aa”, “abc”, “aab” 手动计算预期结果,与程序输出对比。
- 打印中间变量:在循环中打印出每一步的
cur、dp_i和sumDp数组,观察其变化是否符合预期。例如对于 “abac”,一步步跟踪,看是否和我们之前的手动模拟一致。 - 对比暴力法(仅用于小数据验证):写一个简单的DFS暴力枚举程序,对于 n <= 10 的小字符串,验证DP算法的结果是否正确。这是验证算法正确性的黄金标准。
7. 举一反三:同类问题与变种思考
掌握了“本质上升序列”的解法,我们可以看看一些类似的问题,巩固这种DP思想。
变种1:计算不同的上升子序列个数(LeetCode 类似题)LeetCode上有类似题目,比如计算一个整数数组的不同递增子序列的个数。整数范围可能很大。这时,我们的“字符”变成了整数,字符集可能很大。解决思路依然是:
- 定义
dp[i]为以nums[i]结尾的不同递增子序列个数。 - 转移:
dp[i] = 1 + sum(dp[j]),其中j < i且nums[j] < nums[i]。 - 去重:对于相同的数值
nums[i],我们需要避免重复。一种方法是,对于每个值,我们只累加它最后一次出现时的dp值。可以在遍历时,用一个哈希表记录每个数值最新的dp值之和(或者更精确地说,是到当前位置为止,以该值结尾的序列总数)。当遇到重复值时,用新的dp值覆盖旧值在哈希表中的记录。计算当前dp[i]时,需要累加所有比nums[i]小的值的“最新dp值”。这通常需要对nums离散化后,用树状数组维护前缀和,以实现 O(n log n) 的复杂度。
变种2:最长递增子序列(LIS)的计数问题经典的最长递增子序列(LIS)问题是求长度。它的一个变种是:求最长递增子序列的个数。这比本题更难一些,因为不仅要计数,还要保证序列是最长的。通常需要两个DP数组,一个记录长度,一个记录方案数,并在转移时根据长度关系来决定如何累加方案数。去重逻辑也更为复杂。
变种3:带有禁止位的上升序列如果题目增加条件,比如某些字符不能同时出现在序列中,或者序列必须包含某个特定字符等。这通常需要在状态定义中增加维度,例如用位掩码来表示哪些字符已经被使用过,将问题转化为状态压缩DP。
通过解决“本质上升序列”这道题,我们深入练习了以结尾元素定义状态的DP方法,以及利用覆盖更新来处理去重的经典技巧。这种“只关心最后一次出现”的思想,在需要处理重复元素贡献的计数DP问题中非常常见,是一个值得牢记的套路。下次遇到类似需要计数字符串或数组中满足某种条件的、去重后的子序列个数时,不妨先想想,能不能用这种“结尾元素DP+覆盖去重”的模型来解决。
