动态规划实战:最长公共子序列与最长上升子序列的C/C++实现与优化

📅 2026/7/27 4:47:19
动态规划实战:最长公共子序列与最长上升子序列的C/C++实现与优化
1. 项目概述从“最长子序列”到算法实战最近在整理算法笔记翻到“最长子序列”这个经典问题时发现很多刚接触动态规划的朋友包括当年的我自己都容易在这里卡壳。网上资料虽多但要么是纯理论推导看得云里雾里要么是代码一贴了事关键细节和“为什么这么做”却语焉不详。今天我就以一个写过不少C/C底层代码的老码农视角来彻底拆解这个问题。我们不止要搞懂“最长公共子序列”和“最长上升子序列”这两个核心变种更要弄明白它们背后的动态规划思想以及如何用C/C写出既高效又清晰的代码。无论你是正在准备面试还是想在项目中优化某些字符串或序列比对逻辑这篇深度解析都能给你提供可直接“抄作业”的实战方案。2. 核心概念与问题定义不止于“最长”在深入代码之前我们必须把问题边界和核心定义搞清楚。很多人一听到“最长子序列”就懵因为它往往代表一类问题而非一个。2.1 子序列 vs. 子串灵活性是关键这是第一个需要厘清的基础概念。给定一个序列可以是字符串、数组等其子串必须是连续的一段。例如字符串 “algorithm” “gori” 是一个子串。而子序列则宽松得多它只需要保持原序列中的相对顺序但可以不连续。同样是 “algorithm” “lgr”、“aom” 都是它的子序列。这种“保持顺序即可”的特性使得子序列问题在比对、差异分析等场景下应用更广因为现实中的数据缺失或变动常常不是连续的。2.2 两大经典变种LCS与LIS我们主要攻克两个最常考、最常用的“最长子序列”问题最长公共子序列 即 Longest Common Subsequence简称 LCS。问题给定两个序列如字符串找到它们共有的、最长的子序列。场景这是生物信息学中DNA/RNA序列比对、版本控制系统如Git中文件差异比较、以及语音识别中对比的基础算法。它衡量的是两个序列的“相似度”允许跳过不匹配的部分。最长上升子序列 即 Longest Increasing Subsequence简称 LIS。问题给定一个数字序列找到其中最长的、元素严格递增的子序列。场景应用极其广泛。例如寻找股票价格的最长上涨波段、安排会议使参与人数最多按结束时间排序后求最长不冲突序列、甚至是一些游戏中的关卡规划。它关注的是序列内部的单调性。注意LIS有时也要求“非递减”即允许相等具体需看题目要求。本文以严格递增为例原理相通。这两个问题看似不同但其最优解都依赖于动态规划这一核心思想。下面我们就用C/C的视角深入它们的原理与实现。3. 最长公共子序列详解与C/C实现LCS是理解动态规划在序列问题上应用的绝佳范例。它的状态定义非常经典。3.1 动态规划思路拆解如何定义“状态”动态规划的核心是定义状态和找到状态转移方程。对于LCS一个自然而有效的状态定义是dp[i][j]表示序列A的前i个字符即A[0..i-1]和序列B的前j个字符即B[0..j-1]的LCS长度。这里使用i-1和j-1作为索引是为了方便处理空串的情况dp[0][j]和dp[i][0]都代表与空串的LCS自然长度为0。3.2 状态转移方程推导分情况讨论如何从已知的小问题推导出dp[i][j]呢考虑我们正在比较A[i-1]和B[j-1]当A[i-1] B[j-1]时当前两个字符匹配那么它们必然可以加入接在A[0..i-2]和B[0..j-2]的LCS之后形成更长的公共子序列。因此dp[i][j] dp[i-1][j-1] 1当A[i-1] ! B[j-1]时当前字符不匹配。那么A[i-1]和B[j-1]不可能同时出现在当前的LCS中。LCS可能来源于忽略A[i-1]看A[0..i-2]和B[0..j-1]的LCS即dp[i-1][j]。忽略B[j-1]看A[0..i-1]和B[0..j-2]的LCS即dp[i][j-1]。 我们取两者的最大值因为我们要的是“最长”dp[i][j] max(dp[i-1][j], dp[i][j-1])3.3 基础C实现与源码解析有了状态转移方程代码实现就非常直接了。我们先给出一个标准的二维DP表实现。#include iostream #include vector #include algorithm using namespace std; int longestCommonSubsequence(string text1, string text2) { int m text1.length(); int n text2.length(); // 创建 (m1) x (n1) 的DP表并初始化为0 vectorvectorint dp(m 1, vectorint(n 1, 0)); // 填充DP表 for (int i 1; i m; i) { for (int j 1; j n; j) { if (text1[i - 1] text2[j - 1]) { // 字符匹配长度加1 dp[i][j] dp[i - 1][j - 1] 1; } else { // 字符不匹配继承左方或上方的最大值 dp[i][j] max(dp[i - 1][j], dp[i][j - 1]); } } } // dp[m][n] 即为最终结果 return dp[m][n]; } int main() { string s1 abcde; string s2 ace; int result longestCommonSubsequence(s1, s2); cout The length of LCS is: result endl; // 输出 3 (ace) return 0; }代码要点解析dp数组大小是(m1) * (n1)多出来的一行一列用于表示空序列简化边界条件处理。循环从1开始对应到原字符串索引时需要-1。时间复杂度O(m*n)空间复杂度O(m*n)。3.4 空间优化技巧滚动数组在上述代码中我们保存了整个DP表。但观察状态转移方程可以发现计算dp[i][j]时只依赖于上一行(dp[i-1][...]) 和当前行已计算的部分(dp[i][j-1])。因此我们完全可以用两行数组或两个一维数组来交替使用将空间复杂度优化到O(min(m, n))。int longestCommonSubsequence_opt(string text1, string text2) { if (text1.length() text2.length()) { swap(text1, text2); // 保证text2是较短的那个进一步节省空间 } int m text1.length(); int n text2.length(); vectorint prev(n 1, 0); // 代表“上一行” vectorint curr(n 1, 0); // 代表“当前行” for (int i 1; i m; i) { for (int j 1; j n; j) { if (text1[i - 1] text2[j - 1]) { curr[j] prev[j - 1] 1; // 左上角的值来自prev } else { curr[j] max(prev[j], curr[j - 1]); // 上方是prev[j]左方是curr[j-1] } } swap(prev, curr); // 当前行计算完毕变为“上一行”准备下一轮 // 注意swap后curr数组的内容是上一轮的prev但会被新一轮覆盖所以没问题。 } return prev[n]; // 最后一次swap后结果在prev中 }实操心得在面试或竞赛中如果只要求长度务必写出这种空间优化版本它能显著体现你对算法空间复杂度的掌控力。如果要求输出具体的LCS字符串则需要回溯完整的DP表空间无法优化。3.5 重构LCS字符串回溯法有时我们需要知道LCS具体是什么。这需要通过回溯DP表来完成。string getLCSString(string text1, string text2, vectorvectorint dp) { int i text1.length(); int j text2.length(); string lcs ; while (i 0 j 0) { if (text1[i - 1] text2[j - 1]) { // 当前字符属于LCS逆序添加 lcs.push_back(text1[i - 1]); i--; j--; // 跳转到左上角 } else if (dp[i - 1][j] dp[i][j - 1]) { i--; // 跳转到上方值更大的方向 } else { j--; // 跳转到左方 } } reverse(lcs.begin(), lcs.end()); // 因为是逆序构建的需要反转 return lcs; } // 在主函数中先计算并保存完整的dp表再调用此函数。4. 最长上升子序列详解与C/C实现LIS问题有两种主流的DP解法一种直观但较慢另一种巧妙且高效。4.1 方法一O(n²) 标准动态规划这种思路和LCS有异曲同工之妙但只针对一个序列。状态定义dp[i]表示以第i个元素nums[i]结尾的最长上升子序列的长度。状态转移为了求dp[i]我们需要遍历i之前的所有位置j(0 j i)。如果nums[j] nums[i]说明nums[i]可以接在nums[j]结尾的LIS后面形成一个更长的LIS。因此dp[i] max(dp[j] 1)对于所有满足nums[j] nums[i]的j。 如果前面没有比nums[i]小的数那么dp[i] 1只有自己。int lengthOfLIS_DP(vectorint nums) { int n nums.size(); if (n 0) return 0; vectorint dp(n, 1); // 每个元素自身至少是一个长度为1的LIS int maxLen 1; for (int i 1; i n; i) { for (int j 0; j i; j) { if (nums[j] nums[i]) { dp[i] max(dp[i], dp[j] 1); } } maxLen max(maxLen, dp[i]); // 更新全局最大值 } return maxLen; }复杂度分析时间复杂度O(n²)空间复杂度O(n)。对于数据量较大n 10^4的情况这个方法会超时。4.2 方法二O(n log n) 贪心二分查找这是LIS问题的经典优化解法思路非常巧妙。我们并不直接维护长度数组而是维护一个潜在的增长序列tails。核心思想tails[k]存储长度为k1的所有上升子序列中末尾元素最小的那个值。这个序列本身是递增的。维护过程遍历原数组nums中的每个数x。如果x大于tails中所有元素即大于最后一个说明可以延长当前最长的LIS将x追加到tails末尾。否则在tails中找到第一个大于等于x的元素用x替换它。这个操作不会改变tails的长度但让该长度的上升子序列的末尾元素变得更小为后续接上更大的数创造了可能。为什么可行我们只关心LIS的长度。让每个长度的结尾尽可能小是一种“贪心”策略为未来预留了更多增长空间。最终tails的长度就是LIS的长度。int lengthOfLIS_Greedy(vectorint nums) { vectorint tails; // 存储各个长度LIS的最小末尾值 for (int num : nums) { // 在 tails 中寻找第一个 num 的元素位置 auto it lower_bound(tails.begin(), tails.end(), num); if (it tails.end()) { // num 比所有末尾都大可以延长LIS tails.push_back(num); } else { // 用 num 替换掉那个第一个 它的元素使其更小 *it num; } } // tails 的长度即为 LIS 的长度 return tails.size(); }复杂度分析遍历数组 O(n)每次二分查找 O(log n)总时间复杂度O(n log n)空间复杂度O(n)。这是求解LIS长度的最优算法。踩坑提醒tails序列存储的并不一定是真实的LIS它只保证了长度的正确性。如果需要输出具体的LIS通常需要结合dp[i]数组和额外的记录信息进行回溯复杂度会回到 O(n²) 或 O(n log n) 但需要更多空间。4.3 两种方法对比与选择特性O(n²) DP 方法O(n log n) 贪心二分法时间复杂度O(n²)O(n log n)空间复杂度O(n)O(n)能否输出序列可以dp[i]记录了以 i 结尾的长度易于回溯。困难tails序列本身不是真实LIS。适用场景n 较小如 5000或需要输出具体序列。绝大多数情况尤其是 n 很大只需求长度时。代码复杂度简单直观双重循环。需要理解贪心思想并使用二分查找。选择建议在面试或竞赛中如果只问长度无脑选O(n log n)的解法。如果要求输出任意一个LIS可以先用 O(n log n) 法求长度再结合一些技巧如记录每个元素在tails中的位置来重构但代码会复杂一些。明确要求输出所有LIS时则可能需要回溯或更复杂的算法。5. 实战进阶变种问题与C/C实现思路掌握了两个经典问题我们可以看看一些常见的变种这能加深对原理的理解。5.1 最长公共子串这与LCS一字之差但要求子序列是连续的。状态定义可以微调dp[i][j]表示以A[i-1]和B[j-1]结尾的公共子串的长度。转移方程只有当A[i-1] B[j-1]时dp[i][j] dp[i-1][j-1] 1否则dp[i][j] 0。结果遍历整个dp表找到的最大值即为最长公共子串的长度。同样可以优化空间。5.2 最长不下降子序列这是LIS的宽松版将条件nums[j] nums[i]改为nums[j] nums[i]。O(n²)的DP方法只需修改判断条件。O(n log n)的方法中将lower_bound找第一个 num 的改为upper_bound找第一个 num 的即可因为允许相等替换策略是找到第一个比它大的才替换。5.3 多维LIS问题例如“俄罗斯套娃信封”问题给定一堆信封的宽高当且仅当一个信封的宽和高都大于另一个时才能套进去求最多能套多少层。一个巧妙的解法是先将信封按宽度升序排序当宽度相同时按高度降序排序。排序后对高度数组求LIS。为什么可行宽度已排序我们只需关注高度。宽度相同时按高度降序排列保证了在求LIS时宽度相同的信封不会互相嵌套因为高度不满足递增从而将二维问题转化为一维LIS。int maxEnvelopes(vectorvectorint envelopes) { // 排序宽升序同宽则高降序 sort(envelopes.begin(), envelopes.end(), [](const vectorint a, const vectorint b) { return a[0] b[0] || (a[0] b[0] a[1] b[1]); }); // 对高度序列求LIS vectorint heights; for (const auto env : envelopes) { int h env[1]; auto it lower_bound(heights.begin(), heights.end(), h); if (it heights.end()) { heights.push_back(h); } else { *it h; } } return heights.size(); }6. 调试技巧与常见问题排查即使理解了算法实现时也难免遇到bug。以下是一些常见坑点和调试方法。6.1 数组下标越界这是DP问题最常见的错误之一。根本原因dp数组定义的大小与循环的起止索引不匹配。例如字符串长度为ndp数组应定义为n1循环从1到n访问字符串时用i-1。如果混淆了很容易访问到text1[n]导致越界。检查清单dp数组维度是否是长度1循环变量i,j的起始和终止条件是否正确在状态转移方程中访问原序列时是否使用了正确的索引通常是i-1,j-16.2 初始化错误DP表的初始化至关重要。LCSdp[0][j]和dp[i][0]必须初始化为0代表空序列与任何序列的LCS长度为0。LIS (O(n²))dp[i]至少初始化为1因为每个元素本身构成一个长度为1的上升子序列。建议在C中使用vectorint(size, initial_value)构造函数或在循环中显式初始化第一行/第一列。6.3 状态转移方程实现错误特别是else分支的逻辑。LCS当字符不相等时是取max(dp[i-1][j], dp[i][j-1])而不是dp[i-1][j-1]。我曾见过有人在这里写错。LIS (O(n²))内层循环j要从0遍历到i-1并且判断条件是nums[j] nums[i]注意是严格小于。内层循环结束后记得用dp[i]更新全局最大值。6.4 空间优化版本的陷阱使用滚动数组时最容易出错的是状态覆盖和交换时机。覆盖问题在计算curr[j]时curr[j-1]必须是本轮已经计算出的新值而prev[j]和prev[j-1]是上一轮的旧值。代码顺序必须保证这一点。交换时机必须在完成一整行i的计算后才能将curr和prev交换。如果在内层循环中交换逻辑会完全混乱。调试建议对于小规模测试用例如两个长度3-4的字符串可以同时运行普通二维DP版本和空间优化版本对比最终结果和关键中间状态可以打印出每一轮交换前后的prev和curr数组。6.5 使用lower_bound与upper_bound的混淆在LIS的贪心算法中对于严格递增序列我们使用lower_bound找第一个 num 的。对于非严格递增不下降序列则使用upper_bound找第一个 num 的。用反了会导致结果错误。记忆技巧lower_bound是“可以插入相等元素的最小位置”用于维持严格递增不允许相等元素替换后还相等。upper_bound是“必须插入更大元素的位置”用于允许相等。7. 性能优化与工程化思考在真实项目或处理大规模数据时我们还需要考虑更多。7.1 输入数据范围与算法选择这是最首要的考量。务必根据题目或问题的数据规模选择算法。n 200 O(n³) 的暴力或简单DP或许都可接受。n 5000 O(n²) 的DP是安全的选择。n 10^5甚至更大必须使用 O(n log n) 或更优的算法。对于LISO(n log n)是标配对于LCS如果只求长度也有基于位运算和特定优化的近似或更快算法但通用场景下 O(m*n) 是瓶颈。7.2 内存与缓存友好性对于LCS的 O(m*n) DP如果字符串非常长如数万字符即使空间优化到 O(n)时间消耗也可能巨大。此时需要考虑是否真的需要精确解在生物信息学中面对超长DNA序列会使用启发式算法如BLAST进行快速近似比对。使用内存视图或指针在C/C中避免不必要的字符串拷贝使用string_view或const char*配合长度来传递子串信息。循环顺序在二维DP中按行遍历通常比按列遍历更缓存友好因为内存是行优先存储的。7.3 代码可读性与模板化为了复用可以将核心算法封装成函数或类。函数设计明确输入输出。例如int computeLCS(const string s1, const string s2);和pairint, string computeLCSWithString(...);。使用泛型LIS算法不限于vectorint可以模板化使其适用于任何支持操作符的可比类型。templatetypename T int lengthOfLIS(const vectorT nums) { vectorT tails; for (const T num : nums) { auto it lower_bound(tails.begin(), tails.end(), num); if (it tails.end()) tails.push_back(num); else *it num; } return tails.size(); }附上测试用例在代码注释或附近提供典型的测试用例和预期输出方便自己和他人验证。7.4 可视化调试对于复杂的DP问题画出DP表是终极调试利器。手工画表对于小例子在纸上画出dp矩阵手动模拟填充过程与程序打印的结果对比。程序打印在代码中关键步骤后打印出dp数组或tails数组的状态。这对于验证空间优化算法的正确性尤其有用。我个人在实现这些算法时会先写出最直观的二维DP版本确保逻辑正确、结果无误。然后如果需要优化再一步步推导出滚动数组版本。对于LIS则会同时实现 O(n²) 和 O(n log n) 两个版本并用随机生成的大量数据对拍确保优化版本的正确性。最后将清晰的、优化后的版本和关键注释保存到自己的算法库中。这些经典的序列DP问题其思想会反复出现在各种变种题目里吃透它们很多难题都能迎刃而解。