完结撒花常见算法——动态规划

Posted 水之Coding工房

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了完结撒花常见算法——动态规划相关的知识,希望对你有一定的参考价值。



点击上方“水之Coding工房”并关注,更多好玩的等着你~

武汉 加油, 中国 加油!

大家好,我是本公众号唯一御用小编~
我叫水水。
本篇推送是
寒假学习专题的
第十篇
而本次的主题是
简单易懂 地讲解
动态规划
我们继续学习吧~


P.S.本篇推送所讲解的动态规划仅供入门使用,因为动态规划所涉及到的算法也是非常之多的,其多样性从名字中的“动态”便可略知一二了,但是有一点可以确定的是,它的算法核心是不变的。所以,以下的篇幅都是从该点进行切入。文中的文字和代码也不少,很适合静下心来好好学一番,说不定就能因此掌握一个知识点了【完结撒花】常见算法【四】——动态规划

动态规划


Dynamic Programming


动态规划,一个听起来就很难学的东西,它到底可以用来解决什么问题呢?其实,动态规划比较适合用来 求解最优问题 ,比如求最大值、最小值等等,而且就目前的算法题而言,动态规划已经渐渐成为我们的一个绕不开的话题了。它可以非常显著地 降低时间复杂度 ,提高代码的执行效率。也正因为这样,其学习难点与 递归 类似,求解问题的过程不太符合人类常规的思维方式。那么接下来,我们来看看它为何物吧!(放心,这题不难.jpg)

【完结撒花】常见算法【四】——动态规划



实例分析引入“动态规划”

首先,我们从一个简单版的背包问题入手:

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

其实,这个问题可以通过穷举所有的装法,然后通过比较找出满足条件的最大值的方法来实现。其实这种算法也有一个名字,叫做回溯算法(回溯算法相当于穷举搜索。穷举所有的情况,然后对比得到最优解)。

不过,回溯算法的时间复杂度非常高,是 指数 级别的,只能用来解决小规模数据的问题。对于大规模数据的问题,用回溯算法解决的执行效率就很低了,但同时,回溯算法也是相对而言比较容易实现的。那么我们来看看它的代码实现吧~
 1// 回溯算法实现。P.S.输入的变量都定义成了成员变量。
2private int maxW = Integer.MIN_VALUE; // 结果放到 maxW 中
3private int[] weight = {33584};  // 物品重量
4private int n = 5// 物品个数
5private int w = 12// 背包承受的最大重量
6// 调用 f(0, 0),i参数表示将要决策第几个物品是否装入背包,cw参数表示当前背包中物品的总重量
7public void f(int i, int cw) 
8  if (cw == w || i == n) { // cw==w 表示装满了,i==n 表示物品都考察完了
9    if (cw > maxW) maxW = cw;
10    return;
11  }
12  f(i+1, cw); // 选择不装第 i 个物品
13  if (cw + weight[i] <= w) {
14    f(i+1,cw + weight[i]); // 选择装第 i 个物品
15  }
16}
结合代码我们可以知道,以上是利用 递归思想 执行的。但同时我们也知道,递归算法让人诟病的一个点就是,对每一次递归之间, 有许多子递归的值的求解计算是重复的

那么我们该如何去优化这个递归算法呢?有一种“打小抄”的方法可以帮助优化递归算法,这个方法实际上就是,记录已经计算好的f(i, cw)放到“小抄”(抽象而言即是一种数据结构,可以是数组)中,当再次计算到重复的 f(i, cw) 的时候,可以直接从”小抄”中取出来用,就不用再递归计算了,这样就可以避免冗余计算。

那我们再来看看优化后的代码实现是怎么样的吧!
 1private int maxW = Integer.MIN_VALUE; // 结果放到 maxW 中
2private int[] weight = {33584};  // 物品重量
3private int n = 5// 物品个数
4private int w = 12// 背包承受的最大重量
5private boolean[][] mem = new boolean[5][10]; //小抄,默认值 false
6public void f(int i, int cw) {
7  if (cw == w || i == n) { // cw==w 表示装满了,i==n 表示物品都考察完了
8    if (cw > maxW) maxW = cw;
9    return;
10  }
11  if (mem[i][cw]) return// 重复状态
12  mem[i][cw] = true// 记录 (i, cw) 这个状态
13  f(i+1, cw); // 选择不装第 i 个物品
14  if (cw + weight[i] <= w) {
15    f(i+1,cw + weight[i]); // 选择装第 i 个物品
16  }
17}
其实,这种解决思路就已经慢慢向动态规划靠近了。那么问题来了,真正的动态规划是怎么做的呢?

我们可以把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个物品决策(放入或者不放入背包)完之后,背包中的物品的重量会有多种情况,也就是说,会达到多种不同的状态

把每一层重复的状态合并,只记录不同的状态,然后基于上一层的状态集合,来推导下一层的状态集合。我们可以通过合并每一层重复的状态,这样就保证每一层不同状态的个数都不会超过 w 个(w 表示背包的承载重量),也就是例子中的 12。于是,我们就成功避免了每层状态个数的指数级增长。

我们用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。

第 0 个(下标从 0 开始编号)物品的重量是3,要么装入背包,要么不装入背包,决策完之后,会对应背包的两种状态,背包中物品的总重量是 0 或者 3。我们用states[0][0]=true 和 states[0][3]=true 来表示这两种状态。

第 1 个物品的重量也是 3,基于之前的背包状态,在这个物品决策完之后,不同的状态有 3 个,背包中物品总重量分别是 0(0+0),3(0+3 or 3+0),6(3+3)。我们用 states[1][0]=true,states[1][3]=true,states[1][6]=true 来表示这三种状态。

以此类推,直到考察完所有的物品后,整个 states 状态数组就都计算好了。我们只需要在最后一层,找一个值为 true 的最接近 w(也就是例子中的12)的值,就是背包中物品总重量的最大值。

那么,它的代码实现又是怎么样的呢,请看!
 1//weight: 物品重量,n: 物品个数,w: 背包可承载重量
2public int knapsack(int[] weight, int n, int w) {
3  boolean[][] states = new boolean[n][w+1]; // 默认值 false
4  states[0][0] = true;  // 第一行的数据要特殊处理,可以利用哨兵优化
5  states[0][weight[0]] = true;
6  for (int i = 1; i < n; ++i) { // 动态规划状态转移
7    for (int j = 0; j <= w; ++j) {// 不把第 i 个物品放入背包
8      if (states[i-1][j] == true) states[i][j] = states[i-1][j];
9    }
10    for (int j = 0; j <= w-weight[i]; ++j) {// 把第 i 个物品放入背包
11      if (states[i-1][j]==true) states[i][j+weight[i]] = true;
12    }
13  }
14  for (int i = w; i >= 0; --i) { // 输出结果
15    if (states[n-1][i] == truereturn i;
16  }
17  return 0;
18}
实际上,这就是一种用动态规划解决问题的思路。 我们把问题分解为多个阶段,每个阶段对应一个决策。 我们记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进 这也是动态规划这个名字的由来。这样看来,其实这也算不上很难啦~

该代码的时间复杂度也可以根据耗时最多的部分,也就是代码中的两层 for 循环,所以时间复杂度是 O(n*w)。n 表示物品个数,w 表示背包可以承载的总重量。这样从理论来看,指数级的时间复杂度肯定要比 O(n*w) 高很多。

尽管动态规划的执行效率比较高,但是就刚刚的代码实现,我们需要额外申请一个 n 乘以 w+1 的二维数组,对空间的消耗比较多。所以有时候,其实动态规划是一种空间换时间的解决思路

但实际上,我们可以更进一步优化该代码,只需要一个大小为 w+1 的一维数组就可以降低空间消耗。动态规划状态转移的过程,都可以基于这个一维数组来操作。具体的代码实现如下。
 1public static int knapsack2(int[] items, int n, int w) {
2  boolean[] states = new boolean[w+1]; // 默认值 false
3  states[0] = true;  // 第一行的数据特殊处理
4  states[items[0]] = true;
5  for (int i = 1; i < n; ++i) { // 动态规划
6    for (int j = w-items[i]; j >= 0; --j) {// 把第 i 个物品放入背包
7      if (states[j]==true) states[j+items[i]] = true;
8    }
9  }
10  for (int i = w; i >= 0; --i) { // 输出结果
11    if (states[i] == truereturn i;
12  }
13  return 0;
14}

背包问题——升级版




那么我们进一步,将背包问题中引入物品价值因素。也就是说对于一组不同重量、不同价值、不可分割的物品,我们选择将某些物品装入背包,在满足背包最大重量限制的前提下,背包中可装入物品的总价值最大是多少呢?

同样的,我们现根据回溯算法,穷举实现一下,代码见下。
 1private int maxV = Integer.MIN_VALUE;    //结果放到 maxV 中
2private int[] items = {22463};    //物品的重量
3private int[] value = {34896};    //物品的价值
4private int n = 5;                       //物品个数
5private int w = 9;                       //背包承受的最大重量
6public void f(int i, int cw, int cv) {   //调用 f(0, 0, 0)
7  if (cw == w || i == n) { // cw==w 表示装满了,i==n 表示物品都考察完了
8    if (cv > maxV) maxV = cv;
9    return;
10  }
11  f(i+1, cw, cv);                        //选择不装第 i 个物品
12  if (cw + weight[i] <= w) {
13    f(i+1,cw+weight[i], cv+value[i]);    //选择装第 i 个物品
14  }
15}
可以发现,其中同样有用到递归思想。我们可以优化的地方也挺容易想到的,也就是在f(inti,  cw, cv)方法中,会遇到不少状态的 i 和 cw 是完全相同的,但cv却是不一样的 。根据我们的期望是要在物品总重量一样的情况下,选取对应的物品总价值更大的。所以优化的思路便是: 对于 (i, cw) 相同的不同状态,那我们只需要保留 cv 值最大的那个,继续递归处理,其他状态不予考虑。

那我们再想一下,如何通过动态规划解决这个问题呢?

同样的,还是把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个阶段决策完之后,背包中的物品的总重量以及总价值,会有多种情况,也就是会达到多种不同的状态。在这里,可以用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。不过这里数组存储的值不再是布尔类型的了,而是当前状态对应的最大总价值。我们把每一层中 (i, cw) 重复的状态(节点)合并,只记录 cv 值最大的那个状态,然后基于这些状态来推导下一层的状态。

代码实现如下。
 1public static int knapsack3(int[] weight, int[] value, int n, int w) {
2  int[][] states = new int[n][w+1];
3  for (int i = 0; i < n; ++i) {              //初始化 states
4    for (int j = 0; j < w+1; ++j) {
5      states[i][j] = -1;
6    }
7  }
8  states[0][0] = 0;
9  states[0][weight[0]] = value[0];
10  for (int i = 1; i < n; ++i) {              //动态规划,状态转移
11    for (int j = 0; j <= w; ++j) {           //不选择第 i 个物品
12      if (states[i-1][j] >= 0) states[i][j] = states[i-1][j];
13    }
14    for (int j = 0; j <= w-weight[i]; ++j) { //选择第 i 个物品
15      if (states[i-1][j] >= 0) {
16        int v = states[i-1][j] + value[i];
17        if (v > states[i][j+weight[i]]) {
18          states[i][j+weight[i]] = v;
19        }
20      }
21    }
22  }
23  // 找出最大值
24  int maxvalue = -1;
25  for (int j = 0; j <= w; ++j) {
26    if (states[n-1][j] > maxvalue) maxvalue = states[n-1][j];
27  }
28  return maxvalue;
29}


 【完结撒花】常见算法【四】——动态规划


理论讲解“动态规划”

现在,相信对于动态规划已经有了一个初步的认识。那么问题又来了,动态规划能解决的问题有什么规律可循呢?我们再来认真看下。

其实,动态规划作为一个已经十分成熟的算法,已经被前辈们总结为以下三个特征——最优子结构、无后效性和重复子问题


1、最优子结构: 最优子结构指的是,问题的最优解包含子问题的最优解。 反过来说就是,我们 可以通过子问题的最优解,推导出问题的最优解 如果我们把最优子结构,对应到我们前面定义的动态规划问题模型上,那我们也可以理解为,后面阶段的状态可以通过前面阶段的状态推导出来;
2、无后效性: 无后效性有两层含义,第一层含义是,在推导后面阶段的状态的时候,我们 只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的 第二层含义是, 某阶段状态一旦确定,就不受之后阶段的决策影响。 无后效性是一个非常“宽松”的要求。 只要满足前面提到的动态规划问题模型,其实基本上都会满足无后效性;

3、重复子问题: 其实这个概念往通俗里讲,也就是不同的决策序列,到达某个相同的阶段时, 可能会产生重复的状态

将问题以及上面的三个特征,可以抽象为一个模型,也就是“多阶段决策最优解模型”,该模型一定符合以上的三个特征。

现在,动态规划的问题模型了解了,对于这类问题的解决思路又是怎么样的呢?前辈们总结为以下两种方法: 状态转移表法 状态转移方程法

1、状态转移表法

一般能用动态规划解决的问题,都可以使用回溯算法暴力解决。所以,当我们拿到问题的时候,我们可以先用简单的回溯算法解决,然后定义状态,通过自行举例推导,是否存在重复子问题(通常的动态规划问题大概率都是有的),以及重复子问题是如何产生的。以此来寻找规律,看是否能用动态规划解决。

找到重复子问题之后,接下来,我们有两种处理思路,第一种是直接用回溯加上“打小抄”的方法,来避免重复计算子问题。第二种是使用动态规划的解决方法,状态转移表法。

我们先画出一个状态表。状态表一般都是二维的,所以你可以把它想象成二维数组。其中,每个状态包含三个变量,行、列、数组值。我们根据决策的先后过程,从前往后,根据递推关系,分阶段填充状态表中的每个状态。最后,我们将这个递推填表的过程,翻译成代码,就是动态规划代码了。

 

2、 状态转移方程法

状态转移方程法比较类似递归。我们需要先分析,某个问题如何通过子问题来递归求解,也就是所谓的最优子结构。根据最优子结构,写出递归公式,也就是所谓的状态转移方程(可以理解为是高中数学中数列的递推公式)。

一般情况下,对于代码的实现同样有两种方法,一种是递归加“打小抄”,另一种是迭代递推。方法核心与前一种较为类似,水水就不展开了。

总结一下,其实动态规划的两种解决问题的方法思路用一句话来讲就是——状态转移表法解题思路大致可以概括为,回溯算法实现 - 定义状态 - 画递归树 - 找重复子问题 - 画状态转移表 - 根据递推关系填表- 将填表过程翻译成代码;状态转移方程法的大致思路可以概括为,找最优子结构 - 写状态转移方程 - 将状态转移方程翻译成代码

 【完结撒花】常见算法【四】——动态规划

那么初实践和理论都讲解完毕后,最合适就是——多练习啦~对于目前一般的动态规划问题,应该都是在以上所讲的基础上稍微变形,都是可以解决的!那么动态规划入门之路就到这里结束啦~


■ Over ■


本次动态规划专题

就完整结束啦

同时水水的
寒假专题
也终于到了 尾声
(能赶在寒假时间v1.0版本结束太好了)
感谢各位看过我专题
和即将要看我专题
的小伙伴能 捧场

【完结撒花】常见算法【四】——动态规划

笔芯.gif


【完结撒花】常见算法【四】——动态规划附寒假专题所有推送扫码直达【完结撒花】常见算法【四】——动态规划

【完结撒花】常见算法【四】——动态规划



【完结撒花】常见算法【四】——动态规划

我是水水



我们下期再见

祝学习愉快鸭


【完结撒花】常见算法【四】——动态规划

历史



有什么问题的话欢迎在后台输入框中回复哦~

武汉 加油, 中国 加油!



以上是关于完结撒花常见算法——动态规划的主要内容,如果未能解决你的问题,请参考以下文章

台大李宏毅《机器学习》2021课程撒花完结!除了视频PPT,还有人汇编了一本答疑书...

台大李宏毅《机器学习》2021课程撒花完结!除了视频PPT,还有人汇编了一本答疑书...

Vue3+TS项目完结撒花(已部署)

完结^_^撒花TCP/IP 详解 卷一:协议 笔记

软件工程基础 完结撒花

Python简单爬虫第六蛋!(完结撒花)