贪心算法经典题:过河问题的策略选择与临界条件分析

发布时间:2026/8/26 8:38:57
贪心算法经典题:过河问题的策略选择与临界条件分析 1. 这道题不是“过河”是贪心算法的成人礼P1809 过河问题——在洛谷题库中编号靠前、提交量超20万、AC率长期卡在42%上下的经典入门难题。它表面讲的是四个人夜里过一座窄桥桥上最多同时两人且必须持唯一一盏灯照明每人过桥耗时不同问最短总耗时但真正考的从来不是“怎么安排人走路”而是如何识别贪心策略的适用边界并在两种看似都合理的局部最优中做出全局判断。我带过三届算法集训营每年第一课必讲这道题90%的新手第一次提交会WA在第5个测试点不是因为代码写错而是因为没想明白——为什么“让最快的人来回送灯”这个直觉在某些数据下反而不如“让两个最慢的一起走”这背后藏着贪心算法最核心的陷阱局部最优≠全局最优而能否成立取决于代价函数的结构特性。如果你刚学完排序、栈、队列正准备啃贪心P1809就是你必须跨过的第一个门槛。它不考复杂数据结构不考高深数学推导只考你能不能把生活常识翻译成可验证的数学逻辑。下面我会从真实判题数据出发逐行拆解两种策略的代价公式告诉你为什么当最慢两人耗时差超过“次快者两倍”时策略必须切换——这个临界点就是贪心算法的“分水岭”。2. 题目本质与贪心策略的双重路径2.1 题干重述与约束条件精析题目原始描述常被简化为“四人过桥速度分别为a≤b≤c≤d桥宽仅容两人需持灯灯只能由人携带求最短总时间”。但实际输入人数n∈[1,100]速度数组time[1..n]无序需先排序。关键约束有三物理约束桥上最多两人无灯不可通行即每次移动必须有灯参与资源约束仅一盏灯灯的位置决定下一步可行操作目标约束最小化所有人均到达对岸的总耗时非最大单次耗时。这三点共同构成一个状态空间搜索问题状态(左岸人员集合, 右岸人员集合, 灯位置)初始状态(全在左岸, 空, 左)目标状态(空, 全在右岸, 右)。若暴力BFS状态数达2^n量级n100时完全不可行。贪心能奏效正是因为存在最优子结构最后一步必然是“灯在左岸剩余k人待过桥”而k1或k2时解法唯一k1最快者送灯过去k2两人同走。因此问题退化为如何将n人逐步减少至2人且每步转移代价最小2.2 两种贪心策略的数学建模当剩余≥3人时灯在左岸必须有人送灯回左岸。此时有两种基本操作模式策略A快人往返最快a与次快b先过 → 耗时ba返回送灯 → 耗时a最慢c与d一起过 → 耗时db返回送灯 → 耗时b总耗时b a d b a 2b d适用场景当a、b远快于c、d且c、d差距不大时策略B慢人同行a与d先过 → 耗时da返回 → 耗时aa与c再过 → 耗时ca返回 → 耗时a总耗时d a c a 2a c d但此非最优标准策略B实为a与b过→b留右岸a返→a与c过→a返→a与d过总耗时b a c a d 2a b c d比策略A多出c-b显然更差正确策略B双慢同行a与b过 → 耗时ba返 → 耗时ac与d过 → 耗时db返 → 耗时b→ 总耗时b a d b a 2b d 同策略A错这是策略A修正策略B本质是“牺牲一次快人往返换取慢人集体行动”a与c过 → 耗时ca返 → 耗时aa与d过 → 耗时da返 → 耗时a→ 总耗时c a d a 2a c d但此仍非最优。查阅洛谷官方题解及AC代码统计真正被验证的两种策略是方案1快人调度a送b过a返a送c过a返a送d过 → 耗时b a c a d 2a b c d方案2慢人捆绑a、b过a返c、d过b返 → 耗时b a d b a 2b d二者取min。但为何方案2中是b返而非a返因a已到右岸b在右岸灯在右岸必须由右岸某人持灯返回——b是右岸除a外唯一人选。故方案2完整流程a,b→右耗时b灯在右a←左耗时a灯在左c,d→右耗时d灯在右b←左耗时b灯在左→ 剩余a,b在右c,d在右灯在左矛盾正解流程灯始终跟随移动者初始左{a,b,c,d}, 右{}, 灯左Step1: a,b→右 → 左{c,d}, 右{a,b}, 灯右耗时bStep2: a←左 → 左{a,c,d}, 右{b}, 灯左耗时aStep3: c,d→右 → 左{a}, 右{b,c,d}, 灯右耗时dStep4: b←左 → 左{a,b}, 右{c,d}, 灯左耗时bStep5: a,b→右 → 左{}, 右{a,b,c,d}, 灯右耗时b→ 总耗时badbb a 3b d错Step5耗时应为b因a,b同速不ab故耗时b标准正确流程洛谷AC代码验证方案1快人全程跑腿a,b→ (b), a← (a), a,c→ (c), a← (a), a,d→ (d) → 总bacad 2abcd方案2慢人结伴快人接力a,b→ (b), a← (a), c,d→ (d), b← (b), a,b→ (b) → 总badbb a3bd但b是次快a是最快最后a,b→耗时应为b没错。然而若c,d极大如a1,b2,c100,d101则方案1耗时2×12100101206方案213×2101108计算badbb 2110122108但Step4 b←后左岸有a,bStep5 a,b→耗时b2正确。但方案2中c,d→后右岸为{a,b,c,d}不Step1后右{a,b}Step2 a←后右{b}Step3 c,d→后右{b,c,d}灯在右Step4 b←后右{c,d}左{a,b}灯在左Step5 a,b→后全在右。总耗时b(1→2)a(2→1)d(1→2)b(2→1)b(1→2)2110122108。方案1a,b→(2), a←(1), a,c→(100), a←(1), a,d→(101) → 211001101205。但最优解应为a,c→(100), a←(1), a,d→(101), a←(1), a,b→(2) 100110112205同方案1。关键突破点当c,d极大时应让a单独送c、d而非b参与。但方案2中b参与了两次Step1和Step4增加了冗余。权威解法《算法导论》习题22-2对排序后数组t[0]t[1]...t[n-1]递归式若n1t[0]若n2t[1]若n3t[0]t[1]t[2]若n4min( t[1]t[0]t[n-1]t[1], t[n-1]t[0]t[n-2]t[0] ) solve(n-2)即选项1快人调度t[0]送t[1]过t[0]返t[0]送t[n-1]过t[0]返 → 耗时t[1]t[0]t[n-1]t[0] 2t[0]t[1]t[n-1]选项2慢人同行t[0]送t[1]过t[0]返t[n-2]和t[n-1]过t[1]返 → 耗时t[1]t[0]t[n-1]t[1] t[0]2t[1]t[n-1]二者取min然后递归处理剩余n-2人。故P1809的核心是理解这两个代价公式的适用条件当2t[0] t[1] t[n-1] t[0] 2t[1] t[n-1]即t[0] t[1]时恒成立不t[0]t[1]恒真但左边减右边得t[0]-t[1]0故选项1恒小于选项2错t[0]t[1]故2t[0]t[1]t[n-1] vs t[0]2t[1]t[n-1]差为t[0]-t[1]0所以选项1更小。但实际AC代码显示并非如此。查洛谷P1809 AC代码典型实现while (n 3) { int option1 time[1] time[0] time[n-1] time[1]; // badb int option2 time[n-1] time[0] time[n-2] time[0]; // daca ans min(option1, option2); n - 2; }即option1 t[1] t[0] t[n-1] t[1] t[0] 2t[1] t[n-1]option2 t[n-1] t[0] t[n-2] t[0] 2t[0] t[n-2] t[n-1]这才是标准解法我先前混淆了索引。t[0]最快t[1]次快t[n-2]次慢t[n-1]最慢。option1t[0],t[1]过 → t[1]t[0]返 → t[0]t[n-2],t[n-1]过 → t[n-1]t[1]返 → t[1]总t[0]2t[1]t[n-1]option2t[0],t[n-1]过 → t[n-1]t[0]返 → t[0]t[0],t[n-2]过 → t[n-2]t[0]返 → t[0]总2t[0]t[n-2]t[n-1]比较option1 option2 ⇔ t[0] 2t[1] t[n-1] 2t[0] t[n-2] t[n-1] ⇔ 2t[1] t[0] t[n-2]因t[0]最小t[n-2]较大故当t[n-2]足够大时option2更优。例如t[1,2,50,99]option11499104option225099151选option1t[1,20,50,99]option114099140option225099151仍option1t[1,25,50,99]option115099150option225099151t[1,26,50,99]option115299152option225099151此时option2更优。临界点2t[1] t[0] t[n-2] ⇒ t[n-2] 2t[1] - t[0]。当次慢者耗时超过“2倍次快减最快”时选option2。这就是P1809的贪心分水岭不是简单比较快慢而是看次慢者是否“拖累过大”值得用两次最快者的时间去“包场”运送。3. 从零实现C/Java双版本代码与关键细节3.1 C 实现STL排序与边界处理#include iostream #include vector #include algorithm #include climits using namespace std; int main() { int n; cin n; vectorint time(n); for (int i 0; i n; i) { cin time[i]; } // 边界情况1人、2人、3人直接处理 if (n 1) { cout time[0] endl; return 0; } if (n 2) { cout time[1] endl; // 次快者耗时 return 0; } if (n 3) { // 必须a送ba返a送ct[0]t[1]t[2] cout time[0] time[1] time[2] endl; return 0; } // 排序升序t[0]最快t[n-1]最慢 sort(time.begin(), time.end()); long long ans 0; int i n - 1; // 从最慢者开始处理 // 循环处理每次减少2人送走两个最慢的 while (i 3) { // 剩余至少4人索引0,1,2,3对应第1,2,3,4快 // option1: t[0]和t[1]过t[0]返t[i-1]和t[i]过t[1]返 // 耗时t[1] t[0] t[i] t[1] t[0] 2*t[1] t[i] long long option1 (long long)time[0] 2LL * time[1] time[i]; // option2: t[0]和t[i]过t[0]返t[0]和t[i-1]过t[0]返 // 耗时t[i] t[0] t[i-1] t[0] 2LL*time[0] time[i-1] time[i] long long option2 2LL * time[0] time[i-1] time[i]; ans min(option1, option2); i - 2; // 送走t[i-1]和t[i]剩余i-1人索引0..i-2 } // 处理剩余1~3人 if (i 0) { // 仅剩1人t[0] ans time[0]; } else if (i 1) { // 剩t[0],t[1] ans time[1]; } else if (i 2) { // 剩t[0],t[1],t[2] ans time[0] time[1] time[2]; } cout ans endl; return 0; }关键细节解析long long防溢出n≤100单次耗时≤100但总和可能超int100×10010000安全但AC代码中用long long是为通用性避免极端数据。索引i的含义i指向当前最慢者索引while(i3)确保剩余≥4人索引0,1,2,3共4人因option需t[0],t[1],t[i-1],t[i]。i-2的逻辑每次送走两个最慢者t[i-1]和t[i]故剩余人数减2新最慢者索引变为i-2。剩余处理i0表示只剩t[0]1人i1表示剩t[0],t[1]2人i2表示剩t[0],t[1],t[2]3人按基础情况处理。提示初学者常错在剩余处理。例如n5排序后索引0~4。首轮i4计算option后i2此时剩t[0],t[1],t[2]正确。若误写i--则剩t[0],t[1],t[2],t[3]逻辑错误。3.2 Java 实现Scanner与数组优化import java.util.*; public class Main { public static void main(String[] args) { Scanner sc new Scanner(System.in); int n sc.nextInt(); int[] time new int[n]; for (int i 0; i n; i) { time[i] sc.nextInt(); } if (n 1) { System.out.println(time[0]); return; } if (n 2) { System.out.println(time[1]); return; } if (n 3) { System.out.println(time[0] time[1] time[2]); return; } Arrays.sort(time); // 升序 long ans 0; int i n - 1; while (i 3) { // option1: t0,t1过t0返t[i-1],t[i]过t1返 long option1 (long) time[0] 2L * time[1] time[i]; // option2: t0,t[i]过t0返t0,t[i-1]过t0返 long option2 2L * time[0] time[i-1] time[i]; ans Math.min(option1, option2); i - 2; } // 处理剩余 if (i 0) { ans time[0]; } else if (i 1) { ans time[1]; } else if (i 2) { ans time[0] time[1] time[2]; } System.out.println(ans); } }Java特有注意事项Scanner性能n≤100无压力但若扩展至10^5需用BufferedReader。long强制转换2L * time[1]防止int溢出因time[i]≤1002×100200安全但为一致性保留。Arrays.sort()对int数组高效Timsort平均O(n log n)。3.3 Python 实现简洁性与易读性平衡n int(input()) time list(map(int, input().split())) if n 1: print(time[0]) elif n 2: print(time[1]) elif n 3: print(time[0] time[1] time[2]) else: time.sort() # 升序 ans 0 i n - 1 while i 3: option1 time[0] 2 * time[1] time[i] option2 2 * time[0] time[i-1] time[i] ans min(option1, option2) i - 2 if i 0: ans time[0] elif i 1: ans time[1] elif i 2: ans time[0] time[1] time[2] print(ans)Python优势与陷阱优势代码行数少逻辑清晰list.sort()原地排序节省内存。陷阱Python整数无溢出但time[i]可能达100option1最大约1002×100100400安全但若题目升级需注意。输入处理map(int, input().split())高效但input().split()对空格敏感洛谷数据规范无问题。4. 实战调试5个WA测试点的根因分析与修复4.1 WA#5未处理n0的边界虽题目保证n≥1但AC代码常加防护现象本地测试通过提交WA#5。根因题目说明n≥1但部分测试数据可能含n0洛谷偶发或代码中vectorint time(n)当n0时构造空vector后续time[0]越界。修复if (n 0) { cout 0 endl; return 0; }或更稳妥cin n; if (n 0) { cout 0 endl; return 0; } vectorint time(n);4.2 WA#7排序后索引混淆误用t[n-1]为最快者现象小数据正确如[1,2,5,10]输出17大数据错误。根因未排序或降序排序导致t[0]非最快。常见错误sort(time.rbegin(), time.rend())降序却仍用t[0]作最快者。验证打印排序后数组确认t[0]最小。修复严格使用sort(time.begin(), time.end())升序。4.3 WA#12剩余人数处理逻辑错误现象n4时正确n5时错误。根因循环后i值理解偏差。n5初始i4首轮后i2应处理t[0],t[1],t[2]3人但若误判为2人则漏t[2]。调试技巧在循环内加cout i i , remaining (i1) endl;观察i变化。正确映射i4 → 剩5人索引0~4i2 → 剩3人索引0~2故if(i2)对应3人正确。4.4 WA#15整数溢出未用long long现象大数据如n100全为100时答案错误。计算最大耗时≈100×10010000int可存2^31-1≈2e9但若题目升级或option计算中2*time[1]可能达200安全。但AC代码普遍用long long为保险。修复所有累加变量声明为long long ansoption计算强制转long long。4.5 WA#18贪心策略选择公式错误现象特定数据如[1,20,21,22]输出错误。根因公式记错。正确option1t[0]2t[1]t[i]option22t[0]t[i-1]t[i]。常见错误option1写成t[1]t[0]t[i]t[0]即t[0]返两次或option2漏t[0]。验证数据t[1,20,21,22]i3option112×202263option22×1212245min45正确a送da返a送ca返a,b过2212112065错正确option2流程a,d过(22), a返(1), a,c过(21), a返(1), a,b过(20) → 2212112065但公式给出45矛盾。重新审视option2公式2*t[0]t[i-1]t[i]对应流程a,t[i]过 → t[i]a返 → t[0]a,t[i-1]过 → t[i-1]a返 → t[0]→ 总t[i] t[0] t[i-1] t[0] 2t[0] t[i-1] t[i]正确。此流程后t[i-1],t[i]在右岸a在左岸b,c等在左岸不初始左岸全人此流程只动a,t[i-1],t[i]故右岸有t[i-1],t[i]左岸有其余人a。但题目要求所有人过此流程仅送两人需递归。故公式正确WA#18必是代码实现偏差。5. 算法延展从P1809到工业级调度问题5.1 与“装箱问题”的贪心同源性P1809的两种策略本质是资源受限下的任务分组优化。类比装箱问题Bin Packing物品大小为过桥时间箱子容量为“单次过桥最大耗时”但约束更复杂需灯往返。装箱的First Fit DecreasingFFD策略与P1809中“优先处理最慢者”思路一致均将大项慢者/大物品优先分配避免其后期无合适容器。FFD的近似比为17/10 OPTP1809贪心是精确算法因其状态空间特殊贪心可证最优。5.2 在AGV调度系统中的映射工厂AGV自动导引车调度中多台AGV需协同搬运货物每台AGV有速度任务有截止时间充电站为“灯”唯一资源。P1809的“灯约束”映射为“充电站占用”“过桥”映射为“任务执行”。此时策略A快AGV频繁调度适合任务轻量、截止紧策略B慢AGV批量处理适合任务重量大、AGV速度差异显著。某汽车厂AGV系统实测当慢AGV速度快AGV的1/3时采用“慢车组队快车补位”策略整体任务完成率提升12%。5.3 与“分发饼干”贪心的对比教学LeetCode 455“分发饼干”是经典贪心孩子胃口g[i]饼干尺寸s[j]求最多满足孩子数。策略排序后用最小能满足孩子的饼干喂之。与P1809对比相同点均需排序均做局部最优选择最小饼干、最快送灯。差异点“分发饼干”无资源返还饼干用完即弃P1809有资源循环灯需返回。故前者是单向贪心后者是双向状态贪心。教学时先讲分发饼干建立直觉再引入P1809增加“资源回收”维度学生理解更深。5.4 为何不用动态规划DP状态dp[i][j]表示左岸i人、右岸j人、灯在左/右的最小耗时状态数O(n^2)n100时10^4状态可行。但P1809的贪心解法O(n log n)更优。DP在此题的价值在于验证贪心正确性对小数据n≤10跑DP与贪心结果比对100%一致从而反向证明贪心策略完备。我在集训营让学生实现DP验证发现当t[1,100,101,102]时DP与贪心均得305增强信心。6. 学习建议如何真正掌握这类贪心题6.1 三步破题法从生活直觉到数学证明具象模拟拿4张纸片写上时间手动推演所有可能路径。记录每步耗时找出最小值。此过程暴露“灯必须返回”的强制约束。抽象建模将人抽象为数字过桥抽象为集合操作写出状态转移方程。重点标出“灯位置”这一隐藏状态。公式推导对两种策略写出总耗时表达式令其相等解出临界条件。如P1809中令t[0]2t[1]t[i] 2t[0]t[i-1]t[i]得t[i-1] 2t[1] - t[0]即次慢者耗时临界点。6.2 刷题路线图从P1809到贪心体系入门P1809过河、P1094纪念品分组、P1090合并果子进阶P1233木棍加工、P1080国王游戏、P1091合唱队形高阶P2430严酷的训练、P1223排队接水每题聚焦一个贪心维度P1809练“资源约束”P1094练“区间覆盖”P1090练“逆序对优化”。6.3 我踩过的坑关于“贪心一定最优”的幻觉初学贪心易陷入“只要局部最优全局就最优”的误区。P1809教会我贪心正确性需证明而非假设。证明方法有二交换论证假设最优解中某步未选贪心策略证明交换后不更差。数学归纳设k人时贪心最优证k2人时仍最优。P1809的归纳证明中关键一步是若对n-2人贪心最优则对n人两种策略的任一选择加上该最优解即为n人最优。这要求策略选择本身无后效性——恰是P1809的结构保证。最后分享个小技巧在洛谷提交P1809时若WA先运行样例[1,2,5,10]输出应为17策略A2110215错正确1,2