前言
最长上升子序列(Longest Increasing Subsequence,LIS) 是算法面试和动态规划学习中的经典必考题。它不仅能考察我们对动态规划思想的理解,还能延伸出更高效的贪心 + 二分解法。
本文将基于你提供的 C++ 代码,从错误思路分析到正确解法,手把手带你彻底搞懂最长上升子序列问题,包含完整代码、逐行解析、复杂度分析,新手也能轻松掌握。
一、问题描述
给定一个无序的整数数组 ,找到其中最长上升子序列的长度。
- 上升:要求子序列中元素严格递增
- 子序列:不要求连续,但元素相对顺序不能改变
- 只需输出长度,无需输出具体序列
示例 :输入:[10, 9, 2, 5, 3, 7, 101, 18]输出:4解释:最长上升子序列为 [2, 3, 7, 101],长度为 4。
二、核心思路
1. 动态规划定义
我们定义:dp[i] 表示以数组中第 i 个元素结尾的最长上升子序列长度。
2. 状态转移方程
- 每个元素自身就是一个长度为 1 的子序列,所以
dp[i]初始值 = 1 - 对于每个位置
i,遍历它前面所有位置j(j < i)- 如果
ar[i] > ar[j],说明可以把ar[i]接在以ar[j]结尾的上升子序列后面 - 此时
dp[i] = max(dp[i], dp[j] + 1)
- 如果
最终答案 = dp 数组中的最大值。
三、代码实现与深度解析
版本 1:错误思路分析(MaxLengthSub1)
很多初学者最容易写出这个版本,只比较相邻元素,这是典型错误!
cpp
// 错误版本:只比较相邻元素
int MaxLengthSub1(const std::vector<int>& ar)
{
int n = ar.size();
if (0 == n) return 0;
if (1 == n) return 1;
std::vector<int> dp(n, 0);
dp[0] = 1;
for (int i = 1; i < n; ++i)
{
// 错误:只看前一个,不看前面所有
dp[i] = std::max(1, dp[i - 1] + (ar[i] > ar[i - 1] ? 1 : 0));
}
PrintVec(dp);
return dp[n - 1];
}
❌ 错误原因
- 最长上升子序列不一定是连续的
- 只比较
i和i-1,会漏掉前面所有能和当前元素形成上升序列的位置 - 例如:
[2,5,3],正确 LIS 是[2,3],但此代码会错误认为是[2,5]
版本 2:标准动态规划解法(MaxLengthSub2)
这是正确、通用、面试必写的 O (n²) 解法,完全符合题目要求:
cpp
// 正确版本:动态规划 O(n²)
int MaxLengthSub2(const std::vector<int>& ar)
{
int n = ar.size();
if (0 == n) return 0; // 空数组返回0
if (1 == n) return 1; // 一个元素长度为1
std::vector<int> dp(n, 0); // dp[i] = 以i结尾的最长上升子序列长度
int maxsub = 1; // 记录最终答案
for (int i = 0; i < n; ++i)
{
dp[i] = 1; // 每个元素自身长度为1
// 遍历 i 前面所有元素 j
for (int j = 0; j < i; ++j)
{
// 当前元素 > 前面元素,可以接在后面
if (ar[i] > ar[j])
{
dp[i] = std::max(dp[j] + 1, dp[i]);
}
}
// 更新全局最大值
if (dp[i] > maxsub)
{
maxsub = dp[i];
}
}
return maxsub;
}
✅ 逐行解析
- 边界处理
- 数组为空 → 返回 0
- 只有一个元素 → 最长子序列就是自己,返回 1
- dp 数组初始化
dp[i]初始化为 1,每个元素单独构成长度为 1 的序列
- 双层循环
- 外层
i:遍历每个元素作为结尾 - 内层
j:遍历i前面所有元素 - 满足
ar[i] > ar[j]时更新dp[i]
- 外层
- 记录最大值
- 最终答案不是
dp[n-1],而是 dp 数组中的最大值
- 最终答案不是
四、完整可运行代码
cpp
#include<stdio.h>
#include<iostream>
#include<assert.h>
#include<stdlib.h>
#include<string.h>
#include<limits.h>
#include<float.h>
#include<stack>
#include<queue>
#include<vector>
#include<algorithm>
using namespace std;
// 打印数组,方便调试观察dp数组变化
void PrintVec(const std::vector<int>& ar)
{
int n = ar.size();
for (int i = 0; i < n; ++i)
{
printf("%5d", ar[i]);
}
printf("\n------------------------------------\n");
}
// 正确解法:动态规划 O(n²)
int MaxLengthSub2(const std::vector<int>& ar)
{
int n = ar.size();
if (0 == n) return 0;
if (1 == n) return 1;
std::vector<int> dp(n, 0);
int maxsub = 1;
for (int i = 0; i < n; ++i)
{
dp[i] = 1;
for (int j = 0; j < i; ++j)
{
if (ar[i] > ar[j])
{
dp[i] = std::max(dp[j] + 1, dp[i]);
}
}
PrintVec(dp);
if (dp[i] > maxsub)
{
maxsub = dp[i];
}
}
return maxsub;
}
int main()
{
std::vector<int> ar = { 10,9,2,5,3,7,101,18 };
int maxlen = MaxLengthSub2(ar);
cout << "最长上升子序列长度:" << maxlen << endl;
return 0;
}
五、运行结果
bash
1 0 0 0 0 0 0 0
------------------------------------
1 1 0 0 0 0 0 0
------------------------------------
1 1 1 0 0 0 0 0
------------------------------------
1 1 1 2 0 0 0 0
------------------------------------
1 1 1 2 2 0 0 0
------------------------------------
1 1 1 2 2 3 0 0
------------------------------------
1 1 1 2 2 3 4 0
------------------------------------
1 1 1 2 2 3 4 4
------------------------------------
最长上升子序列长度:4
✅ 输出结果完全正确!
六、进阶扩展:O (n log n) 最优解法
面试中如果要求更优时间复杂度 ,可以使用贪心 + 二分查找,效率更高:
// 最优解法:贪心 + 二分 O(nlogn)
int lengthOfLIS(vector<int>& nums) {
vector<int> res;
for (int x : nums) {
auto it = lower_bound(res.begin(), res.end(), x);
if (it == res.end()) res.push_back(x);
else *it = x;
}
return res.size();
}
核心思想
- 维护一个最小可能的上升序列
- 用二分查找加速替换 / 插入位置
- 适合数据量很大的场景(百万级别)
七、两种解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 优点 | 适用场景 |
|---|---|---|---|---|
| 动态规划 | O(n²) | O(n) | 思路直观、易理解、易写 | 笔试、入门、小数据 |
| 贪心 + 二分 | O(n log n) | O(n) | 效率极高 | 面试、大数据量 |
建议:
- 基础练习、考试:写 O (n²) 动态规划
- 面试优化:写 O (n log n) 贪心 + 二分
八、总结
-
最长上升子序列(LIS) 是动态规划经典题,核心是
dp[i]定义 + 状态转移 -
初学者常见错误:只比较相邻元素,正确做法是遍历前面所有元素
-
标准方程 :
cppdp[i] = 1; if(ar[i] > ar[j]) dp[i] = max(dp[i], dp[j]+1); -
最终答案 = dp 数组最大值,不是最后一个元素
-
进阶可学习贪心 + 二分优化到 O (n log n)
本文代码完整可直接运行,从错误到正确、从基础到进阶,非常适合 C++ 新手学习动态规划,建议收藏反复练习~