经典动态规划:子集背包问题 :: labuladong的算法小抄 #1030
Replies: 34 comments 7 replies
-
滴,学生卡。 |
Beta Was this translation helpful? Give feedback.
-
唯一需要注意的是 j 应该从后往前反向遍历,因为每个物品(或者说数字)只能用一次,以免之前的结果影响其他的结果。这句话没读懂啊,为啥会影响之前的其他结果? |
Beta Was this translation helpful? Give feedback.
-
qs,我也想知道 |
Beta Was this translation helpful? Give feedback.
-
关于空间优化的问题,在网站搜索「对动态规划进行降维打击」这篇文章,有详细解释。 |
Beta Was this translation helpful? Give feedback.
-
個人理解 不確定是不是正確 :) 觀察2D 是依據前一排 i-1 得出的 (like dp[i-1][j] ), 轉成1D 要反轉是因為 dp[ j - nums[i-1]], |
Beta Was this translation helpful? Give feedback.
-
还是没理解。尝试按之前的遍历方向那篇博文 https://labuladong.gitee.io/algo/3/24/71/ 画了矩阵图,dp[i][j] 来自于 dp[i-1][j]和 dp[i-1][j-nums[i]] 我画了图,这道题的情况base case 和它相同,方向也一致,为什么这个就是从右到左了,画图的时候怎么区别这种情况呢? |
Beta Was this translation helpful? Give feedback.
-
注意: 这个方向不是固定的 一定要弄清楚 当前的状态依赖于从哪个方向传递过来的前一个状态, 比如 如果 i状态依赖于 从 m方向传递过来的 这种情况你所说的 每一步迭代依赖位置就不一样了 |
Beta Was this translation helpful? Give feedback.
-
@zhongweiLeex 我判断方向错了吗?我画出来两个方向都是左上到右下。区别在于倾斜度不同,为什么一个需要反方向遍历,一个不用呢? |
Beta Was this translation helpful? Give feedback.
-
建议再读一下,东哥公众号 |
Beta Was this translation helpful? Give feedback.
-
@Tokics 楼上大佬们解释得很清晰啦,我再来补充点看会不会更具体些? 1.先看原来2D情况,dp[i][j]的值,都是仅依靠上一行dp[i-1][...]得出的。意思是我们要算当前dp[i]行的值,仅需要上一行dp[i-1]就好。所以可以将其转化为:原地更新1D数组问题; 2.现在考虑降为1D,定义该1D数组为int[] dp。回忆原来2D情况,dp[i][j]的值都是依靠“其正上方的值dp[i-1][j]+左上方的值dp[i-1][j-nums[i]]”来更新。那么如果对1D进行正向遍历即从dp[0]->dp[n-1]填充,对于某一位例如dp[cur]的更新,势必会用到dp[pre](pre<cur),因为是正向遍历,那么dp[pre]在当前轮次已经被更新过了,当在这种情况下计算的dp[cur]肯定不正确(其实说白了,就相当于2D情况下,使用了同一行的值。例如使用dp[i][j-nums[i]]来更新dp[i][j]); 3.现在解释对1D数组进行反向遍历即从dp[n-1]->dp[0]填充。同样,对于某一位例如dp[cur]的更新,势必会用到dp[pre](pre<cur)。但注意,因为是从后往前进行遍历的,此时dp[pre]在当前轮次未被更新,所以就相当于2D情况下使用的上一行的值,这样计算就是正确的了。 |
Beta Was this translation helpful? Give feedback.
-
@lee666-lee 总结的很正确👍 |
Beta Was this translation helpful? Give feedback.
-
@labuladong 我觉得动态规划问题用递归(自顶而下)的时间复杂度是不是天生要比迭代(自底而上)高啊?(递归在参数传递的时候会有额外的时空开销),或者这些开销相对于整体可以忽略不计? 在这题中我也有同样的感觉(这个oj超时了) class Solution {
public:
// 定义dp为在前i个数据中挑数累加, 直到结果等于总和的一半
// sum从0开始选数求和,直到等于总和的一半
bool dp(vector<int>& nums, int i, int sum, int cap )// cap为总和的一半
{
if(sum == cap)
return true;
if(i == -1)
return false;
return dp(nums, i-1, sum, cap)||dp(nums, i-1, sum+nums[i], cap);
}
bool canPartition(vector<int>& nums) {
int cap = accumulate(nums.begin(), nums.end(), 0);
if(cap%2 == 1)
return false;
return dp(nums, nums.size()-1, 0, cap/2);
}
}; |
Beta Was this translation helpful? Give feedback.
-
PS:再引申一个问题,动态规划能用递归做应该也能用迭代做,所以会不会有哪些情况或特殊的问题下这两种方法有优劣之分呢? |
Beta Was this translation helpful? Give feedback.
-
@labuladong 捞一捞,大佬 |
Beta Was this translation helpful? Give feedback.
-
@kuangkuangkuangha 首先给你的思考点赞~ 1、你给出的这个解法,本质上不是动态规划思路,而是回溯算法思路,暴力尝试所有可能的累加和,所以当然会超时。 动态规划的关键在于「状态转移方程」,即如何通过若干个已计算出的「状态」推导出未计算的「状态」,在这个推导「状态」的过程中,可能存在重复计算「状态」的情况(重叠子问题),需要备忘录技巧消除冗余计算。进一步,备忘录可以改造成 那么看看你给出的代码,虽然函数名字叫 比如就按照你的定义,比如说 所以,你的这个解法思路是回溯算法,改写成如下形式看起来更明显: bool backtrack(vector<int>& nums, int i, int trackSum, int cap ) {
if(trackSum == cap)
return true;
if(i == -1)
return false;
// 做选择
trackSum += 0;
backtrack(nums, i - 1, trackSum, cap)
// 撤销选择
trackSum -= 0;
// 做选择
trackSum += nums[i];
backtrack(nums, i - 1, trackSum, cap);
// 撤销选择
trackSum -= nums[i];
} 2、动态规划用于优化暴力穷举解法所用的备忘录 以上是我个人的一些理解,我觉得你提出的这个问题很有价值,后续考虑专门写篇文章写一写动态规划和回溯算法在思路上的区别。如果你还有什么问题,欢迎继续和我探讨~ |
Beta Was this translation helpful? Give feedback.
-
贴一个自顶向下带个备忘录C++ bool dp(vector<int>& nums, int i, int size) {
// base case
int j = size;
if (size == 0) return true; // 背包被装满了,返回true
if (i == -1) return false; // 没有可以选择的数字,因此不可能装满
if (memo[i][j] != -1) return memo[i][j];
if (size - nums[i] >= 0) {
return memo[i][j] = dp(nums, i - 1, size) || dp(nums, i - 1, size - nums[i]); // dp(nums, i - 1, size):判断不放nums[i]能否将size装满; dp(nums, i - 1, size - nums[i]) 放nums[i]的话判断是否在[1, i - 1]这些数字中将size - nums[i]装满
} else {
return memo[i][j] = dp(nums, i - 1, size);
}
}
bool canPartition(vector<int>& nums) {
int n = nums.size();
int sum = 0;
for (auto num: nums) {
sum += num;
}
if (sum % 2 != 0) return false; // 无法分割
int size = sum / 2;
memo.assign(n, vector<int> (size + 1, -1)); // 重量从0到sum / 2;
return dp(nums, n - 1, sum / 2);
}
}; |
Beta Was this translation helpful? Give feedback.
-
记忆化搜索Java版本: class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for (int i = 0; i < nums.length; i++) {
sum += nums[i];
}
if (sum % 2 != 0) return false;
memo = new Boolean[sum / 2 + 1][nums.length];
return process(nums, sum / 2, 0);
}
Boolean[][] memo;
public boolean process(int[] nums, int bag, int index) {
if (index < 0 || index >= nums.length) {
return false;
}
if (bag == 0) {
return true;
} else if (bag < 0) {
return false;
}
if (memo[bag][index] != null) {
return memo[bag][index];
}
memo[bag][index] = process(nums, bag - nums[index], index + 1)
|| process(nums, bag, index + 1);
return memo[bag][index];
}
} |
Beta Was this translation helpful? Give feedback.
-
我觉得递归和递推的区别有两个:
|
Beta Was this translation helpful? Give feedback.
-
二维压缩到一维,一般是指保留一行,去掉其他行。 假设 j为5, j-num[j]为3, 则 在一维的情形下:所以如果j是从0向上增长的话,第i-1行的3已经更新了(被第i行) , 等到计算第i行的5 综上所述,从大到小遍历才不会出问题。 |
Beta Was this translation helpful? Give feedback.
-
Python解法 class Solution:
def canPartition(self, nums: List[int]) -> bool:
n = len(nums)
Sum = sum(nums)
if Sum % 2 != 0:
return False
Sum = Sum // 2 # bag capacity
# 对于前i个物品,背包的容量为j
# dp[i][j] = true能恰好装满,false不能恰好装满
dp = [[False for _ in range(Sum+1)] for __ in range(n+1)]
# base case:背包容量为0时,什么都不装也已经装满了
for i in range(n+1):
dp[i][0] = True
# 对于每一个新加入的元素nums[i-1]循环
for i in range(1, n+1):
# 对于1到Sum中每一个背包容量,判断能否正好装满
for j in range(1, Sum+1):
# 背包容量不足,不装入第i个物品
# 此时能否装满的判断,等于没有加入元素nums[i-1]时的状态
if j - nums[i-1] < 0:
dp[i][j] = dp[i-1][j]
# 容量足够,对nums[i-1]有两个选择:
# 装入nums[i-1],能否恰巧装满取决于dp[i-1][j-nums[i-1]]的状态
# 不装nums[i-1],能否装满取决于dp[i-1][j]的状态
else:
dp[i][j] = dp[i-1][j-nums[i-1]] or dp[i-1][j]
return dp[n][Sum] |
Beta Was this translation helpful? Give feedback.
-
打卡 |
Beta Was this translation helpful? Give feedback.
-
dp[i][j] = x j是背包的剩余重量 |
Beta Was this translation helpful? Give feedback.
-
个人认为在定义状态的时候,n定义为使用前个元素(包含),这样能直接使用nums[j] 不用nums[j-1]会直观一点,缺点就是表达不了没有元素的情况,因为即便n取0也有一个元素。 class Solution {
public boolean canPartition(int[] nums) {
int n = nums.length;
int sum = Arrays.stream(nums).sum();
if (sum % 2 != 0) {
return false;
}
sum /= 2;
// dp[i][j]: 使用 [0:j]个元素是否能够拼出sum
boolean[][] dp = new boolean[sum + 1][n];
for (int i = 0; i <= sum; i++) {
for (int j = 0; j < n; j++) {
if (i == 0) {
// 背包为空,多少元素都可以装满
dp[i][j] = true;
continue;
}
if (j == 0) {
// 只有一个元素,如果刚好跟背包一样大小,就能填充
dp[i][j] = i == nums[j];
continue;
}
if (i >= nums[j]) {
dp[i][j] = dp[i][j - 1] || dp[i - nums[j]][j - 1];
} else {
dp[i][j] = dp[i][j - 1];
}
}
}
return dp[sum][n - 1];
}
} |
Beta Was this translation helpful? Give feedback.
-
请教东哥,根据dp数组的定义,是不是dp[1...n][sum]只要有一个true就可以提前结束遍历了,不需要一直遍历完最后取dp[n][sum]? |
Beta Was this translation helpful? Give feedback.
-
打卡 |
Beta Was this translation helpful? Give feedback.
-
请问这段python代码为何不行啊,调试中发现,dp[..][sum2]已经出现了True,但是没有return:
|
Beta Was this translation helpful? Give feedback.
-
「如果 j - nums[i-1] 的重量可以被恰好装满,那么只要把第 i 个物品装进去,也可恰好装满 j 的重量;否则的话,重量 j 肯定是装不满的。」这句话特别不好理解,可能需要借助具体的例子。如果换成 「重量为 j-nums[i-1] 的背包可能更好理解」,(注意,此时的 j-nums[i-1] 和 j 都不是 sum,也就是当前的 j 不是满足题目要求的 sum ) |
Beta Was this translation helpful? Give feedback.
-
最后那个反向遍历,假设现在遍历到nums的i个,dp[j]的更新可能需要dp[j-nums[i]]。如果从前向后遍历sum的值的话,此时dp[j-nums[i]]已经不是dp[i-1][j-nums[i]],而是dp[i][j-nums[i]]了。这应该就是以免之前的结果影响其他的结果。 |
Beta Was this translation helpful? Give feedback.
-
在文章最后'时间复杂度 O(n*sum)'中 ‘n*sum' 是typo? |
Beta Was this translation helpful? Give feedback.
-
// 严格按照0-1背包定义也能解决。dp数组含义是能放入的最大重量,如果dp[n][w]重量刚好为总和的一半,就表示能分成两组满足各一半。 |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
文章链接点这里:经典动态规划:子集背包问题
评论礼仪 见这里,违者直接拉黑。
Beta Was this translation helpful? Give feedback.
All reactions