【算法导论】动态规划之“最优二叉搜索树”

之前两篇分别讲了动态规划的“钢管切割”“矩阵链乘法”,感觉到了这一篇,也可以算是收官之作了。其实根据前两篇,到这里,也可以进行一些总结的,我们可以找到一些规律性的东西。

所谓动态规划,其实就是解决递归调用中,可能出现重复计算子问题,从而导致耗费大量时间,去做重复劳动的问题。解决思路就是,将重复做过的子问题的结果,先存起来,等之后再需要用到的时候,直接拿过来用,而不需要再去计算。

但是这里还需要注意一些地方:

①要解决的问题,比如“钢管切割”中的长钢管,它的最优化的解,要能够是分解成的子问题的最优化的解的组合。

②如何给出子问题,孙子问题等的最优化解的统一递归调用形式。

③找一个或者几个合适的集合,去保存我们求的子问题的解,或者是划分的方案。比如在钢管切割中,我们使用的一位数组,而到了矩阵链乘法中,我们使用的二维数组。

④采用自顶向下还是自底向上的方案。

一、概述

好,言归正传,来看看最优二叉搜索树。关于二叉搜索树,可以参看之前的两篇文章:二叉树的前中后序非递归遍历实现二叉搜索树的插入和删除。这里不再赘述。

假如说我们要设计一个程序,来实现英语文本到法语的翻译。我们可以建立一个二叉搜索树,将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);
}

输出结果:

时间: 2024-10-25 09:41:46

【算法导论】动态规划之“最优二叉搜索树”的相关文章

动态规划之最优二叉搜索树

/* * 最优二叉搜索树 */ public class OptimalBST { private final int MAX=10000; private final int SCALE = 5; //树的规模 private double[][] e= null; //e[i][j]表示树ki..kj的期望代价 private double[][] w= null; //子树期望代价增加值 private int[][] root=null; //记录子树的根 private double[

算法导论笔记(5)二叉搜索树

二叉查找树简介 集合操作 search搜索 mininum寻找子树的最小key节点 maxnum子树最大key节点 predecessor前序寻找比此节点小的最大节点 succesor后序 insert插入 delete删除 c实现 二叉查找树简介 二叉查找树(Binary Search Tree),又被称为二叉搜索树. 它是特殊的二叉树:对于二叉树,假设x为二叉树中的任意一个结点,x节点包含关键字key,节点x的key值记为key[x].如果y是x的左子树中的一个结点,则key[y] <= k

最优二叉搜索树

OBST问题的解法是动态规划,用到了3层循环, 第一层循环变量是子树的节点个数 l 第二层循环的变量是子树的起点位置i,i即是子树的左边界,j是子树的右边界 第三层循环的变量是子树的根节点位置r 1 #include<stdio.h> 2 #include<stdlib.h> 3 #include<string.h> 4 #define INF 9999; 5 6 void Optimal_BST(double p[],int n){ 7 int i,j,l,r; 8

算法第四章:1.二叉搜索树

原文地址:https://www.cnblogs.com/chenming-1998/p/11703392.html

算法导论—动态规划

华电北风吹 天津大学认知计算与应用重点实验室 日期:2015/8/27 首先区分动态规划和分治策略. 这两者有很相似的地方,都是通过组合子问题的解来求解原问题.不同的是,分治策略将原问题划分为互不相交的子问题,递归的求解子问题,再将它们的解组合起来,求出原问题的解.与之相反,动态规划应用于子问题重叠的情况,即不同的子问题具有公共的子子问题(子问题的求解是递归进行的,将其划分为更小的子问题).在这种情况下,分治法会做许多不必要的工作,他会反复求解那些公共的子子问题.而动态规划算法对每个子子问题只求

算法导论——动态规划

动态规划指的是一个问题可以拆分成多个小的最优子问题,并且这些子问题具有重叠,典型的如斐波那契数列:f(2)=f(1)+f(0),f(3)=f(2)+f(1),f(4)=f(3)+f(2),若使用简单的递归算法求f(4),则f(2)会被计算两次,当计算f(n)时需要计算f(n-1)和f(n-2)而f(n-1)又要计算记一次f(n-2),如此往复时间复杂度为n的指数级别,导致算法效率低下.若能够记录f(2)至f(n-1)的结果,可以保证每一级计算都是O(1)复杂度,整个算法的时间复杂度就能下降至O(

二叉搜索树以及对二叉搜索树平衡调整

代码的思想和图片参考:好大学慕课浙江大学陈越.何钦铭的<数据结构> 我们首先介绍一下什么是二叉搜索树和二叉平衡树: 二叉搜索树:一棵二叉树,可以为空:如果不为空,满足以下性质1. 非空左子树的所有键值小于其根结点的键值.2. 非空右子树的所有键值大于其根结点的键值.3. 左.右子树都是二叉搜索树. 二叉搜索树操作的特别函数:Position Find( ElementType X, BinTree BST ):从二叉搜索树BST中查找元素X,返回其所在结点的地址,查找的次数取决于树的高度  

算法导论第十二章 二叉搜索树

一.二叉搜索树概览 二叉搜索树(又名二叉查找树.二叉排序树)是一种可提供良好搜寻效率的树形结构,支持动态集合操作,所谓动态集合操作,就是Search.Maximum.Minimum.Insert.Delete等操作,二叉搜索树可以保证这些操作在对数时间内完成.当然,在最坏情况下,即所有节点形成一种链式树结构,则需要O(n)时间.这就说明,针对这些动态集合操作,二叉搜索树还有改进的空间,即确保最坏情况下所有操作在对数时间内完成.这样的改进结构有AVL(Adelson-Velskii-Landis)

【算法导论】学习笔记——第12章 二叉搜索树

搜索树数据结构支持多种动态集合操作,包括SEARCH.MINIMUM.MAXIMUM.PREDECESSOR.SUCCESSOR.INSRT和DELETE操作等.基本的搜索树就是一棵二叉搜索树.12.1 什么是二叉搜索树1. 二叉搜索树的性质:设x是二叉搜索树中的一个结点.如果y是x左子树中的一个结点,那么y.key<=x.key.如果y是x右子树中的一个结点,那么y.key>=x.key.三种遍历时间复杂度是O(n),这是显然的. 12.1-3 1 void Inorder_Tree_Wal