算法學習中總結概括的幾種揹包問題的解題思路

前言

筆者最近的算法學習到了動態規劃的階段,刷了很多動態規劃的題,對於動態規劃中的揹包問題,剛開始真的很是頭痛,不少題可能只是稍稍更改了一些約束條件,我就答不出來了。這類題雖然解題的方法都是相似,可是存在不少變種,不一樣的變種,它所須要修改的解題思路都很是須要去仔細琢磨體會,因此我一開始,不但解題成功率不高,解題速度也是很是慢。在這裏,我基於本身的實踐以及一些前輩大佬的經驗,總結概括了幾個比較基礎的解題方法,用來提升解答揹包問題的成功率和效率。html

經典0-1揹包問題

我先對最基礎的0-1揹包問題作一個簡單的介紹和回顧,其它的揹包問題基本都是這個基礎問題的變種。算法

有一個揹包,它的容量爲C。如今有n種不一樣的物品,編號爲0...n-1,
其中每一件物品的重量爲w(物品重量的數組),價值爲v(物品價值的數組)。
問能夠向這個揹包中盛放哪些物品,使得在不超過揹包容量的基礎上,物品的總價值最大?
複製代碼

簡單說下解題邏輯:數組

假設如今有個容量爲c = 5的揹包,有三個物品,它們的重量和價值的數組爲 w = [1, 2, 3]v = [6, 10, 12]markdown

咱們定義一個二維數組dp,物品個數做爲x軸,揹包容量做爲y軸。dp[i][j]表明當放入揹包的物品個數爲i + 1時,揹包容量爲j時,揹包裏的最大物品價值。dp初始值都爲-1,以下:app

y軸長度爲揹包容量 + 1,便於後續計算ide

0 1 2 3 4 5
dp[0] -1 -1 -1 -1 -1 -1
dp[1] -1 -1 -1 -1 -1 -1
dp[2] -1 -1 -1 -1 -1 -1

咱們先給第一排賦值,當容量爲1的時候,才能放下第一個元素,在dp中存入該元素的價值,即6。函數

0 1 2 3 4 5
dp[0] 0 6 6 6 6 6
dp[1] -1 -1 -1 -1 -1 -1
dp[2] -1 -1 -1 -1 -1 -1

而後咱們給第二排賦值,由於第二個元素的重量爲2,因此以2爲標準,這裏有三種狀況。假設當前節點爲dp[i][j]學習

  • 當容量小於2的時候,放不下,因此按dp[i-1][j]的值來。
  • 當容量大於等於2的時候,拿dp[i-1][j]v[i] + dp[i - 1][c - w[i]]比較,取較大值。即比較不使用當前元素的最大值以及當前元素價值 + (容量-當前元素重量)時的最大價值
0 1 2 3 4 5
dp[0] 0 6 6 6 6 6
dp[1] 0 6 12 16 16 16
dp[2] -1 -1 -1 -1 -1 -1

最後賦值第三排,也是同樣的規則,最終獲得優化

0 1 2 3 4 5
dp[0] 0 6 6 6 6 6
dp[1] 0 6 10 16 16 16
dp[2] 0 6 10 16 18 22

dp[2][5]所表明的值即爲咱們題目中須要求的最大值。不過咱們還能夠對空間複雜度進行優化,由於二維數組dp在賦值的時候,只須要使用上面以及上一行左側的元素,因此咱們能夠用一個一維數組進行優化,在初始化dp[0]以後,使用從右往左的順序來對dp進行賦值。ui

經典0-1揹包問題的解法:

// 本例優化了空間複雜度,把二維數組優化成了一個一維數組
const knapsack01 = (w, v, c) => {
    let len = w.length;
    if (len === 0) return 0;
    let memo = new Array(c + 1).fill(0);
    for (let i = 0; i <= c; i ++) {
        if (i >= w[0]) memo[i] = v[0];
    }

    for (let i = 1; i < len; i ++) {
        for (let j = c; j >= w[i]; j --) {
            memo[j] = Math.max(memo[j], v[i] + memo[j - w[i]]);
        }
    }
    return memo[c];
}
複製代碼

本題是最基本的揹包問題,有過動態規劃學習的同窗應該都能解答出來。

總結的幾種揹包問題的基本解題方法

咱們常見的有幾種揹包問題,我先列出它們基本的循環邏輯以及核心的狀態轉移方程。下面幾節,關於這幾種常見的揹包問題,我都會列一個很是經典的例子來實踐。

常見揹包問題的特徵:

通常都會給出一組數組nums,再給一個目標值target,要求從nums中取出多少個元素能夠知足target?

循環邏輯

類0-1揹包問題

nums中的數據只能使用一次,不須要順序關係,它的循環邏輯通常爲

nums循環(x軸)嵌套target循環(y軸),且target循環倒序
複製代碼

可重複揹包

nums中的數據能夠重複使用,不須要順序關係

nums循環(x軸)嵌套target循環(y軸),且target循環正序
複製代碼

排列揹包

nums中的數據可重複使用,可是須要考慮元素之間的順序,不一樣的順序表明不一樣的結果。

target循環(x軸)嵌套nums循環(y軸), 都正序
複製代碼

狀態轉移方程

數量問題

求有多少種組合,有多少知足條件的項

dp[i] += dp[i-num];
複製代碼

true,false問題

驗證是否存在知足條件的項

dp[i] = dp[i] || dp[i-num];
複製代碼

最大最小問題

求知足條件的最大/小值

dp[i] = Math.max / min(dp[i], dp[i-num]+1);
複製代碼

實踐:幾道經典例題

組合總和4

leetcode 377
給你一個由 不一樣 整數組成的數組 nums ,和一個目標整數 target 。請你從 nums 中找出並返回總和爲 target 的元素組合的個數。
題目數據保證答案符合 32 位整數範圍。

輸入:nums = [1,2,3], target = 4
輸出:7
解釋:全部可能的組合爲:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)
請注意,順序不一樣的序列被視做不一樣的組合。
複製代碼

咱們先來肯定循環以及核心狀態轉移方程。

由於取的組合數量,因此核心狀態轉移方程爲dp[i] += dp[i-num]

屬於排列揹包,不一樣順序的結果被視做不一樣的組合,因此循環方式爲target循環嵌套nums循環, 都正序

咱們再來確認dp的定義,dp[i][j]表明當target值爲j時,從nums中取出i個元素,能夠知足總和爲target的元素組合個數。

var combinationSum4 = function(nums, target) {
    // 二維數組優化成一維數組
    // 設置長度爲target + 1,用於處理target = 0的狀況
    const dp = new Array(target + 1).fill(0);
    // 初始化dp[0][j],當使用0個元素,以及target爲0時,存在一種組合數,因此爲1
    dp[0] = 1;
    for (let i = 1; i <= target; i++) {
        for (const num of nums) {
            if (num <= i) {
                // 只有當target大於當前num值,纔可能存在使用當前num項的組合
                dp[i] += dp[i - num];
            }
        }
    }
    return dp[target];
};
複製代碼

零錢兌換

leetcode 322
給定不一樣面額的硬幣 nums 和一個總金額 target。編寫一個函數來計算能夠湊成總金額所需的最少的硬幣個數。
若是沒有任何一種硬幣組合能組成總金額,返回 -1。

你能夠認爲每種硬幣的數量是無限的。

輸入:nums = [1, 2, 5], target = 11
輸出:3
解釋:11 = 5 + 5 + 1

輸入:nums = [2], target = 3
輸出:-1

輸入:nums = [1], target = 0
輸出:0
複製代碼

由於取的最小值,因此核心狀態轉移方程爲dp[i] = Math.min(dp[i], dp[i-num]+1);

屬於可重複揹包,nums中的數據能夠重複使用,因此循環方式爲nums循環嵌套target循環,且target循環正序

dp[i][j]可定義爲當使用第1,2, 3...nums.length的元素,並且targetj時,能夠湊成總金額的最少硬幣個數

var coinChange = function(nums, target) {
    const len = nums.length;
    // 邊界條件處理
    if (len === 0) return target === 0 ? 0 : -1;
    const dp = new Array(target + 1).fill(Infinity);
    // 初始化當x軸爲0,即只取nums中的第一個元素時,dp[0,1,2...target]的值
    for (let i = 0; i <= target; i ++) {
        // 當前target能夠被nums[0]元素整除時,設置該值爲i / nums[0]
        // 這裏須要注意,當target爲0時,能夠存在一個0值,說明取了0個元素
        if ((i % nums[0]) === 0) dp[i] = i / nums[0];
    }
    for (let i = 1; i < len; i ++) {
        // 這裏須要正序遍歷,由於nums中的元素可被重複使用
        // 好比例子中的nums = [1, 2, 5], target = 11,當遍歷到5的值時
        // target = 6時,dp[6]將被更新
        // amout = 11時,dp[11]的值會跟dp[6]有關,可是這時的dp[6]已經被更新過
        // 這樣就實現了,nums中元素的重複使用
        for (let j = 1; j <= target; j ++) {
            if (j >= nums[i]) {
                // 核心狀態轉移方程
                dp[j] = Math.min(dp[j], dp[j - nums[i]] + 1);
            }
        }
    }
  // 結果的處理
  const res = dp[target];
  return res === Infinity ? -1 : res;
}
複製代碼

分割等和子集

leetcode 416
給你一個 只包含正整數 的 非空 數組 nums 。請你判斷是否能夠將這個數組分割成兩個子集,使得兩個子集的元素和相等。

輸入:nums = [1,5,11,5]
輸出:true
解釋:數組能夠分割成 [1, 5, 5] 和 [11] 。

輸入:nums = [1,2,3,5]
輸出:false
解釋:數組不能分割成兩個元素和相等的子集。
複製代碼

本題須要先對該數據進行處理,使其符合揹包問題的形式。

由於是分紅兩個子集,因此只須要判斷是否可以選出n個元素,和爲sum/2

由於是判斷是否存在知足的條件,因此核心狀態轉移方程爲dp[i] = dp[i] || dp[i-num]

屬於類0-1揹包問題,由於nums中的數據只能使用一次,因此循環方式爲nums循環嵌套target(即sum/2)循環,且target循環倒序

dp[i][j]表明當取nums[0...i]裏的元素時,是否存在子集的值之和等於j

var canPartition = function(nums) {
    // 計算sum / 2
    let sum = 0, len = nums.length;
    for (let i = 0; i < len; i ++) {
        sum = sum + nums[i];
    }
    if (sum % 2 !== 0) return false;
    let target = sum / 2;
    // 使用target(sum / 2)做爲容量,建立一個target + 1的數組
    let dp = new Array(target + 1).fill(false);
    // dp[0][j]的初始化,即當只取nums[0]的狀況
    // 當i等於nums[0]的狀況,dp[i]設爲true
    for (let i = 0; i <= target; i ++) {
        dp[i] = (nums[0] === i);
    }
    // 這裏的i表明當前取的是nums[i]元素
    // 套用上面的循環邏輯
    for (let i = 1; i < len;i ++) {
        for (let j = target; j >= nums[i]; j --) {
            // 套用上面的核心狀態轉移方程
            dp[j] = dp[j] || dp[j - nums[i]];
        }
    }
    return dp[target];
}
複製代碼

總結和建議

本文中總結了一些常見的揹包問題的解題方法(循環的方式,狀態轉移方程)。在揹包問題的解題過程當中,若是題型能夠對號入座, 那就能夠根據這些方法去尋找解題的思路,對於初學者來講,能夠提升很多的解題效率。

這邊我還須要說明一下,單純的去記憶這些方法是沒有用的,各位必定要在理解的基礎上去記憶。由於這些方法只是一個最最基本的框,根據題目的不一樣,條件的不一樣,都會致使的邊界狀況、dp, 循環, 狀態轉移方程的定義的變化,因此咱們得根據具體的場景去對代碼邏輯進行修改。

並且揹包問題確定不止這麼幾種分類,它還有不少其餘的變種,好比多維費用的揹包問題,有依賴的揹包問題等等。對於這些問題,還須要咱們對於這些基本的方法進行一個拓展。

總之,變強只有一條路,多刷題。

感謝

感謝各位的閱讀,若是本文對你有所幫助的話,請動手點個贊,感謝!

相關文章
相關標籤/搜索