动态规划的“细节魔鬼”:子序列 vs 子数组 —— 最长重复子数组

哈喽,各位对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-1j-1结尾的最优解
char != match max(dp[i-1][j], dp[i][j-1]) (继承历史) 0 (推倒重来)
最终答案 dp[m][n] (右下角的值) max(所有dp[i][j]) (全局的最大值)

这组"孪生"问题深刻地告诉我们:DP状态的定义,必须精确地服务于问题的核心约束。

"子序列"的"不连续"特性,允许状态从多个方向继承历史最优;

而"子数组"的"连续"铁律,则让状态转移变得更加决绝局部,要么延续,要么归零。

感谢阅读,咱们下期见~

相关推荐
草莓熊Lotso3 小时前
《算法闯关指南:优选算法--二分查找》--19.x的平方根,20.搜索插入位置
java·开发语言·c++·算法
sali-tec4 小时前
C# 基于halcon的视觉工作流-章46-不匀面划痕
人工智能·算法·计算机视觉·c#
yuniko-n4 小时前
【力扣 SQL 50】连接
数据库·后端·sql·算法·leetcode
胖咕噜的稞达鸭5 小时前
算法入门:专题二---滑动窗口(长度最小的子数组)更新中
c语言·数据结构·c++·算法·推荐算法
海洲探索-Hydrovo9 小时前
TTP Aether X 天通透传模块丨国产自主可控大数据双向通讯定位模组
网络·人工智能·科技·算法·信息与通信
2401_8414956412 小时前
【计算机视觉】基于复杂环境下的车牌识别
人工智能·python·算法·计算机视觉·去噪·车牌识别·字符识别
Jonkin-Ma12 小时前
每日算法(1)之单链表
算法
晚风残13 小时前
【C++ Primer】第六章:函数
开发语言·c++·算法·c++ primer
杨云强13 小时前
离散积分,相同表达式数组和公式
算法