这一节我们向大家介绍「最优子结构」这个概念,具体来说就是:问题的最优解参考了子问题的最优解。
这个提法比较学术,我们还是用具体的例子和大家解释。这道题是「力扣」上第 322 号问题:零钱兑换。
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回
-1
。
示例 1:
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1
示例 2:
输入: coins = [2], amount = 3
输出: -1
说明:
你可以认为每种硬币的数量是无限的。
思路:
- 看题目的问法,只问最优值是多少,没有要我们求最优解,一般情况下就是「动态规划」可以解决的问题。
- 最优子结构其实比较明显,我们看示例 1:
输入: coins = [1, 2, 5], amount = 11
凑成面值为 11
的最小硬币数可以由以下
3
3
3 者的最小值得到:
1、凑成面值为 10
的最小硬币数(假设已知) + 面值为 1
的这一枚硬币;
2、凑成面值为 9
的最小硬币数(假设已知) + 面值为 2
的这一枚硬币;
3、凑成面值为 6
的最小硬币数(假设已知) + 面值为 5
的这一枚硬币;
即 dp[11] = min (dp[10] + 1, dp[9] + 1, dp[6] + 1)
。这就是这个问题的最优子结构,在三种选择中,选出一个最优解。
这里需要引入一个概念:状态。状态其实我们在「回溯算法」里介绍说。状态在动态规划里其实含义是一样的,依然是表示我们求解一个问题进行到哪个阶段,只不过表现这个变量不想「回溯算法」那么具体,很多时候,它是一个「概括值」。
我们这里直接把题目的问法设计成「状态」,有些问题不是这样的,我们后面再说。
第 1 步:定义「状态」
dp[i]
:凑齐总价值 i
需要的最少硬币数,状态就是问的问题。
第 2 步:写出「状态转移方程」
所谓「状态转移方程」,其实就是「最优子结构」。
根据对具体例子的分析:
dp[amount] = min(1 + dp[amount - coin[i]]) for i in [0, len - 1] if coin[i] <= amount
注意的是:
1、首先硬币的面值首先要小于等于当前要凑出来的面值;
2、剩余的那个面值应该要能够凑出来,例如:求 dp[11]
需要参考 dp[10]
,如果不能凑出来的话,dp[10]
应该等于一个不可能的值,可以设计为 11 + 1
,也可以设计为 -1
,它们的区别只是在具体的代码编写细节上不一样而已。
再强调一次:新状态的值要参考的值以前计算出来的「有效」状态值。这一点在编码的时候需要特别注意。
因此,不妨先假设凑不出来,因为比的是小,所以初始化的时候应该设置为一个不可能的数。
参考代码:
import java.util.Arrays;
public class Solution {
public int coinChange(int[] coins, int amount) {
// 给 0 占位
int[] dp = new int[amount + 1];
// 注意:因为要比较的是最小值,这个不可能的值就得赋值成为一个最大值
Arrays.fill(dp, amount + 1);
dp[0] = 0;
for (int i = 1; i <= amount; i++) {
for (int coin : coins) {
if (i - coin >= 0 && dp[i - coin] != amount + 1) {
dp[i] = Math.min(dp[i], 1 + dp[i - coin]);
}
}
}
if (dp[amount] == amount + 1) {
dp[amount] = -1;
}
return dp[amount];
}
}
注意:
- 要求的是恰好填满,所以初始化的时候需要赋值为一个不可能的值:
amount + 1
。只有在有「正常值」的时候,「状态转移」才可以正常发生。
总结
可能有的朋友要问了,斐波拉契数列貌似没有「最优子结构」,事实上的确是这样,严格来说「斐波拉契数列」不是「动态规划」问题,但它却是理解「动态规划」问题的一个例子,主要是通过这个例子理解「动态规划」「自底向上」求解的思想和「重复子问题」的特征。大家先不要去纠结这件事情。
这节我们向大家介绍了「最优子结构」。希望大家能够体会,我们在设计「状态」的时候,仅仅只是用一个数值表示了求解一个问题的阶段,所以这个数值是一个「概括性」的数值,它不是具体解,但是它可以代表具体解。
这里要注意:对「状态」的定义一定要非常准确,在这里我的建议是,如果状态定义不是题目问的那个样子,把我们对状态的定义都作为注释写在代码里。
只有「状态」定义准确,「状态转移方程」才会「准确」。
其实求解这个问题,还利用到了一个「动态规划」问题的一个特点「无后效性」。我们在下一节向大家解释。
练习
1、「力扣」第 279 题:完全平方数;