博主在上一篇博文 簡單聊一聊動態規劃算法 中講了一些關於動態規劃的基本知識。本篇博文主要介紹動態規劃相關的習題,目的是幫助大家強化動態規劃的思想與應用。
由於博主從事 web 前端相關的工作,所以編程語言選擇的是 JavaScript。不過算法重要的不是語言而是解題思路,相信學習其他編程語言的同學也能看懂。
最長上升子序列(LIS)
最長上升子序列(Longest Increasing Subsequence)這題算是動態規劃中的經典題,我們先來看看該題。
題目:
給定一個無序的整數數組,找到其中最長上升子序列的長度。
示例:
輸入: [10,9,2,5,3,7,101,18]
輸出: 4
解釋: 最長的上升子序列是 [2,3,7,101],它的長度是 4。
說明:
- 可能會有多種最長上升子序列的組合,你只需要輸出對應的長度即可。
- 你算法的時間複雜度應該爲 。
由於我們主要介紹動態規劃,所以這裏就不討論其他算法了。
題解:
這道題說的是子序列,沒有說連續的,所以只要保證先後順序,即使斷開也算。假設我們知道了第 i
項前所有以各元素爲結尾的序列的最長上升子序列,那麼我們如何求以第 i
項爲結尾的最長上升子序列呢?
只要得出這個答案,我們就找到了本題的狀態轉移方程。其實也不難,舉個例子,看我們上面給出的示例。現在我們知道了以數值 3 爲結尾的最長上升子序列爲 [2,3]
,那以數值 7 爲結尾的如何計算了。7 比 3 大,我們只要在 3 結尾的最大子序列上加 1 即可。
但是如果此時說這個值最大,是不一定了。7 前面不止還有很多子序列,我們需要比較其前面每一個元素結尾的最長上升子序列。
狀態轉移方程爲:
我們可以結合代碼來進一步看如何使用動態規劃的思想。
var lengthOfLIS = function(nums) {
// 基本的空和空數組校驗
if (nums === null || nums.length === 0) {
return 0
}
const len = nums.length, dp = new Array(len)
let ansMax = 1
dp[0] = 1
for (let i = 1; i < len; i++) {
let itemMax = 0
for (let j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
itemMax = Math.max(itemMax, dp[j])
}
}
// 保存每一項爲結尾的最長上升子序列個數
dp[i] = itemMax + 1
// 這一步不要,最後整個從 dp 數組中比較得出最大值也行
ansMax = Math.max(dp[i], ansMax)
}
return ansMax
};
最大子序和
看懂上一題,估計做動態規劃相關的題目就有感覺了。再來看一道簡單的題。
題目:
給定一個整數數組 nums ,找到一個具有最大和的連續子數組(子數組最少包含一個元素),返回其最大和。
示例:
輸入: [-2,1,-3,4,-1,2,1,-5,4],
輸出: 6
解釋: 連續子數組 [4,-1,2,1] 的和最大,爲 6。
題解:
假設我們知道了包含第 k 項的最大和的連續子數組,那麼我們可以很簡單求出包含第 k + 1 項的最大和的連續子數組的值。
狀態轉移方程:
最終我們要求的是 dp 數組中的最大值。
var maxSubArray = function(nums) {
if (!nums || nums.length === 0) {
return 0
}
const len = nums.length, dp = new Array(len)
let max = dp[0]
for (let i = 1; i < len; i++) {
dp[i] = Math.max(dp[i - 1] + nums[i], nums[i])
max = Math.max(dp[i], max)
}
return max
};
這裏我們還可以進一步優化空間複雜度,可以根據需要自行優化。
乘積最大子數組
上一題,我們求的是最大和,這一題我們來看看最大積。
題目:
給你一個整數數組 nums ,請你找出數組中乘積最大的連續子數組(該子數組中至少包含一個數字),並返回該子數組所對應的乘積。
示例:
輸入: [2,3,-2,4]
輸出: 6
解釋: 子數組 [2,3] 有最大乘積 6。
輸入: [-2,0,-1]
輸出: 0
解釋: 結果不能爲 2, 因爲 [-2,-1] 不是子數組。
題解:
可能很多人和博主最開始一樣,直接拿標準的動態規劃模板套。但是最後發現不對頭,因爲負負得正。可能前面連續最小的,最後乘當前值會變成最大的。所以我們需要同時維護上一個最大值和上一個最小值。
var maxProduct = function(nums) {
if (!nums || nums.length === 0) {
return 0
}
const len = nums.length
let max = nums[0], min = nums[0], ans = nums[0]
for (let i = 1; i < len; i++) {
const tmax = max, tmin = min
min = Math.min(tmax * nums[i], tmin * nums[i], nums[i])
max = Math.max(tmax * nums[i], tmin * nums[i], nums[i])
ans = Math.max(ans, max)
}
return ans
};
看到這裏,很多人可能覺得動態規劃好無聊!
確實,我們上面看到的都是一些數學問題。接下來的我們聯繫生活中的場景來看看動態規劃的應用。什麼場景呢?大家感興趣的 5 個字:money(錢)。
硬幣
題目:
給定數量不限的硬幣,幣值爲25分、10分、5分和1分,編寫代碼計算n分有幾種表示法。(結果可能會很大,你需要將結果模上1000000007)
示例:
輸入: n = 5
輸出:2
解釋: 有兩種方式可以湊成總金額:
5=5
5=1+1+1+1+1
輸入: n = 10
輸出:4
解釋: 有四種方式可以湊成總金額:
10=10
10=5+5
10=5+1+1+1+1+1
10=1+1+1+1+1+1+1+1+1+1
說明:
你可以假設:
題解:
這裏和前面幾種不太一樣,因爲它是二維的,需要考慮兩個維度,即硬幣的類型和給出的總錢數(分)。這裏博主畫一個表格幫助大家理解。
首先,我們如果只用1分的硬幣,那答案很簡單。接着,如果我們只用1分和5分兩種硬幣,答案要複雜些,但是我們可以基於前面只用1分得出的結果來計算。依次類推,我們一直往下計算,最終可以得出包所有硬幣的分法。
根據上面的表格,我們可以寫出如下代碼。
var waysToChange = function(n) {
const dp5 = new Array(n + 1)
const dp10 = new Array(n + 1)
const dp25 = new Array(n + 1)
dp5[0] = 1
dp10[0] = 1
dp25[0] = 1
for (let i = 1; i <= n; i++) {
dp5[i] = i - 5 >= 0 ? dp5[i - 5] + 1 : 1
}
for (let i = 1; i <= n; i++) {
dp10[i] = i - 10 >= 0 ? dp10[i - 10] + dp5[i] : dp5[i]
}
for (let i = 1; i <= n; i++) {
dp25[i] = i - 25 >= 0 ? dp25[i - 25] + dp10[i] : dp10[i]
}
return dp25[n] % 1000000007
};
其實上面的代碼的空間複雜度可以繼續優化,通過分析,我們可以發現一旦進行下一種類型的硬幣的計算,其實只需要依賴前一種硬幣的結果,其他的可以覆蓋掉。經過優化我們可以得出如下代碼:
var waysToChange = function(n) {
const dp = new Array(n + 1)
dp.fill(0)
dp[0] = 1
const coins = [1, 5, 10, 25]
for (let i = 0; i < 4; i++) {
for (let j = 1; j <= n; j++) {
const sub = j - coins[i]
if (sub >= 0) {
dp[j] += dp[sub]
}
}
}
return dp[n] % 1000000007
};
零錢兌換
題目:
給定不同面額的硬幣 coins 和一個總金額 amount。編寫一個函數來計算可以湊成總金額所需的最少的硬幣個數。如果沒有任何一種硬幣組合能組成總金額,返回 -1。
示例:
輸入: coins = [1, 2, 5], amount = 11
輸出: 3
解釋: 11 = 5 + 5 + 1
輸入: coins = [2], amount = 3
輸出: -1
題解:
如果對回溯法比較熟悉的人,可能第一感覺是使用回溯的思想,利用遞歸的技巧解題。但是這篇博文的主題是動態規劃,所以大家可以思考如何利用動態規劃的思想來求解。
其實這題和上面那道題很類似,但不同的是這題是一個求最優解的問題。我們先找到最優子結構,F(S):組成金額 S 所需的最少硬幣數量
。若組成金額 S 最少的硬幣數,最後一枚硬幣的面值是 C,分析可得出狀態轉移方程,F(S)=F(S−C)+1
。
我們這裏只要比對每一個硬幣,得出其中的最小值即可。
var coinChange = function(coins, amount) {
if (amount === 0) return 0
let ans = Number.POSITIVE_INFINITY
const dp = new Array(amount + 1)
dp.fill(-1)
dp[0] = 0
for (let i = 1; i <= amount; i++) {
for (let j = 0; j < coins.length; j++) {
if (i - coins[j] < 0 || dp[i - coins[j]] === -1) {
continue
}
dp[i] = dp[i] === -1 ? dp[i - coins[j]] + 1 : Math.min(dp[i - coins[j]] + 1, dp[i])
}
}
return dp[amount]
};
後記
動態規劃比較難的是找到狀態的定義,然後分析得出狀態轉移方程。另一個必要重要的是重疊子問題,事實上在根據狀態轉移方程得出每個狀態時,需要緩存這個狀態,這樣可以避免重複計算,也是動態規劃的核心。
之前說過,動態規劃適合求解最優解問題。這類問題加了一個最優子結構的概念,其實就是前面狀態中符合提題意的一種狀態,博主認爲一種特殊情況。最優子結構(特殊的狀態),找到後和普通動態規劃問題一樣,繼續找到狀態轉移方程。
經過博文中的幾道動態規劃的題,可以讓大家稍微明白動態規劃的應用與解題思路。如果對動態規劃特別感興趣,可以點擊下面的鏈接繼續做題。