iT邦幫忙

2023 iThome 鐵人賽

DAY 29
0

本文同步更新於個人網站中,有更好的排版和程式碼區塊 highlighting 支援。

接續昨天的文章,今天我們繼續來練習動態規劃的題目,熟悉一下動態規劃的解題思路。

爬樓梯 Climbing Stairs

這題的原題為 70. Climbing Stairs,題目如下:假設你正在爬樓梯。需要 n 階你才能到達樓頂,每次你可以爬 1 或 2 個台階。你有多少種不同的方法可以爬到樓頂呢?(1 <= n <= 45

例如:

給定 n = 2,回傳 2,因為有兩種方法可以爬到樓頂。

  • 1 階 + 1 階
  • 2 階

給定 n = 3,回傳 3,因為有三種方法可以爬到樓頂。

  • 1 階 + 1 階 + 1 階
  • 1 階 + 2 階
  • 2 階 + 1 階

思路:我們可以先從範例觀察一下,dp[1] = 1, dp[2] = 2, dp[3] = dp[1] + dp[2] = 3。接下來確認一下 dp[4] 的值,有 [1,1,1,1], [1,1,2], [1,2,1], [2,1,1], [2,2] 這 5 種組合,然後我們發現 dp[4] = dp[3] + dp[2]。這樣我們就可以推論出狀態轉移方程:

https://chart.googleapis.com/chart?cht=tx&amp;chl=f(n)%20%3D%20%5Cbegin%7Bcases%7D%20%20%201%20%26%20%5Ctext%7Bif%20%7D%20n%20%3D%201%20%5C%5C%20%20%202%20%26%20%5Ctext%7Bif%20%7D%20n%20%3D%202%20%5C%5C%20%20%20f(n%20-%201)%20%2B%20f(n%20-%202)%20%26%20%5Ctext%7Bif%20%7D%20n%20%5Cgeq%203%20%5Cend%7Bcases%7D

知道規律後就可以利用動態規劃來解題了,實作程式碼如下:

function climbStairs(n) {
  const dp = [0, 1, 2];
  for (let i = 3; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[n];
}

可以發現這題的解法跟費氏數列很像,但是不同的是費氏數列的初始值是 [0, 1],而這題的初始值是 [0, 1, 2],但是它們之後的規律都是一樣的。

最長不下降子序列 Longest Non-decreasing Subsequence

有一個由 n (n <= 1 <= 200) 個整數組成的數列,標記為:https://chart.googleapis.com/chart?cht=tx&amp;chl=a_1%2C%20a_2%2C%20...%2C%20a_n。請你從中找出一個最長的不下降子序列,也就是說找出一個最長的整數序列 https://chart.googleapis.com/chart?cht=tx&amp;chl=i_1%2C%20i_2%2C%20...%2C%20i_k,使得對於任意的 j (1 <= j <= k - 1),都滿足 https://chart.googleapis.com/chart?cht=tx&amp;chl=i_j%20%3C%20i_%7Bj%20%2B%201%7Dhttps://chart.googleapis.com/chart?cht=tx&amp;chl=a_%7Bi_j%7D%20%5Cleq%20a_%7Bi_%7Bj%20%2B%201%7D%7D

例如:[1, 2, 3, -9, 3, 9, 0, 11],它的最長不下降子序列為 [1, 2, 3, 3, 9, 11]

暴力遞迴

比較直觀的想法會是窮舉出所有的不下降子序列,然後找出最長的那個。我們可以固定序列的最後一項數字,例如 array 的最後一個是 11,我們就先以 11 當作序列結尾,往前找到 0、9、3、-9、3、2、1 都可以接上去 11,依此類推繼續找下去,得出以 11 為結尾的最長序列後再接著去找以 9 為結尾的最長序列,一路找下去,最後找出最長的序列。

我們將思路轉為程式碼,實作如下:

function longestNonDecreasingSequence(array) {
  const allSubsequences = [];

  for (let i = array.length - 1; i >= 0; i--) {
    allSubsequences.push(findLongestFromIndex(array, i));
  }

  return findLongestSubsequence(allSubsequences).reverse();
}

const findLongestSubsequence = (array) => {
  return array.reduce((acc, subsequence) => {
    if (subsequence.length > acc.length) {
      return subsequence;
    }
    return acc;
  }, []);
};

const findLongestFromIndex = (array, index) => {
  if (index === 0) {
    return [array[0]];
  }

  const currentNumber = array[index];
  const subsequences = [];

  for (let i = index - 1; i >= 0; i--) {
    if (array[i] <= currentNumber) {
      subsequences.push(findLongestFromIndex(array, i));
    }
  }

  if (subsequences.length === 0) {
    return [currentNumber];
  }

  const longestSubsequence = findLongestSubsequence(subsequences);

  return [currentNumber, ...longestSubsequence];
};

Memoization

但仔細觀察後我們可以注意到我們在遞迴的過程中,有很多重複的計算,例如:[1, 2, 3, -9, 3, 9, 0, 11],當我們計算以 11 為結尾的最長序列時,會先計算以 9 為結尾的最長序列,而當我們計算以 9 為結尾的最長序列時,又會先計算以 3 為結尾的最長序列,以此類推。我們可以用一個快取記下計算過的結果,程式碼如下:

function longestNonDecreasingSequence(array) {
  const memo = {};
  const allSubsequences = [];

  for (let i = array.length - 1; i >= 0; i--) {
    allSubsequences.push(findLongestFromIndex(array, i, memo));
  }

  return findLongestSubsequence(allSubsequences).reverse();
}

const findLongestSubsequence = (array) => {
  return array.reduce((acc, subsequence) => {
    if (subsequence.length > acc.length) {
      return subsequence;
    }
    return acc;
  }, []);
};

const findLongestFromIndex = (array, index, memo) => {
  if (memo[index] !== undefined) {
    return memo[index];
  }

  if (index === 0) {
    return [array[0]];
  }

  const currentNumber = array[index];
  const subsequences = [];

  for (let i = index - 1; i >= 0; i--) {
    if (array[i] <= currentNumber) {
      subsequences.push(findLongestFromIndex(array, i, memo));
    }
  }

  let longestSubsequence = [];
  if (subsequences.length > 0) {
    longestSubsequence = findLongestSubsequence(subsequences);
  }

  memo[index] = [currentNumber, ...longestSubsequence];

  return memo[index];
};

動態規劃

思路:我們建立一個 table,長度等於原數列長度,table[i] 表示以 array[i] 為結束的非下降子序列的長度。一開始它們的值都是 1,可以參考下表。我們分別用 [4][1,3][3,1][1,2,3] 這些總長為 1、2、3 的陣列來推論規律。

array table initStatus table lastStatus
[4] [1] [1]
[1,3] [1,1] j=0, i=1 [1,2]
[3,1] [1,1] j=0, i=1 [1,1]
[1,2,3] [1,1,1] [1,2,3]

如果 array[i] >= array[j](j < i),那麼 table[i] = Math.max(table[i], table[j] + 1)

因此 table 中的元素隨著索引的遞增,其值即便不加 1 也不會變小,就像一個台階一樣不斷往上累加 1。

又由於 table[i] 的值是透過 0 ~ i 的子陣列推導出來的,因此我們可以用一個雙層迴圈來填表,i 跑整個陣列,j0 ~ i 的子陣列。實作程式碼如下:

function longestNonDecreasingSequence(array) {
  const dp = new Array(array.length).fill(1);
  let max = 1;
  let maxIndex = 0;

  for (let i = 1; i < array.length; i++) {
    for (let j = 0; j < i; j++) {
      const current = array[i];
      const previous = array[j];

      if (current >= previous && dp[i] < dp[j] + 1) {
        dp[i] = dp[j] + 1;

        if (max < dp[i]) {
          max = dp[i];
          maxIndex = i;
        }
      }
    }
  }

  const result = [array[maxIndex]];
  let m = max;
  let i = maxIndex - 1; // 從最後一個往前找
  while (m > 1) {
    // 相鄰的 dp[i] 都是等差或是相差 1
    if (dp[i] === m - 1 && array[i] <= array[maxIndex]) {
      result.unshift(array[i]);
      maxIndex = i;
      m--;
    }
    i--;
  }

  return result;
}

這個問題其實就是最長遞增子序列(Longest Increasing Subsequence, LIS)的變形,多了一個等於的條件。有興趣的讀者可以參考 300. Longest Increasing Subsequence

最長共同子序列 Longest Common Subsequence

給你兩個字串,求解這兩個字串的最長共同子序列(Longest Common Subsequence, LCS)的長度。例如:abcdeace 的 LCS 為 ace,長度為 3。

LCS 是一個很實用的問題,它可以是不連續的。它可以用在描述兩段文字之間的“相似度”,也就是雷同程度,進而去判斷是否為抄襲。對一段文字進行修改時,計算修改前後文字的 LCS,將除此子序列外的部分提取出來,用這個方式來判斷修改的部分,往往十分準確。

暴力遞迴

一開始一樣可以先試著想暴力解要怎麼去解,這題比上一題再難一點,因為多了一個字串,不過我們一樣依循從尾巴開始往前尋找的策略來起手,例如:abcdeace,我們先找出 abcde 的最後一個字母 eace 的最後一個字母 e,發現它們相同,於是我們把這個 e 扣掉然後接這去找 abcdac 的 LCS,接著我們又發現 dc 不同,於是我們可以選擇扣掉 d 或是 c,然後再去找 abcac 的 LCS 和 abcda 的 LCS,然後選一個最長的,依此類推,直到其中一個字串為空為止。

實作起來會長這樣:

function LCS(str1, str2) {
  const findLCS = (str1, str2, i, j) => {
    if (i < 0 || j < 0) {
      return 0;
    }

    if (str1[i] === str2[j]) {
      // 如果兩個字元相同,則 LCS 長度加 1,然後繼續往前找
      return 1 + findLCS(str1, str2, i - 1, j - 1);
    }

    const option1 = findLCS(str1, str2, i - 1, j); // 從 str1 的前一個字元開始找
    const option2 = findLCS(str1, str2, i, j - 1); // 從 str2 的前一個字元開始找

    return Math.max(option1, option2); // 取最大值
  };

  return findLCS(str1, str2, str1.length - 1, str2.length - 1);
}

不過想當然這樣寫的時間複雜度非常大,用我們系列文專案中的測資下去跑測試會在最後一個情境直接卡住,因此我們需要用動態規劃來解決這個問題。

動態規劃

思路:這題有兩個字串,因此填表用的 table 會變成二維陣列,也就是矩陣。矩陣的橫列表示第一個字串,縱行表示第二個字串,如果某一列與某一行相交的字元相同,我們就填 1,否則就填 0。但這樣點完之後還需要再清點一次,如圖所示:

https://ithelp.ithome.com.tw/upload/images/20231014/20152758JfZBzBh9pl.png

我們繼續來改進一下,如果格子對應的字元相同,那麼它的值就等於左上角格子的值加 1,如果不存在則預設為 0;如果不相同,那麼它的值就等於左邊格子與上面格子取最大值。最後右下角的值就是 LCS 的長度。如下圖所示:

https://ithelp.ithome.com.tw/upload/images/20231014/20152758gzmFe8lxNp.png

根據上面的規則,我們可以寫出狀態轉移方程:

https://ithelp.ithome.com.tw/upload/images/20231014/20152758HAS7bsE69B.png

整個轉移的過程如下:

https://ithelp.ithome.com.tw/upload/images/20231014/201527589bDx9D0OLz.png

為了避免左上格子不存在的問題,我們有兩種解法:一是將 str1、str2 字串轉換成陣列,然後在陣列前面加上一個空字串,並規定空字串與其他字串比較時,結果永遠是 0;二是在矩陣左側和上方額外新增 1 行與列,並將它們的值都設為 0,效果如下圖所示:

https://ithelp.ithome.com.tw/upload/images/20231014/201527589XHt6J7oi7.png

我們用第二種方式來實作,程式碼如下:

function LCS(str1, str2) {
  const m = str1.length;
  const n = str2.length;

  const dp = [new Array(n + 1).fill(0)];

  for (let i = 1; i <= m; i++) {
    dp[i] = [0];
    for (let j = 1; j <= n; j++) {
      if (str1[i - 1] === str2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1] + 1;
      } else {
        dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]);
      }
    }
  }

  return dp[m][n];
}

如果我們要實際求出 LCS 字串,可以從右下角開始往左上角找回去,遇到相同的字元就加入結果中,遇到不同的字元就往左或往上找,直到找到左上角為止。我們可以使用一個遞迴函式來實現,程式碼如下:

function printLCS(dp, str1, str2, i, j) {
  if (i === 0 || j === 0) {
    return '';
  }

  if (str1[i - 1] === str2[j - 1]) {
    return printLCS(dp, str1, str2, i - 1, j - 1) + str1[i - 1];
  }

  if (dp[i][j - 1] > dp[i - 1][j]) {
    return printLCS(dp, str1, str2, i, j - 1);
  }

  return printLCS(dp, str1, str2, i - 1, j);
}

function LCS(str1, str2) {
  // 略
  const lcs = printLCS(dp, str1, str2, m, n);
  console.log(lcs);
  return dp[m][n];
}

LCS('DACB', 'ABC'); // AC

背包問題 - 0/1 Knapsack Problem

給定 n 個重量為 https://chart.googleapis.com/chart?cht=tx&amp;chl=w_1%2C%20w_2%2C%20...%2C%20w_n,價值為 https://chart.googleapis.com/chart?cht=tx&amp;chl=v_1%2C%20v_2%2C%20...%2C%20v_n 的物品和一個容量為 capacity 的背包,求如何裝才能在不超過背包承重的情況下,使得背包中物品的總價值最大?

「0/1」的意思是每件物品只能選擇 0 個或 1 個。也就是要嘛就是放進背包,要嘛就是不放進背包。

在介紹 backtracking 時我們就有提到過 0/1 背包問題,我們當時是利用回溯法直接暴力窮舉出所有可能的組合,然後找出最大值,今天我們來看看如何用動態規劃來解決這個問題。

我們可以讓物品編號從 1 開始,陣列索引從 0 開始,因此物品 i 的重量為 w[i - 1],價值為 v[i - 1]。如下表範例所示:

物品 i 重量 w[i - 1] 價值 v[i - 1]
1 10 50
2 20 120
3 30 150
4 40 210
5 50 240

capacity = 50 時,最佳方案為放入 23,總價值為 270。

思路:

第一步:我們要思考每一輪的決策,定義出狀態,進而推導出 dp 表的結構。

對於每個物品來說,不放入背包,背包容量不變;放入背包,背包容量減小。我們把目前物品 https://chart.googleapis.com/chart?cht=tx&amp;chl=i 和剩餘容量 https://chart.googleapis.com/chart?cht=tx&amp;chl=c,記作 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Bi%2C%20c%5D

狀態 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Bi%2C%20c%5D 對應的子問題是:在前 https://chart.googleapis.com/chart?cht=tx&amp;chl=i 個物品中選擇,且剩餘背包容量為 https://chart.googleapis.com/chart?cht=tx&amp;chl=c 時,背包中物品的最大價值,記作 https://chart.googleapis.com/chart?cht=tx&amp;chl=dp%5Bi%5D%5Bc%5D

我們要知道的是 https://chart.googleapis.com/chart?cht=tx&amp;chl=dp%5Bn%5D%5Bcapacity%5D,因此需要一個 https://chart.googleapis.com/chart?cht=tx&amp;chl=(n%20%2B%201)%20*%20(capacity%20%2B%201) 的矩陣來存放子問題的解。

第二步:找出最佳子結構,推導出狀態轉移式

當我們做出物品 https://chart.googleapis.com/chart?cht=tx&amp;chl=i 的決策後,剩餘的是前 https://chart.googleapis.com/chart?cht=tx&amp;chl=i%20-%201 個物品的決策,有兩種情況:

  • 不放入物品 https://chart.googleapis.com/chart?cht=tx&amp;chl=i:背包容量不變,狀態變為 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Bi%20-%201%2C%20c%5D
  • 放入物品 https://chart.googleapis.com/chart?cht=tx&amp;chl=i:背包容量減少 https://chart.googleapis.com/chart?cht=tx&amp;chl=w%5Bi%20-%201%5D,價值增加 https://chart.googleapis.com/chart?cht=tx&amp;chl=v%5Bi%20-%201%5D,狀態變為 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Bi%20-%201%2C%20c%20-%20w%5Bi%20-%201%5D%5D

我們的決策就是在這兩種情況中選擇價值最大的那個,因此狀態轉移方程為:

https://ithelp.ithome.com.tw/upload/images/20231014/20152758EuNYTN9V6V.png

這邊要注意若目前物品 https://chart.googleapis.com/chart?cht=tx&amp;chl=i 的重量大於剩餘容量 https://chart.googleapis.com/chart?cht=tx&amp;chl=c,則必定不能放入物品 https://chart.googleapis.com/chart?cht=tx&amp;chl=i

第三步:確定邊界條件和狀態轉移順序

當沒有物品或是無剩餘空間時,最大價值為 0,即 https://chart.googleapis.com/chart?cht=tx&amp;chl=dp%5B0%2C%20c%5D%20%3D%20dp%5Bi%2C%200%5D%20%3D%200

狀態 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Bi%2C%20c%5D 從上方的 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Bi%20-%201%2C%20c%5D 或是左上方的 https://chart.googleapis.com/chart?cht=tx&amp;chl=%5Bi%20-%201%2C%20c%20-%20w%5Bi%20-%201%5D%5D 轉移而來,因此我們需要先計算出上方的狀態,再計算左上方的狀態,因此我們的狀態轉移順序為:先計算 dp[1, 1], dp[1, 2], ..., dp[1, capacity],再計算 dp[2, 1], dp[2, 2], ..., dp[2, capacity],以此類推。

實作程式碼如下:

function knapsack01(w, v, capacity) {
  const n = w.length;

  // 初始化 dp 表
  const dp = new Array(n + 1).fill(0).map(() => new Array(capacity + 1).fill(0));

  // 填表
  for (let i = 1; i <= n; i++) {
    for (let c = 1; c <= capacity; c++) {
      if (w[i - 1] > c) {
        // 目前物品超過背包容量,不放入背包
        dp[i][c] = dp[i - 1][c];
      } else {
        // 選擇放或不放,取最大值
        dp[i][c] = Math.max(dp[i - 1][c], dp[i - 1][c - w[i - 1]] + v[i - 1]);
      }
    }
  }

  return dp[n][capacity];
}

總結

動態規劃不像回溯法那樣有特定的套路,雖然我們可以針對可變的部分抽取出狀態,但這些狀態需要結合填表法才能找到真正的意義。此外關於狀態轉移式,有的題目已經給出前幾步的結果,你可以輕鬆地推論出來;有的題目就比較難,這些都需要透過練習去累積經驗,在一開始我們可以先試著找出如何暴力解,然後再嘗試用動態規劃來解決,這樣會比較容易理解。

參考資料


上一篇
【動態規劃】Dynamic Programming (1)
下一篇
【貪婪演算法】Greedy Algorithm
系列文
那些前端不用會,但是可以會的資料結構與演算法31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言