动态规划(Dynamic Programming,DP)是运筹学的一个分支,是求解决策过程最优化的过程。
动态规划的核心思想:下一个状态可以由上一个状态推导而来,因此可以利用数组保存之前计算的结果,避免重复计算。
我们来看一个经典案例:斐波那契数列
function fibonacci(n) {
if(n == 1 || n == 2) return 1;
return fibonacci(n - 1) + fibonacci(n - 2)
}
上面的代码看似简单,但其实存在一个严重问题。通过下图我们可以看出,在递归过程中,很多节点被重复计算了,这些无疑导致了性能损失,而且随着问题规模的变大,重复计算的节点呈几何级数增长。
那么怎么样优化这个流程呢?由斐波那契数列的特性可以看出,第 n 个值可以由第 n-1 和第 n-2 个值推导出来,例如我们要求第 10 个值,可以确定第 10 个值必然也是一个数,而且这个数可以由第 9 和第 8 个值相加得出,这样我们就确定了 最后一步 和 子问题 :
最后一步:f(10) 一定也是一个数
子问题:f(10) = f(9) + f(8)
由于初始状态已知,即 f(1) = 1
以及 f(2) = 1
,因此我们可以从最初的状态不断向后递推,即 f(n) = f(n-1) + f(n-1)
,然后用一个数组保存之前的状态,避免重复计算。
function fibonacci(n) {
if (n == 1 || n == 2) return 1; // 边界值处理
let dp = new Array(n);
dp[0] = 1; // 初始条件
dp[1] = 1;
for (let i=2; i<n; i++) {
dp[i] = dp[i-1] + dp[i-2]; // 状态转移方程
}
return dp[n-1]
}
上面的代码中,我们用了一个长度为 n 数组保存之前的状态,时间复杂度降低到了 O(n) ,极大提高了效率。从上面的代码中,我们可以总结出动态规划的两个核心组成部分:初始条件和边界情况 以及 状态转移方程。所谓状态转移方程,也就是由上一状态推导到下一状态的递推公式。在上面的例子中,状态转移方程可以抽象为:
f(i) = f(i-1) + f(i-2)
一般来说,解动态规划的题目都需要开一个数组,求第 n 个元素,就需要开一个长度为 n 的数组,空间复杂度为 O(n) 。但数组不是必须的,有些情况下是可以简化的。例如在斐波那契数列的例子中,我们只关心当前状态的前两个状态就行,不需要把之前所有的状态都给保存下来。因此,上面的代码还可以再优化,只创建一个长度为 2 的数组就满足需要了,空间复杂度降为常数阶。
function fibonacci(n) {
if (n == 1 || n == 2) return 1;
let dp = [1, 1];
for (let i=2; i<n; i++) {
let res = dp[0] + dp[1];
dp[0] = dp[1];
dp[1] = res % 1000000007;
}
return dp[1];
}
上面的代码中使用了长度为 2 的数组,其实还可以再简单点,不用数组,用两个变量就行
总结一下,解动态规划的题目有以下几个步骤:
- 先要确定状态(最后一步 + 子问题)
- 然后确定状态转移方程
- 确定初始条件和边界情况
下面一起分析几道经典例题进一步理解动态规划思想。
leetcode 322. 零钱兑换
你有三种硬币,2元、5元、7元,每种硬币足够多,买一本书需要27元,用最少的硬币组合
看到这个题,最直观的想法就是暴力枚举,想要硬币最少,那就用面值最大的去凑:
7+7+7+7 > 27 (排除)
7+5+5+5+5=27, 5枚硬币
7 +7 +7+2+2+2 6枚
很明显暴力枚举太慢了,而且会涉及到很多重复的计算。既然计算机可以通过内存保存之前的内容,又快,很明显,我们开一个数组来保存之前的状态。
确定状态
最后一步
对于这道题,虽然我们不知道最优策略是什么,但是最优策略肯定是K枚硬币,a1, a2, ....ak 面值加起来是27。所以一定有一枚最后的硬币: ak ,除掉这么硬币,前面硬币的面值加起来是 27-ak 。
我们不关心前面的 k-1 枚硬币是怎么拼出 27-ak 的(可能有一种拼法,也可能有 100 种拼法),但是我们确定前面的硬币拼出了 27-ak 。
因为是最优策略, 所以拼出 27-ak 的硬币数一定要最少,否则这就不是最优策略了。
子问题
在确定了最后一步之后,我们得到了子问题:最少用多少枚硬币可以拼出 27-ak 。很明显,最后的那枚硬币只可能是 2 ,5 或者 7 中的某一个。因此我们可以列出下面几种情况:
如果 ak=2 , f(27) = f(27-2)+1 (1代表最后一枚硬币2)
如果 ak=5 , f(27) = f(27-5)+1 (1代表最后一枚硬币5)
如果 ak=7 , f(27) = f(27-7)+1 (1代表最后一枚硬币7)
所以使用最少的硬币数 f(27) = min{f(27-2)+1, f(27-5)+1, f(27-7)+1}
状态转移方程
设状态 f(x) = 最少用多少枚硬币拼出 x
对于任意的 x : f(X) = min{f(X-2)+1, f(X-5)+1, f(X-7)+1}
初始条件和边界情况
提出问题:
x-2, x-5, x-7 小于0怎么办?
什么时候停下来?
如果不能拼出Y, 就定义f[Y] = 正无穷。例如:拼不出f[1]=min{f(-1)+1, f(-3)+1, f(-6)+1}
初始条件:f[0] = 0
个人觉得前两步其实不难,倒是这一步有点麻烦且易错
参考代码
class Solution {
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount+1];
dp[0] = 0; // 初始值
for (int i=1; i<=amount; i++) {
dp[i] = Integer.MAX_VALUE;
for (int j=0; j<coins.length; j++) {
if (i >= coins[j] && dp[i - coins[j]] != Integer.MAX_VALUE) {
dp[i] = Math.min(dp[i - coins[j]] + 1, dp[i]);
}
}
}
if (dp[amount] == Integer.MAX_VALUE) {
dp[amount] = -1;
}
return dp[amount];
}
}
leetcode 62. 不同路径
零钱兑换其实有点难的,不是很好理解。这道比较简单,很容易就能理解。
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角。
问总共有多少条不同的路径?
确定状态
最后一步
无论机器人用何种方式到达右下角,总有最后挪动的一步:向右或者向下。
如果设右下角的坐标为 (m-1, n-1) ,那么最后一步的上一步机器人的位置只可能在 (m-2, n-1) 或者 (m-1, n-2) 。
子问题
如果机器人从左上角走到 (m-2, n-1) 有 X 种方式,从左上角走到 (m-1, n-2) 有 Y 种方式,那么机器人从左上角走到 (m-1, n-1) 就有 X+Y 种方式。
问题转化为,机器人有多少种 方式从左上角走到 (m-2, n-1) 或者 (m-1, m-2) 。
如下图所示,如果是走到 (m-2, n-1) ,我们可以直接把最后一列去掉;同理如果走到 (m-1, n-2) ,可以直接把最后一行去掉。
状态转移方程
对于任意一个格子都有:f[i][j] = f[i-1][j] + f[i][j-1]
f[i][j]
代表机器人有多少种方式走到[i][j]
f[i-1][j]
代表机器人有多少种方式走到[i-1][j]
f[i][j-1]
代表机器人有多少种方式走到[i][j-1]
初始条件和边界情况
初始条件:f[0][0]=1
,因为机器人只有一个方式到左上角
边界情况:在第 0 行或者第 0 列,即 i=0
或 j=0
的情况,都只有一种走法。在其他位置都满足状态转移方程。
参考代码
class Solution {
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n]; // 使用二维数组来记录某一位置有几种走法
for (int i=0; i<m; i++) {
for (int j=0; j<n; j++) {
if (i == 0 || j == 0) {
dp[i][j] = 1; // 边界情况
} else {
dp[i][j] = dp[i-1][j] + dp[i][j-1]; // 其他情况都满足状态方程
}
}
}
return dp[m-1][n-1];
}
}
剑指 Offer 42 连续子数组的最大和
class Solution {
public int maxSubArray(int[] nums) {
int len = nums.length;
int[] dp = new int[len];
dp[0] = nums[0];
int max = nums[0];
for (int i=1; i<len; i++) {
dp[i] = Math.max((dp[i-1] + nums[i]), nums[i]);
max = Math.max(dp[i], max);
}
return max;
}
}
leetcode 64 最小路径和
class Solution {
public int minPathSum(int[][] grid) {
int rows = grid.length, columns = grid[0].length;
int[][] dp = new int[rows][columns];
dp[0][0] = grid[0][0];
for (int i=1; i<rows; i++) {
dp[i][0] = dp[i-1][0] + grid[i][0];
}
for (int j=1; j<columns; j++) {
dp[0][j] = dp[0][j-1] + grid[0][j];
}
for (int i=1; i<rows; i++) {
for (int j=1; j<columns; j++) {
dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1]) + grid[i][j];
}
}
return dp[rows-1][columns-1];
}
}
leetcode 300 最长递增子序列
参考:
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。