【算法】(3)动态规划

文章目录

  • 参考
  • 基础
  • 解题思路
  • 解题模板
  • 例1 : 斐波那契数列
  • 例2 : 零钱兑换
  • 例3:0-1 背包问题
  • 例4:目标和
  • 例5:最长上升子序列
  • 例6:编辑距离
  • 例7 :鸡蛋掉落
  • 例8 :分割等和子集
  • 例9 :零钱兑换II
  • 例10:最长公共子序列LCS

参考

labuladong/fucking-algorithm

基础

  • 动态规划一般用于求最值问题
  • 问题需要符合最优子结构,问题可以划分为子问题,通过求解子问题,得到更大规模问题的解
  • 如果存在重叠子问题,可以使用额外空间进行优化记录

解题思路

  1. 分析原问题的解空间(所有可能解),观察所有解之间的联系
  2. 观察候选解是否能够分组,以便定义子问题
  3. 分析子问题之间的关系,即是否能从1个子问题的解得到另一个子问题的解
  4. 分析子问题与原问题之间的关系,获得原问题的解

解题模板

  1. 定义状态
    即定义子问题,或定义dp[i]的含义
  2. 定义状态转移方程
    即明确子问题之间的联系dp[i]的推导关系
  3. 初始化
    给定第一个问题的解,即初始化dp[0]或dp[1]等…
  4. 选取结果
    根据所有子问题的解,获取原问题的解
  5. 优化
    是否有进一步优化的空间

例1 : 斐波那契数列

509. 斐波那契数

  1. 定义dp
    dp[i]表示第i个斐波那契数,那么原问题的解即为dp[n]
  2. 状态转移方程
    dp[i] = dp[i-1]+dp[i-2]
  3. 初始化
    dp[1] = 1
    dp[2] = 1
  4. 优化
    dp[i]只依赖于dp[i-1]和dp[i-2]因此只需要两个变量保存即可
//时间O(n),空间O(1)
public int fib(int N) {
     
    if(N<=0) return 0;
    if(N == 1 || N == 2) return 1;
    int first = 1;
    int second = 1;
    for(int i = 3 ; i <= N ;i++){
     
        int num = first + second;
        first = second;
        second = num;
    }
    return second;
}

例2 : 零钱兑换

322. 零钱兑换

  1. 定义dp
    dp[i] 表示凑成总金额为i时,最少需要的硬币数,那么dp[n]即为原问题的解
  2. 状态转移方程
    由于最后一步需要从coins数组中拿一个,因此只需考虑前一步的结果的最小值即可
    dp[i] = min(dp[i-coin]) + 1; coin为coins中的每一个
  3. 初始化
    dp[0] = 0;
public int coinChange(int[] coins, int amount) {
     
    int[] dp = new int[amount+1];
    dp[0] = 0 ;
    for(int i = 1 ; i <= amount ;i++){
     
        int min = -1;//上一步拿硬币需要的最少次数
        for(int coin : coins){
     
            if(i-coin == 0){
      //如果金额为i,其中有一个硬币为i
                min = 0 ; //min = dp[0]
                break;
            }else if(i-coin>0 && dp[i-coin] != -1){
     
                //如果还需要拿i-coin这么多金额,并且coins能组成这个金额
                //那么更新min,如果min为-1,那么更新为dp[i-coin],否则保留最小值
                min = min==-1?dp[i-coin]:Math.min(min,dp[i-coin]);
            }
        }
        dp[i] = min==-1?-1:min+1;
    }
    return dp[amount];
}

例3:0-1 背包问题

  • 给你一个可装载重量为W的背包和N个物品,每个物品有重量和价值两个属性。其中第i个物品的重量为wt[i],价值为val[i],现在让你用这个背包装物品,最多能装的价值是多少?

例:
输入:N = 3, W = 4,wt = [2, 1, 3],val = [4, 2, 3]
输出:6,取前两个物品,重量为2+1=3,小于W,并且价值最大

  1. 分析状态和选择
    原问题要求最大价值,这个最大价值依赖于什么?依赖于当前可选择的物品背包容量
    对于每个物品,我们可以选,也可以不选
  2. 定义dp
    根据两个状态,定义dp[i][w]表示在背包容量为w的情况下,从前i个物品中选出的物品最大价值,dp[n][W]即为原问题的解
  3. 状态转移
    根据选择确定状态转移,对于第i个物品,要么选,要么不选,根据这两种选择的结果,从中取最大值即可
    – 如果不选第i个物品,那么dp[i][w] = dp[i-1][w]
    – 如果选第i个物品,那么dp[i][w] = val[i-1]+dp[i-1][w-wt[i-1]]i是从1开始,因此索引为i-1val[i-1]表示当前物品的价值,dp[i-1][w-wt[i-1]表示去除当前物品重量下的前i-1个物品的最大价值。
  4. 初始化
    dp[...][0] = 0容量为0,不管怎么选,价值都为0
    dp[0][...] = 0,没有物品可以选,价值当然为0
int knapsack(int W, int N, int[] wt, int[] val) {
     
    // vector 全填入 0,base case 已初始化
    int[][] dp = new int[N+1][W];
    for (int i = 1; i <= N; i++) {
     
        for (int w = 1; w <= W; w++) {
     
            if (w - wt[i-1] < 0) {
     
                // 当前背包容量装不下,只能选择不装入背包
                dp[i][w] = dp[i - 1][w];
            } else {
     
                // 装入或者不装入背包,择优
                dp[i][w] = max(dp[i - 1][w - wt[i-1]] + val[i-1], 
                               dp[i - 1][w]);
            }
        }
    }

    return dp[N][W];
}

例4:目标和

494. 目标和

  • 状态:方法数依赖于前i个数目标数
  • 选择:每个数+或-
  • 定义dp:dp[i][s]前i个数凑成目标和为s的方法数,dp[n-1][S]即为所求
  • 状态转移:当前数字,是加或减,那么就要求前i-1个数的和是s+nums[i]s-nums[i],因此到达当前数字(节点)就有dp[i-1][s+nums[i]]+dp[i-1][s-nums[i]]这么多方法(路径)
  • dp[i][s] = dp[i-1][s+nums[i]]+dp[i-1][s-nums[i]]
  • 初始化:由于s可能为负值,因此需要直到s的最大长度,因为nums都为正数,最大值为sum(nums),最小值为-sum(nums),因此整体长度为2*sum+1
  • 由于索引发生变化,那么目标数S的索引为sum+S
public int findTargetSumWays(int[] nums, int s) {
     
    int sum = 0;
    for (int i = 0; i < nums.length; i++) {
     
        sum += nums[i];
    }
    // 绝对值范围超过了sum的绝对值范围则无法得到
    if (Math.abs(s) > Math.abs(sum)) return 0;

    int len = nums.length;
    // - 0 +
    int t = sum * 2 + 1;
    int[][] dp = new int[len][t];
    // 初始化
    if (nums[0] == 0) {
     
        dp[0][sum] = 2;//第一个数字为0,目标数为0,那么有2中方法+0或-0
    } else {
     
        dp[0][sum + nums[0]] = 1;//第一个数字不为0,目标数为nums[0]有1种方法
        dp[0][sum - nums[0]] = 1;//第一个数字不为0,目标数为-nums[0]有1种方法
    }

    for (int i = 1; i < len; i++) {
     
        for (int j = 0; j < t; j++) {
     
            // 边界
            int l = (j - nums[i]) >= 0 ? j - nums[i] : 0;
            int r = (j + nums[i]) < t ? j + nums[i] : 0;
            dp[i][j] = dp[i - 1][l] + dp[i - 1][r];
        }
    }
    return dp[len - 1][sum + s];
}

例5:最长上升子序列

300. 最长上升子序列

  • 定义dp:dp[i]表示以nums[i]结尾的最长上升子序列(包括nums[i])长度,那么原问题的解为max(dp[i])
  • 状态转移:在前面找到比nums[i]小的元素,然后将nums[i]接到后面,就构成了一个新的最长上升序列,dp[i]就取其中的最大值+1
  • dp[i] = max(dp[j]) 其中j属于nums[i]之前的元素中,满足nums[i]>nums[j]的索引
  • 初始化:dp全部取1,因为最长上升子序列最短为1.
  • 时间O(n^2),空间O(n),另外有一种时间O(nlogn)的二分查找解法
public int lengthOfLIS(int[] nums) {
     
    int[] dp = new int[nums.length];
    Arrays.fill(dp,1);
    for(int i = 1 ; i < nums.length ;i++){
     
        for(int j = 0 ; j < i ;j++){
     
            if(nums[i] > nums[j]){
     
                dp[i] = Math.max(dp[i],dp[j]+1);
            }
        }
    }
    int max = 0;
    for(int i = 0 ; i < nums.length ;i++){
     
        max = Math.max(max,dp[i]);
    }
    return max;
}

例6:编辑距离

72. 编辑距离

  • 定义dp数组:dp[i][j]表示word1的前i个字符与word2的前j个字符的编辑距离,那么dp[m][n]即为原问题的解
  • 状态转移:如何求dp[i][j]?
  • 需要考虑对word1的第i个字符与word2的第j个字符采取哪些操作,题目中告知有3种操作
  • 1.在word1中插入一个字符,此时dp[i][j] = dp[i-1][j]+1,因为word1的前i-1个字符最少经过dp[i-1][j]次操作后,变为了word2的前j个字符,因此只需要在通过1次插入操作即可
  • 2.在Word1中删除一个字符,这种情况与在word2中插入1个字符等价,因此dp[i][j] = dp[i][j-1]+1
  • 3.在word1中替换一个字符,此时dp[i][j] = dp[i-1][j-1]+1,因为word1的前i-1个字符最少经过dp[i-1][j-1]次操作后,变为了word2的前j-1个字符,那么再需要1次替换即可,特别情况下,如果这两个字符相同,就不需要替换,即dp[i][j] = dp[i-1][j-1]
  • dp[i][j]为上面3种情况的最小值
  • 初始化:dp[0][j] = j , dp[i][0] = i.因为空字符串到一个非空字符串的编辑距离肯定为非空字符串的长度
public int minDistance(String word1, String word2) {
     
    int m = word1.length();
    int n = word2.length();
    int[][] dp = new int[m+1][n+1];
    for(int i = 0 ; i < m+1 ;i++){
     
        dp[i][0] = i;
    }
    for(int j = 0 ; j < n+1 ;j++){
     
        dp[0][j] = j;
    }
    for(int i = 1 ; i < m+1 ;i++){
     
        for(int j = 1 ; j < n+1 ; j++){
     
            int insertOp = dp[i-1][j]+1;
            int deleteOp = dp[i][j-1]+1;
            int replaceOp = dp[i-1][j-1]+1;
            if(word1.charAt(i-1) == word2.charAt(j-1)){
     
                replaceOp--;
            }
            dp[i][j] = Math.min(insertOp,Math.min(deleteOp,replaceOp));
        }
    }
    return dp[m][n];
}

例7 :鸡蛋掉落

887. 鸡蛋掉落

  • 待完善
public int superEggDrop(int K, int N) {
     
    
    //递归结束
    if(K == 1) return N;
    if(N == 0) return 0;

    int result = Integer.MAX_VALUE;
    //穷举所有的可能性
    //从N层楼的第i层开始扔
    for(int i = 1 ; i < N+1 ; i++){
     
        //碎了,鸡蛋-1,从下面继续尝试
        int broken = superEggDrop(K-1,i-1);
        //没碎,鸡蛋不变,继续从上面尝试
        int notBroken = superEggDrop(K,N-i);
        //这两种情况要取最大值,才能反映最坏情况
        //然后加上从i处扔的这一次,那么从i处扔至少需要的次数为
        int max = Math.max(broken,notBroken)+1;
        //更新result,取这么多次的最小值
        result = Math.min(max,result);
    } 
    return result;
}

例8 :分割等和子集

416. 分割等和子集

  • 分析:首先对nums求和为sum,如果数组可以分割成2个子集,使得这两个子集的元素和相等,那么
  • 其中每个子集的元素和肯定为sum/2,这样可以看成一个01背包问题:
  • 给定一个容量为sum/2的背包,n个物品,是否能够从中选取若干个物品,使其恰好填满背包
  • 分析状态:状态有两个:背包容量和物品数量,这两个状态决定了问题的解
  • 分析选择:对于每个元素,我们可以选或不选
  • 定义dp:由于有2个状态,因此设定2个参数,dp[i][j]表示在背包容量为j的情况下,从前i个物品中选,是否能够从中选取若干个物品,使其元素和等于j。那么原问题的解为dp[n][sum/2];
  • 状态转移:对于元素nums[i-1]来说,我们可以选也可以不选,因此遍历所有的选择即可
  • 1.选:dp[i][j] = dp[i-1][j-nums[i-1]];选择当前元素,那么True还是False就依赖于背包容量减少后的前一个状态
  • 2.不选:dp[i][j] = dp[i-1][j]; 不选的话,背包容量不变,直接依赖于前一个状态
  • 初始化:
  • dp[0][...] = False ; //没有物品可以选了,显然为false
  • dp[...][0] = True ; //背包容量为0就是装满了,为true
public boolean canPartition(int[] nums) {
     
    int n= nums.length;
    int sum = 0 ;
    for(int num : nums){
     
        sum += num;
    }
    //如果sum为奇数,那么不可能
    if((sum & 1) == 1){
     
        return false;
    }
    boolean[][] dp = new boolean[n+1][sum/2+1];
    for(int i = 0 ; i < n+1 ; i++){
     
        dp[i][0] = true;
    }
    for(int j = 0 ; j < sum/2+1 ; j++){
     
        dp[0][j] = false;
    }
    for(int i = 1 ; i < n+1 ;i++){
     
        for(int j = 1; j < sum/2+1 ;j++){
     
            //如果容量j已经不容装下nums[i-1],那就只能不装
            if(j - nums[i-1] < 0){
     
                dp[i][j] = dp[i-1][j];
            }else{
     
                dp[i][j] = dp[i-1][j-nums[i-1]]||dp[i-1][j];
            }
            
        }
    }
    return dp[n][sum/2];
}

例9 :零钱兑换II

518. 零钱兑换 II

    //动态规划
    //确定状态:可选择的硬币和总金额
    //确定选择:选或不选
    //dp定义:dp[i][j]表示前i个硬币组成总金额为j的方法数,原问题的解为dp[N][amout];
    //状态转移:对于第i个硬币,可以选也可以不选
    //选:dp[i][j] = dp[i][j-coins[i-1]] ; 例:前2个硬币组成金额为3的方法数为2,第三个硬币金额为4,选了,那么前3个硬币组成金额为7的方法数还是为2,(在之前每条路径上再添加第三枚硬币的金额,但还是那么多路径)
    //不选:dp[i][j] = dp[i-1][j]; 例:前2个硬币组成金额为3的方法数为2,第三个硬币不选,那么前3个硬币组成金额为3的方法数还是为2
    //初始化:
    //dp[0][...] = 0 如果没有硬币,那么没办法凑成指定金额
    //dp[...][0] = 1  总金额为0,什么都不拿就行了,也是1种方法
    public int change(int amount, int[] coins) {
     
        int[][] dp = new int[coins.length+1][amount+1];

        for(int j = 0 ; j < amount+1 ; j++){
     
            dp[0][j] = 0;
        }

        for(int i = 0 ; i < coins.length+1 ; i++){
     
            dp[i][0] = 1;
        }
        
        for(int i = 1 ; i < coins.length+1 ;i++){
     
            for(int j = 1; j < amount+1 ; j++){
     
                if(j-coins[i-1]<0){
     
                    dp[i][j] = dp[i-1][j];
                }else{
     
                    dp[i][j] = dp[i-1][j]+dp[i][j-coins[i-1]];
                }
                
            }
        }
        return dp[coins.length][amount];
    }

例10:最长公共子序列LCS

1143. 最长公共子序列

//动态规划,画表、举例子即可解决
//状态: text1和text2的前i,j个字符 决定了 LCS
//选择 : i,j指向的字符是否一致
//定义dp:dp[i][j]表示text1,text2的前i,j个字符的LCS,那么原问题的解为dp[m][n]
//状态转移:
//text1[i-1]与text2[j-1]相等:dp[i][j] = dp[i-1][j-1]+1。相等的话,LCS加1
//不相等:dp[i][j] = max(dp[i][j-1],dp[i-1][j])。不相等的话,LCS取决于前面的状态
//如abc和ace,i=3,j=3时,c和e不相等,那么此时的LCS取决于(abc和ac的LCS),(ab,ace的LCS),从中取最大值
//初始化
//dp[0][...] = 0 , dp[...][0]=0
public int longestCommonSubsequence(String text1, String text2) {
     
    int m = text1.length();
    int n = text2.length();
    int[][] dp = new int[m+1][n+1];
    for(int i = 0 ; i < m+1 ; i++){
     
        dp[i][0] = 0;
    }
    for(int j = 0 ; j < n+1 ; j++){
     
        dp[0][j] = 0;
    }

    for(int i = 1 ; i < m+1 ; i++){
     
        for(int j = 1 ; j < n+1 ;j++){
     
            if(text1.charAt(i-1) == text2.charAt(j-1)){
     
                dp[i][j] = dp[i-1][j-1]+1;
            }else{
     
                dp[i][j] = Math.max(dp[i][j-1],dp[i-1][j]);
            }
        }
    }

    return dp[m][n];
}

你可能感兴趣的:(算法)