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

资讯详情

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

动态规划实战:从编辑距离到字符串最优包含问题解析

动态规划实战:从编辑距离到字符串最优包含问题解析 1. 项目概述从一道国赛题看动态规划的实战与优化最近在复盘蓝桥杯的历年真题特别是国赛级别的题目总能发现一些设计精巧、值得深挖的“硬骨头”。2019年第十届软件类国赛C/CB组的这道“最优包含”问题就是其中之一。它初看像是一道经典的字符串编辑距离问题但题目给出的约束和求解目标又让它有了独特的解题路径和优化空间。很多朋友在初次接触时可能会直接套用经典的编辑距离DP动态规划模板结果要么超时要么答案不对最后卡在细节里出不来。这道题的核心是要求我们计算将一个字符串A“包含”进另一个字符串B所需的最小修改次数。这里的“包含”不是简单的子串匹配而是允许我们通过修改B中的字符每次修改可以将任意字符变为任意字符使得A成为B的一个子序列。换句话说我们可以在B中“跳过”一些字符子序列的特性但无法删除或插入字符只能修改。这就在经典的序列比对模型上增加了操作类型的限制也引出了对状态定义和转移方程的重新思考。我花了些时间把这道题的几种解法都捋了一遍从最直观的二维DP到基于问题特性的空间优化再到利用数据特点的进一步剪枝。这个过程本身就是一次对动态规划思想从理解到灵活应用的绝佳训练。它不仅考察你对状态转移方程的构建能力更考验你能否在庞大的状态空间中找到最经济、最高效的遍历方式。下面我就把自己解题时的完整思路、代码实现以及几个关键的优化技巧分享出来希望能帮你彻底吃透这类问题。2. 问题解析与核心思路拆解2.1 题意重述与关键点捕捉我们先抛开代码把题目用更直白的话翻译一遍。题目给了我们两个字符串记作A和B。我们的目标是通过修改B中的字符让A成为B的一个子序列。每次操作我们可以选择B中的一个位置把该位置的字符改成任意我们想要的字符。我们需要找到最少的修改次数。这里有几个至关重要的约束条件直接决定了我们的算法设计只能修改不能增删我们不能在B中插入新的字符也不能删除B中已有的字符。这意味着B的长度是固定的我们只能利用B中现有的字符序列通过改变它们的值来“匹配”A。匹配目标是子序列而非子串A需要是B修改后的子序列。这意味着在匹配时我们不需要连续的字符对应只要在B中能找到一组下标递增的字符它们的值修改后与A完全相同即可。这给了我们“跳过”B中某些字符的灵活性。操作代价统一无论把字符改成什么每次修改的代价都是1。这简化了我们的状态转移代价计算。理解了这些我们就能把问题抽象成一个模型在序列B上以最小的修改代价选出一个子序列使其与序列A完全相同。这本质上是一个序列对齐问题但操作集只有“替换”修改和“跳过”利用子序列特性。2.2 从编辑距离到定制化DP最自然的联想是Levenshtein距离编辑距离问题。经典的编辑距离DP定义dp[i][j]为将A的前i个字符转换成B的前j个字符的最小操作数操作包括增、删、改。我们的问题与之相似但有重大区别我们不能删除B中的字符因为B是固定的我们只能修改它。在编辑距离中“删除B的字符”对应于跳过该字符但在我们的问题中“跳过”是不消耗操作次数的它是子序列匹配的天然权利。我们不能在B中插入字符对应编辑距离中的“插入”操作。我们只能修改现有字符。因此我们的操作实际上只有两种1) 匹配可能伴随修改2) 跳过B的当前字符。这引导我们定义更适合本题的状态dp[i][j]表示考虑A的前i个字符和B的前j个字符让A的前i个字符成为B的前j个字符的子序列所需的最小修改次数。注意这里B的前j个字符是必须全部被考虑在内的尽管其中一些可能被跳过而A的前i个字符是需要被全部匹配的。状态转移方程的推导是这个环节的核心。我们站在状态dp[i][j]考虑如何从之前的状态转移过来焦点在于如何处置A[i]和B[j]这里假设字符串下标从1开始方便叙述。情况一我们让B[j]来匹配A[i]。如果A[i] B[j]那么不需要修改代价为0。我们只需要用B[j]匹配掉A[i]那么问题就转化为用B的前j-1个字符去匹配A的前i-1个字符即从dp[i-1][j-1]转移过来总代价为dp[i-1][j-1] 0。如果A[i] ! B[j]那么我们需要一次修改操作将B[j]改成A[i]代价为1。同样地问题转化为dp[i-1][j-1]总代价为dp[i-1][j-1] 1。综合一下这种情况的转移代价可以统一为dp[i-1][j-1] (A[i] ! B[j])。这里(A[i] ! B[j])是一个布尔表达式在C/C中值为1真或0假非常简洁。情况二我们跳过B[j]不用它来匹配当前的A[i]。这意味着B[j]这个字符被我们“放弃”了它不参与对A当前前缀的匹配。那么让A的前i个字符成为B的前j个字符的子序列等价于让A的前i个字符成为B的前j-1个字符的子序列。即从dp[i][j-1]转移过来代价为0跳过不消耗修改次数。因此dp[i][j]的值就是上述两种情况中的最小值dp[i][j] min(dp[i-1][j-1] (A[i] ! B[j]), dp[i][j-1])边界条件的初始化是DP正确性的基石dp[0][j]表示用A的前0个字符空串去匹配B的前j个字符。空串是任何字符串的子序列且不需要任何修改所以dp[0][j] 0对所有j成立。dp[i][0] (i0)表示用A的前i个字符非空去匹配B的前0个字符空串。这是不可能的因为无法从空串中找到非空子序列。我们可以将其初始化为一个无穷大值如INF表示不可达。最终我们要求的答案就是dp[lenA][lenB]其中lenA和lenB分别是字符串A和B的长度。注意这个状态定义和转移方程是理解本题的基础。一定要在纸上画一个二维表格手动推导一下小例子比如A”ab”, B”acb”的dp值感受“匹配”和“跳过”这两个操作是如何在状态转移中体现的。这是将抽象方程具象化的关键一步。3. 基础DP实现与复杂度分析3.1 朴素的二维DP代码实现基于上面的分析我们可以直接写出最基础的二维DP解法。为了处理边界我们通常将字符串下标设为从1开始这样dp[0][*]和dp[*][0]就可以作为清晰的边界。在C/C中我们可以通过定义dp[maxn][maxn]的数组并从dp[1][1]开始计算来实现。#include iostream #include cstring #include algorithm using namespace std; const int maxn 1005; // 根据题目数据范围设定国赛此题lenA, lenB通常在1000量级 const int INF 0x3f3f3f3f; // 用一个较大的数代表“无穷大” char A[maxn], B[maxn]; int dp[maxn][maxn]; int main() { scanf(%s%s, A 1, B 1); // 从下标1开始读入字符串 int lenA strlen(A 1); int lenB strlen(B 1); // 初始化边界 for (int i 1; i lenA; i) dp[i][0] INF; // A非空B为空不可能 for (int j 0; j lenB; j) dp[0][j] 0; // A为空总是可以代价0 // 状态转移 for (int i 1; i lenA; i) { for (int j 1; j lenB; j) { // 情况1用B[j]匹配A[i]代价取决于是否相等 int cost (A[i] ! B[j]) ? 1 : 0; int match dp[i-1][j-1] cost; // 情况2跳过B[j] int skip dp[i][j-1]; // 取最小值 dp[i][j] min(match, skip); } } printf(%d\n, dp[lenA][lenB]); return 0; }这段代码清晰易懂完全对应了我们推导出的状态转移方程。对于小规模数据比如长度几百它完全可以胜任。3.2 复杂度瓶颈与优化必要性现在我们来审视一下它的复杂度。状态数是O(lenA * lenB)每个状态的计算是O(1)所以总的时间复杂度是O(n*m)其中n和m是字符串长度。空间复杂度也是O(n*m)。在蓝桥杯国赛的语境下字符串长度上限通常是1000甚至更多。1000 * 1000 1e6个状态每个状态是int类型4字节那么dp数组就会占用大约4MB的内存。这对于比赛环境通常内存限制256MB或512MB来说是完全可接受的。但是这里存在一个潜在的陷阱。国赛的题目往往不止一道程序运行时间也有限制通常是1秒或2秒。O(1e6)的常数操作在1秒内完成是绰绰有余的。然而如果我们止步于此就错过了本题更深刻的考察点——空间优化和遍历顺序优化。在真正的竞赛中养成对任何O(n^2)空间复杂度保持警惕的习惯是很有必要的因为下一道题的数据范围可能就会卡你的空间。此外理解优化过程本身就是对动态规划与问题结构理解深度的最好检验。更重要的是本题的状态转移方程dp[i][j] min(dp[i-1][j-1] cost, dp[i][j-1])有一个非常明显的特征dp[i][j]只依赖于上一行 (i-1) 的左上方 (j-1)和同一行 (i) 的左边 (j-1)。它不依赖于上一行的正上方 (dp[i-1][j])。这个依赖关系是进行空间优化的关键。4. 核心优化策略滚动数组与遍历技巧4.1 滚动数组压缩空间由于dp[i][j]只依赖于dp[i-1][j-1]和dp[i][j-1]我们完全不需要保存整个二维表格。在计算第i行时我们只需要第i-1行的数据具体是dp[i-1][j-1]。第i行已经计算出来的、左边的数据dp[i][j-1]。因此我们可以只用两个一维数组一个代表“上一行”prev一个代表“当前行”curr。在计算完当前行后把curr赋值给prev用于下一行的计算。这样空间复杂度就从O(n*m)降到了O(m)。#include iostream #include cstring #include algorithm using namespace std; const int maxn 1005; const int INF 0x3f3f3f3f; char A[maxn], B[maxn]; int prev_dp[maxn], curr_dp[maxn]; // prev代表上一行(i-1)curr代表当前行(i) int main() { scanf(%s%s, A 1, B 1); int lenA strlen(A 1); int lenB strlen(B 1); // 初始化第0行即A为空串 for (int j 0; j lenB; j) prev_dp[j] 0; // dp[0][j] 0 // 计算第1行到第lenA行 for (int i 1; i lenA; i) { // 当前行的边界dp[i][0] INF (i0) curr_dp[0] INF; for (int j 1; j lenB; j) { int cost (A[i] ! B[j]) ? 1 : 0; // dp[i-1][j-1] 就是 prev_dp[j-1] int match prev_dp[j-1] cost; // dp[i][j-1] 就是 curr_dp[j-1] (因为正在计算当前行j-1已经算好了) int skip curr_dp[j-1]; curr_dp[j] min(match, skip); } // 当前行计算完毕将其作为下一轮的“上一行” swap(prev_dp, curr_dp); // 或者用 memcpy, 但swap指针/交换内容更高效 } // 循环结束后最终结果存储在 prev_dp[lenB] 中 // 因为最后一次swap后prev_dp 指向了最后计算的一行 printf(%d\n, prev_dp[lenB]); return 0; }这个版本已经将空间优化到了O(lenB)。但仔细观察内层循环我们发现curr_dp[j]的计算只依赖于prev_dp[j-1]和curr_dp[j-1]。如果我们只使用一个一维数组并从前往后遍历j会发生什么假设我们只有一个数组dp[j]在计算第i行时当我们计算dp[j]新的代表dp[i][j]时dp[j-1]已经被更新为新的dp[i][j-1]这正好是我们需要的skip项。而dp[j-1]旧的在本次计算前代表的是dp[i-1][j-1]吗不它已经被覆盖成了dp[i][j-1]。我们需要的是旧的dp[j-1]即dp[i-1][j-1]。所以直接一个数组、正序遍历行不通因为dp[i-1][j-1]会被提前覆盖。解决方法是逆序遍历j。逆序遍历时dp[j]依赖的dp[i][j-1]即skip是本次循环尚未计算的值因为j-1比j小在逆序中还未被更新它存储的仍然是上一轮第i行计算出的dp[i][j-1]这不对。等等让我们重新严谨推导一下依赖关系。实际上dp[i][j]依赖于dp[i-1][j-1]和dp[i][j-1]。 如果我们用一维数组f[j]表示当前行i计算到某个位置时的状态我们希望在计算新的f[j]即dp[i][j]时f[j-1]应该已经更新为dp[i][j-1]skip项。同时我们需要知道旧的f[j-1]即dp[i-1][j-1]match项。矛盾在于我们需要同一个位置j-1的两个不同时刻的值。逆序遍历可以解决一部分问题当我们逆序从lenB计算到1时f[j]依赖的f[j-1]对于skip是上一轮i-1行的值因为j-1比j小在逆序中还未被本轮更新。而dp[i-1][j-1]这个值在上一轮计算完成后就存储在f[j-1]里并且在本轮逆序计算中当我们计算f[j]时f[j-1]还没有被覆盖所以它正好就是我们要的dp[i-1][j-1]让我们验证一下对于dp[i][j] min(dp[i-1][j-1] cost, dp[i][j-1])。 用一维数组f[...]在计算第i行时逆序计算j从lenB到1。当计算f[j]时f[j-1]存储的仍然是上一轮i-1行计算出的dp[i-1][j-1]因为j-1 j在逆序中尚未被本轮更新。完美这就是match项需要的值。那么skip项dp[i][j-1]呢它应该是本轮计算出的新值。但在逆序中dp[i][j-1]对应的f[j-1]还没有被计算因为j-1 j。所以逆序遍历无法直接提供dp[i][j-1]。看来我之前的分析有误。逆序遍历能保留dp[i-1][j-1]但无法提供dp[i][j-1]。实际上我们推导的依赖关系决定了无法通过单一的一维数组和简单的顺序或逆序遍历来同时满足两个依赖。因为dp[i][j-1]是同一行的左侧元素如果正序计算它会被提前更新如果逆序计算它又还没被计算。所以对于本题的转移方程dp[i][j] min(dp[i-1][j-1] cost, dp[i][j-1])最简洁的优化就是使用两个一维数组滚动如上面代码所示。这是最清晰且不易出错的方式。实操心得在竞赛中不要过分追求极致的单数组优化。双数组滚动 (prev,curr) 的空间复杂度已经是O(m)在绝大多数情况下足够优秀且代码逻辑清晰不易出错。清晰正确的代码远比晦涩难懂的“技巧”更重要。将优化重点放在算法本身而非微小的常数上是更明智的策略。4.2 基于问题特性的剪枝优化虽然时间复杂度O(n*m)对于n, m 1000是安全的但我们还可以思考一下是否有提前结束计算的可能观察状态转移方程dp[i][j] min(dp[i-1][j-1] cost, dp[i][j-1])。其中skip操作 (dp[i][j-1]) 意味着我们可以免费地跳过B的字符。那么dp[i][j]的值一定是非递增的吗沿着j增加的方向即考虑B更长的前缀dp[i][j]有可能变小吗因为可以跳过的字符更多了理论上匹配成功的可能性更大代价可能更小或不变。实际上dp[i][j]是dp[i][j-1]和另一个值取min所以dp[i][j] dp[i][j-1]。即对于固定的idp[i][j]随着j增大而单调不增。这个性质有什么用呢如果我们只关心最终结果dp[lenA][lenB]并且我们在计算过程中发现对于某个idp[i][j]已经等于0或者一个很小的值比如我们已经找到了一个完美匹配前缀那么对于更大的jdp[i][j]也不会大于这个值。但这对整体复杂度优化有限。一个更有效的剪枝思路是B的长度必须至少等于A的长度否则无论如何修改也无法让A成为B的子序列因为子序列长度不能超过原序列。但这在输入时即可判断。另一种思路是如果题目对内存极其苛刻虽然本题不典型我们可以注意到dp[i][j]只依赖于dp[i-1][j-1]这意味着它的依赖是一条斜线。我们可以尝试用i-j作为另一个维度来定义状态但这样处理边界和遍历会更复杂代码可读性会下降在竞赛中性价比不高。对于本题双数组滚动是最优解。5. 代码实现细节与调试技巧5.1 完整AC代码与逐行解析结合滚动数组优化这里给出一个风格良好、边界处理清晰的AC代码。#include bits/stdc.h // 竞赛常用头文件包含大部分标准库 using namespace std; const int MAXN 1005; const int INF 0x3f3f3f3f; // 常用无穷大表示两个0x3f3f3f3f相加不会溢出int char a[MAXN], b[MAXN]; int dp_prev[MAXN]; // 上一行 int dp_curr[MAXN]; // 当前行 int main() { // 读入字符串从下标1开始存储方便DP初始化 scanf(%s%s, a 1, b 1); int n strlen(a 1); // 字符串A的长度 int m strlen(b 1); // 字符串B的长度 // 初始化对应 dp[0][j] 0 for (int j 0; j m; j) { dp_prev[j] 0; } // 主DP循环 for (int i 1; i n; i) { // 当前行边界dp[i][0] INF (i 0) dp_curr[0] INF; for (int j 1; j m; j) { // 情况1匹配代价为 a[i] ! b[j] int cost (a[i] b[j]) ? 0 : 1; int match dp_prev[j - 1] cost; // 情况2跳过b[j] int skip dp_curr[j - 1]; // 状态转移 dp_curr[j] min(match, skip); } // 滚动当前行计算完毕成为下一轮的“上一行” // 使用swap交换指针或整个数组比memcpy更高效直观 swap(dp_prev, dp_curr); } // 循环结束后答案存储在 dp_prev[m] 中 printf(%d\n, dp_prev[m]); return 0; }关键点解析下标处理a1, b1使得字符串从索引1开始dp[0][j]和dp[i][0]可以自然地表示边界情况。INF的设置0x3f3f3f3f是一个约等于10^9的数满足“足够大”且两个相加不溢出int的要求是竞赛中的常用技巧。滚动更新swap(dp_prev, dp_curr)在每行计算结束后交换两个数组的角色。这样在下一轮dp_prev自然就指向了刚刚计算完的当前行数据准备作为新的“上一行”。答案获取最后一次swap后dp_prev指向了最后计算的一行即第n行所以dp_prev[m]就是dp[n][m]。5.2 常见错误与调试案例即使思路正确实现时也容易踩坑。下面列举几个常见的错误点错误1边界初始化不全// 错误示例只初始化了dp[0][j]忘记了dp[i][0] for (int j 0; j m; j) dp[0][j] 0; // 缺失了 for (int i 1; i n; i) dp[i][0] INF;这会导致dp[1][1]在计算match dp[0][0] cost时dp[0][0]是0因为全局数组默认初始化为0从而可能得到一个很小的错误值而实际上dp[1][0]应该是INF表示不可达。错误会像滚雪球一样传递下去。错误2状态转移方程写错混淆“匹配”和“跳过”对应的状态。// 错误示例错误理解了skip操作 dp[i][j] min(dp[i-1][j-1] cost, dp[i-1][j]); // 错dp[i-1][j]是跳过A[i]不符合题意题目要求是跳过B[j]而不是跳过A[i]。dp[i-1][j]的含义是A的前i-1个字符匹配B的前j个字符它消耗了一次“跳过A[i]”的操作这在本问题中是不允许的我们必须匹配A的所有字符所以这个转移是错误的。错误3滚动数组更新逻辑错误在单数组逆序尝试失败后如果坚持用双数组但更新顺序错了// 错误示例在内层循环中错误地更新了dp_prev for (int j 1; j m; j) { int cost (a[i] ! b[j]); int match dp_prev[j-1] cost; int skip dp_curr[j-1]; dp_curr[j] min(match, skip); // 错误地提前将dp_curr[j]赋值给dp_prev[j]破坏了上一行的数据 dp_prev[j] dp_curr[j]; }这样在计算同一行后面的j时dp_prev[j-1]可能已经被错误地覆盖为当前行的值导致计算结果混乱。调试建议小数据测试永远用最小的、能体现问题的例子手动验证。例如 A”a”, B”b”。答案应为1修改b为a。手动模拟你的DP表看输出是否符合预期。打印DP表在提交前对于二维DP版本可以临时打印出整个dp数组对于小数据与手动计算的结果对比。这是发现边界错误和转移错误最直接的方法。理解每个状态的含义在调试时问自己dp[i][j]当前的值代表什么它是怎么从之前的状态来的这个值合理吗强迫自己解释清楚往往就能发现逻辑漏洞。6. 问题变种与思维延伸搞懂了“最优包含”的基础DP和优化我们可以看看它的一些变种这能帮助我们更好地把握这类序列比对问题的核心。变种1操作带权重如果题目修改为将字符x修改为字符y的代价是一个函数w(x, y)而不仅仅是1。那么我们的转移方程中cost就不再是简单的0或1而是w(A[i], B[j])。DP框架完全不变只是代价计算更复杂。这更接近真实的编辑距离问题。变种2允许增删操作如果允许在B中插入或删除字符每次操作代价也是1。那么问题就变成了经典的编辑距离问题但目标是将A编辑为B的子序列。此时状态定义可能需要调整或者增加一个维度来表示“跳过”操作。这比原题更复杂。变种3求具体方案不仅要求最小修改次数还要求输出一种具体的修改方案修改了B的哪些位置改成了什么。这需要在DP的基础上进行回溯。我们额外维护一个path[i][j]数组记录每个状态dp[i][j]是从哪个决策匹配还是跳过转移过来的。然后从最终状态dp[n][m]倒推回去就能重建出修改序列。这练习了DP记录路径的通用技巧。思维延伸为什么这道题值得深究因为它巧妙地修改了经典模型编辑距离的约束条件只能修改不能增删创造了一个新的、有意义的状态转移方程。它考察了选手是否真正理解状态定义如何对应实际问题中的操作而不是死记硬背模板。同时其状态转移的依赖性只依赖左上方和左侧为空间优化提供了完美的场景是学习滚动数组思想的经典例题。在实战中遇到字符串匹配、序列比对类的问题第一步永远是明确操作集允许做什么操作代价如何第二步是定义出能够清晰表达“已经处理了多少”的状态第三步才是推导状态转移方程。这道“最优包含”题为我们提供了践行这一思考流程的绝佳范本。下次再遇到类似问题不妨先想想我的“操作”是什么我的状态dp[i][j]究竟想表示什么想清楚了这些方程往往就水到渠成了。
返回列表