前言
首先说明下什么情况下会使用到动态规划来解决问题。简而言之,你看到一个问题,第一反应就是这问题能穷举,然后比较得出答案!但是,在穷举过程中我们不是一个路径一个路径的重新找,而是针对前面曾遍历过的信息通过创建一个n维列表存储起来,当我们列举新的路径的时候可以再重复利用遍历过的信息,从而不断扩展和维护列表。这就是动态规划的核心思想了。
因此,针对子数组、寻找路径、找零钱等问题,我们都可以采用这一思想来解决。话不多说,直接上题手撕。
1. 连续子数组的最大和
由浅入深,首先看Leecode简单难度的一道动态规划题目:连续子数组的最大和。网址如下:
连续子数组的最大和
PS:大家可以看题目要求及其它语言的代码,本文主要分析python版本代码。
思路分析:
本问题你一看,可以通过穷举出所有子集然后比较加和大小,那就可以用动态规划来实现。
首先说下动态规划问题的官话流程:确定子问题;确定状态;确定状态转移方程;确定边界条件;确定实现方式;确定优化方法
子问题就是数组子集,状态就是子集连续元素加和。
因为问题是一维的,所以可以通过维护一个一维数组来解决此问题,可以新建一个辅助数组来解决此问题,也可以直接用给出的数组来做。因为是连续子数组,可以将状态num[i]定义为到这个点为止的连续子数组最大和,因此状态转移方程可以定义为:
n
u
m
s
[
i
]
=
m
a
x
(
n
u
m
s
[
i
−
1
]
+
n
u
m
s
[
i
]
,
n
u
m
s
[
i
]
)
nums[i] = max(nums[i-1]+nums[i], nums[i])
nums[i]=max(nums[i−1]+nums[i],nums[i])
因为第0个数最大和肯定为其本身,因此从第一个数开始循环。
至此代码如下:
for i in range(1,len(nums)):
nums[i] = max(nums[i-1]+nums[i], nums[i])
return (max(nums))
2. 打家劫舍
再来看一道经典的入门题目:House Robber (小偷问题)。
打家劫舍
思路分析:
因为问题输入是一维的,原输入数组为nums,同时构建一个辅助的一维数组dp来解决此问题。和上个题类似,dp[n]代表前 n间能偷窃到的最高金额。此时最难的就是分析状态转移方程。首先本题有个限制条件:不能偷窃相邻的房屋,因此我们想知道的第n个房间可以由以下几种情况得到:
1.抢了第n间和它相隔的前面一间,那么dp[n] = dp[n-2] + nums[n];#nums[n]为当前房屋的价值
2.没抢第n间,那么dp[n] = dp[n-1] ;
3.没抢第n-1和n:首先不可能,因为我们所求dp[n]和dp[n-1]为最后两间且nums[n-1]和nums[n]均大于0,其次没抢n的话,dp[n-1] = dp[n-2],此时dp[n] = dp[n-1] + nums[n] = dp[n-2] + nums[n],同1。
因此,最后状态转移方程可以定义为:
d
p
[
n
]
=
m
a
x
(
d
p
[
n
−
2
]
+
n
u
m
s
[
n
]
,
d
p
[
n
−
1
]
)
dp[n] = max(dp[n-2]+nums[n], dp[n-1])
dp[n]=max(dp[n−2]+nums[n],dp[n−1])
至此代码如下:
m = len(nums)
dp = [0 for _ in range(m)]
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2,m):
dp[i] = max(dp[i-1],dp[i-2]+nums[i])
print(dp[m-1])
此时时间复杂度O(N),空间复杂度O(N),可以通过定义两个变量m和n交替记录,将空间复杂度降到 O(1)。m定义为dp[n-1],n定义为dp[n-2],每次运算左边向后移位一次。
至此代码如下:
m, n = 0, 0
for i in nums:
m, n = max(n + i, m), m
return m
3. 不同路径
再来看Leecode中等难度的一道动态规划题目,此题进阶为二维然后可以优化为一维。
不同路径
思路分析:
首先此题一看就知道可以穷举出来,所以就可以用动态规划来优化。
- 首先建立辅助矩阵dp[i][j]表示到达当前位置有几条路径;
- 建立状态转移方程即想到扩展列表的思路,本题中机器人每次只能向下或者向右移动一步,所以新的点有多少种路径,等于上面和左面两点的和,即: d p [ i ] [ j ] = d p [ i − 1 ] [ j ] + d p [ i ] [ j − 1 ] dp[i][j] = dp[i-1][j] + dp[i][j-1] dp[i][j]=dp[i−1][j]+dp[i][j−1]
- 填充列表中已知数据。对于第一行和第一列的数据,只有一条路能到达,即向右和向下直接走,因此可填充: d p [ i ] [ 0 ] = 1 , d p [ 0 ] [ j ] = 1 dp[i][0]=1,dp[0][j]=1 dp[i][0]=1,dp[0][j]=1
至此代码如下:
// 无优化直接循环
dp = [[0 for i in range(n)] for j in range(m)]
for i in range(m):
dp[i][0] = 1
for j in range(n):
dp[0][j] = 1
for i in range(1,m):
for j in range(1,n):
dp[i][j] = dp[i-1][j] + dp[i][j-1]
return dp[m-1][n-1]
至此题已经算是完成了,但是本着题完优化不会完的原则,对题目进行空间复杂度和时间复杂度的优化。目前时间复杂度为O(mn),空间复杂度为O(mn)。但是考虑到每点的值仅和上面左面有关联,因此可以通过仅维护一行或一列的值降低空间复杂度。直观点理解,比如我们维护一行,就是通过循环对每一行进行更新,一直到第m行后第n个值即为所求。
至此代码如下:
// 优化循环
dp = [1 for i in range(n)]
for i in range(1,m):
for j in range(1,n):
dp[j] += dp[j-1]
return dp[n-1]
此时时间复杂度为O(m*n),空间复杂度为O(n)。over收工。
4. 零钱兑换
下面在看一道经典的动态规划题目:找零问题,找零问题因为硬币可以使用无限多个,所以是完全背包问题,对于0-1背包和多重背包,本文暂且略过。
零钱兑换
完全背包相比于0-1背包和多重背包,三者非常相似,最大区别在于完全背包是从小到大枚举金额,而另外两者相反。为了方便快速理解,通过表格来分析例题:硬币为[1,2,5],总数为11,穷举表格如下:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
0 | 1 | 1 | 2 | 2 | 3 | 3 | 4 | 4 | 5 | 5 | 6 |
0 | 1 | 1 | 2 | 2 | 1 | 2 | 2 | 3 | 3 | 2 | 3 |
三行为硬币种类,及第一行为只有1,第二行有1和2,第三行为1,2,5。列为找零数。若维护二维数组,则就是此列表,而一维数组,则就是一行,直观理解就是随着硬币种类增加,维护的这一行逐渐下降一直到最后一行。从公式上分析,二维状态转移方程为:
d
p
[
i
]
[
j
]
=
m
i
n
(
d
p
[
i
−
1
]
d
p
[
j
]
,
d
p
[
i
]
[
j
−
c
]
+
1
)
dp[i][j] = min(dp[i-1]dp[j], dp[i][j-c]+1)
dp[i][j]=min(dp[i−1]dp[j],dp[i][j−c]+1)只与上一行同列和同一行前c列有关,而一维情况下下降一行则为本元素,因此可以很直观的优化降维状态方程:
d
p
[
j
]
=
m
i
n
(
d
p
[
j
]
,
d
p
[
j
−
i
]
+
1
)
dp[j] = min(dp[j], dp[j-i]+1)
dp[j]=min(dp[j],dp[j−i]+1)
至此代码如下:
// 优化循环
dp = [float('inf') for i in range(amount+1)]
dp[0] = 0
for c in coins:
for j in range(c,amount+1):
dp[j] = min(dp[j], dp[j-c]+1)
return dp[-1] if dp[-1] != float('inf') else -1
5. 青蛙过河
青蛙过河
这道题本身思路不难,状态转移方程很容易就能想到,列出这道题的原因是在题解中,有人通过哈希表做出了一个简单的方法。
首先这道题最直接的思路就是dp[i][j]表示跳 j 步能否跳到第 i 个石头。但是在写的过程中你会发现用于表示 j 的列太大了无法表示,所以采用哈希表来解决这个问题,即在dp[i]每个点采用set()或者dict()形式存储,此处采用set(),即dp[i][j]为True时,将 j 值存储在dp[i]中。最后判断dp[-1]是否为空则可以判断是否可以到达。
至此代码如下:
n = len(stones)
dp = [set() for i in range(n)]
dp[0].add(0)
for i in range(n):
cur = stones[i]
for j in range(i):
step = cur - stones[j]
if step - 1 in dp[j] or step in dp[j] or step + 1 in dp[j]:
dp[i].add(step)
return len(dp[-1]) > 0
本题和前面解题思路并无多大区别,主要在于数据存储的问题,困难也在于这一点。
6. 买卖股票的最佳时机 IV
又是一道经典的题目。买卖股票在LeeCode上有很多题目,分别如下:
121:最多进行 1 笔交易(k=1)(贪心)
122:不限交易次数(k=+inf)(贪心)
309:不限交易次数(k=+inf),但有冷冻期(二维dp)
714:不限交易次数(k=+inf),但有手续费(二维dp)
123:最多进行 2 笔交易(k=2)(三维DP或者一维DP+贪心)
188:最多进行 k 次交易(三维DP或者一维DP+贪心)
这里不多赘述,只选了困难188讲一下。PS:题解中Python3有一篇讲了所有的类型,感兴趣可以看下。
买卖股票的最佳时机 IV
这里只说一下一维DP+贪心的思路,首先进行一个清晰的思路解释。首先要明白因为只能持有一支股票,所以如果进行k次交易肯定是先后的,也就是[买,卖…,买,卖]。把成本和利润创造两个一维数组分别为cost和profit,长短为k+1。更清晰的分析下:当k=1的时候,就是贪心算法寻找最小成本赚取最大利润;当k=2时,cost1和profit1分别为第一只股票的成本和利润,然后当你买第二只股票的时候,你已经赚了profit1的钱,因此成本cost2为p-profit1,而两只股票合起来的最大利润为p-cost2。因为买卖占用两天,所以当k>=n//2,会变成122题不限制交易次数。
至此代码如下:
n = len(prices)
if n <= 1: return 0
if k >= n//2:
profit = 0
for i in range(1, n):
if prices[i] > prices[i - 1]:
profit += prices[i] - prices[i - 1]
return profit
else:
cost = [float('inf') for i in range(k+1)]
profit = [0 for i in range(k+1)]
for p in prices:
for i in range(1,k+1):
cost[i] = min(cost[i], p - profit[i-1])
profit[i] = max(profit[i], p - cost[i])
return profit[-1]
动态规划的刷题就先讲到这里啦,真正想特别熟练还是得多刷题,这里只是简单的刷题入门经典题目分享哈。又因为本人一向比较懒,各位看官觉得还可以就点赞评论,督促我写出下一篇,么~。