动态规划(Dynamic Programming)

一、动态规划

动态规划(Dynamic Programming)是一种设计的技巧,是解决多阶段决策过程最优化问题的通用方法。

基本思想:将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解(这部分与分治法相似)。与分治法不同的是,适合于用动态规划求解的问题,经分解得到的子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。通常可以用一个来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划的基本思路。

采用动态规划求解的问题需要具有两个特性:

  • 最优子结构(Optimal Substructure):问题的一个最优解中所包含的子问题的解也是最优的。

  • 重叠子问题(Overlapping Subproblems):用递归算法对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。

问题具有最优子结构性质,我们才能写出最优解的递归方程;具有重叠子问题特性,我们才能通过避免重复计算来减少运行时间。

综上所述,动态规划的关键是 —— 记忆,空间换时间,不重复求解,从较小问题解逐步决策,构造较大问题的解。

二、最长公共子序列(LCS)问题

下面通过一个具体的例子来学习动态规划方法 —— 最长公共子序列问题。

*最长公共子串(Longest Common Substring)最长公共子序列(Longest Common Subsequence)的区别: 子串要求在原字符串中是连续的,而子序列则只需保持相对顺序,并不要求连续。*

问题描述:给定两个序列:X[1...m]Y[1...n],求在两个序列中同时出现的最长子序列的长度。

假设 X 和 Y 的序列如下:

1
2
X[1...m] = {A, B, C, B, D, A, B}
Y[1...n] = {B, D, C, A, B, A}

可以看出,X 和 Y 的最长公共子序列有 “BDAB”、“BCAB”、“BCBA”,即长度为4。

1) 穷举法

可能很多人会想到用穷举法来解决这个问题,即求出 X 中所有子序列,看 Y 中是否存在该子序列。

  • X 有多少子序列 —— $2^m$ 个
  • 检查一个子序列是否在 Y 中 —— θ(n)

所以穷举法在最坏情况下的时间复杂度是 $θ(n * 2^m)$,也就是说花费的时间是指数级的,这简直太慢了。

2) 动态规划

首先,我们来看看 LCS 问题是否具有动态规划问题的两个特性。

① 最优子结构

C[i,j] = |LCS(x[1...i],y[1...j])|,即C[i,j]表示序列X[1...i]Y[1...j]的最长公共子序列的长度,则 C[m,n] = |LCS(x,y)|就是问题的解。

递归推导式:

在这里就不证明了。从这个递归公式可以看出,问题具有最优子结构性质!

② 重叠子问题

根据上面的递归推导式,可以写出求LCS长度的递归伪代码:

1
2
3
4
5
LCS(x,y,i,j)
if x[i] = y[j]
then C[i,j] ← LCS(x,y,i-1,j-1)+1
else C[i,j] ← max{LCS(x,y,i-1,j),LCS(x,y,i,j-1)}
return C[i,j]

C++代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 简单的递归求解LCS问题
#include <iostream>
#include <string>
using namespace std;

int max(int a, int b)
{

return (a>b)? a:b;
}

// Return the length of LCS for X[0...m-1] and Y[0...n-1]
int lcs(string &X, string &Y, int m, int n)
{

if (m == 0 || n == 0)
return 0;
if (X[m-1] == Y[n-1])
return lcs(X, Y, m-1, n-1) + 1;
else
return max(lcs(X, Y, m, n-1), lcs(X, Y, m-1, n));
}

int main()
{

string X = "ABCBDAB";
string Y = "BDCABA";

cout << "The length of LCS is " << lcs(X, Y, X.length(), Y.length());
cout << endl;

getchar();
return 0;
}

像这样使用简单的递归,在最坏情况下(X 和 Y 的所有字符都不匹配,即LCS的长度为0)的时间复杂度为 θ(2^n)。这和穷举法一样还是指数级的,太慢了。

根据程序中 X 和 Y 的初始值,我们画出部分递归树:

递归树中红框标记的部分被调用了两次。如果画出完整的递归树,我们会看到很多重复的调用,所以这个问题具有重叠子问题的特性。

③ 动态规划求解

简单的递归之所以和穷举法一样慢,因为在递归过程中进行了大量的重复调用。而动态规划就是要解决这个问题,通过用一个表来保存子问题的结果,避免重复的计算,以空间换时间。前面我们已经证明,最长公共子序列问题具有动态规划所要求的两个特性,所以 LCS 问题可以用动态规划来求解。

下面是用动态规划(打表)解决LCS问题:

C++代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 动态规划求解LCS问题
#include <iostream>
#include <string>
#include <vector>
using namespace std;

int max(int a, int b)
{

return (a>b)? a:b;
}

/**
* 返回X[0...m-1]和Y[0...n-1]的LCS的长度
*/

int lcs(string &X, string &Y, int m, int n)
{

// 动态规划表,大小(m+1)*(n+1)
vector<vector<int>> table(m+1,vector<int>(n+1));

for(int i=0; i<m+1; ++i)
{
for(int j=0; j<n+1; ++j)
{
// 第一行和第一列置0
if (i == 0 || j == 0)
table[i][j] = 0;

else if(X[i-1] == Y[j-1])
table[i][j] = table[i-1][j-1] + 1;

else
table[i][j] = max(table[i-1][j], table[i][j-1]);
}
}

return table[m][n];
}

int main()
{

string X = "ABCBDAB";
string Y = "BDCABA";

cout << "The length of LCS is " << lcs(X, Y, X.length(), Y.length());
cout << endl;

getchar();
return 0;
}

容易看出,动态规划解决LCS问题的时间复杂度为 θ(mn),这比简单的递归实现要快多了。空间复杂度是θ(mn),因为使用了一个动态规划表。当然,空间复杂度还可以进行优化,即根据递推式我们可以只保存填下一个位置所用到的几个位置就行了。(关于如何输出LCS请看另一篇:《输出所有的最长公共子序列》)





总结:

动态规划将原来具有指数级时间复杂度的搜索算法改进成了具有多项式时间复杂度的算法。其中的关键在于解决冗余(重复计算),这是动态规划算法的根本目的。动态规划实质上是一种以空间换时间的技术,它在实现的过程中,不得不存储产生过程中的各种状态,所以它的空间复杂度要大于其它的算法。

从上面的例子中,我们可以总结动态规划解决最优化问题的一般步骤:

  1. 分析最优解的性质,并刻划其结构特征。
  2. 递归地定义最优值。
  3. 以自底向上的方式或自顶向下的记忆化方法计算出最优值。
  4. 根据计算最优值时得到的信息,构造一个最优解。

步骤(1)—(3)是动态规划算法的基本步骤。在只需要求出最优值的情形,步骤(4)可以省略,若需要求出问题的一个最优解,则必须执行步骤(4)。此时,在步骤(3)中计算最优值时,通常需记录更多的信息,以便在步骤(4)中,根据所记录的信息,快速地构造出一个最优解。

(全文完)



参考:
[1] www.algorithmist.com/index.php/Longest_Common_Subsequence
[2] www.geeksforgeeks.org/dynamic-programming-set-4-longest-common-subsequence/