数据结构与算法-表达式二叉树

2,866 阅读15分钟
二叉树的一种应用是无歧义地表示代数、关系或逻辑表达式。在上个世纪20年代初期,波兰的逻辑学家发明了一种命题逻辑的特殊表示方法,允许从公式中删除所有括号,称之为波兰表示法。但是,与原来带括号的公式相比,使用波兰表示法降低了公式的可读性,没有得到广泛的使用。在计算机出现后,这一表示法就很有用了,特别是用于编写编译器和解释器。
想要理解表达式二叉树首先要理解波兰表达式。
先从我们熟悉的公式表达方法开始。
假如现在有一个数学公式:(2-3)*(4+5)
以上公式必须借助括号,我们才能理解到该公式首先需要计算出2-3和4+5的值,最后相乘才能得出结果。试想一下,如果没有括号,没有优先级的概念,对于2-3*4+5就会有多种理解方式,这就是所谓的歧义。前人为了避免这种歧义,就创造了括号以及优先级的概念,可以让我们以唯一的方式来解读公式。但是,如果仅仅是为了避免歧义,可以改变公式中使用符号的顺序,从而省略括号以及优先级的概念,更加的简练。这就是编译器所做的工作。编译器抛弃了一切对理解公式正确含义所不必要的东西,以最简练的方式来表达公式。
以上公式如果抛弃括号以及优先级的概念,仅仅改变符号的顺序,可以这样表示:
*-23+45
公式中的操作符提前了,每个操作符后面跟着两个操作数,从左向右遍历就可以得到唯一的计算步骤,就像这样:
根据就近原则,显然先计算A,再计算B,最后计算C。当我们从左向右遍历的时候,每遇到一个操作符,它后面必然紧邻着两个相对应的操作数。也许有人会疑问,上图中*号后面紧邻着-号并不是操作数,其实-号代表着它会计算出一个临时的操作数tmp1作为*号的第一个操作数。因此,我们只需要把以上公式从左向右遍历一遍,就能知道该公式如何计算。编译器在将高级语言翻译成汇编代码时就是这么干的。
如果将操作符放在操作数的前面,可以得到一种不需要括号和优先级的表达方式,这就是波兰表达式。显然,波兰表达式非常简练,但是降低了公式的可读性,并不能一眼看出公式的结构,导致难以理解。与波兰表达式对应的还有一种表达式,那就是将操作符放在两个操作数的后面,称之为逆波兰表达式。根据操作符的位置,波兰表达式又被称之为先缀表达式,我们平时使用的表达式称之为中缀表达式,逆波兰表达式称之为后缀表达式。
其中,先缀表达式与后缀表达式都是没有歧义的表达式,而中缀表达式如果不借助括号以及优先级会产生歧义,但是中缀表达式容易理解。因为中缀表达式中很容易看出基本计算单元,所谓基本计算单元指的是一个操作符加上两个操作数,这是计算的最小单位。
编译器需要将用户输入的公式转换成先缀表达式或后缀表达式,但是怎么做到呢?
答案是二叉树,怎么就从公式想到二叉树了呢?这就要说到基本计算单元了,在基本计算单元中肯定有一个操作符来组织相关操作数,其次该基本计算单元的计算结果又可能是另一个基本计算单元的操作数。想想二叉树中的节点有什么性质,节点既是一颗树的根节点,同时也是另一棵树的子节点,所以基本计算单元不就可以看成一个根节点挂着两个子节点嘛。
(2-3)*(4+5)组织成二叉树看起来是这样:
以上的二叉树称之为表达式二叉树。表达式二叉树有些特性,所有的叶子节点都是操作数,所有的非叶子节点都是操作符。这很容易理解,在基本计算单元中,操作符是核心,同时计算结果是另一个基本计算单元的操作数,反映到二叉树中,操作符既是子树的根节点同时也是另一颗子树的子节点,那就是非叶子节点。
在以上表达式二叉树中,操作符是一棵树的根节点,左子树是该操作符的第一个操作数,右子树是该操作符的第二个操作数。还记得二叉树的先序、中序、后序遍历吗?不知道的看这里数据结构与算法-二叉树遍历。先序就是先输出树的根节点其次是左子树最后是右子树,反映到公式中,不就是先输出操作符再输出第一个操作数最后是第二个操作数嘛。看来你想到了,表达式二叉树的先序遍历结果就是先缀表达式。同理,中序遍历是中缀表达式,后序遍历是后缀表达式。就像这样:
  • 先序遍历: * - 2 3 + 4 5
  • 中序遍历: 2 - 3 * 4 + 5
  • 后序遍历: 2 3 - 4 5 + *
可以看到,如果将公式用表达式二叉树组织,那么先序就可以获取先缀表达式,中序就可以获取中缀表达式,后序就可以获取后缀表达式。但是,这里有个缺陷,中序遍历结果是没有考虑优先级以及括号的,所以结果是有歧义的。不过这不是问题,我们可以通过判断来添加括号,这在后面探讨。
到目前为止,我们已经探讨过什么是波兰表达式以及波兰表达式和表达式二叉树的关系,我们也懂得可以通过表达式二叉树来获取先缀、中缀、后缀表达式。但是,我们总不能每次看到中缀表达式都要通过画出二叉树来求解先缀以及后缀表达式吧,这里给出一个人工快速求解的方式。
如果有以下中缀表达式:
(2-3)*(4+5)
为了快速求取先缀以及后缀表达式,我们首先把括号补全,变成下面这样:
((2-3)*(4+5))
然后把所有操作符放在它所对应的左括号的前面,就是这样:
*(-(2 3)+(4 5))
最后把括号去掉,变成这样:
* - 2 3 + 4 5
这就是先缀表达式,同理可以获取后缀表达式。
通过以上方式,我们完全可以心算出先缀以及后缀表达式,非常方便。
好了,现在的问题是如何通过先缀、中缀以及后缀表达式来构建表达式二叉树,这也可以看成3个问题,再加上如何正确输出中缀表达式,就是4个问题了。我们来一一探讨。
  • 先缀表达式获取二叉树
老规矩,首先观察先缀表达式的特点,然后总结规律写出算法。
如果有以下先缀表达式:
* - 2 3 + 4 5
为了结构化观察上面公式,画出基本计算单元,就像这样:
看到了吗,如果以基本计算单元为核心,观察先缀表达式,这就是个栈。
我们从左往右遍历先缀表达式,发现操作符就将其入栈,发现操作符的第二个操作数之后,将它们组织成最小的子树,然后操作符出栈,继续遍历下一个字符。在这个过程中,操作数是不入栈的,栈里只有操作符,当操作符组织成最小计算单元之后就将其出栈。当栈空的时候,说明先缀表达式遍历完毕。
代码如下:
void ExpressionBinaryTree::buildBTreeByPreffixE()
{
	root = new BinaryTreeNode<string>();
	char c;
	cout << "->请输入前缀表达式,以=结尾." << endl;
	cout << "->:";
	cin >> c;
	stack<BinaryTreeNode<string> *> parentStack;//用于保存存放父结点
	BinaryTreeNode<string> *pointer = root;//用于指向下一个保存数据的结点
	string blankStr = "";
	double tempDouble = 0;
	string tempStr;//用于输入流,将浮点数转换成字符串
	while (c != '=')
	{
		switch (c)
		{
		case '+':
		case '-':
		case '*':
		case '/':
			pointer->setValue(c + blankStr);//设置当前结点的值
			pointer->setLeftChild(new BinaryTreeNode<string>());//生成左结点
			parentStack.push(pointer);
			pointer = pointer->getLeftChild();
			break;
		}
		if (isdigit(c))
		{
			std::cin.putback(c);
			std::cin >> tempDouble;
			stringstream sss;
			sss << tempDouble;
			sss >> tempStr;
			pointer->setValue(tempStr);
			pointer = parentStack.top();
			while (pointer->getRightChild() != NULL)
			{
				parentStack.pop();//找到按前序遍历的下一个结点
				if (parentStack.empty())
					return;
				pointer = parentStack.top();
			}
			pointer->setRightChild(new BinaryTreeNode<string>());//找到了按前序遍历的下一个结点位置并生成结点
			pointer = pointer->getRightChild();
		}
		std::cin >> c;
	}
}
  • 后缀表达式获取二叉树
后缀表达式获取二叉树的逻辑和上面的差不多,但也有几点改变。首先,由于操作符在操作数后面,在寻找基本计算单元的过程中,将前两个操作数入栈,在找到操作符之后,组织成最小的子树,然后将操作数出栈即可。
代码如下:
void ExpressionBinaryTree::buildBTreeBySuffixE()
{
	char c;
	cout << "->请输入后缀表达式,以=结尾." << endl;
	cout << "->:";
	cin >> c;
	stack<BinaryTreeNode<string> *> opdStack;//抽象意义上为操作数栈,但实际为操作数和操作符构成的结点栈
	double tempDouble = 0;
	string tempStr;//用于输入流,将浮点数转换成字符串
	string blankStr = "";
	while (c != '=')
	{
		switch (c)
		{
		case '+':
		case '-':
		case '*':
		case '/':
			BinaryTreeNode<string> *secondOpd = opdStack.top();
			opdStack.pop();
			BinaryTreeNode<string> *firstOpd = opdStack.top();
			opdStack.pop();
			opdStack.push(new BinaryTreeNode<string>(c + blankStr, firstOpd, secondOpd));
			break;
		}
		if (isdigit(c))
		{
			std::cin.putback(c);
			std::cin >> tempDouble;
			stringstream sss;
			sss << tempDouble;
			sss >> tempStr;
			opdStack.push(new BinaryTreeNode<string>(tempStr));
		}
		std::cin >> c;
	}
	root = opdStack.top();//此时操作数栈中唯一元素即为根元素
	opdStack.pop();
}
  • 中缀表达式获取二叉树
中缀表达式获取二叉树的逻辑比较麻烦,因为括号以及优先级的处理让算法变得复杂。我们可以从没有括号的简单的中缀表达式分析,假如有以下中缀表达式:
2 + 3 * 4 / 2
我们在计算以上表达式时,首先计算4 / 2的结果为22成了*号的第二个操作数,然后计算3 * 2的结果为66成了+号的第二个操作数,最后计算2 + 6得出结果为8
发现规律了吗,如果从右开始计算,每次计算结果都是下一个操作符的第二个操作数,那么遍历结束之后,结果就出来了。用代码实现可以用两个栈,一个栈保存从左到右的操作符,另一个栈保存从左到右的操作数,就像这样:
然后我们每次从操作符栈取出栈顶的操作符,再从操作数栈取出栈顶的两个操作数,将它们组成最小的子树,然后当做新的操作数压入到操作数栈中,重复上面的过程直到栈空,最终表达式二叉树构建出来了。
上面的中缀表达式太简单了,我们换个更复杂的看看算法该如何改进,假如有以下中缀表达式:
2 + 3 * 4 - 2
如果还按照上面的算法来计算,最终计算成了2 + 3 * ( 4 - 2 ),为什么会这样呢?因为*号的优先级高于-号,应该先计算*号再计算-号,怎么处理呢?解决方法也很简单,我们在将-号压入栈的过程中,发现-号的优先级低于*号。这时,将*号弹出,同时将操作数栈顶的两个操作数弹出,组成最小子树压入操作数栈,最后变成这样:
非常完美,我们只是对算法进行了小小的改动就能处理优先级的问题了,再接在励,如何处理括号呢?假如有以下中缀表达式:
2 + ( 4 - 2 ) * 3
发现了吗?其实括号也是优先级的问题,在上面的表达式中,( 4 -2 )的优先级比*号还高,我们在处理括号时按照处理优先级问题的逻辑就行,也就是说右括号的优先级是最高的。在压入右括号的时候,不用看后面的操作符了,右括号就是最高的,应该直接将从左括号到右括号中的表达式组成子树,然后压入到操作数栈中,结果是这样:
非常完美,我们将括号问题转化成优先级问题,很轻松的解决了该问题。到目前为止,我们已经解决了中缀表达式中优先级以及括号的问题,没有更复杂的情况了,目前的算法已经够用了。
代码如下:
//比较优先级
bool ExpressionBinaryTree::aIsGreaterOrEqualThanB(char a, char b)
{
	switch (a)
	{
	case '*':
	case '/':
		return true;
	case '+':
	case '-':
		if (b == '*' || b == '/')
			return false;
		return true;
	case '(':
		return false;
	}
	return false;
}

//中缀表达式转换成二叉树
void ExpressionBinaryTree::buildBTreeByInfixE()//构造中缀表达式二叉树
{
	root = new BinaryTreeNode<string>();
	char c;
	cout << "->请输入中缀表达式,以=结尾." << endl;
	cout << "->:";
	cin >> c;
	stack<BinaryTreeNode<string> *> opd;//操作数栈 //为了方便统一管理,操作数和操作符全部定义为string类型
	stack<string> opt;//操作符栈
	double tempDouble = 0;
	string tempStr;//用于输入流,将浮点数转换成字符串
	string blankStr = "";
	while (c != '=')
	{
		switch (c)
		{
		case '+':
		case '-':
		case '*':
		case '/':
			while (!opt.empty() && aIsGreaterOrEqualThanB(opt.top().c_str()[0], c))//如果栈顶操作符优先级高于读入操作符优先级,则表名应该先计算栈顶操作符
			{
				BinaryTreeNode<string> *secondOpd = opd.top();
				opd.pop();
				BinaryTreeNode<string> *firstOpd = opd.top();
				opd.pop();//从操作数栈取出两个操作数
				opd.push(new BinaryTreeNode<string>(opt.top(), firstOpd, secondOpd));//将操作数和操作符组成一个新结点存入栈中
				opt.pop();
			}
			opt.push(c + blankStr);//将读入操作符入栈
			break;
		case '(':
			opt.push(c + blankStr);//遇到左括号直接入栈
			break;
		case ')':
			while (!opd.empty() && opt.top().c_str()[0] != '(')//为了防止冗赘括号,但未检测括号不匹配
			{
				BinaryTreeNode<string> *secondOpd = opd.top();
				opd.pop();
				BinaryTreeNode<string> *firstOpd = opd.top();
				opd.pop();//从操作数栈取出两个操作数
				opd.push(new BinaryTreeNode<string>(opt.top(), firstOpd, secondOpd));//将操作数和操作符组成一个新结点存入栈中
				opt.pop();
			}
			opt.pop();//将左括号出栈
			break;
		}
		if (isdigit(c))
		{
			std::cin.putback(c);
			std::cin >> tempDouble;
			stringstream sss;
			sss << tempDouble;
			sss >> tempStr;
			opd.push(new BinaryTreeNode<string>(tempStr));
		}
		std::cin >> c;
	}
	while (!opt.empty())
	{
		BinaryTreeNode<string> *secondOpd = opd.top();
		opd.pop();
		BinaryTreeNode<string> *firstOpd = opd.top();
		opd.pop();//从操作数栈取出两个操作数
		opd.push(new BinaryTreeNode<string>(opt.top(), firstOpd, secondOpd));//将操作数和操作符组成一个新结点存入栈中
		opt.pop();
	}
	root = opd.top();//此时操作数栈中唯一元素即为根元素
	opd.pop();
}
  • 正确输出中缀表达式
还有最后一个问题,在中序遍历表达式二叉树时,如何正确的输出括号?
我们使用递归方式输出中序遍历结果,在整个过程中只涉及到3个节点,分别是根节点、左子树以及右子树。
正确输出括号需要分类讨论。比如说:
1、如果根节点是+号,那么无论左子树以及右子树是什么操作符,它们都是不需要加括号的,因为根节点+号是最小优先级的
2、如果根节点是-号,那么只有右子树是+号或者-号时,右子树才需要加括号
3、如果根节点是*号,那么只有左子树或右子树是+号或者-号时,它们才需要加括号
4、如果根节点是/号,那么如果左子树或右子树是+号或者-号时,它们需要加括号,其次,如果右子树是*号或者/号时,右子树也需要加括号
以上是所有需要加括号的情况,我们只需要在遍历左子树或者右子树之前判断一下,就知道是否加括号了。
代码如下:
//是否应该输出括号
bool ExpressionBinaryTree::shouldPrintBracket(BinaryTreeNode<string> *pointer, int leftOrRight)
{
	if (pointer == NULL)
		return false;
	BinaryTreeNode<string> *left = pointer->getLeftChild();
	BinaryTreeNode<string> *right = pointer->getRightChild();
	if (left == NULL || right == NULL)
		return false;
	string pointerValue = pointer->getValue();
	string leftValue = left->getValue();
	string rightValue = right->getValue();
	if (leftOrRight == LEFT)//如果pointer是左结点
	{
		switch (pointerValue[0])
		{
		case '*':
		case '/':
			if (leftValue[0] == '+' || leftValue[0] == '-')
				return true;
		}
	}
	else if (leftOrRight == RIGHT)//如果pointer是右结点
	{
		switch (pointerValue[0])
		{
		case '*':
			if (rightValue[0] == '+' || rightValue[0] == '-')
				return true;
			break;
		case '/':
			if (rightValue[0] == '+' || rightValue[0] == '-' || rightValue[0] == '*' || rightValue[0] == '/')
				return true;
			break;
		case '-':
			if (rightValue[0] == '+' || rightValue[0] == '-')
				return true;
			break;
		}
	}
	return false;
}

void ExpressionBinaryTree::recursionPrintInE(BinaryTreeNode<string> * root)//递归调用打印后缀表达式
{
	if (root == NULL)
		return;
	if (shouldPrintBracket(root, LEFT)){
		cout << "( ";
		recursionPrintInE(root->getLeftChild());
		cout << ") ";
	}
	else
		recursionPrintInE(root->getLeftChild());
	cout << root->getValue() << " ";
	if (shouldPrintBracket(root, RIGHT)){
		cout << "( ";
		recursionPrintInE(root->getRightChild());
		cout << ") ";
	}
	else
		recursionPrintInE(root->getRightChild());
}
好了,到目前为止,关于表达式二叉树的内容已经探讨完毕。
更多内容期待读者在实践中积累。
如果觉得有所收获,希望关注笔者~