算法-高级-动态规划

算法-高级-动态规划

说明:理论部分主要摘录百度百科和极客时间订阅的课程《数据结构与算法之美》(推荐),实战部分则是自己学习过程中解决的一些题目和解题思路记录。

1 概念(摘录自百度百科)

动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。

把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,是一种过程优化问题的方法。

动态规划程序设计是对解最优化问题的一种途径、一种方法,而不是一种特殊算法。 不像搜索或数值计算那样,具有一个标准的数学表达式和明确清晰的解题方法。

动态规划程序设计往往是针对一种最优化问题,由于各种问题的性质不同,确定最优解的条件也互不相同,因而动态规划的设计方法对不同的问题,有各具特色的解题方法,而不存在一种万能的动态规划算法,可以解决各类最优化问题。

在现实生活中,有一类活动的过程,由于它的特殊性,可将过程分成若干个互相联系的阶段,在它的每一阶段都需要作出决策,从而使整个过程达到最好的活动效果。

当然,各个阶段决策的选取不是任意确定的,它依赖于当前面临的状态,又影响以后的发展,当各个阶段决策确定后,就组成一个决策序列,因而也就确定了整个过程的一条活动路线(如下图)

这种把一个问题看作是一个前后关联具有链状结构的多阶段过程就称为多阶段决策过程,这种问题就称为多阶段决策问题。

多阶段决策问题

2 特点

优点:可以非常显著地降低时间复杂度,提高代码的执行效率。

缺点:主要学习难点跟递归类似,那就是,求解问题的过程不太符合人类常规的思维方式

3 贪心、分治、回溯、动态规划几种算法的比较

算法 时间复杂度 空间复杂度 适用场景
回溯 执行效率低,时间复杂度是指数级 小规模数据时,能用的动态规划、贪心解决的问题,我们都可以用回溯算法解决。相当于穷举搜索。时间复杂度非常高,是指数级别的,只能用来解决小规模数据的问题。
动态规划 执行效率高很多,但是动态规划的空间复杂度也提高了。是一种空间换时间的算法思想。 需要满足三个特征,最优子结构、无后效性和重复子问题。
贪心 执行效率更加高效 贪心算法实际上是动态规划算法的一种特殊情况。它解决问题起来更加高效,代码实现也更加简洁。不过,它可以解决的问题也更加有限。它能解决的问题需要满足三个条件,最优子结构、后效性和贪心选择性(这里我们不怎么强调重复子问题)。“贪心选择性”的意思是,通过局部最优的选择,能产生全局的最优选择。每一个阶段,我们都选择当前看起来最优的决策,所有阶段的决策完成之后,最终由这些局部最优解构成全局最优解。
分治 分治算法要求分割成的子问题,不能有重复子问题,而动态规划正好相反,动态规划之所以高效,就是因为回溯算法实现中存在大量的重复子问题。

贪心、分治、回溯、动态规划,这四个算法思想有关的理论知识,大部分都是“后验性”的,也就是说,在解决问题的过程中,我们往往是先想到如何用某个算法思想解决问题,然后才用算法理论知识,去验证这个算法思想解决问题的正确性。

贪心、回溯、动态规划可以归为一类,而分治单独可以作为一类,前三个算法解决问题的模型,都可以抽象成我们今天讲的那个多阶段决策最优解模型,而分治算法解决的问题尽管大部分也是最优解问题,但是,大部分都不能抽象成多阶段决策模型。

4 什么时候使用动态规划

4.1 适用场景:求解最优问题

动态规划比较适合用来求解最优问题,比如求最大值、最小值等。

例如,在编程中常用解决最长公共子序列问题、矩阵连乘问题、凸多边形最优三角剖分问题、电路布线等问题。

4.2 适用条件:要符合“一个模型三个特征”

1、模型:多阶段决策最优解模型

2、“三个特征”分别是最优子结构、无后效性和重复子问题

(1)最优子结构:后面阶段的状态可以通过前面阶段的状态推导出来。

(2)无后效性:有两层含义,第一层含义是,在推导后面阶段的状态的时候,我们只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的。第二层含义是,某阶段状态一旦确定,就不受之后阶段的决策影响。

(3)重复子问题:使用指数级时间复杂度的搜索算法时,不同的决策序列,到达某个相同的阶段时,可能会产生重复的状态。动态规划算法的根本目的在于解决计算的冗余。

动态规划实质上是一种以空间换时间的技术,它在实现的过程中,存储产生过程中的各种状态,从而减少冗余计算,提高执行效率,降低时间时间复杂度。但也因此,其空间复杂度要大于其它的算法。

5 动态规划解题的一般思路(摘录自极客时间)

5.1 状态转移表法

先画出一个状态表。状态表一般都是二维的,所以你可以把它想象成二维数组。

其中,每个状态包含三个变量,行、列、数组值。

我们根据决策的先后过程,从前往后,根据递推关系,分阶段填充状态表中的每个状态。

最后,我们将这个递推填表的过程,翻译成代码,就是动态规划代码了。

状态转移表法解题思路大致可以概括为,回溯算法实现 - 定义状态 - 画递归树 - 找重复子问题 - 画状态转移表 - 根据递推关系填表 -将填表过程翻译成代码。

5.2 状态转移方程法

状态转移方程法有点类似递归的解题思路。

我们需要分析,某个问题如何通过子问题来递归求解,也就是所谓的最优子结构。

根据最优子结构,写出递归公式,也就是所谓的状态转移方程。有了状态转移方程,代码实现就非常简单了。

一般情况下,我们有两种代码实现方法,一种是递归加“备忘录”,另一种是迭代递推。状态转移方程是解决动态规划的关键。

状态转移方程法的大致思路可以概括:找最优子结构 - 写状态转移方程 - 将状态转移方程翻译成代码。

6 案例实践+思路理论

以找零钱的问题问案例,阐述解决问题的思考历程。

6.1 题目(找零钱的问题为例)

假设你是一名超市收银员,现有n种不同面值的货币,每种面值的货币可以使用任意张。
顾客结账时,你需要找给顾客aim元零钱,你可以给出多少种方法。

例如,有1、2、3元三种面值的货币,你需要找零3元,那么共有3种方法:1张1元+1张2元、3张1元、1张3元。

6.2 解题思路

第一步,遇到问题,首先要理解题意,提取关键的信息,抽象问题。

本案例中,关键信息为:

货币面值:int[] penny = {1,2,3};

找零数:假设aim=8;

问题可以抽象为:使用数组{1,2,3}中的3个数,有多少个组合可以求和成aim=8?

第二步,接着,先画出一个状态表,寻找规律。状态表一般是一个二维数组。

其中,数组中的每一个格子表示一个状态,每个状态包含三个变量,行、列、数组值,每个变量可以表示题中实际含义。

如本案例中,设状态表数组为dp[i][j] = value,则dp中的
i指使用前i个数,
j表示当前需要找的零钱总数,
dp[i][j]的值表示使用前i个数组成之和为j时的组合数量。

这样,i不断增加,j也不断增加,从小问题开始慢慢地求解,当i和j增大都指定的值,此时得到的就是需要求的解。
本案例中即使用3种面值的货币,找零数为8,则i从1增加到3,j从0增加到8,此时得到的状态表数组,就是需要求的解。

状态表如下:

0 1 2 3 4 5 6 7 8
1 1 1 1 1 1 1 1 1 1
2 1 1 2 2 3 3 4 4 5
3 1 1 2 2 4 4 6 6 9

第三步,分析一下这个问题是否符合“一个模型和三个特征”。

一个模型:这个问题可以按照使用前几个数,分成多个阶段去解决,即阶段一为只使用{1},阶段为使用{1,2},阶段三为使用{1,2,3}。

最优子结构:然后每个阶段的每个节点的状态都可根据前一个阶段的节点或者本阶段前面的节点推导出来。即能够写出最优子结构。在本案例中,在第3个阶段的第6个节点(3,6)可以根据第三个阶段的第3个节点(3,3)和第二个阶段的第6个节点(2,6)推导出,即(3,6)的值 = (3,3)的值 + (2,6)的值。综合其它节点,用状态转移方程表示为:

if (j < penny[i]) {
    resultTable[i][j] = resultTable[i - 1][j];
} else {
    resultTable[i][j] = resultTable[i - 1][j] + resultTable[i][j - penny[i]];
}

无后效性:前面阶段的任一节点的值一旦确认,后面无论怎么改动都不会变化。

重复子问题:如果使用回溯算法,会产生很多重复计算的子问题。而动态规划能够避免再重复计算这些子问题。

第四步,将状态表的生成过程翻译成代码。这里就是具体的编码实现了。代码如下:

import java.util.Arrays;

/**
 * 假设你是一名超市收银员,现有n种不同面值的货币,每种面值的货币可以使用任意张。
 * 顾客结账时,你需要找给顾客aim元零钱,你可以给出多少种方法。
 * 例如,有1、2、3元三种面值的货币,你需要找零3元,那么共有3种方法:1张1元+1张2元、3张1元、1张3元。
 */
public class Main {

    public static void main(String[] args) {
        int[] penny = {1, 2, 4};
        int size = 3;
        int aim = 8;
        System.out.println("可组合的方法数量为:"+f(penny, size, aim));
    }

    /**
     * @param penny 每个值代表一种面值的货币
     * @param size  数组penny及它的大小
     * @param aim   要找的钱数
     * @return
     */
    public static int f(int[] penny, int size, int aim) throws RuntimeException {

        // 校验
        if (penny.length > 50 || size > 50) {
            throw new RuntimeException("penny的元素个数或者size不能大于50");
        }

        // 校验
        if (aim > 1000) {
            throw new RuntimeException("aim不能大于1000");
        }

        int[][] resultTable = new int[size][aim + 1];

        // 第一行特殊处理
        for (int i = 0; i < aim + 1; i++) {
            resultTable[0][i] = i % penny[0] == 0 ? 1 : 0;
        }
        
        // 动态规划
        for (int i = 1; i < penny.length; i++) {

            for (int j = 0; j <= aim; j++) {

                if (j < penny[i]) {
                    resultTable[i][j] = resultTable[i - 1][j];
                } else {
                    // 例如: dp[1][2] = dp[0][2] + dp[0][2-1*1]),
                    // 代表使用penny前2个元素(即1,2)组成2的方法数 = 不使用2组成2的方法数 + 使用1个2组成2的方法数。
                    resultTable[i][j] = resultTable[i - 1][j] + resultTable[i][j - penny[i]];
                }

            }
        }
        
        // 打印结果,非算法关键代码 start
        for (int[] a : resultTable) {
            System.out.println(Arrays.toString(a));
        }
        // 打印结果,非算法关键代码 end
        
        return resultTable[penny.length - 1][aim];
    }
}

测试案例结果:

[1, 1, 1, 1, 1, 1, 1, 1, 1]
[1, 1, 2, 2, 3, 3, 4, 4, 5]
[1, 1, 2, 2, 4, 4, 6, 6, 9]
可组合的方法数量为:9

6.3 复杂度分析

空间复杂度:需要使用数组大小为3*(8+1),抽象成字母为:n*w。
时间复杂度:O(n*w)。for循环嵌套,一共需要比较n*w次。

7 实战

7.1 揹包问题

题目

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

解题思路

三种方法:回溯、递归+“备忘录”、动态规划。这里主要介绍动态规划算法。

第一步,首先要理解题意,提取关键的信息,抽象问题,假设一些测试数据,将其转为数学问题。本案例中,关键信息为:

物品重量:int[] weight = {2,2,4,6,3} ;

揹包最大重量:int w = 9 ;

问题可以抽象为:使用数组{2,2,4,6,3}中的数,取其中某几个数,组合求和,在和不超过9的情况下,组合求和的最大值为多少?

第二步,接着,先画出一个状态表,寻找规律。状态表一般是一个二维数组。

0 1 2 3 4 5 6 7 8 9
1 true false true false false false false false false false
2 true false true false true false false false false false
3 true false true false true false true false true false
4 true false true false true false true false true false
5 true false true true true true true true true true
如本案例中,设状态表数组为dp[i][j] = boolean,则的dp中的
i指使用前i个数,
j表示使用前i个数时,能否能组合求和成的数。
dp[i][j]表示使用前i个数,能否能组合求和成j。如果可以,则为true,否则为false。

这样,i不断增加,j也增加,从小问题开始慢慢地求解,当i和j增大都指定的值,
本案例中即使用5个物品,揹包重量为9,则i从0增加到5,j从0增加到9,此时得到的状态表数组,就是需要求的解。

第三步,分析一下这个问题是否符合“一个模型和三个特征”。

一个模型:这个问题可以按照前几个物品,分成多个阶段去解决,即阶段一为只使用{2},阶段为使用{2,2},阶段三为使用{2,2,4},阶段四为使用{2,2,4,6},阶段五为使用{2,2,4,6,3}。

最优子结构:然后每个阶段的每个节点的状态都可根据前一个阶段的节点或者本阶段前面的节点推导出来。即能够写出最优子结构。本案例中,第2个阶段的第2个节点(2,2)可以根据第1个阶段的第2个节点(1,2)推导出,(2,2)的值 = (2,4)的值 = (1,2)的值。综合其它节点,用状态转移方程表示为:

// 不把第i个物品放入揹包
for (int j = 0; j <= w; j++) {
    if (states[i - 1][j] == true) {
        states[i][j] = states[i - 1][j];
    }
}
// 把第i个物品放入揹包
for (int j = 0; j <= w - weight[i]; j++) {
    if (states[i - 1][j] == true) {
        states[i][j + weight[i]] = true;
    }
}

无后效性:前面阶段的任一节点的值一旦确认,后面无论怎么改动都不会变化。

重复子问题:如果使用回溯算法,会产生很多重复计算的子问题。而动态规划能够避免再重复计算这些子问题。

第四步,将状态表的生成过程翻译成代码。这里就是具体的编码实现了。代码如下:

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

    public static void main(String[] args) {

        // 物品重量
        int[] weight = {2, 2, 4, 6, 3};
        // 物品个数
        int n = 5;
        // 揹包承受的最大重量
        int w = 9;
        System.out.println(knapsack(weight, n, w));

    }


    /**
     * @param weight 物品重量
     * @param n      物品个数
     * @param w      揹包可承载重量
     * @return
     */
    public static int knapsack(int[] weight, int n, int w) {

        /**
         * i  : 使用前i个物品
         * j  :使用前i个物品是否可装的数量
         * states[i][j]  = true   : 可以装
         * states[i][j]  = false  : 不可以装
         */
        // 默认值false
        boolean[][] states = new boolean[n][w + 1];

        // 第一行数据要特殊处理,可以利用哨兵优化
        states[0][0] = true;
        if (weight[0] <= w) {
            states[0][weight[0]] = true;
        }

        // 动态规划状态转移
        for (int i = 1; i < n; i++) {
            // 不把第i个物品放入揹包
            for (int j = 0; j <= w; j++) {
                if (states[i - 1][j] == true) {
                    states[i][j] = states[i - 1][j];
                }
            }
            // 把第i个物品放入揹包
            for (int j = 0; j <= w - weight[i]; j++) {
                if (states[i - 1][j] == true) {
                    states[i][j + weight[i]] = true;
                }
            }

        }

        print(n,w,states);


        // 从最后一行的最后一个开始找,第一个为true的为结果
        for (int i = w; i >= 0; i--) {
            if (states[n - 1][i] == true) {
                return i;
            }
        }


        return 0;
    }

    private static void print(int n,int w,boolean[][] states){
        for (int i = 0; i < n; i++) {
            for (int j = 0; j <= w; j++) {
                if (states[i][j]){
                    System.out.print("i=" + i + " " + "j=" + j + " " + states[i][j]+" ");
                }else {
                    System.out.print("i=" + i + " " + "j=" + j + " " + states[i][j]);
                }
                System.out.print(" | ");
            }
            System.out.println();
        }
    }

}

测试案例结果:

true | false | true | false | false | false | false | false | false | false | 
true | false | true | false | true | false | false | false | false | false | 
true | false | true | false | true | false | true | false | true | false | 
true | false | true | false | true | false | true | false | true | false | 
true | false | true | true | true | true | true | true | true | true | 
9

优化:上面的方法需要用到二维数组,可以只使用一维数组,减少内存使用,降低空间复杂度。
注意到,二位数组中,只有最有一行是有用的数据。故可以用一个一维数组,保存每个阶段的结果即可。

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

    public static void main(String[] args) {

        // 物品重量
        int[] weight = {2, 2, 4, 6, 3};
        // 物品个数
        int n = 5;
        // 揹包承受的最大重量
        int w = 9;
        System.out.println(knapsack(weight, n, w));

    }


    /**
     * @param weight 物品重量
     * @param n      物品个数
     * @param w      揹包可承载重量
     * @return
     */
    public static int knapsack(int[] weight, int n, int w) {

        /**
         * i  : 使用前i个物品
         * j  :使用前i个物品是否可装的数量
         * states[i][j]  = true   : 可以装
         * states[i][j]  = false  : 不可以装
         */
        // 默认值false
        boolean[] states = new boolean[w + 1];

        // 第一行数据要特殊处理,可以利用哨兵优化
        states[0] = true;
        if (weight[0] <= w) {
            states[weight[0]] = true;
        }

        print(w, states);
        // 动态规划状态转移
        for (int i = 1; i < n; i++) {
            // 把第i个物品放入揹包
            for (int j = w - weight[i]; j >= 0; j--) {
                if (states[j] == true) {
                    states[j + weight[i]] = true;
                    //System.out.println();
                }
            }
            print(w, states);
        }




        // 从最后一行的最后一个开始找,第一个为true的为结果
        for (int i = w; i >= 0; i--) {
            if (states[i] == true) {
                return i;
            }
        }


        return 0;
    }

    private static void print(int w, boolean[] states) {
        for (int j = 0; j <= w; j++) {
            if (states[j]) {
                System.out.print("j=" + j + " " + states[j] + " ");
            } else {
                System.out.print("j=" + j + " " + states[j]);
            }
            System.out.print(" | ");
        }
        System.out.println();
    }

}

测试案例结果:

true | false | true | false | false | false | false | false | false | false | 
true | false | true | false | true | false | false | false | false | false | 
true | false | true | false | true | false | true | false | true | false | 
true | false | true | false | true | false | true | false | true | false | 
true | false | true | true | true | true | true | true | true | true | 
9

7.2 揹包问题升级版

题目

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

解题思路

两种方法:回溯、动态规划。这里主要介绍动态规划算法。

第一步,首先要理解题意,提取关键的信息,抽象问题,假设一些测试数据,将其转为数学问题。本案例中,关键信息为:

物品重量:int[] weight = {2, 2, 4, 6, 3};

物品价值:int[] value = {3, 4, 8, 9, 6};

揹包最大重量:int w = 9 ;

问题可以抽象为:使用数组{2,2,4,6,3}中的数,取其中某几个数,组合求和即重量不超过9,其对应的价值之和的最大值为多少?

第二步,接着,先画出一个状态表,寻找规律。状态表一般是一个二维数组。

0 1 2 3 4 5 6 7 8 9
0 0 0 3 0 0 0 0 0 0 0
1 0 0 4 4 7 4 4 4 4 4
2 0 0 4 4 8 8 12 12 15 12
3 0 0 4 4 8 8 12 12 15 13
4 0 0 4 6 8 10 12 14 15 18

第三步,分析一下这个问题是否符合“一个模型和三个特征”。

第四步,将状态表的生成过程翻译成代码。这里就是具体的编码实现了。代码如下:

public class Main2 {

    public static void main(String[] args) {

        int[] weight = {2, 2, 4, 6, 3};
        int[] value = {3, 4, 8, 9, 6};
        int n = 5;
        int w = 9;

        System.out.println(knapsack3(weight, value, n, w));
    }

    public static int knapsack3(int[] weight, int[] value, int n, int w) {
        int[][] states = new int[n][w + 1];
        // 初始化states
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < w + 1; j++) {
                states[i][j] = 0;
            }
        }

        states[0][0] = 0;
        if (weight[0] <= w) {
            states[0][weight[0]] = value[0];
        }

        // 动态规划状态转移
        for (int i = 1; i < n; i++) {
            // 不选择第i个物品
            for (int j = 0; j <= w; j++) {
                if (states[i - 1][j] >= 0) {
                    states[i][j] = states[i - 1][j];
                }
            }
            // 选择第i个物品
            for (int j = 0; j <= w - weight[i]; j++) {
                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];
            }
        }

        print(n, w, states);

        return maxvalue;
    }

    private static void print(int n, int w, int[][] states) {
        for (int i = 0; i < n; i++) {
            for (int j = 0; j <= w; j++) {
                //System.out.print("i=" + i + " " + "j=" + j + " " + states[i][j]);
                System.out.print(states[i][j]);
                System.out.print(" | ");
            }
            System.out.println();
        }
    }

}

测试案例结果:

0 | 0 | 3 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 
0 | 0 | 4 | 4 | 7 | 4 | 4 | 4 | 4 | 4 | 
0 | 0 | 4 | 4 | 8 | 8 | 12 | 12 | 15 | 12 | 
0 | 0 | 4 | 4 | 8 | 8 | 12 | 12 | 15 | 13 | 
0 | 0 | 4 | 6 | 8 | 10 | 12 | 14 | 15 | 18 | 
18

7.3 国王和金矿

题目

有一个国家发现了5座金矿,每座金矿的黄金储量不同,需要参与挖掘的工人数也不同,具体见图。参与挖矿工人的总数是10人。每座金矿要么全挖,要么不挖,不能派出一半人挖取一半金矿,每个人也只会最多挖一次矿。要求用程序求解出,要想得到尽可能多的黄金,应该选择挖取哪几座金矿?

金矿列表:
500金/5人
400金/5人
350金/3人
300金/4人
200金/3人

解题思路

第一步,首先要理解题意,提取关键的信息,抽象问题,假设一些测试数据,将其转为数学问题。本案例中,关键信息为:

问题可以抽象为:

第二步,接着,先画出一个状态表,寻找规律。状态表一般是一个二维数组。

第三步,分析一下这个问题是否符合“一个模型和三个特征”。

第四步,将状态表的生成过程翻译成代码。这里就是具体的编码实现了。代码如下:

import java.util.Arrays;

public class Main {

	/**
	 * @param worker 工人数量,如:10
	 * @param gold 每个金矿开采所需的工人数量,如:{ 500, 400, 350, 300, 200 }
	 * @param p 每个金矿储量,如:{ 5, 5, 3, 4, 3 }
	 * @return
	 */
	public static int getBestGoldMiningV2(int worker, int[] gold, int[] p) {
		// 校验入参
		//
		if (worker == 0 || gold.length == 0) {
			return 0;
		}
        // 前i个金矿,j个人时的最大收益
		int[][] reslutTable = new int[gold.length + 1][worker + 1];
		// 前i个金矿
		for (int i = 1; i <= gold.length; i++) {
			// 有j个工人时
			for (int j = 1; j <= worker; j++) {
				if (j < p[i - 1]) {
					// 如果当前工人数量<当前金矿开采所需的工人数量,则当前最大收益为上一个金矿同样工人数量的收益
					reslutTable[i][j] = reslutTable[i - 1][j];
				} else {
					// 如果当前工人数量>=当前金矿开采所需的工人数量,则当前最大收益为以下两者的最大值:
					// (1)上一个金矿同样工人数量的收益 
					// (2)上一个金矿,减去本矿工人数量收益 + 当前金矿的收益, 
					reslutTable[i][j] = Math.max(reslutTable[i - 1][j],
							reslutTable[i - 1][j - p[i-1]] + gold[i-1]);
				}
			}
		}

		// 打印结果集
		for(int[] a : reslutTable){
			System.out.println(Arrays.toString(a));
		}
		
        // 返回所有金矿,所有工人的时的收益
		return reslutTable[gold.length][worker];
	}
	
	public static int getBestGoldMiningV3(int w,int[] gold,int[] p){
		
		if(w == 0 || gold.length == 0){
			return 0 ;
		}
		// 存储最后一行的结果集
		int[] results = new int[w+1];
		
		// 前i个金矿
		for(int i = 1;i<gold.length;i++){
			// 有j个工人时
			for(int j = w;j>0;j--){
				if(j>=p[i-1]){
					results[j] = Math.max(results[j], results[j-p[i-1]]+gold[i-1]); 
				}
			}
		}
		// 打印结果集
		System.out.println(Arrays.toString(results));
		return results[w];
	}

	public static void main(String[] args) {

		int worker = 10;
		int[] gold = { 500, 400, 350, 300, 200 };
		int[] p = { 5, 5, 3, 4, 3 };

		int result = getBestGoldMiningV2(worker, gold, p);
		System.out.println("最大收益:" + result);

		// 优化方法,使用一位数组存储结果集
		int result3 = getBestGoldMiningV3(worker, gold, p);
		System.out.println("最大收益:" + result3);
	}
}

输出结果:

[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 0, 500, 500, 500, 500, 500, 500]
[0, 0, 0, 0, 0, 500, 500, 500, 500, 500, 900]
[0, 0, 0, 350, 350, 500, 500, 500, 850, 850, 900]
[0, 0, 0, 350, 350, 500, 500, 650, 850, 850, 900]
[0, 0, 0, 350, 350, 500, 550, 650, 850, 850, 900]
最大收益:900
[0, 0, 0, 350, 350, 500, 500, 650, 850, 850, 900]
最大收益:900

7.4 矩阵最短路径

题目

矩阵存储的都是正整数。棋子起始位置在左上角,终止位置在右下角。我们将棋子从左上角移动到右下角。每次只能向右或者向下移动一位。从左上角到右下角,会有很多不同的路径可以走。我们把每条路径经过的数字加起来看作路径的长度。那从左上角移动到右下角的最短路径长度是多少呢?

解题思路

第一步,首先要理解题意,提取关键的信息,抽象问题,假设一些测试数据,将其转为数学问题。本案例中,关键信息为:

问题可以抽象为:

第二步,接着,先画出一个状态表,寻找规律。状态表一般是一个二维数组。

第三步,分析一下这个问题是否符合“一个模型和三个特征”。

if (i==1){
    resultTable[i][j] = resultTable[i][j-1]  +  data[i-1][j-1];
}

if (j==1){
    resultTable[i][j] = resultTable[i-1][j] + data[i-1][j-1];
}

if (i > 1 && j>1){
    resultTable[i][j] = Math.min(resultTable[i-1][j]+data[i-1][j-1],
                                 resultTable[i][j-1]+data[i-1][j-1]);
}

第四步,将状态表的生成过程翻译成代码。这里就是具体的编码实现了。代码如下:

public class Main {

    public static void main(String[] args){
        int[][] data = {{1,2,3},{1,1,1},{2,4,2}};
        int n = 3 ;
        int m = 3 ;
        System.out.println(getMinPath(data,n,m));
    }


    public static int getMinPath(int[][] data,int n,int m){

        if (data == null || data.length == 0){
            return 0 ;
        }
        if (n == 0 || m == 0){
            return 0 ;
        }

        int[][] resultTable = new int[n+1][m+1];

        for (int i = 1;i<=n;i++){
            for (int j = 1;j<=m;j++){

                if (i==1){
                    resultTable[i][j] = resultTable[i][j-1]  +  data[i-1][j-1];
                }
                if (j==1){
                    resultTable[i][j] = resultTable[i-1][j] + data[i-1][j-1];
                }

                if (i > 1 && j>1){
                    resultTable[i][j] = Math.min(resultTable[i-1][j]+data[i-1][j-1],
                            resultTable[i][j-1]+data[i-1][j-1]);
                }
            }
        }
        for (int[] a : resultTable){
            System.out.println(Arrays.toString(a));
        }
        return resultTable[n][m];
    }

}

测试案例结果:

[0, 0, 0, 0]
[0, 1, 3, 6]
[0, 2, 3, 4]
[0, 4, 7, 6]
6

类似题目

走方格问题
 * 有一个矩阵map,它每个格子有一个权值。从左上角的格子开始每次只能向右或者向下走,
 * 最后到达右下角的位置,路径上所有的数字累加起来就是路径和,返回所有的路径中最小的路径和。
 * 给定一个矩阵map及它的行数n和列数m,请返回最小路径和。保证行列数均小于等于100.
 * 测试样例:
 * [[1,2,3],[1,1,1]],2,3
 * 返回:4

7.5 斐波那契数列

题目

斐波那契数列:1、1、2、3、5、8、13、21、34、……
递推的方法定义:F(1)=1,F(2)=1, F(n)=F(n-1)+F(n-2)(n>=3,n∈N*)

解题思路

第一步,首先要理解题意,提取关键的信息,抽象问题,假设一些测试数据,将其转为数学问题。本案例中,关键信息为:

问题可以抽象为:

第二步,接着,先画出一个状态表,寻找规律。状态表一般是一个二维数组。

第三步,分析一下这个问题是否符合“一个模型和三个特征”。

第四步,将状态表的生成过程翻译成代码。这里就是具体的编码实现了。代码如下:

/**
 * 斐波那契数列:1、1、2、3、5、8、13、21、34、……
 * 递推的方法定义:F(1)=1,F(2)=1, F(n)=F(n-1)+F(n-2)(n>=3,n∈N*)
 * 黄金分割数列、兔子数列
 */
public class Main {

    public static void main(String[] args) throws Exception{

        //System.out.println(f2(-1));
        System.out.println(f2(0));
        System.out.println(f2(1));
        System.out.println(f2(2));
        System.out.println(f2(3));
        System.out.println(f2(4));

        // 结果为:6765
        // 共花费:0
        long start = System.currentTimeMillis();
        System.out.println(f2(20));
        long end = System.currentTimeMillis();
        System.out.println("计算f2(20)共花费:" + (end - start));

        // 结果为:512559680
        // 共花费:0
        start = System.currentTimeMillis();
        System.out.println(f2(48));
        end = System.currentTimeMillis();
        System.out.println("计算f2(48)共花费:" + (end - start));

        // 结果为:-980107325
        // 共花费:0
        start = System.currentTimeMillis();
        System.out.println(f2(100));
        end = System.currentTimeMillis();
        System.out.println("计算f2(100)共花费:" + (end - start));

        // 结果为:1819143227
        // 共花费:264
        start = System.currentTimeMillis();
        System.out.println(f2(100000000));
        end = System.currentTimeMillis();
        System.out.println("计算f2(100000000)共花费:" + (end - start));

    }

    /**
     * 动态规划
     *
     * @param n
     * @return
     */
    public static long f2(int n) throws Exception{

        if(n<0){
            throw new Exception("传入参数不能小于0");
        }

        if (n == 0) {
            return 0;
        }

        if (n == 1) {
            return 1;
        }

        int[] result = new int[n + 1];
        result[0] = 0;
        result[1] = 1;
        for (int i = 2; i <= n; i++) {
            result[i] = result[i - 1] + result[i - 2];
        }
        return result[n];

    }
}

类似题目

黄金分割数列
兔子数列
走台阶问题
有n级台阶,一个人每次上一级或者两级,问有多少种走完n级台阶的方法。
为了防止溢出,请将结果Mod 1000000007
给定一个正整数int n,请返回一个数,代表上楼的方式数。保证n小于等于100000。
测试样例:
1
返回:1
 
解析:这是一个非常经典的为题,设f(n)为上n级台阶的方法,要上到n级台阶的最后一步有两种方式:
从n-1级台阶走一步;从n-1级台阶走两步,
 
于是就有了这个公式f(n) = f(n-1)+f(n-2);
即变为斐波那契数列

7.6 莱文斯坦距离

如何量化两个字符串之间的相似程度呢?有一个非常著名的量化方法,那就是编辑距离(Edit Distance)。包括以下两种距离:

(1)莱文斯坦距离

(2)最长公共子串长度

这里先来学习莱文斯坦距离。

题目


解题思路

第一步,首先要理解题意,提取关键的信息,抽象问题,假设一些测试数据,将其转为数学问题。

本案例中,关键信息为:

问题可以抽象为:

第二步,接着,先画出一个状态表,寻找规律。状态表一般是一个二维数组。

第三步,分析一下这个问题是否符合“一个模型和三个特征”。

第四步,将状态表的生成过程翻译成代码。这里就是具体的编码实现了。代码如下:

import java.util.Arrays;

/**
 * @ClassName Main
 * @Description 莱文斯坦距离
 * @Author Dave Ding
 **/
public class Main {


    public static void main(String[] args) {
        char[] a = "mitcmu".toCharArray();
        char[] b = "mtacnu".toCharArray();
        int n = a.length;
        int m = b.length;

        int result = lwstDp(a,n,b,m);

        System.out.println("莱文斯坦距离为:"+result);
    }

    public static int lwstDp(char[] a, int n, char[] b, int m) {

        int[][] minDist = new int[n][m];

        // 初始化第0行:即a[0] 与 b[0-j]的编辑距离
        for (int j = 0; j < m; j++) {
            if (a[0] == b[j]) {
                // 如果字符数组b后面的某一个字符和数组的第一字符一致,
                // 则意味着数组b的前j个字符都要删除
                minDist[0][j] = j;
            } else if (j != 0) {
                // 数组b第0个后面的字符都要删除
                minDist[0][j] = minDist[0][j - 1] + 1;
            } else {
                //
                minDist[0][j] = 1;
            }

        }

        // 初始化第0列,即a[0-i] 与 b[0]的编辑距离
        for (int i = 0; i < n; i++) {
            if (b[0] == a[i]) {
                minDist[i][0] = i;
            } else if (i != 0) {
                minDist[i][0] = minDist[i-1][0] + 1;
            } else {
                minDist[i][0] = 1;
            }
        }

        // 动态规划
        for (int i = 1; i < n; i++) {
            for (int j = 1; j < m; j++) {

                if (a[i] == b[j]) {
                    minDist[i][j] = min(minDist[i - 1][j] + 1,
                            minDist[i][j - 1] + 1,
                            minDist[i - 1][j - 1]
                    );
                } else {
                    minDist[i][j] = min(minDist[i - 1][j] + 1,
                            minDist[i][j - 1] + 1,
                            minDist[i - 1][j - 1] + 1
                    );
                }

            }
        }

        // 打印,无意义
        for (int[] temp : minDist){
            System.out.println(Arrays.toString(temp));
        }
        
        return minDist[n-1][m-1];
    }

    private static int min(int x, int y, int z) {
        int minv = Integer.MAX_VALUE;
        if (x < minv) {
            minv = x;
        }
        if (y < minv) {
            minv = y;
        }
        if (z < minv) {
            minv = z;
        }
        return minv;
    }
}

输出结果:

[0, 1, 2, 3, 4, 5]
[1, 1, 2, 3, 4, 5]
[2, 1, 2, 3, 4, 5]
[3, 2, 2, 2, 3, 4]
[4, 3, 3, 3, 3, 4]
[5, 4, 4, 4, 4, 3]
莱文斯坦距离为:3

7.7 最大公共子串长度

题目

 给定两个字符串A和B,返回两个字符串的最长公共子序列的长度。
 例如,A="1A2C3D4B56”,B="B1D23CA45B6A”,”123456"或者"12C4B6"都是最长公共子序列。
 给定两个字符串A和B,同时给定两个串的长度n和m,请返回最长公共子序列的长度。
 保证两串长度均小于等于300。

 测试样例:
 "1A2C3D4B56",10,"B1D23CA45B6A",12
 返回:6

解题思路

第一步,首先要理解题意,提取关键的信息,抽象问题,假设一些测试数据,将其转为数学问题。本案例中,关键信息为:

问题可以抽象为:

第二步,接着,先画出一个状态表,寻找规律。状态表一般是一个二维数组。

第三步,分析一下这个问题是否符合“一个模型和三个特征”。


第四步,将状态表的生成过程翻译成代码。这里就是具体的编码实现了。代码如下:

import java.util.Arrays;

/**
 * 最长公共子串长度
 */
public class Main2 {

    public static void main(String[] args) {
        char[] a = "mitcmu".toCharArray();
        char[] b = "mtacnu".toCharArray();
        //char[] a = "1A2C3D4B56".toCharArray();
        //char[] b = "B1D23CA45B6A".toCharArray();
        int n = a.length;
        int m = b.length;

        int result = findLCS1(a, n, b, m);

        System.out.println("最长公共子串长度:" + result);

    }

    public static int findLCS1(char[] a, int n, char[] b, int m) {
        int[][] maxlcs = new int[n][m];
        // 初始化第 0 行:a[0-0] 与 b[0-j] 的 maxlcs
        for (int j = 0; j < m; ++j) {
            if (a[0] == b[j]) {
                maxlcs[0][j] = 1;
            } else if (j != 0) {
                maxlcs[0][j] = maxlcs[0][j - 1];
            } else {
                maxlcs[0][j] = 0;
            }
        }
        // 初始化第 0 列:a[0-i] 与 b[0-0] 的 maxlcs
        for (int i = 0; i < n; ++i) {
            if (a[i] == b[0]) {
                maxlcs[i][0] = 1;
            } else if (i != 0) {
                maxlcs[i][0] = maxlcs[i - 1][0];
            } else {
                maxlcs[i][0] = 0;
            }
        }

        // 动态规划
        for (int i = 1; i < n; ++i) {
            for (int j = 1; j < m; ++j) {
                if (a[i] == b[j]) {
                    maxlcs[i][j] = max(
                            maxlcs[i - 1][j], maxlcs[i][j - 1], maxlcs[i - 1][j - 1] + 1);
                } else {
                    maxlcs[i][j] = max(
                            maxlcs[i - 1][j], maxlcs[i][j - 1], maxlcs[i - 1][j - 1]);
                }
            }
        }

        // 打印,无意义
        for (int[] temp : maxlcs) {
            System.out.println(Arrays.toString(temp));
        }

        return maxlcs[n - 1][m - 1];
    }

    public static int max(int x, int y, int z) {
        int max = x;
        if (y > max) {
            max = y;
        }
        if (z > max) {
            max = z;
        }
        return max;
    }
}

测试案例结果:

[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
[0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2]
[0, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2]
[0, 1, 1, 2, 2, 3, 3, 3, 3, 3, 3, 3]
[0, 1, 1, 2, 3, 3, 3, 3, 3, 3, 3, 3]
[0, 1, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3]
[0, 1, 2, 2, 3, 3, 3, 4, 4, 4, 4, 4]
[1, 1, 2, 2, 3, 3, 3, 4, 4, 5, 5, 5]
[1, 1, 2, 2, 3, 3, 3, 4, 5, 5, 5, 5]
[1, 1, 2, 2, 3, 3, 3, 4, 5, 5, 6, 6]
最长公共子串长度:6

7.8 “杨辉三角”

题目

每个位置的数字可以随意填写,经过某个数字只能到达下面一层相邻的两个数字。
假设你站在第一层,往下移动,我们把移动到最底层所经过的所有数字之和,定义为路径的长度。
请你编程求出从最高层移动到最底层的最短路径长度。

解题思路

第一步,首先要理解题意,提取关键的信息,抽象问题,假设一些测试数据,将其转为数学问题。

这个问题事实上和求矩阵最短路径是一样的,只是矩阵最短路径中矩阵每一行的有效数字是一样的,而该该题目中每行的有效数组不同,第一行只有1个,第二行2个,第三行3个,依次递增。所以该问题关键的一点是要找到边界的计算公式。

本案例中,关键信息为:

三角形数组:
int[][] data = {
                {5,0,0,0,0},
                {7,8,0,0,0},
                {2,3,4,0,0},
                {4,9,6,1,0},
                {2,7,9,4,5}
};

问题可以抽象为:

在矩阵data中找到最短路径,且遵循以下规则:

第二步,接着,先画出一个状态表,寻找规律。状态表一般是一个二维数组。

第三步,分析一下这个问题是否符合“一个模型和三个特征”。

状态转移方程:

// 如果是第一列
if (j == 0){
    dp[i][j] = dp[i-1][j] + data[i][j];
}

// 如果是每一列的最后一个
if (i>0 && j>0 && i ==j){
    dp[i][j] = dp[i-1][j-1] + data[i][j] ;
}

if (i>0 && j>0 && i!=j){
    dp[i][j] = Math.min(dp[i-1][j-1],dp[i-1][j])+data[i][j] ;
}

第四步,将状态表的生成过程翻译成代码。这里就是具体的编码实现了。代码如下:

public class Main {

    public static void main(String[] args){

        int[][] data = {
                {5,0,0,0,0},
                {7,8,0,0,0},
                {2,3,4,0,0},
                {4,9,6,1,0},
                {2,7,9,4,5}
        };

        int n = 5;
        int m = 5;
        int min = f(data,n,m);
        System.out.println("min="+min);
    }

    public static int f(int[][] data,int n,int m){

        int[][] dp = new int[n][m] ;

        dp[0][0] = data[0][0];

        for (int i = 1;i<n;i++){
            for (int j = 0;j<m;j++){

                // 如果是第一列
                if (j == 0){
                    dp[i][j] = dp[i-1][j] + data[i][j];
                }

                // 如果是每一列的最后一个
                if (i>0 && j>0 && i ==j){
                    dp[i][j] = dp[i-1][j-1] + data[i][j] ;
                }

                if (i>0 && j>0 && i!=j){
                    dp[i][j] = Math.min(dp[i-1][j-1],dp[i-1][j])+data[i][j] ;
                }
            }
        }

        for (int i = 0;i<n;i++){
            for (int j = 0;j<m;j++){
                System.out.print(dp[i][j]+" ");
            }
            System.out.println();
        }

        int min = Integer.MAX_VALUE ;
        for (int a : dp[n-1]){
            if (a <min){
                min  = a ;
            }
        }

        return min ;
    }

}

测试案例结果:

5 0 0 0 0 
12 13 0 0 0 
14 15 17 0 0 
18 23 21 18 0 
20 25 30 22 23 
min=20

8 其它题目(摘录自百度百科)

动态规划一般可分为线性动规,区域动规,树形动规,揹包动规四类。

举例:

线性动规:拦截导弹,合唱队形,挖地雷,建学校,剑客决斗等;

区域动规:石子合并, 加分二叉树,统计单词个数,炮兵布阵等;

树形动规:贪吃的九头龙,二分查找树,聚会的欢乐,数字三角形等;

揹包问题:01揹包问题,完全揹包问题,分组揹包问题,二维揹包,装箱问题,挤牛奶(同济ACM第1132题)等;

应用实例:

最短路径问题 ,项目管理,网络流优化等;

POJ动态规划题目列表

9 参考资料

1、百度百科-动态规划

2、极客时间

3、《Dynamic Programming》

4、POJ 动态规划题目列表

5、0009算法笔记——【动态规划】动态规划与斐波那契数列问题,最短路径问题

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章