文章目录
1. 概念
动态规划和分治法类似,把原问题划分成若干个子问题,不同的是,分治法,子问题间相互独立,动态规划,子问题不独立。
暴力递归和动态规划
暴力递归
- 把问题转化为规模小了的同类问题的子问题
- 有明确的的不需要继续进行递归的条件
- 有当得到了子问题的结果之后的决策过程(先自顶向下拆解问题,再从底部往上走,在这里就可以做一点事情)
- 不记录每一个子问题的解
动态规划
- 从暴力递归中来
- 将每一个子问题的解记录下来,避免重复计算
- 把暴力递归的过程,抽象成了状态表达
- 并且存在简化状态表达,使其更加简洁的可能
也就是说,动态规划其实就是暴力递归的优化,暴力递归,不记录每一个子问题的解,而动态规划记录每一个子问题的解,并且把递归的过程,抽象成了状态表达。动态规划,就是把所有的情况都枚举出来,先从递归的结束条件开始算,之后根据这个值一步一步的算,直到算出需要的答案为止。
动态规划解题一般分为三个步骤:
- 表示状态
分析问题的状态时,不要分析整体,只分析最后一个阶段即可!因为动态规划问题都是划分为多个阶段的,各个阶段的状态表示都是一样,而我们的最终答案在就是在最后一个阶段。每一种状态都是使用数组的位数来表示的。一般我们会先使用数组的第一维来表示阶段,然后再根据需要通过增加数组维数,来添加其他状态。
- 找出状态转移方程
状态转移指的是,当前阶段的状态如何通过之前计算过的阶段状态得到。
- 初始化边界
初始化边角状态,以及第一个阶段的状态。
整个动态规划,最难的就是定义状态。一旦状态定义出来,表明你已经抽象出了子问题,可以肢解原来的大问题了。
2. 相关算法题
2.1 求n的阶乘 n!
public class Code_01_Factorial {
//递归法
public static int getFactorial(int n) {
if(n <= 2) return n;
return n * getFactorial(n - 1);
}
//动态规划法
public static int getFactorial2(int n) {
int temp = 1;
for(int i = 1; i <= n; i++) {
temp = i * temp;
}
return temp;
}
}
2.2 斐波那契数列
斐波那契数列指的是这样一个数列:1、1、2、3、5、8、13、21、34、…… 其规律很明显,从第3个数开始,每个数都等于它前两个数的和。
递归解法
//递归方式
public static int fibonacci(int i){
if(i < 2) return i;
return fibonacci(i - 1) + fibonacci(i - 2);
}
使用递归的方式,会有好很多的重复计算
使用动态规划的方式,来避免重复计算
解题思路
定义一个一维数组dp,dp[ i ] 代表斐波那契数列第i个数字,转移方程为dp[ i ] = dp[ i - 1 ] + dp[ i - 2 ],初始化dp[ 0 ] = 1,dp[ 1 ] = 1,之后,从2开始循环,直到计算出要求的第n个数字结束。
// 动态规划
public static int fibonacci2(int i){
//定义一个一维数组dp,dp[i]表示斐波那契数列第i个数字
int[] dp = new int[i + 1];
dp[0] = 0;
dp[1] = 1;
for(int j = 2; j < i + 1; j++){
dp[j] = dp[j - 1] + dp[j - 2];
}
return dp[i];
}
与之类似的还有:跳台阶问题:每次只能跳一个或者两个台阶,跳到n层台阶上有几种方法 。
2.3 矩阵连乘问题
给定n个矩阵{A1,A2,…,An},其中Ai与Ai+1是可乘的,i=1,2…,n-1。如何确定计算矩阵连乘积的计算次序,使得依此次序计算矩阵连乘积需要的数乘次数最少。
例如,给定三个连乘矩阵{A1,A2,A3}的维数分别是10100,1005和550,采用(A1A2)A3,乘法次数为101005+10550=7500次,而采用A1(A2A3),乘法次数为100550+10100*50=75000次乘法,显然,最好的次序是(A1A2)A3,乘法次数为7500次。
矩阵乘法
矩阵A 和矩阵B,只有当A的列数和B的行数相等的时候才可以相乘,假如A(m x n),B(n x k),A x B 得到一个m 行 k列的矩阵,具体乘法是拿A的第一行与B的第一列中各个元素的乘积的和为结果矩阵的第一行第一列的数,然后拿A的第一行和B的第二列的各个元素的乘积作为第一行第二列的数…。
解题思路
定义一个二维数组m,m[ i ] [ j ] 表示第i个矩阵到第j个矩阵连乘的最小次数,则最优值就是m[ 1 ] [ n ]。假设A1A2…An的一个最优加括号把乘积在Ak和Ak+1间分开,则前缀子链A1…Ak的加括号方式必定为A1…Ak的一个最优加括号,后缀子链同理。
所以m[ i ] [ j ] = min( m[ i ] [ k ] + m [ k + 1 ] + p(i - 1) * p(k) *p(j) )
( p(i - 1) 、p(k)、p(j) 分别是第i个矩阵的行数,第k个矩阵的列数,第j个矩阵的列数),如果i = j,m[ i ] [ j ] = 0
一开始并不知道k的确切位置,需要遍历所有位置以保证找到合适的k来分割乘积。
对于一组矩阵:A1(30x35),A2(35x15),A3(15x5),A4(5x10),A5(10x20),A6(20x25) 个数N为6
那么p数组保存它们的行数和列数:p={30,35,15,5,10,20,25}共有N+1即7个元素
p[0],p[1]代表第一个矩阵的行数和列数,p[1],p[2]代表第二个矩阵的行数和列数…p[5],p[6]代表第六个矩阵的行数和列数
辅助表m: m[i][j]代表从矩阵Ai,Ai+1,Ai+2…直到矩阵Aj最小的相乘次数,比如A[2][5]代表A2A3A4A5最小的相乘次数,即最优的乘积代价。我们看上图,从矩阵A2到A5有三种断链方式:A2{A3A4A5}、{A2A3}{A4A5}、{A2A3A4}A5,这三种断链方式会影响最终矩阵相乘的计算次数,我们分别算出来,然后选一个最小的,就是m[2][5]的值,同时保留断开的位置k在s数组中。
代码
public class MartixChain {
/**
*
* @param p p为矩阵链,p[0],p[1]代表第一个矩阵的行数和列数,p[1],p[2]代表第二个矩阵的行数和列数
* @return 返回最优值
*/
public static int martixChain(int[] p){
//计算矩阵的个数
int n = p.length - 1;
//m[i][j],表示第i个矩阵到第j个矩阵连乘的最小相乘次数
int[][] m = new int[n + 1][n + 1];
//s[i][j]=k,表示,第i个矩阵到第j个矩阵的连乘,从第k个矩阵分割,相乘次数最小
int[][] s = new int[n + 1][n + 1];
//初始化二维数组,将对角线位置上的值设为0,本身就是0,可以上略
for(int i = 0; i < n + 1; i++){
m[i][i] = 0;
}
for(int r = 2; r < n + 1; r++){//表示r个矩阵相乘
// i表示从第i个矩阵开始,r个矩阵连乘。n-r,表示r个矩阵相乘,最左边的矩阵是第n-r+1个,
// 如果超过这个,最后一组r个矩阵就不是r个了,也就是i的左边界
for(int i = 1; i <= n - r + 1; i++){
//j 表示从第i个矩阵开始,长度为r的矩阵链的最后一个矩阵
int j = i + r - 1;
//先以i进行划分
m[i][j] = m[i + 1][j] + p[i - 1] * p[i] *p[j];
//记录划分位置
s[i][j] = i;
//寻找使矩阵相乘次数最小的划分点
for(int k = i + 1; k < j; k++){
int t = m[i][k] + m[k + 1][j] + p[i - 1] * p[k] * p[j];
if(t < m[i][j]){
m[i][j] = t;
s[i][j] = k;
}
}
}
}
return m[1][n];
}
public static void main(String[] args) {
int[] p = new int[]{5, 7, 4, 3, 5};
System.out.println(martixChain(p)); // 264
}
}
2.4 剑指 Offer 42. 连续子数组的最大和
输入一个整型数组,数组里有正数也有负数。数组中的一个或连续多个整数组成一个子数组。求所有子数组的和的最大值。
要求时间复杂度为O(n)。
示例1:
输入: nums = [-2,1,-3,4,-1,2,1,-5,4]
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
解题思路
使用动态规划算法,定义一个数组dp,dp[i]代表以nums[i]结尾的连续子数组的最大和。如果dp[i - 1] >= 0,dp[i]=dp[i-1]+nums[i],如果
dp[i - 1] < 0,dp[i] = nums[i]。dp[0] = nums[0]
public static int maxSubArray(int[] nums){
if(nums == null || nums.length == 0) return 0;
//dp[i]表示以nums[i]结尾的连续子数组的最大和
int[] dp = new int[nums.length];
dp[0] = nums[0];
int res = nums[0];
for(int i = 1; i < nums.length; i++){
if(dp[i - 1] < 0){
dp[i] = nums[i];
}else {
dp[i] = dp[i - 1] + nums[i];
}
res = Math.max(res, dp[i]);
}
return res;
}
这道题,也可以不使用数组,只使用一个变量来记录以nums[i]结尾的连续子数组的最大和
public static int maxSubArray2(int[] nums){
if(nums == null || nums.length == 0) return -1;
int max = nums[0];
int res = nums[0];
for (int i = 1; i < nums.length; i++) {
if(max < 0){
max = nums[i];
}else{
max = max + nums[i];
}
res = Math.max(max, res);
}
return res;
}
2.5 01背包问题
有n个物品,它们有各自的体积和价值,现有给定容量的背包,如何让背包里装入的物品具有最大的价值总和?
eg:number=4,capacity=8
i(物品编号) | 1 | 2 | 3 | 4 |
---|---|---|---|---|
w(体积) | 2 | 3 | 4 | 5 |
v(价值) | 3 | 4 | 5 | 6 |
解题思路
- 表示状态
V[i ,j]
,表示当前背包容量 j,前 i 个物品最佳组合获得的最大的价值 - 找出状态转移方程
背包容量不足以装物品i(w(i) 大于背包容量)则装入前i个物品得到的最大价值和装入前i - 1个物品的最大价值是相同的,即V[i ,w] = V[i - 1,w]
背包容量可以装入物品i,可以装也可以不装,所以V[i,j] = max { V[i - 1 ,j - w(i)] + v(i) , V[i - 1,j] }
为什么背包容量足够的情况下,还需要 V(i,j)=max{V(i-1,j),V(i-1,j-w(i))+v(i)}?
V(i-1,j-w(i))+v(i)
表示装了第i个物品后背包中的最大价值,所以当前背包容量 j 中,必定有w(i)个容量给了第i个背包。
因此只剩余j-w(i)个容量用来装除了第i件物品的其他所有物品。
V(i-1,j-w(i))是前i-1个物品装入容量为j-w(i)的背包中最大价值。
注意,这里有一个问题。前i-1个物品装入容量为j-w(i)的背包中最大价值+物品i的价值。可能不如将,前i-1个物品装入容量为j的背包中得到的价值大。也就是说,可能出现 V(i-1,j) > (V(i-1,j-w(i))+v(i))
比如说,将第i个物品放入背包,可能会导致前面更有价值的物品放不进背包。因此,还不如不把第i个物品放进去,把空间让出来,从而能让前i-1个物品中更有价值的物品能够放入背包。从而让V(i,j)取得最大的值。
所以我们需要 max{V(i-1,j),V(i-1,j-w(i))+v(i)},来作为把前i个物品装入容量为j的背包中的最优解。
根据上面的例子,重量分别为2,3,4,5 ,价值分别为3,4,5,6,商品编号是1,2,3,4
表中横坐标代表容量j,纵坐标代表商品编号i,表中数字代表在背包容量为j时,前i个物品最佳组合获得的最大价值dp[ i ][ j ]。物品最小的重量为2,所以容量为0、1时,表格中都为0,并且纵坐标为0的那一列,也全为0,因为0个商品,价值为0。当容量为2时,1号商品,重量为2,可以放,也可以不放,因为1号商品前面没有商品了,所以前1个物品,获得的最大价值为3,不管容量为几都为3,看2号商品,背包容量为2时,没法放2好商品,所以dp[ 2 ][ 2 ] = dp[ 1 ][ 2 ],当背包容量为3时, 2号商品可以放,也可以不放,主要看价值,dp[ 1 ][ 3 ] = 2,
而3号商品的价值为3,所以应该放如背包,这样,背包容量为3时,就不放1号商品了,dp[ 2 ][ 3 ] = max { dp[ 1 ] [ 3 -2] + v(2) ,dp [ 1 ][ 3 ] } = 3。
只要上面的图理解了,以及dp[ i ] [ j ] 代表的含义理解了,01背包问题就很简单了。
代码实现
public class ZeroOnePack {
/**
*
* @param n 物品数量
* @param v 背包容量
* @param weights 物品的重量
* @param values 物品的价值
* @return 返回容量为v的背包所能获得的最大价值
*/
public static void zeroOnePack(int n, int v, int[] weights, int[] values){
//初始化动态规划数组,dp[i][j]代表背包容量为j时,前i个物品最佳组合所能获得的最大价值
int[][] dp = new int[n + 1][v + 1];
//为了便于理解,将dp[i][0]和dp[0][j]均置为0,从1开始计算
for(int i = 1; i < n + 1; i++){
for(int j = 1; j < v + 1; j++){
//如果第i件物品的重量大于背包容量j,则不装入背包
//由于weight和value数组下标都是从0开始,故注意第i个物品的重量为weight[i-1],价值为value[i-1]
if(weights[i - 1] > j){
dp[i][j] = dp[i - 1][j];
}else {
dp[i][j] = Math.max(dp[i - 1][j - weights[i - 1]] + values[i - 1],dp[i - 1][j]);
}
}
}
//获得的最大商品重量
int maxValue = dp[n][v];
System.out.println("获得的最大商品价值为" + maxValue);
//通过回溯法算出哪些商品被装到背包里了。从dp[n][v]开始,如果dp[n][v]=dp[n-1][v]
//说明,第n件商品没有被装到背包里,如果不相等,说明,被装到背包里了,之后,减去
//被装入商品的重量,再判断容量为当前容量时物品是否装入背包了(重复上述过程)。
int j = v;
for(int i = n; i > 0; i--){
//如果dp[i][j] > dp[i - 1][j],说明,第i件商品是放入背包的
if(dp[i][j] > dp[i - 1][j]){
System.out.print(i + " ");
j = j - weights[i - 1];
}
if(j == 0){
break;
}
}
}
}
2.6 面试题66. 构建乘积数组
给定一个数组 A[0,1,…,n-1],请构建一个数组 B[0,1,…,n-1],其中 B 中的元素 B[i]=A[0]×A[1]×…×A[i-1]×A[i+1]×…×A[n-1]。不能使用除法。
示例:
输入: [1,2,3,4,5]
输出: [120,60,40,30,24]
提示:
所有元素乘积之和不会溢出 32 位整数
a.length <= 100000
解题思路
题目中求的是每一个元素左边的元素和右边的元素的乘积,不包括当前元素,可以分别求出每一个元素左边元素的乘积和右边元素的乘积,并且计算的时候,后一个左边元素的乘积是前一个数左边元素的乘积乘以前一个元素,比如3的左边元素的乘积,是2的左边元素的乘积乘以2,同理,每一个数的右边元素的乘积,是这个数后边的元素的乘积乘以后边的元素。
代码
class Solution {
public int[] constructArr(int[] a) {
//用动态规划的思想解题,先算出每一个元素从第一个元素开始到当前元素之前的一个元素的
//乘积,再算出从数组末尾开始,每一个元素到当前元素的前一个元素的乘积,将前面算出的
//结果一一相乘就是答案
int n = a.length;
if(n == 0) return new int[0];
int[] left = new int[n];
//第一个数的左边的数的乘积是1
left[0] = 1;
//算出每一元素左边的乘积(不包括自己)
for(int i = 1; i < n; i++){
left[i] = left[i - 1] * a[i - 1];
}
//算出每一个元素右边的乘积(不包括自己),可以定义一个temp变量代替数组,temp每一个数的右边元素的乘积
//最后一个数的右边元素的乘积是1
int temp = 1;
int[] res = new int[n];
res[n - 1] = left[n - 1];
for(int i = a.length - 2; i >= 0; i--){
temp = temp * a[i + 1];
res[i] = left[i] * temp;
}
return res;
}
}
如有不足之处,欢迎指正,谢谢!