动态规划(Dynamic Programming, DP)算法思想
动态规划是一种 递归+记忆化 或 自底向上 的优化方法,适用于 最优子结构 和 重叠子问题 的问题。它的核心思想是通过分解问题为子问题,并存储子问题的结果,避免重复计算,从而提高效率。
1. 什么时候使用动态规划?
如果问题满足 最优子结构 和 重叠子问题,那么可以考虑用动态规划。
🔹 最优子结构(Optimal Substructure)
大问题的最优解 可以由 小问题的最优解 推导出来。
例如:
• 斐波那契数列:F(n) = F(n-1) + F(n-2),F(n) 的解依赖 F(n-1) 和 F(n-2)。
• 最短路径问题:从起点到终点的最短路径可以由子路径的最短路径推导出来。
🔹 重叠子问题(Overlapping Subproblems)
相同的子问题 会被多次计算,导致冗余计算。
例如:
• 斐波那契数列(递归) :
func fib(_ n: Int) -> Int {
if n == 0 { return 0 }
if n == 1 { return 1 }
return fib(n-1) + fib(n-2) // 这里重复计算了很多次
}
• 计算 fib(5) 时,需要计算 fib(4) 和 fib(3);
• 计算 fib(4) 时,又要计算 fib(3) 和 fib(2);
• fib(3) 被重复计算了 两次,造成了 指数级时间复杂度 O(2ⁿ) 。
动态规划通过存储计算结果,避免重复计算,提高效率。
2. 动态规划的解题思路
动态规划的解题一般分为 5 个步骤:
(1)定义状态(State)
• 明确“子问题” ,即 dp[i] 的含义。
• dp[i] 一般表示一个问题在规模为 i 时的最优解。
(2)状态转移方程(Transition)
• 找到 dp[i] 和 dp[i-1] 、 dp[i-2] … 之间的关系。
• 如何从小问题的解推导出大问题的解。
(3)初始化(Initialization)
• 明确 dp[0] 、 dp[1] 等的初始值。
(4)计算顺序(Order)
• 通常是从小到大计算(自底向上),或递归+记忆化(自顶向下) 。
(5)返回最终结果
• 一般是 dp[n] 或 dp[m][n]。
3. 经典例题讲解
🔹 例 1:斐波那契数列
📌 题目
斐波那契数列:
• F(0) = 0
• F(1) = 1
• F(n) = F(n-1) + F(n-2)
求 F(n)。
✅ 动态规划解法
-
定义状态:dp[i] 表示 F(i) 的值。
-
状态转移方程:
-
初始化:
• dp[0] = 0
• dp[1] = 1
-
计算顺序:从 2 到 n 依次计算 dp[i]。
func fib(_ n: Int) -> Int {
if n < 2 { return n }
var dp = [Int](repeating: 0, count: n + 1)
dp[0] = 0
dp[1] = 1
for i in 2...n {
dp[i] = dp[i-1] + dp[i-2]
}
return dp[n]
}
print(fib(10)) // 输出: 55
✅ 时间复杂度:O(n)
✅ 空间复杂度:O(n) (可以优化为 O(1))
🔹 例 2:0-1 背包问题
📌 题目
有 n 个物品,每个物品有重量 w[i] 和价值 v[i]。
给定一个容量为 W 的背包,问 如何选择物品,使得总价值最大?
✅ 动态规划解法
- 定义状态:
• dp[i][j] 表示 前 i 个物品,背包容量为 j 时的最大价值。
- 状态转移方程:
• 不选第 i 个物品: dp[i][j] = dp[i-1][j]
• 选第 i 个物品: dp[i][j] = dp[i-1][j-w[i]] + v[i]
• 取两者最大值:
- 初始化:
• dp[0][...] = 0(没有物品,价值为 0)。
-
计算顺序:从 1 到 n,从 1 到 W 计算 dp[i][j]。
func knapsack(_ weights: [Int], _ values: [Int], _ capacity: Int) -> Int {
let n = weights.count
var dp = [[Int]](repeating: [Int](repeating: 0, count: capacity + 1), count: n + 1)
for i in 1...n {
for j in 1...capacity {
if j >= weights[i-1] {
dp[i][j] = max(dp[i-1][j], dp[i-1][j - weights[i-1]] + values[i-1])
} else {
dp[i][j] = dp[i-1][j]
}
}
}
return dp[n][capacity]
}
let weights = [2, 3, 4, 5]
let values = [3, 4, 5, 6]
let capacity = 5
print(knapsack(weights, values, capacity)) // 输出: 7
✅ 时间复杂度:O(nW)
✅ 空间复杂度:O(nW) (可以优化为 O(W))
4. 动态规划技巧
- 一维 vs 二维 DP
• 斐波那契、爬楼梯用 一维数组 dp[i]。
• 背包、子序列问题用 二维数组 dp[i][j]。
- 空间优化
• 滚动数组:如果 dp[i][j] 只依赖 dp[i-1][j],可以用 一维数组 代替 二维数组,节省空间。
- 递归+记忆化
• 适用于 重叠子问题明显 的问题,减少重复计算。
总结
✅ 动态规划的核心是“子问题”+“最优子结构”
✅ 避免重复计算,提高效率
✅ 通过“状态转移方程”建立递推关系
✅ 常用于序列、背包、子序列、路径问题