文章目录
- 最长递增子序列
- 解法一:动态规划
- 解法二:LIS 和 LCS 的关系
- 解法三:贪心 + 二分查找
- 相关题目
- 673. 最长递增子序列的个数 https://leetcode.cn/problems/number-of-longest-increasing-subsequence/
- 1964. 找出到每个位置为止最长的有效障碍赛跑路线 https://leetcode.cn/problems/find-the-longest-valid-obstacle-course-at-each-position/
- 1671. 得到山形数组的最少删除次数 https://leetcode.cn/problems/minimum-number-of-removals-to-make-mountain-array/
- 354. 俄罗斯套娃信封问题 https://leetcode.cn/problems/russian-doll-envelopes/
- 1626. 无矛盾的最佳球队 https://leetcode.cn/problems/best-team-with-no-conflicts/
本文介绍最长递增子序列的两种解法,以及一些相关题目的简单答案。
本文的重点是学习 时间复杂度为 O ( N 2 ) O(N^2) O(N2) 的动态规划 和 时间复杂度为 ( N ∗ log 2 N ) (N*\log{2}{N}) (N∗log2N) 的贪心+二分查找 这两种解决 类 最长递增子序列问题的解法。
在最后补充的相关题目中,需要学习当需要考虑的元素有两个时,如何通过自定义排序来避免考虑其中的一个元素。
最长递增子序列
300. 最长递增子序列
解法一:动态规划
双重 for 循环 dp。
class Solution {
public int lengthOfLIS(int[] nums) {
int n = nums.length, ans = 1;
int[] dp = new int[n];
Arrays.fill(dp, 1);
for (int i = 0; i < n; ++i) {
for (int j = 0; j < i; ++j) {
// nums[i]可以作为nums[j]的后续元素
if (nums[i] > nums[j]) dp[i] = Math.max(dp[i], dp[j] + 1);
}
ans = Math.max(ans, dp[i]);
}
return ans;
}
}
还有一种 dp 写法,我感觉比较奇怪还没有理解:
class Solution {
public int lengthOfLIS(int[] nums) {
int n = nums.length, ans = 1;
int[] dp = new int[n];
Arrays.fill(dp, 0);
for (int i = 0; i < n; ++i) {
for (int j = 0; j < i; ++j) {
if (nums[i] > nums[j]) dp[i] = Math.max(dp[i], dp[j]);
}
dp[i]++;
}
return Arrays.stream(dp).max().getAsInt();
}
}
推荐使用第一种写法。
解法二:LIS 和 LCS 的关系
也就是说:
nums = [1, 3, 3, 2, 4]
排序去重后为: [1, 2, 3, 4]
求 nums 和 [1, 2, 3, 4] 的最长公共子序列就好了。方法参见:【算法】最长公共子序列&编辑距离
这种方法 和 上面的 DP 方法的时间复杂度都是 O ( n 2 ) O(n^2) O(n2) 的。
解法三:贪心 + 二分查找
进阶技巧:对于动态规划,可以尝试 交换状态与状态值。
例如:
很容易可以理解下面代码的逻辑,从前向后依次遍历各个元素。
- 当前元素大于列表中已有的最后一个元素时,将其加入列表;
- 当前元素不大于列表中已有的最后一个元素时,则找到列表中第一个大于等于当前元素数字的位置,将其替换成当前元素。
class Solution {
public int lengthOfLIS(int[] nums) {
List<Integer> ls = new ArrayList();
int n = nums.length;
ls.add(nums[0]);
for (int i = 1; i < n; ++i) {
if (nums[i] > ls.get(ls.size() - 1)) ls.add(nums[i]);
else {
int l = 0, r = ls.size() - 1; // 找到第一个大于等于nums[i]的位置
while (l < r) {
int mid = l + r >> 1;
if (ls.get(mid) < nums[i]) l = mid + 1;
else r = mid;
}
ls.set(l, nums[i]);
}
}
return ls.size();
}
}
相关题目
673. 最长递增子序列的个数 https://leetcode.cn/problems/number-of-longest-increasing-subsequence/
https://leetcode.cn/problems/number-of-longest-increasing-subsequence/
这道题目不仅需要知道最长递增子序列的长度,还需要知道它的数量,因此需要一个额外的 cnt[] 数组。
class Solution {
public int findNumberOfLIS(int[] nums) {
int n = nums.length, mxL = 1, ans = 0;
int[] dp = new int[n], cnt = new int[n]; // 一个记录最大长度,一个记录最大长度的数量
Arrays.fill(dp, 1);
Arrays.fill(cnt, 1);
for (int i = 0; i < n; ++i) {
for (int j = 0; j < i; ++j) {
// dp 递推
if (nums[i] > nums[j]) {
if (dp[j] + 1 > dp[i]) {
dp[i] = dp[j] + 1;
cnt[i] = cnt[j];
} else if (dp[j] + 1 == dp[i]) cnt[i] += cnt[j];
}
}
mxL = Math.max(mxL, dp[i]);
}
// 统计所有序列长度为最长的数量之和
for (int i = 0; i < n; ++i) {
if (dp[i] == mxL) ans += cnt[i];
}
return ans;
}
}
这题也可以使用 贪心+前缀和+二分查找 来做。(有兴趣的自己看吧:https://leetcode.cn/problems/number-of-longest-increasing-subsequence/solution/zui-chang-di-zeng-zi-xu-lie-de-ge-shu-by-w12f/)
1964. 找出到每个位置为止最长的有效障碍赛跑路线 https://leetcode.cn/problems/find-the-longest-valid-obstacle-course-at-each-position/
https://leetcode.cn/problems/find-the-longest-valid-obstacle-course-at-each-position/
提示:
n == obstacles.length
1 <= n <= 105
1 <= obstacles[i] <= 107
实际上是求以 i 为结尾的最长非递减子序列长度。
观察到题目给出的数据范围,因此直接使用时间复杂度为
O
(
N
2
)
O(N^2)
O(N2)的动态规划是会TLE的。
下面给出超时的代码:
class Solution {
public int[] longestObstacleCourseAtEachPosition(int[] obstacles) {
int n = obstacles.length;
int[] ans = new int[n];
Arrays.fill(ans, 1);
for (int i = 0; i < n; ++i) {
for (int j = i - 1; j >= 0; --j) {
if (obstacles[j] <= obstacles[i]) ans[i] = Math.max(ans[j] + 1, ans[i]);
}
}
return ans;
}
}
所以,我们需要使用时间复杂度为
O
(
N
∗
log
2
N
)
O(N*\log_{2}{N})
O(N∗log2N) 的贪心+二分查找方法来做这道题目。
代码如下:
class Solution {
public int[] longestObstacleCourseAtEachPosition(int[] obstacles) {
int n = obstacles.length;
List<Integer> ls = new ArrayList();
ls.add(obstacles[0]);
int[] ans = new int[n];
ans[0] = 1;
for (int i = 1; i < n; ++i) {
if (obstacles[i] >= ls.get(ls.size() - 1)) { // 很大,直接放在最后
ls.add(obstacles[i]);
ans[i] = ls.size();
} else {
// 寻找第一个大于obstacles[i]的数字
int l = 0, r = ls.size() - 1;
while (l < r) {
int mid = l + r >> 1;
if (ls.get(mid) <= obstacles[i]) l = mid + 1;
else r = mid;
}
ls.set(l, obstacles[i]);
ans[i] = l + 1;
}
}
return ans;
}
}
将代码与 最长递增子序列 这道题目的答案进行比较,可以发现其实只多了两句:
ans[i] = ls.size();
和
ans[i] = l + 1;
1671. 得到山形数组的最少删除次数 https://leetcode.cn/problems/minimum-number-of-removals-to-make-mountain-array/
https://leetcode.cn/problems/minimum-number-of-removals-to-make-mountain-array/
对于数组中的每个元素,求 以它为结尾的从前往后的最长递增子序列长度 和 以它为结尾的从后往前的最长递增子序列的长度,这样它就是山形数组的山顶。
判断哪个元素作为山顶时,两个递增子序列的长度之和最长,结果就取哪个。
(注意题目要求山顶两边都必须有比它小的数字)
class Solution {
public int minimumMountainRemovals(int[] nums) {
int n = nums.length, ans = n;
int[] l1 = new int[n], l2 = new int[n];
List<Integer> ls = new ArrayList();
// 找从前往后的
for (int i = 0; i < n; ++i) {
if (ls.size() == 0 || nums[i] > ls.get(ls.size() - 1)) ls.add(nums[i]);
else ls.set(bs(ls, nums[i]), nums[i]);
l1[i] = ls.size();
}
ls.clear();
// 找从后往前的
for (int i = n - 1; i >= 0; --i) {
if (ls.size() == 0 || nums[i] > ls.get(ls.size() - 1)) ls.add(nums[i]);
else ls.set(bs(ls, nums[i]), nums[i]);
l2[i] = ls.size();
}
for (int i = 0; i < n; ++i) {
// 山顶两边都必须有比它小的数字,因此序列长度只有1(只有它自己)是不行的
if (l1[i] == 1 || l2[i] == 1) continue;
ans = Math.min(n - l1[i] - l2[i] + 1, ans);
}
return ans;
}
public int bs(List<Integer> ls, int v) {
// 二分查找
int l = 0, r = ls.size() - 1;
while (l < r) {
int mid = l + r >> 1;
if (v > ls.get(mid)) l = mid + 1;
else r = mid;
}
return l;
}
}
354. 俄罗斯套娃信封问题 https://leetcode.cn/problems/russian-doll-envelopes/
https://leetcode.cn/problems/russian-doll-envelopes/
提示:
1 <= envelopes.length <= 10^5
envelopes[i].length == 2
1 <= wi, hi <= 10^5
注意看数据范围,使用 O ( N 2 ) O(N^2) O(N2) 的动态规划是会超时的。
这道题目一个很牛逼的点在于:使用自定义排序,这样在遍历的过程中就可以忽略信封的宽度了。
忽略宽度后,求排序后高度的最长递增子序列即可。
class Solution {
public int maxEnvelopes(int[][] envelopes) {
Arrays.sort(envelopes, (a, b) -> {
return a[0] == b[0]? b[1] - a[1]: a[0] - b[0]; // 第一元素升序,第二元素降序
});
// 之后可以忽略第一元素了
int n = envelopes.length;
List<Integer> ls = new ArrayList();
ls.add(envelopes[0][1]);
for (int i = 1; i < n; ++i) {
if (envelopes[i][1] > ls.get(ls.size() - 1)) ls.add(envelopes[i][1]);
// 二分查找寻找需要放置的位置
int l = 0, r = ls.size() - 1;
while (l < r) {
int mid = l + r >> 1, v = ls.get(mid);
if (v < envelopes[i][1]) l = mid + 1;
else r = mid;
}
ls.set(l, envelopes[i][1]);
}
return ls.size();
}
}
Q:为什么要这样自定义排序?
A:首先按第一元素升序排序没有疑问,为了在遍历的过程中可以忽略第一元素,所以在第一元素相等的情况下,需要对第二元素进行降序排序。举个例子如下:
对第二关键字进行降序排序后,这些 h 值就不可能组成长度超过 1 的严格递增的序列了。
(详情解释可见:https://leetcode.cn/problems/russian-doll-envelopes/solution/e-luo-si-tao-wa-xin-feng-wen-ti-by-leetc-wj68/)
1626. 无矛盾的最佳球队 https://leetcode.cn/problems/best-team-with-no-conflicts/
https://leetcode.cn/problems/best-team-with-no-conflicts/
1 <= scores.length, ages.length <= 1000
数据范围比较小,可以使用时间复杂度为 O ( N 2 ) O(N^2) O(N2) 的动态规划。
对于这种需要同时考虑两种元素的,我们的一个重要策略就是通过自定义排序忽略其中的每一种元素。
在这道题中,先按分数升序排,再按年龄升序排。这样后面遍历到的分数已经时符合条件的,这样只需要判断年龄就可以了。
dp 数组的意义是:dp[i] 表示最后组建的球队中的最大球员序号为排序后的第 i 名球员时的球队最大分数(此时的球员序号为排序后的新序号)
class Solution {
public int bestTeamScore(int[] scores, int[] ages) {
int n = scores.length;
int[][] people = new int[n][2];
for (int i = 0; i < n; ++i) {
people[i][0] = scores[i];
people[i][1] = ages[i];
}
// 排序 按分数升序排,再按年龄升序排
Arrays.sort(people, (a, b) -> {
return a[0] != b[0]? a[0] - b[0]: a[1] - b[1];
});
int[] dp = new int[n];
int ans = 0;
for (int i = 0; i < n; ++i) {
dp[i] = people[i][0]; // 至少选自己
for (int j = 0; j < i; ++j) {
// 我的分数一定大于等于你了,只要年纪也大于等于你就可以和你一起选
if (people[i][1] >= people[j][1]) {
dp[i] = Math.max(dp[i], dp[j] + people[i][0]);
}
}
ans = Math.max(ans, dp[i]);
}
return ans;
}
}