这是我参与更文挑战的第 10 天,活动详情查看: 更文挑战
动态规划
动态规划(Dynamic Programming,DP)是求解决策过程最优化的过程,通过把原问题分解为相对简单的子问题的方式求解复杂问题,在数学、管理科学、计算机科学、经济学和生物信息学等领域被广泛使用。
它的基本思想非常简单,若要求解一个给定问题,我们需要求解其不同部分(即子问题),再根据子问题的解得出原问题的解。
通常许多子问题非常相似,为了减少计算量,动态规划法试图每个子问题仅解决一次,一旦算出某个给定子问题的解,则将其记忆化存储,以便下次求解同一个子问题解时可以直接查表。因此具有天然剪枝的功能。
DP 题目的特点
首先我们一起来看一下,什么样的题目可能需要使用动态规划。一般而言(并不绝对),如果题目如出现以下特点,你就可以考虑(有一定概率)使用动态规划。
特点一:计数
题目问:有多少种方法?有多少种走法?
关键字:多少!
特点二:最大值/最小值
题目问:某种选择的最大值是什么?完成任务的最小时间是什么?数组的最长子序列是什么?达到目标最少操作多少次等。
关键字:最!
特点三:可能性
题目问:是否有可能出现某种情况?是否有可能在游戏中胜出?是否可以取出 k 个数满足条件?
关键字:是否!
通常而言,看到这三类题目,就可以尝试往 DP 解法上靠。
DP 的 6 步破题法
找到题目的特点,确定可以使用 DP 之后,接下来就可以准备逐步破题了。
下面我们以一道题目为例,详细介绍破解 DP 问题的思考过程与解题步骤。其实这道题不难,我相信你们都见过,不过我还是希望你能跟着我的思维重新再思考一遍。
【题目】给定不同面额的硬币 coins 和一个总金额 amount,需要你编写一个函数计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,则返回 -1。你可以认为每种硬币的数量是无限的。
输入:coins = [1, 2, 5],amount = 11
输出:3
解释:11 元可以拆分为 5 + 5 + 1,这是最少的硬币数目。
【分析】首先我们看到关键字“最少”,因此可以尝试往 DP 上面想。在 DP 问题上,很多人都存在一个思维误区,这里我们称为误区 1:
利用 DP 求解问题时,一开始就去想第一步具体做什么!
你之所以没有思路,往往是因为采用了一种顺应题意的方法去求解问题。比如题目问:
如何求“最少步数”,你就去想“从头开始怎么走”; 如何选择可以“达到最大收益”,你就真的开始去想“怎么选择”。
这恰恰是 DP 题目给你下的一个“套”,这样思考很容易带你陷入暴力求解的方法,找不到优化的思路。因此,千万不要从第一步开始思考。就这道题而言,就是不要去想,我的第一个硬币怎么选!
那么我们应该从哪里着手呢?答案是:最后一步!
1. 最后一步
以这道题为例,最后一步指的是:兑换硬币的时候,假设每一步操作总是选择一个硬币,那么我们看一下最后一步如何达到 amount?
以给定的输入为例:
coins = [1, 2, 5], amount = 11
最后一步可以通过以下 3 个选项得到:
已经用硬币兑换好了 10 元,再添加 1 个 1 元的硬币,凑成 11 元;
已经用硬币兑换好了 9 元,再添加 1 个 2 元的硬币,凑成 11 元;
已经用硬币兑换好了 6 元,再添加 1 个 5 元的硬币,凑成 11 元。
接下来,应该立即将以上 3 个选项中的未知项展开成子问题!
注意:如果你找的最后一步,待处理的问题规模仍然没有减小,那么说明你只找到了原始问题的等价问题,并没有找到真正的最后一步。
2. 子问题
拿到 3 个选项之后,你可能会想:[10元,9元,6元] 是如何得到?到此时,一定不要尝试递归地去求解 10 元、9 元、6 元,正确的做法是将它们表达为 3 个子问题:
如何利用最少的硬币组成 10 元?
如何利用最少的硬币组成 9 元?
如何利用最少的硬币组成 6 元?
我们原来的问题是,如何用最少的硬币组成 11 元。
不难发现,如果用 f(x) 表示如何利用最少的硬币组成 x 元,就可以用 f(x) 将原问题与 3 个子问题统一起来,得到如下内容:
原问题表达为 f(11);
3 个子问题分别表达为 f(10)、f(9)、f(6)。
接下来我们再利用 f(x) 表示最后一步的 3 个选项:
f(10) + 1 个 1 元得到 f(11);
f(9) + 1 个 2 元得到 f(11);
f(6) + 1 个 5 元得到 f(11)。
3. 递推关系
递推关系,一般需要通过两次替换得到。
最后一步,可以通过 3 个选项得到。哪一个选项才是最少的步骤呢?这个时候,我们可以采用一个 min 函数来从这 3 个选项中得到最小值。
f(11) = min(f(11-1), f(11-2), f(11-5)) + 1
接下来,第一次替换:只需要将 11 换成一个更普通的值,就可以得到更加通用的递推关系:
f(x) = min(f(x-1), f(x-2), f(x-5)) + 1
当然,这里 [1, 2, 5] 我们依然使用的是输入示例,进行第二次替换:
f(x) = min(f(x-y), y in coins) + 1
写成伪代码就是:
f(x) = inf
for y in coins:
f(x) = min(f(x), f(x-y) + 1)
4. f(x) 的表达
接下来我们要做的就是在写代码的时候,如何表达 f(x)?
这里有一个小窍门。
直接把 f(x) 当成一个哈希函数。那么 f 就是一个 HashMap。
对于大部分 DP 题目而言,如果用 HashMap 替换 f 函数都是可以工作的。如果遇到 f(x, y) 类似的函数,就需要用 Map<Integer x, Map<Integer y, Integer>> 这种嵌套的方式来表达 f(x, y)。
当然,有时候,用数组作为哈希函数是一种更加简单高效的做法。具体来说:
如果要表达的是一维的信息,就用一维数组 dp[] 表示 f(x);
如果要表达的是二维的信息,就用二维数组 dp[][] 表示 f(x, y)
这就是为什么很多 DP 代码里面可以看到很多dp数组的原因。但是,现在你要知道:
用 dp[] 数组并不是求解 DP 问题的核心。
因为,数组只是信息表达的一种方式。而题目总是千万变化的,有时候可能还需要使用其他数据结构来表达 f(x)、f(x, y) 这些信息。比如:
f(x)、f(x, y) 里面的 x, y 都不是整数怎么办?是字符串怎么办?是结构体怎么办?
当然,就这个题而言,可以发现有两个特点:
1)f(x) 中的 x 是一个整数;
2)f(x) 要表达的信息是一维信息。
那么,针对这道题而言言,我们可以使用一维数组,如下所示:
int[] dp = new int[amount + 1];
数组下标 i 表示 x,而数组元素的值 dp[i] 就表示 f(x)。
那么递推关系可以表示如下:
dp[x] = inf;
for y in coins:
dp[x] = min(dp[x], dp[x-y] + 1);
5. 初始条件与边界
那么,如何得到初始条件与边界呢?这里我分享一个小技巧: 你从问题的起始输入开始调用这个递归函数,如果递归函数出现“不正确/无法计算/越界”的情况,那么这就是你需要处理的初始条件和边界。
比如,如果我们去调用以下两个递归函数。
coinChange(0):可以发现给定 0 元的时候,dp[amount-x] 会导致数组越界,因此需要特别处理dp[0]。
coinChange(-1) 或者 coinChange(-2) 的调用也是会遇到数组越界,说明这些情况都需要做特别处理。
那么什么情况作为初始条件?什么情况作为边界?答案就是:
如果结果本身的存放不越界,只是计算过程中出现越界,那么应该作为初始条件。比如 dp[0]、dp[1];
如果结果本身的存放是越界的,那么需要作为边界来处理,比如 dp[-1]。
当然,就这道题而言,初始条件是 dp[0] = 0,因为当只有 0 元钱需要兑换的时候,应该是只需 0 个硬币。
6. 计算顺序
说来有趣,计算顺序最简单,我们只需要在初始条件的基础上使用正向推导多走两步可以了。比如:
初始条件:dp[0] = 0
那么接下来的示例中的输入:coins[] = [1, 2, 5]。我们已经知道 dp[0] = 0,再加上可以做的 3 个选项,那么可以得到:
dp[1] = dp[0] + 1 元硬币 = 1
dp[2] = dp[0] + 2 元硬币 = 1
dp[5] = dp[0] + 5 元硬币 = 1
到这里,递推关系好像还没有用到。那什么时候用呢?我们来看下面两种情况:
如下图所示,第一种情况,dp[5] 可以直接通过 dp[0] 得到,值为 1。
如下图所示,第二种,dp[5] 可以通过 dp[3] 得到,值为 3。
此时可以发现,判断具体取哪个值时,就需要用到前面的递推关系了。
f(x) = min(f(x-1), f(x-2), f(x-5)) + 1
我们只需要取较小的值就可以了。
完整代码便可以得出:
class Solution
{
public int coinChange(int[] coins, int amount)
{
// 没有解的时候,设置一个较大的值
final int INF = Integer.MAX_VALUE / 4;
int[] dp = new int[amount + 1];
// 一开始给所有的数设置为不可解。
for (int i = 1; i <= amount; i++) {
dp[i] = INF;
}
// DP的初始条件
dp[0] = 0;
for (int i = 0; i < amount; i++) {
for (int y : coins) {
// 注意边界的处理,不要越界
if (y <= amount && i + y < amount + 1 && i + y >= 0) {
// 正向推导时的递推公式!
dp[i + y] = Math.min(dp[i + y], dp[i] + 1);
}
}
}
return dp[amount] >= INF ? -1 : dp[amount];
}
}