原理
什么是动态规划
动态规划是一种多阶段决策最优解模型,一般用来求最值问题,多数情况下它可以采用自下而上的递推方式来得出每个子问题的最优解(即最优子结构),进而自然而然地得出依赖子问题的原问题的最优解。
- 多阶段决策,意味着问题可以分解成子问题,子子问题,。。。,也就是说问题可以拆分成多个子问题进行求解
- 最优子结构,在自下而上的递推过程中,我们求得的每个子问题一定是全局最优解,既然它分解的子问题是全局最优解,那么依赖于它们解的原问题自然也是全局最优解。
- 自下而上,可以肯定子问题之间是有一定联系的,即迭代递推公式,也叫「状态转移方程」,要定义好这个状态转移方程, 我们就需要定义好每个子问题的状态(DP 状态),那为啥要自下而上地求解呢,因为如果采用像递归这样自顶向下的求解方式,子问题之间可能存在大量的重叠,大量地重叠子问题意味着大量地重复计算,这样时间复杂度很可能呈指数级上升(在下文中我们会看到多个这样重复的计算导致的指数级的时间复杂度),所以自下而上的求解方式可以消除重叠子问题。
解法
- 判断是否可用递归来解
即, 判断子问题是和原问题相似但规模较小的问题,例如斐波那契数列,求f(k)可以转换成求f(k - 1) - 分析在递归的过程中是否存在大量的重复子问题
即,判断子问题是否是参数化的,在【打家劫舍】这个经典问题中,原问题是“从全部房子中能偷到的最大金额”,将问题的规模缩小,子问题就是“从 k 个房子中能偷到的最大金额”,用 f(k)表示。 - 定义子问题
动态规划实际上就是通过求n个问题的解,来求出原问题的解。这要求子问题需要具备两个性质:- 原问题要能由子问题表示。
- 一个子问题的解要能通过其他子问题的解求出。
- 分析递推关系,列出状态转移方程
- 改用自底向上的方式来递推
- 自底向上的 dp 数组,即子问题数组(因为 DP 数组中的每一个元素都对应一个子问题)
- 确定 DP 数组的计算顺序,保证计算一个子问题的时候,它所依赖的那些子问题已经计算出来了。
- 写出解题代码
例题
198. 打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
1.明显这个过程中存在子问题,原问题是“从全部房子中能偷到的最大金额”,将问题的规模缩小,子问题就是“从 k 个房子中能偷到的最大金额”,用 f(k) 表示,且这些子问题的思路都是类似的
2. 小偷问题中,k=n时实际上就是原问题,所以原问题是可以由子问题表示的,f(k)可以由f(k−1) 和f(k−2) 求出,所以一个子问题的解可以通过其他子问题的解求出
3.分析递推关系,列出状转移方程:
假设一共有 n个房子,每个房子的金额分别是H(0),H(1)…H(n-1),子问题 f(k) 表示从前 k 个房子中能偷到的最大金额。那么,偷 k个房子有两种偷法:
4. dp[k] 对应子问题 f(k),即偷前 k 间房子的最大金额。
只要搞清楚了子问题的计算顺序,就可以确定 DP 数组的计算顺序。对于小偷问题,我们分析子问题的依赖关系,发现每个 f(k)f(k) 依赖 f(k-1)和 f(k-2)。也就是说,dp[k] 依赖 dp[k-1] 和 dp[k-2]
那么,既然 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