动态规划核心:重叠子问题与最优子结构详解及实战
2026/8/25 7:34:35 网站建设 项目流程

1. 动态规划:从“傻算”到“聪明算”的思维跃迁

如果你刷过算法题,或者准备过技术面试,那么“动态规划”这四个字对你来说,绝对是一个又爱又恨的存在。爱的是,一旦掌握了它,很多看似复杂的难题都能迎刃而解,代码简洁优雅;恨的是,它的入门门槛似乎有点高,状态转移方程、重叠子问题、最优子结构这些概念,听起来就让人头大。很多人学动态规划,就像在背公式,题目稍微一变就无从下手。今天,我们不谈那些枯燥的定义,就从最朴素的想法出发,聊聊动态规划到底是怎么一回事,以及它赖以生存的两个核心基石——重叠子问题最优子结构。理解了它们,你才算真正摸到了动态规划的门道,而不是仅仅在背模板。

简单来说,动态规划是一种“用空间换时间”的算法思想,它通过把原问题分解为相对简单的子问题,并存储子问题的解来避免重复计算,从而高效地解决复杂问题。它特别适合解决那些具有“最优子结构”和“重叠子问题”性质的问题。听起来还是有点抽象?别急,我们用一个最经典的例子,一步步把它掰开揉碎。

2. 从斐波那契数列看透“重叠子问题”

2.1 最直观的递归解法及其陷阱

让我们从几乎所有人都见过的斐波那契数列(Fibonacci Sequence)开始。它的定义很简单:F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2) (n >= 2)。比如,数列的前几项是:0, 1, 1, 2, 3, 5, 8, 13...

如果让你写一个函数计算F(n),你的第一反应很可能是递归:

def fib_recursive(n): if n <= 1: return n return fib_recursive(n-1) + fib_recursive(n-2)

代码非常简洁,完全符合数学定义。我们来计算一下fib_recursive(5)的过程。为了得到F(5),我们需要计算F(4)F(3);为了得到F(4),需要计算F(3)F(2);为了得到F(3),需要计算F(2)F(1)…… 我们可以把这个计算过程画成一棵递归树:

F(5) / \ F(4) F(3) / \ / \ F(3) F(2) F(2) F(1) / \ / \ F(2) F(1)F(1)F(0) / \ F(1) F(0)

仔细观察这棵树,你会发现一个严重的问题:F(3)被计算了两次,F(2)被计算了三次,F(1)F(0)被计算的次数更多。随着n的增大,这种重复计算会呈指数级增长。计算F(20)时,F(3)会被重复计算上千次!这就是典型的“重叠子问题”:在求解问题的过程中,相同的子问题被反复计算多次。

注意:这里就是动态规划思想的第一个触发点。当你发现你的递归解法存在大量重复计算时,就应该立刻想到,是否可以用某种方式把这些子问题的解“存起来”,避免重复劳动。

2.2 引入“记忆化搜索”:解决重叠子问题的初级方案

既然问题是重复计算,那么最直接的想法就是“记住”已经算过的结果。这种方法在算法中被称为“记忆化搜索”“带备忘录的递归”。我们创建一个数组(或字典)memo,在计算F(n)之前,先查一下memo[n]有没有值;如果有,直接返回;如果没有,再计算,并把结果存入memo再返回。

def fib_memoization(n, memo=None): if memo is None: memo = {} if n in memo: return memo[n] if n <= 1: memo[n] = n else: memo[n] = fib_memoization(n-1, memo) + fib_memoization(n-2, memo) return memo[n]

还是计算F(5)。这次,当计算完F(3)后,结果被保存在memo[3]中。之后无论哪条分支再需要F(3),都直接从备忘录中读取,避免了重新展开递归树进行计算。这使得时间复杂度从恐怖的指数级O(2^n)降到了线性级O(n),因为每个子问题(F(0)F(n))都只被计算了一次。

实操心得:记忆化搜索是理解动态规划非常棒的桥梁。它本质上是一种“自顶向下”的动态规划。你写的还是递归函数,但通过一个备忘录避免了重复。在面试或竞赛中,如果一时想不出状态转移方程,先写出一个暴力递归,然后加上记忆化,往往就能得到一个可接受的、高效的解法。

2.3 递推解法:标准的“自底向上”动态规划

记忆化搜索是“自顶向下”的,我们从目标F(n)出发,逐步分解到基础情况。动态规划更常见的写法是“自底向上”的递推。我们直接从基础情况开始,一步步推导到目标。

  1. 定义状态dp[i]表示斐波那契数列第i项的值。
  2. 确定初始状态(边界条件)dp[0] = 0,dp[1] = 1
  3. 状态转移方程dp[i] = dp[i-1] + dp[i-2] (i >= 2)。这个方程描述了状态之间是如何“转移”或“推导”的。
  4. 计算顺序:由于dp[i]依赖于dp[i-1]dp[i-2],我们必须从i=2开始,从小到大依次计算。
def fib_dp(n): if n <= 1: return n dp = [0] * (n + 1) # 创建DP数组,多一位是为了方便,dp[i]对应F(i) dp[0], dp[1] = 0, 1 # 初始化 for i in range(2, n + 1): dp[i] = dp[i-1] + dp[i-2] # 状态转移 return dp[n]

这个过程清晰明了。我们用一个表格(dp数组)清晰地记录了所有子问题的解。计算dp[5]时,dp[3]dp[2]早已计算好并被存储在表格中,直接取用即可,完美解决了重叠子问题。

更进一步的空间优化:观察状态转移方程dp[i] = dp[i-1] + dp[i-2],我们发现当前状态i只依赖于前两个状态i-1i-2。这意味着我们不需要保存整个dp数组,只需要用两个变量滚动记录前两个状态即可,将空间复杂度从O(n)优化到O(1)

def fib_optimized(n): if n <= 1: return n prev, curr = 0, 1 # prev = F(0), curr = F(1) for i in range(2, n + 1): prev, curr = curr, prev + curr # 滚动更新 return curr

提示:这种空间优化技巧在动态规划中非常常见,尤其是当状态转移只依赖于有限的几个前序状态时(如前1个、前2个)。在写出标准DP解法后,一定要审视一下状态转移方程,看是否有空间优化的可能。这不仅能提升代码效率,在面试中也是重要的加分项。

3. 最优子结构:动态规划能够求解最优解的前提

理解了重叠子问题,我们解决了“计算效率”的问题。但动态规划更强大的地方在于求解“最优解”问题,比如最短路径、最大利润、最长序列等。这就要求问题必须具备第二个关键性质:最优子结构

3.1 什么是最优子结构?

最优子结构指的是:一个问题的最优解,包含其子问题的最优解。换句话说,我们可以通过子问题的最优解,来构造出原问题的最优解。

这个概念有点绕,我们用一个更生活化的例子来解释:假设你要从北京开车到上海,并且想找一条最短的路线。如果这个问题具有最优子结构,那么意味着:从北京到上海的最短路线,一定是由从北京到某个中间城市(比如济南)的最短路线,加上从济南到上海的最短路线组成的。如果从北京到济南你走的不是最短路线,那么拼出来的北京-上海路线也必然不是最短的。

3.2 经典案例剖析:凑零钱问题

LeetCode上经典的“322. 零钱兑换”问题完美诠释了最优子结构。问题描述:给你一个整数数组coins表示不同面额的硬币,以及一个整数amount表示总金额。计算并返回可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回-1。你可以认为每种硬币的数量是无限的。

为什么它能用动态规划?假设amount = 11,coins = [1, 2, 5]。我们定义dp[i]为凑出金额i所需的最少硬币数量。

  • 我们想知道dp[11](原问题的最优解)。
  • 考虑最后一步,凑出11元,最后一枚硬币可能是1元、2元或5元。
    • 如果最后一枚是1元,那么剩下的11-1=10元需要以最优方式凑出,即需要dp[10]枚硬币。那么总硬币数为dp[10] + 1
    • 如果最后一枚是2元,总硬币数为dp[9] + 1
    • 如果最后一枚是5元,总硬币数为dp[6] + 1
  • dp[11]应该是这三种可能中的最小值:min(dp[10]+1, dp[9]+1, dp[6]+1)

这里的关键在于:为了求dp[11],我们需要知道dp[10]dp[9]dp[6]这些子问题最优解。并且,dp[11]这个原问题的最优解,确实是由这些子问题的最优解(dp[10]等)推导出来的。这就是“最优子结构”。

状态转移方程dp[i] = min(dp[i - coin] + 1 for coin in coins if i - coin >= 0)初始状态dp[0] = 0(凑出0元需要0枚硬币),其他dp[i]初始化为一个很大的数(比如amount + 1),表示暂时不可达。

def coinChange(coins, amount): # 初始化dp数组,dp[i]表示金额i的最小硬币数,初始化为一个不可能的大数 dp = [amount + 1] * (amount + 1) dp[0] = 0 # 边界条件 # 遍历所有金额状态,从1到amount for i in range(1, amount + 1): # 遍历所有硬币选择 for coin in coins: if i - coin >= 0: # 确保减去硬币面值后不会变成负数 # 状态转移:尝试用这枚硬币,看是否能得到更优解 dp[i] = min(dp[i], dp[i - coin] + 1) # 如果dp[amount]没有被更新过,说明无法凑出 return dp[amount] if dp[amount] != amount + 1 else -1

常见问题与排查

  • 问题:为什么dp数组要初始化为amount + 1
    • 解答:因为最多的情况就是用amount个1元硬币来凑,所以amount + 1是一个有效的“无穷大”标识,比任何可能的解都大。最后通过判断dp[amount]是否等于这个值来判断是否无解。
  • 问题:双重循环的顺序能换吗?先遍历硬币还是先遍历金额?
    • 解答:在这个问题中,必须外层遍历金额,内层遍历硬币。因为我们的状态dp[i]表示的是对于固定金额i,考虑所有硬币选择后的最优解。如果外层遍历硬币,就变成了另一种思路(完全背包问题的排列数/组合数问题),求出的就不是本题要求的最小硬币数了。这是动态规划中“遍历顺序”的关键点,顺序错了,结果就错了。

3.3 不具备最优子结构的反例

并非所有求最优解的问题都有最优子结构。一个著名的反例是“无权图的最长简单路径”问题。假设我们要求图中从A点到D点的最长简单路径(不重复经过节点)。

B / \ A D \ / C

路径 A-B-D 长度为2,路径 A-C-D 长度也为2。但是,A到D的最长路径可能是 A-B-C-D 长度为3。你会发现,A-B-C-D 这条整体最优路径,并不是由 A-B(最优子路径)和 B-C-D(最优子路径)组成的,因为A-B只是A到B的一条边,而B-C-D也不是B到D的最长路径(B-D更长)。子问题(A到B的最长路径、B到D的最长路径)的最优解,无法合并成原问题(A到D的最长路径)的最优解。因此,最长简单路径问题不具备最优子结构,不能用动态规划高效求解(实际上它是NP-Hard问题)。

4. 动态规划的通用解题框架与思维模式

通过上面的例子,我们可以总结出一套解决动态规划问题的通用思维框架。这套框架能帮你面对新问题时,一步步理清思路。

4.1 五步法拆解动态规划问题

第一步:定义状态(最重要也是最难的一步)状态就是描述问题局面的一组变量。定义的状态要能唯一确定一个子问题,并且要能通过状态转移方程向其他状态迁移。

  • 对于斐波那契数列:状态就是idp[i]表示第i项的值。
  • 对于凑零钱问题:状态就是当前要凑的金额idp[i]表示凑出金额i的最少硬币数。
  • 对于经典的最长上升子序列(LIS)问题:状态通常是以第 i 个数字结尾的最长上升子序列的长度,记为dp[i]
  • 对于01背包问题:状态通常是二维的dp[i][w],表示考虑前i件物品,在背包容量为w的情况下能获得的最大价值。

第二步:确定状态转移方程(核心推导)找出状态之间的关系,即如何从已知的小状态,推导出未知的大状态。这是动态规划的灵魂。

  • 斐波那契:dp[i] = dp[i-1] + dp[i-2]
  • 凑零钱:dp[i] = min(dp[i - coin] + 1)for coin in coins
  • LIS:dp[i] = max(dp[j] + 1)for allj < iandnums[j] < nums[i]
  • 01背包:dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i]] + value[i])(如果放得下)

第三步:确定初始状态(边界条件)也就是最小的、不可再分的子问题的解。这是递推的起点。

  • 斐波那契:dp[0]=0, dp[1]=1
  • 凑零钱:dp[0]=0
  • LIS:每个dp[i]至少为1(自身构成序列)。
  • 01背包:dp[0][...] = 0(考虑0件物品价值为0),dp[...][0] = 0(容量为0价值为0)。

第四步:确定计算顺序确保在计算当前状态时,它所依赖的子状态已经被计算出来。

  • 斐波那契、凑零钱、LIS:通常是从小到大遍历。
  • 01背包:外层遍历物品i,内层遍历容量w。注意,内层遍历容量时,如果是01背包(每件物品最多选一次),需要从大到小遍历,以避免物品被重复选取;如果是完全背包(物品无限),则需要从小到大遍历。

第五步:优化空间(可选但重要)分析状态转移方程,看是否能用更小的空间来存储状态,例如用滚动数组或几个变量。

4.2 思维模式:如何想到用动态规划?

当你遇到一个新问题时,可以问自己以下几个问题:

  1. 问题是否在求一个最优解(最大值、最小值、最长、最短等)?如果是,动态规划是一个候选。
  2. 问题能否被分解为规模更小的相似子问题?尝试思考,要解决原问题,是否需要先解决几个更小的、模式相同的问题?
  3. 这些子问题是否相互重叠?即解决大问题时,是否会反复遇到相同的小问题?如果是,记忆化/动态规划可以避免重复计算。
  4. 子问题的最优解能构成原问题的最优解吗?即是否满足“最优子结构”?这是动态规划有效的关键。

以“爬楼梯”问题为例(每次可以爬1或2级台阶,到第n级有多少种方法):

  1. 求方案数,可以看作一种“计数”最优解。
  2. 想到达第n级,最后一步要么从第n-1级跨1步,要么从第n-2级跨2步。所以ways(n)依赖于ways(n-1)ways(n-2)。问题被分解了。
  3. 计算ways(n-1)时又会用到ways(n-2)ways(n-3),显然ways(n-2)被重复计算了。存在重叠子问题。
  4. 到达第n级的总方法数,确实等于从n-1级上来的方法数加上从n-2级上来的方法数。最优子结构成立。 结论:这是一个斐波那契数列问题的变种,可以用动态规划完美解决。

5. 经典问题深度实战:01背包与最长上升子序列

掌握了框架,我们用它来解剖两个更复杂、面试频率极高的经典问题。

5.1 01背包问题:二维状态与空间优化

问题描述:有N件物品和一个容量为W的背包。第i件物品的重量是weight[i],价值是value[i]。每件物品只能选择一次(0或1),求解将哪些物品装入背包可使总价值最大。

第一步:定义状态这是最核心的一步。我们必须用状态描述出“当前决策到了哪一步”以及“当前的背包容量”。因此,定义一个二维数组dp[i][w]

  • i代表我们只考虑前i件物品(物品编号从1到N)。
  • w代表当前背包的剩余容量(实际编程中常表示容量上限)。
  • dp[i][w]表示:考虑前i件物品,在背包容量为w的情况下,可以装入的最大价值

第二步:状态转移方程对于第i件物品,我们只有两种选择:或者不装

  1. 不装:那么问题就等价于“考虑前i-1件物品,容量为w的情况”,价值为dp[i-1][w]
  2. :首先需要背包能装下,即w >= weight[i]。如果装,那么背包容量会减少weight[i],价值增加value[i]。此时问题等价于“考虑前i-1件物品,容量为w - weight[i]的情况”加上当前物品的价值,即dp[i-1][w - weight[i]] + value[i]

我们要的是最大价值,所以在这两种选择中取最大值:dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i]] + value[i]),其中后一项仅在w >= weight[i]时有效。

第三步与第四步:初始化和计算顺序

  • 初始化:当没有物品或背包容量为0时,最大价值为0。即dp[0][...] = 0dp[...][0] = 0
  • 计算顺序:外层循环遍历物品i从1到N,内层循环遍历背包容量w从0到W。这样能保证在计算dp[i][w]时,dp[i-1][w]dp[i-1][w - weight[i]]都已经被计算出来。
def knapsack_01(N, W, weight, value): # 初始化dp数组,多一行一列用于表示0物品/0容量的情况 dp = [[0] * (W + 1) for _ in range(N + 1)] for i in range(1, N + 1): # 遍历物品 for w in range(W + 1): # 遍历容量 # 默认选择:不装第i件物品 dp[i][w] = dp[i-1][w] # 如果装得下,尝试装,看是否更优 if w >= weight[i-1]: # 注意weight/value数组索引从0开始 dp[i][w] = max(dp[i][w], dp[i-1][w - weight[i-1]] + value[i-1]) return dp[N][W]

第五步:空间优化(滚动数组)观察状态转移方程dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i]] + value[i]),当前行i的状态只依赖于上一行i-1的状态。因此,我们完全不需要保存整个二维表格,只需要一个一维数组dp[w]即可。

但这里有一个至关重要的细节:内层循环必须从大到小遍历容量W。 为什么?因为dp[i][w]依赖于dp[i-1][w]dp[i-1][w - weight[i]]。如果我们用一维数组,并且从小到大遍历w,那么在计算dp[w]时,dp[w - weight[i]]可能已经被当前第i轮的更新值覆盖了,这就相当于第i件物品被重复使用了多次,违背了01背包“每个物品只能用一次”的规则。从大到小遍历可以保证在计算dp[w]时,dp[w - weight[i]]保存的还是上一轮 (i-1) 的值。

def knapsack_01_optimized(N, W, weight, value): dp = [0] * (W + 1) # 一维dp数组 for i in range(N): # 遍历物品 # 内层循环倒序,从W到weight[i] for w in range(W, weight[i] - 1, -1): dp[w] = max(dp[w], dp[w - weight[i]] + value[i]) return dp[W]

实操心得:01背包的空间优化是面试必考知识点。务必理解“为何要倒序”。你可以这样记忆:01背包是“唯品会”(唯一物品),内层倒序;完全背包是“淘宝”(无限物品),内层正序。这个类比能帮你快速区分两种背包问题的代码实现。

5.2 最长上升子序列(LIS):一维状态与二分查找优化

问题描述:给定一个无序的整数数组nums,找到其中最长严格递增子序列的长度。子序列不要求连续。

第一步:定义状态一种最直观的状态定义是:dp[i]表示以第i个数字结尾的最长上升子序列的长度。注意,这个定义强制要求子序列必须包含nums[i]

第二步:状态转移方程如何求dp[i]?既然子序列以nums[i]结尾,那么我们就需要看看在i之前的所有位置j(0 <= j < i),哪些位置的数比nums[i]小。如果nums[j] < nums[i],那么nums[i]就可以接在以 nums[j] 结尾的LIS后面,形成一个更长的上升子序列,其长度就是dp[j] + 1。 我们需要遍历所有满足条件的j,找到那个能形成最长序列的,即:dp[i] = max(dp[j] + 1),对于所有0 <= j < inums[j] < nums[i]。 如果找不到这样的j(即i前面的数都比它大),那么dp[i] = 1(它自己构成一个序列)。

第三步与第四步

  • 初始化:每个位置至少可以以自己结尾,所以dp[i] = 1
  • 计算顺序:从左到右遍历i,对于每个i,需要遍历它前面所有的j。时间复杂度为O(n^2)
def lengthOfLIS(nums): if not nums: return 0 n = len(nums) dp = [1] * n # 初始化,每个元素自身就是一个长度为1的LIS max_len = 1 for i in range(1, n): for j in range(i): if nums[j] < nums[i]: dp[i] = max(dp[i], dp[j] + 1) max_len = max(max_len, dp[i]) # 更新全局最大值 return max_len

第五步:优化(贪心+二分查找,时间复杂度O(n log n))O(n^2)的解法在数据量大时可能超时。有一种更巧妙的、基于“耐心排序”思想的O(n log n)解法。

我们维护一个数组tailstails[k]的值代表长度为 k+1 的上升子序列的末尾元素的最小值。这个数组一定是严格递增的(为什么?因为长度更长的子序列,其末尾元素不可能比长度短的子序列的末尾元素小)。

遍历数组nums

  • 如果nums[i]tails中所有元素都大,说明它可以接在所有已知子序列后面形成更长的子序列,那么就把它追加到tails末尾。
  • 否则,在tails数组中找到第一个大于等于nums[i]的元素,并用nums[i]替换它。这个查找过程可以用二分查找完成。 最终,tails数组的长度就是最长上升子序列的长度。

这个做法的核心思想是:我们总是希望上升子序列末尾的元素尽可能小,这样后面才有更多机会接上更大的数,使得序列更长。

def lengthOfLIS_optimized(nums): tails = [] for num in nums: # 二分查找在tails中第一个 >= num 的位置 left, right = 0, len(tails) while left < right: mid = (left + right) // 2 if tails[mid] < num: left = mid + 1 else: right = mid # 如果left等于tails长度,说明num比所有末尾都大 if left == len(tails): tails.append(num) else: tails[left] = num # 替换,使得该长度的子序列末尾元素更小 return len(tails) # tails的长度就是LIS的长度

注意事项:这个优化算法得到的tails数组,其长度是正确的LIS长度,但tails本身并不一定是真实的LIS。它只能求出长度。如果需要输出具体的LIS序列,通常还是需要用O(n^2)的DP方法并记录路径。

6. 避坑指南与高频问题排查

动态规划代码写出来,但结果不对?这是初学者常遇到的问题。下面是一些常见的坑和排查技巧。

1. 状态定义不清晰或错误

  • 症状:状态转移方程怎么都写不对,或者写出来非常复杂。
  • 排查:回到问题本身,重新思考你要用哪些信息来描述一个“子问题”。状态变量是否足够?是否包含了所有影响决策的关键信息?对于背包问题,容量通常是必须的;对于序列问题,以某个位置结尾常常是一个好选择。

2. 状态转移方程遗漏情况

  • 症状:结果比预期小(漏算)或比预期大(多算)。
  • 排查:在推导方程时,务必穷举所有可能的选择。比如在背包问题中,对于每个物品,必须考虑“放”和“不放”两种情况。在LIS问题中,对于每个i,必须考虑前面所有比它小的j

3. 初始状态设置错误

  • 症状:程序在边界情况(如空数组、容量为0)下出错或返回错误结果。
  • 排查:仔细考虑最小子问题的解是什么。dp[0]dp[0][0]通常需要手动赋予一个合理的值。对于求最大值/最小值的问题,初始值有时需要设为负无穷或正无穷。

4. 遍历顺序错误

  • 症状:这是背包问题空间优化后最容易出错的地方。结果错误,物品被重复计算。
  • 排查
    • 01背包(物品唯一):使用一维dp数组时,内层循环遍历容量必须从大到小
    • 完全背包(物品无限):使用一维dp数组时,内层循环遍历容量必须从小到大
    • 对于多维状态或复杂依赖,可以画一个简单的依赖图,确保计算当前状态时,它所依赖的状态都已经计算好了。

5. 数组索引越界

  • 症状:运行时报IndexError
  • 排查:检查dp数组的长度是否足够。通常需要len(dp) = n + 1len(dp) = W + 1来容纳边界状态。在状态转移方程中访问dp[i-1]dp[i - coin]等下标时,一定要先判断i-1 >= 0i - coin >= 0

6. 将“子序列”与“子数组”混淆

  • 症状:用解子数组(连续)的方法去解子序列(不连续)问题,或者反过来。
  • 排查:审题时务必看清是“Subsequence”(子序列,可不连续)还是“Subarray”(子数组,必须连续)。它们的状态定义和转移方程通常不同。LIS是子序列问题;而“最大子数组和”是子数组问题,其状态通常定义为以nums[i]结尾的最大子数组和,转移方程为dp[i] = max(nums[i], dp[i-1] + nums[i])

最后,提升动态规划能力没有捷径,唯有多练、多总结。从简单的斐波那契、爬楼梯开始,到背包、LIS、编辑距离等经典问题,每做一题,都严格按照“定义状态、写出方程、确定初值、确定顺序、代码实现、思考优化”的流程过一遍。慢慢地,你就能培养出对动态规划问题的直觉,看到新题也能快速拆解,这才是真正掌握了这门“聪明算”的艺术。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询