动态规划优化实战:如何用一维数组解决最长公共子序列问题(附洛谷例题代码)
动态规划空间优化的艺术:从二维到一维,攻克最长公共子序列的内存瓶颈
在算法竞赛和紧张的面试环节中,我们常常会遇到一个经典问题:最长公共子序列。教科书和入门教程通常会给出一个清晰的二维动态规划解法,它直观、易于理解,是每个学习者必经的第一站。然而,当你信心满满地将代码提交到在线评测系统,面对 n 高达 10^5 的数据规模时,等待你的很可能不是一个绿色的“Accepted”,而是一个冷酷的“Memory Limit Exceeded”。那一刻你才意识到,O(n^2) 的空间复杂度在现实问题面前是多么的奢侈和脆弱。
这个问题并非无解,它恰恰是考察算法工程师是否具备“工程化思维”和“优化直觉”的绝佳试金石。真正的挑战不在于理解基础算法,而在于如何在严苛的资源限制下,对算法进行“外科手术”般的改造。本文将带你深入动态规划的核心,聚焦于一种至关重要的优化技巧——空间压缩。我们将不再满足于理解“是什么”,而是深究“为什么能”以及“如何优雅地做”。通过将经典的二维DP表压缩为一个一维数组,我们不仅能突破内存的桎梏,更能深刻理解状态转移的本质,这种思维对于解决其他复杂的动态规划问题同样具有启发性。本文面向的是那些已经熟悉LCS基础解法,渴望在效率和深度上更进一步的算法爱好者和备战者。
1. 重温经典:二维DP的优雅与局限
在深入优化之前,我们必须牢固掌握问题的标准解法。最长公共子序列问题要求找出两个序列共有的、相对顺序一致的最长子序列。例如,对于序列 A = “abcde” 和 B = “ace”,它们的最长公共子序列是 “ace”,长度为3。
经典的动态规划思路是构建一个二维数组 dp[i][j],其含义是序列 A 的前 i 个字符和序列 B 的前 j 个字符所能形成的最长公共子序列的长度。这个定义是整座逻辑大厦的基石。
状态转移方程清晰地刻画了决策过程:
- 如果
A[i] == B[j]:那么当前字符可以加入公共子序列,长度在dp[i-1][j-1]的基础上加1。即dp[i][j] = dp[i-1][j-1] + 1。 - 如果
A[i] != B[j]:那么当前字符不能同时被采用,最长公共子序列的长度继承自之前最好的情况,即A的前i-1个字符与B的前j个字符的结果,或者A的前i个字符与B的前j-1个字符的结果中的较大值。即dp[i][j] = max(dp[i-1][j], dp[i][j-1])。
用代码实现这个思想非常直观:
#include <iostream>
#include <algorithm>
using namespace std;
const int MAX_N = 1000; // 假设n最大为1000
int dp[MAX_N + 1][MAX_N + 1];
int lcs_2d(string& A, string& B) {
int n = A.size(), m = B.size();
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (A[i-1] == B[j-1]) {
dp[i][j] = dp[i-1][j-1] + 1;
} else {
dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
}
}
}
return dp[n][m];
}
注意:在代码中,为了便于处理边界条件(即前0个字符),我们通常让
dp数组从下标1开始使用,对应地,访问字符串字符时需要调整为A[i-1]。
这种方法的时间复杂度为 O(n*m),对于两个序列长度的乘积。其空间复杂度同样为 O(n*m),因为我们需要存储整个二维表格。当 n 和 m 在 10^3 级别时,所需内存大约为 10^6 * 4 bytes ≈ 4MB,尚可接受。但当规模上升到 10^5 时,所需内存将激增至约 10^10 * 4 bytes = 40GB,这显然超出了任何常规评测环境的内存限制。
因此,二维DP解法虽然逻辑完美,但其空间开销成为了处理大规模数据的阿喀琉斯之踵。我们需要一种方法,在不改变时间复杂度(或尽可能少改变)的前提下,大幅削减空间占用。
2. 洞察本质:状态转移的依赖关系分析
所有优秀的优化都源于对问题本质更深刻的理解。要对二维DP进行空间压缩,我们必须像侦探一样,仔细审视状态转移过程中的数据依赖关系。
观察上述状态转移方程:
dp[i][j]的计算,只依赖于三个位置的值:- 正上方的
dp[i-1][j] - 左侧的
dp[i][j-1] - 左上方的
dp[i-1][j-1]
- 正上方的
我们可以用一个表格来可视化这个依赖关系。假设我们在填充 dp[i][j](当前单元格),它需要的数据来源如下表所示:
| 依赖单元格 | 相对于 (i, j) 的位置 | 在计算中的作用 |
|---|---|---|
dp[i-1][j-1] | 左上方 | 当字符匹配时,作为基础值加1 |
dp[i-1][j] | 正上方 | 当字符不匹配时,作为候选值之一 |
dp[i][j-1] | 正左方 | 当字符不匹配时,作为另一个候选值 |
关键在于,当我们按行主序(即先固定 i,遍历所有 j)来填充这个表格时,在填充第 i 行的任意一个格子 dp[i][j] 时:
- 第
i行中,比j小的格子(即dp[i][1...j-1])已经在本次循环中计算出来了。 - 第
i-1行的所有格子(包括dp[i-1][j-1]和dp[i-1][j])是上一轮循环的结果。 - 第
i行中,比j大的格子,以及第i-1行之前的行,在计算dp[i][j]时完全用不到。
这个观察是革命性的。它意味着,在计算过程中的任意时刻,我们并不需要保存整个二维表格。我们只需要保存:
- 上一行的完整数据(用于提供
dp[i-1][j-1]和dp[i-1][j])。 - 当前行已经计算出来的部分数据(用于提供
dp[i][j-1])。
更具体地说,如果我们正在计算第 i 行第 j 列的值,我们需要:
prev[j]:对应上一行第j列的值(即旧的dp[i-1][j])。prev[j-1]:对应上一行第j-1列的值(即旧的dp[i-1][j-1])。curr[j-1]:对应当前行第j-1列的值(即新的dp[i][j-1]),这个值我们刚刚算出来。
那么,我们能否只用一维数组来模拟这个过程呢?答案是肯定的,但需要一点技巧来处理数据覆盖的时序问题。
3. 核心技巧:滚动数组与一维DP实现
基于上一节的分析,“滚动数组”技术应运而生。其核心思想是:既然计算当前行只需要上一行和本行已计算部分的数据,那么我们可以只使用一个一维数组 dp[j],让它在迭代过程中“滚动”地代表不同行的信息。
但这里有一个关键的陷阱:如果我们直接用一个数组,在计算 dp[j](新的当前行值)时,会覆盖掉 dp[j](旧的上一行值)。而计算 dp[j] 可能需要旧的 dp[j](即 dp[i-1][j])和旧的 dp[j-1](即 dp[i-1][j-1])。如果覆盖顺序不当,所需的历史数据就会被破坏。
解决方案是引入一个临时变量,并注意更新顺序。让我们一步步推导:
- 定义:我们使用一个一维数组
dp[0..m]。在计算外循环第i轮时,dp[j]在被更新前存储的值代表dp[i-1][j](即上一行第j列的值)。 - 计算新值:为了计算新的
dp[i][j],我们需要:dp[i-1][j]:就是当前dp[j](还未被覆盖的值)。dp[i][j-1]:这个值在本轮循环中,j是从小到大遍历的,所以当我们计算dp[j]时,dp[j-1]已经被更新为dp[i][j-1]了。dp[i-1][j-1]:这是一个难点。在计算dp[j]时,dp[j-1]已经被更新为新的行值了,旧的dp[j-1](即dp[i-1][j-1])已经丢失。因此,我们需要在更新dp[j-1]之前,把它旧的值保存下来。
- 实施策略:
- 在每一行
i的计算开始前,我们用一个变量prev(或称为diagonal)来保存“左上角”的值,初始化为0(对应dp[i-1][0])。 - 对于每一列
j从1到m:- 先用一个临时变量
temp保存当前dp[j]的值(即旧的dp[i-1][j]),因为这个值马上要被覆盖。 - 现在我们可以计算新的
dp[i][j]:- 如果
A[i-1] == B[j-1],则new_value = prev + 1。(因为prev保存的是旧的dp[j-1],即dp[i-1][j-1])。 - 否则,
new_value = max(dp[j], dp[j-1])。注意这里的dp[j]是旧的(dp[i-1][j]),dp[j-1]是新的(dp[i][j-1])。
- 如果
- 更新
prev为刚才保存的temp(即旧的dp[i-1][j]),因为对于下一个j+1来说,prev需要代表旧的dp[i-1][j],也就是dp[i-1][(j+1)-1]。 - 最后,将
dp[j]更新为计算出的new_value。
- 先用一个临时变量
- 在每一行
这个过程有点绕,但结合下面的代码和注释会清晰很多:
#include <iostream>
#include <algorithm>
#include <cstring> // for memset
using namespace std;
int lcs_1d(string& A, string& B) {
int n = A.size(), m = B.size();
// 一维DP数组,初始化为0。dp[j]在迭代中代表上一行第j列的值。
int dp[m + 1];
memset(dp, 0, sizeof(dp));
for (int i = 1; i <= n; i++) {
int prev = 0; // 代表dp[i-1][0],始终为0
for (int j = 1; j <= m; j++) {
int temp = dp[j]; // 保存旧的dp[i-1][j],因为它即将被覆盖
if (A[i-1] == B[j-1]) {
// 新的dp[i][j] = 旧的dp[i-1][j-1] + 1
// 而旧的dp[i-1][j-1]在prev中(对于j=1,prev是0;对于j>1,prev是上一轮保存的旧dp[j-1])
dp[j] = prev + 1;
} else {
// 新的dp[i][j] = max(旧的dp[i-1][j], 新的dp[i][j-1])
// 旧的dp[i-1][j]就是temp,新的dp[i][j-1]就是dp[j-1](已经在本轮更新)
dp[j] = max(temp, dp[j-1]);
}
// 为下一列j+1做准备:prev需要变为旧的dp[i-1][j],即temp
prev = temp;
}
// 注意:每一行i计算完后,dp数组存储的就是dp[i][1..m]的值,
// 在下一轮循环中,它们就变成了“上一行”的值。
}
return dp[m]; // 最终dp[m]存储的就是dp[n][m]
}
提示:理解这个算法的关键在于区分数组中“新值”和“旧值”的角色。
dp[j]在被赋值前是“旧值”(上一行的),赋值后是“新值”(当前行的)。prev变量像一个滑动窗口,始终保持着计算下一个dp[j]所需要的“左上角旧值”。
通过这种巧妙的滚动更新,我们将空间复杂度从 O(n*m) 成功降低到了 O(min(n, m))(通常我们让较短的序列长度作为一维数组的大小)。这是一个巨大的飞跃,使得处理 10^5 量级的数据成为可能。
4. 实战演练:洛谷P1439解题与效率对比
理论需要实践的检验。我们以洛谷(Luogu)上的经典题目 P1439 【模板】最长公共子序列 作为实战案例。这道题有一个特殊条件:给出的两个序列都是 1 到 n 的排列。这个条件为我们打开了另一扇优化之门,但首先,让我们用刚刚学到的一维DP方法来解决它。
题目回顾:给定两个 1..n 的排列,求它们的最长公共子序列长度。n 最大为 10^5。
如果直接使用二维DP,内存会瞬间爆炸。使用我们刚实现的一维DP,则可以轻松应对。以下是针对该题目的适配代码:
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100010;
int A[MAXN], B[MAXN];
int dp[MAXN]; // 一维DP数组
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n;
cin >> n;
for (int i = 1; i <= n; i++) cin >> A[i];
for (int i = 1; i <= n; i++) cin >> B[i];
// 一维DP解法
memset(dp, 0, sizeof(dp));
for (int i = 1; i <= n; i++) {
int prev = 0;
for (int j = 1; j <= n; j++) {
int temp = dp[j];
if (A[i] == B[j]) {
dp[j] = prev + 1;
} else {
dp[j] = max(temp, dp[j-1]);
}
prev = temp;
}
}
cout << dp[n] << endl;
return 0;
}
将这段代码提交,你会发现问题并没有完全解决——你可能会得到一个“Time Limit Exceeded”(超时)的判决。这是因为虽然空间复杂度降到了 O(n),但时间复杂度仍然是 O(n^2)。对于 n=10^5,n^2 是 10^10 次操作,远超一般的时间限制(通常要求 10^8 次操作以内)。
这就引出了本题的另一个关键点:利用“排列”的性质进行算法转换。因为 A 和 B 都是 1..n 的排列,我们可以做一个巧妙的映射,将LCS问题转化为最长上升子序列问题,从而利用 O(n log n) 的算法解决。
转化思路:
- 为序列
A中的每个数字建立一个映射:A[i] -> i。即数字A[i]出现在A中的位置是i。 - 根据这个映射,将序列
B转化为一个新的序列C,其中C[i] = pos[A[i]],即B[i]这个数字在A中出现的位置。 - 神奇的事情发生了:
A和B的公共子序列,在转化后,对应着C的一个上升子序列(因为公共子序列在A和B中顺序一致,而A本身是递增的1,2,3,...,n,所以其在A中的位置索引是递增的)。 - 因此,求
A和B的LCS,等价于求序列C的最长上升子序列。
而LIS有一个经典的 O(n log n) 解法(贪心+二分查找)。以下是利用此性质的AC代码:
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100010;
int pos[MAXN]; // pos[x] 记录数字x在序列A中的位置
int B[MAXN];
vector<int> d; // 用于维护LIS的数组
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n, x;
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> x;
pos[x] = i; // 建立映射
}
for (int i = 1; i <= n; i++) {
cin >> x;
B[i] = pos[x]; // 将B转化为位置序列C
}
// O(n log n) 求LIS
d.push_back(0); // 哨兵,方便处理
for (int i = 1; i <= n; i++) {
if (B[i] > d.back()) {
d.push_back(B[i]);
} else {
// 找到第一个 >= B[i] 的位置并替换
*lower_bound(d.begin(), d.end(), B[i]) = B[i];
}
}
// 最终d的长度-1(减去哨兵)就是LIS长度,也即原问题的LCS长度
cout << d.size() - 1 << endl;
return 0;
}
为了更直观地对比几种方法的效率,我们可以在本地进行小规模测试(例如n=1000)来感受时间差异,并推理大规模数据下的表现:
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 | 在P1439 (n=1e5) 下的预期表现 |
|---|---|---|---|---|
| 经典二维DP | O(n²) | O(n²) | 小规模数据,教学演示 | 内存超限 (MLE) |
| 一维滚动数组DP | O(n²) | O(n) | 内存限制严格,但时间宽松的中等规模数据 | 时间超限 (TLE) |
| 转化为LIS的DP | O(n log n) | O(n) | 序列为排列的特殊情况,大规模数据 | 通过 (AC) |
这个实战案例告诉我们,优化是分层级的。一维DP解决了空间瓶颈,是应对内存限制的通用武器。而结合题目特性的转化(LCS转LIS),则是在特定条件下攻克时间瓶颈的“神兵利器”。在实际竞赛或工程中,我们需要根据具体的数据范围和条件,灵活选择和组合这些优化策略。
5. 思维延伸:一维优化技巧的通用性
通过最长公共子序列问题,我们掌握了一种强大的动态规划优化模式——基于依赖分析的空间压缩。这种思维模式绝非LCS独有,它可以推广到一大批具有类似转移方程的DP问题上。
核心的识别模式是:如果状态转移方程中,当前状态 dp[i][...] 的计算只依赖于上一行(或上一个阶段)的状态 dp[i-1][...],以及本行已计算的部分状态,那么就有很大的可能性可以压缩为一维。
让我们看几个其他经典问题的例子:
1. 0/1背包问题 标准二维状态:dp[i][j] 表示考虑前 i 件物品,在背包容量为 j 时的最大价值。 转移方程:dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i]] + value[i])。 观察发现,dp[i][j] 只依赖于 dp[i-1][j] 和 dp[i-1][j - weight[i]]。因此可以压缩为一维,但需要逆序枚举容量 j,以防止物品被重复计算(完全背包问题则是正序枚举)。
// 二维版本(部分)
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= capacity; j++) {
if (j < weight[i]) dp[i][j] = dp[i-1][j];
else dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]);
}
}
// 一维优化版本
int dp[CAPACITY_MAX] = {0};
for (int i = 1; i <= n; i++) {
// 关键:逆序枚举
for (int j = capacity; j >= weight[i]; j--) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
2. 编辑距离(Levenshtein Distance) 状态 dp[i][j] 表示将字符串 A 的前 i 个字符转换为字符串 B 的前 j 个字符所需的最少操作数。 转移方程涉及 dp[i-1][j](删除)、dp[i][j-1](插入)、dp[i-1][j-1](替换/匹配)。 这同样符合“依赖上一行和本行左侧”的模式,可以使用与LCS非常相似的一维滚动数组方法,并配合 prev(左上角)变量来实现。
通用优化步骤总结:
- 分析依赖:画出状态转移表,明确计算当前格需要哪些已计算出的格子的值。
- 确定滚动维度:通常是压缩掉“阶段”维度(如物品序号
i、字符串前缀长度i),保留“状态”维度(如背包容量j、字符串前缀长度j)。 - 处理覆盖冲突:判断更新一维数组时,是正序还是逆序枚举,或者是否需要额外的临时变量来保存会被覆盖的旧值。
- 逆序枚举:适用于当前状态依赖“上一阶段”的“更小状态”(如0/1背包的
j-weight[i]),正序更新会污染未来需要用的历史数据。 - 正序枚举+临时变量:适用于当前状态依赖“上一阶段”的“同一状态”和“左侧状态”(如LCS、编辑距离),需要小心保存左上角的值。
- 逆序枚举:适用于当前状态依赖“上一阶段”的“更小状态”(如0/1背包的
掌握这种优化技巧,能让你在面对动态规划题目时更加从容。它不仅仅是为了节省那点内存,更重要的是,这个过程强迫你去深入理解状态之间的内在联系,这是写出高效、优雅算法代码的基石。下次当你设计出一个二维DP解法后,不妨下意识地问自己一句:“这个能滚成一维吗?” 这将成为你算法工具箱中一个条件反射式的优化动作。
更多推荐
所有评论(0)