动态规划精讲:从LIS到本质上升序列的计数与优化
2026/9/16 4:42:52 网站建设 项目流程

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 < ... < iks[i1] < s[i2] < ... < s[ik]。注意是严格递增,相等是不允许的。
  • 本质不同:这是本题的核心。两个子序列被认为是“本质相同”的,当且仅当它们不仅序列内容相同,而且构成序列的字符在原串中的下标也完全相同。反之,只要下标序列不同,即使内容相同,也算作不同的本质上升序列。

举个例子,字符串“aba”

  • 考虑单个字符:‘a’(来自下标0)和‘a’(来自下标2)是两个不同的本质序列。
  • 考虑序列“ab”:它只能由下标0的‘a’和下标1的‘b’构成,只有一种本质。
  • 序列“aa”不是上升序列,因为字符不严格递增。

所以,我们的目标不是统计有多少种不同的“字符串”,而是统计有多少种不同的“下标选择方案”,使得选出来的字符构成一个严格递增的序列。

2.2 动态规划状态设计

面对计数类DP问题,一个经典思路是定义dp[i]表示“以第i个字符结尾”的某种序列的数量。对于最长上升子序列(LIS),dp[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 < is[j] < s[i]。 这里的1代表序列只包含s[i]本身的情况。

但是,这里有一个巨大的陷阱!这个方程会导致重复计数,违背“本质不同”的原则。

考虑字符串“abab”。我们手动计算一下以最后一个‘b’(下标3)结尾的序列:

  • 序列“b”(下标3): 1种。
  • 接在j=0(‘a’) 后面:“a” -> “ab”。这里“ab”的字符来自下标0和3。
  • 接在j=2(‘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]本身可能已经包含了重复的“内容”。假设j=1是第一个‘b’dp[1]表示以s[1](第一个‘b’) 结尾的所有序列。它可能包含了由j=0(‘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]

  1. 它自身可以作为一个序列:所以以s[i]结尾的序列数量至少增加1。
  2. 它可以接在所有结尾字符小于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’(idx=0):new_add = 1 + sum(dp[0:0]) = 1dp[0] = 0+1=1。 (序列:a0)
  • 遍历‘b’(idx=1):new_add = 1 + dp[0] = 1+1=2dp[1] = 0+2=2。 (新增序列:b1,a0b1)
  • 遍历‘a’(idx=0):new_add = 1 + sum(dp[0:0]) = 1注意,此时sum(dp[0:0])是0,因为要求j < idx, 对于‘a’来说,没有比它小的字符。dp[0] = 1+1=2。 (新增序列:a2注意a0b1后面不能接a2,因为‘b’不大于‘a’。)
  • total = dp[0] + dp[1] = 2 + 2 = 4

但我们之前分析“aba”应该有6个?我们来列一下所有本质不同的上升子序列:

  1. a(下标0)
  2. a(下标2)
  3. b(下标1)
  4. ab(下标0,1)
  5. ab(下标0,2)? 不,s[0]=‘a’, s[2]=‘a’, 不是严格递增。
  6. ab(下标2,1)? 不,下标2>1,顺序不对。 实际上,a2无法和前面的b1组成上升序列,因为a2的字符不大于b1。所以正确的序列是:
  7. a0
  8. a2
  9. b1
  10. a0b1没有a2b1,因为下标顺序是2,1,不是递增的。我们的算法只考虑字符值,不考虑下标顺序吗?考虑!因为我们按顺序遍历字符串,当处理a2时,dp[1](以b结尾的序列数)是2,代表b1a0b1。但new_add的计算是1 + sum(dp[j] for j < idx)。对于a2(idx=0),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,或者初始化total=1(代表空序列)。但蓝桥杯原题通常不包含空序列,所以按上述实现即可。

4.2 大数取模

这类计数问题,结果往往非常巨大,很容易超出整数范围。蓝桥杯的题目经常要求将结果对10^9 + 7取模。务必在计算过程中就进行取模,而不是等到最后。因为中间累加的结果可能已经溢出。

在上面的优化代码中,我们在new_add计算和total累加时都进行了取模操作。树状数组内部存储的也应该是取模后的值。需要注意的是,取模运算下,加法和乘法是安全的,但如果有减法,要避免出现负数,通常(a - b) % MOD要写成(a - b + MOD) % MOD

4.3 字符集范围与树状数组大小

使用树状数组优化时,需要确定字符集的范围。如果题目明确是英文字母,可以用52(大小写)或26(仅小写)。如果是更广泛的ASCII(0-127)或扩展ASCII(0-255),就设置相应的大小。如果字符是数字,范围就是0-9。树状数组的大小应等于字符集的最大编码值+1(因为用1-based索引)。设置过小会导致数组越界,设置过大会浪费空间但一般不影响正确性。

4.4 验证与调试技巧

对于DP计数问题,最好的调试方法是用小规模数据手动计算,并与程序输出对比。

  1. 构造微型测试用例

    • “”(空字符串): 结果应为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-1=7)。
  2. 打印中间状态:在基础版本中,可以在每步循环后打印dp数组,观察其变化,看是否符合预期。

  3. 与暴力枚举对比:对于长度很小(n <= 10)的字符串,可以写一个暴力DFS程序,枚举所有子序列,检查是否严格上升,并用集合(Set)存储序列对应的下标元组来去重(体现“本质不同”)。将暴力结果与DP结果对比,这是最可靠的验证方式。

4.5 从“本质不同”到“内容不同”的变体

这道题的核心是“本质不同”。如果问题变成求“内容不同”的上升子序列数(即只关心序列字符串本身,不关心下标),那么状态定义和转移就需要改变。通常需要用到“去重”技巧,例如,当遇到相同字符时,只考虑最后一次出现的位置,或者用集合来维护以每个字符结尾的“内容”集合。这又是另一类经典DP问题(如LeetCode 940. 不同的子序列 II)。千万不要把这两类问题混淆。

5. 复杂度分析与算法选择

最后,我们来分析一下各个版本的复杂度,以便在不同场景下做出选择。

  • 基础版本(双循环):时间复杂度 O(n * C),其中 C 是字符集大小(如26)。在字符集很小且字符串长度适中时(例如 n <= 10^4, C=26),这个版本完全够用,代码简单不易错。
  • 通用字典版本:时间复杂度 O(n * C’),其中 C’ 是当前已出现的不同字符的个数。在最坏情况下(所有字符都不同),C’ 会增长到 min(n, C)。效率可能比数组版本还低,因为字典遍历有开销。不推荐在竞赛中使用,除非字符集非常大且稀疏。
  • 树状数组优化版本:时间复杂度 O(n log M),其中 M 是字符集大小(如256)。这是效率最高的版本,适用于 n 很大(10^5 级别)的情况。虽然代码稍复杂,但这是应对大数据规模的标准做法。空间复杂度 O(M)。

实战建议

  1. 在蓝桥杯等竞赛中,如果字符串长度在 1000 量级,且只有小写字母,用基础双循环版本足矣,代码简单快速。
  2. 如果题目提示结果很大需要取模,或者长度可能达到 10^5, 务必使用树状数组优化版本。
  3. 在编写树状数组时,一定要注意索引是1-based的,字符编码转换时记得+1。这是一个非常高频的失误点。
  4. 始终先想清楚状态定义dp[c]的含义是“以字符c结尾的序列总数”,并且理解new_add是“由于当前位置的字符c的出现而新增的数量”。这个“增量”的思想是理解整个算法的关键。

这道“本质上升序列”题,完美地将LIS问题的思想与计数DP、去重技巧结合在了一起。它考察的不仅仅是对DP公式的记忆,更是对问题本质的洞察力和将抽象定义转化为数学模型的能力。下次再遇到类似“本质不同”的计数问题,不妨先想想,能不能把状态从“以位置结尾”切换到“以某种特征值结尾”,或许就能豁然开朗。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询