十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

蓝桥杯国赛真题解析:动态规划解决本质上升序列计数问题

蓝桥杯国赛真题解析:动态规划解决本质上升序列计数问题 1. 项目概述从一道国赛真题看“本质上升序列”最近在整理历年蓝桥杯国赛的Python真题时2020年的那道“本质上升序列”题让我印象尤为深刻。这道题乍一看像是经典的最长上升子序列LIS问题但题目中“本质不同”这四个字直接把难度和思考维度提升了一个档次。它不再仅仅是求一个最长的长度而是要我们统计所有不重复的、严格递增的子序列的数量。这对于很多习惯了动态规划求最优解比如最大长度、最小代价的选手来说是一个思维上的转换。我当时做这道题也是绕了点弯路才彻底搞明白其中的递推关系和去重逻辑。简单来说给你一个字符串题目原数据是一个长字符串你需要找出它所有可能的子序列中那些从左到右字符的ASCII码值严格递增的子序列并且还要保证这些子序列本身是互不相同的。最终输出这个数量。这就像是在一堆杂乱无章的字母里寻找所有可能的、按字母表顺序“向上走”的独特路径。它融合了动态规划、字符串处理和集合去重的思想非常考验对DP状态定义的深刻理解和编码的严谨性。无论你是正在备赛蓝桥杯的选手还是对动态规划感兴趣想挑战一下经典LIS问题的变种这道题都是一个绝佳的练手材料。接下来我会带你彻底拆解这道题从最朴素的暴力思路开始一步步优化到高效的正解并分享我在实现过程中踩过的坑和总结的技巧。2. 问题核心与思路拆解2.1 问题重述与定义首先我们必须明确题目到底在问什么。题目会给定一个字符串s。对于这个字符串的某个子序列如果满足它是原字符串的一个子序列即从原串中删除一些字符后剩余字符保持原有顺序的连接。该子序列中每个字符的ASCII码值严格递增即后一个字符大于前一个字符。那么这个子序列就是一个“上升子序列”。而“本质不同”意味着即使两个子序列在原字符串中选取字符的位置不同但只要它们最终形成的字符串是一样的就被视为同一个子序列只计数一次。举个例子字符串abc。a,b,c,ab,ac,bc,abc都是上升子序列并且它们彼此都不同所以总数是7。但如果是aba情况就复杂了。子序列ab可以通过选取第1、2个字符得到也可以通过选取第1、3个字符得到。虽然来源位置不同但形成的字符串都是ab因此它只算作一个“本质上升序列”。我们的目标就是计算这个“本质不同”的上升子序列的总数。2.2 从暴力枚举到动态规划的思维跃迁最直接的想法是暴力枚举所有子序列然后检查是否上升最后用一个集合Set来去重。对于一个长度为n的字符串子序列总数是2^n每个字符选或不选。当n较大时比如题目可能到2002^200是完全不可行的这直接否定了暴力回溯的可行性。这时就必须考虑动态规划DP。动态规划的核心是用空间换时间通过记录子问题的解来避免重复计算。对于经典的最长上升子序列LIS长度问题我们定义dp[i]表示以第i个字符结尾的最长上升子序列的长度。状态转移时我们需要遍历i之前的所有j如果s[j] s[i]那么dp[i] max(dp[i], dp[j] 1)。但我们的问题不是求最长而是求所有不同序列的数量。因此DP数组的意义需要改变。一个自然的想法是定义dp[i]为以字符s[i]结尾的、本质不同的上升子序列的数量。那么最终答案就是所有dp[i]的和再加上所有长度为1的子序列即每个字符本身。然而这里有一个巨大的陷阱重复计数。考虑字符串abab。以最后一个b索引3结尾的上升子序列有哪些我们可以从前面找到a索引0或2来接上。如果简单累加dp[0]和dp[2]那么由第一个a形成的序列如a和由第二个a形成的序列同样是a会被重复计算因为它们结尾形成的字符串ab是同一个。所以直接累加前面所有小于当前字符的dp[j]会导致重复。问题的关键在于对于相同的字符我们如何避免重复计算它们所“贡献”的序列2.3 关键思路以字符值为DP状态而非字符位置这是解决本题最精妙的一步转换。既然重复来源于相同的字符那么我们不如以字符的ASCII码值作为DP数组的维度。定义dp[char]表示以字符char结尾的本质不同的上升子序列的数量。注意这里的char是一个具体的字符值如a,b而不是字符串中的位置。我们从左到右遍历原字符串s的每个字符c。对于当前字符c它可以作为一个全新的、长度为1的子序列的开始。所以以c结尾的序列数量至少为1即序列c本身。更重要的是所有以小于c的字符结尾的上升子序列在末尾添加上c之后仍然是一个上升子序列并且这个新序列的结尾字符是c。因此状态转移方程可以描述为新的dp[c] 1 sum(dp[x])其中x遍历所有小于字符c的字符dp[x]是遍历到当前位置时以x结尾的序列总数。但这里还有一个细节我们需要实时更新dp[c]。因为当我们在字符串后面再次遇到同一个字符c时之前计算过的、以c结尾的序列会和当前字符c形成重复。正确的做法是在计算当前字符c的贡献时dp[c]应该被覆盖为新的值而不是累加。为什么假设之前有一个以c结尾的序列S。现在我们又遇到一个c。如果我们把S后面加上这个新的c会得到Sc。但这个Sc和之前由其他c结尾形成的Sc可能是重复的如果S相同。更关键的是对于同一个结尾字符c我们只关心以它结尾的、所有不同的序列的总数。当新的c出现时它可以和所有小于c的字符结尾的序列结合形成一批新的以c结尾的序列。这批新的序列完全取代了之前旧的、以c结尾的序列集合因为旧的集合是新的集合的子集吗不完全是但通过这种更新方式可以避免重复。实际上dp[c]始终维护的是“到当前遍历位置为止以字符c结尾的本质不同上升子序列的数量”。所以遍历过程的伪代码如下 初始化一个数组dp长度为128覆盖ASCII码所有值为0。 遍历字符串 s 中的每个字符 c 令temp 1。这个1代表字符c自身作为一个新序列。 对于所有字符x从 a 到 c-1即ASCII码小于c的字符temp dp[x]dp[c] temp# 注意是赋值不是累加 遍历结束后答案 所有dp[char]的和。2.4 思路验证与复杂度分析我们用一个小例子aba来验证初始化dp[a..z] 0。遍历到第一个atemp 1(序列a)没有小于a的字符所以temp仍为1。dp[a] 1。 此时以a结尾的序列{a}遍历到btemp 1(序列b)小于b的字符有adp[a]1所以temp 1 1 2。这代表了序列b和ab。dp[b] 2。 此时以b结尾的序列{b, ab}遍历到第二个atemp 1(新的序列a注意这个a和第一步的a本质相同)没有小于a的字符所以temp 1。dp[a] 1。这里覆盖了之前的值。 此时以a结尾的序列{a}。虽然出现了两次a但dp[a]只记录了一种。计算总和dp[a] dp[b] 1 2 3。 所有本质上升序列为a,b,ab。结果正确。复杂度分析时间复杂度O(n * C)其中n是字符串长度C是字符集大小这里ASCII码是128。在遍历每个字符时我们需要累加所有小于它的字符的dp值。如果字符集很大这步是O(C)。对于本题C是固定的128所以可以认为是O(n)。空间复杂度O(C)即一个大小为字符集大小的dp数组。这个思路巧妙地将“位置”维度转化为了“字符”维度利用DP数组直接按字符去重是解决此类“本质不同子序列计数”问题的经典手法。3. 代码实现与逐行解析理解了核心思路后我们来看具体的Python代码实现。这里我会给出两个版本的代码一个基础清晰版一个优化高效版并详细解释每一行代码的作用和背后的思考。3.1 基础清晰版实现def count_distinct_increasing_subsequences(s): 计算字符串 s 中本质不同的严格上升子序列的数量。 严格上升指子序列中每个字符的ASCII码严格递增。 # 初始化DP数组长度为128足以覆盖标准ASCII字符 # dp[ord(c)] 表示以字符 c 结尾的本质不同上升子序列的数量 dp [0] * 128 # 遍历字符串中的每一个字符 for ch in s: # 获取当前字符的ASCII码值 idx ord(ch) # 临时变量记录以当前字符结尾的新序列数量 # 初始为1代表当前字符自身构成一个长度为1的子序列 total 1 # 关键步骤累加所有ASCII码小于当前字符的 dp 值 # 这意味着所有以小于 ch 的字符结尾的序列后面加上 ch都能构成新的以 ch 结尾的序列 for i in range(idx): total dp[i] # 将计算出的 total 赋值给 dp[idx] # 注意这里是赋值 ()而不是累加 () # 因为对于相同的字符后出现的字符会“看到”更全的小于它的字符集合 # 直接赋值可以避免对由之前相同字符产生的序列进行重复计数 dp[idx] total # 最终答案是所有 dp 值的和即所有以任意字符结尾的序列总数之和 result sum(dp) return result # 测试用例 if __name__ __main__: test_str abc print(f字符串 {test_str} 的本质上升序列数量为: {count_distinct_increasing_subsequences(test_str)}) # 应输出 7 test_str2 aba print(f字符串 {test_str2} 的本质上升序列数量为: {count_distinct_increasing_subsequences(test_str2)}) # 应输出 3代码解析与注意事项dp数组大小我们选择了128对应标准ASCII码0-127。题目中的字符串通常由字母组成这完全够用。如果明确知道字符范围比如只有小写字母可以声明为26以节省空间。内层循环for i in range(idx):这是算法的核心也是主要耗时操作。它遍历了所有ASCII码小于当前字符的字符。对于每个字符ch都要进行最多127次加法。dp[idx] total赋值操作这是去重的关键。无论之前dp[idx]是什么值都用新计算的total覆盖它。这保证了对于同一个字符dp值始终代表“到当前位置为止”的最新、最全的计数。如果使用就会重复计算之前相同字符已经生成过的序列。求sum(dp)遍历结束后dp数组中每个元素都存储了以对应字符结尾的序列数。将它们全部相加就得到了所有可能的、以任意字符结尾的本质不同上升子序列的总数。3.2 优化高效版实现前缀和优化基础版本的内层循环是O(128)的虽然对于本题可能可接受但我们可以通过维护一个前缀和数组来将其优化到O(1)这是一个非常实用的优化技巧。思路是我们额外维护一个数组prefix_sum其中prefix_sum[i]表示当前状态下所有ASCII码小于等于i的字符的dp值之和。这样当我们需要计算“所有小于字符ch的dp值之和”时只需要查询prefix_sum[ord(ch)-1]即可。def count_distinct_increasing_subsequences_optimized(s): 使用前缀和优化计算本质不同的严格上升子序列数量。 MOD 10**9 7 # 如果结果可能很大通常需要取模这里先保留 dp [0] * 128 # 前缀和数组prefix[i] 表示当前所有ASCII码 i 的字符的dp值之和 prefix [0] * 128 for ch in s: idx ord(ch) # 计算以当前字符结尾的新序列数 # 它等于 1 (自身) 所有小于ch的字符的dp值之和 # 所有小于ch的字符的dp值之和就是 prefix[idx - 1] (如果idx0) if idx 0: total 1 prefix[idx - 1] else: total 1 # 字符是ASCII最小的前面没有更小的字符 # 更新 dp 数组 old_dp_val dp[idx] dp[idx] total # 更新前缀和数组从 idx 开始后面的前缀和都需要增加 (new_val - old_val) delta total - old_dp_val if delta ! 0: for i in range(idx, 128): prefix[i] delta result sum(dp) return result # 测试结果应与基础版一致 if __name__ __main__: test_str abc print(f优化版 - 字符串 {test_str} 的数量为: {count_distinct_increasing_subsequences_optimized(test_str)}) test_str2 aba print(f优化版 - 字符串 {test_str2} 的数量为: {count_distinct_increasing_subsequences_optimized(test_str2)})优化版解析prefix数组prefix[i]动态维护了dp[0] dp[1] ... dp[i]的和。快速计算totaltotal 1 prefix[idx - 1]。这行代码直接替代了基础版中的整个内层循环将O(128)的操作降为O(1)。更新prefix数组当dp[idx]的值从old_dp_val变为total后所有prefix[j]其中j idx都需要加上差值delta total - old_dp_val。这里我们用一个循环来更新虽然看起来还是O(128)但请注意这个循环是在每个字符处理时都可能执行的而基础版的内层循环是每个字符必定执行。在字符集大小固定为128的情况下两者的最坏时间复杂度都是O(128n)。但在某些情况下如字符种类很少优化版的更新次数可能更少。更重要的是这种前缀和的思想在应对更大字符集或更复杂求和时优势明显。取模操作注意代码中我定义了一个MOD变量但未使用。在真正的竞赛中如果结果可能非常大比如题目要求输出结果对某个大质数取模我们就需要在每次加法和赋值时进行取模操作防止整数溢出。这是竞赛编程的常见要求。实操心得在真正比赛时我建议先写出基础清晰版确保逻辑正确。如果时间充裕且担心效率再考虑优化。前缀和优化虽然优雅但更新prefix数组的循环如果写错调试起来比基础版更麻烦。清晰正确永远是第一位的。4. 针对蓝桥杯真题的实战与答案2020年蓝桥杯国赛Python组的这道题给出的字符串通常很长。我们上面分析的方法完全可以应对。这里我模拟一个更复杂的测试用例并演示完整的解题过程。假设题目给出的字符串是lanqiao蓝桥杯的英文。我们来计算它的本质上升序列数量。我们可以手动推理来验证算法 字符串: l a n q i a o ASCII: 108 97 110 113 105 97 111我们走一遍算法流程使用基础版dp全0。遇到l(108):total 1 sum(dp[0..107]) 1,dp[108]1。遇到a(97):total 1 sum(dp[0..96]) 1,dp[97]1。遇到n(110):total 1 sum(dp[0..109])。此时dp[97]1,dp[108]1其他为0。所以sum(dp[0..109]) dp[97]dp[108] 2。total3。dp[110]3。序列n,an,ln遇到q(113):total 1 sum(dp[0..112])。sum dp[97]dp[108]dp[110] 1135。total6。dp[113]6。序列q,aq,lq,nq,anq,lnq遇到i(105):total 1 sum(dp[0..104])。sum dp[97]1。total2。dp[105]2。序列i,ai遇到第二个a(97):total 1 sum(dp[0..96]) 1。注意这里sum(dp[0..96])为0因为dp[97]虽然之前是1但97不在0..96范围内。所以total1。我们将dp[97]更新为1覆盖旧值1实际上没变。这步保证了去重以a结尾的序列只有a本身遇到o(111):total 1 sum(dp[0..110])。sum dp[97]dp[105]dp[108]dp[110] 12137。total8。dp[111]8。最终求和需要计算sum(dp[0..127])。我们只关心非零项dp[97]1(a)dp[105]2(i)dp[108]1(l)dp[110]3(n)dp[111]8(o)dp[113]6(q)总和 121386 21。所以字符串lanqiao的本质不同上升子序列数量是21。你可以将我们的函数输入这个字符串进行验证。在比赛中你需要处理的是题目给定的超长字符串可能是几百个字符直接调用我们优化版的函数即可快速得到答案。对于2020年国赛那道真题网上流传的题目数据是一个很长的字符串像是随机字符序列。使用上述算法无论是基础版还是优化版在Python中都能在毫秒级时间内计算出结果。最终的答案是一个很大的整数。这里我就不贴出原题的具体字符串和答案了但解题思路和代码是完全一致的。5. 常见错误与深度排查指南在理解和实现这道题时我见过也犯过不少错误。下面把这些“坑”总结出来希望能帮你避开。5.1 错误类型一概念混淆混淆“子序列”与“子串”子串必须连续。子序列可以不连续但必须保持原顺序。本题是子序列问题。如果你错误地按子串去思考会漏掉绝大部分情况。混淆“上升”的定义题目要求严格递增即s[i] s[i1]。如果错误理解为非递减计数会多出很多因为像aa这样的序列也会被算入。5.2 错误类型二动态规划状态设计错误这是最核心的错误区。使用基于位置的dp[i]并简单累加# 错误示范 dp [1] * n # 初始化每个字符本身 for i in range(n): for j in range(i): if s[j] s[i]: dp[i] dp[j] # 错误这会重复计数 ans sum(dp)为什么错以aba为例dp数组变化如下i0: dp[0]1 (a)i1: s 0 s 1 所以 dp[1] 1 dp[0] 2 (b,ab)i2: s 0 s 2 ? 不成立。s 1 s 2 ? 不成立。所以 dp[2] 1 (a)ans 1214。但正确答案是3。多出来的就是重复的a。因为当第二个a出现时它本应只代表自己这一个序列但基于位置的dp无法区分它和第一个a形成的序列是同一个。在基于字符的DP中使用累加而不是赋值# 错误示范 for ch in s: idx ord(ch) total 1 for i in range(idx): total dp[i] dp[idx] total # 错误应该是 dp[idx] total为什么错这会导致对同一个字符每次出现都会把之前计算过的序列再“加”一遍造成严重的重复计数。例如aa正确答案是1只有a但这个方法会算出2。5.3 错误类型三边界条件与初始化前缀和优化版的更新错误 在优化版代码中更新prefix数组时必须计算新值与旧值的差delta然后从idx开始更新到末尾。如果错误地只更新prefix[idx]或者更新逻辑混乱会导致后续的前缀和计算全部错误。# 正确更新 delta new_val - old_val for i in range(idx, 128): prefix[i] delta字符集范围处理 我们的dp数组开了128对应ASCII。如果题目字符串包含扩展ASCII或中文字符Unicodeord(ch)会超过127。这时需要扩大数组范围或者使用字典defaultdict来动态存储。在蓝桥杯比赛中通常只会出现字母和数字128是安全的但养成检查的习惯是好的。# 更安全的做法使用字典适用于任何字符 from collections import defaultdict dp defaultdict(int) # ... 循环中通过 dp[ch] 访问但求和时需要遍历字典的所有值注意使用字典后计算“所有小于当前字符的dp值之和”就需要遍历字典中所有键值对并筛选出键小于当前字符的项效率会降低。在已知字符范围时数组是更优选择。5.4 调试与验证技巧当你写出代码但不确定是否正确时可以按以下步骤验证小数据测试用手工能算清的小字符串测试如空串应为0或1通常约定空子序列不算答案为0a应为1aa应为1ab应为3aba应为3。这是最快发现逻辑错误的方法。打印DP数组对于稍复杂的字符串如abc,aba在循环中打印出每一步后的dp数组或字典的主要部分。观察值的变化是否符合你的预期。对拍写一个暴力枚举去重的函数仅用于测试n不能太大比如n10。用随机生成的短字符串同时运行你的DP算法和暴力算法比较结果是否一致。这是验证算法正确性的“银弹”。import itertools, random def brute_force(s): n len(s) res_set set() # 枚举所有非空子序列 for length in range(1, n1): for indices in itertools.combinations(range(n), length): subseq .join(s[i] for i in indices) # 检查是否严格上升 if all(subseq[i] subseq[i1] for i in range(length-1)): res_set.add(subseq) return len(res_set) # 随机测试 for _ in range(100): length random.randint(1, 8) # 长度小一点暴力能跑 test_s .join(random.choice(abcde) for _ in range(length)) # 字符集也小一点 dp_ans count_distinct_increasing_subsequences(test_s) bf_ans brute_force(test_s) if dp_ans ! bf_ans: print(f发现错误字符串: {test_s}, DP结果: {dp_ans}, 暴力结果: {bf_ans}) break else: print(随机测试100次通过)大数取模如果题目要求输出结果对 (10^97) 取模务必在每一次加法运算后立即取模包括计算total时和更新prefix时。否则中间结果可能溢出在Python中虽然整数不限大小但取模是题目要求且能保证结果在合理范围内。MOD 10**9 7 total 1 if idx 0: total (total prefix[idx - 1]) % MOD # 加法后取模 # ... 更新dp和prefix时delta计算和加法也要考虑取模 delta (total - old_dp_val) % MOD
返回列表