news 2026/10/17 1:11:49

POJ 1700快速渡河问题:贪心策略经典解析与实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
POJ 1700快速渡河问题:贪心策略经典解析与实现

POJ 1700 的快速渡河问题,很多人第一次见到时都以为是一道模拟题:几个人过河、船一次载两个、还得有人把灯送回来,套个循环不就行了?结果一提交就是错。我第一次做这题时也一样,后来才意识到,这题考的根本不是模拟,而是贪心策略里最经典的一类——局部决策会直接影响全局代价,所以你每一步都必须从两种方案里挑那个更省的。这篇文章就把这道题从头到尾拆开讲清楚,包括两种策略为什么存在、每轮决策怎么算、代码怎么写、边界条件怎么处理,以及我实际调试时踩过的几个坑。不管你是刚接触贪心的新手,还是准备复习经典题的选手,这篇都能直接用。

1. 题目本质与贪心直觉

1.1 题目到底在说什么

先把题目规则还原一下:有 n 个人在河的一侧,要到另一侧去。河边有一条船,每次最多坐两个人,船过河需要的时间等于船上两个人的过河时间中较慢的那个。晚上过河必须带一盏灯,而灯只有一盏,所以每次有人过去之后,如果还有人留在起点,就必须有一个人把灯送回来。要求的是所有人全部到达对岸所需的最短总时间。

这个规则里最容易忽略的就是“送灯”这一步。很多人一开始想的是:最快的人来回接送不就行了?确实,这是最朴素的想法,但也是最容易掉进去的坑。

题目给了一个很经典的例子:四个人过河,时间分别是 1、2、5、10。如果只让 1 号最快的人来回接送,流程是:1 带 10 过去,耗时 10;1 回来,耗时 1;1 带 5 过去,耗时 5;1 回来,耗时 1;1 带 2 过去,耗时 2。总耗时 19。但实际最优解是 17,做法完全不同:1 和 2 先过去,耗时 2;1 回来,耗时 1;5 和 10 过去,耗时 10;2 回来,耗时 2;1 和 2 再过去,耗时 2。总耗时 17。

为什么多走了一趟,反而更省?这就是这道题的核心。

做这道题之前,先把所有人的过河时间按从小到大排序。设排序后的数组为 a[0] 到 a[n-1],那么 a[0] 是最快的,a[1] 是第二快的,a[n-1] 是最慢的。这道题所有贪心决策都建立在有序数组上,排序是第一步,也是最重要的一步。

1.2 为什么“每次都让最快的送”不是最优

直观上,让最快的人当“摆渡人”确实有道理:他速度快,回程耗费的时间最少。但问题出在他接力的对象太慢。当最快的人带着最慢的人过河时,这一趟的耗时完全由最慢的人决定,比如 1 带 10 要花 10 分钟。接着最快的人还要回来,再带次慢的人过去,又要花掉一个很长的耗时。也就是说,最快的人虽然自身快,但他被迫陪着最慢的人“慢”了两次,代价非常大。

而第二种策略聪明在,它让最快的两个人先打配合:1 和 2 先过河,2 的耗时虽然比 1 慢一点,但也就多 1 分钟;然后 1 回来;接着让 5 和 10 这两个最慢的人一起过河,反正他们单独过也是 10 分钟,一起过还是 10 分钟;最后让 2 回来接 1。这样一来,最慢的两个人只用了一趟就同时过河,等于把“10 分钟”这个昂贵代价用了一次就解决了。

所以这道题的关键不是“谁最快谁就一直跑”,而是“怎么把最慢的人尽量打包送走,同时让回程的代价最小”。理解了这一点,贪心策略就呼之欲出了。

1.3 核心建模:一轮送走两个人

做这道题要换一个思路:不要一次考虑一个人,而是把“处理掉当前最慢的两个人”当成一轮操作。每次我们从剩下的未过河的人里,找出最慢的 a[n-1] 和次慢的 a[n-2],想办法把他们送到对岸。因为这两个人无论怎么安排,都必须过河,而且他们过河的那一趟耗时基本是确定的——至少是 a[n-1]。问题在于,为了把灯送回起点,需要让谁在中间跑回来。

每送走这两个最慢的人,剩余人数就减少两个。然后继续处理新的最慢的两个人。等到剩余人数变成 2 或 3 时,问题就变得非常简单,直接按公式处理即可。

这样建模之后,问题就从“n 个人一起过河”变成了“每一轮用最小代价送走两个最慢的人”,而每一轮的最优决策,就在两种固定模式里选一个。这就是贪心策略的骨架。

2. 两种策略的推导与取舍

2.1 策略A:最快者来回护送

策略 A 就是很多人第一反应想到的方案:让最快的人 a[0] 负责接送最慢的两个人。具体流程是这样的:

  • 第一步:a[0] 带着当前最慢的 a[n-1] 过河,耗时 a[n-1];
  • 第二步:a[0] 把灯送回起点,耗时 a[0];
  • 第三步:a[0] 带着当前次慢的 a[n-2] 过河,耗时 a[n-2];
  • 第四步:a[0] 再把灯送回起点,耗时 a[0]。

这一轮结束之后,最慢的两个人都到了对岸,a[0] 还在起点,剩余人数减少 2。这一轮的代价是:

tA = 2 * a[0] + a[n-1] + a[n-2]

以 [1, 2, 5, 10] 为例,tA = 2 * 1 + 10 + 5 = 17。注意这 17 只是“送走 5 和 10”这一轮的代价,还没算最后剩下的 1 和 2 过河的时间。

策略 A 的适用场景是两个最慢的人耗时差距很大、或者第二快的人速度也不快的时候。比如一组数据是 [1, 9, 10, 11],tA = 2 + 11 + 10 = 23,这个代价还算合理。

2.2 策略B:双快车手接力

策略 B 就是前面例子里的巧妙方案:让最快的两个人先过去,把灯送回来一个,然后让最慢的两个人一起过去,再由另一个较快的人把灯送回来。具体流程:

  • 第一步:a[0] 和 a[1] 先过河,耗时 a[1];
  • 第二步:a[0] 把灯送回起点,耗时 a[0];
  • 第三步:a[n-1] 和 a[n-2] 一起过河,耗时 a[n-1];
  • 第四步:a[1] 把灯送回起点,耗时 a[1]。

这一轮结束之后,最慢的两个人都到了对岸,a[0] 和 a[1] 也回到了起点,剩余人数同样减少 2。这一轮的代价是:

tB = a[0] + 2 * a[1] + a[n-1]

还是以 [1, 2, 5, 10] 为例,tB = 1 + 2 * 2 + 10 = 15。加上最后 1 和 2 过河的 2 分钟,总耗时 17。

策略 B 的核心思想是:与其让最快的人陪着最慢的人慢吞吞过两次河,不如让最快的两个人配合,用“两个较快的人分别跑一次回程”来替代“最快的人跑两次回程”。代价是 a[1] 多跑了一趟,但省下了 a[0] 陪最慢者过河的那一趟。

2.3 每轮决策:取两者较小值

每轮到底选 A 还是选 B,不是凭感觉,而是直接比较两个公式的大小,取较小值:

tA = 2 * a[0] + a[n-1] + a[n-2]

tB = a[0] + 2 * a[1] + a[n-1]

写成代码就是:

ans += min(2 * a[0] + a[n-1] + a[n-2], a[0] + 2 * a[1] + a[n-1]);

两个公式做差,可以得到一个更有指导意义的判断式:

tA - tB = a[0] + a[n-2] - 2 * a[1]

也就是说,当 a[0] + a[n-2] > 2 * a[1] 时,策略 B 更优;否则策略 A 更优。这个差值很直观:如果第二快的人足够快,策略 B 的“接力跑”优势就大;如果次慢的人和第二快的人速度差不多,那么让最快的人来回送反而更省。实际写代码时不需要自己判断,直接取 min 就行,但理解这个差值能帮你判断一组数据应该走哪种方案。

策略流程概要本轮代价
A最快者分别护送两个最慢者,并两次返回2 * a[0] + a[n-1] + a[n-2]
B最快两人先过,一人返回,最慢两人过,另一人返回a[0] + 2 * a[1] + a[n-1]

2.4 贪心正确性直观说明

有人会问:每轮都取这两种策略的较小值,为什么一定能得到全局最优?这一步需要稍微想一下。

当前最慢的 a[n-1] 和次慢的 a[n-2] 无论如何都要过河。他们过河那一趟的耗时下限是 a[n-1],这是省不掉的。唯一能优化的,就是在这两人过河前后,灯怎么送回起点。灯要回起点,必须要有一个已经在对岸的人再划回来。那么,在“送走这两个最慢的人”这个局部目标下,合理的送灯模式其实只有两种:要么最快的人来回跑,要么最快的两个人配合跑。任何更绕的方案,都可以化简成这两种之一,而且不会更优。

另外,每一轮送走的是当前最慢的两个人,这个过程和之前已经送走的人无关,也不会影响剩余人的相对顺序。所以每一轮的局部最优代价可以直接累加,不需要回头调整。这就是贪心策略能成立的根本原因。

3. 完整代码实现与边界处理

3.1 排序与主流程

实现思路很简单:

  • 读入所有人的过河时间,排序;
  • 用一个变量 n 表示当前还没过河的人数;
  • 当 n 大于 3 时,进入循环,每轮把答案加上 min(tA, tB),然后 n 减 2;
  • 循环结束后,根据剩余人数 1、2、3 分别处理。

这里有一个关键点:为什么循环条件是 n > 3,而不是 n > 2?因为当剩余 3 个人时,不能用“送走两个最慢的人”的策略来套。比如 [1, 2, 5],如果按策略 A 算,tA = 2 * 1 + 5 + 2 = 9,然后剩 1 个人再加 1,总共 10;但实际最优是 8。剩余 3 个人时,最优方案是 a[0] + a[1] + a[2],比如 1 和 2 先过,1 回来,1 和 5 再过,耗时 2 + 1 + 5 = 8。所以必须单独处理。

3.2 C++ AC代码

下面贴一份完整的 C++ 实现,带注释,可以直接用:

#include <bits/stdc++.h> using namespace std; int main() { int T; cin >> T; while (T--) { int n; cin >> n; vector<int> a(n); for (int i = 0; i < n; i++) { cin >> a[i]; } sort(a.begin(), a.end()); int ans = 0; while (n > 3) { // 策略A:最快者护送两个最慢者 int tA = 2 * a[0] + a[n - 1] + a[n - 2]; // 策略B:最快两人配合接力 int tB = a[0] + 2 * a[1] + a[n - 1]; ans += min(tA, tB); n -= 2; } if (n == 3) { ans += a[0] + a[1] + a[2]; } else if (n == 2) { ans += a[1]; } else { ans += a[0]; } cout << ans << endl; } return 0; }

注意这里n是动态变化的,a[n-1]和a[n-2]始终表示当前剩余未过河的人里最慢的两个。由于数组已经排序,即使 n 变小,前几个元素仍然是剩余的人里最快的几个。

3.3 其他语言要点

如果你用 Java,核心逻辑一样,只是排序用Arrays.sort(a),读入用Scanner。用 Python 的话,排序用sorted(a),代码可以写得很短:

t = int(input()) for _ in range(t): n = int(input()) a = sorted(map(int, input().split())) ans = 0 while n > 3: ans += min(2 * a[0] + a[n - 1] + a[n - 2], a[0] + 2 * a[1] + a[n - 1]) n -= 2 if n == 3: ans += a[0] + a[1] + a[2] elif n == 2: ans += a[1] else: ans += a[0] print(ans)

Python 写这类题很舒服,唯一要注意的是多组数据输入时,每组的 n 和数组要读对,别因为换行问题出错。

3.4 边界条件:n=1、2、3

这里把边界情况单独列出来,因为很多人在这里翻车。

  • n = 1:只有一个人,他直接划船过去就行,答案是 a[0];
  • n = 2:两个人一起过河,耗时是两人中较慢的那个,也就是 a[1];
  • n = 3:三个人没有简单的一轮送走方案,最优是 a[0] + a[1] + a[2]。可以理解为:a[0] 和 a[1] 先过,a[0] 回来,然后 a[0] 和 a[2] 再过。这个方案也等价于 a[0] 和 a[2] 先过,a[0] 回来,a[0] 和 a[1] 再过,结果一样。

如果你在循环里把 n > 3 写成了 n >= 3,n = 3 时就会进入循环,然后把 3 个人拆成“2 + 1”处理,结果就会偏大。这个细节值得记下来。

4. 样例推演与复杂度分析

4.1 经典样例手算

用经典的 [1, 2, 5, 10] 完整走一遍:

初始数组 [1, 2, 5, 10],n = 4,进入循环:

  • tA = 2 * 1 + 10 + 5 = 17;
  • tB = 1 + 2 * 2 + 10 = 15;
  • 取较小的 15,ans = 15,n 变为 2。

循环结束,n = 2,ans += a[1] = 2,最终 ans = 17。

再看对应的实际过河方案:1 和 2 先过;1 回来;5 和 10 过;2 回来;1 和 2 过。总耗时 2 + 1 + 10 + 2 + 2 = 17。和代码输出完全一致。

4.2 几组小数据验证

为了加深理解,再手算几组数据:

输入数据第一轮选择计算过程最终结果
[1, 2, 5, 10]B15 + 217
[1, 9, 10, 11]A23 + 932
[1, 2, 3, 4, 5]B10 + 616
[3, 4, 5, 6]A 或 B 对比min(17, 18) + 421

第二组 [1, 9, 10, 11] 比较有意思:tB = 1 + 2 * 9 + 11 = 30,tA = 2 + 11 + 10 = 23,所以选 A。实际方案:1 带 11 过,1 回,1 带 10 过,1 回,最后 1 和 9 过,总耗时 11 + 1 + 10 + 1 + 9 = 32。如果强行用 B,会变成 9 + 1 + 11 + 9 + 9 = 39,明显更差。这就说明不是所有情况都适合策略 B。

第三组 [1, 2, 3, 4, 5] 验证一下:第一次循环,tA = 2 * 1 + 5 + 4 = 11,tB = 1 + 2 * 2 + 5 = 10,取 10;剩余 [1, 2, 3],加 6,总 16。手动模拟可以验证这个结果,说明代码逻辑对“每轮取 min”的依赖是稳定的。

4.3 时间复杂度与数据范围

排序是 O(n log n),贪心循环每一轮 O(1),总共最多执行 n/2 轮,所以整体复杂度就是 O(n log n)。对于 n 最大 1000 甚至更大的数据量,这个复杂度毫无压力。空间上只需要一个数组,O(n) 的存储。

这道题的核心不在于性能,而在于策略选择,所以时间复杂度和空间复杂度都不是瓶颈,真正要理解的是那两行 min 的决策逻辑。

5. 常见问题与避坑记录

5.1 只套用一种策略导致错误

最常见的错误是只使用策略 A,也就是让最快的人来回接送所有人。这种思路在次慢者和第二快者差距大的时候是对的,但遇到 [1, 2, 5, 10] 这样的数据就会差 2 分钟。反过来,如果只使用策略 B,遇到 [1, 9, 10, 11] 这样的数据也会差很多。所以 min 不是可选项,是必须项。

我见过有人把题目改成“每次选最快的和最慢的一起过”,这其实是另一种错误变体。这种方案会让最慢的人拖累最快的人很多次,结果往往比两种标准策略都差。简单说:最慢的人适合和最慢的人“打包”,而不是永远被最快的人护送。

5.2 排序方向与下标错位

排序方向搞反是另一个常见问题。如果你按降序排列,a[0] 变成最慢的人,那么所有公式全部失效。建议从一开始就按升序排序,让 a[0] 是最快的,a[n-1] 是最慢的,并且记住在循环里动态更新 n,保证 a[n-1] 和 a[n-2] 始终指向当前剩余最慢的两个人。

还有一个小细节:如果你用 vector 存数据,在 n 减到 2 或 3 之后,不要再访问 a[n] 之类的越界位置。有些人复制粘贴代码时 n 和原始数组大小混淆,下标越界报错或者答案错乱,就是这个原因。

5.3 循环边界写错

循环条件必须是 n > 3。写成分号写成 n >= 3 或者 n >= 4 都会出错。n >= 3 会导致剩余 3 人时也按“送走两人”的公式处理,结果偏大;n >= 4 会导致剩余 4 人时提前退出循环,结果也偏小。这里没有商量的余地,就是 n > 3。

5.4 多组测试数据的输入输出问题

POJ 1700 这类题通常是多组数据,第一行是测试组数 T。每组数据内部先读 n,再读 n 个数。有人会在 while 循环里忘记更新 n,或者复用同一个 vector 不清空,导致下一组数据混入上一组的内容。建议每组数据都重新定义一个 vector,或者显式 clear() 一次。输出时每组一行,别把答案累加到上一组。

问题原因解决办法
答案偏大只用了策略 A 或 B 中的一种每轮取 min 两种策略
答案偏小循环边界写成 n >= 4改成 n > 3
越界或答案错乱排序方向搞反或动态 n 使用错误升序排序,注意下标
下一组数据受影响vector 未清空或 n 未重置每组重新初始化

6. 同源变体与延伸思考

6.1 桥与手电筒的变体

快速渡河问题其实有一个更广为人知的名字叫“桥与手电筒问题”,规则几乎一样:几个人在夜晚过桥,桥上每次最多过两个人,只有一把手电筒,过桥速度由慢者决定。很多算法教材里都用它来讲解贪心策略。

变体玩法有很多:如果每次最多能过 k 个人,策略会变得更复杂,可能需要分组讨论;如果每个人速度都一样,那答案就简单了,只需要计算必要的过河次数乘以单次耗时;如果允许某些人不带灯直接过去,规则就变了,贪心策略也要重新推导。理解了基本型的两种策略之后,遇到这些变体会更容易上手。

6.2 贪心与动态规划的联系

这道题还有一个有意思的地方:它也可以从动态规划的角度去理解。每一轮决策本质上是“处理掉两个最慢的人”,这个过程天然具有无后效性,所以 DP 也能求解。常见的做法是定义状态表示剩余人数,然后用递推方程在策略 A 和策略 B 之间取转移。不过对于这道题,贪心解法更简洁,代码也更短,这也是为什么它被归为贪心经典题。

如果你在学习算法时就遇到过类似题,你会发现这个“min 两种固定策略”的套路,其实在很多贪心题里都有出现。核心思想就是:把问题分解成若干轮,每一轮只有有限的几种最优模式,然后取最小值递推下去。

6.3 一点个人体会

这道题我前前后后写过很多遍,每次写都能发现一点新的理解。最开始我也只想到最快者护送,被样例卡住后才意识到策略 B 的存在;后来又花了很长时间才想明白,为什么取 min 就一定是全局最优。现在回头看,这题的精髓就在那个 min 上:它是一种“不把话说死”的贪心,两种策略并行比较,选更优的走。这也提醒我,遇到看似简单的贪心题,不要急着写循环,先把所有可能的“最优子结构”列出来,再决定每一步怎么走。刷题如此,实际做技术方案也一样。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 1:11:25

FastAdmin 实战避坑手册:权限、CRUD、API 接口高频问题全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:11:03

基于MP2642GR-Z与TM4C1294的被动均衡BMS设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:10:38

豆瓣Top250爬虫实战:从请求伪装到数据落库的完整工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:10:37

两串锂电池被动均衡方案:MP2642GR-Z与STM32L432KC实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:10:34

两串电芯被动均衡方案:MP2642GR-Z与MKV46F128VLH16协同设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 1:10:32

双节锂电主动均衡设计:MP2642GR-Z与STM32F732IE工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华