重识动态规划

4,057 阅读7分钟

"offer拦路虎"

初识动态规划问题时,真是非常头疼,而很多外企大厂(如MS, Amazon)又对此类问题“乐此不疲”。小可凭借多年外企面试经历以及学习动态规划算法的经验,其中也参考过诸多“套路”,给各位大侠总结一套实操性强的心法口诀。

心法

心法意在辨别形态,以静制动。

动态规划问题一般有 "一个模型" 与 "三个特征"。

一个模型

"一个模型" 指的是动态规划算法适合解决什么样问题的解题模型,我们可称之为“多阶段决策最优解模型”。用动态规划算法来解决最优值问题(最大/最小值,不同种类等),并把解决问题的过程划分成多个决策阶段,每个阶段对应一组状态,我们寻找到一组决策子序列,经过这组决策序列,就能产生最后的最优解。

三个特征

  • 最优子结构:是指问题的最优解包含子问题的最优解,亦或是通过子问题的最优解来推导出问题的最优解。

  • 无后效性:推导某一阶段的状态时,我们只关心前一阶段(或以前阶段)的状态,并不关系这个状态是如何一步一步推导出来的。

  • 重复子问题:推导状态时,会不断记录状态,相同子问题不会重复计算。

理论

如果遇到道行较深的大侠(资深面试官),也要胸有成竹地和他盘盘理论。

"心法" 表明,动态规划比较适合用来求解最优值问题,且无须知道最优值出现时的具体场景或条件无后效性)。动态规划问题可以把所有的可能性穷举出来,从中找到最优解,并且穷举后,发现存在重复子问题

例如 LeetCode 123. 买卖股票的最佳时机3。题干所求的是,规定最多交易两次的情况下取到的最大收益(最优值问题),并未要求列举出具体哪天买、哪天卖收益最大(无后效性:无须列举具体场景和条件),而且通过暴力方法可以枚举(可穷举)出,不交易的最大收益(肯定是0喽),仅交易一次的最大收益,仅交易两次的最大收益,然后在其中找到最大值即可。

所谓重复子问题,就是某个问题直接求解往往不直观且难度较大,但是把这个问题切分成规模更小的独立子问题后,求解起来会很容易,而且拆分出来的子问题依然可以继续拆分,且在拆分的过程中发现,部分子问题相同且已获得最优解,无须重复求解,直接从预先保存解的数组(可以理解成子问题解的缓存数组)读取即可。如果通过求解子问题的最优解(最值),能够计算出整个大问题的最优解,那么这种子问题的结构就是最优子结构

举例来讲,要解决 AA 问题时,可以将 AA 拆分成 a11a_{11}a12a_{12} 两个子问题,我们发现如果能求解出来 a11a_{11}a12a_{12} 这两个问题的最值就能计算出 AA 问题的解,那么 a11a_{11}a12a_{12} 问题结构就是最优子结构。但是此时发现 a11a_{11} 依然不好求解,要继续拆分成a12a_{12}a21a_{21}a22a_{22} 三个更小规模的问题。此时发现 a12a_{12} 这个子问题之前在拆分 AA 问题是已经解决了,并且在子问题最优解的数组中保存了当时计算的解,那么此时 a12a_{12} 子问题就无须再解决一遍了,直接读取值即可。如果直接求解 a12a_{12} 子问题的时间复杂度是 O(n)O(n) 级别的,那么读取 a12a_{12} 子问题解的时间复杂度就是 O(1)O(1) 级别,计算效率的提升可想而知。这也从侧面印证了动态规划算法是一种记忆化递推算法,以空间换取时间(毕竟存储状态的 dpdp 数组要消耗空间)。

口诀

口诀意在见招拆招,以不变应万变。

动规问题的"六脉神剑"

1、确定状态数组 dp,并时刻谨记其代表的含义

少商剑 - 石破天惊。动态规划问题最重要的一步,就是要搞清最优子结构。

确定 dpdp 状态数组以及代表的含义,并时刻谨记dpdp 数组可能是一维的,即 dp[i]dp[i],也有可能是多维的,即 dp[i][j]dp[i][j]dp[i][j][k]dp[i][j][k],它代表了 ii (jj, kk) 维度上的 dpdp 状态,这个状态和我们最终的最优解息息相关,。

举例来说:针对 LeetCode 123. 买卖股票的最佳时机3,设 dp[i][j][k]dp[i][j][k] 代表了第 ii 日,持股状态为 jj 时,交易次数为 zz 时,我们获得的最大利润,其中 j=0j = 0 代表不持股(持有现金), j=1j = 1 代表持股;k=0k = 0 代表交易过0次(尚未交易过),k=1k = 1 代表交易过1次,k=2k = 2 代表交易过2次。

2、确定状态转移方程

商阳剑 - 难以捉摸。动态规划问题最复杂的一步就是状态转移的递推方程。

既然动态规划问题是将一个大问题拆分成了若干独立子问题后求解的,那么就需要一个递推公式将若干子问题的解"串联",通过某种运算关系 fnf_n,才能获取问题整体的最优解。

状态转移方程形如: dp[i][j][k]=fn(dp[i1][j][k],dp[i][j1][k],dp[i][j][k1]dp[i][j][k] = f_n(dp[i - 1][j][k], dp[i][j - 1][k], dp[i][j][k - 1])

NOTE:

  • i1i - 1j1j - 1k1k - 1 仅仅是泛指在当前维度下,之前的某一个状态,并不是特指前一个状态;
  • dp[i][j][k]dp[i][j][k] 递推关系是示例,并不是确定的,依据不同题目的求解关系而定。

3、确定初始状态

中冲剑 - 气势雄迈。先解决边界处所有子问题的最优解。

初始状态代表 dpdp 状态数组的边界值,也是递推关系的开始,如 dp[0][0][0]dp[0][0][0]dp[i][0][0]dp[i][0][0]dp[0][j][0]dp[0][j][0]dp[0][0][k]dp[0][0][k] 等。一般情况下,i=0,j=0,k=0i=0,j=0,k=0 (各个维度的左边界) 亦或 i=I,j=J,k=Ki=I,j=J,k=K (各个维度的右边界)状态是比较容易确定的,它代表着那个最小子问题的最优解。

4、确定遍历顺序

关冲剑 - 以拙滞古朴取胜。从最小子问题开始。

一般情况下,遍历顺序可以从 [0,0,0][0,0,0][I,J,K][I, J, K],亦或 从 [I,J,K][I, J, K][0,0,0][0,0,0],以具体求解关系而定。

5、确定返回值

少冲剑 - 轻灵迅速。 快速回顾各个子问题的状态,从中找到全局最优解。

dpdp 状态数组仅仅是记录遍历过程中子问题的最优解,[0,0,0][0,0,0] 亦或 [I,J,K][I, J, K] 节点并不一定是返回值。问题整体的最优解,要结合 dpdp 状态数组的含义,才能知道具体的返回值。

6、回顾并转化成真代码

少泽剑 - 忽来忽去,变化精微。coding and debugging!

以上5点都是白板谈兵(外企面试最喜欢在白板上画来画去的...),最终要转化成实打实的代码。临门一射,争取 bug free,Ace!!!

实操

入门系列:

买卖股票系列

打家劫舍系列

子序问题系列

0-1背包系列

完全背包系列

编辑距离系列

数学问题

顿悟

动态三件套(心法+口诀+理论)齐全后,剩下的就是开练吧!

参考资料:

1、labuladong 手把手刷动态规划

2、代码随想录 Carl 动态规划

3、全栈潇晨的动态规划

4、负雪明烛 × 数据结构与算法