1. 动态规划入门:理解"目标和"与"零钱兑换"问题
第一次接触这两个问题时,我正坐在LeetCode的刷题页面前发呆。"目标和"问题要求我们在数组中找到所有能组合出特定目标和的数字组合方式,而"零钱兑换"则要求我们用最少数量的硬币凑出指定金额。看似简单的描述背后,隐藏着动态规划这一强大的算法思想。
动态规划(Dynamic Programming)本质上是一种通过将复杂问题分解为更小的子问题来解决的方法。它特别适合解决具有重叠子问题和最优子结构特征的问题。在"目标和"问题中,我们需要考虑每个数字的正负选择;而在"零钱兑换"中,我们需要决定是否使用当前面额的硬币。
提示:动态规划的关键在于找到状态转移方程,这需要明确"状态"的定义和状态之间的转移关系。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 目标和问题详解与解法
2.1 问题定义与暴力解法
给定一个非负整数数组nums和一个目标整数S,我们需要通过为数组中的每个整数添加"+"或"-"符号,使得最终的和等于目标S。返回所有可能的组合方式数量。
例如,对于nums = [1,1,1,1,1]和S = 3,有5种方式:
- +1+1+1+1-1 = 3
- +1+1+1-1+1 = 3
- +1+1-1+1+1 = 3
- +1-1+1+1+1 = 3
- -1+1+1+1+1 = 3
最直观的解法是递归回溯,尝试每个数字的正负两种可能:
python复制def findTargetSumWays(nums, S):
def backtrack(index, current_sum):
if index == len(nums):
return 1 if current_sum == S else 0
return backtrack(index+1, current_sum + nums[index]) + \
backtrack(index+1, current_sum - nums[index])
return backtrack(0, 0)
这种解法的时间复杂度是O(2^n),对于n=20时就需要约100万次操作,显然不够高效。
2.2 动态规划解法优化
我们可以将问题转化为子集和问题。设所有正数的和为P,负数的和为N,则有:
P - N = S
P + N = sum(nums)
=> P = (S + sum(nums)) / 2
因此问题转化为:在nums中找到若干数字,使其和等于(S + sum(nums))/2。
状态定义:dp[i][j]表示前i个数字中选出若干数字,使其和为j的方法数。
状态转移方程:
dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i-1]] (如果j >= nums[i-1])
空间优化后的实现:
python复制def findTargetSumWays(nums, S):
total = sum(nums)
if (total + S) % 2 != 0 or S > total or S < -total:
return 0
target = (total + S) // 2
dp = [0] * (target + 1)
dp[0] = 1
for num in nums:
for j in range(target, num - 1, -1):
dp[j] += dp[j - num]
return dp[target]
这个解法的时间复杂度降为O(n*target),空间复杂度为O(target)。
3. 零钱兑换问题详解与解法
3.1 问题定义与贪心算法的局限性
给定不同面额的硬币coins和一个总金额amount,计算凑成总金额所需的最少硬币数。如果无法凑出,返回-1。
例如,coins = [1,2,5], amount = 11,最少需要3枚硬币(5+5+1)。
初学者可能会尝试贪心算法:每次尽可能使用最大面额的硬币。这在某些情况下有效,但对于coins = [1,3,4], amount = 6时,贪心给出4+1+1=3枚,而最优解是3+3=2枚。
3.2 动态规划解法
状态定义:dp[i]表示凑出金额i所需的最少硬币数。
状态转移方程:
dp[i] = min(dp[i - coin] + 1 for coin in coins if i >= coin)
初始化:dp[0] = 0,其他设为无穷大。
实现代码:
python复制def coinChange(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for coin in coins:
if i >= coin:
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
这个解法的时间复杂度是O(n*amount),空间复杂度是O(amount)。
3.3 边界条件与优化
在实际编码中需要注意:
- 金额为0时应返回0
- 硬币面额可能有重复或为0的情况需要过滤
- 可以提前对coins排序,在某些情况下可以提前终止内层循环
优化版本:
python复制def coinChange(coins, amount):
coins = sorted([c for c in coins if c > 0])
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for coin in coins:
if i < coin:
break
if dp[i - coin] + 1 < dp[i]:
dp[i] = dp[i - coin] + 1
return dp[amount] if dp[amount] != float('inf') else -1
4. 两种问题的对比与实战技巧
4.1 问题特征对比
| 特征 | 目标和问题 | 零钱兑换问题 |
|---|---|---|
| 决策方式 | 每个数字有正负两种选择 | 每个硬币可以选择使用多次 |
| 目标 | 计算所有可能的组合方式数量 | 计算最少硬币数的方案 |
| 状态定义 | 前i个数字和为j的方法数 | 凑出金额i的最少硬币数 |
| 空间优化 | 可以优化为一维数组 | 可以优化为一维数组 |
4.2 常见错误与调试技巧
-
目标和问题:
- 忘记检查(S + sum(nums))是否为偶数
- 初始化dp[0] = 1容易被忽略
- 内层循环应该从大到小遍历,避免重复计算
-
零钱兑换问题:
- 忘记处理amount为0的特殊情况
- 没有过滤掉面额为0或负数的硬币
- 初始化值不够大导致min比较出错
调试时可以:
- 打印中间dp数组的值
- 对于小规模测试用例手动计算预期结果
- 添加断言检查关键条件
4.3 性能优化实战经验
- 记忆化搜索:对于递归解法,可以添加缓存存储已计算的结果
- 提前终止:在零钱兑换中,如果当前硬币面额大于剩余金额可以跳过
- 预处理输入:排序、去重、过滤无效输入
- 空间优化:观察状态转移是否只依赖前几个状态,减少空间使用
python复制# 记忆化搜索示例
def coinChange(coins, amount):
coins.sort(reverse=True)
memo = {}
def dfs(remaining):
if remaining in memo:
return memo[remaining]
if remaining == 0:
return 0
min_coins = float('inf')
for coin in coins:
if remaining >= coin:
res = dfs(remaining - coin)
if res != -1 and res + 1 < min_coins:
min_coins = res + 1
memo[remaining] = min_coins if min_coins != float('inf') else -1
return memo[remaining]
return dfs(amount)
4.4 扩展应用场景
-
目标和问题的变种:
- 允许部分数字不参与计算
- 每个数字可以有多种运算符选择
- 组合结果在一定范围内即可
-
零钱兑换问题的变种:
- 计算所有可能的组合方式(类似目标和)
- 限制每种硬币的使用数量
- 多维度目标(如同时最小化硬币数量和总重量)
在实际工作中,这类问题常见于:
- 金融领域的投资组合优化
- 资源分配与调度
- 游戏开发中的装备合成系统
- 物流领域的装箱问题
掌握这两个经典问题的解法后,可以举一反三解决许多类似的优化问题。关键在于识别问题的重叠子问题和最优子结构特征,然后设计合适的状态表示和转移方程。
