一、问题引入
最长不下降子序列(Longest Non-Decreasing Subsequence,简称LNDS)是一个经典的算法问题。给定一个数字序列,我们需要找到一个子序列(可以不连续),使得这个子序列中的元素是非严格递增的(即每个元素大于等于前一个元素),并且这个子序列的长度尽可能长。
示例:
cpp
序列:13, 7, 9, 16, 38, 24, 37, 18, 44, 18, 21, 22, 63, 15
最长不下降子序列:[7, 9, 16, 24, 37, 44, 63] 或 [7, 9, 16, 18, 21, 22, 63] 等
长度:7
二、为什么用动态规划?
动态规划适用于具有"最优子结构"和"重叠子问题"特征的问题。最长不下降子序列问题正好具备这两个特征:
2.1 最优子结构
以某个元素结尾的最长不下降子序列,可以由更短序列的最长不下降子序列推导出来。
例如,在序列 [3, 1, 2, 4]中:
-
以4结尾的最长不下降子序列是
[1, 2, 4] -
这个序列去掉4后得到的
[1, 2]正是以2结尾的最长不下降子序列
2.2 重叠子问题
计算不同位置结尾的最长不下降子序列时,会反复用到相同的子问题解。
例如,计算以4结尾和以5结尾的最长不下降子序列时,都需要知道以3结尾的最长不下降子序列长度。
三、动态规划解法详解
3.1 状态定义
定义 dp[i]表示以序列中第 i个元素结尾的最长不下降子序列的长度。
为什么这样定义?
因为"以某个元素结尾"是一个很好的状态表示,它满足:
-
可计算:可以从之前的状态推导出来
-
完备:最终答案就是所有
dp[i]中的最大值 -
无后效:
dp[i]只依赖于i之前的状态
3.2 状态转移方程
状态转移方程是动态规划的核心。对于每个位置 i:
cpp
dp[i] = max{1, dp[j] + 1 | 1 ≤ j < i 且 a[i] ≥ a[j]}
这个方程怎么理解?
-
1:至少可以只包含自己 -
dp[j] + 1:如果a[i]可以接在a[j]后面(即a[i] ≥ a[j]),那么长度可以是dp[j] + 1 -
max:在所有可能的情况中取最大值
四、关键问题:为什么需要比较所有前驱?
这是理解这个问题的关键点。内层循环比较所有 j < i是必须的,原因有三:
4.1 子序列可以不连续
这是最核心的原因。由于子序列可以不连续,当前元素可以跳过中间元素,接在任何满足条件的前驱后面。
示例 :[1, 2, 3, 4, 5,1, 2, 3, 4, 6]
当处理6时:
-
可以跳过1234,接在12345后面:
[1, 2,3, 4, 5,6] -
也可以直接,接在1234后面:
[1, 2, 3,4, 6]
如果不比较所有前驱,只和前一个元素比较,就会错过第一种可能。
4.2 序列可能有多个上升段
序列中可能有多个上升段,当前元素应接在能给出最长序列的那个上升段的末尾。
示例 :[1, 2, 3, 4, 1, 2, 3,5]
-
第一段:1, 2, 3
-
第二段:1, 2, 3, 4
当处理5时:
-
如果只和前一个元素(3)比较,得到
[1, 2, 3, 5],长度4 -
但实际上,4可以接在第一段的4后面,形成
[1, 2, 3, 4, 5],长度5
4.3 前驱数值的限制
即使某个前驱的 dp值很大,但当前元素可能无法接在它后面。
示例 :[1, 100, 2, 3, 4]
-
dp[2](对应100)的值是2 -
但4不能接在100后面(4 < 100)
-
4可以接在3后面,形成
[1, 2, 3, 4]
五、代码实现与解释
5.1 基础版本
cpp
#include<iostream>
#include<algorithm>
using namespace std;
int main()
{
int dp[10010];
int a[10010];
int n;
cin >> n;
int i = 1;
for (i = 1; i <= n; i++)
{
cin >> a[i];
}
int res=0;
for (i = 1; i <= n; i++)
{
dp[i] = 1;
int j = 1;
for (j = 1; j < i; j++)//注意这里不能写=i,不然会和自己本身的d[i]+1比较多加一个
{
if (a[i] >= a[j])
{
dp[i] = max(dp[i],dp[j]+1);//这里是为了确定下来每个dp[i]是什么
}
}
res = max(dp[i], res);//这里是比较不同的i,哪个个dp[i],比较大
}
cout << res;
return 0;
}
5.2 算法执行流程
以序列 [3, 1, 2, 4]为例,算法执行过程如下:
| i | a[i] | 计算过程 | dp[i] | 说明 |
|---|---|---|---|---|
| 1 | 3 | 初始化为1 | 1 | 只有自己 |
| 2 | 1 | 1<3,无法接在3后面 | 1 | 只能自己开头 |
| 3 | 2 | 2<3(否), 2≥1(是) → max(1, 1+1)=2 | 2 | 接在1后面 |
| 4 | 4 | 4≥3→2, 4≥1→2, 4≥2→3 → max(1,2,2,3)=3 | 3 | 接在2后面得到最长 |
最终结果为 max(1,1,2,3) = 3。
六、算法优化
6.1 时间复杂度分析
-
外层循环:O(n)
-
内层循环:平均O(n/2)
-
总时间复杂度:O(n²)
当n较大时(如n>10000),可能会超时。
6.2 优化版本(O(n log n))
cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int main() {
int n;
cin >> n;
vector<int> d; // d[i]表示长度为i+1的不下降子序列的最小末尾值
for(int i = 0; i < n; i++) {
int x;
cin >> x;
// 找到第一个大于x的位置
auto it = upper_bound(d.begin(), d.end(), x);
if(it == d.end()) {
d.push_back(x); // x可以扩展最长子序列
} else {
*it = x; // 用x替换第一个大于它的元素
}
}
cout << d.size() << endl;
return 0;
}
优化思路:
-
维护数组
d,其中d[k]表示长度为k+1的不下降子序列的最小末尾值 -
对于每个元素
x:-
如果
x大于等于d的最后一个元素,将x添加到d末尾 -
否则,在
d中找到第一个大于x的元素,用x替换它
-
-
最终
d的长度就是最长不下降子序列的长度
七、总结与思考
最长不下降子序列问题很好地展示了动态规划的思想:
-
状态定义要准确 :
dp[i]表示以第i个元素结尾的最长不下降子序列长度 -
状态转移要全面:必须考虑所有可能的前驱,因为子序列可以不连续
-
内层循环是必要的:它确保了不会错过任何可能的更优解
-
可以进一步优化:通过维护单调数组和二分查找,可以将复杂度从 O(n²) 降到 O(n log n)
关键理解:内层循环比较所有前驱,是解决"子序列可以不连续"这一特性的必然要求。虽然增加了时间复杂度,但保证了算法的正确性。
通过这个问题,我们不仅学会了如何解决最长不下降子序列问题,更重要的是理解了动态规划的核心思想:将大问题分解为相互关联的子问题,通过解决子问题来构建原问题的解。这种思想在许多其他问题中也有广泛应用,值得我们深入掌握。
--------豪
2026.3.23 0:51