算法导论笔记(六)
第十五章:动态规划--装配线调度
前言:动态规划的概念
动态规划(dynamic programming)是通过组合子问题的解而解决整个问题的。分治算法是指将问题划分为一些独立的子问题,递归的求解各个问题,然后合并子问题的解而得到原问题的解。例如归并排序,快速排序都是采用分治算法思想。本书在第二章介绍归并排序时,详细介绍了分治算法的操作步骤,详细的内容请参考:https://www.cnblogs.com/still-smile/p/11586861.html。而动态规划与此不同,适用于子问题不是独立的情况,也就是说各个子问题包含有公共的子问题。如在这种情况下,用分治算法则会重复做不必要的工作。采用动态规划算法对每个子问题只求解一次,将其结果存放到一张表中,以供后面的子问题参考,从而避免每次遇到各个子问题时重新计算答案。
动态规划与分治法之间的区别:
(1)分治法是指将问题分成一些独立的子问题,递归的求解各子问题
(2)动态规划适用于这些子问题不是独立的情况,也就是各子问题包含公共子问题
动态规划通常用于最优化问题(此类问题一般有很多可行解,我们希望从这些解中找出一个具有最优(最大或最小)值的解)。动态规划算法的设计分为以下四个步骤:
(1)描述最优解的结构
(2)递归定义最优解的值
(3)按自低向上的方式计算最优解的值
(4)由计算出的结果构造一个最优解
动态规划最重要的就是要找出最优解的子结构。书中接下来列举4个问题,讲解如何利用动态规划方法来解决。动态规划的内容比较多,我计划每个问题都认真分析,写成日志。今天先来看第一个问题:装配线调度问题
2、问题描述
一个汽车公司在有2条装配线的工厂内生产汽车,每条装配线有n个装配站,不同装配线上对应的装配站执行的功能相同,但是每个站执行的时间是不同的。在装配汽车时,为了提高速度,可以在这两天装配线上的装配站中做出选择,即可以将部分完成的汽车在任何装配站上从一条装配线移到另一条装配线上。装配过程如下图所示:
装配过程的时间包括:进入装配线时间e、每装配线上各个装配站执行时间a、从一条装配线移到另外一条装配线的时间t、离开最后一个装配站时间x。举个例子来说明,现在有2条装配线,每条装配线上有6个装配站,各个时间如下图所示:
从图中可以看出按照红色箭头方向进行装配汽车最快,时间为38。分别现在装配线1上的装配站1、3和6,装配线2上装配站2、4和5。
3、动态规划解决步骤
(1)描述通过工厂最快线路的结构
对于装配线调度问题,一个问题的(找出通过装配站Si,j的 最快线路)最优解包含了子问题(找出通过S1,j-1或S2,j-1的最快线路)的一个最优解,这就是最优子结构。观察一条通过装配站S1,j的最快线路,会发现它必定是经过装配线1或2上装配站j-1。因此通过装配站的最快线路只能以下二者之一:
a)通过装配线S1,j-1的最快线路,然后直接通过装配站Si,j;
b)通过装配站S2,j-1的最快线路,从装配线2移动到装配线1,然后通过装配线S1,j。
为了解决这个问题,即寻找通过一条装配线上的装配站j的最快线路,需要解决其子问题,即寻找通过两条装配线上的装配站j-1的最快线路。
(2)一个递归的解
最终目标是确定底盘通过工厂的所有路线的最快时间,设为f*,令fi[j]表示一个底盘从起点到装配站Si,j的最快时间,则f* = min(f1[n]+x1,f2[n]+x2)。逐步向下推导,直到j=1。
当j=1时: f1[1] = e1+a1,1,f2[1] = e2+a2,1。
当j>1时:f1[j] = min(f1[j-1]+a1,j,f2[j-1]+t2,j-1+a1,j),f2[j] = min(f2[j-1]+a2,j,f1[j-1]+t1,j-1+a2,j)。
(3)计算最快时间
有了递归的解,就可以按照上述的思路编写程序实现,为了避免用递归实现,需要开辟辅助空间来进行,以空间来换取时间,用C语言实现如下所示:
void fastest_way(int a[][N],int t[][N-1],int e[],int x[],int f[][N],int l[][N],int n) { int i,j; f[0][0] = e[0] + a[0][0]; f[1][0] = e[1] + a[1][0]; l[0][0] = 1; l[1][0] = 2; for(j=1;j<n;j++) { if(f[0][j-1] < f[1][j-1] + t[1][j-1]) { f[0][j] = f[0][j-1] + a[0][j]; l[0][j] = 1; } else { f[0][j] = f[1][j-1] + t[1][j-1] + a[0][j]; l[0][j] = 2; } if(f[1][j-1] < f[0][j-1] + t[0][j-1]) { f[1][j] = f[1][j-1] + a[1][j]; l[1][j] = 2; } else { f[1][j] = f[0][j-1] + t[0][j-1] + a[1][j]; l[1][j] = 1; } } if(f[0][n-1] + x[0] < f[1][n-1] + x[1]) { last_f = f[0][n-1] + x[0]; last_l = 1; } else { last_f = f[1][n-1] + x[1]; last_l = 2; } }
(4)构造通过工厂的最快线路
有第三步骤已经计算出来并记录了每个装配站所在的装配线编号,故可以按照以站号递减顺序直接输出,程序如下所示:
void print_station(int l[][N],int last_l,int n) { int i = last_l; int j; printf("line %d,station %d\n",i,n); for(j=n-1;j>0;--j) { i = l[i-1][j]; printf("line %d,station %d\n",i,j); } }
若是按照站号递增顺序输出,则需通过递归进行实现,程序如下所示:
void print_station_recursive(int l[][N],int last_l,int n) { int i = last_l; if(n == 1) printf("line %d,station %d\n",i,n); else { print_station_recursive(l,l[i-1][n-1],n-1); printf("line %d,station %d\n",i,n); } }
4、编程实现
根据上面的分析,采用C语言实现如下:
#include <stdio.h> #include <stdlib.h> #define N 6 void fastest_way(int a[][N],int t[][N-1],int e[],int x[],int f[][N],int l[][N],int n); void print_station(int l[][N],int last_l,int n); void print_station_recursive(); //全局变量,last_t表示最短时间,last_l表示最后一个装配站所在的装配线编号 int last_f,last_l; int main() { int a[2][6] = {{7,9,3,4,8,4},{8,5,6,4,5,7}}; int t[2][5] = {{2,3,1,3,4},{2,1,2,2,1}}; int f[2][6] = {0}; int l[2][6] = {0}; int e[2] = {2,4}; int x[2] = {3,2}; int i,j; fastest_way(a,t,e,x,f,l,6); //打印输出各个装配线上各个装配站执行的最短时间 for(i=0;i<2;++i) { printf("f%d is: ",i+1); for(j=0;j<6;++j) printf("%d ",f[i][j]); printf("\n"); } printf("last_f is: %d\nlast_l is: %d\n",last_f,last_l); for(i=0;i<2;++i) { printf("l%d is: ",i+1); for(j=0;j<6;++j) printf("%d ",l[i][j]); printf("\n"); } print_station(l,last_l,6); printf("output sequence by recursive.\n"); print_station_recursive(l,last_l,6); return 0; } void fastest_way(int a[][N],int t[][N-1],int e[],int x[],int f[][N],int l[][N],int n) { int i,j; f[0][0] = e[0] + a[0][0]; f[1][0] = e[1] + a[1][0]; l[0][0] = 1; l[1][0] = 2; for(j=1;j<n;j++) { if(f[0][j-1] < f[1][j-1] + t[1][j-1]) { f[0][j] = f[0][j-1] + a[0][j]; l[0][j] = 1; } else { f[0][j] = f[1][j-1] + t[1][j-1] + a[0][j]; l[0][j] = 2; } if(f[1][j-1] < f[0][j-1] + t[0][j-1]) { f[1][j] = f[1][j-1] + a[1][j]; l[1][j] = 2; } else { f[1][j] = f[0][j-1] + t[0][j-1] + a[1][j]; l[1][j] = 1; } } if(f[0][n-1] + x[0] < f[1][n-1] + x[1]) { last_f = f[0][n-1] + x[0]; last_l = 1; } else { last_f = f[1][n-1] + x[1]; last_l = 2; } } void print_station(int l[][N],int last_l,int n) { int i = last_l; int j; printf("line %d,station %d\n",i,n); for(j=n-1;j>0;--j) { i = l[i-1][j]; printf("line %d,station %d\n",i,j); } } void print_station_recursive(int l[][N],int last_l,int n) { int i = last_l; if(n == 1) printf("line %d,station %d\n",i,n); else { print_station_recursive(l,l[i-1][n-1],n-1); printf("line %d,station %d\n",i,n); } }
程序执行结果如下所示:
5、总结
动态规划是个非常有效的设计方法,要善于用动态规划去分析问题,重点是如何发现子问题的结构。最优子结构在问题域中以两种方式变化(在找出这两个问题的解之后,构造出原问题的最优子结构往往就不是难事了):
a) 有多少个子问题被用在原问题的一个最优解中
b) 在决定一个最优解中使用哪些子问题有多少个选择
第十六章:动态规划--矩阵链乘法
前言:今天接着学习动态规划算法,学习如何用动态规划来分析解决矩阵链乘问题。首先回顾一下矩阵乘法运算法,并给出C++语言实现过程。然后采用动态规划算法分析矩阵链乘问题并给出C语言实现过程。
![](https://images0.cnblogs.com/blog/305504/201303/10103947-1c71326c75c64c26ae52f891fbf8c505.png)
#include <iostream> using namespace std; #define A_ROWS 3 #define A_COLUMNS 2 #define B_ROWS 2 #define B_COLUMNS 3 void matrix_multiply(int A[A_ROWS][A_COLUMNS],int B[B_ROWS][B_COLUMNS],int C[A_ROWS][B_COLUMNS]); int main() { int A[A_ROWS][A_COLUMNS] = {1,0, 1,2, 1,1}; int B[B_ROWS][B_COLUMNS] = {1,1,2, 2,1,2}; int C[A_ROWS][B_COLUMNS] = {0}; matrix_multiply(A,B,C); for(int i=0;i<A_ROWS;i++) { for(int j=0;j<B_COLUMNS;j++) cout<<C[i][j]<<" "; cout<<endl; } return 0; } void matrix_multiply(int A[A_ROWS][A_COLUMNS],int B[B_ROWS][B_COLUMNS],int C[A_ROWS][B_COLUMNS]) { if(A_COLUMNS != B_ROWS) cout<<"error: incompatible dimensions."<<endl; else { int i,j,k; for(i=0;i<A_ROWS;i++) for(j=0;j<B_COLUMNS;j++) { C[i][j] = 0; for(k=0;k<A_COLUMNS;k++) C[i][j] += A[i][k] * B[k][j]; //将A的每一行的每一列与B的每一列的每一行的乘积求和 } } }
程序测试结果如下所示:
2、矩阵链乘问题描述
给定n个矩阵构成的一个链<A1,A2,A3,.......An>,其中i=1,2,...n,矩阵A的维数为pi-1pi,对乘积 A1A2...An 以一种最小化标量乘法次数的方式进行加全部括号。
注意:在矩阵链乘问题中,实际上并没有把矩阵相乘,目的是确定一个具有最小代价的矩阵相乘顺序。找出这样一个结合顺序使得相乘的代价最低。
3、动态规划分析过程
1)最优加全部括号的结构
动态规划第一步是寻找一个最优的子结构。假设现在要计算AiAi+1....Aj的值,计算Ai...j过程当中肯定会存在某个k值(i<=k<j)将Ai...j分成两部分,使得Ai...j的计算量最小。分成两个子问题Ai...k和Ak+1...j,需要继续递归寻找这两个子问题的最优解。
有分析可以到最优子结构为:假设AiAi+1....Aj的一个最优加全括号把乘积在Ak和Ak+1之间分开,则Ai..k和Ak+1..j也都是最优加全括号的。
2)一个递归解
设m[i,j]为计算机矩阵Ai...j所需的标量乘法运算次数的最小值,对此计算A1..n的最小代价就是m[1,n]。现在需要来递归定义m[i,j],分两种情况进行讨论如下:
当i==j时:m[i,j] = 0,(此时只包含一个矩阵)
当i<j 时:从步骤1中需要寻找一个k(i≤k<j)值,使得m[i,j] =min{m[i,k]+m[k+1,j]+pi-1pkpj} (i≤k<j)。
3)计算最优代价
虽然给出了递归解的过程,但是在实现的时候不采用递归实现,而是借助辅助空间,使用自底向上的表格进行实现。设矩阵Ai的维数为pi-1pi,i=1,2.....n。输入序列为:p=<p0,p1,...pn>,length[p] = n+1。使用m[n][n]保存m[i,j]的代价,s[n][n]保存计算m[i,j]时取得最优代价处k的值,最后可以用s中的记录构造一个最优解。书中给出了计算过程的伪代码,摘录如下:
MAXTRIX_CHAIN_ORDER(p) n = length[p]-1; for i=1 to n do m[i][i] = 0; for t = 2 to n //t is the chain length do for i=1 to n-t+1 j=i+t-1; m[i][j] = MAXLIMIT; for k=i to j-1 q = m[i][k] + m[k+1][i] + qi-1qkqj; if q < m[i][j] then m[i][j] = q; s[i][j] = k; return m and s;
MATRIX_CHAIN_ORDER具有循环嵌套,深度为3层,运行时间为O(n3)。如果采用递归进行实现,则需要指数级时间Ω(2n),因为中间有些重复计算。递归是完全按照第二步得到的递归公式进行计算,递归实现如下所示:
int recursive_matrix_chain(int *p,int i,int j,int m[N+1][N+1],int s[N+1][N+1]) { if(i==j) m[i][j] = 0; else { int k; m[i][j] = MAXVALUE; for(k=i;k<j;k++) { int temp = recursive_matrix_chain(p,i,k,m,s) +recursive_matrix_chain(p,k+1,j,m,s) + p[i-1]*p[k]*p[j]; if(temp < m[i][j]) { m[i][j] = temp; s[i][j] = k; } } } return m[i][j]; }
对递归算计的改进,可以引入备忘录,采用自顶向下的策略,维护一个记录了子问题的表,控制结构像递归算法。完整程序如下所示:
int memoized_matrix_chain(int *p,int m[N+1][N+1],int s[N+1][N+1]) { int i,j; for(i=1;i<=N;++i) for(j=1;j<=N;++j) { m[i][j] = MAXVALUE; } return lookup_chain(p,1,N,m,s); } int lookup_chain(int *p,int i,int j,int m[N+1][N+1],int s[N+1][N+1]) { if(m[i][j] < MAXVALUE) return m[i][j]; //直接返回,相当于查表 if(i == j) m[i][j] = 0; else { int k; for(k=i;k<j;++k) { int temp = lookup_chain(p,i,k,m,s)+lookup_chain(p,k+1,j,m,s) + p[i-1]*p[k]*p[j]; //通过递归的形式计算,只计算一次,第二次查表得到 if(temp < m[i][j]) { m[i][j] = temp; s[i][j] = k; } } } return m[i][j]; }
4)构造一个最优解
第三步中已经计算出来最小代价,并保存了相关的记录信息。因此只需对s表格进行递归调用展开既可以得到一个最优解。书中给出了伪代码,摘录如下:
PRINT_OPTIMAL_PARENS(s,i,j) if i== j then print "Ai" else print "("; PRINT_OPTIMAL_PARENS(s,i,s[i][j]); PRINT_OPTIMAL_PARENS(s,s[i][j]+1,j); print")";
4、编程实现
采用C++语言实现这个过程,现有矩阵A1(30×35)、A2(35×15)、A3(15×5)、A4(5×10)、A5(10×20)、A6(20×25),得到p=<30,35,15,5,10,20,25>。实现过程定义两个二维数组m和s,为了方便计算其第一行和第一列都忽略,行标和列标都是1开始。完整的程序如下所示:
#include <iostream> using namespace std; #define N 6 #define MAXVALUE 1000000 void matrix_chain_order(int *p,int len,int m[N+1][N+1],int s[N+1][N+1]); void print_optimal_parents(int s[N+1][N+1],int i,int j); int main() { int p[N+1] = {30,35,15,5,10,20,25}; int m[N+1][N+1]={0}; int s[N+1][N+1]={0}; int i,j; matrix_chain_order(p,N+1,m,s); cout<<"m value is: "<<endl; for(i=1;i<=N;++i) { for(j=1;j<=N;++j) cout<<m[i][j]<<" "; cout<<endl; } cout<<"s value is: "<<endl; for(i=1;i<=N;++i) { for(j=1;j<=N;++j) cout<<s[i][j]<<" "; cout<<endl; } cout<<"The result is:"<<endl; print_optimal_parents(s,1,N); return 0; } void matrix_chain_order(int *p,int len,int m[N+1][N+1],int s[N+1][N+1]) { int i,j,k,t; for(i=0;i<=N;++i) m[i][i] = 0; for(t=2;t<=N;t++) //当前链乘矩阵的长度 { for(i=1;i<=N-t+1;i++) //从第一矩阵开始算起,计算长度为t的最少代价 { j=i+t-1;//长度为t时候的最后一个元素 m[i][j] = MAXVALUE; //初始化为最大代价 for(k=i;k<=j-1;k++) //寻找最优的k值,使得分成两部分k在i与j-1之间 { int temp = m[i][k]+m[k+1][j] + p[i-1]*p[k]*p[j]; if(temp < m[i][j]) { m[i][j] = temp; //记录下当前的最小代价 s[i][j] = k; //记录当前的括号位置,即矩阵的编号 } } } } } //s中存放着括号当前的位置 void print_optimal_parents(int s[N+1][N+1],int i,int j) { if( i == j) cout<<"A"<<i; else { cout<<"("; print_optimal_parents(s,i,s[i][j]); print_optimal_parents(s,s[i][j]+1,j); cout<<")"; } }
程序测试结果如下所示:
5、总结
动态规划解决问题关键是分析过程,难度在于如何发现其子问题的结构及子问题的递归解。这个需要多多思考,不是短时间内能明白。在实现过程中遇到问题就是数组,数组的下标问题是个比较麻烦的事情,如何能够过合理的去处理,需要一定的技巧。
第十五章:动态规划--最长公共子序列
1、基本概念
一个给定序列的子序列就是该给定序列中去掉零个或者多个元素的序列。形式化来讲就是:给定一个序列X={x1,x2,……,xm},另外一个序列Z={z1、z2、……,zk},如果存在X的一个严格递增小标序列<i1,i2……,ik>,使得对所有j=1,2,……k,有xij = zj,则Z是X的子序列。例如:Z={B,C,D,B}是X={A,B,C,B,D,A,B}的一个子序列,相应的小标为<2,3,5,7>。从定义可以看出子序列直接的元素不一定是相邻的。
公共子序列:给定两个序列X和Y,如果Z既是X的一个子序列又是Y的一个子序列,则称序列Z是X和Y的公共子序列。例如:X={A,B,C,B,D,A,B},Y={B,D,C,A,B,A},则序列{B,C,A}是X和Y的一个公共子序列,但不不是最长公共子序列。
最长公共子序列(LCS)问题描述:给定两个序列X={x1,x2,……,xm}和Y={y1,y2,……,yn},找出X和Y的最长公共子序列。
2、动态规划解决过程
1)描述一个最长公共子序列
如果序列比较短,可以采用蛮力法枚举出X的所有子序列,然后检查是否是Y的子序列,并记录所发现的最长子序列。如果序列比较长,这种方法需要指数级时间,不切实际。
LCS的最优子结构定理:设X={x1,x2,……,xm}和Y={y1,y2,……,yn}为两个序列,并设Z={z1、z2、……,zk}为X和Y的任意一个LCS,则:
(1)如果xm=yn,那么zk=xm=yn,而且Zk-1是Xm-1和Yn-1的一个LCS。
(2)如果xm≠yn,那么zk≠xm蕴含Z是是Xm-1和Yn的一个LCS。
(3)如果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)
3、编程实现
现在采用C++语言实现上述过程,例如有两个序列X={A,B,C,B,D,A,B}和Y={B,D,C,A,B,A},求其最长公共子序列Z。完整程序如下所示:
#include <iostream> using namespace std; #define X_LEN 7 #define Y_LEN 6 #define EQUAL 0 #define UP 1 #define LEVEL 2 void lcs_length(char* X,char* Y,int c[X_LEN+1][Y_LEN+1],int b[X_LEN+1][Y_LEN+1]); void print_lcs(int b[X_LEN+1][Y_LEN+1],char *X,int i,int j); int main() { char X[X_LEN+1] = {' ','A','B','C','B','D','A','B'}; char Y[Y_LEN+1] = {' ','B','D','C','A','B','A'}; int c[X_LEN+1][Y_LEN+1]={0}; int b[X_LEN+1][Y_LEN+1] = {0}; int i,j; lcs_length(X,Y,c,b); for(i=0;i<=X_LEN;i++) { for(j=0;j<=Y_LEN;j++) cout<<c[i][j]<<" "; cout<<endl; } cout<<"The length of LCS is: "<<c[X_LEN][Y_LEN]<<endl; cout<<"The longest common subsequence between X and y is: "<<endl; print_lcs(b,X,X_LEN,Y_LEN); return 0; } //采用动态规划方法自底向上的进行计算,寻找最优解 void lcs_length(char* X,char* Y,int c[X_LEN+1][Y_LEN+1],int b[X_LEN+1][Y_LEN+1]) { int i,j; //设置边界条件,即i=0或者j=0 for(i=0;i<X_LEN;i++) c[i][0] = 0; for(j=0;j<Y_LEN;j++) c[0][j] = 0; for(i=1;i<=X_LEN;i++) for(j=1;j<=Y_LEN;j++) { if(X[i] == Y[j]) //满足递归公式第二条 { c[i][j] = c[i-1][j-1]+1; b[i][j] = EQUAL ; } else if(c[i-1][j] >= c[i][j-1]) //递归公式第三条 { c[i][j] = c[i-1][j]; b[i][j] = UP; } else { c[i][j] = c[i][j-1]; b[i][j] = LEVEL; } } } void print_lcs(int b[X_LEN+1][Y_LEN+1],char *X,int i,int j) { if(i==0 || j==0) return; if(b[i][j] == EQUAL) { print_lcs(b,X,i-1,j-1); cout<<X[i]<<" "; } else if(b[i][j] == UP) print_lcs(b,X,i-1,j); else print_lcs(b,X,i,j-1); }
程序测试结果如下所示:
第十五章:动态规划--最优二叉查找树
1、前言:
接着学习动态规划方法,最优二叉查找树问题。二叉查找树参考http://www.cnblogs.com/Anker/archive/2013/01/28/2880581.html。如果在二叉树中查找元素不考虑概率及查找不成功的情况下,可以采用红黑树或者平衡二叉树来搜索,这样可以在O(lgn)时间内完成。而现实生活中,查找的关键字是有一定的概率的,就是说有的关键字可能经常被搜索,而有的很少被搜索,而且搜索的关键字可能不存在,为此需要根据关键字出现的概率构建一个二叉树。比如中文输入法字库中各词条(单字、词组等)的先验概率,针对用户习惯可以自动调整词频——所谓动态调频、高频先现原则,以减少用户翻查次数,使得经常用的词汇被放置在前面,这样就能有效地加快查找速度。这就是最优二叉树所要解决的问题。
2、问题描述
给定一个由n个互异的关键字组成的有序序列K={k1<k2<k3<,……,<kn}和它们被查询的概率P={p1,p2,p3,……,pn},要求构造一棵二叉查找树T,使得查询所有元素的总的代价最小。对于一个搜索树,当搜索的元素在树内时,表示搜索成功。当不在树内时,表示搜索失败,用一个“虚叶子节点”来标示搜索失败的情况,因此需要n+1个虚叶子节点{d0<d1<……<dn},对于应di的概率序列是Q={q0,q1,……,qn}。其中d0表示搜索元素小于k1的失败结果,dn表示搜索元素大于kn的失败情况。di(0<i<n)表示搜索节点在ki和k(i+1)之间时的失败情况。因此有如下公式:
由每个关键字和每个虚拟键被搜索的概率,可以确定在一棵给定的二叉查找树T内一次搜索的期望代价。设一次搜索的实际代价为检查的节点个数,即在T内搜索所发现的节点的深度加上1。所以在T内一次搜索的期望代价为:
需要注意的是:一棵最优二叉查找树不一定是一棵整体高度最小的树,也不一定总是把最大概率的关键字放在根部。
(3)动态规划求解过程
1)最优二叉查找树的结构
如果一棵最优二叉查找树T有一棵包含关键字ki,……,kj的子树T',那么这棵子树T’对于对于关键字ki,……kj和虚拟键di-1,……,dj的子问题也必定是最优的。
2)一个递归解
定义e[i,j]为搜索一棵包含关键字ki,……,kj的最优二叉查找树的期望代价,则分类讨论如下:
当j=i-1时,说明此时只有虚拟键di-1,故e[i,i-1] = qi-1
当j≥i时,需要从ki,……,kj中选择一个跟kr,然后用关键字ki,……,kr-1来构造一棵最优二叉查找树作为左子树,用关键字kr+1,……,kj来构造一棵最优二叉查找树作为右子树。定义一棵有关键字ki,……,kj的子树,定义概率的总和为:
因此如果kr是一棵包含关键字ki,……,kj的最优子树的根,则有:
故e[i,j]重写为:
最终的递归式如下:
3)计算一棵最优二叉查找树的期望搜索代价
将e[i,j]的值保存到一个二维数组e[1..1+n,0..n]中,用root[i,j]来记录关键字ki,……,kj的子树的根,采用二维数组root[1..n,1..n]来表示。为了提高效率,防止重复计算,需要个二维数组w[1..n+1,0...n]来保存w(i,j)的值,其中w[i,j] = w[i,j-1]+pj+qj。数组给出了计算过程的伪代码:
OPTIMAL_BST(p,q,n) for i=1 to n+1 //初始化e和w的值 do e[i,i-1] = qi-1; w[i,i-1] = qi-1; for l=1 to n do for i=1 to n-l+1 do j=i+l-1; e[i,j] = MAX; w[i,j] = w[i,j-1]+pj+qj; for r=i to j do t=e[i,r-1]+e[r+1,j]+w[i,j] if t<e[i,j] then e[i,j] = t; root[i,j] = r; return e and root;
4)构造一棵最优二叉查找树
根据地第三步中得到的root表,可以递推出各个子树的根,从而可以构建出一棵最优二叉查找树。从root[1,n]开始向下递推,一次找出树根,及左子树和右子树。
4、编程实现
针对一个具体的实例编程实现,现在有5个关键字,其出现的概率P={0.15,0.10,0.05,0.10,0.20},查找虚拟键的概率q={0.05,0.10,0.05,0.05,0.05,0.10}。采用C++语言是实现如下:
#include <iostream> using namespace std; #define N 5 #define MAX 999999.99999 void optimal_binary_search_tree(float *p,float *q,int n,float e[N+2][N+1],int root[N+1][N+1]); void construct_optimal_bst1(int root[N+1][N+1],int i,int j); void construct_optimal_bst2(int root[N+1][N+1],int i,int j); int main() { float p[N+1] = {0,0.15,0.10,0.05,0.10,0.20}; float q[N+1] = {0.05,0.10,0.05,0.05,0.05,0.10}; float e[N+2][N+1]; int root[N+1][N+1]; int i,j; optimal_binary_search_tree(p,q,N,e,root); cout<<"各个子树的期望代价如下所示:"<<endl; for(i=1;i<=N+1;i++) { for(j=i-1;j<=N;j++) cout<<e[i][j]<<" "; cout<<endl; } cout<<"最优二叉查找树的代价为: "<<e[1][N]<<endl; cout<<"各个子树根如下表所示:"<<endl; for(i=1;i<=N;i++) { for(j=i;j<=N;j++) cout<<root[i][j]<<" "; cout<<endl; } cout<<"构造的最优二叉查找树如下所示:"<<endl; construct_optimal_bst1(root,1,N); cout<<"\n最优二叉查找树的结构描述如下:"<<endl; construct_optimal_bst2(root,1,N); cout<<endl; return 0; } void optimal_binary_search_tree(float *p,float *q,int n,float e[N+2][N+1],int root[N+1][N+1]) { int i,j,k,r; float t; float w[N+2][N+1]; for(i=1;i<=N+1;++i) //主表和根表元素的初始化 { e[i][i-1] = q[i-1]; w[i][i-1] = q[i-1]; } for(k=1;k<=n;++k) //自底向上寻找最优子树 for(i=1;i<=n-k+1;i++) { j = i+k-1; e[i][j] = MAX; w[i][j] = w[i][j-1]+p[j]+q[j]; for(r=i;r<=j;r++) //找最优根 { 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; } } } } void construct_optimal_bst1(int root[N+1][N+1],int i,int j) { if(i<=j) { int r = root[i][j]; cout<<r<<" "; construct_optimal_bst1(root,i,r-1); construct_optimal_bst1(root,r+1,j); } } void construct_optimal_bst2(int root[N+1][N+1],int i,int j) { if(i==1 && j== N) cout<<"k"<<root[1][N]<<"是根"<<endl; if(i<j) { int r = root[i][j]; if(r != i) cout<<"k"<<root[i][r-1]<<"是k"<<r<<"的左孩子"<<endl; construct_optimal_bst2(root,i,r-1); if(r!= j) cout<<"k"<<root[r+1][j]<<"是k"<<r<<"的右孩子"<<endl; construct_optimal_bst2(root,r+1,j); } if(i==j) { cout<<"d"<<i-1<<"是k"<<i<<"左孩子"<<endl; cout<<"d"<<i<<"是k"<<i<<"右孩子"<<endl; } if(i>j) cout<<"d"<<j<<"是k"<<j<<"右孩子"<<endl; }
程序测试结果如下所示:
动态规划方法之生成最优二叉查找树
1、概念引入
6、具体实现代码(其中所有数据都存放在2.txt中,其内容为:
其中5表示有5个节点,其他数据表示各个节点出现的概率;
#include<stdio.h> #include<stdlib.h> #define max 9999 void OptimalBST(int,float*,float**,int**); void OptimalBSTPrint(int,int,int**); void main() { int i,num; FILE *point; //所有数据均从2.txt中获取,2.txt中第一个数据表示节点个数;从第二个数据开始表示各个节点的概率 point=fopen("2.txt","r"); if(point==NULL) { printf("cannot open 2.txt.\n"); exit(-1); } fscanf(point,"%d",&num); printf("%d\n",num); float *p=(float*)malloc(sizeof(float)*(num+1)); for(i=1;i<num+1;i++) fscanf(point,"%f",&p[i]); //创建主表; float **c=(float**)malloc(sizeof(float*)*(num+2)); for(i=0;i<num+2;i++) c[i]=(float*)malloc(sizeof(float)*(num+1)); //创建根表; int **r=(int**)malloc(sizeof(int*)*(num+2)); for(i=0;i<num+2;i++) r[i]=(int*)malloc(sizeof(int)*(num+1)); //动态规划实现最优二叉查找树的期望代价求解。。 OptimalBST(num,p,c,r); printf("该最优二叉查找树的期望代价为:%f \n",c[1][num]); //给出最优二叉查找树的中序遍历结果; printf("构造成的最优二叉查找树的中序遍历结果为:"); OptimalBSTPrint(1,4,r); } void OptimalBST(int num,float*p,float**c,int**r) { int d,i,j,k,s,kmin; float temp,sum; for(i=1;i<num+1;i++)//主表和根表元素的初始化 { c[i][i-1]=0; c[i][i]=p[i]; r[i][i]=i; } c[num+1][num]=0; for(d=1;d<=num-1;d++)//加入节点序列 { for(i=1;i<=num-d;i++) { j=i+d; temp=max; for(k=i;k<=j;k++)//找最优根 { if(c[i][k-1]+c[k+1][j]<temp) { temp=c[i][k-1]+c[k+1][j]; kmin=k; } } r[i][j]=kmin;//记录最优根 sum=p[i]; for(s=i+1;s<=j;s++) sum+=p[s]; c[i][j]=temp+sum; } } } //采用递归方式实现最优根的输出,最优根都是保存在r[i][j]中的。。。 void OptimalBSTPrint(int first,int last,int**r) { int k; if(first<=last) { k=r[first][last]; printf("%d ",k); OptimalBSTPrint(first,k-1,r); OptimalBSTPrint(k+1,last,r); } }
7、最终运行结果:
8、参考文献:
(1)算法导论
(2)数据结构 严蔚敏
第十五章:动态规划总结
前言:
书中列举四个常见问题,分析如何采用动态规划方法进行解决。今天把动态规划算法总结一下。关于四个问题的动态规范分析过程可以参考前面的几篇日志,链接如下:
装配线调度问题:http://www.cnblogs.com/Anker/archive/2013/03/09/2951785.html
矩阵链乘问题:http://www.cnblogs.com/Anker/archive/2013/03/10/2952475.html
最长公共子序列问题:http://www.cnblogs.com/Anker/archive/2013/03/11/2954050.html
最优二叉查找树问题:http://www.cnblogs.com/Anker/archive/2013/03/13/2958488.html
1、基本概念
动态规划是通过组合子问题的解而解决整个问题的,通过将问题分解为相互不独立(各个子问题包含有公共的子问题,也叫重叠子问题)的子问题,对每个子问题求解一次,将其结果保存到一张辅助表中,避免每次遇到各个子问题时重新计算。动态规划通常用于解决最优化问题,其设计步骤如下:
(1)描述最优解的结构。
(2)递归定义最优解的值。
(3)按自底向上的方式计算最优解的值。
(4)由计算出的结果构造出一个最优解。
第一步是选择问题的在什么时候会出现最优解,通过分析子问题的最优解而达到整个问题的最优解。在第二步,根据第一步得到的最优解描述,将整个问题分成小问题,直到问题不可再分为止,层层选择最优,构成整个问题的最优解,给出最优解的递归公式。第三步根据第二步给的递归公式,采用自底向上的策略,计算每个问题的最优解,并将结果保存到辅助表中。第四步骤是根据第三步中的最优解,借助保存在表中的值,给出最优解的构造过程。
动态规划与分治法之间的区别:
(1) 分治法是指将问题分成一些独立的子问题,递归的求解各子问题。
(2) 动态规划适用于这些子问题不是独立的情况,也就是各子问题包含公共子问题。
2、动态规划基础
什么时候可以使用动态规范方法解决问题呢?这个问题需要讨论一下,书中给出了采用动态规范方法的最优化问题中的两个要素:最优子结构和重叠子结构。
1)最优子结构
最优子结构是指问题的一个最优解中包含了其子问题的最优解。在动态规划中,每次采用子问题的最优解来构造问题的一个最优解。寻找最优子结构,遵循的共同的模式:
(1)问题的一个解可以是做一个选择,得到一个或者多个有待解决的子问题。
(2)假设对一个给定的问题,已知的是一个可以导致最优解的选择,不必关心如何确定这个选择。
(3)在已知这个选择后,要确定哪些子问题会随之发生,如何最好地描述所得到的子问题空间。
(4)利用“剪贴”技术,来证明问题的一个最优解中,使用的子问题的解本身也是最优的。
最优子结构在问题域中以两种方式变化:
(1)有多少个子问题被使用在原问题的一个最优解中。
(2)在决定一个最优解中使用哪些子问题时有多少个选择。
动态规划按照自底向上的策略利用最优子结构,即:首先找到子问题的最优解,解决子问题,然后逐步向上找到问题的一个最优解。为了描述子问题空间,可以遵循这样一条有效的经验规则,就是尽量保持这个空间简单,然后在需要时再扩充它。
注意:在不能应用最优子结构的时候,就一定不能假设它能够应用。 警惕使用动态规划去解决缺乏最优子结构的问题!
使用动态规划时,子问题之间必须是相互独立的!可以这样理解,N个子问题域互不相干,属于完全不同的空间。
2)重叠子问题
用来解决原问题的递归算法可以反复地解同样的子问题,而不是总是产生新的子问题。重叠子问题是指当一个递归算法不断地调用同一个问题。动态规划算法总是充分利用重叠子问题,通过每个子问题只解一次,把解保存在一个需要时就可以查看的表中,每次查表的时间为常数。
由计算出的结果反向构造一个最优解:把动态规划或者是递归过程中作出的每一次选择(记住:保存的是每次作出的选择)都保存下来,在最后就一定可以通过这些保存的选择来反向构造出最优解。
做备忘录的递归方法:这种方法是动态规划的一个变形,它本质上与动态规划是一样的,但是比动态规划更好理解!
(1) 使用普通的递归结构,自上而下的解决问题。
(2) 当在递归算法的执行中每一次遇到一个子问题时,就计算它的解并填入一个表中。以后每次遇到该子问题时,只要查看并返回表中先前填入的值即可。
3、总结
动态规划的核心就是找到问题的最优子结构,在找到最优子结构之后的消除重复子问题。最终无论是采用动态规划的自底向上的递推,还是备忘录,或者是备忘录的变型,都可以轻松的找出最优解的构造过程。