动态规划(Dynamic Programming)是解决多阶段决策问题常用的最优化理论,动态规划和分治法一样,也是通过定义子问题,先求解子问题,然后在由子问题的解组合出原问题的解。但是它们之间的不同点是分治法的子问题之间是相互独立的,而动态规划的子问题之间存在堆叠关系(递推关系式确定的递推关系)。动态规划方法的原理就是把多阶段决策过程转化为一系列的单阶段决策问题,利用各个阶段之间的递推关系,逐个确定每个阶段的最优化决策,最终堆叠出多阶段决策的最优化决策结果。动态规划问题有很多模型,常见的有线性模型、(字符或数字)串模型、区间模型、状态压缩模型,等等,本节课后面介绍的最长公共子序列问题,就是一个典型的串模型。
动态规划比穷举高效,这一点在很多情况下都得到了印证,这常常给人一种错觉,以为它是高效的多项式时间算法,但是事实并非如此。动态规划法对所有子问题求解的内在机制其实是一种广域搜索,其效率在很大程度上还是取决于问题本身。每种方法都有自身的局限性,动态规划法也不是万能的。动态规划适合求解多阶段(状态转换)决策问题的最优解,也可用于含有线性或非线性递推关系的最优解问题,但是这些问题都必须满足最优化原理和子问题的“无后向性”。
最优化原理:最优化原理其实就是问题的最优子结构的性质,如果一个问题的最优子结构是不论过去状态和决策如何,对前面的决策所形成的状态而言,其后的决策必须构成最优策略。也就是说,不管之前的决策是否是最优决策,都必须保证从现在开始的决策是在之前决策基础上的最优决策,则这样的最优子结构就符合最优化原理。
无后向性(无后效性):所谓“无后向性”,就是当各个阶段的子问题确定以后,对于某个特定阶段的子问题来说,它之前各个阶段的子问题的决策只影响该阶段的决策,对该阶段之后的决策不产生影响。
这里需要解释一下无后向性。在解释之前,我们先淡化一下阶段的概念,只强调状态(决策状态),事实上,这是我本人学动态规划法过程中的一点经验。多阶段决策过程中,随着子问题的划分会产生很多状态,对于某一个状态 S 来说,只要 S 状态确定了以后,S 以后的那些依靠 S 状态做最优选择的状态也就都确定了,S 之后的状态只受 S 状态的影响。也就是说,无论之前是经过何种决策途径来到了 S 状态,S 状态确定以后,其后续状态的演化结果都是一样的,不会因为到达 S 状态的决策路径的不同而产生不同的结果,这就是无后向性。
动态规划的基本思想
和分治法一样,动态规划解决复杂问题的思路也是先对问题进行分解,然后通过求解小规模的子问题再反推出原问题的结果。但是动态规划分解子问题不是简单地按照“大事化小”的方式进行的,而是沿着决策的阶段来划分子问题,决策的阶段可以随时间划分,也可以随着问题的演化状态来划分。分治法要求子问题是互相独立的,以便分别求解并最终合并出原始问题的解。分治法对所有的子问题都“一视同仁”地进行计算求解,如果分解的子问题中存在相同子问题,就会存在重复求解子问题的情况。
比如某个问题 A,第一次分解为 A1 和 A2 两个子问题,A1 又可分解为 A11 和 A12 两个子问题,A2 又分解为 A21 和 A22 两个子问题,分治法会分别求解 A11、A12、A21 和 A22 四个子问题,即便 A12 和 A21 是相同的子问题,分治法也依然会计算四次子问题的解,这就存在重复计算的问题,重复计算相同的子问题会影响求解的效率。
与之相反,动态规划法的子问题不是互相独立的,子问题之间通常有包含关系,甚至两个子问题可以包含相同的子子问题。比如,子问题 A 的解可能由子问题 C 的解递推得到,同时,子问题 B 的解也可能由子问题 C 的解递推得到。对于这种情况,动态规划法对子问题 C 只求解一次,然后将其结果保存在一张表中(此表也被称为备忘录)。当求解子问题 A 或子问题 B 的时候,如果发现子问题 C 已经求解过(在备忘录表中能查到),则不再求解子问题 C,而是直接使用从表中查到的子问题 C 的解,避免了子问题 C 被重复计算求解的问题。
动态规划法不像贪婪法或分治法那样有固定的算法实现模式,线性规划问题和区间动态规划问题的实现方法就完全不同。作为解决多阶段决策最优化问题的一种思想,可以用带备忘录的穷举方法实现,也可以根据堆叠子问题之间的递推公式用递推的方法实现。但是从算法设计的角度分析,使用动态规划法一般需要四个步骤,分别是:
定义最优子问题(最优解的子结构)
定义状态(最优解的值)
定义决策和状态转换方程(定义计算最优解的值的方法)
确定边界条件
这四个问题解决了,算法也就确定了。接下来就结合两个实例分别介绍这四个步骤,这两个例子分别是经典的 0-1 背包问题和最长公共子序列问题。
定义最优子问题
定义最优子问题,也就是最优解的子结构,它确定问题的优化目标以及如何决策最优解,并对决策过程划分阶段。所谓阶段,可以理解为一个问题从开始到解决需要经过的环节,这些环节前后关联。
划分阶段没有固定的方法,根据问题的结构,可以是按照时间或动作的顺序划分阶段,比如《算法导论》书中介绍的“装配线与工作站问题“;也可以是按照问题的组合状态划分阶段,比如经典的“凸多边形三角剖分问题”。阶段划分以后,对问题的求解就变成了各个阶段分别进行最优化决策,问题的解就变成按照阶段顺序依次选择的一个决策序列。
对于 0-1 背包问题,每选择装一个物品可以看做是一个阶段,其子问题就可以定义为每次向包中装(选择)一个物品,直到超过背包的最大容量为止。最长公共子序列问题可以按照问题的演化状态划分阶段,这就需要先定义状态,有了状态的定义,只要状态发生了变化,就可以认为是一个阶段。
定义状态
状态既是决策的对象,也是决策的结果,对于每个阶段来说,对起始状态施加决策,使得状态发生改变,得到决策的结果状态。初始状态经过每一个阶段的决策(状态改变)之后,最终得到的状态就是问题的解。当然,不是所有的决策序列施加于初始状态后都可以得到最优解,只有一个决策序列能得到最优解。状态的定义是建立在子问题定义基础之上的,因此状态必须满足无后向性要求。必要时,可以增加状态的维度,引入更多的约束条件,使得状态定义满足无后向性要求。
0-1 背包问题本身是个线性过程,但是如果简单将状态定义为装入的物品编号,也就是定义 s[i] 为装入第 i 件物品后获得的最大价值,则子问题无法满足无后向性要求,原因是之前的任何一个决策都会影响到所有的后序决策(因为装入物品后背包容量发生变化了),因此需要增加一个维度的约束。
考虑到每装入一个物品,背包的剩余容量就会减少,故而选择将背包容量也包含在状态定义中。最终背包问题的状态 s[i,j] 定义为将第 i 件物品装入容量为 j 的背包中所能获得的最大价值。对于最长公共子序列问题,如果定义 str1[1…i] 为第一个字符串前 i 个字符组成的子串,定义 str2[1…j] 为第二个字符串的前 j 个字符组成的子串,则最长公共子序列问题的状态 s[i,j] 定义为 str1[1…i] 与 str2[1…j] 的最长公共子序列长度。
定义决策和状态转换方程
决策就是能使状态发生转变的选择动作,如果选择动作有多个,则决策就是取其中能使得阶段结果最优的那一个。状态转换方程是描述状态转换关系的一系列等式,也就是从 n-1 阶段到 n 阶段演化的规律。状态转换取决于子问题的堆叠方式,如果状态定义得不合适,会导致子问题之间没有重叠,也就不存在状态转换关系了。没有状态转换关系,动态规划也就没有意义了,实际算法就退化为像分治法那样的朴素递归搜索算法了。
0-1 背包问题的决策很简单,那就是决定是否选择第 i 件物品,即判断装入第 i 件物品获得的收益最大还是不装入第 i 件物品获得的收益最大。如果不装入第 i 件物品,则背包内物品的价值仍然是 s[i-1,j] 状态,如果装入第 i 件物品,则背包内物品的价值就变成了 s[i,j-Vi] + Pi 状态,其中 Vi 和 Pi 分别是第 i 件物品的容积和价值,决策的状态转换方程就是:
s[i,j]=max(s[i−1,j],s[i,j−Vi]+Pi)
最长公共子序列问题的决策方式就是判断 str1[i] 和 str2[j] 的关系,如果 str1[i] 与 str2[j] 相同,则公共子序列的长度应该是 s[i-1,j-1] + 1,否则就分别尝试匹配 str1[1…i+1] 与 str2[1…j] 的最长公共子序列,以及 str1[1…i] 与 str2[1…j+1] 的最长公共子序列,然后取二者中较大的那个值作为 s[i,j] 的值。最长公共子序列问题的状态转换方程就是:
s[i,j]=s[i−1,j−1]+1
其中,str1[i] 与 str2[j] 相同。
s[i,j]=max(s[i,j−1],s[i−1,j])
其中,str1[i] 与 str2[j] 不相同。
确定边界条件
对于递归加备忘录方式(记忆搜索)实现的动态规划方法,边界条件实际上就是递归终结条件,无需额外的计算。对于使用递推关系直接实现的动态规划方法,需要确定状态转换方程的递推式的初始条件或边界条件,否则无法开始递推计算。
0-1 背包问题的边界条件很简单,就是没有装入任何物品的状态:
s[0,Vmax]=0
若要确定最长公共子序列问题的边界条件,要从其决策方式入手,当两个字符串中的一个长度为 0 时,其公共子序列长度肯定是 0,因此其边界条件就是:
s[i,j]=0
其中,i=0 或 j=0。
动态问题分类
对问题进行分类,主要有以下几种:
达到目标的最小(最大)路径
问题列表: https://leetcode.com/list/55ac4kuc
声明
给定目标,找到达到目标的最小(最大)成本/路径/总和。
方法
在当前状态之前的所有可能路径中选择最小(最大)路径,然后为当前状态添加值。
routes[i] = min(routes[i-1], routes[i-2], ... , routes[i-k]) + cost[i]
为目标中的所有值生成最佳解决方案,然后返回目标的值。
for (int i = 1; i <= target; ++i) { for (int j = 0; j < ways.size(); ++j) { if (ways[j] <= i) { dp[i] = min(dp[i], dp[i - ways[j]] + cost / path / sum) ; } } } return dp[target]
类似问题
746.最低价攀登楼梯 Easy
for (int i = 2; i <= n; ++i) { dp[i] = min(dp[i-1], dp[i-2]) + (i == n ? 0 : cost[i]); } return dp[n]
64.最小路径总和 Medium
for (int i = 1; i < n; ++i) { for (int j = 1; j < m; ++j) { grid[i][j] = min(grid[i-1][j], grid[i][j-1]) + grid[i][j]; } } return grid[n-1][m-1]
322.硬币找零 Medium
for (int j = 1; j <= amount; ++j) { for (int i = 0; i < coins.size(); ++i) { if (coins[i] <= j) { dp[j] = min(dp[j], dp[j - coins[i]] + 1); } } }
931.最小下降路径总和 Medium
983.最低票价 Medium
650. 2键键盘 Medium
279.完美正方形 Medium
1049.最后一块石头的重量II Medium
120.三角形 Medium
474.一和零 Medium
221.最大广场 Medium
322.硬币找零 Medium
1240.用最小的正方形平铺一个矩形 Hard
174.地下城游戏 Hard
871.最小加油站数 Hard
不同路径
问题列表:Problem List: https://leetcode.com/list/55ajm50i
声明
给定目标,可以找到许多不同的路劲到达目标。
方法
总结所有可能的方法以达到当前状态。
routes[i] = routes[i-1] + routes[i-2], ... , + routes[i-k]
为目标中的所有值生成总和,然后返回目标的值。
for (int i = 1; i <= target; ++i) { for (int j = 0; j < ways.size(); ++j) { if (ways[j] <= i) { dp[i] += dp[i - ways[j]]; } } } return dp[target]
类似问题
70.爬楼梯 easy
for (int stair = 2; stair <= n; ++stair) { for (int step = 1; step <= 2; ++step) { dp[stair] += dp[stair-step]; } }
62.独特的道路 Medium
for (int i = 1; i < m; ++i) { for (int j = 1; j < n; ++j) { dp[i][j] = dp[i][j-1] + dp[i-1][j]; } }
1155.目标总数的骰子卷数 Medium
for (int rep = 1; rep <= d; ++rep) { vector<int> new_ways(target+1); for (int already = 0; already <= target; ++already) { for (int pipe = 1; pipe <= f; ++pipe) { if (already - pipe >= 0) { new_ways[already] += ways[already - pipe]; new_ways[already] %= mod; } } } ways = new_ways; }
PS: 一些问题指出了重复的次数,在这种情况下,还要增加一个循环来模拟每个重复。
688.国际象棋骑士的概率 Medium
494.目标总和 Medium
377.组合和IV Medium
935.骑士拨号器 Medium
1223.骰子滚动模拟 Medium
416.分区相等子集总和 Medium
808.汤服务 Medium
790. Domino和Tromino平铺 Medium
673.最长递增子序列数 Medium
63.独特之路II Medium
576.超越界限 Medium
1220.元音排列 Hard
合并问题
问题列表: https://leetcode.com/list/55aj8s16
此类问题的陈述模式如下:
给定一组数字,考虑到当前数字以及从左侧和右侧可获得的最佳数值,找到解决问题的最佳方案。
方法
找到每个间隔的所有最佳解决方案,并返回最佳答案。
// from i to j dp[i][j] = dp[i][k] + result[k] + dp[k+1][j]
从左侧和右侧获得最佳效果,并为当前位置添加解决方案。
for(int l = 1; l<n; l++) { for(int i = 0; i<n-l; i++) { int j = i+l; for(int k = i; k<j; k++) { dp[i][j] = max(dp[i][j], dp[i][k] + result[k] + dp[k+1][j]); } } } return dp[0][n-1]
类似问题
1130.从叶值得出的最小成本树 Medium
for (int l = 1; l < n; ++l) { for (int i = 0; i < n - l; ++i) { int j = i + l; dp[i][j] = INT_MAX; for (int k = i; k < j; ++k) { dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + maxs[i][k] * maxs[k+1][j]); } } }
96.唯一二进制搜索树 Medium
1039.多边形的最小分数三角剖分 Medium
546.删除盒子 Medium
1000.合并石头的最低成本 Medium
312.爆裂气球 Hard
375.猜猜数字更高或更低II Medium
字符串上的dp
此模式的一般问题陈述可能会有所不同,但大多数情况下会给您两个字符串,而这些字符串的长度并不大
问题描述
给定两个字符串 s1
和 s2
,返回某种结果。
方法
这种模式中的大多数问题都需要一个可以接受O(n^2)复杂度的解决方案。
// i - indexing string s1 // j - indexing string s2 for (int i = 1; i <= n; ++i) { for (int j = 1; j <= m; ++j) { if (s1[i-1] == s2[j-1]) { dp[i][j] = /*code*/; } else { dp[i][j] = /*code*/; } } }
如果给你一个字符串s
,方法可能几乎没有变化
for (int l = 1; l < n; ++l) { for (int i = 0; i < n-l; ++i) { int j = i + l; if (s[i] == s[j]) { dp[i][j] = /*code*/; } else { dp[i][j] = /*code*/; } } }
1143.最长公共子序列 Medium
for (int i = 1; i <= n; ++i) { for (int j = 1; j <= m; ++j) { if (text1[i-1] == text2[j-1]) { dp[i][j] = dp[i-1][j-1] + 1; } else { dp[i][j] = max(dp[i-1][j], dp[i][j-1]); } } }
647.回文子串 Medium
for (int l = 1; l < n; ++l) { for (int i = 0; i < n-l; ++i) { int j = i + l; if (s[i] == s[j] && dp[i+1][j-1] == j-i-1) { dp[i][j] = dp[i+1][j-1] + 2; } else { dp[i][j] = 0; } } }
516.最长回文序列 Medium
1092.最短的公共超序列 Medium
72.编辑距离 Hard
115.不同的子序列 Hard
712.两个字符串的最小ASCII删除总和 Medium
5.最长回文子串 Medium
做决定
问题列表:https://leetcode.com/list/55af7bu7
对于这种模式的一般问题陈述是在该情况下是否决定使用当前状态。因此,问题需要您在当前状态下做出决定。
声明
给定一组值,找到答案,并提供选择或忽略当前值的选项。
方法
如果决定选择当前值,请使用先前的结果并且需要考虑当前的状态;反之亦然,如果您决定忽略当前值,请使用使用值的先前结果。
// i - indexing a set of values // j - options to ignore j values for (int i = 1; i < n; ++i) { for (int j = 1; j <= k; ++j) { dp[i][j] = max({dp[i][j], dp[i-1][j] + arr[i], dp[i-1][j-1]}); dp[i][j-1] = max({dp[i][j-1], dp[i-1][j-1] + arr[i], arr[i]}); } }
198.强盗屋 Easy
for (int i = 1; i < n; ++i) { dp[i][1] = max(dp[i-1][0] + nums[i], dp[i-1][1]); dp[i][0] = dp[i-1][1]; }
121.买卖股票的最佳时间 Easy
714.带有交易费的最佳买卖股票时间 Medium
309.有冷却时间买卖股票的最佳时机 Medium
123.最佳买卖股票时间 Hard
188.最佳买卖股票时间IV Hard
总结
1、动态规划其实就是穷举遍历,并从中找出一个最优的解。因此,各种最短,最长问题都可以考虑动态规划。动态规划的穷举有点特别,因为这类问题存在「重叠⼦问题」,如果
2、虽然动态规划的核⼼思想就是穷举求最值,但是问题可以千变万化,穷举所有可⾏解其实并不是⼀件容易的事,只有列出正确的「状态转移⽅程」才能正确地穷举。
3、对于状态转移方程,很多时候我们不知道 dp 是几维数组,其实只要看题目有多少变量,有几个变量就是几维,结果是要求的解。其实就是 f(x,y,z)=m; x, y, z, 和 m 的含义都理清楚了,这个状态方程就对了。
参考文章