尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

二分法实战:从礼物问题看算法优化与Python实现

二分法实战:从礼物问题看算法优化与Python实现 1. 从“礼物”问题看二分法的实战价值最近在准备蓝桥杯的算法训练刷到了不少关于“礼物”这道题的讨论。这道题本身并不复杂但它的解法——二分法却是一个在算法竞赛和实际开发中都极具威力的“大杀器”。很多初学者第一次接触二分法可能觉得它不就是在一个有序数组里找个数嘛有什么难的但“礼物”这道题恰恰能打破这种刻板印象它展示的是二分法在解决“最优化问题”时的核心思想在一个单调的答案空间里高效地逼近最优解。这比单纯的查找要深刻得多。简单来说这道“礼物”题通常描述为你有一定预算想给朋友们买礼物。商店里有N种礼物每种礼物的价格和库存已知。你需要决定一个最大的人数K使得你能够给至少K个朋友每人送一份礼物礼物可以不同且总花费不超过预算。这里的K就是我们要找的答案。暴力枚举K从1到总人数数据量稍大就会超时。而二分法能将这个搜索过程从O(N)优化到O(log N)这就是它的魔力所在。所以这篇文章我们不只讲这道题的AC代码更想深入聊聊如何识别一个问题适合用二分法以及用Python实现时有哪些容易踩坑的细节。无论你是正在备战蓝桥杯还是想巩固算法基础相信这种从具体问题到思想升华的拆解会比单纯背模板更有收获。2. 问题本质解析为什么二分法是最优解在动手写代码之前我们必须先吃透问题。题目描述通常是给定一个预算M和N种礼物。第i种礼物的单价是price[i]库存数量是stock[i]。我们需要找到一个最大的整数K满足我们可以选出K份礼物每种礼物最多选stock[i]份使得这K份礼物的总价格不超过M。2.1 将现实问题转化为数学模型首先最直观的想法是既然要最大化人数K那我就从大到小试试看呗。先假设K等于所有礼物库存的总和看看钱够不够如果不够就试K-1以此类推。这思路没错但复杂度是O(K * N)在K很大时比如十万、百万级别必然超时。这里的关键洞察在于是否存在一个函数f(K)能够快速判断“能否满足K个人”这个问题如果能并且这个函数关于K是单调的那么二分法就有了用武之地。什么是单调在这个场景里如果K个人可以满足即能花不超过M的预算买到K份礼物那么对于任意少于K的人数也一定可以满足钱有富余总能少买点。反之如果K个人无法满足那么对于任意多于K的人数也一定无法满足钱不够就是不够。这种“能满足”的性质随着K增大会从“是”突然变成“否”中间有一个明确的临界点。这个临界点就是我们要找的最大K。2.2 设计判定函数check(K)因此整个算法的核心就落在了如何高效实现这个判定函数check(K)上。它的逻辑是给定一个目标人数K我们如何用最省钱的方式凑出K份礼物贪心策略此时登场要最省钱我们肯定优先购买单价最便宜的礼物。所以步骤很清晰将所有礼物按单价从小到大排序。从最便宜的礼物开始购买尽量买光它的库存直到我们总共购买了K份礼物或者所有礼物都买完了。计算购买这K份或尽可能多份礼物所需的总花费total_cost。判断total_cost M是否成立。如果成立说明K是可行的否则不可行。这个贪心策略为什么正确因为预算是固定的我们要在数量达标的前提下最小化花费那么单价最小的礼物自然性价比最高。这是一个经典的“贪心选择性质”可以用反证法证明如果最优解中包含了某个单价较贵的礼物而没有买完一个更便宜礼物的库存那么我们可以用一份便宜礼物替换一份贵礼物从而在满足数量不变的前提下降低总花费这与“最优”矛盾。所以check(K)函数的时间复杂度是O(N log N)主要来自排序但排序可以提前做一次加上O(N)的遍历计算。这比暴力枚举K的O(K*N)要高效得多。3. 二分查找的边界与框架实现有了可靠的判定函数check(K)我们就可以用二分法来搜索那个最大的、可行的K了。3.1 确定二分搜索的上下界这是二分法最容易出错的地方之一。我们必须明确搜索的范围[left, right]。下界left最少可以送 0 份礼物虽然题目可能隐含至少送1份但从算法严谨性出发0通常是安全的起点。上界right最多可以送多少份最理想的情况我们买光所有最便宜的礼物。但一个简单且安全的上界是所有礼物库存的总和。因为即使预算无限我们也买不了超过库存总数的礼物。所以初始范围可以设为left 0,right sum(stock)。3.2 二分查找的两种模板与选择二分查找的循环条件以及left,right的更新方式决定了我们找到的是第一个“否”还是最后一个“是”。对于本题我们要找的是最后一个满足条件check(K)为True的K。这里我推荐使用“左闭右开”或“左闭右闭”区间中寻找右侧边界的那套模板。我个人更习惯使用以下方式它更直观地体现了“寻找最后一个True”def binary_search(left, right): while left right: # 这里 mid 的取法是为了避免死循环当 left 和 right 相邻时mid 会取 right mid (left right 1) // 2 if check(mid): # mid 可行说明答案至少是 mid也可能更大所以向右搜索 left mid else: # mid 不可行答案必须比 mid 小所以向左搜索 right mid - 1 # 循环结束时left right这个位置就是最后一个可行的 K return left为什么mid (left right 1) // 2这是关键技巧。当left和right相差1时例如left3, right4如果使用(leftright)//2得到3。若此时check(3)为True我们会令left mid 3区间变为[3, 4)循环条件left right依然成立但mid再次计算为(34)//2 3这就陷入了left始终等于3的死循环。加1后取整能保证mid偏向右侧从而打破这种平衡。另一种常见的模板是使用“左闭右开”区间[left, right)寻找第一个False的位置然后减一得到最后一个True。两种方式都可以但务必理解透彻一种并在代码中保持清晰的注释。3.3 整合代码框架将判定函数和二分搜索框架结合起来完整的解题骨架如下def main(): # 读取输入 M, N, price_list, stock_list (根据题目实际格式调整) M int(input()) N int(input()) gifts [] total_stock 0 for _ in range(N): p, s map(int, input().split()) gifts.append((p, s)) total_stock s # 按单价排序 gifts.sort(keylambda x: x[0]) # 判定函数 def can_serve(k): cost 0 needed k for p, s in gifts: take min(s, needed) # 当前礼物最多能拿的数量 cost take * p needed - take if needed 0: # 已经凑够k份 break # 如果过程中花费已超预算可以提前结束剪枝 if cost M: return False return cost M # 二分查找 left, right 0, total_stock while left right: mid (left right 1) // 2 if can_serve(mid): left mid else: right mid - 1 print(left) if __name__ __main__: main()4. Python实现中的性能陷阱与优化技巧上面的框架在逻辑上是正确的但在蓝桥杯或其他OJ平台面对大规模数据时细节决定成败。以下是几个必须注意的优化点。4.1 输入输出效率sys.stdin与列表推导式Python的input()在读取大量数据时比较慢。标准的优化方法是使用sys.stdin.read()或sys.stdin.buffer.read()一次性读取然后分割处理。import sys def main(): data sys.stdin.buffer.read().split() # 假设输入格式为M N, 然后是N行的 p s it iter(data) M int(next(it)) N int(next(it)) gifts [] total_stock 0 for _ in range(N): p int(next(it)) s int(next(it)) gifts.append((p, s)) total_stock s # ... 后续排序和二分逻辑对于输出如果只输出一个数字print()问题不大。但如果需要输出多行可以考虑将结果存入列表最后用\n.join(map(str, results))一次性输出。4.2 判定函数can_serve(k)的剪枝在can_serve(k)函数中我们一边累加花费cost一边判断是否已经超过预算M。一旦超过立即返回False无需继续遍历后面的礼物。这是一个非常重要的剪枝能显著减少计算量尤其是在K值较大、预算较紧张时。4.3 避免整数溢出与使用bisect模块虽然Python的整数不会溢出但养成好习惯很重要。在计算take * p时如果p和take都很大乘积可能是一个非常大的数虽然Python能处理但会影响计算速度。剪枝操作if cost M: return False可以避免无意义的大数计算。另外Python标准库的bisect模块提供了高效的二分查找函数但它主要用于在已排序的列表中查找插入位置。对于本题这种需要自定义判定函数的情况手动实现二分循环更为灵活和直观不建议生搬硬套bisect。4.4 排序的稳定性与礼物去重题目没有说礼物单价是否唯一。如果存在单价相同的礼物我们的排序操作是稳定的list.sort()是稳定排序但这不影响贪心策略因为单价相同先买哪个都一样。如果礼物种类非常多比如超过10^5排序的O(N log N)会成为主要开销但这是无法避免的。5. 从“礼物”到泛化识别二分答案问题的特征解完这道题更重要的是掌握一类问题的解法。“礼物”问题是典型的“二分答案”或“二分查找判定”问题。我们可以总结出这类问题的几个共同特征答案单调性存在一个目标值ans以及一个判定函数check(x)。对于所有x anscheck(x)为True对于所有x anscheck(x)为False或相反。这种“一分为二”的特性是二分的基石。直接求解困难问题要求最大化或最小化某个值直接求解这个最优值很困难通常是NP难或者没有多项式解法。判定相对简单但是如果给你一个候选答案x让你判断“x是否可行”这个问题则可以在多项式时间内解决通常通过贪心、模拟、图论、动态规划等。除了“礼物”蓝桥杯和各类算法竞赛中还有很多类似问题“跳石头”在一条数轴上移走最多M块石头使得最短跳跃距离最大。判定函数模拟在给定最短距离下需要移走多少石头。“砍树”锯树要求锯掉的总长度至少为M但希望锯掉的单段高度最大。判定函数给定一个高度H计算锯掉高度超过H的部分的总长度。“月度开销”将N个连续的费用分成M段使得最大段的和最小。判定函数给定一个上限S判断能否在S的限制下将序列分成不超过M段。当你遇到“最大/最小化某个值”的问题时多问自己一句“如果让我猜一个答案我能快速验证它是否可行吗” 如果答案是肯定的并且答案空间是单调的那么二分法很可能就是那把钥匙。6. 调试与验证如何确保二分代码的正确性二分法代码看似简短但边界条件极易出错。以下是我常用的调试和验证方法小数据暴力验证对于小范围的N和K写一个暴力枚举所有可能K的算法与你的二分算法结果对比。这是最直接有效的方法。打印搜索过程在二分循环中临时打印left,right,mid,check(mid)的值观察搜索区间是如何缩小的。这能帮你发现是循环条件不对还是mid的更新有问题。测试边界案例预算无限大M非常大答案应该是总库存total_stock。预算为零M为0答案应该是0如果所有礼物价格为正。只有一种礼物N1测试二分逻辑是否依然工作。所有礼物单价相同测试排序和贪心逻辑。库存非常大测试累加过程是否会因为未剪枝而变慢。理解循环不变量明确你的left和right在循环中始终维持什么性质。例如在我上面提供的模板中循环不变量可以是“left总是满足check(left) True而right1总是满足check(right1) False或者right是上界”。在循环结束时left就是最大的满足条件的值。最后将“礼物”问题的完整、优化后的Python代码附上供大家参考和测试。记住理解思想比记住代码更重要。下次遇到类似问题尝试自己分析出单调性和判定函数你才算真正掌握了二分答案的精髓。import sys def solve(): data sys.stdin.buffer.read().split() if not data: return it iter(data) M int(next(it)) N int(next(it)) gifts [] total_stock 0 for _ in range(N): price int(next(it)) stock int(next(it)) gifts.append((price, stock)) total_stock stock # 按单价排序 gifts.sort(keylambda x: x[0]) # 判定函数能否满足k个人 def can_serve(k: int) - bool: 检查是否能用不超过M的预算购买k份礼物 remaining k total_cost 0 for price, stock in gifts: # 当前礼物最多能取的数量 take stock if stock remaining else remaining total_cost take * price if total_cost M: # 关键剪枝超过预算立即返回False return False remaining - take if remaining 0: break # 循环结束如果remaining0说明库存不够但根据题意和total_stock上界一般不会发生 # 主要判断花费 return total_cost M # 二分查找最大的可行k left, right 0, total_stock while left right: # 注意这里要1防止死循环 mid (left right 1) // 2 if can_serve(mid): left mid # mid可行尝试更大的 else: right mid - 1 # mid不可行必须减小 print(left) if __name__ __main__: solve()
返回列表