08.算法——动态规划
算法专题:动态规划 (Dynamic Programming)
1. 引言
在计算机科学和数学中,动态规划(Dynamic Programming,简称 DP)是一种通过把原问题分解为相对简单的子问题的方式来求解复杂问题的方法。它通常用于优化问题,即在给定约束条件下,找到最大或最小的某个值。
动态规划的核心思想是,如果一个问题可以被分解成相互重叠的子问题,并且这些子问题的最优解可以组合成原问题的最优解(即具有最优子结构),那么就可以使用动态规划。通过存储和重用子问题的解,DP 算法避免了重复计算,从而显著提高了效率,通常将指数级的暴力搜索优化到多项式时间。
2. 动态规划的两个核心特性
一个问题能够用动态规划解决,通常需要满足以下两个关键性质:
2.1 最优子结构 (Optimal Substructure)
- 定义:一个问题的最优解包含其子问题的最优解。
- 理解:这意味着如果你找到了解决一个大问题的最佳方法,那么这个方法所涉及的每个小步骤(子问题)也必须是以最佳方式解决的。
- 例子:如果最短路径问题中从 A 到 B 的最短路径经过 C,那么从 A 到 C 的路径以及从 C 到 B 的路径也必须是各自的最短路径。
2.2 重叠子问题 (Overlapping Subproblems)
- 定义:在解决问题的过程中,会多次遇到相同的子问题。
- 理解:如果子问题是独立的(比如快速排序),那么用分治法就足够了;但如果子问题会被多次计算,那么就可以通过存储子问题的解来避免重复计算,这就是动态规划的优势所在。
- 例子:计算斐波那契数列
F(n)
,F(n) = F(n-1) + F(n-2)
。计算F(5)
需要F(4)
和F(3)
,而F(4)
又需要F(3)
和F(2)
,F(3)
被计算了多次。
3. 动态规划的两种实现方式
动态规划通常有两种主要的实现方式:
3.1 记忆化搜索 (Memoization / Top-down DP)
- 思想:自顶向下,递归地解决问题。在计算子问题时,将结果存储起来(通常在数组或哈希表中)。下次再遇到相同的子问题时,直接从存储中读取,而不是重新计算。
- 优点:
- 更接近直觉的递归思路,容易编写。
- 只计算真正需要的子问题。
- 缺点:
- 递归调用可能导致栈溢出。
- 有函数调用的额外开销。
3.2 列表法 / 递推 (Tabulation / Bottom-up DP)
- 思想:自底向上,迭代地解决问题。从最小的子问题开始,逐步计算并填充一个表格(通常是数组),直到计算出原问题的解。
- 优点:
- 没有递归开销,通常更高效。
- 避免栈溢出。
- 更容易进行空间优化。
- 缺点:
- 需要确定正确的计算顺序。
- 可能需要计算所有子问题,即使某些子问题对最终解没有贡献。
4. 解决动态规划问题的通用步骤
- **定义状态 (State)**:确定
dp
数组的含义,即dp[i]
或dp[i][j]
代表什么。 - **初始化 (Initialization)**:确定
dp
数组的初始值(边界条件)。 - **确定状态转移方程 (Recurrence Relation)**:找出如何从已知的子问题的解推导出当前问题的解。
- **确定遍历顺序 (Iteration Order)**:保证在计算
dp[i]
或dp[i][j]
时,其依赖的子问题已经被计算过。 - **打印遍历与确定返回值 (Result)**:如果存在疑惑可打印输出,以便于确定最终答案在
dp
数组中的位置。
5. 动态规划与贪心算法/回溯算法的区别
- 与贪心算法:
- 贪心:每一步都做出局部最优选择,不考虑全局影响,且不回溯。要求问题具有“贪心选择性质”。通常更简单高效,但适用范围窄。
- DP:考虑所有子问题的最优解,通过组合子问题解来得到全局最优,可能会在计算过程中探索多个路径。要求问题具有“最优子结构”和“重叠子问题”。适用范围更广。
- 与回溯算法:
- 回溯:一种暴力搜索,通过尝试所有可能的路径来找到解,当遇到死路或不满足条件时回溯。通常会进行剪枝来优化。
- DP:在回溯的基础上,通过存储和重用重叠子问题的解来避免重复计算,从而将指数级复杂度降低到多项式级。可以看作是带有记忆化的回溯,或者说是“去掉了重复计算的回溯”。
6. Go 语言实现示例
示例1:斐波那契数列 (Fibonacci Number)
问题:计算第 n
个斐波那契数。F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2)
。
1. 记忆化搜索 (Top-down)
1 |
|
2. 列表法 (Bottom-up)
1 |
|
示例2:凑零钱问题 (Coin Change)
1 |
|
1 |
|
示例3:最长公共子序列 (Longest Common Subsequence, LCS)
问题:给定两个字符串 text1
和 text2
,找出这两个字符串的最长公共子序列的长度。子序列是指一个字符串通过删除一些(或不删除)字符而不改变剩余字符的相对顺序得到的新字符串。 核心思想:二维 DP。
1 |
|
示例4:0/1 背包问题 (0/1 Knapsack Problem)
问题:给定 n
件物品和一个容量为 W
的背包。每件物品有其价值 value[i]
和重量 weight[i]
。每件物品只能选择一次(0/1),问在不超过背包容量的前提下,如何使得背包中物品的总价值最大。 核心思想:二维 DP。
1 |
|
示例5:完全 背包问题
问题描述
有 N
种物品和一个容量为 W
的背包。第 i
种物品的重量是 weights[i]
,价值是 values[i]
。求解将哪些物品装入背包,可使这些物品的重量总和不超过背包容量,且价值总和最大。 特点:每种物品都有无限件可用。
解题思路
与0/1背包非常相似,主要区别在于每种物品可以选取多次。
定义状态:
- 二维 DP:
dp[i][j]
表示从前i
种物品中任意选择(每种可选多次),放入容量为j
的背包中所能获得的最大价值。
- 二维 DP:
状态转移方程: 对于第
i
种物品(对应索引i-1
):不选择第
i
种物品:dp[i-1][j]
**选择第
i
种物品(前提j >= weights[i-1]**):由于可以选多次,当我们为容量j 考虑第i 种物品时,我们可能已经为容量j - weights[i-1] 的背包选择了第i种物品。所以价值是1
dp[i][j - weights[i-1]] + values[i-1]
- 注意:这里是
dp[i][...]
而不是dp[i-1][...]
。这表示在考虑容量j - weights[i-1]
时,仍然可以继续选择第i
种物品。
- 注意:这里是
综合:
dp[i][j] = max(dp[i-1][j], dp[i][j - weights[i-1]] + values[i-1])
(当j >= weights[i-1]
时)dp[i][j] = dp[i-1][j]
(当j < weights[i-1]
时)另一种等价的思考方式是枚举第
i
种物品选k
件:dp[i][j] = max(dp[i-1][j - k*weights[i-1]] + k*values[i-1])
fork*weights[i-1] <= j
。 这个可以通过数学归纳证明与上面的转移方程等价。初始化: 同0/1背包,
dp
表格初始化为 0。遍历顺序:
- 外层循环遍历物品种类(
i
从 1 到N
)。 - 内层循环遍历背包容量(
j
从 0 到W
)。
- 外层循环遍历物品种类(
返回值:
dp[N][W]
1 |
|
背包问题总结与关键区别
特性 | 0/1 背包 | 完全背包 |
---|---|---|
物品限制 | 每件物品最多选1次 | 每种物品可选无限次 |
二维DP状态转移 | dp[i][j] = max(dp[i-1][j], dp[i-1][j-w]+v) |
dp[i][j] = max(dp[i-1][j], dp[i][j-w]+v) |
一维DP内循环 | 倒序遍历背包容量 j |
正序遍历背包容量 j |
核心差异原因 | 倒序保证 dp[j-w] 是上一轮(不含当前物品)的状态 |
正序允许 dp[j-w] 是本轮(已含当前物品)的状态 |
7. 结论
动态规划是一种强大而通用的算法设计范式,它通过识别问题的最优子结构和重叠子问题,并运用记忆化或列表法来避免重复计算,从而高效地解决许多复杂的优化问题。掌握 DP 的核心思想、通用步骤和常见题型,是提升算法能力的基石。虽然它的概念可能初看起来有些抽象,但通过大量的练习和 Go 语言的清晰实现,你会逐渐领悟其精妙之处。