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 代碼應該不是什麼問題。剩下的只是後續的優化了。因此重點仍是前面的分析部分,即咱們如何經過例子來想到這種貪心策略的使用。過程當中咱們使用到了局部最優解和全局最優解這兩個概念,不知道是否有小夥伴能夠幫小豬總結一下,什麼樣的狀況下才能夠基於局部最優解來獲得全局最優解呢?
加油武漢,天佑中華