zl程序教程

您现在的位置是:首页 >  其他

当前栏目

leetcode 322. 零钱兑换----完全背包套路解法详细再探

2023-03-14 22:52:41 时间

零钱兑换完全背包套路解法再探


引言

leetcode 322. 零钱兑换本篇文章题解之前已经发过,但是对完全背包的解法只是模棱解释一番,今天再写一篇文章来详细探讨一下本题套用完全背包公式的解法

完全背包套路题目: leetcode 279. 完全平方数 LintCode 440 · 背包问题 III—完全背包问题 本人目前刷题数量较少,先列举两道,O(∩_∩)O哈哈~


完全背包(朴素解法)

  • 硬币相当于我们的物品,每种硬币可以选择「无限次」,我们应该很自然的想到「完全背包」。
  • 如果不能,那么从现在开始就要培养这样的习惯:
  • 当看到题目是给定一些「物品」,让我们从中进行选择,以达到「最大价值」或者「特定价值」时,我们应该联想到「背包问题」。
  • 这本质上其实是一个组合问题:被选物品之间不需要满足特定关系,只需要选择物品,以达到「全局最优」或者「特定状态」即可。
  • 再根据物品的「选择次数限制」来判断是何种背包问题。
  • 本题每种硬币可以被选择「无限次」,我们可以直接套用「完全背包」的状态定义进行微调:
  • 定义 dp[i][j] 为考虑前 i 件物品,凑成总和为 j 所需要的最少硬币数量。
  • 为了方便初始化,我们一般让 dp[0][x] 代表不考虑任何物品的情况。
  • 因此我们有显而易见的初始化条件:dp[0][0]=0,其余dp[0][x]=INF 。
  • 代表当没有任何硬币的时候,存在凑成总和为 0 的方案,方案所使用的硬币为 0;凑成其他总和的方案不存在。
  • 由于我们要求的是「最少」硬币数量,因此我们不希望「无效值」参与转移,可设 INF=INT_MAX。
  • 当「状态定义」与「基本初始化」有了之后,我们不失一般性的考虑 dp[i][j] 该如何转移。
  • 对于第 i 个硬币我们有两种决策方案:
  • 不使用该硬币:dp[i][j]=dp[i-1][j]
  • 使用该硬币,由于每种硬币可以被选择多次(容量允许的情况下),因此最优解应当是所有方案中的最小值。即dp[i][j]=min(dp[i-1][j-k*coin]+ k)

代码:

class Solution {
public:
	int coinChange(vector<int>& coins, int amount) 
	{
		int size = coins.size();//获取硬币个数
		//因为有个初始状态0,即什么硬币都不考虑,因此行数加一行
		vector<vector<int>> dp(size + 1, vector<int>(amount + 1, 0));

		// 初始化(没有任何硬币的情况):只有 f[0][0] = 0;其余情况均为无效值。
	  // 这是由「状态定义」决定的,当不考虑任何硬币的时候,只能凑出总和为 0 的方案,所使用的硬币数量为 0  
		for (int i = 1; i <= amount; i++) dp[0][i] = INT_MAX;

		// 有硬币的情况
		for (int i = 1; i < size+1; i++)
		{
			int val = coins[i-1];//获取当前硬币的大小
			for (int j = 0; j < amount + 1; j++)
			{
				//不考虑当前硬币的情况
				dp[i][j] = dp[i - 1][j];
				
				// 考虑当前硬币的情况(可选当前硬币个数基于当前容量大小)
				for (int k = 1; k * val <=j; k++)
				{
					if (dp[i - 1][j - val * k] != INT_MAX)
						dp[i][j] = min(dp[i][j], dp[i - 1][j - k * val] + k);
				}
			}
		}
		return dp[size][amount]==INT_MAX?-1: dp[size][amount];
	}
};

无效状态的定义问题–顺带滚动数组优化

借这个问题,刚好说一下,我们初始化时,对于无效状态应该如何定义。

  • 可以看到上述解法,将 INF 定义为 INT_MAX
  • 这是因为我们转移时取的是较小值,我们希望无效值不要被转移,所以将 INF 定义为较大的数,以代表数学上的 +00(正无穷)。
  • 这很合理,但是我们需要注意,如果我们在 INF 的基础上进行累加的话,常规的语言会将其变成负数最小值。
  • 也就是在正无穷基础上进行累加,会丢失其正无穷的含义,这与数学上的正无穷概念冲突。
  • 因此,我们才有先判断再使用的习惯:
if (f[i-1][j] != INF) {
    f[i][j] = Math.min(f[i][j], f[i-1][j]);
}
  • 但事实上,如果每次使用都需要有前置检查的话,是很麻烦的。
  • 于是我们有另外一个技巧,不直接使用 INT_MAX 作为 INF,而是使用一个比 INT_MAX 小的较大数来代表 INF
  • 相当于预留了一些「累加空间」给 INF
  • 比如使用 0x3f3f3f3f 作为最大值,这样我们使用 INF 做状态转移的时候,就不需要先判断再使用了。

代码:

class Solution {
public:
	int coinChange(vector<int>& coins, int amount)
	{
		int INF = 0x3f3f3f3f;
		int size = coins.size();//获取硬币个数
		//因为有个初始状态0,即什么硬币都不考虑,因此行数加一行
		vector<vector<int>> dp(2, vector<int>(amount + 1, 0));

		// 初始化(没有任何硬币的情况):只有 f[0][0] = 0;其余情况均为无效值。
	  // 这是由「状态定义」决定的,当不考虑任何硬币的时候,只能凑出总和为 0 的方案,所使用的硬币数量为 0  
		for (int i = 1; i <= amount; i++) dp[0][i] = INF;

		// 有硬币的情况
		for (int i = 1; i < size + 1; i++)
		{
			int val = coins[i - 1];//获取当前硬币的大小
			for (int j = 0; j < amount + 1; j++)
			{
				//不考虑当前硬币的情况
				dp[i & 1][j] = dp[(i - 1) & 1][j];

				// 考虑当前硬币的情况(可选当前硬币个数基于当前容量大小)
				for (int k = 1; k * val <= j; k++)
				{
						dp[i & 1][j] = min(dp[i & 1][j], dp[(i - 1) & 1][j - k * val] + k);
				}
			}
		}
		return dp[size & 1][amount] == INF ? -1 : dp[size & 1][amount];
	}
};

完全背包(一维优化)

  • 显然朴素版的完全背包进行求解复杂度有点高。
  • 之前我们从最朴素背包转移方程出发,从数学的角度去推导一维优化是如何来的。
  • 这十分科学,而绝对严谨。
  • 但每次都这样推导是十分耗时的。
  • 因此,我们这次站在一个「更高」的角度去看「完全背包」问题。
  • 我们知道传统的「完全背包」二维状态转移方程是:
  • 经过一维空间优化后的状态转移方程是(同时容量维度遍历顺序为「从小到大」):
  • 然后我们只需要对「成本」&「价值」进行抽象,并结合「换元法」即可得到任意背包问题的一维优化状态转移方程。
  • 拿我们本题的状态转移方程来分析,本题的朴素状态转移方程为:
  • 我们将硬币的面值抽象为「成本」,硬币的数量抽象「价值」,再对物品维度进行消除,即可得:

如果还不理解,可以将上述四个状态转移方程「两两成对」结合来看。

代码:

class Solution {
public:
	int coinChange(vector<int>& coins, int amount)
	{
		int INF = 0x3f3f3f3f;
		int size = coins.size();
		vector<int> dp(amount + 1, 0);
		for (int i = 1; i <= amount; i++) dp[i] = INF;
		for (int i = 1; i < size + 1; i++)
		{
			int val = coins[i - 1];
			//注意这里j的起点是val,比当前硬币面值val小的所需硬币总和,也不会去选,因此属于当前硬币不选择状态,那么数据维持上一次就可以,不用更新
			for (int j = val; j < amount + 1; j++)
           dp[j] = min(dp[j], dp[j - val] + 1);

		}
		return dp[amount] == INF ? -1 : dp[amount];
	}
};