首页
/ Hello 算法:动态规划问题特性详解——重叠子问题、最优子结构与无后效性

Hello 算法:动态规划问题特性详解——重叠子问题、最优子结构与无后效性

2026-09-06 13:52:53作者:郦嵘贵Just

在《Hello 算法》动态规划一章中,动态规划通过子问题分解求解原问题,但并非所有问题都能直接套上"递推模板"。本文围绕 docs/chapter_dynamic_programming/dp_problem_features.md 讲解动态规划的三大问题特性——重叠子问题、最优子结构、无后效性,并以"爬楼梯最小代价"和"带约束爬楼梯"两个问题为例,结合仓库中 Python / Java / C++ 多语言实现,说明如何根据问题特性设计状态定义与状态转移方程,并给出空间优化版本,帮助读者判断一个问题是否"值得用动态规划"以及状态该如何扩展。

子问题分解:分治、动态规划与回溯的区别

子问题分解是一种通用的算法思路,在不同算法范式中的侧重点各不相同:

  • 分治算法:递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。
  • 动态规划:同样对问题进行递归分解,但与分治的主要区别在于,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题
  • 回溯算法:在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,可以把每个决策步骤之前的子序列看作一个子问题。

动态规划常用来求解最优化问题,这类问题不仅包含重叠子问题,还具有另外两大特性:最优子结构无后效性

最优子结构

最优子结构的含义是:原问题的最优解是从子问题的最优解构建得来的。

为了展示这一概念,文档将经典的爬楼梯问题稍作改动,引出"爬楼梯最小代价"问题:

爬楼梯最小代价:给定一个楼梯,你每步可以上 1 阶或者 2 阶,每一阶楼梯上都贴有一个非负整数,表示你踩上该台阶所需付出的代价。给定非负整数数组 cost,其中 cost[i] 表示在第 i 个台阶需要付出的代价,cost[0] 为地面(起始点)。请计算最少需要付出多少代价才能到达顶部。

爬到第 3 阶的最小代价

例如,若第 1、2、3 阶的代价分别为 1、10、1,则从地面爬到第 3 阶的最小代价为 2(选择"1 阶 → 3 阶"的路径,跳过代价为 10 的第 2 阶)。

dp[i] 为爬到第 i 阶累计付出的代价。由于第 i 阶只可能从 i-1 阶或 i-2 阶走来,dp[i] 只可能等于 dp[i-1] + cost[i]dp[i-2] + cost[i]。为了尽可能减少代价,应取两者中较小的一个:

dp[i] = min(dp[i-1], dp[i-2]) + cost[i]

这就是本题具有最优子结构的体现:从两个子问题的最优解 dp[i-1]dp[i-2] 中挑选较优的一个,用它构建出原问题 dp[i] 的最优解。

值得注意的是,最优子结构的解释方式比较灵活,在不同问题中含义不同。以爬楼梯计数问题(求解爬到第 n 阶的方案数)为例,它看似是计数问题,但换成"求解最大方案数量"的问法后,最优子结构就浮现出来了:第 n 阶最大方案数量等于第 n-1 阶和第 n-2 阶最大方案数量之和。虽然题目修改前后是等价的,但表述角度改变了"最优解"的含义。

动态规划实现与空间优化

根据状态转移方程以及初始状态 dp[1] = cost[1]dp[2] = cost[2],可以写出完整实现。仓库中 Python 版本位于 min_cost_climbing_stairs_dp.py

def min_cost_climbing_stairs_dp(cost: list[int]) -> int:
    """爬楼梯最小代价:动态规划"""
    n = len(cost) - 1
    if n == 1 or n == 2:
        return cost[n]
    # 初始化 dp 表,用于存储子问题的解
    dp = [0] * (n + 1)
    # 初始状态:预设最小子问题的解
    dp[1], dp[2] = cost[1], cost[2]
    # 状态转移:从较小子问题逐步求解较大子问题
    for i in range(3, n + 1):
        dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i]
    return dp[n]

实现中有几个细节值得注意:

  1. n = len(cost) - 1:因为 cost[0] 是地面,数组长度比阶梯数多 1,楼梯顶部对应下标 len(cost) - 1
  2. 边界处理:当楼梯只有 1 阶或 2 阶时,直接返回 cost[n],避免后续转移越界;
  3. 初始状态dp[1] = cost[1]dp[2] = cost[2],即预设最小子问题的解;
  4. 状态转移:从 i = 3 开始逐步递推,每次只依赖已计算好的 dp[i-1]dp[i-2]

时间复杂度为 O(n),空间复杂度为 O(n)。C++ 与 Java 版本逻辑完全一致,可分别参考 min_cost_climbing_stairs_dp.cppmin_cost_climbing_stairs_dp.java;Go 版本还附带了驱动测试,见 min_cost_climbing_stairs_dp.go

爬楼梯最小代价的动态规划过程

一维压缩至零维的空间优化

由于 dp[i] 只依赖前两个状态,一维 dp 表可以进一步压缩到零维,使空间复杂度从 O(n) 降至 O(1):

def min_cost_climbing_stairs_dp_comp(cost: list[int]) -> int:
    """爬楼梯最小代价:空间优化后的动态规划"""
    n = len(cost) - 1
    if n == 1 or n == 2:
        return cost[n]
    a, b = cost[1], cost[2]
    for i in range(3, n + 1):
        a, b = b, min(a, b) + cost[i]
    return b

变量 ab 分别滚动记录 dp[i-2]dp[i-1],每轮同时更新。C++ 版本使用临时变量 tmp 实现同样的滚动逻辑(见 min_cost_climbing_stairs_dp.cpp)。

无后效性

无后效性的定义:给定一个确定的状态,它的未来发展只与当前状态有关,而与过去经历的所有状态无关。

以标准爬楼梯问题为例,给定状态 i,它会发展出状态 i+1i+2,分别对应跳 1 步和跳 2 步。在做出这两种选择时,无须考虑状态 i 之前的任何状态——它们对状态 i 的未来没有影响。这正是状态转移方程 dp[i] = dp[i-1] + dp[i-2] 成立的前提。

添加约束后无后效性失效

但如果给爬楼梯问题添加一个约束,情况就不一样了:

带约束爬楼梯:给定一个共有 n 阶的楼梯,你每步可以上 1 阶或者 2 阶,但不能连续两轮跳 1 阶,请问有多少种方案可以爬到楼顶?

带约束爬到第 3 阶的方案数量

在这个问题中,如果上一轮是跳 1 阶上来的,那么下一轮就必须跳 2 阶。也就是说,下一步选择不能由当前状态(当前所在楼梯阶数)独立决定,还和前一个状态(上一轮所在楼梯阶数)有关——此问题已不满足无后效性。

原来的状态转移方程 dp[i] = dp[i-1] + dp[i-2] 随之失效:dp[i-1] 代表本轮跳 1 阶,但其中包含了许多"上一轮也是跳 1 阶上来的"方案,为了满足约束,不能把 dp[i-1] 直接计入 dp[i]

扩展状态定义:从一维状态到二维状态

解决办法是扩展状态定义:状态 [i, j] 表示"处在第 i 阶并且上一轮跳了 j 阶",其中 j ∈ {1, 2}。这个定义有效地区分了上一轮跳 1 阶还是 2 阶,使问题重新满足无后效性:

  • 当上一轮跳了 1 阶时,上上一轮只能选择跳 2 阶,即 dp[i, 1] 只能从 dp[i-1, 2] 转移过来;
  • 当上一轮跳了 2 阶时,上上一轮可选择跳 1 阶或跳 2 阶,即 dp[i, 2] 可以从 dp[i-2, 1]dp[i-2, 2] 转移过来。

考虑约束下的递推关系

新的状态转移方程为:

dp[i, 1] = dp[i - 1, 2]
dp[i, 2] = dp[i - 2, 1] + dp[i - 2, 2]

最终返回 dp[n, 1] + dp[n, 2],两者之和代表爬到第 n 阶的方案总数。仓库 Python 实现位于 climbing_stairs_constraint_dp.py

def climbing_stairs_constraint_dp(n: int) -> int:
    """带约束爬楼梯:动态规划"""
    if n == 1 or n == 2:
        return 1
    # 初始化 dp 表,用于存储子问题的解
    dp = [[0] * 3 for _ in range(n + 1)]
    # 初始状态:预设最小子问题的解
    dp[1][1], dp[1][2] = 1, 0
    dp[2][1], dp[2][2] = 0, 1
    # 状态转移:从较小子问题逐步求解较大子问题
    for i in range(3, n + 1):
        dp[i][1] = dp[i - 1][2]
        dp[i][2] = dp[i - 2][1] + dp[i - 2][2]
    return dp[n][1] + dp[n][2]

初始状态的设置有直观的物理含义:爬到第 1 阶只能是跳了 1 阶(dp[1][1] = 1dp[1][2] = 0);爬到第 2 阶只能是跳了 2 阶(dp[2][1] = 0dp[2][2] = 1)。Java 与 C++ 版本同样实现了该二维 dp,见 climbing_stairs_constraint_dp.javaclimbing_stairs_constraint_dp.cpp

严重"有后效性"的问题

上面的案例中,由于仅需多考虑前面一个状态,扩展状态定义(一维变二维)就能让问题重新满足无后效性。然而某些问题具有非常严重的"有后效性":

爬楼梯与障碍生成:给定一个共有 n 阶的楼梯,你每步可以上 1 阶或者 2 阶。规定当爬到第 i 阶时,系统会自动在第 2i 阶上放上障碍物,之后所有轮都不允许跳到第 2i 阶上。例如,前两轮分别跳到了第 2、3 阶上,则之后就不能跳到第 4、6 阶上。请问有多少种方案可以爬到楼顶?

在这个问题中,下次跳跃依赖过去所有的状态,因为每一次跳跃都会在更高的阶梯上设置障碍并影响未来的跳跃。对于这类问题,动态规划往往难以解决。

实际上,许多复杂的组合优化问题(例如旅行商问题)不满足无后效性。对于这类问题,通常会选择其他方法——启发式搜索、遗传算法、强化学习等——从而在有限时间内得到可用的局部最优解。

总结:用三大特性检验动态规划适用性

特性 含义 在本文案例中的体现
重叠子问题 分解过程中出现重复的子问题,与分治的关键区别 dp[i-1]dp[i-2] 会被多个更大的 dp[i] 反复用到,故用 dp 表缓存
最优子结构 原问题的最优解由子问题的最优解构建而来 dp[i] = min(dp[i-1], dp[i-2]) + cost[i],从两个较优子解中选优
无后效性 给定状态后,未来发展只与当前状态有关 标准爬楼梯满足;加"不能连续跳 1 阶"约束后失效,需将状态扩展为 [i, j]

实践中的一个检验流程是:先写出直觉上的状态转移方程;如果发现"下一步选择"依赖历史路径(如上一轮跳了几阶、历史上放过哪些障碍),说明无后效性被破坏;此时优先尝试扩展状态定义(把被依赖的历史信息并入状态),若历史信息无法被有限维度的状态刻画(如障碍生成问题依赖全部历史),则应考虑启发式搜索等其他方法。

文中代码可分别在各语言目录下运行查看效果,例如 Python 版本目录下还有配套的爬楼梯基础版 climbing_stairs_dp.py、回溯版 climbing_stairs_backtrack.py 与记忆化搜索版 climbing_stairs_dfs_mem.py,可与本文的"最小代价"和"带约束"版本对照阅读;关于状态转移方程的推导流程,可继续参考 dp_solution_pipeline.md

登录后查看全文
热门项目推荐
相关项目推荐