华为OD机试高频题解析:二分答案与贪心验证算法实战

📅 2026/7/28 21:11:23
华为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 vectorint 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(vectorint 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() { // 示例输入 vectorint 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(fThe minimum limit is: {min_limit(nums, max_sum)}) # 输出 3Python版特别注意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^5M 达到 10^9log 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了所以验证函数不会遇到need0的情况。但作为一种防御性编程canRemove函数应该能处理need0的情况任何limit 0都可行。我们的实现中totalRemoved从0开始累加need0时totalRemoved need在循环开始前就成立如果提前判断或者第一次判断if (totalRemoved need)时就成立逻辑是兼容的。5.4 调试技巧与小贴士构造极端测试用例最小输入nums [1], maxSum 0检查need1时是否正确找到limit1。无需操作nums [1,2,3], maxSum 6检查是否返回0。单个箱子解决nums [100], maxSum 50need50检查是否返回50limit需等于need因为只有一个箱子。均匀取出nums [4,4,4,4], maxSum 10total16, need6。最优是每个箱子取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通常取理论最小值或0right取理论最大值。宁大勿小确保答案一定在范围内。多花30秒思考范围避免因范围设错导致死循环或答案错误。优先实现验证函数验证函数check(mid)是核心也是复杂度所在。先把它写对、写高效。确保它能正确处理边界情况如空数组、需求为0等。套用二分模板选择一种你最熟悉的二分查找模板闭区间或左闭右开并严格遵循其更新规则。在代码旁边用注释写明“寻找最小可行解”或“寻找最大可行解”提醒自己更新逻辑。测试边界务必测试left,right以及check(left),check(right)的情况。特别是当答案可能就是left或right时你的循环能否正确终止并返回该值。这道“开放日活动”题就像一把钥匙帮你打开了“二分答案”这类题的大门。它在华为OD机试中反复出现不是因为题目本身多难而是因为它能非常综合地考察候选人的问题抽象、算法选择和代码实现能力。吃透这一道总结出模式再遇到类似的“最大值最小化”或“最小值最大化”问题你就能从容应对了。在实际编码时把数据类型、二分边界、验证逻辑这几个点盯紧基本上就能稳稳拿下。