☰
灌溉花园的最少水龙头数:区间覆盖贪心建模全解析
2026/10/21 19:41:53 网站建设 项目流程

在力扣上刷到第1326题——Minimum Number of Taps to Open to Water a Garden,中文名"灌溉花园的最少水龙头数目"——的时候,我盯着题面看了好一会儿,第一反应是这题怕不是要模拟水流扩散,或者建图跑最短路。实际动手之后才发现,它考的是非常经典的"最少区间覆盖",正解是一段不到三十行的贪心,难度全在建模这一步。这篇文章把我从建模、推导、实现到踩坑的完整过程整理出来,希望对正在啃hard题、或者面试前集中刷区间问题的朋友有帮助。我会把两个主流解法都写清楚,讲明白每一步为什么这么做,而不是只丢一个能AC的代码。

1. 把浇水故事翻译成区间模型:这题的hard点其实在建模

1.1 原题到底在说什么

先花点时间把题面还原清楚。花园是一条长度为 n 的直线,坐标从 0 到 n,一共有 n+1 个水龙头,分别放在整点位置 0、1、2、……、n 上。给你一个数组 ranges,ranges[i] 表示第 i 个水龙头打开之后能浇到的半径范围。也就是说,位置 i 的水龙头打开以后,覆盖的是闭区间 [i - ranges[i], i + ranges[i]]。注意水龙头不是分布在花园两侧,而是从 0 到 n 每个整数位置都有一个,数组长度是 n+1。

要求很简单:选择最少个数的水龙头打开,让整段花园 [0, n] 上的每一个点都被至少一个打开的水龙头覆盖。如果做不到,返回 -1。

换成工程语言就是:

  • 输入:n(花园长度),ranges(长度为 n+1 的数组)
  • 输出:最少打开的水龙头数量,或者 -1
  • 数据范围:1 <= n <= 10^4,0 <= ranges[i] <= 100

这里最关键的一点,是"浇水是连续的"。虽然水龙头都在整数坐标上,但它覆盖的是一整段连续区间。比如位置 3 的水龙头半径是 2,覆盖 [1, 5],那么坐标 4.5 也在覆盖范围内。很多第一次做这题的人会在这一步犯迷糊,把问题当成离散点覆盖,想着用状态压缩或者 BFS 去硬搜。n 最大到 10^4,离散化的状态空间根本没法枚举,方向一开始就错了。

正确的姿势,是把"离散的水龙头"和"连续的区间"做一一对应。一旦完成这个对应,题面里的花园、浇水、半径这些迷惑性词汇就全部退场,剩下的只是一道纯区间问题。

1.2 每个水龙头等价于一个区间

建模这一步是整道题的灵魂。第 i 个水龙头,等价于一个区间:

left = max(0, i - ranges[i]) right = min(n, i + ranges[i])

为什么要做 max 和 min 的裁切?因为水龙头可以喷到花园外面。比如位置 0 的水龙头半径是 100,理论上覆盖 [-100, 100],但花园只有 [0, n],-100 到 0 那一段完全没有意义,直接截断到 0。右边同理,超出 n 的部分截掉。这样处理完之后,所有区间都老老实实躺在 [0, n] 内部,后面排序、比较、判重都会方便很多。

于是题目就变成了:给定若干个区间,每个区间形如 [left_i, right_i],问最少选几个区间,能让它们的并集覆盖 [0, n]。这正是计算机科学里非常经典的"最小区间覆盖"(minimum interval covering)问题。

用一个生活化的类比来理解:想象你有一面长 n 米的墙要贴满墙纸,手上有若干卷长度、起止位置各不相同的墙纸,每一卷都只能从某个位置开始贴到某个位置,问最少用几卷才能把整面墙贴满,不能留缝。是不是一下子就熟悉了?一旦你完成这个抽象,hard 题的神秘感就消失了一大半。

1.3 为什么它被评为 hard:难的不是代码,是"想到"

说实话,这道题的代码量非常小,核心逻辑加起来三十行不到。它被评为困难题,难点主要在三处。

第一,题面包装得太"物理",让人误以为需要处理几何、模拟水流扩散,或者考虑水龙头之间互相影响。实际上水龙头之间没有任何干扰,每个水龙头就是独立的一段覆盖区间,想清楚这点题面就废掉了。

第二,区间之间可以大量重叠,选择哪个重叠区间是局部最优,需要你跳出"逐个水龙头看"的惯性,站在全局维护一个"已经覆盖到哪"的边界。

第三,如果没见过"最小区间覆盖"这个模型,很容易往 DP 甚至图论方向想。我自己第一次做的时候,第一反应是:这不就是个最短路吗?每个水龙头是一个节点,两个水龙头覆盖范围有交集就加一条边,然后求从覆盖到 0 的起点到覆盖到 n 的终点的最短路径。理论上确实可以做,但建图就要 O(n^2),10^4 的数据规模直接超时。后来翻讨论区才发现,正解就是贪心,连图都不用建。

2. 贪心策略的完整推导:维护"湿边界"和交换论证

2.1 关键视角:时刻记住"已经湿到哪了"

不管你最后打开哪些水龙头,成功方案覆盖出来的结果,一定是从左往右连续推进的。为什么一定连续?因为如果 [0, a] 和 [b, n] 都被覆盖了,而 a < b 中间有一段没水,那花园没浇透,方案直接失败。所以任何成功的方案,都可以看成:先用若干水龙头把 [0, x1] 浇透,再用若干把 [x1, x2] 浇透,一步一步推进,直到覆盖到 n。

每一次推进,新加入的水龙头必须和已经湿掉的部分有重叠,否则中间会出现干地。这个"重叠"条件翻译成区间语言就是:新区间的左端点必须 <= 当前已覆盖到的右边界 cur。

于是问题就等价成:从位置 0 出发,湿边界 cur = 0,每次选择一个左端点 <= cur 的区间,打开它,湿边界变成 max(cur, 它的右端点)。问最少需要多少次操作,能让 cur >= n。

这一步转化非常关键。它把"选哪几个区间"这个组合问题,变成了"从 0 开始一步步往前跳"的推进问题。而推进问题,天然适合贪心——因为每一步只关心当前能跳多远。

2.2 为什么每次选右端点最远的区间不亏

标准贪心策略是:

  1. 把所有区间按左端点从小到大排序。
  2. 维护当前已覆盖的右边界 cur。
  3. 扫描所有左端点 <= cur 的区间,记录它们右端点的最大值,记为 best。
  4. 如果 best == cur,说明没有任何区间能推进覆盖,直接返回 -1。
  5. 否则答案加一,把 cur 更新为 best,重复直到 cur >= n。

很多人会问:为什么每次一定要挑右端点最远的?这里可以用交换论证(exchange argument)来理解。假设在某一步,湿边界是 c,所有左端点 <= c 的区间构成候选集合 S。方案 A 选了其中某个区间 I,右端点是 r1;方案 B 选了另一个区间 J,右端点是 r2,并且 r2 > r1。那么把方案 A 里的 I 换成 J,覆盖范围只会变大不会变小,用掉的区间数量不变,而且后续的候选集合也不会变小——因为新的湿边界更靠右,满足"左端点 <= 湿边界"条件的区间只会更多,绝不会更少。换句话说,选右端点最远的区间,永远不亏。

既然每个局部决策都存在一个最优解包含它,贪心自然就是全局最优。这是区间覆盖类问题里最经典的正确性证明思路,面试手撕代码的时候如果能讲出这一段,加分效果很明显。

再用过河踩石头类比一次:你站在河边,面前有几块石头,有的在左前方,有的在右前方,你要踩石头过河,当然选能让你前进最远的那一块。踩一块近的石头,下一步能踩到的选择范围只会更小,不会带来任何好处。

2.3 无解判定为什么能放心提前退出

区间按左端点排序之后,扫描过程有一个很好的性质:当扫描到某个位置时,所有"左端点 <= cur"的区间都已经处理完了。如果在这些区间里都找不到一个能把 cur 往前推的,也就是 best == cur,那么后面的区间左端点只会更大,更不可能满足左端点 <= cur,所以可以直接判定无解,返回 -1。

这个"提前退出"不是优化技巧,而是正确性的必要部分。反例很容易构造:n = 5,能覆盖 [0, 2] 和 [4, 5] 的区间各有一个,但 (2, 4) 之间没水。推进到 2 之后,后面区间的左端点是 4,大于 2,形成断层,此时必须返回 -1,而不是继续傻傻扫描。

另外还有一个容易忽略的细节:扫描候选区间时,完全不用管这些区间彼此之间是否重叠。因为我们的目标是"覆盖",重叠是被允许且鼓励的,这和后面要讲的"选最多不重叠区间"是反着的。只要每个新选区间和当前已覆盖区域有交集,整体就保持连续,这就够了。

3. 两套实现:排序贪心与 O(n) 的 rightmost 数组

3.1 实现一:排序后扫描区间

先写出最直观的版本。把每个水龙头转成区间,过滤掉退化区间,排序,然后一遍扫描。退化区间指的是左端点等于右端点的区间,也就是半径为 0 的水龙头,它只浇一个点,永远不可能帮助我们推进覆盖范围,直接扔掉。

def minTaps(self, n: int, ranges: List[int]) -> int: intervals = [] for i, r in enumerate(ranges): left = max(0, i - r) right = min(n, i + r) if right <= left: continue # 退化区间,跳过 intervals.append((left, right)) intervals.sort(key=lambda x: x[0]) # 按左端点排序 ans = 0 cur = 0 # 当前已经覆盖到的右边界 idx = 0 m = len(intervals) while idx < m: best = cur # 把所有左端点 <= cur 的区间都看一下,取最大右端点 while idx < m and intervals[idx][0] <= cur: best = max(best, intervals[idx][1]) idx += 1 if best == cur: return -1 ans += 1 cur = best if cur >= n: return ans return -1

这个版本跑通没问题,时间复杂度 O(n log n),主要开销在排序。空间复杂度 O(n)。对 10^4 的数据量来说完全够用,面试时写这个版本也完全可以接受。

3.2 实现二:用 rightmost 数组省掉排序

观察一个容易被忽略的事实:所有区间被裁切到 [0, n] 之后,左端点的取值范围就是 0 到 n 这些整数。如果我们开一个数组 rightmost,长度 n+1,把"以 left 为左端点、能达到的最远右端点"存进去,那么按 left 从小到大的顺序遍历 rightmost,其实就等于在遍历"排好序的区间"。

这个思路和跳跃游戏 II(Jump Game II)几乎一模一样:rightmost[i] 可以看成从位置 i 起跳,最多能跳到的位置。跳跃游戏里每个位置天然有序,所以它本身就是 O(n);这里我们只是用 rightmost 数组人为构造出了这个有序结构。

def minTaps(self, n: int, ranges: List[int]) -> int: rightmost = [0] * (n + 1) for i, r in enumerate(ranges): left = max(0, i - r) right = min(n, i + r) if right > left: rightmost[left] = max(rightmost[left], right) taps = 0 cur = 0 farthest = 0 for i in range(n + 1): farthest = max(farthest, rightmost[i]) if i == cur: if farthest == cur: # 卡住了,无法推进 return -1 taps += 1 cur = farthest if cur >= n: return taps return -1

整体复杂度变成 O(n) 时间和 O(n) 空间。相比排序版本,少了排序的 log 因子,代码也更短。这里有个细节值得多说一句:for 循环必须走到 n,因为位置 n 上的水龙头也可能贡献区间,它的左端点可能小于 n,影响前面的 rightmost 更新。

3.3 用手推一遍样例,确认行为符合预期

拿题目自带的样例 n = 5, ranges = [3,4,1,1,0,0] 来走一遍:

  • 水龙头 0:区间 [0, 3]
  • 水龙头 1:区间 [0, 5]
  • 水龙头 2:区间 [1, 3]
  • 水龙头 3:区间 [2, 4]
  • 水龙头 4:退化区间,跳过
  • 水龙头 5:退化区间,跳过

rightmost 数组更新之后:rightmost[0] = max(3, 5) = 5,rightmost[1] = 3,rightmost[2] = 4。

模拟过程:i=0 时 farthest 变成 5,i == cur == 0,所以开第一个水龙头,cur 更新成 5,已经 >= n,返回 1。完全符合预期——只开水龙头 1 就行。

再看复杂一点的样例 n = 7, ranges = [1,2,1,0,2,1,0,1]:

  • 水龙头 0:[0, 1]
  • 水龙头 1:[0, 3]
  • 水龙头 2:[1, 3]
  • 水龙头 3:退化
  • 水龙头 4:[2, 6]
  • 水龙头 5:[4, 6]
  • 水龙头 6:退化
  • 水龙头 7:[6, 7]

rightmost:rightmost[0] = 3,rightmost[1] = 3,rightmost[2] = 6,rightmost[4] = 6,rightmost[6] = 7。

模拟:i=0,farthest=3,开第一个(水龙头 1),cur=3;i=1、2 时 farthest 更新到 6;i=3 时 i == cur,开第二个(水龙头 4),cur=6;i=4、5 继续更新;i=6 时 farthest=7,开第三个(水龙头 7),cur=7,达到 n,返回 3。答案选中的是水龙头 1、4、7,覆盖区间分别是 [0,3]、[2,6]、[6,7],无缝衔接,正确。

这两步手推最好自己在草稿纸上过一遍,把"什么时候触发开新龙头"的时机吃透。特别是 i == cur 的判断放在 farthest 更新之后,这个顺序决定了能不能把当前格子的贡献算进去,写错一个位置答案就会偏。

4. 边界条件与翻车点:提交三次才AC的经验

4.1 坑一:把 cur >= n 写成 cur > n

花园右边界是 n 本身,只要覆盖到坐标 n 就算完成任务。如果写成 cur > n,那么当某个水龙头恰好把湿边界推到 n 时,循环不会及时退出,会继续多算一个水龙头。第一次我就是栽在这里,一些刚好能覆盖到边界的测试用例会莫名其妙多 1,WA 得很憋屈。

正确的判断条件是 cur >= n。这属于典型的 off-by-one 错误,区间题里几乎一定会遇到。排查的时候优先检查所有和边界有关的比较运算符,尤其注意"覆盖到端点"和"越过端点"的区别。

4.2 坑二:忘记处理"原地踏步"导致死循环

如果候选区间里没有任何一个能把 cur 往前推,也就是 best == cur,说明从当前湿边界往后出现了一段没有任何水龙头能够到的地方。这时候必须立刻返回 -1。如果不写这个判断,排序版本会陷入 while 死循环,rightmost 版本会多算一个永远不会推进的假步骤,最后返回错误结果或者直接超时。

什么情况会出现原地踏步?最简单的例子是 n = 3, ranges = [0,0,0,0],所有水龙头半径都是 0,没有任何区间能覆盖长度大于 0 的段,第一步就卡死,返回 -1。

另一种情况是花园中间有一截真空,比如 n = 5,能覆盖 [0,2] 和 [4,5],但 [2,4] 之间没有任何水龙头能浇到,推进到 2 之后找不到左端点 <= 2 且右端点 > 2 的区间,卡死。这类用例专门用来测无解判定逻辑,代码里必须显式处理。

4.3 坑三:ranges[i] 顶到边界外,必须裁切

水龙头在边缘时,区间会越界。位置 0 的水龙头半径 100,如果不裁切,左端点是 -100,右端点是 100。排序版本里它似乎还能正常工作,因为 left 是负数,比较 intervals[idx][0] <= cur 时它照样满足条件。但如果你用 rightmost 数组,left = -100 直接数组越界崩溃。

所以必须统一做 max(0, ...) 和 min(n, ...) 的裁切,把区间限制在 [0, n] 内。这不仅防止数组越界,也让"左端点取值范围是 0 到 n"这个加速前提成立。裁切本身就是建模的一部分,不是可有可无的防御性代码。

4.4 坑四:把退化区间也存进去

ranges[i] = 0 的水龙头,打开之后只覆盖 [i, i] 这一个点。在覆盖 [0, n] 的问题里,这种区间没有任何作用:如果 i 这个点已经被覆盖,开它不增加任何东西;如果 i 还没被覆盖,开它也覆盖不了任何新的连续段。直接跳过可以少处理一堆无意义的数据。

不跳过其实也不会错,因为 best = max(best, i) 时,i 一定小于当前 cur,不会真的更新边界。但代码会多处理一些无效数据,排序版本里还可能干扰你对"候选区间"的理解。我建议显式跳过 right <= left 的区间,代码更干净。

提示:判断退化区间要用 right <= left 而不是 right == left。left 和 right 都经过裁切,可能出现 left == right 的情况,用 <= 更保险,即使有个别区间长度为 0 也一并跳过。

4.5 提交前过一遍的测试用例表

建议提交前把这组用例都跑一遍,覆盖正常、无解、边缘覆盖、真空带等各种情况:

测试场景nranges期望结果
单个水龙头全覆盖5[3,4,1,1,0,0]1
全部半径 0,无解3[0,0,0,0]-1
两段拼接8[4,0,0,0,0,0,0,0,4]2
中间有真空带5[2,0,0,0,2]-1
多个重叠区间推进7[1,2,1,0,2,1,0,1]3
边缘水龙头覆盖全段10[10,0,0,0,0,0,0,0,0,0,0]1

其中"中间有真空带"那个用例值得多说一句:n = 5,位置 0 的水龙头半径 2 覆盖 [0,2],位置 5 的水龙头半径 2 覆盖 [3,5],中间 (2,3) 这半开区间没人覆盖,答案必须是 -1。这种用例最考验无解判断,只要它通过了,基本可以放心提交。

5. 举一反三:从区间覆盖模板到什么时候不能贪心

5.1 抽出一张通用模板

把这道题再往上抽一层,凡是"给定一堆区间,问最少选几个能覆盖目标区间 [L, R]"的问题,都可以套下面的贪心流程:

  1. 裁切:把所有区间裁到 [L, R] 范围内,扔掉空区间。
  2. 排序:按左端点升序排列。
  3. 推进:维护当前覆盖到的位置 pos = L;扫描所有左端点 <= pos 的区间,记下最大右端点 best;若 best == pos 则无解;否则答案加一,pos = best;直到 pos >= R。

这类问题的共同特征很明显:区间之间允许重叠,目标是覆盖一段连续区间。只要你识别出这两个特征,就可以放心上贪心,不需要犹豫。反过来,如果题目要求的是"不重叠"或者"最大化覆盖长度",那是另一套思路,别套错模板。

5.2 相关题目:1024、45、435 的横向对比

力扣上有一道 1024. Video Stitching,把视频片段 [start, end] 拼成完整 [0, time],和本题几乎同一个模型,只是把水龙头换成了视频片段。做 1326 之前先做 1024 会顺很多,因为那道题是中等难度,建模更直白,能帮你提前熟悉区间覆盖的套路。

跳跃游戏 II(45 题)则是另一个视角的亲戚。它的数组跳跃本质就是 rightmost 版本的区间覆盖:每个位置 i 能跳到的最远距离就是右端点,位置本身天然有序。做完 1326 再回头看 45 题,你会发现两题的代码骨架几乎可以互相套用。

还要提一个常考的变式:不是"最少覆盖",而是"最多不重叠",比如 435. Non-overlapping Intervals。注意这两类问题的贪心策略是完全相反的——覆盖问题按左端点排序、每次选右端点最大的;不重叠问题按右端点排序、每次选结束最早的。很多面试者把这两套记混,一上场就翻车。

我个人的记忆方法:覆盖问题是"往前够",所以要够得最远;不重叠问题是"给后面留空间",所以要结束得最早。想清楚你要优化的是什么,策略自然就出来了。

5.3 加权重之后贪心失效,该换 DP

最后提醒一句,这题能贪心,前提是每个区间的"代价"相同,也就是每个水龙头都算一个。如果给每个水龙头加上不同的打开成本,变成"最小代价覆盖",贪心就不成立了。

举个例子,假设某个水龙头虽然能覆盖很远,但打开成本是 10,旁边两个短水龙头成本各是 1,合起来也能覆盖同样范围且总成本更低。这时"每次选最远"的贪心就会选错。带权重的区间覆盖需要换成动态规划或者最短路模型,那是另一个方向的 hard 题。所以做题时多问自己一句:代价是均匀的吗?如果是,贪心;如果不是,老老实实 DP。

我在实际刷题中的体会是:1326 这道题的价值不在代码本身,而在于它逼着你完成一次"现实场景到区间模型"的抽象。以后不管是在业务系统里做资源调度,还是在算法题里遇到各种披着故事外衣的区间问题,只要先想想"这能不能拍平成若干个区间",解题路径就会清晰很多。

最后再分享一个小技巧:做这类题时,我的草稿纸上永远会先画一条数轴,把每个区间画成一条横线,眼睛能看到的"断层"就是代码里该返回 -1 的位置。这个习惯帮我少交了很多次错误提交,也让我在讲解时能一眼跟面试官对齐思路,强烈建议你也试试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询