算法思想 - 动态规划算法,0/1背包,旅行商问题,最大和子串 - java实现

动态规划算法

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

在这里插入图片描述

一、前序知识

  • 最优化原理:
    简单来说就是一个最优策略的子策略也是必须是最优的,而所有子问题的局部最优解将导致整个问题的全局最优。如果一个问题能满足最优化原理,就称其具有最优子结构性质

一、基本思想

  动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。具体的动态规划算法多种多样,但它们具有相同的填表格式。

二、适用情况

  1. 最优子结构性质:如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划算法解决问题提供了重要线索。
  2. 无后效性:即子问题的解一旦确定,就不再改变,不受在这之后、包含它的更大的问题的求解决策影响。
  3. 子问题重叠性质:子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率。(重叠子问题)

三、一般解题思路

  1. 将原问题转化为子问题:
    • 子问题与原问题形式相同或类似,只是问题规模变小了,从而变简单了;
    • 子问题一旦求出就要保存下来,保证每个子问题只求解一遍
  2. 确定状态:
    • 状态:和子问题相关的各个变量的一组取值 ,称之为一个“状态”。一个“状态”对应于一个或多个子问题,所谓某“状态的值”,就是这个“状态”所对应的子问题的解。
    • 状态空间:所有“状态”的集合,构成问题的“状态空间”,“状态空间”的大小,与用动态规划解决问题的时间复杂度直接相关。
  3. 确定一些初始状态(边界状态)
    • 可以理解为递归的终止条件
  4. 确定状态转移方程:
    状态的转移可以用递推公式表示,此递推公式也可以被称作“状态转移方程”。
    形式类似如下:
    在这里插入图片描述

四、经典案例

1、0/1背包问题

0-1 背包问题: 给定 n 种物品和一个容量为 C 的背包,物品 i 的重量是 wi,其价值为 vi
问:应该如何选择装入背包的物品,使得装入背包中的物品的总价值最大?。

解题步骤:

  1. 将原问题转化为子问题:
    子问题:
    当背包容量为 0 时,从 0 ~ n个物品中选择放入背包,获得的最优解。
    当背包容量为 1 时,从 0 ~ n个物品中选择放入背包,获得的最优解。
    当背包容量为 2 时,从 0 ~ n个物品中选择放入背包,获得的最优解。
    。。。。。。
    当背包容量为 8 时,从 0 ~ n个物品中选择放入背包,获得的最优解。
    一共有 (c+1) * (n+1) 个子问题。(c:背包容量,n:物品数量)
  2. 确定状态:
    定义状态,对于 0/1 背包问题,可以设计一个二维数组dp[n][c]来存放子问题的状态, 而每一个子问题的状态为 d[i][j] 表示 从物品 1 ~ i 物品中选择放入容量为 j 的背包得到的最优解。
  3. 确定一些初始状态(边界状态):
    当不放入物品时,价值为0:dp[0][0~c] = 0
    当背包容量为0时,价值为0:dp[0~n][0] = 0
  4. 确定状态转移方程:
    该问题的状态转移方程即:分析得出 dp[i][j] 的计算方法
    • j < w[i] 的情况,这时候背包容量不足以放下第 i 件物品,只能选择不拿
      dp[ i ][ j ] = dp[ i-1 ][ j ]

    • j >= w[i] 的情况,这时背包容量可以放下第 i 件物品,我们就要考虑拿这件物品是否能获取更大的价值。

      • 如果拿取:dp[ i ][ j ] = dp[ i-1 ][ j-w[ i ] ] + v[ i ]。 这里的 dp[ i-1 ][ j-w[ i ]]是从 1 ~ i-1 件物品选择,背包容量为 j-w[i] 时的最大价值。
      • 如果不拿:dp[ i ][ j ] = dp[ i-1 ][ j ]

      通过比较这两种情况哪种价值最大,来判断是否要放入第 i 个物品,即:

		if(j >= w[i])
    		dp[i][j] = max(dp[i-1][j],dp[i-1][j-w[i]]+v[i]);
		else
    		dp[i][j] = dp[i-1][j];

实战:
假如有1个背包,背包容量是8。有4个物品,重量分别为:{2,3,4,5},价值为:{3,4,5,6}
要求在不超过背包容量的情况下,使背包装载物品的价值最大。

dp[n][c]数组计算结果如下:当背包容量为8,从4个物品中选,最优解为 dp[4][8] = 10

物品编号 i \ 背包容量 j012345678
0000000000
1003333333
2003 4 \color{blue}4 447777
3003457899
400345789 10 \color{red}10 10

计算过程:以表格中的蓝色的4,即 dp[2][3] 进行分析:
在这里插入图片描述
dp[2][3]: 当不装入2物品时,dp[2][3] = dp[1][3] = 3;当装入2物品时,背包容量为3,去除2物品的重量,背包容量还剩0,即 dp[2][3] = dp[1][0] + w[2] = 0 + 4
选两者的最大值4,即dp[2][3] = 4,填入dp[][]表。

通过这一种方式,我们可以得到背包问题中的最优解,但是并不能知道在最优解的情况下,背包中放入了哪些物品。
为了想要知道在最优解时背包中放了哪些物品,我们可以采取回溯的方法。从表格的右下角(动态规划的终点)开始回溯,利用性质 dp[i][j] == dp[i-1][lj] 表示第 i 个物品没有放入背包,不相等则表示放入背包。

  • 回溯
    dp[4][8] != dp[3][8]:第 4 个物品放入背包。第4个物品放入背包,背包的容量为 8-5=3,这时候回溯到 dp[3][3]。
    dp[3][3] == dp[2][3]:第 3 个物品没有放入背包。这时候可以回溯到dp[2][3]
    dp[2][3] != dp[1][3]:第 2 个物品放入背包。第2个物品放入背包,背包的容量为 3-3=0,这时候回溯到 dp[1][0]。
    dp[1][0] == dp[0][0]:第 1 个物品没有放入背包
    最终可以得出在最优解为10时,此时背包中放入的物品为:物品{2,4}

算法实现

package indi.pentiumcm.thought;

import java.util.ArrayList;
import java.util.List;

/**
 * @projName: algorithm
 * @packgeName: indi.pentiumcm.thought
 * @className: DpBack
 * @author: pentiumCM
 * @email: 842679178@qq.com
 * @date: 2020/3/25 23:38
 * @describe: 动态规划 - 0/1背包问题
 */
public class DpBack {


    /**
     * 0/1背包算法实现
     *
     * @param backCap 背包的容量
     * @param weights 物品的重量数组
     * @param values  物品的价值数组
     * @return
     */
    public void backPro(int backCap, int[] weights, int[] values) {

//      物品个数
        int goodNum = weights.length;

//      1. 确定状态
//      dp[i][j] 表示前i件物品放入重量为j的背包时的最大价值
        int[][] dp = new int[goodNum + 1][backCap + 1];

//      2. 确定一些初始状态:dp[0][0~backCap]=0,dp[0~goodNum][0]=0
        for (int i = 0; i <= backCap; i++) {
            dp[0][i] = 0;
        }
        for (int i = 0; i <= goodNum; i++) {
            dp[i][0] = 0;
        }

//      3.确定状态转移方程:dp[i][j] = max(dp[i-1][j],dp[i-1][j-w[i]]+v[i]);
        for (int i = 1; i <= goodNum; i++) {
//          j 从 1 遍历到背包容量
            for (int j = 1; j <= backCap; j++) {
                if (weights[i - 1] <= j) {
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weights[i - 1]] + values[i - 1]);
                } else {
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }


//      回溯,看背包里面放了哪些物品
        int tempCap = backCap;
        List<Integer> backList = new ArrayList<>();
        for (int i = goodNum; i > 0; i--) {
            if (dp[i][tempCap] != dp[i - 1][tempCap]) {
                backList.add(i);
                tempCap -= weights[i - 1];
            }

        }

        System.out.print("背包中放了物品:");
        for (int i = backList.size() - 1; i >= 0; i--) {
            System.out.print(" " + backList.get(i));
        }


        System.out.print("\n" + "获得的最大价值为: " + dp[goodNum][backCap]);
    }

    public static void main(String[] args) {
//      背包容量
        int backCap = 8;
//      物品的重量
        int[] weights = {2, 3, 4, 5};
//      物品的价值
        int[] values = {3, 4, 5, 6};

        new DpBack().backPro(backCap, weights, values);
    }
}

运行结果:
在这里插入图片描述

2、旅行商问题

旅行商问题(TSP问题):
是指旅行家要旅行n个城市,要求各个城市经历且仅经历一次然后回到出发城市,并要求所走的路程最短。

示例:
其中0,1,2,3,4代表五个城市,例如从城市0出发,经过城市1,2,3,4,最后回到城市0。
此模型可抽象为图,城市之间的距离可用邻接矩阵 C 表示,如下图所示:
在这里插入图片描述解题步骤:

  1. 将原问题转化为子问题:
    原问题:
      从0出发,经过{1,2,3,4}这几个城市,然后回到0,使得路程最短。
    子问题:

    • 从顶点 0 出发,到1,然后再从1出发,经过{2,3,4}这几个城市,然后回到0,使得路程最短。
      • 从顶点 0 出发,到1,然后再从1出发到2,经过{3,4}几个城市,最后回到0,使得路程最短。
        。。。。。。
    • 从顶点 0 出发,到2,然后再从2出发,经过{1,3,4}这几个城市,然后回到0,使得路程最短。
      • 。。。。。。
    • 从顶点 0 出发,到3,然后再从3出发,经过{1,2,4}这几个城市,然后回到0,使得路程最短。
      • 。。。。。。
    • 从顶点 0 出发,到4,然后再从4出发,经过{1,2,3}这几个城市,然后回到0,使得路程最短。
      • 。。。。。。
  2. 确定状态:
    假设从顶点s出发,令d(i, V)表示从顶点 i 出发经过V(点的集合)中各个顶点一次且仅一次,最后回到出发点s的最短路径长度。

  3. 确定一些初始状态(边界状态):
    当V为空集,那么d(i, V)为 d(i,{∅}),表示直接从 i 回到 s 了,此时d(i,V) = Cis(Cis表示顶点 i 和 s 之间的距离)

		// 初始化dp表的第一列,d(i,{∅})
		for(int i =0;i <n;i++){                      
    		dp[i][0] = C[i][0];                        
		}
  1. 确定状态转移方程:
    当V不为空,那么就是对子问题的最优求解。须在V这个城市集合中,尝试每一个,并求出最优解。
    d(i,V) = min{ Cik + d(i,V-k) } (Cik:相邻城市 i 和 k的距离,d(i,V-k):子问题)
    所以状态转移方程为:
    在这里插入图片描述过程步骤:
    假设从城市 0 出发,分别要经过{1,2,3,4},最终回到城市0。所以原问题为:d(0,{1,2,3,4})。
    在这里插入图片描述

以上是选择了路径为:0 → 1 → 2 → 3 → 4 → 0

d(0,{1,2,3,4}) = min{ d(1,{2,3,4}) + C01,d(2,{1,3,4}) + C02,d(3,{1,2,4}) + C03,d(4,{1,2,3}) + C04 }

d(1,{2,3,4}) = min{ d(2,{3,4}) + C12,d(3,{2,4}) + C13,d(4,{2,3}) + C14 }

d(2,{3,4}) = min{ d(3,{4}) + C23,d(4,{3}) + C24 }

d(3,{4}) = min{ d(4,{∅}) + C34 }

d(4,{∅}) = C40

dp表结构:
设置一个二维的动态规划表dp:
先确定一下dp表的大小,有n个城市,从0开始编号,那么dp表的行数就是n,列数就是2(n-1),集合{1,2,3,4}的子集个数。在求解的时候,第一列的值对应这从邻接矩阵可以导出,后面的列可以有前面的列和邻接矩阵导出。

其次,为了编程方便,建立{ }二进制编码转换:{1,3,4}表示为二进制的1101,十进制的13,对于规则为:其中集合里面有的数对应的二进制中位数写成1,没有的写成0。

  • 对于第y个城市,他的二进制表达为,1 << (y-1)
  • 对于数x,要看它的第 i 位是不是1,那么可以通过判断布尔表达式 (((x >> (i - 1) ) & 1) == 1的真值来实现。
  • 符号{1,2,3,4}: 表示经过{1,2,3,4}这几个城市,然后回到0。那么题目就是求dp[0][{1,2,3,4}],即dp[0][15]。
    (d[i][j]:i 表示某一步的起点城市,j 为需要经过城市集合的十进制,如{1,2,3}为二进制的111,对应的十进制为7。)
  • 编程过程中的状态转移:
    dp[ i ][ j ] = C[ i ][ k ] + dp[ k ][ j ^ (1 << (k - 1)) ],解释如下,等式左边表示问题:dp[ i ][ j ]从 i 出发经过 j 对应的顶点集。等式右边表示将等式左边的问题拆分为子问题:从 i 出发到 j 对应的点集中的 顶点 k,然后从顶点 k 经过 剩余的点集。
    j ^ (1 << (k - 1)): 这个其实就是 j 对应的点集中去除 顶点 K 之后的点集。相信很多朋友一下子理解不了,我举个例子来解释一下,当 j = 7时,二进制为111,对应的顶点集为{1,2,3},我们选出顶点 3,则剩下的点集为{1,2},这个过程:j 对应为 111,3 对应为 100 = 1 << (3-1),将 j 的 111 和 3 的100 进行异或便可求出剩余的点集,也可以从减法的角度理解: 111(2) - 100(2) = 11(2)
{∅}{1}{2}{1,2,3}{1,2,3,4}
二进制01101111111
十进制012715

所以求出的动态规划表就是:

{∅}{1}{2}{1,2}{3}{1,3}{2,3}{1,2,3}{4}{1,4}{2,4}{1,2,4}{3,4}{1,3,4}{2,3,4}{1,2,3,4}
索引 i \ j0123456789101112131415
0∞ (C00)6 (C01+C10)∞(C02+C20)16211818171837322423
13 (C10)∞(C11+C10)181514153320
2∞ C(20)6(C21+C10)121712113126
38 (C30)13(C31+C10)10 (min{10+∞, 4+6})29241615
49 (C40)8(C41+C10)9 (min{3+∞, 3+6})28231520
子问题d(i,{∅}) = Ci0d(i,{1}) = Ci1+d(1,{∅}) = Ci1 + C10d(i,{2}) = Ci2+d(2,{∅}) = Ci2 + C20d(i,{1,2})=min{ Ci1+d(1,{2}) ,Ci2+d(2,{1}) }d(i,{3}) = Ci3+d(3,{∅}) = Ci3+C30d(i,{1,3})=min{ Ci1+d(1,{3}) ,Ci3+d(3,{1}) }


同样,这种方式只能获取到最优值,无法获取获取到最优值情况中的路径,为了获取到最优路径,同样可以采用回溯的方法。
  • 回溯:
    如:dp[0]{1,2,3,4} = dp[0][15] = min{ C01 + dp[1]{2,3,4},C02 + dp[2]{1,3,4},C03 + dp[3]{1,2,4},C04 + dp[4]{1,2,3} } = C01 + dp[1]{2,3,4}。可得这一路径为:0 -> 1,接下来从 dp[1]{2,3,4} 开始回溯,直到所有城市顶点被遍历完。

算法实现:

package indi.pentiumcm.thought;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static java.lang.Double.POSITIVE_INFINITY;

/**
 * @projName: algorithm
 * @packgeName: indi.pentiumcm.thought
 * @className: DpTSP
 * @author: pentiumCM
 * @email: 842679178@qq.com
 * @date: 2020/3/28 22:05
 * @describe: 动态规划 - 旅行商问题
 */
public class DpTSP {

    // 无穷大
    static int max = (int) POSITIVE_INFINITY / 2;

    /**
     * 旅行商问题算法实现
     *
     * @param distances 各个顶点之间距离的邻接矩阵的二维数组
     */
    public void tsp(int[][] distances) {
        int cityNum = distances.length;

        int colNum = 1 << cityNum - 1;
//      1. 构建动态规划dp表
        int[][] dp = new int[cityNum][colNum];

//      2. 确定一些初始状态:初始化dp表的第一列,即d(i,{∅}) = Ci0
        for (int i = 0; i < cityNum; i++) {
//          每个城市回到起点的距离
            dp[i][0] = distances[i][0];
        }

//      3. 确定状态转移方程,d[i][j]:i - 城市,j - {1,2,3,4}等
        for (int j = 1; j < colNum; j++) {

            for (int i = 0; i < cityNum; i++) {
                dp[i][j] = max;
//              {j}中的顶点包含起点i ,就continue
                if (((j >> (i - 1)) & 1) == 1) {
                    continue;
                }

//              遍历{ }里面的点集
                for (int k = 1; k < cityNum; k++) {
//                  排除{V}中不包含的顶点,如j = 5(二进制101)时,{}为{1,3},这时候并不含有2,4,需要排除掉,直接continue
                    if (((j >> (k - 1)) & 1) == 0) {
                        continue;
                    }
//                  从i出发到 k,然后从k出发经过{V-k}
                    if (dp[i][j] > distances[i][k] + dp[k][j ^ (1 << (k - 1))]) {
                        dp[i][j] = distances[i][k] + dp[k][j ^ (1 << (k - 1))];
                    }
                }
            }
        }

        int minDistance = dp[0][colNum - 1];

//      回溯,求最短路径的流行
        List<Integer> path = new ArrayList<>();

//      存放剩余未走的城市顶点,key-value:城市顶点标号
        Map<Integer, Integer> remPath = new HashMap<>();
        for (int i = 1; i < cityNum; i++) {
            remPath.put(i, i);
        }


//      将起点先存入列表
        path.add(0);
        int choseCity = 0;

        for (int j = cityNum - 1; j > 0; j--) {

            int col2 = 0;
            for (Integer cityIndex : remPath.keySet()) {
                col2 += 1 << remPath.get(cityIndex) - 1;
            }


            for (int i = 1; i < cityNum; i++) {

                choseCity = path.get(path.size() - 1);
                int cDis = distances[choseCity][i];

                if (dp[i][col2 ^ (1 << (i - 1))] + cDis == minDistance) {
                    path.add(i);

                    remPath.remove(i);
                    minDistance -= cDis;
                    break;
                }
            }
        }
        path.add(0);

        System.out.print("选择的最佳路径为:");
        for (int i = 0; i < path.size(); i++) {
            System.out.print(" " + path.get(i));
        }

        System.out.print("\n" + "最佳路径长度为:" + dp[0][colNum - 1]);
    }

    public static void main(String[] args) {

//      各个城市之间的距离的邻接矩阵
        int[][] distances = {
                {max, 3, max, 8, 9},
                {3, max, 3, 10, 5},
                {max, 3, max, 4, 3},
                {8, 10, 4, max, 20},
                {9, 5, 3, 20, max}
        };

        new DpTSP().tsp(distances);
    }
}

运行结果:
在这里插入图片描述


3、最大和子串问题

  1. 问题描述
    给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

    示例:
    输入: [-2,1,-3,4,-1,2,1,-5,4],
    输出: 6
    解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。

解题套路一致,分析过程在代码注释中,如下↓:

package indi.pentiumcm.leetcode;

import java.util.Arrays;

/**
 * @projName: algorithm
 * @packgeName: indi.pentiumcm.leetcode
 * @className: Q15
 * @author: pentiumCM
 * @email: 842679178@qq.com
 * @date: 2020/4/1 10:55
 * @describe: 53. 最大子序和 - 动态规划解决
 */
public class Q15 {

    public void maxSubArray(int[] nums) {

//      1. 原问题划分为子问题
//      原问题:当序列长度为 n 时,求最大和
//      子问题:当序列长度为 n -1 时,求最大和
//      子问题:当序列长度为 n -2 时,求最大和

//      2. 定义状态:dp[i]为 遍历到第 i 个元素时,子串的最大和
        int[] dp = new int[nums.length];

//      3. 初始状态 dp[0] = nums[0]
        dp[0] = nums[0];

//      4. 状态转移方程:dp[i] = max{dp[i-1] + nums[i], nums[i]}
        for (int i = 1; i < nums.length; i++) {
            int maxSum = dp[i - 1] + nums[i] > nums[i] ? dp[i - 1] + nums[i] : nums[i];
            dp[i] = maxSum;
        }

//      遍历dp[],dp[]最大的值即为连续子串的最大和
        int maxSum = dp[0];
        int maxIndex = 0;
        for (int i = 0; i < dp.length; i++) {
            if (dp[i] > maxSum) {
                maxSum = dp[i];
                maxIndex = i;
            }
        }

        System.out.println("最大子串和:" + dp[maxIndex]);

//      回溯, 求最大连续子串的元素
        System.out.print("最大连续子串元素:");
        for (int i = maxIndex; i >= 0; i--) {
            if (maxSum == nums[i]) {
                System.out.print(" " + nums[i]);
                break;
            } else {
                maxSum -= nums[i];
                System.out.print(" " + nums[i]);
            }
        }
    }

    public static void main(String[] args) {
        int[] arr = {-2, 1, -3, 4, -1, 2, 1, -5, 4};
        new Q15().maxSubArray(arr);
    }
}
  • 0
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值