华为OD机试经典题解析:贪心算法在士兵过河问题中的C++实现
1. 项目概述:从一道题看算法思维与工程实践
最近在技术社区和求职圈里,华为OD(Outsourcing Development)的机试题目热度一直不减,尤其是那些经典的算法问题。今天想和大家深入聊聊其中一道非常有意思的题目——“士兵过河”。这道题乍看之下像是一道简单的智力题,但深入分析后,你会发现它融合了 贪心策略、动态规划思想以及边界条件处理 ,是检验一个程序员基础算法能力和工程实现细节的绝佳试金石。很多朋友在初次接触时,可能会简单地套用“最快的人来回送手电筒”的思路,但实际题目往往有士兵数量、过河时间、承载人数等更多约束,稍有不慎就会掉进坑里。
对于正在准备华为OD机试,或者任何希望夯实C++算法功底的开发者来说,彻底吃透这道题的价值,远不止于通过一次考试。它训练的是一种 将模糊的自然语言描述,转化为精确的数学模型和计算机指令 的能力。接下来,我将结合自己多次调试和教学的经验,从问题本质、多种解法对比,到C++实现的每一个细节,包括如何组织代码、处理输入输出、进行有效测试,进行一次完整的拆解。无论你是算法新手,还是想寻找更优解法的老手,相信都能从中获得启发。
2. 问题本质与数学模型抽象
在动手写代码之前,我们必须像解数学题一样,先把题目翻译成我们熟悉的语言。这是避免后期反复修改逻辑的关键。
2.1 问题场景还原与核心约束
典型的“士兵过河”问题描述可能如下:一支N人的小队在夜间抵达河岸,需要过河。他们只有一盏灯(或一只小船),且桥(或船)每次最多承载2人。过河的速度由两人中较慢者的速度决定(即协同过河,快者需迁就慢者)。每个人单独过河的时间是已知的。目标是找到让所有人过河所需的最短总时间。
我们需要从中提炼出几个 不可变的硬性约束 :
- 承载限制 :每次最多2人过河。
- 速度决定 :两人过河时间 =
max(甲时间, 乙时间)。 - 必须有灯 :灯(或船)必须有人带过去,才能接下一批人。这意味着除了最后一趟,其他每次过河后,都需要有人把灯送回来。
- 初始状态 :所有人都在起点岸,灯在起点岸。
- 目标状态 :所有人都在对岸,灯可以在任意岸(通常题目不关心终点灯的位置)。
2.2 两种核心过河策略的博弈
理解了约束,我们来看具体如何移动人员。假设我们将过河时间从小到大排序,最快的两个人记为A和B(A最快),最慢的两个人记为Y和Z(Z最慢)。那么,将最慢的两个人送过河,通常有两种策略:
策略一:最快者搬运模式
- A和Z过河,耗时 = Z的时间。
- A返回,耗时 = A的时间。
- A和Y过河,耗时 = Y的时间。
- A返回,耗时 = A的时间。 此时,最慢的两人Y和Z已过河,A回到起点。总耗时 =
Z + A + Y + A = 2*A + Y + Z。
策略二:最快两人协作模式
- A和B过河,耗时 = B的时间(因为B比A慢)。
- A返回,耗时 = A的时间。
- Y和Z过河,耗时 = Z的时间。
- 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时,我们循环处理:- 计算策略一耗时
plan1 = times[left]*2 + times[right] + times[right-1]。 - 计算策略二耗时
plan2 = times[left] + times[left+1]*2 + times[right]。 - 选择耗时较小的计划,累加到总时间
total_time中。 - 根据选择,
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;
}
逐行精讲与避坑指南 :
- 排序是前提 :
sort(times.begin(), times.end());这行代码至关重要。我们所有的策略比较(A, B, Y, Z)都依赖于数组是有序的。忘记排序是新手最常见的错误之一,会导致后续索引取到的不是最快或最慢的人。 - 循环条件
while (right > 2):为什么是>2?因为当right等于2时,表示剩余的人索引是0,1,2,共3人。我们需要跳出循环,用专门的逻辑处理3人情况。当right等于1时,表示剩余0和1,共2人。当right等于0时,表示只剩1人。这个边界条件要仔细推敲。 - 变量命名清晰 :使用
a, b, y, z而不是times[0], times[1]...在计算策略时,让公式一目了然,减少出错概率。 - 三人情况处理 :剩余三人时,方案是固定的。可以记住这个结论:
总时间 = 最快 + 次快 + 最慢。推导过程:最快和最慢过,最快回,然后最快和次快过。即C + A + B。 -
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)的过河方式,必然符合以下之一:
- 他们一起过河(在某一次中作为同伴)。
- 他们分别和最快的人(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或其他公司的笔试中,很可能不是以纯算法题的形式出现,而是嵌入一个更大的业务场景里。比如,“多个任务在不同服务器上的执行时间,每次只能同步两个任务,求最短总同步时间”。但只要你识别出它内核是“士兵过河”模型,问题就迎刃而解了。所以,平时多积累这类经典模型,并理解其本质,比盲目刷题更重要。
更多推荐


所有评论(0)