zl程序教程

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

当前栏目

leetcode 322. 零钱兑换

2023-03-14 22:51:49 时间

零钱兑换解法汇总

原题链接: leetcode 322. 零钱兑换


3.BFS—广度优先遍历

  • 具体在纸上画一下,就知道这其实是一个在「图」上的最短路径问题,「广度优先遍历」是求解这一类问题的算法。广度优先遍历借助「队列」实现。

注意:

  • 由于是「图」,有回路,所以需要一个 visited 数组,记录哪一些结点已经访问过。
  • 在添加到队列的时候,就得将 visited 数组对应的值设置为 true,否则可能会出现同一个元素多次入队的情况。
class Solution {
public:
	int coinChange(vector<int>& coins, int amount)
	{
		if (amount == 0) return 0;
		queue<int> q;
		//设置访问数组标记,判断当前剩余零钱数是否被凑出来过
		set<int> visited;
		q.push(amount);
		//将当前剩余需凑零钱数设置为已访问过
		visited.insert(amount);
		//当前用了多少硬币来凑零钱
		int step = 1;
		 排序是为了加快广度优先遍历过程中,对硬币面值的遍历,起到剪枝的效果
		sort(coins.begin(), coins.end());
		while (!q.empty())
		{
            //获取队列中存储当前层元素个数
			int size = q.size();
			for (;size>0;size--)
			{
				//获取当前队列首元素
				int head = q.front();
				q.pop();
				for (auto& coin : coins)
				{
					int next = head - coin;
					if (next == 0)// 只要遇到 0,就找到了一个最短路径
						return step;
					if(next<0)
						// 由于 coins 升序排序,后面的面值会越来越大,剪枝
						break;
					//图中当前点没有被访问过
					if (visited.count(next) == 0)//当前可能性没有被找到过
					{
						q.push(next);
						// 添加到队列的时候,就应该立即设置当前节点为访问过
					   // 否则还会发生重复访问
						visited.insert(next);
					}
				}
			}
				//每遍历完当前层元素都表示拿出了一个硬币来凑零钱,如果不明白,可以看一下图
			step++;
		}
		// 进入队列的顶点都出队,都没有看到 0 ,就表示凑不出当前面值
		return -1;
	}
};

2.动态规划

  • 看题目的问法,只问最优值是多少,没有要我们求最优解,一般情况下可以用「动态规划」解决。

思路:分析最优子结构。根据示例 1:

输入: coins = [1, 2, 5], amount = 11

凑成面值为 11 的最少硬币个数可以由以下三者的最小值得到:

  • 凑成面值为 10 的最少硬币个数 + 面值为 1 的这一枚硬币;
  • 凑成面值为 9 的最少硬币个数 + 面值为 2 的这一枚硬币;
  • 凑成面值为 6 的最少硬币个数 + 面值为 5 的这一枚硬币。

即 dp[11] = min (dp[10] + 1, dp[9] + 1, dp[6] + 1)。

可以直接把问题的问法设计成状态。

  • 第 1 步:定义「状态」。dp[i] :凑齐总价值 i 需要的最少硬币个数;
  • 第 2 步:写出「状态转移方程」。根据对示例 1 的分析:
//这里还需要比较dp[amount]是因为,举个例子吧!
//如例1:如果当前dp[amount]的值已经再之前的比较中被赋值为了3,此时dp[amount-coins[i]]+1得到的值为4,此时显然还是3最小
//因此这里还需要比较如果当前已经是最小值了,那么就不用更新最小值了
dp[amount] = min(dp[amount], 1 + dp[amount - coins[i]]) 

注意:

  • 单枚硬币的面值首先要小于等于 当前要凑出来的面值;
  • 剩余的那个面值也要能够凑出来,例如:求 dp[11] 需要参考 dp[10]。如果不能凑出 dp[10],则 dp[10] 应该等于一个不可能的值,可以设计为 11 + 1,也可以设计为 -1 ,它们的区别只是在编码的细节上不一样。

再次强调:新状态的值要参考的值以前计算出来的「有效」状态值。因此,不妨先假设凑不出来,因为求的是小,所以设置一个不可能的数。

参考代码 1: 注意:要求的是「恰好凑出面值」,所以初始化的时候需要赋值为一个不可能的值:amount + 1。只有在有「正常值」的时候,「状态转移」才可以正常发生。

class Solution {
public:
	int coinChange(vector<int>& coins, int amount)
	{
		//这里dp含义是:凑齐总价值 i 需要的最少硬币个数;
		int* dp = new int[amount+1];

		// 因为我们要挨个求出凑出0元到amount元分别每一个需要的最少硬币数量,
		//因此一开始假设最少需要硬币数量为一个不可能的最大值
		for (int i = 0; i < amount+1; ++i)
			dp[i] = amount + 1;

		//凑出总价值为0,所需要的硬币数为0---这是最小的子问题,我们可以直接得出
		dp[0] = 0;

		//下面从最小子问题一层层往上求出最大问题
		//这里循环从1开始,表示从凑出1元的子问题开始往上面求
		for (int i = 1; i <= amount; ++i)
		{
			//遍历硬币数组,看能否凑出当前需要的硬币数i
			for (int coin : coins)
			{
				//只能当前硬币的面值比我们需要凑的值小才能选,不然就超了
				//选择当前硬币后存在两种可能:
				//1.刚好凑出来所需要的硬币数---i-coin==0---dp[i-coin]==dp[0]==0
				//2.拿了当前硬币后,还是没凑满所需要的硬币数量---i-coin>0---dp[i-coin] ?
				//这里又分了两种情况:
			    //(1): dp[i- coin] != amount + 1 首先这里i-coin是再选择当前硬币后,剩余带凑的硬币数
				//这里剩余带凑的硬币数量不等于amount+1,说明我们在此之前,已经求出了需要凑出i-coin数量硬币的最少需要的硬币数
				//那么这里要求凑出当前i数量的硬币数,不就是拿了当前面值为coin的硬币后,加上之前求出凑出i-coin数量硬币的最少需要的硬币数,即dp[i-coin]
				//所以最终得到选了当前硬币后最少需要的硬币数----dp[i]=dp[i-coin]+1
				//但是凑出dp[i]可能不止一种方法,可能在此之前已经求出一种凑出i的最少数,因此这里需要进行对比,选择所有可能中最小的
				//因此最终得到:  dp[i] = min(dp[i], dp[i-coin]+1);


				//(2):   dp[i- coin] == amount + 1 
				//说明在我们拿了面值为coin硬币后,剩余硬币数为i-coin,按理来说在我们选择了当前硬币后,需要凑出i的最小硬币数应该是:
				//dp[i]=dp[i-coin]+1-----但是dp[i-coin],即凑出剩余硬币数i-coin所需要的最少硬币数没有算出来,
				//但是注意我们最外层循环已经说了,是从最小的1开始往上直到amount,一层层求出每个所需要的最少硬币数
				//既然i-coin比i小,并且dp[i-coin]==amount+1等于最大值初始值,说明现有的硬币种类根本无法凑出i-coin的值
				//既然求不出i-coin的所需要的最少硬币数,自然也无法求出当前i所需要的最少硬币数
				if (i - coin >= 0 && dp[i- coin] != amount + 1)
				{
					dp[i] = min(dp[i], dp[i-coin]+1);
				}
			}
		}
		//如果经历了上面的循环后,dp[amount] == amount + 1,说明现有硬币种类凑不出amount的值
		//例如:我们有硬币5,10,20 --现在要你凑出1元---你给我凑一个试试!!!!
		if (dp[amount] == amount + 1)
			return -1;
		//如果可以凑出,就直接返回最终值即可
		return dp[amount];
	}
};

3.记忆化递归

「动态规划」是「自底向上」求解。事实上,可以 直接面对问题求解 ,即「自顶向下」,但是这样的问题有 重复子问题,需要缓存已经求解过的答案,这叫 记忆化递归。

参考代码 2: 注意:由于 -1 是一个特殊的、有意义状态值(题目要求不能使用给出硬币面值凑出的时候,返回 -1),因此初值赋值为 -2,表示还未计算出结果。

class Solution {
public:
	int coinChange(vector<int>& coins, int amount)
	{
		int* dp = new int[amount + 1];
		//初始值都为-2,表示当前还没结果
		fill(dp, dp + amount + 1, -2);
		//给硬币面值进行排序,目的是方便递归的时候进行剪枝
		sort(coins.begin(), coins.end());
		return dfs(coins, dp, amount);
	}
	int dfs(vector<int>& coins,int*& dp,int amount)
	{
		int res = INT_MAX;
		//说明当前我们要返回最小子问题求解结果---凑出0元钱等于我不要任何硬币
		if (amount == 0) return 0;
		//凑出amount数量的值,我们已经算出了结果
		//-2表示没有算出结果
		if (dp[amount] != -2) return dp[amount];
		//如果还没算出结果,那么就去给我算!!!
		//尝试去拿一次每个面值的硬币,看看拿了之后,加上凑出amount-coin的值需要的最小硬币数是多少
		//用res保存拿了其中某个面值的硬币后,得到的最少需要的硬币数
		for (int coin : coins)
		{
			//说明当前硬币面值大于所需要凑出来的值,因为之前对硬币面值数组进行了排序,所以这里可以进行剪枝
			//后面的面值比当前的硬币面值还要大,你觉得还有必要试试吗???
			if (amount - coin < 0) break;
			
			//获取凑出剩余硬币数所需要的最少硬币数
			int subres = dfs(coins, dp, amount-coin);

			//如果剩余硬币数凑不出来,那么当前硬币拿了也没用,反正也凑不出来
			//那怎么办???  看看还有没有其他面值硬币好拿呗
			if (subres == -1)
				continue;

			//如果我们得到了剩余硬币数的结果,那么我们需要比较
			//因为凑出当前硬币数可能不止一种方法,我们需要选择最小的那种方案
			res = min(res, 1 + subres);
		}

		//如果最终res的值还是无穷大,那么说明当前值凑不出来
		//如果不是无穷大,那么res记录的就是当前硬币被凑出所需要的最少硬币数
		return dp[amount] = (res == INT_MAX) ? -1 : res;
	}
};

4.套「完全背包」问题的公式

为什么是「完全背包」问题:

  • 每个硬币可以使用无限次;
  • 硬币总额有限制;
  • 并且具体组合是顺序无关的,还以示例 1 为例:面值总额为 11,方案 [1, 5, 5] 和方案 [5, 1, 5] 视为同一种方案。

但是与「完全」背包问题不一样的地方是:

  • 要求恰好填满容积为 amount 的背包,重点是「恰好」、「刚刚好」,而原始的「完全背包」问题只是要求「不超过」;
  • 题目问的是总的硬币数最少,原始的「完全背包」问题让我们求的是总价值最多。

这一点可以认为是:每一个硬币有一个「占用空间」属性,并且值是固定的,固定值为 11;作为「占用空间」而言,考虑的最小化是有意义的。等价于把「完全背包」问题的「体积」和「价值」属性调换了一下。因此,这个问题的背景是「完全背包」问题。

可以使用「完全背包」问题的解题思路(「0-1 背包」问题也是这个思路):

  • 一个一个硬币去看,一点点扩大考虑的价值的范围(自底向上考虑问题的思想)。其实就是在不断地做尝试和比较,实际生活中,人也是这么干的,「盗贼」拿东西也是这样的,看到一个体积小,价值大的东西,就会从背包里把占用地方大,廉价的物品换出来。

所以在代码里:外层循环先遍历的是硬币面值,内层循环遍历的是面值总和。

这里我先举一个例子:

  • 我们有1,2,5面值的硬币,我们要凑出11元,最少需要多少硬币?
  • 我们把问题转化一下,假设某一天我被人绑架,被带到了一栋房子内部,我面前有一扇门,蒙面人扯下我的眼罩,给了我一个小钱包,让我用这个小钱包来装硬币,刚好要装够11元并且要求钱包内装的硬币数量越少越好。此时我推开了面前的大门,打开一看,发现一大堆1元硬币,为了凑够11元,我只能选择往钱包内塞入11个一元硬币。此时我喜滋滋的走向房间的另一边,心里想着so easy~~~,原以为推开了走向成功的大门,没成想又来到了新的一个房间,房间里面堆满了面值为2的硬币。此时我看看了钱包中的11个1元硬币,心想要用最少的硬币凑够11元,既然一个2元硬币可以顶替两个一元硬币,那不如拿出10个1元硬币,放入5个两元硬币进去吧!此时我的钱包里面就剩下6个硬币了耶,我可真是个小机灵鬼!我兴冲冲的又再次跑到房间另一侧推开了走向成功的大门,熟不知面对我的又是一大堆5元硬币,哎!!没玩了呗!!,我抱怨了几句,心里又想拿几个五元硬币又可以少拿几个2元硬币了,拿几个呢?1…2…10好像最大的5的倍数是10,拿两个吧!!!,接着我从钱包中拿出了5个2元硬币,放入了2个五元硬币,最终我成功逃了出来,钱包中的硬币个数为3,分别为5,5,1
  • 由上面这个瞎编的例子可以看出外层循环硬币面值和内存循环遍历面值总和的作用,首先外层遍历面值,就像例子中的每个房间都堆满了同样面值的硬币一样,只有一种选择。
  • 而内存循环遍历面值总和时,要注意一点:动态规划求解最大问题是由最顶层的子问题一层层往上面求解算出的。首先在编程中不像生活中一样,我给你一个钱包让你用最少的硬币数组成2元,并且此时我只给你1元硬币和2元硬币,你知道选2构成2。在编程中我们首先要求出最小子问题,即钱包里面一分钱都不放的结果为0个硬币,显而易见。下面通过最小子问题求出钱包必须放刚刚好一块钱时的结果,dp[1]=dp[0]+1—假设此时我们位于堆满一元硬币的房间内,显然结果为1。接着我们把钱包上限调高到2,dp[2]=dp[2-1]+1=2;注意此时我们还处于全是一元硬币的房间内,这里的dp[2-1]意思是我们在选择了一元硬币后,还差一元,又因为之前求出了钱包内只让放一块钱时的least,因此这里就相当于dp[2]=least+1=2; 然后来到第二个堆满二元硬币的房间,我们钱包的容量至少为2,此时的dp[2]=min(dp[2],dp[2-2]+1),相当于我们来到了第二个堆满两元硬币的房间,此时我们的钱包容量为2,钱包里面已经放了两个一元硬币,即当前的dp[2]=2,那么来到了都是两元硬币的房间后,我可不可以通过拿两元硬币来替换一元硬币从而得到更优解呢?显然是可以的,当我们把两个一元硬币从钱包拿出来,拿了一个两元硬币放入钱包后—>对应+1,此时我们还差0元---->对应dp[2-2]—需要,此时0元对应的最少硬币—dp[0]=0,因此最终dp[2]两者取小得到dp[2]=1; 下面看完整代码:
class Solution {
public:
	int coinChange(vector<int>& coins, int amount)
	{
		//注意:这里dp[i]依旧表示最少需要的硬币数量
		//i表示当前钱包的容量---这里只是理解为往钱包里面塞入大小为amount值的钱
		int* dp = new int[amount + 1];
		//都初始化为最大值,表示还没有计算出结果
		fill(dp, dp + amount + 1, amount + 1);
		//当钱包可容纳钱的数量为0时,我们不需要往钱包里面放一毛钱!!!
		dp[0] = 0;
		//遍历当前每个房间---每个房间堆放不同面值的硬币
		for (int coin : coins)
		{
			//钱包的容量至少要为当前面值硬币大小,不然怎么放的下呢?
			//钱包容量由最小值coin一直变大到amount,由子问题一层层求解得到大问题
			for (int i = coin; i <= amount; ++i)
			{
				//当前钱包的最少硬币数,是会随着发现不同硬币而做出不同选择而发生改变的
				//可能选择后硬币数会更少,也可能会更多
				dp[i] = min(dp[i], dp[i - coin] + 1);
			}
		}
		if (dp[amount] == amount + 1)
			return -1;
		return dp[amount];
	}

};
int 

总结