之前两篇分别讲了动态规划的“钢管切割”和“矩阵链乘法”,感觉到了这一篇,也可以算是收官之作了。其实根据前两篇,到这里,也可以进行一些总结的,我们可以找到一些规律性的东西。
所谓动态规划,其实就是解决递归调用中,可能出现重复计算子问题,从而导致耗费大量时间,去做重复劳动的问题。解决思路就是,将重复做过的子问题的结果,先存起来,等之后再需要用到的时候,直接拿过来用,而不需要再去计算。
但是这里还需要注意一些地方:
①要解决的问题,比如“钢管切割”中的长钢管,它的最优化的解,要能够是分解成的子问题的最优化的解的组合。
②如何给出子问题,孙子问题等的最优化解的统一递归调用形式。
③找一个或者几个合适的集合,去保存我们求的子问题的解,或者是划分的方案。比如在钢管切割中,我们使用的一位数组,而到了矩阵链乘法中,我们使用的二维数组。
④采用自顶向下还是自底向上的方案。
一、概述
好,言归正传,来看看最优二叉搜索树。关于二叉搜索树,可以参看之前的两篇文章:二叉树的前中后序非递归遍历实现和二叉搜索树的插入和删除。这里不再赘述。
假如说我们要设计一个程序,来实现英语文本到法语的翻译。我们可以建立一个二叉搜索树,将n个英语残次作为关键字,对应的法语单词作为关联数据。然后我们依次遍历二叉搜索树中每个节点的英文单词,来找到合适的单词进行翻译工作。这里我们肯定是希望这个搜索的时间越少越好,如果只考虑单纯的时间复杂度,我们可以考虑采用红黑树等,来达到搜索时间复杂度为Olg(n)。但是对于英语单词而言,每个单词出现的概率是不一样的,比如"the"等单词。显然的让这类单词越靠近根结点越能减少搜索的时间。而对于某些很少见的单词,则可以让它们远离根结点。
问题来了,假如我们已知n个不同关键字ki以及我们的出现概率p[i],我们如何来组成这样一颗最优二叉搜索树呢?
查找这棵二叉树的结果不外乎是两种,一种是找到了我们想要的key值,另一种是遍历完整棵树都没有找到我们要的key值,我们将这种没有找到key值也安排一些节点di来表示,那么显然di节点都是叶子节点。称di为伪关键字,对应的概率为q[i]。d0代表所有小于k1的值,dn代表所有大于kn的值,其他的di(1<=i<=n-1),代表di值位于ki和ki+1之间。因为一次搜索,要么是搜到di,要么是搜到一个ki,所有有:
然后现在我们可以给出搜索一次会搜索到的节点数的一个期望,因为对于二叉树而言,每多一层,则就会多查找一个节点,所以我们可以对每个节点的深度加1,然后乘以该节点的pi,然后将所有节点的结果求和,就可以求出搜索一棵树会搜索到的节点的一个期望:
最优二叉搜索树的定义就是:对于给定的节点key值概率,使得上述期望值最小的二叉搜索树结构。
二、使用动态规划来处理最优二叉搜索树
那么还是那几步:
①首先将问题分成子问题,看看问题的最优是不是子问题也最优的时候可以达成。
可以使用“jianqie -粘贴”法来证明,对于一颗最优二叉搜索树T而言,对于T的一颗子树Ti,假设它包含关键字ki....kj,如果Ti不是最优二叉查找树,那么就意味着存在另一棵包含节点ki...kj的二叉搜索树Tj,使得Tj的搜索期望比Ti的搜索期望低,那么我们可以将Ti从T中“剪切”掉,然后将Tj“粘贴”到原来Ti的位置,这样一来,就发现新生成的树肯定要比原来的树T的搜索期望要低了,这是矛盾的,因为我们原来假设T是一棵最优二叉搜索树。所以可以反证,最优二叉搜索树的子树都是最优二叉搜索树。
于是我们可以确定,最优二叉搜索树问题,可以分解成子问题,而且子问题也是最优二叉搜索树问题,而又可以很容易的遇见,在分成的子问题中,会有多个重复的子问题,孙子问题等,所以满足动态规划的条件,从而可以使用动态规划方法来解决。
②其次要解决的是递归子问题的最优化解的统一形式(计算式)
这时又跟之前的“光管切割”和矩阵链乘法有些相似了,很容易看出,我们要将一棵树的搜索期望问题,分解成:左子树的搜索期望问题+根结点的搜索期望+右子树的搜索期望;对于根结点而言,它的深度为0,那么直接使用(0+1)*p[根的位置],即是搜索期望。而对于左右子树而言,如果它们是一棵单独的树,那么也可以直接使用(左子树的搜索期望问题+根结点的搜索期望+右子树的搜索期望)的形式来递归,但是这里,左右子树比简单的二叉树的情况要多一层深度,因为它们头顶上要多了一个根结点,所以说这里需要将每一个节点都多加上一个它们自身的出现概率p[i]或者q[i](这里包括了关键字节点和伪关键字节点),于是增加量为每棵树所有节点的概率之和。令w(i,j)表示包含节点ki...kj的树,所有节点的概率之和,于是有:
假如我们以r处的节点为根结点,有:
而根据上面的分析,我们将一棵树的搜索期望,假设根结点在Kr处,分解成根结点的概率p[r]和左子树的搜索期望加左子树的节点概率和:e(i,r-1)+w(i,r-1),右子树的搜索期望加上右子树的节点概率和:e(r+1,j)+w(r+1,j)。
这里还要考虑的一种情况,就是e(i,j),当j=i-1的情况,这时子树不可能包含关键字,因为关键字的序号i<=k<=j,而j=i-1,显然不满足。所以这时子树只会包含一个伪关键字节点:di-1。
于是根据上面两个公式,有:
这样就求出了我们需要的子问题递归统一形式了
③选用合适的数据结构来存储子问题的处理结果和划分信息。
这里我们需要保存的数据有:每个子树的最佳搜索期望值e(i,j),每个子树的最佳时刻根结点位置r(i,j),和每个子树的概率和的值:w(i,j)。
④采用自底向上的方式来解决子问题和父问题。
于是,按照之前“矩阵链乘法”的思路,容易给出下面的代码:
const int MaxVal = 0x7fffffff; const int n = 5; //搜索到根节点和虚拟键的概率 double p[n + 1] = { -1, 0.15, 0.1, 0.05, 0.1, 0.2 }; double q[n + 1] = { 0.05, 0.1, 0.05, 0.05, 0.05, 0.1 }; int root[n + 1][n + 1]; //记录根节点 double w[n + 2][n + 2]; //子树概率总和 double e[n + 2][n + 2]; //子树期望代价 /* * p是存放关键字节点概率的数组[1,n],q是存放伪关键字节点(叶子节点)出现概率的数组[0,n] * n是节点个数 * e[i,j]是存放包含关键字Ki~Kj的最优二叉书进行一次搜索的期望代价,由于要包括e[n+1,n]的q[n]和e[1,0]q[0],所以范围是[0,n+1] * w[i,j]是存放Ki~Kj的概率和,w[i,j]=w[i,r-1]+p[r]+w[r+1,j] * root[i,j]是存放包含Ki~Kj的关键字的子树,最优情况下,root节点的下标 */ void optimal_Bst(double* p, double* q, int n) { //首先处理w[i,j]和e[i,j]中i=j+1的情况,这种情况都是q[i-1] for (int i = 1; i <= n + 1; i++) { w[i][i - 1] = q[i - 1]; w[i][i - 1] = q[i - 1]; } //然后处理长度L从1到n的循环 int l = 0; //代表ki~kj的长度 int j = 0; //代表最后一个元素的下标值j int i = 0; //代表起始的一个元素的下标值i int r = 0; //代表root节点的下标 double tmp = 0; //这里要存储e数组元素计算的临时结果,所以也是double类型的 for (l = 1; l <= n; l++) { //以i为外层循环,这里由于长度是L,i最大为n-l+1,而j-i+1=L,j=L+i-1 for (i = 1; i <= n - l + 1; i++) { j = l + i - 1; //先初始化e[i][j]和w[i][j] e[i][j] = MaxVal; w[i][j] = w[i][j - 1] + p[j] + q[j]; for (r = i; r <= j; r++) { //公式:e[i][j] = e[i][r - 1] + e[r + 1][j] + w[i][j]; tmp = e[i][r - 1] + e[r + 1][j] + w[i][j]; if (tmp < e[i][j]) { e[i][j] = tmp; root[i][j] = r; } } } } }
上面算法的时间复杂度为O(n^3),我们可以作一点优化,让其变成O(n^2),这里需要利用到一个结论:
将optimal_Bst函数变成如下形式,改变对根结点位置r的遍历方式,当然,对于之前也要添加判断条件:
/* * 优化版 */ void optimal_Bst2(double* p, double* q, int n) { //首先处理w[i,j]和e[i,j]中i=j+1的情况,这种情况都是q[i-1] for (int i = 1; i <= n + 1; i++) { w[i][i - 1] = q[i - 1]; w[i][i - 1] = q[i - 1]; } //然后处理长度L从1到n的循环 int l = 0; //代表ki~kj的长度 int j = 0; //代表最后一个元素的下标值j int i = 0; //代表起始的一个元素的下标值i int r = 0; //代表root节点的下标 double tmp = 0; //这里要存储e数组元素计算的临时结果,所以也是double类型的 for (l = 1; l <= n; l++) { //以i为外层循环,这里由于长度是L,i最大为n-l+1,而j-i+1=L,j=L+i-1 for (i = 1; i <= n - l + 1; i++) { j = l + i - 1; e[i][j] = MaxVal; w[i][j] = w[i][j - 1] + p[j] + q[j]; if (i == j) { root[i][j] = i; e[i][j] = p[i] + q[i - 1] + q[j]; } else { //先初始化e[i][j]和w[i][j] for (r = root[i][j - 1]; r <= root[i + 1][j]; r++) { //公式:e[i][j] = e[i][r - 1] + e[r + 1][j] + w[i][j]; tmp = e[i][r - 1] + e[r + 1][j] + w[i][j]; if (tmp < e[i][j]) { e[i][j] = tmp; root[i][j] = r; } } } } } }
经过上面的修改之后,就可以达到O(n^2)的时间复杂度。
习题15.1-1,打印出上面最优二叉搜索树的每个节点与其父节点的关系的函数:
/* * 打印最优二叉搜索树 */ void print_optimal_Bst(int i, int j, int r, bool root_flag) { int root_node = root[i][j]; if (root_flag) { cout << "根结点为:k" << root_node << endl; root_flag = false; print_optimal_Bst(i, root_node - 1, root_node, root_flag); print_optimal_Bst(root_node + 1, j, root_node, root_flag); return; } if (i > j + 1) { return; } else if (i == j + 1) { if (j < r) { cout << "d" << j << "是" << "k" << r << "的左孩子" << endl; } else { cout << "d" << j << "是" << "k" << r << "的右孩子" << endl; } return; //这里加个return是因为,当循环到i==j+1的时候,已经到头了,不能再递归了,此时不存在合理的root_node了 } else { if (root_node < r) { cout << "k" << root_node << "是" << "k" << r << "的左孩子" << endl; } else { cout << "k" << root_node << "是" << "k" << r << "的右孩子" << endl; } } print_optimal_Bst(i, root_node - 1, root_node, root_flag); print_optimal_Bst(root_node + 1, j, root_node, root_flag); }
输出结果: