动态规划(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
我们只需要取较小的值就可以了。
【代码】到这里,你应该可以写出 DP 的代码了:
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];
}
}
复杂度分析:
一共两层循环,外层需要循环 O(Amount) 次,内层需要循环 O(N) 次(如果有 N 种硬币)。那么时间复杂度为 O(Amount * N)。由于申请了数组,那么空间复杂度为 O(Amount)。
这里我利用一个例题,深入地讲解了 DP 的破题法的几个步骤。后面我将利用这个方法带你依次切开每一道难啃的 DP 题。
这里,我再分享一个小技巧,需要注意:
当求最小值的时候,我们往往将不可能的情况设置为 Integer.MAX_VALUE / 4。
因为如果设置为 Integer.MAX_VALUE,那么一旦涉及加法,立马就溢出了,导致程序出错。所以我们尽量设置一个足够大的数,避免进行加法的时候溢出。
这里我已经将 DP 的思路整理成如下图中展示的 6 步。尽管我现在处理 DP 问题已经很熟练了,但有时候,碰到一些特别难处理的 DP 题目,依然会回到这 6 步分析法,一步一步踏踏实实地分析。
DP 的分类
经过前面的讨论,我们学会了 DP 的通用解法,不过 DP 实际上还可以分成很多种类别。比如:
- 线性 DP
- 区间 DP
- 背包 DP
- 树形 DP
- 状态压缩 DP
在练习和准备面试的时候,多看看这些题型,对面试会很有帮助。下面我们一个一个介绍。
线性 DP
我们在读书的时候,遇到的很多 DP 题目,比如最长公共子序列、最长递增子序列等,这类题目实际上都是线性 DP。不过今天我们不再介绍这类经典的 DP 题目,而是介绍一些在面试中经常出现的线性 DP 题目。
例 1:打劫
【题目】你是一个专业的小偷,计划去沿街的住户家里偷盗。每间房内都藏有一定的现金,影响你偷盗的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,要求你计算不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
输入:nums = [1,2,3,1]
输出:4
解释:偷窃 nums[0] 号房屋 (金额 = 1),然后偷窃 nums[2]号房屋(金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4 。
【分析】接下来,我们就照着 DP 的 6 步分析法(千万别顺着题意去想要偷那些房间!!)。我们把思维放慢,一步一步分析。
1. 最后一步
就这道题而言,最后一步就是处理第 N-1 个房间(我们假设一共有 N 个房间,并且从 0 开始)。
那么第 N-1 个房间,有两个选项。
偷:如果要偷第 N-1 个房间,那么收益就是处理前 N-3 个房间之后,再偷第 N-1 房间。
不偷:那么只需要处理到第 N-2 个房间,那么收益就是处理前 N-2 个房间之后的收益。
2. 子问题
最后一步的 2 个选项中都有未知项,我们可以将它们展开为子问题:
处理完 [0, …, N-3] 之后,最大收益是多少?
处理完 [0, …, N-2] 之后,最大收益是多少?
下面我们可以统一问题的表示:
f(x) 表示处理完 [0, …, x] 这些房间之后的最高收益。
3. 递推关系
统一问题的表示之后,首先来表示一下最后一步:
f(N-1) = max(f(N-2), f(N-3) + nums[N-1])
这里需要采用替换法,将 N-1 换为 x。可以得到:
f(x) = max(f(x-1), f(x-2) + nums[x])
4. f(x) 的表达
这里 x 表示的是原数组 [0, …, x] 这个区间范围。由于所有的 x 表示的区间都是从 0 开始的,所以这个区间的起始点信息没有必要保留,因此只需要保存区间端点 x。我们发现:
x 是个整数;
x 的范围刚好是 nums 数组的长度。
尽管 f(x) 可以用哈希来表示,但如果用数组来表达这个函数映射关系,更加直接和高效。因此,我们也用 dp[] 数组来表达 f(x)。并且利用元素 i 表示 x,可以让 i 与 nums 数组的下标对应起来。
5. 初始条件与边界
初始条件:首先我们看“无法计算/越界”的情况:
dp[0] = max(dp[0-1], dp[0-2] + nums[0]); // <-- 越界!
dp[1] = max(dp[1-1], dp[1-2] + nums[1]); // <-- 越界
dp[2] = max(dp[2-1], dp[2-2] + nums[2]);
我们发现 dp[0], dp[1] 会在计算过程中出现越界,所以需要优先处理这两项。
- dp[0]:当只有 nums[0] 可以偷的时候,其值肯定为 max(0, nums[0])。
注意陷阱,有的题可能会给你的带负数值的情况,不要直接写成 nums[0]。
- dp[1]:当有 0 号,1 号房间可以偷的时候,由于不能连续偷盗,那么只需要在 0、nums[0]、nums[1] 里面选最大值就可以了。所以 dp[1] = max(0, nums[0], nums[1])。
边界:要保证不能越过数组的边界!
6. 计算顺序
拿到初始条件与边界之后,只需要再多走两步,就知道代码怎么写了。接下来我们开始求解 dp[2], dp[3]。
dp[2] = max(dp[2-1], dp[2-2] + nums[2]);
dp[3] = max(dp[3-1], dp[3-2] + nums[3]);
【代码】利用前面分析过的初始条件和递推关系,可以写出如下代码:
class Solution
{
public int rob(int[] nums)
{
final int N = nums == null ? 0 : nums.length;
if (N <= 0) {
return 0;
}
int[] dp = new int[N];
dp[0] = Math.max(0, nums[0]);
if (N == 1) {
return dp[0];
}
dp[1] = Math.max(0, Math.max(nums[0], nums[1]));
for (int i = 2; i < N; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[N - 1];
}
}
**复杂度分析:**时间复杂度 O(N),空间复杂度 O(N)。
**【小结】**通过 6 步分析法,我们很快就搞定来这道经典的 DP 题目。
这道题还有一个小变形,我想你可以尝试求解下面的练习题 1。
练习题 1:你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方的所有房屋都围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下 ,能够偷窃到的最高金额。
输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 nums[0] 号房屋(金额 = 2),然后偷窃 nums[2] 号房屋(金额 = 2), 因为他们是相邻的。最大收益是偷取nums[1]=3。