华为OD机试高频题解析:二分答案与贪心验证算法实战
1. 项目概述:从一道机试真题看华为OD的算法考察逻辑
最近在帮几个准备华为OD机试的朋友做模拟练习,发现“开放日活动”这道题出现的频率相当高,尤其是在C++、Java这些主流语言的机试环节。题目本身有个挺生活化的名字,但内核是一个典型的“二分答案”结合“贪心验证”的算法问题。很多朋友第一次看到“取出尽量少的球”这个描述容易懵,不知道从何下手,或者暴力求解超时。这道题完美地体现了华为OD机试的一个核心考察点: 在明确的业务场景下,如何将问题抽象为数学模型,并选择高效、稳定的算法实现 。它不像纯算法竞赛题那样追求极致的技巧,而是更看重你解决问题的完整思路和代码的健壮性。今天,我就结合自己带人刷题和面试官交流的经验,把这题的“里子”和“面子”都拆开讲讲,提供一个从理解到实现的完整参考。
简单来说,题目是这样的:假设你负责一个开放日活动的准备,有若干个箱子,每个箱子里有不同数量的球。为了控制现场球的总数不超过某个安全上限 maxSum ,你需要从一些箱子里取出一些球。目标是 在所有箱子中,单箱取出球数的最大值尽可能小 。换句话说,我们希望最“惨”的那个箱子,被拿走的球也别太多,要“公平”地、尽可能少地从每个箱子取球,来满足总量要求。这听起来有点绕,但转化一下就是:找到一个最小的整数 limit ,使得当我们规定“从任何一个箱子中最多取出 limit 个球”时,所有箱子被取出的球数总和能够达到(或超过)使总球数降到 maxSum 以下所需的值。如果还没感觉,想象一下你是活动负责人,要均匀地减少各个站点的物料(球),不能对某一个站点“涸泽而渔”,又要保证总物料不超标,这个 limit 就是你规定的每个站点最多能削减的物料上限,你当然希望这个上限越小越好。
这道题适合所有正在准备华为OD机试,尤其是目标岗位涉及后端开发、算法优化的同学。它不要求你掌握多么冷僻的数据结构,但非常考验你对二分查找应用场景的识别能力、对边界条件的处理,以及编写清晰、无BUG代码的基本功。下面,我们就从解题思路开始,一步步拆解。
2. 核心思路解析:为什么二分查找是“最优解”
2.1 问题转化与数学模型建立
首先,我们得把口语化的描述变成计算机能处理的形式。给定两个输入:
- 一个数组
nums,代表每个箱子里的球数。例如[2, 5, 8, 3]。 - 一个整数
maxSum,代表允许的球的总数上限。
设所有箱子初始总球数为 total 。如果 total <= maxSum ,那皆大欢喜,一个球都不用取,此时 limit 为 0。这是第一个边界情况。
如果 total > maxSum ,我们就需要取出一些球。设我们设定的“单箱最大取出数”为 limit 。那么对于任意一个箱子 i :
- 如果
nums[i] <= limit,我们可以把这个箱子里的球全部取出,取出球数为nums[i]。 - 如果
nums[i] > limit,我们最多只能从这个箱子取出limit个球,取出球数为limit。
那么,在设定某个 limit 的情况下,总共能取出的球数 total_removed(limit) 就是所有箱子取出球数的总和。我们需要找到 最小的 limit ,使得 total_removed(limit) >= total - maxSum 。这里 total - maxSum 就是我们至少需要取出的球的总量,记为 need 。
至此,问题转化为了:在单调函数 total_removed(limit) 中,查找满足条件 total_removed(limit) >= need 的最小 limit 。
2.2 二分查找的适用性分析
为什么想到二分查找?核心在于函数 total_removed(limit) 具有单调非递减的特性。想一想,如果 limit 变大,允许从单个箱子取出的球数上限增加,那么每个箱子能贡献的“可取出球数”只会不变或增加,因此总和 total_removed(limit) 也只会不变或增加。这是一个单调递增(非严格)的函数。
对于单调函数,在一个有序的候选答案集(这里是 limit 的可能取值)中查找满足条件的最小值,二分查找就是最高效的方法。 limit 的下界显然是 0(一个不取),上界是多少呢?最极端的情况,我们只需要从一个箱子里拼命取球就能满足需求,那么这个 limit 最大也不会超过所有箱子中球数的最大值 max(nums) 。因为对于球数最多的箱子,我们最多将其全部取空,所以 limit 的搜索范围是 [0, max(nums)] 。
注意 :这里容易产生的误区是认为上界是
need或者total。务必理解,limit约束的是“单次操作”的上限,而不是总数。即使需要取出的总数need很大,我们也可以通过从多个箱子各取一部分来满足,而不需要让单个箱子的取出数超过其本身容量(即max(nums))。
2.3 贪心验证函数的设计
二分查找的框架是“猜答案-验证答案”。我们需要一个函数 canDo(limit, need) 来判断:如果限定单箱最多取 limit 个,能否取出至少 need 个球。
这个验证函数的实现就是贪心思想:遍历每个箱子,计算在该 limit 下能从这个箱子取出多少球( min(nums[i], limit) ),并累加。如果累加和 sum_removed >= need ,说明这个 limit 是可行的,我们可以尝试更小的 limit ;否则,这个 limit 太小了,需要增大。
这个贪心策略为什么正确?因为对于每个箱子,在 limit 固定时,尽可能多地取球(取 min(nums[i], limit) )总是最优的。这不会影响其他箱子,并且能使总取出数最大化,从而最有可能满足 need 的要求。
3. 代码实现与逐行解析(C++/Java/Python)
理解了思路,我们来看代码。我会用C++作为主要示例,因为它性能好且是OD高频语言,同时对比给出Java和Python的关键实现,并指出各语言实现的细微差别和易错点。
3.1 C++ 实现详解
#include <iostream>
#include <vector>
#include <algorithm>
#include <numeric> // 用于 accumulate
using namespace std;
// 验证函数:当单箱取出上限为 limit 时,能否至少取出 need 个球
bool canRemove(const vector<int>& nums, long long limit, long long need) {
long long totalRemoved = 0;
for (int num : nums) {
// 当前箱子最多能取出的球数
totalRemoved += min((long long)num, limit);
// 贪心:提前终止,如果已经满足需求,提前返回true,节省计算
if (totalRemoved >= need) {
return true;
}
}
return totalRemoved >= need;
}
int minLimit(vector<int>& nums, int maxSum) {
long long total = accumulate(nums.begin(), nums.end(), 0LL); // 使用 long long 防止大数溢出
if (total <= maxSum) {
return 0; // 情况一:无需取球
}
long long need = total - maxSum; // 至少需要取出的球数
// 确定二分查找的上下界
int left = 0;
// 上界是数组最大值,因为 limit 不可能超过任何一个箱子本身的球数
int right = *max_element(nums.begin(), nums.end());
int ans = right; // 初始化答案为上界,即最坏情况
while (left <= right) {
int mid = left + (right - left) / 2; // 标准二分写法,防止溢出
if (canRemove(nums, mid, need)) {
// mid 可行,尝试寻找更小的可行解
ans = mid; // 更新当前最优答案
right = mid - 1;
} else {
// mid 不可行,需要增大 limit
left = mid + 1;
}
}
return ans;
}
int main() {
// 示例输入
vector<int> nums = {2, 5, 8, 3};
int maxSum = 12;
int result = minLimit(nums, maxSum);
cout << "The minimum limit is: " << result << endl; // 输出应为 3
return 0;
}
关键点解析与避坑指南:
- 数据类型是第一个大坑 :
total,need,totalRemoved务必使用long long。题目虽未明确给出数据范围,但机试中常包含较大的累加和。使用int可能导致溢出,产生负数,进而让判断逻辑完全错误。accumulate的初始值0LL确保了累加过程在long long类型下进行。 - 验证函数中的优化 :在
canRemove函数中,一旦累计取出数totalRemoved >= need,立即返回true。这是一个有效的剪枝,对于长数组和较大的need能提升效率。 - 二分查找的边界与更新 :
while (left <= right)是经典的闭区间查找模板,清晰不易错。mid = left + (right - left) / 2是计算中点的标准写法,可防止(left + right)潜在溢出。- 当
mid可行时,我们记录ans = mid,然后让right = mid - 1去左侧寻找更小的可行解。这是寻找“最小满足值”的标准操作。 - 循环结束时,
ans存储的就是我们找到的最小可行limit。
- 初始值的设定 :
ans初始化为right(上界),这是一个保守且安全的做法,保证了即使二分查找的更新逻辑有瑕疵,最终也有一个兜底值(最坏情况下的解)。
3.2 Java 实现对比
import java.util.Arrays;
public class Solution {
private boolean canRemove(int[] nums, long limit, long need) {
long totalRemoved = 0L;
for (int num : nums) {
totalRemoved += Math.min(num, limit);
if (totalRemoved >= need) {
return true;
}
}
return totalRemoved >= need;
}
public int minLimit(int[] nums, int maxSum) {
long total = 0L;
int maxVal = 0;
for (int num : nums) {
total += num;
maxVal = Math.max(maxVal, num);
}
if (total <= maxSum) {
return 0;
}
long need = total - maxSum;
int left = 0;
int right = maxVal;
int ans = right;
while (left <= right) {
int mid = left + (right - left) / 2;
if (canRemove(nums, mid, need)) {
ans = mid;
right = mid - 1;
} else {
left = mid + 1;
}
}
return ans;
}
}
Java版特别注意:
- Java没有内置的
accumulate和max_element,需要手动遍历计算total和maxVal。 - 同样,所有涉及累加和可能溢出的变量(
total,need,totalRemoved)必须使用long。 - 算法逻辑与C++完全一致。
3.3 Python 实现对比
from typing import List
def min_limit(nums: List[int], max_sum: int) -> int:
total = sum(nums)
if total <= max_sum:
return 0
need = total - max_sum
left, right = 0, max(nums)
ans = right
def can_remove(limit: int) -> bool:
"""检查给定limit下能否取出至少need个球"""
removed = 0
for num in nums:
removed += min(num, limit)
if removed >= need: # 提前退出优化
return True
return removed >= need
while left <= right:
mid = (left + right) // 2
if can_remove(mid):
ans = mid
right = mid - 1
else:
left = mid + 1
return ans
# 示例
if __name__ == "__main__":
nums = [2, 5, 8, 3]
max_sum = 12
print(f"The minimum limit is: {min_limit(nums, max_sum)}") # 输出 3
Python版特别注意:
- Python的整数不会溢出,所以不需要担心
int和long的问题,这是其一大优势。 - 二分查找中
mid = (left + right) // 2在Python中安全,因为Python整数无上限。 - 函数定义在内部(
can_remove)或外部均可,内部定义可以避免传递nums和need参数,利用闭包特性,使代码更简洁。 - 逻辑与C++/Java版本保持一致。
4. 算法复杂度分析与优化思考
4.1 时间复杂度
- 计算总和与最大值 :需要一次数组遍历,时间复杂度为 O(N),其中 N 是箱子数量(数组长度)。
- 二分查找 :在范围
[0, max(nums)]内进行二分查找,每次迭代将范围减半。查找次数为 O(log M),其中 M 是max(nums)的值。 - 每次验证 :
canRemove函数需要遍历整个数组,时间复杂度为 O(N)。
因此,总时间复杂度为 O(N + N * log M) = O(N log M) 。在绝大多数机试场景下,这个复杂度是完全可接受的。N 通常达到 10^5,M 达到 10^9, log M 约为 30,乘积也在千万级别,运行时间绰绰有余。
4.2 空间复杂度
除了存储输入数组 nums 本身的空间 O(N) 外,算法只使用了几个额外的整型变量( total , need , left , right , mid , ans 等),因此 额外空间复杂度为 O(1) ,是非常优秀的。
4.3 潜在优化点与变体思考
虽然上述解法已经足够好,但我们可以思考一些边界情况和优化:
-
上界的进一步优化 :上界
right初始化为max(nums),这是安全的。但有没有更紧的上界?考虑最贪心的情况:我们把所有取出操作都施加在球最多的那个箱子上。那么需要的limit至少是need(因为一个箱子最多贡献limit个球)。但need可能远大于max(nums)吗?不可能,因为need = total - maxSum,而total是所有箱子的和,maxSum非负,所以need <= total。而total可能大于max(nums),但一个箱子最多贡献max(nums),所以当need > max(nums)时,我们必须从多个箱子取。实际上,limit的实际上界是min(max(nums), need)。不过,由于max(nums)通常易于获取,且二分查找对数级复杂度对初始范围不敏感,这个优化带来的收益不大,但体现了更深入的思考。 -
另一种二分写法 :有些朋友喜欢用“左闭右开”区间
[left, right)的写法,while (left < right),更新时right = mid或left = mid + 1。这种写法也可以,但需要特别注意循环终止条件和最终答案的选取,更容易出错。我推荐上面使用的“闭区间”写法,语义最清晰。 -
如果数组是排序的? 如果题目预先将
nums排序了(虽然本题没有),那么验证函数canRemove可以利用二分查找进一步加速。对于排序数组,我们可以快速找到第一个大于limit的索引,该索引之前的箱子全部取完(和可以用前缀和O(1)得到),之后的箱子都只能取limit个。这样验证复杂度可以从 O(N) 降到 O(log N),总复杂度变为 O(N log N + log N * log M)。但这属于进阶优化,除非题目有特别说明或数据量极大,否则不需要。
5. 常见错误与调试技巧实录
在带人刷题和模拟面试中,我见过太多在这道题上翻车的案例。这里总结几个高频错误点,并给出调试方法。
5.1 错误类型一:整数溢出
错误现象 :代码在小数据测试时正确,遇到大数据量(特别是各箱子球数都很大)时,结果错误,甚至出现负数。 问题根源 :在C++或Java中,使用 int 类型存储累加和 total 、 need 或验证函数中的 totalRemoved 。当这些值超过 INT_MAX (约21亿)时,发生溢出。 排查方法 :
- 在代码中打印或调试查看
total、need的值,看是否异常。 - 最稳妥的方案: 默认使用
long long(C++) 或long(Java) 来处理所有可能累加的变量。这是一个成本极低但能避免一大类错误的好习惯。 - Python开发者可以忽略此问题。
5.2 错误类型二:二分查找边界条件错误
错误现象 :陷入死循环,或者返回的答案不是最小的可行解。 问题根源 : while 循环条件、 left / right 的更新语句、 mid 的计算方式不匹配。 标准模板与检查清单 :
- 区间定义 :明确你使用的是闭区间
[left, right]还是左闭右开[left, right)。选定一种并坚持到底。 - 循环条件 :闭区间对应
while (left <= right);左闭右开对应while (left < right)。 - 中点计算 :使用
mid = left + (right - left) / 2防溢出。 - 更新逻辑 :
- 寻找最小可行解(本题):
- 如果
mid可行 (canRemove(mid) == true),则答案可能是mid或更小,所以right = mid - 1(闭区间) 或right = mid(左闭右开)。 - 如果
mid不可行,则答案一定比mid大,所以left = mid + 1。
- 如果
- 寻找最大可行解(反之):
- 如果
mid可行,则答案可能是mid或更大,所以left = mid + 1。 - 如果
mid不可行,则right = mid - 1。
- 如果
- 寻找最小可行解(本题):
- 返回值 :在闭区间
while (left <= right)写法中,循环结束时left > right,通常用一个额外变量ans在每次可行时记录mid,最后返回ans。这是最不易错的方式。
5.3 错误类型三:验证函数逻辑错误
错误现象 :对于某些特定测试用例,结果不对。 问题根源 :
- 误解“取出”含义 :误以为
limit是每个箱子必须取出的数量,而不是最大数量。验证时错误地计算为sum(min(nums[i], limit)),这是对的,但有人会写成sum(limit)或sum(nums[i] - limit if nums[i] > limit else 0),后者计算的是“剩余球数”,逻辑反了。 - 未提前终止 :在验证函数中,即使累计值已经达到
need,仍然继续遍历整个数组。这不会导致结果错误,但属于无效计算。在机试中虽不影响正确性,但体现了代码优化意识不足。 - 忽略
need可能为0的情况 :虽然total > maxSum时need > 0,但若total <= maxSum,我们在函数入口处就返回0了,所以验证函数不会遇到need=0的情况。但作为一种防御性编程,canRemove函数应该能处理need=0的情况(任何limit >= 0都可行)。我们的实现中totalRemoved从0开始累加,need=0时,totalRemoved >= need在循环开始前就成立(如果提前判断),或者第一次判断if (totalRemoved >= need)时就成立,逻辑是兼容的。
5.4 调试技巧与小贴士
-
构造极端测试用例 :
- 最小输入:
nums = [1], maxSum = 0,检查need=1时是否正确找到limit=1。 - 无需操作:
nums = [1,2,3], maxSum = 6,检查是否返回0。 - 单个箱子解决:
nums = [100], maxSum = 50,need=50,检查是否返回50(limit需等于need,因为只有一个箱子)。 - 均匀取出:
nums = [4,4,4,4], maxSum = 10,total=16, need=6。最优是每个箱子取1.5个,但球是整数,所以limit至少为2。检查算法是否返回2。 - 大数测试:构造一个长数组,每个元素接近
INT_MAX,检查是否溢出。
- 最小输入:
-
使用IDE或在线调试器 :单步跟踪
left,right,mid,ans的变化,以及验证函数的返回值,观察二分查找的收敛过程。 -
打印关键变量 :在二分循环内打印
left, right, mid, canRemove(mid)的值,可以非常直观地看到搜索过程,快速定位是验证函数出错还是二分更新逻辑出错。 -
对比暴力解 :对于小数据范围(N和M很小),可以写一个暴力算法:从0到
max(nums)遍历每个可能的limit,调用验证函数,找到第一个可行的。用暴力解的结果作为标准答案,来验证你的二分查找算法。这是验证算法正确性的黄金标准。
6. 从解题到举一反三:掌握“二分答案”套路
这道“开放日活动”题本质上是“二分答案”或“二分查找判定性问题”的经典应用。这类问题的识别和处理有一套通用的方法论。
6.1 “二分答案”适用场景的特征
当你遇到一个问题,并且同时满足以下两个条件时,很可能就能用二分答案:
- 答案在一个确定的、有序的范围内 。这个范围通常比较容易确定,比如本题的
[0, max(nums)],或者一些题目中的[0, 10^9]。 - 对于给定的一个候选答案,存在一个相对高效的“验证函数” ,可以判断这个候选答案是否“可行”或“满足要求”。这个验证函数的复杂度通常比直接求解最优答案要低。
6.2 同类题型归纳
华为OD或其他笔试中,类似的题目很多,举几个例子:
- “分割数组的最大值” :给定一个数组和一个整数k,将数组分成k个连续子数组,使得所有子数组和的最大值最小。这里“答案”是“最大子数组和”,范围是
[max(nums), sum(nums)],验证函数是“给定一个最大和上限,判断能否将数组分成不超过k段”。 - “在D天内送达包裹的能力” :传送带上的包裹必须在D天内运完,求船的最低运载能力。答案范围是
[max(weights), sum(weights)],验证函数是“给定运载能力,判断能否在D天内运完”。 - “制作m束花所需的最少天数” :花需要时间生长,求能制作m束花的最少天数。答案范围是
[min(days), max(days)],验证函数是“给定一个天数,判断能否收集到足够的花制作m束花”。
它们的解题框架都是一样的:
- 确定答案的搜索范围
[left, right]。 - 设计验证函数
check(mid)。 - 二分查找,根据
check(mid)的结果更新left或right。 - 根据题目要求(找最小还是最大可行解)返回
left或right或ans。
6.3 在机试中的实战策略
- 快速识别 :读完题,先问自己:答案是不是一个单调变化的数值?我能不能快速判断一个数值是否可行?如果答案是肯定的,立刻考虑二分。
- 谨慎确定范围 :
left通常取理论最小值或0,right取理论最大值。宁大勿小,确保答案一定在范围内。多花30秒思考范围,避免因范围设错导致死循环或答案错误。 - 优先实现验证函数 :验证函数
check(mid)是核心,也是复杂度所在。先把它写对、写高效。确保它能正确处理边界情况(如空数组、需求为0等)。 - 套用二分模板 :选择一种你最熟悉的二分查找模板(闭区间或左闭右开),并 严格遵循其更新规则 。在代码旁边用注释写明“寻找最小可行解”或“寻找最大可行解”,提醒自己更新逻辑。
- 测试边界 :务必测试
left,right以及check(left),check(right)的情况。特别是当答案可能就是left或right时,你的循环能否正确终止并返回该值。
这道“开放日活动”题就像一把钥匙,帮你打开了“二分答案”这类题的大门。它在华为OD机试中反复出现,不是因为题目本身多难,而是因为它能非常综合地考察候选人的问题抽象、算法选择和代码实现能力。吃透这一道,总结出模式,再遇到类似的“最大值最小化”或“最小值最大化”问题,你就能从容应对了。在实际编码时,把数据类型、二分边界、验证逻辑这几个点盯紧,基本上就能稳稳拿下。
更多推荐


所有评论(0)