带限制的最长重复子数组(限制子数组长度至少为L且最多允许k次不匹配)
这是一个线性动态规划的进阶题目,我们将循序渐进地讲解它。
题目描述
给定两个整数数组 nums1 和 nums2,以及两个非负整数 L 和 k。要求找出最长的公共子数组(连续子序列),使得:
- 该公共子数组的长度至少为
L。 - 在子数组对应位置上,允许最多
k个位置的元素不相同(即最多允许k次不匹配)。 - 输出这个满足条件的公共子数组的最大可能长度。
示例
nums1 = [1, 2, 3, 4, 5]nums2 = [2, 3, 1, 4, 5]L = 2, k = 1- 解释:最长公共子数组可以是
[2, 3, 4, 5](在nums1中为下标1,2,3,4;在nums2中为下标0,1,3,4)。对比两个子数组:[2,3,4,5]vs[2,3,4,5],完全匹配,长度为4。另一个候选是[1,2,3](nums1[0:2]和nums2[2:4]为[1,2]vs[1,4],有1处不匹配),但长度3小于4。所以答案为4。
这个题目综合了最长公共子数组(经典DP)与带容错的最长公共子串(允许不匹配)的思想,并增加了最小长度约束。我们将用动态规划来解决它。
解题思路
1. 问题拆解
我们需要在两个数组中找到一个连续的片段(子数组),使得它们尽可能长,同时:
- 长度 ≥ L。
- 对应位置上不同的元素数量 ≤ k。
核心难点:如何高效地记录“从某个位置开始对齐时,已经产生了多少不匹配”。
2. 基础动态规划定义
一个直观的想法是,用 dp[i][j] 表示“以 nums1[i-1] 和 nums2[j-1] 结尾的公共子数组的长度”。但这样无法直接处理不匹配限制。
改进:我们定义 dp[i][j][t],表示“以 nums1[i-1] 和 nums2[j-1] 结尾的公共子数组的长度,并且该子数组中恰好有 t 个不匹配的位置”。但这样空间复杂度会很高(O(n * m * k)),且难以处理“至少L长度”的条件。
更高效的方法:采用二维DP + 不匹配计数滚动数组。
3. 状态定义
我们定义 dp[i][j] 表示:以 nums1[i-1] 和 nums2[j-1] 结尾的公共子数组中,最少的不匹配数量。
注意:这里“公共子数组”必须是连续的,所以如果 nums1[i-1] != nums2[j-1],那么以它们结尾的子数组就不成立?不对——因为我们允许不匹配,所以即使末尾两个元素不同,子数组也可能成立,只要不匹配数量不超过k。
但这样定义的话,dp[i][j] 仅仅记录了“最少不匹配数量”,我们还需要知道对应的子数组长度。因此我们可以用另一个数组 len[i][j] 来记录对应的子数组长度。
更简洁的方法:我们不将不匹配数量作为状态值,而是作为约束条件,用递推关系来隐含地计算它。
4. 递推关系设计(核心)
让我们考虑两个数组的当前位置 i(nums1 的第 i-1 个元素)和 j(nums2 的第 j-1 个元素)。
设 f[i][j] 表示:以 nums1[i-1] 和 nums2[j-1] 结尾的公共子数组的长度(注意这里“公共子数组”是允许不匹配的,但我们必须知道这个子数组里有多少不匹配)。
为了知道不匹配数量,我们同时维护一个不匹配计数 cnt[i][j],表示以 nums1[i-1] 和 nums2[j-1] 结尾的公共子数组中,不匹配的元素个数。
递推关系:
- 如果
nums1[i-1] == nums2[j-1],那么我们可以从f[i-1][j-1]扩展过来:f[i][j] = f[i-1][j-1] + 1cnt[i][j] = cnt[i-1][j-1](没有新增不匹配)
- 如果
nums1[i-1] != nums2[j-1],那么我们依然可以尝试扩展,但会增加一个不匹配:f[i][j] = f[i-1][j-1] + 1cnt[i][j] = cnt[i-1][j-1] + 1
但是,如果 cnt[i][j] > k,那么这个子数组就不合法了。怎么办?我们需要回溯缩短子数组,直到不匹配数 ≤ k。
所以,仅仅从 (i-1, j-1) 转移是不够的,我们需要知道:从当前位置 (i, j) 往前推,最多能延伸多长的子数组,使得不匹配数 ≤ k。
5. 优化:双指针(滑动窗口)思想
我们固定一对起点偏移量 d = i - j(即两个数组的起始下标差),然后在每个偏移量下,用双指针扫描。
具体来说,对于每个 d,我们考虑 nums1 从某个位置开始,nums2 从 某个位置-d 开始,然后向右同步扩展,并维护当前窗口内的不匹配数。当不匹配数 > k 时,左端点右移,直到不匹配数 ≤ k。在过程中,如果窗口长度 ≥ L,我们就更新答案。
但是这种方法需要枚举所有可能的偏移量 d,其范围是 [-(m-1), n-1],复杂度约为 O((n+m) * min(n,m)),在数组长度较大时可能较慢。
6. 最终高效DP方法
我们可以用 dp[i][j] 表示以 nums1[i-1] 和 nums2[j-1] 结尾的公共子数组中,不匹配的数量。
但为了快速得到长度,我们实际上需要二分答案。
二分答案思路:
- 假设我们要判断是否存在长度为
len的公共子数组,满足不匹配 ≤ k 且长度 ≥ L。 - 如果
len < L,显然不满足最小长度条件,所以二分的下界是L,上界是min(n, m)。 - 对于某个猜测的长度
mid,我们检查是否存在这样的子数组。
如何检查是否存在长度为 mid 的公共子数组(允许 ≤ k 个不匹配)?
- 我们可以枚举所有可能的起始位置
i在nums1中,对应的j在nums2中,使得子数组长度为mid。 - 对于每个对齐的起始位置,计算两个子数组的不匹配数(对应位置不同的个数)。
- 如果不匹配数 ≤ k,则找到答案。
但是直接枚举起点复杂度高。我们可以用前缀和优化来快速计算不匹配数:
- 对于每个对齐的起始位置
(i, j),长度为mid,不匹配数 = 对应位置不同的数量。 - 我们可以预处理一个二维差分或直接扫描,但更简单的是:
用diff[i][j] = (nums1[i] != nums2[j] ? 1 : 0),然后对于每个d = i - j,我们固定d,然后计算diff在斜线上的前缀和,用滑动窗口求区间和 ≤ k。
7. 动态规划斜线前缀和
设 n = len(nums1), m = len(nums2)。
对于每个差值 d(范围 -(m-1) 到 n-1),我们定义数组 arr,其中 arr[p] = (nums1[p] != nums2[p-d] ? 1 : 0),这里 p 的取值范围是使得下标合法的范围。
然后我们在 arr 上使用滑动窗口,找长度 ≥ L 且区间和 ≤ k 的最长连续子数组。
注意:arr 的长度可能小于 min(n,m),但没关系。
8. 算法步骤
- 初始化答案
ans = 0。 - 枚举差值
d从-(m-1)到n-1:- 确定起点范围:
i从max(0, d)到min(n-1, m-1+d)。 - 构造数组
arr,长度为len_arr,其中arr[t] = (nums1[i_start + t] != nums2[i_start + t - d] ? 1 : 0)。 - 在
arr上执行滑动窗口:- 左指针
l = 0,当前窗口不匹配数sum_mismatch = 0。 - 右指针
r从 0 到len_arr-1:sum_mismatch += arr[r]- 当
sum_mismatch > k时,移动左指针l直到sum_mismatch ≤ k(同时减少sum_mismatch减去arr[l])。 - 此时窗口长度
win_len = r - l + 1,如果win_len ≥ L,则更新ans = max(ans, win_len)。
- 左指针
- 确定起点范围:
- 返回
ans。
9. 复杂度分析
- 枚举
d的个数:O(n + m)。 - 每个
d对应的arr长度最多min(n, m)。 - 每个
d的滑动窗口扫描是线性的。 - 总复杂度:
O((n+m) * min(n,m)),在n和m同数量级时约O(n^2)。
10. 举例说明
假设:
nums1 = [1,2,3,4,5]nums2 = [2,3,1,4,5]L = 2, k = 1
枚举 d = 0:
- 对齐位置:
i和j相同下标。 arr比较nums1[i]和nums2[i]:i=0: 1 vs 2 → 1i=1: 2 vs 3 → 1i=2: 3 vs 1 → 1i=3: 4 vs 4 → 0i=4: 5 vs 5 → 0
arr = [1,1,1,0,0]- 滑动窗口找最长子数组使得和 ≤ 1 且长度 ≥ 2:
- 最长窗口可能是
[0,0](对应下标 3,4),长度2,和=0,满足。 - 所以长度2可行。但我们找更长的:如果窗口包含前面的1,和会>1,除非只包含一个1。比如
[1,0](和=1)长度2可行,但长度不够大。 - 最终得到该
d下的最长满足条件的窗口长度 = 2。
- 最长窗口可能是
枚举 d = 1:
- 对齐:
nums1[i]与nums2[i-1]比较。 i从1到4:- i=1: 2 vs 2 → 0
- i=2: 3 vs 3 → 0
- i=3: 4 vs 1 → 1
- i=4: 5 vs 4 → 1
arr = [0,0,1,1]- 滑动窗口:
- 最长窗口
[0,0](前两个)和=0,长度=2。 - 加上第三个元素,和=1,长度=3,仍满足 ≤1。
- 加上第四个元素,和=2 >1,左移直到和≤1,窗口变为
[1](第三个元素)或[0,0,1]等。 - 最长窗口长度=3(对应
[0,0,1]),满足长度≥2且不匹配=1。这就是我们找到的[2,3,4](nums1[1:3])和[2,3,1](nums2[0:2]),有一个不匹配。 - 但这个长度3小于我们之前找到的4吗?注意这个窗口长度是3,但对应原数组的子数组长度就是3(因为
d固定时,窗口长度就是公共子数组长度)。
- 最长窗口
继续枚举 d,最终会发现 d = -1 时可能得到更长结果:
d = -1:nums1[i]与nums2[i+1]比较。i从0到3:- i=0: 1 vs 3 → 1
- i=1: 2 vs 1 → 1
- i=2: 3 vs 4 → 1
- i=3: 4 vs 5 → 1
- 全是1,没有长度≥2且和≤1的窗口。
但是否漏掉了更优解?我们之前示例说答案是4,对应 d = -3(nums1[1:4] 与 nums2[0:3]):
d = -3:nums1[i]与nums2[i+3]比较。i从0到1(因为 nums2 下标 i+3 ≤4):- i=0: 1 vs 4 → 1
- i=1: 2 vs 5 → 1
- 长度只有2,和=2>1,不满足。
等等,示例中的 [2,3,4,5] 在 nums1[1:4](下标1,2,3,4)和 nums2[0:3](下标0,1,3,4)对齐时,差值 d = 1(因为 nums1 下标1对应 nums2 下标0,d=1)。我们检查 d=1 时的情况:
- 前面
d=1时我们只比较了i=1..4,但实际公共子数组长度4对应i=1..4与j=0..3。 - 对于
i=1..4(nums1[1]到nums1[4])和j=0..3(nums2[0]到nums2[3]):- 对比: (2,2)=0, (3,3)=0, (4,1)=1, (5,4)=1 → 不匹配数=2。
- 但允许 k=1,所以这个对齐不满足条件。
这说明我之前的示例有误!实际上 [2,3,4,5] 与 [2,3,4,5] 完全匹配,对应 nums1[1:4] 和 nums2[0:3] 时,应该是 (2,2)=0, (3,3)=0, (4,4)=0, (5,5)=0,不匹配数=0。这要求 nums2 的下标是 0,1,3,4?不,这不对应连续子数组,因为下标 0,1,3,4 不连续。所以这个例子中,连续公共子数组 [2,3,4,5] 必须在 nums2 中也连续,但 nums2 是 [2,3,1,4,5],连续子数组 [2,3,4,5] 不存在。所以示例中的答案4是错误的。
我们修正示例:
nums1 = [1,2,3,4,5]
nums2 = [2,3,4,5,1]
L=2, k=1
则 [2,3,4,5] 在 nums1[1:4] 和 nums2[0:3] 完全匹配,长度4,满足。
对于这个修正后的例子,枚举 d=1(nums1[i] 与 nums2[i-1]):
- i=1..4:对比 (2,2)=0, (3,3)=0, (4,4)=0, (5,5)=0 →
arr=[0,0,0,0] - 滑动窗口:最长窗口长度=4,不匹配数=0,满足条件,更新 ans=4。
11. 最终算法总结
- 初始化 ans = 0。
- 对于每个差值 d 从 -(m-1) 到 n-1:
- 确定 i 的范围:i_start = max(0, d), i_end = min(n-1, m-1+d)。
- 构造 arr 长度为 len_arr = i_end - i_start + 1,arr[t] = (nums1[i_start+t] != nums2[i_start+t-d] ? 1 : 0)。
- 在 arr 上运行滑动窗口:维护窗口内不匹配数 sum_mismatch,当 sum_mismatch > k 时左移左指针,当窗口长度 ≥ L 时更新 ans。
- 返回 ans。
通过以上步骤,我们就能在 O((n+m)*min(n,m)) 时间内解决这个带限制的最长重复子数组问题。