哈喽,各位对DP世界充满好奇的"细节控"们,我是前端小L。
在 上一篇文章 中,我们刚刚用二维DP棋盘,优雅地解决了"最长公共子序列(LCS)"这个经典问题。今天,我们来看一个它的"孪生兄弟"------题目只改了一个词,从"子序列"变成了"子数组"。
你可能会想,这不都差不多吗?然而,在算法的世界里,一个词的差异,就可能是一个全新的宇宙。正是这个词,让DP的状态转移逻辑发生了微妙而深刻的变化。今天,就让我们来扮演一次"侦探",找出这个藏在细节里的"魔鬼"。
力扣 718. 最长重复子数组
https://leetcode.cn/problems/maximum-length-of-repeated-subarray/

题目分析 & 关键的区别 给定两个整数数组,找到一个在两个数组中都出现过的,长度最长的子数组。
再次强调这两个概念的区别,这是本文的核心:
-
子序列 (Subsequence) :可以不连续 ,保持相对顺序。
[1, 3, 5]
是[1, 2, 3, 4, 5]
的子序列。 -
子数组 (Subarray) :必须连续 。
[2, 3, 4]
是[1, 2, 3, 4, 5]
的子数组。
对于 A = [1, 2, 3, 2, 1]
和 B = [3, 2, 1, 4, 7]
:
-
它们的最长公共子序列 是
[3, 2, 1]
,长度为3。 -
它们的最长公共子数组 (题目里的"重复子数组")是
[3, 2]
,长度为2。
思路:DP状态定义的"变与不变"
不变的是 :问题依然涉及两个序列的比较,所以使用一个二维的 dp[i][j]
棋盘,仍然是我们最强大的武器。
变化的是 :dp[i][j]
的含义,必须为了"连续"这个铁律而改变。
-
在LCS中,
dp[i][j]
代表前i个 和前j个 元素构成的全局最优解 。因为子序列可以不连续,所以即使text1[i-1] != text2[j-1]
,我们依然可以继承dp[i-1][j]
或dp[i][j-1]
的"历史成果"。 -
但在本题中,连续性是铁律 !一旦
nums1[i-1] != nums2[j-1]
,连续性就被无情地打破,之前的一切努力都与此地无关,一切都要从0开始。所以,dp[i][j]
的含义必须更加**"局部化"**。
全新的DP状态定义: dp[i][j]
表示:第一个数组中必须以 nums1[i-1]
结尾 ,且第二个数组中必须以 nums2[j-1]
结尾 的最长公共子数组的长度。
状态转移的"决绝"
这个全新的"局部化"定义,使得状态转移变得异常简单和"决绝"。当我们计算 dp[i][j]
时,只看 nums1[i-1]
和 nums2[j-1]
:
-
情况A:一刀两断 (
nums1[i-1] != nums2[j-1]
)-
两个结尾的元素不相等。
-
连续性被打破。
-
根据我们的定义,任何以这两个不同元素结尾的公共子数组,长度只能是 0。
-
dp[i][j] = 0
-
-
情况B:血脉延续 (
nums1[i-1] == nums2[j-1]
)-
两个结尾的元素相等,匹配成功!
-
这个匹配,是建立在它们各自前一个元素
nums1[i-2]
和nums2[j-2]
也匹配成功的基础上的。 -
所以,当前的公共长度,是左上角那个格子的长度
dp[i-1][j-1]
的延续。 -
dp[i][j] = dp[i-1][j-1] + 1
-
最终答案: 因为 dp[i][j]
只记录了以特定位置结尾的局部信息,所以全局的最长重复子数组可能在棋盘的任何一个地方诞生。因此,我们需要一个全局变量 maxLength
,在遍历棋盘的过程中,不断用 dp[i][j]
的值去挑战它。
代码实现
O(m*n) 空间版本:
class Solution {
public:
int findLength(vector<int>& nums1, vector<int>& nums2) {
int m = nums1.size();
int n = nums2.size();
// dp[i][j]: 以 nums1[i-1] 和 nums2[j-1] 结尾的最长重复子数组的长度
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
int maxLength = 0;
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
if (nums1[i - 1] == nums2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
}
// 注意:这里没有 else { dp[i][j] = 0; }
// 因为数组初始化时已经是0了。
// 不断更新全局最大值
maxLength = max(maxLength, dp[i][j]);
}
}
return maxLength;
}
};
空间优化 O(n): 和LCS一样,dp[i][j]
只依赖于上一行的数据,可以进行空间优化。但这里的依赖关系更简单,只依赖左上角的 dp[i-1][j-1]
。为了在更新 dp[j]
时,还能访问到未被覆盖的 dp[i-1][j-1]
,我们需要从右向左遍历内层循环。
class Solution {
public:
int findLength(vector<int>& nums1, vector<int>& nums2) {
int m = nums1.size();
int n = nums2.size();
vector<int> dp(n + 1, 0);
int maxLength = 0;
for (int i = 1; i <= m; ++i) {
// 从右向左遍历,以保证 dp[j-1] 还是上一行的值
for (int j = n; j >= 1; --j) {
if (nums1[i - 1] == nums2[j - 1]) {
dp[j] = dp[j - 1] + 1;
} else {
dp[j] = 0; // 不连续了,必须归零
}
maxLength = max(maxLength, dp[j]);
}
}
return maxLength;
}
};
总结:LCS 与 本题的"异同辩"
对比项 | 最长公共子序列 (LCS) | 最长重复子数组 (本题) |
---|---|---|
核心约束 | 保持相对顺序 | 必须连续 |
dp[i][j] 含义 |
全局性 :前i 和前j 个元素的最优解 |
局部性 :必须以i-1 和j-1 结尾的最优解 |
char != match 时 |
max(dp[i-1][j], dp[i][j-1]) (继承历史) |
0 (推倒重来) |
最终答案 | dp[m][n] (右下角的值) |
max(所有dp[i][j]) (全局的最大值) |
这组"孪生"问题深刻地告诉我们:DP状态的定义,必须精确地服务于问题的核心约束。
"子序列"的"不连续"特性,允许状态从多个方向继承历史最优;
而"子数组"的"连续"铁律,则让状态转移变得更加决绝 和局部,要么延续,要么归零。
感谢阅读,咱们下期见~