最长递增子序列(LIS)与最长公共子序列(LCS)
介绍 LIS 和 LCS 的动态规划、路径恢复、复杂度优化,以及严格递增序列的二分模板。
子序列可以删除原序列中的部分元素,但必须保持剩余元素的相对顺序。它和子串不同,子串要求元素连续。
本文讨论两个经典问题:
- LIS:一个序列中的最长严格递增子序列。
- LCS:两个序列的最长公共子序列。
一、最长递增子序列 LIS
1.1 O(n^2) 动态规划
定义 dp[i] 为以 nums[i] 结尾的最长严格递增子序列长度。遍历 j < i,如果 nums[j] < nums[i],就可以尝试使用 dp[j] + 1 更新 dp[i]:
dp[i] = max(1, dp[j] + 1) (j < i 且 nums[j] < nums[i])
#include <algorithm>
#include <vector>
std::vector<int> LISQuadratic(const std::vector<int>& nums) {
if (nums.empty()) {
return {};
}
const int n = static_cast<int>(nums.size());
std::vector<int> length(n, 1);
std::vector<int> previous(n, -1);
int best_end = 0;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < i; ++j) {
if (nums[j] < nums[i] && length[j] + 1 > length[i]) {
length[i] = length[j] + 1;
previous[i] = j;
}
}
if (length[i] > length[best_end]) {
best_end = i;
}
}
std::vector<int> result;
for (int current = best_end; current != -1;
current = previous[current]) {
result.push_back(nums[current]);
}
std::reverse(result.begin(), result.end());
return result;
}
时间复杂度为 O(n^2),空间复杂度为 O(n)。previous 数组记录前驱下标,因此不仅能得到长度,也能恢复一条具体的 LIS。
1.2 O(n log n) 求长度
维护数组 tails:
tails[k] 表示长度为 k + 1 的递增子序列的最小可能结尾值
遍历新元素时,使用 lower_bound 找到第一个大于等于它的位置并替换。对于严格递增序列必须使用 lower_bound;如果要求非递减序列,则使用 upper_bound。
#include <algorithm>
#include <vector>
int LISLength(const std::vector<int>& nums) {
std::vector<int> tails;
for (int value : nums) {
auto position = std::lower_bound(
tails.begin(), tails.end(), value);
if (position == tails.end()) {
tails.push_back(value);
} else {
*position = value;
}
}
return static_cast<int>(tails.size());
}
tails 本身不一定是一条真实的 LIS,但它的长度就是 LIS 长度。时间复杂度为 O(n log n),空间复杂度为 O(n)。
1.3 O(n log n) 恢复 LIS
如果还需要恢复序列,可以额外记录每个元素的下标以及它的前驱:
#include <algorithm>
#include <vector>
std::vector<int> LIS(const std::vector<int>& nums) {
if (nums.empty()) {
return {};
}
std::vector<int> tails;
std::vector<int> tail_indices;
std::vector<int> previous(nums.size(), -1);
for (int i = 0; i < static_cast<int>(nums.size()); ++i) {
auto position = std::lower_bound(
tails.begin(), tails.end(), nums[i]);
const int length = static_cast<int>(
position - tails.begin());
if (length > 0) {
previous[i] = tail_indices[length - 1];
}
if (position == tails.end()) {
tails.push_back(nums[i]);
tail_indices.push_back(i);
} else {
*position = nums[i];
tail_indices[length] = i;
}
}
std::vector<int> result;
for (int current = tail_indices.back(); current != -1;
current = previous[current]) {
result.push_back(nums[current]);
}
std::reverse(result.begin(), result.end());
return result;
}
二、最长公共子序列 LCS
2.1 动态规划求长度
定义 dp[i][j] 为 first 的前 i 个字符和 second 的前 j 个字符的最长公共子序列长度。
状态转移如下:
first[i - 1] == second[j - 1]
dp[i][j] = dp[i - 1][j - 1] + 1
first[i - 1] != second[j - 1]
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
#include <algorithm>
#include <string_view>
#include <vector>
int LCSLength(std::string_view first, std::string_view second) {
const int n = static_cast<int>(first.size());
const int m = static_cast<int>(second.size());
std::vector<std::vector<int>> dp(
n + 1, std::vector<int>(m + 1, 0));
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
if (first[i - 1] == second[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = std::max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[n][m];
}
时间复杂度为 O(nm),空间复杂度为 O(nm)。
2.2 恢复一条 LCS
从 dp[n][m] 反向遍历:
- 如果两个字符相等,它们属于一条 LCS,移动到左上角。
- 如果不相等,移动到上方或左方较大的状态。
#include <algorithm>
#include <string>
#include <string_view>
#include <vector>
std::string LCS(std::string_view first, std::string_view second) {
const int n = static_cast<int>(first.size());
const int m = static_cast<int>(second.size());
std::vector<std::vector<int>> dp(
n + 1, std::vector<int>(m + 1, 0));
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
if (first[i - 1] == second[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = std::max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
std::string result;
int i = n;
int j = m;
while (i > 0 && j > 0) {
if (first[i - 1] == second[j - 1]) {
result.push_back(first[i - 1]);
--i;
--j;
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
--i;
} else {
--j;
}
}
std::reverse(result.begin(), result.end());
return result;
}
如果只需要 LCS 的长度,可以将空间优化为 O(min(n, m)),只保留上一行和当前行;如果需要恢复路径,则通常需要保留完整的 dp 表,或者额外使用 Hirschberg 算法。
三、复杂度对比
| 问题 | 方法 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|
| LIS 长度 | 动态规划 | O(n^2) |
O(n) |
| LIS 长度 | tails + 二分 |
O(n log n) |
O(n) |
| LCS 长度 | 二维动态规划 | O(nm) |
O(nm) |
| LCS 长度(只求长度) | 滚动数组 | O(nm) |
O(min(n, m)) |
四、总结
- LIS 的
O(n^2)动态规划适合推导和恢复路径。 - LIS 的二分方法需要使用
lower_bound处理严格递增序列。 tails数组不一定本身构成一条 LIS,但其长度正确。- LCS 使用两个前缀序列的状态进行动态规划。
- 恢复 LCS 需要根据动态规划表反向寻找路径。