Hi 你们好,我是张小猪。欢迎来到『宝宝也能看懂』系列之 leetcode 周赛题解。git
这里是第 172 期的第 4 题,也是题目列表中的第 1326 题 -- 『灌溉花园的最少水龙头数目』github
在 x 轴上有一个一维的花园。花园长度为 n
,从点 0
开始,到点 n
结束。算法
花园里总共有 n + 1
个水龙头,分别位于 [0, 1, ..., n]
。shell
给你一个整数 n
和一个长度为 n + 1
的整数数组 ranges
,其中 ranges[i]
(下标从 0 开始)表示:若是打开点 i
处的水龙头,能够灌溉的区域为 [i - ranges[i], i + ranges[i]]
。segmentfault
请你返回能够灌溉整个花园的 最少水龙头数目。若是花园始终存在没法灌溉到的地方,请你返回 -1。数组
示例 1:优化
输入:n = 5, ranges = [3,4,1,1,0,0] 输出:1 解释: 点 0 处的水龙头能够灌溉区间 [-3,3] 点 1 处的水龙头能够灌溉区间 [-3,5] 点 2 处的水龙头能够灌溉区间 [1,3] 点 3 处的水龙头能够灌溉区间 [2,4] 点 4 处的水龙头能够灌溉区间 [4,4] 点 5 处的水龙头能够灌溉区间 [5,5] 只须要打开点 1 处的水龙头便可灌溉整个花园 [0,5] 。
示例 2:spa
输入:n = 3, ranges = [0,0,0,0] 输出:-1 解释:即便打开全部水龙头,你也没法灌溉整个花园。
示例 3:code
输入:n = 7, ranges = [1,2,1,0,2,1,0,1] 输出:3
示例 4:blog
输入:n = 8, ranges = [4,0,0,0,0,0,0,0,4] 输出:2
示例 5:
输入:n = 8, ranges = [4,0,0,0,4,0,0,0,4] 输出:1
提示:
1 <= n <= 10^4
ranges.length == n + 1
0 <= ranges[i] <= 100
HARD
题目的意思稍微容易有点让人误解。首先是题目给定了一个长度为 n
的花园,以及在上面平均分布的 n + 1
个水龙头,即从 0
到 n
,把这个花园平均分割开。而后经过 ranges
数组,给定了每一个水龙头喷水所覆盖的范围,经过下标和水龙头一一对应。最终须要返回能够灌溉整个花园所需的最少的水龙头数量,若是没法灌溉整个花园则返回 -1
。
这里须要注意的是,若是水龙头的覆盖范围是 0,那么它其实对灌溉花园彻底没有贡献。由于水龙头只是一个点,而不是有长度的一小段。以下图,是对于 n = 7, ranges = [1, 3, 1, 2, 0, 0, 0, 1]
这个数据的一个图示:
从图示中咱们能够看到几种常见的情况:
以上几种状况在后文的分析中都会用到,届时则直接使用对应的情况代号来称呼啦。那么接下来咱们就开始分析具体的处理思路。
首先,为了灌溉整个花园,也就是指望每一段都被覆盖到,咱们能够选定从 0 开始尝试逐步灌溉整个花园。以上面图示中的数据为例,咱们逐步增长花园的长度来看看:
其中从长度 1 变成长度 2,咱们遇到的是「情况A」。因为咱们最终须要的只是最少的水龙头数量,因此对于「情况A」咱们的最优处理方式就是直接选用更长的范围。从长度 2 变成到长度 4 的过程,都仍是 1 号水龙头的覆盖范围,因此并不须要作变化。而从长度 4 变成长度 5,咱们遇到的是「情况C」。对于这种情况,因为灌溉是容许范围重叠的,因此咱们只须要同时选择两段范围便可。而且咱们能够发现其实「情况B」就是「情况C」没有重叠的状况,因此咱们能够直接用一样的处理逻辑。接下来,长度变成 6 和 7 之后,咱们遇到了「情况D」和「情况E」,这时候咱们没法完成灌溉。
通过上述过程的分析,相信小伙伴们应该已经发现了些蛛丝马迹。若是咱们先忽略「情况D」和「情况E」的话,那么咱们的逻辑其实很是明确,就是不断的应对两种状况:「情况A」的用更大的范围来替换小的范围;「情况B」和「情况C」的添加另外一个范围。其中对于「情况A」的处理,咱们能够理解为就是在寻找局部最优解。对于「情况B」和「情况C」的处理,咱们能够理解为是在结合不一样的局部最优解。最终咱们就能够获得全局最优解。
这种基于局部最优解来获得全局最优解的过程,咱们称为贪心算法。
上述思路其实已经分析出了核心处理逻辑,不过还有另一个问题就是如何获取局部最优解。若是从头开始遍历全部水龙头,对于遍历中的每个水龙头的位置,从这里开始的最长的范围,也就是局部最优解,并不必定是在这以前出现的。由于可能会有后面的一个水龙头,它的覆盖范围很大,覆盖到了当前位置。
这里最直接的处理方式,咱们能够再结合一个内部的遍从来找到从这个位置开始的局部最优解。不过这样作的话,咱们须要 O(n^2) 的时间复杂度。这也就是这道题的 brute force 暴力解法。那么咱们是否有更好的方式呢?
因为 ranges
已经给定后就不会变了,因此咱们能够先算出每个水龙头的覆盖范围,而后再进行一次排序,便可作到对于每个位置咱们先获得它的局部最优解。具体流程以下:
ranges
数组计算出全部水龙头的范围数组。遍历排好序的范围数组:
-1
。基于这个流程,咱们能够实现相似这样的代码:
const minTaps = (n, ranges) => { const calRanges = ranges.map((range, idx) => [idx - range < 0 ? 0 : idx - range, idx + range > n ? n : idx + range]).sort((a, b) => (a[0] === b[0] ? b[1] - a[1] : a[0] - b[0])); let count = 1; let left = nleft = calRanges[0][0]; let right = nright = calRanges[0][1]; for (const range of calRanges) { if (range[0] === range[1] || range[0] === left || range[0] === nleft) continue; if (range[0] <= right) { nleft = range[0]; if (range[1] > nright) { nright = range[1]; } continue; } if (nright < range[0]) return -1; left = nleft; right = nright; nleft = range[0]; nright = range[1]; ++count; } if (right < n && nright < n) return -1; return right === n ? count : count + 1; };
上面的代码中使用了一个开销 O(nlogn) 的排序来确保局部最优解的获取。那么是否有方法来优化这一部分呢?没有,文章结束。
既然这样说了,那固然是有的啦。咱们在第一次遍历 ranges
的过程当中,其实能够不用生成每个范围,而是直接尝试根据计算的开始位置和结束位置,更新对应的开始位置的局部最优解的值。同理,后续的遍历计数中的流程也简单不少。具体流程以下:
ranges
数组计算出每一个位置的局部最优解。遍历全部位置:
-1
。const minTaps = (n, ranges) => { const LEN = ranges.length; const calRanges = new Uint16Array(LEN); for (let i = 0; i < LEN; ++i) { const left = i - ranges[i] > 0 ? i - ranges[i] : 0; const right = i + ranges[i] < n ? i + ranges[i] : n; right > calRanges[left] && (calRanges[left] = right); } let count = 1; let cur = next = calRanges[0]; for (let i = 1; i < LEN; ++i) { if (i > next) return -1; if (i > cur) { cur = next; ++count; } calRanges[i] > next && (next = calRanges[i]); } return count; };
上面代码的时间复杂度已是 O(n) 了,不过空间复杂度也是 O(n),咱们是否能够优化到 O(1) 的空间复杂度呢?
若是仔细观察上述代码的逻辑,其实能够发现,咱们是能够作到的。由于咱们额外的 calRanges
数组其实并没必要须,咱们彻底能够直接利用 ranges
数组来保存全部的局部最优解。这里的具体逻辑以下:
首先,对于给定的 ranges
数组中的每个值,咱们能够认为它就是对应的那个位置的局部最优解的初始值。举个具体的例子,假设 ranges[2] === 3
,那么对于 2 这个水龙头的位置开始,局部最优解至少是到位置 5。而其余的水龙头是否可能更新这个局部最优解,就是咱们须要在遍历中完成计算和更新的部分。例如假设 ranges[4] === 2
,那么刚才 2 这个位置的局部最优解就应该被更新为到位置 6。
这个逻辑想通了以后,咱们只须要在上面的代码中稍作修改便可实现 O(1) 空间复杂度的目标。具体代码以下:
const minTaps = (n, ranges) => { for (let i = 0; i < ranges.length; ++i) { const left = i - ranges[i] > 0 ? i - ranges[i] : 0; const right = i + ranges[i] < n ? i + ranges[i] : n; right > ranges[left] && (ranges[left] = right); } let count = 1; let cur = next = ranges[0]; for (let i = 1; i < ranges.length; ++i) { if (i > next) return -1; if (i > cur) { cur = next; ++count; } ranges[i] > next && (next = ranges[i]); } return count; };
这段代码跑了 40ms 暂时 beats 100%。
这道题是一道比较典型的贪心算法的问题。一旦想到这一点并理清思路,那么 AC 代码应该不是什么问题。剩下的只是后续的优化了。因此重点仍是前面的分析部分,即咱们如何经过例子来想到这种贪心策略的使用。过程当中咱们使用到了局部最优解和全局最优解这两个概念,不知道是否有小伙伴能够帮小猪总结一下,什么样的状况下才能够基于局部最优解来获得全局最优解呢?
加油武汉,天佑中华