1. 排队接水问题与贪心算法实战
排队接水问题是信息学竞赛中的经典贪心算法例题,也是GESP C++四级/五级考试的常见题型。题目描述为:有n个人在一个水龙头前排队接水,每个人接水所需的时间为ti。如何安排他们的接水顺序,才能使所有人的平均等待时间最小?
这个问题看似简单,却蕴含着深刻的算法思想。我第一次在洛谷P1223遇到这道题时,就被它简洁外表下的巧妙解法所吸引。通过这个问题,我们可以深入理解贪心算法的核心思想——局部最优导致全局最优。
2. 问题分析与算法选择
2.1 问题建模与数学分析
假设有n个人,编号为1到n,第i个人接水需要时间ti。如果他们按某种顺序排列,那么第k个接水的人的等待时间就是前k-1个人接水时间的总和。所有人的总等待时间就是:
总等待时间 = Σ(第k个人的等待时间) = Σ(Σti, i=1到k-1), k=1到n
我们的目标是找到一个排列顺序,使得这个总等待时间最小。
通过数学推导可以发现,当我们将接水时间短的人安排在前面时,能够最小化总等待时间。这是因为短任务优先执行可以减少后面所有人的等待时间。
2.2 贪心算法证明
为什么按照接水时间从短到长排序就是最优解?这需要严格的数学证明:
假设存在一个最优解,其中存在两个相邻的人i和j,且ti > tj。我们可以交换i和j的位置,计算交换前后的总等待时间变化:
交换前:... + A + ti + tj + ...
交换后:... + A + tj + ti + ...
其中A是i和j之前所有人的接水时间总和。交换后,总等待时间的变化为:(A + tj) - (A + ti) = tj - ti < 0(因为ti > tj)
这意味着交换后总等待时间减少了,与"最优解"假设矛盾。因此,最优解中不可能存在接水时间长的人排在接水时间短的人前面的情况。
3. C++实现详解
3.1 数据结构设计
我们需要存储每个人的编号和接水时间,并按照接水时间排序。在C++中,我们可以使用结构体数组或vector来存储这些信息:
cpp复制struct Person {
int id; // 人员编号
int time; // 接水时间
};
bool compare(const Person &a, const Person &b) {
return a.time < b.time; // 按接水时间升序排序
}
3.2 完整代码实现
下面是完整的C++实现代码,包含输入、处理和输出:
cpp复制#include <iostream>
#include <vector>
#include <algorithm>
#include <iomanip>
using namespace std;
struct Person {
int id;
int time;
};
bool compare(const Person &a, const Person &b) {
return a.time < b.time;
}
int main() {
int n;
cin >> n;
vector<Person> people(n);
for (int i = 0; i < n; ++i) {
people[i].id = i + 1;
cin >> people[i].time;
}
sort(people.begin(), people.end(), compare);
double total_wait = 0;
double current_wait = 0;
for (int i = 0; i < n; ++i) {
if (i > 0) {
cout << " ";
}
cout << people[i].id;
if (i > 0) {
current_wait += people[i-1].time;
}
total_wait += current_wait;
}
cout << endl;
double average_wait = total_wait / n;
cout << fixed << setprecision(2) << average_wait << endl;
return 0;
}
3.3 代码关键点解析
- 输入处理:使用vector存储人员信息,包括编号和接水时间。
- 排序:使用STL的sort函数,配合自定义的比较函数compare。
- 等待时间计算:current_wait记录当前人的等待时间,total_wait累计总等待时间。
- 输出格式:使用fixed和setprecision(2)控制平均等待时间输出为两位小数。
4. 算法优化与变种
4.1 时间复杂度分析
该算法的主要时间消耗在排序步骤。使用STL的sort函数,时间复杂度为O(n log n),对于n≤1000的常规题目规模完全足够。
4.2 空间复杂度优化
如果不需要保留原始编号,可以只存储接水时间,排序后再输出索引,这样可以节省一些空间:
cpp复制vector<int> times(n);
for (int i = 0; i < n; ++i) {
cin >> times[i];
}
vector<int> indices(n);
for (int i = 0; i < n; ++i) {
indices[i] = i + 1;
}
sort(indices.begin(), indices.end(), [&](int a, int b) {
return times[a-1] < times[b-1];
});
4.3 问题变种与扩展
- 多水龙头情况:如果有k个水龙头,问题就变成了调度问题,可以使用优先队列(堆)来解决。
- 带优先级排队:某些人可能有更高的优先级,这时需要综合考虑优先级和接水时间。
- 动态加入人员:人员在排队过程中可能不断加入,需要设计在线算法。
5. 常见错误与调试技巧
5.1 典型错误案例
- 排序方向错误:将compare函数写成
return a.time > b.time,导致长任务先执行。 - 等待时间计算错误:忘记累加前人的接水时间,导致等待时间计算不准确。
- 输出格式错误:平均时间没有保留两位小数,或者编号输出时有多余空格。
5.2 调试建议
- 小规模测试:先用n=2或n=3的简单案例验证算法正确性。
- 打印中间结果:在排序后打印每个人的接水时间,确认排序正确。
- 边界测试:测试n=1和所有人接水时间相同的情况。
提示:在竞赛编程中,总是要注意输出格式的精确要求,比如空格、换行和小数位数。这些细节往往决定了一个解法能否通过所有测试用例。
6. GESP考试准备建议
6.1 贪心算法考点梳理
GESP四级/五级考试中,贪心算法是重要考点。除了排队接水问题,还应掌握:
- 区间调度问题(最多不相交区间)
- 部分背包问题
- 霍夫曼编码
- 最小生成树(Prim/Kruskal算法)
6.2 洛谷刷题路线
建议按照以下顺序在洛谷上练习贪心算法题目:
- P1223 排队接水(入门)
- P1803 凌乱的yyy / 线段覆盖(区间调度)
- P1090 合并果子(优先队列)
- P3817 小A的糖果(稍难的综合题)
6.3 代码编写规范
在GESP考试中,除了算法正确性,代码规范也很重要:
- 使用有意义的变量名(如total_wait而非tw)
- 适当添加注释,特别是算法关键步骤
- 处理好输入输出格式
- 考虑边界情况(如n=0或n=1)
7. 实际应用与扩展思考
贪心算法在现实生活中有着广泛的应用,排队接水问题可以延伸到:
- 操作系统调度:短作业优先(SJF)调度算法
- 磁盘I/O调度:最短寻道时间优先(SSTF)算法
- 生产排程:最小化总完成时间
- 服务行业:银行、医院等服务窗口的排队优化
理解这类问题的解法,不仅能帮助我们在编程竞赛中取得好成绩,更能培养我们解决实际问题的算法思维。在实际项目中,我们经常需要在各种约束条件下做出最优决策,贪心算法提供了一种简单而有效的思路。
