从混乱日志中揪出"节奏大师"------一次DP优化的奇妙漂流 😎
我遇到了什么问题?
那是一个风平浪静的下午,直到告警系统开始疯狂地"唱歌"🎤。我负责的一个核心交易服务出现了大量的不明错误,错误日志刷得比瀑布还快。
一开始我以为是某个单点故障,但当我把日志拉下来仔细看时,头都大了。这些错误码毫无规律可言,比如 3001
, 5002
, 3001
, 5002
, 8003
, 3001
, 5002
... 它们像一群醉汉在舞池里乱撞,毫无章法。
但是,作为一个老兵,我隐约感觉这混乱背后有一种"节奏"。直觉告诉我,可能不是一个服务挂了,而是两个模块在相互"打架",导致它们交替性地产生特定类型的错误。我的任务就是从这堆看似杂乱的错误日志(可以看作一个超长的数字数组 nums
)中,找到最长的、符合这种"交替打架"模式的子序列,从而锁定问题根源。
这个"交替打架"模式有个很诡异的特征:任意连续的两个错误码之和,在对某个常数 k
(比如系统模块总数) 取模后,结果都是恒定的。比如 (错误A + 错误B) % k == (错误B + 错误C) % k
。
于是,我的问题就转化成了一个算法挑战:在一个整数数组 nums
和一个整数 k
中,找到一个最长的子序列,该子序列中任意相邻两项的和对 k
取模都相等。 (3202. 找出有效子序列的最大长度 II)
题目解读
这道题是3201. 找出有效子序列的最大长度 I的推广和泛化。核心要求是找到一个最长的子序列
sub
,使得其中任意相邻两项之和对k
取模的结果都是一个固定的常数。
(sub[i-1] + sub[i]) % k == constant
核心洞察:
- 取模运算 :整个问题只与数字对
k
取模后的余数有关。nums[i]
的具体大小不重要,nums[i] % k
才重要。我们可以将问题转化为在0
到k-1
的余数序列中寻找模式。 - 固定常数 :一个有效的子序列,其"相邻和模k"的值是唯一的。我们可以枚举所有可能的
constant
值(即0
到k-1
),对于每一种可能性,都找出最长的子序列,最后取所有结果中的最大值。
提示解读:
nums.length <= 10^3
,k <= 10^3
:这个数据规模提示我们,一个O(N*k)
的算法(约为10^3 * 10^3 = 10^6
次操作)是可以通过的。而O(N^2)
或O(N*k^2)
可能会超时。1 <= nums[i] <= 10^7
:再次强调nums[i]
的具体值不重要,取模是第一步。
基于这个核心思路,我们可以设计出动态规划的解法。
解法一
该解法采用动态规划,其核心思想是枚举所有可能的目标和(模k) 。
一个有效的子序列必须满足 (sub[i-1] + sub[i]) % k == target_sum
,其中 target_sum
是一个介于 0
到 k-1
之间的常数。我们不知道哪个 target_sum
会产生最长的子序列,所以我们干脆对每一个可能的 target_sum
都计算一次。
对于一个固定 的 target_sum
,我们可以设计一个 DP 过程:
- 遍历
nums
数组中的每个数字num
。 - 令
rem = num % k
。 - 要将
num
接到某个子序列的末尾,那么它前面的那个数prev_num
的余数prev_rem
必须满足(prev_rem + rem) % k == target_sum
。(模运算性质:和的模等于模的和的模 即:(A + B) % k
等价于((A % k) + (B % k)) % k
。) - 通过数学变换,可以得到
prev_rem = (target_sum - rem + k) % k
。(+k
是为了防止target_sum - rem
结果为负,prev_rem + rem
和target_sum
是模 k 同余) - 我们定义一个
dp
数组,dp[r]
表示在当前target_sum
下,以余数为r
的数结尾的最长有效子序列的长度。 - 那么,当我们遇到
num
(余数为rem
) 时,我们可以将它接到任何一个以prev_rem
结尾的子序列后面,形成一个更长的序列。状态转移方程就是:dp[rem] = dp[prev_rem] + 1
。
我们对 target_sum
从 0
到 k-1
遍历,每次都执行上述 DP 过程,并用一个全局变量记录出现过的最大长度。
java
/*
* 思路:动态规划(按目标和划分)。我们遍历所有可能的目标和 target_sum (从 0 到 k-1)。
* 对于每个 target_sum,我们使用一个一维 DP 数组 dp[k] 来找到最长的有效子序列。
* dp[r] 表示在当前 target_sum 条件下,以余数为 r 的数结尾的最长子序列长度。
* 遍历 nums,对于每个数 num,其模 k 的余数为 rem,它能接在余数为 prev_rem 的数后面,
* 其中 prev_rem = (target_sum - rem + k) % k。状态转移为:dp[rem] = dp[prev_rem] + 1。
*
* 时间复杂度:O(N*k),外层循环 k 次 (对于每个 target_sum),内层循环 N 次 (遍历 nums)。
* 空间复杂度:O(k),我们使用了一个大小为 k 的一维 DP 数组。这个数组在每次外层循环中被重用。
*/
class Solution {
public int maximumLength(int[] nums, int k) {
int maxLen = 0;
// 遍历所有可能的目标和 (modulo k)
for (int targetSum = 0; targetSum < k; targetSum++) {
// dp[r] 表示在当前 targetSum 下,以余数 r 结尾的最长子序列长度
// 对于每一个新的 targetSum,我们都重新开始计算,所以需要一个新的 dp 数组。
int[] dp = new int[k];
// 遍历数组中的每一个数
for (int num : nums) {
int rem = num % k;
// 计算出这个数期望的前一个数的余数
int prevRem = (targetSum - rem + k) % k;
// 状态转移:当前数可以接在任何以 prevRem 结尾的子序列之后,
// 使得新的子序列长度增加1。
dp[rem] = dp[prevRem] + 1;
}
// 在完成对当前 targetSum 的计算后,遍历 dp 数组找到该 targetSum 下的最长长度
for (int len : dp) {
// Math.max 是标准库函数,用于比较两个数并返回较大者。
// 我们用它来持续追踪所有可能性中的全局最大长度。
maxLen = Math.max(maxLen, len);
}
}
return maxLen;
}
}
- 时间复杂度 :O(N*k)。外层循环
k
次,内层对nums
数组的循环N
次,内部操作为 O(1)。总计k * N
次核心操作。 - 空间复杂度 :O(k)。我们使用了一个大小为
k
的dp
数组。虽然外层循环有k
次,但dp
数组是复用的,所以空间是 O(k) 而不是 O(N*k)。
解法二
让我们从题目的根本条件出发: 一个有效的子序列 sub
满足 (sub[i-1] + sub[i]) % k == (sub[i] + sub[i+1]) % k
。
这个等式描述了子序列中任意连续三个 元素 a, b, c
(a=sub[i-1]
, b=sub[i]
, c=sub[i+1]
) 之间的关系。 即:(a + b) % k == (b + c) % k
现在,让我们利用模运算的性质来简化这个同余式。我们可以在同余式的两边同时减去 b % k
,这不会改变同余关系。 (a + b) % k - (b % k) ≡ (b + c) % k - (b % k) (mod k)
根据模运算性质 (X+Y)%k - Y%k ≡ X%k
,上式可以简化为: a % k ≡ c % k
这是一个惊人的、根本性的简化!它告诉我们: 一个子序列是有效的,当且仅当它的元素对k取模后,形成一个交替出现的模式,即 r1, r2, r1, r2, r1, ...
。
- 这里的
r1
就是a % k
,r2
就是b % k
,下一个元素c % k
必须等于a % k
,也就是r1
。
特殊情况: 如果 r1 == r2
,那么这个模式就变成了 r1, r1, r1, r1, ...
。这意味着,一个所有元素对k取模后都相等的子序列也是有效的。
因此,原问题被巧妙地转化成了:在 nums
数组的余数序列中,寻找最长的交替模式 r1, r2, r1, r2, ...
。
基于这个全新的、更简单的目标,我们可以定义一个动态规划状态。我们需要记录以某个交替模式结尾的子序列的最大长度。一个交替模式由最后两个元素的余数决定。
java
class Solution {
public int maximumLength(int[] nums, int k) {
// 全局最大长度,用于记录在整个过程中所有可能交替模式的最长值。
int maxLen = 0;
// dp[r1][r2]: 表示以余数 r1, r2 交替的模式(...r1, r2)结尾的最长子序列长度。
int[][] dp = new int[k][k];
// 遍历日志中的每一个错误码
for (int num : nums) {
// r2 是当前错误码 num 对 k 取模后的余数
int r2 = num % k;
// 遍历所有可能的"前一个余数"r1。
// r1代表交替模式 (..., r1, r2) 中的前一个余数。
for (int r1 = 0; r1 < k; r1++) {
// --- 核心状态转移方程 ---
// 我们要计算以 (r1, r2) 结尾的模式长度。
// 这个序列是由一个以 (r2, r1) 结尾的序列,后面接上当前数得到的。
dp[r1][r2] = dp[r2][r1] + 1;
// 每次更新dp表后,都用新计算出的长度去挑战全局最大值。
maxLen = Math.max(maxLen, dp[r1][r2]);
}
}
return maxLen;
}
}
- 时间复杂度 :O(N*k)。外层循环
N
次,内层循环k
次,总操作次数为N * k
。 - 空间复杂度 :O(k^2)。需要一个
k x k
的dp
数组来存储状态。
<例子过程> 以 nums = [1, 4, 2, 3, 1, 4]
, k = 3
为例,我们追踪 DP 表 dp
的变化。 余数序列 : [1, 1, 2, 0, 1, 1]
初始化 : maxLen = 0
, dp
是 3x3 全零矩阵。
1. 处理 num = 1
(r2 = 1)
-
r1=0
:dp[0][1] = dp[1][0] + 1 = 1
-
r1=1
:dp[1][1] = dp[1][1] + 1 = 1
-
r1=2
:dp[2][1] = dp[1][2] + 1 = 1
-
maxLen
变为1
。dp
如下:lua[[0, 1, 0], [0, 1, 0], [0, 1, 0]]
2. 处理 num = 4
(r2 = 1)
r1=0
:dp[0][1] = dp[1][0] + 1 = 1
r1=1
:dp[1][1] = dp[1][1] + 1 = 1 + 1 = 2
(模式...,1,1
)r1=2
:dp[2][1] = dp[1][2] + 1 = 1
maxLen
变为2
。
3. 处理 num = 2
(r2 = 2)
r1=0
:dp[0][2] = dp[2][0] + 1 = 1
r1=1
:dp[1][2] = dp[2][1] + 1 = 1 + 1 = 2
(模式...,2,1
后接2
->...,1,2
)r1=2
:dp[2][2] = dp[2][2] + 1 = 1
maxLen
仍为2
。
4. 处理 num = 3
(r2 = 0)
r1=0
:dp[0][0] = dp[0][0] + 1 = 1
r1=1
:dp[1][0] = dp[0][1] + 1 = 1 + 1 = 2
r1=2
:dp[2][0] = dp[0][2] + 1 = 1 + 1 = 2
maxLen
仍为2
。
5. 处理 num = 1
(r2 = 1)
r1=0
:dp[0][1] = dp[1][0] + 1 = 2 + 1 = 3
(模式3,1, ...
接1
->...0,1
)r1=1
:dp[1][1] = dp[1][1] + 1 = 2 + 1 = 3
(模式1,4, ...
接1
->...1,1
)r1=2
:dp[2][1] = dp[1][2] + 1 = 2 + 1 = 3
(模式1,2, ...
接1
->...2,1
)maxLen
变为3
。
6. 处理 num = 4
(r2 = 1)
r1=0
:dp[0][1] = dp[1][0] + 1 = 2 + 1 = 3
r1=1
:dp[1][1] = dp[1][1] + 1 = 3 + 1 = 4
(模式1,4,1, ...
接4
->...1,1
)r1=2
:dp[2][1] = dp[1][2] + 1 = 2 + 1 = 3
maxLen
变为4
。
最终,遍历结束,maxLen
为 4
,这对应了子序列 [1, 4, 1, 4]
(余数模式 1, 1, 1, 1
)。
这个解释将代码的每一个部分都与背后的 r1, r2
交替模式的洞察力联系起来,希望这能让你更透彻地理解这个优雅的解法。
解法对比
对比维度 | 解法一:枚举目标和 (Target-Sum) | 解法二:发现交替模式 (Alternating-Pattern) |
---|---|---|
核心思想 | 将问题分解为 k 个独立的子问题。每个子问题对应一个固定的 target_sum 。 |
通过数学变换,发现问题的本质是寻找最长的 r1, r2, r1, r2... 交替余数序列。 |
思维路径 | 分治 / 枚举 。我们不知道哪个"节拍" (a+b)%k 是最优的,所以把所有可能的节拍(0 到 k-1 )都尝试一遍。 |
洞察 / 转换 。深入挖掘条件 (a+b)%k == (b+c)%k ,将其转化为等价但更简单的形式 a%k == c%k 。 |
DP 状态定义 | dp[r] :在一维视角下 ,对于一个固定的 target_sum ,以余数 r 结尾的最长子序列的长度。 |
dp[r1][r2] :在二维视角下 ,代表一个完整的交替模式 ,即以 ...r1, r2 结尾的最长子序列长度。 |
状态转移方程 | dp[rem] = dp[prev_rem] + 1; 其中 prev_rem = (target_sum - rem + k) % k; |
dp[r1][r2] = dp[r2][r1] + 1; |
方程解读 | 序列的下一个成员 rem ,它的前任 prev_rem 是由 外部的 target_sum 规则 唯一确定的。 |
序列的下一个成员 r2 ,它的前任是 r1 ,这个模式的前一个状态必定是 ...r2, r1 ,体现了内在的交替性。 |
时间复杂度 | O(N*k) |
O(N*k) |
空间复杂度 | O(k) (优) |
O(k^2) |
实现复杂度 | 较低 。代码结构更清晰:外层循环枚举 target_sum ,内层循环跑一次独立的 DP 过程。 |
极低。代码非常简洁,将数学洞察力浓缩在单行状态转移中,显得十分精妙。 |
直观性 | 更符合常规 DP 思路。固定一个条件,然后求解,这种模式在很多问题中都适用,容易理解。 | 更具数学美感 。需要一个"啊哈!"的顿悟时刻,一旦理解了 a%k == c%k ,代码就变得不言自明。 |
优点 | 1. 空间效率高 ,当 k 很大时优势明显。 2. 思路直接,容易想到和实现。 |
1. 代码极度简洁 ,优雅地体现了问题的本质。 2. 将所有状态的更新融合在一个循环内,结构紧凑。 |
缺点 | 代码层级稍多(三层循环嵌套)。 | 空间开销较大,当 k 接近 N 的平方根时可能会遇到内存限制问题 (MLE)。 |
适用场景 | 对内存有严格限制,或者 k 的值非常大的情况。通用性强,是解决这类问题的"标准武器"。 |
在 k 不大的情况下,追求代码的极致简洁和优雅。或者在算法竞赛中,作为快速解题的利器。 |
好比说... | 一个勤奋的侦探 🕵️♂️ 他不知道凶手是谁,于是把所有嫌疑人 (target_sum ) 一个个地审问(跑DP),最终锁定目标。 |
一个天才的福尔摩斯 🕵️♀️ 他通过一个微小的线索(同余变换),直接推理出了凶手的作案模式(交替),一举破案。 |
举一反三,这个思想还能用在哪?
这个"发现隐藏交替模式"的思想非常强大,可以用在很多地方:
- 用户行为分析:分析用户在 App 内的操作序列。如果发现某个用户在"加购物车"和"清空购物车"两个行为的 ID 余数之间,形成了超长的交替序列,这可能是个刷单机器人正在进行价格测试。
- 网络安全:检测 DDoS 攻击。攻击流量的源端口或数据包大小,有时会呈现出特定的交替模式,用来绕过简单的防火墙规则。用这个 DP 模型可以快速识别出这种有"节奏"的攻击流。
- 基因序列分析:在DNA序列中寻找特定碱基对(如 A-T, G-C)交替出现的最长片段,可能对研究基因功能有重要意义。
类似好题推荐
如果你对这类 DP 问题很感兴趣,可以试试下面这几道题,它们都需要你找到巧妙的状态定义来解决问题:
- 2746. 字符串连接删减字母 :这道题的DP状态需要同时考虑首尾字母,和我们的
(r1, r2)
有异曲同工之妙。 - 1027. 最长等差数列 :经典的DP题,状态定义为
dp[i][diff]
,代表以nums[i]
结尾,公差为diff
的最长等差序列。 - 300. 最长递增子序列:DP入门和优化的必刷题,理解它能帮你建立DP的基本盘。
希望这次的分享能对大家有所启发!有时候,最棘手的问题背后,往往藏着最简洁优美的解决方案。下次遇到难题,不妨也像我一样,盯着它多看几分钟,也许那个"恍然大悟"的瞬间就在等着你呢!😉