
1. 项目概述从“过河”到“贪心”的思维跃迁看到“P1809 过河问题”这个标题很多刚接触算法竞赛的朋友可能会有点懵这不就是个简单的过河游戏吗但如果你在洛谷、Codeforces这类OJ平台上刷过题就会立刻明白这背后藏着的是一道经典的贪心算法入门题。它不像深度学习或者强化学习那样听起来高大上却是你构建算法思维大厦必不可少的第一块砖。这道题的核心是让你在资源时间有限、规则约束的条件下找到最优的解决方案本质上是对你逻辑抽象和策略优化能力的一次基础锤炼。我最初接触这道题时也犯过“想当然”的错误觉得不就是让最快的人来回送灯吗但实际一上手各种边界条件和策略组合就让人头大。这道题的价值在于它用一个极其生活化的场景——几个人夜里过独木桥只有一盏灯——逼着你跳出直觉用严谨的、可计算的步骤去证明你的策略为什么是最优的。今天我就结合自己刷题和教学的经验把P1809里里外外拆解清楚不仅告诉你“怎么做”更重点剖析“为什么这么做”以及在实际编码中那些容易踩坑的细节。2. 问题本质与数学模型抽象2.1 场景还原与约束条件拆解让我们先把题目描述还原成一个具体的场景有n个人在夜晚要过一座独木桥。桥每次最多只能承受两个人同时通过。由于天色太暗过桥时必须要有灯题目里通常是一把手电筒。这盏灯在所有人过桥的过程中是唯一的。每个人过桥的时间是已知且互不相同的。目标是找到一种过桥方案使得所有人从桥的一端移动到另一端的总时间最短。这里面的约束条件需要逐条明确任何一条理解偏差都会导致算法错误人数限制每次过桥可以是1个人也可以是2个人。这是基本规则。灯具约束这是核心约束。灯必须随着过桥的人移动。也就是说如果有人要从A岸到B岸他必须带着灯过去。这就意味着当灯在B岸时A岸的人是无法独自过桥的必须由B岸的人把灯送回来。时间计算两人同行时过桥时间以较慢者为准。这是很关键的一点它直接决定了我们的配对策略。如果你让一个1分钟的快手和一个10分钟的慢手一起过这10分钟就完全被慢手“浪费”了。目标函数最小化总耗时。我们不是要找出任何一种可行方案而是要找出耗时最短的那一个。把这些文字描述抽象成数学模型就是给定一个正整数n和一个长度为n的数组time[]其中time[i]表示第i个人单独过桥所需的时间。寻找一个操作序列每次操作选择1个或2个“当前在起点岸”的人移动到对岸或者选择1个“当前在终点岸”的人移动回起点岸送灯最终使所有人位于终点岸且所有“移动操作”的耗时之和最小。其中多人移动的耗时取参与者中的最大值。2.2 贪心策略的直觉与反直觉面对这个问题最直接的直觉可能是让最快的人来回送灯。这个直觉在大部分情况下是有效的但并非永远最优。这也是贪心算法迷人的地方——你需要证明在什么情况下这个直觉成立以及在什么情况下需要打破这个直觉。让我们思考两种极端情况情况A假设有4个人过桥时间分别是1, 2, 100, 101。直觉策略最快送灯1和2过(2)1回(1)100和101过(101)2回(2)1和2再过(2)。总时间 2110122 108。有没有更好的试试1和100过(100)1回(1)1和101过(101)1回(1)1和2过(2)。总时间 100110112 205。更差了。再试试另一种打破直觉的1和2过(2)1回(1)100和101过(101)1回(1)不对灯在对面需要2回来……等等这又回到了第一种。看来对于这种两个“超级慢手”的情况用最快的两个人作为“搬运工”似乎是对的。情况B假设有4个人过桥时间分别是1, 20, 21, 22。直觉策略最快送灯1和20过(20)1回(1)21和22过(22)20回(20)1和20过(20)。总时间 201222020 83。打破直觉的策略1和2过(2)? 不对是1和22过(22)1回(1)1和21过(21)1回(1)1和20过(20)。总时间2212112065。等等这个更差。再试一种1和20过(20)1回(1)1和21过(21)1回(1)1和22过(22)。总时间2012112265。还是65。有没有可能让两个慢手一起过节省一次快的回来的时间方案1和2过(2)1回(1)21和22过(22)2回(2)1和2过(2)。总时间21222229。这个结果明显更优通过对比情况B的两种策略我们发现了一个关键点当最快的两个人1和2本身过桥时间并不算太慢而两个最慢的人21和22时间接近时让这两个最慢的人一起过桥只“浪费”一次较快者2的返回时间可能比让最快者1来回跑三次更划算。注意这里的“浪费”是带引号的。因为让最快者来回跑每次虽然耗时少但次数多。让次快者回来一次虽然单次耗时多但总次数少。这就需要我们进行量化比较。这就引出了解决过河问题的两种核心贪心转移模式模式一最快送灯用最快的两个人a, bab作为搬运工。步骤a和b过桥(b)a返回(a)两个最慢的z, y过桥(z)b返回(b)。这完成了一次将两个最慢的人送过去的操作耗时 b a z b 2*b a z。模式二最慢搭档用最快的人a作为搬运工但让两个最慢的人一起过。步骤a和z过桥(z)a返回(a)a和y过桥(y)a返回(a)。这同样送走了两个最慢的耗时 z a y a 2*a y z。那么在每一步决策中我们到底是采用模式一还是模式二呢答案就是比较2*b a z和2*a y z的大小。注意这里的y和z是当前剩余人中最慢的两个且y z。化简一下比较项模式一耗时2*b a z模式二耗时2*a y z比较2*b a z和2*a y z 等价于比较2*b和a y。决策规则如果2*b a y则采用模式一最快两人搬运更优否则采用模式二最快者送最慢两人更优。这个比较式的直观意义是模式一中我们付出了两次b次快者的时间代价模式二中我们付出了一次a最快者和一次y次慢者的时间代价。我们选择代价小的那种模式。3. 算法流程与代码实现详解3.1 算法步骤拆解理解了两种转移模式后我们可以梳理出完整的算法流程。假设有n个人过桥时间存储在数组t中且已按升序排序t[0]最快t[n-1]最慢。初始化总耗时total_time 0。定义左右指针left 0指向最快的人right n - 1指向最慢的人。循环处理当河对岸终点岸人数不足n时继续循环。更具体的是当left right时我们还有人没过河。边界情况处理如果只剩一个人 (left right)那么他只能自己拿着灯过去耗时total_time t[left]结束。如果只剩两个人 (left 1 right)那么他们一起过去即可耗时total_time t[right]因为两人同行取较慢者结束。如果只剩三个人 (left 2 right)这是一个经典子问题。最优方案是最快者带最慢者过最快者回最快者带次慢者过。耗时total_time t[left] t[right] t[left1]。注意这里t[left1]是三人中的第二快/第一慢的人。也可以有另一种顺序最快带次快过最快回最快带最慢过。耗时total_time t[left1] t[left] t[right]。两者结果相同因为加法满足交换律。通常采用第一种描述。多人情况决策核心当剩余人数大于3时 (right - left 2)我们需要决策是使用模式一还是模式二。计算模式一代价cost1 t[left1] t[left] t[right] t[left1]即2*t[left1] t[left] t[right]。这里t[left]是a最快t[left1]是b次快t[right-1]是y次慢t[right]是z最慢。计算模式二代价cost2 t[right] t[left] t[right-1] t[left]即2*t[left] t[right-1] t[right]。比较cost1和cost2如果cost1 cost2即2*t[left1] t[left] t[right-1]采用模式一。操作序列t[left]和t[left1]过t[left]回t[right-1]和t[right]过t[left1]回。总耗时加上cost1。然后right指针减2表示最慢的两个人已过河。否则采用模式二。操作序列t[left]和t[right]过t[left]回t[left]和t[right-1]过t[left]回。总耗时加上cost2。然后right指针减2。这里有一个极其重要的优化在模式一中t[left]最快者和t[left1]次快者在完成一轮运输后又都回到了起点岸。他们作为“搬运工”的角色没有改变因此left指针不需要移动。只有在模式二中最快者t[left]也一直留在起点岸所以left指针同样不动。真正移动的是right指针每次循环减少2。循环结束当left right时所有人已过河total_time即为所求最短时间。3.2 C代码实现与逐行解析下面给出一个清晰、健壮的C实现并附上详细注释。#include iostream #include algorithm using namespace std; int main() { int n; cin n; int t[n]; for (int i 0; i n; i) { cin t[i]; } // 关键步骤1排序 sort(t, t n); int total_time 0; int left 0; // 指向当前最快未过河的人 int right n - 1; // 指向当前最慢未过河的人 // 关键步骤2贪心决策循环 while (left right) { // 情况1只剩一个人 if (left right) { total_time t[left]; break; } // 情况2只剩两个人 if (left 1 right) { total_time t[right]; // 两人同行取慢者 break; } // 情况3只剩三个人 if (left 2 right) { // 最优方案最快带最慢过最快回最快带次慢过 total_time t[left] t[right] t[left 1]; break; } // 情况4大于三个人进行模式比较 // t[left]: 最快 a // t[left1]: 次快 b // t[right-1]: 次慢 y // t[right]: 最慢 z int cost1 2 * t[left 1] t[left] t[right]; // 模式一ab过a回yz过b回 int cost2 2 * t[left] t[right - 1] t[right]; // 模式二az过a回ay过a回 if (cost1 cost2) { // 采用模式一更优 total_time cost1; } else { // 采用模式二更优 total_time cost2; } // 无论哪种模式一次循环都送走了两个最慢的人 right - 2; // left指针不变因为最快的两个人模式一或最快的一人模式二仍留在起点岸 } cout total_time endl; return 0; }代码关键点解析排序sort(t, t n);这是贪心策略成立的前提。我们必须基于有序的时间序列来定义“最快”、“最慢”并进行策略比较。指针的移动right - 2;是循环的核心。每完成一次决策循环无论采用模式一还是模式二我们都成功将当前最慢的两个人送到了对岸。left指针在循环体内不动因为最快的“搬运工”始终留在起点岸等待下一次任务。边界处理对剩余1、2、3个人的处理是独立的必须放在循环开始处判断。因为当人数少于4时我们之前的两种多人大规模转移模式不再适用或者说不存在“两个最慢的人”这种概念了需要用特判的逻辑。比较逻辑if (cost1 cost2)直接实现了我们推导出的决策规则。你也可以写成if (2 * t[left1] t[left] t[right-1])两者是等价的但前者使用cost1和cost2更直观体现了“选择总耗时更小的方案”这一本质。3.3 算法正确性简要分析贪心算法的正确性通常需要证明其贪心选择性质和最优子结构。对于过河问题贪心选择性质每一步选择当前“将两个最慢的人送过河”的最优局部策略模式一或模式二能保证最终得到全局最优解。这个性质可以通过反证法或数学归纳法来理解如果某一步不采用这种局部最优而采用其他方法送走最慢的两个人那么替换成我们的最优模式总时间不会增加。因此每一步都贪心是安全的。最优子结构在送走两个最慢的人之后剩下的n-2个人的过河问题形成了一个规模更小的、性质完全相同的子问题。我们可以在子问题上继续应用相同的贪心策略。虽然这不是一个严格的数学证明但对于算法竞赛和工程理解而言把握其决策比较式的由来2*bvsay以及通过几个典型样例如之前的1,20,21,22的验证足以让我们确信这个算法的正确性。4. 实战演练与复杂度分析4.1 手算推演与过程跟踪让我们用之前提到的反直觉案例[1, 20, 21, 22]来手动跟踪一下算法过程加深理解。初始排序后t [1, 20, 21, 22],left0,right3,total_time0。剩余人数43进入决策比较。a t[left]1,b t[left1]20,y t[right-1]21,z t[right]22。cost1 2*b a z 40 1 22 63。cost2 2*a y z 2 21 22 45。cost2 cost1所以采用模式二。total_time 45变为45。right - 2变为1。现在left0,right1。进入下一轮循环此时left1 right满足“只剩两人”条件。total_time t[right] t[1] 20。total_time变为45 20 65。循环结束。最终结果65等等我们之前不是算出一个29的方案吗这里出问题了仔细看算法结束后right1但t[1]20这意味着算法认为最后剩下的是20和...不对left0是1right1是20所以剩下的是1和20。但我们的模式二方案是1和22过(22)1回(1)1和21过(21)1回(1)。这样操作后对岸是21和22起点岸是1和20。没错算法在第一次循环后送走了22和21剩下1和20。然后第二次循环处理1和20一起过桥耗时20。总时间2212112065。那么29的方案是怎么来的29的方案是1和20过(20)1回(1)21和22过(22)20回(20)1和20过(20)。这个方案里20也充当了一次“搬运工”。在我们的算法决策中第一次比较时cost1模式一用1和20搬运是63cost2模式二用1搬运是45算法选择了更小的45。所以算法没有选择29的方案。这说明我们的算法错了吗不恰恰相反这说明我们之前对29方案的直觉计算是错的让我们重新计算一下29方案1和20过耗时max(1,20)201回耗时121和22过耗时max(21,22)2220回耗时20注意此时灯在对岸需要人带回能带回灯的只有20或2222太慢所以只能是20回1和20过耗时max(1,20)20总时间 20 1 22 20 20 83。我最初在情况B里把20回来和1、20一起过的时间都算成了2这是严重的计算错误。两人同行取较慢者所以最后一步1和20过桥时间是20不是2。因此29是错误答案83才是那个“直觉策略”的真实耗时。而我们的算法得出的65比83要优。所以算法是正确的它帮我们发现了直觉中的计算错误。那么有没有可能比65更优呢我们可以暴力枚举一下所有可能人数少可以手动尝试。实际上65就是这个问题的最优解。模式二耗时45送走22和21再让1和20一起过20总耗时65已经是最短时间了。这个推演过程告诉我们两件事第一手动计算务必小心严格遵循“两人同行取较慢者”的规则第二这个贪心算法确实能有效找到最优解。4.2 时间与空间复杂度分析时间复杂度算法的主要耗时在于排序和贪心循环。排序使用标准库的sort时间复杂度为 O(n log n)。贪心循环每次循环减少两个最慢的人right - 2所以循环次数约为 n/2 次。每次循环内部是常数时间的操作比较、加法。因此贪心循环部分的时间复杂度为 O(n)。综上总时间复杂度为 O(n log n) O(n) O(n log n)主要由排序步骤决定。空间复杂度我们只使用了一个大小为n的数组t和一些常数个别的整型变量。因此空间复杂度为O(n)如果输入数据本身就需要存储这已经是下界了。对于洛谷上的题目限制通常n在10^5量级以内O(n log n)的复杂度是绰绰有余的。5. 常见错误与调试技巧5.1 典型错误案例汇编在实现和提交代码时以下几个错误非常常见未排序这是最致命的错误。贪心策略强烈依赖于速度的顺序。如果输入数据未排序那么“最快”、“最慢”的定义就乱了决策比较式完全失效结果必然错误。边界条件处理遗漏或错误忘记处理n1的情况如果只有一个人他直接过河时间就是他自己的时间。如果循环逻辑没写if (left right)的判断可能会导致指针越界或逻辑错误。三人情况处理不当对于三人最优方案是固定的最快者往返两次带人。如果错误地套用了多人决策逻辑可能会得到次优解。例如对于[1, 100, 101]正确结果是1101100202。如果错误决策可能会算成2*1001101302。两人情况处理不当两人一起过时间是较慢者的时间。不能写成t[left] t[right]。指针移动逻辑错误在多人决策后right - 2是正确的因为送走了两个最慢的。但有人可能会错误地将left也加2认为送走了最快的两个。这是不对的因为最快的“搬运工”在模式一中会回来在模式二中根本没过去他们始终在起点岸等待。循环条件写成while (right 0)或while (left right)要小心处理只剩一个人的情况。决策比较式记错或写错比较2*b a z和2*a y z时弄混了y和z。必须是当前最慢的两个人。直接比较2*b和a y时注意y是次慢不是最慢z。整数溢出虽然洛谷P1809的输入数据范围通常不会导致int溢出但养成好习惯很重要。总时间total_time在极端情况下很多人时间都很大可能会超过32位int的范围。在C中可以使用long long类型来存储总时间。5.2 调试与测试策略构造极端和小规模测试用例n1:[5]- 结果应为5。n2:[1, 10]- 结果应为10。n3:[1, 2, 5]- 结果应为1528。方案1和5过(5)1回(1)1和2过(2)。n4模式一优:[1, 2, 100, 101]- 结果应为2110122108。计算cost12*21101108,cost22*1100101203选模式一。n4模式二优:[1, 20, 21, 22]- 结果应为2212112065。计算cost12*2012263,cost22*1212245选模式二然后剩下1和20一起过(20)总时间65。n5:[1, 2, 5, 8, 9]。可以手算或信任程序。使用调试输出在循环内打印出每一步的left,right,cost1,cost2, 选择模式以及增加的时间可以非常清晰地看到算法的决策过程。while (left right) { cout 当前 left left , right right endl; // ... 处理边界情况 ... int cost1 ...; int cost2 ...; cout cost1 cost1 , cost2 cost2 endl; if (cost1 cost2) { total_time cost1; cout 选择模式一送走 t[right-1] 和 t[right] endl; } else { total_time cost2; cout 选择模式二送走 t[right-1] 和 t[right] endl; } right - 2; cout 当前总时间: total_time endl; }对拍如果你有一个能保证正确但效率较低的算法比如深度优先搜索枚举所有方案仅适用于n很小的情况如n10可以用它来生成随机小数据对比两个程序的结果快速发现错误。5.3 算法扩展与变种思考经典的过河问题通常假设“两人同行时间取慢者”。但还有一些有趣的变种可以帮你深化理解变种1每次过桥人数不限如果桥足够宽每次可以过任意多人但灯还是只有一盏。那策略就变成了最快的人来回送灯每次带一个人过去。总时间 最快者的时间 * (n-2) 其他所有人的时间之和。因为最快者需要往返n-2次送完倒数第二个人后和最后一个人一起过去。变种2有两盏灯那问题就退化成了简单的“每次过两人”因为不需要送回灯了。总时间就是最慢两人的时间如果排序后分批过。变种3过桥时间相同如果所有人过桥时间都一样比如都是T那么策略很简单每次尽量两人一起过。总时间 ceil(n/2) * T如果n是奇数最后一次一个人过时间还是T。这就不需要贪心决策了。理解这些变种能让你更深刻地把握原问题中“灯的唯一性”和“时间差异”这两个约束条件是如何影响策略的。6. 从洛谷P1809到泛化问题解决P1809过河问题作为一道经典的贪心入门题其价值远不止于通过一道OJ题。它训练的是一种优化思维和建模能力。当你面对一个看似复杂的调度或资源分配问题时可以尝试识别约束像过河问题中的“每次最多两人”、“必须有灯”这些都是硬性约束。定义代价目标是最小化总时间这就是我们的代价函数。寻找决策单元我们发现问题的关键操作单元不是一次单人过河而是“将两个最慢的人送到对岸”这个组合操作。这步抽象至关重要。比较局部策略对于这个决策单元存在有限的几种可行策略模式一和模式二。我们通过数学比较选择当前代价最小的策略。迭代与化简应用最优策略后问题规模减小n-2性质不变可以递归或迭代解决。这种“定义子问题 - 枚举局部策略 - 选择最优 - 化简问题”的套路在许多贪心问题中都能见到影子比如“区间调度”、“哈夫曼编码”、“部分背包”等。最后关于代码实现我个人的习惯是永远先写边界条件。在动手写核心循环之前先把n1,n2,n3这些特殊情况用if语句处理好并确保测试通过。这样能让你在思考核心逻辑时更专注也避免了在复杂的循环条件中嵌入过多的特判让代码更清晰、更健壮。这道题就是一个很好的练习下次当你再遇到“最短时间”、“最优调度”这类问题时不妨想想能不能像过河问题一样找到一个关键的“决策单元”和比较策略。