目录
- 定义
- 动态规划的步骤
- 例题分析
- 算法对比
- 总结
一、定义
1.1 定义
把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法——动态规划 --百度定义
动态规划算法(Dynamic Programming-DP)是通过拆分问题,定义问题状态和状态之间的关系,将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策选取那些有可能达到最优的局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
1.2 能用动规解决的问题的特点
- 问题具有最优子结构性质。如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质。
- 无后效性:某状态以后的过程不会影响以前的状态,只与当前状态有关。
- 有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)
1.3 说明
已知问题规模为n的前提A,求解一个未知解B。(我们用An表示"问题规模为n的已知条件")
此时,如果把问题规模降到0,即已知A0,可以得到A0->B.
1)如果从A0添加一个元素,得到A1的变化过程。即A0->A1; 进而有A1->A2; A2->A3; …… ; Ai->Ai+1. 这就是严格的归纳推理,也就是我们经常使用的数学归纳法;
对于Ai+1,只需要它的上一个状态Ai即可完成整个推理过程(而不需要更前序的状态)。我们将这一模型称为马尔科夫模型。对应的推理过程叫做"贪心法"。
2)然而,Ai与Ai+1往往不是互为充要条件,随着i的增加,有价值的前提信息越来越少,我们无法仅仅通过上一个状态得到下一个状态,因此可以采用如下方案:
{A1->A2}; {A1, A2->A3}; {A1,A2,A3->A4};……; {A1,A2,...,Ai}->Ai+1. 这种方式就是第二数学归纳法。
对于Ai+1需要前面的所有前序状态(或几个状态)才能完成推理过程。我们将这一模型称为高阶马尔科夫模型。对应的推理过程叫做"动态规划法"。
二、动态规划的步骤
这个步骤可能有点枯燥,建议结合后面例题进行阅读。
2.1 定义子问题
子问题是和原问题相似,但规模较小的问题。第一步就是缩小规模,利用小规模子问题刻画其结构特征。
- 划分阶段:按照问题的时间或空间特征,把问题分为若干个阶段。在划分阶段时,注意划分后的阶段一定要是有序的或者是可排序的,否则问题就无法求解。
- 确定状态和状态变量:将问题发展到各个阶段时所处于的各种客观情况用不同的状态表示出来。当然,状态的选择要满足无后效性。
说明:创建一个一维数组或者二维数组,保存每一个子问题的结果,具体创建一维数组还是二维数组看题目而定
注意:子问题的解一旦求出就会被保存,所以每个子问题只需求 解一次。
2.2 写出子问题的递推关系
找出状态转换方程,也就是说找到每个状态跟他上一个状态的关系,根据状态转化方程写出代码。
- 确定一些初始状态(边界状态)的值
- 确定状态转移方程: 定义出什么是"状态",以及如何从一个或多个"值"已知的 "状态",求出另一个"状态"的"值"(递推型)。状态的迁移可以用递推公式表示,此递推公式也可被称作"状态转移方程"。
2.3 确定 DP 数组的计算顺序
在确定了子问题的递推关系之后,下一步就是依次计算出这些子问题了。一般地,动态规划有两种计算顺序:
- 自顶向下的、使用备忘录的递归方法
- 自底向上的、使用 dp 数组的循环方法。
不过在普通的动态规划题目中,99% 的情况我们都不需要用到备忘录方法,即自底向上的 dp 数组。
2.4 空间优化(可选)
目的:空间复杂度的较小。
在很多问题中,DP数组并非全部用上,很多情况下只使用一小部分或空间可重复利用,这给空间压缩带来可能。
三、例题分析 - leetcode198. 打家劫舍
3.1 问题
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
示例 1:
输入: [1,2,3,1]
输出: 4
解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。 偷窃到的最高金额 = 1 + 3 = 4 。
示例 2:
输入: [2,7,9,3,1]
输出: 12
解释: 偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。偷窃到的最高金额 = 2 + 9 + 1 = 12 。
3.2 求解
1)步骤一:定义子问题
- f(k)表示从前面k个房子能获取的最大金额;
- 原问题要能由子问题表示。例如这道小偷问题中,k=n 时实际上就是原问题;
- 一个子问题的解要能通过其他子问题的解求出。例如本题:f(k) 可以由 f(k-1)和 f(k-2)求出,具体原理后面会解释。这个性质就是所说的"最优子结构"。如果定义不出这样的子问题,那么这道题实际上没法用动态规划解。
2)写出子问题的递推关系
每个房子的金额用H表示,那么k个房子有两种偷发,如上图所示。容易得出递推公式(重要):
注意这里涉及到边界值:
3)确定 DP 数组的计算顺序
那么,既然 DP 数组中的依赖关系都是向右指的,DP 数组的计算顺序就是从左向右。这样我们可以保证,计算一个子问题的时候,它所依赖的那些子问题已经计算出来了。
确定了 DP 数组的计算顺序之后,我们就可以写出题解代码了:见3.3的代码一。
4 )空间优化(可选)
最后一步计算 f(n) 的时候,实际上只用到了 f(n-1)和 f(n-2) 的结果。那么只用两个变量保存两个子问题的结果,就可以依次计算出所有的子问题。下面的图比较了空间优化前和优化后的对比关系,代码见3.3代码二:
3.3 代码
代码一:未进行优化的DP
def rob(self, nums: List[int]) -> int: if len(nums) == 0: return 0 # 子问题: # f(k) = 偷 [0..k) 房间中的最大金额 # f(0) = 0 # f(1) = nums[0] # f(k) = max{ rob(k-1), nums[k-1] + rob(k-2) } N = len(nums) dp = [0] * (N+1) dp[0] = 0 dp[1] = nums[0] for k in range(2, N+1): dp[k] = max(dp[k-1], nums[k-1] + dp[k-2]) return dp[N]
代码二:优化后的DP
def rob(self, nums: List[int]) -> int: prev = 0 curr = 0 # 每次循环,计算“偷到当前房子为止的最大金额” for i in nums: # 循环开始时,curr 表示 dp[k-1],prev 表示 dp[k-2] # dp[k] = max{ dp[k-1], dp[k-2] + i } prev, curr = curr, max(curr, prev + i) # 循环结束时,curr 表示 dp[k],prev 表示 dp[k-1] return curr
四、算法对比
4.1 动态规划和分治区别
动态规划算法:它通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。
分治法:若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。
分治算法不强调记录算过的数据,动态规划为了避免重复计算,一定会记录数据。
五、总结
不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。
参考文献:
【1】 六大算法之三:动态规划
【2】leetcode动态规划的解析