最长上升子序列(LIS)问题的四种求解方法(JavaScript版,自用)

前言

问题:300. 最长递增子序列

本篇文章用于记录对LIS问题的求解思路总结,用到的方法如下:

1. DP:时间复杂度:O(n^2)        2.二分+贪心:O(nlogn)       

3.dfs+回溯:O(2^n)                 4.树状数组优化DP:O(nlogn)    

在明白LIS问题,我们需要懂得一个概念,那就是何为子串,何为子序列的问题。

简单来说,假设你拥有一数字序列,我从其中抽出数字,若你属于连续抽取,取出的数都是相邻的,则为子串,若非连续,则为子序列。


LIS的定义

最长上升子序列(简称LIS),也有些时候,求的是最长非降序子序列,二者区别就是序列中是否可以有相等的数。

那什么是LIS问题呢,简单来说,假设我给你一个数字序列,如果这个序列中,存在这样一个子序列,该子序列中的数成递增现象,则为上升子序列,而LIS则是求其中最长的子序列,例子如下:

比如,对于序列(1, 7, 3, 5, 9, 4, 8),有它的一些上升子序列,如(1, 7), (3, 4, 8)等等。这些子序列中最长的长度是4,比如子序列(1, 3, 5, 8)


方法一:DP

我们都知道,动态规划的一个特点就是当前解可以由上一个阶段的解推出, 由此,把我们要求的问题简化成一个更小的子问题。子问题具有相同的求解方式,只不过是规模小了而已。最长上升子序列就符合这一特性。我们要求n个数的最长上升子序列,可以求前n-1个数的最长上升子序列,再跟第n个数进行判断。求前n-1个数的最长上升子序列,可以通过求前n-2个数的最长上升子序列……直到求前1个数的最长上升子序列,此时LIS当然为1。

  让我们举个例子:求 2 7 1 5 6 4 3 8 9 的最长上升子序列。我们定义d(i) (i∈[1,n])来表示前i个数以A[i]结尾的最长上升子序列长度。

  前1个数 d(1)=1 子序列为2;

  前2个数 7前面有2小于7 d(2)=d(1)+1=2 子序列为2 7

  前3个数 在1前面没有比1更小的,1自身组成长度为1的子序列 d(3)=1 子序列为1

  前4个数 5前面有2小于5 d(4)=d(1)+1=2 子序列为2 5

  前5个数 6前面有2 5小于6 d(5)=d(4)+1=3 子序列为2 5 6

  前6个数 4前面有2小于4 d(6)=d(1)+1=2 子序列为2 4

  前7个数 3前面有2小于3 d(3)=d(1)+1=2 子序列为2 3

  前8个数 8前面有2 5 6小于8 d(8)=d(5)+1=4 子序列为2 5 6 8

  前9个数 9前面有2 5 6 8小于9 d(9)=d(8)+1=5 子序列为2 5 6 8 9

  d(i)=max{d(1),d(2),……,d(i)} 我们可以看出这9个数的LIS为d(9)=5

  总结一下,d(i)就是找以A[i]结尾的,在A[i]之前的最长上升子序列+1,当A[i]之前没有比A[i]更小的数时,d(i)=1。所有的d(i)里面最大的那个就是最长上升子序列。

        简单来说,每次都向前找比它小的数与比它大的数的位置,将第一个比它大的替换掉,这样虽然LIS序列的具体数字可能会变,但是很明显LIS长度还是不变的,因为只是把数替换掉了,并没有改变增加或者减少长度。

状态设计:F[i]代表以A[i]结尾的LIS的长度

状态转移:F[i]=max{F[j]+1}(1<=j< i,A[j]< A[i])

边界处理:F[i]=1(1<=i<=n)

时间复杂度:O(n^2)

/**
 * @param {number[]} nums
 * @return {number}
 */
var lengthOfLIS = function(nums) {
    let max = 1;
    const dp = new Array(nums.length).fill(1);
    for (let i = 1; i < nums.length; i++) {
        for (let j = 0; j < i; j++) {
            nums[j] < nums[i] && (dp[i] = Math.max(dp[j] + 1, dp[i]));
        }
        max = Math.max(dp[i], max);
    }
    return max;
};

        这个算法的时间复杂度为〇(n²),代码也清晰,但这并不是最优的算法。在限制条件苛刻的情况下,这种方法行不通。那么怎么办呢!有没有时间复杂度更小的算法呢?说到这里了,当然是有的啦!还有一种时间复杂度为〇(nlogn)的算法,下面就来看看。


方法二:二分+贪心

        新建一个low数组,里面的low[i]表示长度为i的LIS结尾元素的最小值。对于一个上升子序列,显然,其结尾元素越小,越有利于在后面接其他的元素,也就越可能变得更长。因此,我们只需要维护low数组,对于每一个a[i],如果a[i] > low[当前最长的LIS长度],就把a[i]接到当前最长的LIS后面,即low[++当前最长的LIS长度]=a[i]。
        那么,怎么维护low数组呢?
        对于每一个a[i],如果a[i]能接到LIS后面,就接上去;否则,就用a[i]取更新low数组。具体方法是,在low数组中找到第一个大于等于a[i]的元素low[j],用a[i]去更新low[j]。如果从头到尾扫一遍low数组的话,时间复杂度仍是O(n^2)。我们注意到low数组内部一定是单调不降的,所有我们可以二分low数组,找出第一个大于等于a[i]的元素。二分一次low数组的时间复杂度的O(lgn),所以总的时间复杂度是O(nlogn)。

/**
 * @param {number[]} nums
 * @return {number}
 */
var lengthOfLIS = function(nums) {
    function binary_search(arr, l, r, key) {
    if (arr[r] < key)
        return r + 1;

    while (l < r) {
        let mid = l + Math.floor((r - l) / 2);
        if (arr[mid] < key)
            l = mid + 1;
        else
            r = mid;
    }
    return l;
}
    let B = [nums[0]];
    for (let i = 1; i < nums.length; i++) {
        let item=B.at(-1);
        if(item < nums[i]) B.push(nums[i]);
        else if(nums[i]<item) {
            let next=binary_search(B,0,B.length-1,nums[i])
            B[next]=nums[i]
        };
    }
    return B.length;
};

方法三:dfs + 回溯

这个方法其实会稍微好理解些,比如此时的序列为:5,3,4,9,7

我们换种想法,我们的目标是为了能够得到最长的子序列,那么,假设我们能够得到所有的序列,在求得的同时进行剪枝操作,或许就能求出来,但我们不可避免的就是,时间复杂度会特别高:O(2^n),只做了解,因为特别扯淡,千万别在面试用哈哈

/**
 * @param {number[]} nums
 * @return {number}
 */
var lengthOfLIS = function(nums) {
    let res = 1;
    const dfs = (nums, path, index) => {
        res = Math.max(res, path.length);
        for (let i = index; i < nums.length; i++) {
            if (path.length > 0 && path[path.length - 1] >= nums[i]) {
                continue;
            }
            if ((path.length > 0 && path[path.length - 1] < nums[i]) || path.length === 0) {
                path.push(nums[i]);
            }
            dfs(nums, path, i + 1);
            path.pop();
        }
    }
    dfs(nums, [], 0);
    return res;
};

方法四:树状数组优化DP

        在方法一中,我们遇到一个问题就是,在动态规划数组的状态,每次都需要去遍历F函数前面的值,这会大大的增加我们的时间开销。我们可以借助数据结构来优化这个过程。用树状数组来维护F数组(据说分块也是可以的,但是分块是O(n*sqrt(n))的时间复杂度,不如树状数组跑得快),每次都来获取到F数组中,第i个前面的数,大于F[i]的值,这样就能大大地提高我们的运行速度,代码如下:

/**
 * @param {number[]} nums
 * @return {number}
 */
// 实现树状数组
class FenwickTree {
    constructor(size) {
        this.size = size;
        this.tree = new Array(size + 1).fill(0);
    }
    // 更新
    update(index, delta) {
        for (; index <= this.size; index += index & -index) {
            this.tree[index] = Math.max(this.tree[index], delta);
        }
    }
    // 查询
    query(index) {
        let maxVal = 0;
        for (; index > 0; index -= index & -index) {
            maxVal = Math.max(maxVal, this.tree[index]);
        }
        return maxVal;
    }
}

var lengthOfLIS = function(nums) {
    // 去重,排序,并创建二维存索引
    const numToIndex = new Map([...new Set(nums)].sort((a, b) => a - b).map((num, index) => [num, index + 1]));
    const n = nums.length;
    const tree = new FenwickTree(numToIndex.size);
    let maxLen = 0;

    for (let i = 0; i < n; i++) {
        const index = numToIndex.get(nums[i]);
        const prevMax = tree.query(index-1);
        if(prevMax==0|| nums[i]!==nums[i-1]){
            const dpVal=prevMax+1
            tree.update(index, dpVal);
            maxLen = Math.max(maxLen, dpVal);
        }
    }
    return maxLen;
};

 值得一提的是:树状数组求LIS不去重的话就变成了最长不下降子序列了

🎈一个问题多解化,有时候有助于学习,题目往往会难在你是否发现此种类型的题。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值