news 2026/9/3 13:19:28

贪心算法与二分答案实战:从“书页”问题看最小化最大值的经典解法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
贪心算法与二分答案实战:从“书页”问题看最小化最大值的经典解法

1. 项目概述:从一道模拟赛题看贪心算法的实战拆解

最近在整理过去的算法竞赛题目,翻到了这道“书页”题。它来自一场模拟赛,标签是“贪心”,但实际做下来,发现远不止一个“贪”字那么简单。很多刚接触贪心算法的朋友,容易陷入一个误区:认为贪心就是“每一步都选当前最优的”,这没错,但关键在于,你得先想明白“在当前局面下,什么才是‘最优’的定义”。这道“书页”题就是一个绝佳的例子,它表面上是一个分配问题,但内核却考验着你如何设计合理的“贪心策略”以及如何证明其正确性。今天,我就结合这道题,把贪心算法的核心思路、策略设计、证明方法,以及编码实现中的坑,给大家掰开揉碎了讲清楚。无论你是正在备赛的选手,还是对算法设计感兴趣的程序员,相信这篇深度解析都能让你对“贪心”有更立体的认识。

2. 问题背景与核心需求解析

2.1 原题场景还原与抽象建模

我们先来还原一下题目的大致场景(基于常见的竞赛题风格进行合理重构):

假设我们有n本书,每本书都有一定的页数。现在需要将这些书分配给m个抄写员进行抄写。每个抄写员必须抄写连续序列的书(比如,不能把第一本和第三本给同一个人,而跳过第二本)。每个抄写员的抄写速度是相同的,因此他们所花费的时间正比于分配到的书的总页数。我们的目标是:找到一种分配方式,使得所有抄写员中,抄写页数最多的那个人,其工作量(总页数)尽可能小。换句话说,我们要最小化最大子段和。

这是一个非常经典的“最小化最大和”问题,在资源分配、负载均衡等领域有广泛的应用。例如,将一批任务分配给多个处理器,使得最忙的处理器的完成时间最短;或者将数据块分配到多个磁盘,使得负载最重的磁盘数据量最小。

2.2 问题形式化定义与输入输出

为了后续讨论清晰,我们将问题形式化:

  • 输入
    1. 两个整数nm,表示书的总数和抄写员的数量。(1 <= m <= n <= 10^5)(典型数据范围)。
    2. n个正整数a[1], a[2], ..., a[n],表示每本书的页数。
  • 输出
    1. 一个整数ans,表示在最优分配方案下,抄写页数最多的那个抄写员需要抄写的最小页数。
    2. (有时题目还会要求输出具体的分配方案,即每个抄写员负责哪几本书。本篇重点讨论核心的最优值求解,方案输出可作为延伸练习)。

核心矛盾m个抄写员是有限的资源。如果m很大(接近n),我们可以让每个人只抄一本书,那么最大工作量就是最厚的那本书的页数。如果m很小(比如为1),那么所有书都由一个人抄,最大工作量就是所有书的总页数。一般情况下,我们需要在“分段”和“合并”之间找到平衡,让每段的和尽可能均匀。

3. 算法思路演进:从暴力到贪心再到二分答案

3.1 暴力搜索与动态规划的不可行性

最直观的想法是:枚举所有可能的分割点。在n本书的n-1个空隙中,选择m-1个位置进行分割,将书分成m个连续段。计算每种分法下最大段的和,然后取最小值。这是一个组合数问题,计算量是C(n-1, m-1),在nm较大时完全不可行。

另一个思路是动态规划(DP)。定义dp[i][k]为将前i本书分配给k个抄写员的最小化最大工作量。状态转移需要考虑最后一个抄写员负责从j+1i的书,即dp[i][k] = min{ max(dp[j][k-1], sum(j+1, i)) },其中sum(j+1, i)表示第j+1到第i本书的页数和。这个DP的时间复杂度是O(n^2 * m),在n达到10^5量级时也无法承受。

注意:这里涉及区间和的计算,通常需要用前缀和数组prefix来优化,使得sum(j+1, i) = prefix[i] - prefix[j]可以在O(1)时间内得到。但即便如此,O(n^2 * m)的复杂度依然太高。

3.2 贪心策略的初步尝试与陷阱

既然标签是贪心,我们首先尝试设计贪心策略。一个常见的错误贪心是:每次尽可能让当前抄写员多抄,直到再抄下一本就会使他成为“当前最大”时,就换下一个抄写员

具体来说:设定一个“当前最大工作量”的预期值limit(初始可以设为单本书的最大页数或者平均页数),然后遍历书本,累加页数。如果当前累加和加上下一本书的页数会超过limit,就让当前抄写员停止,从下一本书开始新的累加(即分配给下一个抄写员)。最后看需要多少抄写员。

这个策略的问题在于:limit的值是未知的,且这个策略对limit非常敏感。如果我们limit设得太大,可能只需要少于m个抄写员;如果设得太小,可能需要多于m个抄写员。我们的目标恰恰是找到那个最小的limit,使得恰好(或至少)能用m个抄写员分配完所有书。

因此,单纯的“过程贪心”无法直接得出答案,它需要一个“目标值”来驱动。这引出了本题乃至这一类问题的核心解法框架:二分答案 + 贪心验证

3.3 二分答案框架的引入

我们发现,如果给定一个猜测的答案X(即假设最大工作量不超过X),我们可以很容易地判断是否可行。判断方法就是上面提到的贪心策略:

  1. 初始化:当前抄写员计数cnt = 1,当前抄写员累计页数current_sum = 0
  2. 遍历每一本书i(页数为a[i]):
    • 如果current_sum + a[i] > X,说明当前抄写员不能再抄这本书了,否则会超负荷。那么我们就启用一个新的抄写员(cnt += 1),让新抄写员从这本书开始抄(current_sum = a[i])。

      这里有个关键细节:必须判断单本书的页数a[i]是否本身就大于X。如果是,那么任何包含这本书的分配方案都会导致工作量超过X,因此这个X直接不可行。我们在编码时需要加上这个检查。

    • 否则(current_sum + a[i] <= X),就让当前抄写员继续抄这本书(current_sum += a[i])。
  3. 遍历结束后,如果使用的抄写员数量cnt <= m,说明在最大工作量不超过X的限制下,可以用不超过m个人完成所有工作,因此X是一个可行的上界。
  4. 如果cnt > m,说明X太小了,限制太严格,需要更多的人才能完成,因此X不可行。

这个验证函数check(X)的时间复杂度是O(n),非常高效。

现在,问题转化为:寻找最小的可行X。显然,X的取值范围是:

  • 下界L:至少是单本书的最大页数。因为总有一本书需要被一个人抄。
  • 上界R:最坏情况下,所有书由一个人抄,即所有书的总页数。

在这个有序范围[L, R]内,满足check(X)为真的X构成一个连续的区间(例如,如果X可行,那么任何大于X的值也一定可行)。我们的目标是找到这个区间的左端点,即最小值。这完美符合二分查找(Binary Search)的应用场景——在有序序列中寻找第一个满足条件的值。

二分查找的过程

  1. 初始化left = L,right = R
  2. while (left < right):
    • 计算中间值mid = left + (right - left) / 2。(注意防止溢出)
    • 调用check(mid)
    • 如果check(mid)为真,说明mid是一个可行解,并且答案可能更小或等于mid。因此,将搜索范围缩小到左半部分:right = mid
    • 如果check(mid)为假,说明mid太小了,不可行。答案一定在更大的那边。因此,将搜索范围缩小到右半部分:left = mid + 1
  3. 循环结束时,left(或right)的值就是最小的可行X,即所求答案。

这个“二分答案+贪心验证”的框架,将原本复杂的优化问题,分解为一个简单的判定问题和一个高效的搜索过程,是算法竞赛中处理“最小化最大值”或“最大化最小值”问题的标准套路。

4. 核心细节解析与实操要点

4.1 贪心验证函数check(X)的编码细节与边界处理

check函数的实现虽然思路简单,但边界情况处理不好极易出错。下面给出一个稳健的实现模板(以C++为例):

bool check(long long limit, vector<int>& pages, int m) { int cnt = 1; // 至少需要一个抄写员 long long current_sum = 0; for (int page : pages) { // 关键检查:如果单本书页数就超过限制,直接不可行 if (page > limit) { return false; } if (current_sum + page > limit) { // 当前抄写员装不下了,需要新开一个 cnt++; if (cnt > m) { // 如果抄写员数量已经超了,提前返回失败 return false; } current_sum = page; // 新抄写员从当前这本书开始 } else { current_sum += page; // 当前抄写员继续抄 } } return cnt <= m; // 最终使用的抄写员数不超过m则可行 }

实操要点与避坑指南

  1. 数据类型:页数之和可能很大,n最大为10^5,每本书页数假设最大为10^4,总页数可达10^9,超出了32位整型(int)的范围。因此,current_sumlimit、以及二分查找中的left,right,mid都必须使用long long(64位整型)。
  2. 提前退出优化:在循环内部,一旦发现cnt > m,就可以立刻返回false,无需遍历完所有书。这是一个重要的常数优化。
  3. 单本书超限检查if (page > limit)这个检查至关重要。没有它,如果limit小于某本书的页数,current_sum + page > limit的判断逻辑虽然最终也会导致cnt激增而返回false,但逻辑上不清晰,且在某些变体问题中可能出错。
  4. cnt的初始值:必须为1。因为至少需要一个人开始抄第一本书。如果初始化为0,逻辑上会多出一轮判断,容易混乱。

4.2 二分查找的“左闭右开”与“左闭右闭”区间选择

二分查找是易错点。上面给出的是“左闭右闭”区间[left, right]的写法,并且寻找的是第一个满足条件的值(即“最小可行值”)。这种写法的循环条件是while (left < right),更新策略是right = midleft = mid + 1。最终leftright相等,即为答案。

另一种常见写法是“左闭右开”区间[left, right)。在这种写法下,right初始化为R + 1(一个不可行的位置),循环条件仍是while (left < right),更新策略为:如果check(mid)为真,则right = mid;如果为假,则left = mid + 1。循环结束后,left是答案。

个人经验:我强烈推荐并始终使用“左闭右闭”的写法,并明确记住“找第一个可行解”的模板。这更容易理解,且不易出错。关键点在于:

  • mid的计算:mid = left + (right - left) / 2,这是标准的防溢出写法。
  • check(mid)为真时,说明mid可能就是答案,或者答案在左边,所以right = mid(保留mid)。
  • check(mid)为假时,说明mid肯定不是答案,答案在右边,所以left = mid + 1(排除mid)。

4.3 复杂度分析与适用场景总结

  • 时间复杂度:二分查找的复杂度为O(log(R-L)),其中R-L最大为总页数,约为10^9量级,log2(10^9)约为30。每次验证check需要O(n)。因此总复杂度为O(n log(SUM)),对于n=10^5是绰绰有余的。
  • 空间复杂度:主要是存储书页数组O(n)和几个变量O(1)

这种方法之所以强大,是因为它将“求最优解”这个本身可能很难的问题,转化为了“判断一个解是否可行”这个相对简单的问题。只要验证函数check是单调的(即如果X可行,则所有大于X的值也可行),并且可以在多项式时间内完成,二分答案就是一把利器。

适用场景特征

  1. 问题的答案在一个确定的范围内。
  2. 对于给定的一个候选答案,容易判断其是否可行(或是否满足某个条件)。
  3. 可行性函数具有单调性。

5. 完整代码实现与逐行解析

下面给出基于上述思路的完整C++代码实现,并附上详细注释。

#include <iostream> #include <vector> #include <algorithm> using namespace std; typedef long long ll; // 使用long long防止溢出 // 贪心验证函数:判断在最大工作量不超过limit的情况下,能否用不超过m个抄写员完成 bool check(ll limit, const vector<int>& pages, int m) { int cnt = 1; // 需要的抄写员数量,初始为1 ll current_sum = 0; // 当前抄写员累计页数 for (int page : pages) { // 如果单本书页数超过限制,绝对不可能分配 if (page > limit) { return false; } // 如果当前抄写员加上这本书会超负荷,则启用新抄写员 if (current_sum + page > limit) { cnt++; // 增加抄写员计数 // 如果抄写员数已经超过m,提前结束,返回不可行 if (cnt > m) { return false; } current_sum = page; // 新抄写员从这本书开始抄 } else { // 否则,当前抄写员可以继续抄这本书 current_sum += page; } } // 遍历完所有书,若所需抄写员数不超过m,则此limit可行 return cnt <= m; } int main() { int n, m; cin >> n >> m; vector<int> pages(n); ll left = 0; // 二分下界,初始为0,但会被更新为最大单本书页数 ll right = 0; // 二分上界,初始为0,累加为总页数 for (int i = 0; i < n; ++i) { cin >> pages[i]; right += pages[i]; // 上界:所有书页数之和 if (pages[i] > left) { left = pages[i]; // 下界:单本书的最大页数 } } // 二分查找最小的可行limit ll ans = right; // 初始化答案为上界(最坏情况) while (left <= right) { ll mid = left + (right - left) / 2; // 防止溢出的取中方法 if (check(mid, pages, m)) { // 如果mid可行,尝试寻找更小的可行解 ans = mid; // 更新答案为当前可行的mid right = mid - 1; // 收缩右边界 } else { // 如果mid不可行,说明解在更大的那边 left = mid + 1; // 收缩左边界 } } // 输出答案 cout << ans << endl; return 0; }

代码解析与关键点

  1. 输入与初始化:在读取数据的同时,就计算好了二分的初始边界left(最大单本书页数)和right(总页数)。这是一个小优化,避免再次遍历数组。
  2. 二分循环条件:这里使用了while (left <= right),这是另一种“左闭右闭”的写法。当left > right时循环结束。这种写法下,ans需要在check为真时及时更新。最终ans存储的就是我们找到的最小可行值。
  3. ans的初始化:初始化为right(总页数),这是绝对可行的最大值。在二分过程中,我们只会用更小的可行值去更新它。
  4. 防溢出mid = left + (right - left) / 2是计算中间值的标准安全写法,避免了(left + right) / 2可能导致的溢出。

6. 变体拓展与相关问题联想

掌握了“书页”/“抄写员”问题的解法,你就掌握了一类问题的通解。下面列举几个本质相同或高度相关的问题,可以帮助你举一反三:

  1. “分割数组的最大值”(LeetCode 410):这是本题的英文原题,描述几乎一致。
  2. “在 D 天内送达包裹的能力”(LeetCode 1011):传送带上的包裹必须在D天内运完,求传送带的最小运载能力。将“包裹重量”类比“书页数”,“天数”类比“抄写员数”,完全一样。
  3. “制作 m 束花所需的最少天数”(LeetCode 1482):花园里有n朵花,每朵花在第bloomDay[i]天开放。需要制作m束花,每束需要k朵相邻的、已经开放的花。求最少需要等待多少天。这里“天数”是二分的答案,验证函数check(day)是判断在第day天能否找到足够的连续k朵已开放的花来组成m束。
  4. “小张刷题计划”:类似题目,可能增加“跳过某些难题”的变体,但核心二分框架不变。

解题思维定式:当你看到问题描述中出现“最小化最大值”、“最大化最小值”、“在...条件下,求至少/至多...”这类字眼,并且数据范围暗示O(n^2)DP会超时,就应该立刻想到“二分答案”这个方向。然后,集中精力设计那个O(n)O(n log n)的贪心验证函数check

7. 常见错误与调试技巧实录

在实际编码和调试中,我遇到过不少坑,这里分享给大家:

  1. 整数溢出:这是最隐蔽的错误。没有使用long long,在计算总和或mid时,n和页数稍大就会溢出,导致二分循环无法结束或结果错误。务必在读取数据后估算最大可能值
  2. 二分查找死循环:主要发生在更新leftright时。牢记你选择的区间形式和寻找的目标(第一个可行还是最后一个可行)。如果循环一直无法退出,通常是更新语句写错了。可以用小的样例数据,打印出每一步的left,right,midcheck(mid)结果来调试。
  3. 贪心验证逻辑错误
    • 忘记检查单元素超限:如之前所述,这是必须的。
    • cnt初始值错误:如果初始化为0,需要在循环开始前处理第一本书,逻辑变得复杂,容易出错。初始化为1更自然。
    • 条件判断顺序:先判断current_sum + page > limit,还是先判断cnt > m?通常先判断是否超限,再增加cnt并判断是否超过m,逻辑更清晰。像上面代码那样,在增加cnt后立刻判断cnt > m并返回,是高效的写法。
  4. 样例能过,提交WA
    • 边界条件:测试m = n的情况(每人一本),答案应该是最大页数。测试m = 1的情况(一人全抄),答案应该是总页数。
    • 极端数据:所有书页数相同;书页数递增或递减;nm都等于1。
    • 对拍:写一个暴力搜索或DP程序(用于小数据n <= 20),用随机生成的数据与你的二分答案程序对比结果。这是发现逻辑错误最有效的方法。

调试表格:当你对验证函数不确定时,可以手动模拟一个小例子。

书页数组[10, 20, 30, 40, 50]
猜测 limit贪心分配过程
70[10+20+30]=60, [40], [50]
65[10+20+30]=60, [40], [50]
64[10+20+30]=60, [40], [50]
63[10+20+30]=60, [40+50]=90 (超限) -> [10+20]=30, [30+40]=70 (超限) -> [10]=10, [20+30]=50, [40], [50]

从这个表可以直观看出,limit从70降到65、64时,分配方案不变,cnt都是3。当limit降到63时,第一个抄写员无法装下40,导致分段变多,需要4个人,因此不可行。所以最终答案应该是64。

8. 从“书页”题升华的贪心算法心得

回过头看这道“书页”题,它的标签虽然是“贪心”,但纯粹的贪心策略(不结合二分)很难直接求解。这给我们一个重要的启示:贪心算法往往不是孤立的,它常常作为一种高效的“子过程”或“验证工具”,嵌入到更大的算法框架中(如二分答案、动态规划)

贪心算法的核心在于“局部最优选择能导致全局最优解”。但如何定义“局部最优”?这需要我们对问题有深刻的理解和证明。在“书页”题的验证函数check中,我们的贪心策略是:“在不超过上限limit的前提下,尽可能让当前抄写员多抄”。这个策略对于“判断给定limit是否可行”这个问题是正确且最优的,因为如果存在一种可行分配,我们总可以通过调整,让前面的人尽可能多抄,而不影响可行性。这种“尽可能填满”的思想,是很多贪心验证的基础。

最后,再强调一下这类问题的解题流程:

  1. 识别题型:最小化最大值/最大化最小值。
  2. 确定二分框架:分析答案上下界,设计check函数。
  3. 实现验证函数:用贪心或其它线性/近线性方法实现check
  4. 完成二分查找:注意边界和更新条件。
  5. 测试与调试:用边界数据和随机小数据对拍。

这道题就像一把钥匙,帮你打开了“二分答案”这扇大门。以后遇到类似的题目,你就能迅速抓住本质,化繁为简。算法学习就是这样,吃透一道经典题,胜过盲目刷十道陌生题。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/31 11:34:56

432道MySQL面试题 161 - 180 题

为方便阅读,这里整理了整个系列的索引导航。本系列共 432 道 MySQL 面试题,按每 20 题为一篇进行连载,点击下方链接即可跳转到对应章节,方便你按需查阅、系统复习。 432道MySQL面试题 1 - 20 题 432道MySQL面试题 21 - 40 题 432道MySQL面试题 41 - 60 题 432道MySQL面试题…

作者头像 李华
网站建设 2026/8/31 23:42:26

解决超声波测距稳定增长问题:从HC-SR04原理到STC15单片机代码优化

1. 项目概述&#xff1a;从“稳定增长”现象切入超声波测距核心最近在准备蓝桥杯单片机竞赛&#xff0c;特别是国赛和客观题部分&#xff0c;很多同学在调试超声波测距模块时&#xff0c;都会遇到一个经典又让人头疼的问题&#xff1a;代码烧录进去&#xff0c;超声波模块的返回…

作者头像 李华
网站建设 2026/9/1 5:41:08

双2.5G网口+AMD AI芯片的迷你主机:软路由、虚拟机与本地AI一体机

上周有个朋友问我&#xff1a;有没有一台小主机&#xff0c;能当软路由、能跑几个虚拟机、偶尔还能玩点本地小模型&#xff1f;我当时还没给出明确答案&#xff0c;因为他提的要求其实很分裂&#xff1a;软路由需要网口够多、功耗够省&#xff1b;本地 AI 推理需要 CPU 强、内存…

作者头像 李华
网站建设 2026/9/2 8:27:52

水面无人艇控制实战:从系统建模到PID轨迹跟踪与参数整定

简介&#xff1a;在无人系统运动控制领域&#xff0c;PID控制凭借结构简单、参数物理意义明确等优势&#xff0c;依然是工程落地的首选算法。但面对水面无人艇这类存在强非线性、模型不确定性与环境扰动的欠驱动系统&#xff0c;仅靠PID调试经验难以获得理想效果&#xff0c;其…

作者头像 李华
网站建设 2026/9/1 7:37:16

定制CPU上运行Doom:从交叉编译到性能验证的完整指南

“万物皆可 Doom”这句话在极客圈流传了很多年。过去几年它被反复验证&#xff1a;计算器、打印机、智能冰箱、键盘、法律文档、Windows 记事本&#xff0c;甚至生物细胞里都跑过《毁灭战士》。这次要看的&#xff0c;是这一类玩法里更贴近底层的一个方向&#xff1a;开发者在名…

作者头像 李华