深入理解动态规划 (Dynamic Programming)
动态规划(Dynamic Programming, DP)是一种在数学、计算机科学和经济学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题。
一、动态规划的核心思想
动态规划的核心思想可以概括为“一个模型三个特征”。
一个模型:动态规划试图解决多阶段决策最优解模型。多阶段决策问题是指,我们可以将一个问题的解决过程分解为若干个相互联系的阶段,在每一个阶段都需要做出决策,而这些决策共同构成了一个决策序列,最终产生一个结果。动态规划的目标就是找到这个决策序列,使得结果达到最优。
三个特征:
最优子结构 (Optimal Substructure):
问题的最优解所包含的子问题的解也是最优的。如果我们能将一个大问题分解成小问题,并且这些小问题的最优解能组合成大问题的最优解,那么这个问题就具备最优子结构性质。这是动态规划能够使用的前提。
例如,在求解最短路径问题时,如果从 A 到 C 的最短路径经过 B,那么从 A 到 B 的路径以及从 B 到 C 的路径也必定是它们各自的最短路径。重叠子问题 (Overlapping Subproblems):
在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划正是利用了这种子问题的重叠性质,对每一个子问题只解一次,而后将其解保存在一个表格中,当再次需要解此子问题时,直接查表获取该子问题的解,而不必重新计算。
例如,在计算斐波那契数列F(n) = F(n-1) + F(n-2)
时,F(n-2)
会在计算F(n-1)
的过程中再次被计算。无后效性 (No Aftereffect / Memoryless Property):
即子问题的解一旦确定,就不会再受到后续阶段决策的影响。当前状态是历史状态的完整总结,一旦当前状态确定,未来的决策只与当前状态有关,与达到当前状态的历史路径无关。
例如,在求解从 A 点到 E 点的最短路径问题时,如果我们已经找到了从 A 点到 C 点的最短路径长度,那么无论我们后续从 C 点选择哪条路走向 E 点,A 到 C 的这段最短路径长度是不会改变的。
二、动态规划的使用方式 (实现方法)
动态规划主要有两种实现方式:
自顶向下 (Top-Down) 带备忘录 (Memoization):
这种方式从目标问题开始,将其分解为子问题。如果子问题已经被解决过(其解已存储在备忘录中),则直接使用该解;否则,解决该子问题,并将解存入备忘录。这通常通过递归实现。- 优点:只计算实际需要的子问题,可能比自底向上更直观。
- 缺点:递归调用可能产生较大的函数调用开销,甚至可能导致栈溢出。
自底向上 (Bottom-Up) / 递推 / 迭代 (Tabulation):
这种方式从最小的子问题开始解决,然后逐步构建更大子问题的解,直到解决原始问题。通常通过迭代实现,将子问题的解存储在一个表格(通常是一维或多维数组)中。- 优点:没有递归开销,效率通常更高。
- 缺点:需要事先确定所有可能被依赖的子问题的计算顺序,可能会计算一些最终用不上的子问题。
选择哪种方式取决于问题的具体特性和个人偏好,两者在时间复杂度上通常是相同的。
三、动态规划的使用流程 (五步曲)
解决一个动态规划问题,通常可以遵循以下五个步骤:
定义状态 (State Definition):
这是动态规划中最核心也最困难的一步。状态定义需要清晰、无歧义,并且能够唯一地描述问题的某个阶段或子问题。通常,我们会用一个或多个变量来表示状态,例如dp[i]
表示考虑到第i
个元素时的最优解,或者dp[i][j]
表示在某个二维约束下的最优解。- 关键是找到能够“概括”子问题解的变量。
- 要思考:
dp[i]
代表什么?(例如,是最大值、最小值、数量、还是可行性?) - 状态的定义必须满足无后效性。
找出状态转移方程 (State Transition Equation):
状态转移方程描述了不同状态之间的关系,即如何从一个或多个已知解的子问题(较小规模的状态)推导出当前问题(较大规模的状态)的解。这是动态规划的灵魂。- 例如:
dp[i] = f(dp[i-1], dp[i-2], ...)
。 - 思考:
dp[i]
是如何由之前的状态推导出来的?有哪些决策可以从dp[j]
(j<i) 转移到dp[i]
? - 需要考虑所有可能的转移路径,并从中选择最优的(如果是优化问题)或进行累加(如果是计数问题)。
- 例如:
确定边界条件/初始状态 (Base Cases / Initialization):
边界条件是状态转移方程的递归出口,即最小规模子问题的解。没有正确的边界条件,状态转移方程就无法正确启动。- 例如:
dp[0]
或dp[1]
的值,或者矩阵dp
的第一行/第一列的值。 - 初始化的值需要根据状态的定义和问题的性质来确定。有时可能需要初始化为0,有时是正无穷或负无穷(用于求最小/最大值时)。
- 例如:
确定计算顺序 (Order of Computation):
根据状态转移方程,确定计算dp
数组的顺序。通常,如果dp[i]
依赖于dp[j]
(其中j < i
),那么就需要从小到大计算。对于二维dp
数组,可能是逐行逐列,或者逐列逐行。- 自底向上的方法天然保证了计算顺序的正确性,即在计算
dp[i]
时,所有它依赖的子问题dp[j]
都已经被计算出来了。 - 自顶向下的备忘录法则不需要显式确定顺序,递归会自动处理。
- 自底向上的方法天然保证了计算顺序的正确性,即在计算
返回最终解 (Return Final Answer):
根据问题的要求,从dp
数组中提取最终的答案。这可能是dp
数组中的某一个值(如dp[n]
或dp[n][m]
),也可能是整个数组中的最大值或最小值。
四、C/C++ 测试用例
下面通过几个经典的动态规划问题及其 C++ 实现来加深理解。
1. 斐波那契数列 (Fibonacci Sequence)
- 问题描述:计算斐波那契数列的第
n
项。F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2)
forn > 1
。 - 状态定义:
dp[i]
表示斐波那契数列的第i
项的值。 - 状态转移方程:
dp[i] = dp[i-1] + dp[i-2]
- 边界条件:
dp[0] = 0
,dp[1] = 1
- 计算顺序:从
i=2
到n
。 - 最终解:
dp[n]
C++ 实现 (自底向上)
#include <iostream>
#include <vector>
long long fibonacci_bottom_up(int n) {
if (n <= 0) return 0;
if (n == 1) return 1;
// 1. 定义状态: dp[i] 表示斐波那契数列的第 i 项
std::vector<long long> dp(n + 1);
// 3. 边界条件
dp[0] = 0;
dp[1] = 1;
// 4. 计算顺序 (自底向上) & 2. 状态转移方程
for (int i = 2; i <= n; ++i) {
dp[i] = dp[i-1] + dp[i-2];
}
// 5. 返回最终解
return dp[n];
}
int main() {
int n = 10;
std::cout << "Fibonacci(" << n << ") = " << fibonacci_bottom_up(n) << std::endl; // Output: 55
n = 20;
std::cout << "Fibonacci(" << n << ") = " << fibonacci_bottom_up(n) << std::endl; // Output: 6765
return 0;
}
C++ 实现 (自顶向下带备忘录)
#include <iostream>
#include <vector>
#include <map> // 或者使用数组如果n的范围不大
// 使用 map 作为备忘录
std::map<int, long long> memo;
long long fibonacci_top_down(int n) {
if (n <= 0) return 0;
if (n == 1) return 1;
// 检查备忘录中是否已计算过
if (memo.count(n)) {
return memo[n];
}
// 2. 状态转移 (递归调用)
long long result = fibonacci_top_down(n - 1) + fibonacci_top_down(n - 2);
// 将结果存入备忘录
memo[n] = result;
return result;
}
int main() {
memo.clear(); // 每次调用前清空备忘录 (如果是多次独立调用)
int n = 10;
// 3. 边界条件在递归基中处理
// 1. 状态隐含在递归函数的参数 n 中
// 4. 计算顺序由递归决定
// 5. 返回最终解
std::cout << "Fibonacci(" << n << ") [Top-Down] = " << fibonacci_top_down(n) << std::endl; // Output: 55
memo.clear();
n = 20;
std::cout << "Fibonacci(" << n << ") [Top-Down] = " << fibonacci_top_down(n) << std::endl; // Output: 6765
return 0;
}
2. 0/1 背包问题 (0/1 Knapsack Problem)
- 问题描述:有
N
件物品和一个容量为W
的背包。第i
件物品的重量是weight[i]
,价值是value[i]
。求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。每种物品只有一件,可以选择放或者不放。 - 状态定义:
dp[i][j]
表示从前i
件物品中选择,放入容量为j
的背包中所能获得的最大价值。 - 状态转移方程:
- 如果不放第
i
件物品:dp[i][j] = dp[i-1][j]
- 如果放第
i
件物品(前提是j >= weight[i-1]
,假设物品索引从0开始):dp[i][j] = dp[i-1][j - weight[i-1]] + value[i-1]
- 综合起来:
dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i-1]] + value[i-1])
(如果j >= weight[i-1]
)
dp[i][j] = dp[i-1][j]
(如果j < weight[i-1]
)
- 如果不放第
- 边界条件:
dp[0][j] = 0
(不选任何物品,价值为0)dp[i][0] = 0
(背包容量为0,价值为0)
- 计算顺序:
i
从 1 到N
,j
从 1 到W
。 - 最终解:
dp[N][W]
C++ 实现 (自底向上,二维 DP)
#include <iostream>
#include <vector>
#include <algorithm>
int knapsack_01(int W, const std::vector<int>& weights, const std::vector<int>& values) {
int N = weights.size();
if (N == 0) return 0;
// 1. 定义状态: dp[i][j] 表示考虑前 i 个物品 (物品索引 0 到 i-1), 背包容量为 j 时的最大价值
// 行数 N+1 (0 到 N), 列数 W+1 (0 到 W)
std::vector<std::vector<int>> dp(N + 1, std::vector<int>(W + 1, 0));
// 3. 边界条件: dp[0][j] = 0 和 dp[i][0] = 0 已经在初始化时完成 (都为0)
// 4. 计算顺序 & 2. 状态转移方程
for (int i = 1; i <= N; ++i) { // 物品 (i 对应 weights/values 的索引 i-1)
for (int j = 1; j <= W; ++j) { // 容量
// 不选第 i 个物品 (实际是第 i-1 号物品)
dp[i][j] = dp[i-1][j];
// 如果可以选择第 i 个物品
if (j >= weights[i-1]) {
dp[i][j] = std::max(dp[i][j], dp[i-1][j - weights[i-1]] + values[i-1]);
}
}
}
// 5. 返回最终解
return dp[N][W];
}
int main() {
std::vector<int> values = {60, 100, 120};
std::vector<int> weights = {10, 20, 30};
int W = 50;
std::cout << "Max value in knapsack (0/1) = " << knapsack_01(W, weights, values) << std::endl; // Output: 220 (物品2+3, 100+120=220, 重量20+30=50)
values = {10, 40, 30, 50};
weights = {5, 4, 6, 3};
W = 10;
std::cout << "Max value in knapsack (0/1) = " << knapsack_01(W, weights, values) << std::endl; // Output: 90 (物品2+4, 40+50=90, 重量4+3=7)
return 0;
}
C++ 实现 (空间优化,一维 DP)
对于0/1背包问题,可以注意到 dp[i][j]
的计算只依赖于 dp[i-1]
这一层的数据。因此,可以将二维 dp
数组压缩为一维。
#include <iostream>
#include <vector>
#include <algorithm>
int knapsack_01_optimized(int W, const std::vector<int>& weights, const std::vector<int>& values) {
int N = weights.size();
if (N == 0) return 0;
// 1. 定义状态: dp[j] 表示容量为 j 的背包所能获得的最大价值 (滚动数组)
std::vector<int> dp(W + 1, 0);
// 3. 边界条件: dp[0] = 0 (已初始化)
// 4. 计算顺序 & 2. 状态转移方程
for (int i = 0; i < N; ++i) { // 遍历物品
// 关键:内层循环必须逆序遍历容量
// 这是为了保证在计算 dp[j] 时,dp[j - weights[i]] 存储的是上一轮 (i-1) 的结果
for (int j = W; j >= weights[i]; --j) {
dp[j] = std::max(dp[j], dp[j - weights[i]] + values[i]);
}
}
// 5. 返回最终解
return dp[W];
}
int main() {
std::vector<int> values = {60, 100, 120};
std::vector<int> weights = {10, 20, 30};
int W = 50;
std::cout << "Max value (optimized) = " << knapsack_01_optimized(W, weights, values) << std::endl; // Output: 220
values = {10, 40, 30, 50};
weights = {5, 4, 6, 3};
W = 10;
std::cout << "Max value (optimized) = " << knapsack_01_optimized(W, weights, values) << std::endl; // Output: 90
return 0;
}
- 为什么内层循环需要逆序?
如果正序遍历j
(从小到大),那么当计算dp[j]
时,dp[j - weights[i]]
可能已经是本轮(第i
个物品)计算更新过的值了。这意味着一个物品可能被重复选择,这就变成了完全背包问题。逆序遍历可以确保dp[j - weights[i]]
引用的是处理第i-1
个物品时的状态。
3. 最长公共子序列 (Longest Common Subsequence, LCS)
- 问题描述:给定两个字符串
text1
和text2
,返回这两个字符串的最长公共子序列的长度。子序列是指从原字符串中删除一些(也可以不删除)字符而不改变剩余字符相对顺序得到的新字符串。 - 状态定义:
dp[i][j]
表示text1
的前i
个字符 (即text1[0...i-1]
) 和text2
的前j
个字符 (即text2[0...j-1]
) 的最长公共子序列的长度。 - 状态转移方程:
- 如果
text1[i-1] == text2[j-1]
:那么这个字符一定是 LCS 的一部分。
dp[i][j] = 1 + dp[i-1][j-1]
- 如果
text1[i-1] != text2[j-1]
:那么这个字符至少有一个不在 LCS 中。我们需要考虑两种情况:- LCS 是
text1[0...i-2]
和text2[0...j-1]
的 LCS,即dp[i-1][j]
- LCS 是
text1[0...i-1]
和text2[0...j-2]
的 LCS,即dp[i][j-1]
取两者中较大的:dp[i][j] = max(dp[i-1][j], dp[i][j-1])
- LCS 是
- 如果
- 边界条件:
dp[0][j] = 0
(空字符串与任何字符串的 LCS 长度为0)dp[i][0] = 0
(任何字符串与空字符串的 LCS 长度为0)
- 计算顺序:
i
从 1 到text1.length()
,j
从 1 到text2.length()
。 - 最终解:
dp[text1.length()][text2.length()]
C++ 实现
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
int longestCommonSubsequence(const std::string& text1, const std::string& text2) {
int m = text1.length();
int n = text2.length();
// 1. 定义状态: dp[i][j] 表示 text1 的前 i 个字符和 text2 的前 j 个字符的 LCS 长度
// 行数 m+1, 列数 n+1
std::vector<std::vector<int>> dp(m + 1, std::vector<int>(n + 1, 0));
// 3. 边界条件: dp[0][j] = 0 和 dp[i][0] = 0 已通过初始化完成
// 4. 计算顺序 & 2. 状态转移方程
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
if (text1[i-1] == text2[j-1]) { // 字符串索引从0开始, dp状态索引从1开始
dp[i][j] = 1 + dp[i-1][j-1];
} else {
dp[i][j] = std::max(dp[i-1][j], dp[i][j-1]);
}
}
}
// 5. 返回最终解
return dp[m][n];
}
int main() {
std::string text1 = "abcde";
std::string text2 = "ace";
std::cout << "LCS(\"" << text1 << "\", \"" << text2 << "\") = "
<< longestCommonSubsequence(text1, text2) << std::endl; // Output: 3 ("ace")
text1 = "abc";
text2 = "def";
std::cout << "LCS(\"" << text1 << "\", \"" << text2 << "\") = "
<< longestCommonSubsequence(text1, text2) << std::endl; // Output: 0
text1 = "AGGTAB";
text2 = "GXTXAYB";
std::cout << "LCS(\"" << text1 << "\", \"" << text2 << "\") = "
<< longestCommonSubsequence(text1, text2) << std::endl; // Output: 4 ("GTAB")
return 0;
}
五、开源项目中的使用案例
动态规划的思想和具体算法在许多大型开源项目中都有广泛应用,尽管它们可能不直接命名为“动态规划模块”,但其核心逻辑是DP。
diff
工具 (如 GNU diffutils):- 用途:比较文件差异。
- DP 应用:
diff
工具的核心算法之一是寻找两个文件内容的最长公共子序列 (LCS)。通过找到LCS,可以确定哪些行是相同的,哪些是新增、删除或修改的。Eugene W. Myers 提出的 O(ND) 算法是对传统 LCS 算法的一个优化,广泛用于diff
类工具中。
生物信息学工具 (如 BLAST, FASTA, ClustalW/Clustal Omega):
- 用途:序列比对 (DNA, RNA, 蛋白质序列)。
- DP 应用:
- Needleman-Wunsch 算法:用于全局序列比对,找到两个序列整体上最相似的比对方式。这是一个典型的DP算法。
- Smith-Waterman 算法:用于局部序列比对,找到两个序列中最相似的片段。这也是一个经典的DP算法。
- 启发式算法如 BLAST 和 FASTA 虽然为了速度牺牲了部分精确性,但其内部评分和部分比对阶段也可能借鉴DP思想或在小片段上使用精确DP。Clustal系列进行多序列比对时,也常常基于两两比对的DP结果进行渐进式构建。
编译器 (如 GCC, LLVM):
- 用途:将高级语言代码转换为机器码。
- DP 应用:
- 指令调度 (Instruction Scheduling):为了优化流水线处理器的性能,编译器需要对指令进行重排。某些指令调度算法(尤其是在考虑资源约束时)会使用DP来找到最优或近似最优的指令序列。
- 寄存器分配 (Register Allocation):虽然图染色是寄存器分配的常用方法,但在某些受限情况或特定子问题中,DP可以用来决定变量是存放在寄存器还是内存中,以最小化加载/存储次数。
- 最优二叉搜索树构造 (用于某些符号表或数据结构实现):如果键的访问频率已知,可以用DP(如Knuth的算法)构造平均查找时间最优的二叉搜索树。
自然语言处理 (NLP) 工具/库 (如 NLTK, SpaCy, Stanford CoreNLP):
- 用途:文本分析、处理。
- DP 应用:
- 维特比算法 (Viterbi Algorithm):广泛用于隐马尔可夫模型 (HMM) 的解码问题,如词性标注 (POS Tagging)、命名实体识别 (NER) 和语音识别中的声学模型到词序列的转换。维特比算法本质上是一种DP算法,用于在篱笆网络(trellis)中寻找最优路径。
- CYK 算法 (Cocke-Younger-Kasami):用于上下文无关文法 (CFG) 的句子解析 (parsing),判断一个句子是否符合给定的文法,并可以构建解析树。
- 编辑距离 (Edit Distance) / Levenshtein 距离: 用于计算两个字符串之间的相似度,常用于拼写检查、机器翻译评估等。其计算是一个标准的DP问题。
网络路由协议 (如 RIP 中的 Bellman-Ford 算法):
- 用途:在网络中确定数据包的最佳路径。
- DP 应用:Bellman-Ford 算法用于计算单源最短路径,即使边的权重为负也可以处理(只要没有负权环路)。该算法的迭代过程体现了DP的思想,即每轮迭代都利用上一轮计算出的路径长度来更新当前路径长度。RIP (Routing Information Protocol) 就使用了类似 Bellman-Ford 的距离向量算法。Floyd-Warshall 算法计算所有节点对之间的最短路径,也是一个经典的DP算法。
这些例子表明,动态规划不仅仅是算法竞赛中的题目,它也是解决现实世界复杂优化问题的强大工具,并已深度集成到许多核心技术和软件中。
六、推荐书籍或网站如何学习动态规划
学习动态规划是一个循序渐进的过程,需要理论学习和大量练习相结合。
推荐书籍
《算法导论》(Introduction to Algorithms, CLRS):
- 这本书是算法领域的圣经。其中第15章专门讲解动态规划,内容非常经典和深入,包含多个典型例子如钢条切割、矩阵链乘法、最长公共子序列、最优二叉搜索树等。理论性强,适合有一定基础的读者。
《算法》(Algorithms, by Robert Sedgewick and Kevin Wayne):
- 这本书以更现代和实用的方式讲解算法,动态规划章节同样清晰易懂,并提供了Java实现。它对动态规划的讲解也非常系统。
《挑战程序设计竞赛》(Competitive Programming, by Antti Laaksonen) / 《算法竞赛入门经典》(Programming Contests - Algorithm Training, by 刘汝佳):
- 这类书籍通常面向算法竞赛,包含了大量动态规划的例题和解题技巧,从基础DP到各种复杂类型的DP(如树形DP、数位DP、状态压缩DP等)都有涉及。实践性非常强。
《背包九讲》(by崔添翼,dd_engi):
- 这是一份网上流传非常广泛的关于各种背包问题的讲义,对背包类DP问题讲解得极为透彻。虽然不是正式出版物,但质量非常高。
推荐网站与在线资源
LeetCode (力扣):
- 网址:
https://leetcode.com/
(国际版) /https://leetcode.cn/
(中国版) - 特点: 包含海量的动态规划题目,从易到难,有详细的题解和讨论区。通过刷题可以快速提升对DP的理解和应用能力。可以筛选 “dynamic-programming” 标签。
- 网址:
GeeksforGeeks (GFG):
- 网址:
https://www.geeksforgeeks.org/dynamic-programming/
- 特点: 提供了大量关于动态规划的教程、分类问题和代码实现。文章通常讲解清晰,覆盖面广。
- 网址:
TopCoder / Codeforces:
- 网址:
https://www.topcoder.com/
,https://codeforces.com/
- 特点: 这两个是顶级的算法竞赛平台。它们的教程区(TopCoder SRM Editorials, Codeforces Gym/Blogs)有很多高质量的DP文章和问题分析。题目难度较高,适合进阶。
- 网址:
Educative.io / Grokking the Coding Interview:
- 网址:
https://www.educative.io/
- 特点: 其中 “Grokking Dynamic Programming Patterns for Coding Interviews” 课程专门讲解面试中常见的DP问题模式,如0/1 Knapsack, Unbounded Knapsack, Fibonacci, LCS, Palindromic Subsequence等。从模式入手,帮助理解DP题目的共性。
- 网址:
YouTube 上的教学视频:
- 搜索 “Dynamic Programming tutorials”, “MIT OpenCourseWare Algorithms”, “CS61B (UC Berkeley)” 等。许多大学的算法课程视频质量很高。例如,MIT 6.006 Introduction to Algorithms 的动态规划部分就非常棒。国内也有很多优秀的UP主讲解DP。
学习建议
- 理解核心概念:确保真正理解什么是重叠子问题和最优子结构。
- 从简单问题入手:先解决斐波那契数列、爬楼梯、简单路径计数等基础问题。
- 掌握五步法:刻意练习定义状态、写状态转移方程、找边界、定顺序。
- 多做题,多总结:DP的题目类型很多,需要通过大量练习来识别模式和技巧。做完题后要反思,总结这类题目的共同点和关键突破口。
- 画表格/递归树:对于一些问题,手动模拟计算过程,画出
dp
表格或者递归调用的树状图,有助于理解状态是如何转移的,以及子问题是如何被重复计算的(对于递归)。 - 尝试两种实现方式:对同一个问题,尝试用自顶向下(备忘录递归)和自底向上(迭代填表)两种方法都实现一遍,加深理解。
- 学习经典模型:背包问题、LCS、LIS(最长递增子序列)、矩阵链乘法等都是非常经典的DP模型,理解它们有助于解决更复杂的问题。
- 不要怕困难:动态规划是算法学习中的一个难点,遇到困难是正常的。坚持下去,多思考,多讨论。
通过系统学习和刻意练习,你会逐渐掌握动态规划这一强大的问题解决工具。