俄罗斯套娃信封问题 图解题解
这道题到底在问什么
- 输入
- envelopes=[[5,4],[6,4],[6,7],[2,3]]
- 输出
- 3([2,3] 套 [5,4] 套 [6,7])
- 输入
- envelopes=[[1,1],[1,1],[1,1]]
- 输出
- 1(宽高相同不能互套)
最优解:为什么这么做
一句话答案:LeetCode 354 俄罗斯套娃信封是二维最长递增子序列(LIS):先按宽升序排、同宽再按高降序排,把套娃降成对高度求严格递增子序列,同宽高降序专防同宽被误当能互套,用二分维护 lis 数组做到 O(n log n)、空间 O(n)。
俄罗斯套娃信封,这道题在挑什么
给一组信封,每项 [宽, 高]。一个要装进另一个,宽和高都得严格小于对方,有一边相等就套不进。问最多能套几层。题面 [[5,4],[6,4],[6,7],[2,3]],[2,3] 塞进 [5,4]、[5,4] 塞进 [6,7],套成 3 个。宽或高相等的彼此套不了,是全题的机关。
两两试套,为什么会慢到跑不完
n 个信封两两配对看能不能套只是 O(n²),可真要穷举挑哪几个连成一串,每个选或不选分两条岔,2ⁿ 条组合枚举不完,还大半在重算同一段前缀。先把信封排好序、让谁能接谁有方向,指数枚举就收拢成一趟从左到右的递推(拿前面算好的结果往后推),开销靠这步压下来。
同宽的信封,为什么要按高从大到小排
排序规矩两条:宽从小到大,宽一样时高从大到小。第一条好懂:宽不减,套娃链才不回头。第二条才绕:宽相等的两个信封永远套不进彼此(宽得严格更小),可同宽若按高升序排,它俩的高一小一大、像能接上的递增对,求递增链时就被当成能套。改成高降序,同宽的高一路往下、绝不递增,链里最多挑一个,同宽误套被排序堵死。题面 [6,7]、[6,4] 同宽,排成先 7 后 4 正是这用意。
排完这列高度,套娃怎么就成了 LIS
排好序后宽已从小到大、不回头,只要在高上找一条严格变大的挑法,对应那串信封宽不减、高严格增,就能一个个套起来。二维套娃塌成一维:求高度序列的最长严格递增子序列(LIS,最长的一个个变大、可跳着挑的子序列)。
O(n log n) 的快法:开一个 lis 数组,存的不是真链,而是『各长度的递增链能达到的最小结尾』。每来一个高度 h,用二分(Python 的 bisect_left,对半砍找位置)在 lis 里找第一个不小于 h 的位置:落末尾说明 h 比所有结尾都大、能把最长链接长一节,链变长;落中间就用 h 替换那位置原值,把这档链结尾压小、日后更易接长。
拿题面那四个信封,亲手把 lis 填出来
拿题面 [[5,4],[6,4],[6,7],[2,3]] 走一遍。按『宽升、同宽高降』排成 [2,3]、[5,4]、[6,7]、[6,4],抽出高度 [3,4,7,4],lis 起初空。前三个 3、4、7 一个比一个大,二分都落末尾、依次接上成 [3,4,7]。最后的 4:找第一个不小于 4 的位置是 1(值正好 4),落中间,用 4 替换原来的 4,lis 仍 [3,4,7]、长度不变。走完长度 3,答案 3,即 [2,3] 套 [5,4] 套 [6,7]。
二分若改找第一个比 h 大的位置,答案为什么会偏大
二分若改成找『第一个比 h 大』,等高的高度会被接到链尾而非替换,两个等高信封就被当成能递增互套,答案凭空偏大——严格递增全靠『不小于』守住:等高触发替换、不接长。开销:排序 O(n log n),每个高度一次二分、合计 O(n log n),lis 最多装 n 个、空间 O(n)。两个边界:单个信封链长 1;题面 [[1,1],[1,1],[1,1]] 宽高全同,排完高度 [1,1,1],只第一个接成 [1]、其余替换,答案 1。
▶ 动画逐步走查(共 23 步)——想跟着动画一帧帧对照就展开
- 3记住「宽升高降排序 → 高度求严格 LIS → tails 二分(末尾 append、否则替换)」,下面逐步套它。
- 4先排序。注意宽都为 6 的两个信封 [6,8] 排在 [6,7] 前面(高降序):这样它俩的高 8、7 在后面不会形成递增,避免把同宽的错当成能互套。
- 5抽出排序后的高度序列 [3, 5, 8, 7, 4, 5]。问题已降成一维:在这串高度里找最长严格递增子序列的长度。
- 6准备 tails 数组(初始空)。它不是真正的子序列,而是「每种长度的递增链所能达到的最小结尾」,用来给后续高度快速找接入点。
- 7回到高度序列。前面 0 个已处理(蓝),现在看第 1 个高度 3(紫)。把它拿去更新 tails。
- 8轮到高度 3。在 tails=[空] 里二分查第一个不小于 3 的位置,得到 i=0。
- 9i 落在末尾,说明 3 能接到当前最长链后面,append。tails 变长,LIS 长度增到 1。
- 10回到高度序列。前面 1 个已处理(蓝),现在看第 2 个高度 5(紫)。把它拿去更新 tails。
- 11轮到高度 5。在 tails=[3] 里二分查第一个不小于 5 的位置,得到 i=1。
- 12i 落在末尾,说明 5 能接到当前最长链后面,append。tails 变长,LIS 长度增到 2。
- 13回到高度序列。前面 2 个已处理(蓝),现在看第 3 个高度 8(紫)。把它拿去更新 tails。
- 14轮到高度 8。在 tails=[3, 5] 里二分查第一个不小于 8 的位置,得到 i=2。
- 15i 落在末尾,说明 8 能接到当前最长链后面,append。tails 变长,LIS 长度增到 3。
- 16回到高度序列。前面 3 个已处理(蓝),现在看第 4 个高度 7(紫)。把它拿去更新 tails。
- 17轮到高度 7。在 tails=[3, 5, 8] 里二分查第一个不小于 7 的位置,得到 i=2。
- 18i 在中间,用 7 替换 tails[2] 原来的 8:让长度 3 的链结尾更小,日后更容易接长。LIS 长度暂不变(仍 3)。
- 19回到高度序列。前面 4 个已处理(蓝),现在看第 5 个高度 4(紫)。把它拿去更新 tails。
- 20轮到高度 4。在 tails=[3, 5, 7] 里二分查第一个不小于 4 的位置,得到 i=1。
- 21i 在中间,用 4 替换 tails[1] 原来的 5:让长度 2 的链结尾更小,日后更容易接长。LIS 长度暂不变(仍 3)。
- 22回到高度序列。前面 5 个已处理(蓝),现在看第 6 个高度 5(紫)。把它拿去更新 tails。
- 23轮到高度 5。在 tails=[3, 4, 7] 里二分查第一个不小于 5 的位置,得到 i=2。
- 24i 在中间,用 5 替换 tails[2] 原来的 7:让长度 3 的链结尾更小,日后更容易接长。LIS 长度暂不变(仍 3)。
- 25全部处理完,tails 长度 = 3,就是高度序列的最长严格递增子序列长度,也就是最多能套娃的信封数 3。注意 tails 本身的值 [3, 4, 5] 不一定是真实的那条链,但它的「长度」一定正确。
⚠️ 容易写错的地方
✗ 错:宽相同的按高升序排
✓ 对:宽相同必须按高降序排
若同宽按高升序,后面更大的高会被错当成可递增、把两个同宽信封算进一条链;高降序让同宽的高不递增,自然排除
✗ 错:在 LIS 里用「≤」当递增
✓ 对:套娃要求严格小于,用严格递增
宽高都要严格小于才能套;tails 二分用 lower_bound(第一个 ≥ h)对应严格递增,用 upper_bound 会错算成非严格
✗ 错:以为 tails 就是那条最长链
✓ 对:tails 只保证长度对,值未必是真实链
替换操作会让 tails 的值不构成实际子序列,但「长度」始终等于 LIS 长度;要还原真实链需额外记录前驱
完整代码(Python / C++ / Java)
Python
from typing import List
from bisect import bisect_left
class Solution:
def maxEnvelopes(self, envelopes: List[List[int]]) -> int:
envelopes.sort(key=lambda x: (x[0], -x[1]))
lis = []
for _, h in envelopes:
i = bisect_left(lis, h)
if i == len(lis):
lis.append(h)
else:
lis[i] = h
return len(lis)C++
#include <algorithm>
#include <vector>
using namespace std;
class Solution {
public:
int maxEnvelopes(vector<vector<int>>& envelopes) {
sort(envelopes.begin(), envelopes.end(), [](auto& a, auto& b){ return a[0] == b[0] ? a[1] > b[1] : a[0] < b[0]; });
vector<int> lis;
for (auto &e : envelopes) {
int h = e[1];
auto it = lower_bound(lis.begin(), lis.end(), h);
if (it == lis.end()) lis.push_back(h);
else *it = h;
}
return lis.size();
}
};Java
import java.util.*;
class Solution {
public int maxEnvelopes(int[][] envelopes) {
Arrays.sort(envelopes, (a,b) -> a[0] == b[0] ? b[1] - a[1] : a[0] - b[0]);
int[] lis = new int[envelopes.length];
int size = 0;
for (int[] e : envelopes) {
int i = Arrays.binarySearch(lis, 0, size, e[1]);
if (i < 0) i = -i - 1;
lis[i] = e[1];
if (i == size) size++;
}
return size;
}
}复杂度
时间
O(n log n)
n 是信封数。排序 O(n log n);遍历每个高度做一次二分 O(log n),共 O(n log n);整体 O(n log n)
空间
O(n)
tails 数组最坏存 n 个高度,排序若用额外数组也是 O(n)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 俄罗斯套娃信封问题 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
这道题和普通最长递增子序列(LeetCode 300)是什么关系?+
LeetCode 300 直接在一维数组上求最长严格递增子序列,本题是它的二维版:信封多了宽、高两个维度。诀窍是先用排序把宽这一维消化掉:宽升序、同宽高降序,排完只剩高度一列,就退化成 300 那样的一维 LIS。会了 300,本题只是前面多加一步『把二维排成能求 LIS 的一维』,核心那套二分求 LIS 一模一样。
同宽的信封为什么一定要按高从大到小排,升序会怎样?+
同宽的信封彼此永远套不进(宽必须严格更小)。若同宽按高升序排,它们的高就排成一串递增,求最长递增子序列时会把这几个同宽信封当成能一个套一个,长度虚高、答案偏大。改成高降序,同宽的高一路递减、在递增子序列里最多取到一个,同宽误套就被彻底堵住。
lis 里最后存的那串数,是最大套娃方案本身吗?+
不是。lis 每个位置存的是『某个长度的递增链目前能达到的最小结尾』,二分替换的过程会不断刷新这些结尾值,所以走完后 lis 里的数未必真能首尾相接成一条链。但接长只发生在末尾、替换又从不改变长度,所以 lis 的长度始终等于当前最长严格递增子序列的长度:要的正是这个长度,而不是具体哪几个信封。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 俄罗斯套娃信封问题 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。