贪心算法实战:如何用C++解决洛谷P1223排队接水问题(附完整代码)

在算法竞赛和编程学习中,贪心算法因其直观性和高效性而备受青睐。排队接水问题作为经典的贪心算法应用场景,不仅能帮助我们理解贪心选择性质,还能锻炼实际问题建模能力。本文将深入探讨如何用C++高效解决洛谷P1223排队接水问题,从算法原理到代码实现,再到常见陷阱分析,为算法学习者提供全方位指导。

1. 问题分析与贪心策略构建

排队接水问题的核心是安排n个人的接水顺序,使得所有人的平均等待时间最小。乍看之下,这似乎需要尝试所有排列组合,但贪心算法可以提供更优解。

关键观察点:

  • 当接水时间短的人先接水时,后续等待的人数更多,但每人等待的时间更短
  • 接水时间长的人后接水时,影响的人数已经减少

通过数学归纳法可以证明:每次选择剩余人员中接水时间最短的人先接水,能够得到全局最优解。这种局部最优选择能导致全局最优的特性,正是贪心算法的精髓所在。

贪心选择性质的简单证明: 假设存在一个最优解,其中接水时间最短的人g不是第一个接水。那么将g与第一个接水的人交换位置,可以证明总等待时间会减少,这与"最优解"假设矛盾。因此,最优解必然包含贪心选择。

2. 数据结构设计与稳定排序

实现贪心策略需要合适的数据结构和排序方法。我们使用结构体存储每个人的编号和接水时间:

struct Person {
    double time;
    int index;
};

排序稳定性至关重要:题目要求接水时间相同时,保持输入顺序。因此必须使用稳定排序算法。C++中stable_sort正是为此设计:

bool compare(const Person &a, const Person &b) {
    return a.time < b.time; 
}

// 使用stable_sort保持相同元素的原始顺序
stable_sort(persons.begin(), persons.end(), compare);

为什么不用普通sort? 虽然在某些实现中sort可能保持稳定性,但这不是语言标准保证的行为。竞赛中必须使用stable_sort确保万无一失。

3. 两种等待时间计算方法对比

计算总等待时间有两种等效方法,各有优劣:

方法一:累积等待时间

double total_time = 0, current_wait = 0;
for(int i = 0; i < n; ++i) {
    total_time += current_wait;
    current_wait += persons[i].time;
}

特点:

  • 直观易理解
  • 需要额外变量记录当前累积时间
  • 时间复杂度O(n)

方法二:贡献度计算

double total_time = 0;
for(int i = 0; i < n; ++i) {
    total_time += persons[i].time * (n - i - 1);
}

特点:

  • 数学表达更简洁
  • 直接计算每个人对总时间的贡献
  • 同样时间复杂度O(n)

提示:竞赛中推荐使用方法二,代码更简洁且不易出错。实际项目中方法一可能更易维护。

4. 完整代码实现与解析

下面给出两种风格的完整解决方案,包含详细注释和IO处理:

版本一:面向竞赛的紧凑写法

#include <bits/stdc++.h>
using namespace std;

struct Person { double t; int id; };

int main() {
    int n; cin >> n;
    vector<Person> v(n);
    for(int i = 0; i < n; ++i) {
        cin >> v[i].t;
        v[i].id = i + 1;
    }
    
    stable_sort(v.begin(), v.end(), [](auto &a, auto &b) {
        return a.t < b.t;
    });
    
    double sum = 0;
    for(int i = 0; i < n; ++i) {
        cout << v[i].id << " ";
        sum += v[i].t * (n - i - 1);
    }
    
    cout << "\n" << fixed << setprecision(2) << sum / n;
    return 0;
}

版本二:工程风格的清晰实现

#include <iostream>
#include <vector>
#include <algorithm>
#include <iomanip>

using namespace std;

struct Person {
    double time;
    int original_index;
    
    // 重载小于运算符用于排序
    bool operator<(const Person &other) const {
        return time < other.time;
    }
};

void solve_water_filling(int n) {
    vector<Person> people(n);
    
    // 输入处理
    for(int i = 0; i < n; ++i) {
        cin >> people[i].time;
        people[i].original_index = i + 1;
    }
    
    // 稳定排序保持原始顺序
    stable_sort(people.begin(), people.end());
    
    // 输出排序结果并计算总等待时间
    double total_waiting_time = 0;
    for(int i = 0; i < n; ++i) {
        cout << people[i].original_index << " ";
        total_waiting_time += people[i].time * (n - i - 1);
    }
    
    // 输出平均等待时间,保留2位小数
    cout << "\n" << fixed << setprecision(2) 
         << total_waiting_time / n << "\n";
}

int main() {
    int n;
    cin >> n;
    solve_water_filling(n);
    return 0;
}

关键改进点:

  1. 使用vector替代原生数组,更安全
  2. 重载运算符使代码更易读
  3. 分离问题求解逻辑到独立函数
  4. 更详细的变量命名

5. 常见错误与调试技巧

即使理解了算法原理,实现时仍可能遇到各种问题。以下是典型错误案例:

错误1:忽略排序稳定性

// 错误代码:使用普通sort可能导致相同时间时顺序错误
sort(persons.begin(), persons.end(), compare);

错误2:等待时间计算错误

// 错误代码:错误计算了贡献人数
total_time += persons[i].time * (n - i);  // 应该是n-i-1

错误3:输出格式不符

// 错误代码:未设置fixed导致精度显示问题
cout << setprecision(2) << average;  // 需要添加fixed

调试建议:

  1. 对于小规模数据(n=3-5),手工计算验证
  2. 测试边界情况:所有人时间相同、时间递减排列等
  3. 使用assert检查中间结果,如:
    assert(is_sorted(v.begin(), v.end(), 
        [](auto &a, auto &b) { return a.t <= b.t; }));
    

6. 算法扩展与变种思考

掌握了基础解法后,可以思考问题的各种变种:

变种1:最小化最大等待时间

  • 目标函数变化:需要不同的贪心策略
  • 可能需要优先让长时间任务先执行

变种2:多水龙头情况

  • 问题转化为多机调度
  • 需要结合优先队列实现

变种3:带优先级排队

  • 增加优先级权重
  • 需要自定义更复杂的比较函数
// 多条件比较函数示例
bool compare_advanced(const Person &a, const Person &b) {
    if(a.time != b.time) return a.time < b.time;
    return a.priority > b.priority; 
}

7. 性能分析与优化

虽然O(nlogn)的排序步骤主导了时间复杂度,但对于极端大规模数据仍有优化空间:

优化1:输入输出加速

ios::sync_with_stdio(false);
cin.tie(nullptr);

优化2:避免浮点运算

  • 用整数计算最后再转换
  • 减少除法运算次数

优化3:特定数据分布优化

  • 对于小范围整数时间,可以使用计数排序
  • 对于几乎有序的数据,考虑适应性排序算法

实际测试表明,在n=1e5量级时,基础实现能在100ms内完成,完全满足竞赛需求。过度优化往往得不偿失,清晰正确的代码更重要。

更多推荐