Hello 算法:动态规划问题特性详解——重叠子问题、最优子结构与无后效性
在《Hello 算法》动态规划一章中,动态规划通过子问题分解求解原问题,但并非所有问题都能直接套上"递推模板"。本文围绕 docs/chapter_dynamic_programming/dp_problem_features.md 讲解动态规划的三大问题特性——重叠子问题、最优子结构、无后效性,并以"爬楼梯最小代价"和"带约束爬楼梯"两个问题为例,结合仓库中 Python / Java / C++ 多语言实现,说明如何根据问题特性设计状态定义与状态转移方程,并给出空间优化版本,帮助读者判断一个问题是否"值得用动态规划"以及状态该如何扩展。
子问题分解:分治、动态规划与回溯的区别
子问题分解是一种通用的算法思路,在不同算法范式中的侧重点各不相同:
- 分治算法:递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。
- 动态规划:同样对问题进行递归分解,但与分治的主要区别在于,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。
- 回溯算法:在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,可以把每个决策步骤之前的子序列看作一个子问题。
动态规划常用来求解最优化问题,这类问题不仅包含重叠子问题,还具有另外两大特性:最优子结构、无后效性。
最优子结构
最优子结构的含义是:原问题的最优解是从子问题的最优解构建得来的。
为了展示这一概念,文档将经典的爬楼梯问题稍作改动,引出"爬楼梯最小代价"问题:
爬楼梯最小代价:给定一个楼梯,你每步可以上 1 阶或者 2 阶,每一阶楼梯上都贴有一个非负整数,表示你踩上该台阶所需付出的代价。给定非负整数数组
cost,其中cost[i]表示在第i个台阶需要付出的代价,cost[0]为地面(起始点)。请计算最少需要付出多少代价才能到达顶部。
例如,若第 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]
实现中有几个细节值得注意:
n = len(cost) - 1:因为cost[0]是地面,数组长度比阶梯数多 1,楼梯顶部对应下标len(cost) - 1;- 边界处理:当楼梯只有 1 阶或 2 阶时,直接返回
cost[n],避免后续转移越界; - 初始状态:
dp[1] = cost[1]、dp[2] = cost[2],即预设最小子问题的解; - 状态转移:从
i = 3开始逐步递推,每次只依赖已计算好的dp[i-1]与dp[i-2]。
时间复杂度为 O(n),空间复杂度为 O(n)。C++ 与 Java 版本逻辑完全一致,可分别参考 min_cost_climbing_stairs_dp.cpp 与 min_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
变量 a、b 分别滚动记录 dp[i-2] 与 dp[i-1],每轮同时更新。C++ 版本使用临时变量 tmp 实现同样的滚动逻辑(见 min_cost_climbing_stairs_dp.cpp)。
无后效性
无后效性的定义:给定一个确定的状态,它的未来发展只与当前状态有关,而与过去经历的所有状态无关。
以标准爬楼梯问题为例,给定状态 i,它会发展出状态 i+1 和 i+2,分别对应跳 1 步和跳 2 步。在做出这两种选择时,无须考虑状态 i 之前的任何状态——它们对状态 i 的未来没有影响。这正是状态转移方程 dp[i] = dp[i-1] + dp[i-2] 成立的前提。
添加约束后无后效性失效
但如果给爬楼梯问题添加一个约束,情况就不一样了:
带约束爬楼梯:给定一个共有
n阶的楼梯,你每步可以上 1 阶或者 2 阶,但不能连续两轮跳 1 阶,请问有多少种方案可以爬到楼顶?
在这个问题中,如果上一轮是跳 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] = 1,dp[1][2] = 0);爬到第 2 阶只能是跳了 2 阶(dp[2][1] = 0,dp[2][2] = 1)。Java 与 C++ 版本同样实现了该二维 dp,见 climbing_stairs_constraint_dp.java、climbing_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。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00



