每日一题_673. 最长递增子序列的个数

每日一题_673. 最长递增子序列的个数

题目:
在这里插入图片描述
题意分析:
这道题和LeetCode_300很相像,300是让我们求一个数组的最长递增子序列,只要求一个就行了,这一道题则是让我们求一个数组中最长递增子序列的个数。题意简单明了。

算法:

动态规划:
首先如果做过第300题的话,其实第一想到的就是动态规划了,因为这非常符合动态规划的特征,求一个数组或者字符串的某个值比如最长最多或者最小的某个性质,当想不到什么其他的办法的时候,我们的思路就可以朝着动态规划的方向去靠。来看这一题,题目是一个数组,求得性质是数组的最长递增子序列的个数,注意子序列是不需要连续的。既然使用动态规划,那么我们就需要弄清楚两件事:

  1. 动态规划的dp数组代表什么含义,这个数组的值代表的是什么?
  2. 状态转移方程是什么?

这两个是动规的核心之处,其实状态转移方程很类似我们高中学的数学归纳法。但凡动规问题我们都可以朝这两个问题思考。
首先,针对这一题,我们的动态规划数组代表什么呢?因为我们要求整个数组的最长递增子数组的个数,这个时候其实有两个量需要我们记录,一个是最长的长度,另一个是最长的个数,这两个肯定都需要,因为状态转移的时候我们需要比较才能知道是不是最长。我们先尝试一下定义dp数组,假设我们定义 d p [ i ] dp[i] dp[i]表示前i个数字构成的所有的子序列,数组的值表示这些子序列中最长的长度,辅助数组 c n t [ i ] cnt[i] cnt[i]记录最长的个数,接着就是状态转移方程,这个时候,其实就是找到 d p [ i ] dp[i] dp[i] d p [ j ] ( j < i ) dp[j](j < i) dp[j](j<i)的关系,如果按照我们刚刚定义的方式,似乎很难找到已知和未知之间的递推关系,为什么呢?在于刚刚定义的dp数组条件过于宽泛,飘忽不定,就好像“听君一席话,如听一席话”, d p [ i ] dp[i] dp[i]无法抓住 d p [ j − 1 ] dp[j - 1] dp[j1]之间的联系,得需要一个锚点。从这就看出来定义dp数组要加上适当的限制条件,这个时候回头看我们的这一题。题目是让求递增子数组,那么其实如果当前第i个数组比前面的一个子序列的最后一个数字大的话,那么这个子序列的长度就加1了,所以这就表明和子序列的最后一个字符关系很大,因此我们定义:

  1. d p [ i ] dp[i] dp[i]表示以第i数字结尾的所有子序列, d p [ i ] dp[i] dp[i]的值代表这鞋子序列中最长递增子序列的个数
  2. 状态转转移方程: d p [ i ] = m a x ( d p [ j ] ) + 1 , 其 中 0 ≤ j < i 且 n u m [ j ] < n u m [ i ] dp[i] = max(dp[j])+ 1,其中0≤j<i且num[j]<num[i] dp[i]=maxdp[j]+1,0j<inum[j]<num[i]

辅助数组 c n t [ i ] cnt[i] cnt[i]的更新可以在状态转移的时候同步更新,解释一下转移方程,首先转移的前提条件是 n u m [ j ] < n u m [ i ] num[j]<num[i] num[j]<num[i],这点是因为递增,接着就是取max,因为求得是最长。

代码:

class Solution {
public:
    int findNumberOfLIS(vector<int>& nums) {
        int n = nums.size(), maxLen = 0, ans = 0;
        vector<int> dp(n, 1);
        vector<int> cnt(n, 1);

        for (int i = 0; i < n; i ++ )
        {
            for (int j = 0; j < i; j ++)
                if (nums[j] < nums[i])
                    if (dp[j] + 1 > dp[i])
                    {
                        dp[i] = dp[j] + 1;
                        cnt[i] = cnt[j];
                    }
                    else if (dp[j] + 1 == dp[i])
                        cnt[i] += cnt[j];
            
            if (dp[i] > maxLen)
            {
                maxLen = dp[i];
                ans = cnt[i];
            }
            else if (dp[i] == maxLen)
                ans += cnt[i];
        }

        return ans;
    }
};

贪心+前缀和+二分查找:
这个算法我是看题解学的,坦白讲确实花了点时间理解,想了好几种case。首先这个算法的思路来源于第300题的第二种做法,当时求得是最长的递增子序列,我觉得做法和单调栈很相像,有那么一点感觉。当时第300题的做法是我们会维护一个有序的数组,来了一个新的数字,我们在维护的数组中二分查找,然后把第一个大于当前数字给替换掉,这个替换其实就是说,当前这个数字比你小,那么它给后面的数字提供的更多的比它大的机会,那么后面得到的子序列的长度就可能会越长,这就是贪心。
那么问题回到这个问题上,求得是最长的个数,那么替换肯定就不可能了,因为这样损失掉了很多其他的信息,因此我们需要记录历史的信息,大佬们想出来个绝妙的方法:

将数组 d 扩展成一个二维数组,其中 d[i] 数组表示所有能成为长度为 i 的最长上升子序列的末尾元素的值。具体地,我们将更新 d [ i ] = n u m s [ j ] d[i]=nums[j] d[i]=nums[j]这一操作替换成将 n u m s [ j ] nums[j] nums[j]置于 d [ i ] d[i] d[i] 数组末尾。这样 d [ i ] d[i] d[i] 中就保留了历史信息,且 d [ i ] d[i] d[i]中的元素是有序的(单调非增)。
类似地,我们也可以定义一个二维数组 c n t cnt cnt,其中 c n t [ i ] [ j ] cnt[i][j] cnt[i][j] 记录了以 d [ i ] [ j ] d[i][j] d[i][j] 为结尾的最长上升子序列的个数。为了计算 c n t [ i ] [ j ] cnt[i][j] cnt[i][j],我们可以考察 d [ i − 1 ] d[i−1] d[i1] c n t [ i − 1 ] cnt[i−1] cnt[i1],将所有满足 d [ i − 1 ] [ k ] < d [ i ] [ j ] d[i−1][k]<d[i][j] d[i1][k]<d[i][j] c n t [ i − 1 ] [ k ] cnt[i−1][k] cnt[i1][k] 累加到 c n t [ i ] [ j ] cnt[i][j] cnt[i][j],这样最终答案就是 c n t [ m a x L e n ] cnt[maxLen] cnt[maxLen] 的所有元素之和。
在代码实现时,由于 d [ i ] d[i] d[i] 中的元素是有序的,我们可以二分得到最小的满足 d [ i − 1 ] [ k ] < d [ i ] [ j ] d[i-1][k]<d[i][j] d[i1][k]<d[i][j] 的下标 k。另一处优化是将 c n t cnt cnt 改为其前缀和,并在开头填上 0,此时 d [ i ] [ j ] d[i][j] d[i][j] 对应的最长上升子序列的个数就是 c n t [ i − 1 ] [ − 1 ] − c n t [ i − 1 ] [ k ] cnt[i−1][−1]−cnt[i−1][k] cnt[i1][1]cnt[i1][k],这里 [ − 1 ] [−1] [1] 表示数组的最后一个元素。

不得不再次感叹大佬们的强大,确实巧妙,值得我们思考和学习。
直接看文字,第二种解法可能比较难理解,举个例子:
数组是1, 3, 5, 4,7,接下来是最后d数组的情况:
在这里插入图片描述
其实这么一看就大致清楚了,因为是递增子序列,我们每次遍历把当前遍历的数字插入到之前遍历的刚好大于的数字的后面,这样就能保证我们是在贪心的大于前面的数,也就是贪心的构造递增子序列,而且因为我们维持了有序,那么插入的时候就可以二分。

代码:

class Solution {
    int binarySearch(int n, function<bool(int)> f) {
        int l = 0, r = n;
        while (l < r) {
            int mid = (l + r) / 2;
            if (f(mid)) {
                r = mid;
            } else {
                l = mid + 1;
            }
        }
        return l;
    }

public:
    int findNumberOfLIS(vector<int> &nums) {
        vector<vector<int>> d, cnt;
        for (int v : nums) {
            int i = binarySearch(d.size(), [&](int i) { return d[i].back() >= v; });
            int c = 1;
            if (i > 0) {
                int k = binarySearch(d[i - 1].size(), [&](int k) { return d[i - 1][k] < v; });
                c = cnt[i - 1].back() - cnt[i - 1][k];
            }
            if (i == d.size()) {
                d.push_back({v});
                cnt.push_back({0, c});
            } else {
                d[i].push_back(v);
                cnt[i].push_back(cnt[i].back() + c);
            }
        }

        for(int i = 0; i < d.size(); i ++)
        {
            for (int j = 0; j < d[i].size(); j ++)
                cout << d[i][j] << " ";
            cout <<endl;
        }
        return cnt.back().back();
    }
};

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值