从混乱日志中揪出“节奏大师”——一次DP优化的奇妙漂流(3202. 找出有效子序列的最大长度 II)

从混乱日志中揪出"节奏大师"------一次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 核心洞察

  1. 取模运算 :整个问题只与数字对 k 取模后的余数有关。nums[i] 的具体大小不重要,nums[i] % k 才重要。我们可以将问题转化为在 0k-1 的余数序列中寻找模式。
  2. 固定常数 :一个有效的子序列,其"相邻和模k"的值是唯一的。我们可以枚举所有可能的 constant 值(即 0k-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 是一个介于 0k-1 之间的常数。我们不知道哪个 target_sum 会产生最长的子序列,所以我们干脆对每一个可能的 target_sum 都计算一次。

对于一个固定target_sum,我们可以设计一个 DP 过程:

  1. 遍历 nums 数组中的每个数字 num
  2. rem = num % k
  3. 要将 num 接到某个子序列的末尾,那么它前面的那个数 prev_num 的余数 prev_rem 必须满足 (prev_rem + rem) % k == target_sum。(模运算性质:和的模等于模的和的模 即:(A + B) % k 等价于 ((A % k) + (B % k)) % k。)
  4. 通过数学变换,可以得到 prev_rem = (target_sum - rem + k) % k。(+k是为了防止 target_sum - rem 结果为负,prev_rem + remtarget_sum模 k 同余
  5. 我们定义一个 dp 数组,dp[r] 表示在当前 target_sum 下,以余数为 r 的数结尾的最长有效子序列的长度。
  6. 那么,当我们遇到 num (余数为rem) 时,我们可以将它接到任何一个以 prev_rem 结尾的子序列后面,形成一个更长的序列。状态转移方程就是:dp[rem] = dp[prev_rem] + 1

我们对 target_sum0k-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)。我们使用了一个大小为 kdp 数组。虽然外层循环有 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 % kr2 就是 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 kdp 数组来存储状态。

<例子过程> 以 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 变为 1dp 如下:

    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

最终,遍历结束,maxLen4,这对应了子序列 [1, 4, 1, 4] (余数模式 1, 1, 1, 1)。

这个解释将代码的每一个部分都与背后的 r1, r2 交替模式的洞察力联系起来,希望这能让你更透彻地理解这个优雅的解法。

解法对比

对比维度 解法一:枚举目标和 (Target-Sum) 解法二:发现交替模式 (Alternating-Pattern)
核心思想 将问题分解为 k 个独立的子问题。每个子问题对应一个固定的 target_sum 通过数学变换,发现问题的本质是寻找最长的 r1, r2, r1, r2... 交替余数序列。
思维路径 分治 / 枚举 。我们不知道哪个"节拍" (a+b)%k 是最优的,所以把所有可能的节拍(0k-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),最终锁定目标。 一个天才的福尔摩斯 🕵️‍♀️ 他通过一个微小的线索(同余变换),直接推理出了凶手的作案模式(交替),一举破案。

举一反三,这个思想还能用在哪?

这个"发现隐藏交替模式"的思想非常强大,可以用在很多地方:

  1. 用户行为分析:分析用户在 App 内的操作序列。如果发现某个用户在"加购物车"和"清空购物车"两个行为的 ID 余数之间,形成了超长的交替序列,这可能是个刷单机器人正在进行价格测试。
  2. 网络安全:检测 DDoS 攻击。攻击流量的源端口或数据包大小,有时会呈现出特定的交替模式,用来绕过简单的防火墙规则。用这个 DP 模型可以快速识别出这种有"节奏"的攻击流。
  3. 基因序列分析:在DNA序列中寻找特定碱基对(如 A-T, G-C)交替出现的最长片段,可能对研究基因功能有重要意义。

类似好题推荐

如果你对这类 DP 问题很感兴趣,可以试试下面这几道题,它们都需要你找到巧妙的状态定义来解决问题:

希望这次的分享能对大家有所启发!有时候,最棘手的问题背后,往往藏着最简洁优美的解决方案。下次遇到难题,不妨也像我一样,盯着它多看几分钟,也许那个"恍然大悟"的瞬间就在等着你呢!😉

相关推荐
屁股割了还要学3 分钟前
【C语言进阶】柔性数组
c语言·开发语言·数据结构·c++·学习·算法·柔性数组
草莓熊Lotso3 分钟前
【LeetCode刷题指南】--有效的括号
c语言·数据结构·其他·算法·leetcode·刷题
Alla T12 分钟前
【通识】算法案例
算法
Electrolux15 分钟前
你敢信,不会点算法没准你赛尔号都玩不明白
前端·后端·算法
天天开心(∩_∩)19 分钟前
代码随想录算法训练营第三十一天
算法
qq_513970441 小时前
力扣 hot100 Day55
算法·leetcode
不绝1913 小时前
ARPG开发流程第一章——方法合集
算法·游戏·unity·游戏引擎
Arwen3033 小时前
解密国密 SSL 证书:SM2、SM3、SM4 算法的协同安全效应
算法·安全·ssl
地平线开发者3 小时前
征程 6|工具链部署实用技巧 6:hbm 解析 API 集锦
算法·自动驾驶
nlp研究牲4 小时前
latex中既控制列内容位置又控制列宽,使用>{\centering\arraybackslash}p{0.85cm}
服务器·前端·人工智能·算法·latex