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

资讯详情

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

动态规划入门:从最长不下降子序列问题理解状态设计与转移

动态规划入门:从最长不下降子序列问题理解状态设计与转移 1. 项目概述从“最长不下降序列”到动态规划思维的构建看到“信息学奥赛一本通 1259【例9.3】求最长不下降序列”这个标题很多初次接触动态规划的同学可能会心头一紧。这不就是经典的“最长上升子序列”问题吗没错但“不下降”这个微小的变化恰恰是理解动态规划边界条件和状态转移方程精妙之处的绝佳入口。这道题不仅是信息学奥赛的经典例题更是动态规划入门后从“看懂”到“会用”再到“能变通”的关键一步。我当年备赛时也是在这类问题上反复琢磨才真正把动态规划从“背模板”变成了“一种思考方式”。简单来说这道题要求我们从一个给定的数字序列中找出一个最长的子序列使得这个子序列中的元素从左到右是“不下降”的即后一个元素不小于前一个元素。例如序列[3, 1, 2, 1, 8, 5, 6]的最长不下降子序列之一是[1, 2, 5, 6]长度为4。解决这个问题最核心、最经典的方法就是动态规划。它不像暴力搜索那样穷举所有子序列时间复杂度是O(2^n)的指数级完全不可接受而是通过一种“记录历史递推未来”的智慧将时间复杂度优化到O(n²)对于n1000级别的数据量也能轻松应对。这篇文章我将以一个过来人的身份不仅带你一步步推导出这道题的标准解法更会深入拆解动态规划背后的思维过程我们是如何定义状态的状态转移方程是怎么“想”出来的如何记录并输出具体的序列而不仅仅是长度最后我还会分享几种优化思路和实际编码中极易踩坑的细节。无论你是正在刷《信息学奥赛一本通》的选手还是对算法感兴趣的开发者相信这篇结合了原理、实操与心得的详解能帮你把“最长不下降序列”这个问题吃透并建立起解决一类动态规划问题的通用思维框架。2. 核心思路拆解动态规划的状态设计与转移逻辑动态规划之所以让初学者感到抽象往往是因为卡在了“状态定义”这一步。我们不要一上来就想着方程先回到问题本身用最朴素的思路去理解。2.1 问题重述与暴力搜索的局限给定一个长度为N的整数序列A例如A [3, 1, 2, 1, 8, 5, 6]。我们需要找到最长的一个子序列其元素满足A[i] A[j]对于子序列中任意两个相邻的原始下标i, j且 i j。注意子序列不要求连续这是和子数组最大的区别。最笨的方法是枚举所有可能的子序列。一个长度为N的序列其子序列个数是2^N个每个元素选或不选。当N20时这已经是百万级别N30时超过十亿。这显然不是竞赛或工程中能接受的算法。我们需要更聪明的方法而动态规划的核心思想就是避免重复计算。2.2 动态规划的状态定义以终为始的思考动态规划的关键是设计一个状态数组用来描述问题的某个“子问题”的最优解。对于序列问题一个非常自然的想法是让状态与序列的前缀即前i个元素相关。我们定义dp[i]表示以第i个元素A[i]为结尾的所有不下降子序列中最长的那个子序列的长度。注意这里的状态定义是“以A[i]结尾”。这是解决LIS最长上升/不下降子序列类问题最经典、也最核心的状态定义方式。为什么不是“前i个元素中最长不下降子序列的长度”呢因为那样定义我们很难写出从dp[i-1]到dp[i]的转移方程——我们不知道前一个状态对应的子序列最后一个元素是多少从而无法判断A[i]能否接在后面。而以A[i]结尾就固定了子序列的最后一个元素为状态转移创造了条件。举个例子对于序列A [3, 1, 2, 1, 8, 5, 6]dp[0]以A[0]3结尾的最长不下降子序列就是[3]长度为1。dp[1]以A[1]1结尾。它前面只有A[0]3由于1 3不能接在后面所以只能自己作为一个序列长度为1。dp[2]以A[2]2结尾。它前面有A[0]3不能接因为23不满足不下降和A[1]1可以接因为12。接在A[1]后面长度就是dp[1]12。所以dp[2]2对应的序列是[1, 2]。2.3 状态转移方程的推导有了状态定义转移方程就呼之欲出了。对于每个位置i我们需要考虑在它之前的所有位置j0 j i。转移逻辑如果A[j] A[i]满足“不下降”条件那么A[i]就可以接在以A[j]结尾的那个最长不下降子序列后面形成一个以A[i]结尾的、新的不下降子序列其长度就是dp[j] 1。我们遍历所有满足条件的j取dp[j] 1的最大值作为dp[i]的值。如果所有j都不满足条件即A[i]比前面的都小那么A[i]只能自己作为一个序列开头此时dp[i] 1。因此状态转移方程为dp[i] max{ dp[j] 1 }其中0 j i且A[j] A[i]同时dp[i]至少为1所以最终是dp[i] max(1, max{ dp[j] 1 })。初始化每个位置初始时至少可以以自己为结尾形成一个长度为1的子序列所以dp数组全部初始化为1。最终答案整个序列的最长不下降子序列长度就是dp数组中的最大值即ans max(dp[0], dp[1], ..., dp[N-1])。2.4 记录路径如何输出具体的序列题目不仅要求长度还要求输出任意一个最长的序列。这就需要我们在状态转移时额外记录信息。最常用的方法是使用一个pre数组或称father数组。我们定义pre[i]表示在以A[i]结尾的最长不下降子序列中A[i]的前一个元素的下标。如果A[i]是子序列的第一个元素即dp[i]1那么pre[i] -1或一个特殊值如i。如何更新pre[i]在计算dp[i]时当我们发现通过某个j能获得更大的dp[i]值即dp[j] 1 dp[i]我们不仅要更新dp[i] dp[j] 1同时要记录pre[i] j。这意味着我们选择了接在A[j]后面。如何输出序列找到dp值最大的下标maxIndex。从这个下标开始根据pre数组不断向前回溯maxIndex - pre[maxIndex] - pre[pre[maxIndex]] - ...直到遇到-1。回溯过程中经过的下标对应的A中的元素逆序后就是我们要找的一个最长不下降子序列。3. 详细实现步骤与代码解析理解了原理我们来看具体的代码实现。我会用C语言进行演示因为这是信息学奥赛的主要语言其思想可以平移到任何语言。3.1 基础版本O(n²)动态规划这是最直接、最易于理解的实现对应上述的思路。#include iostream #include vector #include algorithm using namespace std; int main() { int n; cin n; vectorint a(n); for (int i 0; i n; i) { cin a[i]; } // dp[i]: 以a[i]结尾的最长不下降子序列长度 vectorint dp(n, 1); // pre[i]: 记录路径以a[i]结尾的最优序列中前一个元素的下标 vectorint pre(n, -1); // 动态规划填表 for (int i 0; i n; i) { for (int j 0; j i; j) { // 注意条件不下降是 a[j] a[i] if (a[j] a[i]) { // 如果找到更长的可能 if (dp[j] 1 dp[i]) { dp[i] dp[j] 1; pre[i] j; // 记录前驱 } } } } // 找到最长长度及其结束位置 int maxLen 0; int maxIndex 0; for (int i 0; i n; i) { if (dp[i] maxLen) { maxLen dp[i]; maxIndex i; } } // 输出长度 cout max maxLen endl; // 通过前驱数组回溯得到序列此时是逆序的 vectorint path; int cur maxIndex; while (cur ! -1) { path.push_back(a[cur]); cur pre[cur]; } // 逆序输出 reverse(path.begin(), path.end()); for (int i 0; i path.size(); i) { cout path[i]; if (i ! path.size() - 1) cout ; } cout endl; return 0; }代码要点解析输入处理首先读入序列长度n和序列a。初始化dp数组全部初始化为1pre数组初始化为-1。双重循环外层循环i遍历每个元素内层循环j遍历i之前的所有元素寻找可以接在后面的位置。状态转移与路径记录在满足a[j] a[i]的条件下如果dp[j]1能更新dp[i]则同时更新dp[i]和pre[i]。查找结果遍历dp数组找到最大值及其下标。路径回溯与输出从maxIndex开始根据pre数组向前回溯将元素存入path最后逆序输出。这个算法的时间复杂度是O(n²)空间复杂度是O(n)。对于《信息学奥赛一本通》中该题目的典型数据范围n 1000这个解法是完全足够的。3.2 输出格式的特别注意事项原题“1259”的输出要求是第一行输出长度格式如max6第二行输出该序列数字之间用空格隔开。上面的代码严格遵循了这个格式。在实际做题时务必仔细阅读题目输出要求一个空格或换行的错误都可能导致丢分。我建议在本地调试时将样例输入输出的格式复制过来进行对比测试。4. 算法优化从O(n²)到O(n log n)的思路虽然O(n²)的解法对于本题已足够但了解更优的算法对于提升思维和应对更大数据量至关重要。最长不下降子序列问题存在一种O(n log n)的优化算法它基于贪心二分查找的思想。4.1 优化算法的核心思想我们维护一个数组d或者叫low数组。d[i]的定义是所有长度为i的不下降子序列中末尾元素的最小值。这个定义非常巧妙。因为对于相同长度的子序列末尾元素越小未来扩展的可能性就越大更容易让后面的元素接上。维护过程初始化d[1] a[0]长度len 1。遍历原序列a中的每个元素x a. 如果x d[len]说明x可以接在当前最长子序列后面形成更长的子序列。那么d[len] x。 b. 否则x d[len]我们在d[1...len]数组中找到第一个大于x的元素并用x替换它。因为d数组是单调不下降的所以可以用二分查找时间复杂度O(log n)。为什么可以替换假设d[k]是第一个大于x的元素。用x替换d[k]意味着我们找到了一个长度为k的不下降子序列其末尾元素比之前记录的更小x d[k]这为未来构造更长的子序列提供了更好的基础。这个替换操作并没有改变当前已发现的最长长度但优化了潜在子序列的“潜力”。4.2 O(n log n)算法实现与路径记录难点#include iostream #include vector #include algorithm using namespace std; int main() { int n; cin n; vectorint a(n); for (int i 0; i n; i) cin a[i]; vectorint d; // d[i] 表示长度为i1的子序列末尾最小值 vectorint pos(n); // 记录a[i]在d数组中的插入位置长度-1 for (int i 0; i n; i) { // 在d中二分查找第一个 a[i] 的位置 (对于不下降序列我们找第一个 a[i] 的) // 如果要求严格上升则找第一个 a[i] 的 auto it upper_bound(d.begin(), d.end(), a[i]); int k it - d.begin(); // a[i]应该放入d中的位置索引 pos[i] k; // 记录a[i]最终构成了多长的序列以0为起始的索引实际长度为k1 if (it d.end()) { // 如果a[i]比d中所有元素都大或不小于则扩展d d.push_back(a[i]); } else { // 否则替换掉那个比它大的最小元素 *it a[i]; } } int maxLen d.size(); cout max maxLen endl; // 输出序列注意此法输出的不一定是正确的原序列仅能输出一个合法序列 // 为了正确输出通常需要配合另一个数组来回溯较为复杂。 // 简单输出d数组的内容这是一个合法的最长不下降子序列但未必是原序列的子序列 for (int i 0; i maxLen; i) { cout d[i]; if (i ! maxLen - 1) cout ; } cout endl; return 0; }重要提示这个O(n log n)的算法在仅求长度时非常高效并且d数组最终存储的也是一个合法的、最长的不下降子序列。但是d数组存储的序列并不一定是原序列的一个子序列因为元素被替换了。如果题目要求输出原序列中的具体元素如本题单纯使用d数组是无法正确回溯的。需要配合pos数组和更复杂的反向推导才能得到路径其实现复杂度远高于O(n²)的路径记录法。实操心得在竞赛或面试中如果只要求长度果断使用O(n log n)的贪心二分法。如果要求输出具体序列且数据量不大n 5000使用O(n²)的经典动态规划搭配pre数组是更稳妥、更清晰的选择。不要为了追求时间复杂度而引入不必要的实现复杂性和出错风险。5. 常见问题、调试技巧与思维扩展5.1 易错点与边界条件“不下降”与“上升”的条件混淆这是最经典的错误。最长不下降子序列的条件是a[j] a[i]而最长严格上升子序列的条件是a[j] a[i]。一字之差代码和结果完全不同。审题时务必圈出关键词。dp数组初始化必须全部初始化为1。因为每个元素自身就是一个长度为1的子序列。路径回溯的终点pre数组初始化为-1回溯时判断条件为while(cur ! -1)。如果初始化为0则可能陷入死循环。输出格式严格按照题目要求包括“max”这样的前缀和空格。可以在本地用文件输入输出重定向进行测试。下标从0还是1开始根据个人习惯。如果从1开始读入和循环时要注意对应pre数组的初始值也要相应调整如0表示无前驱。保持一致性是关键。5.2 调试技巧打印中间状态对于小样例在双重循环中打印出每一步的i, j, dp[i], dp[j], pre[i]的值对照手动模拟的过程能快速定位逻辑错误。设计小样例单调序列[1,2,3,4,5]结果应为5。单调递减序列[5,4,3,2,1]结果应为1。有相等元素的序列[2,2,2,2]结果应为4测试不下降条件。混合序列[3,1,2,1,8,5,6]手动推导长度应为4如[1,2,5,6]或[1,2,8]注意[1,2,5,6]更长。验证路径得到最长序列后检查是否满足“不下降”条件并且序列中的元素是否都来自原序列的对应位置顺序一致。5.3 思维扩展变种问题彻底理解基础模型后可以尝试解决一些变种问题这也是动态规划举一反三能力的体现求最长上升子序列只需将状态转移条件中的a[j] a[i]改为a[j] a[i]。求最长不上升/下降子序列可以将序列反转或者将状态定义中的比较符号反向并重新思考转移过程。求序列的“不下降”最小划分Dilworth定理相关此问题可以转化为求最长上升子序列的长度。二维LIS例如“信封嵌套问题”需要先对一维排序然后在另一维上求LIS。带权值的LIS每个元素有一个权值求权值和最大的不下降子序列。此时dp[i]的定义需变为“以i结尾的、权值最大的不下降子序列的权值和”转移方程类似dp[i] max(dp[i], dp[j] weight[i])。5.4 从本题到动态规划思维的提升解完这道题不应该只记住代码。更重要的是提炼出解决动态规划问题的通用思维步骤这对我后续学习其他DP问题帮助巨大定义状态这是最难也最关键的一步。思考什么信息足以描述一个子问题并且易于递推。通常状态与问题的“规模”如序列长度、物品个数和“限制条件”有关。在序列问题中“以某个位置结尾”是一个非常有效的状态定义模式。确定状态转移方程思考如何通过更小规模子问题已计算好的状态的组合来得到当前状态的值。重点是找到那个“决策点”在这里就是“接在哪个j后面”。初始化确定最小子问题的解边界条件。对于序列问题通常单个元素就是最小子问题。确定计算顺序确保在计算一个状态时它所依赖的子状态都已经被计算过。对于线性序列从左到右遍历是自然的顺序。输出方案如果需要输出具体方案就在状态转移时同步记录“决策”即pre数组最后通过回溯还原路径。最后关于输出具体序列还有一个我常用的检查方法在回溯得到序列后除了检查是否不下降再检查一下序列中的每个元素在原序列中的下标是否也是递增的。这能确保你找到的确实是一个“子序列”而不仅仅是数值上满足条件的一组数。动态规划的精髓在于“状态”和“转移”把这两个概念内化很多问题都能迎刃而解。这道“求最长不下降序列”的题就是一个完美的起点。多手推几个例子多改几行代码试试不同的条件理解会深刻得多。
返回列表