Dynamic Programming (NPTEL)
08 Mar 2014 CommentsNPTEL 讲的 Dynamic Programming 是我听过讲的最好的,给人一种看穿本质的感觉,这篇笔记对视频所讲内容做个总结。
DP 过程
视频里给出的用 DP 解决问题的过程包含以下 3 个步骤:
-
将问题描述为一个搜索问题,定义问题的 search space 和 objective function。比如对于 Longest Common Subsequence 问题,search space 就是所有的 common subsequence,objective function 是 longest。
-
给出解决这个问题的 recursive process。实现方法是将 search space 分割成若干个 subspace,然后在每个 subspace 中递归搜索,伪代码如下。
Search(S): divide S into S[1], S[2], ... S[N] for i in [1, N]: best[i] = Search(S[i]) return best of best[1, N]
-
将 recursive process 转化成 non-recursive process。这是通过观察每个 recursive call 的 argument 并将每个 recursive call 转换成查表操作实现。当然在查表之前,我们得先填表,在填表的每一项时,我们都假设填这一项依赖的其他项都已经填上 (这个假设如果不成立,就没法 DP 了)。
经过上述步骤就完成整个 DP 过程,最终的 non-recursive process 的 running time = O(table size * time to fill each entry),recursive process 的 running time 通常是 exponential 的,所以 DP 让不可能变成可能。
下面 3 个小节中给出 3 个具体的例子。
Longest Common Subsequence (LCS)
问题描述
给定两个字符串 A 和 B,找出这两个字符串的最长公共子串 C。
套用上述步骤,解决过程如下。
定义问题
search space | all common subsequence between A and B |
---|---|
objective function | longest subsequence |
Recursive Process
将 S 分成两个两部分。
S[1] | common subsequences starting with A[1] |
---|---|
S[2] | common subsequences not starting with A[1] |
根据这种空间划分法,可以定义如下 recursive process:
其实函数的 signature 写成 LCS(A[i:M], B[j:N])
是更准确的,之所以写成 LCS(A[1:M], B[1:N])
其实是故意的,是为了传递这么一个信息,就是 无论你从其他函数调用 LCS,还是在 LCS 内部递归调用 LCS,你都应该把 LCS 的参数看成是它自身的一个完整的整体,而不是原始 A 或者 B 的一部分 。从物理上讲,递归调用的 LCS 的参数确实是最开始调用的 LCS 参数的一部分,但是,逻辑上,你应该把它单独看成自身的一个整体,这种对输入参数的看法对于你想象递归过程是有好处的。(看完这 3 个例子会更明白这个意思)。
Non-recursive Process
观察上面 recursive call 的 argument,它包含这么几个信息:
- A
- B
- A 的开始位置
- A 的结束位置
- B 的开始位置
- B 的结束位置
其中每次调用发生变化的有两个:A 的开始位置和 B 的开始位置。
由此我们可以定义一个二维表 T:
其中 M+1, N+1 对应 A = null 和 B = null 的情况。
填表操作可以用如下代码表示:
Fill (i, j):
if i == M + 1 or j == N + 1:
return
/* search S1 */
cs1 = null
k = first index of A[i] in B[j:N]
if k != null:
cs1 = A[1] | T[i+1, k+1]
/* search S2 */
cs2 = T[i+1, j]
T[i, j] = longer of (cs1, cs2)
在填 T[i, j] 时,我们假设 T[i+1, k+1], T[i+1, j] 已经填上。为了保证这个假设在整个过程中都成立,我们必须按一定的顺序调用 Fill 函数,从上面的代码可以看出 T[i, j] 依赖项的索引中第一维都大于 i,第二维都大于 j,这样我们只要按 i, j 从大到小遍历即可,如下代码所示。
LCS(A[1:M], B[1:N]):
for i in [1, M+1]:
T[i][N+1] = null
for j in [1, N+1]:
T[M+1][j] = null
for i in [M, 1]:
for j in [N, 1]:
Fill(i, j)
return T[1, 1]
到此就完成了 recursion 到 non-recursion 的转换。也就实现了用 DP 解决 LCS 的想法。视频中给出了一个性能更好的 Fill 函数,这里就不给出了。
Knapsack
问题描述
给定 W[1:N], V[1:N],其中 W[i] 表示第 i 个 object 的 weight,V[i] 表示第 i 个 object 的 value。再给定 C,C 表示 knapsack 的容量限制,问题是要最大化装入 knapsack 的 object 的 value 总和。
定义问题
search space | all possible object combinations, which are of size $2^N$ |
---|---|
objective function | maximum value of object combination, under the constrain that the weight <= C |
Recursive Process
将 S 分成两个两部分。
S[1] | combinations not containing the first object |
---|---|
S[2] | combinations containing the first object |
Recursive process 可以表示为:
/* @i denotes the index of first object */
KS(W[1:N], V[1:N], N, C, i):
if C <= 0 || i > N:
return 0
/* search S1 */
v1 = KS(W, V, N, C, i+1)
/* search S2 */
v2 = 0
if W[i] <= C:
v2 = V[i] + KS(W, V, N, C-W[i], i+1)
return max(v1, v2)
Non-recursive Process
观察上面 recursive call 的 argument,它包含这么几个信息:
- W: weight array
- V: value array
- N: number of objects
- C: total capacity
- i: index of first object
其中变化的有两个:C 和 i。
由此我们可以定义一个二维表 T:
填表操作可以用如下代码表示:
Fill(W, V, c, i):
if c <= 0 || i > N:
T[c][i] = 0
return
v1 = T[c][i+1]
v2 = 0
if W[i] <= c:
v2 = V[i] + T[c-W[i]][i+1]
T[c][i] = max(v1, v2)
同样在写上面的 Fill 函数时,我们假设某些项已经被填上。项之间的依赖关系可以由下图表示,其中曲线箭头表示依赖关系。
根据项与项之间的依赖关系,我们应该按 i 从大到小,C 从小到大的顺序调用 Fill 函数。
KS(W, V, C, N):
for c = 0 to C:
T[c][N+1] = 0
for i = 1 to N+1:
T[0][i] = 0
for i = N to 1:
for c = 1 to C:
T[c][i] = Fill(W, V, c, i)
Matrix Multiplication
问题描述
给定 N 个 matrix,每个 matrix 的维度在 d[0:N] 数组中给出,其中第 i 个 matrix 的维度为 (d[i-1], d[i]),问题是要得到 N 个 matrix 相乘的代价最小的乘法。
定义问题
search space | all possible ways of matrix multiplication |
---|---|
objective function | minimum multiplication cost |
Recursive Process
按最后一次乘积发生的位置来分割 search space,这样共有 N-1 个 subspace。
| S[i] | subspace in which the last multiplication happens between the ith matrix and the (i+1)th one | |:–|:–|
Recursive process 可以表示为:
MM(d[0:N]):
if N == 1:
return 0
m = INF
for i = 1 to N-1:
c = MM(d[0:i]) + MM(d[i:N]) + d[0]*d[i]*d[N]
if c < m:
m = c
return m
Non-recursive Process
观察上面 recursive call 的 argument,它包含这么几个信息:
- d
- d 的开始位置
- d 的结束位置
其中变化的有两个:d 的开始位置和结束位置。
由此我们可以定义一个二维表 T:
填表操作可以用如下代码表示:
Fill(d[0:N], i, j):
if j - i == 1:
T[i][j] = 0
m = INF
for k = i + 1 to j - 1:
c = T[i:k] + T[k:j] + d[i]*d[k]*d[j]
if c < m:
m = c
T[i][j] = m
T[i][j]与表中其他项得依赖关系如下图所示。
其中蓝色的框表示 T[i][j],它依赖于绿色部分的项,另外注意到这个表是 half-full 的,对角线的下方是不需要的。这样调用 Fill 函数的过程如下面伪代码所示:
MM(d[0:N]):
fori = 0 to N-1:
T[i][i+1] = 0
for i = N-2 to 0:
for j = i+2 to N:
Fill(d, i, j)
return T[0][N]
心得
-
在第二步设计递归函数时将所有输入都作为函数参数,然后再看其中变化的是什么。
-
把表项之间的依赖关系用图画出来,同时也可以把 Fill 调用中 i,j 的变化用图画出来,这样一切都会变得清晰起来。
-
项之间的依赖不能是随意的,T[i, j] 依赖项的两个下标至少得有一维是只往一个方向发展的。
在学会上述招数之后, 你会发现以前觉得复杂的 DP 问题原来都是这么的简单。
UVA 做题感想
-
同一个问题可以有不同的 recursive process 的设计方法,比如 uva 10131 中在寻找 Longest decreasing sequence(LDS) 时,可以有两种设计 recursion 的方法:
- 一种是给定一个 array,这个 recursion 返回这个 array 的 LDS,然后在 recursion 中按是否包含 array 的第一个元素来 divide search space
- 另一种是给定一个 array,recursion 返回包含这个 array 的第一个元素的情况下的 LDS,这个时候,在最外层,你还需要一次循环得到 global optimum
对于这道题第二种方法的效率要高很多,具体参考代码 uva 10131 中的
DP()
和DP2()
函数。