从农场记账到代码实现:用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;
}

调试技巧:

  1. 边界情况测试:当M=1或M=N时的表现
  2. 极端值测试:包含极大单日开销的情况
  3. 打印中间变量:在复杂案例中输出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;
}

优化点:

  1. 输入处理:使用vector替代原生数组,更安全方便
  2. 初始范围:将left设为最大单日开销,减少无效搜索
  3. 防溢出:使用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. 性能分析与优化

算法的时间复杂度主要来自两部分:

  1. 二分查找:O(logS),其中S是搜索范围大小
  2. check函数:O(N)每次调用

总复杂度为O(N logS),对于N=1e5的典型竞赛规模完全足够。

进一步优化方向:

  1. 并行check:在二分过程中并行执行多个check
  2. 预处理前缀和:加速子段和计算
  3. 启发式初始范围:根据统计数据缩小搜索范围

实际编程竞赛中,建议使用更保守的写法1,它更容易正确处理各种边界情况。写法2虽然可能少几次迭代,但需要更小心的边界处理。

更多推荐