开胃菜 - 零钱兑换
用该题作为动态规划讲解的第一道题再合适不过,其 leetcode 链接为 零钱兑换。简单的来说,给定一堆硬币面额,计算用这些硬币凑出指定金额花费的最少硬币数量,可以重复使用。比如现在的硬币面额为 1, 2, 5,需要凑出的金额为 11。那么所花费的最少硬币数量就为 3 枚,即用 5 + 5 + 1 来凑出 11。
动态规划的特点就是将一个任务分解,先计算子任务的解再通过该解计算出最终任务的解。该题也不例外,首先用 f(x) 表示凑出金额为 x 的问题的解,可以得到:
f(11) = f(6) + 1 # 只要凑出了 6,那么用一枚面值为 5 的就可以凑出 11
f(11) = f(10) + 1 # 只要凑出了 10,那么用一枚面值为 1 的就可以凑出 11
f(11) = f(9) + 1 # 只要凑出了 9,那么用一枚面值为 2 的就可以凑出 11
由于我们要求最小值,因此当然要也要使用 , , 中的最小值,最终结果为:
当然对于 , , 我们也可以用同样的方式去计算,不难看到这是一个不断递归的计算过程。既然是递归,那么就不可能无限制的计算下去,总有跳出递归的条件。对于该题来说,跳出递归的条件就是当需要凑的金额刚好等于硬币面值的时候。比如要凑 f(5) 那边必定是等于 1,不可能比这个更小了。当然从递推公式也可以求解这个问题,因为有:
f(2) = f(2 - 2) + 1 = f(0) + 1 = 1 # 这里 f(0) = 0
f(2) = f(2 - 1) + 1 = f(1) + 2 = 2 # 这里就不写 f(1) 的求解了,明显等于1
此时 , , ) 就是最基本的子问题,我们根据这三个子问题的解以及递推公式就可以得到最终的解。因此按照这个递归的思想,我们可以写出代码:
class Solution(object):
def coinChange(self, coins, amount):
# f(0) = 0
if amount == 0:
return 0
minv = float('inf')
for coin in coins:
# 如果银币面值等于金额,直接返回 1 无需求解
if coin == amount:
return 1
elif coin < amount:
# 否则不断更新最小值
res = self.coinChange(coins, amount - coin) + 1
if res < minv:
minv = res
return minv
直接递归的思路是最好理解的,但是它存在一个问题,即大量的子问题被重复计算。比如计算 f(11) 时我们需要计算一次 f(6),同样计算 f(9) 时我们也会计算 f(6)。因为有
这样的话会使得计算复杂度很高,无法在有效的时间范围内求解。当然一个简单的解决方法是将所有计算过的子问题缓存起来,比如使用一个数组来存储每次计算过的子问题解。某次需要该解的时候如果已经存在了直接取出即可。很多人会说的动态规划等于递归+缓存就是这个道理。
不过既然已经用到了数组缓存结果,那么我们在计算 时只需要先把 都记录下来。这样使用循环的方式也能完成求解,代码为:
def coinChange(self, coins, amount):
"""
:type coins: List[int]
:type target: int
:rtype: int
"""
# f(0) = 0
dp = [0]
# 对于 amount i 求解最小值,这样我们在 amout i+1 时可以直接使用
for i in range(1, amount + 1):
min_value = float('inf')
for c in coins:
if c <= i:
min_value = min(min_value, dp[i - c] + 1)
dp.append(min_value)
return -1 if dp[-1] == float('inf') else dp[-1]
实际上循环的方式完成代码更为简单。
进阶问题 - 01 背包
从零钱兑换的例子中我们不难看出求解动态规划的最重要的一步是如何将一个问题转换为子问题的解。我们接下来再看一个问题。这个问题也是非常经典的一道动态规划题目,在很多地方都有提到。这个题目的名字为0-1背包,即给定一个背包的容量,以及一堆物品的重量。求解这个背包的总共最多可以装多重的物品。在选取物品时不能重复使用,也就是对于任意一个物品要么选要么不选,这也是 0-1 背包这个题目名字的来源。比如背包容量为 11,物品的重量为 3 5 4,那么最多能装的重量为 9 = 5 + 4。
采用相同的思路,我们可以这样分析。假设现在我们遇到了第 i 个物体,那么如果拿了这个物品,那么整个重量为:
也就是如果我们已经知道容量为 问题的解为 ,那么拿了第 i 个物体后背包的重量为 。 当然也可以选择不拿这个物品,那么结果直接为 ,当然在计算这个结果时第 i 个物品就不能参加计算。这里可以看出来在计算最终结果的过程中出现了两个维度,一个是背包的重量 n,另一个是可以参与计算的前 i 个物品。那么这个问题的递推公式就为:
其中 表示第 i 件物品的重量。
由于这里有两个维度,因此我们需要使用一个二维数组来对子问题的结果进行缓存。该数组下标 (i, j) 表示为前 i 个物品在容量为 n 的条件下的解。代码为:
def max_weight(items, capacity):
dp = [[0] * (capacity + 1) for _ in items]
for i, w in enumerate(items):
for c in range(1, capacity + 1):
# 如果只有一个物品可选,那么只要该物品能放入背包则结果就该物品的重量
if i == 0 and w <= c:
dp[i][c] = w
# 如果物品可以放入背包,则根据递推公式进行计算
elif w <= c:
dp[i][c] = max(
dp[i - 1][c], # 如果不选该物品,那重量就为前 i - 1 个物品在 capacity = c 时计算出的重量
dp[i - 1][c - w] + w # 如果选该物品,那就为前 i - 1 个物品 capacity = c - w 时算出的重量
)
# 如果第 i 个物品无法放入背包,那么结果就为前 i - 1 物品的结果
else:
dp[i][c] = dp[i - 1][c]
return dp[-1][-1]
总结
动态规划的问题虽然看起来比较困难,但是实际上只要找准了递推公式也能很快的写出代码。多做类似的题,很快就能找到规律。