OpenJudge NOI解题秘籍:如何用动态规划吃掉最多糖果?

从“糖果”到“模数”:拆解信息学奥赛中线性动态规划的余数约束艺术

如果你正在备战信息学奥赛,尤其是面对NOI或OpenJudge上那些经典的动态规划题目,你可能会发现,很多题目表面上是关于“最大价值”、“最长序列”或“最优方案”的,但它们的核心难点,往往藏在一个不起眼的“约束条件”里。比如,题目要求你选择的物品总价值必须能被某个数K整除,或者总和模K等于某个特定值。这类问题,就像一道精巧的数学谜题,将朴素的动态规划与数论中的模运算结合了起来。今天,我们不谈空洞的理论,就从一道经典的“糖果”问题出发,一起拆解这类“带模数约束”的线性动态规划,看看如何将抽象的数学条件,转化为清晰、高效的代码逻辑,并掌握一套能举一反三的解题框架。

1. 问题本质:当“选择”遇上“模运算”

我们先抛开具体的题目代码,思考一个更本质的问题:给你N个数字(比如糖果数量),你需要从中选出一部分,使得它们的总和满足某个关于模K的条件(比如能被K整除,即模K余0)。为什么这个问题不能直接用简单的0-1背包解决?

关键在于,传统的0-1背包只关心“总和不大于某个容量”,而这里我们关心的是“总和模K的余数”。总和可能非常大,但余数只有0到K-1这K种可能。这给了我们一个强烈的提示:状态的定义需要从“总价值”维度,转移到“余数”维度。

想象一下,你有一个容量无限的背包,但出口处有一个“余数检测器”。你放入物品的价值会被累加,但检测器只关心最终总价值除以K的余数是否为0。你的目标不是塞满背包,而是让检测器亮绿灯。那么,在挑选物品的过程中,你就需要时刻关注“当前已选物品总价值的余数是多少”,并据此决定下一个物品是否要选。

这就是状态设计的核心思想:以“考虑前i个物品时,总价值模K的余数为j”作为一个状态。我们用 dp[i][j] 来表示这个状态下的最优解(比如最大总价值)。这里,i 是物品的索引(阶段),j 是余数(状态)。通过这种方式,我们将一个可能无限大的总和空间,压缩到了一个有限的、规模为 N * K 的状态空间中。

注意:这里 dp[i][j] 存储的是在满足“余数为j”这个条件下的最大总价值。如果根本不存在一种选择前i个物品的方案能使得余数为j,那么这个状态的值应该被设置为一个“不可能”的值(比如负无穷),以表示其不可达。

这种状态定义的美妙之处在于,它将一个复杂的全局约束(总和模K为0),分解成了一个个局部的、可递推的子问题。我们不再需要记录具体的总和是多少,只需要记录它除以K后的余数,因为对于判断最终是否满足条件而言,余数信息已经足够了。

2. 状态转移:从“要不要选”到“余数如何变化”

定义了状态,接下来就要建立状态之间的联系,也就是状态转移方程。这是动态规划从理论走向实践的关键一步。对于每个物品 i(其价值为 a[i]),当我们考虑状态 dp[i][j](前i个物品,总价值模K余j)时,决策很简单:第 i 个物品,选,还是不选?

  1. 不选第i个物品:那么情况就完全等同于只考虑前 i-1 个物品,并且总价值余数仍然是 j。所以,一种可能的转移是 dp[i][j] = dp[i-1][j]

  2. 选第i个物品:这就有点意思了。假设我们在前 i-1 个物品中已经选了一些,总价值为 x,且 x % K 的余数是 r。现在我们决定再选上第 i 个物品,那么新的总价值是 x + a[i]。我们希望新的总价值模K等于 j,即:

    (x + a[i]) % K = j
    

    根据模运算的性质 (a + b) % K = (a % K + b % K) % K,我们可以将上式改写为:

    (r + a[i] % K) % K = j
    

    我们的目标是找到那个“前i-1个物品”的余数 r,使得加上 a[i] 后,余数能变成 j。对上面的等式做一下变换,可以解出 r

    r = (j - a[i] % K + K) % K
    

    这里加上 K 再取模,是为了处理 j - a[i] % K 可能为负数的情况,确保 r 是一个在 [0, K-1] 范围内的合法余数。

    因此,如果我们选择i 个物品,并且想要最终余数为 j,那么我们必须确保在前 i-1 个物品中,存在一种选择方案,使得其总价值余数恰好为 r = (j - a[i] % K + K) % K。在这种情况下,新的总价值就是 dp[i-1][r] + a[i]

将这两种决策结合起来,状态转移方程就清晰了:

dp[i][j] = max(dp[i-1][j], dp[i-1][(j - a[i] % K + K) % K] + a[i])

这个方程的含义是:为了在前 i 个物品中得到余数为 j 的最大总价值,我们有两种选择——要么直接继承前 i-1 个物品的成果(不选i),要么从前 i-1 个物品中构造出一个特定的余数 r,然后加上当前物品的价值(选i)。我们取这两种选择中价值更大的那个。

为了更直观地理解这个转移过程,我们可以看一个简单的例子。假设 K=5,当前物品价值 a[i]=7a[i] % K = 2)。我们想计算 dp[i][0](余数为0的最大和)。

  • 不选idp[i][0] 可能等于 dp[i-1][0]
  • 选i:我们需要找到前 i-1 个物品中,余数 r = (0 - 2 + 5) % 5 = 3 的最大和,即 dp[i-1][3]。然后加上7,得到 dp[i-1][3] + 7

最终 dp[i][0] 就是这两者的最大值。这个过程就像是在不同的余数“频道”之间进行切换和累加。

3. 初始化与边界:一切从“空集”开始

任何动态规划都需要一个坚实的起点。对于这个问题,我们的起点是“考虑0个物品”的情况,即 i=0

  • 初始状态:一个物品都不选,总价值为0。0除以任何数K,余数都是0。所以,dp[0][0] = 0。这表示“选0个物品,总价值余0”的最大总价值就是0。
  • 非法状态:对于其他余数 j(1 <= j < K),在“选0个物品”的情况下,我们不可能得到总价值余数为j(因为总价值只能是0)。这些状态在初始时是“不可达”的。为了在后续的 max 比较中不被误认为有效方案,我们需要将它们初始化为一个“非常小”的值,通常是一个绝对值很大的负数(比如 -INF)。这样,任何试图从这些非法状态转移过来的路径,其价值都会是负无穷,在取最大值时自然会被淘汰。

用代码表示初始化就是:

dp[0][0] = 0;
for (int j = 1; j < K; ++j) {
    dp[0][j] = -INF; // 用一个很大的负数表示负无穷
}

这里的 INF 可以定义为 0x3f3f3f3fINT_MIN / 2 等,确保其绝对值足够大,不会在计算中被“合法”的值覆盖。

4. 代码实现与空间优化:从二维到一维的优雅蜕变

有了状态定义、转移方程和初始条件,我们就可以写出完整的动态规划代码了。最直观的是使用二维数组 dp[N+1][K]

#include <bits/stdc++.h>
using namespace std;
const int N = 105; // 假设最大物品数
const int INF = 0x3f3f3f3f; // 定义一个很大的数作为“负无穷”

int main() {
    int n, k;
    cin >> n >> k;
    vector<int> a(n + 1);
    for (int i = 1; i <= n; ++i) cin >> a[i];

    vector<vector<int>> dp(n + 1, vector<int>(k, -INF));
    dp[0][0] = 0; // 初始化

    for (int i = 1; i <= n; ++i) {
        int mod_val = a[i] % k; // 预先计算,避免重复求模
        for (int j = 0; j < k; ++j) {
            // 不选第i个物品
            int not_take = dp[i-1][j];
            // 选第i个物品,需要的前置余数
            int prev_mod = (j - mod_val + k) % k;
            int take = dp[i-1][prev_mod] + a[i];
            // 取最大值
            dp[i][j] = max(not_take, take);
        }
    }

    // 最终答案:考虑所有n个物品,总价值模k余0的最大值
    cout << dp[n][0] << endl;
    return 0;
}

这段代码清晰反映了我们之前的推导。但仔细观察内层循环,你会发现 dp[i][j] 只依赖于 dp[i-1][...],即上一行的数据。这是典型的“滚动数组”优化场景。我们可以将二维数组压缩成一维,只保留“当前余数状态”。

优化后的核心循环如下:

vector<int> dp(k, -INF);
dp[0] = 0; // 初始化,对应dp[0][0]=0

for (int i = 1; i <= n; ++i) {
    int mod_val = a[i] % k;
    vector<int> new_dp = dp; // 关键:复制一份上一轮的状态,代表“不选”的继承
    for (int j = 0; j < k; ++j) {
        int prev_mod = (j - mod_val + k) % k;
        // 在“不选”的基础上,尝试“选”的转移
        if (dp[prev_mod] != -INF) { // 确保前置状态可达
            new_dp[j] = max(new_dp[j], dp[prev_mod] + a[i]);
        }
    }
    dp = move(new_dp); // 更新为当前轮的状态
}
cout << dp[0] << endl;

这里有一个关键细节:我们不能直接在原 dp 数组上更新,因为 dp[j] 的新值可能会用到本轮尚未被覆盖的 dp[prev_mod](即上一轮的值)。如果直接覆盖,prev_mod 可能指向一个已经被本轮更新过的值,导致状态依赖错误。所以我们需要一个 new_dp 数组来暂存本轮结果,或者采用“逆序”更新 j 的方式(但这里由于 prev_mod 的计算不是简单的 j-1,逆序更新并不总是安全,复制一份是最稳妥的)。

空间优化后,算法的时间复杂度依然是 O(N * K),但空间复杂度从 O(N * K) 降到了 O(K)。这在K不大但N很大的情况下非常有用。

5. 举一反三:余数约束DP的变体与实战技巧

掌握了“糖果”问题的核心后,我们可以将其思路推广到一系列类似问题。这类问题的共性在于,目标函数(通常是和、差、积等)需要满足一个关于模数的条件。下面我们看几个变体:

变体一:总和模K等于特定值t “糖果”问题是要求模K余0。如果题目改成“总和模K等于t”呢?非常简单,只需要将最终答案从 dp[n][0] 改为 dp[n][t] 即可。状态转移方程完全不变,因为我们在计算每个 dp[i][j] 时,已经涵盖了所有可能的余数 j

变体二:选择物品个数也有约束 有时题目不仅要求总价值模K满足条件,还可能要求选择的物品数量是某个定值,或者在一定范围内。这时,我们的状态就需要增加一维来表示已选物品的数量。例如,定义 dp[i][c][j] 表示考虑前i个物品、恰好选了c个物品、总价值模K余j的最大总价值。状态转移时,除了考虑“选/不选”对余数j的影响,还要考虑对物品数量c的影响(选则c+1,不选则c不变)。初始化时,dp[0][0][0]=0,其他为负无穷。最终答案可能是在 dp[n][c][0] 中寻找满足c约束的最大值。

变体三:乘积模K 如果约束条件不是总和模K,而是乘积模K呢?例如,从N个数中选若干个数,使得它们的乘积模K等于某个值。思路依然是余数DP,但状态转移时的计算方式变了。假设当前数字是 val,其模K为 m。从状态 dp[i-1][r] 转移到 dp[i][j](选择当前数字)的条件是:

(r * m) % K == j

我们需要找到所有满足 (r * m) % K == jr。这比加法情况下的线性关系 r = (j - m + K) % K 要复杂一些,因为乘法模运算下,一个 j 可能对应多个 r(取决于m和K是否互质)。不过,由于K通常不会太大(否则状态数爆炸),我们可以直接遍历所有可能的 r (0 <= r < K),检查是否满足条件。此时状态转移方程为:

for (int r = 0; r < K; ++r) {
    if ((r * m) % K == j) {
        dp[i][j] = max(dp[i][j], dp[i-1][r] * val); // 假设是求最大乘积
    }
}

当然,还需要考虑不选当前数字的情况 dp[i][j] = max(dp[i][j], dp[i-1][j])

实战技巧:调试与验证 在竞赛中,这类题目容易因余数计算错误或初始化不当而出错。这里分享几个调试技巧:

  1. 小数据手工模拟:用N=3, K=5这样的小例子,在纸上一步步画出 dp 表格,验证你的状态转移逻辑。
  2. 打印中间状态:在代码中关键步骤后,打印出 dp[i] 数组(或优化后的 dp 数组),观察其变化是否符合预期。特别是检查那些被初始化为负无穷的状态,在后续迭代中是否被正确更新。
  3. 验证边界:测试K=1的情况(任何数模1都余0),测试所有糖果数都能被K整除的情况,测试根本无解的情况(最终 dp[n][0] 是否为初始的0或负值)。
  4. 对比暴力搜索:对于N很小(比如<=20)的情况,可以写一个暴力枚举所有子集的程序,计算其总和并检查模K条件,与你的DP结果对比。这是验证算法正确性的黄金标准。

6. 竞赛中的思维拓展:从线性DP到更复杂的场景

“糖果”问题属于线性动态规划,物品的顺序是固定的。但在信息学奥赛中,余数约束可能出现在更复杂的场景,比如区间DP、树形DP甚至状态压缩DP中。其核心思想是相通的:将模数作为状态的一维,在状态转移时维护模运算的正确性

例如,考虑一个区间DP问题:给定一个数字序列,你可以在其中添加加号或乘号,求计算结果模K的最大值。这里,状态可以定义为 dp[l][r][m],表示区间 [l, r] 通过某种加乘组合,能得到模K等于m的所有可能结果(或者最大结果)。状态转移时,需要枚举分割点,并根据操作符是加号还是乘号,来组合左右两个子区间的余数状态。

再比如,在树形DP中,如果要求树上一条路径的权值和模K最大,我们可以定义 dp[u][m] 表示在以u为根的子树中,从u开始向下的一条路径,其权值和模K等于m的最大权值和。状态转移需要合并子节点的信息。

面对这些复杂变体,解题步骤可以归纳为:

  1. 识别约束:明确题目中关于模数的限制条件。
  2. 设计状态:在原有DP状态基础上,增加一维表示“当前结果模K的值”。
  3. 推导转移:仔细分析操作(选择、合并、计算等)如何影响模数值。利用模运算的性质 (a op b) % K = ((a % K) op (b % K)) % K(op为加、减、乘),将大数的运算转化为余数之间的运算。
  4. 处理初始化:确定“空”或“起点”状态对应的余数值(通常是0),并将其他余数状态设为非法。
  5. 确定答案:从最终状态中,根据题目要求(最大、最小、计数等)提取答案。

最后,想说的是,动态规划的魅力在于其“以空间换时间”和“记录历史以避免重复计算”的思想。而引入模运算约束,则是给这种思想加上了一层精巧的数学滤镜。它要求我们不仅关注“量”的积累,更关注“质”的分布(余数)。当你再遇到类似“总和模K为某值”的问题时,希望你能立刻联想到“余数DP”这个强大的工具,并自信地构建出状态和方程。多练习几道相关的题目,比如OpenJudge或各大OJ题库中带有“模”、“余数”、“整除”关键词的动态规划题,你会对这种模式越来越熟悉。在竞赛中,这很可能就是帮你拉开差距的关键一步。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值