动态规划(dynamic programming,这里的programming不是程序,而是表示表格)。它与分治算法类似,都是通过组合子问题的解来求解原问题。分治算法是将原问题分解为互不相交的子问题,递归的求解子问题,然后将解组合起来。
动态规划则不同,它应用于求解子问题重叠的情况,也就是不同的子问题会涉及相同的子子问题。这样,普通的递归方法会反复的求解那些公共子问题,因而浪费了时间,动态规划则是对公共子问题只求解一次,然后将其解保存在表格中,避免了不必要的重复工作。
动态规划通常用来解决最优化问题,这类问题通常有很多可行解,每个解法都有一个值,希望找到具有最优值(最小值或者最大值)的解。这样的解为一个最优解,有可能会有多个解都能得到最优值。
设计动态规划算法的步骤:
a:描述一个最优解的结构特征;
b:递归定义最优解的值;
c:计算最优解的值,通常采用自底向上的方式计算最优解的值;
d:利用计算出的信息构造一个最优解。
第1-3步是动态规划求解问题的基础。如果仅需要一个最优解的值,而非最优解本身,则第4步可以忽略。如果需要求得一个最优解,则有时要在第3步的计算中记录一些附加信息,以便用来构造一个最优解。
一:钢条切割问题
给定一个长度为n的钢条,以及一个价格表p,p中列出了每英寸钢条的价格,将长度为n的钢条切割为若干短钢条出售,求一个钢条的切割方案,使得收益 最大,切割工序没有成本。比如价格表p如下:
在该问题中,长度为n的钢条,一共有种不同的切割方案,因为可以再距离钢条左边为i(i=1,2,…,n-1)处,选择切割或者不切割。(类似于一个二进制数),比如下图表示了n=4的切割情况:
在该问题中,对于最优解 , 可以用更短的钢条的最优解来描述:将钢条从左边切割下长度为i的一段,只对剩下的n-i的一段进行继续切割(递归求解),而不对左边长度为i的一段在进行切割。这样对的求解可用下面的公式表示:
=
这样,原问题的最优解就表示成了子问题的最优解的形式。
为了求解规模为n的原问题,可以先求解形式完全一样,但规模更小的子问题,这样,钢条切割问题满足最优子结构:问题的最优解由相关子问题的最优解组合而成,这些子问题可以独立求解。
根据上面的公式,可以写出原始的切割方案:
CUT-ROD(p,n)
if n == 0
return 0
q = -∞
for i = 1 to n
q = max(q, p[i]+CUT-ROD(p, n-i))
return q
CUT-ROD的效率很差,这是因为CUT-ROD反复的求解一些相同的子问题,下图显示了当n==4时的调用情况:
下图表示了实际程序运行时,调用cutrod的情况,当n=4时,总共调用cutrod 16次,其中求解相同的子问题若干次:
分析CUT-ROD的运行时间,设T(n)表示第二个参数值为n时,CUT-ROD的调用次数。这个值等于递归调用树中,根为n的子树中的节点总数,T(0)=1。并且:
T(n) = 1 + 。根据数学归纳法可证明:T(n) = 。在递归调用树中,总共有个叶子节点,根到每个叶节点的路径都对应一种可能的切割方案。
可以使用动态规划的方法求解,CUT-ROD的效率低是因为它反复求解相同的子问题,动态规划方法是仔细安排求解循序,对每个子问题只求解一次,并将子问题的结果都保存下来,如果随后在此需要子问题的解,直接取值而不用再次计算。所以,动态规划方法是典型的时空权衡的例子,是用空间换时间。时间上的节省是巨大的,可能将一个指数时间转化为一个多项式时间。
动态规划方法有两种等价的实现方法,两种方法具有相同的渐进时间,仅有的差异是某些特殊情况下,自顶向下的方法没有真正递归考察所有可能的子问题,因为没有使用递归,所以自底向上的方法的时间复杂度函数通常具有更小的系数。
a:带备忘的自顶向下方法,该方法与之前的普通递归方法类似,只是会在过程中保存子问题的解,当需要一个子问题的解的时候,先查看是否已经保存过了,如果是,则直接使用即可。否则,按常规的递归方式计算子问题。所以称为带备忘的,因为它记住了之前已经计算出的结果。
MEMOIZED-CUT-ROD(p,n)
let r[0..n] be a new array
for i = 0 to n
r[i]= -∞
return MEMOIZED-CUT-ROD-AUX(p, n, r)
MEMOIZED-CUT-ROD-AUX(p,n, r)
if r[n] >= 0
return r[n]
if n == 0
q= 0
else q = -∞
for i = 1 to n
q= max(q, p[i]+MEMOIZED-CUT-ROD-AUX(p, n-i,r))
r[n] = q
return q
在带备忘的自顶向下的方法中,首先检查所需的值是否已知。
b:自底向上的方法:这种方法要恰当定义子问题的规模,使得任意子问题的求解只依赖于“更小的“子问题解。所以通常按照规模从小到大的顺序进行求解。当求救某个子问题时,它所依赖的更小的子问题的解都已经求解完毕了。
BOTTOM-UP-CUT-ROD(p,n)
let r[0..n] be a new array
r[0]= 0
for j = 1 to n
q= -∞
for i = 1 to j
q = max(q, p[i]+r[j-i])
r[j]= q
return r[n]
这两种算法具有相同的时间复杂度,BOTTOM-UP-CUT-ROD主要是双层嵌套循环,所以时间复杂度为Θ( )。MEMOIZED-CUT-ROD的时间复杂度也是Θ( )。可以使用子问题图进行分析。
当思考一个动态规划问题时,应该弄清楚子问题以及子问题之间的依赖关系。子问题图可以准确表达这些信息,比如下图反映了n=4时钢条切割问题的子问题图:
每个顶点对应一个子问题,如果求解子问题x的最优解时需要用到子问题y的最优解,那么图中会有一条x指向y的有向边。这个图可以看成是递归调用树的简化版,因为递归调用树中,相同子问题的结点合并为图中的单一顶点。
子问题图可以帮助我们确定动态规划算法的运行时间,由于每个子问题只求解一次,所以算法运行时间等于每个子问题求解时间之和。通常一个子问题的求解时间与图中该顶点的出度成正比,而子问题的数目等于图中的顶点数。所以,通常情况下,动态规划算法的运行时间与顶点和边的数量呈线性关系。
前面只给出了钢条切割问题的最优解的值(最大收益),并没有切得最优解,也就是最佳切割方案,所以,可以简单的扩展算法,得到最优解:
EXTENDED-BOTTOM-UP-CUT-ROD(p,n)
let r[0..n] and s[0..n] be new arrays
r[0]= 0
for j =1 to n
q= -∞
for i = 1 to j
if q < p[i]+r[j-i]
q = p[i]+r[j-i]
s[j] = i
r[j]= q
return r[n]
PRINT-CUT-ROD-SOLUTION(p,n)
(r,s)= EXTENDED-BOTTOM-UP-CUT-ROD(p, n)
while n > 0
print s[n]
n = n-s[n]
该方法通过数组s保存切割方案。
二:矩阵链乘法
给定n 个矩阵的序列,希望求它们的乘积: 。因为矩阵的乘法满足结合律,所以可以对n个矩阵序列加括号,来改变乘积顺序。比如对于矩阵链< , ,>可以有下面的加括号方案:
不同的加括号的方案,对于乘积运算的代价影响很大,两个矩阵相乘,A为p q矩阵,B为q r矩阵。所以A 的乘法次数为pqr。如果A(10 ),B(100 ), C(5 )三个矩阵相乘,如果按照((AB)C)的顺序,则需要10*100*5 + 10*5*50 = 7500次乘法运算,如果按照(A(BC))的顺序,则需要100*5*50 + 10*100*50 = 75000次乘法运算。所以,不同的加括号方案,对于矩阵链乘法的代价影响很大。
矩阵链乘法问题:给定n个矩阵的链( ),矩阵 的规模为 。求加括号方案,使得所需要的乘法运算次数最少。求解矩阵链乘法问题并不是要真正的进行矩阵乘积运算,我们的目标只是确定代价最小的计算顺序。
对于一个n个矩阵的链,假设P(n)表示可供选择的加括号方案的数量。当n=1时,P(1)=1,当n >= 2时,n个矩阵的相乘可以表示为两个子矩阵链相乘,而两个子矩阵链的分界点k可以为1,2,…,n-1。所以,得到下面的递归式:
通过数学归纳法,可证明P(n) =Ω()。所以,可选的加括号方案是n的指数函数。采用普通的递归方法求解,代码如下:
normal_matrixchain(p, psize, i, j)
if(i == j)
{
return 0;
}
for(k = i;k < j; k++)
{
resik= normal_matrixchain(p, psize, i, k);
reskj= normal_matrixchain(p, psize, k+1, j);
res= resik + reskj + p[i-1]*p[k]*p[j];
q =min(q, res);
}
return q;
该算法采用最普通的递归求解方案,如果n=6,则normal_matrixchain一共被调用243次,许多相同的子问题被调用多次:
下图表示了调用normal_matrixchain(p, psize, 1, 4)所产生的递归调用树,可以看见某些子问题出现了很多次:
用代入法可以证明,该算法的运行时间为T(n) =Ω()。所以是n的指数函数。
所以,该问题与钢条切割问题类似,可以采用动态规划的方法求解:
1:刻画一个最优解的结构特征
动态规划方法的第一步是寻找最优子结构,然后就利用这种子结构从子问题的最优解中构造原问题的最优解。假设表示矩阵 的结果。为了对 进行加括号,需要找到一个k,首先计算,然后计算 ,然后计算他们的乘积,最终求得 。这个方案的代价是求 的代价,加上求 的代价,然后再加上 的代价。
如果在 的最优加括号方案,分割点为k,那么同样也必须是最优的加括号方案,同理,也必须有最优的加括号方案。所以,该问题同样具有最优子结构性质。任何最优解都是由子问题的最优解构成的。所以,为了求得原问题的最优解,可以先求出子问题的最优解,也就是需要先找到分割点k,在确定k时,必须保证考察了所有的分割点。
2:一个递归求解方案
令m[i,j]表示计算 的乘积,所需乘法操作的最小值,所以,对于i = 1,2,…,n。m[i,i] = 0。对于i < j,假设已经知道了分界点k,所以有:
m[i,j] = m[i,k] + m[k+1, j] + 。因为k有j-i种取值,必须检查所有可能的情况,才能最终确定k,所以有:
m[i,j]给出了子问题的最小代价,但是没有提供最优解的信息,所以可以用s[i,j]记录分界点k的信息。从而可以构造最优解。
3:计算最小代价
普通的递归方法重复求解很多相同的子问题,所以动态规划需要记住已求得子问题的解。动态规划的标志就是:子问题重叠和最优子结构。每一对满足1<= i <= j <= n的i和j,对应于唯一的子问题,所以子问题数一共有Θ()个。
自底向上的方法:
bottom_matrixchain(p, n)
for(i = 0;i < n; i++)
{
for(j= 0; j < n; j++)
{
if(i== j)
{
m[i][j]= 0; //初始化m[i][i]
}
}
}
for(l = 2;l <= n; l++) //l表示矩阵链的长度
{
for(i= 1; i <= n-l+1; i++) //i表示起点
{
j= i+l-1; //j表示终点
q= INFINITE;
lastq= q;
for(k= i; k < j; k++) //k表示分界点
{
tempres= m[i][k] + m[k+1][j] + p[i-1]*p[k]*p[j];
q= min(q, tempres);
if(lastq!= q)
{
lastq= q;
kindex= k;
}
totalcall++;
}
m[i][j]= q;
s[i][j]= kindex;
}
}
带备忘的自顶向下的方法,该方法与普通递归算法类似,只是加入了备忘机制:
top_matrixchain(p, psize, i, j)
if(m[i][j] != -1)
{
return m[i][j];
}
if(i == j)
{
m[i][j]= 0;
return 0;
}
for(k = i; k < j; k++)
{
resik = top_matrixchain(p, psize, i, k);
reskj = top_matrixchain(p, psize, k+1, j);
res = resik + reskj + p[i-1]*p[k]*p[j];
q =min(q, res);
if(lastq != q)
{
lastq= q;
kindex= k;
}
}
m[i][j] = q;
s[i][j] = kindex;
算法的运行时间为O( )
4:构造最优解
s记录了求 过程中,最优解的分界点k的信息,所以,利用s可以得到最优解:
void printmatrixchain(s, i, j)
{
if(i == j)
{
printf("A%d", i);
}
else
{
printf("(");
printmatrixchain(s, i, s[i][j]);
printmatrixchain(s,s[i][j]+1, j);
printf(")");
}
}
三:动态规划原理
适合应用动态规划方法求解的最优化问题,应该具备两个要素:最优子结构和子问题重叠。
1:最优子结构
用动态规划方法求解最优化问题的第一步就是刻画最优解的结构,如果一个问题的最优解包含其子问题的最优解,就称此问题具有最优子结构性质。某个问题是否能用动态规划方法解决,是否具有最优子结构是关键因素,当然具有最优子结构的性质也适合贪心策略。
使用动态规划方法时,用子问题的最优解来构造原问题的最优解,因此,必须小心确保考察了最优解中用到的所有子问题。
在求解最优解的过程中,需要作出选择(比如钢条切割问题中的切割点,矩阵链中的分界点),这个选择产生出若干子问题。作为构成原问题最优解的组成部分,每个子问题的解也是最优解。(可用反证法证明)
对于不同的问题领域,最优子结构的不同体现在:原问题的最优解涉及多少个子问题,以及在确定最优解使用哪些子问题时,需要观察多少种选择。
可以用子问题的总数,以及每个子问题需要考察多少种选择,这两个因素的乘积错略分析动态规划算法的运行时间。比如钢条切割问题,总共有Θ(n)个子问题,每个子问题最多需要考察n种选择,因此运行时间为O( )。矩阵链乘法问题中,总共有Θ( )个子问题,每个子问题最多需要考察n-1种选择,所以运行时间为O( )。
也可用子问题图进行同样的分析,图中每个顶点代表一个子问题,每个顶点的边表示该子问题需要考察的选择。
贪心算法与动态规划有类似之处,他们之间最大的区别在于:贪心算法并不是寻找子问题的最优解,然后在其中进行选择,而是首先做出“贪心”选择—在当时(局部)看来最优的选择。然后求解出该子问题。因而不必费心求解所有可能相关的子问题。
在尝试使用动态规划时要注意问题是否具有最优子结构性质,比如下面的问题:
无权最短路径:找到一条从u到v的边数最少的简单路径。
无权最长路径:找到一条从u到v的边数最多的最短路径。
上述的两个问题,无权最短路径具有最优子结构性质,考虑从u到v的路径中的中间节点w,如果u->v的路径是最短的,那么u->w和w->v的路径也是最短的。所以,可以考察所有中间顶点w来求u到v的最短路径。每个中间节点w,求u到w和w到v的最短路径,然后选择两条路京之和最短的顶点w。
无权最长路径不具有最优子结构性质,比如下图,考虑路径q->r->t,它是从q到t的最长简单路径,但是q->r并不是从q到r的最长路径,而是q->s->t-r。所以该问题不具有最优子结构性质。
无权最长路径之所以不具有最优子结构性质,是因为该问题的两个子问题,他们是相关的,而最短路径中的两个子问题,它们是无关的。无关的意思是,同一个原问题的一个子问题的解,不影响另一个子问题的解。在无权最长路径的问题中,找出从q到t的最长简单路径的问题有两个问题:找出从q到r及从r到t的最长简单路径。对第一个子问题,我们选择路径q->s->t-r,因此使用了顶点s和t。在第二个子问题中就不能再使用这些顶点了,因为合并两个子问题的解会得到一个非简单的路径。然后求第二个问题又不能不用到顶点t。
钢条切割问题和矩阵链乘积问题都具有子问题无关性,钢条切割问题只涉及一个子问题,所以无关,矩阵链乘积涉及的两个子问题时独立的。
2:重叠子问题
适合用动态规划方法解决的最优化问题应该具备的第二个性质是子问题重叠性质,也就是问题的递归算法会反复求解相同的子问题。钢条切割问题和矩阵乘积问题都会有子问题重叠的性质。
利用子问题重叠的性质,可以再自顶向下的递归算法中,加入备忘机制,记录已经求解的子问题,这样,再次遇到事就不必重复计算了;或者采用自底向上的方法,从最小的子问题开始计算,这样在求解某个子问题时,它所依赖的子子问题已经知道答案了。
通常情况下,如果所有的子问题都至少要被计算一次,则一个自底向上的动态规划算法通常要比一个自顶向下的备忘算法好出一个常数因子,因为前者无需递归的代价。或者,如果子问题空间中的某些子问题根本没有必要求解。自顶向下的备忘方法就会体现出优势了,因为它只会求解哪些绝对必要的子问题。
四:最长公共子序列
一个给定序列的子序列,就是将给定序列中0个或者多个元素去掉之后得到的结果。不要求子序列在原序列中连续,比如如果序列X = {A, B, C, B, D, A, B},则Z = {B, C, D, B}就是X的子序列。
给定两个序列X和Y,如果Z即是X的子序列,又是Y的子序列,则称Z为X和Y的公共子序列。求给定两个序列的最长公共子序列问题,成为LCS问题。
如果X有m个元素,那么X有个子序列(类似于有m位的二进制数)。如果采用暴力搜索的方法,考察X的所有子序列,然后再看该子序列是否为Y的子序列。则这种方法需要指数时间。
前缀的定义如下:给定序列X = { },定义X的第i前缀为 = { },LCS有下面的性质:
如果X = { },Y = {}。Z = {}为X和Y的任意LCS,则有:
根据上述性质,LCS问题具有最优子结构的性质。原问题的最优解可以表示为子问题的最优解。定义C[i,j]表示和 的LCS的长度,根据上述性质,可以得到下面的递归式:
根据这个递归式,可以容易的写出LCS问题的普通递归算法:
normal_LCS(A, B, Asize, Bsize)
if(Asize== 0 || Bsize == 0)
{
return 0;
}
if(A[Asize-1] == B[Bsize-1])
{
return normal_LCS(A, B, Asize-1, Bsize-1)+1;
}
else
{
len1= normal_LCS(A, B, Asize-1, Bsize);
len2= normal_LCS(A, B, Asize, Bsize-1);
return max(len1, len2);
}
该算法的时间复杂度为指数级别。这个算法,如果对于A = "abcbdab"; B ="bdcaba";来说,该方法需要调用normal_LCS的次数为152,其中也涉及到许多重复子问题,如下图所示:
在上述递归式中,根据条件选择不同的子问题,而不是考察所有的子问题。LCS问题有(mn)个子问题。每个子问题只涉及常数( 1 or 2)个选择。所以采用动态规划方法,时间复杂度可以为(mn)。
1:带备忘的自顶向下方法,类似于普通递归方法,只是加入了备忘机制C[i][j],算法如下:
top_LCS(A, B, Asize, Bsize)
if(c[Asize][Bsize]>= 0)
{
return c[Asize][Bsize];
}
if(Asize == 0 || Bsize == 0)
{
c[Asize][Bsize] = 0;
return 0;
}
if(A[Asize-1] == B[Bsize-1])
{
c[Asize][Bsize] = top_LCS(A, B, Asize-1, Bsize-1)+1;
}
else
{
len1= top_LCS(A, B, Asize-1, Bsize);
len2= top_LCS(A, B, Asize, Bsize-1);
c[Asize][Bsize] = max(len1, len2);
}
return c[Asize][Bsize];
该算法的运行时间为 (mn)。这个算法,如果对于A = "abcbdab"; B ="bdcaba";来说,该方法需要调用top_LCS的次数为42。
2:自底向上方法,在该方法中,加入数组b[][]来记录每次的选择,以便能够重构LCS。算法如下:
bottom_LCS(A, B, Asize, Bsize)
for(i = 0;i <= Asize; i++)
{
for(j= 0; j <= Bsize; j++)
{
if(i== 0 || j == 0)c[i][j] = 0;
}
}
for(i = 1;i <= Asize; i++)
{
for(j= 1; j <= Bsize; j++)
{
if(A[i-1] == B[j-1])
{
c[i][j] = c[i-1][j-1] + 1;
b[i][j] = '.';
}
else
{
if(c[i-1][j] > c[i][j-1])
{
b[i][j]= '^';
}
else
{
b[i][j]= '<';
}
c[i][j]= max(c[i-1][j], c[i][j-1]);
}
}
}
该算法的运行时间为 (mn)。这个算法,对于对于A = "abcbdab"; B ="bdcaba";来说,可形成如下的表:
重构的时候,从b[m][n]开始,根据b[m][n]的取值不同,沿着箭头的方向跟踪,当b[m][n] =‘.’时,表明X[m]==Y[n]是LCS的一个元素。如果是‘^’,则查看b[m-1][n];如果是‘<’,则查看b[m][n-1]。重构算法如下:
printlcs (b, Asize, Bsize)
if(Asize ==0 || Bsize == 0) return;
if(b[Asize][Bsize] == '.')
{
printlcs(b, Asize-1, Bsize-1);
printf("%c", A[Asize-1]);
}
else if(b[Asize][Bsize] == '^')
{
printlcs(b,Asize-1, Bsize);
}
else
{
printlcs(b,Asize, Bsize-1);
}
该算法的时间为O(m+n)。
一旦设计出某个算法之后,常常可以在时间或空间上对该算法作些改进。对直观的动态规划算法来说尤其如此。有些改变可以简化代码并改进一些常数因子。但并不会带来算法性能方面的渐近改善。其他一些改变则可以在时间和空间上有相当大的渐近节省。
比如对于LCS问题,可以将b数组省去,因为c[i][j]只依赖于c[i-1][j-1],c[i][j-1],c[i-1][j]三项。所以,可以很快的判断具体依赖于哪一项,确定好依赖于哪一项后,就可以用类似printLCS的算法来重构LCS。这样就节省了(mn)的空间。
如果只求LCS的长度,而不要求重构,则只需要c数组中两行即可,就是当前计算的一行,以及前一行。这也是优化的措施之一。
五:最优二叉搜索树
考虑实现英语文本翻译为法语的问题,可以创建一颗二叉搜索树,将n个英语单词作为关键字,对应的法语作为关联数据。由于对文本的每个单词都要进行搜索,希望花费在搜索上的总时间尽量少。通过使用红黑树或者其他平衡搜索树,可以使每次搜索的时间为O(lg n),但是每个单词出现的频率不同,所以希望文本中出现频率高的单词出现在根附近的位置,而且,还有一些单词没有对应的法语,这些单词不应该出现在二叉搜索树中。
上面的问题可以归结为最优二叉搜索树问题:给定n个不同关键字已经排序的序列K= < >( < )。我们想从这些关键字中构造一棵二叉搜索树。对每个关键字 ,都有一个概率表示其搜索频率。有些搜索的值可能不在K内,因此还有n+1个伪关键字,,…,表示不在K内的值。代表所有小于的值,代表所有大于的值, 表示所有在和之间的值。对每个伪关键字 ,也有一个频率 表示对应的搜索频率。每个关键字是一个内部节点,而为叶子节点。每次搜索要么成功(找到关键字 ),要么失败(找到关键字 )。所以有: + = 1。
假设一次搜索的实际代价为检查的结点个数,也就是,在T内搜索所发现的结点的深度加1。所以在二叉搜索树中的搜索代价为:
E = +
= 1 + +
对于一个给定的概率集合,希望构造一颗具有最小搜索代价的二叉搜索树,这就是最优二叉搜索树问题。最优二叉搜索树不一定是高度最矮的,概率最大的节点也不一定是根节点。比如:如果给定概率为:
可以构造出二叉搜索树如下,其中b是最优二叉搜索树:
与矩阵链乘法问题类似,如果要穷举所有可能的二叉搜索树,则不是一个高效的算法,n个节点的二叉搜索树数量为Ω()。考虑使用动态规划方法。
1:最优子结构
考虑最优二叉搜索树的子问题:如果一颗最优二叉搜索树T有一颗包含关键字 ,1 <= j <= n,而且其叶子节点为: 的子树T1,那么子树T1对于关键字 和 的子问题也必定是最优的。因而该问题具有最优子结构性质。
可以用子问题的解来表示原问题的解。给定关键字序列 ,其中某个关键字(i <= r <= j)是最优子树的根节点,那么左子树就包含关键字 ,右子树就包含 。所以,可以检查所有的节点(i <= r <= j),最终求得子问题的最优解。
2:递归算法
最优二叉搜索树的子问题为:求解包含关键字 ,1<= i, j <= n且j >= i-1。(当j=i-1时,表示子树不包含任何关键字,只包含伪关键字 )。定义e[i,j]为子问题的最小搜索代价,所以,希望求得e[1,n]。
当j=i-1时,子树只包含 ,所以e[i,j] = 。
当j >= i 时,如果已经选定了根节点 ,那么左子树就包含关键字 ,右子树就包含。当一颗子树作为一个节点的左或右子树时,原子树所有节点的深度都加1,所以原子树的搜索代价增加了,根据公式:
E = 1 + +,所以,成为子子树之后,搜索代价为: = 1 + + = E ++。
所以搜索代价增加了所有节点的概率之和。记w[i, j] =+ 表示所有节点的概率之和。所以,如果为根节点,那么有:
e[i, j] = (e[i,r-1]+ w[i, r-1]) + (e[r+1, j] + w[r+1, j]) = e[i,r-1] + e[r+1, j] + w[i, j]。
这个公式是在假定已知为根节点的情况下的最小搜索代价,所以,可以遍历所有r,得到下面的公式:
所以,得到普通的递归算法如下:
normal_optimalbst(i, j)
if(j == i-1)
{
return q[j];
}
temp = ∞;
for(k = i; k <= j; k++)
{
res1= normal_optimalbst(i, k-1);
res2= normal_optimalbst(k+1, j);
temp = min(temp, res1+res2+w[i][j]);
}
return temp;
对于上面的例子,得到的结果为2.75,总共调用normal_optimalbst 243次,而且涉及到重复调用子问题的情况,如下图:
带备忘自顶向下的算法如下:
top_optimalbst(i, j)
if(e[i][j] > 0)
{
return e[i][j];
}
if(j == i-1)
{
e[i][j] = q[j];
return q[j];
}
temp = ∞;
for(k = i; k <= j; k++)
{
res1= top_optimalbst(i, k-1);
res2= top_optimalbst(k+1, j);
temp= min(temp, res1+res2+w[i][j]);
}
e[i][j] =temp;
return e[i][j];
该算法总共调用top_optimalbst 71次。
其中,w的取值算法如下:
void getW()
{
int i,j;
for(i = 1;i <= N; i++)
{
for(j= i-1; j <= N; j++)
{
if(j == i-1) w[i][j] = q[j];
else
{
w[i][j]= w[i][j-1] + q[j] + p[j];
}
}
}
}
自底向上的方法如下:
bottom_optimalbst
for(i = 1; i <= N+1; i++)
{
e[i][i-1]= q[i-1];
w[i][i-1]= q[i-1];
}
for(l = 1; l <= N; l++)
{
for(i= 1; i <= N-l+1; i++)
{
j= i+l-1;
w[i][j] = w[i][j-1] + p[j] + q[j];
temp= ∞;
for(k = i; k <= j; k++)
{
res= (e[i][k-1] + e[k+1][j] + w[i][j]);
if(temp > res)
{
temp = res;
index = k;
}
}
e[i][j] = temp;
root[i][j] = index;
}
}
return e[1][N];
该算法类似于矩阵链乘法的算法,并且用root数组保存了每一步选取的根节点,所以可以重构最优解,重构算法如下:
printinfo(int i, int j)
{
int k;
int leftc, rightc;
k = root[i][j];
if(i == 1&& j == N)
{
printf("K%dis root ", k);
}
if(i == j)
{
printf("D%dis leftchild of K%d ", i-1, i);
printf("D%dis rightchild of K%d ", i, i);
return;
}
if(k-1< i)
{
printf("D%dis leftchild of K%d ", i-1, i);
}
else
{
printf("K%dis leftchild of K%d ", root[i][k-1], k);
printinfo(i,k-1);
}
if(k+1> j)
{
printf("D%dis rightchild of K%d ", j, j);
}
else
{
printf("K%dis rightchild of K%d ", root[k+1][j], k);
printinfo(k+1,j);
}
return;
}