算法导论学习——15章 动态规划
适用范围
动态规划通过求解组合子问题来解决原问题,与分治方法划分成不相交子问题并递归求解的方式不同,动态规划应用于子问题重叠的情况,并通过打表等方式存储已有结果供后续操作使用。
通常使用四个步骤设计一个动态规划算法:
- 刻画一个最优解的结构特征
- 递归定义最优解的值
- 计算最优解的值,通常采用自底向上的方法
- 通过计算出的信息构造一个最优解
一个例子
下面通过一个应用动态规划的例子来引入动态规划的原理
钢条切割问题
问题叙述
一个钢条加工公司可将长钢条切割成短钢条并销售,下表表示钢条长度与销售利润的对应关系。给出一个长钢条的长度 L L L,求可以得到的最大利润。
长度 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
价格 | 1 | 5 | 8 | 9 | 10 | 17 | 17 | 20 | 24 | 30 |
朴素的递归搜索算法
对于长为 L L L的钢条,一共有 2 L − 1 2^{L-1} 2L−1种切割方法,一个朴素的求解思路是:在可以切割的地方都进行切割或不切割的尝试,通过穷举搜索来得到最大的利润。求解代码如下。
int maxCut(int *p,int n) //p表示价格列表,n表示还有多长可供切割
{
if (n==0) //已经没有可切割的了
{
return 0;
}
int q=INT_MIN;
for (int i=1;i<=n;i++)
{
q=max(q,p[i]+maxCut(p,n-i)); //递归,在每个可以切割的位置搜索
}
return q;
}
该算法自顶向下,穷举了 2 n − 1 2^{n-1} 2n−1种切割方法,因此时间复杂度为 O ( 2 n ) O(2^n) O(2n),较为复杂,因此使用动态规划的算法求解。
按照动态规划的思想求解
按照动态规划的思想,采用自底向上的设计方法,当前的最优解由之前的最优解提供的信息得到。
最优解结构设计与实现
按照设计动态规划算法的四个一般步骤,确定最优解的结构,并递归定义:
- 设数组 O O O表示各长度钢条所得的最大利润, O [ n ] O[n] O[n]表示长度为 n n n的钢条可得的最大利润。
-
O
[
n
]
O[n]
O[n]由
O
[
n
−
l
i
]
O[n-l_i]
O[n−li]决定,
l
i
l_i
li表示长度小于
n
n
n的短钢条长度。即:一切长度的长钢条所获的最大利润都由长度更短的长钢条的最大利润得来。
根据这个思想,代码实现为:
int maxCut(vector<int> p,int L) // p表示利润列表,而L表示所要计算的长钢条的长度
{
if (L==0)
return 0;
int margin[L+1];
memset(margin,0,sizeof(margin));
margin[1]=p[0];
for (int i=2;i<=L;i++)
{
for (int j=1;j<=p.size()&&j<=i;j++)
{
margin[i]=max(margin[i],margin[i-j]+p[j-1]); // 当前最大利润由之前的最大利润得来
}
}
return margin[L]; //返回长度为L的钢条可能得到的最大利润
}
复杂度分析
首先,空间上,创建了长度为
L
+
1
L+1
L+1的数组,空间复杂度为
O
(
n
)
O(n)
O(n)。
时间上,经历了两重循环,外循环执行
L
−
1
L-1
L−1次,内循环执行
m
i
n
(
n
,
L
)
min(n,L)
min(n,L)次(
n
n
n为短钢条长度种数)。因此,时间复杂度为
O
(
n
2
)
O(n^2)
O(n2)。可见由于省略了很多递归搜索的部分,动态规划算法优于递归搜索算法。
自顶向下的设计方法
动态规划往往也存在自顶向下的设计方法,只需要将已经计算过的最优解存储下来,采用递归搜索的思想,搜索到一个状态时先查看当前状态是否已经求解,若已有最优解则直接使用,停止递归,否则继续搜索。通过历史信息提前终止不必要的递归,减小了时间复杂度。代码在此不作累述。
动态规划算法原理
什么样的问题适用于动态规划的解决方法呢? 使用动态规划解决的最优化问题应该具备两个要素:最优子结构和子问题重叠。
最优子结构
是用动态规划求解的第一步就是刻画最优解的结构。如果一个问题的最优解包含其子问题的最优解,则称此问题具有最优子结构性质。发掘最优子结构,就完成了动态规划的第一步。
在发掘最优子结构的过程中,实际上遵循了以下的通用模式:
- 证明问题最优解的第一个组成部分是做出一个选择。比如在钢条切割问题中,选择第一个切割位置。
- 对于一个给定问题,在其可能的第一步选择中,假定已经知道哪种选择会得到最优解。
- 给定可获得最优解的选择后,确定这次选择会产生哪些子问题。
- 作为构成原问题最优解的组成部分,每个子问题的解就是它本身的最优解。反证法证明:如果子问题的解不是自己的最优解,将自己的最优解作为子问题的解,可得到原问题的更优解,与假设相悖。
重叠子问题
子问题空间足够小,使得问题的递归算法不断求解相同的子问题,而不是一直生成新的子问题。这种性质称为重叠子问题性质。
当问题具有重叠子问题性质时,就可以把子问题最优解存储起来供后续问题求解使用。这样的操作大大降低了时间复杂度。
备忘
上文提到动态规划常使用自底向上的策略,先求出所有子问题的最优解再求原问题的最优解。同时动态规划也可以采用自顶向下的策略。采用递归的控制流程,将已求解问题的结果存储在表中,再次求解相同问题时查表即可。这种机制称为备忘机制。自底向上和自顶向下的策略时间复杂度数量级一致,相差一个常量系数。
举一反三
最长公共子序列LCS
给定两个字符串
S
1
S_1
S1,
S
2
S_2
S2,求两个字符串的最长公共子序列。
按照动态规划基本步骤求解,步骤如下:
- 刻画最长公共子序列的特征,找到最优子结构。
给定一个序列 X = < x 1 , x 2 , . . . , x m > X=<x_1,x_2,...,x_m> X=<x1,x2,...,xm>,定义 X X X的第 i i i前缀为 X i = < x 1 , x 2 , . . . , x i > X_i=<x_1,x_2,...,x_i> Xi=<x1,x2,...,xi>
令 X = < x 1 , x 2 , . . . , x m > X=<x_1,x_2,...,x_m> X=<x1,x2,...,xm>, Y = < y 1 , y 2 , . . . , y n > Y=<y_1,y_2,...,y_n> Y=<y1,y2,...,yn>, Z = < z 1 , z 2 , . . . , z k > Z=<z_1,z_2,...,z_k> Z=<z1,z2,...,zk>为 X , Y X,Y X,Y的任一LCS。- 如果 x m = y n x_m=y_n xm=yn,则 z k = x m = y n z_k=x_m=y_n zk=xm=yn且 Z k − 1 Z_{k-1} Zk−1是 X m − 1 , Y n − 1 X_{m-1},Y_{n-1} Xm−1,Yn−1的一个LCS
- 如果 x m ≠ y n x_m \neq y_n xm=yn则 z k ≠ x m z_k\neq x_m zk=xm, Z Z Z是 X m − 1 , Y X_{m-1},Y Xm−1,Y的一个LCS
- 如果
x
m
≠
y
n
x_m\neq y_n
xm=yn则
z
k
≠
y
n
z_k\neq y_n
zk=yn,
Z
Z
Z是
X
,
Y
n
−
1
X,Y_{n-1}
X,Yn−1的一个LCS
根据上式可知,对于所有可能的三种情况,两个序列的LCS都包含子问题的LCS,LCS问题具有最优子结构性质
- 构造递归解
根据上文分析,使用二维数组 c c c存储LCS长度, c [ i ] [ j ] c[i][j] c[i][j]表示 X i X_i Xi与 Y j Y_j Yj的LCS长度。- c [ i ] [ j ] = 0 , i = 0 o r j = 0 c[i][j]=0, i=0~ or j=0 c[i][j]=0,i=0 orj=0
- c [ i ] [ j ] = c [ i − 1 ] [ j − 1 ] + 1 , x i = y j c[i][j]=c[i-1][j-1]+1, x_i=y_j c[i][j]=c[i−1][j−1]+1,xi=yj
- c [ i ] [ j ] = m a x ( c [ i − 1 ] [ j ] , c [ i ] [ j − 1 ] ) , x i ≠ y + j c[i][j]=max(c[i-1][j],c[i][j-1]), x_i\neq y+j c[i][j]=max(c[i−1][j],c[i][j−1]),xi=y+j
- 计算LCS长度
根据递归解定义, c [ i ] [ j ] c[i][j] c[i][j]表示 X i X_i Xi和 Y j Y_j Yj的LCS长度,因此 X X X和 Y Y Y的LCS长度为 c [ i ] [ j ] c[i][j] c[i][j] - 构造LCS
在计算 c [ i ] [ j ] c[i][j] c[i][j]的过程中,可以使用二维数据 b b b存储每一步采用的子问题,根据 b b b数组即可回溯决策过程,找出LCS。 b b b的构造如下图所示
代码实现
void LCS(string s1, string s2, int **c, int **b)
{
int l1=s1.length();
int l2=s2.length();
for (int i=0;i<l1;i++)
{
if (s1[i]==s2[0])
c[i][0]=1;
}
for (int i=0;i<l2;i++)
{
if (s2[i]==s1[0])
c[0][i]=1;
}
for (int i=1;i<l1;i++)
{
for (int j=1;j<l2;j++)
{
if (s1[i]==s2[j])
{
c[i][j]=c[i-1][j-1]+1;
b[i][j]=1; // 1表示指向左上方
}
else
{
if (c[i-1][j]>=c[i][j-1])
{
c[i][j]=c[i-1][j];
b[i][j]=2; //2表示向上
}
else
{
c[i][j]=c[i][j-1];
b[i][j]=3; //3表示向左
}
}
}
}
}