最长递增子序列 图解题解
这道题到底在问什么
- 输入
- nums=[10,9,2,5,3,7,101,18]
- 输出
- 4
最优解:为什么这么做
一句话答案:LeetCode 300 最长递增子序列的经典解法是动态规划:定义 dp[i] 为以 nums[i] 结尾的最长递增子序列长度,每个数往前找比它小的数、接上其中 dp 值最大的那条再加一,最终答案取整个 dp 数组的最大值。时间 O(n²)、空间 O(n);进阶可用贪心加二分维护尾巴数组,优化到 O(n log n)。
最长递增子序列到底在问什么
题目给一个数组 nums,要求挑出一个严格递增的子序列,返回它的最长长度。两个关键词都藏着坑:子序列不要求连续,可以跳着选,只要保持原数组里的先后顺序;严格递增意味着相等不算。比如 nums=[10,9,2,5,3,7,101,18],最长的一条是 2、5、7、101,答案是 4——这四个数并不相邻。
为什么暴力枚举子序列行不通
最直接的想法是枚举所有子序列逐个检查:每个数都有选或不选两种可能,n 个数就有 2 的 n 次方个子序列,指数级的量根本跑不完。慢的根源是大量重复劳动——很多子序列共享同一段前缀,它们的递增性被反复检查。
换个角度看:任何一条递增子序列总要以某个数收尾。而以 nums[i] 收尾的最优解,只取决于它前面那些更小的数各自的最优解——这个观察让问题可以从左往右递推,动态规划由此成形。
dp 数组为什么定义成以 i 结尾而不是前 i 个
dp[i] 的定义是:以 nums[i] 结尾的最长严格递增子序列的长度。注意不能定义成前 i 个数里的最长长度——那样的状态丢了子序列的结尾是谁,来了新数判断不了能不能接,转移写不出来。固定住结尾,新旧状态之间才能比较、才能拼接。
初始值是 dp 全填 1:任何一个数自己就是长度为 1 的子序列,这是每个位置的保底答案,写成 0 会从根上算错。
转移方程为什么成立
算 dp[i] 时,往前看每一个 j:只要 nums[j] < nums[i],就可以把 nums[i] 接在那条以 nums[j] 结尾的最长子序列后面,得到长度 dp[j] + 1。在所有合法的 j 里挑最大的那个,就是 dp[i] = max(dp[i], dp[j] + 1)。
它为什么对?因为以 nums[i] 结尾的任何一条递增子序列,去掉 nums[i] 之后,剩下的部分必然以某个更小的数 nums[j] 结尾,而且那部分不会比 dp[j] 更长——这就是无后效性:前面怎么选出来的不重要,只要知道以 j 结尾的最优长度就够了。把所有候选 j 都试一遍取最大,最优解一定不会漏。
答案为什么是 max(dp) 而不是最后一格
填完整个 dp 数组后,答案是 max(dp),不是 dp[n-1]。原因很简单:最长的递增子序列不一定以最后那个数结尾。上面的例子里最大值出现在 101 的位置,如果数组以一个很小的数收尾,最后一格甚至可能是 1。直接返回最后一格是这道题最常见的错误。
复杂度多少,还能不能更快
时间复杂度 O(n²):每个位置 i 都要往前扫一遍全部 j,两层循环。空间 O(n),一个 dp 数组。两个易错点再强调一次:严格递增的判断必须写 nums[j] < nums[i],写成小于等于会把相等的数也接进去;dp 初值必须是 1 不是 0。
面试若追问更优解法:维护 tails 数组,tails[k] 存长度为 k+1 的递增子序列的最小结尾,每个新数二分查找该替换或追加的位置,tails 的长度就是答案,整体 O(n log n)。先讲清 O(n²) 动态规划,再谈这个贪心加二分的优化,层次更好。
▶ 动画逐步走查(共 21 步)——想跟着动画一帧帧对照就展开
- 3核心一句:每个数往前找比它小的、接最长的那条 +1。下面每格都在套它。
- 4上行是原数组 nums(固定),下行 dp 待填。每个数结尾至少能自成长度 1。
- 5算 dp[0](数 10):往前看,没有比它小的数能接,只能自己起头,dp[0]=1。
- 6dp[0] 落子为 1(10 自成一条)。
- 7算 dp[1](数 9):往前看,没有比它小的数能接,只能自己起头,dp[1]=1。
- 8dp[1] 落子为 1(9 自成一条)。
- 9算 dp[2](数 2):往前看,没有比它小的数能接,只能自己起头,dp[2]=1。
- 10dp[2] 落子为 1(2 自成一条)。
- 11算 dp[3](数 5):往前找到比它小的 2(蓝格),挑它们里 dp 最大的接上,再 +1。
- 12dp[3] 落子为 2:把 5 接在 2(dp=1)后面最划算。
- 13算 dp[4](数 3):往前找到比它小的 2(蓝格),挑它们里 dp 最大的接上,再 +1。
- 14dp[4] 落子为 2:把 3 接在 2(dp=1)后面最划算。
- 15算 dp[5](数 7):往前找到比它小的 2、5、3(蓝格),挑它们里 dp 最大的接上,再 +1。
- 16dp[5] 落子为 3:把 7 接在 5(dp=2)后面最划算。
- 17算 dp[6](数 101):往前找到比它小的 10、9、2、5、3、7(蓝格),挑它们里 dp 最大的接上,再 +1。
- 18dp[6] 落子为 4:把 101 接在 7(dp=3)后面最划算。
- 19算 dp[7](数 18):往前找到比它小的 10、9、2、5、3、7(蓝格),挑它们里 dp 最大的接上,再 +1。
- 20dp[7] 落子为 4:把 18 接在 7(dp=3)后面最划算。
- 21dp 每一格都填好了。下面要找答案——别急着看最后一格。
- 22顺着「从哪接来的」回溯,点亮一条真实的递增子序列 2、5、7、101,正好 4 个。
- 23取整行 dp 最大值 4(在 dp[6])——注意答案是 dp 的最大值,不是最后一格,因为最长的子序列不一定以最后那个数结尾。
⚠️ 容易写错的地方
✗ 错:答案取 dp[n-1]
✓ 对:答案是 max(dp)
最长子序列不一定以最后一个数结尾
✗ 错:条件写 <=
✓ 对:严格递增用 <
相等不算递增
✗ 错:dp 初值写 0
✓ 对:dp 全初始化为 1
每个数自己就是长度 1 的子序列
完整代码(Python / C++ / Java)
Python
def lengthOfLIS(nums):
n = len(nums)
dp = [1] * n
for i in range(n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)C++
int lengthOfLIS(vector<int>& nums){
int n = nums.size();
vector<int> dp(n, 1);
for(int i = 0; i < n; i++)
for(int j = 0; j < i; j++)
if(nums[j] < nums[i])
dp[i] = max(dp[i], dp[j] + 1);
return *max_element(dp.begin(), dp.end());
}Java
int lengthOfLIS(int[] nums){
int n = nums.length, ans = 1;
int[] dp = new int[n];
Arrays.fill(dp, 1);
for(int i = 0; i < n; i++){
for(int j = 0; j < i; j++)
if(nums[j] < nums[i])
dp[i] = Math.max(dp[i], dp[j] + 1);
ans = Math.max(ans, dp[i]);
}
return ans;
}复杂度
时间
O(n²)
每个 i 往前扫一遍
空间
O(n)
dp 数组
可优化
O(n log n)
贪心+二分维护尾巴
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 最长递增子序列 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
怎么做到 O(n log n)?+
维护一个数组 tails,tails[k] 表示长度为 k+1 的递增子序列的最小结尾。每个数二分找它该替换/追加的位置,tails 的长度就是答案。
要还原出具体子序列怎么办?+
记录每个 i 的前驱 j(从哪接来的),从取得最大 dp 的位置回溯即可。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 最长递增子序列 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。