文章目录
一、动态规划
1. 什么是动态规划?
动态规划,就是基于我们写的状态转换方程,将已经计算过的解,计算并记录下来,来避免后面重复计算子解,使得解决问题更加高效。而这些历史记录,我们得需要一些变量来保存,一般是用一维数组或者二维数组dp来保存。这样说你可能很突兀,没关系,接着向下看。
如果一个问题采用动态规划的方式解决问题,那么你必须默认两点:
(1)对于最优解的每个子解都是最优的。实际一点来讲,就是状态转换方程的后一个最优状态是基于前一个最优状态得到的。
(2)所有状态中,不存在重复计算的部分。
2. 为什么会有动态规划?
动态规划是蛮力法的简化版。
其实绝大多数问题理论上全部可以通过蛮力法。蛮力法就是将所有的可能都计算出来,再取最优的解。而这样会导致一个问题,就是效率不高,一旦问题复杂,解题的时间开销会成指数增长。
有一个现象就是,通过蛮力法解决一些问题的时候,会发现重复计算很多。自然的,就有人想到能不能把这些子解记录到备忘录中,下次需要重复计算的时候,直接从备忘录中拿出来,从而避免了重复的计算。
而这种方法就是动态规划,又叫备忘录方法。
3. 动态规划解题步骤
步骤一:定义备忘录(数组dp)的元素的含义。这一步很重要,但是也是不唯一的,一般,你定义的含义不同,状态转换方程也会随之不同。一般是题目问什么,就把dp元素的含义看成什么,有时候需要等价转换。
一般问题是几维的dp数组就设置为几维。我不确定这个规律是否正确,但是我做了这么多题,一般都是这个规律。
步骤二:求状态转换方程。 采用自低向上解决问题。
- 列出所有解。用蛮力法列出前3个或多个状态的最基础的可能解,并圈出当前的元素,方便观察该状态是基于哪个状态转换而来。
- 删除不会出现的可能解。比如求最大值,且
Ai > 0
,假如该状态列出的可能解有A1+A2+A3
和A1+A2
,那么A1+A2+A3
绝对大于A1+A2
,故A1+A2
不是可能解,删除A1+A2
。 - 查看重复的可能解
- 法一(递归法):观察该状态的重复解全部来自于哪个状态。
- 法二(动态规划):删除重复元素,此时所有状态的可能解会构成全部解,且没有重复。但是,这种情况一般需要变量或者数组标记重复元素,这就是动态规划。
- 基于当前元素的解从哪个状态而来。剩余的可能解中,你会发现有些状态的解是基于他前面的状态的解变化而来。比如状态2的可能解有
A1A2
,状态3的可能解有A1A2A3
,那么状态3是在状态2的基础上加上当前元素A3
得到。而这事实上就是状态之间的联系。 - 基于重复的可能解和状态之间的联系得出状态转换方程。
步骤三:设置初始值。 首先,你要知道,初始值往往位于开始的几个状态中,而开始的状态中,往往会有一两个不符合状态转换方程的状态,这几个状态就是初始解,需要解题者直接求出的。简而言之,就是dp数组中的元素,无法用状态转换方程套出值的为初始元素,需要设置初始值。
看到这里,你可能还是很懵逼,没关系的,那是因为你没有结合实例理解理论,实践与理论结合才是真理,下面我会结合上面所讲的步骤去解题,方便大家理解。
二、习题练习
1.题目一 爬楼梯 (LeetCode 70):
问题描述:一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法。
(1)定义数组元素的含义
问题是要求青蛙跳上 n
级的台阶总共由多少种跳法,那我们就定义 dp[i]
的含义为:跳上第 i
级的台阶总共有 dp[i]
种跳法。
(2)求状态转换方程
上图是用蛮力法列出前5个状态的各个可能解。
上图是查看重复计算的部分,得出状态转换方程:dp[i] = dp[i-1] + 1
按照这个状态转换方程推后面的状态看有没有错误:
dp[2] = dp[1] + 1 = 2 + 1 = 3
正确dp[3] = dp[2] + 1 = 3 + 1 = 4
不等于5,错误
那么,说明该状态转换方程是错误的。那么我们接着看下面的状态。
通过上图我们得到了,状态转换方程:dp[i] = dp[i-1] + dp[i-2]
按照这个状态转换方程推后面的状态看有没有错误:
dp[4] = dp[3] + dp[2] = 3 + 2 = 5
正确dp[5] = dp[4] + dp[3] = 5 + 3 = 8
正确
只要后续两个状态都正确,那么状态转换方程就没有问题。
所以,状态转换方程为:dp[i] = dp[i-1] + dp[i-2]
(3)初始值
按照状态转换方程:dp[i] = dp[i-1] + dp[i-2]
,dp[0]和dp[1]
不符合该状态转换方程,所以,初始值为:
dp[0] = 1
dp[1] = 2
重点:通过这个例子我们可以看见,因为开始的几个状态可能为初始值,如果从开始的状态找,可能找的是错误的状态转换方程,所以,我们从中间的状态开始找。比如说从第3个状态开始找,再回过头来根据状态转换方程来找初始值,这样可以避免错误,提高效率!!!!!!!!!!!!!!!!!!!!!!!!!!!!
(4)代码如下:
public static int fun(int n) {
if(n <= 0)
return 0;
int[] dp = new int[n+1]; //备忘录
// 设置初始值
// dp[0] = 0; 可以不设置,因为dp[0]的情况在上面if条件中处理了,如果if中不带等于,这里可以设置dp[0]=0
dp[1] = 1;
dp[2] = 2;
// 通过状态转换方程计算出 dp[n]
for(int i = 3; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
//
return dp[n];
}
其实,上面的代码可以进行空间优化,现在先不讲优化。
2.题目二 最大子序和(LeetCode 53):
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
实例:
输入: [-2,1,-3,4,-1,2,1,-5,4],
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
(1)定义数组元素的含义
按照题目所问的,设 dp[i]
的含义为:从nums[0]
到nums[i]
中最大和的连续子数组的和。
(2)求状态转换方程
上图是用蛮力法列出前4个状态的各个可能解。
通过上图我们得到了,状态转换方程:dp[i] = max(dp[i-1]+nums[i], nums[i])
按照这个状态转换方程推后面的状态看有没有错误:
dp[3-1]+nums[3]
得到:A2A3
、A1A2A3
,而nums[3]
得到A3
。即dp[3]
从A2A3
、A1A2A3
、A3
中选最大值,正确dp[4-1]+nums[3]
得到:A3A4
、A2A3A4
、A1A2A3A4
,而nums[4]
得到A4
。即dp[4]
从A3A4
、A2A3A4
、A1A2A3A4
、A4
中选最大值,正确
(3)初始值:
因为第一个状态不符合该状态转换方程,所以初始值为:dp[0] = nums[0]
(4)代码如下:
class Solution {
public int maxSubArray(int[] nums) {
int max = nums[0];
int len = nums.length;
int[] dp = new int[len];
//初始值
dp[0] = nums[0];
//状态转换:dp[i] = max(dp[i-1] + nums[i], nums[i])
for (int i = 1; i < len; i++) {
if (dp[i-1] > 0)
dp[i] = dp[i-1] + nums[i];
else
dp[i] = nums[i];
if (dp[i] > max)
max = dp[i];
}
return max;
}
public static void main(String[] args) {
int[] nums = {-2,1,-3,4,-1,2,1,-5,4};
Solution solution = new Solution();
System.out.println(solution.maxSubArray(nums));
}
}
注意:max(dp[i-1]+nums[i], nums[i])
等价于当dp[i-1]>0
时,dp[i-1]+nums[i]>nums[i]
,取dp[i-1] + nums[i]
。反之,取nums[i]
3.题目三 (LeetCode 121):
给定一个数组,它的第 i 个元素是一支给定股票第 i天的价格。如果你最多只允许完成一笔交易(即买入和卖出一支股票),设计一个算法来计算你所能获取的最大利润。
注意:你不能在买入股票前卖出股票。
实例1:
输入: [7,1,5,3,6,4]
输出: 5
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意:利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格。
实例2:
输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。
(1)定义数组元素的含义
按照题目所问的,设 dp[i]
的含义为:从nums[0]
到nums[i]
中获得的最大利润。
(2)求状态转换方程
上图是用蛮力法列出前4个状态的各个可能解。
通过上图我们得到了,状态转换方程:dp[i] = max(dp[i-1], nums[i]-nums[i-1], nums[i]-nums[i-2]...nums[i]-nums[0])
按照这个状态转换方程推后面的状态没有错。推的过程我就不写了。
(3)初始值:
状态1不符合该状态转换方程,状态2符合。所以,初始值为dp[0] = 0
`
(4)代码如下:
class Solution {
public int maxProfit(int[] prices) {
// dp[i]为从元素0-i,最小的数
int m = prices.length;
if (m <= 0)
return 0;
int[] dp = new int[m];
//设置初始值
dp[0] = 0;
//dp[i] = max(dp[i-1], nums[i]-nums[i-1], nums[i]-nums[i-2]...nums[i]-nums[0])
for (int i = 1; i < m; i++) {
// 求nums[i]-nums[i-1], nums[i]-nums[i-2]...nums[i]-nums[0]中最大值,记为max
int max = Integer.MIN_VALUE;
for (int j = 0; j < i; j++) {
int temp = prices[i] - prices[j];
if (temp > max)
max = temp;
}
//求 dp[i-1] 和 max中的最大值
dp[i] = Math.max(dp[i-1], max);
}
return dp[m-1];
}
}
4.题目四 (LeetCode 198):
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
示例 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 。
(1)定义数组元素的含义
按照题目所问的,设 dp[i]
的含义为:从nums[0]
到nums[i]
偷的最高金额。
(2)求状态转换方程
上图是用蛮力法列出前4个状态的各个可能解。
通过上图我们得到了,状态转换方程:dp[i] = max(dp[i-2]+nums[i], dp[i])
按照这个状态转换方程推后面的状态没有错。推的过程我就不写了。
(3)初始值:
状态1和状态2不符合该状态转换方程,状态2符合。
所以,初始值为dp[0] = nums[0]
,dp[1] = max(nums[0], nums[1])
(4)代码如下:
class Solution {
public int rob(int[] nums) {
int m = nums.length;
if (m <= 0)
return 0;
else if (m == 1)
return nums[0];
int[] dp = new int[m];
//设置初始值
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
//状态转换方程:dp[i] = Math.max(dp[i-2] + nums[i], dp[i-1])
for (int i = 2; i < m; i++)
dp[i] = Math.max(dp[i-2] + nums[i], dp[i-1]);
return dp[m-1];
}
}