从一个小算法开始动态规划入门
算法问题描述:
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。
解题:
如果你对于动态规划还不是很了解,或者没怎么做过动态规划的题目的话,那么 House Robber (小偷问题)这道题是一个非常好的入门题目。本文会以 House Robber 题目为例子,讲解动态规划题目的四个基本步骤。
动态规划的的四个解题步骤是:
定义子问题
写出子问题的递推关系
确定 DP 数组的计算顺序
空间优化(可选)
下面我们一步一步地进行讲解。
步骤一:定义子问题
稍微接触过一点动态规划的朋友都知道动态规划有一个“子问题”的定义。什么是子问题?子问题是和原问题相似,但规模较小的问题。例如这道小偷问题,原问题是“从全部房子中能偷到的最大金额”,将问题的规模缩小,子问题就是“从 kk 个房子中能偷到的最大金额”,用 f(k)f(k) 表示。
可以看到,子问题是参数化的,我们定义的子问题中有参数 kk。假设一共有 nn 个房子的话,就一共有 nn 个子问题。动态规划实际上就是通过求这一堆子问题的解,来求出原问题的解。这要求子问题需要具备两个性质:
1、原问题要能由子问题表示。例如这道小偷问题中,k=nk=n 时实际上就是原问题。否则,解了半天子问题还是解不出原问题,那子问题岂不是白解了。
2、一个子问题的解要能通过其他子问题的解求出。例如这道小偷问题中,f(k)f(k) 可以由 f(k-1)f(k−1) 和 f(k-2)f(k−2) 求出,具体原理后面会解释。这个性质就是教科书中所说的“最优子结构”。如果定义不出这样的子问题,那么这道题实际上没法用动态规划解。
小偷问题由于比较简单,定义子问题实际上是很直观的。一些比较难的动态规划题目可能需要一些定义子问题的技巧。
步骤二:写出子问题的递推关系
这一步是求解动态规划问题最关键的一步。然而,这一步也是最无法在代码中体现出来的一步。在做题的时候,最好把这一步的思路用注释的形式写下来。做动态规划题目不要求快,而要确保无误。否则,写代码五分钟,找 bug 半小时,岂不美哉?
我们来分析一下这道小偷问题的递推关系:
假设一共有 nn 个房子,每个房子的金额分别是H0,H1,...,Hn-1,子问题 f(k)f(k) 表示从前 kk 个房子(即H0,H1,...,Hn-1)中能偷到的最大金额。那么,偷 kk 个房子有两种偷法:
k 个房子中最后一个房子是 Hk−1。如果不偷这个房子,那么问题就变成在前k−1 个房子中偷到最大的金额,也就是子问题f(k−1)。如果偷这个房子,那么前一个房子Hk−2 显然不能偷,其他房子不受影响。那么问题就变成在前 k−2 个房子中偷到的最大的金额。两种情况中,选择金额较大的一种结果。
在写递推关系的时候,要注意写上 k=0k=0 和 k=1k=1 的基本情况:
1、当 k=0 时,没有房子,所以 f(0) = 0。
2、当 k=1 时,只有一个房子,偷这个房子即可,所以 f(1)=H0
步骤三:确定 DP 数组的计算顺序
在确定了子问题的递推关系之后,下一步就是依次计算出这些子问题了。在很多教程中都会写,动态规划有两种计算顺序,一种是自顶向下的、使用备忘录的递归方法,一种是自底向上的、使用 dp 数组的循环方法。不过在普通的动态规划题目中,99% 的情况我们都不需要用到备忘录方法,所以我们最好坚持用自底向上的 dp 数组。
DP 数组也可以叫”子问题数组”,因为 DP 数组中的每一个元素都对应一个子问题。如下图所示,dp[k] 对应子问题 f(k),即偷前 k 间房子的最大金额。
那么,只要搞清楚了子问题的计算顺序,就可以确定 DP 数组的计算顺序。对于小偷问题,我们分析子问题的依赖关系,发现每个 f(k) 依赖 f(k−1) 和f(k−2)。也就是说,dp[k] 依赖 dp[k-1] 和 dp[k-2],如下图所示。
那么,既然 DP 数组中的依赖关系都是向右指的,DP 数组的计算顺序就是从左向右。这样我们可以保证,计算一个子问题的时候,它所依赖的那些子问题已经计算出来了。
确定了 DP 数组的计算顺序之后,我们就可以写出题解代码了:
public int rob(int[] nums) {
if (nums.length == 0) {
return 0;
}
// 子问题:
// f(k) = 偷 [0..k) 房间中的最大金额
// f(0) = 0
// f(1) = nums[0]
// f(k) = max{ rob(k-1), nums[k-1] + rob(k-2) }
int N = nums.length;
int[] dp = new int[N+1];
dp[0] = 0;
dp[1] = nums[0];
for (int k = 2; k <= N; k++) {
dp[k] = Math.max(dp[k-1], nums[k-1] + dp[k-2]);
}
return dp[N];
}
步骤四:空间优化
空间优化是动态规划问题的进阶内容了。对于初学者来说,可以不掌握这部分内容。
空间优化的基本原理是,很多时候我们并不需要始终持有全部的 DP 数组。对于小偷问题,我们发现,最后一步计算 f(n) 的时候,实际上只用到了f(n−1) 和 f(n-2)的结果。n-3之前的子问题,实际上早就已经用不到了。那么,我们可以只用两个变量保存两个子问题的结果,就可以依次计算出所有的子问题。下面的动图比较了空间优化前和优化后的对比关系:
这样一来,空间复杂度也从 O(n)O(n) 降到了 O(1)O(1)。优化后的代码如下所示:
public int rob(int[] nums) {
int prev = 0;
int curr = 0;
// 每次循环,计算“偷到当前房子为止的最大金额”
for (int i : nums) {
// 循环开始时,curr 表示 dp[k-1],prev 表示 dp[k-2]
// dp[k] = max{ dp[k-1], dp[k-2] + i }
int temp = Math.max(curr, prev + i);
prev = curr;
curr = temp;
// 循环结束时,curr 表示 dp[k],prev 表示 dp[k-1]
}
return curr;
}
我的完整代码:
/**
* 动态规划的的四个解题步骤是:
*
* 定义子问题
* 写出子问题的递推关系
* 确定 DP 数组的计算顺序
* 空间优化(可选)
*
* 1、子问题。可以定义个k,先求k中的最大金额。k<=n,当k=0时,金额=0,当k=1时,金额=第数组第一个数。当k=n时,就是题目所要求的最大金额
* 2、写出子问题的递推关系。f(k)=max{f(k-1), f(k-2)+num[k]}
* 3、确定 DP 数组的计算顺序。dp数组中依次存放k个计算出来的最大金额。最后k=n时,dp[k]就是n的最大金额
* 4、空间优化(可选)。从递推关系中可以看出我们只需要dp数组中的前一个和前两个数据,所以可以只保留这两个数据。其他的就不需要放到dp中。
*/
// 方法一:动态规划
/*
* 1、子问题。可以定义个k,先求k中的最大金额。k<=n,当k=0时,金额=0,当k=1时,金额=第数组第一个数。当k=n时,就是题目所要求的最大金额
* 2、写出子问题的递推关系。f(k)=max{f(k-1), f(k-2)+num[k]}
* 3、确定 DP 数组的计算顺序。dp数组中依次存放k个计算出来的最大金额。最后k=n时,dp[k]就是n的最大金额
*/
public static int rob(int[] nums) {
//当数组为空或者只有一个值时,直接返回
if (nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
//定义dp数组用来存放数据。因为要保留nums为空时的数据,所以dp长度+1
int[] dp = new int[nums.length+1];
//当数组为空时dp存0
dp[0] = 0;
//当数组长度为1时dp[1]存数组一个数
dp[1] = nums[0];
//依次往dp中存放数据
for (int i = 2; i < dp.length; i++) {
//使用递推公示依次计算出每个k的值,存到数组dp中
dp[i] = Math.max(dp[i-1], dp[i-2]+nums[i-1]);
}
//dp中最后一个值存的是当k=n时的最大金额。所以返回即可
return dp[dp.length-1];
}
// 方法二:动态规划-空间优化
/*
* 4、空间优化(可选)。从递推关系中可以看出我们只需要dp数组中的前一个和前两个数据,所以可以只保留这两个数据。其他的就不需要放到dp中。
*/
public static int rob2(int[] nums) {
if (nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
//存放k-2的数据
int before = 0;
//存放k-1的数据
int curr = nums[0];
//从k=2开始循环,一直到k=n
for (int i = 2; i < nums.length+1; i++) {
int temp = curr;
//重新计算curr的值
curr = Math.max(curr, before+nums[i-1]);
//前两个位置的数重新赋值
before = temp;
}
//最后curr存放的就是当k=n时的最大金额
return curr;
}
}
该解析来自leetcode的198. 打家劫舍下的nettee大佬的答案