跳至主要內容

过河问题

周子力大约 8 分钟教学文档动态规划

题目描述

picture 0
picture 0

解题思路

贪心过桥问题分析

题目理解

  • N个人需要过桥,只有一只手电筒
  • 桥只能容纳两人同时过
  • 两人同行时,时间取较慢者的时间
  • 手电筒必须有人带回,才能继续送其他人过桥
  • 目标:最小化总过桥时间

贪心策略分析

问题特点

  • 每次过桥最多2人,但必须有人把手电筒带回来
  • 除了最后一次过桥,每次都需要有人返回
  • 关键是如何安排返回的人,使得总时间最少

核心观察

假设我们将所有人按过桥时间从小到大排序:a[0] ≤ a[1] ≤ ... ≤ a[n-1]

对于最慢的两个人(a[n-1]a[n-2]),有两种策略将他们送到对岸:

策略1:最快的带最慢的过去,最快的回来,最快的带次慢的过去,最快的回来

  • 时间:a[0] + a[n-1] + a[0] + a[n-2] = 2*a[0] + a[n-1] + a[n-2]

策略2:最快的和次快的过去,最快的回来,最慢的和次慢的过去,次快的回来

  • 时间:a[1] + a[0] + a[n-1] + a[1] = a[0] + 2*a[1] + a[n-1]

选择策略:每次选择两种策略中时间较少的那个

贪心算法步骤

  1. 将所有人按过桥时间升序排序
  2. 当剩余人数 > 3时,重复应用上述策略处理最慢的两人
  3. 处理剩余的特殊情况:
    • 剩1人:直接过桥,时间 = a[0]
    • 剩2人:一起过桥,时间 = a[1]
    • 剩3人:最快带最慢过去,最快回来,最快带次慢过去,时间 = a[0] + a[1] + a[2]

为什么贪心正确?

  • 每次我们都将最慢的两个人送到对岸,这是必须完成的任务
  • 在完成这个任务的前提下,我们选择代价最小的方式
  • 由于最慢的人必须过桥,让他们尽早过桥不会影响后续决策
  • 贪心选择具有最优子结构:处理完最慢两人后,剩余问题规模减小但性质相同

算法复杂度

  • 时间复杂度:O(n log n) - 主要是排序的时间
  • 空间复杂度:O(1) - 只使用常数额外空间

Python代码实现

def min_crossing_time(n, times):
    """
    计算N个人过桥的最短时间
    
    Args:
        n: int - 人数
        times: List[int] - 每个人的过桥时间
    
    Returns:
        int - 最短总时间
    """
    if n == 1:
        return times[0]
    elif n == 2:
        return max(times[0], times[1])
    elif n == 3:
        return times[0] + times[1] + times[2]
    
    # 排序,确保 times[0] <= times[1] <= ... <= times[n-1]
    times.sort()
    
    total_time = 0
    remaining = n
    
    # 处理人数大于3的情况
    while remaining > 3:
        # 策略1: 最快的带最慢的,最快的回来,最快的带次慢的,最快的回来
        strategy1 = 2 * times[0] + times[remaining - 1] + times[remaining - 2]
        
        # 策略2: 最快和次快过去,最快回来,最慢和次慢过去,次快回来
        strategy2 = times[0] + 2 * times[1] + times[remaining - 1]
        
        # 选择时间较少的策略
        total_time += min(strategy1, strategy2)
        remaining -= 2  # 最慢的两个人已经过桥了
    
    # 处理剩余的特殊情况
    if remaining == 3:
        total_time += times[0] + times[1] + times[2]
    elif remaining == 2:
        total_time += times[1]  # 两人一起过
    else:  # remaining == 1
        total_time += times[0]
    
    return total_time

# 主程序
if __name__ == "__main__":
    n = int(input().strip())
    times = list(map(int, input().strip().split()))
    
    result = min_crossing_time(n, times)
    print(result)

算法执行过程示例

以输入 [1, 2, 5, 10] 为例:

  1. 排序后:[1, 2, 5, 10]
  2. 剩余4人 > 3,计算两种策略:
    • 策略1:2*1 + 10 + 5 = 17
    • 策略2:1 + 2*2 + 10 = 15
    • 选择策略2(15)
  3. 执行策略2的具体步骤:
    • 1和2过去(时间2)
    • 1回来(时间1)
    • 5和10过去(时间10)
    • 2回来(时间2)
    • 累计时间:2+1+10+2 = 15
  4. 剩余2人(1和2),一起过去(时间2)
  5. 总时间:15 + 2 = 17

注意:这里有个细节,我们的算法计算的是将最慢两人送过去的代价,但实际上策略2的完整执行还包括最后两人过桥的时间。

让我重新验证一下:

实际上对于4个人的情况,最优方案是:

  1. 1和2过去 → 时间2
  2. 1回来 → 时间1
  3. 5和10过去 → 时间10
  4. 2回来 → 时间2
  5. 1和2过去 → 时间2 总时间:2+1+10+2+2 = 17

而我们的算法中,当remaining=4时选择策略2(代价15),然后remaining=2时加上times[1]=2,总共17,是正确的。

这个贪心算法通过每次最优地处理最慢的两个人,确保了全局最优解。

注意事项与题目启发

需要注意的问题

1. 边界条件的完整性

if n == 1:
    return times[0]
elif n == 2:
    return max(times[0], times[1])
elif n == 3:
    return times[0] + times[1] + times[2]
  • n=1:只有一个人,直接过桥
  • n=2:两人一起过,取较慢者时间
  • n=3:必须让最快的来回接送,固定模式
  • 容易遗漏n=3的特殊情况,错误地套用通用策略

2. 策略计算的准确性

  • 策略1公式2*a[0] + a[n-1] + a[n-2]
    • 对应:快带慢→快回→快带次慢→快回
  • 策略2公式a[0] + 2*a[1] + a[n-1]
    • 对应:快和次快→快回→慢和次慢→次快回
  • 常见错误:混淆两个策略的公式,或者忘记最后还需要处理剩余人员

3. 排序的重要性

  • 必须先对时间数组排序,否则无法正确应用贪心策略
  • 排序后才能保证times[0]是最小值,times[1]是次小值

4. 循环终止条件

  • 循环条件是remaining > 3,不是remaining > 2
  • 当剩余3人时,需要特殊处理,不能继续应用通用策略
  • 错误的终止条件会导致无限循环或错误结果

5. 算法逻辑的理解误区

  • 不是简单的"总是用最快的人接送":有时用次快的人配合更优
  • 策略选择依赖具体数值:需要比较两种策略的实际代价
  • 最后一步的处理:循环结束后还要处理剩余的1-3人

6. 输入数据的假设

  • 题目保证时间都是正整数,但实际编码中应考虑输入验证
  • 人数范围1-1000,时间范围1-100,不会出现溢出问题

7. 测试用例覆盖

需要测试多种情况:

  • 极端情况:[1, 100][1, 2, 100, 100]
  • 相同时间:[5, 5, 5, 5]
  • 递增序列:[1, 2, 3, 4, 5]
  • 大差异:[1, 2, 50, 100]

题目的启发

1. 贪心算法的复杂性

  • 多策略选择:不是单一的贪心规则,而是比较多个策略的代价
  • 局部最优≠直观最优:最快的人不一定总是最优的"搬运工"
  • 数学分析的重要性:需要通过公式推导来比较不同策略

2. 问题分解的思维方式

  • 逆向思维:从最困难的部分(最慢的人)开始解决
  • 分治思想:将大问题分解为"处理最慢两人 + 剩余问题"
  • 模式识别:识别出重复的子问题结构

3. 算法设计的经典模式

这个问题展示了经典的"两难选择"模式:

  • 每次面临多个可行方案
  • 需要通过数学比较选择最优
  • 选择后问题规模减小,但性质不变

4. 实际应用价值

  • 资源调度:类似的问题出现在任务调度、资源分配中
  • 成本优化:在多个约束条件下寻找最小成本方案
  • 决策制定:现实中的很多决策都需要在不同策略间权衡

5. 编程思维的培养

  • 从具体到抽象:通过具体例子总结出通用规律
  • 边界思维:特别关注特殊情况的处理
  • 验证驱动:通过手动模拟验证算法正确性

6. 算法复杂度的权衡

  • 预处理代价:O(n log n)排序 vs O(n)处理
  • 空间效率:原地排序,空间复杂度O(1)
  • 时间效率:线性处理,整体效率很高

7. 扩展思考方向

这个问题可以引申出更多变种:

  • 多人手电筒:如果有k个手电筒怎么办?
  • 桥容量变化:桥能容纳k人同时过
  • 动态时间:过桥时间随次数变化
  • 返回约束:某些人不能单独返回

8. 面试价值

  • 经典程度:这是算法面试中的经典贪心问题
  • 考察维度
    • 逻辑分析能力
    • 数学建模能力
    • 边界处理能力
    • 代码实现能力
  • 延伸性强:可以自然过渡到其他贪心或动态规划问题

9. 学习方法论

  • 理解本质:不要死记硬背策略公式,要理解为什么这样选择
  • 手动验证:通过小例子手动模拟算法执行过程
  • 举一反三:将这种多策略比较的思路应用到其他问题

这个题目完美展示了贪心算法的精髓:在看似复杂的约束条件下,通过深入分析找到简洁而高效的解决方案。它告诉我们,最优策略往往不是最直观的那个,而是需要通过严谨的数学分析得出的。

上次编辑于:
贡献者: zilizhou