给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例2:
输入:nums = [0,1,0,3,2,3]
输出:4
示例3:
输入:nums = [7,7,7,7,7,7,7]
输出:1
提示:
1 <= nums.length <= 2500
-104 <= nums[i] <= 104
进阶:
你可以设计时间复杂度为 O(n2) 的解决方案吗?
你能将算法的时间复杂度降低到 O(n log(n)) 吗?
首先,本题要求结果是严格递增的子序列,最为朴素的方法就是:从nums数组的首位开始,一个个地向后遍历,如果有大于它的数,则计入子序列数组中,一趟下来找到一个子序列,添加到符合要求的子序列集合中;之后再从第二个数开始向后找,直到数组末尾,再将本趟找到的子序列添加到子序列集合中…如此反复,产生了一个符合要求的子序列集合,在其中找到最长的子序列长度,即为答案。该方法的时间复杂度即为O(n2)。
由上述分析,我们可以换个方式,遍历nums中的每一个数,统计以下标 i 结尾的元素的最长上升子序列的长度,使用dp[ i ]表示以 i 结尾的序列中,最长递增子序列的长度。最后,整个数组的最长递增子序列即所有 dp[ i ] 中的最大值。
这里我们可以定义状态转移方程:
即:往 dp[0…i-1] 中最长的递增子序列后面再加一个 nums[ i ]。由于 dp[ j ] 代表 nums[0…j] 中以 nums[ j ] 结尾的最长递增子序列,所以若要更新,那么 nums[ i ] 必然要大于 nums[ j ],才能将 nums[ i ] 放在 nums[ j ] 后面以形成更长的递增子序列。
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
if(n == 0) return 0;
vector<int> dp(n, 0);
for(int i = 0; i < n; i++){
dp[i] = 1;
for(int j = 0; j < i; j++){
if(nums[i] > nums[j]){
dp[i] = max(dp[i], dp[j] + 1);
}
}
}
return *max_element(dp.begin(), dp.end());
}
};
时间复杂度:O(n2),n 为数组 nums 的长度。对nums[i]进行遍历用时O(n2);对每个nums[i]求解dp[i] 时,需要 O(n) 的时间遍历 dp[0…i−1] 的所有情况,所以总时间复杂度为 O(n2)
空间复杂度:O(n),使用长度为n的dp数组。
这个解法是看题解看到的另外一种目前看最优解法,复杂度更低(O(nlogn))。
考虑一个简单的贪心,如果我们要使上升子序列尽可能的长,则我们需要让序列上升得尽可能慢,因此我们希望每次在上升子序列最后加上的那个数尽可能的小。
基于上面的贪心思路,我们维护一个数组 d ,d[i]表示长度为 i 的最长上升子序列的末尾元素的最小值,用 len 记录目前最长上升子序列的长度,起始时 len 为 1,d[1]=nums[0]。
我们依次遍历数组 nums 中的每个元素,并更新数组 d 和 len 的值。如果 nums[i] > d[len] 则更新 len=len+1,否则在d[1…len]中找满足 d[i−1] < nums[j] < d[i] 的下标 i,并更新d[i]=nums[j]。
此处:因为 d 数组是严格单调递增的,所以我们可以使用二分查找寻找下标 i,优化时间复杂度。
整个算法流程:
设当前已求出的最长上升子序列的长度为 len(初始为 1),从前往后遍历数组 nums,在遍历到 nums[i] 时:
如果 nums[i] > d[len] ,则直接加入到 d 数组末尾,并更新 len=len+1;
否则,在 d 数组中二分查找,找到第一个比 nums[i] 小的数 d[k] ,并更新 d[k+1]=nums[i]。
例:
以输入序列 [0, 8, 4, 12, 2] 为例:
第一步插入 0,d = [0];
第二步插入 8,d = [0, 8];
第三步插入 4,d = [0, 4];
第四步插入 12,d = [0, 4, 12];
第五步插入 2,d = [0, 2, 12]。
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
//2.BinarySearch + Greedy
int len = 1, n = nums.size();
if(n == 0) return 0;
vector<int> d(n + 1, 0);
d[len] = nums[0];
for(int i = 1; i < n; i++){
if(nums[i] > d[len])
d[++len] = nums[i];
else{
int l = 1, r = len, pos = 0;
while(l <= r){
int mid = l + (r - l) / 2;
// 要找第一个比nums[i] 小 的数d[k]
if(d[mid] < nums[i]){ // 当前d[mid]比要插入的值小
pos = mid; // 先将待插入位置指针定为mid(比nums[i]小 暂定为pos)
l = mid + 1; // 继续向右找,有没有更大的 比nums[i]小的(从小向nums[i]逼近)
}
else { // 当前d[mid]比要插入的值大(相等)
r = mid - 1; // 向左区间缩,查找更小的部分,找到第一个比nums[i]小的数
}
}
d[pos + 1] = nums[i];
}
}
return len;
}
};
注意:此处二分查找要求较为特殊,要反复、仔细将其研究明白。
一道经典的求解最长上升子序列问题,原本对这部分是了解较少的,由暴力O(n2)的思路转换为动态规划的做法,再采用贪心+二分查找的方法,将复杂度降下来。这次通过这道题进行方法的积累,以便之后进行同类型的问题求解。之后应反复复习该类型题目,擅于总结方法。