代码随想录 | Day 44 - SP OJ. Piggy-Bank(完全背包)、LeetCode 518. 零钱兑换 II、LeetCode 377. 组合总和 Ⅳ

        今天是完全背包问题的基础和对应的组合数,排列数问题。完全背包相比01背包问题在实现上的改变只有内层循环遍历顺序。内外层循环的方向是否可交换是重点,有时是可以交换的,有时却不行。比如第2、3题,交换后就由组合数转化为了排列数的计算。同时这两道题又分别是关于回溯算法的day 27中第1(39. 组合总和)、2题(40. 组合总和II)的退化版。如果只需要计算组合或排列数,用DP方法就可以实现,但想知道具体的组合或排列的话,还是得用回溯法。


        第1题(SP OJ. Piggy-Bank)开始接触完全背包问题,再次由于LeetCode上没有原原本本的完全背包问题,都是完全背包的应用,所以找到SP OJ上这一题来验证代码是否正确(虽然这道题相比经典完全背包问题也有一点改变)。dp[i][j]的定义是与01背包问题一样的。状态转移方程方面,当前点的值同样可能来源于两个选择,即不装当前物品和装当前物品。如果不装当前物品的话,那就与01背包问题一样是dp[i - 1][j]。而如果要装当前物品,因为每个物品的数量变为无限个了,所以“要装当前物品”就指“再多装一件当前物品”。也就是说,在多装一个当前件物品之前,就可能有装过当前物品。那么第1个下标就不再是(i - 1)了,而是i,所以这种情况对应dp[i][j - weight[i]]。所以最终的状态转移方程是dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i])。

        此时当前点的值依赖于其上方和左方,所以内层循环(space的遍历)方向要固定为从左到右,节省空间版本的实现也同样如此。另外,同样因为当前点的值只依赖于其上方和左方,所以内外层循环的顺序也是可以交换的。

        初始化方面与01背包问题一致,节省空间的实现版本中,只需将dp数组全部初始化为0,然后外层循环从下标0开始,内层循环从下标weight[i]开始。

        再回到这一题目,题目求的并不是最多,而是最少能装多少。所以状态转移公式中的max()需要改为min(),同时将dp[0]以外的DP数值初始化为一个“很大的值”,dp[0]还是初始化为0。这里的“很大的值”是一个坑,如果直接设置为INT_MAX,那么状态转移公式中INT_MAX再加上value[i]就会导致越界,所以正确的做法是设置为(INT_MAX / 10)这样的数字。

#include
#include
#include

using namespace std;

int main() {
	int caseNum;
	vector value, weight, dp;
	cin >> caseNum;
	while (caseNum--) {
		int spaceEmpty, spaceAll;
		cin >> spaceEmpty >> spaceAll;
		int space = spaceAll - spaceEmpty;
		int num;
		cin >> num;
		value.resize(num);
		weight.resize(num);
		dp.resize(space + 1, INT_MAX / 10);
		for (int j = 1; j <= space; ++j) {
			dp[j] = INT_MAX / 10;
		}
		dp[0] = 0;
		for (int i = 0; i < num; ++i) {
			cin >> value[i] >> weight[i];
		}
		for (int i = 0; i < num; ++i) {
			for (int j = weight[i]; j <= space; ++j) {
				dp[j] = min(dp[j], dp[j - weight[i]]+ value[i]);
			}
		}
		if (dp[space] == INT_MAX / 10) {
			cout << "This is impossible." << endl;
		}
		else {
			cout << "The minimum amount of money in the piggy-bank is " << dp[space] << "." << endl;
		}
	}
	return 0;
}

        另外在实现和调试过程中,发现了一个自己之前忽视掉的错误,就是vector等容器的resize()操作。之前我以为resize(n, val)会在resize大小之后将容器内的所有值都填充为val,而因为之前都是在LeetCode里刷题,没有用ACM模式,所以也没有出现问题。但这次用ACM模式就出了问题,原来resize(n, val)只有在n大于原来大小的时候,才会将新扩展的一部分填充为val。具体可见这里。所以每次要重新初始化容器时,只用resize()是不行的,还是要用循环遍历的方式。另外,用memset()初始化容器或包含容器的结构体也是不行的,因为容器都是有自己结构的。


        第2题(LeetCode 518. 零钱兑换 II)与day 43中第2题(LeetCode 494. 目标和)一样是求装法数,只是物品数由1变成了无限个,也就是由01背包变成了完全背包问题。所以用dp[i][j]表示“有前i件物品,装满容量j的方法数”的话,状态转移方程就变成了dp[i][j] = dp[i - 1][j] + dp[i][j - weight[i]]。初始化与01背包一样,仍然是将dp[0]设置为1,其他位置设置为0。而对于节省空间版本的实现,状态转移方程的改变也就落实在了遍历方向上。相对LeetCode 494. 目标和只需要将内层循环的遍历方向由从右向左改为从左向右即可。

class Solution {
public:
    int change(int amount, vector& coins) {
        vector dp(amount + 1, 0);
        dp[0] = 1;
        for (int i = 0; i < coins.size(); ++i) {
            for (int j = coins[i]; j <= amount; ++j) {
                dp[j] += dp[j - coins[i]];
            }
        }
        return dp[amount];
    }
};

        自己写到这里AC后就以为结束了,但看了题解后发现这一题还有一个相关问题,并没有这么简单。如果只是求能否装满,内外层循环可以对调。而这道题求的是不同组合的数量,只能是外层循环对应物品,内层循环对应背包大小。因为在外层循环中,物品是从无到有扩展的,所以后面的物品永远在前面的物品之后才被添加。比如物品是{1, 2, 3}的话,背包中只可能出现{1, 2}而不可能出现{2, 1}。

        而如果外层循环对应背包空间,内层循环对应物品的话,每次内层循环都会将所有物品遍历。所以在外层循环的下一个阶段扩展到某一物品时,其他未扩展到的物品也可能已经存在于收集到的方法中。比如物品是{1, 2, 3}的话,背包中既可能出现{1, 2},也可能出现{2, 1}。这种循环方法对应的便是不同排列的数量


        第3题(LeetCode 377. 组合总和 Ⅳ)就是上一题末尾处所说的,求不同排列数量的情况。相对于上一题,只需要将内外层循环对调。状态转移方程、初始化、遍历方向上都与上一题一样。

class Solution {
public:
    int combinationSum4(vector& nums, int target) {
        vector dp(target + 1, 0);
        dp[0] = 1;
        for (int j = 1; j <= target; j++) {
            for (int i = 0; i < nums.size(); ++i) {
                if (j >= nums[i] && dp[j] < INT_MAX - dp[j - nums[i]]) {
                    dp[j] += dp[j - nums[i]];
                }
            }
        }
        return dp[target];
    }
};

需要注意测试用例中有和超过int范围的数据,所以要将加法转化为减法来判断是否会溢出,溢出的话就跳过(目前还不清楚为什么直接跳过对最终结果没影响),如第8行。

你可能感兴趣的:(代码随想录,leetcode,算法,c++,数据结构,动态规划)