作者:一个曾被
tails[0] = 1搞崩溃的普通程序员
关键词:LIS、二分优化、贪心、排序降维、动态规划
适用人群:想搞懂「最长递增子序列」本质的你
💣 开局即翻车:我的第一次提交,只过了3个测试用例
那天,我在 LeetCode 上遇到了一道题:
354. 俄罗斯套娃信封问题
给你一堆信封,每个信封有宽度w和高度h。只有当w1 < w2且h1 < h2时,才能把信封1套进信封2。问最多能套几层?
看着像DP?像贪心?还是……暴力搜索?
我信心满满写下代码:
// 我的第一版:冒泡排序 + 贪心跳跃
var maxEnvelopes = function(envelopes) {
// ……(省略一段混乱的双重循环)
let last = 0, res = 1;
for (let i = 1; i < envelopes.length; i++) {
while (envelopes[last][0] >= envelopes[i][0] || ...) {
i++;
}
res++;
last = i;
}
return res;
};
结果:
❌ Runtime Error: Cannot read properties of undefined (reading '0')
更惨的是,连 [1,1],[1,1] 这种简单样例都过不了……
那一刻我才意识到:这不是简单的“找下一个更大的”,而是一个披着“套娃”外衣的高级算法陷阱。
🔍 第一关:为什么不能直接贪心?
我们先看一组数据:
envelopes = [[3,7], [3,5], [3,4], [6,6], [6,8]]
目标是尽可能多层嵌套。
但注意⚠️:
[3,4]和[3,5]宽度相同 → ❌ 无法互相嵌套!- 所以每种宽度最多只能选一个信封参与最终链条
如果我按宽度升序排,高度也升序排:
[3,4], [3,5], [3,7], [6,6], [6,8]
提取高度数组:[4,5,7,6,8]
然后求 LIS(最长递增子序列)→ 得到 [4,5,6,8],长度为 4!
听起来很美,对吧?
但这条路径对应的是:
[3,4] → [3,5] → [6,6] → [6,8]
🚨 危险!第二步 [3,5] 是不能放进 [3,4] 的——它们宽度一样啊!
👉 这就是“虚假递增”的陷阱:我们在同一宽度内偷偷用了多个信封,违反了游戏规则。
✨ 神来之笔:同宽时,让高度“倒着走”
怎么破?
答案藏在一个反直觉的操作里:
排序时:按宽度升序;当宽度相同时,按高度降序!
什么意思?再跑一遍上面的例子:
原数组: [[3,7], [3,5], [3,4], [6,6], [6,8]]
排序后: [3,7], [3,5], [3,4], [6,8], [6,6]
↑ 同宽组内,高的在前,矮的在后
提取高度数组:
heights = [7, 5, 4, 8, 6]
现在你在 [7,5,4] 中还能选出递增序列吗?不能!
因为 7 > 5 > 4,根本没法形成上升趋势。
所以 LIS 最多只能从这组里选一个值(比如最后保留 4),然后尝试接后面的 [8,6]。
这就天然防止了“同宽多选”!
🎯 小结一句话:
通过“同宽降序”,我们把“物理上不能嵌”的限制,编码进了“数学上不能递增”的结构中。
🚀 第二关:二维变一维,LIS 来救场
经过排序后,我们的任务变成了:
在保持顺序的前提下,从
heights数组中选出最长的严格递增子序列。
因为此时:
- 宽度已经有序(前面的 ≤ 后面的)
- 同宽时高度是降序 → 不可能选出两个同宽信封
- 所以只要高度能递增,就一定能嵌套!
于是问题成功从二维降维成一维!
这就是所谓的 “排序降维 + LIS” 技巧,也是本题最精妙之处。
🔥 高能预警:LIS 的二分优化,到底是不是“真实路径”?
接下来我干了一件事:
const tails = [];
for (let h of heights) {
let left = 0, right = tails.length;
while (left < right) {
const mid = (left + right) >> 1;
if (tails[mid] < h) left = mid + 1;
else right = mid;
}
if (left === tails.length) tails.push(h);
else tails[left] = h;
}
return tails.length;
运行结果:✅ AC!
但我发现一个问题:
最终
tails = [4, 6]或[4,8],看起来没问题。
但如果中间出现[2,6]这种组合(2在6后面才出现),它难道不是“时间倒流”了吗?这个序列根本不存在!
是的,你说得对。
📌 tails 数组并不一定代表真实的子序列路径。
但它记录的是:
“对于长度为
k的递增子序列,目前我能维护的最小结尾是多少。”
这就像打游戏时的角色状态栏:
| 等级 | 最小攻击力 |
|---|---|
| 1级 | 2 |
| 2级 | 5 |
| 3级 | 7 |
我不需要知道你是怎么练到3级的,我只关心:只要怪物攻击力 > 7,你就能升4级。
👉 所以 tails 是一种贪心的状态压缩表,它的长度永远等于真实 LIS 的长度。
🧠 类比理解:人生没有回头路,但我们可以“重开存档”
想象你玩一款 RPG 游戏,每次遇到更强的敌人就升级装备。
有一天你回到过去,发现自己当初选错了起始职业。
你会怎么做?
✔️ 重新开始,用现在的经验打造一条更优路线。
tails 就是这样一个“最优历史快照”:
- 它不会真的回到过去修改历史
- 但它会说:“如果我当时选那个更小的数开头,现在早就通关了!”
- 于是它悄悄更新状态,为未来铺路
所以哪怕 tails 里的数字来自不同时间线,只要它能让未来的扩展更容易,就是值得的。
📈 实战演练:完整代码 + 注释
var maxEnvelopes = function(envelopes) {
if (!envelopes || envelopes.length === 0) return 0;
// 排序:宽升序;同宽时,高降序(关键!)
envelopes.sort((a, b) => {
if (a[0] === b[0]) {
return b[1] - a[1]; // 防止同宽多选
}
return a[0] - b[0];
});
// 提取高度数组
const heights = envelopes.map(e => e[1]);
// 求 LIS:使用二分优化
const tails = []; // tails[i] = 长度为 i+1 的 LIS 的最小尾部值
for (let h of heights) {
let left = 0, right = tails.length;
while (left < right) {
const mid = (left + right) >> 1;
if (tails[mid] < h) {
left = mid + 1;
} else {
right = mid;
}
}
if (left === tails.length) {
tails.push(h); // 可以延长
} else {
tails[left] = h; // 优化某个长度的结尾
}
}
return tails.length;
};
⏰ 时间复杂度:O(n log n)
💾 空间复杂度:O(n)
🏁 总结:三句话记住本题精髓
- 先排序降维:把二维问题变成一维 LIS
- 同宽降序:用“高度下降”堵住“非法嵌套”的漏洞
- LIS 二分:维护最小尾部,不求路径真实,但求长度精准
💬 写在最后
曾经我以为,算法只是写代码的技巧。
直到我看到 tails[left] = h 这一行,突然明白:
算法的本质,不是模拟现实,而是构建理想模型。
它允许虚构路径,只为逼近真理; 它接受时间错乱,只为抵达最优。
就像我们的人生,也许无法重来,但每一次反思,都是对过去的重构,为的是走向更好的未来。
📌 如果你觉得这篇文章帮你打通了任督二脉,欢迎点赞、收藏、转发!
💬 评论区留下你的疑问或心得,我们一起讨论~
#算法 #LeetCode #动态规划 #LIS #前端算法 #面试题 #程序员成长