一叶障目 -- 你不知道的"最长递增子序列"!

前言

在前端领域, 相信不少朋友都有在面试时被问到性能优化, 框架源码的问题?如果是Vue前端开发, 大概率会被问到Vue3对比Vue2做了哪些性能优化这个问题了。这就避不开diff算法的优化了,具体做了什么优化呢?

看了源码后, 知道了这个问题的答案:最核心的就是通过"最长递增子序列"的算法策略, 实现了diff过程中子节点的移动次数最小化, 来达到提升性能收益的。

于是乎我去了leetcode, 刷了"最长递增子序列"这道题, 于是乎也就有了这篇文章。

leetcode直通车

最长递增子序列

最长递增子序列

这个题目的官方题解有两个方法,关于动态规划的方法,看了下面的评论后, 很多人说动态规划的实现虽然代码比较精简,但是不太好理解,下面我来谈谈我的理解。

在我看来, 动态规划最重要的是要找到切入点, 这也是最难的一步。对于这个题目来说, 就是官方题解中的这一句:

"定义 dp[i] 为考虑前 i 个元素,以第 i 个数字结尾的最长上升子序列的长度,注意 nums[i] 必须被选取"

也就是将序列中的每个元素作为递增子序列的最后一个元素时的当前最长递增子序列的长度。

最后再从这些值中求出最大的值,就是题解了。那么剩下的问题就是如何求解dp[i]了。

如何求解dp[i]

每一个元素和前一个元素相比,无非是两种情况, 大于或者不大于前一个元素。如果dp[i]大于前一个元素, 在dp[i]和dp[i-1]+1中求最大值,就是dp[i]了!

代码实现

ini 复制代码
/**
 * @param {number[]} nums
 * @return {number}
 */
var lengthOfLIS = function(nums) {
    // 边界情况
    if(nums.length === 0) return 0
    // 只要序列长度不为空, 则最长递增子序列至少为1
    let dp = new Array(nums.length).fill(1)
    // 这两轮循环实质是求序列中的每一位作为递增子序列的最后一位索引时的最长递增子序列
    for(let i = 1; i < nums.length; i++) {
       for(let j = 0; j < i; j++) {
         // 将nums[i]和前面的每一位num[j]进行比较, 取其中最大的dp[j]+1, 就是dp[i]
         if(nums[i] > nums[j]){
            dp[i] = Math.max(dp[i], dp[j] + 1)
         }
       }
    }
    return Math.max(...dp)
};

如何动态规划

通过对这道题的题解进行分析,可以得出动态规划解题的"四步走", 如下:

第一步:找出"切入点",对应这个题目,就是找出以序列中的每个元素作为递增子序列的最后一个元素时的最长递增子序列的值dp[i]

第二步:构造"状态转移方程",对应这个题目,就是: dp[i]=max(dp[i-1]+1, dp[i])

第三步:求出题解,对应这个题目,就是 Math.max(...dp)

一句话总结, 就是找准切入点, 写出状态转移方程(或者想出状态转移过程也是一样的),求出题解。

复杂度分析

  • 时间复杂度:O(n 2 ),其中 n 为数组 nums 的长度。动态规划的状态数为 n,计算状态 dp[i] 时,需要 O(n) 的时间遍历 dp[0...i−1] 的所有状态,所以总时间复杂度为 O(n 2 )。

  • 空间复杂度:O(n),需要额外使用长度为 n 的 dp 数组。

下面来讲讲第二种解题思路:贪心算反 + 二分搜索 的实现

实现思路

这里引用下官方题解:

考虑一个简单的贪心,如果我们要使上升子序列尽可能的长,则我们需要让序列上升得尽可能慢,因此我们希望每次在上升子序列最后加上的那个数尽可能的小。基于上面的贪心思路,我们维护一个数组 d[i] ,表示长度为 i 的最长上升子序列的末尾元素的最小值,用 len 记录目前最长上升子序列的长度,起始时 len 为 1,d[1]=nums[0]。

关于d[i]单调递增的证明, 个人觉得官方题解讲得不够清晰,这里引用下leetcode下面评论区的一位朋友的解释:

证明数组d具有单调性,即证明i<j时,d[i]<d[j],可以使用反证法,假设存在k<j时,d[k]>d[j],但在长度为j,末尾元素为d[j]的子序列A中,将后j-i个元素减掉,可以得到一个长度为i的子序列B,其末尾元素t1必然小于d[j](因为在子序列A中,t1的位置上在d[j]的后面),而我们假设数组d必须符合表示长度为 i 的最长上升子序列的末尾元素的最小值,此时长度为i的子序列的末尾元素t1<d[j]<d[k],即t1<d[k],所以d[k]不是最小的,与题设相矛盾,因此可以证明其单调性

我们依次遍历数组 nums 中的每个元素,并更新数组 d 和 len 的值。如果 nums[i]>d[len] 则更新 len=len+1,否则在数组d的索引1和len对应的值范围内寻找满足 d[i−1]<nums[j]<d[i] 的下标 i,也就是说在同等长度下比如最长递增子序列为2(i为2)的情况下找一个尽可能小的值作为d[2]的值,也就是d[2]=nums[j]

那么问题来了,怎么找到这个尽可能小的值呢?

上面已经证明了数组d是一个单调递增数组,利用这个特性,也就可以通过二分搜索的思路去找了!而二分搜索的时间复杂度是 O(lgn), 也就是说理论上来说,通过贪心+二分的方式,来找到这个最长递增子序列,最后的时间复杂度比 O(n2)要小,预计是 O(nlogn)

那么除了临界情况,也就是数组为空数组的时候返回0以外,返回的最小递增子序列的值至少为1。所以我们可以初始化一个足够长的数组d来统计所有的d[i], 也就是和数组的长度相同就可以了。而为了更加直观的看出最长递增子序列的值与对应的最小值之间的关系,也可以新建一个长度+1的数组,从d[1] = nums[0]开始进行遍历。下面用代码来实现下。

代码实现

ini 复制代码
/**
 * @param {number[]} nums
 * @return {number}
 */
var lengthOfLIS = function(nums) {
    let n = nums.length
    if(n === 0) return 0
    let len = 1, d = new Array(n+1)
    d[len]=nums[0]
    for(let i = 1; i < n; i++) {
        // 比较极端的情况是顺序序列,例如 [1, 2, 3, 4, 5, 6]
        // 则对应的d为 [, 1, 2, 3, 4, 5, 6]
        if(nums[i] > d[len]){
            len++
            d[len] = nums[i]
        }else{
            // 通过二分搜索在现有的d序列中找到小于nums[i]的最大的值,并记录位置。
            // 如果都比nums[i]大,就更新d[1]=nums[i], 否则更新d[position+1]=nums[i]
 
            // 例如 [7, 8, 10, 11, 9, 6], 当遍历到9的时候, 此时的d为 [, 7, 8, 10, 11], 则去d序列中,通过二分搜索,找到比9小的最大的这个值8的下一位并进行更新, 也就是 d[3] = 9(长度为3的最小递增子序列的最小末位值为9), 此时d变为 [, 7, 8, 9, 11]。接着遍历,遍历到6的时候,由于6比所有的d序列中的值都要小, 所以d变为  [, 6, 8, 9, 11], len为4,对应的最长递增子序列为d[4] = 11, 也就是 [7, 8, 10, 11]. 

             // 如果是 [7, 8, 10, 11, 9, 12] , 则最终的d序列是 [, 7, 8, 9, 11, 12], len = 5, 对应的最长递增子序列为[7, 8, 10, 11, 12]. 
            let left = 1, right = len, position = null
            while(left <= right) {
                let middle = (left + right) >> 1
                // 比nums[i]小,就继续去此单调递增序列d的右半边去找,一直找到比nums[i]小的最大的值并记录下来。如果都比nums[i]大,就更新d[1] = nums[i]
                if(nums[i] > d[middle]){
                    position = middle
                    left = middle + 1
                }else{
                    right = middle -1
                }
            }
           if(position){
               d[position+1] = nums[i]
           }else{
               d[1]=nums[i]
           }
        }
    }
    return len
};

如何二分搜索

固定范式,如下:

scss 复制代码
            while(left <= right) {
                let middle = (left + right) >> 1
                if(nums[i] > d[middle]){
                    // 二分搜索要做的事情
                    left = middle + 1
                }else{
                    right = middle -1
                }
            }

二分搜索要做的事情写到以上两个条件分支的其中一个上就可以了。

复杂度分析

  • 时间复杂度:O(nlogn)。数组 nums 的长度为 n,我们依次用数组中的元素去更新 d 数组,而更新 d 数组时需要进行 O(logn) 的二分搜索,所以总时间复杂度为 O(nlogn)。

  • 空间复杂度:O(n),需要额外使用长度为 n 的 d 数组。

总结

以上两种思路,动态规划的实现,代码最精简,思路最清晰易懂,但是时间复杂度比较高. 而贪心 + 二分查找的思路,时间复杂度最低.

写在最后

感谢你能看到这里,继续加油哦💪!如果小伙伴们还有其他的应用场景,欢迎在评论区留言哈😄。码字不易, 如若有错误,也望指出哈❤️。

如果本文对你有一点点帮助,点个赞支持一下呗,你的每一个【赞】都是我创作的最大动力 😘。

如果您觉得文章不错,记得 点赞关注加收藏 哦 💕

相关推荐
hhw1991122 分钟前
vue总结
前端·javascript·vue.js
学习2年半3 分钟前
汇丰eee2
前端·spring
代码续发5 分钟前
Vue进行前端开发流程
前端·vue.js
zpjing~.~8 分钟前
CSS &符号
前端·css
白白糖38 分钟前
组合与括号生成(回溯)
python·算法·力扣
whltaoin38 分钟前
动态规划算法深度解析:0-1背包问题(含完整流程)
算法·动态规划
冴羽44 分钟前
SvelteKit 最新中文文档教程(19)—— 最佳实践之身份认证
前端·javascript·svelte
拉不动的猪1 小时前
ES2024 新增的数组方法groupBy
前端·javascript·面试
huangkaihao1 小时前
单元测试 —— 用Vitest解锁前端可靠性
前端
好好学习^按时吃饭1 小时前
蓝桥杯2022年第十三届省赛真题-统计子矩阵
算法·蓝桥杯