1. 问题引入:当费用报销遇上日期与金额的双重约束
在算法竞赛的众多题目中,动态规划(DP)是检验选手逻辑建模与状态设计能力的试金石。蓝桥杯国赛的F题“费用报销”正是这样一道经典的DP应用题,它模拟了一个非常贴近现实的业务场景:如何在给定的一堆带有日期和金额的发票中,挑选出若干张进行报销,使得总金额最大化,同时满足“任意两张被选中的发票日期之差必须大于等于K天”以及“总金额不超过给定的上限M”这两个核心约束。
这道题之所以能成为国赛级别的题目,绝不仅仅是因为它考察了基础的0/1背包模型。更关键的是,它将日期处理、状态压缩(将日期映射为连续整数)以及带有额外限制条件的DP状态转移巧妙地融合在了一起。很多初次接触的同学可能会觉得思路清晰——不就是个带限制的背包嘛。但真正动手实现时,才会在日期差的计算、状态的定义与转移的细节上频频踩坑。最终能够AC(Accepted)的代码,其背后是对问题本质的深刻理解和对边界情况的周密处理。
我自己在研究和讲解这道题时,最大的感触是:它完美地诠释了如何将一个看似复杂的业务规则,通过预处理和合理的状态定义,转化为一个清晰、高效的计算机算法模型。接下来,我将彻底拆解这道题,从题意理解、核心思路、关键预处理,到状态转移方程的推导与实现细节,最后分享几个让我调试了半天的“坑点”。无论你是正在备赛的选手,还是对动态规划感兴趣的学习者,相信这篇超过5000字的详解都能让你有所收获。
2. 题意拆解与核心约束分析
要解决任何问题,第一步永远是彻底、无歧义地理解题目。我们先把题目描述(根据常见赛题)转化为更具体的业务语言和数学模型。
2.1 问题要素定义
假设我们有一年的发票,每张发票包含两个关键属性:
- 日期:由月份
mm和日期dd组成,例如4月5日。题目通常会保证日期在同一年内,这大大简化了处理难度。我们可以将日期转换为从年初(1月1日)开始计算的天数序号,这是一个非常关键的预处理步骤。 - 金额:一个整数值
val,代表这张发票的面额。
我们拥有N张这样的发票。此外,我们还有两个全局约束参数:
- K:任意两张被选中报销的发票,它们对应的日期(已转换为天数序号)之差的绝对值必须大于等于K。这意味着我们不能报销日期太过接近的发票,模拟了公司财务对费用发生时间间隔的要求。
- M:所有被选中发票的金额总和必须不超过M。这是报销的总额度上限。
我们的目标是:从这N张发票中,选出一个发票的子集,使得子集中发票的总金额尽可能大(最大化),同时严格满足上述两个约束条件。
2.2 约束条件的深层解读
这两个约束条件共同决定了本题的解题框架:
- 日期间隔约束(K):这个约束是本题区别于标准0/1背包的核心。在标准背包中,物品之间是独立的,选择任意物品组合只受容量限制。而在这里,物品(发票)的选择与否,还受到其他已选物品“日期”属性的影响。这直接导致了我们不能简单地将“发票”作为DP的状态维度,因为状态需要记忆“最后一张被选中的发票是哪一天”这个信息,以便判断下一张能否被选中。
- 金额上限约束(M):这是经典的背包容量限制。我们的DP状态中必须包含一个维度来表示“当前已使用的金额”或“剩余的金额”。
因此,一个直观的DP状态设计雏形就出现了:dp[i][j]表示考虑前i张发票,在总金额不超过j的情况下,所能获得的最大报销金额。但是,这个状态无法体现日期约束。我们需要增强这个状态。
2.3 状态设计的关键思路
为了处理日期约束,一个非常巧妙且常见的思路是:对发票按日期(天数序号)从小到大进行排序。
排序之后,发票序列就有了时间上的先后顺序。此时,日期间隔约束可以重新表述为:如果选择了第i张发票,那么下一张可以选择的发票j,必须满足day[j] - day[i] >= K。
这个重新表述带来了一个巨大的好处:它让“日期约束”变成了一个关于发票索引的“可跳转”关系。对于排序后的第i张发票,我们可以预处理出一个指针pre[i],它表示在排序后的发票列表中,在第i张发票之前,且满足与第i张发票日期相差至少K天的、最后一张发票的索引。如果不存在这样的发票,则pre[i] = 0(我们假设一个虚拟的第0张发票,其日期为负无穷,金额为0)。
为什么是“之前最后一张”?因为DP的过程是顺序考虑发票的。当我们决定是否选择第i张发票时,我们需要知道,如果选了它,那么上一个被选的发票“可能是谁”。为了保证日期约束,上一个被选的发票的日期必须小于等于day[i] - K。而pre[i]就给出了所有满足这个条件的发票中,索引最大的那个。在状态转移时,如果我们选择第i张发票,那么我们的状态就应该从pre[i]那个状态转移过来,这样就自动保证了日期间隔。
于是,我们的DP状态可以定义为:dp[i][j]:考虑排序后的前i张发票(即发票1...i),在总报销金额恰好为j的情况下,所能获得的最大金额(实际上就是j,但此定义利于转移)?不,这个定义有问题。更准确、更标准的背包定义是:dp[i][j]:考虑排序后的前i张发票,总报销金额不超过j的情况下,所能获得的最大金额。
那么,状态转移方程就需要考虑第i张发票选或不选:
- 不选第
i张:dp[i][j] = dp[i-1][j] - 选第
i张:前提是j >= val[i]。如果选,那么上一个被选的发票必须是pre[i]之前的某一张(具体是pre[i]那张,或者更早的)。因此,状态应该从dp[pre[i]][j - val[i]]转移过来,并加上val[i]。即dp[i][j] = max(dp[i][j], dp[pre[i]][j - val[i]] + val[i])。
这里有一个关键点:为什么是dp[pre[i]]而不是dp[i-1]?因为pre[i]保证了如果我们选了i,那么pre[i]及之前的发票与i的日期差至少为K,是合法的上一个选择点。而i-1可能就是i的前一天,日期差为1,不满足K,不能作为转移来源。
至此,我们已经将原问题转化为了一个基于排序和预处理pre数组的、带有“状态依赖”的0/1背包问题。其时间复杂度大致为 O(N * M),在蓝桥杯的数据范围内通常是可行的。
3. 从零开始的完整实现步骤
理解了核心思路后,我们一步步实现代码。我将过程分为几个清晰的阶段,并解释每个阶段为什么要这么做。
3.1 数据输入与日期转换
首先,我们需要读取所有发票数据。每张发票输入格式通常是mm dd val。
#include <iostream> #include <algorithm> #include <vector> #include <cstring> using namespace std; struct Invoice { int day; // 从1月1日起的天数序号 int val; // 金额 }; // 每月天数表,用于日期转换 int monthDays[13] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; // 计算从1月1日到mm月dd日的天数 int convertToDay(int mm, int dd) { int days = 0; for (int i = 1; i < mm; ++i) { days += monthDays[i]; } days += dd; return days; } int main() { int N, M, K; cin >> N >> M >> K; vector<Invoice> invoices(N + 1); // 下标从1开始,方便处理 for (int i = 1; i <= N; ++i) { int mm, dd, v; cin >> mm >> dd >> v; invoices[i].day = convertToDay(mm, dd); invoices[i].val = v; } // ... 后续步骤 }注意:这里假设了年份是平年。蓝桥杯题目通常不会涉及闰年,但如果你看到年份是闰年且包含2月29日,需要在
monthDays[2]上加1。这是一个常见的边界细节,虽然本题大概率不考,但养成检查的习惯很重要。
3.2 发票排序与预处理pre数组
接下来,我们对发票按day从小到大排序。排序后,发票的原始输入顺序就失去了意义,我们只关心它们的时间先后。
// 排序,注意下标从1开始,所以排序范围是 invoices.begin()+1, invoices.end() sort(invoices.begin() + 1, invoices.end(), [](const Invoice& a, const Invoice& b) { return a.day < b.day; // 按日期升序 });现在,最关键的一步:计算pre[i]。对于排序后的第i张发票,我们需要找到最大的索引j(j < i),使得invoices[i].day - invoices[j].day >= K。 最直接的方法是对于每个i,从i-1向前遍历找到第一个满足条件的j。但这样复杂度是O(N²),在N较大时可能超时。更高效的方法是使用双指针: 因为数组已排序,day是递增的。当i向后移动时,满足day[i] - day[j] >= K的j的下界也是单调不减的。我们可以维护一个指针p,使其始终指向对于当前i来说满足条件的最大j。
vector<int> pre(N + 1, 0); // pre[1] = 0 int p = 0; // 指针,指向当前i对应的pre[i]候选 for (int i = 1; i <= N; ++i) { // 移动指针p,直到 invoices[i].day - invoices[p+1].day >= K // 注意:p指向的是上一个满足条件的,我们要检查p+1是否满足 while (p + 1 < i && invoices[i].day - invoices[p + 1].day >= K) { p++; } // 循环结束后,p指向的是满足 day[i]-day[p]>=K 的最大索引 // 但是,如果 invoices[i].day - invoices[p].day < K,说明一个都没有,则pre[i]=0 if (p >= 1 && invoices[i].day - invoices[p].day >= K) { pre[i] = p; } else { pre[i] = 0; // 实际上p就是0,显式赋值清晰 } // 更简洁的写法:pre[i] = p; 因为p的移动逻辑已经保证了当p=0时就是无合法前驱 // 但为了逻辑绝对清晰,我们采用上面的写法。 // 实际上,由于p是从0开始的,且while循环的条件是 p+1 < i,所以当循环结束时, // p可能指向一个满足条件的,也可能一个都没有(此时p=0)。所以直接 pre[i] = p; 即可。 pre[i] = p; // 这就是最终的简洁写法 }这段双指针预处理是O(N)的,非常高效。pre[i]=0是一个特殊标记,表示在选择第i张发票时,前面没有日期间隔超过K天的发票,那么它就可以作为“第一张”被选的发票。
3.3 动态规划状态转移
现在进入DP部分。我们定义dp[i][j]:考虑前i张发票(排序后),总报销金额不超过j元的情况下,能获得的最大金额。
- 状态维度:
i从0到N,j从0到M。 - 初始化:
dp[0][j] = 0,考虑0张发票,金额为0。
// DP数组,空间优化可采用滚动数组,这里先展示二维版本便于理解 vector<vector<int>> dp(N + 1, vector<int>(M + 1, 0)); for (int i = 1; i <= N; ++i) { int curVal = invoices[i].val; int prevIdx = pre[i]; // 选择i时,前驱状态是pre[i] for (int j = 0; j <= M; ++j) { // 不选第i张 dp[i][j] = dp[i-1][j]; // 选第i张,需要满足金额条件,并且状态转移从prevIdx来 if (j >= curVal) { // 注意:是从 dp[prevIdx][j - curVal] 转移,而不是 dp[i-1][j-curVal] dp[i][j] = max(dp[i][j], dp[prevIdx][j - curVal] + curVal); } } }这里有一个极其重要的细节!状态转移方程是dp[i][j] = max(dp[i-1][j], dp[pre[i]][j - val[i]] + val[i])。 为什么是dp[pre[i]]?这体现了“日期约束”的精髓。dp[pre[i]][x]表示在考虑第pre[i]张发票(即第i张发票之前,最后一个日期相差至少K天的发票)时的情况。当我们决定选择第i张发票时,我们“承诺”了上一次选择发生在pre[i]或更早。因此,当前的状态必须基于pre[i]时的状态进行更新,这样就保证了从pre[i]到i之间,我们没有选择其他发票(因为如果选了,日期差就不满足K了),从而满足了题目约束。
3.4 空间优化(滚动数组)
上述二维DP在N和M较大时(例如M=1000, N=1000)需要约4MB内存,通常可以接受。但为了更优,我们可以使用滚动数组,将空间复杂度降至O(M)。观察转移方程:dp[i][j]只依赖于dp[i-1][j]和dp[pre[i]][j-curVal]。pre[i]可能比i-1小很多,所以不能简单地从i-1滚动。但我们可以用两个一维数组,或者更巧妙一点,直接在一维数组上操作,但需要注意遍历顺序。
标准0/1背包的一维优化是j从M到curVal逆序遍历,以防止物品被重复选择。但这里我们的转移来源是dp[pre[i]],而不是dp[i]自身。如果我们直接用一维数组dp[j],在计算第i个物品时,dp[j-curVal]可能已经被第i个物品更新过了(如果j是顺序遍历),这不符合dp[pre[i]][j-curVal]的定义。因此,我们不能直接套用逆序。
一个稳妥的、适用于本题的滚动数组方法是:我们仍然使用二维的思想,但只保留两行:当前行cur和上一行prev。但pre[i]可能指向更早的行,我们需要一个完整的、存储了所有i的dp[i][...]历史记录吗?其实,由于pre[i]一定小于i,我们可以用一个二维数组dp[N+1][M+1],或者,如果我们发现内存紧张,可以意识到pre[i]虽然小于i,但可能只小一点。为了绝对正确,在竞赛中,如果N和M在几千的量级,直接使用二维数组是最省心、最不容易出错的。这里为了展示优化思路,我们假设使用二维数组。
3.5 答案输出
最终,我们需要的是考虑所有N张发票,总金额不超过M的最大值,即dp[N][M]。
cout << dp[N][M] << endl;至此,一个完整的、逻辑清晰的解法就完成了。核心代码(不含IO)大约在30-40行。
4. 代码实现中的关键细节与易错点
即使思路正确,实现时也可能因为细节问题导致WA(Wrong Answer)或TLE(Time Limit Exceeded)。下面我结合自己的调试经验,总结几个最容易出错的点。
4.1 日期转换的偏移问题
这是第一个坑。convertToDay函数计算的是从1月1日到该日期的天数。例如,1月1日转换后是1天,而不是0天。这会影响日期差的计算。在计算day[i] - day[j] >= K时,如果K=1,表示至少间隔1天。那么1月1日和1月2日相差2-1=1天,满足>=1,是可以同时选的。这个逻辑是自洽的。关键在于,你的pre[i]查找逻辑必须和这个定义一致。使用上述双指针算法时,条件是invoices[i].day - invoices[p+1].day >= K,这里用的是>=,符合“至少间隔K天”的语义。如果你错误地计算了日期(比如把1月1日算作0天),那么整个间隔判断就会错位。
4.2 预处理pre[i]的双指针边界
双指针算法写起来需要小心边界。
p的初始值为0,指向一个虚拟的第0张发票(日期可视为-∞)。while循环的条件p + 1 < i确保了p+1是一个有效的、小于i的索引。- 循环内移
p的条件是invoices[i].day - invoices[p+1].day >= K。注意是p+1,因为我们想测试下一个候选是否满足条件。 - 循环结束后,
p指向的是满足day[i] - day[p] >= K的最大p。如果没有任何发票满足(即连p=1都不满足),那么p将保持为0。 - 最后
pre[i] = p。 一定要自己用一个小例子(比如N=5)模拟这个过程,确保pre数组计算正确。这是整个DP正确的基础。
4.3 DP状态转移的维度与含义
这是最大的思维陷阱。我们定义了dp[i][j]是“不超过j”的最大金额。这是背包问题的常见定义。在状态转移时:
- 不选i:直接从
dp[i-1][j]继承,这很自然。 - 选i:我们需要从
dp[pre[i]][j - val[i]]转移过来,并加上val[i]。这里j - val[i]可能为0,这是允许的,表示在pre[i]阶段,报销总额为0。 关键是要理解,dp[pre[i]][j - val[i]]已经是在“考虑前pre[i]张发票,总金额不超过j-val[i]”下的最优解。在这个最优解的基础上,我们添加了第i张发票,总金额变为不超过j,且由于pre[i]的定义,日期约束自动满足。所以dp[i][j]的新候选值就是dp[pre[i]][j - val[i]] + val[i]。
4.4 初始化与答案
初始化dp[0][j] = 0是正确的。因为考虑0张发票,最大金额就是0。 最终答案dp[N][M]就是所求。不需要再遍历j从0到M找最大值,因为我们的状态定义就是“不超过j”,所以dp[N][M]自然是在总金额限制M下的最优解。
4.5 时间复杂度与优化
算法的时间复杂度主要由DP部分决定,为 O(N * M)。在蓝桥杯国赛环境中,N和M通常都在10^3量级,O(10^6)的复杂度是完全可以接受的。空间复杂度O(N*M)也通常可以接受。如果M非常大(比如10^5),而N也很大,可能需要考虑其他优化(如基于价值的DP),但本题的M(报销上限)一般不会设置得离谱。
5. 完整AC代码参考与逐行解析
将以上所有步骤整合,并加上必要的注释,就得到了AC代码。这里我提供一份使用二维DP的清晰版本,它虽然空间占用稍大,但逻辑最直白,易于理解和调试。
#include <iostream> #include <algorithm> #include <vector> #include <cstring> using namespace std; struct Invoice { int day; // 从1月1日开始的天数 int val; // 发票金额 }; // 每月天数,平年 int monthDays[13] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; // 将月/日转换为一年中的第几天 int convertToDay(int mm, int dd) { int days = dd; for (int m = 1; m < mm; ++m) { days += monthDays[m]; } return days; } int main() { int N, M, K; cin >> N >> M >> K; vector<Invoice> inv(N + 1); // 下标1~N for (int i = 1; i <= N; ++i) { int mm, dd, v; cin >> mm >> dd >> v; inv[i].day = convertToDay(mm, dd); inv[i].val = v; } // 1. 按日期排序 sort(inv.begin() + 1, inv.end(), [](const Invoice& a, const Invoice& b) { return a.day < b.day; }); // 2. 预处理pre数组,pre[i]表示在i之前,最后一个与i日期差>=K的发票索引 vector<int> pre(N + 1, 0); int p = 0; // 双指针 for (int i = 1; i <= N; ++i) { // 移动指针p,使得inv[i].day - inv[p+1].day >= K while (p + 1 < i && inv[i].day - inv[p + 1].day >= K) { p++; } pre[i] = p; // p可能就是0,表示前面没有满足条件的发票 } // 3. 动态规划 dp[i][j]: 考虑前i张发票,总金额不超过j的最大报销额 vector<vector<int>> dp(N + 1, vector<int>(M + 1, 0)); for (int i = 1; i <= N; ++i) { int curVal = inv[i].val; int prev = pre[i]; // 选择i时,依赖的状态是prev for (int j = 0; j <= M; ++j) { // 不选第i张 dp[i][j] = dp[i - 1][j]; // 选第i张,需要金额足够,并且从prev状态转移 if (j >= curVal) { dp[i][j] = max(dp[i][j], dp[prev][j - curVal] + curVal); } } } // 4. 输出答案 cout << dp[N][M] << endl; return 0; }逐行解析关键部分:
- 第35-40行(排序):
sort函数对inv[1]到inv[N]排序,排序依据是day字段。排序后,发票按时间顺序排列。 - 第44-50行(预处理pre):这是双指针法的核心。
p始终指向对于当前i来说,满足条件的最大索引。while循环的条件p+1 < i和inv[i].day - inv[p+1].day >= K确保了p的移动是正确且单调的。最终pre[i]=p。 - 第57-65行(DP转移):外层循环
i遍历每张发票。内层循环j遍历所有可能的报销总额(0到M)。dp[i][j]首先继承不选i的情况(dp[i-1][j])。然后,如果当前金额j足够支付第i张发票(j >= curVal),我们尝试选择它。选择它时,我们是从dp[prev][j-curVal]转移过来,其中prev=pre[i]。这保证了日期约束。用max函数更新最优值。 - 第69行(输出):
dp[N][M]即为最终答案。
这份代码在蓝桥杯官方评测系统上应该可以AC。它完整地体现了“排序 -> 预处理前驱 -> 带依赖的背包DP”这一核心解题链条。
6. 举一反三:变种与扩展思考
AC一道题不是终点,理解其思想并能解决类似问题才是。基于“费用报销”模型,我们可以思考几种变种:
6.1 如果发票有“有效期”或“必须在一定时间内报销”怎么办?这相当于给每张发票增加了一个属性:最晚报销日期lastDay。约束变为:选择的发票集合中,每张发票的日期必须在其有效期内,并且任意两张发票的日期差仍要>=K。这会更复杂,可能需要结合贪心或更复杂的DP状态(如状态中包含当前日期)。
6.2 如果金额M非常大(例如10^9),但发票总张数N较小(<=100),怎么办?此时O(N*M)的DP会超时。一个经典的优化思路是交换DP的维度和状态含义。我们可以定义dp[i][s]为考虑前i张发票,恰好报销总金额为s时,所需的最小“最后一张发票日期”或一个布尔状态。但需要处理日期约束。另一种思路是“基于价值的DP”,但本题的日期约束使得状态转移依赖前驱,直接交换维度并不容易。对于N很小的情况,或许可以考虑状态压缩DP(状压DP),枚举所有子集(2^N),然后检查日期和金额约束,但N<=20左右才可行。
6.3 如果约束不是“任意两张发票日期差>=K”,而是“相邻被选发票日期差>=K”呢?这其实是简化了!因为“任意两张”比“相邻两张”更强。如果只要求相邻,那么我们的pre[i]定义可以简化为:pre[i] = i-1(如果day[i]-day[i-1] >= K),否则需要向前找到第一个满足的。状态转移方程可能更简单,但本质上还是同一类模型。
6.4 如何输出具体选择了哪些发票?这是一个经典的DP路径还原问题。我们需要在状态转移时,记录每个状态dp[i][j]是从哪个决策(选或不选,以及从哪个前驱)转移过来的。可以额外开一个preChoice[i][j]数组,在更新dp[i][j]时,如果发现从“选i”转移过来更优,就记录preChoice[i][j] = {prev, j-curVal}(表示从dp[prev][j-curVal]选i而来)。最后从dp[N][M]状态倒推,即可得到选择的发票序列。
7. 调试心得与赛场策略
最后,分享一些从这道题中提炼出的、适用于其他DP问题的通用经验。
7.1 一定要手动模拟小数据这是调试DP最有效的方法。不要依赖感觉,准备纸笔,用题目给的样例或者自己构造的极端小样例(比如N=3, K=1, M=10),一步步模拟你的代码:日期转换结果、排序后的顺序、pre数组的计算、dp表格的填充。把你的计算过程和程序输出(可以添加调试打印)进行对比,任何不一致的地方都是bug的源头。我在这道题上,就是因为一开始pre数组计算逻辑有偏差,导致整个DP结果错误,通过模拟一个N=4的例子才快速定位。
7.2 状态定义要清晰,并始终如一dp[i][j]是“不超过j”还是“恰好为j”?这两种定义在初始化(dp[0][0])和最终答案(遍历j找max还是直接取dp[N][M])上都有区别。本题适合“不超过j”,因为最终答案就是dp[N][M],比较方便。如果你定义“恰好为j”,那么初始化dp[0][0]=0,其他dp[0][j]=-INF(表示不可达),最终答案需要遍历所有j<=M取dp[N][j]的最大值。两种都可以,但必须想清楚,并在整个推导和代码中保持一致。
7.3 空间优化要谨慎一维滚动数组优化虽然节省空间,但会使得状态转移的逻辑变得不那么直观,尤其是当转移依赖的不是i-1而是pre[i]时,更容易出错。在竞赛中,如果时间允许,优先使用逻辑清晰的二维DP。确保算法正确性比那一点空间优化更重要。只有在内存明确不足(比如M很大)时,才去考虑复杂的滚动优化,并且要画图理清依赖关系。
7.4 理解“排序”和“预处理”的威力这道题的精髓在于通过排序,将原本无序的、带有二维属性(日期,金额)的物品,转化为一个一维的序列问题,并通过pre数组将“日期差约束”转化为序列上的“可跳转”关系。这是一种非常经典的技巧,在解决一些涉及时间区间、距离约束的调度、选择问题时经常用到。其核心思想是:通过预处理,将约束条件编码到状态转移的“来源”中。
费用报销这道题,从理解题意到AC,走完整个流程,你对动态规划的状态设计、预处理技巧以及边界处理会有更深的认识。它不像一些纯模板题那样枯燥,而是需要你真正动脑去建模。希望这篇详细的拆解能帮你不仅AC这道题,更能掌握这一类问题的思考方法。在算法学习的路上,这种透过具体题目看到通用模式的能力,才是最宝贵的。