尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

动态规划核心思想与实战:从最优子结构到零钱兑换问题

动态规划核心思想与实战:从最优子结构到零钱兑换问题 1. 从“最优”到“可解”为什么动态规划是建模者的利器如果你在解决一个复杂的决策问题时感觉像在走一个巨大的迷宫每条岔路都通向更多岔路穷举所有可能性几乎不可能那么你很可能遇到了一个适合用动态规划来建模的场景。这不是一个高深莫测的数学玩具而是解决现实世界中资源分配、路径规划、生产调度等问题的强大思想框架。我最初接触动态规划是在为一个物流中心设计最优的货物分拣路径时当传统的贪心算法和暴力搜索在几十个节点面前彻底失效后动态规划提供了一条清晰、高效的求解路径。简单来说动态规划的核心思想是“记住过去避免重复计算”。它把一个复杂的大问题分解成一系列相互关联的小问题通过解决这些小问题并存储它们的解记忆化最终组合出大问题的最优解。这听起来有点像数学归纳法但其威力在于它对“最优子结构”和“重叠子问题”这两个特性的精准利用。很多看似离散、组合爆炸的问题一旦被识别出具备这两个特性就能被动态规划优雅地“驯服”。从计算最短路径、背包问题到序列比对、资源调度甚至一些游戏AI的决策背后都有它的身影。这篇文章我将结合自己从原理理解到项目实战的完整经历拆解动态规划的核心思想、建模步骤、编码实现以及那些容易踩坑的细节目标是让你不仅能看懂算法更能亲手用它解决一个具体问题。2. 动态规划的两大基石最优子结构与重叠子问题理解动态规划必须从它的两个核心性质入手。这是判断一个问题能否用动态规划求解的“试金石”也是我们设计状态转移方程的逻辑起点。2.1 最优子结构全局最优源于局部最优最优子结构意味着一个问题的最优解包含了其子问题的最优解。换句话说我们可以通过组合子问题的最优解来构造原问题的最优解。这是一个非常强的性质它保证了我们的分解策略是有效的。举个例子经典的“最短路径”问题。假设我们要从城市A到城市D途径B或C。如果我们已经知道了从A到B的最短路径是P_AB从A到C的最短路径是P_AC并且也知道从B到D和从C到D的最短路径。那么从A到D的最短路径必然是 min( P_AB 最短(B-D) P_AC 最短(C-D) )。这里“从A到D”这个全局问题的最优解最短路径依赖于“从A到B”和“从A到C”这些子问题的最优解。这就是最优子结构。如果一个问题不具备最优子结构动态规划就无从谈起。比如求图中两个点的最长简单路径路径中节点不重复。从A到D的最长路径可能经过了B但这条路径中的“A到B”这一段并不一定是A到B的最长路径因为最长路径可能为了到达D而绕路导致A到B段不是最优。因此最长简单路径问题就没有最优子结构通常不能用动态规划高效求解。注意在建模时我们首先要问自己如果我知道了所有规模更小的子问题的最优解我能否有效地构造出当前问题的最优解如果能那很可能就具备了最优子结构。2.2 重叠子问题记忆化避免无效劳动重叠子问题是指在递归求解过程中相同的子问题会被多次计算。动态规划通过“记忆化”缓存子问题的解来避免这种重复计算这是它提升效率的关键。考虑计算斐波那契数列 F(n) F(n-1) F(n-2)。如果用朴素的递归计算F(5)时需要计算F(4)和F(3)计算F(4)时又需要计算F(3)和F(2)。你看F(3)被计算了至少两次。当n很大时这种重复是指数级增长的。这就是典型的重叠子问题。动态规划的做法是我们创建一个数组dpdp[i]表示F(i)的值。我们从最小的子问题开始dp[0]0, dp[1]1。然后我们可以按顺序计算dp[2] dp[1] dp[0],dp[3] dp[2] dp[1] 以此类推。这样每个F(i)只计算一次时间复杂度从指数级的O(2^n)降到了线性的O(n)。这个dp数组就是我们的“记忆”它存储了所有已解决的子问题的答案。在实际的复杂问题中重叠子问题可能不那么显而易见需要我们对问题有深入的理解和恰当的建模才能发现。识别出重叠子问题往往就找到了动态规划状态定义的线索。3. 动态规划建模五步法以“零钱兑换”问题为例理论懂了怎么用我总结了一个通用的五步建模法几乎可以套用到所有动态规划问题上。我们用一个经典的LeetCode问题“322. 零钱兑换”作为贯穿始终的例子来演示。问题描述给定不同面额的硬币coins和一个总金额amount计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额返回-1。假设每种硬币的数量无限。3.1 第一步定义状态DP数组的含义这是最关键也最需要技巧的一步。状态的定义必须能够描述当前问题的“局面”并且这个局面能够通过更小的子局面推导出来。通常状态会与问题的目标直接相关。对于零钱兑换问题我们的目标是“最少硬币个数”。一个很自然的想法是设dp[i]表示凑成总金额i所需的最少硬币个数。这里状态变量就是金额i。dp数组的长度就是amount 1因为我们要表示从0到amount的所有金额。3.2 第二步确定状态转移方程递推关系找到了状态定义就要找出状态之间的关系即如何用已知的小状态dp[j](j i) 来求出当前状态dp[i]。这是动态规划的核心逻辑。对于金额i我们最后一步操作是什么一定是选择了硬币列表coins中的某一枚硬币假设其面额为coin。那么在凑出金额i之前我们一定已经凑出了金额i - coin并且用了dp[i - coin]枚硬币。然后再加上这枚coin就得到了总额i硬币数就是dp[i - coin] 1。由于我们要求的是“最少”硬币数所以我们需要遍历所有可能的coin选择那个使得dp[i - coin] 1最小的方案。注意i - coin必须大于等于0。因此状态转移方程为dp[i] min(dp[i], dp[i - coin] 1) 其中coin遍历coins中的所有硬币且i - coin 0。这里有一个边界dp[0]表示凑出金额0需要的最少硬币数显然是0。3.3 第三步初始化DP数组在开始递推之前我们需要给DP数组一个初始值。这个初始值要保证状态转移方程能够正确启动并且通常要表示“无解”或“初始状态”。对于dp[i] 我们最初可以将其初始化为一个很大的数比如amount 1或float(inf)表示目前还没有找到凑出金额i的方法。因为最多的情况就是用i枚1元硬币所以amount 1是一个安全的上界。特别地dp[0] 0。3.4 第四步确定遍历顺序我们需要决定以什么顺序来填充这个dp数组。这取决于状态之间的依赖关系。从状态转移方程dp[i]依赖于dp[i - coin]可以看出要计算dp[i] 必须先知道所有比i小的dp值。因此我们应该从小到大遍历i。对于硬币列表coins的遍历放在内层或外层都可以但通常放在内层更直观对于每个金额i 我尝试所有可能的硬币。所以最终的循环结构是for i in range(1, amount 1): for coin in coins: if i - coin 0: dp[i] min(dp[i], dp[i - coin] 1)3.5 第五步举例推导验证在编码前用一个简单的例子在纸上演算一遍是避免低级错误的最佳方法。假设coins [1, 2, 5]amount 11。初始化dp [0, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12](长度12dp[0]0 其余为12)i1 coin1 -dp[1] min(12, dp[0]11) 1i2 coin1 -dp[2] min(12, dp[1]12) 2 coin2 -dp[2] min(2, dp[0]11) 1i3 coin1 -dp[3] min(12, dp[2]12) 2 coin2 -dp[3] min(2, dp[1]12) 2i4 coin1 -dp[4]min(12, dp[3]13)3 coin2 -dp[4]min(3, dp[2]12)2i5 coin1 -dp[5]min(12, dp[4]13)3 coin2 -dp[5]min(3, dp[3]13)3 coin5 -dp[5]min(3, dp[0]11)1... 以此类推最终dp[11]应该等于3551。纸上推导无误后就可以放心编码了。4. 从自顶向下到自底向上两种实现范式的选择与对比动态规划有两种主流的实现方式记忆化搜索自顶向下和制表法自底向上。它们本质相同但思考角度和代码风格迥异。4.1 记忆化搜索更贴近自然思维的递归记忆化搜索就是给递归加“缓存”。我们直接从原问题比如f(amount)开始思考递归地调用子问题f(amount - coin)。在每次计算完一个子问题后将其结果存储起来。下次再遇到相同的子问题时直接返回缓存的结果避免重复递归。对于零钱兑换问题记忆化搜索的Python代码可能长这样def coinChange(coins, amount): from functools import lru_cache lru_cache(None) # 使用Python内置的缓存装饰器 def dfs(rem): if rem 0: return float(inf) # 无解 if rem 0: return 0 # 金额为0需要0个硬币 min_cost float(inf) for coin in coins: res dfs(rem - coin) if res ! float(inf): min_cost min(min_cost, res 1) return min_cost ans dfs(amount) return ans if ans ! float(inf) else -1优点思维直观非常贴近我们对问题的自然分解递归树。只会计算实际需要的子状态对于某些状态空间很大但实际触及状态不多的问题可能更高效。缺点递归有深度限制对于问题规模极大时可能引发栈溢出。递归调用有一定开销常数时间可能比迭代稍大。代码结构相对分散状态转移的逻辑隐藏在递归函数中。4.2 制表法更高效的迭代制表法就是我们前面五步法演示的方法。我们显式地定义DP表数组并按照确定的顺序通常是从小到大逐个填充表中的每一个状态。零钱兑换的制表法实现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 0: dp[i] min(dp[i], dp[i - coin] 1) return dp[amount] if dp[amount] ! float(inf) else -1优点运行效率高通常是迭代形式没有递归开销。代码结构清晰DP表一目了然便于调试。不受递归深度限制。缺点需要事先明确所有状态的计算顺序有时不如记忆化搜索直观。会计算所有状态即使有些状态可能用不到。如何选择初学者或问题复杂时我推荐先从记忆化搜索入手。因为它强迫你思考“这个问题的状态是什么”以及“状态之间如何转移”而不用过早纠结于循环顺序。写出来之后再尝试将其转化为制表法这是一个很好的练习。追求极致性能或状态顺序清晰时直接使用制表法。在竞赛或工程中制表法通常是首选。当状态空间依赖复杂比如拓扑序时记忆化搜索的“懒计算”特性可能更省事。实操心得在真实项目中我通常会先写一个记忆化搜索的版本作为“原型”和验证逻辑正确性的工具。一旦逻辑清晰无误再重构成迭代的制表法用于最终部署。这个过程能帮你更深刻地理解状态间的依赖关系。5. 经典模型剖析背包问题的状态定义艺术掌握了基本步骤后我们需要接触更复杂的模型来提升建模能力。“背包问题”是动态规划的试金石它衍生出多种变体核心区别就在于状态定义。我们来看两个最经典的0-1背包和完全背包。5.1 0-1背包每个物品只能选一次问题有N件物品和一个容量为V的背包。第i件物品的体积是v[i]价值是w[i]。求解将哪些物品装入背包可使这些物品的总体积不超过背包容量且总价值最大。状态定义这是最容易出错的地方。一个经典且高效的定义是dp[i][j]表示从前i件物品中选择并且总体积不超过j时所能获得的最大价值。这里“前i件物品”和“体积不超过j”共同构成了一个状态。为什么这么定义因为它完美地刻画了决策过程我们正在处理第i件物品并且背包还剩j的容量。状态转移方程对于第i件物品我们只有两种选择不选那么最大价值就是从前i-1件物品中选容量不超过j的最大价值即dp[i-1][j]。选前提是j v[i]那么最大价值就是“第i件物品的价值w[i]”加上“从前i-1件物品中选容量不超过j - v[i]的最大价值”即w[i] dp[i-1][j - v[i]]。我们要取两者的最大值dp[i][j] max(dp[i-1][j], dp[i-1][j - v[i]] w[i])(if j v[i])。初始化dp[0][...] 0 表示前0件物品价值为0。空间优化滚动数组观察转移方程dp[i][...]只依赖于dp[i-1][...]。这意味着我们不需要保存整个二维数组只需要一个一维数组dp[j]来表示“容量不超过j的最大价值”。但遍历顺序有讲究我们必须逆序遍历容量j从V到0。因为dp[j]更新时需要用到上一轮i-1时的dp[j - v[i]]如果正序遍历dp[j - v[i]]可能已经被本轮i时更新过了这就变成了“完全背包”的逻辑。优化后的核心代码dp [0] * (V 1) for i in range(1, N 1): for j in range(V, v[i] - 1, -1): # 逆序遍历 dp[j] max(dp[j], dp[j - v[i]] w[i])这个“逆序”是0-1背包空间优化的精髓务必理解其缘由。5.2 完全背包每个物品无限次可选问题条件同0-1背包但每种物品有无限件。状态定义可以和0-1背包一样dp[i][j]表示前i种物品容量不超过j的最大价值。状态转移方程区别在于对于第i种物品我们可以选0件、1件、2件...直到放不下。理论上需要加一个循环kdp[i][j] max(dp[i-1][j], dp[i-1][j - k*v[i]] k*w[i])。但这效率太低。更优的推导是当我们考虑dp[i][j]时如果选择至少一件第i种物品那么我们可以看作先放一件i物品然后问题变成了“依然从前i种物品里选因为无限件容量变为j - v[i]”即dp[i][j - v[i]] w[i]。所以方程简化为dp[i][j] max(dp[i-1][j], dp[i][j - v[i]] w[i])。注意第二个项是dp[i][...]而不是dp[i-1][...]这体现了物品可以重复选取。空间优化同样可以优化到一维。状态转移方程为dp[j] max(dp[j], dp[j - v[i]] w[i])。此时遍历顺序必须是正序从v[i]到V。因为我们需要用到的dp[j - v[i]]应该是已经考虑了本件物品第i种的更新结果这样才能实现“无限取用”。优化后的核心代码dp [0] * (V 1) for i in range(1, N 1): for j in range(v[i], V 1): # 正序遍历 dp[j] max(dp[j], dp[j - v[i]] w[i])对比0-1背包的逆序和完全背包的正序是理解两者本质区别的关键。零钱兑换问题本质上就是一个完全背包问题硬币无限求的是最小硬币数最小价值所以我们的遍历顺序是正序。6. 实战用动态规划解决一个生产调度问题理论模型终究要为实际问题服务。我曾参与一个简单的工厂生产调度项目其中一个小模块就用了动态规划。问题简化后如下某车间有一条生产线可以生产两种产品A和B。生产一件A需要2小时利润为5生产一件B需要3小时利润为8。生产线每周有效工时为40小时。产品A和B每周的市场需求上限分别为12件和10件。问如何安排每周生产计划生产多少A和B使得总利润最大且不超工时和需求上限。这本质上是一个二维约束的背包问题工时和需求。我们可以用动态规划来求解。第一步定义状态我们需要两个维度来刻画“资源使用情况”使用的工时和生产的A产品数量B的数量可以推导但为了清晰我们将其也作为状态维度之一但这样状态空间会很大。更优的做法是将一种产品的数量作为决策变量另一种通过资源约束计算。这里为了演示我们采用更通用的三维DP。 设dp[i][j][k]表示考虑前i周本例中i1可省略在生产了j件A产品k件B产品时所花费的最少工时因为我们要求利润最大等价于在工时约束下求最大利润但这里我们转换一下思路用DP来枚举所有可行的(j,k)组合再计算利润。更实用的状态定义是dp[t][a]表示花费了t工时生产了a件A产品时所能生产的最多B产品数量。但这样还是有点绕。让我们回归背包思想总资源是40工时每个“物品”是“生产一件A”或“生产一件B”它们消耗工时体积产生利润价值。但这里有额外约束每种物品有数量上限。这是一个多维约束的背包问题。我们可以定义dp[t][a][b]为布尔值表示使用t工时生产a件A和b件B是否可行。但三维布尔数组寻找最大利润不够直接。第二步寻找更优的状态定义一个更清晰的方法是dp[t][a]表示在使用了t工时生产了a件A产品的情况下所能获得的最大利润。此时我们能生产的B产品数量为b (t - 2*a) / 3必须为整数且0且b 10。同时a 12。利润就是5*a 8*b。这样我们只需要遍历工时t和A的数量a检查对应的b是否合法即可。第三步状态转移与实现我们遍历所有可能的t和a对于每个状态我们可以尝试增加生产一件A如果资源允许来转移到新状态或者增加生产一件B。但更简单的方法是直接枚举。伪代码思路max_profit 0 for t in range(0, 41): # 工时 for a in range(0, 13): # A产品数量 if 2 * a t: # 生产a件A所需工时已超 continue remaining_time t - 2 * a # 计算在剩余工时内最多能生产多少件B max_b min(remaining_time // 3, 10) # 不能超过需求上限 for b in range(0, max_b 1): if 2*a 3*b 40: # 总工时约束 profit 5*a 8*b if profit max_profit: max_profit profit best_a, best_b a, b print(f最大利润: {max_profit}, 生产A: {best_a}件, 生产B: {best_b}件)这段代码其实是枚举法但对于本题规模很小是可行的。如果要严格用DP递推可以定义dp[t][a]为使用t工时生产a件A时的最大利润然后通过状态转移dp[t][a] max(dp[t][a], dp[t-2][a-1] 5, ...)来更新但转移关系涉及B产品写起来稍复杂。对于这种小规模离散问题清晰的枚举有时比强套DP模板更易理解和维护。这个例子想说明的是动态规划建模没有唯一的标准答案。状态定义需要你深入理解问题本质在“状态表达能力”和“状态空间大小”之间做权衡。有时一个巧妙的状态定义能让问题瞬间简化。7. 避坑指南动态规划中那些常见的“坑”在实际使用中动态规划有几个高频出错点我几乎在每个项目初期都会遇到或看到队友遇到。7.1 坑一错误的状态定义导致信息丢失这是最致命的错误。状态必须包含做出后续决策所需的所有必要信息。例如在“股票买卖”系列问题中如果你只定义dp[i]为第i天的最大利润你就无法知道当天是否持有股票从而无法决定今天是买入、卖出还是持有。正确的做法是定义两个状态dp[i][0]表示第i天结束时不持有股票的最大利润dp[i][1]表示第i天结束时持有股票的最大利润。缺少了持股状态这个信息转移方程就无法建立。检查方法问自己知道了当前状态后能否在不依赖历史决策细节的情况下做出下一步的所有合法决策如果不能说明状态定义可能遗漏了关键信息。7.2 坑二遍历顺序的陷阱我们已经在0-1背包和完全背包中看到了正序和逆序的重要性。在其他问题中遍历顺序也可能由状态依赖关系决定。例如在一个二维网格如机器人路径规划中如果只能向右或向下走那么dp[i][j]通常依赖于dp[i-1][j]和dp[i][j-1]。因此我们遍历i和j时从小到大即可。但如果移动方向更复杂比如可以向左就可能产生循环依赖需要更复杂的处理如拓扑排序或SPFA。黄金法则在编写循环时确保当你计算dp[state]时它所依赖的所有子状态dp[prev_state]都已经被计算过了。画一个状态依赖图有助于理清顺序。7.3 坑三初始化不恰当初始化不仅是为递推提供起点也常常用来表示“不可能状态”。例如在求最小值问题时我们通常将DP数组初始化为一个很大的数inf表示初始时没有合法解。在求方案数问题时dp[0]通常初始化为1表示空方案是一种方案。错误的初始化会导致结果错误或无法启动递推。建议仔细考虑边界情况如索引为0时。对于求最优解问题思考“一个都不选”时的状态值是什么。对于求方案数问题思考“空方案”是否算一种方案。7.4 坑四混淆“恰好”与“不超过”在背包问题中dp[j]可以有两种含义1) 容量恰好为j时的最优解2) 容量不超过j时的最优解。这两种定义对应的初始化和最终答案可能不同。“恰好”dp[0]0 其他dp[...] -inf求最大或inf求最小。最终答案需要遍历所有j V取最优。“不超过”dp[...]0求最大或inf求最小。最终答案就是dp[V]。在零钱兑换问题中我们用的是“恰好”的概念凑成总金额amount所以初始化时dp[0]0 其他为inf。如果定义为“不超过”初始化全0最终dp[amount]可能为0如果只用大额硬币凑不够amount但也没用硬币这显然不对。7.5 坑五忽视空间优化后的状态覆盖问题使用滚动数组进行空间优化时务必注意更新顺序是否会导致需要用的旧状态被新状态覆盖。0-1背包的逆序就是为了防止本轮的更新覆盖掉下一轮还需要用的“上一轮”状态。这是一个非常经典的错误即使有经验的工程师在写新代码时也可能疏忽。调试技巧当怀疑DP结果不对时首先打印出完整的、未进行空间优化的二维DP表与你的手工推导进行对比。这能快速定位是状态转移方程错误还是空间优化导致的覆盖错误。动态规划是一门需要大量练习来培养直觉的技术。最好的学习方法就是去实现那些经典模型背包、LCS、LIS、编辑距离等然后在实际项目中寻找可以应用它的场景。开始时可能会觉得建模困难但当你成功用DP优雅地解决一个棘手问题后那种成就感是无与伦比的。记住多画图状态转移图、多举例小规模测试、多思考状态定义的物理意义是掌握这门艺术的唯一路径。
返回列表