一、动态规划法
动态规划,DP,即Dynamic Programming。它是运筹学的一个分支,是一种非常经典的算法之一。它本质上是一种通过"记住已经解决过的子问题的解"来避免重复计算的穷举方法。其核心思想就是将复杂问题分解为可重用的子问题,通过对子问题的解进行保存,从而可以自上而下或自下而上的推导出整个问题的最优解。用空间换时间,将指数级复杂度降为多项式级。它的主要用途是解决最优化的问题。
它和分治法的不同在于分治法的子问题是独立的(不可重用,如快排),而动态规划法则必须是子问题是可重叠(简单理解就是走了同一路径)且其解是可重用的。动态规划法从数学上来看主要解决两个问题即状态定义和状态转移方程。
二、Bellman最优性原理
Bellman最优性原理本身是动态规划的理论基石。所谓Bellman最优性原理是美国数学家R.E. Bellman 于20世纪50年代初提出,它指一个最优策略具有这样的性质,无论初始状态和初始决策如何,其余的决策序列对于由第一个决策所产生的新状态而言,必须构成该状态下的最优策略。
适用条件和应用场景
对于动态规划法来说,要想使用DP是需要有一定的条件的。一般来说,其必须符合以下三种约束:
-
最优子结构
这个比较好理解,就是整体最优解包含了子问题的最优解(可以理解为子路径中的所有路径中它最短)。注意不要和贪心算法混淆,这里的子问题最优指的是入度到子节点的路径,而不是并行分支。看下面的图:

在最优的路径A到C到D中,A到C这个子问题就是最优的(C的入度只有一个),只有一条。而不要和A到B混淆(贪心算法)。如果虚线B到C也真的存在,假如A到C到D仍然是最优,那么A到C路径与A到B到C两条路径来看,最优子结构就是A到C
-
重叠子问题
重叠子问题看上去不好理解,其实用上面的路径来看就非常简单了。也就是说走过有重复的路径。对于D来说,走B和走C这两点都可以被存储解复用
-
无后效性
即当前状态确定后,后续的状态演变不会影响当前状态,其后续状态变化仅受当前状态影响而与其它无关。有点马尔可夫链的味道。
三、处理方式
正如有递归也有递推,对于动态规划法也可以分为两种情况,即自上而下(记忆化搜索)或自下而上(递推,也可以理解是由前向后或由后到前,怎么方便怎么理解)。但无论怎么处理,其基本的步骤如下:
- 状态定义
即定义相关的dp数组的含义,如dpi代表前i个路径的最优解,dpij代表从初始节点(0,0)到节点(i,j)的路径通路数量 - 确定状态转移方程
即找出上述定义的状态的关系,即dpi和dpi-1之间的关系,如dpi = dpi-1 + dpi-2 - 确定初始化条件和边界
这个好理解,就是初始化的节点或初始值是什么,也就是最小的子问题是哪个,从哪里开始解决问题 - 确定计算顺序
由于是状态控制,所以必须明确计算时状态的依赖顺序,不能出现重复计算。比如可以从小到大或者按照拓扑顺序等
对于动态规划法,其应用的典型的场景常见的有两个即最长公共子序列和数塔问题。对于第一个大家可能非常熟悉,面试时经常会出现类似的问题。
四、具体分析
针对上面的说明,下面给出一个具体的最短路径的查找的过程。通过这个过程的分析,来加深对DP的理解。看下面的图:

先不考虑虚线框内的路径,其具体的解决思路如下:
首先,可以暴力拆解出此图的路径为两条:
第一条:A->B->E-G,总长为9(3+5+1)
第二条:A->C-G,总长为6(4+2)
这里采用自下而上的反向递推的方式来分析使用动态规划法,具体的步骤:
- 定义DP数组的状态
dpx表示从节点X出发到达终点G的最短路径,最终计算结果为dpA即最短路径 - 定义初始化的最小问题
第一个节点(反向,即节点G)起始无出路径,所以G到G点的路径权重为0,即dpG=0;其它节点dpA等均为未知(表示为无穷大,实际在处理中为处理两个无穷大的溢出,可以将具体的值进行处理,如减半等) - 确定计算顺序
由上面的图可分析出,A依赖B和C,B依赖E,C依赖G,E依赖G。所以其计算顺序(反向)为G -> E -> C -> B -> A。这里需要说明的是,E和C有顺序,但B和它们平等,前后均可 - 确定状态转移议程
根据动态规划法的特点,最优路径一定包含最优子路径,所以:
dpX=minX→Y(weight(X,Y)+dpY)dpX = \min_{X \to Y} \left( \text{weight}(X, Y) + dpY \right)dpX=X→Ymin(weight(X,Y)+dpY)
由上面的步骤可以转为具体的计算过程(反向)如下:
| 计算顺序 | 当前节点 | 可选后继 | 转移方程计算 | 结果 |
|---|---|---|---|---|
| 第一步 | G | 无 | 终点,直接赋值: dpG=0dpG = 0dpG=0 | dpG=0dpG = 0dpG=0 |
| 第二步 | E | 只有 E→GE \to GE→G(权重 1) | dpE=1+dpG=1+0dpE = 1 + dpG = 1 + 0dpE=1+dpG=1+0 | dpE=1dpE = 1dpE=1 |
| 第三步 | C | 只有 C→GC \to GC→G(权重 2) | dpC=2+dpG=2+0dpC = 2 + dpG = 2 + 0dpC=2+dpG=2+0 | dpC=2dpC = 2dpC=2 |
| 第四步 | B | 只有 B→EB \to EB→E(权重 5) | dpB=5+dpE=5+1dpB = 5 + dpE = 5 + 1dpB=5+dpE=5+1 | dpB=6dpB = 6dpB=6 |
| 第五步 (获取路径) | A | 两条路: 1. A→BA \to BA→B(权重 3) 2. A→CA \to CA→C(权重 4) | 走 B:3+dpB=3+6=93 + dpB = 3 + 6 = 93+dpB=3+6=9 走 C:4+dpC=4+2=64 + dpC = 4 + 2 = 64+dpC=4+2=6 两者取最小:min(9,6)\min(9, 6)min(9,6) | dpA=6dpA = 6dpA=6 |
利用动态规划法的过程进行详细的步骤分析如果能够看明白,自己再把虚线框内的节点释放出来,重新走一次。如果能够正确的理解和运用,请再次从正向走一次(递归方式),当都明白后,基本就知道DP是什么了。
对于想使用好DP来说,没有捷径可言,一般就是要多手绘状态转移的图表。可以先通过暴力推算的方式来解决问题,然后再给出递归的逻辑,画出相关的树图,找到相关的重叠子问题。说白了就是多画图找到转移的规律,然后再使用DP。特别需要说明的是,可以使用滚动数组或变量来优化空间的使用率,这在面试时可能会是加分项。
五、例程
下面看一个经典的例程:
c
int fib_normal(int n) {
if (n <= 1) return n;
//向量数组,占用O(N)的空间
vector<int> dp(n + 1);
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; ++i) {
dp[i] = dp[i - 1] + dp[i - 2]; // 只依赖前两个状态
}
return dp[n];
}
int fib_rolling(int n) {
if (n <= 1) return n;
// 只需要3个变量代替向量数组
int prev2 = 0; // 相当于 dp[i-2]
int prev1 = 1; // 相当于 dp[i-1]
int curr = 0; // 相当于 dp[i]
for (int i = 2; i <= n; ++i) {
curr = prev1 + prev2; // 状态转移
//滚动窗口向前移动
prev2 = prev1; // 旧的i-1变成了新的i-2
prev1 = curr; // 旧的i 变成了新的i-1
}
return prev1; // 循环结束,prev1即最终结果
}
int fib_perfect(int n) {
int a = 0, b = 1; // a = dp[i-2], b = dp[i-1]
for (int i = 2; i <= n; ++i) {
tie(b, a) = make_tuple(a + b, b);
// 等号右边先计算完毕,然后同时赋值给左边
// b变成了新的 dp[i],a变成了旧的b(即dp[i-1])
}
return n <= 1 ? n : b;
}
int main(){
int ret = 0;
ret = fib_normal(6);
ret = fib_rolling(6);
ret = fib_perfect(6);
return 0;
}
上面的例子是使用DP解决斐波那切数列的三种方式,第一种是经典的方式,空间复杂度为O(N),第二种是使用滚动数组减少空间占用,空间复杂度为O(1),最后一种是最简单的方式来解决滚动数组。
下面再给出上面的那个最短路径的例程:
c
#include <algorithm>
#include <climits>
#include <iostream>
#include <vector>
using namespace std;
int main() {
// 1. 定义图:邻接表 (终点, 权重)
vector<vector<pair<int, int>>> adj(5);
// 映射:A=0, B=1, C=2, E=3, G=4
adj[0].push_back({1, 3}); // A->B
adj[0].push_back({2, 4}); // A->C
adj[1].push_back({3, 5}); // B->E
adj[2].push_back({4, 2}); // C->G
adj[3].push_back({4, 1}); // E->G
vector<int> dp(5, INT_MAX);
// 2. 初始化:最小子问题
dp[4] = 0; // 终点 G 到自己的距离为 0
// 3. 逆拓扑序(从终点到起点)
vector<int> order = {4, 3, 2, 1, 0};
// 通用反向松弛逻辑即不严格依赖于顺序号
for (int u : order) {
if (adj[u].empty()) {
continue;
}
// 遍历当前节点u的所有出边u -> v(权重为 w)
for (auto &edge : adj[u]) {
int v = edge.first; // 后继节点
int w = edge.second; // 边的权重
// 如果后继节点 v 的最短距离已经被算出来了(因为逆序,肯定算出来了)
if (dp[v] != INT_MAX) {
// 核心松弛操作:从u出发到终点 = 先走u->v,再从v到终点
// 取所有出边中最小的那个
// 4. 计算状态转移结果
dp[u] = min(dp[u], w + dp[v]);
}
}
}
cout << "A到G的最短路径长度为: " << dp[0] << endl; // 输出 6
return 0;
}
六、总结
动态规划法是一种很强大的算法思想,其利用空间换时间解决了指数级重复计算的问题。只要能够正确的处理好状态和状态转换议程,许多复杂的问题都可以转换为多项式时间可解的问题。但这也恰恰是其问题的所在,没有重叠问题、无法给出状态转移议程都不适合于DP。同样,如果问题规模很大的情况下,也有可能导致状态空间的快速膨胀,所以需要在使用DP时,认真的考虑实际场景。