【Java数据结构与算法】动态规划从入门到入坟,思路、方法、技巧(一)

动态规划

概念

把一个大问题,拆解成为有重复特性的小问题,当前状态可以由前面的状态推导出来。由此通过初始状态从而得到目标结果状态。

解决问题

主要解决一些最值问题,最优路径问题

解题步骤

1. 确定dp数组以及下标的含义
2. 确定递推公式
3. dp数组初始化
4. 确定遍历顺序
5. 举例推到dp数组变化。

经典题目

基础题

爬楼梯/三步问题

题目:有个小孩正在上楼梯,楼梯有n阶台阶,小孩一次可以上1阶、2阶或3阶。实现一种方法,计算小孩有多少种上楼梯的方式。
结果可能很大,你需要对结果模1000000007。n范围在[1, 1000000]之间

输入:n = 3
输出:4
输入:n = 5
输出:13

题目分析:3阶台阶,有111、12、3 、21四种方法上楼。如果最后一步上了1阶,这个问题就变成了2阶台阶有几种上楼方式(2种)。
同理,如果最后一步上了2阶,这个问题变为1阶台阶有几种上楼方式(1种)。同理最后一步上了3阶,问题变成0阶台阶(1种)。
很明显,当前问题(3阶 )可以由之前更小的问题(2阶、1阶)推导得出,所以我们用动态规划

解题思路:
  1. 确定dp数组以及下标含义。 dp[i] 表示 i 阶台阶上楼方式的数量。返回dp[n]即为所求。
  2. 确定递推公式。i阶台阶上楼方式由i-1阶、i-2阶、i - 3阶决定。即:dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3]
  3. dp数组初始化。 由题目分析,我们可以发现0阶台阶上楼方式应该为1种,所以dp[0] = 1
  4. 遍历顺序: i由小到大。
  5. 举例dp: 3阶台阶 dp[4] = {1,1,2,4}
Java代码

初始代码

class Solution {
    public int waysToStep(int n) {
        int MOD = 1000000007;
        int[] dp = new int[n + 1];
        dp[0] = 1;
        for(int i = 1; i <= n; i++) {
            dp[i] = dp[i- 1] % MOD;
            if(i > 1) dp[i] = (dp[i] + dp[i - 2]) % MOD;
            if(i > 2) dp[i] = (dp[i] + dp[i - 3]) % MOD;
        }
        return dp[n];
    }
}

代码优化:我们发现后面的变量只与前三个变量有关,所以可以缩减dp数组为4个变量。
为了防止三个数相加溢出我们将变量设置为long,这样就可以加完再取MOD了

class Solution {
    public int waysToStep(int n) {
        if(n == 1) return 1;
        if(n == 2) return 2;
        if(n == 3) return 4;
        int MOD = 1000000007;
        long a = 1, b = 2, c = 4;
        long sum = 0;
        for(int i = 4; i <= n; i++) {
            sum = (a + b + c) % MOD;
            a = b;
            b = c;
            c = sum;
        }
        return (int)sum;
    }
}

最小花费爬楼梯

题目:数组的每个下标作为一个阶梯,第 i 个阶梯对应着一个非负数的体力花费值 cost[i](下标从 0 开始)。
每当爬上一个阶梯都要花费对应的体力值,一旦支付了相应的体力值,就可以选择向上爬一个阶梯或者爬两个阶梯。请找出达到楼层顶部的最低花费,
你可以选择从下标为 0 或 1 的元素作为初始阶梯。

输入:cost = [10, 15, 20]
输出:15,解释:最低花费是从 cost[1] 开始,然后走两步即可到阶梯顶,一共花费 15 。

题目分析
爬楼梯,每次爬之前得支付体力,支付完,可以选择走两阶,也可以选择走一阶。
举例:[1, 100, 1, 1, 1, 100, 1, 1, 100, 1] 花费为6,选择从0开始,然后走1 4 6 7 9 共花费6。
对于第n阶台阶, 我们可以从第n-1阶过来,也可以从第 n - 2 阶过来。很明显,哪个过来之后花费得低选择哪个。
如果我们知道到n-1阶,和n-2阶的最低花费,加上 cost[n-1] 或者cos[n - 2] 就可以知道哪个过来花费的低。因此用动态规划

解题思路
  1. dp数组及下标含义: dp[i] 表示 到第 i 阶台阶的 最低花费。
  2. 递推公式, dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])
  3. dp数组初始化,dp[0] dp[1] 不需要花费都为0。 dp[0] = 0, dp[1] = 0;
  4. 遍历顺序,i 从小到大
  5. 举例dp:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1], dp={0, 0, 1, 2, 2, 2, 3, 4, 4, 5 6} 最终最小花费为dp[n]。
Java代码
class Solution {
    public int minCostClimbingStairs(int[] cost) {
        //动态规划 dp[i] 表示走到第i阶台阶得最小花费
        //   [1, 100, 1, 1, 1, 100, 1, 1, 100, 1]
        //dp={0,   0, 1, 2, 2,   2, 3, 4,   4, 5  6}
        int n = cost.length;
        int[] dp = new int[n + 1];
        for(int i = 2; i < n + 1; i++) {
            dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
        }
        return dp[n];
    }
}

代码优化:我们将dp数组的含义,往后推进一步,dp[i] 表示支付了cost[i]体力后的最小花费,最终需要返回的就是min(dp[n - 1], dp[n - 2])。
dp数组遍历到n - 1就行了,递推公式就变为了 dp[i] = Math.min(dp[i - 1], dp[i - 2]) + cost[i];初始化条件为dp[0] = cost[0], dp[1] = cost[1];

        int n = cost.length;
        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 - 1], dp[n - 2]);

代码优化:很明显dp数组每次只用了dp[i - 1],dp[i - 2] 两个值,dp[2] 的值可以直接写到dp[0] 把dp[0]覆盖掉。因为下一步求dp[3]只会用dp[1],dp[2] 不在用dp[0]了,同理dp[3]的值也可以直接写道dp[1]。

        int[] dp = new int[]{cost[0], cost[1]};
        for(int i = 2; i < cost.length; i++) {
            dp[i % 2] = Math.min(dp[0], dp[1]) + cost[i];
        }
        return Math.min(dp[0], dp[1]);

不同路径

题目:一个机器人位于一个 m x n 网格的左上角 ,机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角。问总共有多少条不同的路径?

在这里插入图片描述
题目分析: 机器人从左上角走到右下角,只能往右或往下。 对于一个点(i, j),机器人又两种方式到达。一种是从上面走下来,一种是从左边走过来。如果我们知道它到达此点上面的点(i - 1, j)的路径数量,和它到达此点左边那个点(i, j - 1)的路径数量,那么机器人到达此点(i, j)的路径数量就是上述两个路径数量之和。 因此用动态规划,dp保存到达每个点的路径数量,由左上的数据推出右下的数据。

解题思路
  1. dp数组及其下标含义: dp[i][j]表示 到达点(i,j)的路径数量
  2. 递推公式: dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
  3. 初始化dp,很明显dp[1][0] = 1, dp[0][1]=1,结合题目分析,我们需要将dp[0][0]初始化为1;
  4. 遍历顺序: 从左上到右下, 即i j 都是从小到大。
  5. dp举例:略。
Java代码
class Solution {
    public int uniquePaths(int m, int n) {
        int[][] dp = new int[m][n];
        dp[0][0] = 1;
        for(int i = 0; i < m; i++) {
            for(int j = 0; j < n; j++) {
                if(i > 0) dp[i][j] += dp[i - 1][j];
                if(j > 0) dp[i][j] += dp[i][j - 1];
            }
        }
        return dp[m - 1][n - 1];
    }
}

练习题目: 不同路径Ⅱ,差别是中间有障碍物,有障碍物,则到达障碍物的路径数为0即可

最小路径和

给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。说明:每次只能向下或者向右移动一步。

在这里插入图片描述
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7 解释:因为路径 1→3→1→1→1 的总和最小。

题目分析,记录到达每个位置的最小花费,到达(i,j)有两条路可以选,选花费小的那条路。
dp[i][j]表示到达位置(i,j)的最小花费。

class Solution {
    public int minPathSum(int[][] grid) {
        int m = grid.length, n = grid[0].length;
        for(int i = 1; i < m; i++) {
            grid[i][0] += grid[i - 1][0];
        }

        for(int j = 1; j < n; j++) {
            grid[0][j] += grid[0][j - 1];
        }

        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                grid[i][j] += Math.min(grid[i][j - 1], grid[i - 1][j]);
            }
        }
        return grid[m - 1][n - 1];
    }
}

三角形最小路径和

题目:给定一个三角形 triangle ,找出自顶向下的最小路径和。每次往下一层,只能走i 或者i + 1
输入:triangle = [[2],[3,4],[6,5,7],[4,1,8,3]]
输出:11
解释:如下面简图所示:
   2
  3  4
 6  5  7
4  1  8  3
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。

题目分析: 从最顶层开始往下找,不能用贪心。 我们找出每行走过那个点的最小路径和,例如:示例输入。走到第二行的3最小花费为5,走到4最小花费为6,走到第三行的6最小花费5+6=11, 走到第三行的5最小花费min(5+5, 6+5) = 10, 走到第三行的7 最小花费6+7=13,第四行同理,用动态规划,可得dp
    2
   5  6
  11  10  13
15  11  18   16

解题思路
  1. dp[i][j] 表示第i层,到达第j个数字的花费。
  2. 递推公式: dp[i][j] = min(dp[i - 1][j - 1], dp[i - 1][j]) + triangle [i][j]
  3. 初始化:dp[0][0] = triangle [0][0]
  4. 遍历顺序,i 从小大,一层一层遍历
  5. dp举例,见题目分析
Java代码
class Solution {
    public int minimumTotal(List<List<Integer>> triangle) {
        int raw = triangle.size();
        int[][] dp = new int[raw][raw];
        dp[0][0] = triangle.get(0).get(0);
        for(int i = 1; i < raw; i++) {
            for(int j = 0; j <= i; j++) {
                if(j == 0) dp[i][j] = dp[i - 1][j] + triangle.get(i).get(j);
                else if(j == i) dp[i][j] = dp[i - 1][j - 1] + triangle.get(i).get(j);
                else {
                    dp[i][j] = Math.min(dp[i - 1][j - 1], dp[i - 1][j]) + triangle.get(i).get(j);
                }
            }
        }

        int ret = Integer.MAX_VALUE;
        for(int i = 0; i < raw; i++) {
            if(dp[raw - 1][i] < ret) {
                ret = dp[raw - 1][i];
            }
        }
        return ret;
    }
}

整数拆分

题目:给定一个正整数 n,将其拆分为至少两个正整数的和,并使这些整数的乘积最大化。 返回你可以获得的最大乘积。

输入: 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。
说明: 你可以假设 n 不小于 2 且不大于 58。

题目分析:给定正整数,拆分成几个数相加,乘积最大,返回这个最大的乘积。36可以拆分为18+18,如果我们知道18拆分后最大乘积是多少,那可得36拆分的乘积有一种是18拆分的乘积+18拆分的乘积,36还可以拆分为17 + 19…所以用动态规划

解题思路
  1. dp数组及下标含义: dp[i] 表示数字 i 拆分后最大乘积
  2. 递推公式: dp[i] =max(dp[i], max( j * dp[i - j] , j * (i - j) )) j从1到i
  3. 初始化:dp[2] = 1,
  4. 遍历顺序, i从小到大
  5. 举例:dp[3] = [0,0,1,2] dp[5] = dp[0,0,1,2,4,6]
java 代码
class Solution {
    public int integerBreak(int n) {
        int[] dp = new int[n + 1];
        dp[2] = 1;
        for(int i = 3; i < n + 1; i++) {
            for(int j = 1; j < i; j++) {
                dp[i] = Math.max(dp[i], Math.max(j * (i - j), j * dp[i - j]));
            }
        }
        return dp[n];
    }
}

这题用动态规划并不优秀,但是思路简单,能解。

不同的二叉搜索树

题目:给你一个整数 n ,求恰由 n 个节点组成且节点值从 1 到 n 互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。

在这里插入图片描述

输入:n = 3
输出:[[1,null,2,null,3],[1,null,3,2],[2,1,3],[3,1,null,null,2],[3,2,null,1]]

题目分析: 二叉搜索树,左子树结点小于右子树。 题目要求返回有多少种不同的二叉搜索树.
当n = 1 时,只有1种,当n=2时,只有两种, 1作为头节点、2作为头节点。
当n = 3时, 1作为头节点,剩下2,3。 2,3分别作为1的右子树的头结点有两种情况。2作为头节点,剩下1,3,只有一种情况, 3作为头节点,同样有两种情况。共5种情况
当n = 4时,,1作为头节点,剩下2,3,4,有5种情况, 2作为头节点, 左边必须为1, 左右34,有两种情况。3作为头节点, 12,4 两种情况, 4作为头节点 123,5种情况,总计5 + 12+21+ 5 = 14.
显然,后面的可由前面计算的数推算而来,用动态规划

解题思路:
  1. dp数组及下标含义: dp[i] 表示 n = i 时, 有几种不同的二叉搜索树, 也表示 i 个 全大于 根节点的数,能形成的二叉搜索树种类
  2. 递推公式: dp[i] = dp[0] * dp[i - 1] + dp[1] * dp[i - 2] + dp[2] * dp[i - 3] + dp[3] * dp[i - 4]+…
  3. dp初始化: dp[0] = 1, dp[1] = 1, dp[2] = 2;
  4. dp举例, dp[3] = [1,1,2, 2 + 1 * 1 + 2 * 1 ]
    dp[4]=[1,1,2, 5,5 + 1 * 2 + 2*1 + 5 * 1 ]
java代码
class Solution {
    public int numTrees(int n) {
        int[] dp = new int[n + 1];
        //dp[i] 表示 i 个 全大于 根节点的数,能形成的二叉搜索树种类
        dp[0] = 1;
        dp[1] = 1;
        for(int i = 2; i <= n; i++) {
            for(int j = 0; j < i; j++) {
                dp[i] += dp[j] * dp[i - j - 1];
            }
        }
        return dp[n];
    }
}

把数字翻译成字符串

题目:给定一个数字,我们按照如下规则把它翻译为字符串:0 翻译成 “a” ,1 翻译成 “b”,……,11 翻译成 “l”,……,25 翻译成 “z”。
一个数字可能有多个翻译。请编程实现一个函数,用来计算一个数字有多少种不同的翻译方法。

输入: 12258
输出: 5
解释: 12258有5种不同的翻译,分别是"bccfi", “bwfi”, “bczi”, “mcfi"和"mzi”

题目分析:把数字翻译成字符串,对于一个数字它可以直接直接翻译成成字符,也可以后后面一位一起翻译成字符。
例如12有两种翻译方式 b,m,122,有三种 bcc,bw, mc。
对于多出来的数字2,可以直接组成一个字符, 即和12翻译种类一样,和前面的2组成22 翻译种类和 1 翻译种类一样。 总共为 2 + 1 = 3
发现此规律,用动态规划即可
说明: 如果新给的数字,不能和前面的数字组成一新的变化,那么多添的这个数字就不能带来新的变化,种类数不变,如果这个数字能和前面数字组成新的字母,种类是为 自己翻译种类数 + 和前面组合翻译种类数。

解题思路:
  1. dp数组及下标含义: dp[i + 1] 表示 前 i 位数能翻译的字符串种类数
  2. 递推公式:第i个 字符和前面有新变化, 即前面字符 =1 ,或前面字符=2且当前字符 < 6 则dp[i + 1] = dp[i] + dp[i - 1]否则 dp[i + 1] = dp[i]
  3. 初始化, dp[0] = 1;方便运算12为两种, dp[1] = 1 表示1为一种
  4. 遍历顺序 从左到右
  5. 举例: 12258 : dp[5] = [1,1,2,3,5,5]
Java 代码
class Solution {
    public int translateNum(int num) {
        if(num == 0) return 1;
        Deque<Integer> deque = new LinkedList<>();
        while(num != 0) {
            deque.add(num % 10);
            num /= 10;
        }
        int n = deque.size();
        int[] dp = new int[n + 1];
        dp[0] = 1;
        dp[1] = 1;
        int pre = 0;
        int index = 1;
        while(!deque.isEmpty()) {
            int cur = deque.removeLast();
            if(pre == 0 || pre * 10 + cur > 25) {
                dp[index] = dp[index - 1];
            }else {
                dp[index] = dp[index - 1] + dp[index - 2];
            }
            index++;
            pre = cur;
        }
        //System.out.println(Arrays.toString(dp));
        return dp[n];
    }
}

代码优化: 只用了前两个状态,滚动数组优化。

class Solution {
    public int translateNum(int num) {
        if(num == 0) return 1;
        Deque<Integer> deque = new LinkedList<>();
        while(num != 0) {
            deque.add(num % 10);
            num /= 10;
        }
        int n = deque.size();
        int a = 1, b = 1, c = 0;
        int pre = 0;
        while(!deque.isEmpty()) {
            int cur = deque.removeLast();
            if(pre == 0 || pre * 10 + cur > 25) {
                c = b;
            }else {
                c = b + a;
            }
            a = b;
            b = c;
            pre = cur;
        }
        return c;
    }
}

练习题目:

杨辉三角

给定一个非负整数 numRows,生成「杨辉三角」的前 numRows 行。

在这里插入图片描述
输入: numRows = 5
输出: [[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1]]

题目分析:杨辉三角,当前数字是上一行两边数字之和。用动态规划,状态dp不再是数组,而是List列表。

class Solution {
    public List<List<Integer>> generate(int numRows) {
        List<List<Integer>> ret = new ArrayList<>();
        for(int i = 0; i < numRows; i++) {
            List<Integer> tem = new ArrayList();
            for(int j = 0; j <= i; j++) {
                if(j == 0 || j == i) tem.add(1);
                else tem.add(ret.get(i - 1).get(j - 1) + ret.get(i - 1).get(j));
            }
            ret.add(tem);
        }
        return ret;
    }
}

反转数位

题目:给定一个32位整数 num,你可以将一个数位从0变为1。请编写一个程序,找出你能够获得的最长的一串1的长度。

输入: num = 1775(11011101111)
输出: 8

题目分析

按顺序遍历每位,可以通过 & 1 来实现从右往左遍历二进制的每一位。
按照题目要求, 以1775(11011101111)为例,第一次应该计算11110111的长度为8。然后计算111011的长度为6,最终返回最大的为8.
我们计算过11110111的长度后, 再去计算111011的长度,很明显可以发现红色数字部分是重复的,如果我们能保存每一位前面有几个连续的1,这样这部分就不用重复去统计了,直接从数组中取即可。所以用动态规划

解题思路:
  1. dp数组及其下标含义: dp[i] 表示从右往左第i位前面有几个连续的1
  2. 递推公式:当第 i - 1 位为1时dp[i] = dp[i - 1] + 1 ,当第 i - 1 位为0时dp[i] = 0。
  3. 数组初始化,不需要初始化
  4. 遍历方向,i从小到大,表示从二进制低位到高位对应位。
  5. 举例dp。 1775(11011101111), dp[32] = {1,2,3,4,0,1,2,3,0,1,2}
    在有dp数组的情况下,每当遇到0时,当前统计的长度就等于前面连续的1的个数+1,然后继续统计即可。同时记录最大的,最后返回最大的。
Java代码:

初始代码

class Solution {
    public int reverseBits(int num) {
        int[] dp = new int[32];
        int pre = 0;
        int count = 0;
        int max = 0;
     
        for(int i = 0; i < 32; i++) {
            //设置dp数组
           if(pre == 0) {
               dp[i] = 0;
           }else {
               dp[i] = dp[i - 1] + 1;
           }
            //当前位为0
           if((num & 1) != 1) {
                max = Math.max(max, count);
                count = dp[i] + 1;
            } else count++;
            
            pre = num & 1;
            num >>>= 1; //连带符号位一起右移
        }
        return Math.max(max, count);
    }
}

代码优化:很明显可以发现,每次我们只用了dp数组中的一个值,所以用一个变量来保存这个值即可。一个值完成清零与自增,就够用了。

class Solution {
    public int reverseBits(int num) {
        if(num == 0) return 1;
        int pre1Num = 0;
        int count= 0;
        int max = 0;
        for(int i = 0; i < 32; i++) {
            if((num & 1) != 1) {
                max = Math.max(max, count);
                count = pre1Num + 1;
                pre1Num = 0;
            } else {
                 pre1Num++;
                 count++;
            }
            num >>>= 1; //连带符号位一起右移
        }
        return Math.max(max, count);
    }
}

比特位计数

题目: 给你一个整数 n ,对于 0 <= i <= n 中的每个 i ,计算其二进制表示中 1 的个数 ,返回一个长度为 n + 1 的数组 ans 作为答案。

输入:n = 2
输出:[0,1,1]

题目分析,计算整数0-n,每个数的二进制数中1的个数。最直接的算法是求每一个数的1的个数。但是显然过于复杂了,小的数1的个数和大的数一定是有关系的,例如我们已知3的二进制为0011,1个数是2。 1的二进制为0001二进制1的个数是1,观察两个二进制数,我们发现3去掉首位的1,一定会变小,变成数字1,所以3的二进制1的个数就是1的二进制1的个数 + 1。
由此,只要能明确的去掉二进制数的固定个数的1,就可以把二进制数变小,从而从较小数的二进制1的个数推出较大数的二进制1的个数。
即动态规划。
例如,用位运算 i & (i - 1)去掉数字 i 二进制最后一个1。

class Solution {
    public int[] countBits(int n) {
        //动态规划
        int[] dp = new int[n + 1];
        for(int i = 1; i <= n; i++) {
            // i & (i - 1) 消除最后一个1,由较小的数推得较大的数
            dp[i] = dp[i & (i - 1)] + 1;
        }
        return dp;
    }
}

其他去掉固定个数1的方法,如去掉最高有效位、最低设置位等方法见题解

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

甲 烷

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值