动态规划是算法设计中的一个强大技术,它将一个复杂问题分解为更小、更易于管理的子问题,并通过存储子问题的解来避免重复计算,从而高效地找到问题的最优解。
第一部分:动态规划核心思想
在深入具体问题之前,必须理解DP的灵魂。
1. DP是什么?
动态规划(DP)本质上是一种解决多阶段决策过程最优化问题的方法。
- 多阶段决策:问题可以被分解成一系列相互关联的步骤(阶段),在每个阶段都需要做出决策。
- 最优化:目标是找到一个决策序列,使得整个过程的某个衡量指标(如总成本、总价值等)达到最优(最大或最小)。
“Programming”的含义:这里的“Programming”不是指写代码,而是指“规划”或“计划”,由美国数学家理查德·贝尔曼在20世纪50年代提出。
2. DP的两个核心特征
一个问题能否用动态规划解决,取决于它是否具备以下两个性质:
-
最优子结构 (Principle of Optimality)
- 定义:一个问题的最优解包含了其子问题的最优解。换句话说,无论初始状态和初始决策是什么,余下的决策都必须相对于初始决策所形成的状态构成一个最优决策序列。
- 通俗理解:如果你的最终目标(比如从A到Z的最短路径)经过了某个中间点M,那么从A到M的那一段路,也必须是所有从A到M的路径中最短的。
-
重叠子问题 (Overlapping Subproblems)
- 定义:在问题的求解过程中,许多子问题的解会被反复使用。
- 通俗理解:在使用递归求解时,你会发现你一次又一次地在计算同一个子问题。例如,在计算斐波那契数列
Fib(5)时,Fib(3)会被计算两次。DP通过“记忆化”(存储)已经计算过的子问题解,避免了这种不必要的重复计算。
3. DP vs. 其他算法思想
| 特性对比 | 动态规划 (DP) | 贪心法 (Greedy) | 分治法 (Divide & Conquer) |
|---|---|---|---|
| 决策依据 | 依赖当前状态,会考虑决策对未来的影响,追求全局最优。 | 只看眼前,做出局部最优决策,不保证全局最优。 | 子问题相互独立,不关心其他子问题。 |
| 问题特征 | 最优子结构、重叠子问题。 | 最优子结构、贪心选择性质。 | 最优子结构、子问题相互独立。 |
| 经典例子 | 0/1背包、数塔问题 | 部分背包、霍夫曼编码 | 归并排序、快速排序 |
一个例子看清DP和贪心的区别:数塔问题
从顶部出发,每次只能走向相邻的下一层节点,求一条路径使得经过的数字之和最大。
- 贪心法:在每一步都选择当前能走的最大数字。路径:
9 -> 15 -> 8 -> 9 -> 10,总和为 51。这不是最优解。 - 动态规划:从下往上思考。对于倒数第二层的
2,它的最优选择是走向19(因为19>7),所以2的最优路径和是2+19=21。同理,18的最优路径是18+10=28。这样逐层向上计算,直到顶点。最终得到的最优解是9 -> 12 -> 10 -> 18 -> 10,总和为 59。DP看到了全局,而贪心只看到了局部。
4. 动态规划解题通用步骤
这是你准备机试时最重要的部分,面对一个新问题,按这个思路去思考:
- 识别问题:判断问题是否具有最优子结构和重叠子问题特性,是否是求解最优化问题。
- 定义状态 (State):这是最关键也最难的一步。你需要定义一个数组(通常是
dp[i]或dp[i][j]),想清楚dp[i]代表什么。它通常表示“在阶段i时,原问题的一个子问题的最优解”。 - 写出状态转移方程 (Recurrence Relation):找出
dp[i]和dp[i-1],dp[i-2]… 之间的关系。也就是如何通过一个或多个较小子问题的解,来计算出当前较大问题的解。 - 确定基础情况 (Base Case):确定最小的子问题,它的解是已知的,作为递推的起点。例如
dp[0]或dp[0][0]的值。 - 实现:通常使用“自底向上”(Bottom-Up)的方式,用循环来填充dp数组。
第二部分:经典动态规划问题详解
下面我们来详细拆解课程中提到的几个经典问题。每个问题都会包含问题描述、状态转移方程、求解过程、代码实现和复杂度分析。
1. 多段图问题 (Multistage Graph)
-
问题描述:给定一个有向无环图,节点被划分为
k个阶段。求一条从起点s(第一阶段)到终点t(第k阶段)的路径,使得路径上的边的成本之和最小。 -
状态转移方程:
- 设
Cost(i, j)是从阶段i的节点j到终点t的最小成本。 - 向前处理法 (Forward Approach):从终点向前推。
Cost(i,j)=minl∈Vi+1,⟨j,l⟩∈E{ c(j,l)+Cost(i+1,l)}Cost(i, j) = \min_{l \in V_{i+1}, \langle j,l \rangle \in E} \{c(j,l) + Cost(i+1, l)\}Cost(i,j)=l∈Vi+1,⟨j,l⟩∈Emin{ c(j,l)+Cost(i+1,l)}
其中c(j,l)是边<j,l>的成本。终点t的成本为 0。 - 向后处理法 (Backward Approach):从起点向后推。
设BCost(i, j)是从起点s到阶段i的节点j的最小成本。
BCost(i,j)=minl∈Vi−1,⟨l,j⟩∈E{ BCost(i−1,l)+c(l,j)}BCost(i, j) = \min_{l \in V_{i-1}, \langle l,j \rangle \in E} \{BCost(i-1, l) + c(l,j)\}BCost(i,j)=l∈Vi−1,⟨l,j⟩∈Emin{ BCost(i−1,l)+c(l,j)}
起点s的成本为 0。
- 设
-
求解过程:无论是向前还是向后,都通过填表的方式,逐个阶段计算每个节点的最小成本,直到计算出起点(或终点)的成本。在计算的同时,记录下每个决策(即选择了哪个后继/前驱节点),以便最后回溯找到完整路径。
-
复杂度:时间复杂度 Θ(n+e)\Theta(n+e)Θ(n+e),空间复杂度 Θ(n+e)\Theta(n+e)Θ(n+e) (使用邻接表存储图时),其中
n是节点数,e是边数。 -
代码实现 (向前处理法)
# Python 实现 (向前处理法) import math def multistage_graph(graph, stages): """ graph: 邻接表表示的图, e.g., {node: [(neighbor, cost), ...]} stages: 阶段数 k """ n = len(graph) # 节点总数,假设节点从 1 到 n 编号 cost = [0] * (n + 1) path = [0] * (stages + 1) d = [0] * (n + 1) # 记录决策 # 从后向前计算 cost # 最后一个阶段的 cost[n] = 0 (已初始化) for i in range(n - 1, 0, -1): min_cost = math.inf best_neighbor = -1 if i in graph: for neighbor, c in graph[i]: if c + cost[neighbor] < min_cost: min_cost = c + cost[neighbor] best_neighbor = neighbor if min_cost != math.inf: cost[i] = min_cost d[i] = best_neighbor # 回溯找到路径 path[1] = 1 path[stages] = n for i in range(2, stages): path[i] = d[path[i-1]] return cost[1], path// C++ 实现 (向前处理法) #include <iostream> #include <vector> #include <limits> using namespace std; const int INF = numeric_limits<int>::max(); pair<int, vector<int>> multistage_graph(const vector<vector<pair<int, int>>>& adj, int stages, int n) { vector<int> cost(n + 1, INF); vector<int> d(n + 1, 0); vector<int> path(stages + 1

最低0.47元/天 解锁文章
2889

被折叠的 条评论
为什么被折叠?



