4.25

300. 最长递增子序列 - 力扣(LeetCode)

dp[i]表示i之前包括i的以nums[i]结尾的最长递增子序列的长度。

位置i的最长升序子序列等于j从0到i-1各个位置的最长升序子序列 + 1 的最大值。所以:

if (nums[i] > nums[j])  dp[i] = max(dp[i], dp[j] + 1);

注意这里不是要dp[i]dp[j] + 1进行比较,而是我们要取dp[j] + 1的最大值

class Solution {
  public:
      int lengthOfLIS(vector<int>& nums) {
          vector<int> dp(nums.size() , 1);
          int res = 1;//初始化res为1,避免讨论边界条件nums.size() == 1
          for (int i = 1; i < nums.size(); i++) {
              for (int j = 0; j < i; j++) {
                 if(nums[j] < nums[i]){
                  dp[i] = max(dp[i] , dp[j] + 1);                
                 } 
              } 
              res = max(res , dp[i]);           
          }
          return res;
      }
  };

复杂度分析

  • 时间复杂度:O(n2),其中 n 为 nums 的长度。
  • 空间复杂度:O(n)。

扩展:

参考连接:最长递增子序列(nlogn 二分法、DAG 模型 和 延伸问题) | 春水煎茶 (writings.sh)

方法三:贪心 + 二分查找

定义 g[i] 表示长为 i+1 的上升子序列的末尾元素的最小值。

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        vector<int> g;
        for (int x : nums) {
            auto it = ranges::lower_bound(g, x);
            if (it == g.end()) {
                g.push_back(x); // >=x 的 g[j] 不存在
            } else {
                *it = x;
            }
        }
        return g.size();
    }
};

容易知道,数组 p 一定是严格递增的。

为了维护这个辅助数组的性质,在扫描主数组时:

  1. 如果遇到一个比 p 的尾部元素更大的值,说明形成了一个更长的上升序列。 则把它追加到 p 的尾部。

  2. 如果遇到一个比 p 的尾部元素更小的值,说明发现了某个上升子序列的、更小的末尾元素,需要更新它。

  3. 因为 p 是有序的,可以二分查找目标位置(lower_bound 查下界)来更新,因此总时间复杂度是 O(nlogn) 。

    最终 p 的长度就是最长递增子序列的长度。

形象的动图

最后,给一个形象的动图演示,可以看到: p 数组总是以低趴的姿态存在,这样才可以找到尽可能长的上升序列。

但是注意,辅助数组 p 并不一定存储最长递增序列本身。

只是求「长度」的问题的话,这个简单的算法是够用了。

分层 DAG 建模

下面将介绍最长递增子序列的一种分层的、 DAG(有向无环图)建模。

仍以数组 4,2,7,6,8,3,5,6 为例,先加一个无穷小 -inf 为其虚拟头元素。 其作用仅仅是图示上让这个 DAG 看上去更像一棵树。更好理解。

总结下这个图的构建过程:

  1. 先确定要放入哪一层。

    要保证前面的层中至少有一个值可以和当前元素形成递增关系。 因为每个层的最小值都在顶部,所以只需要找到恰好不小于顶部元素的层就可以。

    如果比所有层的顶部元素都大,那么就新开一层。

  2. 放入这一层的顶部。上一层中所有比此元素小的,都作为父节点,连线。

简而言之,从找一个最浅的可以放入的层,放到顶部。

把整个过程的串成一个动图如下:

总结下这个图的性质:

  1. 求长度时所说的辅助数组 p 其实是在维护各个层的顶部元素。
  2. 各个层中的元素是有序的,顶部元素最小。
  3. 每一条路径都是一个上升序列,层数最大的路径对应的上升序列最长。
  4. 层的数目就是最长上升子序列的长度。

各个层中的元素是有序的,暗示着我们把每一层的元素存在一个数组里,这样方便二分的运用。

我的建模是这样的,姑且管它叫做一种「分层 DAG 模型」:

  • 每一层叫做一个桶,用数组来描述。数组的尾部就是桶的顶部。
  • 冗余维护一个辅助数组 p ,追踪所有桶的顶部情况。

如果要输出一个最长递增子序列

首先,最长的路径可能并不止有一条, 比如下面的这个例子,数组是 [8,10,2,9,6,13,1,9,5,12,3,10,16]

这意味着,要想输出所有的最长递增子序列,就要维护好图中所有的路径。

但是,如果只需要输出一个的话,我们仍可以做到 nlog(n) 的时间复杂度。

这时候,没必要维护所有的路径,每次新加一个元素时,只需要连接上一个桶的顶部作为父节点即可。 这样并不影响找到一个最长的上升序列。 比如下图:

class Solution {
  public:
   vector<int> findOneLIS(vector<int>& nums) {
       using Node = pair<int, int>;  // 槽位, {父节点在桶中的位置、元素值}
       using Bucket = vector<Node>;  // 桶

       vector<Bucket> buckets;  // 各个列的桶
       vector<int> p;           // 辅助数组, 冗余各个桶的尾部元素

       // 放入头节点到第一个桶
       buckets.push_back({{-1, nums[0]}});
       p.push_back(nums[0]);

       // 辅助函数, 给第 k 个桶放入一个新元素
       auto push = [&](int k, int num) {
           // 上个桶的当前尾部节点当做父节点
           int j = (k == 0) ? -1 : (buckets[k - 1].size() - 1);
           buckets[k].push_back({j, num});
           // 更新辅助数组
           p[k] = num;
       };

       for (int i = 1; i < nums.size(); i++) {
           if (nums[i] > p[p.size() - 1]) {
               // 新建一个空桶, 扩展辅助数组
               buckets.push_back({});
               p.push_back(0);

               // 加入此元素到新桶 (最后一个桶)
               push(buckets.size() - 1, nums[i]);
           } else {
               // 查找合适的桶放入新元素
               int k = lower_bound(p.begin(), p.end(), nums[i]) - p.begin();
               push(k, nums[i]);
           }
       }

       // 要返回最长的上升子序列,需要从最后一个桶反向找回去
       vector<int> ans(buckets.size());

       // i 是第 i 个桶,j 父节点在前面一个桶的位置
       for (int i = buckets.size() - 1, j = -1; i >= 0; i--) {
           auto& b = buckets[i];
           auto& [j1, num] = b[j < 0 ? (b.size() - 1) : j];
           j = j1;
           ans[i] = num;
       }

       return ans;
   }
};

如果求最长递增子序列的个数673. 最长递增子序列的个数 - 力扣(LeetCode)

DP法:

这道题目我们要一起维护两个数组。

dp[i]:i之前(包括i)最长递增子序列的长度为dp[i]

count[i]:以nums[i]为结尾的字符串,最长递增子序列的个数为count[i]

我们要考虑两个维度,一个是dp[i]的更新,一个是count[i]的更新。

那么在nums[i] > nums[j]前提下

  1. 如果在[0, i-1]的范围内,找到了j,使得dp[j] + 1 > dp[i],说明找到了一个更长的递增子序列。

    那么以j为结尾的子串的最长递增子序列的个数,就是最新的以i为结尾的子串的最长递增子序列的个数,相当于在所有的递增子序列后加入一个nums[i] , 长度变了,个数不变。

    即:count[i] = count[j] , dp[i] = dp[j] + 1;

  2. 在nums[i] > nums[j]前提下,如果在[0, i-1]的范围内,找到了j,使得dp[j] + 1 == dp[i],说明找到了两个相同长度的递增子序列。

    那么以i为结尾的子串的最长递增子序列的个数 就应该加上以j为结尾的子串的最长递增子序列的个数,即:count[i] += count[j];

    [!NOTE]

    如何理解? 相当于把子序列 0 , 1 ,2 ···nums[j]换成了0 , 1 ,2···nums[i],这样子序列长度是相同的,那么count[i] 就应该加上count[j]的个数。

题目要求最长递增序列的长度的个数,我们应该把最长长度记录下来。

for (int i = 1; i < nums.size(); i++) {
    for (int j = 0; j < i; j++) {
        if (nums[i] > nums[j]) {
            if (dp[j] + 1 > dp[i]) {
                count[i] = count[j];
              	dp[i] = dp[j] + 1;
            } else if (dp[j] + 1 == dp[i]) {
                count[i] += count[j];
            }           
        }
        if (dp[i] > maxCount) maxCount = dp[i]; // 记录最长长度
    }
}

以上分析完毕,C++整体代码如下:

class Solution {
  public:
      int findNumberOfLIS(vector<int>& nums) {
          int n = nums.size();
          if(n <= 1)  return n;
          vector<int> dp(n , 1);
          vector<int> count(n , 1);
          int maxCount = 0;
          for (int i = 1; i < n; i++) {
              for (int j = 0; j < i ; j++) {
                 if(nums[i] > nums[j]){
                  if(dp[j] + 1 > dp[i]){
                    dp[i] = dp[j] + 1;
                    count[i] = count[j];
                  }else if(dp[j] + 1 == dp[i]){
                    count[i] += count[j];
                  }                 
                 }
                 if(dp[i] > maxCount) maxCount = dp[i];
              }
          }
          int res = 0;
          for (int i = 0; i < n; i++) {
             if(dp[i] == maxCount) res += count[i];
          }
          return res;
      }
  };

法二:

从前面做的 DAG 模型来看,这个问题 实际上是在对图中可以到达最后一个桶的路径做计数。

其实这个问题,也没必要维护所有的路径,只需要计数就可以。

原因的关键点在于,每个桶是有序的。

假设我们在构造每个节点的时候,追踪可以到达这个节点的路径数的数量的话,这个过程可以递推。

如下图所示,现在要放入元素 8,到前面一个桶中找到所有比 8 小的绿色节点作为父节点。 那么 8 这个节点处的路径数量,就是前面所有绿色节点对应的路径数量的总和。

由于桶中的元素都是有序的,因此可以二分查找上界位置 locupper_bound 函数), 这个位置上的数字恰好严格小于 8

而绿色节点的计数总和,可以由前面整个桶的总和 sum(n) 减去前缀和 sum(loc-1) 来确定。

也就是说,我们只需在每个节点维护其在桶内的路径计数的前缀和。

新增一个数字 num 到第 k 个桶时,伪代码是这样的:

push(k, num)
    // 上一个桶叫 a、下一个桶叫 b
    a, b = buckets[k-1], buckets[k]

    // 上一个桶 a 的分界点 loc
    loc = upper_bound(a, num)

    // 上一个桶的顶部计数总和
    count = sum[a][-1] - sum[a][loc-1]

    // 新节点的桶 b 在新节点处的前缀和
    sum[b].push(sum[b][-1] + count)

最后一个桶中所有的元素的路径计数之和, 其实就是这个桶的顶部位置的前缀和,就是这个问题的最终答案。

这其实是一个在分层 DAG 模型上、结合二分查找、动态规划递推前缀和的过程。 如此看来,其时间复杂度仍然为 nlog(n) 。 前缀和在这里的作用,是 避免了重复求和过程。

其中有一个细节的巧妙点,每次寻找上一个桶的分界点 loc 的过程其实可以渐进式处理。 因为,桶中的元素一定是追加在桶的顶部的,那么下一次进入的元素对应的前一个桶中的 loc 位置, 一定比当前元素的 loc 位置更靠上。也就是说,这一次的 loc 位置可以作为下一次二分查找的起点。 可以维护每个桶当前的 loc 位置,下次二分查找的范围就会更小了。 非常巧妙。

最终的实现代码见下方。相比动态规划的 O(n^2) 的做法,要复杂许多,但是它快,在 leetcode 上的运行时间可以击败 99% 的提交。

求最长递增子序列的个数 C++ 实现

class Solution {
   public:
    int findNumberOfLIS(vector<int>& nums) {
        // 各个列的桶, 每个桶存放元素的值
        vector<vector<int>> buckets;
        // 每一个桶的二分检索起点, 递进式二分
        vector<int> locs;
        // 前缀和桶, sums[k][j] 表示第 k 个桶上的 [0..j] 区间上的路径计数的总和
        vector<vector<int>> sums;
        // 辅助数组, 快照任一时刻各个桶的尾部元素, 辅助主迭代
        vector<int> p;

        // 放入头节点到第一个桶
        buckets.push_back({nums[0]});
        locs.push_back(0);
        sums.push_back({1});
        p.push_back(nums[0]);

        // 辅助函数, 给第 k 个桶放入一个新元素
        // 并同步计算路径总数, 时间复杂度 log(n)
        auto push = [&](int k, int num) {
            // 找到上一个桶中严格小于 num 的项,然后把计数累加起来
            // 如果是第 0 个桶,count 初始化为 1
            int count = 1;

            if (k >= 1) {
                // 需要注意的是 buckets 数组的尾部才是桶顶
                // buckets[k] 都是递减排列的;
                // 本次 loc 可以作为下次的二分起点
                // 所谓,维护 loc 数组以递进式二分
                int k1 = k - 1;
                auto& b1 = buckets[k1];
                int loc = upper_bound(b1.begin() + locs[k1], b1.end(), num,
                                      greater<int>()) -
                          b1.begin();
                locs[k1] = loc;  // 更新上一个桶的检索节点

                // 前一个桶的区间 [0..loc] 的 count 总和
                // 即区间 [loc, n) 的 (总和 - 前缀和)
                auto total = sums[k1][buckets[k1].size() - 1];

                // 区间  [0.. loc-1] 的计数和::
                // 如果 loc 是 0 , 则相当于区间 [loc, n) 上是求总和
                auto sum_before_loc = loc > 0 ? sums[k1][loc - 1] : 0;
                count = total - sum_before_loc;
            }

            buckets[k].push_back(num);
            p[k] = num;  // 更新辅助数组

            // 更新前缀和
            auto sum = count;
            if (!sums[k].empty()) sum += sums[k][sums[k].size() - 1];
            sums[k].push_back(sum);
        };

        for (int i = 1; i < nums.size(); i++) {
            if (nums[i] > p[p.size() - 1]) {
                // 扩展桶列表, 扩展辅助数组
                buckets.push_back({});
                sums.push_back({});
                locs.push_back(0);  // 初始化其检索起点 0
                p.push_back(0);
                // 推入此元素到这个新桶
                push(buckets.size() - 1, nums[i]);
            } else {
                // 查找合适的桶放入新元素
                int k = lower_bound(p.begin(), p.end(), nums[i]) - p.begin();
                push(k, nums[i]);
            }
        }

        // 最长上升序列的个数,就是最后一个桶中所有元素的路径数的总和
        const auto& last = sums[sums.size() - 1];
        return last[last.size() - 1];
    }
};

此外,求 LIS 个数的问题还有其他 nlogn 的方法:

posted @ 2025-05-07 16:21  七龙猪  阅读(2)  评论(0)    收藏  举报
-->