【算法】动态规划:从斐波那契数列到背包问题
文章目录
1.斐波那契数列
斐波那契数列(Fibonacci sequence)是一个非常著名的数列,在数学中有着广泛的应用。这个数列的每一项都是前两项的和,通常从1开始。数列的前几项是这样的:
1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, …
数学上,斐波那契数列可以定义为如下递归形式:
F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2),对于 n ≥ 2 的整数。
依据这个递推式,我们将我们想要求的F(n)
转换为求F(n-1)
与F(n-2)
的值,这就是动态规划中的状态转移方程。简单来说,F(n)的得来是由F(n-1)
与F(n-2)
决定的。
那么F(n-1)
与F(n-2)
从何而来呢?
我们可以将它先计算好,存起来,用的时候直接拿来用就行。
这就是动态规划中的记忆化:
通过将已经计算过的子问题的结果存储起来,当再次遇到相同的子问题时,可以直接使用存储的结果,从而节省计算时间和资源。
所以如何用动态规划来求出F(n)
呢?
动态规划有三大步:
- 定义子问题
- 定义状态数组dp
- 定义状态转移方程
我们有dp[i]
来代表F(i)
的值
首先我们需要初始化dp
数组:
dp=[0]*(n+1)
然后我们先预处理一些值:
dp[1],dp[2]=1,1
这样之后,我们就可以得出dp[3]:
dp[3]=dp[2]+dp[1]
以此类推……
我们用一个for
循环就都可以表示出来:
for i in range(3,n+1):
dp[i]=dp[i-1]+dp[i-2]
最后dp[n]
就是我们要求的值了。
完整代码:
def fibonacci(n):
if n <= 1:return n
dp = [0] * (n + 1)
dp[1],dp[2] = 1,1
for i in range(3, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
2.爬楼梯
了解这些之后,就可以做一些动态规划的题目了
而力扣的70. 爬楼梯与斐波那契数列非常相似
题目:
假设你正在爬楼梯。需要 n
阶你才能到达楼顶。
每次你可以爬 1
或 2
个台阶。你有多少种不同的方法可以爬到楼顶呢?
示例 1:
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
示例 2:
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
提示:
1 <= n <= 45
该题中,我们只能爬1阶或2阶,求爬到楼顶的方法数
那么首先我们想到该如何爬到楼顶
我们可以在离楼顶差一阶的时候再走1阶,差两阶的时候再走2阶。
由于本题要求爬到楼顶的方法数,所以我们可以定义子问题:求楼梯阶数为i
时,我们爬到楼顶所需的方法数。
依据子问题,我们可以定义状态数组:
用dp[i]
来表示当楼梯阶数为i
时,我们爬到楼顶所需的方法数。
而我们又知道:
i
是由i-1
再走1阶或者i-2
再走2阶而来。
所以dp[i](楼梯阶数为i时的方法数)
就等于dp[i-1](楼梯阶数为i-1时的方法数)
加上dp[i-2](楼梯阶数为i-2时的方法数)
所以我们就可以得出状态转移方程:
dp[i]=dp[i-1]+dp[i-2]
是不是很熟悉?
没错,这和斐波那契数列的状态转移方程一模一样
不过区别在于预处理的值不同:
dp[1],dp[2]=1,2
所以这道题也就解决了:
class Solution(object):
def climbStairs(self, n):
"""
:type n: int
:rtype: int
"""
if n<=1:return n
dp=[0]*(n+1)
dp[1],dp[2]=1,2
for i in range(3,n+1):
dp[i]=dp[i-1]+dp[i-2]
return dp[n]
思考一:该题中的dp[i]=dp[i-1]+dp[i-2]
为什么是正确的?
我们将这题升级以下,可爬的阶梯数是不确定的,放在一个数组stairs
中,那么代码可以这样写:
class Solution(object):
def climbStairs(self, n):
"""
:type n: int
:type stairs: List[int]
:rtype: int
"""
dp=[0]*(n+1)
dp[0]=1
for i in range(1,n+1):
for j in stairs:
if i-j>=0:dp[i]+=dp[i-j]
return dp[n]
提示:如果感觉有点难理解请先看下一题
3.零钱转换
接下来我们上升难度,力扣第332题:322. 零钱兑换
题目:
给你一个整数数组 coins
,表示不同面额的硬币;以及一个整数 amount
,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1
。
你可以认为每种硬币的数量是无限的。
示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
示例 2:
输入:coins = [2], amount = 3
输出:-1
示例 3:
输入:coins = [1], amount = 0
输出:0
提示:
1 <= coins.length <= 12
1 <= coins[i] <= 231 - 1
0 <= amount <= 104
本题和爬楼梯一题及其相似
我们可以将两题对比着来写:
将该题中的amount
类比于爬楼梯中的n(楼梯的阶数)
;将题中的coins
类比于可以爬的阶梯数1、2
只不过这题的coins
不是给定的1、2,而是随机的而已。
我们设dp[i]
表示当总金额为i时凑成总金额所需的 最少的硬币个数。
我们以coins = [1, 2, 5], amount = 11
为例,我们知道,硬币就1,2,5
这三种,所以当总金额为i时的最少硬币个数的得来就有三种情况:
dp[i-1]
时再来拼凑面值1
的硬币;
dp[i-2]
时再来拼凑面值2
的硬币;
dp[i-5]
时再来拼凑面值5
的硬币
如图所示:
所以就能得出递推式:
dp[i]=min(dp[i-coin]+1,dp[i])
coin
代表的就是硬币的面值
我们的coins[1,2,5]
就相当于爬楼梯
中我们一次能爬的阶梯的阶数[1,2]
只不过爬楼梯
是给定的[1,2]
,而本题在变化而已,写代码时,只要注意捕获coins
的变化就行了
Python代码
class Solution(object):
def coinChange(self, coins, amount):
"""
:type coins: List[int]
:type amount: int
:rtype: int
"""
dp=[float('inf')]*(amount+1) # 初始化dp
dp[0]=0
for i in range(1,amount+1):
for j in coins: # j代表硬币的面值,由于coins的不确定性,所以得增加一个for来捕获coin(每个硬币的面值)
if i-j>=0: # 检验总额是否大于硬币面值
dp[i]=min(dp[i-j]+1,dp[i]) # 如果大于,才能就行递推
return dp[-1] if dp[-1] != float('inf') else -1
思考二:在这一题中你是否看到背包问题的影子?
4.零钱兑换 II
接下来看力扣的第518题:518. 零钱兑换 II
题目:
给你一个整数数组 coins
表示不同面额的硬币,另给一个整数 amount
表示总金额。
请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0
。
假设每一种面额的硬币有无限个。
题目数据保证结果符合 32 位带符号整数。
示例 1:
输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1
示例 2:
输入:amount = 3, coins = [2]
输出:0
解释:只用面额 2 的硬币不能凑成总金额 3 。
示例 3:
输入:amount = 10, coins = [10]
输出:1
提示:
1 <= coins.length <= 300
1 <= coins[i] <= 5000
coins
中的所有值 互不相同0 <= amount <= 5000
将这题和爬楼梯作比较,发现几乎一模一样
我们将amount
看成n(楼梯的阶数)
;coins
看成[1,2](一次可以爬的楼梯阶数)
。
所以我们可以很自然的写出以下代码:
def change(self, amount, coins):
"""
:type amount: int
:type coins: List[int]
:rtype: int
"""
dp=[0]*(amount+1)
dp[0]=1
for i in range(1,amount+1):
for j in coins:
if i>=j:dp[i]+=dp[i-j]
return dp[-1]
我们将这段代码和升级后的爬楼梯的代码作对比:
class Solution(object):
def climbStairs(self, n):
"""
:type n: int
:type stairs: List[int]
:rtype: int
"""
dp=[0]*(n+1)
dp[0]=1
for i in range(1,n+1):
for j in stairs:
if i-j>=0:dp[i]+=dp[i-j]
return dp[n]
发现:几乎一模一样
不过,我们以amount = 5, coins = [1, 2, 5]
为例,输出结果应该是4
但是这个代码的输出结果为9
为什么呢?
我们来模拟一下这段代码的dp
:
初始化
amount = 5
coins = [1, 2, 5]
dp = [0] * (amount + 1)
dp[0] = 1
初始状态:
dp = [1, 0, 0, 0, 0, 0]
外层循环:遍历金额
for i in range(1, amount + 1):
第一轮:i = 1
for j in coins:
if i >= j:
dp[i] += dp[i - j]
j = 1
:i >= 1
(True)dp[1] += dp[1 - 1]
=>dp[1] += dp[0]
=>dp[1] += 1
=>dp[1] = 1
- 组合情况:1 = 1
更新后的 dp
:
dp = [1, 1, 0, 0, 0, 0]
第二轮:i = 2
for j in coins:
if i >= j:
dp[i] += dp[i - j]
j = 1
:i >= 1
(True)dp[2] += dp[2 - 1]
=>dp[2] += dp[1]
=>dp[2] += 1
=>dp[2] = 1
- 组合情况:2 = 1 + 1
j = 2
:i >= 2
(True)dp[2] += dp[2 - 2]
=>dp[2] += dp[0]
=>dp[2] += 1
=>dp[2] = 2
- 组合情况:2 = 2
更新后的 dp
:
dp = [1, 1, 2, 0, 0, 0]
第三轮:i = 3
for j in coins:
if i >= j:
dp[i] += dp[i - j]
j = 1
:i >= 1
(True)dp[3] += dp[3 - 1]
=>dp[3] += dp[2]
=>dp[3] += 2
=>dp[3] = 2
- 组合情况:3 = 1 + 1 + 1, 3 = 2 + 1
j = 2
:i >= 2
(True)dp[3] += dp[3 - 2]
=>dp[3] += dp[1]
=>dp[3] += 1
=>dp[3] = 3
- 组合情况:3 = 1 + 2
j = 5
:i >= 5
(False) -> 跳过
更新后的 dp
:
dp = [1, 1, 2, 3, 0, 0]
第四轮:i = 4
for j in coins:
if i >= j:
dp[i] += dp[i - j]
j = 1
:i >= 1
(True)dp[4] += dp[4 - 1]
=>dp[4] += dp[3]
=>dp[4] += 3
=>dp[4] = 3
- 组合情况:4 = 1 + 1 + 1 + 1, 4 = 2 + 1 + 1, 4 = 1 + 2 + 1
j = 2
:i >= 2
(True)dp[4] += dp[4 - 2]
=>dp[4] += dp[2]
=>dp[4] += 2
=>dp[4] = 5
- 组合情况:4 = 2 + 2, 4 = 1 + 1 + 2
j = 5
:i >= 5
(False) -> 跳过
更新后的 dp
:
dp = [1, 1, 2, 3, 5, 0]
第五轮:i = 5
for j in coins:
if i >= j:
dp[i] += dp[i - j]
j = 1
:``i >= 1`(True)dp[5] += dp[5 - 1]
=>dp[5] += dp[4]
=>dp[5] += 5
=>dp[5] = 5
- 组合情况:5 = 1 + 1 + 1 + 1 + 1, 5 = 2 + 1 + 1 + 1, 5 = 1 + 2 + 1 + 1, 5 = 1 + 1 + 2 + 1, 5 = 1 + 1 + 1 + 2
j = 2
:i >= 2
(True)dp[5] += dp[5 - 2]
=>dp[5] += dp[3]
=>dp[5] += 3
=>dp[5] = 8
- 组合情况:5 = 2 + 2 + 1, 5 = 2 + 1 + 2, 5 = 1 + 2 + 2
j = 5
:i >= 5
(True)dp[5] += dp[5 - 5]
=>dp[5] += dp[0]
=>dp[5] += 1
=>dp[5] = 9
- 组合情况:5 = 5
更新后的 dp
:
dp = [1, 1, 2, 3, 5, 9]
最终结果
return dp[-1]
返回值为 9
。
具体组合情况:
- 5 = 1 + 1 + 1 + 1 + 1
- 5 = 2 + 1 + 1 + 1
- 5 = 1 + 2 + 1 + 1
- 5 = 1 + 1 + 2 + 1
- 5 = 1 + 1 + 1 + 2
- 5 = 2 + 2 + 1
- 5 = 2 + 1 + 2
- 5 = 1 + 2 + 2
- 5 = 5
模拟完成后,我们可以很轻松地发现问题所在:出现了很多重复的情况
而爬楼梯这一题中为什么不出错,因为
- 5 = 1 + 2 + 1 + 1
- 5 = 1 + 1 + 2 + 1
本身在爬楼梯里面就是两种情况,而在零钱兑换Ⅱ里面确是一种情况。
为什么会这样呢?有什么办法来去掉这些重复的从而解决掉这个问题呢?
首先我们需要理解这两个概念:组合数dp
和排列数dp
。
5.组合数dp和排列数dp
- 组合数DP:当我们关注的是不同元素的组合而不考虑顺序时,应该先遍历物品(例如硬币),再遍历背包容量(例如金额)。这样可以保证每个组合仅被计算一次。
- 排列数DP:如果我们关心的是排列,即同样的元素以不同的顺序出现被视为不同的结果,那么应该先遍历背包容量,再遍历物品。这样可以确保每个元素在每个容量下都被尝试放置,导致相同的元素组合可能因为放置顺序不同而被多次计算。
通过前面的爬楼梯与零钱兑换Ⅱ两题,想必大家都知道了两者的区别
爬楼梯是排列数dp
而零钱兑换是组合数dp
那么我们如何将我们刚刚写的零钱兑换Ⅱ中的排列数dp
转换成组合数dp
呢?
要想做到这一步,我们应该交换遍历的顺序:先遍历硬币,再遍历金额
为什么呢?
6.为什么
要理解为什么先遍历硬币再遍历金额可以计算组合数,而不是排列数,我们需要从动态规划(DP)的角度来分析这个问题。关键在于DP数组的更新顺序和状态转移方程。
动态规划的核心思想
在动态规划中,我们通常用一个数组dp
来存储中间结果。对于零钱兑换问题,dp[i]
表示凑足金额i
的方法数。状态转移方程决定了如何从已知的子问题结果推导出新的问题结果。
计算组合数的正确方法
代码实现
def change(amount, coins):
dp = [0] * (amount + 1)
dp[0] = 1 # 当金额为0时,有一种方法,即不使用任何硬币
# 先遍历硬币
for coin in coins:
# 再遍历金额
for i in range(coin, amount + 1):
dp[i] += dp[i - coin]
return dp[amount]
为什么先遍历硬币再遍历金额可以计算组合数
-
初始化:
dp = [0] * (amount + 1) dp[0] = 1 # 当金额为0时,有一种方法,即不使用任何硬币
-
外层循环:遍历硬币
for coin in coins:
这一步确保我们在处理每种硬币时,不会重复考虑已经处理过的硬币。这样可以避免产生重复的组合。
-
内层循环:遍历金额
for i in range(coin, amount + 1): dp[i] += dp[i - coin]
这一步确保我们在处理每个金额时,只会考虑当前硬币对当前金额的影响。这样可以确保每个组合只被计算一次。
详细解释
举例说明
假设amount = 5
,coins = [1, 2, 5]
,我们来逐步模拟这个过程。
-
初始化:
dp = [1, 0, 0, 0, 0, 0]
-
第一轮:coin = 1
for i in range(1, 6): dp[i] += dp[i - 1]
i = 1
:dp[1] += dp[0]
=>dp[1] += 1
=>dp[1] = 1
i = 2
:dp[2] += dp[1]
=>dp[2] += 1
=>dp[2] = 1
i = 3
:dp[3] += dp[2]
=>dp[3] += 1
=>dp[3] = 1
i = 4
:dp[4] += dp[3]
=>dp[4] += 1
=>dp[4] = 1
i = 5
:dp[5] += dp[4]
=>dp[5] += 1
=>dp[5] = 1
dp = [1, 1, 1, 1, 1, 1]
-
第二轮:coin = 2
for i in range(2, 6): dp[i] += dp[i - 2]
i = 2
:dp[2] += dp[0]
=>dp[2] += 1
=>dp[2] = 2
i = 3
:dp[3] += dp[1]
=>dp[3] += 1
=>dp[3] = 2
i = 4
:dp[4] += dp[2]
=>dp[4] += 2
=>dp[4] = 3
i = 5
:dp[5] += dp[3]
=>dp[5] += 2
=>dp[5] = 3
dp = [1, 1, 2, 2, 3, 3]
-
第三轮:coin = 5
for i in range(5, 6): dp[i] += dp[i - 5]
i = 5
:dp[5] += dp[0]
=>dp[5] += 1
=>dp[5] = 4
dp = [1, 1, 2, 2, 3, 4]
最终结果
return dp[5]
返回值为 4
。
具体组合情况
- 5 = 1 + 1 + 1 + 1 + 1
- 5 = 1 + 1 + 1 + 2
- 5 = 1 + 2 + 2
- 5 = 5
为什么有效
- 避免重复组合:通过先遍历硬币,再遍历金额,我们确保每个组合只被计算一次。例如,
1 + 2 + 2
和2 + 1 + 2
被视为同一个组合,因为我们在处理硬币1时已经考虑了所有可能的组合方式。 - 状态转移方程:
dp[i] += dp[i - coin]
只考虑当前硬币对当前金额的影响,确保每个组合只被计算一次。
通过这种方式,我们可以有效地计算出组合数,而不是排列数。
所以零钱兑换Ⅱ这一题我们就可以解决了:
class Solution(object):
def change(self, amount, coins):
"""
:type amount: int
:type coins: List[int]
:rtype: int
"""
dp=[0]*(amount+1)
dp[0]=1
for j in coins:
for i in range(1,amount+1):
if i>=j:dp[i]+=dp[i-j]
return dp[-1]
思考三:你是否在零钱兑换Ⅱ中看到背包问题的影子?
7.背包问题
01背包问题
定义
01背包问题是一种经典的动态规划问题,其中有一组物品,每个物品有一个重量和一个价值。我们需要选择一些物品放入一个容量有限的背包中,使得背包中物品的总价值最大,同时总重量不超过背包的容量。每个物品只能选择0个或1个,不能重复选择。
完全背包问题
定义
完全背包问题与01背包问题类似,但每个物品可以被选择无限次。也就是说,每个物品可以选择0个、1个、2个、…,直到总重量超过背包容量为止。
示例
假设我们有以下物品和一个容量为10的背包:
物品 | 重量 | 价值 |
---|---|---|
A | 2 | 3 |
B | 3 | 4 |
C | 4 | 5 |
D | 5 | 8 |
我们的目标是选择一些物品放入背包中,使得总价值最大,同时总重量不超过10。
我们先来看完全背包:这是不是和零钱兑换Ⅱ非常相似?
我们可以把物品重量w=[2,3,4,5]
看成coins
,将容量c
看成amount
只不过是问的问题不同而已
一个是求最大价值,一个是求凑成总金额的硬币组合数
这题我们定义dp[i]
表示当背包容量为i
时所装物品的最大价值,
而dp[i]
是怎么来的呢?
在这个示例中,当i>=5时,我们有四种选择:
- 在
dp[i-2]
时再装一个价值为3的物品A - 在
dp[i-3]
时再装一个价值为4的物品B - 在
dp[i-4]
时再装一个价值为5的物品C - 在
dp[i-5]
时再装一个价值为8的物品D
所以有:
dp[i]=max(dp[i],dp[i-某个物品的重量]+该物品的价值)
所以参考零钱兑换,我们可以很容易地写出代码:
w=[2,3,4,5]
v=[3,4,5,8]
c=10
dp=[0]*(c+1)
for i in range(1,c+1):
for j in range(len(w)):
if i-w[j]>=0:dp[i]=max(dp[i],dp[i-w[j]]+v[j])
print(dp)
# 输出: [0, 0, 3, 4, 6, 8, 9, 11, 12, 14, 16]
这种写法是排列数dp,但这题明显是组合数dp,那为什么写成排列数dp也是正确的呢?
因为完全背包问题本身允许每个物品被选择无限次。这意味着无论我们先遍历物品还是先遍历容量,每个物品在每个容量下的最优解都会被多次考虑,从而不会产生重复计数的问题。
但01背包就不一样了,我们必须老老实实写成组合数dp,因为01背包每个物品只能选择一次。
为了确保每个物品只能选择一次,我们还得倒序遍历dp数组:
w=[2,3,4,5]
v=[3,4,5,8]
c=10
dp=[0]*(c+1)
for j in range(len(w)):
for i in range(c,0,-1):
if i-w[j]>=0:dp[i]=max(dp[i],dp[i-w[j]]+v[j])
print(dp)
# 输出: [0, 0, 3, 4, 5, 8, 8, 11, 12, 13, 15]
那么为什么要倒序遍历dp数组呢?
为什么需要倒序遍历
- 避免重复使用同一个物品:
- 如果正序遍历数组(从
weight[i]
到capacity
),在更新dp[j]
时,可能会使用已经更新过的dp[j - weight[i]]
,导致同一个物品被多次使用。 - 例如,假设我们有一个物品A(重量2,价值6),背包容量为4,
dp
数组初始化为[0, 0, 0, 0, 0]
。
- 如果正序遍历数组(从
- 正序遍历的问题:
- 正序遍历时,
dp
数组的更新顺序如下:dp[2] = max(dp[2], dp[0] + 6) = 6
dp[3] = max(dp[3], dp[1] + 6) = 6
dp[4] = max(dp[4], dp[2] + 6) = 12
- 这里,
dp[4]
的值是12,但实际上我们只能选择一次物品A,所以正确的值应该是6。
- 正序遍历时,
- 倒序遍历的优点:
- 倒序遍历时,
dp
数组的更新顺序如下:dp[4] = max(dp[4], dp[2] + 6) = 6
dp[3] = max(dp[3], dp[1] + 6) = 6
dp[2] = max(dp[2], dp[0] + 6) = 6
- 这里,
dp[4]
的值是6,正确地反映了我们只能选择一次物品A。
- 倒序遍历时,
8.结语
这篇文章主要讲动态规划算法,我们从斐波那契数列讲起,通过力扣中的爬楼梯
、零钱兑换
、零钱兑换Ⅱ
这几道经典动态规划题,讲了动态规划的基本方法、以及组合数dp
与排列数dp
,并通过它们引出了动态规划中的背包问题
。我们结合前面的示例,写出了完全背包
和01背包
的代码,并解释了其内部的原理 。这里补充一点,背包问题写代码的顺序应该是先写二维,再压缩成一维,这是背包问题正常的解法及思路。本篇文章提供了理解背包问题的另一个思路,如果大家理解不了背包问题的正常思路,可以参考一下本人的思路,若有一些需要指正的地方,欢迎大家指正!