資料結構與演算法-表示式二叉樹

BackSlash發表於2019-01-12
二叉樹的一種應用是無歧義地表示代數、關係或邏輯表示式。在上個世紀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());
}複製程式碼
好了,到目前為止,關於表示式二叉樹的內容已經探討完畢。
更多內容期待讀者在實踐中積累。
如果覺得有所收穫,希望關注筆者~


相關文章