动态规划(Dynamic Programming)是一种求解优化问题的技巧. 它适用于这样一类优化问题: 原问题包含了一系列 重叠的 子问题. 换句话说, 父问题最优解的结构包含了其子问题的最优解. 例如斐波那契(Fibonacci)数列, 已知 F ( n − 1 ) F(n-1) F(n−1)和 F ( n − 2 ) F(n-2) F(n−2)的值, 我们可以在 O ( 1 ) O(1) O(1)内计算 F ( n ) = F ( n − 1 ) + F ( n − 2 ) F(n) = F(n-1) + F(n-2) F(n)=F(n−1)+F(n−2). 因此 F ( n ) F(n) F(n)的值可以通过计算所有子问题 F ( i ) F(i) F(i), i = 1 , 2 , … , n − 1 i = 1, 2, \ldots, n-1 i=1,2,…,n−1得到.
基本步骤
给定一个优化问题, 我们可以按照如下思路设计动态规划算法.
-
刻画最优解的结构. 首先定义子问题, 然后用反证法证明原问题的最优解包含子问题的最优解. 注意: 不是所有问题都可以用动态规划求解. 因此, 设计动态规划算法之前必须验证最优解结构的正确性.
-
定义最优值的递归式. 根据最优解的结构定义 目标函数 的递归表达式. 注意: 函数初始值的定义.
-
计算最优值并构造最优解. 计算递归表达式一般有两种方式: (a) 递归求解并记录所有子问题的计算结果(Top-down with memoization), 递归地利用子问题的解得到原问题的解; (b) 按问题规模从小到大求解(Bottom-up). 构造最优解的方式可以通过记录递归式的求解信息然后构造得到(详情可以参考下面的例子). (一般来说, 已知递归式构造最优解是比较直观的.)
最短路
以最短路问题为例(下图). 给定图
G
=
(
V
,
E
)
G=(V,E)
G=(V,E),
V
V
V代表顶点的集合,
E
E
E代表边的集合.
∀
(
i
,
j
)
∈
E
\forall (i,j)\in E
∀(i,j)∈E, 令
c
i
,
j
∈
R
+
c_{i,j} \in \mathbb{R}^+
ci,j∈R+代表
(
i
,
j
)
(i,j)
(i,j)的距离. 求任意
s
,
t
∈
V
s, t\in V
s,t∈V两点之间的最短路.
给定任意两点
s
,
t
s, t
s,t, 最短路问题可以用Dijkstra算法在
O
(
m
+
n
log
n
)
O(m+n\log n)
O(m+nlogn)求解, 其中
m
=
∣
E
∣
,
n
=
∣
V
∣
m=|E|, n=|V|
m=∣E∣,n=∣V∣. 如果对所有的
(
s
,
t
)
∈
V
×
V
(s,t)\in V\times V
(s,t)∈V×V, 用Dijkstra算法求解每一个最短路问题, 总的时间复杂度问题
O
(
m
n
+
n
2
log
n
)
=
O
(
n
5
)
O(mn + n^2\log n) = O(n^5)
O(mn+n2logn)=O(n5). 下面我们用动态规划的算法在
O
(
n
4
)
O(n^4)
O(n4)内求解.
定义子问题
令 V k = { 1 , 2 , … , k } V_k = \{1, 2, \ldots, k\} Vk={1,2,…,k}. 令 P k ( u , v ) = { u , i 1 , … , i s , v } P_k(u,v) = \{u, i_1, \ldots, i_s, v\} Pk(u,v)={u,i1,…,is,v}代表 V k V_k Vk上的路(Path), 其中 i 1 , … , i s ∈ V k i_1, \ldots, i_s \in V_k i1,…,is∈Vk, 称为内部点(Interior points). 设 P k ∗ ( u , v ) P^*_k(u,v) Pk∗(u,v)代表 V k V_k Vk上的最短路, 因此原问题是要计算 P n ∗ ( u , v ) , ∀ ( u , v ) ∈ V × V P^*_n(u,v), \forall (u,v) \in V\times V Pn∗(u,v),∀(u,v)∈V×V.
最优解的结构
考虑一条最短路 P = { v 1 , . . . , v k } P = \{v_1, ..., v_k\} P={v1,...,vk}以及它的任意一条子路 P ′ = { v i , … , v j } P' = \{v_i, \ldots, v_j\} P′={vi,…,vj}, 那么 P ′ P' P′是从 v i v_i vi到 v j v_j vj的最短路.
证明. 假设存在一条比 P ′ P' P′更短的从 u u u到 v v v的路 P ′ ′ P'' P′′, 那么我们可以构造一条更短的从 v 1 v_1 v1到 v k v_k vk的路: 沿着 P P P从 v 1 v_1 v1出发, 然后沿着 P ′ ′ P'' P′′从 v i v_i vi到 v j v_j vj, 最后沿着 P P P到达 v k v_k vk(如下图所示). 与 P P P是最短路的假设矛盾.
递归式
令 d k ( u , v ) d_k(u,v) dk(u,v)代表 V k V_k Vk上从 u u u到 v v v的最短路距离. 为方便描述, 令 P P P代表对应的最短路. 考虑两种情况:
- k k k是 P P P的内部点. 把 P P P在 k k k处截断成条子路 P 1 , P 2 P_1, P_2 P1,P2, 根据前文 最优解的结构, 我们知道 P 1 P_1 P1和 P 2 P_2 P2都是最短路. 我们有 d k ( u , v ) = d k − 1 ( u , k ) + d k − 1 ( k , v ) d_k(u,v) = d_{k-1}(u,k) + d_{k-1}(k,v) dk(u,v)=dk−1(u,k)+dk−1(k,v).
- k k k不是 P P P的内部点. 换句话说 k k k不在最优解中, 因此 d k ( u , v ) = d k − 1 ( u , v ) d_{k}(u,v) = d_{k-1}(u,v) dk(u,v)=dk−1(u,v).
综上所述, 我们有
d
k
(
u
,
v
)
=
min
{
d
k
−
1
(
u
,
v
)
+
d
k
−
1
(
k
,
v
)
,
d
k
−
1
(
u
,
v
)
}
,
k
≥
1.
d_k(u,v) = \min \{d_{k-1}(u,v) + d_{k-1}(k,v), d_{k-1}(u,v)\}, \quad k\geq 1.
dk(u,v)=min{dk−1(u,v)+dk−1(k,v),dk−1(u,v)},k≥1.
初始条件
d 0 ( u , v ) = { c u , v , ( u , v ) ∈ E 0 , u = v ∞ , u ≠ v , ( u , v ) ∉ E . d_0(u,v) = \begin{cases} c_{u,v}, & (u,v) \in E \\ 0, & u=v \\ \infty, & u\neq v, (u,v)\not\in E. \end{cases} d0(u,v)=⎩⎪⎨⎪⎧cu,v,0,∞,(u,v)∈Eu=vu=v,(u,v)∈E.
时间复杂度
从两个角度来看: 1.宽度, 即根据子问题构造父问题的时间. 计算 min { ⋅ } \min\{\cdot\} min{⋅}的时间为 O ( 1 ) O(1) O(1), 一共要计算 V × V V\times V V×V个顶点对, 因此构造所有 d k ( u , v ) d_k(u,v) dk(u,v)的时间是 O ( n 2 ) O(n^2) O(n2); 2. 深度, 即递归的深度. k = 1 , 2 , … , n k=1, 2, \ldots, n k=1,2,…,n, 因此深度为 ( n ) (n) (n). 综上述所, 总的时间复杂度为 O ( n 2 ⋅ n ) = O ( n 3 ) O(n^2 \cdot n) = O(n^3) O(n2⋅n)=O(n3).
求解
根据上述递归式, 我们可以采用Top-down或Bottom-up的方式求解. (个人比较喜欢Bottom-up的方式, 原因是代码比较干净, 容易分析).
# Floyd-Warshall Algorithm
d(u,v) = d0(u,v), for all u,v in V # 初始化d0(u,v)
for k = 1 to n:
for (u,v) in V * V:
if d(u,v) > d(u,k) + d(k,v):
d(u,v) = d(u,k) + d(k, v)
构造最优解
令 P u , v ⊆ V P_{u,v}\subseteq V Pu,v⊆V代表 u u u到 v v v的最短路. 回顾递归式 d k ( u , v ) = min { d k − 1 ( u , v ) + d k − 1 ( k , v ) , d k − 1 ( u , v ) } d_k(u,v) = \min \{d_{k-1}(u,v) + d_{k-1}(k,v), d_{k-1}(u,v)\} dk(u,v)=min{dk−1(u,v)+dk−1(k,v),dk−1(u,v)}, 当 d k ( u , v ) = d k − 1 ( u , v ) + d k − 1 ( k , v ) d_k(u,v) = d_{k-1}(u,v) + d_{k-1}(k,v) dk(u,v)=dk−1(u,v)+dk−1(k,v)时, 说明 k ∈ P u , v k\in P_{u,v} k∈Pu,v. 因此 P ( u , v ) = P ( u , k ) ∪ P ( k , u ) P(u,v) = P(u,k) \cup P(k,u) P(u,v)=P(u,k)∪P(k,u).
# Floyd-Warshall Algorithm
d(u,v) = d0(u,v), for all u,v in V # 初始化d0(u,v)
P(u,v) = empty for all u,v in V # 初始化u,v之间的最短路的内部点
for k = 1 to n:
for (u,v) in V * V:
if d(u,v) > d(u,k) + d(k,v):
d(u,v) = d(u,k) + d(k, v)
# 根据递归式构造原问题的解
P(u,v) = P(u,k) + {k} + P(k,v)
总结
- 构造递归表达式是动态规划的核心. 递归式定义好之后, 算法实现和最优解的构造都是比较直观的.
- 定义递归式时需要考虑最优解的结构. 一般来说, 我们可以通过标准的反证法来证明结构的正确性. 特别需要注意的是: 不是所有的问题都可以用动态规划的方式求解. 以 最长路 为例, 上述最优解结构的证明还凑效吗? (留给读者思考)
Python实现
from copy import deepcopy
def shortest_paths(c):
""" 计算Graph中任意两点的最短路
:param c: 成本矩阵
* c[i][j] = infinity if (i,j) not in E
* c[i][j] = 0 if i = j
* c[i][j] = cost from i to j, if (i,j) in E
:return: i,j之间的最短路程以及最短路
"""
n = len(c)
d = deepcopy(c) # 初始化i和j之间的最短路程
paths = init_paths(n) # 初始化i和j之间的最短路
for k in range(n):
for i in range(n):
for j in range(n):
if d[i][j] > d[i][k] + d[k][j]:
d[i][j] = d[i][k] + d[k][j]
# 记录最短路
paths[i][j] = paths[i][k] + [k] + paths[k][j]
return d, paths
def init_paths(n):
""" 初始化任意两点的最短路为空list.
:param n: 顶点的个数
:return: n*n的list
"""
paths = [[]] * n
for i in range(n):
paths[i] = [[]] * n
return paths
最长公共子序列
在计算生物学中DNA可以表示成含4种氮碱基
{
A
,
C
,
G
,
T
}
\{A, C, G, T\}
{A,C,G,T}构成的序列. 考虑两个序列:
X
=
A
C
C
G
G
T
C
G
A
G
T
G
C
G
C
G
G
A
A
G
C
C
G
G
C
C
G
A
A
Y
=
G
T
C
G
T
T
C
G
G
A
A
T
G
C
C
G
T
T
G
C
T
C
T
G
T
A
A
A
\begin{aligned} & X = ACCGGTCGAGTGCGCGGAAGCCGGCCGAA \\ & Y = GTCGTTCGGAATGCCGTTGCTCTGTAAA \end{aligned}
X=ACCGGTCGAGTGCGCGGAAGCCGGCCGAAY=GTCGTTCGGAATGCCGTTGCTCTGTAAA
我们希望计算
X
X
X和
Y
Y
Y之间的相似性. 下面定义相似性: 给定两个字符串
s
,
s
′
s, s'
s,s′, 如果
s
s
s包含
s
′
s'
s′的所有字符且保持相同的前后顺序, 我们说
s
′
s'
s′是
s
s
s的子序列. 例如acdf是abcdeef的子序列. 我们用
X
X
X和
Y
Y
Y中最长的公共子序列的长度来衡量
X
X
X和
Y
Y
Y的相似性.
问题描述
给定两个字符串 X , Y X,Y X,Y. 计算 X , Y X,Y X,Y的最长公共子序列 Z Z Z.
定义子问题
- 令 X i = < x 1 , x 2 , … , x i > X_i = \left<x_1, x_2, \ldots, x_i \right> Xi=⟨x1,x2,…,xi⟩, Y j = < y 1 , y 2 , … , y j > Y_j = \left<y_1, y_2, \ldots, y_j\right> Yj=⟨y1,y2,…,yj⟩
- 假设 ∣ X ∣ = m , ∣ Y ∣ = n |X| = m , |Y|=n ∣X∣=m,∣Y∣=n, 目标是计算 X m X_m Xm和 Y n Y_n Yn的最长公共子序列
递归式
令 c i , j c_{i,j} ci,j代表 X i X_i Xi和 Y j Y_j Yj最长公共子序列的长度. 考虑两种情况:
- x i = y j x_i = y_j xi=yj. 那么 x i x_i xi(即 y j y_j yj)一定在公共子序列中. 因此, 通过计算 X i − 1 X_{i-1} Xi−1和 Y j − 1 Y_{j-1} Yj−1的最长公共子序列, 即可得到 X i X_i Xi和 Y j Y_j Yj的最长公共子序列. 我们有 c i , j = c i − 1 , j − 1 + 1 c_{i,j} = c_{i-1,j-1} + 1 ci,j=ci−1,j−1+1.
- x i ≠ y j x_i\neq y_j xi=yj. 那么 x i x_i xi或 y j y_j yj不在最长的公共子序列中. 因此 c i , j = max { c i − 1 , j , c i , j − 1 } c_{i,j} = \max\{c_{i-1,j}, c_{i,j-1}\} ci,j=max{ci−1,j,ci,j−1}
综上所述, 我们有
c
i
,
j
=
{
c
i
−
1
,
j
−
1
+
1
i
,
j
>
0
,
x
i
=
y
j
max
{
c
i
,
j
−
1
,
c
i
−
1
,
j
}
i
,
j
>
0
,
x
i
≠
x
j
0
i
=
0
or
j
=
0
c_{i,j} = \begin{cases} c_{i-1,j-1} + 1 & i,j>0, x_i = y_j \\ \max \{c_{i,j-1}, c_{i-1,j}\} & i, j > 0, x_i\neq x_j\\ 0 & i=0 \text{ or } j = 0 \end{cases}
ci,j=⎩⎪⎨⎪⎧ci−1,j−1+1max{ci,j−1,ci−1,j}0i,j>0,xi=yji,j>0,xi=xji=0 or j=0
Remark: 最优解的结构和时间复杂度分析留给读者.
构造最优解
在计算递归式的同时记录下标位置的变化, 用来追溯公共子序列. 回顾上面的递归式, 考虑三种情况:
- c i , j = c i − 1 , j − 1 + 1 c_{i,j} = c_{i-1, j-1} +1 ci,j=ci−1,j−1+1. 令 b i , j = ‘ ‘ ↖ " b_{i,j} = ``\nwarrow" bi,j=‘‘↖", 表示从 ( i , j ) (i,j) (i,j)向左上移动到 ( i − 1 , j − 1 ) (i-1, j-1) (i−1,j−1);
- c i , j = c i , j − 1 c_{i,j} = c_{i, j-1} ci,j=ci,j−1. 令 b i , j = ‘ ‘ ↑ " b_{i,j} =``\uparrow" bi,j=‘‘↑", 表示从 ( i , j ) (i,j) (i,j)向上移动到 ( i , j − 1 ) (i,j-1) (i,j−1);
- c i , j = c i − 1 , j c_{i,j} = c_{i-1, j} ci,j=ci−1,j. 令 b i , j = ‘ ‘ ← b_{i,j} =``\leftarrow bi,j=‘‘←, 表示从 ( i , j ) (i,j) (i,j)向左移动到 ( i − 1 , j ) (i-1, j) (i−1,j).
从
(
i
,
j
)
=
(
m
,
n
)
(i,j) = (m,n)
(i,j)=(m,n)开始, 按照
b
i
,
j
b_{i,j}
bi,j指示的方向判断
x
i
,
y
j
x_i,y_j
xi,yj是否相同, 如果相同则把
x
i
x_i
xi加入到公共子序列中(如下图所示).
图片来自经典教材算法导论第三版(Introduction to Algorithms, Third Edition).
Python实现
import numpy as np
def longest_common_subsequence(x, y):
""" 根据递归式计算最长公共子序列
"""
m, n = len(x), len(y)
# 把x,y中字符的下标向右移一位(方便计算)
x1 = ' ' + x
y1 = ' ' + y
# c[m][n]为最大公共子序列的长度
c = np.zeros((m+1, n+1)) # 初始化c
# 记录下标改变的路径(用字符串表示)
b = np.zeros((m+1, n+1)).tolist()
for i in range(1, m+1):
for j in range(1, n+1):
if x1[i] == y1[j]:
c[i][j] = c[i-1][j-1] + 1
b[i][j] = 'lu' # go left up
elif c[i-1][j] >= c[i][j-1]:
c[i][j] = c[i-1][j]
b[i][j] = 'u' # go up
else:
c[i][j] = c[i][j-1]
b[i][j] = 'l' # go left
return get_common_subsequence(x1, y1, b)
def get_common_subsequence(x1, y1, b):
""" 根据b还原最长公共子序列
"""
i, j = len(x1) - 1, len(y1) - 1
res = []
while i and j:
if x1[i] == y1[j]:
res.insert(0, x1[i])
if b[i][j] == 'lu': # go left up
i -= 1
j -= 1
elif b[i][j] == 'l': # go left
j -= 1
elif b[i][j] == 'u': # go up
i -= 1
return ''.join(res)