news 2026/8/28 18:55:57

动态规划去重技巧:本质不同上升子序列计数问题详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
动态规划去重技巧:本质不同上升子序列计数问题详解

1. 项目概述:一道经典的动态规划“陷阱题”

拿到这道“本质上升序列”的题目,很多参加过蓝桥杯国赛的同学可能都印象深刻。它来自2020年第十一届蓝桥杯软件类国赛C/C++大学A组的第三题,题面看似是经典的最长上升子序列(LIS)问题的变种,但实际考察点却精巧地设置了一个“陷阱”。如果你直接用标准的LIS动态规划(DP)思路去求解序列总数,大概率会掉进坑里,得到错误的答案。这道题的核心,在于理解“本质不同”这个约束条件,并设计出能够去重的状态转移方程。它不仅仅考察动态规划的基本功,更考验选手对问题本质的抽象能力和对状态定义的严谨性。对于正在备赛蓝桥杯,尤其是目标冲击国奖的A组选手来说,吃透这道题,对理解DP中“状态定义如何决定问题解法”这一核心思想有极大的帮助。今天,我们就来彻底拆解这道题,从暴力思路到优化DP,再到代码实现与调试技巧,完整复现解题的全过程。

2. 问题解析与核心难点定位

2.1 题目重述与关键信息提取

首先,我们明确题目内容。题目给定一个字符串(通常由小写字母组成,但在国赛环境下也可能是数字或其他字符序列),要求我们找出该字符串中所有“本质不同的上升子序列”的个数。

我们需要明确几个关键定义:

  1. 子序列:从原字符串中删除零个或多个字符后,剩余字符保持原有相对顺序形成的序列。例如,“abc”的子序列包括 “”, “a”, “b”, “c”, “ab”, “ac”, “bc”, “abc”。
  2. 上升序列:对于字符串,通常指字典序严格递增的序列。即对于子序列中的任意两个相邻字符,后一个字符的ASCII码(或直接字符比较)严格大于前一个。
  3. 本质不同:这是本题的难点所在。即使两个子序列由原字符串中不同位置的字符组成,只要它们最终形成的字符串完全相同,就被视为同一个子序列。例如,字符串 “ababc” 中,选取第一个’a’和第三个’b’形成的 “ab”,与选取第一个’a’和第四个’b’形成的 “ab” 是同一个本质上升序列。

问题的输入是一个字符串s,输出是一个整数,表示所有本质不同的上升子序列的数量。通常结果会很大,可能需要对一个较大的数(如1000000007)取模。

2.2 从经典LIS问题出发的误区

一看到“上升子序列”,学过动态规划的同学第一反应很可能是最长上升子序列(LIS)的模型。LIS的经典DP定义是:dp[i]表示以第i个字符结尾的最长上升子序列的长度。其状态转移方程为:dp[i] = max(dp[j]) + 1,其中j < is[j] < s[i]

然而,本题要求的是数量,而非长度。一个自然的错误延伸是定义dp[i]为以s[i]结尾的本质不同上升子序列的个数。然后尝试这样转移:dp[i] = sum(dp[j]),其中j < is[j] < s[i],最后将所有的dp[i]求和。

这个思路为什么是错的?因为它无法处理“本质不同”带来的去重问题。考虑字符串“ababc”

  • 按照上述思路,计算dp[4](对应最后一个字符 ‘c’)。
  • 满足j < 4s[j] < ‘c’的下标j有 0(‘a’), 1(‘b’), 2(‘a’), 3(‘b’)。
  • 我们会把dp[0],dp[1],dp[2],dp[3]都加起来。
  • 但这里dp[0]dp[2]都代表了以 ‘a’ 结尾的序列集合,其中包含了大量相同的序列(比如单独的 “a”)。简单相加会导致这些序列被重复计数。

问题的根源在于,当有多个位置j的字符相同时(例如多个 ‘a’),以这些位置结尾的序列集合之间存在交集。直接求和,交集部分就被重复计算了。

2.3 正确思路的突破口:按字符结尾进行归并

既然重复来源于相同的结尾字符,那么一个直观的想法是:我们不再记录以“某个位置”结尾的序列数,而是记录以“某个字符”结尾的序列数。

定义dp[ch]表示以字符ch结尾的本质不同上升子序列的总数。这里ch可以是 ‘a’ 到 ‘z’(假设只有小写字母)。

我们从左到右遍历原字符串s的每个字符s[i]。对于当前字符s[i]

  1. 它可以作为一个全新的、长度为1的子序列的开始。
  2. 它可以接在所有结尾字符小于s[i]的子序列后面,形成新的、更长的子序列。

那么,以s[i]结尾的新序列数量是多少?应该是1(它自身)加上所有结尾字符小于s[i]的序列总数。用状态转移表示就是:new_count = 1 + sum(dp[ch]),其中ch遍历所有小于s[i]的字符。

接下来,我们需要用new_count去更新dp[s[i]]。这里有一个至关重要的点:直接赋值,而不是累加。即dp[s[i]] = new_count

为什么是赋值而不是累加?因为dp[ch]定义的是“以字符ch结尾的本质不同序列总数”。当我们处理到当前位置的字符s[i](假设是 ‘b’)时,dp[‘b’]可能已经有一个值了,这个值代表了之前处理过的其他 ‘b’ 字符所形成的、以 ‘b’ 结尾的序列总数。 现在,这个新的 ‘b’ 能形成的、以 ‘b’ 结尾的序列,和之前所有 ‘b’ 形成的序列,本质上是一样的吗?答案是否定的。因为序列的来源位置不同,但更重要的是,我们通过sum(dp[ch])已经将所有结尾小于 ‘b’ 的序列(包括之前 ‘b’ 所依赖的那些前缀)都考虑进来了。如果此时再累加,就会把之前 ‘b’ 已经统计过的、由相同前缀扩展而来的序列再统计一次,造成重复。 实际上,对于同一个字符,越靠后出现,它能形成的本质不同序列的集合,完全包含了之前同字符位置所能形成的集合。所以,最新的计算结果new_count就是当前时刻,以该字符结尾的、最全的本质不同序列数。我们应该用它覆盖旧值。

最终,整个字符串遍历完毕后,所有本质不同的上升子序列总数,就是sum(dp[ch])对所有字符ch求和。注意,这个总和不包括空序列。如果题目要求包含空序列,需要额外加1。

3. 动态规划算法实现与细节剖析

3.1 状态定义与转移方程形式化

基于上面的分析,我们可以形式化算法:

  • 状态定义: 设dp[26]为一个数组,dp[k]表示以第k个小写字母(‘a’ + k)结尾的本质不同上升子序列的个数。
  • 初始化dp[0..25] = 0
  • 遍历过程: 对于字符串s中的每个字符c = s[i]
    1. 计算total = 1。这个1代表字符c自身作为一个新序列。
    2. 对于所有字符ch从 ‘a’ 到c-1(即 ASCII 码小于c的字符),将dp[ch_index]累加到total上。这代表了c可以接在所有以小于它的字符结尾的序列之后,形成新序列。
    3. 更新:dp[c_index] = total这里是赋值操作
  • 结果计算: 遍历结束后,ans = sum(dp[0..25])。如果题目字符串包含其他字符(如大写字母、数字),则dp数组的范围要相应扩大。

3.2 C++ 代码实现与逐行解读

下面给出该算法的标准C++实现。我们假设输入字符串仅包含小写字母,结果对MOD=1e9+7取模。

#include <iostream> #include <string> #include <vector> using namespace std; const int MOD = 1000000007; int countDistinctIncreasingSubsequences(const string& s) { // dp[26], dp[i] 表示以字符 ('a'+i) 结尾的本质不同上升子序列个数 vector<long long> dp(26, 0); for (char c : s) { int idx = c - 'a'; // 当前字符的索引 long long total = 1; // 字符c本身作为一个新序列 // 累加所有结尾字符小于c的序列个数 for (int i = 0; i < idx; ++i) { total = (total + dp[i]) % MOD; } // 关键步骤:赋值,而非累加 dp[idx] = total; } // 计算所有以某个字符结尾的序列总数 long long ans = 0; for (long long num : dp) { ans = (ans + num) % MOD; } return (int)ans; } int main() { // 示例:题目可能给出的测试字符串 string s = "ababc"; int result = countDistinctIncreasingSubsequences(s); cout << "本质不同的上升子序列个数(不含空序列): " << result << endl; // 可以验证,对于 "ababc",正确结果是 21。 return 0; }

代码关键点解读:

  1. dp数组的数据类型:使用了long long。因为在累加过程中,序列数量可能增长非常快,超出int范围,即使在取模前也需要大整数暂存。取模操作在每一步加法后进行,防止溢出。
  2. 内层循环for (int i = 0; i < idx; ++i):这就是在求sum(dp[ch])forch < c。循环的上界是idx,严格小于当前字符索引,保证了“严格上升”。
  3. dp[idx] = total:这是算法的灵魂,实现了状态的“覆盖”更新,确保了去重。
  4. 时间复杂度:O(26 * n),其中 n 是字符串长度。因为内层循环最多遍历26次(字符集大小),所以对于仅小写字母的字符串,这是一个 O(n) 的算法。如果字符集很大(如ASCII全集),则需要优化内层求和,可以使用树状数组或线段树将求和复杂度降至 O(log C),其中 C 是字符集大小。

3.3 算法正确性验证与手工演算

为了加深理解,我们用手工计算一个小例子s = “abac”

初始化dp[a..c] = 0

  1. 处理s[0] = ‘a’

    • idx = 0
    • total = 1(序列: “a”)
    • 内层循环i < 0不执行。
    • dp[0] = 1。 (以’a’结尾的序列:{“a”})
  2. 处理s[1] = ‘b’

    • idx = 1
    • total = 1(序列: “b”)
    • 内层循环i=0(ch=’a’):total = 1 + dp[0] = 1+1=2。这表示“b”自身(“b”),和“a”后面接“b”(“ab”)。
    • dp[1] = 2。(以’b’结尾的序列:{“b”, “ab”})
  3. 处理s[2] = ‘a’

    • idx = 0
    • total = 1(新的“a”,注意它和第一个‘a’位置不同)
    • 内层循环i < 0不执行。(因为’a’是最小的,没有字符小于它)
    • dp[0] = 1这里覆盖了之前的值。现在的含义是:到当前位置为止,以’a’结尾的本质不同序列是 {“a”}。虽然第二个’a’位置靠后,但它能形成的新序列只有它自己“a”,而这个序列在第一个’a’时已经统计过了。所以总数仍然是1。这正体现了“本质相同”的去重。
  4. 处理s[3] = ‘c’

    • idx = 2
    • total = 1(序列: “c”)
    • 内层循环:
      • i=0(ch=’a’):total = 1 + dp[0] = 1+1=2。 (序列: “c”, “ac”)
      • i=1(ch=’b’):total = 2 + dp[1] = 2+2=4。 (新增序列: “bc”, “abc”)
    • dp[2] = 4。(以’c’结尾的序列:{“c”, “ac”, “bc”, “abc”})

最终,ans = dp[0] + dp[1] + dp[2] = 1 + 2 + 4 = 7。 我们枚举验证一下字符串 “abac” 的所有本质不同上升子序列: 长度为1: “a”, “b”, “c” (3个) 长度为2: “ab”, “ac”, “bc” (3个) 长度为3: “abc” (1个) 总共 3+3+1 = 7个。结果正确。

4. 性能优化与扩展场景讨论

4.1 针对大字符集的优化:树状数组(Fenwick Tree)

上述算法在字符集仅为小写字母时,O(26n)的复杂度完全足够。但如果题目扩展,字符集是全部ASCII(128或256),甚至是更大的Unicode范围,那么内层循环的O(C)求和就会成为瓶颈。此时,我们需要将求和操作优化到O(log C)。

树状数组(或线段树)是处理这种“前缀和动态更新与查询”的利器。我们可以维护一个树状数组bit,其中bit[ch]维护的是以字符值ch结尾的序列数量(即我们的dp[ch])的前缀和。但注意,我们的dp更新是“覆盖”而非“增加”,所以不能直接使用标准的单点增加、区间求和的树状数组。

我们需要一点转化。观察更新操作:dp[idx] = 1 + sum(dp[0..idx-1])。 令new_val = 1 + query(idx-1),其中query(x)是查询字符值[0..x]dp值总和。 然后我们执行update(idx, new_val - old_val)。这里的old_valdp[idx]更新前的值。update(pos, delta)表示在位置pos的值上增加delta

由于树状数组支持单点增加(add)和前缀和查询(sum),我们就能在 O(log C) 时间内完成一次状态转移。

以下是使用树状数组优化的C++代码框架:

#include <iostream> #include <string> #include <vector> #include <cstring> using namespace std; const int MOD = 1000000007; const int MAX_CHAR = 256; // 假设字符集为扩展ASCII class Fenwick { private: vector<long long> tree; int size; public: Fenwick(int n) : size(n), tree(n + 1, 0) {} void add(int idx, long long delta) { idx++; // 树状数组通常从1开始索引 while (idx <= size) { tree[idx] = (tree[idx] + delta) % MOD; idx += idx & -idx; } } long long sum(int idx) { idx++; // 同上 long long res = 0; while (idx > 0) { res = (res + tree[idx]) % MOD; idx -= idx & -idx; } return res; } long long queryRange(int l, int r) { if (l > r) return 0; return (sum(r) - sum(l - 1) + MOD) % MOD; } }; int countDistinctIncreasingSubsequencesBIT(const string& s) { Fenwick bit(MAX_CHAR); vector<long long> dp(MAX_CHAR, 0); // 仍然需要记录旧值 for (char c : s) { int idx = (unsigned char)c; // 获取字符的数值 // 查询所有小于当前字符的dp值之和 long long prefix_sum = bit.sum(idx - 1); long long new_val = (1 + prefix_sum) % MOD; long long delta = (new_val - dp[idx] + MOD) % MOD; if (delta != 0) { bit.add(idx, delta); dp[idx] = new_val; } } return (int)bit.sum(MAX_CHAR - 1); }

注意:在蓝桥杯竞赛环境中,除非题目明确字符集很大,否则使用简单的26次循环足矣。引入树状数组会增加代码复杂度,在时间紧张的赛场需权衡。但了解这种优化思路,对于解决其他类似“带权值前缀和动态更新”的DP问题非常有帮助。

4.2 包含空序列的处理与初始化陷阱

有些类似的题目可能要求包含空序列。此时,我们的定义需要稍作调整。有两种理解方式:

  1. 在最终结果上加1:这是最简单的方式。因为我们的算法统计了所有非空序列,所以最终答案加1即可。
  2. 修改DP初始状态:我们可以认为空序列是任何序列的前缀。一种巧妙的方法是,在遍历字符串之前,将dp数组的所有值想象为已经包含了一个空序列作为前缀。但这实现起来比较绕。更清晰的做法是,在计算每个字符的new_count时,total不从1开始,而是从0开始(代表不选该字符),然后new_count表示的是以该字符结尾的序列数。但这样最终求和时,逻辑会变得复杂。

强烈建议采用第一种方式:先计算不含空序列的结果ans,如果需要包含,则输出(ans + 1) % MOD。思路清晰,不易出错。

初始化陷阱:务必确保dp数组初始化为0。任何非零的初始化都会导致计数错误,因为我们会错误地引入不存在的“基础序列”。

4.3 当“上升”定义变化时:非降序与严格降序

本题是“严格上升”(字典序递增)。如果问题变式为“非降序”(即允许相等)的本质不同子序列个数呢?

状态定义dp[ch]仍然适用,但转移条件需要改变。对于当前字符c,它能接在哪些序列后面?是所有结尾字符ch满足ch <= c的序列后面。因此,内层求和应变为sum(dp[ch])forch <= c。但注意,这包含了ch == c的情况。这意味着当前字符可以接在之前以相同字符结尾的序列后面。

此时,状态更新还能用覆盖吗?不能!因为对于非降序,新字符c接在旧序列后面形成的新序列,与旧序列本身是不同的(因为长度增加了)。例如 “aa” 和 “a” 是不同的序列。所以,更新操作应该是累加:dp[c] += new_count,其中new_count = 1 + sum(dp[ch])forch <= c。这里的1代表单独由当前字符构成的序列,或者理解为在空序列后接上当前字符。

对于“严格降序”,思路类似,只是求和范围变成所有ch > c的字符。

核心原则状态更新用覆盖还是累加,取决于新产生的序列集合与原有集合的关系是“覆盖”还是“扩充”。在严格上升序列中,后出现的相同字符产生的序列集合是前者的超集,故覆盖。在非降序序列中,后出现的相同字符产生的是全新的、更长的序列,与原有集合互斥,故累加。

5. 常见错误与调试技巧实录

5.1 典型错误案例与原因分析

  1. 错误:使用LIS计数模型,导致重复计数。

    • 错误代码特征:使用dp[i]表示以s[i]结尾的数量,转移时dp[i] = 1 + sum(dp[j])forj < i && s[j] < s[i]
    • 导致结果:对于有重复字符的字符串,结果会比正确答案大很多。
    • 调试方法:用“ababc”“aaa”这样的小样例测试。“aaa”的本质不同上升子序列只有“a”一个(因为不允许相等),但错误算法会给出多个。
  2. 错误:在“覆盖更新”时使用了累加dp[idx] += total

    • 导致结果:对于重复字符,计数会指数级膨胀,结果巨大且错误。
    • 检查方法:在更新dp[idx]后,打印dp数组。观察处理重复字符时,对应位置的值是否被合理重置。例如处理“abac”的第二个 ‘a’ 时,dp[0]应该保持为1,而不是变成2。
  3. 错误:取模运算不当,导致中间结果溢出或负数。

    • 常见于:使用int类型存储dptotal,在累加多个大数时溢出;或者在做减法取模时未加MOD导致出现负数。
    • 修正方案
      • 使用long long存储中间变量。
      • 每次加法、减法运算后立即取模。
      • 减法取模使用(a - b + MOD) % MOD的形式。
  4. 错误:内层求和范围错误。

    • 严格上升:应求和ch < current_char
    • 非降序:应求和ch <= current_char
    • 混淆后果:导致计数缺失或增多。务必根据题意仔细核对比较符号。

5.2 蓝桥杯赛场调试策略

  1. 设计小规模测试用例:不要只依赖题目给的样例。自己构造包含以下特征的短字符串:

    • 单字符:“a”(答案:1)
    • 重复字符:“aa”(严格上升答案:1;非降序答案:3 [“”, “a”, “aa”]? 注意是否含空序列)
    • 递增序列:“abc”(答案:7 [a,b,c,ab,ac,bc,abc])
    • 乱序且有重复:“abac”(我们演算过,是7)
    • 全部相同:“zzz”(严格上升答案:1;非降序答案:4 [‘z’, ‘zz’, ‘zzz’] + 空序列?)
  2. 打印DP数组:在循环中打印每个字符处理后的dp数组(前几个字符即可),与手工演算过程对比,能快速定位状态转移的错误。

  3. 对拍(暴力枚举):对于长度非常短(n <= 10)的字符串,可以写一个DFS暴力程序,枚举所有子序列,检查是否上升且用集合去重,得到准确答案。用这个答案来验证你的DP程序。这是最可靠的调试方法。

  4. 注意输入输出格式:蓝桥杯经常需要文件输入输出(freopen),或者需要将结果输出为特定格式。比赛时务必先确认。

5.3 从本题延伸的DP思考模式

这道题给我们最大的启示是:动态规划的状态定义,需要根据问题的最终目标(求种类数且去重)来精心设计,而不能生搬硬套经典模型。

  • LIS模型:状态定义聚焦于“位置”,求的是长度最大值。其扩展性在于“位置”的偏序关系。
  • 本质上升序列计数模型:状态定义聚焦于“字符值”,求的是种类数。其扩展性在于“字符值”的偏序关系,并通过覆盖更新来去重。

当遇到“计数”且要求“去重”的问题时,思考:

  1. 重复的来源是什么?(本题是不同位置产生相同序列)
  2. 能否找到一个关键属性,使得基于这个属性定义的状态,其对应的集合是互斥的?(本题是序列的最后一个字符)
  3. 状态转移时,如何保证不重不漏?(本题通过计算新集合的总数并覆盖旧状态)

这种“以结尾元素分类”的思想,在处理子序列计数问题时非常普遍,例如统计本质不同的子序列(不要求上升),其DP方程为dp[ch] = sum(dp[all]) + 1,然后total = sum(dp[all]),同样需要用新值覆盖旧值。多练习这类题目,就能培养出定义合适DP状态的本能。

这道“本质上升序列”题,堪称蓝桥杯国赛动态规划考点中的一颗明珠,它把状态定义、去重思想、字符集处理融合在一起。理解它,不仅是为了解一道题,更是为了掌握一类题的解法。在竞赛和面试中,类似的思维转换能力,往往就是区分高手与普通选手的关键。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/28 18:55:32

RAG大模型选型指南:小白/程序员必备三方案,收藏这篇轻松入门!

本文深入解析传统RAG的三大困境&#xff1a;跨文档问题、关系推理和文档解析质量。介绍三种主流RAG框架&#xff1a;RAGFlow侧重文档解析&#xff0c;LightRAG兼顾关系推理与低成本更新&#xff0c;GraphRAG专注全局归纳与多跳推理。文章对比分析三者优劣&#xff0c;提供场景决…

作者头像 李华
网站建设 2026/8/28 18:55:06

第40章:【高级篇综合实战】从零打造生产级 FastAPI 平台

1. 项目背景 业务场景 "SaaS 工厂"是一个面向中小企业的多租户管理平台&#xff0c;支持用户管理、租户隔离、权限控制&#xff08;RBACABAC&#xff09;、消息通知、审计日志、开放 API 等功能。公司决定用 FastAPI 从零构建——这是高级篇&#xff08;第 31-39 章…

作者头像 李华
网站建设 2026/8/28 18:54:59

模型可解释性评估实战:从忠实度到稳定性构建可信AI

在机器学习模型落地过程中&#xff0c;可解释性已经不是一个“加分项”&#xff0c;而是模型可信、可审查、可迭代的必备能力。但这里有一个被很多人忽略的问题&#xff1a;SHAP、LIME、Integrated Gradients、LRP 这些解释方法本身也是算法&#xff0c;它们的输出同样需要被验…

作者头像 李华
网站建设 2026/8/28 18:52:08

AI重塑软件行业:从传统架构到Agent与MCP转型实践

我最近和几个做企业软件的朋友聊天&#xff0c;几乎每个人都在问同一个问题&#xff1a;AI 到底会不会把我们的饭碗端了&#xff1f;这个问题放在两年前&#xff0c;听起来像科幻片。但放在现在&#xff0c;任何写代码、卖软件、做 SaaS 的人都能感受到那种压力——不是来自某一…

作者头像 李华
网站建设 2026/8/28 18:50:36

Scratch镜像画笔:坐标变换与实时交互的图形化编程实践

1. 项目概述&#xff1a;从“镜像画笔”看Scratch图形化编程的深度应用最近在整理历年蓝桥杯国赛的Scratch真题时&#xff0c;第十三届的这道“镜像画笔”题让我印象特别深刻。它不像一些简单的动画或游戏题&#xff0c;而是真正考察了选手对Scratch底层坐标系统、画笔模块以及…

作者头像 李华
网站建设 2026/8/28 18:49:21

LINGO优化建模:从数学公式到运输问题实战

1. 从“数学建模”到“LINGO”&#xff1a;为什么它依然是你的秘密武器如果你正在准备数学建模竞赛&#xff0c;或者在工作中遇到了需要优化决策的问题&#xff0c;比如“如何安排生产计划成本最低”、“如何设计物流路线效率最高”&#xff0c;那么你大概率会听到一个名字&…

作者头像 李华