
打卡信奥刷题第2702天这次拿 P3116 [USACO15JAN] Meeting Time S 开刀。题目背景挺生活化的两头牛约在同一片草地上见面但农场主的栅栏把地分成了两套互不相通的路网Bessie 和 Elsie 各走各的都从 1 号地块出发去 N 号地块问最早能在哪个时间点同时到达。注意“同时”这两个字很多同学第一次做这题就栽在这儿。这道题的核心考点是 DAG 上的状态 DP 加 bitset 优化N 最多 16路径总时长最多 10000非常适合用来练习“某个时间点能否到达”这一类集合型 DP 的写法。分值不大但很典型刷完之后你对这种把布尔状态压成二进制位的思路基本就有肌肉记忆了。1. 题意重述两头牛、两套路网、一个共同时间1.1 原题故事翻译成算法模型先花半分钟把题面彻底转成计算机语言。假设有 N 个农场编号从 1 到 N题目保证了输入的时候每条边都是从编号小的农场指向编号大的农场也就是说整个图是一个 DAG没有环。Bessie 的路网有 M1 条有向边每条边给三个数 a、b、t表示从农场 a 走到农场 b 需要花费 t 分钟Elsie 的路网有 M2 条边数据结构长完全一样但边的集合和 Bessie 毫无关系。两头牛同时从农场 1 出发各自沿着自己的路网走到农场 N题目允许每一头牛选择的路径不唯一所以它们到达终点的时间可能有好几种。现在要找的是是否存在一个时间点 T使得 Bessie 能恰好花 T 分钟到达 NElsie 也能恰好花 T 分钟到达 N并且 T 是所有可行解里最小的那一个。如果根本找不到这样的 T输出 IMPOSSIBLE。这里有个非常容易忽略的隐藏条件因为 N 很小总时间长度也有限制所以“时间 T”并不是无限枚举的它的上限是路径总时长的某个有限值这也是后面能开定长 bitset 的原因。1.2 这题到底在考什么表面上这题是图论但它不是传统意义的最短路题而是一道用 DP 暴力维护“可达时间集合”的题。USACO 银组很喜欢出这种套路图论只是壳真正考的是状态设计。你需要意识到两个路网是完全独立的所以解题的第一步是把问题拆成两个互不干扰的子问题分别求出从点 1 到点 N 的所有可能耗时集合最后求交集的最小值。第二步是意识到这个集合可以用二进制位表示用 bitset 的移位操作一次完成所有状态转移。这两步想通了代码其实不到四十行。2. 第一直觉为什么是错的2.1 最短路只能算出“各自最早”拿到这种“求时间”的题条件反射肯定是跑最短路。Dijkstra 或 SPFA 先求出 Bessie 从 1 到 N 的最短时间再求出 Elsie 从 1 到 N 的最短时间然后取个 max 作为答案代码五分钟敲完交上去 WA 到怀疑人生。问题出在哪最短路给出的只是一个单值可题目要的是两个集合的交集。举个最简单的反例假设 Bessie 从 1 号点到 N 号点有两条路耗时分别是 5 和 7Elsie 也有两条路耗时分别是 7 和 10。Bessie 的最短耗时为 5Elsie 的最短耗时为 7取 max 会得到 7这题答案确实也是 7因为 Bessie 在耗时 7 时也能到达。但如果把 Bessie 的耗时改成 5 和 8Elsie 的耗时改成 6 和 8那么两边最短耗时分别是 5 和 6取 max 是 6可 Elsie 在 6 分钟到得了Bessie 却到不了真正的答案反而应该是 8。这就是单值和集合的本质区别。2.2 从“求值”到“求状态集合”的转变所以核心思路必须换不再关心“最短是多少”而是关心“都有哪些时间能到达”。对每一个点 u我需要的不是一个数而是一个集合 S(u)里面存的可能是时间点。如果从 u 出发走某条路能恰好用 w 分钟到达 v那么 S(v) 里的每一个时间 x 加上 w 之后都应该属于 S(u)。这种“集合整体偏移”的操作本质上就是一个动态规划。状态从标量变成了布尔数组数组下标是时间值表示可不可达。一旦状态是布尔数组题目难度就降到了“如何高效实现”这个层面思维上再也没有绕弯的地方。3. DP 状态设计反向思考省掉一半麻烦3.1 状态定义我定义的 DP 状态是这样的设 f[u][t] 是一个布尔值表示从农场 u 出发是否存在某条合法路径恰好花费 t 分钟到达农场 N。因为题目的路网是无环的所以这个定义是良定义的不存在无限绕圈的情况。把 f[u] 看成一个长度固定的布尔数组下标从 0 到 MAXT-1那么 f[u][t] 为 true 就代表 u 到 N 存在耗时恰好为 t 的路径。对应到两头牛分别维护两套 DP 数组 f1 和 f2。最终要求的就是最小的 t使得 f1[1][t] 和 f2[1][t] 同时为 true。为什么从终点 N 反推而不是从起点 1 正推因为在 DAG 里反向推可以复用同一套出边结构代码写起来更顺手。正推也完全可行从 1 开始把到达每个点的时间集合往后传最后看 N 的集合思路没有本质区别只是代码顺序反一下。3.2 转移方程转移关系其实非常朴素。考虑一条边 u - v花费时间为 w。如果从 v 出发能在时间 x 到达 N那么从 u 出发就能在时间 xw 到达 N。写成式子就是f[u][xw] true当且仅当 f[v][x] true其中 u - v 的边权为 w。用集合写法表达更清爽f[u] f[u] 或 (f[v] 整体左移 w)。这个“或”表示新来的方案和之前已经算出的方案合并左移 w 表示把 v 的全部可行时间整体往后推 w 分钟。整体左移的思想非常重要它把常规 DP 里的一个 for 循环时间下标压缩成了 bit 位上的一次位移操作。3.3 边界条件边界条件非常干净。对于终点 N 本身从 N 到 N 不需要走任何路所以耗时是 0也就是 f[N][0] true其余时间点都是 false。除此之外没有别的初始状态。有了边界和转移整个 DP 就可以从 N 往编号小的方向递推了。3.4 为什么可以倒着枚举节点这里有一个必须想清楚的细节也是题目给的最重要条件每条边都是从小编号指向大编号。所以在处理节点 u 的时候u 的所有出边指向的节点 v 都满足 v u。如果我从大到小枚举节点编号先处理 N再处理 N-1那么在处理 u 的时候所有可能被 u 用到的 f[v] 已经全部算好了。这就是典型的 DAG 拓扑序递推不需要再额外写拓扑排序。如果题目没有这个 a b 的条件那就得老老实实先拓扑排序否则可能因为依赖没算完而出错。我第一次做的时候就是没注意到这一点直接从小到大枚举结果样例都过不了。4. 用 bitset 把 DP 跑成位运算4.1 为什么不直接用 bool 数组如果直接开一个布尔二维数组 f[MAXN][MAXT]N 是 20MAXT 是 10005内存占用大概是 20 乘 10005 乘 1 字节约 200KB内存倒也没压力。问题在执行效率对于每一条边我得从头到尾扫一遍 t判断 f[v][x] 是不是 true再给 f[u][xw] 赋值复杂度是 O(边数 乘 MAXT)。这个题规模下也能跑但完全没有发挥出“状态是布尔数组”这个特性的优势。换用 bitset 之后一条边对应的转移只用一句dp[u] | (dp[v] w)背后的逻辑就是一次性把 f[v] 的整个布尔数组整体左移 w再和 f[u] 做按位或合并。布尔数组被看成一个大整数每一位代表一个时间点整段向左移动 w 位相当于所有可行时间整体加 w速度快了不是一星半点。4.2 左移与按位或的物理意义举个具体的例子。假设 f[v] 的二进制表示是 00000101001从低位到高位分别表示时间 0、1、2……这里第 0 位和第 3 位、第 5 位是 1说明从 v 出发能在 0 分钟、3 分钟、5 分钟到达 N。现在有条边 u - v耗时 w2那么dp[v] 2得到 00010100100即原来的三个时间点全部加 2变成 2 分钟、5 分钟、7 分钟。这正好就是“从 u 出发先花 2 分钟到 v再走原来那些路径”的所有可能耗时。然后dp[u] | 结果就是把新发现的这些方案并入 u 原有的方案集合因为 u 可能还有别的出边通向别的节点多条路的方案都需要保留。超出 bitset 位数的高位会被自动丢弃这也没问题因为超出 MAXT 上限的时间点本来也超过题目给定的范围不需要考虑。4.3 时间复杂度分析bitset 的移位和按位或都不是逐 bit 操作的而是按机器字长一次处理好多个 bit。假设 MAXT 是 10005在 64 位机器上一次dp[v] w需要处理的 64 位字大概是 10005 除以 64约 157 个。题目给出的两个图边数总和最多 200 左右所以整体计算量大约就是 200 乘 157约三万多字级别的位操作实际运行时间可以忽略不计。USACO 的测试数据再多也毫秒级出结果。这就是 bitset 的威力它把最内层的枚举时间下标的循环直接抹掉了。4.4 MAXT 的取值依据MAXT 到底取多少取决于路径总时长的上限。根据题目约束N 最大 16每个图中边数最多 100每条边耗时最大 100那么从 1 走到 N 最长不会超过 10000 分钟所以数组开 10005 就够我习惯多留几个位置的余量。如果你心里没底开成 20005 也无所谓多占 1KB 左右的内存完全不影响性能。位运算左移后的高位会自动丢弃所以开大一点没有副作用反而让人更安心。5. 完整 C 代码实现与逐段解析5.1 数据结构设计代码用邻接表存图。因为 N 很小直接用 vector 数组就行。每条边是一个结构体包含目标节点 to 和耗时 w。两个路网分别用 g1 和 g2 两个 vector 数组存。DP 数组用两个全局 bitset 数组第一维是节点编号第二维也就是 bitset 内部的位下标是时间。#include bits/stdc.h using namespace std; const int MAXN 20; const int MAXT 10005; struct Edge { int to, w; }; int n, m1, m2; vectorEdge g1[MAXN], g2[MAXN]; bitsetMAXT dp1[MAXN], dp2[MAXN];5.2 核心 DP 函数把两个路的 DP 逻辑抽成同一个函数传入图数组和对应的 dp 数组这样可以减少重复代码。函数内部从左到右做三件事初始化 dp[n][0] 1从 n-1 到 1 逆序枚举节点对每个节点遍历它的每一条出边执行状态转移。void calc(vectorEdge g[], bitsetMAXT dp[]) { dp[n][0] 1; for (int u n - 1; u 1; --u) { for (const Edge e : g[u]) { dp[u] | (dp[e.to] e.w); } } }这里有个很关键的写代码习惯dp[u] | (dp[e.to] e.w)的括号不能省。C 里移位运算符的优先级比按位或高所以不加括号其实也能编译但读起来非常容易误解一旦后面增加别的表达式很可能就踩到优先级坑。统一加括号逻辑一目了然。5.3 主函数与答案统计主函数读入 n、m1、m2然后分别读入两个路网的边。注意 m1 和 m2 不一定相等不要惯性用同一个变量循环两次。读完之后分别调用两次 calc最后从小到大扫描时间 t找到第一个 f1[1][t] 和 f2[1][t] 同时为 true 的时刻输出。如果扫描完整个 MAXT 都没有就输出 IMPOSSIBLE。int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin n m1 m2; for (int i 0; i m1; i) { int a, b, c; cin a b c; g1[a].push_back({b, c}); } for (int i 0; i m2; i) { int a, b, c; cin a b c; g2[a].push_back({b, c}); } calc(g1, dp1); calc(g2, dp2); for (int t 1; t MAXT; t) { if (dp1[1][t] dp2[1][t]) { cout t \n; return 0; } } cout IMPOSSIBLE\n; return 0; }为什么答案扫描从 1 开始不从 0 开始因为每头牛都必须走至少一条边从 1 到 N 的耗时至少是 1时间为 0 只代表它们一开始就在 N 点这种情况题目不会出现。当然从 0 开始扫也不会有错因为 dp1[1][0] 和 dp2[1][0] 正常情况下都是 false只是从 1 开始更符合直觉。5.4 验证一个手搓的小样例写程序最怕逻辑想当然我习惯交题前用一个最小样例手推一遍。假设输入4 3 2 1 2 1 1 3 2 2 4 3 1 2 2 2 4 2Bessie 的路网里1 到 2 耗时 11 到 3 耗时 22 到 4 耗时 3那么从 1 到 4 的可行时间是 134。Elsie 的路网里1 到 2 耗时 22 到 4 耗时 2可行时间也是 4。程序跑完 dp1[4] 和 dp2[4] 后在 t4 时两个数组的第 4 位都为 true输出 4。手推结果一致代码逻辑正确。6. 高频踩坑点与调试建议6.1 忽略 DAG 的隐式条件导致乱序更新这个坑我必须单独拿出来讲。第一次做的时候我读题不仔细没意识到每条边都满足 a b随手写了个循环从 1 到 n 正向更新结果依赖关系全乱套了。后来仔细读题才发现题目里确实有“每条边都从编号较小的农场通向编号较大的农场”这个条件这让整个图天然就是按编号有序的 DAG。没有这个条件就得自己拓扑排序但那样代码复杂度就上去了而且排序后还要注意重边和自环有环就没法做这种 DP。所以做题之前先花三十秒确认题目是不是 DAG能省下后面一整晚的调试时间。6.2 bitset 开小了导致状态丢失如果 MAXT 设置得太小比如只开 5000而真实答案在 8000那左移的时候超出 5000 的高位会被直接丢弃最终永远找不到正确结果。这类 bug 不会报编译错误也不会 RE只会悄悄 WA特别难排查。我自己的习惯是看题目数据范围把理论上限算出来之后再加一个缓冲值。路径总时长不超过 10000就开 10005如果你拿不准直接翻倍开 20005内存也就多几个字节安全第一。6.3 输出字符串的大小写问题题目要求输出大写的 IMPOSSIBLE全大写一个字母都不能差。USACO 的评测对字符串比对是大小写敏感的写成 Impossible 或者 impossivle 都是 WA。这种错误不是算法层面的而是输出规范没注意最冤不过。建议每次写完输出语句把题面里的输出例子直接复制过来比对一遍。6.4 两个图的边数不相等读入的时候要清醒一点第一轮循环用 m1第二轮用 m2千万别图省事两个都用同一个变量。这种错误很隐蔽因为小样例可能恰好 m1m2样例能过提交就挂。我自己的习惯是输入变量名取得很长m1、m2 一直带着数字后缀让编译器帮我把这种低级错误提前拦下来。6.5 位运算优先级与括号dp[u] | dp[e.to] e.w这行代码在语法上能通过|和的优先级关系其实已经决定了它是先做移位再做或。但为了代码可读性和团队协作时的安全性我强烈建议写成dp[u] | (dp[e.to] e.w)。这是典型的读代码比写代码重要的场景不要跟运算符优先级玩心跳。6.6 时间扫描范围别拍脑袋找答案的循环如果写成for (int t 1; t n; t)这种就完全跑偏了。这里的上界是 MAXT跟 N 的值没有直接关系N 小不代表时间小边的权值完全可以很大。我见过有人把上界写成 100 交上去答案超过 100 就全错这种问题在代码 review 阶段一眼就能看出来但自己写的时候反而容易麻木。反正 bitset 已经把状态算完了老老实实从 1 扫到 MAXT-1 就行又不会超时。7. 从这道题扩散出去的“bitset DP”套路7.1 什么时候可以无脑用 bitset回顾一下这道题能用 bitset 的根本原因DP 状态是“某个值能否达到”转移方式是“把已知的可行状态整体偏移一个固定量”最后需要合并多个来源的结果。这三条一旦同时成立就可以把状态压成二进制位用位移和按位或两个操作完成全部计算。一个最常见的变体是布尔背包问题有一堆物品每种物品可选或不选问哪些总重量能被拼出来。把 dp 数组当成 bitset每加入一个重量为 w 的物品就执行dp | dp w最后数 dp 里有几个 1 就是答案。这个技巧在很多中等难度的题里都能大幅简化代码和常数。7.2 还能在哪里用上这道题的思路并查集维护集合可用性、可达性分析、NPC 问题的小规模暴力状态压缩这些都是 bitset DP 的常见应用场景。甚至一些复杂的字符串状态机题目如果状态是“某种情况是否符合”也可以用 bitset 按位转移。USACO 里还有不少题目是这种套路的变体比如一些 Silver/Gold 级别的题会披着图论的外衣核心却是集合或布尔背包。做题遇到“求某个时刻/某个容量/某个长度是否存在”的描述可以先条件反射地想想能不能往 bitset 上靠通常 90% 的时候能靠上。7.3 个人一点实在体会这题给我的最大启发不是“会写 bitset”而是做题顺序一定要对先看清题目条件的隐藏性质再设计状态最后才谈优化。很多同学上来就想着用最短路或者奇怪的数据结构反而把简单问题复杂化了。P3116 表面是图论实际上你把它当成一个布尔状态的传播问题一切都顺理成章。刷完这题我后来遇到类似的问题都会先在草稿纸上写清楚“状态是什么、转移是什么、边界是什么”棋盘推明白再动手写代码宁可多想十分钟不调试一小时。最后分享一个调试小技巧当dp1[1]和dp2[1]的 bitset 内容比较复杂时别硬盯着数字看写个临时循环把两个 bitset 里所有为 true 的位置打出来肉眼扫一遍交集很快就能定位是转移漏了边还是初始状态没设对。bitset 是黑盒的时候确实不好 debug但你把它还原成布尔数组打印出来整个 DP 过程就透明了。