尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

蓝桥杯真题解析:动态规划解决带时间约束的费用报销问题

蓝桥杯真题解析:动态规划解决带时间约束的费用报销问题 1. 项目概述从一道真题看竞赛中的动态规划实战最近在复盘蓝桥杯国赛的真题2022年B组的“费用报销”这道题给我留下了挺深的印象。它不像一些纯考算法的“硬骨头”题而是把动态规划DP这个核心工具巧妙地包装在一个非常贴近实际业务——费用报销的场景里。题目大概意思是给你一堆带有日期和金额的发票要求在总金额不超过某个预算、且任意两张报销的发票日期间隔不小于K天的约束下选出一些发票使得报销的总金额最大。这听起来是不是很像公司里财务或者业务系统要处理的实际问题但它的内核却是一个经典的0/1背包问题的变种只不过背包的“容量”是预算物品的“价值”和“重量”都是发票金额还额外加了一个时间间隔的约束。这道题之所以值得拿出来细说是因为它完美地体现了算法竞赛如何考察选手将实际问题抽象为数学模型并选择合适算法解决的能力。它主要涉及两种DP思路线性DP和状态DP通常指基于日期或发票索引顺序推进的DP与基于二进制状态压缩的DP区分。对于正在备赛蓝桥杯尤其是目标国赛的同学来说吃透这道题不仅能掌握一种重要的DP建模技巧更能理解如何应对带有复杂约束的优化问题。今天我就结合自己的解题和教学经验把这道题的核心思路、两种DP解法的详细实现、以及其中容易踩的坑掰开揉碎了讲清楚。2. 问题核心与抽象建模在动手写代码之前我们必须先把题目描述翻译成程序员和算法能理解的语言。这一步的清晰与否直接决定了后续解题的成败。2.1 约束条件与问题转化首先我们明确输入和输出。输入N张发票每张发票有日期通常转化为一年中的第几天方便计算间隔和金额value[i]。一个总预算M。一个最小间隔天数K。输出一个整数表示在满足约束条件下能报销的最大总金额。约束金额约束所选发票总金额 ≤M。时间约束任意两张被选中的发票其日期之差绝对值必须 ≥K。这意味着如果你决定报销某一天的发票那么前K天和后K天内的其他发票都不能再选。这立刻让我们联想到经典的0/1背包问题有一个容量为M的背包和N件物品每件物品有重量w[i]和价值v[i]每个物品只能选或不选求在不超过背包容量的前提下能装下的最大价值。在本题中发票金额同时扮演了“重量”和“价值”的角色。因为我们的目标是最大化总金额所以w[i] v[i] value[i]。这其实简化了问题因为通常背包问题需要权衡价值与重量的比值而这里两者一致选择策略变得更直接——在容量允许的前提下尽可能选金额大的发票。但别高兴太早那个时间间隔约束打破了这种简单性它引入了物品之间的互斥关系使得问题不再是简单的独立选择。2.2 排序与预处理化解互斥约束的关键时间约束是本题最大的难点。如何让DP状态能够方便地处理“选了这张票前K天内的票都不能选”这个规则呢一个非常关键的预处理步骤是将所有发票按照日期从小到大排序。排序之后发票就有了一个自然的线性顺序。此时时间约束可以重新表述为如果选择了排序后的第i张发票那么下一张可以选择的发票j必须满足date[j] - date[i] K。这样一来对于每张发票i我们可以预处理出一个指针prev[i]。prev[i]表示在排序后的发票序列中满足date[i] - date[prev[i]] K的最大索引。换句话说prev[i]是i之前日期更早且与i的时间间隔至少为K的最后一张发票。如果i之前没有这样的发票即第一张或者前面的票离得太近则prev[i] 0我们通常假设一个虚拟的第0张发票金额为0方便处理边界。这个prev数组是连接线性DP和状态DP的桥梁。它告诉我们当考虑是否选择发票i时我们不需要关心prev[i]之前的所有发票是否被选因为那些票即使被选了也距离i足够远不影响i的选择我们只需要关心从prev[i] 1到i-1这个区间内的发票它们因为离i太近而不能与i同时被选。而prev[i]这张票本身是可以和i同时被选的。实操心得预处理prev数组通常使用双指针法。因为日期是排序好的所以对于每个i我们只需要移动一个指向prev[i]的指针j直到date[i] - date[j] K并且j尽可能大。这个操作是O(N)的非常高效。这是解决此类带间隔约束DP问题的标准预处理操作务必熟练掌握。3. 解法一线性DP基于发票顺序这是最直观也最容易理解的一种DP定义方式。我们定义状态dp[i][j]。3.1 状态定义与转移方程状态定义dp[i][j]表示只考虑前i张发票排序后的且报销总金额恰好为j时是否可行。这里“是否可行”可以用布尔值True/False表示。但更常见的优化是我们用dp[i][j]直接存储在前i张发票、总金额不超过j的前提下能获得的最大金额。后一种定义更利于状态转移和求最终答案。我们采用后一种定义。状态转移对于第i张发票金额为v我们有两种选择不选第i张发票那么最大金额继承自前i-1张发票的情况即dp[i][j] dp[i-1][j]。选第i张发票那么我们必须确保在选了i之后总金额j不能超过预算。并且由于时间约束我们不能同时选择与i时间冲突的发票即prev[i]1到i-1区间的票。但是我们可以选择prev[i]及之前的票。因此如果选择i那么能达到的最大金额是dp[prev[i]][j - v] v。这里dp[prev[i]][j - v]表示在prev[i]及之前的发票中总金额不超过j-v的最大金额再加上i的金额v。综合两者状态转移方程为dp[i][j] max(dp[i-1][j], dp[prev[i]][j - v] v)其中第二个选择仅在j v时有效。初始化dp[0][j] 0表示没有发票时任何预算下的最大金额都是0。最终答案遍历所有i和j之后dp[N][M]就是我们想要的结果即考虑所有N张发票预算为M时的最大可报销金额。3.2 代码实现与空间优化根据上述思路我们可以写出核心代码框架以C为例#include iostream #include algorithm #include cstring using namespace std; struct Invoice { int date, value; } inv[1010]; // 假设最多1000张发票 int dp[1010][5010]; // dp[i][j] 假设预算M最大5000 int prevIdx[1010]; // prev数组 int main() { int N, M, K; cin N M K; for (int i 1; i N; i) { int y, m, d; cin y m d inv[i].value; // 简化日期处理计算当年第几天 (实际比赛需注意闰年) inv[i].date d (m-1)*30; // 此处为示例应用更精确的日期计算 } // 1. 按日期排序 sort(inv 1, inv N 1, [](Invoice a, Invoice b) { return a.date b.date; }); // 2. 预处理prev数组 int j 0; // 双指针j指向prev[i] for (int i 1; i N; i) { while (inv[i].date - inv[j1].date K) { j; } prevIdx[i] j; } // 3. 线性DP memset(dp, 0, sizeof(dp)); for (int i 1; i N; i) { int v inv[i].value; for (int j 0; j M; j) { dp[i][j] dp[i-1][j]; // 不选i if (j v) { // 选i则状态从prevIdx[i]转移而来 dp[i][j] max(dp[i][j], dp[prevIdx[i]][j - v] v); } } } cout dp[N][M] endl; return 0; }空间优化滚动数组观察状态转移方程dp[i][j]只依赖于dp[i-1][...]和dp[prev[i]][...]。由于prev[i]可能比i-1小很多我们不能简单地从i-1滚动到i。但是我们可以注意到在计算dp[i]时dp[prev[i]]肯定已经在之前的某一轮计算好了因为prev[i] i。因此我们可以直接使用一个二维数组或者更节省空间地使用一个一维数组但需要逆序枚举预算j并且小心处理prev[i]的引用。对于本题使用二维数组清晰且不易错在N和M不大时蓝桥杯典型范围是完全可以接受的。注意事项日期处理是本题的一个细节坑。题目给的日期可能是“年-月-日”格式你需要将其转换为一个整数比如“从当年1月1日开始的第几天”。这里要特别注意闰年的判断否则在日期差计算上会出错导致prev数组计算错误进而影响整个DP结果。一个稳健的做法是写一个days_from_start(int y, int m, int d)函数来计算。4. 解法二状态DP基于日期维度“状态DP”在这里可能有些歧义。在更广泛的语境中状态DP常指状态压缩DP。但在这道题里另一种更自然的思路是以日期作为DP的维度之一。我们定义状态f[t][j]。4.1 状态定义与转移方程状态定义f[t][j]表示考虑到第t天一年中的第t天且报销总金额恰好为j时是否可行同样可以用布尔值或最大金额。这里“考虑到第t天”意味着我们只处理日期 ≤ t 的发票。这种定义方式更加贴近“时间线”。对于第t天可能有多张发票如果多张发票同一天。状态转移需要考虑的是在第t天我们最终决定报销哪张或哪些发票但由于时间约束间隔K天我们在第t天做决定时会受到t-K天之前决定的影响。更精确的转移如下第t天不报销任何发票那么状态直接从t-1天继承即f[t][j] f[t-1][j]。第t天报销一张金额为v的发票那么我们必须确保在t-K天之前包括t-K天的最后一次报销后间隔了至少K天。这意味着如果我们想在第t天报销那么上一次报销必须发生在第t-K天或更早。 因此状态转移为f[t][j] f[t][j] | f[t-K][j-v]布尔型或f[t][j] max(f[t][j], f[t-K][j-v] v)数值型。但是这里有一个问题t-K天可能没有发票或者我们可能在那天并没有报销。实际上我们关心的不是“第t-K天”的状态而是“距离第t天至少K天之前”的所有天数中能达到某个金额的最佳状态。这引导我们定义另一个状态g[t][j]。4.2 优化定义与实现定义g[t][j]为从第1天到第t天考虑所有发票且报销总金额恰好为j时是否可行。那么g[t][j]可以由两部分转移而来继承g[t-1][j]第t天不选。如果第t天有发票假设金额为v_t那么可以选择它。但选择它就必须从t-K天之前的状态转移过来即g[t-K-1][j-v_t]。为什么是t-K-1因为要求间隔至少K天所以上一张报销的发票日期必须 ≤t-K-1。因此转移方程为g[t][j] g[t-1][j]如果第t天有发票设其集合为S_t则对于每个vinS_t且j vg[t][j] g[t][j] | g[t-K-1][j-v]初始化g[0][0] true其他为false。最终答案所有g[T][j](j M) 中使得g[T][j]为 true 的最大的j。其中T是最大的日期。这种方法的优势是状态定义非常直观就是沿着时间线推进。缺点是空间和时间复杂度与日期的范围有关。如果一年有365天预算M为5000那么状态数是365*5000 ≈ 1.8e6可以接受。但如果日期范围很大比如跨多年或者比赛内存限制很紧这种方法可能不如线性DP高效。实操心得状态DP日期维度的方法其性能高度依赖于日期范围的离散化程度。如果原始日期很稀疏我们可以将所有出现过的日期进行排序和映射压缩到一个更小的索引范围内再用类似线性DP的方法处理这其实又回到了解法一的思路上。所以在竞赛中解法一基于发票排序和prev数组的线性DP通常是更通用、更推荐的首选方法。解法二可以帮助我们从另一个角度理解问题但在实现效率和普适性上稍逊一筹。5. 核心难点与易错点剖析这道题思路清晰后实现起来并不复杂但有几个地方一不注意就会丢分。5.1 日期处理与prev数组的准确性这是错误的重灾区。很多同学能写出DP框架但就因为日期差算错了一两天导致整个结果不对。闰年判断一定要写对。(year % 4 0 year % 100 ! 0) || (year % 400 0)。月份天数数组用一个数组month_days存储平年每月的天数闰年单独处理2月。计算日期序号写一个函数int getDayId(int y, int m, int d)返回从某个基准日如当年1月1日到该日期的天数。这样两个日期的序号相减就是间隔天数。双指针求prev确保循环条件正确。while (j1 i day[i] - day[j1] K) j;循环结束后j就是prev[i]。要仔细检查边界情况比如第一张发票的prev[1]0。5.2 DP状态定义与初始化的细节“恰好” vs “不超过”我们之前讨论的dp[i][j]定义为“不超过j的最大金额”这种定义下初始化dp[0][j]0最终答案就是dp[N][M]。如果定义为“恰好为j是否可行”布尔型则初始化dp[0][0]true其他为false最终需要遍历dp[N][0..M]找到为true的最大的j。前者在编码上通常更简洁。数组大小DP数组和第二维预算维度的大小要开够。预算M可能达到5000或更高要根据题目数据范围来定。通常可以开到M5以防边界。5.3 时间复杂度与优化朴素DP复杂度O(N * M)。N是发票数≤1000M是预算≤5000计算量在5e6量级完全在蓝桥杯的时间限制内通常1s或2s。空间优化如前所述可以使用滚动数组。但注意因为转移需要用到dp[prev[i]][...]而prev[i]不是固定的i-1所以不能简单用一维数组从前往后更新。一个可行的优化是因为prev[i]是单调不减的日期排序后我们可以用一个一维数组dp_prev来记录考虑完前i-1张票后的状态在计算第i张票时prev[i]对应的状态就是dp_prev数组在之前的某个“快照”。但实现起来稍复杂不如直接用二维数组清晰。在竞赛中清晰正确比极致的优化更重要除非内存明显不够。6. 从真题到举一反三DP建模的通用思路这道“费用报销”题是一个非常好的教学案例它展示了如何用DP解决带有额外约束的背包问题。我们可以从中提炼出更通用的解题模式识别问题本质首先判断是否是背包问题每个物品选/不选有容量限制。本题明显是。处理附加约束背包问题附加的约束如本题的时间间隔通常会破坏物品之间的独立性。解决思路是通过排序和预处理将约束转化为状态转移时可以方便利用的信息。排序让问题具有顺序性预处理如prev数组明确告诉DP状态当选择当前物品时可以安全地回溯到哪个历史状态。定义DP状态状态需要包含“考虑到的范围”前i个物品和“当前的容量”总金额j。这是处理背包问题的经典维度。构造转移方程根据“选”与“不选”当前物品两种情况并结合预处理信息prev[i]写出方程。处理边界与初始化确保状态转移的起点正确。计算最终答案根据状态定义从DP表中读出答案。这种“排序预处理prev数组线性DP”的组合拳适用于很多带有“间隔限制”、“互斥选择”的变种背包问题。例如“在一条时间线上选择若干个不重叠的时间段每个时间段有收益求最大总收益”经典的最大不重叠区间和问题就可以用类似的思路解决其中prev[i]就是结束时间早于第i个区间开始时间的最后一个区间索引。最后再分享一个调试小技巧对于DP问题尤其是状态定义稍微复杂的题不要急于写完整代码。可以先用小规模的测试数据比如3-5张发票用手算或者打印出整个DP表核对每一个dp[i][j]的值是否符合你的预期。这能帮你快速定位是状态定义、转移方程还是预处理环节出了问题。理解一道题的价值远大于AC一道题把这道“费用报销”吃透你对动态规划处理复杂约束的能力会上一个台阶。
返回列表