筆者最近的算法學習到了動態規劃的階段,刷了很多動態規劃的題,對於動態規劃中的揹包問題,剛開始真的很是頭痛,不少題可能只是稍稍更改了一些約束條件,我就答不出來了。這類題雖然解題的方法都是相似,可是存在不少變種,不一樣的變種,它所須要修改的解題思路都很是須要去仔細琢磨體會,因此我一開始,不但解題成功率不高,解題速度也是很是慢。在這裏,我基於本身的實踐以及一些前輩大佬的經驗,總結概括了幾個比較基礎的解題方法,用來提升解答揹包問題的成功率和效率。html
我先對最基礎的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]
學習
dp[i-1][j]
的值來。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
?
nums
中的數據只能使用一次,不須要順序關係,它的循環邏輯通常爲
nums循環(x軸)嵌套target循環(y軸),且target循環倒序
複製代碼
nums
中的數據能夠重複使用,不須要順序關係
nums循環(x軸)嵌套target循環(y軸),且target循環正序
複製代碼
nums
中的數據可重複使用,可是須要考慮元素之間的順序,不一樣的順序表明不一樣的結果。
target循環(x軸)嵌套nums循環(y軸), 都正序
複製代碼
求有多少種組合,有多少知足條件的項
dp[i] += dp[i-num];
複製代碼
驗證是否存在知足條件的項
dp[i] = dp[i] || dp[i-num];
複製代碼
求知足條件的最大/小值
dp[i] = Math.max / min(dp[i], dp[i-num]+1);
複製代碼
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
的元素,並且target
爲j
時,能夠湊成總金額的最少硬幣個數
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
, 循環, 狀態轉移方程的定義的變化,因此咱們得根據具體的場景去對代碼邏輯進行修改。
並且揹包問題確定不止這麼幾種分類,它還有不少其餘的變種,好比多維費用的揹包問題,有依賴的揹包問題等等。對於這些問題,還須要咱們對於這些基本的方法進行一個拓展。
總之,變強只有一條路,多刷題。
感謝各位的閱讀,若是本文對你有所幫助的話,請動手點個贊,感謝!