【算法学习专栏】动态规划基础·中等两题精讲(198.打家劫舍、322.零钱兑换)

引言

动态规划(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=0n=1 时需单独处理,避免数组越界。

1.3 时间复杂度与空间复杂度分析

设房屋数量为 n

时间复杂度

  • 需要遍历一次数组,每次转移为 O ( 1 ) O(1) O(1) 操作,总时间复杂度为 O ( n ) O(n) O(n)。

空间复杂度

  • 若使用长度为 ndp 数组,空间复杂度为 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

代码要点解析

  1. 边界处理n=0n=1n=2 三种情况直接返回,避免数组越界。
  2. 状态定义dp[i] 严格表示"前 i 间房屋"的最大金额,与题目描述一致。
  3. 转移实现 :循环从 i=2 开始,每次取 dp[i-1](不偷第 i 间)与 dp[i-2]+nums[i](偷第 i 间)的较大值。
  4. 返回值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]。因此可用两个变量 prev2prev1 滚动更新,将空间复杂度从 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 面试考点

  1. 状态定义准确性 :能否清晰定义 dp[i] 的含义(前 i 间而非恰好偷第 i 间)。
  2. 转移方程推导 :能否从"偷/不偷"的二分选择中正确推出 max(dp[i-1], dp[i-2]+nums[i])
  3. 边界处理 :对 n=0,1,2 的特殊情况是否有妥善处理。
  4. 空间优化:是否能提出滚动数组优化,并正确实现变量滚动。
  5. 变体问题:是否了解环形、树形等变体的解题思路。

注:面试中经常出现的陷阱是误将 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

代码要点解析

  1. 初始化技巧 :使用 float('inf') 表示不可达状态,避免初始0值干扰最小值比较。
  2. 完全背包遍历顺序:外层正序金额、内层正序硬币,确保硬币可无限次使用。
  3. 条件判断 :只有 coin <= j 时才尝试使用该硬币,防止数组下标为负。
  4. 结果返回 :若 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 面试考点

  1. 背包问题识别:能否将问题转化为完全背包,并正确选择遍历顺序。
  2. 状态初始化 :理解 dp[0]=0 的含义及不可达状态的表示方法。
  3. 转移方程细节 :解释 dp[j-coin] + 1 中"+1"的由来(使用了一枚硬币)。
  4. 边界条件 :处理 amount=0、硬币面额大于 amount 等情况。
  5. 优化意识:能否提出排序剪枝、记忆化搜索等优化思路。

注:面试中常见错误是混淆完全背包与0-1背包的遍历顺序,导致结果错误。务必牢记:完全背包求最值------外层正序金额、内层正序硬币;0-1背包求最值------外层正序物品、内层逆序金额。

三、动态规划思想总结

通过以上两题,我们可以提炼出解决动态规划中等题目的通用方法论:

  1. 状态定义

    • 明确 dp 数组下标含义,通常表示"规模"或"容量"。
    • 确保状态能涵盖问题的所有可能情况。
  2. 转移方程

    • 分析问题的最优子结构,找到状态间的递推关系。
    • 常用思路:分类讨论(如打家劫舍的偷/不偷)、枚举决策(如零钱兑换的硬币选择)。
  3. 边界初始化

    • 确定最小规模问题的解(如 dp[0]dp[1])。
    • 设置不可达状态的初始值(如 float('inf'))。
  4. 遍历顺序

    • 根据状态依赖关系确定循环方向。
    • 背包类问题需特别注意完全背包与0-1背包的顺序差异。
  5. 空间优化

    • 观察状态依赖,若只与有限前驱相关,可使用滚动数组降维。

四、动态规划调试技巧与常见错误

4.1 调试技巧

  1. 打印dp数组 :在循环中打印dp数组的值,观察状态更新是否正确。例如,在打家劫舍问题中,可在每次迭代后打印 dp[i],验证是否与手动计算一致。
  2. 绘制状态转移图 :对于线性DP问题,画出每个状态的前驱关系,验证转移方程。例如,打家劫舍中状态 dp[i] 依赖于 dp[i-1]dp[i-2],可通过图示直观理解。
  3. 边界测试:测试空数组、单个元素、两个元素等边界情况,确保代码鲁棒性。特别是在面试中,主动提及边界处理能体现你的严谨性。

4.2 常见错误

  1. 数组越界 :未处理 n=0n=1 的情况,直接访问 dp[1] 导致运行时错误。务必在代码开头检查数组长度。
  2. 初始化错误 :零钱兑换中未将 dp[0] 设为0,或未将其他位置初始化为极大值,导致最小值计算错误。
  3. 遍历顺序错误:混淆完全背包与0-1背包的更新方向。完全背包求最值需正序更新金额,0-1背包需逆序更新。
  4. 状态定义模糊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(递归开销略高)

注:实际性能受测试环境、数据分布影响,上述时间为示意性参考。在面试中,应优先给出清晰正确的解法,再讨论优化可能。


相关推荐
计算机安禾2 小时前
【数据结构与算法】第28篇:平衡二叉树(AVL树)
开发语言·数据结构·数据库·线性代数·算法·矩阵·visual studio
测试_AI_一辰2 小时前
AI 如何参与 Playwright 自动化维护:一次自动修复闭环实践
人工智能·算法·ai·自动化·ai编程
未来之窗软件服务2 小时前
算法设计—计算机等级考试—软件设计师考前备忘录—东方仙盟
算法·软件设计师·计算机等级考试
未来之窗软件服务2 小时前
哈夫曼树构造—计算机等级考试—软件设计师考前备忘录—东方仙盟
算法·软件设计师·计算机等级考试·仙盟创梦ide·东方仙盟
SUNNY_SHUN3 小时前
VLM走进农田:AgriChat覆盖3000+作物品类,607K农业视觉问答基准开源
论文阅读·人工智能·算法·开源
arvin_xiaoting3 小时前
OpenClaw学习总结_III_自动化系统_1:Hooks详解
运维·学习·自动化
黎阳之光3 小时前
视频孪生赋能车路云一体化,领跑智慧高速新征程
人工智能·算法·安全·数字孪生
Darkwanderor3 小时前
高精度计算——基础模板整理
c++·算法·高精度计算
杜子不疼.3 小时前
Java 智能体学习避坑指南:3 个常见误区,新手千万别踩,高效少走弯路
java·开发语言·人工智能·学习