对于动态规划,一直都是算法笔面试中的重难点,并且动态规划是通过牺牲空间来换取时间的方式解决实际问题,本文旨在说明什么是动态规划,以及面对动态规划问题,一般的思考步骤以及注意事项等,并通过一些题目结合起来。
【维基百科】动态规划(Dynamic programming,简称DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。
动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再合并子问题的解以得出原问题的解。
通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化(memoization)存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
用一句通俗的话解释就是:动态规划就是“记住你之前做过的事”,或者理解为“记住之前得到的答案”,而当前问题的答案和之前问题有关联性,通过记录之前的答案来求当前问题的答案的方法就可以理解为动态规划。
举个例子:在软件开发中,大家经常会遇到一些系统配置的问题,配置不对,系统就会报错,这个时候一般都会去 Google 或者是查阅相关的文档,花了一定的时间将配置修改好。
过了一段时间,去到另一个系统,遇到类似的问题,这个时候已经记不清之前修改过的配置文件长什么样,这个时候有两种方案,一种方案还是去 Google 或者查阅文档,另一种方案是借鉴之前修改过的配置,第一种做法其实是万金油,因为你遇到的任何问题其实都可以去 Google,去查阅相关文件找答案,但是这会花费一定的时间,相比之下,第二种方案肯定会更加地节约时间,但是这个方案是有条件的,条件如下:
当然在这个例子中,可以看到的是,上面这两个条件均满足,大可去到之前配置过的文件中,将配置拷贝过来,然后做些细微的调整即可解决当前问题,节约了大量的时间。
从上面的例子可以发现,对于一个动态规划问题,我们只需要从两个方面考虑,那就是 找出问题之间的联系,以及 记录答案,这里的难点其实是找出问题之间的联系,记录答案只是顺带的事情,利用一些简单的数据结构就可以做到。
下面就说说动态规划的几大要素,动态规划就是用历史记录来解当前的解,避免重复计算,而这些历史记录需要通过变量来保存,通常是一维或二维数组,定义为dp,动态规划主要分为四大步骤:
定义状态,通俗点讲就是定义数组元素的含义,前面说过,我们要用一个数组来保存历史记录,这一步的关键就是要找出这个数组元素的含义,它代表什么?通常不太容易想到我们定义定义的数组的含义;
思考状态转移方程,即找出数组元素之间的关系,有点像递归,当我们要计算dp[n]时,一般都和dp在n之前的值有关系,这是最难的一步;
技巧是分类讨论。对状态空间进行分类,思考最优子结构到底是什么。即大问题的最优解如何由小问题的最优解得到。
初始化,找出初始值,一般一维数组通常是前两三个数,二位数组则是分别当行i或列j为0时需要初始化;
思考输出值,也叫返回值,就是我们要得到的结果要通过数组如何得到。
本题是LeetCode 70 号题
题目
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
输入: 2
输出: 2
解释: 有两种方法可以爬到楼顶。
1、1 阶 + 1 阶
2、 2 阶
算法思路
通过分析,我们不难发现这个问题可以拆解为最优子结构问题,当前能够到达第n个楼梯可以从第n-1个楼梯和n-2个楼梯到达,所以将问题分解为求解第n-1个问题和n-2个问题,一步步拆解,直到第0个问题,也就是起点。
定义状态 :我们将定义一个状态数组dp用于存放当前问题的解,对于第i个状态,可以定义为:从起点开始到第i个楼梯的路径总数,状态之间的关系就是相加关系。
状态转移方程 :第 i 阶可以由以下两种方法得到:
所以到达第 i 阶的方法总数就是到第 i-1 阶和第 i−2 阶的方法数之和。
令 dp[i] 表示能到达第 i 阶的方法总数:
所以状态转移方程为:dp[i]=dp[i-1]+dp[i-2]
初始化 :从状态转移方程可以发现,当 i 的值小于2时是不成立的,所以我们需要先初始化 dp[0],dp[1]的值,同时也需特别注意 dp[2] 的值,因为2层楼只有两种方法,而dp[0] + dp[1]的值为1,所以dp[2]的值也需初始化。
即 dp[0] = 0,dp[1] = 1,dp[2] = 2
输出值 :在定义数组dp时,需要将其长度定义为n+1,我们需要的结果0号位置可用不用,所以dp[n]即为我们要求的解。
代码
class Solution {
public int climbStairs(int n) {
if(n <= 2 ) return n;
int[] dp = new int[n+1];
dp[0] = 0;dp[1] = 1;dp[2] = 2;
for(int i = 3;i <= n;i++){
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
}
复杂度
时间复杂度: O(N),只进行了一层for循环
空间复杂度: O(N),定义了一个数组来记录状态值
这是LeetCode上 62 和 63 号题目
例如,上图是一个7 x 3 的网格。有多少可能的路径?
题目一 不同路径I
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
问总共有多少条不同的路径?
输入: m = 3, n = 2
输出: 3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1.向右 -> 向右 -> 向下
2.向右 -> 向下 -> 向右
3.向下 -> 向右 -> 向右
算法思路
关键点在于机器人只能向右或向下运动,所以我们定义的状态就是当前位置能够到达的线路数量,定义一个二维数组dp用于记录每个点的路径数,而当前路径数是其左边路径数加上方的路径数,即动态转移方程式:dp[i][j] = dp[i-1][j] + dp[i][j-1]
注意:对于第一行和第一列都需特殊对待,边界的每个点的路径数都是1。
代码
class Solution {
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
for (int i = 0; i < n; i++) dp[0][i] = 1;
for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
}
时间复杂度:O(MN),双层for循环
空间复杂度:O(MN),需要一个二维数组,额外开辟了空间存储状态值
题目二 不同路径II
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1 和 0 来表示。
说明:m 和 n 的值均不超过 100。
输入:
[
[0,0,0],
[0,1,0],
[0,0,0]
]
输出: 2
解释:
3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1.向右 -> 向右 -> 向下 -> 向下
2.向下 -> 向下 -> 向右 -> 向右
算法思路
这道题和上一题类似,区别在于增加了障碍物,使原本能够联通的路径被障碍物隔绝了,因此在分析时要特殊考虑障碍物处以及其周边的值。
**定义状态:**同样时定义为当前位置的路径总数;
**状态转移方程:**由于存在障碍物,所以当当前位置有障碍物,此处的路径数量直接为0,否则还是同上一题类似:dp[i][j] = dp[i-1][j] + dp[i][j-1];
**初始化:**对于边界的初始化特别需要注意当存在一个障碍物时,其后面的状态值都不可达,直接为0,所以当首位就是障碍物时,整个网格都不可到达,全为0.
输出值: 在定义了数组dp时,dp[m-1][n-1]即为最终的返回值。
代码
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
if(obstacleGrid.length == 0 ||obstacleGrid == null || obstacleGrid[0][0] == 1) return 0;
int m = obstacleGrid.length, n = obstacleGrid[0].length;
int[][] dp = new int[m][n];
dp[0][0] = obstacleGrid[0][0] == 1 ? 0 : 1; //初始化00位置,如果开始位置就为1,说明后面的都不可到
for(int i = 1;i<m;i++) dp[i][0] = (obstacleGrid[i][0] == 1||dp[i-1][0] == 0) ? 0 : 1;
for(int i = 1;i<n;i++) dp[0][i] = (obstacleGrid[0][i] == 1||dp[0][i-1] == 0) ? 0 : 1;
for(int i = 1;i<m;i++){
for(int j = 1;j<n;j++)
dp[i][j] = obstacleGrid[i][j] == 1 ? 0 : (dp[i][j-1]+dp[i-1][j]);
}
return dp[m-1][n-1];
}
}
时间复杂度:O(MN)
空间复杂度:O(MN)
.
本题是LeetCode 第 50 号题
题目
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例:
输入: [-2,1,-3,4,-1,2,1,-5,4], 输出: 6 解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
思路算法
求最大子数组和,非常经典的一道题目,是因为这道题有很多的解法,多种算法都可以在这道题目上体现出来,比如动态规划、贪心、分治,这里就只用动态规划的思想来解题,各个步骤如下:
定义状态: 问题的核心是子数组,子数组可以看做一段区间,因此可以由起始点和终止点确定一个子数组,两个点中,我们先确定一个点,然后去找另外一个点,比如说:如果我们确定一个元素的结束位置为i,这个时候我们需要思考的问题是‘以i结尾的数组中,和最大的是多少?’,然后继续拆解,会出现两种情况:
所以通过上面的分析,其实状态已经有了,dp[i] 就是 “以 i 结尾的所有子数组的最大值”
状态转移方程: 前面有提到,拆解问题会出现两种情况,一是当前元素自成一个子数组(说明当前元素值非常大),二是由前一个状态中得到,即:
dp[i] = Math.max(dp[i - 1] + array[i], array[i])
简化为:
dp[i] = Math.max(dp[i - 1], 0) + array[i]
初始化: 需要初始化第一个值,使得dp[0] = array[0],以保证最终答案的可靠性;
输出值: 在使用动态转移方程过程中,需要一个变量来记录最终的答案,因为子数组可以在任意一个位置结束,不一定是到最后才结束。
代码
class Solution {
public int maxSubArray(int[] nums) {
if(nums.length == 0 || nums==null) return 0;
int[] dp = new int[nums.length];
dp[0] = nums[0];
int res = dp[0];
for(int i = 1;i<nums.length;i++){
//dp[i] = Math.max(dp[i-1]+nums[i], nums[i]);
dp[i] = Math.max(dp[i-1],0) + nums[i];//dp[i-1]和0比较,如果比0小说明dp[i]的值没必要加上前面的值
res = Math.max(res,dp[i]);
}
return res;
}
}
复杂度分析
时间复杂度:O(N),N 为数组长度,需要遍历一次所有的数组元素
空间复杂度:O(1),只是用了常数个空间来存放变量
LeetCode 322 号题目
题目
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
示例 1:
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1
示例 2:
输入: coins = [2], amount = 3
输出: -1
算法思路
本题不同于普通的人民币面额兑换,因为面额大小是不确定的,而普通的人民币兑换面额是固定的1、2、5、10、20、50、100元,普通的人民币兑换可以使用贪心算法来求解,每次满足最大面额即可,且不会出现不能够兑换的数。而本题则不同,可能会出现面额数不能能够兑换,而且不能够通过贪心来求解。
此时动态规划的作用就显现出来了,动态规划一般求解最值问题,通过穷举需要的(满足条件)值,从而得到答案,即问题的答案是通过是通过之问题的最优解得到的。
定义状态:我们定义dp[i]表示总金额为 i 最少需要的面额数;
状态转移方程:dp[i] = Math.min(dp[i] , dp[i - coins[j]]+ 1)
初始化:首先将所有的值赋值,dp[0] = 0;
输出值: dp[i]即为最后的答案。
代码
class Solution {
public int coinChange(int[] coins, int amount) {
int max = amount + 1;
int[] dp = new int[max];
Arrays.fill(dp,max);
dp[0] = 0;
for (int i = 1; i <= amount; i++) {
for (int j = 0; j < coins.length; j++) {
if (coins[j] <= i) {
dp[i] = Math.min(dp[i], dp[i - coins[j]] + 1);
}
}
}
return dp[amount] >= max ? -1 : dp[amount];
}
}
复杂度
时间复杂度:O(MN),M为金额总数,N为面额的数目;
空间复杂度:O(N)
参考
国外一个大佬总结的LeetCode上所有关于动态规划的题目和思路