动态规划解最长不下降子序列:深入理解状态转移与内层循环

一、问题引入

最长不下降子序列(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;
}

优化思路

  1. 维护数组 d,其中 d[k]表示长度为 k+1的不下降子序列的最小末尾值

  2. 对于每个元素 x

    • 如果 x大于等于 d的最后一个元素,将 x添加到 d末尾

    • 否则,在 d中找到第一个大于 x的元素,用 x替换它

  3. 最终 d的长度就是最长不下降子序列的长度

七、总结与思考

最长不下降子序列问题很好地展示了动态规划的思想:

  1. 状态定义要准确dp[i]表示以第 i个元素结尾的最长不下降子序列长度

  2. 状态转移要全面:必须考虑所有可能的前驱,因为子序列可以不连续

  3. 内层循环是必要的:它确保了不会错过任何可能的更优解

  4. 可以进一步优化:通过维护单调数组和二分查找,可以将复杂度从 O(n²) 降到 O(n log n)

关键理解:内层循环比较所有前驱,是解决"子序列可以不连续"这一特性的必然要求。虽然增加了时间复杂度,但保证了算法的正确性。

通过这个问题,我们不仅学会了如何解决最长不下降子序列问题,更重要的是理解了动态规划的核心思想:将大问题分解为相互关联的子问题,通过解决子问题来构建原问题的解。这种思想在许多其他问题中也有广泛应用,值得我们深入掌握。

--------豪

2026.3.23 0:51

相关推荐
小王不爱笑1324 天前
Java 代理模式与 AOP 底层
java·开发语言·代理模式
青主创享阁5 天前
玄晶引擎XgenCore Works V2.8.1升级解析 技术迭代赋能场景 代理模式全新落地
大数据·人工智能·代理模式
阿猿收手吧!6 天前
【C++】建造者与代理模式实战解析
开发语言·c++·代理模式
Real-Staok7 天前
(集合)C / C++ 设计模式综合
单例模式·设计模式·代理模式
sg_knight7 天前
设计模式实战:代理模式(Proxy)
python·设计模式·代理模式·proxy
小江的记录本8 天前
【AOP】AOP-面向切面编程 (系统性知识体系全解)
java·前端·后端·python·网络协议·青少年编程·代理模式
学嵌入式的小杨同学9 天前
STM32 进阶封神之路(五):库函数移植全解析 —— 从底层原理到移植实操(含环境适配 + 报错解决)
vscode·单片机·嵌入式硬件·代理模式·智能硬件·pcb工艺·嵌入式实时数据库
weixin_403810139 天前
EasyClick iOS USB版本蓝牙点击坐标代码
ios·自动化·代理模式
蜜獾云9 天前
设计模式之代理模式:本地接口代理远程接口的调用
设计模式·系统安全·代理模式