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 一定是严格递增的。
为了维护这个辅助数组的性质,在扫描主数组时:
-
如果遇到一个比
p的尾部元素更大的值,说明形成了一个更长的上升序列。 则把它追加到p的尾部。 -
如果遇到一个比
p的尾部元素更小的值,说明发现了某个上升子序列的、更小的末尾元素,需要更新它。 -
因为
p是有序的,可以二分查找目标位置(lower_bound查下界)来更新,因此总时间复杂度是 O(nlogn) 。最终
p的长度就是最长递增子序列的长度。
形象的动图
最后,给一个形象的动图演示,可以看到: p 数组总是以低趴的姿态存在,这样才可以找到尽可能长的上升序列。

但是注意,辅助数组 p 并不一定存储最长递增序列本身。
只是求「长度」的问题的话,这个简单的算法是够用了。
分层 DAG 建模
下面将介绍最长递增子序列的一种分层的、 DAG(有向无环图)建模。
仍以数组 4,2,7,6,8,3,5,6 为例,先加一个无穷小 -inf 为其虚拟头元素。 其作用仅仅是图示上让这个 DAG 看上去更像一棵树。更好理解。
总结下这个图的构建过程:
-
先确定要放入哪一层。
要保证前面的层中至少有一个值可以和当前元素形成递增关系。 因为每个层的最小值都在顶部,所以只需要找到恰好不小于顶部元素的层就可以。
如果比所有层的顶部元素都大,那么就新开一层。
-
放入这一层的顶部。上一层中所有比此元素小的,都作为父节点,连线。
简而言之,从找一个最浅的可以放入的层,放到顶部。
把整个过程的串成一个动图如下:

总结下这个图的性质:
- 求长度时所说的辅助数组
p其实是在维护各个层的顶部元素。 - 各个层中的元素是有序的,顶部元素最小。
- 每一条路径都是一个上升序列,层数最大的路径对应的上升序列最长。
- 层的数目就是最长上升子序列的长度。
各个层中的元素是有序的,暗示着我们把每一层的元素存在一个数组里,这样方便二分的运用。
我的建模是这样的,姑且管它叫做一种「分层 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]前提下
如果在[0, i-1]的范围内,找到了j,使得dp[j] + 1 > dp[i],说明找到了一个更长的递增子序列。
那么以j为结尾的子串的最长递增子序列的个数,就是最新的以i为结尾的子串的最长递增子序列的个数,相当于在所有的递增子序列后加入一个nums[i] , 长度变了,个数不变。
即:count[i] = count[j] , dp[i] = dp[j] + 1;
在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 这个节点处的路径数量,就是前面所有绿色节点对应的路径数量的总和。
由于桶中的元素都是有序的,因此可以二分查找上界位置 loc(upper_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 的方法:

浙公网安备 33010602011771号