想象一位登山者要翻越一座峰峦叠嶂的大山。如果他从山脚就闷头往上冲,遇到岔路只能瞎猜,那多半会在半山腰迷路。聪明的登山者会先在地图上规划好沿途的补给营地,然后从山脚的大本营出发,每到一个营地就翻开随身小本子,记下“从这里继续向上能拿到的最优收益”。下次再经过这个营地时,翻一下本子就能直接抄答案,不用重新探路。动态规划(Dynamic Programming,简称 DP)在算法面试里的地位,就像这份“登山策略”之于登山者:把复杂的大问题拆成一层层小营地,逐级建立最优解。很多同学觉得 DP 难,不是因为需要高深的数学功底,而是缺了一套“先定营地、再铺路、后出发”的章法。这篇指南会用同一个登山故事,把 DP 的核心思想、状态定义、状态转移方程、初始化、遍历顺序,以及背包问题、打家劫舍两大经典题型串起来,最后给你一套面试速答模板。

岗位关联

动态规划是 AI 算法面试的高频考点,吃透 DP 的思维框架,你不仅能稳拿背包、打家劫舍等经典题,还能量化分析模型中的序列决策问题——这是 AI 从业者拉开差距的硬核技能。

目录
  1. 可视化演示
  2. DP核心思想
  3. 状态定义方法
  4. 状态转移方程
  5. 初始化与遍历
  6. 背包问题
  7. 打家劫舍
  8. 解题模板
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

本节配有交互动效(进入视口后自动加载,约 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 的全部秘密——它并不神奇,只是比不懂记录的登山者多了一个“小本子”。

f(n)={1n=11n=2f(n−1)+f(n−2)n≥3f(n) = \begin{cases} 1 & n = 1 \\ 1 & n = 2 \\ f(n-1) + f(n-2) & n \ge 3 \end{cases}
这个公式说明:到达第 n 级台阶的方案,等于“先到第 n-1 级再走一步”与“先到第 n-2 级再跳两级”的方案数之和;而 n=1、n=2 这两个最小的子问题,答案被直接定义为 1。
  1. 确定边界条件当 n=1 或 n=2 时,子问题小到不需要拆分,答案直接定为 1,这是整条递推链的地基。
    f(1)=1,f(2)=1f(1) = 1,\quad f(2) = 1
  2. 写出递推关系当 n≥3 时,把当前问题拆成两个更小的子问题,并把两个答案相加。
    f(n)=f(n−1)+f(n−2)f(n) = f(n-1) + f(n-2)

状态定义方法

登山者规划路线时,第一件事不是迈腿,而是选营地:在哪些位置扎营、每个营地记录什么信息。营地选得准,后面的路就顺;营地选得偏,整条路线都会绕远。

在 DP 里,“选营地”就是状态定义——决定 dp 数组的下标表示什么、数组里存什么。状态定义是解题中最重要也最容易卡住新手的一步。一个实用技巧是:从题目问什么出发。题目问“前 n 个物品能装下多大价值”,就把 dp[i] 定义为“前 i 个物品的最优价值”;题目问“走到第 i 格的最短步数”,就把 dp[i] 定义为“到达第 i 格的最短步数”。下标 i 就是子问题的“规模刻度”,数组值就是该刻度的答案。这叫作题意抽象。

状态定义还要注意两点。一是维度:只和“位置”有关的问题用一维 dp[i];和“位置 + 剩余容量”两个因素有关的问题用二维 dp[i][j];因素再多就继续加维度。二是无后效性:一个状态一旦确定,它后面的决策不需要知道“当初是怎么到这个状态的”,就像登山者到了某个营地后,只需关心从这里到山顶怎么走,而不必关心自己之前走过哪条路。如果状态定义把“来时的路线”都记进去,维度会爆炸,递推也无法进行。

营地的位置定了,本子上每一行写什么也就定了。很多同学写不出转移方程,其实是状态定义太模糊——就像登山者连营地都没选好,当然不知道下一段路怎么走。状态定义是 DP 的地图,画好地图,铺路才有方向。

dp[i]=前 i 个补给站能获得的最优累计得分dp[i] = \text{前 } i \text{ 个补给站能获得的最优累计得分}
这是背包、打家劫舍等一维 DP 通用的状态定义模板:下标 i 锁定问题规模,值 dp[i] 存放该规模下的最优答案。
  1. 确定下标含义i 表示“考虑了前 i 个元素”,相当于登山者走到了第 i 个营地的时刻。
    i=0,1,2,…,ni = 0, 1, 2, \ldots, n
  2. 确定数组值含义dp[i] 存的是前 i 个补给站能获得的最优累计得分,代表“这个子问题的最优解”。
    dp[i]=前 i 个补给站的最优累计得分dp[i] = \text{前 } i \text{ 个补给站的最优累计得分}

状态转移方程

营地选好之后,登山者开始铺路。他从大本营出发,每到一个新营地都会问自己:我是从上一个营地直接走过来的,还是绕过了上一个营地、从更早的营地过来的?每段山路都有一个“路标”,写着这条路线的收益。

在 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]=max⁡(dp[i−1], dp[i−2]+vi)dp[i] = \max\left(dp[i-1],\ dp[i-2] + v_i\right)
dp[i-1] 对应“不偷当前家、沿用前一个状态”,dp[i-2] + v_i 对应“偷当前家并跳过邻居”,取最大值就是第 i 个营地的最优答案。
  1. 不选当前决策dp[i-1] 表示直接沿用上一个营地的结果,当前营地的收益 v_i 不计入。
    dp[i−1]dp[i-1]
  2. 选择当前决策dp[i-2] + v_i 表示从 i-2 号营地一步跨到 i 号营地,并收取当前营地的收益 v_i。
    dp[i−2]+vidp[i-2] + v_i
  3. 取较优路径用 max 在两条来路之间选出收益更大的那条,保证每个营地都记录最优值。
    max⁡(⋅, ⋅)\max\left(\cdot,\ \cdot\right)

初始化与遍历

登山者对路线的第一步永远是定大本营:起点在哪里、第一段路怎么走。大本营没搭好,后面所有营地都失去了参照——这就是 DP 里的初始化。

初始化给递推提供地基边界。以打家劫舍为例,dp[0] 表示只有第一间房时的最优值,显然就是 v_1;dp[1] 表示有两间房且不能同时偷,答案是 max(v_1, v_2)。这两个值一旦定好,从 i=2 开始循环,转移方程就有东西可用了。

除了初始值,还要注意数组大小和遍历顺序。数组长度要能容纳所有状态下标,防止越界;有些题目为了统一逻辑,会加一个“哨兵”(dummy 节点),在 dp 数组前多开一位,让 dp[0]、dp[1] 对应真实的第一、二个元素,把特判吸收进初始化。遍历方向则要服从依赖关系:计算当前状态需要哪些更小的状态,就保证那些状态先被算出来。登山者从不多想,他记得最牢的一句话就是:地基决定高度,方向决定终点。 初始化把大本营钉牢,遍历顺序保证每一步都踩在已经铺好的山上。

dp[0]=v1,dp[1]=max⁡(v1,v2),dp[i]=max⁡(dp[i−1],dp[i−2]+vi),i=2,3,…,ndp[0] = v_1,\quad dp[1] = \max(v_1, v_2),\quad dp[i] = \max(dp[i-1], dp[i-2] + v_i),\quad i = 2, 3, \ldots, n
初始化不是随便填零:dp[0] 和 dp[1] 必须符合实际语义。它们决定整条递推链的准确性,就像地基决定大楼的朝向。
  1. 初始化第一个状态只有一间房时,最优值就是这间房的价值。
    dp[0]=v1dp[0] = v_1
  2. 初始化第二个状态有两间房且不能相邻偷时,只能在两家之间选更值钱的一家。
    dp[1]=max⁡(v1,v2)dp[1] = \max(v_1, v_2)
  3. 确定遍历起点与方向从 i=2 开始逐个计算,保证算 dp[i] 时 dp[i-1]、dp[i-2] 已经就绪;遍历方向通常是从小到大、从前到后。
    i=2,3,…,ni = 2, 3, \ldots, n

背包问题

登山行囊的空间是有限的。出发前,他对着地图和物品清单纠结:每一件补给品都有重量和价值,行囊最多能装容量 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]=max⁡(dp[j], dp[j−wi]+vi)dp[j] = \max\left(dp[j],\ dp[j - w_i] + v_i\right)
这个式子看似简单,玄机全在 j 的遍历方向:一维数组下必须让 j 从大到小倒序遍历,保证一件物品不会被重复装进行囊。
  1. 理解容量维度dp[j] 表示容量为 j 的行囊能装下的最大价值,j 从 0 到总容量 C。
    j=0,1,2,…,Cj = 0, 1, 2, \ldots, C
  2. 不装当前物品dp[j] 原值保留,对应“行囊腾不出空间或不想装它”的选择。
    dp[j]dp[j]
  3. 装下当前物品dp[j - w_i] + v_i 表示先腾出 w_i 的空间,放入价值 v_i 的物品。
    dp[j−wi]+vidp[j - w_i] + v_i
  4. 倒序遍历防止重复0-1 背包要求每件物品至多用一次,所以内层循环 j 必须从 C 向 w_i 递减。
    j=C,C−1,…,wij = C, C - 1, \ldots, 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)。面试中主动说出这一步,往往能让面试官眼前一亮。登山者面对环形山道没有慌,他只是把环上某一段路“剪开”,变成自己熟悉的直线,然后把两条可能的直线都走一遍,取更好的那条。遇到环形、圆形这类首尾相连的问题,先想想怎么把它剪成直线——这是打家劫舍与许多变形题通用的破局思路。

dp[i]=max⁡(dp[i−1], dp[i−2]+nums[i])dp[i] = \max\left(dp[i-1],\ dp[i-2] + \mathit{nums}[i]\right)
环形打劫的关键是“首尾相邻”:第一家与最后一家不能同时偷。解法不是写更复杂的转移,而是把环拆成两条线——去掉第一家的线性序列,和去掉最后一家的线性序列,分别求最优再取最大值。
  1. 线性情形对于普通线性序列,站在第 i 家面前只有“偷”与“不偷”两种决策。
    max⁡(dp[i−1], dp[i−2]+nums[i])\max\left(dp[i-1],\ dp[i-2] + \mathit{nums}[i]\right)
  2. 拆环为线环形问题中,把序列切成 [0, n-2] 和 [1, n-1] 两段,分别按线性 DP 求解。
    max⁡(rob(nums[0..n−2]), rob(nums[1..n−1]))\max\big(\text{rob}(nums[0..n-2]),\ \text{rob}(nums[1..n-1])\big)
  3. 空间压缩线性递推只依赖前两个状态,可以用两个滚动变量替代整个数组。
    dp[i]→prev2, prev1dp[i] \to \text{prev2},\ \text{prev1}

解题模板

经验丰富的登山者每次出发前都检查四件装备:地图(状态定义)、路标(转移方程)、大本营(初始化)、登山杖(遍历顺序)。这四件装备齐全,再陡的山也不怕。面试中遇到 DP 题,同样按四步走。

四步模板如下。第一步,定状态:明确 dp 数组的下标和值分别表示什么。第二步,写转移:站在当前状态问自己“从哪个状态、经过什么决策能到这里”,写出递推关系。第三步,初始化:把地基边界填好,常需要特殊处理 dp[0]、dp[1] 或空集情况。第四步,定遍历顺序:确保当前状态依赖的子状态已经计算完毕,根据依赖方向选择正序或倒序。

面试时还有一个小技巧:先说思路再写代码。比如开口就说“这道题我用一维 dp,dp[i] 表示前 i 家的最大收益,转移是 max 两个分支,复杂度 O(n)”,然后才动笔。老登山者把四件装备挂在山杖上,一步一步稳稳走向山顶。他明白,登山不是拼速度,而是拼章法;面试 DP 题不是拼灵感,而是拼模板。 四步走顺畅了,任何 DP 题在你眼里都只是“换了一张地图的登山路线”。

T(n)=O(n),S(n)=O(n),优化后 S(n)=O(1)T(n) = O(n),\qquad S(n) = O(n),\qquad \text{优化后 } S(n) = O(1)
完成四步后,还要快速估计复杂度。绝大多数一维 DP 的时间复杂度是 O(n),空间是 O(n);如果转移只依赖前几个状态,空间可以压到 O(1)。
  1. 估算时间复杂度状态个数乘以每个状态转移的代价,一维线性 DP 通常是 O(n)。
    T(n)=O(n)T(n) = O(n)
  2. 估算空间复杂度dp 数组占用的空间,一般与状态个数同阶。
    S(n)=O(n)S(n) = O(n)
  3. 滚动优化当转移只依赖前两个状态时,用两个变量替代数组,空间降到 O(1)。
    S(n)=O(1)S(n) = 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 演示

python36 行
# -*- 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))
目的:用三个函数分别实现线性打家劫舍、环形打家劫舍和 0-1 背包,演示“定状态 → 写转移 → 初始化 → 定遍历”的完整套路。
  1. dp = [0] * n先铺好 n 个营地的“小本子”,每个位置初始为 0 分。
  2. dp[0] = nums[0]初始化第一个营地:只有一间房时直接拿它的金额。
  3. dp[1] = max(nums[0], nums[1])初始化第二间房:相邻不能都偷,选金额更大的那家。
  4. dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])状态转移方程:不偷当前 vs 隔一家偷当前。
  5. max(house_robber(nums[:-1]), house_robber(nums[1:]))环形拆线:分别求“不偷尾”和“不偷头”的线性最优再取最大。
  6. dp = [0] * (capacity + 1)一维背包数组,下标 j 就是行囊容量。
  7. for j in range(capacity, w - 1, -1):倒序遍历容量,防止同一件物品被重复装入。
  8. 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. 动态规划能高效求解下列哪类问题的关键前提?

单选题 · easy

  • A.问题具备最优子结构← 正确答案
  • B.问题不存在重复计算
  • C.问题只含单一决策项
  • D.问题规模固定不变化

答案:A。动态规划的适用前提是“最优子结构”——大问题的最优解由子问题的最优解构成,如同整条登山路线由一段段最优山道拼成。B 说反了,DP 恰恰擅长解决存在“重叠子问题”的情况。C、D 都不是 DP 的关键特征。故答案选 A。

Q2. 用 dp 数组自底向上求斐波那契数列第 n 项,时间复杂度是?

单选题 · easy

  • 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 背包一维空间优化后,内层循环遍历容量的正确方向是?

单选题 · easy

  • 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] 的含义是?

单选题 · mid

  • 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 背包的本质区别是?

单选题 · mid

  • A.物品数量与价值大小各异
  • B.物品是否可以无限次重复选← 正确答案
  • C.背包总容量是否受到限制
  • D.外层遍历物品时的顺序不同

答案:B。0-1 背包每件物品最多选一次,完全背包每种物品可以无限取用。这一本质差异在代码上体现为内层容量循环的方向:倒序对应 0-1,正序对应完全。A、C、D 都不是本质区别,故选 B。

Q6. 环形打家劫舍(首尾相连)最常见的解法思路是?

单选题 · hard

  • A.直接当作普通线性问题来解
  • B.分不选首尾两情况取最大← 正确答案
  • C.增加状态维度记录首尾情况
  • D.使用贪心策略选取最大金额

答案:B。环形比线性只多一个约束:首尾不能同时偷。最常见的做法是把环剪开成两条线,分别求“不偷第一家”(nums[1:])与“不偷最后一家”(nums[:-1])的线性最优解,再取最大值,所以选 B。A 无视首尾约束;C 可行但复杂;D 贪心无法保证全局最优。

Q7. 为什么动态规划的初始化至关重要?

单选题 · mid

  • A.初始化决定动态规划的最优值
  • B.提供递推地基边界影响答案← 正确答案
  • C.初始化可以降低时间复杂度
  • D.初始化能省去状态转移过程

答案:B。初始化提供递推链的起点和边界,比如打家劫舍的 dp[0]、dp[1],一个值写错,后面的 dp 会一路错下去,正如地基决定大楼的朝向。初始化本身不决定最优值,也不能降复杂度或省去转移,A、C、D 描述均不准确。

Q8. 动态规划适合解决的问题通常具备哪些特征?

多选题 · mid

  • A.问题具备最优子结构特性← 正确答案
  • B.存在大量重叠的子问题← 正确答案
  • C.满足贪心选择性质的约束
  • D.各子问题之间相互独立

答案:AB。最优子结构与重叠子问题是 DP 的两大支柱。C 是贪心算法的特征,与 DP 无关;D 是分治法的前提,DP 恰恰允许并利用子问题重叠来节省计算。故选 A、B。

Q9. 关于多维状态定义,下列说法正确的是?

多选题 · hard

  • A.状态维度越多刻画越细致← 正确答案
  • B.维度增多通常推高复杂度← 正确答案
  • C.状态定义须满足无后效性← 正确答案
  • D.状态维度越多结果一定越好

答案:ABC。维度用于刻画问题的必要信息,增加维度确实让状态更细致,也会使时间与空间复杂度上升;无后效性则是一切 DP 状态的硬性要求。但维度不是越多越好,冗余维度只会浪费计算资源,D 说法错误。故选 A、B、C。

Q10. 下列属于动态规划空间优化手段的有?

多选题 · extreme

  • A.使用滚动数组压缩存储← 正确答案
  • B.用状态压缩合并冗余维← 正确答案
  • C.使用贪心策略替代求解
  • D.将二维数组降为一维存储← 正确答案

答案:ABD。滚动数组(只保留前几个状态)、状态压缩(用位运算合并冗余维)、二维降一维(0-1 背包的经典做法)都是 DP 空间优化手段。C 是更换算法思路而非空间优化,故选 A、B、D。