尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

动态规划解决本质不同上升子序列计数:从O(n²)到O(n log M)优化

动态规划解决本质不同上升子序列计数:从O(n²)到O(n log M)优化 1. 项目概述一道经典的动态规划计数题最近在整理蓝桥杯的历年真题特别是国赛B组的题目发现“本质上升序列”这道题试题 D的出镜率相当高也经常被拿出来作为动态规划DP和字符串处理的经典例题来讨论。很多刚接触算法竞赛的同学一看到“上升序列”、“不同子序列”这些词就容易发懵感觉概念缠绕在一起理不清头绪。这道题恰恰是一个很好的切入点它不像一些复杂的图论或数论题那样需要深厚的数学背景而是更考验我们对问题本质的抽象能力和对DP状态定义的精准把握。简单来说题目会给你一个字符串比如lanqiao要求你找出这个字符串中所有的“本质不同的上升子序列”的个数。这里有两个关键约束“本质不同”和“上升”。“上升”在字符串的语境下通常指的是子序列中字符的索引是严格递增的这是子序列的天然定义一般无需特别处理。而“本质不同”则意味着即使两个子序列由相同的字符组成只要它们在原字符串中的位置索引不同就被视为不同的序列。这才是题目的核心难点和考点它要求我们计数时必须基于字符在原串中的位置来区分而不能仅仅看字符本身。举个例子字符串aab。如果只考虑由字符组成的子序列a出现了两次但这两个a来自原串中不同位置索引0和索引1因此它们是两个不同的“本质上升序列”。同样ab也有两个一个由索引0的a和索引2的b组成另一个由索引1的a和索引2的b组成。所以总数为 2单个a 2ab 1单个b 5。如果错误地按字符集去重就会得到错误结果。解决这类问题暴力枚举所有子序列显然不可行时间复杂度 O(2^n)。标准的解法是使用动态规划。但具体怎么定义状态怎么转移怎么保证计数不重不漏里面有不少细节和技巧。接下来我就结合自己的解题和教学经验把这道题从思路到代码再到各种变体和坑点彻底拆解清楚。2. 核心思路解析与动态规划状态设计面对“本质不同的上升子序列计数”问题我们首先要摒弃“先找出所有子序列再去重”的暴力想法。动态规划的精髓在于利用已计算的信息高效地递推出新的信息。我们的目标是设计一个DP状态使得在递推过程中天然地满足“本质不同”和“上升”的要求。2.1 为什么是动态规划字符串子序列计数问题尤其是要求“不同”的计数非常适合用DP解决。因为子序列的生成具有明显的阶段性按原串顺序逐个考虑字符并且后一个字符能否接在前面的序列之后只取决于前面序列的最后一个字符或者更广义地说最后一个字符的位置和大小。这满足了DP的“无后效性”条件。2.2 状态定义的艺术最直接的想法是定义dp[i]表示“以第i个字符结尾的本质不同的上升子序列”的个数。这个定义很直观但存在一个重大问题当我们在后续位置j (j i)考虑字符s[j]时如果s[j] s[i]那么s[j]可以接在所有以s[i]结尾的子序列后面形成新的子序列。但是不同的i可能对应相同的字符s[i]。例如aab有两个a。以第一个a结尾的序列有{a}以第二个a结尾的序列也有{a}。如果我们简单地将dp[j]加上所有满足s[i] s[j]的dp[i]那么对于s[j] b它会同时加上dp[0]和dp[1]都是1从而认为可以形成两个ab。这看起来是对的但这里隐藏了重复计数的问题吗让我们深入思考“本质不同”。序列ab有两个分别是(索引0, 索引2)和(索引1, 索引2)。在我们的计算中dp[2]对应字符b 应该最终等于2代表以b结尾的序列有两个ab来自第一个a和ab来自第二个a。但是dp[i]本身表示“以 i 结尾”的序列数这个定义已经隐含了位置信息。所以只要我们在转移时让dp[j]累加所有i j 且 s[i] s[j]的dp[i]那么来自不同i的贡献自然就是不同的序列因为它们结尾的a的位置不同。然而还有一个更棘手的问题单个字符构成的子序列。按照dp[i]的定义它包含了以i结尾的所有序列这自然也包括长度为1的序列即字符s[i]本身。那么dp[i]的初始值应该是什么如果设为1代表序列{s[i]}本身那么在转移时dp[j]累加dp[i]时就会把{s[i]}这个序列后面加上s[j]形成{s[i], s[j]}这是正确的。但是最终的总数如果简单地将所有dp[i]相加会不会重复计算不会因为每个dp[i]计数的是以特定位置i结尾的序列它们彼此互斥。所以状态定义dp[i]是可行的。最终答案就是sum(dp[0], dp[1], ..., dp[n-1])其中n是字符串长度。2.3 状态转移方程的推导基于状态dp[i]以字符串中第i个位置索引从0开始的字符结尾的、本质不同的严格上升子序列的个数。初始化对于每个位置i至少有一个以其自身结尾的、长度为1的子序列。因此dp[i]的初始值为 1。转移方程对于当前位置j我们需要考虑所有在它之前的位置i (0 i j)。如果s[i] s[j]注意题目中的“上升”在字符序列中通常指字典序或数值序对于小写字母就是ASCII码的大小那么所有以s[i]结尾的上升子序列在其末尾添加上s[j]后仍然是一个上升子序列并且由于结尾变成了j这个新序列自然是以j结尾的。因此转移方程为dp[j] 1 sum(dp[i])其中i满足0 i j且s[i] s[j]。这里的1代表长度为1的子序列即s[j]本身。sum(dp[i])代表了所有能以s[j]接在后面形成更长序列的情况。关键点为什么这样能保证“本质不同”因为dp[i]中的每个序列都由其具体的字符索引路径唯一确定。当s[j]接在某个以i结尾的特定序列后面时产生的新序列的路径是原路径加上j这个路径是唯一的。即使两个不同的i1和i2对应的字符相同s[i1] s[i2]但由于i1 ! i2它们所代表的“以该位置结尾的序列集合”是不同的因此贡献给dp[j]的序列也是不同的。2.4 复杂度分析与初步实现根据上述方程我们需要对每个j遍历所有i j。这是一个典型的双重循环结构。时间复杂度O(n²)其中 n 是字符串长度。对于蓝桥杯的题目字符串长度一般控制在几百到几千O(n²) 通常是可接受的。空间复杂度O(n)用于存储dp数组。一个最基础的C实现框架如下#include iostream #include string #include vector using namespace std; int countDistinctIncreasingSubseq(string s) { int n s.length(); vectorlong long dp(n, 0); // 使用long long防止大数溢出 long long ans 0; for (int j 0; j n; j) { dp[j] 1; // 初始化自身作为一个序列 for (int i 0; i j; i) { if (s[i] s[j]) { dp[j] dp[i]; } } ans dp[j]; } return ans; // 注意ans可能很大题目可能要求取模 }这就是最核心的解法。但是这道题的魅力和坑点远不止于此。上面的解法是基础但在实际竞赛中可能会遇到各种变体和需要优化的地方。3. 细节深化、优化与变体分析掌握了基础DP解法我们才算刚刚入门。在实际应用中尤其是面对蓝桥杯这种对效率和正确性要求极高的竞赛我们需要考虑更多。3.1 处理大数与取模问题蓝桥杯的题目往往不满足于小规模数据。当字符串长度达到几千且字符分布较均匀时本质上升序列的数量会呈指数级增长很容易超出int甚至long long的范围。因此题目经常会要求对结果取模例如1e9 7。注意取模运算必须在加法和乘法过程中随时进行防止中间结果溢出。同时要特别注意负数取模的问题在C中%运算符对负数取模的结果是负数需要调整。修改后的代码需加入取模const int MOD 1e9 7; int countDistinctIncreasingSubseq(string s) { int n s.length(); vectorint dp(n, 0); // 改用int因为会取模 int ans 0; for (int j 0; j n; j) { dp[j] 1; // 自身序列 for (int i 0; i j; i) { if (s[i] s[j]) { dp[j] (dp[j] dp[i]) % MOD; } } ans (ans dp[j]) % MOD; } return ans; }3.2 当“上升”定义变化时我们之前的讨论基于s[i] s[j]这是最常见的字典序ASCII码严格上升。但如果题目变体呢非严格上升不下降即允许s[i] s[j]。这时问题会变得更复杂因为要处理相等字符带来的重复计数。自定义顺序例如规定a z b y这种非常规顺序。这时只需将比较条件s[i] s[j]替换为一个自定义的比较函数即可。重点讨论非严格上升如果允许相等那么当s[i] s[j]时以i结尾的序列后面加上s[j]会形成一个新的以j结尾的序列。但是这里必须非常小心地处理重复例如字符串aa。按照朴素想法dp[0] 1(序列a0)计算dp[1]i0, s[0]a s[1]所以dp[1] 1 dp[0] 2。这表示以第二个a结尾的序列有{a1}和{a0, a1}。总答案 dp[0] dp[1] 3。但实际上序列有哪些{a0},{a1},{a0, a1}。这看起来是对的。但再看aba按上述逻辑计算最终会包含{a0, a2}和{a1, a2}吗注意s[2]是as[0]和s[1]都是a且s[0] s[2]?不是等于。所以按照s[i] s[j]它们应该被计入。但{a0, a2}和{a1, a2}是本质不同的吗是的因为中间的字符索引不同。所以算法似乎仍然有效这里有一个巨大的陷阱。考虑aaadp[0] 1dp[1] 1 dp[0] 2(序列a1,a0, a1)dp[2] 1 dp[0] dp[1] 1124(序列a2,a0, a2,a1, a2,a0, a1, a2)总和 1247。但我们手动枚举所有非严格上升子序列a0a1a2a0, a1a0, a2a1, a2a0, a1, a2正好7个。看起来没错。但是如果我们改变计算顺序或者深究DP的定义会发现这个朴素加法在更复杂的情况下会导致重复计算。问题出在哪里出在当s[i] s[j]时dp[j]直接加上了dp[i]。这意味着所有以i结尾的序列都复制了一份到j的名下并把结尾改为j。这在i和j之间没有其他相等字符时是可行的。但如果有多个相等的字符就会重复。更严谨的做法是对于非严格上升我们需要保证对于相同的字符只在最后一次出现时计算所有以其结尾的序列。否则同一个序列会因为可以通过不同位置的相同字符作为“最后一步”而产生多次贡献。正确的状态定义需要改变dp[i]表示以第 i 个位置结尾的本质不同的非下降子序列个数但在转移时对于字符ch我们只应该从上一个字符ch出现的位置转移过来而不是所有更早的位置。这通常需要维护一个last[ch]数组记录字符ch上一次出现时的DP值总和。当遇到新的s[j]时dp[j] 1 sum(dp[i] for all i j where s[i] s[j])但需要减去之前相同字符已经计算过的部分。这变得非常复杂。实操心得在竞赛中如果遇到“非严格上升”的计数一定要先用手动枚举小例子如”aa“”aab“”aba“”aaa“验证自己的DP方程是否正确。通常这类问题会转化为对每个字符维护一个累积和并利用容斥原理来避免重复。一个常见的技巧是定义dp[j]为以s[j]结尾的序列数同时维护一个sum[ch]表示当前以字符ch结尾的所有序列总数。当处理到s[j]时dp[j] 1 sum(sum[ch])其中ch遍历所有小于等于s[j]的字符。然后更新sum[s[j]] dp[j]注意这里是赋值不是累加因为新的dp[j]已经包含了所有以小于等于s[j]的字符结尾的序列后面接上s[j]的情况而旧的sum[s[j]]对应的那些序列的结尾位置更早它们已经包含在新的dp[j]的生成路径中了如果累加就会重复。最后答案就是所有sum[ch]的总和。这种方法可以将复杂度优化到 O(n * 字符集大小)。3.3 算法优化从 O(n²) 到 O(n log n) 或 O(n * 26)对于基础DP的 O(n²) 算法当 n 达到 10^5 时就不行了。我们需要优化内层循环——即快速求出“所有在j之前且字符小于s[j]的dp[i]之和”。这本质上是一个动态前缀和问题。字符集通常是有限的如小写字母26个。我们可以维护一个数组prefixSum[26]其中prefixSum[k]表示当前所有字符小于等于char(ak)的、且位置在j之前的dp[i]值的总和。那么对于当前位置j的字符c我们需要所有字符严格小于c的dp值之和即prefixSum[c - a - 1]如果c是a则为0。然后dp[j] 1 这个和。更新prefixSum数组对于所有字符ch cprefixSum[ch]都需要加上dp[j]因为现在dp[j]代表了以c结尾的新序列这些序列对于未来字符ch c来说都是可以接在后面的“前缀”。但是注意我们更新的是prefixSum[ch]字符维度而不是位置维度。prefixSum[k]的定义是“所有字符 k 的、已处理过的位置的dp值之和”。当我们计算出dp[j]后字符c对应的prefixSum[c_idx]应该增加dp[j]。同时为了后续字符ch c在计算时能包含dp[j]所有ch c对应的prefixSum也需要增加dp[j]。这相当于对prefixSum数组从索引c_idx到末尾进行一次区间加法。我们可以用树状数组Fenwick Tree或线段树Segment Tree来高效维护这个字符维度上的前缀和以及区间更新、单点查询或者单点更新、前缀查询取决于定义方式。这样每次计算dp[j]和更新prefixSum的复杂度可以降到 O(log M)其中 M 是字符集大小如26。整体复杂度优化为 O(n log M)对于 M26几乎是 O(n)。以下是使用树状数组维护“单点更新、前缀查询”模式的C优化代码#include iostream #include string #include vector #include cstring using namespace std; const int MOD 1e9 7; const int CHAR_SET 26; // 小写字母 class Fenwick { private: vectorint tree; int n; public: Fenwick(int size) : n(size), tree(size 1, 0) {} void update(int idx, int delta) { // idx: 字符索引 (0-25) idx; // 树状数组通常从1开始 while (idx n) { tree[idx] (tree[idx] delta) % MOD; idx idx -idx; } } int query(int idx) { // 查询前缀和 [0, idx] idx; int sum 0; while (idx 0) { sum (sum tree[idx]) % MOD; idx - idx -idx; } return sum; } }; int countDistinctIncreasingSubseqFast(string s) { int n s.length(); Fenwick bit(CHAR_SET); int ans 0; for (int j 0; j n; j) { int ch_idx s[j] - a; // 查询所有严格小于当前字符的dp值之和 int sum_less (ch_idx 0) ? 0 : bit.query(ch_idx - 1); // 以s[j]结尾的序列数 1自身 sum_less int dp_j (1 sum_less) % MOD; ans (ans dp_j) % MOD; // 更新树状数组当前字符ch_idx对应的dp值增加了dp_j // 注意这里不是直接赋值而是累加。因为bit.query(ch)返回的是所有字符ch的dp和。 // 当我们把dp_j加到bit中ch_idx的位置上后续查询大于ch_idx的字符时自然也能包含它。 // 但为了严格符合“小于”查询我们只需要更新当前节点。因为后续字符查询的是前缀和。 // 实际上对于未来字符cc它查询的是bit.query(c_idx -1)这个和已经包含了我们刚刚更新的dp_j。 // 所以只需要单点更新ch_idx即可。 bit.update(ch_idx, dp_j); } return ans; }这段代码是优化后的核心。bit.query(ch_idx - 1)高效地得到了我们需要的“小于当前字符的dp和”。bit.update(ch_idx, dp_j)将当前字符新产生的序列数累加到对应的“桶”中供后面的字符使用。4. 完整解题流程与代码实现现在我们整合前面的分析给出针对蓝桥杯风格题目的完整、健壮的解决方案。我们假设题目是标准形式给定一个由小写字母组成的字符串求本质不同的严格上升子序列个数结果对1e97取模。4.1 基础解法O(n²)适用于 n 5000这是最直观、最不易出错的写法适合在比赛初期快速实现并验证思路。#include bits/stdc.h using namespace std; const int MOD 1e9 7; int main() { string s; cin s; // 假设输入字符串 int n s.size(); vectorlong long dp(n, 0); long long ans 0; for (int i 0; i n; i) { dp[i] 1; // 字符本身作为一个序列 for (int j 0; j i; j) { if (s[j] s[i]) { dp[i] (dp[i] dp[j]) % MOD; } } ans (ans dp[i]) % MOD; } cout ans endl; return 0; }4.2 优化解法O(n log 26)适用于 n 10^5使用树状数组进行优化这是应对大数据量的标准做法。#include bits/stdc.h using namespace std; const int MOD 1e9 7; const int CHAR_NUM 26; struct Fenwick { vectorint tree; int n; Fenwick(int size) : n(size), tree(size 1, 0) {} void add(int pos, int val) { pos; // 转为1-indexed while (pos n) { tree[pos] (tree[pos] val) % MOD; pos pos -pos; } } int sum(int pos) { if (pos 0) return 0; // 重要当查询字符a之前时返回0 pos; int res 0; while (pos 0) { res (res tree[pos]) % MOD; pos - pos -pos; } return res; } }; int main() { string s; cin s; Fenwick bit(CHAR_NUM); int ans 0; for (char c : s) { int idx c - a; // 查询所有严格小于当前字符的dp值之和 int pre_sum bit.sum(idx - 1); // 当前字符结尾的序列总数 1(自身) pre_sum int current (1 pre_sum) % MOD; ans (ans current) % MOD; // 将当前值加入树状数组供后续字符使用 bit.add(idx, current); } cout ans endl; return 0; }4.3 测试与验证编写代码后必须用多种案例测试边界测试空字符串应输出0但题目一般不会给空串。单字符字符串如a应输出1。所有字符相同如zzzz严格上升序列只有每个字符自身所以答案是字符串长度 n。功能测试ab序列有a,b,ab答案为3。aab如前所述序列有a0,a1,b,ab(0,2),ab(1,2)答案为5。abc所有可能子序列除空序列外都是严格上升的。长度为1的3个长度为2的3个ab,ac,bc长度为3的1个abc共7个。也可以用公式 2^n - 1 验证n3, 2^3-17。性能测试生成一个长字符串如10000个随机小写字母用优化版代码运行应能在短时间内得出结果。5. 常见陷阱、疑难解答与扩展思考即使理解了算法实现时也可能踩坑。下面是一些常见问题和进阶思考。5.1 为什么初始化dp[i] 1这代表每个字符本身构成一个长度为1的子序列。这是所有上升子序列的“起点”。在状态转移中当s[j]接在某个序列后时我们是在延长已有的序列。如果没有这个初始的“1”我们就无法生成那些以j开头实际上是作为序列唯一元素的子序列。5.2 “本质不同”到底是如何通过DP保证的这是最核心的理解点。DP状态dp[i]的物理意义是“以第 i 个字符结尾的所有本质不同上升子序列的集合的大小”。这个定义的关键在于“以第 i 个字符结尾”。任何两个不同的序列只要它们最后一个字符的索引不同就一定属于不同的dp[i]。如果它们最后一个字符索引相同但序列本身不同那么它们都是同一个dp[i]所计数的不同对象。在转移时dp[j] dp[i]意味着我们把dp[i]集合里的每一个序列都复制一份并在末尾追加s[j]然后将这些新序列全部放入dp[j]集合。由于dp[i]集合里的序列彼此不同复制追加后得到的新序列也必然彼此不同。同时对于不同的i即使s[i]相同它们对应的序列集合也是不同的因为结尾索引不同所以贡献给dp[j]的序列也不会重复。这就保证了从源头dp[i]集合到终点dp[j]集合的映射是一对一的没有重复。5.3 如果字符串包含大写字母、数字或更大字符集怎么办我们的优化算法依赖于字符集大小 M。对于小写字母M26对于大写字母也是26对于数字0-9M10。如果字符集扩大到所有ASCII可见字符约100个O(n log M)依然高效。如果字符集非常大比如整个Unicode树状数组的大小和效率就成了问题。此时有几种思路离散化坐标压缩先将字符串中所有出现的字符去重排序映射到从0开始的连续整数。这样字符集大小 M 就等于字符串中不同字符的个数最坏情况是 n但通常远小于完整的Unicode集。然后再用树状数组。使用平衡树或数组代替树状数组如果离散化后M仍然很大接近n那么 O(n log M) 约等于 O(n log n)也是可以接受的。可以直接用std::map或std::set来维护前缀和但常数较大。回到O(n²)DP如果 n 本身不大几千以内直接用基础DP更省事。5.4 如何输出具体的序列而不仅仅是计数这是一个经典的扩展问题。DP只能计数要输出所有序列必须结合回溯。我们可以修改dp数组让它存储一个“序列列表”的引用但这会消耗巨大内存序列数量是指数级的。通常题目不会要求输出所有可能只要求输出第K大的序列等。这需要结合DP计数和字典序搜索类似第K小子序列问题复杂度会更高。5.5 内存与溢出问题取模如前所述随时取模。数据类型即使取模在累加过程中中间变量也可能超出int范围例如两个int相加后再取模相加时可能溢出。因此在C中可以使用long long类型进行中间计算或者确保加法和乘法后立即取模。在dp[i]和ans的累加时使用(a b) % MOD的写法是安全的因为a和b都是已经取过模的数它们的和小于2*MOD不会溢出int如果MOD是1e972*MOD约等于2e914仍在int范围内约21亿。但为了保险比赛时常用long long。负数取模在C中(-1) % MOD结果是-1。如果需要得到非负余数可以(a % MOD MOD) % MOD。但在我们的算法中所有运算都是加法不会产生负数。5.6 一个综合性的调试案例假设字符串是cba。按照严格上升定义没有任何一个字符对满足s[i] s[j](ij)。所以dp[0] 1(c)dp[1] 1(b)因为s[0](c) s[1](b)不满足s[i] s[j]所以不加dp[0]。dp[2] 1(a)同理s[0]和s[1]都大于a。总答案 3。正确因为只有三个单字符子序列。通过这个小例子可以验证转移条件s[i] s[j]是否正确应用。
返回列表