动态规划(Dynamic Programming)问题及其求解
动态规划(Dynamic Programming 简称 DP)是一类优化问题的总称,单从名称上来看,其实这里的programming并不是程序编程的意思,至于为什么要叫这个名字,只能说是算法界的一个历史原因,详情请查看Why is dynamic programming called dynamic programming?。
什么样的问题适合用DP来解决
DP的确是和一般算法和递归问题有特殊的特征,因此需要拿当前的问题和DP的问题解法套用。
解决DP问题的关键是定义合适的状态,如果定义正确的状态那么DP问题就解决了一半。
使用DP来解决问题的特征
能使用DP解决的问题具备下面3个特征
- 最优子结构:如果最优解的所有子问题的解也是最优的,那么就称这个问题具有最优子结构。
- 无后效性:也就是后面的状态一旦确定就不会影响之前决策过的状态。
- 有重复子问题:分拆的子问题之间不独立。
更多详细内容参照这里
DP问题思考和解决的过程是怎样的
- 递推
- 状态的定义
- 最优子结构
- 状态转移方程(DP方程)
DP问题的程序模版是怎样的
为了描述方便这里用一维DP问题为例,二维、三维问题类似:
1 | public int solveDynamicProgrammingProblem(int problemSize) { |
好的,现在我们有了锤子,就可以找钉子了。
一些适合DP的经典问题
- Fibonacci Number
- Climbing Stairs
- Coin Change
- Triangle
- Longest Increasing Subsequence
- Maximum Product Subarray
- Best Time to Buy and Sell Stock
- Best Time to Buy and Sell Stock II
- Best Time to Buy and Sell Stock III
- Best Time to Buy and Sell Stock IV
- Best Time to Buy and Sell Stock with Cooldown
- Best Time to Buy and Sell Stock with Transaction Fee
- Edit Distance
Fibonacci 数列
问题描述:
原题传送门
问题: 求Fibonacci数列的第n项值。
分析后知道由于Fibonacci数列定义的性质,我们有该问题是有递推的结构,并且状态转移方程其实就是数列的定义:$$f(n) = f(n - 1) + f(n - 2)$$
下面我们就用DP的方式来解fibonacci数列问题:
1 | public long fibonacci(int n) { |
下面做下复杂度分析:
首先是时间复杂度: DP的算法使用到了一个n-2的循环, 并且循环内部的计算结果使用了上面子问题的缓存,因此这个循环的复杂度是$O(n)$
其次是空间复杂度: DP的算法使用了额外的长度为n的数组因此空间复杂度为$O(n)$
这里使用了$O(n)$的额外空间,我们能否在空间上在寻找一个优化?答案是肯定的。
1 | public long fibonacci(int n) { |
好吧, 我们现在有了时间复杂度:$O(n)$, 空间复杂度$O(1)$的算法实现,抛开动态规划这个前提,就这个问题本身来说还有其他更好的解决方案么? 答案是肯定的:
斐波那契数列其实在数学上是有一个通项公式的:
$$
f(n) = \frac{(\frac{1 + \sqrt{5}}{2})^{n} - (\frac{1 - \sqrt{5}}{2})^{n}}{\sqrt{5}}
$$
这里可以通过这个数学上的通项公式求出结果,至于算法复杂度,因为过程中使用了n次幂的数学计算,这个计算的时间复杂度会是$O(log^n)$的,所以整个的时间复杂度会是$O(log^n)$
1 | public long fibonacci(int n) { |
类似的问题会有简单的变种,比如爬楼梯问题
爬楼梯问题
Climbing Stairs
这里的递推公式其实是一致的,$$f(n) = f(n - 1) + f(n - 2)$$
只是最开始的前3项数列值不同罢了,a[0] = 0, a[1] = 1, a[2] = 2, a[3] = 3 ……
1 | public long climbStairs(int n) { |
换硬币
Coin Change
如果将目标数额换成台阶数,给定的硬币面额换成可以走的步数,那么这个问题就转化成了爬楼梯的问题。
这里我们简单验证下DP问题的特征,无后效性(当前选择的硬币方案不会对之前的选择有影响)、重复子问题(数额从 1 到 i的问题求解是同样的子问题),和最优子结构(一旦数额确定,那么换硬币最少个数也是确定的)。所以符合DP求解问题的特征。下面我们用DP来求解该问题:
状态定义: $dp[i]: 数额为i的最小硬币个数$
状态转移方程: $dp[i] = Min(dp[i - coins[j]]) + 1$
结果所在:$dp[amount]$
时间复杂度 $O(amount * coins.length)$
对应代码:
1 | public int coinChange(int[] coins, int amount) { |
三角形最小路径和
无后效性,和重复子问题是显而易见的, 对于数组的某一个点(i,j)到着一点的最小路径和也是确定的,所以最优子结构也是成立的,符合DP解决问题的特征。下面用DP来求解:
状态定义: $dp[i][j] : 从最下一层三角形到 i,j 点的最小路径和$。
状态转移方程: $dp [i][j] = Min (dp[i + 1][j] , dp[i + 1][j + 1]) + array[i][j];$
时间复杂度: $O(m \cdot n)$ 其中$m$,$n$代表三角形的高度和底长度
对应代码:
1 | public int minimumTotal(List<List<Integer>> triangle) { |
乘积最大子序列
同样,无后效性、重复子问题是显然的,对于数组的第i个位置之前的子数组乘积最大子序列也是确定的,所以最优子结构也成立,符合DP解决问题特征。
状态定义: $dpPos[i]: 从数组下标0位置到i位置的乘积最大子序列值,dpNeg[i]从数组下标0位置到i位置的乘积负数最大子序列值$
状态转移方程:
$$
dpPos[i] = Max(dpPos[i - 1] \times nums[i], dpNeg[i - 1] \times nums[i], nums[i]) \
dpNeg[i] = Min(dpPos[i - 1] \times nums[i], dpNeg[i - 1] \times nums[i], nums[i])
$$
时间复杂度: $O(n)$
对应代码实现:
1 | int maxProduct(int[] nums) { |
最长递增子序列问题(Longest Increasing Subsequence)
拿到题目首先审题,能想到的问题会是什么?空数组怎么办?数组内的元素会不会有相邻相等的情况?当然这些都是你需要和面试官沟通的内容。
无后效性、重复子问题是显而易见的,对于数组i位置之前的子数组的最大递增子序列也是确定的,所以最优子结构也成立,符合DP解决问题的特征。
状态定义:$dp[i] 从头到第i个元素(选中第i个元素)最长子序列的长度$
状态转移方程:
$$
dp[i] = Max{dp[j]} + 1 (j: 0-> i -1 a[i] < a[j])
$$
所求解的值:
$$
Max(dp[0], dp[1], dp[2] …dp[n])
$$
时间复杂度: $O(n^2)$
1 | public int lengthOfLIS(int[] nums) { |
本题还有一个follow up, 要求寻找时间复杂度为$O(nlog^n)$的算法, 这个算法虽然存在,但跳出了DP的解题范围, 所以暂时就留一个TODO 作为题目完整性的补充(之后再来填坑)
1 | // TODO: Algorithm with time complexity O(nlog^n) |
编辑距离(Edit Distance/Levinstein Distance)
Edit Distance
这里解决编辑距离问题有几种常见思路,比如以一个字符串为目标字符串,对另一个字符串采取BFS/DFS“编辑操作”,然后记录编辑深度,在字符相等的时候停止,在所有可能的编辑方案中找出最小编辑距离。
由于这里是动态规划的专题,所以下面的方案主要围绕如何使用DP来解决该问题
首先是状态定义$minED(i, j)$表示word1的第i位之前的子字符串与word2的第j位之前子字符串的最小编辑距离
其中 $i = 0, 1 … len(word1); j = 0, 1…len(word2)$
状态转移方程:
$$
minED(i,j) =
\begin{cases}
minED(i - 1, j - 1), & \text{word1[i - 1] = word2[j - 1]}\
1 + min(minED(i - 1, j), minED(i - 1, j - 1), minED(i - 1, j + 1)), & \text{word1[i - 1] != word2[j - 1]}
\end{cases}
$$
1 | int minDistance(String word1, String word2) { |
1 | def minDistance(self, word1, word2): |
买股票系列问题
最简单的买股票时机问题
Best Time to Buy and Sell Stock
只允许交易一次(买入一次、卖出一次)的限制。
首先关于题目目前没有太多需要沟通的点,对于传入的参数做好必要的防御性编程就好。
首先看下所有可行解:
- 直觉算法(暴力法)
解法1查找数组的最小值, 然后从这个最小值位置开始到数组结束为止找出这个子数组的最大值,然后将最大值减去最小值,两者的差值就是所求最大利润值。
时间复杂度: 两次遍历数组$O(n)$
空间复杂度: $O(1)$
- DP
首先无后项性和重复子问题的特性是显而易见的, 对于最优子结构,第i天的最大利润值其实也是确定的,所以最优子结构也是成立的。 下面我们尝试使用动态规划的思路来解决这个问题。
状态定义: $mp(i) 表示第i天最大利润$
状态转移方程:
$$
mp(i) =
\begin{cases}
mp(i - 1) - a[i], & \text{买股票} \
mp(i - 1) + a[i], & \text{卖股票}
\end{cases}
$$
到这里我们发现对于买卖股票的操作无法用现有的状态定义了,说明现有的状态定义可能是缺少了维度,因此我们DP的定义进入了第二个版本:
状态定义2.0
状态定义: $mp(i, j) j = 0,1; mp(i, 0) 表示第i天未持有股票,mp(i, 1) 表示第i天持有股票$
似乎这里的定义还是有问题的,就是忽略了只能交易一次的限制,添加一个是否交易过(或者交易次数)的维度是否就可以呢?
当然下面的题目中还有一个泛化的k次交易限制的问题,可以使用k次的问题(k=1)来求解该题。
同一股票可以交易无数次(买之前需要将之前买进的股票卖出)
Best Time to Buy and Sell Stock II
我们发现对上面的状态做简单的修改就可以适用本题
状态定义: $mp(i,j) j = 0,1; mp(i,0) 表示第i天未持有,mp(i,1) 表示第i天持有股票$
状态转移方程:
$$
mp(i,0) = Max
\begin{cases}
mp(i - 1,0), & \text{i-1天持有}\
mp(i - 1,1) + a[i], & \text{i-1天未持有,第i天买入}
\end{cases}
$$
$$
mp(i,1) = Max
\begin{cases}
mp(i - 1, 1), & \text{i-1天未持有}\
mp(i - 1, 0) - a[i], & \text{i-1天持有, 第i天卖出}
\end{cases}
$$
同时如果细心你会发现,这其实也是交易K次泛化问题的一种特殊条件(k=N//2)。
交易两次,不能同时拥有两只股票
Best Time to Buy and Sell Stock III
交易两次其实是下面泛化K次的特殊情况(k=2),所以可以考虑先解决下面的k次泛化问题。
交易K笔,不能同时拥有两只股票
Best Time to Buy and Sell Stock IV
状态定义: $mp(i, j, k) 表示第i天最大利润 – j: 0 未持有股票 1 持有股票 – k:之前交易次数$
状态转移方程:
$$
mp(i, 0, k) = Max
\begin{cases}
mp(i-1, 0, k), & \text{}\
mp(i-i, 1, k-1) + a[i], & \text{}
\end{cases}
$$
$$
mp(i, 1, k) = Max
\begin{cases}
m([i-1, 1, k), & \text{}\
mp(i-1, 0, k-1) - a[i], & \text{}
\end{cases}
$$
1 | int maxProfitklimitedTransaction(int k, int[] prices) { |
1 | def max_profit_klimited_transaction(self, k, prices): |
到这里会发现,其实限制条件越多,用DP去解决问题所需要的维度就越多。
交易有冻结期
Best Time to Buy and Sell Stock with Cooldown
拥有冻结期其实就是在没有交易次数限制的题目上扩展了一个状态“cooldown”,从而在状态定义和状态转移方程的时候需要做对应的调整:
状态定义:$mp(i, j) j = 0,1,2 ; mp(i, 0) 表示第i天未持有,mp(i, 1) 表示第i天持有股票, mp(i, 2)处于交易冻结状态$
由于卖出股票后(即未持有股票状态)会转变为交易冻结状态,之后冻结状态会转变为未持有状态或者持有状态(这里建议画一个状态转移图,写状态转移方程的时候会更清晰些)。
状态转移方程:
$$
mp(i, 0) = Max
\begin{cases}
mp(i - 1, 0), & \text{i-1天持有}\
mp(i - 1, 1) + a[i], & \text{i-1天未持有,第i天买入}\
mp(i - 1, 2), &\text{i-1天处于冻结期,第i天未操作}
\end{cases}
$$
$$
mp(i, 1) = Max
\begin{cases}
mp(i - 1, 1), & \text{i-1天未持有}\
mp(i - 1, 2) - a[i], & \text{i-1天处于, 第i天买入}
\end{cases}
$$
$$
mp(i, 2) = mp(i - 1, 0)
$$
1 | int maxProfitWithCooldown(int[] prices) { |
1 | def maxProfitWithCooldown(self, prices): |
交易有手续费
Best Time to Buy and Sell Stock with Transaction Fee
这里的交易有手续费实际上是基于在没有交易次数限制的题目上扩展了一个交易费用扣除的条件,只需要在买入或卖出时扣除即可(注意:不要在买入和卖出都扣除)
状态定义: $mp(i,j) j = 0,1; mp(i,0) 表示第i天未持有,mp(i,1) 表示第i天持有股票$
状态转移方程:
$$
mp(i,0) = Max
\begin{cases}
mp(i - 1,0), & \text{i-1天持有}\
mp(i - 1,1) + a[i] - fee, & \text{i-1天未持有,第i天买入}
\end{cases}
$$
$$
mp(i,1) = Max
\begin{cases}
mp(i - 1, 1), & \text{i-1天未持有}\
mp(i - 1, 0) - a[i], & \text{i-1天持有, 第i天卖出}
\end{cases}
$$
1 | int maxProfitWithTransactionFee(int[] prices, int fee) { |
1 | def maxProfitWithTransactionFee(self, prices, fee): |