尊重知识产权
掘金—程序员吴先生 [看动画轻松理解「递归」与「动态规划」]https://juejin.im/post/5c2308abf265da615304ce41#heading-8)
上一篇已经讲过递归 了, 现在我们重点介绍动态规划
什么是动态规划呢?
介绍动态规划之前先介绍一下分治策略(Divide and Conquer)。
分治策略
将原问题分解为若干个规模较小但类似于原问题的子问题(Divide),「递归」的求解这些子问题(Conquer),然后再合并这些子问题的解来建立原问题的解。
因为在求解大问题时,需要递归的求小问题,因此一般用「递归」的方法实现,即自顶向下。
动态规划(Dynamic Programming)
动态规划其实和分治策略是类似的,也是将一个原问题分解为若干个规模较小的子问题,递归的求解这些子问题,然后合并子问题的解得到原问题的解。
区别在于这些子问题会有重叠,一个子问题在求解后,可能会再次求解,于是我们想到将这些子问题的解存储起来,当下次再次求解这个子问题时,直接拿过来就是。
其实就是说,动态规划所解决的问题是分治策略所解决问题的一个子集,只是这个子集更适合用动态规划来解决从而得到更小的运行时间。
即用动态规划能解决的问题分治策略肯定能解决,只是运行时间长了。因此,分治策略一般用来解决子问题相互对立的问题,称为标准分治,而动态规划用来解决子问题重叠的问题。
与「分治策略」「动态规划」概念接近的还有「贪心算法」「回溯算法」,由于篇幅限制,程序员小吴就不在这进行展开,在后续的文章中将分别详细的介绍「贪心算法」、「回溯算法」、「分治算法」,敬请关注:)
将「动态规划」的概念关键点抽离出来描述就是这样的:
- 1.动态规划法试图只解决每个子问题一次
- 2.一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。
从递归到动态规划
还是以 爬台阶 为例,如果以递归的方式解决的话,那么这种方法的时间复杂度为O(2^n)。
相同颜色代表着 爬台阶问题 在递归计算过程中重复计算的部分。
通过图片可以发现一个现象,我们是 自顶向下 的进行递归运算,比如:f(n)
是f(n-1)
与f(n-2)
相加,f(n-1)
是f(n-2)
与f(n-3)
相加。
思考一下:如果反过来,采取自底向上,用迭代的方式进行推导会怎么样了?
由此可见,每一次迭代过程中,只需要保留之前的两个状态,就可以推到出新的状态。
def fib(n):
if n==1:
return 1
if n==2:
return 2
a,b = 1,2
while n>=3:
a,b = b,a+b
n -=1
return b
看一看出,事实上并没有增加太多的代码,只是简单的进行了优化,时间复杂度便就降为O(n),而空间复杂度也变为O(1),这,就是「动态规划」的强大!
详解动态规划
「动态规划」中包含三个重要的概念:
- 【最优子结构】
- 【边界】
- 【状态转移公式】
在「 爬台阶问题 」中
f(10) = f(9) + f(8)
是【最优子结构】
f(1) 与 f(2)
是【边界】
f(n) = f(n-1) + f(n-2)
【状态转移公式】
「 爬台阶问题 」 只是动态规划中相对简单的问题,因为它只有一个变化维度,如果涉及多个维度的话,那么问题就变得复杂多了。
难点就在于找出 「动态规划」中的这三个概念。
比如「 国王和金矿问题 」。
国王和金矿问题
有一个国家发现了 5 座金矿,每座金矿的黄金储量不同,需要参与挖掘的工人数也不同。参与挖矿工人的总数是 10 人。每座金矿要么全挖,要么不挖,不能派出一半人挖取一半金矿。要求用程序求解出,要想得到尽可能多的黄金,应该选择挖取哪几座金矿?
找出 「动态规划」中的这三个概念
国王和金矿问题中的【最优子结构】
国王和金矿问题中的【最优子结构】有两个:
- ① 4 金矿 10 工人的最优选择
- ② 5金矿10工人的最优选择—4 金矿 (10 - 5) 工人的最优选择
4 金矿的最优选择与 5 金矿的最优选择之间的关系是:
MAX[(4 金矿 10 工人的挖金数量),(4 金矿 5 工人的挖金数量 + 第 5 座金矿的挖金数量)]
国王和金矿问题中的【边界】 有两个:
- ① 当只有 1 座金矿时,只能挖这座唯一的金矿,得到的黄金数量为该金矿的数量
- ② 当给定的工人数量不够挖 1 座金矿时,获取的黄金数量为 0
国王和金矿问题中的【状态转移公式】
我们把金矿数量设为 N,工人数设为 W,金矿的黄金量设为数组G[],金矿的用工量设为数组P[],得到【状态转移公式】:
- 边界值:
f(n,w)=0 (n<=1,w<p[0])
F(n,w) = g[0] (n==1, w >= p[0])
F(n,w) = F(n-1,w) (n > 1, w < p[n-1])
f(n,w)=max(f(n-1,w),f(n-1,w-p[n-1])+g[n-1]) (n>1,w>=p[n-1])
国王和金矿问题中的【实现】
先通过几幅动画来理解 「工人」 与 「金矿」 搭配的方式
这是0-1背包问题
或者看不来,咱上动态图
1.只挖第一座金矿
在只挖第一座金矿前面两个工人挖矿收益为 零,当有三个工人时,才开始产生收益为 200,而后即使增加再多的工人收益不变,因为只有一座金矿可挖。
2.挖第一座与第二座金矿
在第一座与第二座金矿这种情况中,前面两个工人挖矿收益为 零,因为 W < 3,所以F(N,W) = F(N-1,W) = 0。
当有 三 个工人时,将其安排挖第 一 个金矿,开始产生收益为 200。
当有 四 个工人时,挖矿位置变化,将其安排挖第 二 个金矿,开始产生收益为 300。
当有 五、六 个工人时,由于多于 四 个工人的人数不足以去开挖第 一 座矿,因此收益还是为 300。
当有 七 个工人时,可以同时开采第 一 个和第 二 个金矿,开始产生收益为 500。
3.挖前三座金矿
这是「国王和金矿」 问题中最重要的一个动画之一,可以多看几遍
4.挖前四座金矿
这是「国王和金矿」 问题中最重要的一个动画之一,可以多看几遍
国王和金矿问题中的【规律】
仔细观察上面的几组动画可以发现:
- 对比「挖第一座与第二座金矿」和「挖前三座金矿」,在「挖前三座金矿」中,3 金矿 7 工人的挖矿收益,来自于 2 金矿 7 工人和 2 金矿 4 工人的结果,Max(500,300 + 350) = 650;
- 对比「挖前三座金矿」和「挖前四座金矿」,在「挖前四座金矿」中,4 金矿 10 工人的挖矿收益,来自于 3 金矿 10 工人和 3 金矿 5 工人的结果,Max(850,400 + 300) = 850;
代码—python
def good(n,w,g=[],p=[]):
'''
n:金矿数
w:工人数
g:金矿数组
p:人数数组
'''
if n <=1 and w<p[0]:
return 0
if n == 1 and w>p[0]:
return g[0]
#先构建一个矩阵
g.insert(0, 0)
p.insert(0, 0)#np.zeros((n+1,w+1),dtype=np.int32)
dp = [[0 for _ in range(w+1)] for _ in range(n+1)]
for i in range(1,1+n):
for j in range(1,1+w):
if p[i]<=j:
dp[i][j]=max(dp[i-1][j],dp[i-1][j-p[i]]+g[i])
#dp[i,j]=max(dp[i-1,j],dp[i-1,j-p[i]]+g[i])
else:
dp[i][j] = dp[i-1][j]
return dp[-1][-1]
if __name__ == '__main__':
res = good(5,10,[400,500,200,300,350],[5,5,3,4,3])
print(res)
咱们再尝试几个?
尊重知识产权
打家劫舍
题目:你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。 给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
转移公式:dp[i]=max(nums[i]+nums[i-2],nums[i-1])
边界值:dp[1]=nums[0] len(nums)==1
dp[i]=max(nums[0],nums[1])
class Solution(object):
def rob(self, nums):
"""
:type nums: List[int]
:rtype: int
"""
if nums==[]:
return 0
if len(nums)==1:
return max(nums)
#空数组
dp = [0]*len(nums)
dp[0] = nums[0]
dp[1] = max(nums[1],nums[0])
for i in range(2,len(nums)):
dp[i] = max(dp[i-1],dp[i-2]+nums[i])
return dp[len(nums)-1]
或者也可以这样:
class Solution(object):
def rob(self, nums):
"""
:type nums: List[int]
:rtype: int
"""
if not nums:
return 0
if len(nums) == 1:
return nums[0]
a = nums[0]
b = max(nums[0], nums[1])
for i in range(2, len(nums)):
a, b = b, max(a + nums[i], b)
return b
打家劫舍2
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
class Solution(object):
def judge(self,nums):
if nums==[]:
return 0
if len(nums)==1:
return max(nums)
#空数组
dp = [0]*len(nums)
dp[0] = nums[0]
dp[1] = max(nums[1],nums[0])
for i in range(2,len(nums)):
dp[i] = max(dp[i-1],dp[i-2]+nums[i])
return dp[len(nums)-1]
def rob(self, nums):
"""
:type nums: List[int]
:rtype: int
"""
if nums==[]:
return 0
if len(nums)==1:
return max(nums)
if len(nums)==2:
return max(nums[1],nums[0])
#选择偷最后一家,那就不偷第一家
num1 = nums[1:]
num2 = nums[:len(nums)-1]
val1 = self.judge(num1)
val2 = self.judge(num2)
return max(val1,val2)