算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)

动态规划(dynamic programming,这里的programming不是程序,而是表示表格)。它与分治算法类似,都是通过组合子问题的解来求解原问题。分治算法是将原问题分解为互不相交的子问题,递归的求解子问题,然后将解组合起来。

动态规划则不同,它应用于求解子问题重叠的情况,也就是不同的子问题会涉及相同的子子问题。这样,普通的递归方法会反复的求解那些公共子问题,因而浪费了时间,动态规划则是对公共子问题只求解一次,然后将其解保存在表格中,避免了不必要的重复工作。

动态规划通常应用于最优化问题。此类问题可能有很多种可行解。每个解有一个值,而我们希望找出一个具有最优(最大或最小)值的解。称这样的解为该问题的”一个”最优解(而不是“确定的“最优解),因为可能存在多个取最优值的解。

动态规划算法的核心就是记住已经解决过的子问题的解。

动态规划算法的设计可以分为如下4个步骤:

描述最优解的结构;

递归定义最优解的值;

按自底向上的方式计算最优解的值;

由计算出的结果构造一个最优解。

动态规划算法的两种形式:

动态规划算法求解的方式有两种:

自顶向下的备忘录法

自底向上。 

如:

求斐波拉契数列Fibonacci 

Fibonacci (n) = 1;   n = 0
Fibonacci (n) = 1;   n = 1
Fibonacci (n) = Fibonacci(n-1) + Fibonacci(n-2)

我们一般实现的递归解法:

public int fib(int n){
    if(n<=0)
        return 0;
    if(n==1)
        return 1;
    return fib(n-1)+fib(n-2);
}

如果n=6,递归树如下:

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

上面的递归树中的每一个子节点都会执行一次,很多重复的节点被执行,比如fib(2)被重复执行了5次。如果在执行的时候把执行过的子节点保存起来,后面要用到的时候直接查表调用的话可以节约大量的时间。下面看动态规划的两种方法解决斐波拉契数列Fibonacci 数列问题。

自顶向下的备忘录法:

public static int Fibonacci(int n){
        if(n<=0)
            return n;
        int []Memo=new int[n+1];        
        for(int i=0;i<=n;i++)
            Memo[i]=-1;
        return fib(n, Memo);
}
public static int fib(int n,int []Memo){
    if(Memo[n]!=-1)
        return Memo[n];
        //如果已经求出了fib(n)的值直接返回,否则将求出的值保存在Memo备忘录中。               
    if(n<=2)
        Memo[n]=1;
    else 
        Memo[n]=fib(n-1,Memo)+fib(n-2,Memo);  
    return Memo[n];
}

备忘录法创建了一个n+1大小的数组来保存求出的斐波拉契数列中的每一个值,在递归的时候如果发现前面fib(n)的值计算出来了就不再计算,如果未计算出来,则计算出来后保存在Memo数组中,下次在调用fib(n)的时候就不会重新递归了。比如上面的递归树中在计算fib(6)的时候先计算fib(5),调用fib(5)算出了fib(4)后,fib(6)再调用fib(4)就不会在递归fib(4)的子树了,因为fib(4)的值已经保存在Memo[4]中。

自底向上的动态规划:

先计算子问题,再由子问题计算父问题。

public static int fib(int n){
    if(n<=0)
        return n;
    int []Memo=new int[n+1];
    Memo[0]=0;
    Memo[1]=1;
    for(int i=2;i<=n;i++){
        Memo[i]=Memo[i-1]+Memo[i-2];
    }       
    return Memo[n];
}

自底向上方法也是利用数组保存了先计算的值,为后面的调用服务。观察参与循环的只有 i,i-1 , i-2三项,因此该方法的空间可以进一步的压缩如下。

public static int fib(int n){
    if(n<=1)
        return n;
    int Memo_i_2=0;
    int Memo_i_1=1;
    int Memo_i=1;
    for(int i=2;i<=n;i++){
        Memo_i=Memo_i_2+Memo_i_1;
        Memo_i_2=Memo_i_1;
        Memo_i_1=Memo_i;
    }       
    return Memo_i;
}

一般来说由于备忘录方式的动态规划方法使用了递归,递归的时候会产生额外的开销,使用自底向上的动态规划方法要比备忘录方法好。 

装配线调度:

某个汽车工厂共有两条装配线,每条有n个装配站。装配线i的第j个装配站表示为Si,j ,在该站的装配时间为 ai,j 。一个汽车底盘进入工厂,然后进入装配线 i(i 为1或 2),花费时间为ei 。在通过一条线的第j个装配站后,这个底盘来到任一条装配线的第(j+1)个装配站。如果它留在相同的装配线,则没有移动开销。但是,如果它移动到另一条线上,则花费时间为ti,j 。在离开一条装配线的第n个装配站后,完成的汽车底盘花费时间为xi离开工厂。待求解的问题是,确定应该在装配线1内选择哪些站,在装配线 2 内选择哪些站,才能使汽车通过工厂的总时间最短。

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

1、描述最优解结构的特征:

观察一条通过装配站 S1,j 的最快路线,发现它必定是经过装配线 1 或2 上的装配站(j-1)。因此,通过装配站 S1,j 的最快路线只能是以下二者之一:

通过装配站 S1,j−1 的最快路线,然后直接通过装配站 S1,j;

通过装配站 S2,j−1 的最快路线,从装配线 2 转移到装配线 1,然后通过装配站 S1,j 。

故寻找通过任一条装配线上的装配站 j 的最快路线,我们可以规划为先寻找通过两条装配线上的装配站 j-1 的最快路线。

2、利用问题的最解递归定义一个最优解的值:

记 fi[j] 表示一个汽车底盘从起点到装配站 Si,j 的最快可能时间;f∗表示底盘通过工厂的所有装配站的最快时间。

根据题意,可以写出f(i)与f(i-1)之间的关系。

先剥离边界性条件差异,我们得到如下递归式:

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

然后处理边界性问题:

出口时,应该求解f1[n]+x1,f2[n]+x2。然后选出其中的最小值求解。

入口时,也有条件:

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

因此得到两个方程组和一个结果方程

 《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》
3、计算最快时间:

如果单纯根据表达式求解的话,需要用到递归,但时间复杂度是O(2^n)。但对于动态规划问题,通常采用自底向上的方式,需要建立数组来保持。那么这里就有个问题,数组保存哪些值呢?利用数组就是为了防止递归带来的时间消耗,因此数组保存的是要递归时的返回值。也就是递归表达式中的f1[n-1]以及f2[n-2]。有了这样的数组,将i从1->n增长,一步一步求解。

记li[j] 表示进入装配站 Si,j 的前一个装配站来自哪条装配线;l∗表示从哪一条装配线离开工厂。

伪代码:

FATEST-WAY(a, t, e, x, n)
    f1 [1] ← e1 + a1,1
    f2 [1] ← e2 + a2,1
    for j ← 2 to n
        if f1 [j − 1] + a1,j ≤ f2 [j − 1] + t2,j−1 + a1,j
        then f1 [j] ← f1 [j − 1] + a1,j
                l1 [j] ← 1
        else f1 [j] ← f2 [j − 1] + t2,j−1 + a1,j
                l1 [j] ← 2
        endif
        if f2 [j − 1] + a2,j ≤ f1 [j − 1] + t1,j−1 + a2,j
        then f2 [j] ← f2 [j − 1] + a2,j
                l2 [j] ← 2
        else f2 [j] ← f1 [j − 1] + t1,j−1 + a2,j
                l2 [j] ← 1
        endif
    endfor
    if f1 [n] + x1 ≤ f2 [n] + x2
    then f∗ ← f1 [n] + x1
            l∗ ← 1
    else f∗ ← f2 [n] + x2
            l∗ ← 2
    endif
end

如:

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

观察fi[j]和li[j]值的计算过程的一种方式是在表格中填入记录。在上图中,我们在表格内从左到右填入fi[j]和li[j]值的数值(在每一列中从上到下)。要填入一个记录fi[j],需要f1[j-1]和f2[j-1]的值,由于巳经计算并保存了它们,只需简单地查表来确定它们的值。

观察上图下面的计算过程:

装配线有两条,所以问题入口有2个。2+7=9和4+8=12;

在考虑下一站时,我们只考虑前一站总花费,以及前一站到下一站的花费(其实就是把前一站总花费当成最优解,现在我们考虑的是下一站最优解,这样就省去我们在考虑下一站时还需要前一站之前所有站的花费,那样路线过于复杂,也没有必要。因为我们已经找到前一站最优路线)。

先考虑S1,2所在站,要想到S1,2有两条路:S1,1-S1,2:9+9=18和S2,1-S1,2:12+2+9=23.显然选择S1,1-S1,2较好。

再考虑S2,2所在站,要想到S2,2有两条路:S1,1-S2,2:9+7=16和S2,1-S2,2:12+5=17.显然选择S1,1-S2,2较好。如此迭代。

最后到退出,有S1,6-退出:35+3=38和S2,6退出:37+2=39。选择S1,6-退出花费最短。如此我们找出最优解。

4、构造通过工厂的最快路线:

根据3获得的li[j]和l∗来打印出装配线调度耗时最小的路线。

伪代码:

PRINT-STATIONS(l, l∗ , n)
    i ← l∗
    print ’line:’ i ’, station:’ n
    for j ← n downto 2
         i ← li [j]
         print ’line:’ i ’, station:’ j-1
    endfor
end

矩阵链乘法:

给定n个矩阵构成的一个链<A1,A2,…,An>,矩阵Ai的维数为mi和ni,其中i=1,2,…n。对乘积<A1,A2,…,An>以一种最小化标量乘法次数的方式进行加全部括号。

注意:

在矩阵链乘问题中,实际上并没有把矩阵相乘,目的是确定一个具有最小代价的矩阵相乘顺序。简单讲,即使找出如何打括号的方案,来使得相乘的运算代价最低。

如:

矩阵链<A1,A2,A3>,三个矩阵规模分别是(10*100),(100*5),(5*50)。对于这个矩阵链,计算方法有两种,一种是(A1A2)A3,另一种是A1(A2A3)。

计算量分别是:

10*100*5+10*5*50=5000+2500=7500;

100*5*50+10*100*50=25000+50000=75000。

显然,第一种方法比第二种效率高了10倍。

解题步骤:

1、寻找最优子结构:

对乘积A1A2…An的任意加括号方法都会将序列在某个地方分成两部分,也就是最后一次乘法计算的地方,我们将这个位置记为k,也就是说首先计算A1…Ak和Ak+1…An,然后再将这两部分的结果相乘。

最优子结构如下:假设A1A2…An的一个最优加括号把乘积在Ak和Ak+1间分开,则前缀子链A1…Ak的加括号方式必定为A1…Ak的一个最优加括号,后缀子链同理。

一开始并不知道k的确切位置,需要遍历所有位置以保证找到合适的k来分割乘积。

2、构造递归解:

我们令m[i,j]为AiAi+1….Aj所需的标量乘法次数的最小值,即m[i,j]表示AiAi+1…AjAiAi+1…Aj矩阵链的最优解。那么,对于矩阵链<A1,A2,…,An>而言,其实就是求解m[1,n]。假如我们已经得到了最优的k,那么m[i,j]=m[i,k]+m[k+1,j]+m(i)*n(k)*n(j),那么我们的k是如何确定的呢?k的取值只可能有j-i种,所以我们就检查所有的情况,找出最优者就可以了。怎样才算最优?

当i=j时,就一个矩阵,k=i=j,不用算了,m[i,j]=0;

当i<j时,那么m[i,j]=m[i,k]+m[k+1,j]+m(i)*n(k)*n(j)。

3、构建辅助表,解决重叠子问题:

从第二步的递归式可以发现解的过程中会有很多重叠子问题,可以用两个nxn维的辅助表m[n][n],s[n][n]分别表示最优乘积代价及其分割位置k 。
辅助表s[n][n]可以由2种方法构造,一种是自底向上填表构建,该方法要求按照递增的方式逐步填写子问题的解,也就是先计算长度为2的所有矩阵链的解,然后计算长度3的矩阵链,直到长度n;另一种是自顶向下填表的备忘录法,该方法将表的每个元素初始化为某特殊值(本问题中可以将最优乘积代价设置为一极大值),以表示待计算,在递归的过程中逐个填入遇到的子问题的解。

伪代码:

MATRIX-CHAIN-ORDER(p)
    n = p.length - 1
    let m[1..n,1..n] and s[1..n-1,2..n] be a new array
    for i = 1 to n
        m[i,i] = 0
    for l = 2 to n  //l is the chain length
        for i = 1 to n-l+1
            j = i + l -1
            m[i,j] = \inf
            for k = i to j-1
                q = m[i,k] + m[k+1,j] + p[i-1]p[k]p[j]
                if q < m[i,j]
                    m[i,j] = q
                    s[i,j] = k
    return m,s

如:

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

上面例子的计算过程:

第一次:我们从最初开始划分,也就是划分两个子矩阵,首先列出所有情况。 

A1A2 30*35*15=15750 
A2A3 35*15*5=2625 
A3A4 15*5*10=750 
A4A5 5*10*20=1000 
A5A6 10*20*25=5000 

由于我们不知道最优划分方法使用了那些上述划分,所以我们不能进行pk

第二次:划分三个子矩阵 

A1A2A3:此时注意A1A2A3有两种子结构划分,由于不管使用哪种子结构,划分后都归类到A1A2A3,也就是说A1A2A3无论有多少子结构,无论如何划分,划分后他都成为(A1A2A3)这一个整体。因此此时我们可以进行pk。(A1(A2A3))2625+30*35*5=7875或((A1A2)A3)15750+30*15*5=18000,显然选择7875 
A2A3A4:2625+35*5*10=4375或750+35*15*10=6000,显然选择4375 
A3A4A5:750+15*10*20=3750或1000+15*5*20=2500,显然选择2500 
A4A5A6:1000+5*20*25=3500或5000+5*10*25=6250,显然选择3500

第三次:划分四个子矩阵 

A1A2A3A4:(A1A2A3)A4:7875+30*5*10=9375或A1(A2A3A4):2500+35*10*30=13000或者(A1A2)(A3A4):15750+750+30*15*10=21000显然选择9375 
A2A3A4A5:(A2A3A4)A5:4375+35*10*20=11375或者A2(A3A4A5):2500+15*20*35=13000或(A2A3)(A4A5):2625+1000+35*5*20=7125显然选择7125 
A3A4A5A6:(A3A4A5)A6:2500+15*20*25=10000或者A3(A4A5A6):3500+5*25*15=5375或(A3A4)(A5A6):750+5000+15*10*25=9500显然选择5375

第四次:划分五个子矩阵 

同理得A1A2A3A4A5:11875和A2A3A4A5A6:10500

第五次:最后对整个矩阵链进行划分。 

A1(A2A3A4A5A6):30*35*25+18500=44750 
(A1A2)(A3A4A5A6):157580+5375+30*5*25=32375 
(A1A2A3)(A4A5A6):7875+3500+30*5*25=15125 
(A1A2A3A4)(A5A6):9375+5000+30*10*25=21875 
(A1A2A3A4A5)A6:15375+30*20*25=30375

综上:最后选择15125。

知道了整个矩阵链的分配,我们就知道接下来的分配,因为我们使用的子矩阵链都是最优子结构,只需要往前找就行。最后分配:(A1(A2A3))((A4A5)A6)。

4、构造最优解:

我们根据第三步记录的表格k来进行最优括号化方案。因为表格里的每一个数实际上就记录的就是表格所在的位置。

伪代码:

PRINT-OPTIMAL-PARENS(s,i,j)
    if i == j
        print "A"
    else print "("
        PRINT-OPTIMAL-PARENS(s,i,s[i,j])
        PRINT-OPTIMAL-PARENS(s,s[i,j]+1,j)
        print ")"

动态规划基础:

最优子结构:

在寻找最优子结构性质的过程中,实际上遵循了如下的通用模式: 

1、证明问题最优解的第一个组成部分是做出一个选择;

2、对于一个给定问题,在其可能的第一步选择中,假定已经知道哪种选择才会得到最优解;

3、给定可获得最优解的选择后,确定这次选择会产生哪些子问题,以及如何最好地刻画子问题空间;

4、利用“剪切-粘贴”技术证明作为构成原问题最优解的组成部分,每个子问题的解就是它本身的最优解。

最优子结构在问题域中以两种方式变化:

有多少个子问题被使用在原问题的一个最优解中;

在决定一个最优解中使用哪些子问题时有多少个选择。

非正式地,一个动态规划算法的运行时间依赖于两个因素的乘积:子问题的总个数和每个子问题中有多少种选择。

注意:

贪心算法与动态规划有着很多相似之处。特别地,贪心算法适用的问题也具有最优子结构。贪心算法与动态规划有一个显著的区别:贪心算法中,是以自顶向下的方式使用最优子结构的。

一些细微之处:

要注意在不能应用最优子结构的时候,就一定不能假设它能够应用。考虑下面两个问题,已知一个有向图G=( V,E)和结点u, v属于V。
无权最短路径:

找出一条从u到v的包含最少边数的路径。这样的一条路径必须是简单路径,因为从路径中去掉一个回路后,会产生边数更少的路径。

无权最长简单路径:

找出一条从u到v的包含最多变数的简单路径(无环)。我们需要加入简单性的要求,否则,就可以随意地遍历一个回路任意多次来得到有任意多的边数的路径。

对于这两个问题,无权最短路径具有最优子结构(可以用剪贴法来论证);而后者没有,而它是一个NP-Complete事件。

重叠子问题:

适用于动态规划求解的最优化问题必须具有的第二个要素是子问题的空间要“很小”,也就是用来解原问题的递归算法可反复地解同样的子问题,而不是总在产生新的子问题。典型地,不同的子问题数是输入规模的一个多项式。当一个递归算法不断地调用同一问题时,我们说该最优问题包含重叠子问题。相反地,适合用分治法解决的问题往往在递归的每一步都产生全新的问题。动态规划算法总是充分利用重叠子问题,即通过每个子问题只解一次,把解保存在一个在需要时就可以查看的表中,而每次查表的时间为常数。

重新构造一个最优解:

在实际应用中,我们通常把每一个子问题中所作的选择保存在一个表格中, 这样在需要时,就不必根据已经存储下来的代价信息来重构这方面的信息了。重构时只需O(1)时间。

做备忘录:

低效的递归算法加入备忘机制可称为自顶向下动态规划法。加了备忘的递归算法为每一个子问题的解在表中记录一个表项。开始时,每个表项最初都包含一个特殊的值,以表示该表项有待填入。当在递归算法的执行中第一次遇到一个子问题时,就计算它的解并填入表中。以后每次遇到该子问题时,只要查看并返回表中先前填入的值即可。

通常情况下,自底向上动态规划算法没有递归调用的开销,会比自顶向下备忘算法快。但对于某些问题,它的子问题空间中的某些子问题完全不必求解,备忘方法就会体现出优势了,因为它只会求解那些绝对必要的子问题。

在实际应用中,如果所有的子问题都至少要被计算一次,则一个自底向上的动态规划算法通常要比一个自顶向下的做备忘录算法好出一个常数因子,因为前者无需递归的代价,而且维护表格的开销也小些。在有些问题中,还可以用动态规划算法中的表存取模式来进一步减少时间或空间上的需求。

如果子问题空间中的某些子问题根本没有必要求解,做备忘录方法有着只解那些肯定要求解的子问题的优点。

最长公共子序列:

子序列(subsequence):

一个特定序列的子序列就是将给定序列中零个或多个元素去掉后得到的结果(不改变元素间相对次序)。如,序列<A,B,C,B,D,A,B><A,B,C,B,D,A,B>的子序列有:<A,B><A,B>、<B,C,A><B,C,A>、<A,B,C,D,A><A,B,C,D,A>等。 

公共子序列(common subsequence):

给定序列X和Y,序列Z是X的子序列,也是Y的子序列,则Z是X和Y的公共子序列。如X=<A,B,C,B,D,A,B>X=<A,B,C,B,D,A,B>,Y=<B,D,C,A,B,A>Y=<B,D,C,A,B,A>,那么序列Z=<B,C,A>Z=<B,C,A>为X和Y的公共子序列,其长度为3。但ZZ不是XX和YY的最长公共子序列,而序列<B,C,B,A><B,C,B,A>和<B,D,A,B><B,D,A,B>也均为XX和YY的最长公共子序列,长度为4,而XX和YY不存在长度大于等于5的公共子序列。 

最长公共子序列(Iongest-Common-Subsequence, LCS)问题:

给定两个序列X={x1,x2,……,xm}和Y={y1,y2,……,yn},找出X和Y的最长公共子序列。

解题步骤:

1、描述一个最长公共子序列:

如果序列比较短,可以采用蛮力法枚举出X的所有子序列,然后检查是否是Y的子序列,并记录所发现的最长子序列。如果序列比较长,这种方法需要指数级时间,不切实际。

LCS的最优子结构定理:

设X={x1,x2,……,xm}和Y={y1,y2,……,yn}为两个序列,并设Z={z1、z2、……,zk}为X和Y的任意一个LCS,则:

如果xm=yn,那么zk=xm=yn,而且Zk-1是Xm-1和Yn-1的一个LCS;

如果xm≠yn,那么zk≠xm蕴含Z是是Xm-1和Yn的一个LCS;

如果xm≠yn,那么zk≠yn蕴含Z是是Xm和Yn-1的一个LCS。

定理说明两个序列的一个LCS也包含两个序列的前缀的一个LCS,即LCS问题具有最优子结构性质。

2、一个递归解:

根据LCS的子结构可知,要找序列X和Y的LCS,根据xm与yn是否相等进行判断的,如果xm=yn则产生一个子问题,否则产生两个子问题。设C[i,j]为序列Xi和Yj的一个LCS的长度。如果i=0或者j=0,即一个序列的长度为0,则LCS的长度为0。LCS问题的最优子结构的递归式如下所示:

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

3、计算LCS的长度:

采用动态规划自底向上计算解。书中给出了求解过程LCS_LENGTH,以两个序列为输入。将计算序列的长度保存到一个二维数组C[M][N]中,另外引入一个二维数组B[M][N]用来保存最优解的构造过程。M和N分别表示两个序列的长度。

伪代码:

LCS_LENGTH(X,Y)
    m = length(X);
    n = length(Y);
    for i = 1 to m
        c[i][0] = 0;
    for j=1 to n
        c[0][j] = 0;
    for i=1 to m
        for j=1 to n
            if x[i] = y[j]
                then c[i][j] = c[i-1][j-1]+1;
                     b[i][j] = '\';
            else if c[i-1][j] >= c[i][j-1]
                then c[i][j] = c[i-1][j];
                     b[i][j] = '|';
            else
                     c[i][j] = c[i][j-1];
                     b[i][j] = '-';
    return c and b

由伪代码可以看出LCS_LENGTH运行时间为O(mn)。

4、构造一个LCS:

根据第三步中保存的表b构建一个LCS序列。从b[m][n]开始,当遇到’\’时,表示xi=yj,是LCS中的一个元素。通过递归即可求出LCS的序列元素。

伪代码:

PRINT_LCS(b,X,i,j)
    if i==0 or j==0
        then return
    if b[i][j] == '\'
        then PRINT_LCS(b,X,i-1,j-1)
             print X[i]
    else if b[i][j] == '|'
        then PRINT_LCS(b,X,i-1,j)
    else PRINT_LSC(b,X,i,j-1)

最优二叉查找树:

 假定设定一个程序,实现英语文本到法语的翻译。由于单词出现的频率是不同的,我们希望文本中频繁出现的单词被置于靠近根的位置。在给定单词出现频率的前提下,我们应该如何组织一棵二叉搜索树,使得所有搜索操作访问的结点总数最少呢?

这个问题称为最优二叉搜索树(optimal binary search tree)问题。定义如下:

给定一个n个不同关键字已排序的序列K=<k1,k2,…,kn>(因此k1<k2<…<kn),我们希望用这些关键字构造一棵二叉搜索树。对每个关键字ki,都有一个概率pi表示其搜索频率。有些要搜索的值可能不在K中,因此我们还与n+1个“伪关键字”d0,d1,d2…dn表示不在K中的值。d0表示所有小于k1的值,dn表示所有大于kn的值,对i=1,2,…,n-1,伪关键字di表示所有在ki和ki+1之间的值。对每个伪关键字di,也都有一个概率pi表示对应的搜索频率。

因为已知了每个关键字和每个虚拟键被搜索的概率,因而可以确定在一棵给定的二叉查找树T内一次搜索的期望代价。假设一次搜索的实际代价为检查的结点个数,亦即,在T内搜索所发现的结点的深度加上1。所以在T内一次搜索的期望代价为

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

如:

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》
以a)图为例,逐个结点计算期望的搜索代价:

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

对给定的一组概率,我们的目标是构造一个期望搜索代价最小的二叉查找树。把这种树称作最优二叉查找树。

图b)显示了一棵最优二叉查找树,其概率在图的标题中给出;期望代价是2.75 。这个例子说明一棵最优二叉查找树不一定是一棵整体高度最小的树。我们也不一定总是把有最大概率的关键字放在根部来构造一棵最优二叉查找树

使用动态规划来解决这个问题:

1、最优二叉搜索树的结构:

为了刻画最优二叉搜索树的结构,我们从观察子树特征开始。考虑一棵二叉搜索树的任意子树。它必须包含连续关键字k(i),…k(j),1<=i<=j<=n,而且其叶结点必然是伪关键字d(i-1),…,d(i)。

我们现在可以给出二叉搜索树问题的最优子结构:

如果一棵最优二叉搜索树T有一棵包含关键字k(i),…,k(j)的子树T’,那么T’必然是包含关键字k(i),…,k(j)和伪关键字d(i-1),…,d(j)的子问题的最优解。我们依旧用“剪切-粘贴”法来证明这一结论。如果存在子树T”,其期望搜索代价比T’低,那么我们将T’从T中删除,将T”粘贴到相应的位置,从而得到一颗期望搜索代价低于T的二叉搜索树,与T最优的假设矛盾。

使用最优子结构来说明可以根据子问题的最优解, 来构造原问题的一个最优解。给定关键字序列k(i),…,k(j),其中某个关键字,比如说k(r)(i<=r<=j),是这些关键字的最优子树的根结点。那么k(r)的左子树就包含关键字k(i),…,k(r-1)(和伪关键字d(i-1),…,d(r-1) ),而有字数包含关键字k(r+1),…,k(j)(和伪关键字d(r),…,d(j) )。只要我们检查所有可能的根结点k(r)(i<=r<=j),并对每种情况分别求解包含k(i),…,k(r-1)及包含k(r+1),…,k(j)的最优二叉搜索树,即可保存找到原问题的最优解。

这里还有一个值得注意的细节——“空子树”。假定对于包含关键字ki,…,kj的子问题,我们选定ki为根结点。根据前文论证,k(i)的左子树包含关键字k(i),…,k(i-1)的子问题,我们将此序列解释为不包含任何关键字。但请注意,子树仍然包含伪关键字。按照惯例,我们认为包含关键字序列k(i),…,k(i-1)的子树不包含任何实际关键字,但包含单一伪关键字d(i-1)。对称地,我们如果现在k(j)为根结点,那么k(j)的右子树包含关键字k(j+1),…,k(j)——此右子树不包含任何实际关键字,但包含伪关键字d(j)。

2、一个递归算法:

我们已经准备好给出最优解值的递归定义。

我们选取子问题域为:求解包含关键字k(i),…,k(j)的最优二叉搜索树,其中i>=1,j<=n且j>=i-1(当j=i-1时,子树不包含实际关键字,只包含伪关键字d(i-1)。定义e[i,j]为包含关键字k(i),…,k(j)的最优二叉搜索树中进行一次搜索的期望代价,最终,我们希望计算出e[1,n]。

当j=i-1的情况最为简单,由于子树只包含伪关键字d(i-1),期望搜索代价为e[i,i-1]=q(i-1)。

当j>=i时,我们需要从k(i),…,k(j)中选择一个跟结点k(r),然后构造一棵包含关键字k(i),…,k(r-1)的最优二叉搜索树作为其左子树,以及一棵包含关键字k(r+1),…,k(j)的二叉搜索树作为其右子树。

当一棵子树成为一个结点的子树时,期望搜索代价有何变化?由于每个结点的深度都增加了1,根据公式

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

这棵子树的期望搜索代价的增加值应为所有概率这和。对于包含关键字k(i),…,k(j)的子树,所有概率之和为

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

因此,若k为包含关键字k(i),…,k(j)的最优二叉搜索树的根结点,我们有如下公式:

 《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

因此e[i,j]可重写为

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

上式假定我们知道哪个结点k应该作为根结点。如果选取期望搜索代价最低者作为根结点,可得最终递归公式

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

e[i,j]的值给出了最优二叉搜索树的期望搜索代价。为了记录最优二叉搜索树的结构,对于包含关键字k(i),…,k(j)(1<=i<=j<=n)的最优二叉搜索树,我们定义root[i,j]保存根结点k(r)的下标r

3、计算最优二叉搜索树的期望搜索代价:

现在,我们可以注意到我们求解最优二叉搜索树和矩阵链乘法的一些相似之处。它们的子问题都由连续的下标子域组成。而公式

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

的直接递归实现,也会与矩阵链乘法问题的直接递归算法一样低效。

因此,我们设计替代的高效算法,我们用一个表e[1..n+1,0..n]来保存e[i,j]的值第一维下标上界为n+1而不是n,原因在于对于只包含伪关键字d(n)的子树,我们需要计算并保存e[n+1,n]。第二维下标下界为0,是因为对于只包含伪关键字d0的子树,我们需要计算并保存e[1,0]。使用j大于等于i-1的表项e[i,j]和表root[i,j]来记录包含关键字ki,…,kj的子树的根。

这个表只使用《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》的表项。

我们还需要另一个表来提高计算效率。为了避免每次计算e[i,j]时都重新计算w(i,j),我们将这些值保存在表w[1..n+1,0..n]中,这样每次可节省Θ(j-i)次加法。

对基本情况,令w[i,i-1]=q(i-1)(1<=i<=n+1)。

对j>=i的情况,可如下计算:

w[i,j]=w[i,j-1]+p(j)+q(j)

这样对Θ(n^2)个w[i,j],每个计算时间为Θ(1)。

伪代码:

OPTIMAL-BST(p, q, n)
    let e[1 .. n + 1, 0 .. n], w[1 .. n + 1, 0 .. n] and root[1 .. n, 1 .. n] be new tables
    for i = 1 to n + 1
        e[i, i - 1] = q_i - 1
        w[i, i - 1] = q_i - 1
    for l = 1 to n
        for i = 1 to n - l + 1
            j = i + l - 1
            e[i, j] = ∞
            w[i, j] = w[i, j - 1] + p_j + q_j
            for r = i to j
                t = e[i, r - 1] + e[r + 1, j] + w[i, j]
                if t < e[i, j]
                    e[i, j] = t
                    root[i, j] = r
    return e and root

此过程的操作比较直观。第1~4行初始化e[i, j-1]和w[i,j – 1]的值。第5~14行的for循环利用上面两个递归式来计算e[i,j]和w[i , j],在第10~14行,尝试每个下标r以确定使用哪个关键字kr来作为包含关键字ki,…,kj的最优二叉查找树的根。无论何时发现一个更好的关键字来作为根,这个for循环在root [i , j]中保存下标r的当前值。

如:

还是这个例子:

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

程序OPTIMAL-BST计算出的表e[i , j]和w[i , j]和root[i , j]。

《算法导论 第十五章:动态规划 笔记(动态规划算法的两种形式、装配线调度、矩阵链乘法、动态规划基础、最长公共子序列、最优二叉查找树)》

OPTIMAL-BST过程需要Θ(n3)。

    原文作者:二叉查找树
    原文地址: https://blog.csdn.net/zgcr654321/article/details/83151535
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞