动态规划

164 阅读4分钟

动态规划(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)。

✅ 动态规划解法

  1. 定义状态:dp[i] 表示 F(i) 的值。

  2. 状态转移方程

  3. 初始化

• dp[0] = 0

• dp[1] = 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 的背包,问 如何选择物品,使得总价值最大?

✅ 动态规划解法

  1. 定义状态

• dp[i][j] 表示 i 个物品,背包容量为 j 时的最大价值

  1. 状态转移方程

不选第 i 个物品: dp[i][j] = dp[i-1][j]

选第 i 个物品: dp[i][j] = dp[i-1][j-w[i]] + v[i]

• 取两者最大值:

  1. 初始化

• dp[0][...] = 0(没有物品,价值为 0)。

  1. 计算顺序:从 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. 动态规划技巧

  1. 一维 vs 二维 DP

• 斐波那契、爬楼梯用 一维数组 dp[i]。

• 背包、子序列问题用 二维数组 dp[i][j]。

  1. 空间优化

滚动数组:如果 dp[i][j] 只依赖 dp[i-1][j],可以用 一维数组 代替 二维数组,节省空间。

  1. 递归+记忆化

• 适用于 重叠子问题明显 的问题,减少重复计算。

总结

动态规划的核心是“子问题”+“最优子结构”

避免重复计算,提高效率

通过“状态转移方程”建立递推关系

常用于序列、背包、子序列、路径问题