从农场记账到代码实现:用C++手把手还原USACO月度开销问题的贪心+二分解法
从农场记账到代码实现:用C++手把手还原USACO月度开销问题的贪心+二分解法
约翰·史密斯是一位经验丰富的农场主,每天清晨他都会在厨房的旧木桌上摊开账本,记录当天的开支。从饲料采购到设备维修,每一笔开销都清晰记录。到了月底,约翰需要将这些日常开销划分到不同的"fajo月"中——这是他祖父留下的叫法,指的是财务周期。约翰的目标很简单:让开销最多的那个fajo月的金额尽可能少,这样资金周转会更灵活。
这个看似简单的农场记账问题,实际上隐藏着一个经典的算法挑战——最小化最大子段和问题。在信息学竞赛中,它频繁出现在USACO、NOI等赛事中,考察选手对贪心算法和二分查找的综合运用能力。本文将带你从农场账本出发,一步步用C++实现这个优雅的解法。
1. 问题建模与算法选择
想象约翰的账本上有N天的开销记录,需要划分为M个fajo月。每个fajo月包含连续几天的开销,我们需要找到一种划分方式,使得所有fajo月中最大的那个开销尽可能小。
这个问题可以抽象为:给定一个正整数序列,将其划分为M个连续子段,使得最大子段和最小。这种"最小化最大值"的问题特征,往往提示我们可以使用二分答案的策略。
为什么二分查找适用?
- 单调性:如果x能满足划分要求,那么所有大于x的值也都能满足
- 检查函数:对于给定的x,我们可以用贪心算法验证是否能将序列划分为不超过M段,且每段和≤x
2. 核心算法实现
2.1 贪心check函数设计
check函数是算法的核心,它决定给定的x是否可行。我们采用贪心策略:尽可能将更多元素放入当前段,直到加入下一个元素会超过x。
bool check(int x, const vector<int>& expenses, int m) {
int current_sum = 0;
int segments = 1; // 至少有一个段
for (int expense : expenses) {
if (expense > x) return false; // 单日开销已超过x,直接失败
if (current_sum + expense <= x) {
current_sum += expense; // 加入当前段
} else {
segments++; // 开启新段
current_sum = expense; // 新段的初始值
if (segments > m) return false; // 超过允许的段数
}
}
return true;
}
调试技巧:
- 边界情况测试:当M=1或M=N时的表现
- 极端值测试:包含极大单日开销的情况
- 打印中间变量:在复杂案例中输出current_sum和segments的变化
2.2 二分查找实现
二分查找部分需要确定搜索范围和终止条件。这里展示两种常见写法:
写法1:左闭右开区间
int binarySearch(const vector<int>& expenses, int m) {
int left = 0;
int right = accumulate(expenses.begin(), expenses.end(), 0);
while (left < right) {
int mid = left + (right - left) / 2;
if (check(mid, expenses, m)) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
写法2:左闭右闭区间
int binarySearch(const vector<int>& expenses, int m) {
int left = *max_element(expenses.begin(), expenses.end());
int right = accumulate(expenses.begin(), expenses.end(), 0);
while (left <= right) {
int mid = left + (right - left) / 2;
if (check(mid, expenses, m)) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return left;
}
两种写法的对比:
| 特性 | 写法1 | 写法2 |
|---|---|---|
| 初始left | 0 | 最大单日开销 |
| 初始right | 总和 | 总和 |
| 循环条件 | left < right | left <= right |
| 更新策略 | right = mid | right = mid - 1 |
| 适用场景 | 更通用 | 已知明确下界时更高效 |
3. 完整代码实现与优化
结合上述组件,我们构建完整解决方案:
#include <iostream>
#include <vector>
#include <numeric>
#include <algorithm>
using namespace std;
bool check(int x, const vector<int>& expenses, int m) {
int current_sum = 0;
int segments = 1;
for (int expense : expenses) {
if (expense > x) return false;
if (current_sum + expense <= x) {
current_sum += expense;
} else {
segments++;
current_sum = expense;
if (segments > m) return false;
}
}
return true;
}
int binarySearch(const vector<int>& expenses, int m) {
int left = *max_element(expenses.begin(), expenses.end());
int right = accumulate(expenses.begin(), expenses.end(), 0);
while (left < right) {
int mid = left + (right - left) / 2;
if (check(mid, expenses, m)) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
int main() {
int n, m;
cin >> n >> m;
vector<int> expenses(n);
for (int i = 0; i < n; ++i) {
cin >> expenses[i];
}
cout << binarySearch(expenses, m) << endl;
return 0;
}
优化点:
- 输入处理:使用vector替代原生数组,更安全方便
- 初始范围:将left设为最大单日开销,减少无效搜索
- 防溢出:使用left + (right - left)/2计算mid
4. 实战案例分析
让我们通过几个典型测试案例来验证算法:
案例1:均匀分布
输入:
7 3
10 20 30 40 50 60 70
处理过程:
- 最大单日开销:70
- 总和:280
- 二分查找过程:
mid=175 → 可行
mid=122 → 可行
mid=96 → 不可行
...
最终结果:110
案例2:包含峰值
输入:
5 2
1 1 1 1 100
处理过程:
- 最大单日开销:100
- 总和:104
- 必须将100单独作为一段
最终结果:100
案例3:USACO官方测试数据
输入:
10 4
100 200 300 400 500 600 700 800 900 1000
处理过程:
- 最优划分:[100-400], [500-700], [800-900], [1000]
- 各段和:1000, 1800, 1700, 1000
最终结果:1800
5. 算法扩展与变种
这个基础算法可以延伸解决多种变体问题:
变体1:最大化最小值
- 如将资源分配给多个项目,求最小分配量的最大值
- 只需修改check函数的逻辑方向
变体2:二维版本
- 将序列改为矩阵,需要在行列两个维度划分
- 复杂度显著增加,需要更复杂的check函数
变体3:带权重划分
- 每段的代价不仅是和,还包含其他计算方式
- 需要重新设计check函数中的累计策略
// 变体1示例:最大化最小值
bool checkMin(int x, const vector<int>& resources, int k) {
int sum = 0;
int projects = 0;
for (int res : resources) {
sum += res;
if (sum >= x) {
projects++;
sum = 0;
if (projects >= k) return true;
}
}
return false;
}
6. 性能分析与优化
算法的时间复杂度主要来自两部分:
- 二分查找:O(logS),其中S是搜索范围大小
- check函数:O(N)每次调用
总复杂度为O(N logS),对于N=1e5的典型竞赛规模完全足够。
进一步优化方向:
- 并行check:在二分过程中并行执行多个check
- 预处理前缀和:加速子段和计算
- 启发式初始范围:根据统计数据缩小搜索范围
实际编程竞赛中,建议使用更保守的写法1,它更容易正确处理各种边界情况。写法2虽然可能少几次迭代,但需要更小心的边界处理。
更多推荐
所有评论(0)