動態規劃算法題

博主在上一篇博文 簡單聊一聊動態規劃算法 中講了一些關於動態規劃的基本知識。本篇博文主要介紹動態規劃相關的習題,目的是幫助大家強化動態規劃的思想與應用。

由於博主從事 web 前端相關的工作,所以編程語言選擇的是 JavaScript。不過算法重要的不是語言而是解題思路,相信學習其他編程語言的同學也能看懂。

最長上升子序列(LIS)

最長上升子序列(Longest Increasing Subsequence)這題算是動態規劃中的經典題,我們先來看看該題。

題目:

給定一個無序的整數數組,找到其中最長上升子序列的長度。

示例:

輸入: [10,9,2,5,3,7,101,18]
輸出: 4 
解釋: 最長的上升子序列是 [2,3,7,101],它的長度是 4。

說明:

  • 可能會有多種最長上升子序列的組合,你只需要輸出對應的長度即可。
  • 你算法的時間複雜度應該爲 O(n2)O(n^2)

由於我們主要介紹動態規劃,所以這裏就不討論其他算法了。

題解:

這道題說的是子序列,沒有說連續的,所以只要保證先後順序,即使斷開也算。假設我們知道了第 i 項前所有以各元素爲結尾的序列的最長上升子序列,那麼我們如何求以第 i 項爲結尾的最長上升子序列呢?

只要得出這個答案,我們就找到了本題的狀態轉移方程。其實也不難,舉個例子,看我們上面給出的示例。現在我們知道了以數值 3 爲結尾的最長上升子序列爲 [2,3] ,那以數值 7 爲結尾的如何計算了。7 比 3 大,我們只要在 3 結尾的最大子序列上加 1 即可。

但是如果此時說這個值最大,是不一定了。7 前面不止還有很多子序列,我們需要比較其前面每一個元素結尾的最長上升子序列。

狀態轉移方程爲:dp[i]=max(dp[j])+1,0j<inum[j]<num[i]dp[i]=max(dp[j])+1,其中0≤j<i且num[j]<num[i]

我們可以結合代碼來進一步看如何使用動態規劃的思想。

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[k]=Math.max(dp[k1]+nums[k],nums[k])dp[k] = Math.max(dp[k - 1] + nums[k], nums[k])

最終我們要求的是 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

說明:

你可以假設:0<=n()<=10000000 <= n (總金額) <= 1000000

題解:

這裏和前面幾種不太一樣,因爲它是二維的,需要考慮兩個維度,即硬幣的類型和給出的總錢數(分)。這裏博主畫一個表格幫助大家理解。

首先,我們如果只用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]
};

後記

動態規劃比較難的是找到狀態的定義,然後分析得出狀態轉移方程。另一個必要重要的是重疊子問題,事實上在根據狀態轉移方程得出每個狀態時,需要緩存這個狀態,這樣可以避免重複計算,也是動態規劃的核心。

之前說過,動態規劃適合求解最優解問題。這類問題加了一個最優子結構的概念,其實就是前面狀態中符合提題意的一種狀態,博主認爲一種特殊情況。最優子結構(特殊的狀態),找到後和普通動態規劃問題一樣,繼續找到狀態轉移方程。

經過博文中的幾道動態規劃的題,可以讓大家稍微明白動態規劃的應用與解題思路。如果對動態規劃特別感興趣,可以點擊下面的鏈接繼續做題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章