news 2026/9/26 17:54:40

贪心算法+堆+排序:LeetCode 2208与2406的最优解拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
贪心算法+堆+排序:LeetCode 2208与2406的最优解拆解

刷算法题这件事,很多人觉得是“背模板”,但真正到了LeetCode 2208和2406这两道题面前,你会发现光背模板根本不够——一个考的是“数组和减半的最少操作次数”,一个考的是“将区间分为最少组数”。两题看起来一个在折腾数组、一个在折腾区间,但底层都是同一类思维:贪心。我最初刷这两题时也想当然地写了暴力,结果一个超时一个翻车,后来才意识到,它们之所以常被拿来当面试题,是因为能迫使你在“局部最优”和“全局最优”之间做判断,并且必须熟练使用堆和排序这两个基础武器。

这篇文章我会把两道题一起拆开讲:先从题意和考点入手,再逐步推导为什么贪心成立,接着给出可直接复现的Python实现,最后聊聊差分数组这种替代思路和实战中容易踩的坑。无论你是刚刷数组/区间专题的新手,还是准备面试想快速复盘的老手,都能在这里找到一套“下次遇到同类题直接秒”的思考路径。

1. 两题概览:一个折腾数组,一个折腾区间

1.1 将数组和减半的最少操作次数(2208)

题目给一个正整数数组nums,每次操作可以选任意一个元素,把它的值减半,问最少操作多少次,能让数组所有元素之和至少减少一半。

举个例子:nums = [5, 19, 8, 1],数组和是33,目标是至少减少到16.5(也就是减少量达到16.5)。如果第一次把19减半成9.5,数组和变成23.5,减少9.5;第二次把9.5减半成4.75,数组和变成18.75,累计减少14.25;第三次把8减半成4,数组和变成14.75,累计减少18.25,这就算达标。三次操作就是答案。你可能会想:为什么不第一次减8,而是先减19?这就是题目核心考点——每次操作选谁,才能让操作次数最少。

1.2 将区间分为最少组数(2406)

题目给一个二维数组intervals,每个元素形如[left, right],表示一个闭区间。现在要把所有区间分配到若干个组里,同一个组内的任意两个区间不能重叠(注意闭区间里端点重合也算重叠,比如[1, 3]和[3, 5]不能放同一组)。问最少需要分成多少组。

例如intervals = [[5,10],[6,8],[1,5],[2,3],[1,10]],肉眼可以看出[1,10]这个长区间几乎和所有区间都重叠,至少要单独占一组;剩下的区间里[2,3]、[6,8]之间没有重叠,可以并组,但[5,10]和[1,5]端点5重叠,也不能同组。最终结果是3组。这个问题本质上是在问:同一时刻最多有几个区间“叠在一起”,这个最大重叠数就是最少组数。

1.3 两道题放在一起看的共同特征

两题都涉及“选择顺序”和“全局约束”。2208每次要选一个元素操作,2406每次要决定区间放进哪个组;如果你凭感觉乱选,结果经常不是最优。它们共同指向两个高频工具:优先队列(堆)和排序。2208需要在每次操作时快速拿到当前最大值,2406需要在扫描区间时快速找到可复用的组,两者都可以用堆把复杂度压到O(n log n)级别。

维度2208 数组和减半2406 区间分组
数据结构数组、优先队列区间数组、优先队列
核心策略每次减半最大元素左端点排序 + 最小堆维护各组最右端点
时间复杂度O(n log n)O(n log n)
本质问题最大化单次收益最小化资源组数

2. 贪心思路拆解:为什么局部最优能推出全局最优

2.1 2208:为什么每次都减当前最大元素一定最优

假设当前数组和是S,目标减少量是S / 2。你每次操作能让某个元素变为原来的一半,也就是说本次操作带来的“减少量”等于当前元素值 / 2。要想尽快累计到目标减少量,直觉上当然是每次让“减少量”尽量大。

我一开始想过一个问题:比如有两个元素a=100和b=60,目标减少量是80。如果先减100,减少50,再减50,累计75,还不够,再减25,累计100,共3次;如果先减60,减少30,再减30,累计60,再减100,减少50,累计110,也是3次。看起来次数相同?那贪心还成立吗?关键在于,这是特殊场景下碰巧相同,换成a=100, b=99,目标减少量55:先减100,第一次减少50,第二次减50变成25,累计75达标题;先减99,第一次减少49.5,第二次减49.5累计99也达标,还都是两次。再换a=100, b=1,目标减少量是40:先减100只需一次就减少50达标题;先减1,第一次减少0.5,再减0.5,要减几百次才够。所以贪心的收益不是体现在“局部立刻达标题”,而是体现在“把大盘子的价值榨干”——最大元素减半后依然可能比别的小元素大,继续减它仍然收益更高。每次取最大值,就等价于在保证“每一步都获取最大可得的减少量”,而由于每次操作获得的收益是递减的、且相互独立,每一步最优的累加就是全局最优。这个证明思路用反证法可以写清楚:如果某次操作不选当前最大元素x,而选了y,那么这次收益y/2一定不大于x/2;并且减完y后,x仍然原样保留,后续收益不会比“先减x再处理y”更好。因此最优解一定包含“每次减最大”这个策略。

这里给一个更直观的生活类比:你有一堆大小不一的冰块,想尽快把它们化成一半。你肯定先拿最大那块去晒,因为它融化出的水最多;晒完它可能还是很大,继续晒它依然划算,直到它小到不如第二大的那块,才切换目标。这个“切换”的过程,恰好就是优先队列每次弹最大值、减半后重新入队的过程。

2.2 2406:区间分组本质上是在求“最大重叠厚度”

把每个区间想象成一段会议时间,你要用最少的会议室安排所有会议,同一个会议室里不能同时开两场会(端点时间也被占用)。那么“最少会议室数量”就是所有时刻里“同时进行的会议数量”的最大值。这个结论看起来很直觉,但要证明足够严谨:一方面,任意时刻如果有k个区间重叠,这k个区间必然两两不能同组,所以至少需要k组,因此答案必定大于等于最大重叠数;另一方面,我们需要证明可以用“最大重叠数”这么多组就安排完,不会需要更多。这个构造性证明可以通过“按左端点排序,再用最小堆贪心地复用组”来完成。

当你把区间按左端点从小到大排序后,从左往右扫描。假设当前已经开了若干组,每组记录它最后一个区间的右端点,也就是“这组最晚的结束时间”。下一个区间[l, r]如果想放进某一组,要求这组最后结束时间小于l(严格小于,因为闭区间端点重合算冲突)。在所有结束时间中,肯定优先选择“结束时间最早”的那一组来尝试——如果最早的结束时间都大于等于l,说明当前所有组都还忙,只能新开一组;如果最早的结束时间小于l,那用这一组放入新区间一定最优,因为其他组的结束时间更晚,留它们继续占用反而更灵活。这个过程不断维护组的结束时间集合,最终组数不会超过最大重叠数,因为只有当某个时刻确实同时存在“当前活跃区间数+1”的重叠时,你才会新开组。两相结合,正好证明“最少组数等于最大重叠数”。

2.3 贪心的使用前提:选了一次不影响后续的“可选择集合”

很多新手对贪心最大的困惑是:为什么这里能用贪心,别的地方不能用?答案在于:这两道题里,每次操作的“选择集合”不会因为你选了某个元素而改变其他元素的相对价值——2208里,减半一个数不会影响别的数的大小;2406里,决定把新区间放入哪一组,不会影响后续区间的排序结果。也就是说,局部决策不会改变未来的可选范围,所以局部最优能叠加成全局最优。反过来,像背包问题、旅行商这类问题,你选了某件物品就把容量占了,会影响后续可选空间,贪心就不一定成立。

3. 核心实现:用堆把贪心落地

3.1 2208 的大顶堆写法

Python 里heapq是小顶堆,想用大顶堆,最简单的办法是存入负值。核心流程:先算出原数组总和,设定目标减少量为总和的一半;把所有元素取负后入堆,循环里弹出堆顶(绝对值最大的元素),把它减半,再取负入堆;同时累计减少量,直到减少量大于等于目标值,返回操作次数。

import heapq from typing import List def halveArray(nums: List[int]) -> int: total = sum(nums) # 原数组总和 target = total / 2 # 需要减少的量 heap = [-x for x in nums] # 大顶堆:存负数,堆顶是绝对值最大的 heapq.heapify(heap) reduced = 0.0 ops = 0 while reduced < target: # 弹出当前最大元素,减半后再放回去 cur = -heapq.heappop(heap) half = cur / 2 reduced += half heapq.heappush(heap, -half) ops += 1 return ops

这段代码有几个细节值得说一下。第一,total可能很大,但题目给的数值范围在int范围内,最后比较时用浮点target,注意 Python 浮点精度足够处理到1e-5级别的比较,因为每次减半后差值不会出现极小误差导致死循环。第二,为什么是cur / 2而不是cur // 2?因为题目说的是减半,不是整除,需要保持浮点精度。第三,循环结束条件是reduced >= target,不是sum(heap) <= target,后者的计算成本是 O(n),会拖慢整体性能。

3.2 2406 的最小堆写法

按左端点排序后,用一个最小堆维护“各组的最后右端点”。遍历每个区间时,先看堆顶(最小的右端点)是否小于当前区间的左端点:如果严格小于,说明有组已经空闲,可以复用,就把堆顶弹出并用当前区间右端点顶替;否则新开一组,把右端点直接入堆。最后堆的大小就是最少组数。

import heapq from typing import List def minGroups(intervals: List[List[int]]) -> int: intervals.sort(key=lambda x: x[0]) # 按左端点排序 heap = [] # 小顶堆,存每个组当前的最后右端点 for left, right in intervals: if heap and heap[0] < left: # 最早结束的组已经空闲,复用这一组 heapq.heappop(heap) # 无论复用还是新开,当前区间的右端点都要入堆 heapq.heappush(heap, right) return len(heap)

这里最容易写错的就是heap[0] < left和heap[0] <= left的区别。因为题目定义是闭区间,[1,3]和[3,5]端点3重叠,不能放同一组,所以必须严格小于才能复用。如果题目改成开区间,那<=就对了。这个边界我一开始就踩了坑,提交之后发现答案总是比预期大1,排查了半天才反应过来。

3.3 复杂度分析与数据规模论证

2208 的时间复杂度看起来是 O(k log n),其中 k 是操作次数。最坏情况下会操作多少次?每次操作至少把一个元素减半,最多操作次数不会超过把所有元素都降到非常小的程度,但实际题目中目标只是减少总和的一半。可以这样估算:每次减少量至少是“当前最小非零元素的一半”,而每次取最大值,减少量往往远大于这个下界,所以操作次数通常远小于 n。不过即使按最坏情况看,每次操作都是O(log n),总体也是可以接受的,实际提交在n <= 10^5范围内都能秒过。2406 的复杂度更明确:排序 O(n log n),每个区间入堆、出堆各一次,总复杂度 O(n log n),空间复杂度 O(n)。

这里额外提一个热词相关的点:很多人搜“数组排序的几种方法”,其实在这类题目里,排序不是目的,是为了给贪心扫描提供有序的输入。2208 不需要排序,因为它要的是随时取最大元素,这恰恰是堆的用武之地;如果换成一个乱序数组做排序再每次取最大,复杂度反而会退化。数据结构选择背后的依据是“你需要什么样的数据访问顺序”,而不是“哪个API更酷”。

4. 另一种视角:差分数组与扫描线

4.1 为什么区间重叠可以转成“事件叠加”

区间分组的问题还可以用差分数组来做,这个思路在热词里也频繁出现,比如“数组求区间最大值的算法题”“树状数组模板”“差分数组”等等。核心思想是:把每个区间看成两个事件,左端点位置“进入量 +1”,右端点之后的位置“进入量 -1”。如果我们把坐标轴上的每个点扫一遍,累加这些事件,得到的值就是“当前点的重叠区间数”。那么所有点里重叠数的最大值,就是最少组数。

这里需要特别小心端点边界。因为闭区间[left, right]中right本身也算区间内,所以区间对重叠数的贡献应该是[left, right]闭区间内每个点加1。如果用差分,应该在left处 +1,在right + 1处 -1,这样扫到right时重叠数还在,到right + 1才减掉。如果你写成right处 -1,就会少算右端点的重叠,答案可能偏小。

4.2 用事件排序法实现扫描线

坐标范围如果很大,直接开数组会爆内存,所以要先把所有事件排序,再从左到右扫。一种简洁写法是:

from typing import List def minGroups(intervals: List[List[int]]) -> int: events = [] for left, right in intervals: events.append((left, 1)) # 左端点进入 events.append((right + 1, -1)) # 右端点之后离开 events.sort(key=lambda x: x[0]) cur = 0 max_overlap = 0 i = 0 while i < len(events): pos = events[i][0] # 把同一坐标的所有事件一次性处理 while i < len(events) and events[i][0] == pos: cur += events[i][1] i += 1 max_overlap = max(max_overlap, cur) return max_overlap

为什么同一坐标的事件要一次性处理?因为如果同一个坐标点上既有离开事件又有进入事件(比如[1,3]右端点3的离开事件发生在4,另一个区间[4,5]的左端点进入事件发生在4),它们并不会在同一时刻叠加。把同一坐标所有事件合并处理,可以避免出现中间态的虚高。这个细节在热词“合并重叠区间”“无重叠区间”的很多题解里也是容易忽略的点。

4.3 堆方案和差分方案怎么选

堆方案的空间复杂度是 O(n),差分事件方案的空间也是 O(n),但差分方案的时间主要是排序事件,常数比堆稍大一点。更重要的是思考方式:堆方案是“在线”的,你边扫区间边维护组状态,像开会议室一样一张一张安排;差分方案是“离线”的,你先算出每个点的重叠度,直接取最大值,更像是在做统计。

实际面试中我建议优先掌握堆写法,因为它能直接套用到很多变体题上,比如“会议室II”就是一模一样的题目。差分方案则更适合用来验证答案,或者当你需要额外知道“哪个点重叠最多”的时候。两种方法都写一遍,你对区间问题的理解会明显深一层。

5. 实战避坑:我在这两题上翻过的车

5.1 2208 的浮点精度和循环边界

第一版代码我犯了个低级错误:把total直接除以2之后存成整数,导致目标值比实际小1,答案少算一次。第二版我学乖了,用浮点target,结果又因为比较时用了reduced <= target导致差一点点就退不出循环。正确的写法是while reduced < target,并且循环体内部每次减少量都是正数,总能收敛。

还有一个细节:堆里存负数后,取出时要记得取反。我见过不少新手在heapq.heappop(heap)之后忘记加负号,直接拿负数做除法,结果减少量变成负数,循环直接死循环。这种错误编译期不会报,运行期看起来只是“不结束”,特别难排查。建议把取反和减半写成一行:cur = -heapq.heappop(heap) / 2,然后直接reduced += cur,再把-cur塞回堆,这样逻辑最清晰。

5.2 2406 的边界条件:闭区间和左端点排序

最大的坑就是heap[0] < left和heap[0] <= left。因为闭区间端点重叠冲突,必须严格小于。题目里如果给你的是开区间,比如(1,3)和(3,5)不重叠,那就要改成heap[0] <= left。建议在做题前先看清题目对“重叠”的定义,不同平台的表述不完全一致。LeetCode 2406 明确写了闭区间且端点相接触也算重叠,所以严格小于是唯一的正解。

另一个容易踩的坑是排序时只排左端点够不够。如果两个区间左端点相同,优先排右端点小的还是大的?其实对于这个题来说,左端点相同的情况下,它们的入堆顺序不影响最终组数,因为堆里维护的是每组右端点,左端点相同的区间必然无法放入同一个正在处理位置之前的组里,最终堆大小的计算依然正确。不过为了习惯统一,我一般会写成intervals.sort(key=lambda x: (x[0], x[1])),这能保证在依赖扫描顺序的其他变体题里也不出错。

5.3 常见错误速查表

题目错误现象原因正确做法
2208结果总是比答案小1目标值用整数除法,丢了0.5target = sum(nums) / 2
2208程序死循环堆里负数未取反就做除法先取反再减半,再取反入堆
2208用sum(heap)判断结束每次求和O(n),超时维护累计减少量,用reduced判断
2406结果偏大用heap[0] <= left判断复用闭区间必须heap[0] < left
2406结果偏小差分事件在right处 -1要在right + 1处 -1
2406事件扫描中间态虚高同一坐标事件未合并先合并同一坐标所有事件再更新答案

6. 题型延伸:这套思路还能直接套到哪些题

6.1 “减半”类问题的变体与扩展

2208 的变体非常多。比如把目标改成“把数组和减少到小于某个阈值k”,那只需要把循环条件换成total - current_sum < k即可;如果是“每次可以选两个元素同时减半”,那堆里每次弹出两个最大值处理;如果是“每次操作可以让某个元素减少三分之一”,思路完全一样,只是减少量的计算方式变了。核心都是:用优先队列维护当前最大的操作对象,每次都能获取最大收益。这个套路也可以迁移到“合并石子最少代价”一类问题上,只是那里用的是小顶堆取两个最小值合并,收益模型不同,但数据结构的选型逻辑一致。

6.2 “区间分组”类题目的全家桶

热词里反复出现的“无重叠区间”“合并重叠区间”“会议室II”和2406的关系非常紧密。252 会议室是一道基础判断:能不能用一个会议室安排全部会议,等价于最大重叠数是否不超过1;253 会议室II就是2406的原题;435 无重叠区间是求最少移除几个区间能让剩余区间互不重叠,思路是排序后贪心保留右端点小的区间;56 合并区间则是在排序后看相邻区间的重叠情况做合并。把这些题放在一起刷,你会发现排序 + 堆/双指针几乎覆盖了所有区间类题目的解空间。

如果还想挑战更高阶的变体,可以试试这类:给出很多查询区间和一个值域,问每个查询区间包含了多少个点,这种题通常会用到扫描线加树状数组,本质上也还是“事件 + 区间贡献”的扩展。热词里提到的“树状数组模板”“二维数组”“指针数组”等,都是顺着这个方向延伸出去的。

6.3 刷题时的个人复盘建议

我自己的习惯是,每刷完一组题就停下来问三个问题:这题如果数据范围扩大十倍还能不能过;这题换成开区间/闭区间答案会怎么变;这题能不能用两种不同算法做,各自复杂度如何。2208 和 2406 正好适合做这种复盘,因为它们实现代码都很短,但背后的分析链条很长。尤其是2208,你甚至可以手动模拟几轮堆的变化,把“为什么每次取最大”在纸上画出来,印象会比看十篇题解都深。

我个人在实际操作中还有一个体会:很多题解喜欢直接贴代码,但很少讲“为什么这个贪心是对的”和“为什么边界要这样处理”。如果你刷这两题时能把证明过程也写一遍,哪怕只写给自己看,之后再遇到“会议室II”“无重叠区间”这类题,基本就是送分题了。这也是这篇文章把大量篇幅放在推导和踩坑上的原因——代码你看一眼就会,但判断依据和边界意识,才是真正值钱的东西。

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

AI Agent开发实战:从概念拆解到安全评估的完整路径

这阵子我一直在研究AI Agent方向&#xff0c;每天泡在agent框架、agent记忆、agent安全这些东西里&#xff0c;说实话&#xff0c;这个方向现在热得有点发烫&#xff0c;但真正能把概念讲清楚、能把项目落地的人其实不多。很多人一上来就跟我聊“我准备做一个AI Agent”&#x…

作者头像 李华
网站建设 2026/9/26 17:53:30

Agent-Native改造:让传统系统成为AI Agent的一等公民

上个月刚把一个老旧的内部排班系统改造成可以被 AI 直接调用的服务&#xff0c;改完之后有个很深的感触&#xff1a;过去我们做软件&#xff0c;默认用户是"人"&#xff0c;要照顾人的视觉习惯、操作直觉、点击路径&#xff0c;甚至耐心程度&#xff1b;但现在越来越…

作者头像 李华
网站建设 2026/9/26 17:52:35

基于Django+Flask的无人超市管理系统架构与实现

去年年底帮朋友搭一套校园里的无人超市原型机&#xff0c;前端结算屏、后台进销存、门禁联动都要有&#xff0c;项目排期压得紧&#xff0c;最后用了Django加Flask这套Python双框架组合&#xff1a;Django管运营后台和核心数据&#xff0c;Flask跑门禁接口和轻量服务&#xff0…

作者头像 李华
网站建设 2026/9/26 17:51:57

超宽禁带半导体氮化硼:材料特性、制备工艺与器件应用指南

宽禁带半导体这几年在国内半导体圈子里讨论度一直很高&#xff0c;碳化硅和氮化镓几乎成了代名词&#xff0c;一个扛着千伏级功率器件的大旗&#xff0c;一个在高频通信里连连突破。但我今天想把视角拉到另一个材料上——氮化硼。它的禁带宽度能冲到6个电子伏特上下&#xff0c…

作者头像 李华
网站建设 2026/9/26 17:51:55

浸没式液冷光模块在储能机柜中的应用与安全解析

浸没式液冷这个词&#xff0c;今年在数据中心圈子里已经不算新鲜了&#xff0c;但放到储能机柜里&#xff0c;不少人心里就犯嘀咕&#xff1a;电池是发热大户&#xff0c;泡在冷却液里我理解&#xff0c;可光模块这种娇贵的光电器件&#xff0c;也跟着泡进去&#xff0c;到底是…

作者头像 李华