导言:java
动态规划问题一直是算法面试当中的重点和难点,而且动态规划这种经过空间换取时间的算法思想在实际的工做中也会被频繁用到,这篇文章的目的主要是解释清楚什么是动态规划,还有就是面对一道动态规划问题,通常的思考步骤以及其中的注意事项等等,最后经过几道题目将理论和实践结合面试
若是你尚未据说过动态规划,或者仅仅只有耳闻,或许你能够看看 Quora 上面的这个 回答。用一句话解释动态规划就是 “记住你以前作过的事”,若是更准确些,实际上是 “记住你以前获得的答案”。我举个本身工做中常常遇到的例子,在软件开发中,我常常会遇到一些系统配置的问题,配置不对,系统就会报错,这个时候我通常都会去 Google 或者是查阅相关的文档,花了必定的时间将配置修改好,过了一段时间,我去到另外一个系统,遇到相似的问题,这个时候我已经记不清以前修改过的配置文件长什么样,这个时候有两种方案,一种方案仍是去 Google 或者查阅文档,另外一种方案是借鉴以前修改过的配置,第一种作法实际上是万金油,由于你遇到的任何问题其实均可以去 Google,去查阅相关文件找答案,可是这会花费必定的时间,相比之下,第二种方案确定会更加地节约时间,可是这个方案是有条件的,条件以下:算法
固然在这个例子中,能够看到的是,上面这两个条件均知足,我大可去到以前配置过的文件中,将配置拷贝过来,而后作些细微的调整便可解决当前问题,节约了大量的时间。数组
不知道你是否从这些描述中发现,对于一个动态规划问题,咱们只须要从两个方面考虑,那就是找出问题之间的联系,以及记录答案,这里的难点实际上是找出问题之间的联系,记录答案只是顺带的事情,利用一些简单的数据结构就能够作到。bash
通常解决动态规划问题,分为四个步骤,分别是数据结构
这里面的重点实际上是前两个,若是前两个步骤顺利完成,后面的递推方程推导和代码实现会变得很是简单。这里仍是拿 Quora 上面的例子来说解,“1+1+1+1+1+1+1+1” 得出答案是 8,那么如何快速计算 “1+ 1+1+1+1+1+1+1+1”,咱们首先能够对这个大的问题进行拆解,这里我说的大问题是 9 个 1 相加,这个问题能够拆解成 1 + “8 个 1 相加的答案”,8 个 1 相加继续拆,能够拆解成 1 + “7 个 1 相加的答案”,... 1 + “0 个 1 相加的答案”,到这里,第一个步骤已经完成。优化
状态定义实际上是须要思考在解决一个问题的时候咱们作了什么事情,而后得出了什么样的答案,对于这个问题,当前问题的答案就是当前的状态,基于上面的问题拆解,你能够发现两个相邻的问题的联系实际上是 后一个问题的答案 = 前一个问题的答案 + 1
,这里,状态的每次变化就是 +1。spa
定义好了状态,递推方程就变得很是简单,就是 dp[i] = dp[i - 1] + 1
,这里的 dp[i]
记录的是当前问题的答案,也就是当前的状态,dp[i - 1]
记录的是以前相邻的问题的答案,也就是以前的状态,它们之间经过 +1 来实现状态的变动。code
最后一步就是实现了,有了状态表示和递推方程,实现这一步上须要重点考虑的实际上是初始化,就是用什么样的数据结构,根据问题的要求须要作那些初始值的设定。leetcode
public int dpExample(int n) {
int[] dp = new int[n + 1]; // 多开一位用来存放 0 个 1 相加的结果
dp[0] = 0; // 0 个 1 相加等于 0
for (int i = 1; i <= n; ++i) {
dp[i] = dp[i - 1] + 1;
}
return dp[n];
}
复制代码
你能够看到,动态规划这四个步骤实际上是相互递进的,状态的定义离不开问题的拆解,递推方程的推导离不开状态的定义,最后的实现代码的核心其实就是递推方程,这中间若是有一个步骤卡壳了则会致使问题没法解决,当问题的复杂程度增长的时候,这里面的思惟复杂程度会上升。接下来咱们再来看看 LeetCode 上面的几道题目,经过题目再来走一下这些个分析步骤。
题目解析:
爬楼梯,能够爬一步也能够爬两步,问有多少种不一样的方式到达终点,咱们按照上面提到的四个步骤进行分析:
问题拆解:
咱们到达第 n 个楼梯能够从第 n - 1 个楼梯和第 n - 2 个楼梯到达,所以第 n 个问题能够拆解成第 n - 1 个问题和第 n - 2 个问题,第 n - 1 个问题和第 n - 2 个问题又能够继续往下拆,直到第 0 个问题,也就是第 0 个楼梯 (起点)
状态定义
“问题拆解” 中已经提到了,第 n 个楼梯会和第 n - 1 和第 n - 2 个楼梯有关联,那么具体的联系是什么呢?你能够这样思考,第 n - 1 个问题里面的答案实际上是从起点到达第 n - 1 个楼梯的路径总数,n - 2 同理,从第 n - 1 个楼梯能够到达第 n 个楼梯,从第 n - 2 也能够,而且路径没有重复,所以咱们能够把第 i 个状态定义为 “从起点到达第 i 个楼梯的路径总数”,状态之间的联系实际上是相加的关系。
递推方程
“状态定义” 中咱们已经定义好了状态,也知道第 i 个状态能够由第 i - 1 个状态和第 i - 2 个状态经过相加获得,所以递推方程就出来了 dp[i] = dp[i - 1] + dp[i - 2]
实现
你其实能够从递推方程看到,咱们须要有一个初始值来方便咱们计算,起始位置不须要移动 dp[0] = 0
,第 1 层楼梯只能从起始位置到达,所以 dp[1] = 1
,第 2 层楼梯能够从起始位置和第 1 层楼梯到达,所以 dp[2] = 2
,有了这些初始值,后面就能够经过这几个初始值进行递推获得。
参考代码
public int climbStairs(int n) {
if (n == 1) {
return 1;
}
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];
}
复制代码
题目解析:
给定一个三角形数组,须要求出从上到下的最小路径和,也和以前同样,按照四个步骤来分析:
问题拆解:
这里的总问题是求出最小的路径和,路径是这里的分析重点,路径是由一个个元素组成的,和以前爬楼梯那道题目相似,[i][j]
位置的元素,通过这个元素的路径确定也会通过 [i - 1][j]
或者 [i - 1][j - 1]
,所以通过一个元素的路径和能够经过这个元素上面的一个或者两个元素的路径和获得
状态定义
状态的定义通常会和问题须要求解的答案联系在一块儿,这里其实有两种方式,一种是考虑路径从上到下,另一种是考虑路径从下到上,由于元素的值是不变的,因此路径的方向不一样也不会影响最后求得的路径和,若是是从上到下,你会发现,在考虑下面元素的时候,起始元素的路径只会从 [i - 1][j] 得到,每行当中的最后一个元素的路径只会从 [i - 1][j - 1] 得到,中间两者均可,这样不太好实现,所以这里考虑从下到上的方式,状态的定义就变成了 “最后一行元素到当前元素的最小路径和”,对于 [0][0] 这个元素来讲,最后状态表示的就是咱们的最终答案
递推方程
“状态定义” 中咱们已经定义好了状态,递推方程就出来了
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle[i][j]
复制代码
实现
这里初始化时,咱们须要将最后一行的元素填入状态数组中,而后就是按照前面分析的策略,从下到上计算便可
参考代码
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();
int[][] dp = new int[n][n];
List<Integer> lastRow = triangle.get(n - 1);
for (int i = 0; i < n; ++i) {
dp[n - 1][i] = lastRow.get(i);
}
for (int i = n - 2; i >= 0; --i) {
List<Integer> row = triangle.get(i);
for (int j = 0; j < i + 1; ++j) {
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + row.get(j);
}
}
return dp[0][0];
}
复制代码
这里有一个小小的空间上面的优化,就是每次咱们更新状态(dp)数组都是基于以前的结果,咱们并不须要知道以前的以前的结果,平行的状态之间也没有相互影响,所以只用开一维数组便可
参考代码(空间优化后)
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();
int[] dp = new int[n];
List<Integer> lastRow = triangle.get(n - 1);
for (int i = 0; i < n; ++i) {
dp[i] = lastRow.get(i);
}
for (int i = n - 2; i >= 0; --i) {
List<Integer> row = triangle.get(i);
for (int j = 0; j < i + 1; ++j) { // i + 1 == row.size()
dp[j] = Math.min(dp[j], dp[j + 1]) + row.get(j);
}
}
return dp[0];
}
复制代码
题目解析:
求最大子数组和,很是经典的一道题目,这道题目有不少种不一样的作法,并且不少算法思想均可以在这道题目上面体现出来,好比动态规划、贪心、分治,还有一些技巧性的东西,好比前缀和数组,这里仍是使用动态规划的思想来解题,套路仍是以前的四步骤:
问题拆解:
问题的核心是子数组,子数组能够看做是一段区间,所以能够由起始点和终止点肯定一个子数组,两个点中,咱们先肯定一个点,而后去找另外一个点,好比说,若是咱们肯定一个子数组的截止元素在 i 这个位置,这个时候咱们须要思考的问题是 “以 i 结尾的全部子数组中,和最大的是多少?”,而后咱们去试着拆解,这里其实只有两种状况:
你能够看到,咱们把第 i 个问题拆成了第 i - 1 个问题,之间的联系也变得清晰
状态定义
经过上面的分析,其实状态已经有了,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]
,保证最后答案的可靠性,另外咱们须要用一个变量记录最后的答案,由于子数组有可能以数组中任意一个元素结尾
参考代码
public int maxSubArray(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
int n = nums.length;
int[] dp = new int[n];
dp[0] = nums[0];
int result = dp[0];
for (int i = 1; i < n; ++i) {
dp[i] = Math.max(dp[i - 1], 0) + nums[i];
result = Math.max(result, dp[i]);
}
return result;
}
复制代码
经过这几个简单的例子,相信你不难发现,解动态规划题目其实就是拆解问题,定义状态的过程,严格说来,动态规划并非一个具体的算法,而是凌驾于算法之上的一种思想,这种思想强调的是从局部最优解经过必定的策略推得全局最优解,从子问题的答案一步步推出整个问题的答案,而且利用空间换取时间。从不少算法之中你均可以看到动态规划的影子,因此,仍是那句话 技术都是相通的,找到背后的本质思想是关键。