想象一位登山者要翻越一座峰峦叠嶂的大山。如果他从山脚就闷头往上冲,遇到岔路只能瞎猜,那多半会在半山腰迷路。聪明的登山者会先在地图上规划好沿途的补给营地,然后从山脚的大本营出发,每到一个营地就翻开随身小本子,记下“从这里继续向上能拿到的最优收益”。下次再经过这个营地时,翻一下本子就能直接抄答案,不用重新探路。动态规划(Dynamic Programming,简称 DP)在算法面试里的地位,就像这份“登山策略”之于登山者:把复杂的大问题拆成一层层小营地,逐级建立最优解。很多同学觉得 DP 难,不是因为需要高深的数学功底,而是缺了一套“先定营地、再铺路、后出发”的章法。这篇指南会用同一个登山故事,把 DP 的核心思想、状态定义、状态转移方程、初始化、遍历顺序,以及背包问题、打家劫舍两大经典题型串起来,最后给你一套面试速答模板。
动态规划是 AI 算法面试的高频考点,吃透 DP 的思维框架,你不仅能稳拿背包、打家劫舍等经典题,还能量化分析模型中的序列决策问题——这是 AI 从业者拉开差距的硬核技能。
可视化演示
本节配有交互动效(进入视口后自动加载,约 3KB)。
状态转移——DP核心思想 dp[i]: 前i个状态的最优解 dp[i]=max(dp[i-1],dp[i-2]+v) 最优子结构+重叠子问题 dp[i] = max(dp[i-1], dp[i-2] + nums[i])
DP核心思想
登山者出发前,手里有两样东西:一张标满营地位置的地图,一本空白的小本子。他深知,翻越整座山的最好办法,是先征服脚下的一座座小山坡——每个小山坡怎么走,都不会被后面的大山影响;整座山的最优路线,就是由这些小山坡的最优路线拼接出来的。这是他智慧的起点:大问题的最优解,藏在子问题的最优解里。
计算科学家把这种智慧叫作动态规划(Dynamic Programming,DP)。它解决的是两类问题。第一类叫最优子结构:原问题的最优解可以通过子问题的最优解推导出来,就像整条登山路线由一段段最优山道拼成。第二类叫重叠子问题:同一个子问题会被反复用到,就像从不同路线出发的人都会经过同一个营地。如果不用 DP 而用裸递归,同一段山路会被重复走无数遍;DP 的做法是:每个营地只算一次,把答案记在小本子上,下次直接查。
实现 DP 有两条路。自顶向下叫记忆化搜索:按递归思路计算,但把算过的结果缓存起来。自底向上叫递推:从第一个子问题开始,按顺序一个个算到目标,面试中更常用。斐波那契数列是理解这两个概念的经典例子,设 f(n) 为登上第 n 级台阶的方案数:
回到登山者身上。他每征服一个小山坡,就在本子上写下“这个营地的营地最优值”;后面来的登山者看到记录,就不用再自己探一遍路。斐波那契的 f(n) 和登山营地记录本质上是同一件事:把已解决过的子问题答案存下来,避免重复计算。这就是 DP 的全部秘密——它并不神奇,只是比不懂记录的登山者多了一个“小本子”。
- 确定边界条件当 n=1 或 n=2 时,子问题小到不需要拆分,答案直接定为 1,这是整条递推链的地基。
- 写出递推关系当 n≥3 时,把当前问题拆成两个更小的子问题,并把两个答案相加。
状态定义方法
登山者规划路线时,第一件事不是迈腿,而是选营地:在哪些位置扎营、每个营地记录什么信息。营地选得准,后面的路就顺;营地选得偏,整条路线都会绕远。
在 DP 里,“选营地”就是状态定义——决定 dp 数组的下标表示什么、数组里存什么。状态定义是解题中最重要也最容易卡住新手的一步。一个实用技巧是:从题目问什么出发。题目问“前 n 个物品能装下多大价值”,就把 dp[i] 定义为“前 i 个物品的最优价值”;题目问“走到第 i 格的最短步数”,就把 dp[i] 定义为“到达第 i 格的最短步数”。下标 i 就是子问题的“规模刻度”,数组值就是该刻度的答案。这叫作题意抽象。
状态定义还要注意两点。一是维度:只和“位置”有关的问题用一维 dp[i];和“位置 + 剩余容量”两个因素有关的问题用二维 dp[i][j];因素再多就继续加维度。二是无后效性:一个状态一旦确定,它后面的决策不需要知道“当初是怎么到这个状态的”,就像登山者到了某个营地后,只需关心从这里到山顶怎么走,而不必关心自己之前走过哪条路。如果状态定义把“来时的路线”都记进去,维度会爆炸,递推也无法进行。
营地的位置定了,本子上每一行写什么也就定了。很多同学写不出转移方程,其实是状态定义太模糊——就像登山者连营地都没选好,当然不知道下一段路怎么走。状态定义是 DP 的地图,画好地图,铺路才有方向。
- 确定下标含义i 表示“考虑了前 i 个元素”,相当于登山者走到了第 i 个营地的时刻。
- 确定数组值含义dp[i] 存的是前 i 个补给站能获得的最优累计得分,代表“这个子问题的最优解”。
状态转移方程
营地选好之后,登山者开始铺路。他从大本营出发,每到一个新营地都会问自己:我是从上一个营地直接走过来的,还是绕过了上一个营地、从更早的营地过来的?每段山路都有一个“路标”,写着这条路线的收益。
在 DP 里,铺路对应的就是状态转移方程——回答“dp[i] 是从哪个状态、经过什么决策得到的”。写转移方程有两个思路:一是看谁来,想清楚从哪个子问题转移来;二是看决策,每个状态有哪些选择,选最优。这就是决策枚举的过程。比如经典的“打家劫舍”:一排房屋,每间有金额 nums[i],不能偷相邻两家。站在第 i 家门前只有两个选择——不偷它,答案就是前 i-1 家的最优解 dp[i-1];偷它,就必须跳过第 i-1 家,答案是 dp[i-2] + nums[i]。两个选择取最大,就是当前营地的递推关系。
写转移方程时最容易犯错的是边界衔接——比如 i=1 时,dp[i-2] = dp[-1] 会越界,这时要么先手动初始化 dp[0]、dp[1],要么在循环里加特判。把状态比作营地名单,转移方程就是连接营地的山路网,每一段路的“路标”都写着:从哪个营地出发、经过什么决策、收益是多少。转移方程写得顺,登山者就能一路小跑;写得乱,每一步都卡在半山腰。
- 不选当前决策dp[i-1] 表示直接沿用上一个营地的结果,当前营地的收益 v_i 不计入。
- 选择当前决策dp[i-2] + v_i 表示从 i-2 号营地一步跨到 i 号营地,并收取当前营地的收益 v_i。
- 取较优路径用 max 在两条来路之间选出收益更大的那条,保证每个营地都记录最优值。
初始化与遍历
登山者对路线的第一步永远是定大本营:起点在哪里、第一段路怎么走。大本营没搭好,后面所有营地都失去了参照——这就是 DP 里的初始化。
初始化给递推提供地基边界。以打家劫舍为例,dp[0] 表示只有第一间房时的最优值,显然就是 v_1;dp[1] 表示有两间房且不能同时偷,答案是 max(v_1, v_2)。这两个值一旦定好,从 i=2 开始循环,转移方程就有东西可用了。
除了初始值,还要注意数组大小和遍历顺序。数组长度要能容纳所有状态下标,防止越界;有些题目为了统一逻辑,会加一个“哨兵”(dummy 节点),在 dp 数组前多开一位,让 dp[0]、dp[1] 对应真实的第一、二个元素,把特判吸收进初始化。遍历方向则要服从依赖关系:计算当前状态需要哪些更小的状态,就保证那些状态先被算出来。登山者从不多想,他记得最牢的一句话就是:地基决定高度,方向决定终点。 初始化把大本营钉牢,遍历顺序保证每一步都踩在已经铺好的山上。
- 初始化第一个状态只有一间房时,最优值就是这间房的价值。
- 初始化第二个状态有两间房且不能相邻偷时,只能在两家之间选更值钱的一家。
- 确定遍历起点与方向从 i=2 开始逐个计算,保证算 dp[i] 时 dp[i-1]、dp[i-2] 已经就绪;遍历方向通常是从小到大、从前到后。
背包问题
登山行囊的空间是有限的。出发前,他对着地图和物品清单纠结:每一件补给品都有重量和价值,行囊最多能装容量 C 的东西,怎么塞才能让总价值最大?这正是背包问题的考法。
0-1 背包是最基础的一类:每件物品只能选一次。设 dp[j] 表示容量为 j 的行囊能装下的最大价值。遍历每件物品 i 时,对容量 j 只有两个选择:不装它(保持 dp[j]),或腾出 w_i 的空间装下它(dp[j - w_i] + v_i)。
如果每件物品可以无限次取用,那就是完全背包——这时内层循环反而要从小到大正序遍历,让“取一件再取一件”成为可能。两类背包只差一个遍历方向,却是面试官最爱埋的坑。此外,二维 dp[i][j] 可以优化成一维 dp[j],这种空间优化思想在编程界有个朴素的称呼——滚动数组:不需要保留全部历史,只滚动保留当前有用的几行。登山者最后把行囊塞得满满当当,既没超重,价值又最高。他总结道:背包问题的本质,是在容量限制下做“要还是不要”的取舍;一维数组加倒序遍历,就是这场取舍中防止贪婪重复的金钥匙。
- 理解容量维度dp[j] 表示容量为 j 的行囊能装下的最大价值,j 从 0 到总容量 C。
- 不装当前物品dp[j] 原值保留,对应“行囊腾不出空间或不想装它”的选择。
- 装下当前物品dp[j - w_i] + v_i 表示先腾出 w_i 的空间,放入价值 v_i 的物品。
- 倒序遍历防止重复0-1 背包要求每件物品至多用一次,所以内层循环 j 必须从 C 向 w_i 递减。
打家劫舍
有一片营地建在环形山道上,首尾相接。保安每隔一个营地设一个岗哨,登山者不能连续走进两个相邻营地,否则会惊动岗哨。面对一圈价值不同的营地,怎么走才能拿到最大收益?这就是打家劫舍的环形版本。
先看线性序列。一排营地从 0 到 n-1,状态转移和第三节一样:dp[i] = max(dp[i-1], dp[i-2] + nums[i])。物理含义很直观:走到第 i 个营地,要么不进去(沿用 dp[i-1]),要么进去(跳过第 i-1 个营地,拿 nums[i] 并加上 dp[i-2])。
更妙的是状态压缩:线性打家劫舍的递推只用到 dp[i-1] 和 dp[i-2],所以根本不需要存整个数组,用两个变量滚动更新即可,空间复杂度从 O(n) 降到 O(1)。面试中主动说出这一步,往往能让面试官眼前一亮。登山者面对环形山道没有慌,他只是把环上某一段路“剪开”,变成自己熟悉的直线,然后把两条可能的直线都走一遍,取更好的那条。遇到环形、圆形这类首尾相连的问题,先想想怎么把它剪成直线——这是打家劫舍与许多变形题通用的破局思路。
- 线性情形对于普通线性序列,站在第 i 家面前只有“偷”与“不偷”两种决策。
- 拆环为线环形问题中,把序列切成 [0, n-2] 和 [1, n-1] 两段,分别按线性 DP 求解。
- 空间压缩线性递推只依赖前两个状态,可以用两个滚动变量替代整个数组。
解题模板
经验丰富的登山者每次出发前都检查四件装备:地图(状态定义)、路标(转移方程)、大本营(初始化)、登山杖(遍历顺序)。这四件装备齐全,再陡的山也不怕。面试中遇到 DP 题,同样按四步走。
四步模板如下。第一步,定状态:明确 dp 数组的下标和值分别表示什么。第二步,写转移:站在当前状态问自己“从哪个状态、经过什么决策能到这里”,写出递推关系。第三步,初始化:把地基边界填好,常需要特殊处理 dp[0]、dp[1] 或空集情况。第四步,定遍历顺序:确保当前状态依赖的子状态已经计算完毕,根据依赖方向选择正序或倒序。
面试时还有一个小技巧:先说思路再写代码。比如开口就说“这道题我用一维 dp,dp[i] 表示前 i 家的最大收益,转移是 max 两个分支,复杂度 O(n)”,然后才动笔。老登山者把四件装备挂在山杖上,一步一步稳稳走向山顶。他明白,登山不是拼速度,而是拼章法;面试 DP 题不是拼灵感,而是拼模板。 四步走顺畅了,任何 DP 题在你眼里都只是“换了一张地图的登山路线”。
- 估算时间复杂度状态个数乘以每个状态转移的代价,一维线性 DP 通常是 O(n)。
- 估算空间复杂度dp 数组占用的空间,一般与状态个数同阶。
- 滚动优化当转移只依赖前两个状态时,用两个变量替代数组,空间降到 O(1)。
AI 应用场景
广告预算分配:给投放渠道“塞行囊”
阶段:广告投放策略优化
解决的问题:广告平台的算法工程师面对几十个投放渠道,每个渠道有成本与预期点击回报。人工按经验分配预算,要么超预算、要么浪费。0-1 背包把预算当作行囊容量、渠道当作物品,自动选出“总回报最大”的渠道组合,宛如登山者把最有价值的补给品塞进行囊。
具体案例:某信息流广告系统在每日预算 10 万元下,借助 0-1 背包算法从 30 个候选中选出 12 个渠道组合,使投放回报提升约 18%。
互斥项目选择:金融风控的“不能相邻”
阶段:投资组合与风控决策
解决的问题:投资组合中,某些项目因共用资金池或合规限制不能相邻配置。量化投研人员用打家劫舍的思路,把项目排成一列,用 dp[i] = max(dp[i-1], dp[i-2] + value[i]) 选出互不相邻的最优组合,相当于登山者绕着环形山道选露营点。
具体案例:某券商的智能投顾模块处理 15 个候选债券时,要求相邻债券不能同时重仓,使用线性 DP 求最优组合后,年化收益风险比提升约 12%。
序列决策路径规划:AI 助手的“登山小本子”
阶段:智能调度与路径规划
解决的问题:外卖物流平台要给骑手规划最优取送路线,每个路口的状态值被大量后续路线反复用到。用 DP 记录“到达每个路口的最低耗时”避免重复计算,正是登山者小本子的工程化版本。
具体案例:某即时配送平台用类似 DP 的递推思想为骑手规划多取多送路线,高峰期平均配送时长缩短约 9%。
Python 演示
# -*- coding: utf-8 -*-def house_robber(nums): if not nums: return 0 n = len(nums) if n == 1: return nums[0] dp = [0] * n dp[0] = nums[0] dp[1] = max(nums[0], nums[1]) for i in range(2, n): dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]) return dp[n - 1] def house_robber_circle(nums): if len(nums) == 1: return nums[0] return max(house_robber(nums[:-1]), house_robber(nums[1:])) def knapsack_01(weights, values, capacity): dp = [0] * (capacity + 1) for w, v in zip(weights, values): for j in range(capacity, w - 1, -1): dp[j] = max(dp[j], dp[j - w] + v) return dp[capacity] if __name__ == '__main__': nums = [2, 7, 9, 3, 1] print('linear rob:', house_robber(nums)) print('circle rob:', house_robber_circle(nums)) weights = [2, 3, 4, 5] values = [3, 4, 5, 6] print('knapsack:', knapsack_01(weights, values, 8))
dp = [0] * n先铺好 n 个营地的“小本子”,每个位置初始为 0 分。dp[0] = nums[0]初始化第一个营地:只有一间房时直接拿它的金额。dp[1] = max(nums[0], nums[1])初始化第二间房:相邻不能都偷,选金额更大的那家。dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])状态转移方程:不偷当前 vs 隔一家偷当前。max(house_robber(nums[:-1]), house_robber(nums[1:]))环形拆线:分别求“不偷尾”和“不偷头”的线性最优再取最大。dp = [0] * (capacity + 1)一维背包数组,下标 j 就是行囊容量。for j in range(capacity, w - 1, -1):倒序遍历容量,防止同一件物品被重复装入。dp[j] = max(dp[j], dp[j - w] + v)背包核心转移:不装当前物品 vs 腾出空间装下它。
常见误区
误区我一直觉得 DP 就是把递归改成“加个缓存”,反正都是把所有情况试一遍,加不加缓存只是快慢问题。
正解记忆化搜索只是 DP 的一种实现形式,并非所有“递归+缓存”都是 DP。DP 的灵魂是用子问题最优解构建全局最优解,关键在提炼状态与状态转移;纯粹暴力递归即使加了缓存,只要子问题结构没有理清,也不能被称为 DP。面试中更常见的自底向上递推,在空间占用和代码可读性上往往更优。
为何常见:很多教材都用斐波那契讲 DP,而斐波那契的递推与记忆化递归长得太像,容易让人误以为“缓存就是 DP”。
误区我写 DP 题都是 `dp = [0] * n` 开局,反正后面会被刷成最大值,初始化为多少无所谓。
正解初始化必须符合问题语义。求最大值时边界常用 0 或 -∞(表示不可达);求最小值时边界常设为 +∞(如编辑距离中 dp[0][j] 应为 j 而非 0)。打家劫舍的 dp[1] 也绝不是 0,而是 max(nums[0], nums[1])。地基错了,上面的路再直也白搭。
为何常见:很多入门题恰好初始化为 0 也能通过,导致“初始化无用”的错觉被反复强化。
误区反正都是一维 dp,容量从大到小还是从小到大遍历,最后结果应该一样吧?
正解差别极大。倒序遍历时,dp[j - w] 还是“上一件物品”的旧值,物品不会重复装;正序遍历时,dp[j - w] 可能已被当前物品更新过,一件物品会被无限次使用,悄悄变成完全背包。记住口诀:0-1 背包倒序,完全背包正序。
为何常见:人脑习惯从左往右遍历,正序写起来更顺手,只有提交踩坑后才会注意到顺序的玄机。
误区我只要把 dp[i] 定义对,转移方程照着含义写就行,初始化随便安排一下不会出大问题。
正解状态定义只是第一步。转移方程、边界初始化、遍历顺序三者会单独出错,且错误会互相放大。比如打家劫舍的 dp[1] 初始化不对,即使转移方程完全正确,结果也会差之千里。完整四步——定状态、写转移、初始化、定遍历——每一步都要向面试官交代清楚。
为何常见:新手看题解时只盯着“dp[i] 表示什么”这一行,很容易忽略后面几行的细节。
误区首尾相连的环形房,直接把线性的 dp 循环从 0 写到 n-1,最后再检查一下首尾是否冲突就行。
正解环上没有明确的“起点”,直接递推很难定义边界条件。通用解法是拆环为线:分别求“去掉第一家”与“去掉最后一家”两条线性序列的最优解,再取二者最大值。这样首尾不能同时偷的约束被天然满足。
为何常见:环形只比线性多“首尾相邻”这一点,容易让人以为稍加修补即可,实际上递推的边界在环上会循环引用。
面试题
Q1. 动态规划能高效求解下列哪类问题的关键前提?
- A.问题具备最优子结构← 正确答案
- B.问题不存在重复计算
- C.问题只含单一决策项
- D.问题规模固定不变化
答案:A。动态规划的适用前提是“最优子结构”——大问题的最优解由子问题的最优解构成,如同整条登山路线由一段段最优山道拼成。B 说反了,DP 恰恰擅长解决存在“重叠子问题”的情况。C、D 都不是 DP 的关键特征。故答案选 A。
Q2. 用 dp 数组自底向上求斐波那契数列第 n 项,时间复杂度是?
- A.O(1) 常数级时间
- B.O(logn) 对数级时间
- C.O(n) 线性级时间← 正确答案
- D.O(n²) 平方级时间
答案:C。自底向上递推时每个子问题只算一次,从 f(1) 推到 f(n) 共 n 次计算,所以时间复杂度是 O(n)。若不使用 DP 而直接裸递归,才会退化成指数级。A、B、D 分别对应常数时间、快速幂和双重循环的复杂度,故选 C。
Q3. 0-1 背包一维空间优化后,内层循环遍历容量的正确方向是?
- A.从小到大正序遍历容量
- B.从大到小倒序遍历容量← 正确答案
- C.按照任意方向顺序遍历
- D.先正序后倒序再正序
答案:B。倒序遍历保证 dp[j-w] 取到的是“上一轮”的旧值,防止当前物品被重复放入,这正是 0-1 背包每件物品至多一次的要求。正序遍历会让一件物品被多次装入,等价于完全背包。故选 B。
Q4. 打家劫舍线性问题的 dp[i] = max(dp[i-1], dp[i-2] + nums[i]) 中,dp[i] 的含义是?
- A.前 i 家能偷到的最高金额← 正确答案
- B.第 i 家一定会被偷的最大值
- C.前 i 家的所有金额相加总和
- D.第 i 家到末尾的最高金额
答案:A。dp[i] 表示从前 i 家(下标 0 到 i)能偷到的最大金额,与第 i 家是否被偷无关。若含义是“第 i 家一定被偷”,转移应写成 dp[i] = dp[i-2] + nums[i],不会出现 dp[i-1] 分支;C 是求和而非最优化;D 方向反了。故选 A。
Q5. 完全背包与 0-1 背包的本质区别是?
- A.物品数量与价值大小各异
- B.物品是否可以无限次重复选← 正确答案
- C.背包总容量是否受到限制
- D.外层遍历物品时的顺序不同
答案:B。0-1 背包每件物品最多选一次,完全背包每种物品可以无限取用。这一本质差异在代码上体现为内层容量循环的方向:倒序对应 0-1,正序对应完全。A、C、D 都不是本质区别,故选 B。
Q6. 环形打家劫舍(首尾相连)最常见的解法思路是?
- A.直接当作普通线性问题来解
- B.分不选首尾两情况取最大← 正确答案
- C.增加状态维度记录首尾情况
- D.使用贪心策略选取最大金额
答案:B。环形比线性只多一个约束:首尾不能同时偷。最常见的做法是把环剪开成两条线,分别求“不偷第一家”(nums[1:])与“不偷最后一家”(nums[:-1])的线性最优解,再取最大值,所以选 B。A 无视首尾约束;C 可行但复杂;D 贪心无法保证全局最优。
Q7. 为什么动态规划的初始化至关重要?
- A.初始化决定动态规划的最优值
- B.提供递推地基边界影响答案← 正确答案
- C.初始化可以降低时间复杂度
- D.初始化能省去状态转移过程
答案:B。初始化提供递推链的起点和边界,比如打家劫舍的 dp[0]、dp[1],一个值写错,后面的 dp 会一路错下去,正如地基决定大楼的朝向。初始化本身不决定最优值,也不能降复杂度或省去转移,A、C、D 描述均不准确。
Q8. 动态规划适合解决的问题通常具备哪些特征?
- A.问题具备最优子结构特性← 正确答案
- B.存在大量重叠的子问题← 正确答案
- C.满足贪心选择性质的约束
- D.各子问题之间相互独立
答案:AB。最优子结构与重叠子问题是 DP 的两大支柱。C 是贪心算法的特征,与 DP 无关;D 是分治法的前提,DP 恰恰允许并利用子问题重叠来节省计算。故选 A、B。
Q9. 关于多维状态定义,下列说法正确的是?
- A.状态维度越多刻画越细致← 正确答案
- B.维度增多通常推高复杂度← 正确答案
- C.状态定义须满足无后效性← 正确答案
- D.状态维度越多结果一定越好
答案:ABC。维度用于刻画问题的必要信息,增加维度确实让状态更细致,也会使时间与空间复杂度上升;无后效性则是一切 DP 状态的硬性要求。但维度不是越多越好,冗余维度只会浪费计算资源,D 说法错误。故选 A、B、C。
Q10. 下列属于动态规划空间优化手段的有?
- A.使用滚动数组压缩存储← 正确答案
- B.用状态压缩合并冗余维← 正确答案
- C.使用贪心策略替代求解
- D.将二维数组降为一维存储← 正确答案
答案:ABD。滚动数组(只保留前几个状态)、状态压缩(用位运算合并冗余维)、二维降一维(0-1 背包的经典做法)都是 DP 空间优化手段。C 是更换算法思路而非空间优化,故选 A、B、D。