数据结构与算法---动态规划

动态规划

在这里插入图片描述

1. 动态规划特点,一个模型三个特征

动态规划比较适合用来求解最优问题,比如求最大值、最小值等等。它可以非常显著地降低时间复杂度,提高代码的执行效率。

  • 最优子结构
  • 无后效性
    无后效性从字面上意思可以理解为:一旦一个阶段的结果计算出来,后面阶段的求解过程不会影响前面阶段的计算结果。
  • 重复子问题

2. 背包问题

对于一组不同重量、不可分割的物品,我们需要选择一些装入背包,在满足背包最大重量限制的前提下,背包中物品总重量的最大值是多少呢?

我们把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个物品决策(放入或者不放入背包)完之后,背包中的物品的重量会有多种情况,也就是说,会达到多种不同的状态,对应到递归树中,就是有很多不同的节点。
我们把每一层重复的状态(节点)合并,只记录不同的状态,然后基于上一层的状态集合,来推导下一层的状态集合。我们可以通过合并每一层重复的状态,这样就保证每一层不同状态的个数都不会超过 w 个(w 表示背包的承载重量),也就是例子中的 9。
于是,我们就成功避免了每层状态个数的指数级增长。
我们用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。
第 0 个(下标从 0 开始编号)物品的重量是 2,要么装入背包,要么不装入背包,决策完之后,会对应背包的两种状态,背包中物品的总重量是 0 或者 2。
我们用 states[0][0]=true 和 states[0][2]=true 来表示这两种状态。
第 1 个物品的重量也是 2,基于之前的背包状态,在这个物品决策完之后,不同的状态有 3 个,背包中物品总重量分别是 0(0+0),2(0+2 or 2+0),4(2+2)。我们用 states[1][0]=true,states[1][2]=true,states[1][4]=true 来表示这三种状态。
以此类推,直到考察完所有的物品后,整个 states 状态数组就都计算好了。我把整个计算的过程画了出来,你可以看看。
图中 0 表示 false,1 表示 true。我们只需要在最后一层,找一个值为 true 的最接近 w(这里是 9)的值,就是背包中物品总重量的最大值。

在这里插入图片描述


weight:物品重量,n:物品个数,w:背包可承载重量
public int knapsack(int[] weight, int n, int w) {
  boolean[][] states = new boolean[n][w+1]; // 默认值false
  states[0][0] = true;  // 第一行的数据要特殊处理,可以利用哨兵优化
  if (weight[0] <= w) {
    states[0][weight[0]] = true;
  }
  for (int i = 1; i < n; ++i) { // 动态规划状态转移
    for (int j = 0; j <= w; ++j) {// 不把第i个物品放入背包
      if (states[i-1][j] == true) states[i][j] = states[i-1][j];
    }
    for (int j = 0; j <= w-weight[i]; ++j) {//把第i个物品放入背包
      if (states[i-1][j]==true) states[i][j+weight[i]] = true;
    }
  }
  for (int i = w; i >= 0; --i) { // 输出结果
    if (states[n-1][i] == true) return i;
  }
  return 0;
}

实际上,这就是一种用动态规划解决问题的思路。我们把问题分解为多个阶段,每个阶段对应一个决策。我们记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进。

时间复杂度为O(nw)
空间复杂度为O(n
w)
优化空间复杂度 O(n)


public static int knapsack2(int[] items, int n, int w) {
  boolean[] states = new boolean[w+1]; // 默认值false
  states[0] = true;  // 第一行的数据要特殊处理,可以利用哨兵优化
  if (items[0] <= w) {
    states[items[0]] = true;
  }
  for (int i = 1; i < n; ++i) { // 动态规划
    for (int j = w-items[i]; j >= 0; --j) {//把第i个物品放入背包
      if (states[j]==true) states[j+items[i]] = true;
    }
  }
  for (int i = w; i >= 0; --i) { // 输出结果
    if (states[i] == true) return i;
  }
  return 0;
}

3. 背包问题升级

在递归树中,每个节点表示一个状态。现在我们需要 3 个变量(i, cw, cv)来表示一个状态。其中,i 表示即将要决策第 i 个物品是否装入背包,cw 表示当前背包中物品的总重量,cv 表示当前背包中物品的总价值。
在这里插入图片描述


private int maxV = Integer.MIN_VALUE; // 结果放到maxV中
private int[] items = {22463};  // 物品的重量
private int[] value = {34896}; // 物品的价值
private int n = 5; // 物品个数
private int w = 9; // 背包承受的最大重量
public void f(int i, int cw, int cv) { // 调用f(0, 0, 0)
  if (cw == w || i == n) { // cw==w表示装满了,i==n表示物品都考察完了
    if (cv > maxV) maxV = cv;
    return;
  }
  f(i+1, cw, cv); // 选择不装第i个物品
  if (cw + weight[i] <= w) {
    f(i+1,cw+weight[i], cv+value[i]); // 选择装第i个物品
  }
}

在递归树中,有几个节点的 i 和 cw 是完全相同的,比如 f(2,2,4) 和 f(2,2,3)。在背包中物品总重量一样的情况下,f(2,2,4) 这种状态对应的物品总价值更大,我们可以舍弃 f(2,2,3) 这种状态,只需要沿着 f(2,2,4) 这条决策路线继续往下决策就可以。也就是说,对于 (i, cw) 相同的不同状态,那我们只需要保留 cv 值最大的那个,继续递归处理,其他状态不予考虑


public static int knapsack3(int[] weight, int[] value, int n, int w) {
  int[][] states = new int[n][w+1];
  for (int i = 0; i < n; ++i) { // 初始化states
    for (int j = 0; j < w+1; ++j) {
      states[i][j] = -1;
    }
  }
  states[0][0] = 0;
  if (weight[0] <= w) {
    states[0][weight[0]] = value[0];
  }
  for (int i = 1; i < n; ++i) { //动态规划,状态转移
    for (int j = 0; j <= w; ++j) { // 不选择第i个物品
      if (states[i-1][j] >= 0) states[i][j] = states[i-1][j];
    }
    for (int j = 0; j <= w-weight[i]; ++j) { // 选择第i个物品
      if (states[i-1][j] >= 0) {
        int v = states[i-1][j] + value[i];
        if (v > states[i][j+weight[i]]) {
          states[i][j+weight[i]] = v;
        }
      }
    }
  }
  // 找出最大值
  int maxvalue = -1;
  for (int j = 0; j <= w; ++j) {
    if (states[n-1][j] > maxvalue) maxvalue = states[n-1][j];
  }
  return maxvalue;
}

4.状态转移表

我们先画出一个状态表。状态表一般都是二维的,所以你可以把它想象成二维数组。其中,每个状态包含三个变量,行、列、数组值。我们根据决策的先后过程,从前往后,根据递推关系,分阶段填充状态表中的每个状态。最后,我们将这个递推填表的过程,翻译成代码,就是动态规划代码了。

5.状态转移方程

状态转移方程是解决动态规划的关键。如果我们能写出状态转移方程,那动态规划问题基本上就解决一大半了,而翻译成代码非常简单。但是很多动态规划问题的状态本身就不好定义,状态转移方程也就更不好想到。
动态规划只能多做多看

6. 题目

  1. 最大子序和
    链接:https://leetcode-cn.com/problems/maximum-subarray/
    在这里插入图片描述
    贪心算法:
class Solution {
public:
    int maxSubArray(vector<int>& nums) 
    {
        int len = nums.size();
        int sum = 0;
        int max = nums[0];
        int i = 0;
        while(i < len)
        { 
            if(sum >= 0)
            {
                
                sum += nums[i];
                ++i;
                if(sum > max)
                {
                    max = sum;
                }
            }
            else
            {
                sum = 0;
            }
        }
        return max;
    }
};
//思路为:
//直接做单层遍历,用sum保存从头加的和,当sum小于0时,清零重新累加,在过程中记下sum的最大值

使用dp算法:


// 思考状态:dp[i]代表着以nums[i]结尾的最大的子序列和。
// 思考状态转移方程:dp[i] = Math.max(dp[i-1] + nums[i], nums[i]); 取dp[i-1]+nums[i]和nums[i]的最大值是因为考虑dp[i-1]是否对nums[i]产生了负增益,如果对nums[i]产生了负增益,那么不如不产生,对应的就是将dp[i-1]去掉。
// 思考初始化:dp[0] = nums[0],所以i必须从1开始直到末尾。
// 思考输出:输出dp数组的最大值即可。
class Solution {
    public int maxSubArray(int[] nums) {
        int len = nums.length;
        int[] dp = new int[len];
        dp[0] = nums[0];
        int res = Integer.MIN_VALUE;
        for(int i = 1; i < len; i++){
            dp[i] = Math.max(dp[i-1] + nums[i], nums[i]);
            if(dp[i] > res){
                res = dp[i];
            }
        }
        return Math.max(dp[0], res);
    }
}

这两种方法实现不一样,其实想法是相同的

  1. 最小路径和
    链接:https://leetcode-cn.com/problems/minimum-path-sum/
    在这里插入图片描述
// 思路: 基础的dp思想
// 迁移方程:dp[i][j] = Math.min((dp[i-1][j] + grid[i][j]), (dp[i][j-1] + grid[i][j]));


class Solution {
    public int minPathSum(int[][] grid) {
        if(grid == null || grid.length < 1 || grid[0] == null || grid[0].length < 1){
            return 0;
        }
        int row = grid.length, col = grid[0].length;

        int dp[][] = new int[row][col];
        // 初始化
        dp[0][0] = grid[0][0];
        for(int i = 1; i < row; i++){
            dp[i][0] = dp[i-1][0] + grid[i][0];
        }
        for(int i = 1; i < col; i++){
            dp[0][i] = dp[0][i-1] + grid[0][i];
        }

        for(int i = 1; i < row; i++){
            for(int j = 1; j < col; j++){
                dp[i][j] = Math.min((dp[i-1][j] + grid[i][j]), (dp[i][j-1] + grid[i][j]));
            }
        }

        return dp[row-1][col-1];

    }
}
  1. 不同路径
    链接:https://leetcode-cn.com/problems/unique-paths/
    在这里插入图片描述
// 迁移方程 dp[i][j] = dp[i-1][j] + dp[i][j-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];
    }
}
  1. 不同路径2
    链接:https://leetcode-cn.com/problems/unique-paths-ii/
    在这里插入图片描述
// 思路: dp[i][j] 
// 状态转移方程:  当obstacleGrid[i][j] == 1 时 dp[i][j] = 0 ,
//               当obstacleGrid[i][j] == 0 时 dp[i][j] = dp[i-1][j] + dp[i][j-1];
class Solution {
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        int row = obstacleGrid.length;
        int col = obstacleGrid[0].length;
        // 定义dp,初始化
        int[][] dp = new int[row][col];
        int frow = 1;
        for(int i = 0; i < row; i++){
            if(obstacleGrid[i][0] == 1){
                frow = 0;
            }
            dp[i][0] = frow;
        }
        int fcol = 1;
        for(int i = 0; i < col; i++){
            if(obstacleGrid[0][i] == 1){
                fcol = 0;
            }
            dp[0][i] = fcol;
        }

        for(int i = 1; i < row; i++){
            for(int j = 1; j < col; j++){
                if(obstacleGrid[i][j] == 1){
                    dp[i][j] = 0;
                }else{
                    dp[i][j] = dp[i-1][j] + dp[i][j-1];
                }
            }
        }
        return dp[row-1][col-1];
    }
}
  1. 爬楼梯
    链接: https://leetcode-cn.com/problems/climbing-stairs/
    在这里插入图片描述
// 思路:dp[i] 为到达i阶的方法数,到达i前只有两种方法,从i-1走一阶,从i-2走两阶
// 迁移方程:dp[i] = dp[i-1] + dp[i-2]
class Solution {
    public int climbStairs(int n) {
        if(n == 1 || n == 2){
            return n;
        }

        int[] dp = new int[n];
        // 初始化
        dp[0] = 1;
        dp[1] = 2;
        for(int i = 2; i < n; i++){
            dp[i] = dp[i-1] + dp[i-2];
        }
        return dp[n -1];
    }
}
  1. 使用最小花费爬楼梯
    链接:https://leetcode-cn.com/problems/min-cost-climbing-stairs/
    在这里插入图片描述
// 思路: 同爬楼梯,难度在于题目读不懂
// 动态迁移方程:dp[i] = Math.min(dp[i-1], dp[i-2])  + cost[i];
class Solution {
    public int minCostClimbingStairs(int[] cost) {
        int n = cost.length;
        if(n <= 2){
            return 0;
        }
        int[] dp = new int[n];
        dp[0] = cost[0];
        dp[1] = cost[1];

        for(int i = 2; i < n; i++){
                dp[i] = Math.min(dp[i-1], dp[i-2])  + cost[i];
        }
        return Math.min(dp[n-2], dp[n-1]);
    }
}
  1. 打家劫舍
    链接:https://leetcode-cn.com/problems/house-robber/
    在这里插入图片描述
// 思路:dp[i] 为到达低i间房偷盗的最高金额数
// 迁移方程: dp[i] = Max(dp[i-2] + nums[i], dp[i-1])
class Solution {
    public int rob(int[] nums) {
        int len = nums.length;
        if(len == 0){
            return 0;
        }
        if(len == 1){
            return nums[0];
        }
        if(len == 2){
            return Math.max(nums[0], nums[1]);
        }

        int[] dp = new int[len];
        dp[0] = nums[0];
        dp[1] = Math.max(nums[0], nums[1]);
        for(int i = 2; i < len; i++){
             dp[i] = Math.max(dp[i-2] + nums[i], dp[i-1]);
        }
        return dp[len-1];
    }
}
  1. 买卖股票的最佳时机
    链接:https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock/
    在这里插入图片描述
// 思路: 定义状态 dp[i][0]  dp[i][1]  i表示当前天数, 0 1 表示是否持仓, dp[i][j]表示当前利润
// dp[i][0]:规定了今天不持股,有以下两种情况:
// 昨天不持股,今天什么都不做;
// 昨天持股,今天卖出股票(现金数增加),
// dp[i][1]:规定了今天持股,有以下两种情况:
// 昨天持股,今天什么都不做(现金数与昨天一样);
// 昨天不持股,今天买入股票(注意:只允许交易一次,因此手上的现金数就是当天的股价的相反数)。

// 状态方程: 当天未持仓时,dp[i][0] = Math.max(dp[i-1][0], dp[i-1][0] + prices[i])
//            当天持仓时, dp[i][1] = Math.max(dp[i-1][1], -prices[i])
class Solution {
    public int maxProfit(int[] prices) {
        int n = prices.length;
        
        int[][] dp = new int[n][2];
        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        for(int i = 1; i < n; i++){
            dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] + prices[i]);
            dp[i][1] = Math.max(dp[i-1][1], -prices[i]);
        }
        return Math.max(dp[n-1][0], dp[n-1][1]);

    }
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值