动态规划

68次阅读

共计 4416 个字符,预计需要花费 12 分钟才能阅读完成。

1 可信宣言

最近公司的可信考试认证如火如荼, 大众纷纷开始了刷题的痛快之旅
在字符串题目中, 有关动态规划(Dynamic Programming)算法 (y 以下简称 DP) 的题目很多
对于工作级别认证的我来说,不管是算法还是 OJ 里面遇到的问题里面感觉最难的也就是 DP
查找了相关的文献和资料, 准备攻破 DP

2 核心思想

A * "1+1+1+1+1+1+1+1 =?" *

A : "上面等式的值是多少"
B : * 计算 * "8!"

A * 在上面等式的左边写上 "1+" *
A : "此时等式的值为多少"
B : *quickly* "9!"
A : "你怎么这么快就知道答案了"
A : "只要在 8 的基础上加 1 就行了"
A : "所以你不用重新计算因为你记住了第一个等式的值为 8! 动态规划算法也可以说是' 记住求过的解来节省时间 '"

算法的核心就是 记住已经解决过的子问题的解

2 两种形式

记住求解的方式有两种

  • 自顶向下的备忘录法
  • 自底向上

为了说明动态规划的这两种方法,举一个最经典的例子:求斐波拉契数列 Fibonacci。先看一下这个问题:

Fibonacci (n) = 1;   n = 0

Fibonacci (n) = 1;   n = 1

Fibonacci (n) = Fibonacci(n-1) + Fibonacci(n-2)

该算法使用递归十分的简单 (但容易超时)
先使用递归版本来实现这个算法:

int fib(int n)
{if (n <= 0)
        return 0;
    if (n == 1)
        return 1;
    return fib(n - 1)+fib(n - 2);
}

递归算法的执行流程

假如输入 5,那么执行的递归树如下:

上面的递归树中的每一个子节点都会执行一次,于是很多重复的节点被执行,fib(2)被重复执行了 4 次。
由于调用每一个函数的时候都要保留上下文,所以空间上开销也不小。
这么多的子节点被重复执行,如果在执行的时候把执行过的子节点保存起来,后面要用到的时候直接查 hash 表的话可以节约大量的时间(空间换时间)。

下面就看看动态规划的两种方法怎样来解决斐波拉契数列 Fibonacci 数列问题。

将数组看成是特别的 hash 表, 下标即 hash 的 key, 对应数组值即为 hash 的 value

2.1 自顶向下的备忘录法(top-down with memoization)



创建一个 n+1 的数组来保存求出的斐波拉契数列中的每一个值

  • 在递归的时候如果发现前面 fib(n)的值计算出来了就不再计算
  • 如果未计算出来,则计算出来后保存在 Memo 数组中,下次在调用 fib(n)的时候就不会重新递归了。

比如上面的递归树中在计算 fib(5)的时候先计算 fib(4),调用 fib(4)算出了 fib(3)后,fib(5)再调用 fib(3)就不会再递归 fib(3)的子树了,因为 fib(3)的值已经保存在 Memo[4]中。

2.2 自底向上的动态规划

备忘录法还是利用了递归,其实并非正宗的 DP 算法
上面算法不管怎样,计算 fib(5)的时候最后还是要计算出 fib(1),fib(2),fib(3), 递归本身的开销不可避免

何不先直接计算出 fib(1),fib(2),fib(3)……, 呢?这也就是 DP 的核心,先计算子问题,再由子问题计算父问题。

自底向上方法也是利用数组保存了先计算的值,为后面的调用服务。
观察参与循环的只有 i,i – 1 , i – 2 三项,因此该方法的空间可以进一步的压缩如下。

一般来说由于备忘录方式的动态规划方法使用了递归,递归的时候会产生额外的开销,使用自底向上的动态规划方法要比备忘录方法好。

你以为看懂了上面的例子就懂得了动态规划吗?那就 too young too simple 了。动态规划远远不止如此,下面先给出一个例子看看能否独立完成。然后再对动态规划的其他特性进行分析。

3 算法导论经典问题 – 钢条切割

  • 来自于算法导论


现在使用一下前面讲到三种方法来来实现一下。

递归版本


递归的思路其实和回溯法是一样的,遍历所有解空间但这里和上面斐波拉契数列的不同之处在于,在每一层上都进行了一次最优解的选择,q = max(q, p[i-1] + cut(p, n-i))这个段语句就是最优解选择,这里上一层的最优解与下一层的最优解相关。

备忘录版本

备忘录方法就是在递归的时候记录下已经调用过的子函数的值
这道钢条切割问题的经典之处在于自底向上的动态规划问题的处理,理解了这个也就理解了动态规划的精髓。

自底向上的动态规划

最重要的是理解注释 1 处的循环,这里
外面的循环是求 r[1],r[2]……
里面的循环是求出 r[1],r[2]……的最优解
也就是说 r[i]中保存的是钢条长度为 i 时划分的最优解,这里面涉及到了最优子结构问题,也就是一个问题取最优解的时候,它的子问题也一定要取得最优解。

  • 下面是长度为 4 的钢条划分的结构图

4 DP 的原理

开始总结方法论吧!

无论是斐波拉契还是钢条切割,都涉及到了重叠子问题和最优子结构。

4.1 最优子结构

第一步就是刻画最优解的结构

如果一个问题的解结构包含其子问题的最优解,就称此问题具有最优子结构性质。
因此,某个问题是否适合应用 DP,甄别它是否具有最优子结构是一个极好的切入点。
使用 DP 时,用子问题的最优解来构造原问题的最优解。
因此必须考查 最优解中用到的所有子问题

4.2 重叠子问题

在斐波拉契数列和钢条切割结构图中,可以看到大量的重叠子问题,比如说在求 fib(6)的时候,fib(2)被调用了 5 次,在求 cut(4)的时候 cut(0)被调用了 4 次。如果使用递归算法的时候会反复的求解相同的子问题,不停的调用函数,而不是生成新的子问题。如果递归算法反复求解相同的子问题,就称为具有重叠子问题(overlapping subproblems)性质。在动态规划算法中使用数组来保存子问题的解,这样子问题多次求解的时候可以直接查表不用调用函数递归。

动态规划的经典模型
线性模型
线性模型的是动态规划中最常用的模型,上文讲到的钢条切割问题就是经典的线性模型,这里的线性指的是状态的排布是呈线性的。【例题 1】是一个经典的面试题,我们将它作为线性模型的敲门砖。

【例题 1】在一个夜黑风高的晚上,有 n(n <= 50)个小朋友在桥的这边,现在他们需要过桥,但是由于桥很窄,每次只允许不大于两人通过,他们只有一个手电筒,所以每次过桥的两个人需要把手电筒带回来,i 号小朋友过桥的时间为 T[i],两个人过桥的总时间为二者中时间长者。问所有小朋友过桥的总时间最短是多少。

每次过桥的时候最多两个人,如果桥这边还有人,那么还得回来一个人(送手电筒),也就是说 N 个人过桥的次数为 2 *N-3(倒推,当桥这边只剩两个人时只需要一次,三个人的情况为来回一次后加上两个人的情况…)。有一个人需要来回跑,将手电筒送回来(也许不是同一个人,realy?!)这个回来的时间是没办法省去的,并且回来的次数也是确定的,为 N -2,如果是我,我会选择让跑的最快的人来干这件事情,但是我错了…如果总是跑得最快的人跑回来的话,那么他在每次别人过桥的时候一定得跟过去,于是就变成就是很简单的问题了,花费的总时间:

T = minPTime * (N-2) + (totalSum-minPTime)

来看一组数据 四个人过桥花费的时间分别为 1 2 5 10,按照上面的公式答案是 19,但是实际答案应该是 17。

具体步骤是这样的:

第一步:1 和 2 过去,花费时间 2,然后 1 回来(花费时间 1);

第二歩:3 和 4 过去,花费时间 10,然后 2 回来(花费时间 2);

第三部:1 和 2 过去,花费时间 2,总耗时 17。

所以之前的贪心想法是不对的。我们先将所有人按花费时间递增进行排序,假设前 i 个人过河花费的最少时间为 opt[i],那么考虑前 i - 1 个人过河的情况,即河这边还有 1 个人,河那边有 i - 1 个人,并且这时候手电筒肯定在对岸,所以 opt[i] = opt[i-1] + a[1] + a[i] (让花费时间最少的人把手电筒送过来,然后和第 i 个人一起过河)如果河这边还有两个人,一个是第 i 号,另外一个无所谓,河那边有 i - 2 个人,并且手电筒肯定在对岸,所以 opt[i] = opt[i-2] + a[1] + a[i] + 2*a[2] (让花费时间最少的人把电筒送过来,然后第 i 个人和另外一个人一起过河,由于花费时间最少的人在这边,所以下一次送手电筒过来的一定是花费次少的,送过来后花费最少的和花费次少的一起过河,解决问题)
所以 opt[i] = min{opt[i-1] + a[1] + a[i] , opt[i-2] + a[1] + a[i] + 2*a[2] }

区间模型
区间模型的状态表示一般为 di,表示区间 [i, j] 上的最优解,然后通过状态转移计算出 [i+1, j] 或者 [i, j+1] 上的最优解,逐步扩大区间的范围,最终求得 [1, len] 的最优解。

【例题 2】给定一个长度为 n(n <= 1000)的字符串 A,求插入最少多少个字符使得它变成一个回文串。
典型的区间模型,回文串拥有很明显的子结构特征,即当字符串 X 是一个回文串时,在 X 两边各添加一个字符’a’后,aXa 仍然是一个回文串,我们用 di 来表示 A[i…j]这个子串变成回文串所需要添加的最少的字符数,那么对于 A[i] == A[j]的情况,很明显有 di = di+1(这里需要明确一点,当 i +1 > j- 1 时也是有意义的,它代表的是空串,空串也是一个回文串,所以这种情况下 di+1 = 0);当 A[i] != A[j]时,我们将它变成更小的子问题求解,我们有两种决策:

1、在 A[j]后面添加一个字符 A[i];

2、在 A[i]前面添加一个字符 A[j];

根据两种决策列出状态转移方程为:

di = min{di+1, di} + 1; (每次状态转移,区间长度增加 1)

空间复杂度 O(n^2),时间复杂度 O(n^2),下文会提到将空间复杂度降为 O(n)的优化算法。

背包模型
背包问题是动态规划中一个最典型的问题之一。由于网上有非常详尽的背包讲解,这里只将常用部分抽出来。

【例题 3】有 N 种物品(每种物品 1 件)和一个容量为 V 的背包。放入第 i 种物品耗费的空间是 Ci,得到的价值是 Wi。求解将哪些物品装入背包可使价值总和最大。fi 表示前 i 种物品恰好放入一个容量为 v 的背包可以获得的最大价值。决策为第 i 个物品在前 i - 1 个物品放置完毕后,是选择放还是不放,状态转移方程为:

fi = max{fi-1, fi-1 +Wi}

时间复杂度 O(VN),空间复杂度 O(VN)(空间复杂度可利用滚动数组进行优化达到 O(V))。

动态规划题集整理
1、最长单调子序列
Constructing Roads In JG Kingdom★★☆☆☆
Stock Exchange ★★☆☆☆

2、最大 M 子段和
Max Sum ★☆☆☆☆
最长公共子串 ★★☆☆☆

3、线性模型
Skiing ★☆☆☆☆

总结

弄懂动态规划问题的基本原理和动态规划问题的几个常见的模型,对于解决大部分的问题已经足够了。希望能对大家有所帮助,转载请标明出处 http://write.blog.csdn.net/md…,创作实在不容易,这篇博客花了我将近一个星期的时间。

参考

算法导论

本文由博客一文多发平台 OpenWrite 发布!

正文完
 0

动态规划

69次阅读

共计 3311 个字符,预计需要花费 9 分钟才能阅读完成。

重温动态规划~

今天刷 leetcode:<u>198. House Robber</u> 时用到了动态规划,看的是一个小哥哥的视频讲得灰常的清晰明了,推荐!!!<u>basketwangCoding</u>,看他的视频突然想到了算法分析课 DQ 老师当时给我们讲动态规划的场景,当时感觉这种思路 so amazing

由于刷题的时候发现很多地方都用到了动态规划,但是自己总是想不到,可能还缺一点火候吧,所以打算再看看书,梳理梳理它的思想原理

接下来是《算法导论第三版》的读书笔记

什么是动态规划

维基百科的解释:

动态规划(英语:Dynamic programming,简称 DP)通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。

动态规划常常适用于有 <u> 重叠 </u> 子问题和 <u> 最优 </u> 子结构(英语:Optimal substructure)性质的问题,动态规划方法所耗时间往往远少于朴素解法。

最优子结构:一个问题的最优解包含其子问题的最优解

所以如果我们需要寻找原问题的最优解,那么我们就需要考察最优解中用到的所有子问题的最优解

动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。

通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题 <u> 一次 </u>,从而减少计算量:一旦某个给定子问题的解已经算出,则将其 记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。

所以总结一下就是:

  • 动态规划适用于子问题有重叠依赖的情况,并且尝试寻找最优解
  • 动态规划的子问题一般只会计算一次,然后会将每个子问题的解存储下来,以便 下次使用
  • 动态规划一般用于求解最优化问题(最大 or 最长 or 最短等等),那么这个时候你就可以考虑用动态规划了,这类问题一般都会有很多的可行解,每一个可行解都有潜力成为那个最优的解,而我们的任务就是找到那个真正的最优的解

这里在提一下分治算法,分治算法和动态规划很像,都是通过组合子问题的解来求原问题,但是分治法的子问题之间是没有重叠的,也就是分治法的子问题之间 互不相交,通过递归的求解子问题,再将他们的解组合起来,如果你觉得比较抽象的话,可以想想归并算法,归并算法算是一个比较典型的分治算法了,归并排序可以移步:快速排序 - 归并排序 - 插入排序,分治法由于子问题都不相交,所以它会重复的求解一些公共的子子问题,相对于动态规划来说增加了计算开销

如何设计一个动态规划算法

我们可以参考如下 4 个步骤

  • 最优解有什么样的特点(题目的限定条件?)
  • 选定某一个状态进行递归定义最优解
  • 计算最优解(通常采用自底向上)

动态规划实例问题一:钢条切割问题

下面按照算法导论里面的讲解画了一个自己的思路,抽象派。。。(看不懂的话可以直接参考算法导论里面的讲解 15.1)

上面的这种算法采用自顶向下的思路,依次递归求解每个子问题,但是这种方式会重复计算大量的子问题,当 n 足够大时,会出现指数时间的增长

带备忘的自顶向下法

所以下面基于上面的方式进行了一个优化:以空间换时间

通过一个辅助空间来存储子问题的解,从而避免重复计算,我们也叫作“带备忘的自顶向下法(top-down with memoization)”

带备忘的自顶向下法会在计算子问题的过程中将计算结果保存下来,下次计算子问题的时候先检查是否已经保存过此解,如果保存过就直接返回保存的值,否则再计算

有自顶向下就肯定有自底向上

自底向上:自底向上一般需要提前规划好子问题的规模,使得任何子问题都只依赖于“更小的”子问题的解,所以我们需要将子问题按照规模排序 ,从小到大 依次求解。当求解一个子问题时,它所依赖的更小的子问题已经求解出来了,每个子问题只用求解一次(并且也只会遇到一次 )( 就像是一个不断向上攀登金字塔的过程,每走一步都是一个脚印,并且绝不会回头

通常来说自底向上方法比自顶向下方法具有更小的时间复杂性系数

带备忘的自顶向下法

这里的备忘的自顶向下的方法有几个思想我们可以借鉴:

采用一个 辅助数组 用来存储每个子问题的解,并且数组的初始值设置为 负无穷 这也是一种设置未知值得常用手段

递归求解时先判断辅助数组里面是否有值,如果有就直接返回

如果没有那么就按照正常递归求解,然后最后将结果保存到辅助数组里面

算法导论上还举了一个动态规划的例子:矩阵连乘,貌似有丢丢复杂,就没仔细看了,直接找了 DQ 老师的 PPT 温习了一遍

动态规划实例问题二:矩阵连乘问题

首先了解什么是矩阵连乘问题:通过使用合适的加括号的方式,使得最后矩阵连乘积的乘法次数最少

完整题目:

给定 n 个矩阵{A1,A2,…,An},其中 Ai 与 Ai+ 1 是可乘的,i=1,2 ,…,n-1。如何确定计算矩阵连乘积的计算次序,使得依此次序计算矩阵连乘积需要的数乘次数最少。

由于矩阵乘法满足结合律,所以计算矩阵的连乘可以有许多不同的计算次序。这种计算次序可以用加括号的方式来确定。

若一个矩阵连乘积的计算次序完全确定,也就是说该连乘积已完全加括号,则可以依此次序反复调用 2 个矩阵相乘的标准算法计算出矩阵连乘积

  • 对于 p × q 矩阵 Aq × r 矩阵 BA × B 需要多少次乘法计算?p × q × r 次乘法计算
  • 例如: AB 分别是 20 × 100, 100 × 10 矩阵, 则乘法总数为 20 × 100 × 10 = 20000

再来个例子~

通过上面的例子可以看出加括号的顺序对运算次数的影响之大,所以我们的任务就是为了找到一个最优的加括号的顺序,来使得最后的乘法次数最少

接下来开始入手,我们先将问题一般化

下面我们按照算法导论中的“动态规划四部曲”来一步一步的分析

  • 分析最优解的结构特征

    特征:如果 A[i:j]是最优次序,那么它所包含的计算矩阵子链 A[i:k]和 A[k+1:j]的次序也是最优的

满足最优子结构性质:最优解包含着其子问题的最优解

  • 建立递归关系

  • 计算最优解的值 通常采用自底向上

动态规划实现:

什么时候该用动态规划来做

看了上面两个例子我们大概对动态规划的问题有了一些了解,那到底什么时候应该派出动态规划这样的杀手锏呢

首先观察这个问题是否具有最优子结构的性质 最优解包含着其子问题的最优解

其次观察子问题之间是否有重叠

如果满足了上述条件,那么毫不犹豫用动态规划(算法导论里面还有提到贪心算法)

那么我们在实际过程中如何发觉原问题与子问题的关系呢?也就是如何挖掘最优子结构的性质

  • 首先先随机做出一个选择:比如我们切钢条,不知道第一刀切到哪,那么我们就随机选择一个位置切割,再比如我们划分矩阵链,不知道第一个划分位置在哪,我们也随机选择一个位置。那么做出的第一个选择,会产生多个待解的子问题

    还有一个重要的是我们不知道最优解到底是什么,那么我们就假设我们第一次的选择就是最优解,那么我们接下来的任务就是如何刻画这个最优解的子问题空间

  • 然后再继续寻找子问题的最优解,这样一步一步寻找就会找到一个通用的递归关系

还需要注意的是:尽量保证子问题空间简单,在必要时才扩展

比如我们前面切钢条,本来我们划分的是两个子问题:如果随机切一刀,那么必然会产生两部分:长度为 i 和长度为 n -i, 那么我们对于两部分又要分别去找最优切割方案,如果我们换个思路,就能将两个子问题空间缩小为 1 个子问题空间

但是这种思路不能用于矩阵乘积,因为一旦超过了 3 个矩阵那么有需要重新寻找最优的划分位置,所以那道题只能是两个子问题空间 A[i,k]和 A[k,j]

估算动态规划的时间复杂度

我们可以根据 子问题的个数以及每个子问题需要考虑的选择数 来粗略估计时间复杂度

对于钢条那题 r(n) = max(p(i) + r(n – i)) 其中 i ∈ [1,n]

也就是一共有 n 个子问题,而对于每个子问题又有 n- i 种选择,所以大概的时间复杂度为 O(n^2)

对于矩阵那题,

算法导论后面还讲了一 pa 拉烧脑的 ,比如子问题图中存在环怎么办,以及最长公共子序列,最优二叉搜索树。。。我没有继续看下去了,有兴趣的可以找来仔细研读研读 今天小秋就浅尝辄止到这啦~


欢迎关注:小秋的博客(包含 Java 后台开发,算法,数据结构,分布式,计算机网络等),小秋会时不时的分享自己的学习心得给大家

码字不易,点个赞吧:+1:

正文完
 0