动态规划算法详解(附代码实现)

1,745 阅读17分钟

如何理解“动态规划算法”?

动态规划(Dynamic Programming,简称DP)是一种解决复杂问题的优化方法,它将原问题分解为一系列相互重叠的子问题,并通过存储子问题的解来避免重复计算。动态规划的核心思想是利用空间换时间,通过存储已经计算过的子问题的解,从而减少计算量。

动态规划的特点

  • 重叠子问题:原问题可以分解为若干个重叠的子问题。
  • 最优子结构:原问题的最优解可以通过其子问题的最优解构造出来。
  • 无后效性:某个状态一旦确定,不会受到后续决策的影响。

1. 重叠子问题

动态规划的第一个特征是问题可以被分解成相似的子问题,并且这些子问题会被多次求解。例如,在计算斐波那契数列时,F(n) = F(n-1) + F(n-2)F(n-1)F(n-2)会被多次计算。

2. 最优子结构

动态规划的第二个特征是最优解包含了其子问题的最优解。如果一个问题的最优解可以从其子问题的最优解有效地构造出来,那么该问题就具备最优子结构特性。

3. 无后效性

无后效性有两层含义,第一层含义是,在推导后面阶段的状态的时候,我们只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的。第二层含义是,某阶段状态一旦确定,就不受之后阶段的决策影响。无后效性是一个非常“宽松”的要求。只要满足前面提到的动态规划问题模型,其实基本上都会满足无后效性。

实现方式

动态规划有两种主要实现方式:

  • 自底向上:也称为迭代法,先解决最简单的小规模问题,然后逐渐构建更大规模问题的解。
  • 自顶向下:也称为递归法+记忆化,从问题的大规模开始尝试解决,遇到子问题时递归求解,并将已解决的子问题的答案缓存起来供后续使用

动态规划的步骤

  1. 识别状态:确定问题的状态表示,也就是哪些信息是必须保存的
  2. 定义状态转移方程:找出从一个状态到另一个状态的转移方程,这通常涉及到递推关系。
  3. 确定初始条件:明确问题的边界条件或者起始状态。
  4. 计算所有状态:按照一定的顺序计算所有状态的值,通常是从最简单的状态开始,逐步构建到复杂的状态。
  5. 根据计算结果得出最终答案:根据计算过程中得到的所有状态值,最后得出整个问题的解。

动态规划的应用

动态规划广泛应用于各种问题,如:

  • 最短路径问题(如 Dijkstra 算法)
  • 背包问题
  • 字符串匹配(如最长公共子序列)
  • 硬币找零问题
  • 编辑距离
  • 路由选择

“动态规划算法”和”回溯算法”的对比

使用表格来对比“动态规划算法”和“回溯算法”的异同及适用场景可以使信息更加清晰明了。以下是一个详细的对比表格:

对比项动态规划算法回溯算法
定义一种通过将问题分解成更小的子问题,并利用子问题的解来构建原问题的解的方法。一种通过试探性地构建问题的解,并在发现解不可行时撤销之前的步骤的方法。
核心思想最优化原理 + 重叠子问题 + 记忆化试探性构建解 + 回溯 + 剪枝
子问题特点子问题具有重叠性质子问题通常是独立的
解的构建通过记忆化避免重复计算通过回溯撤销错误的选择
实现方式自底向上(迭代法)或自顶向下(递归加记忆化)递归 + 剪枝
时间复杂度通常较低,因为通过记忆化减少了重复计算较高,因为它需要探索所有可能的路径
空间复杂度需要额外的空间来存储子问题的解主要消耗栈空间来记录递归调用
适用场景最优化问题(如最长公共子序列、最短路径、背包问题等)搜索问题(如N皇后问题、迷宫问题等)
示例背包问题N皇后问题
优点显著减少计算量,提高效率可以探索所有可能性,找到所有可行解
缺点需要额外空间来存储中间结果时间复杂度高,不适合大规模问题

回溯算法是个“万金油”。基本上能用动态规划、贪心解决的问题,我们都可以用回溯算法解决。回溯算法相当于穷举搜索。穷举所有的情况,然后对比得到最优解。不过,回溯算法的时间复杂度非常高,是指数级别的,只能用来解决小规模数据的问题。对于大规模数据的问题,用回溯算法解决的执行效率就很低了。

尽管动态规划比回溯算法高效,但是,并不是所有问题,都可以用动态规划来解决。能用动态规划解决的问题,需要满足三个特征,最优子结构、无后效性和重复子问题。在重复子问题这一点上,动态规划和分治算法的区分非常明显。分治算法要求分割成的子问题,不能有重复子问题,而动态规划正好相反,动态规划之所以高效,就是因为回溯算法实现中存在大量的重复子问题。

贪心算法实际上是动态规划算法的一种特殊情况。它解决问题起来更加高效,代码实现也更加简洁。不过,它可以解决的问题也更加有限。它能解决的问题需要满足三个条件,最优子结构、无后效性和贪心选择性(这里我们不怎么强调重复子问题)。

其中,最优子结构、无后效性跟动态规划中的无异。“贪心选择性”的意思是,通过局部最优的选择,能产生全局的最优选择。每一个阶段,我们都选择当前看起来最优的决策,所有阶段的决策完成之后,最终由这些局部最优解构成全局最优解。

示例1

0-1 背包问题

0-1 背包问题是一个经典的动态规划问题。给定一个背包,最多能承载的重量为 W,还有 n 个物品,每个物品有一个重量 w[i] 和价值 v[i]。目标是在不超过背包最大承载重量的前提下,选择一部分物品放入背包,使得背包内物品的总价值最大化。每个物品只能选择放入或不放入背包。

算法原理

动态规划通过将问题分解为更小的子问题,并通过记录每个子问题的结果来避免重复计算,从而有效地解决问题。

1. 状态定义

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

2. 状态转移方程

  • 如果不选择第 i 个物品:dp[i][j] = dp[i-1][j]
  • 如果选择第 i 个物品:dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i-1]] + v[i-1]),前提是 j >= w[i-1]

3. 初始条件

  • dp[0][j] = 0 对于所有的 j,表示没有物品时价值为 0。
  • dp[i][0] = 0 对于所有的 i,表示背包重量为 0 时价值为 0。

4. 记录选择的物品

  • 通过一个 choice 数组记录每次选择的物品。choice[i][j] 表示在容量 j 时是否选择物品 i

5. 目标

  • dp[n][W] 即为在 n 个物品中选择,背包最大承重为 W 时的最大价值。
  • 通过回溯 choice 数组,可以恢复出哪些物品被选择放入背包。

Swift 实现代码

实现 0-1 背包问题,并记录选择的物品的代码:

import Foundation

/// 0-1 背包问题的动态规划实现,并记录选择的物品
/// - Parameters:
///   - weights: 物品的重量数组
///   - values: 物品的价值数组
///   - maxWeight: 背包的最大承重
/// - Returns: (最大价值, 选择的物品索引列表)
func knapsack(_ weights: [Int], _ values: [Int], _ maxWeight: Int) -> (Int, [Int]) {
    let n = weights.count
    
    // 创建一个 (n+1) x (maxWeight+1) 的二维数组 dp
    var dp = Array(repeating: Array(repeating: 0, count: maxWeight + 1), count: n + 1)
    
    // 创建一个二维数组 choice,用于记录物品选择情况
    var choice = Array(repeating: Array(repeating: false, count: maxWeight + 1), count: n + 1)
    
    // 填充 dp 数组
    for i in 1...n {
        let currentWeight = weights[i - 1]
        let currentValue = values[i - 1]
        
        for j in 0...maxWeight {
            if j >= currentWeight {
                // 如果当前背包容量大于或等于当前物品的重量,
                // 那么我们可以选择放入当前物品或者不放入当前物品
                if dp[i - 1][j] < dp[i - 1][j - currentWeight] + currentValue {
                    dp[i][j] = dp[i - 1][j - currentWeight] + currentValue
                    choice[i][j] = true  // 记录选择了当前物品
                } else {
                    dp[i][j] = dp[i - 1][j]
                }
            } else {
                // 如果当前背包容量小于当前物品的重量,
                // 那么我们只能选择不放入当前物品
                dp[i][j] = dp[i - 1][j]
            }
        }
    }
    
    // 回溯 choice 数组,找到被选择的物品
    var selectedItems = [Int]()
    var remainingCapacity = maxWeight
    
    for i in stride(from: n, through: 1, by: -1) {
        if choice[i][remainingCapacity] {
            selectedItems.append(i - 1)  // 记录物品索引
            remainingCapacity -= weights[i - 1]  // 更新剩余容量
        }
    }
    
    // 返回最大价值和选择的物品索引列表
    return (dp[n][maxWeight], selectedItems.reversed())
}

// 示例使用
let weights = [2, 3, 4, 5]  // 物品重量数组
let values = [3, 4, 5, 6]   // 物品价值数组
let maxWeight = 5           // 背包的最大承重

// 计算并打印最大价值和选择的物品
let (maxValue, selectedItems) = knapsack(weights, values, maxWeight)
print("在背包容量为 \(maxWeight) 时,最大可获得的价值为 \(maxValue)")
print("选择的物品索引为: \(selectedItems)")

代码详解

  1. 输入参数

    • weights: 物品的重量数组。
    • values: 物品的价值数组。
    • maxWeight: 背包的最大承重。
  2. 状态数组 dp

    • dp[i][j] 表示在前 i 个物品中选择,并且背包容量为 j 时能够获得的最大价值。
  3. 记录选择的物品

    • choice[i][j] 数组用于记录在容量 j 时是否选择了物品 i
    • 通过动态规划填充 dpchoice 数组。
  4. 回溯选择的物品

    • 通过回溯 choice 数组,可以恢复出哪些物品被选择放入背包。
    • remainingCapacity 表示当前剩余的背包容量,通过回溯逐步减少,直到回到起点。
  5. 返回结果

    • 返回 dp[n][maxWeight],即最大价值。
    • 返回 selectedItems,即选择的物品索引列表。

时间复杂度为 O(n * W),其中 n 是物品的数量,W 是背包的最大容量。

示例2

拼写纠错

拼写纠错功能简介

拼写纠错是自然语言处理中的一个经典问题。给定一个错误的单词,目标是找到最接近的正确单词。常用的拼写纠错算法基于编辑距离(例如 Levenshtein 距离)来衡量两个单词的相似性。编辑距离是两个字符串之间的最小操作次数,这些操作包括插入、删除和替换字符。

算法原理

为了实现拼写纠错功能,我们可以使用动态规划来计算输入单词与字典中每个单词的编辑距离,并选择编辑距离最小的单词作为纠正结果。

编辑距离 (Levenshtein Distance)

  1. 状态定义

    • dp[i][j] 表示将字符串 word1[0...i-1] 转换为 word2[0...j-1] 所需的最小操作次数。
  2. 状态转移方程

    • 如果 word1[i-1] == word2[j-1],则 dp[i][j] = dp[i-1][j-1]
    • 否则,dp[i][j] 是以下三种情况的最小值加 1:
      • 替换操作:dp[i-1][j-1]
      • 插入操作:dp[i][j-1]
      • 删除操作:dp[i-1][j]
  3. 初始条件

    • dp[0][j] = j,表示将空字符串转换为长度为 j 的字符串所需的插入操作数。
    • dp[i][0] = i,表示将长度为 i 的字符串转换为空字符串所需的删除操作数。
  4. 目标

    • dp[m][n] 表示将字符串 word1 转换为 word2 的最小操作次数,其中 mword1 的长度,nword2 的长度。

Swift 实现代码

import Foundation

/// 计算两个字符串之间的编辑距离 (Levenshtein 距离)
/// - Parameters:
///   - word1: 第一个字符串
///   - word2: 第二个字符串
/// - Returns: 两个字符串之间的编辑距离
func editDistance(_ word1: String, _ word2: String) -> Int {
    let m = word1.count
    let n = word2.count
    
    // 将字符串转换为字符数组以便于访问
    let word1Array = Array(word1)
    let word2Array = Array(word2)
    
    // 创建一个 (m+1) x (n+1) 的二维数组 dp
    var dp = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1)
    
    // 初始化 dp 数组
    for i in 0...m {
        dp[i][0] = i    // 从 word1 的前 i 个字符到空字符串的距离为 i(删除操作)
    }
    
    for j in 0...n {
        dp[0][j] = j    // 从空字符串到 word2 的前 j 个字符的距离为 j(插入操作)
    }
    
    // 填充 dp 数组
    for i in 1...m {
        for j in 1...n {
            if word1Array[i - 1] == word2Array[j - 1] {
                dp[i][j] = dp[i - 1][j - 1] // 字符相等,无需操作
            } else {
                dp[i][j] = min(
                    dp[i - 1][j - 1],  // 替换操作
                    dp[i][j - 1],      // 插入操作
                    dp[i - 1][j]       // 删除操作
                ) + 1
            }
        }
    }
    
    // 返回最终的编辑距离
    return dp[m][n]
}

/// 拼写纠错功能
/// - Parameters:
///   - inputWord: 用户输入的单词
///   - dictionary: 正确单词的字典
/// - Returns: 与输入单词最接近的正确单词
func spellCorrection(inputWord: String, dictionary: [String]) -> String {
    var minDistance = Int.max
    var closestWord = ""
    
    // 遍历字典中的每个单词,计算其与输入单词的编辑距离
    for word in dictionary {
        let distance = editDistance(inputWord, word)
        if distance < minDistance {
            minDistance = distance
            closestWord = word
        }
    }
    
    return closestWord
}

// 示例使用
let dictionary = ["example", "apple", "orange", "banana", "grape"]
let inputWord = "applle"

let correctedWord = spellCorrection(inputWord: inputWord, dictionary: dictionary)
print("输入的单词: \(inputWord)")
print("最接近的正确单词是: \(correctedWord)")

代码详解

  1. 编辑距离计算

    • editDistance 函数计算两个字符串之间的编辑距离(Levenshtein 距离)。
    • 通过动态规划填充 dp 数组,其中 dp[i][j] 表示将 word1[0...i-1] 转换为 word2[0...j-1] 所需的最小操作次数。
  2. 拼写纠错功能

    • spellCorrection 函数实现拼写纠错功能。
    • 对于输入单词 inputWord,遍历字典中的每个单词,计算其与 inputWord 的编辑距离,并记录最小距离的单词作为纠正结果。
  3. 示例使用

    • 给定一个字典 dictionary 和一个用户输入的错误单词 inputWord
    • 通过 spellCorrection 函数计算最接近的正确单词,并输出结果。

示例3

最短路径问题

最短路径问题在图论中是一个经典的问题,目标是在一个加权图中,找到从起点到终点的路径,使得路径上的权重之和最小。常见的算法有 Dijkstra 算法(用于非负权图)和 Bellman-Ford 算法(可以处理负权边)。这里,我们将使用动态规划的思想来实现最短路径问题,并记录下最短路径。

算法原理

动态规划的思想是通过解决子问题并记录其结果来避免重复计算,从而有效地解决问题。对于最短路径问题,通常使用的动态规划方法是基于 Bellman-Ford 算法。

Bellman-Ford 算法的核心思想:

  1. 状态定义

    • dp[i][v] 表示从起点 s 到顶点 v,经过最多 i 条边的最短路径长度。
  2. 状态转移方程

    • dp[i][v] = min(dp[i-1][v], dp[i-1][u] + w(u, v)),其中 u 是与 v 相连的顶点,w(u, v) 是边 u -> v 的权重。
  3. 初始条件

    • dp[0][s] = 0,表示从起点 s 到自身的最短路径长度为 0。
    • 对于所有其他顶点 vdp[0][v] 都初始化为无穷大(表示不可达)。
  4. 目标

    • 最终答案为 min(dp[k][v]),即经过最多 k 条边后,从起点 s 到终点 v 的最短路径长度。

Swift 实现代码

以下是使用 Swift 实现 Bellman-Ford 算法,并记录最短路径的代码,附有详细注释:

import Foundation

/// 边的结构体,表示从一个顶点到另一个顶点的边及其权重
struct Edge {
    let from: Int
    let to: Int
    let weight: Int
}

/// 使用 Bellman-Ford 算法寻找最短路径
/// - Parameters:
///   - edges: 图中的边的列表
///   - vertexCount: 顶点的数量
///   - source: 起点顶点
/// - Returns: (最短路径的距离数组, 前驱节点数组) 如果存在负权回路则返回 nil
func bellmanFord(edges: [Edge], vertexCount: Int, source: Int) -> ([Int], [Int])? {
    // 初始化最短路径数组,所有顶点到自身的距离为 0,其他为正无穷大
    var distance = Array(repeating: Int.max, count: vertexCount)
    distance[source] = 0
    
    // 前驱节点数组,用于记录最短路径的前一个节点
    var predecessor = Array(repeating: -1, count: vertexCount)
    
    // 进行 vertexCount - 1 次松弛操作
    for _ in 0..<vertexCount - 1 {
        for edge in edges {
            if distance[edge.from] != Int.max && distance[edge.from] + edge.weight < distance[edge.to] {
                distance[edge.to] = distance[edge.from] + edge.weight
                predecessor[edge.to] = edge.from
            }
        }
    }
    
    // 检测负权回路,如果进行一次松弛后还能更新,说明存在负权回路
    for edge in edges {
        if distance[edge.from] != Int.max && distance[edge.from] + edge.weight < distance[edge.to] {
            print("图中存在负权回路")
            return nil
        }
    }
    
    // 返回最短距离数组和前驱节点数组
    return (distance, predecessor)
}

/// 获取从起点到目标顶点的最短路径
/// - Parameters:
///   - predecessor: 前驱节点数组
///   - target: 目标顶点
/// - Returns: 最短路径的顶点列表
func getPath(predecessor: [Int], target: Int) -> [Int] {
    var path = [Int]()
    var current = target
    
    while current != -1 {
        path.insert(current, at: 0)
        current = predecessor[current]
    }
    
    return path
}

// 示例使用
let edges = [
    Edge(from: 0, to: 1, weight: 4),
    Edge(from: 0, to: 2, weight: 1),
    Edge(from: 2, to: 1, weight: 2),
    Edge(from: 1, to: 3, weight: 1),
    Edge(from: 2, to: 3, weight: 5)
]

let vertexCount = 4
let source = 0
let target = 3

if let (distance, predecessor) = bellmanFord(edges: edges, vertexCount: vertexCount, source: source) {
    print("从顶点 \(source) 到顶点 \(target) 的最短路径长度为 \(distance[target])")
    let path = getPath(predecessor: predecessor, target: target)
    print("最短路径为: \(path)")
} else {
    print("图中存在负权回路,无法计算最短路径")
}

代码详解

  1. Edge 结构体

    • Edge 结构体表示图中的一条边,包含起点 from、终点 to 和权重 weight
  2. Bellman-Ford 算法

    • bellmanFord 函数实现了 Bellman-Ford 算法,返回从源点 source 到所有顶点的最短路径长度 distance 数组和前驱节点 predecessor 数组。
    • distance 数组存储从起点到每个顶点的最短路径长度。
    • predecessor 数组用于记录最短路径中每个顶点的前一个顶点,方便构建路径。
  3. 路径恢复

    • getPath 函数使用 predecessor 数组来回溯从起点到目标顶点 target 的最短路径,最终返回路径上的顶点列表。
  4. 示例使用

    • 构建一个简单的图,定义边的列表,并调用 bellmanFord 函数计算从 source 顶点到 target 顶点的最短路径及其长度。
    • 如果算法返回了有效的结果,则输出路径长度和具体路径。

运行示例

在示例中,图中有 4 个顶点和 5 条边,起点为顶点 0,目标为顶点 3。通过 Bellman-Ford 算法计算,得到从顶点 0 到顶点 3 的最短路径长度为 4,路径为 [0, 2, 1, 3]

这个实现可以处理含有负权边的图,但不能处理负权回路。如果图中存在负权回路,算法会检测到并返回 nil