1. 项目概述:从一道题看算法思维与工程实践

最近在技术社区和求职圈里,华为OD(Outsourcing Development)的机试题目热度一直不减,尤其是那些经典的算法问题。今天想和大家深入聊聊其中一道非常有意思的题目——“士兵过河”。这道题乍看之下像是一道简单的智力题,但深入分析后,你会发现它融合了 贪心策略、动态规划思想以及边界条件处理 ,是检验一个程序员基础算法能力和工程实现细节的绝佳试金石。很多朋友在初次接触时,可能会简单地套用“最快的人来回送手电筒”的思路,但实际题目往往有士兵数量、过河时间、承载人数等更多约束,稍有不慎就会掉进坑里。

对于正在准备华为OD机试,或者任何希望夯实C++算法功底的开发者来说,彻底吃透这道题的价值,远不止于通过一次考试。它训练的是一种 将模糊的自然语言描述,转化为精确的数学模型和计算机指令 的能力。接下来,我将结合自己多次调试和教学的经验,从问题本质、多种解法对比,到C++实现的每一个细节,包括如何组织代码、处理输入输出、进行有效测试,进行一次完整的拆解。无论你是算法新手,还是想寻找更优解法的老手,相信都能从中获得启发。

2. 问题本质与数学模型抽象

在动手写代码之前,我们必须像解数学题一样,先把题目翻译成我们熟悉的语言。这是避免后期反复修改逻辑的关键。

2.1 问题场景还原与核心约束

典型的“士兵过河”问题描述可能如下:一支N人的小队在夜间抵达河岸,需要过河。他们只有一盏灯(或一只小船),且桥(或船)每次最多承载2人。过河的速度由两人中较慢者的速度决定(即协同过河,快者需迁就慢者)。每个人单独过河的时间是已知的。目标是找到让所有人过河所需的最短总时间。

我们需要从中提炼出几个 不可变的硬性约束

  1. 承载限制 :每次最多2人过河。
  2. 速度决定 :两人过河时间 = max(甲时间, 乙时间)
  3. 必须有灯 :灯(或船)必须有人带过去,才能接下一批人。这意味着除了最后一趟,其他每次过河后,都需要有人把灯送回来。
  4. 初始状态 :所有人都在起点岸,灯在起点岸。
  5. 目标状态 :所有人都在对岸,灯可以在任意岸(通常题目不关心终点灯的位置)。

2.2 两种核心过河策略的博弈

理解了约束,我们来看具体如何移动人员。假设我们将过河时间从小到大排序,最快的两个人记为A和B(A最快),最慢的两个人记为Y和Z(Z最慢)。那么,将最慢的两个人送过河,通常有两种策略:

策略一:最快者搬运模式

  1. A和Z过河,耗时 = Z的时间。
  2. A返回,耗时 = A的时间。
  3. A和Y过河,耗时 = Y的时间。
  4. A返回,耗时 = A的时间。 此时,最慢的两人Y和Z已过河,A回到起点。总耗时 = Z + A + Y + A = 2*A + Y + Z

策略二:最快两人协作模式

  1. A和B过河,耗时 = B的时间(因为B比A慢)。
  2. A返回,耗时 = A的时间。
  3. Y和Z过河,耗时 = Z的时间。
  4. B返回,耗时 = B的时间。 此时,最慢的两人Y和Z已过河,最快的两人A和B都回到了起点。总耗时 = B + A + Z + B = A + 2*B + Z

注意 :这里容易混淆的一点是,策略二第4步是B返回,而不是A。因为此时对岸有A、Y、Z,起点岸有B。要让B回来,才能让A和B继续服务后续的过河。如果让A回来,那么对岸剩下Y、Z和B,起点岸只有A,下次送人时,A还是得和一个慢的人过河,效率可能更低。

那么,在每一步决策中,我们如何选择?答案是比较两种策略的耗时:

  • 如果 2*A + Y + Z < A + 2*B + Z ,即 A + Y < 2*B ,则选择 策略一 (最快者搬运)。
  • 否则,选择 策略二 (最快两人协作)。

这个比较式的推导过程很重要,它直接决定了我们贪心选择的正确性。化简过程: 2*A + Y + Z ? A + 2*B + Z -> 两边同时减去 (A+Z) -> A + Y ? 2*B

2.3 从特例到通解:递归与迭代思想的建立

当人数N较多时(N>3),我们可以通过上述策略,每次解决“把当前最慢的两个人送过河”这个子问题。送完之后,问题规模N就减少了2(如果最后剩3人,则是一个需要特殊处理的终止条件)。这本质上是一种 递归或迭代 的思想。

我们用一个数组 times 存储每个人的过河时间,并已按升序排序。用两个“指针”或索引来标记当前尚未过河的最慢的人。

  • 初始时, left = 0 (最快), right = N-1 (最慢)。
  • right - left + 1 (即剩余人数)大于3时,我们循环处理:
    1. 计算策略一耗时 plan1 = times[left]*2 + times[right] + times[right-1]
    2. 计算策略二耗时 plan2 = times[left] + times[left+1]*2 + times[right]
    3. 选择耗时较小的计划,累加到总时间 total_time 中。
    4. 根据选择, right 指针减少2(因为送走了两个最慢的)。
  • 当剩余人数等于2或3时,跳出循环,处理最后的边界情况。

这个思考过程,就是将生活问题抽象为循环和条件判断的过程,是算法思维的核心。

3. C++题解实现与逐行精讲

理论清晰后,我们来看C++代码如何实现。我会提供一个清晰、健壮且易于理解的版本,并附上详细注释。

3.1 代码框架与输入处理

一个完整的机试题解,必须考虑输入格式。通常题目会先输入人数n,然后输入n个整数代表每个人的过河时间。

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    int n;
    cin >> n; // 读取士兵人数
    vector<int> times(n);
    for (int i = 0; i < n; ++i) {
        cin >> times[i]; // 读取每个人的过河时间
    }
    
    // 核心算法逻辑将在这里实现
    // ...
    
    cout << total_time << endl; // 输出最短总时间
    return 0;
}

注意事项

  • 使用 vector<int> 动态数组来存储时间,比原生数组更安全方便。
  • 务必对输入数据做合法性判断(虽然机试环境通常保证输入正确,但好习惯能避免实际开发中的崩溃)。例如,可以检查 n 是否大于0,时间是否为正数。

3.2 核心算法函数实现

我们将核心逻辑封装成一个函数,提高代码的可读性和可测试性。

int minCrossingTime(vector<int>& times) {
    int n = times.size();
    if (n == 0) return 0;
    if (n == 1) return times[0]; // 只有一个人,直接过
    
    // 关键步骤1:排序
    sort(times.begin(), times.end());
    
    int total_time = 0;
    int right = n - 1; // 指向当前最慢的人
    
    // 关键步骤2:循环处理,直到剩下不超过3人
    while (right > 2) { // 当最慢的人索引大于2,说明至少还有4个人
        int a = times[0]; // 最快
        int b = times[1]; // 次快
        int y = times[right - 1]; // 次慢
        int z = times[right]; // 最慢
        
        // 计算两种策略的耗时
        int plan1 = a * 2 + y + z; // 最快者搬运模式:A带Z,A回,A带Y,A回
        int plan2 = a + b * 2 + z; // 最快两人协作模式:A&B过,A回,Y&Z过,B回
        
        // 选择耗时更少的策略
        total_time += min(plan1, plan2);
        
        // 处理完两个最慢的,指针移动两位
        right -= 2;
    }
    
    // 关键步骤3:处理最后的边界情况(剩余2或3人)
    if (right == 1) {
        // 剩余两人:A和B,一起过河,时间由较慢的B决定
        total_time += times[1]; // 即 b
    } else if (right == 2) {
        // 剩余三人:A, B, C
        // 最优方案:A&C过(A回),A&B过
        // 耗时 = times[2] + times[0] + times[1]
        total_time += times[0] + times[1] + times[2];
        // 注意:也有方案是A&B过,A回,A&C过。耗时 = times[1] + times[0] + times[2]
        // 两者结果相同,因为加法满足交换律。
    }
    // 如果right==0,说明只剩最快的一人,但这种情况在n=1时已处理,理论上不会进入循环。
    
    return total_time;
}

逐行精讲与避坑指南

  1. 排序是前提 sort(times.begin(), times.end()); 这行代码至关重要。我们所有的策略比较( A, B, Y, Z )都依赖于数组是有序的。忘记排序是新手最常见的错误之一,会导致后续索引取到的不是最快或最慢的人。
  2. 循环条件 while (right > 2) :为什么是 >2 ?因为当 right 等于2时,表示剩余的人索引是0,1,2,共3人。我们需要跳出循环,用专门的逻辑处理3人情况。当 right 等于1时,表示剩余0和1,共2人。当 right 等于0时,表示只剩1人。这个边界条件要仔细推敲。
  3. 变量命名清晰 :使用 a, b, y, z 而不是 times[0], times[1]... 在计算策略时,让公式一目了然,减少出错概率。
  4. 三人情况处理 :剩余三人时,方案是固定的。可以记住这个结论: 总时间 = 最快 + 次快 + 最慢 。推导过程:最快和最慢过,最快回,然后最快和次快过。即 C + A + B
  5. total_time 的累加 :在循环中,每次累加的是 解决当前两个最慢的人 所花费的时间。这个时间包含了过河和送回灯的所有步骤。

3.3 主函数整合与测试

将核心函数整合到主函数中,并添加简单的测试逻辑。

int main() {
    int n;
    cin >> n;
    vector<int> times(n);
    for (int i = 0; i < n; ++i) {
        cin >> times[i];
    }
    
    int result = minCrossingTime(times);
    cout << result << endl;
    
    // 以下可用于本地测试多种案例
    // vector<int> test1 = {1, 2, 5, 10};
    // cout << minCrossingTime(test1) << endl; // 应输出 17
    // vector<int> test2 = {1, 2, 3, 4, 5};
    // cout << minCrossingTime(test2) << endl; // 应输出 16
    // vector<int> test3 = {5};
    // cout << minCrossingTime(test3) << endl; // 应输出 5
    
    return 0;
}

本地测试的重要性 :机试时,系统会提供多个测试用例。在本地编写代码时,一定要自己设计几个典型用例进行测试:

  • 用例1 {1, 2, 5, 10} 。这是最经典的例子,答案是17。过程:1和2过(2),1回(1),5和10过(10),2回(2),1和2过(2)。总时间=2+1+10+2+2=17。用我们的算法验证一下:排序后为[1,2,5,10]。第一次循环,a=1,b=2,y=5,z=10。plan1=1 2+5+10=17,plan2=1+2 2+10=15。选择plan2,累加15,right从3变为1。剩余两人[1,2],总时间加2,最终17。等等,结果是17,但我们算法第一次选了plan2(15),最后加了2,总共是17。等等,15+2=17,没错。但经典过程是plan1的模式?这里出现了分歧。让我们手动模拟一下plan2方案:1和2过(2),1回(1),5和10过(10),2回(2)。此时对岸有5和10,起点有1和2。还需要1和2再过一次(2)。总时间=2+1+10+2+2=17。和plan1结果一样。这说明对于[1,2,5,10],两种策略结果相同。我们的算法选择哪个都可以。这是一个很好的测试点,验证了算法的正确性。
  • 用例2 {1, 2, 3, 4, 5} 。预期结果?我们来算一下。排序后[1,2,3,4,5]。第一次处理最慢的4和5:a=1,b=2,y=4,z=5。plan1=1 2+4+5=11,plan2=1+2 2+5=10。选plan2,累加10,剩余[1,2,3]。处理3人:总时间加1+2+3=6。最终16。可以手动验证。
  • 边界用例 {5} (1人), {3, 7} (2人), {1, 10, 11} (3人)。确保你的函数都能返回正确结果。

4. 算法深入分析与变种探讨

掌握了基础解法,我们可以更进一步,探讨其算法分类、时间复杂度和可能的变种题目。

4.1 贪心算法的正确性证明

为什么每次选择两种策略中更优的,最终能得到全局最优解?这需要一点证明思路。我们可以考虑,在任何最优解中,最慢的两个人(Y和Z)的过河方式,必然符合以下之一:

  1. 他们一起过河(在某一次中作为同伴)。
  2. 他们分别和最快的人(A)过河。

并且,在它们过河之后,灯必须回到起点岸,这个“送回灯”的任务必然由A或B完成。通过比较这两种“模式”的成本(即我们之前计算的plan1和plan2),我们可以断言, 任何包含最慢两人的最优解,其在这两人过河这个片段上的耗时,至少是我们所选择的两种策略中较优的那种方案的耗时 。由于这个选择是局部最优的,并且问题具有无后效性(送走最慢两人后,剩余子问题形式相同),因此贪心选择可以导致全局最优。这是一种“领先板”或“配对”贪心的典型应用。

4.2 时间复杂度与空间复杂度分析

  • 时间复杂度 :主要消耗在排序 O(n log n) 和一次线性遍历 O(n) 。因此总时间复杂度为 O(n log n) ,对于机试常见的n(比如n<=1000)来说绰绰有余。
  • 空间复杂度 :我们只使用了输入数组和一些临时变量,因此是 O(1) 的额外空间(如果不算输入存储的话)。如果算上输入数组,则是 O(n)

这是一个非常高效的算法。如果题目人数上限很大(例如10^5),这个算法也完全能胜任。

4.3 常见变种与应对思路

机试题目可能会在基础版本上稍作变化,以增加难度。了解变种,能让你在考场上更从容。

变种1:船有载重限制,每个人有体重

描述:船每次最多载重W,每个人有过河时间t_i和体重w_i。每次过河的两人体重之和不能超过W。求最短总时间。

思路 :这变成了一个带有约束的优化问题。单纯的贪心可能失效。通常需要结合动态规划(DP)。定义状态 dp[i] 表示前i个人过河的最短时间,但状态转移需要考虑最后一批过河的人(1个或2个)且满足重量约束。这比原题复杂很多,需要仔细设计状态和转移方程。

变种2:灯(或船)的初始位置可能在对岸

描述:初始时,灯在对岸。需要有人先从对岸把灯划过来。

思路 :这相当于在初始状态增加了一个“送灯过来”的步骤。我们可以虚拟一个“时间为零”的送灯人吗?更直接的思路是,第一次过河必须是单人(从对岸带灯过来),其时间就是过来这个人的时间。之后的问题就退化成了标准的N+1人问题(多了一个刚从对岸过来的人)。处理好这个初始化步骤即可。

变种3:输出具体的过河方案步骤

描述:不仅要输出最短时间,还要输出每一步是谁过河,谁返回。

思路 :我们的算法计算出了时间,但丢失了具体的步骤序列。为了输出方案,我们需要在贪心选择的过程中,记录每一步的选择。例如,用一个 vector<pair<string, vector<int>>> 来记录。当选择plan1时,记录步骤:[A,Z]过, [A]回, [A,Y]过, [A]回。当选择plan2时,记录:[A,B]过, [A]回, [Y,Z]过, [B]回。最后在处理边界2人或3人时,也记录相应步骤。最后统一输出。这要求代码有更强的状态记录能力。

遇到变种题不要慌,核心仍然是分析约束,识别出它改变了基础问题的哪个假设,然后对症下药调整算法。

5. 实战调试与性能优化技巧

理论满分,代码一跑就错?这是算法学习中的常态。下面分享一些调试和确保代码鲁棒性的经验。

5.1 设计全面的测试用例

自己编写测试用例,是发现逻辑漏洞的最快方式。一个完整的测试集应该包括:

用例描述 输入数组 预期输出 测试目的
最小规模 [] 0 空数组处理
[5] 5 单人手
[3, 7] 7 两人情况
[1, 10, 11] 22 (1+10+11) 三人情况
经典案例 [1, 2, 5, 10] 17 验证策略选择
递增序列 [1, 2, 3, 4, 5] 16 多人数验证
全相同 [5, 5, 5, 5] 5+5+5+5? 我们来算:排序[5,5,5,5]。第一次送最慢两个:a=5,b=5,y=5,z=5。plan1=5 2+5+5=20, plan2=5+5 2+5=20。选哪个都一样,加20。剩两人[5,5],加5。总时间25。也可以手动模拟:5和5过(5),5回(5),5和5过(5),5回(5),最后两个5过(5)。总时间5+5+5+5+5=25。 验证无差异情况
最大最慢 [1, 100, 100, 100] 1+100+100+1+100? 排序[1,100,100,100]。送最慢两个100和100:a=1,b=100,y=100,z=100。plan1=1 2+100+100=202, plan2=1+100 2+100=301。选plan1,加202。剩余[1,100],加100。总时间302。方案:1和100过(100),1回(1),1和100过(100),1回(1),1和最后100过(100)。100+1+100+1+100=302。 验证策略一明显优的情况
随机大数 生成100个随机数(1~1000) 用暴力搜索或已知正确代码验证 压力测试与正确性验证

在本地编写一个 test() 函数,自动运行这些用例并对比结果,能极大提升调试效率。

5.2 调试技巧:打印中间状态

当结果不对时,不要干瞪眼。在循环中添加打印语句,观察每一步的计算和决策。

while (right > 2) {
    int a = times[0];
    int b = times[1];
    int y = times[right - 1];
    int z = times[right];
    int plan1 = a * 2 + y + z;
    int plan2 = a + b * 2 + z;
    
    // 调试打印
    cout << “处理索引 “ << right-1 << “,“ << right << “: (“ << y << “,“ << z << “)” << endl;
    cout << “ Plan1: “ << plan1 << “, Plan2: “ << plan2 << “, 选择: “ << (plan1 < plan2 ? “Plan1” : “Plan2”) << endl;
    
    total_time += min(plan1, plan2);
    right -= 2;
}

通过观察每次循环选择了哪个策略,以及累加的时间是否正确,可以快速定位是策略比较公式写错了,还是指针更新逻辑有误。

5.3 性能考量与代码简洁性

对于机试,通常不需要极致优化,但养成好习惯很重要。

  • 避免不必要的拷贝 minCrossingTime 函数参数使用 vector<int>& times (引用),而不是 vector<int> times (值拷贝),避免复制整个数组。
  • 使用标准库函数 sort , min 等STL函数比自己手写循环更简洁高效。
  • 警惕整数溢出 :虽然本题过河时间通常不会太大,但如果题目没说范围,且 n 很大,累加 total_time 时可以考虑使用 long long 类型。
  • 代码风格 :清晰的变量名、适当的空行、注释,这些都能让代码在紧张的上机环境中更易检查和调试。

6. 从解题到思维:举一反三的能力培养

解决一道题,收获不应该只是一段AC(Accepted)的代码。更重要的是,通过这道题,我们巩固了哪些可迁移的思维模式?

1. 分解与降维思想 :“士兵过河”问题通过“每次解决两个最慢的人”,将一个大问题分解成一系列结构相同的子问题。这是解决复杂问题的通用法门:找到那个可以重复进行的“最优操作单元”。在很多动态规划和贪心问题中,如“区间调度”、“哈夫曼编码”,都有类似的思想。

2. 贪心选择的证明思路 :我们通过比较两种局部策略来决定当前步骤。如何证明这种局部最优能导致全局最优?通常有两种方式:a) 交换论证法:假设有一个最优解与我们的贪心解在第一步选择不同,证明交换后不会更差。b) 领先板法:证明我们的贪心选择在任何最优解中都可以被替换而不增加成本。有意识地去思考并尝试证明贪心策略,能极大加深对问题的理解。

3. 边界条件的敏感性 :本题中,剩余3人和2人的处理是独立的边界情况。在算法实现中, while (right > 2) 这个循环条件,以及循环结束后对 right == 1 right == 2 的处理,是代码正确与否的关键。几乎所有的算法题都有类似的边界(空输入、单个元素、两个元素、已排序、逆序等)。养成系统考虑边界条件的习惯,能避免大量的“WA”(Wrong Answer)。

4. 从具体到抽象的建模能力 :题目描述是一个情景故事。我们的第一步是将其抽象为:一个有序数组,每次可移动1或2个元素从集合A到集合B,代价是移动元素的最大值,且每次移动后需要有一个最小元素返回… 这种剥离具体情境、抓住核心数据结构和操作规则的能力,是解决所有算法问题的基本功。

最后,这道题在华为OD或其他公司的笔试中,很可能不是以纯算法题的形式出现,而是嵌入一个更大的业务场景里。比如,“多个任务在不同服务器上的执行时间,每次只能同步两个任务,求最短总同步时间”。但只要你识别出它内核是“士兵过河”模型,问题就迎刃而解了。所以,平时多积累这类经典模型,并理解其本质,比盲目刷题更重要。

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐