最长递增子序列(LIS)动态规划详解:从O(n²)到O(n log n)的C++模板与工程实践

发布时间:2026/7/28 6:52:11

最长递增子序列(LIS)动态规划详解:从O(n²)到O(n log n)的C++模板与工程实践 1. 项目概述从“最长递增子序列”到一份C/C框架全景图最近在整理自己的技术笔记发现一个很有意思的现象无论是刚入门的新手还是有一定经验的开发者在面对“动态规划”这类经典算法时第一反应往往是去网上找一份“万能模板”。这本身没错高效学习始于模仿。但问题在于很多流传的模板要么过于抽象缺乏具体场景的注释要么就是“玩具代码”离工程实践有距离变量命名随意边界条件模糊真拿到项目里用处处是坑。就拿“最长递增子序列”这个动态规划的入门必刷题来说。它的核心状态转移方程dp[i] max(dp[i], dp[j] 1)几乎人尽皆知网上代码一搜一大把。但为什么你的代码跑出来结果不对为什么数据量稍大就超时为什么明明逻辑一样换到二维的“最长公共子序列”问题就不知道怎么套了这些才是真正卡住人的地方。模板的价值不在于那几行固定的代码而在于它背后所承载的问题分析范式、状态设计思想和优化脉络。一个好的模板应该是一把钥匙帮你打开一类问题的大门而不是一个黑盒让你只会机械地填空。基于这个想法我决定做两件事第一以“最长递增子序列”为引子彻底拆解动态规划模板的“所以然”不仅给出代码更要讲清楚每一个变量、每一个循环背后的意图以及如何将其思路迁移到其他问题上。第二也是更庞大的一项工程把我过去十多年在嵌入式、后端、游戏、高频交易等不同领域摸爬滚打接触和积累的各种C/C框架、库和工具链进行了一次系统的梳理和归档。从底层的标准库、容器、智能指针到网络编程的ACE、Boost.Asio、libevent再到并发领域的TBB、OpenMP以及测试框架、序列化、日志库等等我把它们的核心思想、适用场景、优缺点对比以及一个最简化的“Hello World”级使用示例都整理了出来。最终这份梳理的成果被我汇编成了一份结构清晰的PDF。它不像市面上那些厚重的源码解析书而是更像一份“技术地图”或“工具手册”。当你面临一个具体的技术选型问题时比如“我要写一个高性能的TCP服务器该用哪个网络库”可以快速在这份PDF里找到几个候选方案并看到它们最核心的代码风格和性能特点对比从而快速做出决策。这份PDF和本篇关于动态规划模板的深度解析构成了一个从具体算法到宏观技术体系的完整分享。接下来我们就先深入这个看似简单实则内涵丰富的“最长递增子序列”模板。2. 最长递增子序列动态规划的经典起手式2.1 问题定义与暴力枚举的困境最长递增子序列英文是Longest Increasing Subsequence简称LIS。它的定义非常直观给定一个整数序列找到其中最长的、元素严格递增的子序列的长度。这里有两个关键点需要厘清“子序列”和“递增”。子序列意味着不需要连续你可以从原序列中按顺序挑选一些元素出来只要保持它们原有的相对顺序即可。例如序列[10, 9, 2, 5, 3, 7, 101, 18][2, 3, 7, 101]就是一个合法的、长度为4的递增子序列。而“递增”通常指严格递增即后一个元素必须大于前一个元素。最直观的解法是暴力枚举所有可能的子序列。对于一个长度为n的序列每个元素都有“选”或“不选”两种可能因此子序列总数是2^n个。检查每个子序列是否递增的复杂度是O(n)所以总时间复杂度是O(n * 2^n)。当n超过20时这个计算量就已经无法接受了。这迫使我们寻找更聪明的办法。动态规划的核心思想是“以空间换时间”和“避免重复计算”。对于LIS问题一个自然的想法是如果我能知道以每个位置结尾的最长递增子序列的长度那么整个序列的LIS长度不就是所有这些长度中的最大值吗这个“以i结尾”的状态定义是解决许多单序列DP问题的关键切入点。它把一个大问题整个序列的LIS分解成了若干个重叠的子问题以每个位置结尾的LIS并且子问题之间可以通过递推关系联系起来。2.2 标准动态规划解法状态设计与转移方程基于上述思路我们定义状态dp[i]表示以第i个元素下标从0开始结尾的、最长递增子序列的长度。现在思考如何求解dp[i]。以nums[i]结尾的递增子序列它的前一个元素可以是原序列中在i之前、并且值小于nums[i]的任何一个元素nums[j]j i且nums[j] nums[i]。如果我们已经知道了以nums[j]结尾的LIS长度dp[j]那么把nums[i]接在它的后面就形成了一个以nums[i]结尾、长度为dp[j] 1的新递增子序列。我们要找的是最长的那个所以需要遍历所有满足条件的j取dp[j] 1的最大值。如果找不到任何一个满足条件的j即nums[i]比前面所有数都小那么以它结尾的LIS就是它自己长度为1。由此得到状态转移方程dp[i] max(dp[i], dp[j] 1)对于所有j i且nums[j] nums[i]。 初始状态每个位置的dp[i]至少为1它自身构成一个子序列。最终答案max(dp[0], dp[1], ..., dp[n-1])。我们用一个具体的例子来走一遍流程。序列nums [10, 9, 2, 5, 3, 7, 101, 18]。i0nums[0]10前面没有元素dp[0]1。i1nums[1]9前面只有10比它大不满足nums[j] nums[i]所以dp[1]1。i2nums[2]2前面10和9都比它大dp[2]1。i3nums[3]5前面比它小的有nums[2]2dp[2]1所以dp[3] dp[2] 1 2。i4nums[4]3前面比它小的有nums[2]2所以dp[4] dp[2] 1 2。i5nums[5]7前面比它小的有2(dp1),5(dp2),3(dp2)。取最大dp值2加1得dp[5]3。对应的子序列可以是[2,5,7]或[2,3,7]。i6nums[6]101前面所有数都比它小找最大的dp是dp[5]3所以dp[6]4。子序列[2,5,7,101]。i7nums[7]18前面比它小的数中dp最大的是dp[5]3对应7所以dp[7]4。子序列[2,5,7,18]。 最终所有dp[i]中的最大值是4即LIS长度为4。注意dp数组存储的是长度而不是子序列本身。如果需要输出具体的子序列通常需要额外维护一个pre数组记录每个状态是从哪个前驱状态转移而来的最后通过回溯还原路径。这在面试中也是常考点。2.3 贪心二分查找优化O(n log n)的魔法上面的DP解法时间复杂度是O(n^2)对于n在10^4级别的数据已经够用。但如果数据量达到10^5甚至更大我们需要O(n log n)的算法。这里就引入了经典的“贪心二分查找”优化。这个算法的思想非常巧妙它并不直接计算长度而是维护一个“潜力列表”。我们定义一个数组tail或者叫low。tail[i]的含义是所有长度为i1的递增子序列中结尾元素的最小值。为什么关注“结尾最小值”因为对于相同长度的子序列结尾元素越小它未来能够接上更多数、变得更长的“潜力”就越大。算法流程如下初始化tail为空数组。遍历原序列中的每个数x。在tail数组中寻找第一个大于等于x的元素的位置。如果找不到即x比tail中所有元素都大说明x可以接在当前最长的子序列后面形成更长的子序列。将x添加到tail末尾。如果找到了假设位置是pos那么用x替换tail[pos]。因为x比原来的tail[pos]更小但同样能构成一个长度为pos1的子序列并且让这个长度的子序列的结尾元素变得更小未来潜力更大。遍历结束后tail数组的长度就是最长递增子序列的长度。这个“寻找第一个大于等于x的元素”的操作正是二分查找的用武之地因此单次操作复杂度是O(log n)整体O(n log n)。还用之前的例子[10, 9, 2, 5, 3, 7, 101, 18]x10tail[]直接加入 -tail[10]x9在tail中找到第一个9的是10位置0替换 -tail[9]x2找到9位置0替换 -tail[2]x5比tail末尾2大添加 -tail[2,5]x3在tail中找到第一个3的是5位置1替换 -tail[2,3]x7比3大添加 -tail[2,3,7]x101比7大添加 -tail[2,3,7,101]x18在tail中找到第一个18的是101位置3替换 -tail[2,3,7,18]最终tail长度为4即LIS长度为4。重要提示tail数组并不一定是真实的最长递增子序列它只是存储了各个长度下的最小结尾值。在上例中最后的tail[2,3,7,18]恰好是一个LIS但这只是巧合。如果序列是[3,4,1,2]算法得到的tail是[1,2]但真实的LIS是[3,4]或[1,2]长度都是2。这个算法只能求出长度无法直接得到子序列。如果需要序列算法会复杂很多。3. C模板实现与工程化考量3.1 O(n²) 动态规划模板实现首先给出最直接、最易于理解的O(n^2)DP模板。在面试或快速原型开发中如果数据范围明确较小如n5000这个版本是首选因为它逻辑清晰不易出错。#include vector #include algorithm using namespace std; int lengthOfLIS_dp(vectorint nums) { if (nums.empty()) return 0; int n nums.size(); // 1. 定义dp数组并初始化 vectorint dp(n, 1); // 每个元素自身至少是一个长度为1的子序列 int maxLen 1; // 记录全局最大值 // 2. 状态转移 for (int i 1; i n; i) { // 遍历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; }代码细节与工程化思考边界处理函数开头检查nums是否为空这是鲁棒性的基本要求。避免对空容器进行操作。初始化vectorint dp(n, 1)在构造的同时完成了初始化比先声明再循环赋值更高效、简洁。循环范围外层循环i从1开始因为dp[0]已知为1。内层循环j遍历[0, i)这是标准的比较模式。实时更新最大值在计算出每个dp[i]后立即更新maxLen避免了最后再遍历一次dp数组。这是一个微小的性能优化也使得逻辑更连贯。参数传递使用vectorint引用传递避免不必要的拷贝。如果函数承诺不修改原数组应使用const vectorint。3.2 O(n log n) 贪心二分模板实现对于大数据量O(n log n)的算法是必须掌握的。下面是它的标准实现其中二分查找部分直接使用了C标准库的lower_bound。#include vector #include algorithm using namespace std; int lengthOfLIS_greedy(vectorint nums) { if (nums.empty()) return 0; vectorint tail; // 存储各个长度递增子序列的最小末尾值 for (int x : nums) { // 使用lower_bound在tail中查找第一个 x 的元素位置 auto it lower_bound(tail.begin(), tail.end(), x); if (it tail.end()) { // x比所有末尾值都大可以延长最长子序列 tail.push_back(x); } else { // 用更小的x替换该位置的元素以增加未来潜力 *it x; } } // tail的大小即为LIS长度 return tail.size(); }代码细节与工程化思考lower_bound的使用这是算法的核心。lower_bound在有序区间内返回第一个不小于x的元素的迭代器。它内部使用二分查找复杂度O(log n)。务必与upper_bound第一个大于x的元素区分开。对于LIS问题我们需要的是“第一个 x 的”所以用lower_bound。迭代器操作it tail.end()判断是否所有元素都小于x。*it x是经典的迭代器解引用赋值操作用于替换元素。容器选择这里使用vector作为tail的容器是合适的因为我们需要在尾部添加元素 (push_back)并且需要随机访问迭代器以供lower_bound使用。vector的尾部插入是摊还常数时间且内存连续缓存友好。算法正确性理解务必向面试官或代码评审者讲清楚tail数组含义的非直观性——它存储的不是LIS而是“每个长度下的最小末尾”。这是此算法最精妙也最容易让人困惑的地方。3.3 模板的变体与扩展一个真正好用的模板不能只解决标准问题。下面讨论几个常见变体展示如何微调模板来应对不同需求。变体1非严格递增即允许相等如果要求子序列是非递减的即nums[j] nums[i]只需要修改判断条件或二分查找的策略。对于O(n^2)DP将内层循环的判断条件if (nums[j] nums[i])改为if (nums[j] nums[i])。对于O(n log n)贪心二分需要将lower_bound找第一个x的改为upper_bound找第一个x的。因为对于相等的元素我们希望替换掉第一个比它大的以保持“最小末尾”的性质。代码中只需将lower_bound替换为upper_bound即可。变体2输出一个具体的LIS如前所述标准O(n log n)算法无法直接得到序列。如果需要输出一个通常采用O(n^2)DP 并配合回溯。vectorint getOneLIS(vectorint nums) { int n nums.size(); vectorint dp(n, 1); vectorint pre(n, -1); // 记录前驱索引-1表示无前驱 int maxLen 1, endIdx 0; for (int i 0; i n; i) { for (int j 0; j i; j) { if (nums[j] nums[i] dp[j] 1 dp[i]) { dp[i] dp[j] 1; pre[i] j; // 记录是从j转移来的 } } if (dp[i] maxLen) { maxLen dp[i]; endIdx i; // 记录最长序列的结尾位置 } } // 回溯构造序列 vectorint lis; while (endIdx ! -1) { lis.push_back(nums[endIdx]); endIdx pre[endIdx]; } reverse(lis.begin(), lis.end()); // 回溯是逆序的需要反转 return lis; }变体3二维LIS问题如“俄罗斯套娃信封问题”问题描述给定一些信封的宽度和高度(w, h)当另一个信封的宽度和高度都大于这个信封时可以套进去。问最多能套多少层。这本质上是一个二维的LIS。解法是先对宽度w进行升序排序如果w相同则按高度h降序排序。然后对高度数组h求LIS即可。排序中高度降序是关键它避免了宽度相同的信封被错误地计入序列因为宽度相同不能嵌套。int maxEnvelopes(vectorvectorint envelopes) { if (envelopes.empty()) return 0; // 排序宽度升序宽度相同则高度降序 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 e : envelopes) { heights.push_back(e[1]); } return lengthOfLIS_greedy(heights); // 使用O(n log n)的LIS函数 }4. 动态规划的思维延伸与框架PDF的价值4.1 从LIS模板到动态规划思维框架通过深度拆解LIS问题我们可以提炼出解决动态规划问题的通用思维框架这远比记忆单个模板重要定义状态这是最难也最关键的一步。状态的定义需要满足“无后效性”——未来的决策只依赖于当前状态而不依赖于过去是如何到达这个状态的。LIS中的dp[i]以i结尾就是一个经典定义。其他常见定义还有dp[i][j]涉及两个序列或两个维度的位置、dp[i][k]位置i且使用了k次机会等。确定状态转移方程找出状态之间的关系。通常需要分类讨论思考如何从已知的、规模更小的子问题推导出当前状态。LIS的方程dp[i] max(dp[j] 1)就是一个典型的“枚举前驱”式转移。初始化与边界条件给最小的、不可再分的子问题赋值。LIS中每个dp[i]初始为1。边界条件如数组越界需要仔细处理。计算顺序确保在计算当前状态时它所依赖的子问题已经被计算过。LIS中计算dp[i]需要所有j i的dp[j]所以i从0到n-1顺序遍历即可。输出结果状态定义决定了最终答案的位置。可能是dp[n-1]也可能是dp数组中的最大值或最小值。将这个框架应用到其他经典问题上最大子数组和状态dp[i]定义为以nums[i]结尾的最大子数组和。转移方程dp[i] max(nums[i], dp[i-1] nums[i])。结果是max(dp)。爬楼梯状态dp[i]表示到第i阶的方法数。转移dp[i] dp[i-1] dp[i-2]。0-1背包状态dp[i][j]表示考虑前i件物品在容量为j的背包下的最大价值。转移dp[i][j] max(dp[i-1][j], dp[i-1][j-w[i]] v[i])。4.2 C/C框架PDF你的技术决策导航图谈完了具体的算法让我们把视野拉回到更宏观的层面。为什么我要花大力气整理那份C/C框架的PDF因为在真实的项目开发中尤其是性能敏感的系统级开发选择正确的工具和框架其重要性不亚于设计正确的算法。一个不合适的网络库可能会让服务器性能下降50%一个错误的内存管理策略可能导致难以追踪的崩溃一个笨重的序列化库会让你的协议解析成为瓶颈。这份PDF试图解决的就是“技术选型焦虑”。它按照功能领域进行划分每个类别下列举了主流和新兴的框架/库并附上一个极简的“心智模型”对比。例如在网络库部分框架/库核心模型优点缺点/适用场景一句话感受Boost.Asio前摄器模式(Proactor)跨平台功能强大文档丰富是标准库网络TS的基础。学习曲线陡峭接口基于回调现代C下需配合协程。“工业级标准功能大而全但需要时间驾驭。”libevent事件驱动(Reactor)轻量级高性能专注于网络IO被大量知名项目使用。C接口需要手动管理资源面向过程风格。“纯粹的事件循环Unix哲学的体现追求极致性能的C语言选择。”muduoReactor 线程池基于C11陈硕大神作品代码质量极高适合学习Linux多线程网络编程。主要面向Linux社区活跃度相对较低。“一本优秀的‘网络编程教科书’代码本身就是最好的文档。”POCO面向对象提供完整的网络、HTTP、加密、数据库等框架更像一个应用框架。庞大可能引入不必要的依赖性能非极致追求。“瑞士军刀想快速构建一个功能完备的应用它是好帮手。”这份对比不是简单的罗列而是基于实际项目踩坑后的总结。比如为什么高频交易系统往往选择自己基于epoll/kqueue从零写网络层或者使用像libevent这样极简的库因为依赖越少延迟的可控性就越高。为什么很多互联网后台服务青睐Boost.Asio因为其跨平台能力和活跃的社区能降低长期维护成本。PDF中类似的对比还涵盖并发与并行std::threadvs.pthread, Intel TBB vs. OpenMP vs.std::async。内存管理智能指针使用陷阱内存池方案如boost::pool自定义分配器。序列化protobuf、flatbuffers、msgpack、json如nlohmann/json的选型考量。日志库spdlog、glog、log4cxx的性能与功能权衡。测试框架Google Test、Catch2的哲学差异。脚本集成嵌入Lua还是Python这份PDF的价值在于它提供了一个决策起点。当你需要为一个新项目选择技术栈或者为某个模块寻找替代方案时可以快速定位到相关章节了解有哪些选项它们各自的核心思想和适用边界是什么。然后你可以根据自己项目的具体约束性能、内存、依赖、团队技能、许可证等去做更深入的调研和测试。5. 常见问题与实战调试技巧5.1 算法实现中的典型“坑”初始化错误dp数组忘记初始化或者初始值设错。例如在LIS的O(n^2)算法中每个dp[i]必须初始化为1如果初始化为0结果永远为0。边界条件遗漏对于空输入序列没有处理直接访问nums[0]导致段错误。这是面试中最常见的失分点之一。务必在函数开头检查输入有效性。状态转移条件不完整在LIS问题中内层循环的判断if (nums[j] nums[i])是严格递增。如果题目要求“非递减”这里就必须改为。一字之差结果迥异。二分查找的误用在O(n log n)算法中混淆lower_bound和upper_bound。牢记求严格递增LIS用lower_bound求非严格递增非递减用upper_bound。可以通过一个小例子[2,2]来验证严格递增结果应为1[2]非严格递增结果应为2[2,2]。tail数组含义误解这是最高频的困惑点。再次强调tail数组存储的不是LIS本身而是“每个长度下最小的末尾元素”。试图从tail直接输出序列在多数情况下会得到错误答案。5.2 调试与性能分析实践当你写的DP代码结果不对时如何调试第一步小数据量肉眼调试不要一上来就用复杂用例。构造一个最小规模的、你知道答案的测试用例比如[1][1,2][2,1]。在关键位置如内层循环结束后打印出dp数组或tail数组的中间状态与你的手动推导过程对比。这是定位逻辑错误最快的方法。第二步设计针对性测试用例升序序列[1,2,3,4,5]结果应为5。降序序列[5,4,3,2,1]结果应为1。重复序列[2,2,2,2]严格递增结果为1非严格递增结果为4。混合序列[10,9,2,5,3,7,101,18]结果应为4。空序列[]结果应为0。单元素序列[5]结果应为1。第三步性能分析与优化验证对于O(n^2)算法当n较大时如10^4运行时间会显著变慢。你可以写一个简单的性能测试#include chrono #include random #include vector using namespace std; using namespace chrono; void performanceTest() { int n 10000; vectorint nums(n); random_device rd; mt19937 gen(rd()); uniform_int_distribution dis(1, 1000000); for (int i 0; i n; i) { nums[i] dis(gen); } auto start high_resolution_clock::now(); int result1 lengthOfLIS_dp(nums); // O(n^2) auto stop high_resolution_clock::now(); auto duration_dp duration_castmilliseconds(stop - start); start high_resolution_clock::now(); int result2 lengthOfLIS_greedy(nums); // O(n log n) stop high_resolution_clock::now(); auto duration_greedy duration_castmilliseconds(stop - start); cout DP O(n^2) result: result1 , time: duration_dp.count() ms endl; cout Greedy O(n log n) result: result2 , time: duration_greedy.count() ms endl; // 验证结果一致性 assert(result1 result2); }在我的测试环境中普通台式机n10000时O(n^2)算法可能需要几百毫秒到数秒而O(n log n)算法通常在1毫秒以内。这个对比能让你直观感受到算法复杂度差异带来的巨大性能鸿沟。5.3 模板的泛化与举一反三掌握LIS模板的真正标志是能将其思想迁移到其他问题上。这里举两个例子问题A最长连续递增序列要求子序列必须是连续的。这反而更简单了因为连续意味着状态转移只依赖于前一个元素。状态定义dp[i]表示以nums[i]结尾的最长连续递增序列长度。转移方程如果nums[i] nums[i-1]则dp[i] dp[i-1] 1否则dp[i] 1。这甚至可以用一个变量代替dp数组在遍历过程中维护当前连续长度和最大长度即可。时间复杂度O(n)。问题B最长递增子序列的个数这是LIS的一个变体LeetCode第673题。不仅要求长度还要求数量。状态定义需要两个数组。lengths[i]记录以nums[i]结尾的LIS长度counts[i]记录以nums[i]结尾的、长度为lengths[i]的LIS的个数。转移方程在标准的LIS DP过程中当发现nums[j] nums[i]时如果lengths[j] 1 lengths[i]说明找到了更长的序列更新lengths[i] lengths[j] 1并且counts[i] counts[j]重新开始计数。如果lengths[j] 1 lengths[i]说明找到了另一条路径能达到相同长度则counts[i] counts[j]。最终找到最大的LIS长度maxLen然后将所有lengths[i] maxLen的counts[i]累加即为总个数。通过这两个例子可以看到动态规划就像一个乐高积木基础的状态定义和转移思想是通用的针对不同的问题需求我们在这个基础上增加状态维度如增加counts数组或修改转移条件如连续序列就能构建出解决新问题的模型。这份灵活运用模板的能力才是算法学习的终极目标。而那份C/C框架PDF则是为了让你在构建更大的软件系统时也能拥有这样一份“可组合、可选型”的工具箱从而更从容地应对复杂的技术挑战。

相关新闻