目录
一、动态规划(Dynamic Programming,DP)
一、动态规划(Dynamic Programming,DP)
动态规划是分治思想的延伸:将大问题化解为小问题的分治过程中,保存对这些小问题已经处理好的结果,并供后面处理更大规模的问题时直接使用这些结果。
特点:
1. 把原来的问题分解成了几个相似的子问题。
2. 所有的子问题都只需要解决一次。
3. 储存子问题的解。
本质:对问题状态的定义和状态转移方程的定义(状态以及状态之间的递推关系)。
考虑角度:状态定义(要求:定义的状态要形成递推关系)、状态间的转移方程定义、 状态的初始化、返回结果
适用场景:最大值/最小值, 可不可行, 是不是,方案个数
对于动态规划问题,拆解为如下五步骤:
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组(打印数组)
1.1 【编程题】斐波那契数列
题目:斐波那契数列,现在要求输入一个正整数 n ,输出斐波那契数列的第 n 项。
斐波那契数列(黄金分割数列):1、1、2、3、5、8、13、21、34、…,以递推的方法定义:
F(0)=0,F(1)=1, F(n)=F(n-1)+F(n-2)(n ≥2,n ∈ N*)
输入描述:
一个正整数n
输出描述:
输出一个正整数
注意·!!!斐波那契数组,要单独考虑n=0和n=1的情况
1.递归方法:时间复杂度O(2^n),输入较大时,可能栈溢出,递归过程中有大量的重复计算
public class Solution {
public int Fibonacci(int n) {
if(n <= 1) return n;
int f = Fibonacci(n-1)+Fibonacci(n-2); //如果n>2 则输出f
return f;
}
}
2.动态规划: 空间复杂度为O(1)、空间复杂度为O(n)
状态:F(n)
状态递推:F(n)=F(n-1)+F(n-2)
初始值:F(1)=F(2)=1
返回结果:F(N)
public class Solution {
public int Fibonacci(int n) { //空间复杂度为O(1)
if(n <= 0) return 0;
if(n == 1 || n == 2) return 1;
int ret = 0;
int fn1 = 1, fn2 = 1;
for(int i = 3; i <= n; i++) {
ret = fn1 + fn2;
fn1 = fn2;
fn2 = ret;
}
return ret;
}
}
//方法二: //空间复杂度为O(n)
public int Fibonacci(int n) {
int[] dp= new int[n+1]; //创建一个数组保存中间状态的解
dp[0] = 0;
dp[1] = 1;
for(int i = 2; i < n+1; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
1.2【编程题】青蛙跳台阶扩展问题
题目1:青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶(n为正整数)总共有多少种跳法。
方法1:递归
1.逆向思维:若从第n个台阶进行下台阶,下一步有2中可能,一种走到第n-1个台阶,一种是走到第n-2个台阶,可得到如下关系:f[n] = f[n-1] + f[n-2]. (f[n] 表示在第n个台阶上的方法数)
2.初始条件:f[0] = f[1] = 1
3.看到此问题可以想到斐波那契数组,使用动态规划:可优化空间,优化掉递归的栈空间,动态规划直接从子树求得答案。过程是从下往上。
//递归方法
class Solution {
public int jumping(int num) {
if(num<=1) return n;
return jumping(num-1)+jumping(num-2);
}
//优化1:动态规划
public int jumping(int num) {
int[] arr= new int[num+1]; //创建一个数组存放每一级台阶可以的方法数
arr[0] = 0;
arr[1] = 1;
for(int i = 2; i < num+1; i++) {
arr[i] = arr[i-1] + arr[i-2];
}
return arr[num]; // 返回num此时对应的方法数量
}
//优化2:可以发现在这个过程中,计算当前台阶数只用到了前两个台阶的值,因此,只需定义三个变量即可
public int jumping(int num) {
int a = 1;
int b = 1;
int sum = 0;
for(int i=2,i <= num,i++) {
sum = a + b;
a = b;
b = sum;
}
return sum;
}
}
题目2:一只青蛙一次可以跳上1级台阶,也可以跳上2级……它也可以跳上n级。求该青蛙跳上一个n级的台阶(n为正整数)总共有多少种跳法。
解析:此问题是上面的延伸:可得到关系 :f(n)=f(n-1)+…f(1)---->f(n)=2*f(n-1)
public class Solution {
//方法一:排列
// 每个台阶看成一个位置,除过最后一个位置,其它位置都有两种可能性,
// 所以总的排列数为2^(n-1)*1 = 2^(n-1)
public int jumpFloorII(int n) {
int sum = 1;
if (n == 1) return 1; //如果只有一个台阶,则只有一种方法
for(int i = 1; i < n; i++) { //从第二级台阶开始
sum *= 2;
}
return sum;
}
}
1.3【编程题】最小花费爬楼梯
给一个整数数组 cost
,其中 cost[i]
是从楼梯第 i
个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。可以选择从下标为 0
或下标为 1
的台阶开始爬楼梯。计算并返回达到楼梯顶部的最低花费。
输入:cost = [10,15,20]
输出:15
解释:从下标为 1 的台阶开始。 支付 15 ,向上爬两个台阶,到达楼梯顶部, 总花费 15。
输入:cost = [1,100,1,1,1,100,1,1,100,1]
输出:6
解释:从下标为 0 的台阶开始。
- 支付 1 ,向上爬两个台阶,到达下标为 2 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 4 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 6 的台阶。
- 支付 1 ,向上爬一个台阶,到达下标为 7 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 9 的台阶。
- 支付 1 ,向上爬一个台阶,到达楼梯顶部。
总花费为 6 。
解析:
!!!!!!!!每当你爬上一个阶梯,都要花费对应的体力值cost[i]
1.确定dp数组以及下标的含义:
dp[i]的定义:到达第i个台阶所花费的最少体力为dp[i]。
2.确定递推公式:
可以有两个途径得到dp[i],一个是dp[i-1] 一个是dp[i-2]。
dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i];
3. dp数组如何初始化
dp[0] =cost[0] ; dp[1] = cost[1];
4.确定遍历顺序
从前到后遍历cost数组
5.举例推导dp数组
// 方式一:第一步支付费用
class Solution {
public int minCostClimbingStairs(int[] cost) {
int len = cost.length; //求数组长度
int[] dp = new int[len+1]; //动态规划初始化数组dp大小一般为n+1
dp[0] = 0;
dp[1] = 0; // dp[i]为到达第i个台阶所需要支付的费用
//!!!!!!!!每当你爬上一个阶梯,都要花费对应的体力值cost[i]
for(int i = 2;i < len+1; i++) {
dp[i] = Math.min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]); //支付费用后,有两种可选方式,爬一个台阶或者两个台阶,取每一步(局部)最小值,即可求得最后所有步的最小值
}
return dp[len]; //返回爬上最后一个台阶需要花费的费用
}
}
// 方式二:第一步不支付费用
class Solution {
public int minCostClimbingStairs(int[] cost) {
int len = cost.length;
int[] dp = new int[len];
dp[0] = cost[0];
dp[1] = cost[1];
for (int i = 2; i < len; i++) {
dp[i] = Math.min(dp[i - 1], dp[i - 2]) + cost[i];
}
//最后一步,如果是由倒数第二步爬,则最后一步的体力花费可以不用算
return Math.min(dp[len - 1], dp[len - 2]);
}
}
1.4【编程题】不同路径(机器人走方格)
一个机器人位于 m *n
网格的左上角 (起始点标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(标记为 “Finish” )。问总共有多少条不同的路径?
解析:
1.根据分析画图可得到:当n或m为1时,ret = 1,只有一种方法
2.m = 2,且 n = 2时,f(2,2) = 2;
2.递归公式f(m,n) = f(m-1,n) + f(m,n-1)
1.动态规划解法:
class Solution {
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
for(int i = 0; i < m; i++) {
dp[i][0] = 1; //初始化,表示一条竖线,故只有一种方法·
}
for(int i = 0; i < n; i++) { //初始化,表示一条横线
dp[0][i] = 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]; //f(m,n) = f(m-1,n)+f(m,n-1)
}
}
2.递归方法:
class Solution {
public int uniquePaths(int m, int n) {
if(m <= 0 || n <= 0) return 0 ;
if(m == 1 || n == 1) return 1 ;
if(m == 2 && n == 2) return 2 ;
return uniquePaths(m-1,n) + uniquePaths(m,n-1);
}
}
1.5【编程题】不同路径机器人走方格(有障碍)
机器人位于 m*n
网格的左上角 (起始点标记为 “Start” )每次只能向下或者向右移动一步。机器人到网格的右下角( “Finish”)。现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?网格中的障碍物和空位置分别用 1
和 0
来表示。
输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
输出:2
解释:3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右
解析:
1.确定dp数组(dp table)以及下标的含义
dp[i][j] :表示从(0 ,0)出发,到(i, j) 有 dp[i][j] 条不同的路径。
2.确定递推公式
无障碍进行递推:dp[i][j] = dp[i - 1][j] + dp[i][j - 1]。有了障碍,(i, j)如果就是障碍的话应该就保持初始状态(初始状态为0)
if (obstacleGrid[i][j] == 0) { // 当(i, j)没有障碍的时候,再推导dp[i][j]
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
3.初始化
若无障碍 dp[i][0] = dp[0][i] = 1,有障碍则为·0
4.确定遍历顺序
从递归公式dp[i][j] = dp[i - 1][j] + dp[i][j - 1] 中得出,一定是从左到右一层一层遍历,保证推导dp[i][j]时,dp[i - 1][j] 和 dp[i][j - 1]一定是有数值。
5.举例推导dp数组
完整代码:
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int m = obstacleGrid.length;
int n = obstacleGrid[0].length;
int[][] dp = new int[m][n];
if(obstacleGrid[m-1][n-1] == 1 || obstacleGrid[0][0] == 1) {
return 0; //起点和终点若是障碍物,则不同通行
}
for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) {
dp[i][0] = 1;
}
for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) {
dp[0][j] = 1;
}
for(int i = 1; i < m; i++) { //无障碍动规操作 ,有障碍为0
for(int j = 1; j < n; j++) {
dp[i][j] = (obstacleGrid[i][j] == 0) ? (dp[i-1][j] + dp[i][j-1]): 0; //无障碍执行动规,有障碍0
}
}
return dp[m-1][n-1];
}
}
1.6【编程题】走方格的方案数(同上)
请计算n*m的棋盘格子(n为横向的格子数,m为竖向的格子数)从棋盘左上角出发沿着边缘线从左上角走到右下角,总共有多少种走法,要求不能走回头路,即:只能往右和往下走,不能往左和往上走。
注:沿棋盘格之间的边缘线行走
输入描述:
输入两个正整数n和m,用空格隔开。(1≤n,m≤8)
输出描述:
输出一行结果 如:输入:2 2---->6
解析:总路径:(n,m)=(n-1,m)+(n,m-1) ---->使用递归
2.当n==1 && m>= 1------>对应路径数n+m;
3.当m==1 && n>= 1------>对应路径数n+m
终止条件m,n = 1
4.当m,n都>1时,如下情况:每走一步有两种情况,因此用递归方法来实现
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
while (in.hasNextInt()) { //多组样例,因此需要循环读入
int n = in.nextInt();
int m = in.nextInt();
System.out.println(func(n,m));
}
}
public static int func(int n,int m) { //处理输入的两个数据,递归函数
if((n == 1 && m >=1) || (m == 1 && n >=1)) {
return m+n;
}
return func(n-1,m)+func(n,m-1);
}
}
1.7【编程题】拆分词句
给定一个字符串s和一组单词dict,判断s是否可以用空格分割成一个单词序列,使得单词序列中所有的单词都是dict中的单词(序列可以包含一个或多个单词),如:
给定s=“nowcode”;dict=["now", "code"].
返回true,因为"nowcode"可以被分割成"now code".
import java.util.Set;
public class Solution {
public boolean wordBreak(String s, Set<String> dict) {
if(s == null || s.length() == 0) return false;
boolean[] dp = new boolean[s.length()+1]; //给定一个状态数组,存放每个字符是否被分割的true,false值,判定其是否能够被分割,
dp[0] = true; //初始值
for(int i = 1;i <= s.length();i++) { //遍历字符串,从1开始,因为下标0给了初始状态
for (int j = i - 1; j >= 0; j--) {
if (dp[j] && dict.contains(s.substring(j, i))) { // 字典里有字符串的子串
dp[i] = true;
}
}
}
return dp[s.length()];
}
}
1.8【编程题】三角形
给出一个三角形,计算从三角形顶部到底部的最小路径和,每一步都可以移动到下面一行相邻的数字,如 : 给出的三角形如下:
[[20],[30,40],[60,50,70],[40,10,80,30]]
最小的从顶部到底部的路径和是20 + 30 + 50 + 10 = 110。
问题:从顶部到底部的最小路径和
状态F(i,j):(0,0)到(i,j)的最小路径和
状态递推:F(i,j) = min(i-1,j-1), F(i-1,j) + array[i , j]
初始值:F(1)=F(2)=1
返回结果:F(N)
解析:
1. 新增一个数组来存储当前层到下一层各个节点最短的路径值
2.用这个三角形数组每一层本身来存储到达当前层的最短路径,这样就不需要额外的存储空间。。
import java.util.*;
public class Solution {
public int minimumTotal(ArrayList<ArrayList<Integer>> triangle) {
if (triangle.size() == 0 || triangle == null) return 0;
int n = triangle.size(); //记录三角形的层数 (外层数组)
int[] temp = new int[n]; //创建一个数组,存放到达每一层的最小步数,数组大小为层数 (内层数组)
for (int i = 0; i < n; i++)
// triangle.get(n-1)获取(n-1) 行的所有元素 ----->(n-1)行中i位置的元素
temp[i] = triangle.get(n-1).get(i); //获取最后一层 i位置 元素
//继续由下向上运算
for(int i = n-2; i >= 0; i--){ //i代表行数,j为每一行的元素
for(int j = 0; j <= i; j++){
temp[j] = triangle.get(i).get(j)+Math.min(temp[j],temp[j+1]); //获取当前行i-1的最小值min + 上一层i节点
}
}
return temp[0];
}
}
二、背包理论
2.1 二维dp数组01背包
1. 确定dp数组以及下标的含义
dp[i][j] :[0,i ] 物品里任意,放进容量为 j 的背包里,价值总和的最大值。
2.确定递推公式
- 不放物品 i :最大价值为dp[ i - 1] [ j ],也即是物品 i 的容量 > 背包 j 的体积
- 放物品 i :最大价值为dp[ i - 1] [ j - weight[ i ] ] + value[ i ] ---> 也即是 i-1 个物品的价值+第 i 个物品的价值 ( value[ i ] 为物品 i 的价值;weight[ i ]为物品 i 的容量)
递归公式: dp[ i ][ j ] = max(dp[i - 1][ j ], dp[i - 1][j - weight[ i ]] + value[ i ]);
3.dp数组如何初始化
1) 若背包容量 j = 0(dp[ i ][ 0 ]),背包价值总和 dp[ i ][ j ] = 0。
2) i 为 0,存放编号0的物品,各个容量的背包所能存放的最大价值 dp[ i ][ j ]。
当 j < weight [ 0 ] 时,dp[ 0 ][ j ] = 0,因为背包容量 < 物品容量,(装不下,最大价值为0)
当j >= weight [ 0 ] 时,dp[ 0 ][ j ] = value[ 0 ],背包容量只要大于物品容量即可存放。
先遍历 物品 还是 背包?都可以!! 先遍历物品更好理解。
二维dp数组实现01背包 完整代码:
public static void main(String[] args) {
int[] goodsWeight = {1, 3, 4}; // 物品容量
int[] value = {15, 20, 30}; // 物品价值
int bagSize = 4; //背包最大体积
testweightbagproblem(goodsWeight, value, bagSize); //递归
}
public static void testweightbagproblem(int[] goodsWeight, int[] value, int bagSize){
int goodsNum= goodsWeight.length; //物品个数
int value0 = 0; //价值为0
int[][] dp = new int[goodsNum+ 1][bagSize + 1]; //dp[i][j]包容量为j,前i个物品的最大价值
for (int i = 0; i <= goodsNum; i++){ //背包容量为0,价值0(不能放物品)
dp[i][0] = value0;
}
for (int i = 1; i <= goodsNum; i++){ //先遍历物品,再遍历背包容量
for (int j = 1; j <= bagSize; j++){
if (j < goodsWeight[i - 1]){ //背包容量 < 物品i,则i不能放入
dp[i][j] = dp[i - 1][j];
}else{
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - goodsWeight[i - 1]] + value[i - 1]);
}
}
}
for (int i = 0; i <= goodsNum; i++){ //打印dp数组
for (int j = 0; j <= bagSize; j++){
System.out.print(dp[i][j] + " ");
}
System.out.print("\n");
}
}
2.2 一维dp数组 01背包
二维:dp[ i ][ j ] = max(dp[ i ][ j ], dp[ i ][ j - weight[i] ] + value[i]);
dp[ j ]:容量为j的背包,所背物品价值dp[ j ]
递推公式:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
初始化:非0下标都初始化为0
一维dp数组实现01背包 完整代码:
public static void main(String[] args) {
int[] goodsWeight = {1, 3, 4};
int[] value = {15, 20, 30};
int bagWight = 4;
testWeightBagProblem(goodsWeight, value, bagWight); //递归方法
}
public static void testWeightBagProblem(int[] goodsWeight, int[] value, int bagWeight){
int doodsNum = goodsWeight.length;
int[] dp = new int[bagWeight + 1]; // dp[j]背包容量为 j 的最大价值
for (int i = 0; i < doodsNum; i++){ //先遍历物品,再遍历背包容量
for (int j = bagWeight; j >= weight[i]; j--){
dp[j] = Math.max(dp[j], dp[j - goodsWeight[i]] + value[i]);
}
}
for (int j = 0; j <= bagWeight; j++){ //打印dp数组
System.out.print(dp[j] + " ");
}
}