力扣题解《禁止套娃!(图解过程)》
方法一:比较暴力的动态规划
思路
-
状态定义:
- 表示仅使用信封 (这里是区间的意思,表示前 个信封),且以第 个信封为顶端信封时的最大高度。
-
状态转移:
- 首先对整个数组根据宽度排序,宽的信封放在前面。
- 设 ,考虑每轮计算新的 时,遍历 区间,做以下判断:
- 当 且 时:认为信封 严格大于信封 ,此时尝试更新底座最大高度 :
maxh = max(maxh, dp[j])
- 当信封 不严格大于信封 时:跳过。
- 全部遍历完成后,更新 :
dp[i] = maxh + 1
。理解为在最大可放置的高度上,再放上当前信封。 - 转移方程:
dp[i] = max(dp[j]) + 1 (j ∈ [0, i) 且信封j比信封i严格大)
-
初始状态:
- 所有元素置0,当计算后才进行赋值。
-
返回值:
- 返回 列表最大值,即可得最大套娃层数。
复杂度分析
时间复杂度 :排序需要 ,遍历 列表需要 ,计算每一个 需要
空间复杂度 :dp列表占用 空间
快乐小视频
具体代码:
class Solution {
public:
int maxEnvelopes(vector>& envelopes) {
int n = envelopes.size();
// 先按照宽做排序,越大的放在越前面
sort(envelopes.begin(), envelopes.end(), [](vector& a, vector& b){
return a[0] > b[0];
});
// 预开空间(不妨多开几个防溢出)
vector dp(n + 5);
int ans = 0;
// 依次尝试放置每个信封
for(int i = 0; i < n; i++){
// 遍历他之前的每个信封,看能放下他且最多层的个数
int maxh = 0;
for(int j = 0; j < i; j++){
// 判断是否可以放下当前的信封
if(envelopes[j][0] > envelopes[i][0]
&& envelopes[j][1] > envelopes[i][1]){
// 如果可以放下当前信封,看看是不是最大高度
if(maxh < dp[j]){
maxh = dp[j];
}
}
}
// 遍历一圈,找到最高,且能放下当前信封的maxh
dp[i] = maxh + 1;
// 判断当前信封高度是不是最高高度
ans = max(ans, dp[i]);
}
return ans;
}
};
用C++提交花费了1264 ms,确实挺慢的,看其他题解里写不同语言可能会超时。
但是确实这个思路最好想,也可以很方便的套用到面试题 08.13. 堆箱子这道题上。
方法二:第二维参与排序
思路
目标是将二维问题,转换为我们所熟悉的一维的最长上升子序列问题。因此需要降维,想办法在计算时,仅考虑 的顺序即可,忽略 影响。
-
状态定义:
- 表示仅使用信封 ,且以第 个信封为底部信封时的最大高度。
-
状态转移:
- 首先对整个数组按照宽度由小到大排序,排序同时,对于宽度相同的项,根据高度由大到小排序。
假如我们仍像方法一一样,仅针对宽度排序。那么对于形如 和 的两项,由于 排序在 之前,这就导致仅考虑h时,我们认为 和 可以构成一组“套娃”。但是按照“二维严格升序”的定义,两个矩形的宽相等,不属于严格升序。
为了规避上述的问题,我们对宽度相等的两个矩形,使用高度降序排序,这样一来,上述问题的排列将变成: 。在我们计算最长上升子序列时,由于 ,也就不会出现“错误套娃”。
- 排序完毕后,提取出所有的h,形成一个新的数组。对新的数组执行“最长上升子序列”问题的求解:
- 设 ,考虑每轮计算新的 时,遍历 区间,做以下判断:
1. 当 时:认为信封 严格大于信封 ,此时尝试更新 :dp[i] = max(dp[i], dp[j] + 1)
2. 当信封 不严格大于信封 时:跳过。
- 转移方程:dp[i] = max(dp[j]) + 1 (j ∈ [0, i) 且信封j比信封i高度严格大)
-
初始状态:
- 所有元素置1,表示该信封可独自形成层数为1的套娃。
-
返回值:
- 返回 列表最大值,即可得最大套娃层数。
复杂度分析
时间复杂度 :排序需要 ,遍历 列表需要 ,计算每一个 需要
空间复杂度 :dp列表占用 空间
快乐小视频
具体代码
class Solution {
public:
int maxEnvelopes(vector>& envelopes) {
int n = envelopes.size();
// 首先执行排序,按照宽度排序,小的在前大的在后
sort(envelopes.begin(), envelopes.end(), [](vector& a, vector& b){
if(a[0] == b[0]){
// 对于宽度相等的信封,根据高度逆序,大的在前小的在后
return a[1] > b[1];
}
return a[0] < b[0];
});
// 预开空间,设初始值为1,即仅包含当前信封
vector dp(n, 1);
int ans = 0;
// 计算最长上升子序列
for(int i = 0; i < n; i++){
for(int j = 0; j < i; j++){
if(envelopes[j][1] < envelopes[i][1]){
// 如果h严格升序,尝试更新dp[i]
dp[i] = max(dp[i], dp[j] + 1);
}
}
// 尝试更新最大值ans
ans = max(ans, dp[i]);
}
return ans;
}
};
比起方法一,有一定优化,但是优化效果仍然有限,使用C++提交的运行时间是1004 ms。
仍然是一个 的解决方法
方法三:维护单增序列
思路
更换想法,对方法二中最长上升子序列问题的求解做优化,不再维护长度为 的 数组,改为维护一条最长递增序列。
-
状态定义:
- 表示长度为 的最长上升子序列,第 位的最小值。
-
状态转移:
- 同方法二,首先对整个数组按照宽度由小到大排序,排序同时,对于宽度相同的项,根据高度由大到小排序。
- 排序完毕后,提取出所有的h,形成一个新的数组。对新的数组执行“最长上升子序列”问题的求解:
-
遍历 区间,对每一个值 ,判断 在单增序列 中的位置,做以下判断:
- 当 中仅存在某值满足 :使用 替换该值
- 当 中存在多值满足 :使用 替换符合条件的最小值
- 当 中不存在值满足 :将 添加到 数组最末端
为什么可以这样做?:假设当前 队列为1, 3, 6。
这翻译过来是:- 存在一条上升子序列,长度为1,且以1为结尾;
- 存在一条上升子序列,长度为2,且以3为结尾;
- 存在一条上升子序列,长度为3,且以6为结尾。
假设此时来了个新的值4,显然的,可以在长度为2且末尾为3的那条上升子序列后,再添加一个4,形成一条长度为3,且以4为结尾的上升子序列。
同时更新 队列变成1,3,4。这是因为同样是长度为3,以6为结尾后,想继续扩充,新增的数字至少为7;而以4为结尾时,新增数字仅需至少为5即可。额外需要注意的是: 的结果,不一定就是一条存在的上升子序列:仍然以1,3,6为例,假设此时新增的数字为2,则 更新为1,2,6。但实际上并不存在一条子序列,满足1,2,6。1,2,6仅可翻译为:
- 存在一条上升子序列,长度为1,且以1为结尾;
- 存在一条上升子序列,长度为2,且以2为结尾;
- 存在一条上升子序列,长度为3,且以6为结尾。
- 转移方程:dp[index] = x (index为第一个大于x的值所在位置)
-
-
初始状态:
- 仅有一个元素,为排序后第一个信封的 值。
-
返回值:
- 返回 列表长度,即可得最大套娃层数。
复杂度分析
时间复杂度 :排序需要 ,遍历信封列表需要 ,计算每一个信封插入位置需要
空间复杂度 :dp列表占用 空间
快乐小视频
具体代码
class Solution {
public:
int maxEnvelopes(vector>& envelopes) {
int n = envelopes.size();
// 首先执行排序,按照宽度排序,小的在前大的在后
sort(envelopes.begin(), envelopes.end(), [](vector& a, vector& b){
if(a[0] == b[0]){
// 对于宽度相等的信封,根据高度逆序,大的在前小的在后
return a[1] > b[1];
}
return a[0] < b[0];
});
// 预开空间,初始值为排序后第一个信封的高度
vector dp(1, envelopes[0][1]);
int ans = 0;
// 计算最长上升子序列
// 第0个元素已默认放入dp,因此从1开始遍历
for(int i = 1; i < n; i++){
// 搜索合适的更新位置
int j = 0;
for(; j < dp.size(); j++){
// 需要注意,只要不小于当前大小,即可更新
if(dp[j] >= envelopes[i][1]){
dp[j] = envelopes[i][1];
break;
}
}
// 如果整个dp列表中,不含有比当前h大的值,则扩展dp列表
if(j == dp.size()){
dp.emplace_back(envelopes[i][1]);
}
}
return dp.size();
}
};
比起方法二,这个方法其实优化了很多,远不是 的复杂度,而是 但是假如一开始给定的就是一个层层套娃的合法序列,那么最差时间复杂度仍然能达到
方法四:二分查找
思路
方法三中,搜索插入位置的过程,用的是 的搜索插入,可以通过二分法,优化到 。
-
状态定义:
- 表示长度为 的最长上升子序列,第 位的最小值。
-
状态转移:
- 同方法二,首先对整个数组按照宽度由小到大排序,排序同时,对于宽度相同的项,根据高度由大到小排序。
- 排序完毕后,提取出所有的h,形成一个新的数组。对新的数组执行“最长上升子序列”问题的求解:
- 遍历 区间,对每一个值 ,判断 在单增序列 中的位置,做以下判断:
- **当 中仅存在某值 **:使用 替换该值
- **当 中存在多值 **:使用 替换符合条件的最小值
- 当 中不存在值满足 :将 添加到 数组最末端
- 转移方程:
dp[index] = x (index为第一个大于x的值所在位置)
- 遍历 区间,对每一个值 ,判断 在单增序列 中的位置,做以下判断:
-
初始状态:
- 仅有一个元素,为排序后第一个信封的 值。
-
返回值:
- 返回 列表长度,即可得最大套娃层数。
复杂度分析
时间复杂度 :排序需要 ,遍历信封列表需要 ,计算每一个信封插入位置需要
空间复杂度 :dp列表占用 空间
快乐小视频
没啥快乐小视频了,看看方法三的得了。
具体代码
class Solution {
public:
int maxEnvelopes(vector>& envelopes) {
int n = envelopes.size();
// 首先执行排序,按照宽度排序,小的在前大的在后
sort(envelopes.begin(), envelopes.end(), [](vector& a, vector& b){
if(a[0] == b[0]){
// 对于宽度相等的信封,根据高度逆序,大的在前小的在后
return a[1] > b[1];
}
return a[0] < b[0];
});
// 预开空间,初始值为排序后第一个信封的高度
vector dp(1, envelopes[0][1]);
int ans = 0;
// 计算最长上升子序列
// 第0个元素已默认放入dp,因此从1开始遍历
for(int i = 1; i < n; i++){
// 搜索合适的更新位置,使用二分模板
// 额外引入一个index来记录满足条件合法的值
// 有的人的模板中,只有l和r两个变量,但是那个边界条件我总是记不住
// 引入一个新的变量,个人感觉逻辑更明朗
int l = 0, r = dp.size() - 1;
int index = -1;
while(l <= r){
// mid这里用l加一半的形式,不容易溢出int
int mid = l + (r - l) / 2;
if(dp[mid] >= envelopes[i][1]){
// 我们要找的是dp数组中第一个大于等于当前h的位置
// 因此在这里更新index值
index = mid;
r = mid - 1;
}
else{
l = mid + 1;
}
}
if(index == -1){
dp.emplace_back(envelopes[i][1]);
}
else{
dp[index] = envelopes[i][1];
}
}
return dp.size();
}
};
C++写到这里,差不多就是40-50 ms了,比方法一快了不老少,心满意足。
写在最后
延伸扩展问题
简化到一维:300. 最长递增子序列
扩展到三维:面试题 08.13. 堆箱子