news 2026/8/27 20:48:36

动态规划核心实现:备忘录法与自底向上法详解与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
动态规划核心实现:备忘录法与自底向上法详解与实战

1. 项目概述:从“暴力”到“优雅”的必经之路

如果你刷过算法题,或者在工作中处理过复杂的优化问题,那么“动态规划”这四个字对你来说,一定不陌生,甚至可能带着一丝又爱又恨的情绪。爱的是,它确实能解决那些看似无从下手的复杂问题;恨的是,状态转移方程想不明白,代码写出来又慢又吃内存。今天我们不聊那些高深莫测的理论证明,就聚焦在两个最核心、最实用的实现技巧上:备忘录法自底向上法。你可以把它们看作是解决动态规划问题的两把“瑞士军刀”,一把从上往下“探索式”解决问题,另一把从下往上“建设式”铺平道路。

简单来说,动态规划的核心思想是“记住已经解决过的子问题答案,避免重复计算”。但怎么“记住”?从哪里开始“记”?这就是备忘录法和自底向上法的分野。前者,我们称之为记忆化搜索,它保留了递归的直观思维,但用一张“备忘录”表格剪掉了重复的递归分支;后者,我们称之为经典的DP表格法,它彻底抛弃递归,从最小的子问题开始,一步步迭代填满整个表格,最终得到答案。理解这两者的区别、各自的适用场景以及内在的优化技巧,是你能把动态规划从“看懂答案”提升到“随手写出高效解”的关键一步。无论你是正在备战面试的求职者,还是需要优化业务逻辑的工程师,掌握这两招,都能让你在面对“最长上升子序列”、“01背包”这类经典问题时,思路更清晰,代码更稳健。

2. 核心思路拆解:两种哲学,一种目标

动态规划不是一种具体的算法,而是一种方法论。它的目标始终如一:通过最优子结构和重叠子问题,避免重复计算,提升效率。备忘录法和自底向上法是实现这一目标的两种不同路径,其背后的设计哲学和思考起点截然不同。

2.1 备忘录法:带着地图的深度探险者

备忘录法,本质上是递归+缓存。它的思考过程是最符合人类直觉的:我们想要解决一个大问题(比如爬到第n级台阶有多少种方法),就直接去思考这个大问题。要解f(n),我需要知道f(n-1)f(n-2),那我就递归地去调用f(n-1)f(n-2)。如果不加任何优化,这个递归树会指数级爆炸,因为f(n-1)计算时会再算f(n-2),而f(n-2)在之前已经被计算过了。

备忘录的引入,就是给这次探险配了一张地图。我们初始化一个数组memo,全部填上-1(表示未计算)。每次进入递归函数dfs(n)时,先查地图:如果memo[n] != -1,说明这个位置我已经来过了,答案已知,直接返回。否则,我才真正去计算它,计算完成后,把结果memo[n],然后返回。这样,每个子问题只会被计算一次。

它的核心优势在于“按需计算”。我们只计算那些为了得到最终答案所必须计算的子问题。对于某些状态空间很大、但实际可达状态不多的题目(比如一些游戏状态DP),备忘录法可以避免初始化并计算整个庞大的DP表,节省空间和时间。它的代码结构几乎就是递归的翻版,对初学者理解“状态转移”非常友好。

注意:备忘录法虽然直观,但它依然有递归的开销——函数调用栈。当递归深度很大时(比如n=10000),可能会导致栈溢出错误。这是它相对于自底向上法的一个潜在缺点。

2.2 自底向上法:步步为营的建筑师

自底向上法,则是纯粹的迭代法。它完全摒弃了递归的“顶层思考”,转而从最基础、最小的子问题开始。就像一个建筑师,他不先想屋顶怎么盖,而是先打好地基(基础状态),然后根据严格的图纸(状态转移方程),一层一层地向上建造。

我们首先定义好DP数组dp[]的含义(例如dp[i]表示到达第i级台阶的方法数)。然后,确定基础情况dp[0] = 1(起点算一种方法),dp[1] = 1。接着,就是那个经典的循环:for i in range(2, n+1): dp[i] = dp[i-1] + dp[i-2]。我们从i=2开始,因为i=0i=1我们已经知道了。这样,当我们计算dp[i]时,它所依赖的dp[i-1]dp[i-2]一定已经在之前的迭代中计算并存储好了。

它的核心优势在于“顺序确定,无递归开销”。由于是顺序迭代,我们很容易进行空间优化(例如滚动数组),也完全不用担心栈溢出。它强迫你将所有状态清晰地定义出来,思维更严谨。绝大多数动态规划教程和面试解答,默认采用的都是这种方法。

那么,如何选择?这里有一个简单的决策流:如果你的问题状态转移关系非常直观,且状态空间是连续、完整的(比如线性、矩阵),优先用自底向上法,它更高效、更标准。如果你的问题状态定义复杂,或者存在大量无效状态(计算时才会发现),或者你首先想到的是递归解法,那么备忘录法是一个极佳的优化起点,它能帮你快速得到一个正确解,之后再考虑能否转化为迭代。

3. 经典案例实战:从斐波那契到背包问题

理论说再多,不如代码跑一遍。我们通过三个经典问题,来对比这两种方法的实现和细微差别。

3.1 斐波那契数列:入门第一课

问题:求第n个斐波那契数(F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2))。

备忘录法实现:

def fib_memo(n): memo = [-1] * (n + 1) # 初始化备忘录 def dfs(i): if i <= 1: return i if memo[i] != -1: # 查备忘录 return memo[i] memo[i] = dfs(i-1) + dfs(i-2) # 计算结果并存入 return memo[i] return dfs(n)

自底向上法实现:

def fib_dp(n): if n <= 1: return n dp = [0] * (n + 1) dp[0], dp[1] = 0, 1 # 基础状态 for i in range(2, n + 1): dp[i] = dp[i-1] + dp[i-2] # 状态转移 return dp[n]

自底向上法(空间优化版):

def fib_dp_opt(n): if n <= 1: return n prev, curr = 0, 1 # 只保留前两个状态 for i in range(2, n + 1): prev, curr = curr, prev + curr return curr

对比分析:

  • 备忘录法dfs函数调用树会被大量剪枝,每个i只计算一次,时间复杂度O(n)。但它需要O(n)的栈空间(递归深度)和O(n)的备忘录空间。
  • 经典自底向上法时间复杂度O(n),空间复杂度O(n)。
  • 优化版自底向上法时间复杂度O(n),空间复杂度O(1)。这是最优解。

实操心得:斐波那契问题清晰地展示了自底向上法在空间优化上的巨大优势。一旦你发现状态转移只依赖于前几个固定状态,立刻想到“滚动数组”或变量交替。

3.2 最长上升子序列:一维状态的延伸

问题:给定一个整数数组nums,找到其中最长严格递增子序列的长度。

状态定义dp[i]表示以第i个数字结尾的最长上升子序列的长度。注意这个定义是关键,它确保了子序列的连续性。

备忘录法实现思路: 计算dfs(i),即以nums[i]结尾的LIS长度。我们需要遍历i之前的所有位置j,如果nums[j] < nums[i],那么nums[i]可以接在nums[j]形成的子序列后面,状态转移为dfs(i) = max(dfs(i), dfs(j) + 1)。同样,用memo数组存储dfs(i)的结果。

自底向上法实现:

def lengthOfLIS(nums): if not nums: return 0 n = len(nums) dp = [1] * n # 每个元素本身至少是一个长度为1的LIS for i in range(n): # 计算每个dp[i] for j in range(i): # 遍历i前面的所有元素 if nums[j] < nums[i]: dp[i] = max(dp[i], dp[j] + 1) # 状态转移 return max(dp) # 答案不是dp[n-1],而是dp数组中的最大值

复杂度分析:时间复杂度O(n²),空间复杂度O(n)。

优化技巧(贪心+二分查找): 这不是备忘录或自底向上的直接优化,而是利用了LIS问题的特殊性质。我们维护一个数组tails,其中tails[k]存储长度为k+1的上升子序列的最小可能末尾值。遍历nums,用二分查找将当前数放入tails合适的位置。最终tails的长度就是答案。此法可将复杂度降至O(n log n)。

def lengthOfLIS_opt(nums): tails = [] for num in nums: # 在tails中寻找第一个大于等于num的位置 l, r = 0, len(tails) while l < r: mid = (l + r) // 2 if tails[mid] < num: l = mid + 1 else: r = mid if l == len(tails): tails.append(num) # 比所有末尾都大,延长子序列 else: tails[l] = num # 替换,使得该长度的子序列末尾更小 return len(tails)

注意事项:二分查找优化是LIS问题的经典技巧,但它改变了DP的定义,属于“另辟蹊径”。在面试中,先给出O(n²)的标准DP解并分析复杂度,再提出可以优化到O(n log n),会是非常加分的表现。

3.3 01背包问题:二维状态的典范

问题:有N件物品和一个容量为C的背包。第i件物品重量是w[i],价值是v[i]。求解将哪些物品装入背包可使总价值最大,且不超过背包容量。

状态定义dp[i][j]表示考虑前i件物品(物品编号从1开始),在背包容量为j的情况下,能获得的最大价值。

自底向上法实现(标准版):

def knapsack_01(N, C, w, v): # 初始化dp数组,多一行一列用于边界处理 dp = [[0] * (C + 1) for _ in range(N + 1)] for i in range(1, N + 1): # 遍历物品 for j in range(C + 1): # 遍历容量 if j < w[i-1]: # 当前容量装不下第i件物品(注意索引) dp[i][j] = dp[i-1][j] # 继承不放这件物品的状态 else: # 选择:不放 或 放 dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i-1]] + v[i-1]) return dp[N][C]

状态转移方程解读dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i])。这个方程是01背包的灵魂。它意味着,对于第i件物品,我们有两种选择:不拿它,那么价值就是前i-1件物品在容量j下的最优解dp[i-1][j];拿它,那么就要在前i-1件物品中,为它腾出w[i]的重量,即dp[i-1][j - w[i]],然后加上它的价值v[i]。两者取最大值。

空间优化(滚动数组):观察状态转移方程,dp[i][...]只依赖于dp[i-1][...]。因此,我们可以只用两行数组,甚至一行数组。

def knapsack_01_opt(N, C, w, v): dp = [0] * (C + 1) # 一维数组,dp[j]表示容量为j时的最大价值 for i in range(N): # 遍历物品 # 必须逆序遍历容量!这是关键。 for j in range(C, w[i] - 1, -1): dp[j] = max(dp[j], dp[j - w[i]] + v[i]) return dp[C]

为什么必须逆序?因为dp[j]依赖于上一轮(考虑前i-1个物品时)的dp[j - w[i]]。如果正序遍历,当更新dp[j]时,dp[j - w[i]]可能已经在同一轮(考虑第i个物品时)被更新过了,这就相当于第i件物品被重复放入,变成了“完全背包”问题。逆序保证了在更新dp[j]时,dp[j - w[i]]还是上一轮的状态。

备忘录法实现思路: 定义递归函数dfs(i, j),表示考虑前i件物品,剩余容量为j时的最大价值。用二维数组memo[i][j]记录结果。递归边界是i==0(没有物品)或j==0(没有容量),返回0。递归过程同样判断当前物品能否放入,并取max(dfs(i-1, j), dfs(i-1, j-w[i])+v[i])。备忘录法在这里写起来更直观,但递归深度可能达到O(N+C),对于大规模数据可能栈溢出。

踩坑记录:01背包的空间优化写法中,内层循环逆序是一个必须刻在脑子里的点。我见过太多人在这里犯错,导致程序逻辑完全错误。正序是“完全背包”,逆序才是“01背包”。

4. 优化技巧深度剖析:时间与空间的博弈

掌握了两种基本方法,我们来看看如何让它们跑得更快、更省内存。这些技巧是区分普通解法和优秀解法的关键。

4.1 状态定义的艺术:如何设计高效的DP数组

状态定义是动态规划的灵魂,直接决定了转移方程的复杂度和空间开销。

  • 维度选择:能用一维数组绝不用二维。例如在路径问题中,如果只能向右和向下,那么到达(i, j)点的路径数dp[i][j]可以只依赖于上一行和左侧,有时可以用滚动数组优化为一维。
  • 状态含义:有时改变状态含义能极大简化问题。比如在“买卖股票”系列问题中,定义dp[i][0]表示第i天结束时持有股票的最大利润,dp[i][1]表示第i天结束时不持有股票的最大利润,比定义成“第i天买入/卖出”要清晰得多。
  • 偏移处理:当状态值可能为负数时(如一些带负权值的问题),可以将整个状态值加上一个偏移量,使其索引为正。例如,如果状态范围在[-100, 100],我们可以定义数组大小为201,索引时用状态值+100

4.2 空间优化利器:滚动数组与状态压缩

这是自底向上法的核心优化手段。

  • 滚动数组:当状态转移只依赖于固定的前几行(或前几列)时,可以只保留这些行,循环使用。最常见的是dp[i][...]只依赖于dp[i-1][...],那么只需一个dp[2][...]的数组,用i % 2来切换当前行和上一行。更进一步的,如01背包,优化到一维数组。
  • 状态压缩:当状态可以用二进制位表示时(如旅行商问题TSP中“访问过哪些城市”的状态),可以用一个整数的二进制位来表示集合,将二维甚至多维DP压缩成一维。例如,mask = 5(二进制101)表示城市0和城市2已访问。这能将指数级状态空间用位运算高效处理。

4.3 剪枝与提前终止:减少无效计算

在备忘录法和某些自底向上法中,可以通过判断提前跳过无效状态。

  • 可行性剪枝:在背包问题中,如果当前剩余容量已经小于最小物品重量,可以直接终止循环。
  • 最优性剪枝:在某些搜索类DP(如记忆化搜索)中,如果当前路径的“预估最优值”已经比已知答案差,可以立即返回。
  • 初始化优化:合理设置DP数组的初始值。有时将数组初始化为一个“不可能值”(如-INF),可以简化边界条件判断。在求最大值问题时,常初始化为0或-INF;在求最小值问题时,常初始化为INF。

4.4 遍历顺序的奥秘:拓扑序与依赖关系

自底向上法的循环顺序不是随意的,它必须满足状态依赖的拓扑序。即,在计算dp[i]时,它所依赖的所有状态dp[k](k < i)都必须已经计算完成。

  • 线性DP:通常顺序遍历即可。
  • 区间DP:通常先遍历区间长度len,再遍历起点l,终点r = l + len - 1。这样保证在计算大区间时,它依赖的小区间都已算好。
  • 背包问题:01背包内层容量逆序,完全背包内层容量正序,这是由物品能否重复选取决定的。
  • DAG上的DP:有时需要先对图进行拓扑排序,然后按照拓扑序进行状态转移。

5. 从备忘录到自底向上:思维转换与代码重构

很多同学觉得备忘录法好想,但自底向上法难写。其实它们是一个硬币的两面,可以相互转化。

转换步骤:

  1. 写出备忘录法的递归函数:明确函数签名dfs(state),定义清楚状态参数和返回值。
  2. 确定DP数组:递归函数的参数组合,就是DP数组的维度。dfs(i, j)对应dp[i][j]。递归函数的返回值,就是dp[i][j]要存储的值。
  3. 找出基础状态:对应递归的终止条件(base case)。把这些情况下的dp值直接填好。
  4. 确定遍历顺序:分析递归函数中,dfs(state)调用了哪些dfs(next_state)next_state就是state所依赖的状态。在自底向上中,你必须保证在计算dp[state]时,所有dp[next_state]都已经计算好了。这通常意味着你需要按照某种拓扑序来遍历状态。
  5. 写出状态转移方程:递归函数体内的计算逻辑,就是状态转移方程。把dfs调用换成dp数组的访问即可。

举例:爬楼梯问题(每次可爬1或2级)

  • 备忘录法:def dfs(n): if n<2:return 1; if memo[n]!=-1:return memo[n]; memo[n]=dfs(n-1)+dfs(n-2); return memo[n]
  • 转换:
    1. DP数组:一维dp[n+1]
    2. 基础状态:dp[0]=1, dp[1]=1。(从0级到0级有1种方法:不动)
    3. 遍历顺序:dfs(n)依赖dfs(n-1)dfs(n-2),即大n依赖小n。所以从i=2遍历到n
    4. 状态转移:dp[i] = dp[i-1] + dp[i-2]

个人体会:我强烈建议在初学某个新型DP问题时,先尝试用备忘录法写出一个正确的解。这能帮你理清状态和转移。一旦备忘录法通过,再着手将其转化为自底向上法。这个过程能极大地加深你对问题状态之间依赖关系的理解。久而久之,你看到问题就能直接构思出自底向上的解法了。

6. 常见陷阱与调试技巧

动态规划的bug往往比普通算法更难查,因为状态是层层递推的,一个地方出错,后面全盘皆错。

陷阱1:初始化错误

  • 现象:结果比预期小,或者出现负数等异常值。
  • 检查:仔细检查dp数组的初始值。求最大值时,是否该初始化的地方初始化为0了?是否有些状态根本不可能达到,需要初始化为-inf?边界情况(如索引为0)是否处理正确?

陷阱2:遍历顺序错误

  • 现象:结果不正确,尤其是涉及多维状态或依赖关系复杂时。
  • 检查:画一个小的状态依赖图。确认你循环的i,j顺序,是否保证了在计算dp[i][j]时,它所需要的dp[i-1][j]dp[i][j-1]等状态都已经计算完毕?在背包问题中,检查容量循环是正序还是逆序。

陷阱3:状态转移方程遗漏情况

  • 现象:结果对一部分测试用例正确,对另一部分错误。
  • 检查:重新推导状态转移方程。考虑所有可能的“选择”。在背包问题中,是“放”与“不放”;在字符串编辑距离中,是“增、删、改、不变”。确保你的maxmin操作涵盖了所有可能性。

陷阱4:数组越界

  • 现象:运行时出现索引错误。
  • 检查:特别是在状态转移中访问dp[i-1][j-w]这类索引时,确保j-w大于等于0。在初始化dp数组时,维度是否足够大(通常是n+1)。

调试技巧:

  1. 打印DP表:这是最有效的方法。在关键循环结束后,将整个dp数组打印出来。与手动计算的小规模样例的DP表进行对比,不一致的地方就是bug所在。
  2. 缩小输入:用一个非常小的、可以手动计算的输入(比如n=3,4)来测试你的程序。对比你的程序输出和手算结果。
  3. 橡皮鸭调试法:向别人(或者一只橡皮鸭)一行一行解释你的代码,特别是状态定义和转移方程。在解释的过程中,你经常能自己发现逻辑漏洞。
  4. 单元测试:针对不同的边界条件(空数组、单个元素、最大值、最小值)编写测试用例。

7. 复杂场景应用与思维拓展

掌握了基础模型和优化技巧后,动态规划可以应用于更复杂的场景,这往往需要你将实际问题巧妙地映射到已知模型,或者组合多种技巧。

场景1:带维度增加的DP例如“股票买卖”问题,状态中需要增加一个维度来表示当前是否持有股票、以及交易次数。dp[i][k][0/1]表示第i天,最多进行了k次交易,手上不持有/持有股票的最大利润。状态转移方程需要同时考虑天数的推移和交易动作。

场景2:区间DP用于解决涉及区间性质的问题,如石子合并、最长回文子串。核心是定义dp[l][r]表示区间[l, r]上的最优解,然后枚举区间分割点k,状态转移通常形如dp[l][r] = min/max(dp[l][k] + dp[k+1][r] + cost(l, r, k))。遍历顺序必须是先小区间后大区间。

场景3:树形DP当问题结构是一棵树时(如公司派对、二叉树抢劫),需要在树上进行状态转移。通常采用后序遍历(深度优先搜索),在递归返回时,将子节点的状态信息汇总到父节点。状态定义往往与节点是否被选中有关(如dp[node][0]表示不选node节点的最优解,dp[node][1]表示选中的最优解)。

场景4:状态机DP有些问题可以抽象成在一个状态机中转移。例如“买卖股票含冷冻期”问题,可以定义三个状态:dp[i][0]持有股票,dp[i][1]不持有股票且在冷冻期,dp[i][2]不持有股票且不在冷冻期。然后清晰地画出状态之间的转移关系图,再写出转移方程。

思维拓展:何时想到用DP?我个人的经验是,当问题满足以下一个或多个特征时,可以优先考虑DP:

  1. 求最值:最大值、最小值、最长、最短等。
  2. 计数问题:有多少种方法、多少种方案。
  3. 可行性问题:是否存在某种方案。
  4. 问题可以分解:大问题的最优解包含子问题的最优解(最优子结构)。
  5. 子问题重叠:在递归求解过程中,相同的子问题被反复计算。

最后,再分享一个我自己的学习心得:动态规划的功力=经典模型熟练度 + 问题抽象能力 + 大量练习。先把“斐波那契”、“爬楼梯”、“01背包”、“完全背包”、“最长公共子序列”、“最长上升子序列”、“编辑距离”这几个最经典的模型练到肌肉记忆。然后,遇到新问题时,努力去联想它和哪个经典模型相似,或者如何通过增加状态维度来转化为经典模型。这个过程没有捷径,刷题量上去后,那种“这道题一看就是DP”的直觉自然就来了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/27 20:47:30

【单片机毕设案例分享】基于 STM32 蓝牙通信的智能供水参数远程调控系统 基于 STM32 的传感器融合智能饮水设备控制系统设计(012105)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于单片机&#xff0c;STM32单片机&#xff0c;51单片机&#xff0c;J…

作者头像 李华
网站建设 2026/8/27 20:47:11

数学建模竞赛复盘:从综合评价到回归分析的全流程实战解析

1. 从“已完成”到“可复现”&#xff1a;华数杯C题深度复盘的价值 看到“2023华数杯C题已完成”这个标题&#xff0c;很多同学的第一反应可能是&#xff1a;哦&#xff0c;一篇分享答案的帖子。但如果你点进来&#xff0c;是想找一份可以直接“抄”的代码和论文&#xff0c;那…

作者头像 李华
网站建设 2026/8/27 20:46:15

26-CPU进程

进程 - 线程 进程&#xff1a;1.进程概念&#xff1a;程序&#xff1a;存放在外存中的一段代码的集合&#xff0c;不动的&#xff0c;只是一个文件进程&#xff1a;程序动态执行的过程&#xff0c;包括进程的创建、调度、消亡一个程序可以有多个进程&#xff0c;各种软件的多用…

作者头像 李华
网站建设 2026/8/27 20:45:16

少儿编程怎么选?

先说结论&#xff1a;别问"哪家最好"&#xff0c;这个问题没有答案。 我从今年春天开始给孩子挑编程课&#xff0c;试听上了不少&#xff0c;销售电话接了一堆。前期完全是懵的——每家都说自己课程体系科学、师资强、能培养思维&#xff0c;听完全都一样。 后来我摸…

作者头像 李华
网站建设 2026/8/27 20:45:09

大促期间被DDoS攻击怎么办?618、直播、秒杀,五个高危时刻的防护要

对电商、直播和在线发布业务而言&#xff0c;风险最集中的时刻&#xff0c;往往是活动开始后的关键窗口。正常用户、接口请求和攻击流量同时涌入&#xff0c;带宽、连接数、应用接口和源站资源可能一起出现异常。618、双11、直播开播、秒杀、新品发布和会员日又有明确时间点&am…

作者头像 李华
网站建设 2026/8/27 20:45:06

谷歌云上部署Claude Code:从零构建AI生成Web应用

很多同学第一次把 Claude Code 这类 AI 编程助手和谷歌云&#xff08;Google Cloud&#xff09;放在一起时&#xff0c;首先要面对的问题往往是“环境到底怎么搭”“AI 生成的代码到底怎么部署到云服务器上”“部署完怎么让它一直在线”。本文就围绕这条完整链路&#xff0c;从…

作者头像 李华