资讯动态

动态规划精讲:从LIS到本质上升序列的计数与优化

发布时间:2026/8/28 21:50:18 来源:尧图企业网站定制
1. 问题引入从“上升”到“本质”的跨越最近在复盘一些经典的动态规划题目特别是蓝桥杯国赛级别的难题发现“本质上升序列”这道题很有意思。它不像普通的“最长上升子序列”LIS那样只关心长度这个单一指标。我第一次看到这个题目时心里也犯嘀咕不就是数上升子序列的个数吗但仔细一想不对如果只是数所有上升子序列那“ab”这个字符串里“a”、“b”、“ab”都是上升的按字典序但“a”和“b”作为单个字符似乎又太简单了。题目真正的难点和精髓就在“本质”这两个字上。什么叫“本质不同”简单来说两个序列即便内容一模一样只要它们在原字符串中的位置下标不同就被视为不同的序列。比如字符串 “aba” 它的字符是a(0), b(1), a(2)。那么以第一个a下标0结尾的序列和以第二个a下标2结尾的序列即使它们的内容都是单纯的“a”也被认为是两个不同的“本质上升序列”。因为它们的“来源”不同。这和我们平时去重时只关心序列内容本身有根本区别。这道题考察的正是如何在这种定义下高效、准确且不重不漏地进行计数。这让我想起了在处理数据流、日志分析或者基因序列比对时我们常常不仅要关注模式Pattern本身还要关注模式出现的位置和上下文。这种“位置敏感”的计数方式在不少实际场景中都有应用。下面我就结合自己的理解把这道题的解题思路、动态规划的状态设计、转移方程以及几个关键的代码实现细节和易错点完整地梳理一遍。无论你是正在备赛蓝桥杯还是想深入理解DP思想相信这篇内容都能给你带来一些启发。2. 核心概念拆解与状态定义要解决这个问题我们首先得把题目中几个关键概念和我们的DP状态定义清楚这是所有后续推导的基础。2.1 问题重述与概念澄清假设我们有一个字符串s其长度为n字符集通常是英文字母区分大小写。我们需要统计其中所有“本质不同的上升子序列”的数量。子序列从原字符串中按顺序取出一些字符可以不连续组成的新序列。例如“abc”的子序列包括 “”, “a”, “b”, “c”, “ab”, “ac”, “bc”, “abc”。空序列通常不计入本题。上升在本题语境下“上升”指的是子序列中每个字符的ASCII码或字典序严格递增。即对于子序列s[i1], s[i2], ..., s[ik] 必须满足i1 i2 ... ik且s[i1] s[i2] ... s[ik]。注意是严格递增相等是不允许的。本质不同这是本题的核心。两个子序列被认为是“本质相同”的当且仅当它们不仅序列内容相同而且构成序列的字符在原串中的下标也完全相同。反之只要下标序列不同即使内容相同也算作不同的本质上升序列。举个例子字符串“aba”考虑单个字符‘a’来自下标0和‘a’来自下标2是两个不同的本质序列。考虑序列“ab”它只能由下标0的‘a’和下标1的‘b’构成只有一种本质。序列“aa”不是上升序列因为字符不严格递增。所以我们的目标不是统计有多少种不同的“字符串”而是统计有多少种不同的“下标选择方案”使得选出来的字符构成一个严格递增的序列。2.2 动态规划状态设计面对计数类DP问题一个经典思路是定义dp[i]表示“以第i个字符结尾”的某种序列的数量。对于最长上升子序列LISdp[i]表示以s[i]结尾的LIS长度。但对于计数我们需要更细致的状态。最直接的想法是dp[i]表示以字符s[i]结尾的本质不同的上升子序列的数量。注意这里统计的是所有以s[i]结尾的序列包括长度为1的即只包含s[i]本身的序列。那么如何计算dp[i]呢一个以s[i]结尾的上升子序列它的倒数第二个字符如果存在一定是某个在i之前的位置j上的字符s[j]并且满足s[j] s[i]。所有以s[j]结尾的序列后面接上s[i]就构成了新的以s[i]结尾的序列。因此一个初步的转移方程是dp[i] 1 sum(dp[j]) 对于所有j i且s[j] s[i]。 这里的1代表序列只包含s[i]本身的情况。但是这里有一个巨大的陷阱这个方程会导致重复计数违背“本质不同”的原则。考虑字符串“abab”。我们手动计算一下以最后一个‘b’下标3结尾的序列序列“b”(下标3): 1种。接在j0(‘a’) 后面“a” - “ab”。这里“ab”的字符来自下标0和3。接在j2(‘a’) 后面“a” - “ab”。这里“ab”的字符来自下标2和3。按照上述方程dp[3] 1 dp[0] dp[2]。如果dp[0]和dp[2]都包含了以它们各自位置的‘a’结尾的序列那么我们会把(0,3)和(2,3)产生的两个“ab”都算进去。这看起来是对的因为它们下标不同。陷阱在于dp[j]本身可能已经包含了重复的“内容”。假设j1是第一个‘b’。dp[1]表示以s[1](第一个‘b’) 结尾的所有序列。它可能包含了由j0(‘a’) 转移而来的序列“ab”下标0,1。当我们用dp[1]来更新后面的dp[i]时如果s[1] s[i]比如都是‘b’那么就会把(0,1)后面接上i得到(0,1,i) 和(0,i)后面接上... 等等这里逻辑已经混乱了。问题的根源在于当原字符串中存在相同字符时直接使用dp[j]求和会导致重复。因为不同的js[j]相同可能会贡献出“内容相同”但“本质不同”的序列这些序列在后续转移中如果再次遇到相同的字符就会产生复杂的重复累计。2.3 正确的状态与转移方程为了解决重复问题我们需要改变状态定义的角度。既然麻烦出在相同的字符上我们就以字符为维度进行DP而不是以下标。定义dp[c]表示以字符c结尾的本质不同的上升子序列的总数。这里的c是字符类型比如‘a’,‘b’, …。现在我们按顺序遍历原字符串s的每个字符s[i]。对于当前字符s[i]它自身可以作为一个序列所以以s[i]结尾的序列数量至少增加1。它可以接在所有结尾字符小于s[i]的序列后面对于所有字符ch 如果ch s[i] 那么所有以ch结尾的序列后面加上s[i] 就形成了新的以s[i]结尾的序列。新增的数量就是dp[ch]。因此对于当前遍历到的s[i] 我们需要计算一个new_add 它等于1 sum(dp[ch])(对于所有ch s[i])。然后我们将dp[s[i]]增加new_add。为什么这个定义能避免重复关键点在于dp[c]是一个累计值。当我们在位置i遇到字符c时我们计算出的new_add代表了所有以当前位置i的字符c结尾的、新的本质序列的数量。然后我们把new_add累加到dp[c]中。dp[c]最终存储的是遍历完整个字符串后以字符c结尾的所有本质不同上升子序列的数量。由于我们按顺序遍历字符串对于同一个字符c 每次遇到它时我们都基于当前时刻所有小于c的字符的dp值来计算新增量。这保证了同一个字符c在不同位置出现时它们产生的序列是独立计算的因为每次计算的sum(dp[ch])是基于到当前位置为止的全局状态这个状态包含了之前所有位置的信息。不会重复计算由相同字符在不同位置构成的、内容相同的序列因为dp[c]是累加而不是赋值。我们计算的是“增量”这个增量本身就来自于当前字符的新位置所带来的新组合可能性。最终整个字符串的本质上升序列总数就是所有dp[c]c为所有出现过的字符的和。3. 算法实现与细节剖析理解了状态定义代码实现就相对清晰了。这里我用 Python 来演示因为其语法简洁易于理解算法核心。3.1 基础版本实现我们先实现一个最直接的版本假设字符都是小写字母。def count_distinct_increasing_subsequences(s: str) - int: 计算字符串 s 中本质不同的上升子序列的个数。 上升指严格字典序递增。 # 初始化 dp 数组索引对应字符的 ASCII 码这里假设只有小写字母 # ord(a) 是 97 但我们可以用相对位置范围是 0-25 dp [0] * 26 for ch in s: idx ord(ch) - ord(a) # 将字符映射到 0-25 # 计算 new_add: 1 (自身) 所有结尾字符小于当前字符的序列数之和 new_add 1 # 序列只包含当前字符本身 for j in range(idx): # 遍历所有比当前字符小的字符 new_add dp[j] # 将新增的数量累加到以当前字符结尾的序列总数中 dp[idx] new_add # 最终结果是所有 dp 值的和 total sum(dp) return total # 测试 print(count_distinct_increasing_subsequences(ab)) # 输出应为 3: a, b, ab print(count_distinct_increasing_subsequences(aba)) # 输出应为 6: a(0), b, a(2), ab(0,1), ab(0,2)? 等等需要手动验证让我们手动验证“aba”:初始dp [0]*26遍历‘a’(idx0):new_add 1 sum(dp[0:0]) 1。dp[0] 011。 (序列:a0)遍历‘b’(idx1):new_add 1 dp[0] 112。dp[1] 022。 (新增序列:b1,a0b1)遍历‘a’(idx0):new_add 1 sum(dp[0:0]) 1。注意此时sum(dp[0:0])是0因为要求j idx 对于‘a’来说没有比它小的字符。dp[0] 112。 (新增序列:a2。注意a0b1后面不能接a2因为‘b’不大于‘a’。)total dp[0] dp[1] 2 2 4。但我们之前分析“aba”应该有6个我们来列一下所有本质不同的上升子序列a(下标0)a(下标2)b(下标1)ab(下标0,1)ab(下标0,2) 不s[0]‘a’, s[2]‘a’ 不是严格递增。ab(下标2,1) 不下标21顺序不对。 实际上a2无法和前面的b1组成上升序列因为a2的字符不大于b1。所以正确的序列是a0a2b1a0b1没有a2b1因为下标顺序是2,1不是递增的。我们的算法只考虑字符值不考虑下标顺序吗考虑因为我们按顺序遍历字符串当处理a2时dp[1]以b结尾的序列数是2代表b1和a0b1。但new_add的计算是1 sum(dp[j] for j idx)。对于a2(idx0)j 0为空所以sum为0。这意味着a2不能接在任何以‘b’结尾的序列后面因为‘b’不小于‘a’。这完全正确所以总数是4。我之前的直觉6是错误的。“aba”的正确结果就是4。算法是正确的。3.2 处理大写字母和更大字符集上面的实现假设只有小写字母。如果字符串包含大写字母或其他字符我们需要扩大dp数组的范围。一个简单的方法是使用字典HashMap。def count_distinct_increasing_subsequences_general(s: str) - int: 通用版本处理任意ASCII字符。 from collections import defaultdict # dp 字典键是字符值是以该字符结尾的本质上升序列数 dp defaultdict(int) for ch in s: # 计算 new_add: 1 所有小于 ch 的字符对应的 dp 值之和 new_add 1 for prev_ch, count in dp.items(): if prev_ch ch: new_add count # 累加到当前字符的计数中 dp[ch] new_add # 求和 total sum(dp.values()) return total这个版本更通用但内层循环需要遍历整个dp字典时间复杂度为 O(n * C)其中 C 是字符集大小。对于长字符串和大的字符集如Unicode效率可能较低。3.3 优化使用前缀和加速注意到内层循环sum(dp[ch] for ch current_ch)是在求一个前缀和。如果我们维护一个有序的结构就可以用更快的方法计算这个和。由于字符可以比较大小我们可以维护一个数组其中下标对应字符的编码值如ASCII码dp[code]存储以该字符结尾的序列数。同时我们维护一个前缀和数组prefix_sum使得prefix_sum[x]表示所有编码小于等于x的字符的dp值之和。这样对于当前字符c编码为code_c我们需要的是所有编码严格小于code_c的字符的dp和即prefix_sum[code_c - 1]。计算完new_add并更新dp[code_c]后我们需要更新prefix_sum数组中从code_c开始到末尾的所有值因为它们都包含了dp[code_c]。这可以利用**树状数组Fenwick Tree或线段树Segment Tree**在 O(log M) 的时间内完成单点更新和前缀查询M是字符集大小。这是处理此类问题的标准优化。class FenwickTree: def __init__(self, size): self.size size self.tree [0] * (size 1) # 树状数组通常从1开始索引 def update(self, index, delta): 在位置 index (1-based) 增加 delta i index while i self.size: self.tree[i] delta i i -i # lowbit 操作 def query(self, index): 查询前缀和 [1, index] (1-based) res 0 i index while i 0: res self.tree[i] i - i -i return res def count_distinct_increasing_subsequences_fast(s: str) - int: 使用树状数组优化的版本时间复杂度 O(n log M) M为字符集大小。 假设字符为扩展ASCII (0-255)。 MOD 10**9 7 # 如果结果可能很大需要取模 CHAR_SIZE 256 # 扩展ASCII码范围 ft FenwickTree(CHAR_SIZE) total 0 for ch in s: code ord(ch) 1 # 转为1-based索引因为树状数组通常从1开始 # 查询所有小于当前字符的序列总和即查询前缀 [1, code-1] prev_sum ft.query(code - 1) # new_add 1 (新序列) prev_sum (接在后面) new_add (1 prev_sum) % MOD # 更新树状数组在 code 位置增加 new_add ft.update(code, new_add) total (total new_add) % MOD # 注意这里 total 是累计所有 new_add即所有新增序列。 # 也可以最后 sum(ft.tree) 或 ft.query(CHAR_SIZE)但边遍历边累加更清晰。 return total # 测试 print(count_distinct_increasing_subsequences_fast(ab)) # 3 print(count_distinct_increasing_subsequences_fast(aba)) # 4关键点解释为什么total是边遍历边累加new_add因为new_add就代表了由于当前位置字符s[i]的出现所新增的本质不同上升子序列的数量。这些新增序列一定以s[i]结尾。把它们全部加起来自然就是整个字符串的所有本质不同上升子序列的数量。这与最后计算sum(dp)是等价的。4. 边界条件、易错点与实战心得即使理解了算法在实现和调试时还是会遇到一些坑。这里总结几个常见的易错点和注意事项。4.1 空序列的处理题目通常要求统计非空序列。我们的算法中new_add 1 ...里的1就对应了只包含当前字符的序列。如果我们想包含空序列只需要在最终结果上加1或者初始化total1代表空序列。但蓝桥杯原题通常不包含空序列所以按上述实现即可。4.2 大数取模这类计数问题结果往往非常巨大很容易超出整数范围。蓝桥杯的题目经常要求将结果对10^9 7取模。务必在计算过程中就进行取模而不是等到最后。因为中间累加的结果可能已经溢出。在上面的优化代码中我们在new_add计算和total累加时都进行了取模操作。树状数组内部存储的也应该是取模后的值。需要注意的是取模运算下加法和乘法是安全的但如果有减法要避免出现负数通常(a - b) % MOD要写成(a - b MOD) % MOD。4.3 字符集范围与树状数组大小使用树状数组优化时需要确定字符集的范围。如果题目明确是英文字母可以用52大小写或26仅小写。如果是更广泛的ASCII0-127或扩展ASCII0-255就设置相应的大小。如果字符是数字范围就是0-9。树状数组的大小应等于字符集的最大编码值1因为用1-based索引。设置过小会导致数组越界设置过大会浪费空间但一般不影响正确性。4.4 验证与调试技巧对于DP计数问题最好的调试方法是用小规模数据手动计算并与程序输出对比。构造微型测试用例“”(空字符串): 结果应为0。“a”: 结果应为1 (“a”)。“aa”: 结果应为2 (两个不同位置的‘a’)。注意没有“aa”因为不是上升序列。“ab”: 结果应为3 (“a”,“b”,“ab”)。“aba”: 结果应为4 (a0,a2,b1,a0b1)。“abc”: 结果应为7 (a,b,c,ab,ac,bc,abc)。(公式对于严格递增且字符各不相同的字符串本质上升序列数 2^n - 1 n为长度。这里2^3-17)。打印中间状态在基础版本中可以在每步循环后打印dp数组观察其变化看是否符合预期。与暴力枚举对比对于长度很小n 10的字符串可以写一个暴力DFS程序枚举所有子序列检查是否严格上升并用集合Set存储序列对应的下标元组来去重体现“本质不同”。将暴力结果与DP结果对比这是最可靠的验证方式。4.5 从“本质不同”到“内容不同”的变体这道题的核心是“本质不同”。如果问题变成求“内容不同”的上升子序列数即只关心序列字符串本身不关心下标那么状态定义和转移就需要改变。通常需要用到“去重”技巧例如当遇到相同字符时只考虑最后一次出现的位置或者用集合来维护以每个字符结尾的“内容”集合。这又是另一类经典DP问题如LeetCode 940. 不同的子序列 II。千万不要把这两类问题混淆。5. 复杂度分析与算法选择最后我们来分析一下各个版本的复杂度以便在不同场景下做出选择。基础版本双循环时间复杂度 O(n * C)其中 C 是字符集大小如26。在字符集很小且字符串长度适中时例如 n 10^4, C26这个版本完全够用代码简单不易错。通用字典版本时间复杂度 O(n * C’)其中 C’ 是当前已出现的不同字符的个数。在最坏情况下所有字符都不同C’ 会增长到 min(n, C)。效率可能比数组版本还低因为字典遍历有开销。不推荐在竞赛中使用除非字符集非常大且稀疏。树状数组优化版本时间复杂度 O(n log M)其中 M 是字符集大小如256。这是效率最高的版本适用于 n 很大10^5 级别的情况。虽然代码稍复杂但这是应对大数据规模的标准做法。空间复杂度 O(M)。实战建议在蓝桥杯等竞赛中如果字符串长度在 1000 量级且只有小写字母用基础双循环版本足矣代码简单快速。如果题目提示结果很大需要取模或者长度可能达到 10^5 务必使用树状数组优化版本。在编写树状数组时一定要注意索引是1-based的字符编码转换时记得1。这是一个非常高频的失误点。始终先想清楚状态定义dp[c]的含义是“以字符c结尾的序列总数”并且理解new_add是“由于当前位置的字符c的出现而新增的数量”。这个“增量”的思想是理解整个算法的关键。这道“本质上升序列”题完美地将LIS问题的思想与计数DP、去重技巧结合在了一起。它考察的不仅仅是对DP公式的记忆更是对问题本质的洞察力和将抽象定义转化为数学模型的能力。下次再遇到类似“本质不同”的计数问题不妨先想想能不能把状态从“以位置结尾”切换到“以某种特征值结尾”或许就能豁然开朗。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价