🎯 从“套娃失败”到“信封全嵌”:我如何用 LIS 算法征服俄罗斯套娃信封问题

5 阅读6分钟

作者:一个曾被 tails[0] = 1 搞崩溃的普通程序员
关键词:LIS、二分优化、贪心、排序降维、动态规划
适用人群:想搞懂「最长递增子序列」本质的你


💣 开局即翻车:我的第一次提交,只过了3个测试用例

那天,我在 LeetCode 上遇到了一道题:

354. 俄罗斯套娃信封问题
给你一堆信封,每个信封有宽度 w 和高度 h。只有当 w1 < w2h1 < 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)


🏁 总结:三句话记住本题精髓

  1. 先排序降维:把二维问题变成一维 LIS
  2. 同宽降序:用“高度下降”堵住“非法嵌套”的漏洞
  3. LIS 二分:维护最小尾部,不求路径真实,但求长度精准

💬 写在最后

曾经我以为,算法只是写代码的技巧。

直到我看到 tails[left] = h 这一行,突然明白:

算法的本质,不是模拟现实,而是构建理想模型。

它允许虚构路径,只为逼近真理; 它接受时间错乱,只为抵达最优。

就像我们的人生,也许无法重来,但每一次反思,都是对过去的重构,为的是走向更好的未来。


📌 如果你觉得这篇文章帮你打通了任督二脉,欢迎点赞、收藏、转发!
💬 评论区留下你的疑问或心得,我们一起讨论~

#算法 #LeetCode #动态规划 #LIS #前端算法 #面试题 #程序员成长