第一章:动态规划的核心思想与学习路径
动态规划(Dynamic Programming,简称DP)是一种通过将复杂问题分解为更小的子问题来求解的算法设计思想。其核心在于状态定义与状态转移,通过记录已解决的子问题结果,避免重复计算,从而显著提升效率。它适用于具有最优子结构和重叠子问题性质的问题,如斐波那契数列、背包问题、最长公共子序列等。
理解状态与转移方程
在动态规划中,“状态”是对问题某一阶段的描述,“转移方程”则是如何从已知状态推导出新状态的规则。例如,在计算斐波那契数列时,可以定义状态 dp[i] 表示第 i 个斐波那契数,其转移方程为:
dp[0] = 0
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2] # 当前状态由前两个状态决定
该代码通过自底向上的方式填充数组,避免了递归中的重复调用,时间复杂度从指数级降至 O(n)。
学习路径建议
掌握动态规划需要循序渐进地训练思维模式。以下是推荐的学习路径:
- 基础阶段:理解记忆化搜索与递推的区别,熟练实现经典问题(如爬楼梯、打家劫舍)
- 进阶阶段:掌握多维DP(如二维背包)、区间DP(如石子合并)
- 高阶阶段:学习状态压缩、树形DP、数位DP等高级技巧
| 阶段 | 典型问题 | 关键技能 |
|---|---|---|
| 基础 | 斐波那契、最大子序和 | 状态定义、一维转移 |
| 中级 | 0-1背包、编辑距离 | 二维DP、边界处理 |
| 高级 | 状压DP、环形石子合并 | 状态优化、环形处理 |
持续练习并归纳题型模式,是掌握动态规划的关键。
第二章:基础DP模型与Go实现
2.1 理解状态转移方程的设计原理
状态转移方程是动态规划算法的核心,它描述了从一个状态到另一个状态的演化规则。设计合理的转移方程,关键在于准确识别问题的状态表示与子问题之间的依赖关系。
状态分解与递推逻辑
通常,状态可表示为 dp[i] 或 dp[i][j],其含义需清晰定义,例如“前 i 个元素的最大收益”。转移时,需考虑所有可能的决策路径。
dp[i] = max(dp[i-1], dp[i-2] + value[i]) # 选择是否取第i项
上述代码实现打家劫舍问题的状态转移。
dp[i-1]表示不取第 i 项,继承前序最大值;dp[i-2] + value[i]表示取第 i 项,则不能取 i-1 项。该设计体现了状态间的互斥决策。
转移方程构建步骤
- 明确状态含义
- 枚举合法决策
- 建立当前状态与子状态的数学关系
决策依赖可视化
graph TD
A[状态 dp[i]] --> B[不选第i项 → dp[i-1]]
A --> C[选第i项 → dp[i-2]+val]
B --> D[最终取最大值]
C --> D
2.2 斐波那契数列与记忆化搜索实践
斐波那契数列是递归算法的经典示例,其定义为:F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2)。直接递归实现会导致大量重复计算,时间复杂度高达 O(2^n)。
朴素递归的性能瓶颈
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
该实现未缓存子问题结果,导致同一状态被多次求解,效率极低。
引入记忆化搜索优化
使用字典缓存已计算结果,避免重复递归:
def fib_memo(n, memo={}):
if n in memo:
return memo[n]
if n <= 1:
return n
memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
return memo[n]
memo 字典存储中间状态,将时间复杂度降至 O(n),空间复杂度为 O(n)。
性能对比表
| 方法 | 时间复杂度 | 空间复杂度 | 是否可行 |
|---|---|---|---|
| 朴素递归 | O(2^n) | O(n) | n |
| 记忆化搜索 | O(n) | O(n) | 是 |
优化思路可视化
graph TD
A[fib(5)] --> B[fib(4)]
A --> C[fib(3)]
B --> D[fib(3)]
D --> E[fib(2)]
C --> F[fib(2)]
C --> G[fib(1)]
style D stroke:#f66,stroke-width:2px
style C stroke:#6f6,stroke-width:2px
图中 fib(3) 被重复计算,记忆化可消除此类冗余分支。
2.3 爬楼梯问题中的递推关系剖析
爬楼梯问题是动态规划中的经典入门案例,其核心在于发现状态之间的递推关系。假设每次可走1阶或2阶,到达第 $n$ 阶的方法数等于到达第 $n-1$ 阶和第 $n-2$ 阶方法数之和。
递推公式的建立
该问题满足斐波那契数列的递推关系: $$ f(n) = f(n-1) + f(n-2) $$ 初始条件为 $f(0)=1$、$f(1)=1$,分别表示站在地面算一种方式,以及第一阶只有一种走法。
动态规划实现
def climbStairs(n):
if n <= 1:
return 1
a, b = 1, 1
for i in range(2, n + 1):
a, b = b, a + b # 滚动更新,节省空间
return b
上述代码使用滚动变量将空间复杂度优化至 $O(1)$,时间复杂度为 $O(n)$。a 和 b 分别代表前两个状态的解,通过迭代避免重复计算。
| n | f(n) |
|---|---|
| 0 | 1 |
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
状态转移图示
graph TD
A[开始] --> B[f(0)=1]
A --> C[f(1)=1]
C --> D[f(2)=2]
D --> E[f(3)=3]
E --> F[f(4)=5]
2.4 打家劫舍问题的状态定义技巧
动态规划的核心在于状态的合理定义。在“打家劫舍”问题中,若直接以房屋索引作为状态,容易忽略相邻限制带来的约束。
状态设计的关键洞察
应将状态拆分为两种情形:
dp[i][0]:第i间房未被抢劫时的最大收益dp[i][1]:第i间房被抢劫时的最大收益
# 状态转移方程
dp[i][0] = max(dp[i-1][0], dp[i-1][1]) # 不抢当前房,前一间可抢可不抢
dp[i][1] = dp[i-1][0] + nums[i] # 抢当前房,前一间必须不抢
上述代码中,
nums[i]表示第i间房的金额。状态dp[i][1]的来源受限于dp[i-1][0],体现了相邻不能同时抢劫的约束。
状态压缩优化
由于只依赖前一状态,可用两个变量替代数组:
| 变量 | 含义 |
|---|---|
rob_prev |
前一间房被抢时的最大收益 |
not_rob_prev |
前一间房未被抢时的最大收益 |
通过逐步更新,最终返回两者最大值即可完成求解。
2.5 最长递增子序列的O(n²)解法实战
动态规划是解决最长递增子序列(LIS)问题的经典方法。我们定义 dp[i] 表示以 nums[i] 结尾的最长递增子序列长度。
状态转移逻辑
对于每个位置 i,遍历其之前的所有元素 j(j < i),若 nums[j] < nums[i],则可将 nums[i] 接在 dp[j] 后形成更长递增序列:
def lengthOfLIS(nums):
if not nums: return 0
dp = [1] * len(nums)
for i in range(1, len(nums)):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
- 时间复杂度:O(n²),双层循环嵌套
- 空间复杂度:O(n),
dp数组存储各位置最优解
状态演化示例
| i | nums[i] | dp数组变化过程 |
|---|---|---|
| 0 | 10 | [1,1,1,1,1] |
| 1 | 9 | [1,1,1,1,1] |
| 2 | 2 | [1,1,1,1,1] |
| 3 | 5 | [1,1,1,2,1] |
| 4 | 3 | [1,1,1,2,2] |
上述流程展示了状态逐步更新的过程,最终结果为 2。
第三章:经典线性DP问题精讲
3.1 最长公共子序列(LCS)的二维DP构建
最长公共子序列(LCS)是动态规划中的经典问题,用于找出两个序列中最长的公共子序列。其核心思想是利用二维DP表 dp[i][j] 表示字符串 A[0..i-1] 与 B[0..j-1] 的LCS长度。
状态转移逻辑
当字符匹配时,状态递增;否则继承前值:
if A[i-1] == B[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
代码说明:
dp[i][j]依赖左、上、左上三个方向的值。若当前字符相同,则从对角线加1;否则取上方或左侧较大值,保证最优子结构。
DP表构建过程
| i\j | “” | a | b | c | d |
|---|---|---|---|---|---|
| “” | 0 | 0 | 0 | 0 | 0 |
| a | 0 | 1 | 1 | 1 | 1 |
| c | 0 | 1 | 1 | 2 | 2 |
状态依赖关系图
graph TD
A[dp[i-1][j-1]] --> C[dp[i][j]]
B[dp[i-1][j]] --> C
D[dp[i][j-1]] --> C
该结构确保每一步都基于已解决的子问题,实现全局最优。
3.2 编辑距离问题的状态转移推导与Go编码
编辑距离(Levenshtein Distance)用于衡量两个字符串之间的相似度,定义为将一个字符串转换为另一个所需的最少单字符编辑操作次数(插入、删除、替换)。
状态定义与转移方程
设 dp[i][j] 表示将字符串 s1[0..i-1] 转换为 s2[0..j-1] 所需的最小操作数。状态转移如下:
- 若字符相等:
dp[i][j] = dp[i-1][j-1] - 否则取三种操作的最小值加1:
- 插入:
dp[i][j-1] + 1 - 删除:
dp[i-1][j] + 1 - 替换:
dp[i-1][j-1] + 1
- 插入:
func minDistance(s1, s2 string) int {
m, n := len(s1), len(s2)
dp := make([][]int, m+1)
for i := range dp {
dp[i] = make([]int, n+1)
}
// 初始化边界:转为空串的操作数
for i := 1; i <= m; i++ {
dp[i][0] = i
}
for j := 1; j <= n; j++ {
dp[0][j] = j
}
// 填表
for i := 1; i <= m; i++ {
for j := 1; j <= n; j++ {
if s1[i-1] == s2[j-1] {
dp[i][j] = dp[i-1][j-1]
} else {
dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
}
}
}
return dp[m][n]
}
func min(a, b, c int) int {
if a < b {
a, b = b, a
}
if b < c {
return b
}
return c
}
逻辑分析:该实现采用二维动态规划,dp[i][j] 依赖左、上、左上三个方向的状态。初始化阶段处理空字符串转换,内层循环根据字符匹配情况选择最优转移路径。时间复杂度 O(mn),空间复杂度 O(mn)。
| 操作类型 | 对应状态转移 |
|---|---|
| 插入 | dp[i][j-1] + 1 |
| 删除 | dp[i-1][j] + 1 |
| 替换 | dp[i-1][j-1] + 1 |
优化方向示意
可通过滚动数组将空间优化至 O(min(m,n)),适用于长文本对比场景。
3.3 股票买卖系列问题的统一DP视角
股票买卖问题看似多样,实则可通过动态规划(DP)建立统一模型。核心思想是将状态定义为持有(hold)与不持有(sold)两种情形,并引入交易次数或冷却期等约束进行扩展。
状态定义的通用形式
dp[i][k][0]:第 i 天结束时,最多进行 k 次交易且不持有股票的最大利润dp[i][k][1]:第 i 天结束时,最多进行 k 次交易且持有股票的最大利润
经典问题映射
| 问题类型 | k 的限制 | 是否允许手续费/冷却期 |
|---|---|---|
| 无限次交易 | k = +∞ | 否 |
| 最多两次交易 | k = 2 | 否 |
| 含冷冻期 | k = +∞ | 是(1天) |
# 以“最多k次交易”为例的DP实现
def maxProfit(prices, k):
n = len(prices)
if not prices or k == 0:
return 0
dp = [[[0]*2 for _ in range(k+1)] for _ in range(n)]
# 初始化第一天持有状态
for j in range(1, k+1):
dp[0][j][1] = -prices[0]
for i in range(1, n):
for j in range(1, k+1):
dp[i][j][0] = max(dp[i-1][j][0], dp[i-1][j][1] + prices[i]) # 卖出
dp[i][j][1] = max(dp[i-1][j][1], dp[i-1][j-1][0] - prices[i]) # 买入
return dp[n-1][k][0]
上述代码中,状态转移清晰体现“买入消耗一次交易额度”,而dp[i-1][j-1][0]确保买入前已完成上一轮卖出。该框架可灵活适配各类变种问题。
第四章:背包模型与高级DP优化
4.1 0-1背包问题的二维与一维数组实现
动态规划基础思路
0-1背包问题是典型的动态规划应用场景。给定物品重量和价值,求在容量限制下能获得的最大价值。状态定义为 dp[i][j]:前i个物品在容量j下的最大价值。
二维数组实现
def knapsack_2d(w, v, C):
n = len(w)
dp = [[0] * (C + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(C + 1):
if w[i-1] > j:
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]
逻辑分析:外层遍历物品,内层逆向遍历容量。若当前物品超重,则继承上一行结果;否则取“不放”与“放入”的较大值。时间复杂度 O(nC),空间 O(nC)。
空间优化:一维数组实现
使用滚动数组压缩空间:
def knapsack_1d(w, v, C):
dp = [0] * (C + 1)
for i in range(len(w)):
for j in range(C, w[i] - 1, -1):
dp[j] = max(dp[j], dp[j - w[i]] + v[i])
return dp[C]
参数说明:j 从大到小遍历,避免同一物品重复放入。dp[j] 表示当前容量下的最优解。空间复杂度降为 O(C)。
| 对比维度 | 二维实现 | 一维实现 |
|---|---|---|
| 空间复杂度 | O(nC) | O(C) |
| 可读性 | 高 | 中 |
| 适用场景 | 教学理解 | 实际应用 |
4.2 完全背包问题的滚动数组优化技巧
在动态规划求解完全背包问题时,状态转移方程为:dp[i][w] = max(dp[i-1][w], dp[i][w-wt[i-1]] + val[i-1])。观察发现,当前行仅依赖于上一行或本行左侧的状态,因此可将二维 dp 数组压缩为一维。
滚动数组实现方式
使用一维数组 dp[w] 表示容量为 w 时的最大价值,遍历物品时正向更新容量:
def unbounded_knapsack(weights, values, W):
dp = [0] * (W + 1)
for i in range(len(weights)):
for w in range(weights[i], W + 1): # 正向遍历
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[W]
逻辑分析:内层循环正向遍历,确保 dp[w - weights[i]] 使用的是已更新(即包含当前物品)的状态,体现“物品可重复选取”的特性。若反向则退化为0-1背包。
空间复杂度对比
| 方法 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 二维DP | O(nW) | O(nW) |
| 滚动数组优化 | O(nW) | O(W) |
通过一维数组复用,显著降低内存占用,适用于大规模数据场景。
4.3 多重背包问题的二进制拆分策略
多重背包问题中,每种物品有有限的可用数量。当直接枚举每件物品的所有副本会导致时间复杂度急剧上升时,二进制拆分策略提供了一种高效的优化方法。
核心思想:将数量拆分为二进制组合
通过将数量为 $ c_i $ 的物品拆分为若干组,每组数量为 $ 1, 2, 4, …, 2^k $ 和剩余部分,可将原问题转化为0-1背包问题,且保证状态覆盖完整。
例如,若某物品有13个,则拆分为:1、2、4、6(剩余)四组,每组视为独立物品。
拆分过程示例
def binary_decompose(count, weight, value):
items = []
power = 1
while power <= count:
items.append((power * weight, power * value))
count -= power
power *= 2
if count > 0:
items.append((count * weight, count * value))
return items
上述函数将一个数量为
count的物品拆分为多个等效组合。每个组合的重量和价值按幂次累加,确保总和不变。拆分后总组数由 $ \log_2(c_i) $ 控制,显著降低输入规模。
| 原数量 | 拆分组合 | 组数 |
|---|---|---|
| 10 | 1, 2, 4, 3 | 4 |
| 15 | 1, 2, 4, 8 | 4 |
| 7 | 1, 2, 4 | 3 |
策略优势
该方法在保持正确性的同时,将时间复杂度从 $ O(V \sum c_i) $ 优化至 $ O(V \sum \log c_i) $,适用于大数量场景。
4.4 背包问题变种在面试中的高频变形
多重背包:物品数量有限制
当每类物品有指定数量限制时,转化为多重背包问题。常见优化是将数量拆分为二进制组合,降低时间复杂度。
分组背包:每组仅选其一
每一组内有多个物品,但只能选择一个放入背包。状态转移需逆序遍历容量,确保每组只贡献一次决策。
# dp[j] 表示容量为 j 时的最大价值
dp = [0] * (W + 1)
for group in groups:
for j in range(W, 0, -1): # 逆序遍历
for w, v in group:
if j >= w:
dp[j] = max(dp[j], dp[j - w] + v)
代码逻辑:外层遍历物品组,中层逆序枚举背包容量,内层尝试组内每个物品。逆序防止同一组物品重复选取。
| 变形类型 | 约束条件 | 典型应用场景 |
|---|---|---|
| 0-1背包 | 每物仅用一次 | 投资项目选择 |
| 完全背包 | 物品无限可重复 | 硬币找零 |
| 多重背包 | 每物使用次数受限 | 库存商品最优搭配 |
| 分组背包 | 每组至多选一个 | 预算内功能模块选型 |
第五章:从理解到精通——动态规划的思维跃迁
动态规划(Dynamic Programming, DP)作为算法设计中的核心范式之一,其真正的掌握不在于记忆模板,而在于思维方式的根本转变。许多开发者在初学阶段能顺利解决斐波那契、背包问题,但在面对状态转移不明显的实际场景时仍感束手无策。这种困境的本质,是停留在“套公式”的层面,尚未完成向“建模思维”的跃迁。
状态定义的艺术
在真实项目中,状态的设计往往需要结合业务语义进行抽象。例如,在电商平台的优惠券组合推荐系统中,若需计算用户使用多张满减券的最大优惠金额,传统的0-1背包模型不再适用。此时可将状态定义为 dp[i][j] 表示前 i 张券在累计订单金额为 j 时的最大减免值。但更进一步,若券之间存在互斥规则(如不可叠加使用),则状态需扩展为三维:dp[i][j][k],其中 k 表示当前是否已使用特定类型的券。
# 三维DP处理互斥优惠券
def max_discount(coupons, total):
n = len(coupons)
dp = [[[0]*2 for _ in range(total+1)] for __ in range(n+1)]
for i in range(1, n+1):
for j in range(total+1):
dp[i][j][0] = dp[i-1][j][0]
if j >= coupons[i-1].threshold:
# 使用当前券,标记状态为已用
dp[i][j][1] = max(dp[i-1][j - coupons[i-1].threshold][0] + coupons[i-1].amount,
dp[i-1][j][1])
return max(dp[n][total][0], dp[n][total][1])
转移方程的逆向推导
在高并发订单超卖控制场景中,需动态计算库存分配方案。此时可通过逆向思维构建DP:设 f[stock][order_count] 为剩余库存下满足订单的概率最大值。通过从最终状态反推,可避免重复计算无效路径。
| 订单数量 | 剩余库存 | 最优分配概率 |
|---|---|---|
| 5 | 3 | 0.82 |
| 4 | 2 | 0.76 |
| 3 | 1 | 0.68 |
空间优化的工程实践
在实时风控系统中,每毫秒需评估数千笔交易的风险累积路径。此时采用滚动数组技术,将二维DP压缩至一维,显著降低内存占用:
# 滚动数组优化
dp = [0] * (max_risk + 1)
for transaction in transactions:
for r in range(max_risk, transaction.risk - 1, -1):
dp[r] = max(dp[r], dp[r - transaction.risk] + transaction.value)
复杂状态机的建模
使用 mermaid 流程图描述多阶段决策过程:
stateDiagram-v2
[*] --> Idle
Idle --> Evaluating: 开始评估
Evaluating --> Approved: 风控通过
Evaluating --> Rejected: 触发规则
Approved --> Settled: 完成结算
Rejected --> ManualReview: 进入人工
ManualReview --> Approved
ManualReview --> [*]
