一、动态规划
1、从斐波那契数列说起
斐波那契数列我们比较熟悉了,它的递推公式为 f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n)=f(n-1)+f(n-2) f(n)=f(n−1)+f(n−2),它的解法在《数据结构与算法——递归》这一节里也说过了。直接使用递归会造成很多重复计算,它的时间复杂度为 O ( 2 N ) O(2^N) O(2N);而使用记忆化,即将中间过程缓存起来,可以降到 O ( N ) O(N) O(N)的时间复杂度。实际上这样的递归是“从上而下”的,我们可以“从下而上”地做,由 f ( 0 ) f(0) f(0)和 f ( 1 ) f(1) f(1)得到 f ( 2 ) f(2) f(2),再由 f ( 1 ) f(1) f(1)和 f ( 2 ) f(2) f(2)得到 f ( 3 ) f(3) f(3)……这样的写法如下所示:
F[0], F[1] = 0, 1
for i in range(2, n):
F[i] = F[i-1] + F[i-2]
上面的“由下而上地使用递推公式”代表了动态规划的基本思想。上面的斐波那契数列的解法,是最最简单的动态规划,实际上的递推公式会更复杂,比如会有各种限制条件。
动态规划的基本思想:问题的最优解如果可以由子问题的最优解推导得到,则可以先求解子问题的最优解,再构造原问题的最优解;若子问题有较多的重复出现,则可以自底向上从最终子问题向原问题逐步求解。[1]
状态:在动规解题中,我们将和子问题相关的各个变量的一组取值,称之为一个“状态”。[2]
状态转移方程:即递推公式。上面的斐波那契数列的递推公式是给定的,而一般情况下需要我们自己推导出来递推公式。
2、举例进一步说明
现在举个例子进一步说明动态规划:数路径问题。有下面一个 m m m行 n n n列的方格格,从最左上角的 ( 0 , 0 ) (0, 0) (0,0)出发,达到最右下角的 ( m − 1 , n − 1 ) (m-1, n-1) (m−1,n−1),一共有几条路?图上颜色的格子是障碍物不能走,并且只能向右或向下走。
对于这个问题,我们可以递归地求解,如下图所示。从一开始,可以向右走到B,也可以向下走到A,那么从start到end的有多少条路径=从B到end的路径数+从A到end的路径数
。同样从B到end的路径数=从E到end的路径数+从C到end的路径树
,对于A也是类似的,这样可以一直递归下去。但是这样会有个问题,和斐波那契数列的原始递归解法一样,有很多路径被重复计算了,比如从C到end的路径
,它的时间复杂度也会是指数级的。
根据上节对斐波那契数列的分析,我们可以采用“自底向上”的方式,如下图所示,我们从end开始往前推。要到达end,只有从红色箭头指的两个格子那里走(因为只能向右走或向下走)。也就是说,当处于这两个格子之一的时候,只有一条路可以走,我们在格子里记“1”。对于最下面一排的格子,我们都只能向右走,所以都标记为“1”。从蓝色箭头指的格子出发,我们可以向右走也可以向下走,有两条路所以标记为“2”。这个“2”其实是右边格子到end的路径数+下面格子到end的路径数
,也就是“1+1”。同理,白色箭头指的格子应该标记为1+1=2
,也有两条路可走;绿色箭头指的格子应该标记为2+1=3
,则有3条路可走。
就这样从end开始一直往上遍历每个格子,我们可以把格子都标记上,一直到start。最后start右边是17,下边是10,因此这个题的结果就是27。
动态规划模板:
# 以二维为例,只是一个大概的框架
def DP():
# 定义状态
dp = [[0 for i in range(m + 1)] for j in range(n + 1)]
# 状态初始化
dp[0][0], dp[0][1], ... = x, y, ...
# DP状态的推导
for i in range(m):
for j in range(n):
dp[i][j] = min(dp[i-1][j], dp[i][j-1])
return dp[m][n] # 最优解
二、例题
1、爬楼梯
此题为leetcode第70题。
假设我们现在在第n个阶梯,我们要求的是走到第n个阶梯有多少种走法,即 F ( n ) F(n) F(n)。我们知道只能走1步或2步,那么要走到第n个阶梯,只能从第n-1个阶梯或第n-2个阶梯走,那么走到第n个阶梯的走法个数等于走到第n-1个阶梯的走法个数加上走到第n-2个阶梯的走法个数。上述过程我们可以总结出此题的状态及状态转移方程:
- 状态: F ( n ) F(n) F(n),到第n个阶梯的走法个数
- 状态转移方程: F ( n ) = F ( n − 1 ) + F ( n − 2 ) F(n)=F(n-1)+F(n-2) F(n)=F(n−1)+F(n−2)
class Solution:
def climbStairs(self, n: int) -> int:
if n <= 2:
return n
a, b = 1, 2
for i in range(2, n):
c = a + b
b, a = c, b
return c
- 时间复杂度: O ( n ) O(n) O(n)
- 空间复杂度: O ( 1 ) O(1) O(1)
2、三角形最小路径和
此题为leetcode第120题
三角形是个二维数组,我们要求得从最上面的 ( 0 , 01 ) (0, 01) (0,01)出发,到最底下一层的最小路径和,即DF(0, 0)。比如上图的最小路径和的路径是 2 → 3 → 5 → 1 2 \to 3 \to 5 \to 1 2→3→5→1。此题不可以用贪心算法,反例如上面右图所示。要用动态规划,我们要从底向上地去思考。加入我们处于第 i i i行,那么从 ( i , j ) (i, j) (i,j)出发到底部的最小路径和为:(它的左下方格的最小路径和,它的右下方格的最小路径和)的最小值加上它自己。注意我们需要得到是“最小路径和”,而不是方格本身的最小值。由此我们可以确定本题的状态和状态转移方程:
- 状态: D F ( i , j ) DF(i, j) DF(i,j),第 ( i , j ) (i, j) (i,j)个方格到最底部的最小路径和
- 状态转移方程: D F [ i , j ] = m i n ( D F [ i + 1 , j ] , D F [ i + 1 , j + 1 ] ) + T r i a n g l e [ i , j ] DF[i, j] = min(DF[i+1, j], DF[i+1,j+1]) + Triangle[i, j] DF[i,j]=min(DF[i+1,j],DF[i+1,j+1])+Triangle[i,j],初始值为 D F [ m − 1 , j ] = T r i a n g l e [ − 1 , j ] DF[m-1, j]=Triangle[-1, j] DF[m−1,j]=Triangle[−1,j]
class Solution:
def minimumTotal(self, triangle: List[List[int]]) -> int:
m, n = len(triangle), len(triangle[-1])
DP = triangle[-1]
for i in range(m-1)[::-1]:
for j in range(len(triangle[i])):
DP[j] = min(DP[j], DP[j+1]) + triangle[i][j]
return DP[0]
- 时间复杂度: O ( m × n ) O(m \times n) O(m×n)
- 空间复杂度: O ( n ) O(n) O(n)
3、乘积最大子序列
此题是leetcode第152题。
我们设有一个数组a=[2, 3, -2, 4]
,其乘积最大的子序列为2, 3
,最大乘积为6。假设我们在第i
位,因为这个数可能为正也可能为负,所以我们需要记录最大乘积子序列和最小乘积子序列。我们可以这样定义状态和状态转移方程:
- 状态: D P m a x [ i ] DP_{max}[i] DPmax[i]和 D P m i n [ i ] DP_{min}[i] DPmin[i]。代表的意思是,在第 i i i个数时,从 0 → i 0 \to i 0→i的最大乘积子序列的乘积值,注意这里包括第 i i i个数。
- 状态转移方程:因为
a
[
i
]
a[i]
a[i]可能为正也可能为负,为负时要乘前面的最小子序列的乘积才能变为最大值,因此要区分
a
[
i
]
a[i]
a[i]为正负的情况:
D P m a x [ i ] = { D P m a x [ i − 1 ] × a [ i ] , a [ i ] ≥ 0 D P m i n [ i − 1 ] × a [ i ] , a [ i ] < 0 DP_{max}[i]= \begin{cases} DP_{max}[i-1] \times a[i], a[i] \geq 0 \\ DP_{min}[i-1] \times a[i], a[i] < 0 \end{cases} DPmax[i]={DPmax[i−1]×a[i],a[i]≥0DPmin[i−1]×a[i],a[i]<0
D P m i n [ i ] = { D P m i n [ i − 1 ] × a [ i ] , a [ i ] ≥ 0 D P m a x [ i − 1 ] × a [ i ] , a [ i ] < 0 DP_{min}[i]= \begin{cases} DP_{min}[i-1] \times a[i], a[i] \geq 0 \\ DP_{max}[i-1] \times a[i], a[i] < 0 \end{cases} DPmin[i]={DPmin[i−1]×a[i],a[i]≥0DPmax[i−1]×a[i],a[i]<0
最后的结果为 m a x { D P m a x } max\{DP_{max}\} max{DPmax}
class Solution:
def maxProduct(self, nums: List[int]) -> int:
if nums is None:
return None
# 这里我们不需要为nums里的每个元素都开辟一个存储最大最小值的空间
# 只需要当前元素和前一个元素的就行
DP_max, DP_min, res = [nums[0], nums[0]], [nums[0], nums[0]], nums[0]
for i in range(1, len(nums)):
num = nums[i]
x, y = i % 2, (i - 1) % 2
DP_max[x] = max(DP_max[y] * num, DP_min[y] * num, num)
DP_min[x] = min(DP_max[y] * num, DP_min[y] * num, num)
res = max(DP_max[x], res)
return res
- 时间复杂度: O ( N 2 ) O(N^2) O(N2)
- 空间复杂度: O ( 1 ) O(1) O(1)
4、最长上升子序列
此题为leetcode第300题。
假设有一序列a=[10, 9, 2, 5, 3, 7, 101, 18, 20]
,其最长上升子序列为2, 3, 7, 18, 20
,长度为5。假如我们处于第
i
i
i个位置,对于它前面的每个元素
j
j
j,都有个从0到
j
j
j的最长上升子序列长度,如果
a
[
i
]
>
a
[
j
]
a[i]>a[j]
a[i]>a[j],那么
a
[
0
]
a[0]
a[0]到
a
[
j
]
a[j]
a[j]的最长上升子序列再加上
a
[
i
]
a[i]
a[i]通用可以构成上升序列。所以对应每个
j
j
j,我们找到他们当中加上
a
[
j
]
a[j]
a[j]后最长上升子序列的长度即可。
- 状态: D P [ i ] DP[i] DP[i],从0到 i i i的最长上升子序列的长度(包含第 i i i个元素)
- 状态转移方程:对于 i ∈ [ 0 , n − 1 ] i \in[0, n-1] i∈[0,n−1], D P [ i ] = m a x { D P [ i ] , D P [ j ] + 1 } DP[i]=max\{DP[i], DP[j]+1\} DP[i]=max{DP[i],DP[j]+1},其中 j ∈ [ 0 , i − 1 ] j \in [0, i-1] j∈[0,i−1]且 a [ j ] < a [ i ] a[j] < a[i] a[j]<a[i]
最后的结果为 m a x { D P [ 0 ] , D P [ 1 ] , ⋯ , D P [ n − 1 ] } max\{DP[0], DP[1], \cdots, DP[n-1]\} max{DP[0],DP[1],⋯,DP[n−1]}
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
if nums is None or len(nums) == 0:
return 0
DP = [1] * len(nums)
res = 1
for i in range(1, len(nums)):
for j in range(0, i):
if nums[j] < nums[i]:
DP[i] = max(DP[j] + 1, DP[i])
res = max(DP[i], res)
return res
5、零钱兑换
此题为leetcode第322题。
设有不同面额的硬币coins=[1, 2, 5]
,和一个总金额amount=11
。这个题可以转为类似爬楼梯的问题(上面第1题),每次可以爬1、2、5步,一共有11级台阶,所需的最少步数是多少。
- 状态: D P [ i ] DP[i] DP[i],到达第 i i i阶时最少的步数
- 状态转移方程: D P [ i ] = m i n { D P [ i − c o i n s [ j ] ] } + 1 , j ∈ [ 0 , n − 1 ] and c o i n s [ j ] ≤ i DP[i]=min\{DP[i-coins[j]]\} +1,j \in [0, n-1] \space \space \text{and} \space \space coins[j] \leq i DP[i]=min{DP[i−coins[j]]}+1,j∈[0,n−1] and coins[j]≤i
最终结果为 D P [ a m o u n t ] DP[amount] DP[amount]
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
# 初始化一个长度为amount + 1的数组
DP = [0] + [amount+1] * (amount)
for i in range(1, amount+1):
for coin in coins:
if coin <= i:
DP[i] = min(DP[i], DP[i-coin] + 1)
if DP[amount] > amount: # 说明DP[i]没有被更新,没有可以组成amount的组合
return -1
else:
return DP[amount]
- 时间复杂度: O ( a m o u n t × n ) O(amount \times n) O(amount×n)
- 空间复杂度: O ( a m o u n t ) O(amount) O(amount)
6、编辑距离
此题为leetcode第72题
有两个单词word1
和word2
,假设我们处于word1
的第
i
i
i位和word2
的第
j
j
j位,分为两种情况。第一种情况,
w
[
i
]
=
=
w
[
j
]
w[i]==w[j]
w[i]==w[j],此时不需要任何变化,此时的最少的操作数为word1的第0到i-1个字符变为word2第0至j-1个字符所需的最少操作数
。第二种情况,
w
[
i
]
!
=
w
[
j
]
w[i]!=w[j]
w[i]!=w[j],那么可能执行三种操作(插入,删除,替换)。如果执行插入操作,那么到此步需要懂得最少操作数为word1的第0至i-1个字符变为word2第0至j个字符所需的最少操作数
;如果执行删除操作,那么到此步需要懂得最少操作数为word1的第0至i个字符变为word2第0至j-1个字符所需的最少操作数
;如果执行替换操作,那么和第一种情况是类似的。那到底选哪种操作呢,答案是选择使得当前状态最小的操作,即上面的三个操作中取
m
i
n
min
min。
- 状态:
D
P
[
i
]
[
j
]
DP[i][j]
DP[i][j],
word1
的前 i i i个字符变为word2
的前 j j j个字符所需的最少步数 - 状态转移方程:
D P [ i ] [ j ] = { D P [ i − 1 ] [ j − 1 ] , i f w o r d [ i ] = = w o r d [ j ] m i n { D P [ i − 1 , j ] ⏟ i n s e r t , D P [ i , j − 1 ] ⏟ d e l e t e , D P [ i − 1 , j − 1 ] ⏟ r e p l a c e } + 1 , i f w o r d [ i ] ! = w o r d [ j ] DP[i][j] = \begin{cases} DP[i-1][j-1], \space \space if \space \space word[i] == word[j] \\ min\{\underbrace{DP[i-1,j]}_{insert}, \underbrace{DP[i, j-1]}_{delete}, \underbrace{DP[i-1, j-1]}_{replace}\} + 1, \space \space if \space \space word[i] != word[j] \end{cases} DP[i][j]=⎩⎪⎨⎪⎧DP[i−1][j−1], if word[i]==word[j]min{insert DP[i−1,j],delete DP[i,j−1],replace DP[i−1,j−1]}+1, if word[i]!=word[j]
最终结果为 D P [ m ] [ n ] DP[m][n] DP[m][n]
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
# 初始化一个二维数组,为(m+1) x (n+1)大小
DP = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
# 初值
# word1的第0至i个字符变为空需要i步操作
# word1由空变为word的第0至j个字符需要j步操作
for i in range(m + 1): DP[i][0] = i
for j in range(n + 1): DP[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
DP[i][j] = DP[i - 1][j - 1]
else:
DP[i][j] = min(DP[i - 1][j], DP[i][j - 1], DP[i - 1][j - 1]) + 1
return DP[m][n]
- 时间复杂度: O ( m × n ) O(m \times n) O(m×n)
- 空间复杂度: O ( m × n ) O(m \times n) O(m×n)
7、股票买卖问题
这里我们讲解关于股票买卖的6道系列题:121、122、123、188、309、714。以上题目均可以用一个状态转移方程解决,只需稍微修改既可以。我们以一个通用的情况为例,即“每天可以完成 K K K笔交易”。注意:(1)一次交易是包括买和卖的过程;(2)当前有股票时只能卖,不能再次买入
我们设数组 a a a的长度为 N N N,我们可以设状态为到第 i i i天时所获得的最大利润,是一个一维数组。但我们会发现,在写状态转移方程时无法判断当前有无股票(无法判断当前应该买还是卖)、无法判断当前是第几次交易(达到最大交易次数则无法再交易),因此上面两个信息也应该出现在状态里。那么我们需要定义一个三维的状态:
-
状态的定义: D P [ i ] [ k ] [ j ] DP[i][k][j] DP[i][k][j],到第 i i i天时的最大利润。其中:(1) k ∈ [ 0 , K ] k \in [0, K] k∈[0,K],表示 i i i之前总共进行了多少次交易;(2) j = 0 o r 1 j=0 \space or \space 1 j=0 or 1,0表示当前无股票,只能不动或买,1表示当前有股票,只能不动或卖。
-
状态转移方程:当处于第 i i i天第 k k k次交易时,可能出现有股票也可能无股票的情况,即 j j j可取值为0或1。当 j = 0 j=0 j=0即没有股票时,可能是前一天也没有股票,当下也不做交易;也可能是前一天有股票,当下把它卖掉了,注意前一天有股票依然是第 k k k次交易,因为只有再次卖掉才算一次交易。同理当 j = 1 j=1 j=1即有股票时,可能是前一天有股票,当下不做交易;也可能是前一天无股票,当下买入了,注意前一天无股票那么就是已经完成了 k − 1 k-1 k−1次交易。
D
P
[
i
,
k
,
0
]
=
m
a
x
{
D
P
[
i
−
1
,
k
,
0
]
,前一天无股票,不动
D
P
[
i
−
1
,
k
,
1
]
+
a
[
i
]
,前一天有股票,卖掉
DP[i, k, 0]=max \begin{cases} DP[i-1, k, 0] \text{,前一天无股票,不动}\\ DP[i-1, k, 1] + a[i] \text{,前一天有股票,卖掉} \end{cases}
DP[i,k,0]=max{DP[i−1,k,0],前一天无股票,不动DP[i−1,k,1]+a[i],前一天有股票,卖掉
D
P
[
i
,
k
,
1
]
=
m
a
x
{
D
P
[
i
−
1
,
k
,
1
]
,前一天有股票,不动
D
P
[
i
−
1
,
k
−
1
,
0
]
−
a
[
i
]
,前一天无股票,买入
DP[i, k, 1]=max \begin{cases} DP[i-1, k, 1] \text{,前一天有股票,不动}\\ DP[i-1, k-1, 0] - a[i] \text{,前一天无股票,买入} \end{cases}
DP[i,k,1]=max{DP[i−1,k,1],前一天有股票,不动DP[i−1,k−1,0]−a[i],前一天无股票,买入
最终结果为 m a x { D P [ n − 1 , k , 0 ] , k ∈ [ 0 , K ] } max\{DP[n-1, k, 0], k \in [0, K]\} max{DP[n−1,k,0],k∈[0,K]}。在这样的状态和状态转移方程下,时间复杂度为 O ( N × K ) O(N \times K) O(N×K),空间复杂度为 O ( N × K ) O(N \times K) O(N×K)。
用这么一个状态转移方程就可以解决下面6道类似的题目:
188题: 最多可以完成 K K K笔交易
class Solution:
def maxProfit(self, K: int, prices: List[int]) -> int:
# 特殊情况,只有0天或1天无法完成一次完整的交易,利润为0
if len(prices) <= 1:
return 0
# 这里需要对K进行一下讨论:若K大于prices长度的一半,那么实际上可以进行不限次数的交易,和122题的情况一样
# 不区分这样的情况也可以,但在leetcode上会超时
if K < len(prices) // 2:
# 定义状态,三维数组
DP = [[[0, 0] for _ in range(K + 1)] for _ in range(len(prices))]
# 初始化状态,第0天的每次交易有股票的情况下,利润为-prices[0]
for k in range(1, K+1):
DP[0][k][1] = -prices[0]
# 根据状态方程遍历每天和每天的交易次数
res = 0
for i in range(1, len(prices)):
for k in range(1, K+1):
DP[i][k][0] = max(DP[i-1][k][0], DP[i-1][k][1] + prices[i])
DP[i][k][1] = max(DP[i-1][k][1], DP[i-1][k-1][0] - prices[i])
if res < DP[i][k][0]:
res = DP[i][k][0]
return res
# 和122题一样的情况,解释见下面的122题
else:
DP = [0, -prices[0]]
for price in prices[1:]:
DP[0] = max(DP[0], DP[1] + price)
DP[1] = max(DP[1], DP[0] - price)
return DP[0]
121题:只能进行一次交易
class Solution:
def maxProfit(self, prices: List[int]) -> int:
if len(prices) <= 1:
return 0
# 只能进行一次交易的话可以把K这一维去掉,同时我们只关注相邻的状态,DP的空间复杂度可以由O(N)变为O(1)
DP = [0, -prices[0]]
for price in prices[1:]:
DP[0] = max(DP[0], DP[1] + price)
DP[1] = max(DP[1], -price)
return DP[0]
122题: 可以交易无限次
class Solution:
def maxProfit(self, prices: List[int]) -> int:
if len(prices) <= 1:
return 0
DP = [0, -prices[0]]
# 可以交易无限次的话K这个维度也没有意义了,可以去掉
for price in prices[1:]:
DP[0] = max(DP[0], DP[1] + price)
DP[1] = max(DP[1], DP[0] - price)
return DP[0]
123题: 最多可以完成2笔交易
class Solution:
def maxProfit(self, prices: List[int]) -> int:
if len(prices) <= 1:
return 0
# 是188题的特殊情况,可以直接设K=2
K = 2
dp = [[0, -prices[0]] for _ in range(K + 1)]
res = 0
for i in range(1, len(prices)):
for k in range(1, K + 1):
dp[k][0] = max(dp[k][0], dp[k][1] + prices[i])
dp[k][1] = max(dp[k][1], dp[k - 1][0] - prices[i])
if dp[k][0] > res:
res = dp[k][0]
return res
309题: 不限交易次数,但有一天冷冻期,即卖出股票后,你无法在第二天买入股票
class Solution:
def maxProfit(self, prices: List[int]) -> int:
if len(prices) <= 1:
return 0
DP = [0, -prices[0]]
prev_prev = 0 # 只有在前前一天卖掉后才能买
for price in prices[1:]:
temp = DP[0] # 用一个临时变量保存前一天的DP[0]
DP[0] = max(DP[0], DP[1] + price)
DP[1] = max(DP[1], prev_prev - price)
prev_prev = temp # 当前操作完成后,temp就成了前前一天
return DP[0]
714题: 不限交易次数,但买的时候有交易费。
class Solution:
def maxProfit(self, prices: List[int], fee: int) -> int:
if len(prices) <= 1:
return 0
# 整体和122题一样
DP = [0, -prices[0]-fee] # 在买的时候减去交易费
for price in prices[1:]:
DP[0] = max(DP[0], DP[1] + price)
DP[1] = max(DP[1], DP[0] - price - fee) # 买的时候减去交易费
return DP[0]
总结
动态规划的题目最重要的是定义好状态和状态转移方程,同时要注意边界条件
参考文献
[1] https://www.cnblogs.com/hithongming/p/9229871.html
[2] https://blog.csdn.net/ailaojie/article/details/83014821