LeetCode300. 最长递增子序列

description

给你一个整数数组 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)。

解法1:动态规划(暴力)

由上述分析,我们可以换个方式,遍历nums中的每一个数,统计以下标 i 结尾的元素的最长上升子序列的长度,使用dp[ i ]表示以 i 结尾的序列中,最长递增子序列的长度。最后,整个数组的最长递增子序列即所有 dp[ i ] 中的最大值。
这里我们可以定义状态转移方程:

  • dp[ i ] = max (dp[ j ]) + 1 , 其中 0 ≤ j< i 且 num[ j ] < num[ 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数组。

解法2:贪心+二分查找

这个解法是看题解看到的另外一种目前看最优解法,复杂度更低(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(nlogn) ,数组 nums 的长度为 n,我们依次用数组中的元素去更新 d 数组,而更新 d 数组时需要进行 O(logn) 的二分搜索,所以总时间复杂度为 O(nlogn)。
  • 空间复杂度:O(n),需要使用辅助数组 d 数组(长度为n)。

心得

一道经典的求解最长上升子序列问题,原本对这部分是了解较少的,由暴力O(n2)的思路转换为动态规划的做法,再采用贪心+二分查找的方法,将复杂度降下来。这次通过这道题进行方法的积累,以便之后进行同类型的问题求解。之后应反复复习该类型题目,擅于总结方法。

你可能感兴趣的:(LeetCode题解,算法,二分查找,贪心算法)