天天看点

动态规划入门

作者:闪念基因

1.前言

对于软件工程师的同学来讲,肯定都熟悉《算法与数据结构》的领域,图灵奖获得者的Pascal之父—Nicklaus Wirth提出的著名公式“算法+数据结构=程序”,可见算法的重要性。

在攀登算法这个领域山峰的时候,我们从排序、搜索、贪心等算法一路披荆斩棘,最终来到山顶,即将发出“一览众山小”的感慨时,突然发现还有一座更高的绝壁——动态规划(俗称DP)挡在我们的前面。

同时我们在刷leetcode、面试大厂的时候,经常会遇到需要使用动态规划才能解决的题目和场景;并且在我们的日常复杂业务开发中,也可以见到动态规划的应用场景。

动态规划问题非常非常经典,也很有技巧性,但绝非是一个遥不可及的概念。

今天跟大家一起来学习动态规划的“套路”,主要从如下主题进行介绍:

1、神秘的动态规划,主要是引出动态规划这个概念,也就是大家平常看到的学术性的角度,如果有了一定的认识,这里大家可以跳过。2、走进动态规划,这个章节主要讲解我自己理解的动态规划是什么,以及在学习过程中提炼的核心思想,以及推导过程。3、动态规划实战和应用,用一个例子来推导出实现的全过程,如何写出暴力递归版本,记忆化搜索版本,和最终的状态转移方程版本;最后还有动态规划在售后业务中的应用介绍。

动态规划入门

文章如果有不正确的地方,欢迎大家指出哈,感谢感谢!

2.神秘的动态规划

2.1 什么是动态规划

动态规划(英语:Dynamic programming,简称 DP),是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题。

dynamic programming is a method for solving a complex problem by breaking it down into a collection of simpler subproblems.

以上定义来自维基百科,看定义感觉还是有点抽象。简单来说,动态规划其实就是,给定一个问题,我们把它拆成一个个子问题,直到子问题可以直接解决。然后呢,把子问题答案保存起来,以减少重复计算。再根据子问题答案反推,得出原问题解的一种方法。

一般这些子问题很相似,可以通过函数关系式递推出来。然后呢,动态规划就致力于解决每个子问题一次,减少重复计算,比如斐波那契数列就可以看做入门级的经典动态规划问题。

2.2 动态规划的应用场景

2.2.1 最优解问题

这样的问题一般都会让你求最大子数组、求最长递增子数组、求最长递增子序列或求最长公共子串、子序列等等;还有著名的背包问题,以及优惠券最合理使用问题。

2.2.2 求可行性

让你判断是否存在一条总和为 x 的路径(如果找到了,就是 True;如果找不到,自然就是 False),或者让你判断能否找到一条符合某种条件的路径,那么这类问题都可以归纳为求可行性问题,并且可以使用动态规划来解。

2.2.3 求总数

除了求最值与可行性之外,求方案总数也是比较常见的一类动态规划问题。比如给定一个数据结构和限定条件,让你计算出一个方案的所有可能的路径,那么这种问题就属于求方案总数的问题。

3.走进动态规划

这个章节主要讲解我自己理解的动态规划是什么,以及在学习过程中提炼的核心思想,以及推导过程。

3.1 动态规划核心思想

动态规划的核心思想是:动态规划是一种将问题实例分解为更小的、相似的子问题,并存储子问题的解而避免计算重复的子问题,以解决最优化问题的算法策略。 动态规划的实质是分治思想和解决冗余。

如果使用通俗易懂的一句话,那么应该是:拆分子问题,记住过往,减少重复计算。我们举一个简单日常的例子:

A :21+12+3+64+45+6+70+37 , 这个等式的值是多少? B :嗯,花了3分钟算出来答案是 258A : 在上面等式的右边写上 "+2" 呢,此时等式的值为多少? B : 很快得出答案 "260" A : 你怎么这么快就知道答案了 B : 只要在258的基础上加2就行了啊

3.2 动态规划解题套路

下面我们分几个步骤介绍动态规划的解题套路,分别是:

1)尝试设计暴力递归模型:重点!这是基础2)分析有没有重复解:(列出调用过程,可以只列出前几层)3)用记忆化搜索法 :空间换时间,减少重复计算4)状态转移方程:精细化分析DP表结构和递归模型,实现动态规划的状态转移方程

3.2.1 尝试暴力递归模型

这是实现动态规划算法的第一步,也是最基础最重要的前置条件;我们在这个步骤中,需要根据实际场景和问题去尝试设计递归模型,首先我们需要把问题转化为规模缩小了的同类问题的子问题,并且还需要确定明确的不需要继续进行递归的条件,我们俗称为 Base Case,不能让递归进入死循环哈。

暴力递归模型有一个致命的缺陷,即 仅得到了子问题的结果,但不会记录每个子过程的解;这样就导致产生重复计算,从而提高了算法的时间复杂度;比如在Leetcode上面的一些题目,如果仅仅使用暴力递归实现算法,提交的时候会报“超出时间限制”。

动态规划入门

3.2.2 分析重复解

也叫「重复子问题」,从「斐波拉契数列」求解的问题中,我们知道,如果递归地去这个问题,会遇到很多「重复子问题」。

斐波那契数列规定第一项是1,第二项是1,以后的每一项F(n) = F(n-1) + F(n-2)。我们展开求解第7项的过程如下:

动态规划入门

我们只需要将求第7项的值在白板展开后会发现,整个递归过程会有很多重复的过程,这时候我们就可以使用动态规划进行优化求解。

3.2.3 记忆化搜索法

对于重复出现的子问题,只在第一次遇到时加以求解,并把答案保存起来,让以后再遇到时直接引用,不必重新求解。

动态规划法所针对的问题有一个显著的特征,即它所对应的子问题树中的子问题呈现大量的重复。

比如上面的斐波那契数列,在我们求解过一次F(5)后,将其值存起来,下次再遇到需要求F(5)时直接取就可以了,减少了重复求解的过程。同理,对于每一个求解过的值都这样做,这就是动态规划。

并且根据算法中可变参数的个数,以此来分析并定义几维的缓存DP数组。

3.2.4 状态转移方程

我们不妨把状态转移方程理解为递归关系,就是如何从已知求得未知的表达式。

最终的方程式完全脱离了原题本意,是根据暴力递归模型和题目中给出的已知条件,比如:

Base Case是什么?

可变参数范围多大?

普遍位置如何依赖?

最终求的位置是哪个?

其他...

结合上面的已知条件提炼出一个算法(就是状态转移方程),对缓存DP数组进行填充赋值,最终返回DP数组中的某个位置即为我们最终求解的值。

所以,设计动态规划的步骤和解题套路流程如下图所示:

动态规划入门

4.动态规划实战和应用

本章节我们会通过1个动态规划的例子 和1个发票系统中实际应用动态规划的场景来介绍和讲解。

我们实战动态规划算法的过程,会按照上面讲述的动态规划解题套路中介绍的流程展开,分别是:

1)尝试设计暴力递归模型:重点!这是基础2)分析有没有重复解:(列出调用过程,可以只列出前几层)3)用记忆化搜索法 :空间换时间,减少重复计算4)状态转移方程:精细化分析DP表结构和递归模型,实现动态规划的状态转移方程

4.1 机器人走位问题

题目如下:

给定1~N个位置,有一个机器人在其中行走,每次只能往左、右走一步。

机器人从cur位置出发最终到达目标位置aim, 所走过的总步数必须等于restSteps,计算满足条件的走法有多少种?

比如:给定4个位置,机器人从2号位置发出,走过6步,最终到达4号位置, 一共有多少种走法?

注意:如果当前机器人走到了最左边1的位置,则只能走到2的位置;如果走到了N的位置,则只能走到N-1位置。

第一步:尝试设计暴力递归模型

这个题目是一个很经典的动态规划入门题,有些同学见到后可能有点蒙圈,不知道怎么解决。其实可以作如下分析:

退出递归的base case是什么:当剩余步数(restSteps)等于0,即 restSteps == 0

如何定义为一种满足条件的走法:当剩余步数(restSteps)等于0 且 机器人刚好停在aim位置,即 restSteps == 0 && cur==aim

递归场景:

如果当前机器人走到了最左边1的位置,则只能走到2的位置,

如果走到了N的位置,则只能走到N-1位置

否则,机器人可以往左或者 往右走

这个时候,我们可以得出最终的递归模型如下:

动态规划入门

据上分析,我们可以写出暴力递归版本的代码如下:

动态规划入门

到目前为止,我们已经设计出了暴力递归的代码,完成了动态规划解题套路的第一步;我们接下来看看是否有重复解。

第二步:分析是否存在重复解

我们给定如下数据做分析:

int cur = 2; //开始位置

int restSteps = 4; //剩余步数

int aim = 4; //目标位置

int N =4; //位置总数

一共有4个位置,需要从2号位置出发,机器人走过4步,最终能够停在4号位置的所有走法。(注意!这里为了方便我们可以在纸上展开所有的走法,我们特意讲走步数设置为4)

第一种走法:机器人从2 号位置出发,走到3号位置(剩余3步),再走到4号位置(剩余2步);再走到3号位置(剩余1步);最终走到4号位置(剩余0步)

第二种走法:机器人从2 号位置出发,走到3号位置(剩余3步),再走到2号位置(剩余2步);再走到3号位置(剩余1步);最终走到4号位置(剩余0步)

第三种走法:机器人从2 号位置出发,走到1号位置(剩余3步),再走到2号位置(剩余2步);再走到3号位置(剩余1步);最终走到4号位置(剩余0步)

移动过程详见如下图:

动态规划入门

分析第一种走法和第二种走法的第一步(2→3)的这个子过程,我们发现,都是当前位置(cur)从2走到3,剩余步数(restSteps)还剩余3步,这是一个重复计算的过程。

分析第二种走法和第三种走法的第三步(2→3)的这个子过程,我们同样可以发,都是当前位置(cur)从2走到3,剩余步数(restSteps)还剩余1步,同样,这也是一个重复计算的过程。

如果机器人可以走的步数越多,能统计出来的走法就越多;同时产生的重复计算的子过程也越多。

因此,我们展开前面3种走法,就知道了的确存在重复解;所以,我们可以进一步优化。

第三步:记忆化搜索法

首先,我们要分析整个递归调用过程中的可变参数有几个,根据上面的递归代码版本,我们得知函数process一共有4个入参,固定不变的入参有2个,分别是数组长度N,目标位置aim;可变的入参有2个,分别是机器人所在的当前位置cur,剩余的步数restSteps。

这里我们只关注可变参数,由此可知,我们需要准备一个2维数组来囊括这两个参数所有的变化范围,而cur的范围最大是N。即:dp[N+1][restSteps+1],这里+1 是为了方便理解能够包含全部范围和计算。

dp数组的第一维(行)表示机器人可走的所有位置,即范围从1~N。dp数组的第二位(列)表示机器人还剩余的步数,即范围从0~restSteps。

动态规划入门

PS:感兴趣的同学可以打印这两种算法的运行时间,随着restSteps的值越大,算法二的优势越明显,我这里则不再演示。

第四步:状态转移方程

状态转移方程可以理解为递归关系,就是如何从已知求得未知的表达式。

那么,我们需要分析上面暴力递归版本的递归关系,并能够进行如下推导:

1)递归调用模型中,根据base case填充dp数组

停止递归调用的条件(base case)为:剩余步数(restSteps)等于0时,且分两种情况:

  • 当机器人所在的当前位置(cur) 刚好停在目标位置(aim)时,即为1种走法;
  • 否则,即为0种走法。

所以,当剩余步数(restSteps)等于0时,即表示dp数组的第0列;再根据机器人最终停留的位置等于目标位置,将dp[aim][0]赋值为1,其他行的第一列都是0,这样我们就填充好了dp数组的第一列。

2)根据特殊场景填充dp数组

我们看题目中给出了哪些特殊场景,分别透露出了哪些信息呢?

  • 机器人走到最左边1号位置时,只能往右走到2号位置,相应的剩余步数减去1;
  • 机器人走到最右边N号位置时,只能往左走到N-1位置,相应的剩余步数减去1上面的定义得知,dp数组的第一维表示位置范围,因此:

填充dp数组第一行的逻辑为:dp[1][restSteps] 依赖于 dp[2][restSteps - 1]

填充dp数组最后一行的逻辑为:dp[N][restSteps] 依赖于dp[N-1][restSteps - 1]

3)普遍位置依赖填充dp数组

这是状态转移方程中最核心的部分,因为普遍性位置的依赖逻辑关系是比较隐秘的,也占了dp数组绝大多数位置空间。

我们分析的方式还是从暴力递归模型中找蛛丝马迹。

因为一个机器人如果不在最左边也不在最右边,那么他下一步移动的位置即可以往左也可以往右,依赖关系如下:

dp[cur][restSteps] = dp[cur+1][restSteps -1] + dp[cur - 1][restSteps -1]

即依赖于上一行左上角的值 加上 下一行左下角的值。

4)最终返回的位置

如果我们从已知条件中找到了未知的状态转移方程表达式,并填充好了dp数组,那我们就需要知道最终求的解时dp中的哪个位置。

我们还是需要从暴力递归模型中寻找。

在调用process递归方法时,最开始从的入参如下:

int cur = 2; //开始位置

int restSteps = 4; //剩余步数

int aim = 4; //目标位置

int N =4; //位置总数

...return process2(cur,restSteps,aim,N,dp);

其中,可变参数为:cur、restSteps 即为我们需要从dp数组中返回的最终值。

所以,cur等于2,restSteps等于4, 我们需要返回dp[2][4]。

推导过程如下:

动态规划入门

最终,基于上面的推导过程,最终状态转移方程代码如下:

动态规划入门

4.2 发票项目-可开票金额最优匹配

这是在我们项目中的一个功能场景,财务人员在开发票的过程中,会需要给消费者开具等额的发票,所以就需要根据用户消费的金额来匹配金额最相近的商品记录,具体流程如下:

1、从商品线获取符合要求的商品列表,商品金额随机分布

2、财务人员输入开票金额

3、系统自动勾选最接近开票金额的商品清单

我们分析这个问题属于动态规划算法的范畴,类似于背包问题的升级版本,就是将最大价值最终结果的路径记录,从而逆向推导出路径元素。

具体代码如下:

动态规划入门

该动态规划算法的实现过程和上述解题套路一样,这里由于篇幅暂不展开了。

5.结语

动态规划是一个很具有挑战性的话题和领域,其中变化高深莫测,同时也是我们解决复杂性问题的霹雳手段;

我们从最开始的尝试开始,一步一步的往下推导,这就是状态转移的过程,想要一步直接写出最后的动态规划代码来是需要不断的练习和总结的。

写到这里,深感惶恐,若大家都有收获便是善莫大焉!

作者:兴盛优选技术社区

出处:https://zhuanlan.zhihu.com/p/618731459