华为OD机试真题解析:二分查找算法在资源调度中的应用

📅 2026/7/28 23:38:17
华为OD机试真题解析:二分查找算法在资源调度中的应用
1. 项目概述从一道机试真题看华为OD的算法考察逻辑最近在帮几个准备华为OD机试的朋友做模拟练习发现他们普遍对“开放日活动”这道题感到棘手。这道题在各大论坛和备考群里的讨论热度一直很高核心是“取出尽量少的球”听起来像是个简单的贪心或模拟题但实际一上手很多人都会在边界条件和最优策略上栽跟头。我翻看了历年真题的回忆录结合自己当年面试和后来带人的经验觉得有必要把这道题的里里外外彻底拆解一遍。这不只是一道题的解法和代码更是理解华为OD这类大厂机试出题思路和评分标准的绝佳窗口。无论你是用C语言追求极致的效率还是用Python图个快速验证或者是Java、C、JS的开发者这篇文章都会带你从问题本质出发理清思路避开陷阱最终拿出一份能让面试官眼前一亮的代码。简单来说这道题模拟了一个常见的运维或资源调度场景你有一系列盒子每个盒子里有若干小球代表某种资源或负载现在需要为一场“开放日”活动准备展示要求所有盒子中剩余小球的总数不能超过一个指定容量capacity。为了满足这个要求你可以从任意盒子中取出任意数量的小球但我们的目标是使取出的球总数尽可能少。为什么是“尽可能少”这直接对应了实际业务中“最小化资源调整”、“最小化服务影响”的核心诉求。题目会给你一个数组nums代表每个盒子初始的球数以及一个整数capacity。你需要计算在满足剩余球总数 ≤capacity的前提下每个盒子最多可以保留多少个球或者说每个盒子最少需要被取出多少个球。最终输出是一个和nums等长的数组表示每个盒子调整后的球数即保留数。2. 核心思路拆解为什么二分查找是正解刚拿到题目很多人的第一反应是动态规划或者贪心算法。比如是不是应该优先从球数最多的盒子里取这听起来很合理但仔细一想就会发现问题如果单纯每次从当前最多的盒子里取一个球你需要反复排序时间复杂度会很高而且最关键的是这未必能得到全局最优解。举个例子假设capacity很小而所有盒子球数都很多那么最优策略可能是所有盒子都大幅度削减而不是只盯着最多的那几个盒子薅羊毛。这道题的精妙之处在于它有一个可以被二分搜索的特性“每个盒子保留的球数”存在一个上限值limit。什么意思呢我们可以换个角度思考如果我们规定每个盒子最多只能保留limit个球超出的部分必须被取出。那么在这种规定下所有盒子剩余球的总数就是一个关于limit的单调非递减函数sum(limit)。sum(limit)很好计算对于每个盒子里的球数num它最多保留min(num, limit)个球。所以sum(limit) Σ min(nums[i], limit)。现在我们的目标从“直接求每个盒子的保留数”变成了“寻找一个合适的limit”。这个limit需要满足在它的限制下剩余球总数sum(limit)不超过给定的capacity。并且由于我们希望取出的球总数最少即保留的球总数尽可能多我们实际上要找的是满足sum(limit) capacity的最大limit值。为什么是最大因为limit越大每个盒子能保留的球就越多直到达到其原始球数sum(limit)也就越大越接近capacity从而被取出的球总数就越少。一旦我们通过二分搜索找到了这个最大的、合法的limit那么每个盒子的最终保留数就确定了final_num[i] min(nums[i], limit)。这个思路将一个看似复杂的分配问题转化为了一个在整数范围内寻找特定阈值的搜索问题复杂度从可能是指数级或高次多项式级直接降到了O(n log M)其中n是盒子数量M是盒子中球数的最大值。这是典型的“二分答案”技巧在解决“最小化最大值”或“最大化最小值”这类问题时非常有效。注意这里二分的是“每个盒子允许保留的上限值”而不是直接二分“取出的球数”。因为“取出的球数”和最终结果之间没有简单的单调关系而“保留上限”与“剩余总球数”之间有清晰的单调关系这才是二分搜索能够应用的关键。2.1 二分查找的边界与细节思路确定了实现二分查找时还有几个坑点需要注意搜索范围limit的下界left显然是 0一个球都不留肯定满足条件但显然不是我们想要的。上界right应该是max(nums)因为如果limit已经大于等于所有盒子的原始球数那么sum(limit)就等于所有盒子的原始球数之和total。如果total本身已经小于等于capacity那么我们根本不需要取球limit可以直接是max(nums)甚至更大但更大的limit没有意义因为保留数不会超过原始球数。所以初始的搜索区间是[0, max(nums)]。终止条件标准的二分查找模板使用while (left right)循环。但这里有一个关键因为我们要找的是最大的满足条件的limit所以在计算mid并判断sum(mid) capacity时如果满足条件说明mid是一个可行的解并且可能有更大的limit也满足条件。所以我们应该到右半区间去搜索即left mid。如果不满足条件说明mid太大了导致保留的球总数超过了容量那么limit必须减小所以应该到左半区间搜索即right mid - 1。 但是如果直接left mid在left和right相邻时例如left3, right4mid计算为(34)/23整数除法如果mid3满足条件则left被更新为3循环条件while (left right)仍然成立34但下次计算mid又是(34)/23会导致无限循环。这是二分查找中经典的“死循环”问题。 解决方案是使用mid (left right 1) / 2即取右中位数。这样在left3, right4时mid4。如果mid4满足条件则left4循环结束如果不满足则right3循环也结束。这样就避免了死循环。计算sum(limit)这是一个简单的遍历数组的过程累加min(num, limit)即可。注意使用长整型如long,long long来存储累加和防止在球数很大时整型溢出。3. 多语言代码实现与解析理解了核心算法代码实现就是水到渠成的事情。下面我将分别用 C语言、C、Java、Python 和 JavaScript 五种语言来实现并重点分析每种语言实现时的特有技巧和注意事项。3.1 C语言实现追求极致的效率与控制C语言的实现注重手动管理循环和条件判断代码看起来会相对“原始”但执行效率高对内存和计算过程有完全的控制权。#include stdio.h #include stdlib.h #include limits.h // 计算当限制为limit时所有盒子保留的球总数 long long calculateSum(int* nums, int numsSize, int limit) { long long sum 0; for (int i 0; i numsSize; i) { sum (nums[i] limit) ? nums[i] : limit; } return sum; } int* minBallsToRemove(int* nums, int numsSize, int capacity, int* returnSize) { *returnSize numsSize; int* result (int*)malloc(numsSize * sizeof(int)); if (result NULL) { return NULL; } // 特殊情况处理如果初始总球数已经小于等于容量则不需要取出任何球 long long total 0; int maxBalls 0; for (int i 0; i numsSize; i) { total nums[i]; if (nums[i] maxBalls) { maxBalls nums[i]; } } if (total capacity) { for (int i 0; i numsSize; i) { result[i] nums[i]; } return result; } // 二分查找最大的limit int left 0; int right maxBalls; while (left right) { // 使用右中位数避免死循环 int mid left (right - left 1) / 2; if (calculateSum(nums, numsSize, mid) capacity) { left mid; // mid可行尝试更大的值 } else { right mid - 1; // mid不可行必须减小 } } int limit left; // 最终找到的limit // 根据limit生成结果数组 for (int i 0; i numsSize; i) { result[i] (nums[i] limit) ? nums[i] : limit; } return result; } // 示例用法 int main() { int nums[] {2, 4, 7, 9, 5}; int capacity 15; int returnSize; int* result minBallsToRemove(nums, 5, capacity, returnSize); printf(每个盒子最终保留的球数\n); for (int i 0; i returnSize; i) { printf(%d , result[i]); } printf(\n); // 计算并输出取出的球数 long long removed 0; long long original_total 0; for (int i 0; i returnSize; i) { original_total nums[i]; removed (nums[i] - result[i]); } printf(原始总球数%lld\n, original_total); printf(取出球总数%lld\n, removed); printf(剩余总球数%lld (需 %d)\n, original_total - removed, capacity); free(result); // 释放动态分配的内存 return 0; }C语言实现要点解析内存管理函数minBallsToRemove需要返回一个数组因此必须使用malloc在堆上动态分配内存。调用者如main函数在使用完毕后必须调用free释放该内存防止内存泄漏。这是C语言编程的基本功也是面试中常考的要点。溢出处理calculateSum函数和total的计算使用了long long类型。这是因为题目中球数和容量可能很大用int累加可能导致溢出产生错误结果。在机试中不注意数据范围导致的溢出是常见的失分点。提前特判在二分查找之前先计算原始总球数total。如果total capacity那么直接返回原数组即可无需进行二分查找。这是一个有效的优化虽然不影响时间复杂度的大O表示但在某些场景下能提前结束。二分查找细节mid left (right - left 1) / 2是防止死循环的关键写法。(right - left 1) / 2等价于(left right 1) / 2但前者能更好地避免left right可能出现的溢出尽管本题中left和right都是int一般不会溢出但这是一个好习惯。3.2 C实现利用STL简化代码C在C的基础上引入了标准模板库STL让代码更简洁、更安全。#include iostream #include vector #include algorithm #include numeric // 用于 accumulate using namespace std; class Solution { public: vectorint minBallsToRemove(vectorint nums, int capacity) { int n nums.size(); vectorint result(n); // 计算原始总和和最大值 long long total accumulate(nums.begin(), nums.end(), 0LL); int max_val *max_element(nums.begin(), nums.end()); // 特判如果总和已经满足条件直接返回原数组 if (total capacity) { return nums; } // 二分查找 limit int left 0, right max_val; while (left right) { int mid left (right - left 1) / 2; if (calculateSum(nums, mid) capacity) { left mid; } else { right mid - 1; } } int limit left; // 生成结果 for (int i 0; i n; i) { result[i] min(nums[i], limit); } return result; } private: long long calculateSum(const vectorint nums, int limit) { long long sum 0; for (int num : nums) { sum min(num, limit); } return sum; } }; int main() { Solution sol; vectorint nums {2, 4, 7, 9, 5}; int capacity 15; vectorint result sol.minBallsToRemove(nums, capacity); cout 每个盒子最终保留的球数 endl; for (int num : result) { cout num ; } cout endl; // 验证 long long remaining accumulate(result.begin(), result.end(), 0LL); cout 剩余总球数: remaining (需 capacity ) endl; long long removed accumulate(nums.begin(), nums.end(), 0LL) - remaining; cout 取出球总数: removed endl; return 0; }C实现要点解析使用STL算法accumulate用于便捷地计算容器内元素的和max_element用于查找最大值min函数用于简化比较。这些算法让代码意图更清晰减少了手写循环可能出现的错误。容器与RAII使用vectorint管理动态数组无需手动malloc/free内存管理通过RAII资源获取即初始化机制自动进行更加安全。函数封装将calculateSum作为类的私有方法逻辑清晰。注意参数使用const vectorint传递避免不必要的拷贝。面向对象将解决方案封装在Solution类中这是一种常见的刷题和面试代码组织方式清晰且易于测试。3.3 Java实现健壮性与工程化Java代码强调健壮性和清晰的接口通常会考虑更多的边界情况。import java.util.Arrays; public class OpenDayActivity { public int[] minBallsToRemove(int[] nums, int capacity) { int n nums.length; int[] result new int[n]; // 计算总和和最大值 long total 0; int maxVal 0; for (int num : nums) { total num; if (num maxVal) { maxVal num; } } // 特判 if (total capacity) { // 注意返回原数组的拷贝避免修改原数组 return Arrays.copyOf(nums, n); } // 二分查找 int left 0, right maxVal; while (left right) { int mid left (right - left 1) / 2; if (calculateSum(nums, mid) capacity) { left mid; } else { right mid - 1; } } int limit left; // 生成结果 for (int i 0; i n; i) { result[i] Math.min(nums[i], limit); } return result; } private long calculateSum(int[] nums, int limit) { long sum 0; for (int num : nums) { sum Math.min(num, limit); } return sum; } public static void main(String[] args) { OpenDayActivity solution new OpenDayActivity(); int[] nums {2, 4, 7, 9, 5}; int capacity 15; int[] result solution.minBallsToRemove(nums, capacity); System.out.println(每个盒子最终保留的球数); for (int num : result) { System.out.print(num ); } System.out.println(); // 验证 long remaining 0; for (int num : result) { remaining num; } System.out.println(剩余总球数: remaining (需 capacity )); long originalTotal 0; for (int num : nums) { originalTotal num; } System.out.println(取出球总数: (originalTotal - remaining)); } }Java实现要点解析数组拷贝在特判分支if (total capacity)中我们返回Arrays.copyOf(nums, n)而不是直接返回nums。这是一个良好的实践可以防止调用者意外修改了返回的数组进而影响到原数组nums的数据。这体现了Java对数据封装和防御性编程的重视。使用Math.minJava的Math.min方法清晰易读。长整型处理和C/C一样累加和使用long类型防止溢出。类与方法设计将解题逻辑放在一个类的方法中main函数作为测试入口结构清晰符合Java的工程规范。3.4 Python实现简洁与高效Python以其极致的简洁性著称非常适合快速实现算法原型和思路验证。from typing import List def min_balls_to_remove(nums: List[int], capacity: int) - List[int]: n len(nums) total sum(nums) max_val max(nums) # 特判 if total capacity: return nums[:] # 返回一个副本 # 辅助函数计算限制为limit时的总和 def calculate_sum(limit: int) - int: return sum(min(x, limit) for x in nums) # 二分查找 left, right 0, max_val while left right: mid (left right 1) // 2 # 右中位数 if calculate_sum(mid) capacity: left mid else: right mid - 1 limit left # 生成结果 return [min(x, limit) for x in nums] # 示例 if __name__ __main__: nums [2, 4, 7, 9, 5] capacity 15 result min_balls_to_remove(nums, capacity) print(每个盒子最终保留的球数) print(result) remaining sum(result) print(f剩余总球数: {remaining} (需 {capacity})) removed sum(nums) - remaining print(f取出球总数: {removed})Python实现要点解析列表推导式[min(x, limit) for x in nums]和sum(min(x, limit) for x in nums)用一行代码完成了循环和条件判断极其简洁。内置函数sum(),max()等内置函数性能优异且使用方便。类型提示from typing import List和- List[int]虽然不是强制要求但能提高代码的可读性和可维护性是编写高质量Python代码的好习惯。切片拷贝在特判返回时使用nums[:]返回原列表的一个浅拷贝避免直接返回引用。对于包含整数的列表浅拷贝足够。整数除法//运算符确保结果是整数这是二分查找所必需的。3.5 JavaScript实现前端与全栈视角JavaScript的实现需要考虑其在V8引擎下的执行特点代码风格偏向函数式和ES6语法。/** * param {number[]} nums - 每个盒子的初始球数数组 * param {number} capacity - 剩余球总容量上限 * return {number[]} - 调整后每个盒子的球数保留数 */ function minBallsToRemove(nums, capacity) { const n nums.length; // 计算总和和最大值 let total 0; let maxVal 0; for (const num of nums) { total num; if (num maxVal) maxVal num; } // 特判 if (total capacity) { return [...nums]; // 返回数组的浅拷贝 } // 辅助函数计算限制为limit时的总和 const calculateSum (limit) { let sum 0; for (const num of nums) { sum Math.min(num, limit); } return sum; }; // 二分查找 let left 0, right maxVal; while (left right) { const mid Math.floor((left right 1) / 2); // 右中位数 if (calculateSum(mid) capacity) { left mid; } else { right mid - 1; } } const limit left; // 生成结果数组 return nums.map(num Math.min(num, limit)); } // 示例 const nums [2, 4, 7, 9, 5]; const capacity 15; const result minBallsToRemove(nums, capacity); console.log(每个盒子最终保留的球数); console.log(result.join( )); const remaining result.reduce((a, b) a b, 0); console.log(剩余总球数: ${remaining} (需 ${capacity})); const originalTotal nums.reduce((a, b) a b, 0); console.log(取出球总数: ${originalTotal - remaining});JavaScript实现要点解析ES6语法使用const/let声明变量for...of循环箭头函数以及数组的map和reduce方法代码现代且简洁。数组拷贝在特判返回时使用扩展运算符[...nums]创建原数组的浅拷贝这是一个干净利落的做法。Math.floorJavaScript中除法/总是返回浮点数因此二分查找计算mid时必须使用Math.floor向下取整确保是整数。函数式编程nums.map(num Math.min(num, limit))用一行代码完成了结果数组的构建意图明确。大整数问题JavaScript的Number类型是双精度浮点数但在安全整数范围内Number.MAX_SAFE_INTEGER约9e15可以精确表示整数。对于本题通常的输入范围是足够的。如果题目明确数字极大可能需要使用BigInt类型。4. 算法复杂度分析与优化思考我们实现的算法核心是二分查找和每次查找过程中的求和计算。时间复杂度二分查找的次数是 O(log M)其中 M 是max(nums)。每次查找都需要遍历一次数组计算sum(limit)耗时 O(n)。因此总时间复杂度为O(n log M)。对于绝大部分机试场景这个复杂度是完全可接受的。空间复杂度除了存储结果的数组O(n)和几个临时变量算法只使用了常数级别的额外空间。因此如果不算返回结果所占用的空间通常不计入空间复杂度为O(1)。有没有可能优化到 O(n)理论上如果我们将所有盒子的球数排序然后使用前缀和技巧可以在 O(n log n) 排序后以 O(log n) 的时间计算任意limit下的sum(limit)。但排序本身已经是 O(n log n) 了再加上二分查找的 O(log M)整体复杂度并没有比 O(n log M) 有本质提升而且代码会复杂很多。在M不是特别巨大比如不超过 10^9的情况下O(n log M) 的解法通常就是最优解简洁且高效。实操心得在机试或面试中遇到这类问题第一时间应该判断其是否具有“单调性”从而考虑二分答案。O(n log M)的解法在解释清楚后面试官通常都会满意。不必一味追求理论上可能存在的、更复杂的最优解清晰、正确、健壮的代码才是首要目标。5. 常见陷阱与调试技巧即使理解了算法在实现时也可能遇到各种问题。下面是一些常见的“坑”和解决方法整数溢出这是最容易忽略也最致命的错误。capacity和单个nums[i]可能是int但它们的累加和很容易超过int的范围约21亿。务必使用long long(C/C)、long(Java)、int注意范围 (Python/JS) 来存储累加和。在C语言中我甚至见过有人用int做mid计算(left right 1) / 2时leftright1溢出导致负数进而产生错误的mid。二分查找的死循环如前所述寻找最大可行解时如果使用mid (left right) / 2和left mid的更新策略在left和right相邻时会陷入无限循环。牢记公式mid left (right - left 1) / 2。你可以这样记忆当更新条件是left mid时即满足条件向右边搜索mid要取右中位数当更新条件是right mid时即满足条件向左边搜索mid要取左中位数(left right) / 2。特判遗漏忘记处理total capacity的情况。虽然算法在limit max(nums)时也能得到正确结果但显式的特判能让代码逻辑更清晰并且在某些极端情况下如空数组更安全。返回值的误解题目要求输出的是“调整后每个盒子的球数”即保留数。有些同学可能输出成了“每个盒子需要取出的球数”。一定要仔细审题。调试技巧小数据测试用最简单的例子手动模拟比如nums [1, 2, 3], capacity 3。心算或纸上演算二分查找的每一步验证你的代码逻辑。打印中间变量在二分查找的循环里打印出left,right,mid,sum(mid)的值可以非常直观地看到搜索过程是否正确是否出现了死循环或者逻辑错误。边界测试输入空数组[]。capacity 0那么结果应该全是0。所有盒子球数相同如[5,5,5], capacity12。capacity非常大大于等于总和。capacity非常小比如0或1。使用在线判题系统的自定义测试很多刷题平台都支持自定义测试用例。设计几组包含上述边界情况的用例快速验证代码的鲁棒性。6. 从解题到举一反三掌握“二分答案”套路“开放日活动”这道题是“二分答案”算法的经典应用题。这类问题的通用特征是存在一个单调的函数f(x)其定义域x的取值范围是有序的通常是整数范围。我们需要找到一个最大或最小的x使得f(x)满足或不满足某个条件C。直接求解x很困难但给定一个x判断f(x)是否满足条件C却相对容易通常是 O(n) 或 O(n log n)。解题模板如下确定答案x的搜索范围[left, right]。设计判定函数check(mid)判断mid作为答案是否可行满足条件C。根据题目要求找最大还是最小可行解和check(mid)的返回值决定二分搜索的收缩方向寻找最大的可行解如本题如果check(mid)为真说明mid可行答案可能在[mid, right]令left mid否则答案在[left, mid-1]令right mid - 1。计算mid时使用mid (left right 1) / 2。寻找最小的可行解如果check(mid)为真说明mid可行但可能有更小的答案在[left, mid]令right mid否则答案在[mid1, right]令left mid 1。计算mid时使用mid (left right) / 2。循环结束时left(或right) 即为所求答案。同类问题举例“分割数组的最大值”给定一个数组和一个整数k将数组分成k个连续子数组使得这k个子数组各自和的最大值最小。这里x是“子数组和的最大值”f(x)是“在该最大值限制下最少需要分成多少段”条件是f(x) k。我们要找的是最小的x。“在 D 天内送达包裹的能力”传送带上的包裹必须在D天内运完求船的最低运载能力。x是运载能力f(x)是需要的天数条件是f(x) D。找最小的x。“制作 m 束花所需的最少天数”花需要时间生长求制作m束花的最少等待天数。x是天数f(x)是能制作的花束数量条件是f(x) m。找最小的x。当你发现题目中有“最大化最小值”、“最小化最大值”、“最大的可行解”、“最小的可行解”这类描述时就要立刻想到“二分答案”。多练习几道这个套路就会成为你解决中等难度算法问题的利器。7. 华为OD机试备考建议最后结合这道题给正在准备华为OD机试的朋友几点建议吃透经典题型与算法动态规划、深度/广度优先搜索、二分查找、双指针、滑动窗口、贪心、并查集、前缀和、单调栈/队列这些是机试高频考点。“二分答案”属于二分查找的变体务必掌握。重视代码的健壮性华为OD的机试平台通常会运行多个测试用例包括边界情况。你的代码必须能处理空输入、极大值、极小值等情况。数据溢出、数组越界、空指针是主要的失分点。时间与空间复杂度意识在动手前先估算一下数据规模n的范围选择合适复杂度的算法。像本题n可能达到 10^5M达到 10^9O(n^2) 的暴力法肯定超时O(n log M) 是合理的选择。熟练掌握一门语言无论是C/C、Java还是Python选择一门你最熟悉的语言并熟悉其标准库如C的STLJava的CollectionsPython的list/dict/set。这能极大提升你的编码速度和正确率。像本题中Python的sum()、max()、列表推导式能让你用极少的代码完成功能。调试与自测能力机试环境可能不提供强大的调试器因此要习惯使用print或System.out.println进行调试。在本地练习时就要养成自己设计测试用例的习惯包括功能用例、边界用例和压力用例。审题与沟通仔细阅读题目描述明确输入输出格式、数据范围、特殊要求。如果有疑问比如对题目描述的理解在模拟面试或真实机试中可以及时向考官澄清。“开放日活动”这道题就像一块很好的试金石它考察了你对问题的抽象能力将取球问题转化为二分搜索、对基础算法的掌握二分查找的实现细节、以及编码的基本功循环、条件判断、防止溢出。希望这篇详细的拆解能帮助你不仅搞定这一道题更能掌握这一类题在未来的机试和面试中从容应对。