动态规划算法的理解

        动态规划(dynamic programming)与分治方法类似,都是通过组合子问题的解来求解原问题。但是动态规划算法对每个子子问题只求解一次,将其保存在一个表格中,从而无需每次求解一个子子问题时都重复计算,避免来这种不必要对对计算工作。

       通常按照一下四个步骤来设计一个动态规划算法:

  1. 刻画一个最优对结构特征。
  2. 递归地定义最优解对值。
  3. 计算最优解的值,通常采用自底向上的方法。
  4. 利用计算出的信息构造一个最优解。

      动态规划算法通常基于一个递推公式及一个或多个初始状态。当前子问题的解将由上一次子问题的解推出。首先,需要先找到某个状态的最优解,然后在它的帮助下,找到下一状态的最优解,“状态”用来描述该问题的子问题的解。“状态”并不是随便给的,在大多数情况下,某个状态只与它前面出现的状态有关,而独立于后面的状态。然后分析状态转移方程以及初始状态。

        动态规划的本质就是递归,这样比较容易判断一个问题是否可以用动态规划算法去求解,因为只要思考问题的规模缩小/扩大之后是不是同样的方法求解即可。

     适用应用动态规划算法求解的最优问题应该具备两个要素:

  • 最优子结构
  • 子问题重叠

如果一个问题的最优解包含其子问题的最优解,则称此问题具有最优解结构的性质。

重叠子问题其实就是子问题空间必须足够“小”,即问题的递归算法会反复的求解相同的子问题,而不是一直生成新的子问题。

     下面,我们来看一个动态规划经典的问题:求最长递增序列长度LIS(longest increasing subsequence)

设有由n个不相同的整数组成的数列,记为:a(1)、a(2)、……、a(n)且a(i)<>a(j) (i<>j)

若存在i1长度为e的不下降序列

      问题:一个序列有n个数:a[1],a[2],…,a[N],求出最长非降子序列的长度

首先,我们先理一下思路:

1)我们最终目标是要求n个数的序列中最长非降子序列的长度,如果能先求出a[1],a[2],a[i](i

2)为了简化理解如何找到状态转移方程的,我们可以让i=1,2,3,4,5。如果我们要求的这n个数的序列是:

18,7,14,20,18,23

根据前面的状态,我们可以进行推断:

(1)i=1时,序列:18,LIS长度d(1)=1;

(2)i=2时,序列:7,此时7前面没有比7小的了,所以LIS长度d(2)=1;

(3)i=3时,序列:7,14,此时14前面有7比它小,所以LIS长度d(3)=2也可以理解为d(3)=d(2)+1;

(4)i=4时,序列:7,14,20,此时20前面7,14比它小,有LIS长度d(4)=3,即d(4)=d(3)+1;

分析到这里,我们基本上可以找到状态转移的方程了,如果我们已经求出了d(1)到d(i-1),那么d(i)可以用下面的状态转移方程得到:

d(i) = max{1, d(j) + 1},其中要满足条件:j < i && a[j] <= a[i]

程序如下:

  public static void getLongest(int[] arr, int n){
    //存放状态
    int[] d = new int[n];
    //最长非降子序列长度初始化为1
    int length = 1;
    //状态初始化为1
    d[0] = 1;
    for (int i = 1; i < n; i++){
      //状态初始化为1
      d[i] = 1;
      for (int j = 0; j < i; j++){
        if (arr[i] > arr[j] && d[j] + 1 > d[i]){
          d[i] = d[j] + 1;
        }
        if (length < d[i]){
          length = d[i];
        }
      }
    }

    System.out.println(length);

  }

       上面line11-line18即内层for循环其实就是我们在求a[1],a[2],a[i](i

       上面的时间复杂度为n^2,下面我们通过二分法将时间复杂度降为nlogn,代码如下:

public class Test {

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        System.out.println("请输入序列:");
        String[] strLines = scanner.nextLine().trim().split(" ");
        int n = strLines.length;
        int[] nums = new int[n + 1];
        int[] f = new int[n + 1];
        for (int i = 1; i <= n; i++){
            nums[i] = Integer.parseInt(strLines[i-1]);
            f[i] = Integer.MAX_VALUE;
        }
        scanner.close();
        f[1] = nums[1];
        //通过记录f数组的有效位数,求得个数
        int len = 1;
        for (int i = 2; i <= n; i++){
            int l = 0;
            int r = len;
            int mid;
            //若大于末尾,则向后填充
            if (nums[i] > f[len]){
                f[len++] = nums[i];
            } else {
                //二分法查找将时间复杂度降为nlogn
                while (l < r){
                    mid = l + (r-l)/2;
                    if (f[mid] > nums[i]){
                        r = mid;
                    } else {
                        l = mid + 1;
                    }
                }
                f[l] = Math.min(nums[i], f[l]);
            }
        }
        System.out.println(len);
    }
}

在这我们在继续求解另外一个关于序列的问题,两个序列中的最长公共子序列( LCS )

譬如给定2个序列:

1 2 3 4 5

3 2 1 4 5

试求出最长的公共子序列。

 显然长度是 3 ,包含 3  4  5三个元素(不唯一)

解析:

       可以用 dp[i][j] 来表示第一个串的前 i 位,第二个串的前j位的 LCS的长度,那么我们是很容易想到状态转移方程的:

如果当前的 nums1[i] 和 nums2[j] 相同(即是有新的公共元素) 那么dp[ i ] [ j ] = max(dp[ i ] [ j ], dp[ i-1 ] [ j-1 ] + 1);

如果不相同,即无法更新公共元素,考虑继承:dp[i][j]=max(dp[i−1][j],dp[i][j−1])

代码如下:

public class Test {

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        System.out.println("请输入序列1:");
        String[] str1 = scanner.nextLine().trim().split(" ");
        int n = str1.length;
        int[] nums1 = new int[n+1];
        for (int i = 1; i <= n; i++){
            nums1[i] = Integer.parseInt(str1[i-1]);
        }
        System.out.println("请输入序列2:");
        String[] str2 = scanner.nextLine().trim().split(" ");
        int m = str2.length;
        int[] nums2 = new int[m+1];
        for (int i = 1; i <= m; i++){
            nums2[i] = Integer.parseInt(str2[i-1]);
        }
        scanner.close();
        //dp[i][j]表示两个字符串从头开始,直到第一个串的第i位和第二个串的第j位最多有多少个公共子元素
        int[][] dp = new int[n+1][m+1];

        for (int i = 1; i <= n; i++){
            for (int j = 1; j <= m; j++){
                dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
                if (nums1[i] == nums2[j]){
                    dp[i][j] = Math.max(dp[i][j], dp[i-1][j-1] + 1);
                }
            }
        }
        System.out.println(dp[n][m]);
    }
}

下面在看两道题目进行练习巩固。


一、首先,看一个导弹拦截的问题(http://codevs.cn/problem/1044/)

题目描述 Description

      某国为了防御敌国的导弹袭击,发展出一种导弹拦截系统。但是这种导弹拦截系统有一个缺陷:虽然它的第一发炮弹能够到达任意的高度,但是以后每一发炮弹都不能高于前一发的高度。某天,雷达捕捉到敌国的导弹来袭。由于该系统还在试用阶段,所以只有一套系统,因此有可能不能拦截所有的导弹。

输入描述 Input Description

输入导弹依次飞来的高度(雷达给出的高度数据是不大于30000的正整数)

输出描述 Output Description

输出这套系统最多能拦截多少导弹,如果要拦截所有导弹最少要配备多少套这种导弹拦截系统。

样例输入 Sample Input

389 207 155 300 299 170 158 65 

样例输出 Sample Output

6

2

数据范围及提示 Data Size & Hint

导弹的高度<=30000,导弹个数<=20

分析:本题其实就是求解最长降序序列长度(每套系统拦截的导弹数量)和最长升序序列长度(系统数量

代码如下:

public class Test {
    public static void main(String[] args) {
        Scanner scan = new Scanner(System.in);
        System.out.println("输入导弹依次飞来的高度(中间用空格间隔): ");
        String[] strs = scan.nextLine().split(" ");
        int[] high = new int[strs.length];
        for (int i = 0; i < strs.length; i++){
            high[i] = Integer.parseInt(strs[i]);
        }
        scan.close();
        //需要的系统数量
        int[] d1 = new int[high.length];
        //每套系统拦截的导弹数量
        int[] d2 = new int[high.length];
        int len1 = 1, len2 = 1;
        d1[0] = 1;
        d2[0] = 1;
        for (int i = 0; i < high.length; i++){
            d1[i] = 1;
            d2[i] = 1;
            for (int j = 0; j < i; j++){
                //导弹系统数量——最长升序序列长度
                if (high[i] > high[j] && d1[i] < d1[j] + 1){
                    d1[i] = d1[j] + 1;
                }
                if (len1 < d1[i]){
                    len1 = d1[i];
                }
                //每套系统拦截的导弹数量——最长降序序列长度
                if (high[i] <= high[j] && d2[i] < d2[j] + 1){
                    d2[i] = d2[j] + 1;
                }
                if (len2 < d2[i]){
                    len2 = d2[i];
                }
            }
        }
        System.out.println(len2);
        System.out.println(len1);
    }
}

二、再看一个数字划分问题(http://codevs.cn/problem/1039/)

题目描述 Description

将整数n分成k份,且每份不能为空,任意两种划分方案不能相同(不考虑顺序)。
例如:n=7,k=3,下面三种划分方案被认为是相同的。
1 1 5

1 5 1

5 1 1
问有多少种不同的分法。

输入描述 Input Description

输入:n,k (6

输出描述 Output Description

输出:一个整数,即不同的分法。

样例输入 Sample Input

 7 3

样例输出 Sample Output

4

数据范围及提示 Data Size & Hint

 {四种分法为:1,1,5;1,2,4;1,3,3;2,2,3;}

分析:

还是使用动态规划算法,将划分后的数字分成包含1和不包含1两种情况。

dp[i][j] = dp[i-1][j-1] + dp[i-j][j],

即:dp[i][j]表示把数字 i 划分为 j 部分;

       dp[i-1][j-1]:划分的j份中至少有一份为1,把这个1拿出来,剩下的相当于把i-1分为j-1份

       dp[i-j][j]:划分的j份中没有一份是1,每一份中都拿走1个1,剩下的相当于把i-j分为j份

       dp状态一般由前一个状态转移而来的。

代码如下:

public class DivideNum {
    public static void main(String[] args) {
        Scanner scan = new Scanner(System.in);
        System.out.println("请输入数字以及划分数目(中间空格隔开):");
        String[] strs = scan.nextLine().split(" ");
        scan.close();
        int n = Integer.parseInt(strs[0]);
        int k = Integer.parseInt(strs[1]);
        int[][] dp = new int[n+1][k+1];
        //初始化dp,先暂时默认成1部分
        for (int i = 0; i < n; i++){
            dp[i][1] = 1;
        }
        //初始化方案数目为1
        int size = 1;
        for (int i = 1; i <= n; i++){
            for (int j = 1; j <=k; j++){
                if (i >= j){
                    dp[i][j] = dp[i-1][j-1] + dp[i-j][j];
                }
                if (size < dp[i][j]){
                    size = dp[i][j];
                }
            }
        }
        System.out.println(size);
    }
}

另外,如果还不太理解的童鞋,可以在在继续看看下面两篇博文:

  • 算法-动态规划 Dynamic Programming--从菜鸟到老鸟
  • 动态规划算法
  • 动态规划的本质
  • 夜深人静写算法(二) - 动态规划
  • 进阶DP专题:序列区间DP与环形区间DP
  • 算法讲解之Dynamic Programing —— 区间DP [变形:环形DP]

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