最长递增子序列系列题目总结

893 阅读4分钟

基本介绍

最长递增子序列(Longest Increasing Subsequence,简写 LIS)是非常经典的一个算法问题,比较容易想到的是动态规划解法,时间复杂度 O(N^2),我们借这个问题来由浅入深讲解如何找状态转移方程,如何写出动态规划解法。比较难想到的是利用二分查找,时间复杂度是 O(NlogN),我们通过一种简单的纸牌游戏来辅助理解这种巧妙的解法。

300. 最长递增子序列

题目链接:leetcode.cn/problems/lo…

image.png

输入一个无序的整数数组,请你找到其中最长的严格递增子序列的长度,函数签名如下:

int lengthOfLIS(int[] nums);

比如说输入nums=[10,9,2,5,3,7,101,18],其中最长的递增子序列是 [2,3,7,101],所以算法的输出应该是 4。

注意「子序列」和「子串」这两个名词的区别,子串一定是连续的,而子序列不一定是连续的。下面先来设计动态规划算法解决这个问题。

动态规划的核心设计思想是数学归纳法。

相信大家对数学归纳法都不陌生,高中就学过,而且思路很简单。比如我们想证明一个数学结论,那么我们先假设这个结论在k < n时成立,然后根据这个假设,想办法推导证明出k = n的时候此结论也成立。如果能够证明出来,那么就说明这个结论对于k等于任何数都成立。

类似的,我们设计动态规划算法,不是需要一个 dp 数组吗?我们可以假设dp[0...i-1]都已经被算出来了,然后问自己:怎么通过这些结果算出dp[i]

直接拿最长递增子序列这个问题举例你就明白了。不过,首先要定义清楚 dp 数组的含义,即dp[i]的值到底代表着什么?

我们的定义是这样的:dp[i]表示以nums[i]这个数结尾的最长递增子序列的长度

根据这个定义,我们就可以推出 base case:dp[i]初始值为 1,因为以nums[i]结尾的最长递增子序列起码要包含它自己。

举两个例子:

image.png

根据这个定义,我们的最终结果(子序列的最大长度)应该是 dp 数组中的最大值。

int res = 0;
for (int i = 0; i < dp.length; i++) {
    res = Math.max(res, dp[i]);
}
return res;

读者也许会问,刚才的算法演进过程中每个dp[i]的结果是我们肉眼看出来的,我们应该怎么设计算法逻辑来正确计算每个dp[i]呢?

这就是动态规划的重头戏,如何设计算法逻辑进行状态转移,才能正确运行呢?这里需要使用数学归纳的思想:

假设我们已经知道了dp[0..4]的所有结果,我们如何通过这些已知结果推出dp[5]

MH}GT%SJIV%H9VLNJ4%4O.png

根据刚才我们对dp数组的定义,现在想求dp[5]的值,也就是想求以nums[5]为结尾的最长递增子序列。

nums[5] = 3,既然是递增子序列,我们只要找到前面那些结尾比 3 小的子序列,然后把 3 接到这些子序列末尾,就可以形成一个新的递增子序列,而且这个新的子序列长度加一

nums[5]前面有哪些元素小于nums[5]?这个好算,用 for 循环比较一波就能把这些元素找出来。

以这些元素为结尾的最长递增子序列的长度是多少?回顾一下我们对dp数组的定义,它记录的正是以每个元素为末尾的最长递增子序列的长度。

以我们举的例子来说,nums[0]nums[4]都是小于nums[5]的,然后对比dp[0]dp[4]的值,我们让nums[5]和更长的递增子序列结合,得出dp[5] = 3

image.png

for (int j = 0; j < i; j++) {
    if (nums[i] > nums[j]) {
        dp[i] = Math.max(dp[i], dp[j] + 1);
    }
}

i = 5时,这段代码的逻辑就可以算出dp[5]。其实到这里,这道算法题我们就基本做完了。

读者也许会问,我们刚才只是算了dp[5]呀,dp[4],dp[3]这些怎么算呢?类似数学归纳法,你已经可以算出dp[5]了,其他的就都可以算出来:

for (int i = 0; i < nums.length; i++) {
    for (int j = 0; j < i; j++) {
        // 寻找 nums[0..j-1] 中比 nums[i] 小的元素
        if (nums[i] > nums[j]) {
            // 把 nums[i] 接在后面,即可形成长度为 dp[j] + 1,
            // 且以 nums[i] 为结尾的递增子序列
            dp[i] = Math.max(dp[i], dp[j] + 1);
        }
    }
}

完整代码如下:

class Solution {
    public int lengthOfLIS(int[] nums) {
        int n = nums.length;
        if(n == 0) {
            return 0;
        }
        int[] dp = new int[n];
        int max = Integer.MIN_VALUE;
        Arrays.fill(dp, 1);
        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] + 1);
                }
            }
        }
        for(int i = 0; i < n; i++) {
            if(dp[i] > max) {
                max = dp[i];
            }
        }
        return max;
    }
}

二分查找解法

这个解法的时间复杂度为O(NlogN),但是说实话,正常人基本想不到这种解法(也许玩过某些纸牌游戏的人可以想出来)。所以大家了解一下就好,正常情况下能够给出动态规划解法就已经很不错了。

根据题目的意思,我都很难想象这个问题竟然能和二分查找扯上关系。其实最长递增子序列和一种叫做 patience game 的纸牌游戏有关,甚至有一种排序方法就叫做 patience sorting(耐心排序)。

为了简单起见,后文跳过所有数学证明,通过一个简化的例子来理解一下算法思路。

首先,给你一排扑克牌,我们像遍历数组那样从左到右一张一张处理这些扑克牌,最终要把这些牌分成若干堆。

image.png

处理这些扑克牌要遵循以下规则

只能把点数小的牌压到点数比它大的牌上;如果当前牌点数较大没有可以放置的堆,则新建一个堆,把这张牌放进去;如果当前牌有多个堆可供选择,则选择最左边的那一堆放置。

比如说上述的扑克牌最终会被分成这样 5 堆(我们认为纸牌 A 的牌面是最大的,纸牌 2 的牌面是最小的)。

image.png

为什么遇到多个可选择堆的时候要放到最左边的堆上呢?因为这样可以保证牌堆顶的牌有序(2, 4, 7, 8, Q),证明略。

我们只要把处理扑克牌的过程编程写出来即可。每次处理一张扑克牌不是要找一个合适的牌堆顶来放吗,牌堆顶的牌不是有序吗,这就能用到二分查找了:用二分查找来搜索当前牌应放置的位置。

代码如下:

class Solution {
    public int lengthOfLIS(int[] nums) {
        //存储每个牌堆的堆顶元素
        int[] top = new int[nums.length];
        //牌堆数初始化为0
        int piles = 0;
        for(int i = 0; i < nums.length; i++) {
            int poker = nums[i];
            //搜素左侧边界的二分查找
            int left = 0, right = piles;
            while(left < right) {
                int mid = (left + right) / 2;
                if(top[mid] > poker) {
                    right = mid;
                } else if (top[mid] < poker) {
                    left = mid + 1;
                } else if (top[mid] == poker) {
                    right = mid;
                }
            }
            //没找到合适的牌堆,新建一个牌堆
            if(left == piles) {
                piles++;
            }
            //把这个牌放到牌堆顶
            top[left] = poker;
        }
        //牌堆数就是LIS长度
        return piles;
    }
}

354. 俄罗斯套娃信封问题

题目链接:leetcode.cn/problems/ru…

image.png

这道题目其实是最长递增子序列的一个变种,因为每次合法的嵌套是大的套小的,相当于在二维平面中找一个最长递增的子序列,其长度就是最多能嵌套的信封个数

前面说的标准 LIS 算法只能在一维数组中寻找最长子序列,而我们的信封是由(w, h)这样的二维数对形式表示的,如何把 LIS 算法运用过来呢?

image.png

读者也许会想,通过w × h计算面积,然后对面积进行标准的 LIS 算法。但是稍加思考就会发现这样不行,比如1 × 10大于3 × 3,但是显然这样的两个信封是无法互相嵌套的。

这道题的解法比较巧妙:

先对宽度w进行升序排序,如果遇到w相同的情况,则按照高度h降序排序;之后把所有的h作为一个数组,在这个数组上计算 LIS 的长度就是答案

画个图理解一下,先对这些数对进行排序:

image.png

然后在h上寻找最长递增子序列,这个子序列就是最优的嵌套方案:

image.png

为什么呢?稍微思考一下就明白了:

首先,对宽度w从小到大排序,确保了w这个维度可以互相嵌套,所以我们只需要专注高度h这个维度能够互相嵌套即可。

其次,两个w相同的信封不能相互包含,所以对于宽度w相同的信封,对高度h进行降序排序,保证 LIS 中不存在多个w相同的信封(因为题目说了长宽相同也无法嵌套)。

完整代码如下:

class Solution {
    public int maxEnvelopes(int[][] envelopes) {
        int m = envelopes.length;
        int[] heights = new int[m];
        //先按照宽度升序排序,再按照高度降序排序
        Arrays.sort(envelopes, (a, b) -> {
            if(a[0] == b[0]) {
                return b[1] - a[1];
            }
            return a[0] - b[0];
        });
        
        //获取高度数据
        for(int i = 0; i < m; i++) {
            heights[i] = envelopes[i][1];
        }
        return lengthOfLIS(heights);//
    }
    
    /**
     * 通过二分查找法
     */
    public int lengthOfLIS(int[] nums) {
        int[] top = new int[nums.length];
        int piles = 0;
        for(int i = 0; i < nums.length; i++) {
            int poker = nums[i];
            int left = 0, right = piles;
            while(left < right) {
                int mid = (left + right) / 2;
                if(top[mid] > poker) {
                    right = mid;
                } else if (top[mid] < poker) {
                    left = mid + 1;
                } else if (top[mid] == poker) {
                    right = mid;
                }
            }
            if(left == piles) {
                piles++;
            }
            top[left] = poker;
        }
        return piles;
    }
}