Hi 你們好,我是張小豬。歡迎來到『寶寶也能看懂』系列之 leetcode 周賽題解。git
這裏是第 173 期的第 4 題,也是題目列表中的第 1335 題 -- 『工做計劃的最低難度』github
你須要制定一份 d
天的工做計劃表。工做之間存在依賴,要想執行第 i
項工做,你必須完成所有 j
項工做(0 <= j < i
)。shell
你天天 至少 須要完成一項任務。工做計劃的總難度是這 d
天每一天的難度之和,而一天的工做難度是當天應該完成工做的最大難度。segmentfault
給你一個整數數組 jobDifficulty
和一個整數 d
,分別表明工做難度和須要計劃的天數。第 i
項工做的難度是 jobDifficulty[i]
。數組
返回整個工做計劃的 最小難度。若是沒法制定工做計劃,則返回 -1。緩存
示例 1:函數
輸入:jobDifficulty = [6,5,4,3,2,1], d = 2 輸出:7 解釋:第一天,您能夠完成前 5 項工做,總難度 = 6. 次日,您能夠完成最後一項工做,總難度 = 1. 計劃表的難度 = 6 + 1 = 7
示例 2:優化
輸入:jobDifficulty = [9,9,9], d = 4 輸出:-1 解釋:就算你天天完成一項工做,仍然有一天是空閒的,你沒法制定一份可以知足既定工做時間的計劃表。
示例 3:spa
輸入:jobDifficulty = [1,1,1], d = 3 輸出:3 解釋:工做計劃爲天天一項工做,總難度爲 3 。
示例 4:設計
輸入:jobDifficulty = [7,1,7,1,7,1], d = 3 輸出:15
示例 5:
輸入:jobDifficulty = [11,111,22,222,33,333,44,444], d = 6 輸出:843
提示:
1 <= jobDifficulty.length <= 300
0 <= jobDifficulty[i] <= 1000
1 <= d <= 10
HARD
題目內容看起來彷佛有一點繞,咱們把它剝去包裝的外衣以後看看。其實就是一個由正整數組成的數組,須要把它分紅 d
段,每一段至少有 1 個數字,而且每一段的值爲這一段數字中最大的那個數。完成分段後即可以計算出全部分段的值的總和,須要返回這個總和的最小值。若是不能被拆分紅 d
段則返回 -1
。
看完題目以後,小豬的第一反應是,先把特殊狀況處理掉吧。因爲每一段至少須要一個數字,因此若是當數字的總數不夠 d
的時候,也就沒法完成拆分。這時候提早返回 -1
便可。另外,其實若是數字的總數就是 d
的話,那麼拆分狀況只有一種。這時候也能夠提早返回了。不過這個看小夥伴們的我的愛好吧,畢竟也是能夠和其餘狀況一塊兒被計算的。
而後就到了核心的問題,咱們應該如何拆分呢?
乍看起來咱們是否是應該儘可能把比較大的數劃分到一段裏?又或者和平均數、中位數什麼的有關係?若是從這個角度去思考的話,可能會發現這樣想起來會陷入泥潭。例如假設如今有 5 個數 [a, b, c, d, e]
,須要被分紅 3 段,那麼可能的分發會有多種。假設咱們如今拿到了平均數或者中位數,又該怎麼判斷呢?
小豬腦子苯苯的,數學又超爛,想不到這裏應該如何用數學的方式去處理。因此,那就換個思路吧,咱們試試用計算機的方式來處理。由於沒有想到什麼可用的區分條件,因此就發揮計算機的特長,枚舉出全部可能,而後就能知道總和的最小值啦。摸摸豬鼻子,小豬真機智 >.<
爲了獲得全部的可能狀況,咱們能夠想一些這些狀況是如何產生的。下圖是把 4 個數拆分紅 2 段的狀況:
而後咱們再來看看把 5 個數拆分紅 3 段的狀況:
不知道小夥伴們有沒有從上面的兩個圖中看出點什麼,其實小豬這裏不一樣拆分狀況的排列順序已經能體現這裏的思路啦。咱們從上往下看,會發現每一次咱們都是把當前段落取到了極值。拆分爲 2 段的時候,第一段就是當前段落,咱們逐漸把它取到極值就獲得了全部的可能性。拆分爲 3 段的時候,咱們先把第 1 段做爲當前段落,取到極值;而後再針對第 1 段的不一樣狀況,把第 2 段做爲當前段落,取到極值。這樣咱們逐漸也就獲得了全部可能狀況。
那麼,其實拆分紅 d
段也是同一個道理。咱們把當前段落不斷的取到極值,而且做爲下一段的開始條件,直到咱們拆分到了最後一段。咱們能夠把它想象成是一顆分叉不少的樹,不斷的走完全部路徑,咱們也就獲得了全部的可能狀況。
既然說到了對於樹的遍歷,那咱們就先嚐試着用深度優先遍從來實現一下吧,而且遞歸寫起來也比較簡單嘛。這裏稍微說一下遞歸函數的設計過程吧。
首先,咱們明確一下這個遞歸函數的功能。咱們但願它能返回把一段數據拆分紅 n 段的最小總和。那麼基於這個目的,咱們的返回值就明確啦。
接下來,基於上面的思路,咱們來看看函數的參數。因爲終止條件是咱們的拆分達到了最後一段,因此參數中必定須要還剩餘待拆分的段數。另外,在拆分的過程當中,咱們也須要知道上一段的拆分的情況,也就是這一段拆分所開始的位置。因此參數中也必定須要當前所處的位置。
最後,有了思路,也想好了如何實現遞歸,接下來淦就完事啦。具體流程以下:
根據上面的流程,咱們能夠獲得相似下面的代碼:
const minDifficulty = (jobDifficulty, d) => { const LEN = jobDifficulty.length; if (LEN < d) return -1; const cache = new Map(); return helper(0, d); function helper(idx, count) { const key = idx * 100 + count; if (!cache.has(key)) { if (count === 1) { let max = 0; for (let i = idx; i < LEN; ++i) { jobDifficulty[i] > max && (max = jobDifficulty[i]); } return max; } let min = 10000; let curMax = 0; for (let i = idx; i <= LEN - count; ++i) { if (jobDifficulty[i] > curMax) curMax = jobDifficulty[i]; min = Math.min(min, curMax + helper(i + 1, count - 1)); } cache.set(key, min); } return cache.get(key); } };
接下來咱們來換個思路。先來定義兩個符號吧:
i
,而後從 i
開始咱們還差 j
次拆分沒有作。咱們把當前狀態下的這個最小值記做 dp[i][j]
。i
開始到 j
的一段,這一段裏的最大值咱們記做 max(i, j)
。基於上面的定義,首先能夠獲得的就是咱們的目標,即算出 dp[0][d]
。那麼咱們能夠先看看咱們目前有什麼數據:
dp[n - 1][1]
、dp[n - 2][1]
... dp[0][1]
這些值。dp[n - 2][2]
、dp[n - 3][3]
... dp[n - d][d]
。max(i, j)
也是能夠直接計算出來的值。那麼接下來,基於咱們已經有的數據,再來看看還能夠獲得那些其餘的值。首先,咱們來看看以前圖裏的那個栗子吧 -- dp[n - 4][2]
,它可能會有哪些狀況:
dp[n - 3][1] + max(n - 4, n - 4)
dp[n - 2][1] + max(n - 4, n - 3)
dp[n - 1][1] + max(n - 4, n - 2)
不知道有沒有小夥伴發現,其實這裏的幾個值都是在咱們上面當前能夠直接計算出的數據裏的。再來看個更復雜一點的,以前圖裏的栗子 -- dp[n - 5][3]
,它可能的狀況會有這些:
dp[n - 3][1] + max(n - 5, n - 5) + max(n - 4, n - 4)
dp[n - 2][1] + max(n - 5, n - 5) + max(n - 4, n - 3)
dp[n - 1][1] + max(n - 5, n - 5) + max(n - 4, n - 2)
dp[n - 2][1] + max(n - 5, n - 4) + max(n - 3, n - 3)
dp[n - 1][1] + max(n - 5, n - 4) + max(n - 3, n - 2)
dp[n - 1][1] + max(n - 5, n - 3) + max(n - 2, n - 2)
阿勒?是否是以爲咱們已經發現了什麼?不過若是咱們一直這樣展開下去,項數便會愈來愈多。那麼是否能夠減小一些項數呢?咱們能夠詳細的看一下 dp[n - 5][3]
裏面的內容,再對比 dp[n - 4][2]
的內容。若是還不夠清晰,咱們再補充一下下面兩條內容:
dp[n - 3][2]
展開後:
dp[n - 2][1] + max(n - 3, n - 3)
dp[n - 1][1] + max(n - 3, n - 2)
dp[n - 2][2]
展開後:
dp[n - 1][1] + max(n - 2, n - 2)
嘿嘿,這下是否是就和 dp[n - 5][3]
的內容都一一對應上了呀。小豬開心的扭了扭尾巴 >.<
最後,咱們把它抽象成 dp[i][j]
吧:
dp[i][j] = Math.min( dp[i + 1][j - 1] + max(i, i), dp[i + 2][j - 1] + max(i, i + 1), ... dp[n - j + 1][j - 1] + max(i, n - j) );
有了這個遞推公式之後,剩下的就是實現便可。具體流程以下:
dp
數組和爲段爲 1 的值。const minDifficulty = (jobDifficulty, d) => { const LEN = jobDifficulty.length; if (LEN < d) return -1; const dp = Array.from({ length: LEN }, () => new Uint16Array(d + 1).fill(10000)); for (let i = LEN - 1, curMax = 0; i >= 0; --i) { jobDifficulty[i] > curMax && (curMax = jobDifficulty[i]); dp[i][1] = curMax; } for (let i = 2; i <= d; ++i) { for (let j = 0; j <= LEN - i; ++j) { let max = 0; for (let k = j; k <= LEN - i; ++k) { jobDifficulty[k] > max && (max = jobDifficulty[k]); dp[j][i] = Math.min(dp[j][i], dp[k + 1][i - 1] + max); } } } return dp[0][d]; };
按照慣例,咱們把這個二維 dp
優化成一維的吧。改動其實並不大,主要就是迭代過程當中不斷的覆蓋以前的值,以達到縮減成一維數組的目的。具體代碼以下:
const minDifficulty = (jobDifficulty, d) => { const LEN = jobDifficulty.length; if (LEN < d) return -1; const dp = new Uint16Array(LEN + 1); for (let i = LEN - 1; i >= 0; --i) { dp[i] = jobDifficulty[i] > dp[i + 1] ? jobDifficulty[i] : dp[i + 1]; } for (let i = 2; i <= d; ++i) { for (let j = 0; j <= LEN - i; ++j) { let max = 0; dp[j] = 10000; for (let k = j; k <= LEN - i; ++k) { jobDifficulty[k] > max && (max = jobDifficulty[k]); dp[j] > dp[k + 1] + max && (dp[j] = dp[k + 1] + max); } } } return dp[0]; };
這段代碼目前跑出了 52ms,暫時 beats 100%。
這裏在最內層循環中,咱們能夠嘗試不去遍歷全部內容,而是經過一個內容有序的棧來作判斷和記錄,以儘可能減小這裏的計算次數。具體代碼以下:
const minDifficulty = (jobDifficulty, d) => { const LEN = jobDifficulty.length; if (LEN < d) return -1; const dp = new Uint16Array(LEN); dp[0] = jobDifficulty[0]; for (let i = 1; i < LEN; ++i) { dp[i] = jobDifficulty[i] > dp[i - 1] ? jobDifficulty[i] : dp[i - 1]; } for (let i = 1; i < d; ++i) { const stack = []; let old = dp[i - 1]; for (let j = i; j < LEN; ++j) { let min = old; while (stack.length && jobDifficulty[stack[stack.length - 1]] <= jobDifficulty[j]) { const top = stack.pop(); min = Math.min(min, dp[top] - jobDifficulty[top]); } old = dp[j]; dp[j] = min + jobDifficulty[j]; if (stack.length) { const top = dp[stack[stack.length - 1]]; top < dp[j] && (dp[j] = top); } stack.push(j); } } return dp[LEN - 1]; };
這段代碼目前跑出了 48ms,代替了以前的代碼暫時 beats 100%。
這道題的核心內容又是在 HARD 難度中很常見的動態規劃問題。小豬這裏經過栗子和羅列數據,一步一步的推導出動態規劃的遞推公式,但願可以幫到對這一部分還不太清晰的小夥伴們。至於後續的兩次優化,小豬以爲只是錦上添花而以。無關緊要,看看就好啦。
發現咱們彷佛已經說過好幾回動態規劃的問題了,小夥伴們有沒有找到一點其中的小技巧呢?小豬也想知道呢,哼唧 >.<
加油武漢,天佑中華