算法基础(Java)--动态规划

前言

**动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)**最优化的数学方法。在面试笔试中动态规划也是经常作为考题出现,其中较为简单的 dp 题目我们应该有百分之百的把握顺利解决才可以。

1. 基本概念

动态规划实际上是一类题目的总称,并不是指某个固定的算法。动态规划的意义就是通过采用递推(或者分而治之)的策略,通过解决大问题的子问题从而解决整体的做法。动态规划的核心思想是巧妙的将问题拆分成多个子问题,通过计算子问题而得到整体问题的解。而子问题又可以拆分成更多的子问题,从而用类似递推迭代的方法解决要求的问题。

2. 动态规划的基本思想与策略

基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。

由于动态规划解决的问题多数有重叠子问题这个特点,为减少重复计算,对每一个子问题只解一次,将其不同阶段的不同状态保存在一个二维数组中。

与分治法最大的差别是:适合于用动态规划法求解的问题,经分解后得到的子问题往往不是互相独立的(即下一个子阶段的求解是建立在上一个子阶段的解的基础上,进行进一步的求解)。

3. 动态规划的应用场景

看过了如何去使用动态规划,那么随之而来的问题就在于:既然动态规划不是某个固定的算法,而是一种策略思路,那么什么时候应该去使用什么时候不能用呢?答案很简短:

对于一个可拆分问题中存在可以由前若干项计算当前项的问题可以由动态规划计算。能用动态规划计算的问题存在一种递推的连带关系。

4. 动态规划的常用状态转移方程

动态规划算法三要素(摘自黑书,总结的很好,很有概括性):

  • ①所有不同的子问题组成的表
  • ②解决问题的依赖关系可以看成是一个图
  • ③填充子问题的顺序(即对②的图进行拓扑排序,填充的过程称为状态转移);则如果子问题的数目为O(nt),每个子问题需要用到O(ne)个子问题的结果,那么我们称它为tD/eD的问题,于是可以总结出四类常用的动态规划方程:
    (下面会把opt作为取最优值的函数(一般取min或max), w(j, i)为一个实函数,其它变量都可以在常数时间计算出来)。)

1. 1D/1D

d[i] = opt{ d[j] + w(j, i) | 0 <= i < j } (1 <= i <= n)

2. 2D/0D

d[i][j] = opt{ d[i-1][j] + xi, d[i][j-1] + yj, d[i-1][j-1] + zij }(1<= i, j <= n)

最典型的就是最长公共子序列问题。

3. 2D/1D

d[i][j] = w(i, j)+opt{ d[i][k-1] + d[k][j] }, (1 <= i < j <= n)

区间模型常用方程。

另外一种常用的2D/1D的方程为:

d[i][j] = opt{ d[i-1][k] + w(i, j, k) | k < j }(1<= i <= n, 1 <= j <= m)

4. 2D/2D

d[i][j] = opt{ d[i’][j’] + w(i’, j’, i, j) | 0 <= i’ < i, 0 <= j’ < j}

常见于二维的迷宫问题,由于复杂度比较大,所以一般配合数据结构优化,如线段树、树状数组等。

对于一个tD/eD 的动态规划问题,在不经过任何优化的情况下,可以粗略得到一个时间复杂度是O(nt+e),空间复杂度是O(nt)的算法,大多数情况下空间复杂度是很容易优化的,难点在于时间复杂度。

5. 常见动态规划问题

1. 【LeetCode】322. CoinChange 硬币找零

题目描述:
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

示例 1:

输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1

示例 2:

输入: coins = [2], amount = 3
输出: -1

说明:
你可以认为每种硬币的数量是无限的。

题目分析

理论分析

理论分析是重复取所有coins,判断当前数额n可以找零取钱最少
dp(n) = min{ dp(n - coins[i]) + 1} , i = 0, 1, 2,…, m.

编程实现分析

每一次取值与已经存在的最小F(n)比较,选择最小的,因为我们只要知道最小的,中间的数据不必保存
dp(n) = min{ dp(n), dp(n - conins[i]) + 1}, i = 0, 1, 2,…, m.

问题剖析

由于本问题是一个单个币种可以重复选择的问题,所以这就意味着每取一个元素,后面可以取的情况与前一次是独立的,没有关系,独立重复处理。而对于给定字符串的组合问题,则是取了元素之后,能取的元素就少了一个。字符串组合和给定全部一个的币种的问题是一样的。

当前问题与上一次遇到的情况是一样的,如果把每一次取元素当作当前问题,下一次取值就是子问题,这种分析问题的方式比较容易理解

代码实现

public int coinChange(int[] coins, int amount){
     
  if(coins.length < 1 || amount < 1) return -1;
  int[] dp = new int[amount + 1];
  for(int i = 1; i <= amount; ++i){
     
    dp[i] = amount;
    for(int j = 0; j < coins.length; ++j){
     
      if(coins[j] <= i){
     
        dp[i] = Math.min(dp[i], dp[i - coins[j]] + 1);
      }
    }
  }
  return dp[amount] > amount ? -1:dp[amount];
}

2. 币种无限个数的硬币找零组合

给你不同面值的硬币数组coins和总金额amount。 编写一个函数来计算组成该amount的组合的数量。每种硬币的个数是无限的。

注意:假设

  • 0 <= amount <= 5000
  • 1 <= coin <= 5000
  • the number of coins is less than 500
  • the answer is guaranteed to fit into signed 32-bit integer

例 1:
Input: amount = 5, coins = [1, 2, 5]
Output: 4
有四种组合方式:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

例2:
Input: amount = 3, coins = [2]
Output: 0
Explanation: the amount of 3 cannot be made up just with coins of 2.

例 3:
Input: amount = 10, coins = [10]
Output: 1

代码分析

求种类数,多少种方法,多少条路径等这类问题
只要开头走到结尾就算1种(1次),所以共有多少种方法,就是看分叉的次数,分叉次数就是总和。

代码实现

public class Solution {
       
    public int change(int amount, int[] coins) {
       
        int[] dp=new int[amount+1];  
        dp[0]=1;  
        for(int i=0;i<coins.length;i++){
       
            for(int j=0;j<amount+1;j++){
       
                if(j-coins[i]>=0){
       
                    dp[j]+=dp[j-coins[i]];  
                }  
            }  
        }  
        return dp[amount];  
    }  
}  

3. 回文子串问题

3.1 Palindromic Substrings

题目描述

给定一个字符串,你的任务是计算这个字符串中有多少个回文子串。

具有不同起始索引或结束索引的子字符串即使由相同的字符组成,也会被计为不同的子字符串。
例1:

输入: “abc”
输出: 3

说明:三个回文串:“a”,“b”,“c”。
例2:

输入: “aaa”
输出: 6

说明:六个回文串:“a”,“a”,“a”,“aa”,“aa”,“aaa”。

代码实现

public int countSubstrings(String s) {
     
    int n = s.length();
    int res = 0;
    boolean[][] dp = new boolean[n][n];
    for (int i = n - 1; i >= 0; i--) {
     
        for (int j = i; j < n; j++) {
     
            dp[i][j] = s.charAt(i) == s.charAt(j) && (j - i < 3 || dp[i + 1][j - 1]);
            if(dp[i][j]) ++res;
        }
    }
    return res;
}

复杂度更好的算法

3.2 Longest Palindromic Substring

问题描述

给定一个字符串s,找到s中的最长回文子串。假设s的最大长度是1000。
例1:

输入: “babad” 
输出: “bab” 
注意: “aba”也是一个有效的答案。

例2:

输入: “cbbd” 
输出: “bb”

思路分析:

算法基础(Java)--动态规划_第1张图片

编程实现过程
输入:BCDFDECB

算法基础(Java)--动态规划_第2张图片

输出:DFD

代码实现

public String longestPalindrome(String s) {
     
  int n = s.length();
  String res = null;

  boolean[][] dp = new boolean[n][n];
  //依次从最后面进行迭代,前一轮迭代为可能的回文的第一个字符,然后依次进行比对是否与第一个字符相等,如果不等则直接为False,然后进行后续比对,如果找到相同的字符,则比对左斜下的子字符的回文信息,由于i+1,j-1,所以开始比对的是第i-1和第j-1字符是否相等,依次向里面靠拢,直到相遇。
  for (int i = n - 1; i >= 0; i--) {
     
    for (int j = i; j < n; j++) {
       //dp[i+1][j-1]是一个左斜下的小回文
      dp[i][j] = s.charAt(i) == s.charAt(j) && (j - i < 3 || dp[i + 1][j - 1]);// j-i<3是在只有三个字符或四个字符为回文时的快速判断,不需要获取左斜下对角的值

      if (dp[i][j] && (res == null || j - i + 1 > res.length())) {
      //找出比之前更长的回文,则更新字符串
        res = s.substring(i, j + 1);
      }
    }
  }

  return res;
}

复杂度:

Time complexity : O(n^2)
Space complexity : O(n^2)

复杂度更好的算法2

4. 背包问题

背包问题的特点:

给定了一个固定的容量结果数,计算最优解并且限制条件是这个固定容量

Max f(x)
s.t.  w <= W

问题描述:

有N件物品,每件物品的体积为W1,W2……Wn(Wi为整数),与之相对应的价值为P1,P2……Pn(Pi为整数),现在从中取出若干件物品放入容量为W的背包里。求背包能够装下的最大价值。

题目分析:

实现固定体积装下最大价值的方法:
1.从物品索引开始,依次选择取本物品或不取物品。
2.对待第一个是这样,对待第二物品也是选择或不选择
3.选择或不选择,还有一个判断条件,就是选择的本物品的体积不能大于当前背包的剩余的空间

所以,本质上也是一个组合问题!从n个物品中选择m(m的取值从0至n)个,使m个物品的价值之和最大,这个最大就是最优问题。

上述分析的编程结果分析:

依次分析结果,W1,W1W2,W2,W1W2W3,W2W3,…,Wn。
但是在中间由于增加了一个最优解,所以利用O(1)的空间复杂度就可以保存之前遍历的组合的最大价值。

代码实现

import java.util.Scanner;
public class Main {
     
    public static void main(String[] args) {
     
        Scanner in = new Scanner(System.in);
        int n = in.nextInt();
        int v = in.nextInt();
        int[] dp = new int[v + 1];
        int[] price = new int[n + 1];
        int[] weight = new int[n + 1];
        long max = 0;
        for (int i = 1; i < n + 1; i++) {
     
            weight[i] = in.nextInt();
            price[i] = in.nextInt();
        }
        for (int i = 1; i < n + 1; i++)
            for (int j = v; j > 0; j--)
                if (j - weight[i] >= 0)
                    dp[j] = Math.max(dp[j], dp[j - weight[i]] + price[i]);
                else
                    dp[j] = dp[j];
        for (int i = 0; i < v + 1; i++)
            max = max > dp[i] ? max : dp[i];
        System.out.println(max);
    }
}

5. Integer Break

本问题与背包问题也是一样的,分裂固定的总数,使相乘结果最优。

题目描述:

给定一个正整数n,将其分解为至少两个正整数的和,并使这些整数的乘积最大化。返回您可以获得的最大产品。
例如,给定n = 2,返回1(2 = 1 + 1); 给定n = 10,返回36(10 = 3 + 3 + 4)。
注意:你可以假设n不小于2且不大于58。

题目分析:

数学的分析1:大于4的自然数,每次乘以3便可以,小于4的数枚举便可。
例如:
dp[8] = dp[3] * dp[3] * dp[2]
dp[11] = dp[3] * dp[8]
dp[4] = dp[2] * dp[2]

所以取值问题是尽量让被分的数离3接近,如果dp[2] * dp[2] > dp[3] * dp[1]

代码实现:

public int integerBreak(int n) {
     
       int[] dp = new int[n + 1];
       dp[1] = 1;
       for(int i = 2; i <= n; i ++) {
     
           for(int j = 1; j < i; j ++) {
     
               dp[i] = Math.max(dp[i], (Math.max(j,dp[j])) * (Math.max(i - j, dp[i - j])));
           }
       }
       return dp[n];
    }

6. Perfect Squares

问题描述:

给定一个正整数n,找到与1, 4, 9, 16, …相加的和为n的最小完美平方数。

例如,给定n = 12,返回3,因为12 = 4 + 4 + 4; 给n = 13,返回2,因为13 = 4 + 9

问题分析:

这实际上是一个背包问题的改进,每次减去的是一个数的平方(j^2),然后计算最小的减法操作次数

背包问题与零钱找零问题也是类似的,所以统称为“背包问题

代码实现:

public int numSquares(int n) {
     
    int[] dp = new int[n + 1];
    Arrays.fill(dp, Integer.MAX_VALUE);
    dp[0] = 0;
    for(int i = 1; i <= n; ++i) {
     
        int min = Integer.MAX_VALUE;
        int j = 1;
        while(i - j*j >= 0) {
     
            min = Math.min(min, dp[i - j*j] + 1);
            ++j;
        }
        dp[i] = min;
    }       
    return dp[n];
}

7. 字符串的距离和编辑问题

问题描述:

对于序列S和T,它们之间距离定义为:
对二者其一进行几次以下的操作
(1)删去一个字符;
(2)插入一个字符;
(3)改变一个字符。
每进行上面任意一次操作,计数增加1。
将S和T变为同一个字符串的最小计数即为它们的距离(最优问题)

问题的递推思路分析:

说明:dp[i][j] 表示截取字符S和T在第i和第j个字符之前的字符进行比对,这个相对于拿整个字符来处理,是子问题。

  • 1.从两个字符串尾字符开始建立索引并向前走,如果两个字符相等则dp[i][j] = dp[i-1][j-1]

  • 2.如果两个字符不等,则从三种选择中选择一种操作进行本次更改(以下b和c中有多个重复的情况):
    (a)修改S或T的这个字符,让其等于另外一个字符,相等后就表示两个字符相等了,也就是第一种情况,但操作了一回,所以为dp[i][j] = dp[i-1][j-1] + 1
    (b)删除S或T的这个字符,然后进行下一次比较,此时这两个字符还是不等于,也就回到了下一次比较的情况,同时比较删除S中这个不等的字符得到的结果与删除T中的这个字符得到的结果,取小的,如dp[i][j] = min{ dp[i-1][j] + 1, dp[i][j-1] + 1 }
    ©在S或T中插入一个字符让这两个字符相等,同时比较插入到S后的结果与插入到T中的结果,取小的,如dp[i][j] = min{ dp[i][j-1] + 1, dp[i-1][j] + 1 }

  • 3.第二步的三种不相等情况综合结果,再取最小值就是本次处理不相等的情况,dp[i][j] = min{ dp[i-1][j-1] + 1, dp[i-1][j] + 1, dp[i][j-1] + 1 }

代码实现:

利用for循环把递归思路转化为非递归思路,重点理解for循环迭代实现了递归中的思路,注意for循环中的全部步骤为什么可以覆盖这个问题的全部情况

    public static int similarityString(String s, String t) {
       
        if(sLen == 0)  return tLen;  
        if(tLen == 0) return sLen;  
        int sLen = s.length();  
        int tLen = t.length();  
        int i,j;    
        char ch1,ch2;   
        int cost;  
        int[][] dp = new int[sLen + 1][tLen + 1];  

        //下面两个是边界条件
        for(i = 0; i <= sLen; i++)  dp[i][0] = i; //这里是有意义的,就是当一个字符串长度为0,这就意味着另外一个字符串必须全部删除
        for(i = 0; i <= tLen; i++)  dp[0][i] = i ; 

        for(i = 1; i < = sLen; i++) {
       //第一个for循环表示第一个字符串取其前i个字符
            ch1 = s.charAt(i - 1);  
            for(j = 1; j <= tLen; j++) {
       // 第二个for循环表示第二个字符串取其前j个字符
                ch2 = t.charAt(j - 1);  
                if(ch1 == ch2) cost = 0;  
                else  cost = 1;
                dp[i][j] = Math.min(Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1),dp[i - 1][j - 1] + cost);  
            }  
        }  
        return dp[sLen][tLen];  
    }  

8. 最长公共子序列(Longest Common Subsequence,LCS)

问题描述:

对于序列S和T,求它们的最长公共子序列的长度。例如X={A,B,C,B,D,A,B},Y={B,D,C,A,B,A}则它们的lcs是{B,C,B,A}和{B,D,A,B},所以结果为4.

题目分析:

dp[i][j] 表示取字符串S的第i个字符之前的序列和T的第j个字符之前的序列进行比对,求其最长子序列的长度。
1.从尾部字符开始取起,如果S和T字符串的字符相等,则dp[i][j] = dp[i-1][j-1] + 1.
2.如果S和T字符串的字符不相等,则比较以下两种情况,取其中的最大值
(a)留下S的字符,去掉T的字符,利用S当前字符与T的第j-1字符进行比较,dp[i][j] = dp[i][j-1]
(b)去掉S的字符,留下T的字符,利用S的前一个字符i-1字符与T的当前j字符进行比较,dp[i][j] = dp[i-1][j]
3.每进行一次取元素的时候,遇到的问题又与上一次遇到的情况是一样的,所以递归就可以实现。

代码实现:

  public static int compute(char[] str1, char[] str2){
     
        int substringLength1 = str1.length;
        int substringLength2 = str2.length;

        // 构造二维数组记录子问题A[i]和B[j]的LCS的长度
        int[][] dp = new int[substringLength1 + 1][substringLength2 + 1];

        // 从后向前,动态规划计算所有子问题。也可从前到后。
        for (int i = substringLength1 - 1; i >= 0; i--){
     
            for (int j = substringLength2 - 1; j >= 0; j--){
     
                if (str1[i] == str2[j])
                    dp[i][j] = dp[i + 1][j + 1] + 1;// 状态转移方程
                else
                    //索引加的不同,表示参考基准不同
                    dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);// 状态转移方程
            }
        }
        System.out.println("substring1:" + new String(str1));
        System.out.println("substring2:" + new String(str2));
        System.out.print("LCS:");

        int i = 0, j = 0;
        while (i < substringLength1 && j < substringLength2){
     
            if (str1[i] == str2[j]){
     
                System.out.print(str1[i]);  //逐个输出最长公共子串
                i++;  j++;
            }
            else if (dp[i + 1][j] >= dp[i][j + 1])  i++;
            else  j++;
        }
        System.out.println();
        return dp[0][0];  //最长公共子串的长度
    }

9. Maximal Square

题目描述:

给定一个只包含0和1的矩阵,找到只包含1的最大方阵并返回其面积。

例如,给出以下矩阵:

1 0 1 0 0
1 0 1 1 1
1 1 1 1 1
1 0 0 1 0
返回4。

题目分析:

初始化另一个矩阵(dp),其尺寸与初始化为0的所有矩阵相同。

dp(i,j)表示右下角是原始矩阵中索引为(i,j)的单元格的最大方格的边长。

从索引(0,0)开始,对于在原始矩阵中找到的每个1元素,我们将当前元素的值更新为

代码实现:

public class Solution {
     
    public int maximalSquare(char[][] matrix) {
     
        int rows = matrix.length, cols = rows > 0 ? matrix[0].length : 0;
        int[][] dp = new int[rows + 1][cols + 1];
        int maxsqlen = 0;
        for (int i = 1; i <= rows; i++) {
     
            for (int j = 1; j <= cols; j++) {
     
                if (matrix[i-1][j-1] == '1'){
     
                    dp[i][j] = Math.min(Math.min(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1;
                    maxsqlen = Math.max(maxsqlen, dp[i][j]);
                }
            }
        }
        return maxsqlen * maxsqlen;
    }
}

10.House Robber

问题描述:

假如你是一名专业的强盗,计划抢劫沿街的房屋。每间房屋都藏有一定数量的金钱,但是同一晚上有两间相邻的房屋被闯入,它将自动触发警报。

输入一个代表每个房屋的金额的非负整数列表,在没有触发警报的情况下,输出你抢劫的最高金额。

代码实现:

class Solution {
     
    public int rob(int[] nums){
     
    if(nums.length == 0) return 0;
    int n = nums.length;
    int[] dp = new int[n];
    dp[0] = nums[0];
    dp[1] = Math.max(dp[0], nums[1]);
    if(nums.length < 2) return dp[nums.length -1];
    for(int i = 2; i < n; i++){
     
      dp[i] = Math.max(dp[i-2] + nums[i], dp[i-1]);
    }
    return dp[n];
  }
}

6. 小结&参考资料

小结

动态规划是笔试常考题型,也是必须掌握的类型,还需要深入了解或学习的话,参考这篇博客:非常好的动态规划总结,DP总结

参考资料

  • java 动态规划策略原理及例题
  • 动态规划算法入门—java版
  • 动态规划问题(Java)
  • [LeetCode] 322. Coin Change 硬币找零
  • 非常好的动态规划总结,DP总结

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