寶寶也能看懂的 leetcode 周賽 - 173 - 4

1335. 工做計劃的最低難度

Hi 你們好,我是張小豬。歡迎來到『寶寶也能看懂』系列之 leetcode 周賽題解。git

這裏是第 173 期的第 4 題,也是題目列表中的第 1335 題 -- 『工做計劃的最低難度』github

題目描述

你須要制定一份 d 天的工做計劃表。工做之間存在依賴,要想執行第 i 項工做,你必須完成所有 j 項工做(0 <= j < i)。shell

你天天 至少 須要完成一項任務。工做計劃的總難度是這 d 天每一天的難度之和,而一天的工做難度是當天應該完成工做的最大難度。segmentfault

給你一個整數數組 jobDifficulty 和一個整數 d,分別表明工做難度和須要計劃的天數。第 i 項工做的難度是 jobDifficulty[i]數組

返回整個工做計劃的 最小難度。若是沒法制定工做計劃,則返回 -1緩存

示例 1:函數

1335-1.png

輸入: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 段的狀況:

1335-2.png

而後咱們再來看看把 5 個數拆分紅 3 段的狀況:

1335-3.png

不知道小夥伴們有沒有從上面的兩個圖中看出點什麼,其實小豬這裏不一樣拆分狀況的排列順序已經能體現這裏的思路啦。咱們從上往下看,會發現每一次咱們都是把當前段落取到了極值。拆分爲 2 段的時候,第一段就是當前段落,咱們逐漸把它取到極值就獲得了全部的可能性。拆分爲 3 段的時候,咱們先把第 1 段做爲當前段落,取到極值;而後再針對第 1 段的不一樣狀況,把第 2 段做爲當前段落,取到極值。這樣咱們逐漸也就獲得了全部可能狀況。

那麼,其實拆分紅 d 段也是同一個道理。咱們把當前段落不斷的取到極值,而且做爲下一段的開始條件,直到咱們拆分到了最後一段。咱們能夠把它想象成是一顆分叉不少的樹,不斷的走完全部路徑,咱們也就獲得了全部的可能狀況。

深度優先遍歷

既然說到了對於樹的遍歷,那咱們就先嚐試着用深度優先遍從來實現一下吧,而且遞歸寫起來也比較簡單嘛。這裏稍微說一下遞歸函數的設計過程吧。

首先,咱們明確一下這個遞歸函數的功能。咱們但願它能返回把一段數據拆分紅 n 段的最小總和。那麼基於這個目的,咱們的返回值就明確啦。

接下來,基於上面的思路,咱們來看看函數的參數。因爲終止條件是咱們的拆分達到了最後一段,因此參數中必定須要還剩餘待拆分的段數。另外,在拆分的過程當中,咱們也須要知道上一段的拆分的情況,也就是這一段拆分所開始的位置。因此參數中也必定須要當前所處的位置。

最後,有了思路,也想好了如何實現遞歸,接下來淦就完事啦。具體流程以下:

  1. 特殊狀況判斷。
  2. 初始化緩存並開始遞歸。
  3. 根據分析實現結束條件和處理。
  4. 逐漸取到當前段落的極值,並繼續遞歸。

根據上面的流程,咱們能夠獲得相似下面的代碼:

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)
);

有了這個遞推公式之後,剩下的就是實現便可。具體流程以下:

  1. 特殊狀況判斷。
  2. 初始化 dp 數組和爲段爲 1 的值。
  3. 根據遞推公式推導多個分段的結果。
  4. 返回結果。
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 難度中很常見的動態規劃問題。小豬這裏經過栗子和羅列數據,一步一步的推導出動態規劃的遞推公式,但願可以幫到對這一部分還不太清晰的小夥伴們。至於後續的兩次優化,小豬以爲只是錦上添花而以。無關緊要,看看就好啦。

發現咱們彷佛已經說過好幾回動態規劃的問題了,小夥伴們有沒有找到一點其中的小技巧呢?小豬也想知道呢,哼唧 >.<

加油武漢,天佑中華

相關連接

qrcode_green.jpeg

相關文章
相關標籤/搜索