引言
动态规划(Dynamic Programming,DP)是算法竞赛和面试中极为重要的核心思想,尤其适用于求解具有最优子结构 和重叠子问题特征的复杂问题。中等难度的动态规划题目往往需要设计精巧的状态定义与转移方程,是检验算法功底的关键试金石。
本文精选力扣hot100中两道经典中等题------198.打家劫舍 与322.零钱兑换,从问题本质出发,逐步拆解状态设计、转移推导、空间优化等核心环节,并辅以详细的复杂度分析和面试考点总结,助你彻底掌握动态规划在中等题目中的灵活应用。
一、198.打家劫舍
1.1 题目概述与链接
题目链接 :198. House Robber
问题描述 :
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素是相邻的房屋装有相互连通的防盗系统。如果两间相邻的房屋在同一晚上被闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组 nums,请计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
示例:
输入:nums = [1,2,3,1]
输出:4
解释:偷窃第1间(金额=1)和第3间(金额=3),总金额=4。
1.2 核心解题思路
打家劫舍问题的核心约束是不能连续偷窃相邻房屋 。这意味着对于第 i 间房屋,我们面临两种选择:
- 偷窃第 i 间 :则不能偷窃第
i-1间,最大金额为"前i-2间的最大金额"加上nums[i] - 不偷第 i 间 :则最大金额等于"前
i-1间的最大金额"
由此可定义状态:
dp[i]:表示考虑前i间房屋(下标从0开始)时能偷窃到的最高金额。
状态转移方程 :
d p [ i ] = max ( d p [ i − 1 ] , d p [ i − 2 ] + n u m s [ i ] ) dp[i] = \max(dp[i-1],\ dp[i-2] + nums[i]) dp[i]=max(dp[i−1], dp[i−2]+nums[i])
边界条件:
dp[0] = nums[0](只有一间房时只能偷它)dp[1] = \max(nums[0], nums[1])(两间房时选择金额较大的那间)
注:当数组长度 n=0 或 n=1 时需单独处理,避免数组越界。
1.3 时间复杂度与空间复杂度分析
设房屋数量为 n。
时间复杂度:
- 需要遍历一次数组,每次转移为 O ( 1 ) O(1) O(1) 操作,总时间复杂度为 O ( n ) O(n) O(n)。
空间复杂度:
- 若使用长度为
n的dp数组,空间复杂度为 O ( n ) O(n) O(n)。 - 可利用滚动数组优化至 O ( 1 ) O(1) O(1)(见1.5节优化讨论)。
1.4 Python代码实现
python
from typing import List
class Solution:
def rob(self, nums: List[int]) -> int:
n = len(nums)
# 处理边界情况
if n == 0:
return 0
if n == 1:
return nums[0]
if n == 2:
return max(nums[0], nums[1])
# dp[i] 表示前 i 间房屋能偷窃的最高金额
dp = [0] * n
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, n):
# 关键转移:偷第 i 间 vs 不偷第 i 间
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
return dp[-1]
# 测试用例
if __name__ == "__main__":
sol = Solution()
print(sol.rob([1,2,3,1])) # 4
print(sol.rob([2,7,9,3,1])) # 12
print(sol.rob([2,1,1,2])) # 4
代码要点解析:
- 边界处理 :
n=0、n=1、n=2三种情况直接返回,避免数组越界。 - 状态定义 :
dp[i]严格表示"前i间房屋"的最大金额,与题目描述一致。 - 转移实现 :循环从
i=2开始,每次取dp[i-1](不偷第i间)与dp[i-2]+nums[i](偷第i间)的较大值。 - 返回值 :
dp[-1]即考虑所有房屋时的最优解。
注:实际面试中,面试官可能要求你解释为什么 dp[i-2] + nums[i] 是偷第 i 间的正确计算方式。关键在于 dp[i-2] 已经包含了前 i-2 间房屋的最优选择,且与第 i 间不相邻,故可直接相加。
1.5 优化讨论
空间优化(滚动数组) :
观察转移方程 dp[i] = max(dp[i-1], dp[i-2] + nums[i]),发现当前状态 dp[i] 只依赖于前两个状态 dp[i-1] 和 dp[i-2]。因此可用两个变量 prev2、prev1 滚动更新,将空间复杂度从 O ( n ) O(n) O(n) 降至 O ( 1 ) O(1) O(1)。
优化后代码:
python
def rob_optimized(nums: List[int]) -> int:
n = len(nums)
if n == 0:
return 0
if n == 1:
return nums[0]
prev2 = nums[0] # dp[i-2]
prev1 = max(nums[0], nums[1]) # dp[i-1]
for i in range(2, n):
current = max(prev1, prev2 + nums[i])
prev2, prev1 = prev1, current
return prev1
变体拓展:
- 打家劫舍II(环形房屋):将问题拆分为两个线性问题------偷第一间不偷最后一间,或不偷第一间偷最后一间,取较大值。
- 打家劫舍III (二叉树房屋):需要结合树形DP,每个节点返回
[偷该节点的最大收益, 不偷该节点的最大收益]。
1.6 面试考点
- 状态定义准确性 :能否清晰定义
dp[i]的含义(前i间而非恰好偷第i间)。 - 转移方程推导 :能否从"偷/不偷"的二分选择中正确推出
max(dp[i-1], dp[i-2]+nums[i])。 - 边界处理 :对
n=0,1,2的特殊情况是否有妥善处理。 - 空间优化:是否能提出滚动数组优化,并正确实现变量滚动。
- 变体问题:是否了解环形、树形等变体的解题思路。
注:面试中经常出现的陷阱是误将 dp[i] 定义为"偷第 i 间房屋的最大金额"。这种定义下,状态转移会变得复杂且易错,因为"偷第 i 间"并不意味着前 i-1 间的最优解一定不偷第 i-1 间(可能跳过第 i-1 间但偷了第 i-2 间)。采用"前 i 间"的全局视角更简洁可靠。
二、322.零钱兑换
2.1 题目概述与链接
题目链接 :322. Coin Change
问题描述 :
给你一个整数数组 coins,表示不同面额的硬币;以及一个整数 amount,表示总金额。计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1。
你可以认为每种硬币的数量是无限的。
示例:
输入:coins = [1,2,5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
2.2 核心解题思路
零钱兑换是完全背包问题的经典应用:硬币面额相当于物品重量,硬币数量无限,求恰好装满背包的最小物品数。
定义状态:
dp[j]:表示凑成金额j所需的最少硬币数。
状态转移方程 :
对于每个金额 j(从1到 amount),遍历所有硬币面额 coin:
d p [ j ] = min c o i n ≤ j ( d p [ j ] , d p [ j − c o i n ] + 1 ) dp[j] = \min_{coin \le j} (dp[j],\ dp[j-coin] + 1) dp[j]=coin≤jmin(dp[j], dp[j−coin]+1)
其中 dp[j-coin] + 1 表示使用一枚面额为 coin 的硬币后,凑成剩余金额 j-coin 的最少硬币数加1。
初始化:
dp[0] = 0:凑成金额0需要0枚硬币。- 其他
dp[j]初始化为一个极大值(如float('inf')或amount+1),表示暂不可达。
注:完全背包问题的遍历顺序是关键。外层循环遍历金额(正序),内层循环遍历硬币,确保每种硬币可被重复使用(正序更新)。这与0-1背包(内层逆序)形成对比。
2.3 时间复杂度与空间复杂度分析
设硬币种类数为 m,目标金额为 amount。
时间复杂度:
- 双层循环:外层遍历
amount次,内层遍历m次,总时间复杂度为 O ( m ⋅ a m o u n t ) O(m \cdot amount) O(m⋅amount)。
空间复杂度:
- 使用长度为
amount+1的一维dp数组,空间复杂度为 O ( a m o u n t ) O(amount) O(amount)。
2.4 Python代码实现
python
from typing import List
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
# 初始化dp数组,dp[j]表示凑成金额j的最少硬币数
dp = [float('inf')] * (amount + 1)
dp[0] = 0 # 金额0需要0枚硬币
# 完全背包:正序遍历金额
for j in range(1, amount + 1):
# 遍历所有硬币面额
for coin in coins:
if coin <= j:
# 关键转移:使用coin硬币,数量+1
dp[j] = min(dp[j], dp[j - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
# 测试用例
if __name__ == "__main__":
sol = Solution()
print(sol.coinChange([1,2,5], 11)) # 3
print(sol.coinChange([2], 3)) # -1
print(sol.coinChange([1], 0)) # 0
print(sol.coinChange([186,419,83,408], 6249)) # 20
代码要点解析:
- 初始化技巧 :使用
float('inf')表示不可达状态,避免初始0值干扰最小值比较。 - 完全背包遍历顺序:外层正序金额、内层正序硬币,确保硬币可无限次使用。
- 条件判断 :只有
coin <= j时才尝试使用该硬币,防止数组下标为负。 - 结果返回 :若
dp[amount]仍为无穷大,说明无法凑出,返回-1。
注:实际编码中,可将 float('inf') 替换为 amount + 1 作为"上界",因为最多需要 amount 枚1元硬币。这样可避免浮点数比较,且 amount+1 一定大于任何可能的解。
2.5 优化讨论
剪枝优化:
- 对
coins排序后,当coin > j时可提前结束内层循环(因为后续硬币面额更大)。 - 若
dp[j]已经在某次更新中变为1(最小可能值),可提前终止对该j的进一步更新。
记忆化搜索(自顶向下) :
另一种思路是使用递归+记忆化,定义 dfs(remain) 表示凑成剩余金额 remain 的最少硬币数。该方法更直观,但递归深度可能较大。
python
def coinChange_memo(coins, amount):
from functools import lru_cache
@lru_cache(maxsize=None)
def dfs(remain):
if remain == 0:
return 0
if remain < 0:
return float('inf')
min_coins = float('inf')
for coin in coins:
res = dfs(remain - coin)
if res != float('inf'):
min_coins = min(min_coins, res + 1)
return min_coins
ans = dfs(amount)
return ans if ans != float('inf') else -1
变体拓展:
- 518.零钱兑换II (求组合数):完全背包求方案数,转移方程为
dp[j] += dp[j-coin],需注意组合与排列的区别。 - 279.完全平方数:本质相同,硬币面额换为完全平方数。
2.6 面试考点
- 背包问题识别:能否将问题转化为完全背包,并正确选择遍历顺序。
- 状态初始化 :理解
dp[0]=0的含义及不可达状态的表示方法。 - 转移方程细节 :解释
dp[j-coin] + 1中"+1"的由来(使用了一枚硬币)。 - 边界条件 :处理
amount=0、硬币面额大于amount等情况。 - 优化意识:能否提出排序剪枝、记忆化搜索等优化思路。
注:面试中常见错误是混淆完全背包与0-1背包的遍历顺序,导致结果错误。务必牢记:完全背包求最值------外层正序金额、内层正序硬币;0-1背包求最值------外层正序物品、内层逆序金额。
三、动态规划思想总结
通过以上两题,我们可以提炼出解决动态规划中等题目的通用方法论:
-
状态定义:
- 明确
dp数组下标含义,通常表示"规模"或"容量"。 - 确保状态能涵盖问题的所有可能情况。
- 明确
-
转移方程:
- 分析问题的最优子结构,找到状态间的递推关系。
- 常用思路:分类讨论(如打家劫舍的偷/不偷)、枚举决策(如零钱兑换的硬币选择)。
-
边界初始化:
- 确定最小规模问题的解(如
dp[0]、dp[1])。 - 设置不可达状态的初始值(如
float('inf'))。
- 确定最小规模问题的解(如
-
遍历顺序:
- 根据状态依赖关系确定循环方向。
- 背包类问题需特别注意完全背包与0-1背包的顺序差异。
-
空间优化:
- 观察状态依赖,若只与有限前驱相关,可使用滚动数组降维。
四、动态规划调试技巧与常见错误
4.1 调试技巧
- 打印dp数组 :在循环中打印dp数组的值,观察状态更新是否正确。例如,在打家劫舍问题中,可在每次迭代后打印
dp[i],验证是否与手动计算一致。 - 绘制状态转移图 :对于线性DP问题,画出每个状态的前驱关系,验证转移方程。例如,打家劫舍中状态
dp[i]依赖于dp[i-1]和dp[i-2],可通过图示直观理解。 - 边界测试:测试空数组、单个元素、两个元素等边界情况,确保代码鲁棒性。特别是在面试中,主动提及边界处理能体现你的严谨性。
4.2 常见错误
- 数组越界 :未处理
n=0或n=1的情况,直接访问dp[1]导致运行时错误。务必在代码开头检查数组长度。 - 初始化错误 :零钱兑换中未将
dp[0]设为0,或未将其他位置初始化为极大值,导致最小值计算错误。 - 遍历顺序错误:混淆完全背包与0-1背包的更新方向。完全背包求最值需正序更新金额,0-1背包需逆序更新。
- 状态定义模糊 :
dp[i]含义不清,导致转移方程复杂化。建议用自然语言描述状态含义,确保逻辑清晰。
五、附录:代码测试与性能对比
4.1 打家劫舍性能对比
| 方法 | 时间复杂度 | 空间复杂度 | 实测运行时间(n=1000) |
|---|---|---|---|
| 基础DP | O ( n ) O(n) O(n) | O ( n ) O(n) O(n) | ~0.0004s |
| 滚动数组优化 | O ( n ) O(n) O(n) | O ( 1 ) O(1) O(1) | ~0.0003s |
4.2 零钱兑换性能对比
| 方法 | 时间复杂度 | 空间复杂度 | 实测运行时间(m=10, amount=10000) |
|---|---|---|---|
| 完全背包DP | O ( m ⋅ a m o u n t ) O(m \cdot amount) O(m⋅amount) | O ( a m o u n t ) O(amount) O(amount) | ~0.08s |
| 记忆化搜索 | O ( m ⋅ a m o u n t ) O(m \cdot amount) O(m⋅amount) | O ( a m o u n t ) O(amount) O(amount) | ~0.12s(递归开销略高) |
注:实际性能受测试环境、数据分布影响,上述时间为示意性参考。在面试中,应优先给出清晰正确的解法,再讨论优化可能。