ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/8/8 15:33:09 拓冰建站 浏览量
动态规划入门:从最长不下降子序列问题理解状态设计与转移

1. 项目概述:从“最长不下降序列”到动态规划思维的构建

看到“信息学奥赛一本通 1259:【例9.3】求最长不下降序列”这个标题,很多初次接触动态规划的同学可能会心头一紧。这不就是经典的“最长上升子序列”问题吗?没错,但“不下降”这个微小的变化,恰恰是理解动态规划边界条件和状态转移方程精妙之处的绝佳入口。这道题不仅是信息学奥赛的经典例题,更是动态规划入门后,从“看懂”到“会用”,再到“能变通”的关键一步。我当年备赛时,也是在这类问题上反复琢磨,才真正把动态规划从“背模板”变成了“一种思考方式”。

简单来说,这道题要求我们从一个给定的数字序列中,找出一个最长的子序列,使得这个子序列中的元素从左到右是“不下降”的,即后一个元素不小于前一个元素。例如,序列[3, 1, 2, 1, 8, 5, 6]的最长不下降子序列之一是[1, 2, 5, 6],长度为4。解决这个问题最核心、最经典的方法就是动态规划。它不像暴力搜索那样穷举所有子序列(时间复杂度是O(2^n)的指数级,完全不可接受),而是通过一种“记录历史,递推未来”的智慧,将时间复杂度优化到O(n²),对于n=1000级别的数据量也能轻松应对。

这篇文章,我将以一个过来人的身份,不仅带你一步步推导出这道题的标准解法,更会深入拆解动态规划背后的思维过程:我们是如何定义状态的?状态转移方程是怎么“想”出来的?如何记录并输出具体的序列,而不仅仅是长度?最后,我还会分享几种优化思路和实际编码中极易踩坑的细节。无论你是正在刷《信息学奥赛一本通》的选手,还是对算法感兴趣的开发者,相信这篇结合了原理、实操与心得的详解,能帮你把“最长不下降序列”这个问题吃透,并建立起解决一类动态规划问题的通用思维框架。

2. 核心思路拆解:动态规划的状态设计与转移逻辑

动态规划之所以让初学者感到抽象,往往是因为卡在了“状态定义”这一步。我们不要一上来就想着方程,先回到问题本身,用最朴素的思路去理解。

2.1 问题重述与暴力搜索的局限

给定一个长度为N的整数序列A(例如A = [3, 1, 2, 1, 8, 5, 6])。我们需要找到最长的一个子序列,其元素满足A[i] <= A[j](对于子序列中任意两个相邻的原始下标i, j,且 i < j)。注意,子序列不要求连续,这是和子数组最大的区别。

最笨的方法是枚举所有可能的子序列。一个长度为N的序列,其子序列个数是2^N个(每个元素选或不选)。当N=20时,这已经是百万级别;N=30时,超过十亿。这显然不是竞赛或工程中能接受的算法。我们需要更聪明的方法,而动态规划的核心思想就是避免重复计算

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(不能接,因为2<3不满足不下降)和A[1]=1(可以接,因为1<=2)。接在A[1]后面,长度就是dp[1]+1=2。所以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 < iA[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; vector<int> a(n); for (int i = 0; i < n; ++i) { cin >> a[i]; } // dp[i]: 以a[i]结尾的最长不下降子序列长度 vector<int> dp(n, 1); // pre[i]: 记录路径,以a[i]结尾的最优序列中,前一个元素的下标 vector<int> 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; // 通过前驱数组回溯,得到序列(此时是逆序的) vector<int> 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; }

代码要点解析

  1. 输入处理:首先读入序列长度n和序列a
  2. 初始化dp数组全部初始化为1,pre数组初始化为-1。
  3. 双重循环:外层循环i遍历每个元素,内层循环j遍历i之前的所有元素,寻找可以接在后面的位置。
  4. 状态转移与路径记录:在满足a[j] <= a[i]的条件下,如果dp[j]+1能更新dp[i],则同时更新dp[i]pre[i]
  5. 查找结果:遍历dp数组找到最大值及其下标。
  6. 路径回溯与输出:从maxIndex开始,根据pre数组向前回溯,将元素存入path,最后逆序输出。

这个算法的时间复杂度是O(n²),空间复杂度是O(n)。对于《信息学奥赛一本通》中该题目的典型数据范围(n <= 1000),这个解法是完全足够的。

3.2 输出格式的特别注意事项

原题“1259”的输出要求是:第一行输出长度,格式如max=6;第二行输出该序列,数字之间用空格隔开。上面的代码严格遵循了这个格式。在实际做题时,务必仔细阅读题目输出要求,一个空格或换行的错误都可能导致丢分。我建议在本地调试时,将样例输入输出的格式复制过来进行对比测试。

4. 算法优化:从O(n²)到O(n log n)的思路

虽然O(n²)的解法对于本题已足够,但了解更优的算法对于提升思维和应对更大数据量至关重要。最长不下降子序列问题存在一种O(n log n)的优化算法,它基于贪心+二分查找的思想。

4.1 优化算法的核心思想

我们维护一个数组d(或者叫low数组)。d[i]的定义是:所有长度为i的不下降子序列中,末尾元素的最小值

这个定义非常巧妙。因为对于相同长度的子序列,末尾元素越小,未来扩展的可能性就越大(更容易让后面的元素接上)。

维护过程

  1. 初始化d[1] = a[0],长度len = 1
  2. 遍历原序列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; vector<int> a(n); for (int i = 0; i < n; ++i) cin >> a[i]; vector<int> d; // d[i] 表示长度为i+1的子序列末尾最小值 vector<int> 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为起始的索引,实际长度为k+1) 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 易错点与边界条件

  1. “不下降”与“上升”的条件混淆:这是最经典的错误。最长不下降子序列的条件是a[j] <= a[i],而最长严格上升子序列的条件是a[j] < a[i]。一字之差,代码和结果完全不同。审题时务必圈出关键词。
  2. dp数组初始化:必须全部初始化为1。因为每个元素自身就是一个长度为1的子序列。
  3. 路径回溯的终点pre数组初始化为-1,回溯时判断条件为while(cur != -1)。如果初始化为0,则可能陷入死循环。
  4. 输出格式:严格按照题目要求,包括“max=”这样的前缀和空格。可以在本地用文件输入输出重定向进行测试。
  5. 下标从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 思维扩展:变种问题

彻底理解基础模型后,可以尝试解决一些变种问题,这也是动态规划举一反三能力的体现:

  1. 求最长上升子序列:只需将状态转移条件中的a[j] <= a[i]改为a[j] < a[i]
  2. 求最长不上升/下降子序列:可以将序列反转,或者将状态定义中的比较符号反向,并重新思考转移过程。
  3. 求序列的“不下降”最小划分(Dilworth定理相关):此问题可以转化为求最长上升子序列的长度。
  4. 二维LIS:例如“信封嵌套问题”,需要先对一维排序,然后在另一维上求LIS。
  5. 带权值的LIS:每个元素有一个权值,求权值和最大的不下降子序列。此时dp[i]的定义需变为“以i结尾的、权值最大的不下降子序列的权值和”,转移方程类似:dp[i] = max(dp[i], dp[j] + weight[i])

5.4 从本题到动态规划思维的提升

解完这道题,不应该只记住代码。更重要的是提炼出解决动态规划问题的通用思维步骤,这对我后续学习其他DP问题帮助巨大:

  1. 定义状态:这是最难也最关键的一步。思考什么信息足以描述一个子问题,并且易于递推。通常状态与问题的“规模”(如序列长度、物品个数)和“限制条件”有关。在序列问题中,“以某个位置结尾”是一个非常有效的状态定义模式。
  2. 确定状态转移方程:思考如何通过更小规模子问题(已计算好的状态)的组合,来得到当前状态的值。重点是找到那个“决策点”(在这里就是“接在哪个j后面”)。
  3. 初始化:确定最小子问题的解(边界条件)。对于序列问题,通常单个元素就是最小子问题。
  4. 确定计算顺序:确保在计算一个状态时,它所依赖的子状态都已经被计算过。对于线性序列,从左到右遍历是自然的顺序。
  5. 输出方案:如果需要输出具体方案,就在状态转移时同步记录“决策”(即pre数组),最后通过回溯还原路径。

最后,关于输出具体序列,还有一个我常用的检查方法:在回溯得到序列后,除了检查是否不下降,再检查一下序列中的每个元素在原序列中的下标是否也是递增的。这能确保你找到的确实是一个“子序列”,而不仅仅是数值上满足条件的一组数。动态规划的精髓在于“状态”和“转移”,把这两个概念内化,很多问题都能迎刃而解。这道“求最长不下降序列”的题,就是一个完美的起点。多手推几个例子,多改几行代码试试不同的条件,理解会深刻得多。