引入
今天做到面经的17.08.马戏团人塔🔗和面试题 08.13. 堆箱子问题,两个题目很相似,这里会一一介绍。
马戏团人塔问题分析
首先是马戏团人塔问题,题目要求在2个维度上(即身高 + 体重)同时保持严格递增。
那么我们可以先将其中一个维度排好序,以保证在一个维度上保持递增(此时并非严格递增);
之后就可以专注于处理另一个维度。
具体而言:
先根据身高升序排序,若身高一样则根据体重降序排序。身高排序好之后,剩余待处理的就是体重。
处理体重的问题就是处理最长递增子序列的问题。
为什么要按照身高升序,而按照体重降序呢?为什么不能两个都是升序呢?
我们查看下面的序列(已经排序好了):
身高:[1, 2, 2, 3, 4]
体重:[1, 3, 4, 2, 7]
按身高和体重同时升序排列,那么,体重的最长递增子序列是[1, 3, 4, 7]
,但是题目要求是完全递增,不能产生等于的情况。
那么如果按照身高升序,按照体重降序又是什么样子的呢?
身高:[1, 2, 2, 3, 4]
体重:[1, 4, 3, 2, 7]
此时,体重的最长递增子序列是[1, 4, 7]
或者[1,3,7]
,这个时候就能保证身高递增且体重也是递增的了。
现在就是来解决最长递增子序列问题。
最长递增子序列问题(LIS)解法
具体题目参考:300. 最长上升子序列🔗
方法一:动态规划
状态dp[i]表示前i个元素的最长递增子序列长度是dp[i]。
有了状态,那么状态转移方程也很好找到了:
- 前面有元素j比元素i小,那么:
dp[i]=max(dp[j]),j>=0&&j<i
- 前面没有元素j比元素i小,那么:
dp[i]=1
所以初始状态也出来了:dp[0]=1
那么,题解为:
class Solution {
public int lengthOfLIS(int[] nums) {
if(nums.length==0) return 0;
int[] dp=new int[nums.length+1];
dp[0]=1;
int max=1;
for(int i=0;i<nums.length;i++){
for(int j=i-1;j>=0;j--){//往前搜索
if(nums[i]>nums[j]){
dp[i+1]=Math.max(dp[j+1]+1,dp[i+1]);
max=Math.max(dp[i+1],max);
}
}
if(dp[i+1]==0) dp[i+1]=1;//前面的j都比i大
}
return max;
}
}
由于每次找dp[i]的时候都要向前一个一个搜索,这种动态规划的算法时间复杂度是O(N^2)。
方法二:贪心 + 二分查找
考虑一个简单的贪心,如果我们要使上升子序列尽可能的长,则我们需要让序列上升得尽可能慢,因此我们希望每次在上升子序列最后加上的那个数尽可能的小。
基于上面的贪心思路,我们维护一个递增数组 d [ i ] d[i] d[i],表示长度为 i i i 的最长上升子序列的末尾元素的最小值,用 len \textit{len} len 记录目前最长上升子序列的长度,起始时 l e n len len为 1 1 1, d [ 1 ] = nums [ 0 ] d[1] = \textit{nums}[0] d[1]=nums[0]。
同时,我们可以从上面的设定中知道,数组
d
[
i
]
d[i]
d[i]是关于
i
i
i单调递增的,因为如果出现了
d
[
i
]
<
d
[
j
]
d[i]<d[j]
d[i]<d[j]且
i
>
j
i>j
i>j的情况,那么必然可以出现一个
k
k
k,使得
d
[
k
]
<
d
[
j
]
d[k]<d[j]
d[k]<d[j]且
k
=
j
k=j
k=j的情况。
具体举例来说,就是
[
a
,
b
,
13
]
[a,b,13]
[a,b,13]和
[
a
,
15
]
[a,15]
[a,15]的情况是不成立的,因为能够找到一个
[
a
,
b
,
13
]
[a,b,13]
[a,b,13]和
[
a
,
b
]
[a,b]
[a,b]的存在,其中
a
<
b
<
13
a<b<13
a<b<13。
根据 d [ i ] d[i] d[i]数组的单调性,就可以根据二分法来查找符合条件的那个 d [ i ] d[i] d[i]。
用理性的逻辑来分析这个问题,这个问题的最关键因素是为什么能用贪心的方式去替换掉递增数组 d [ i ] d[i] d[i]的值,假如目前维护的递增数组 d [ i ] d[i] d[i]包含[1,4,8]三个元素,如果新增的元素 k k k比8更小,那么我们可以用贪心的方式让 d [ i ] d[i] d[i]的值更收敛,比如如果插入了6,那么能使得新数组[1,4,6]更收敛,但是它的长度不变;而如果插入的元素 k k k比8更大,那么必然存在一个长度为4的递增序列,其真实的数组数据不一定是[1,4,8, k k k],有可能真实数据是[1,5,8, k k k],虽然5被4替换了,但是这个5实际上是存在过的,贪心只是为了让后面的序列更加收敛,更容易的去找到合适的 k k k,所以这样的贪心是正确的。
那么,代码如下:
public class Solution {
public int lengthOfLIS(int[] nums) {
if (nums.length == 0) return 0;
int[] d = new int[nums.length + 1];
d[1] = nums[0];
int currLen = 1;
for (int i = 1; i < nums.length; i++) {
if (nums[i] > d[currLen]) {
d[++currLen] = nums[i];
} else {
//二分法
int left = 1,right = currLen;
while (left <= right) {
int mid = (left + right) >>> 1;//无符号右移一位
if (nums[i] > d[mid]) {
left = mid + 1;
} else right = mid - 1;
}
d[left]=nums[i];
}
}
return currLen;
}
}
利用 d [ i ] d[i] d[i]的单调性,我们可以把原本动态规划的O(N^2)的复杂度降低为O(NlogN)。
更简单的,我们可以利用Arrays提供的方法类来完成二分查找。
对于Arrays.binarySearch(int[] a,int num)
方法,如果返回值是大于等于0的数,那么说明在二分查找中找到了这个数。如果返回值是小于0的数,那么将其反转后减去1,就是它应该在二分法里面插入的位置。
比如对于序列[1,3,4,5,7]:
使用Arrays.binarySearch(arr,3)
会返回1。
使用Arrays.binarySearch(arr,2)
会返回-2,则-(-2)-1=1就是应该插入的位置。
由于这里并不是利用全部的数组
d
d
d,所以可以简单修改为Arrays.binarySearch(d,0,currLen,nums[i])
:
public class Solution {
public int lengthOfLIS(int[] nums) {
if (nums.length == 0) return 0;
int[] d = new int[nums.length + 1];
d[1] = nums[0];
int currLen = 1;
for (int i = 1; i < nums.length; i++) {
if (nums[i] > d[currLen]) {
d[++currLen] = nums[i];
} else {
//二分法
int index=Arrays.binarySearch(d,0,currLen,nums[i]);
if (index<0) index=-index-1;
d[index]=nums[i];
}
}
return currLen;
}
}
马戏团人塔问题解法
马戏团问题,我们这里采用贪心+二分查找的方式来解决(采用动态规划会导致超时)。
public class Solution {
public int bestSeqAtIndex(int[] height, int[] weight) {
int M=height.length;
if(M==0) return M;
int[][] person=new int[M][2];
for (int i=0;i<M;i++){
person[i]=new int[]{height[i],weight[i]};
}
Arrays.sort(person,(o1, o2) -> {
if (o1[1]==o2[1]){
return o2[0]-o1[0];//如果体重一样,就按照身高降序
}
return o1[1]-o2[1];//按体重升序
});
int[] d=new int[M+1];
d[1]=person[0][0];
int currLen=1;
for (int i=1;i<M;i++){
if (person[i][0]>d[currLen]){
d[++currLen]=person[i][0];
}else{
int index=Arrays.binarySearch(d,0,currLen,person[i][0]);
if (index<0) index=-index-1;
d[index]=person[i][0];
}
}
return currLen;
}
}
这里通过排序的方法, 将二维的递增问题转换成了一维的递增问题。
堆箱子问题解法
这里的面试题 08.13. 堆箱子,新增了一个新的纬度,那么应该怎么解呢?是否可以化简为二维或者一维呢?
这里,我们仍然可以使用最长递增子序列的方法,不过这里已经不能使用(贪心+二分查找)的方法了,因为要多比较一个纬度,使用动态规划最为划算。
public class Solution {
public int pileBox(int[][] box) {
Arrays.sort(box,(o1, o2) -> {
if (o1[0]==o2[0]){
if (o1[1]==o2[1]){
return o1[2]-o2[2];
}else
return o1[1]-o2[1];
}else
return o1[0]-o2[0];
});
// Arrays.stream(box).forEach(o-> System.out.println(o[0]+" "+o[1]+" "+o[2]));
int[] dp=new int[box.length+1];
dp[0]=box[0][2];
int max=dp[0];
for (int i=1;i<box.length;i++){
dp[i]=box[i][2];
for (int j=0;j<i;j++){
if (box[i][2]>box[j][2]&&box[i][1]>box[j][1]&&box[i][0]>box[j][0]){
//为什么0,1,2都要比较,要去除等于的情况
dp[i]=Math.max(dp[i],dp[j]+box[i][2]);
}
}
max=Math.max(max,dp[i]);
}
return max;
}
}