在(7)的末尾,我們提到了棧可以用於實現計算器,並且我們給出了存儲表達式的數據結構,如下:
//SIZE用於多個場合,如棧的大小、表達式數組的大小 #define SIZE 1000 //表達式的單個元素所使用的結構體 typedef struct elem { int num = 0; //若元素存儲操作數則num為該操作數 char oper = '='; //若元素存儲操作符則oper為該操作符 bool IsNum = false; //用於判斷元素是否為操作數 }Elem; Elem Expression[SIZE];
可能有讀者會疑惑我們為什么將num定義為int,我們這么做的原因是為了簡便,或者說就是偷懶吧,因為如果要支持使用者輸入小數,那么我們的程序在獲取、處理輸入方面的代碼會更加復雜一點╮(╯_╰)╭。關於如何獲取、處理輸入,我們將在本文的最后給出答案。同時也會給出完整的計算器程序代碼,或者說是給出完整的只支持整數輸入的、不具備查錯糾錯能力的四則運算計算器
目前,我們先將獲取、處理輸入的問題放在一邊,先關注於計算器實現的“核心部分”,或者說需要運用棧的部分。
對於只有兩個操作數的表達式,如a+b、a-b、a*b、a/b,計算起來是很簡單的。因為已經確定了是兩個操作數,所以處理表達式的步驟就是:取第一個操作數,確定操作符,取第二個操作數,計算(即依次計算);或者說是:取操作符,取左、右操作數,計算。這樣的簡單的表達式不需要什么特別的技巧,自然也用不上“先進后出”的棧。
對於有多個操作數,但操作符優先級一樣的表達式,如a+b+c+d、a-b-c+d之類的,計算起來也很簡單,步驟類似於上一段,依然是依次計算,這樣的表達式依然不需要什么特別的技巧(也就是不需要用棧)。
但是,對於四則運算混合的表達式,如a+b*c-d之類的,計算就不能再像之前那樣了(即依次計算),因為我們要考慮到優先級的問題。顯然的,如果我們取到a、'+'、b后就直接計算a+b的值是錯的,這違背了運算符的優先級順序。更甚者,如a+b*(c-d)這樣帶括號的,我們的優先級處理還會更麻煩。那么我們該怎么應對這樣的表達式呢?顯然,我們一直在談的棧要准備出場了!
但是在介紹棧如何解決計算表達式問題之前,我們還要先介紹一些預備知識。
對於混合的四則運算表達式,處理的難點顯然就是操作符的優先級問題(當然,還有不屬於運算符的操作符,'('和')'的處理問題)。回顧前兩種表達式(即沒有優先級問題的那兩種特殊的表達式),我們發現,對於“依次計算”(沒有優先級問題)的表達式,我們處理起來是非常方便的,因此,我們是不是可以找一找,看有沒有辦法將存在優先級問題的表達式轉換為“依次計算”的表達式呢?
答案是有的,我們很輕松地就能看出a+b*c-d可以變為b*c+a-d,而帶括號的也可以去掉括號,如a+b*(c-d)=c-d*b+a(從數學角度來說這個表達式不等於原先帶括號的,但是我們扮演的角色為計算機,或者說我們的要求為“依次計算”,不考慮優先級)。但是要讓計算機做到我們剛才做的這種轉換,嗯,不是不可以,但是很難。不過別灰心,我們可以將表達式轉換為另一種符合“依次計算”的特殊表達式(日常生活中見不到的表達式),而且轉換過程很輕松,並且轉換后得到的表達式對於編程實現來說比之前的兩種更輕松!
讓我們先把如何轉換暫且壓下,先來看看這轉換后的特殊表達式長什么樣,有什么稱呼。
首先我們要明白,表達式總是由操作數和操作符組成的,且一個操作符總是對應了兩個操作數,比如a+b=中'+'對應a和b。而像a+b*c=這樣的多操作符表達式中,'*'對應b和c,'+'則對應a和b*c的結果,依然是一個操作符對應兩個操作數,因為一個操作符和兩個操作數即一個結果、一個新的數。
既然一個操作符對應兩個操作數,那么就帶來一個選擇問題:操作符寫在哪?這個問題可能世界上99%的人都沒考慮過,因為我們都已經習慣了“將操作符寫在兩個操作數的中間”,比如“a加上b”的表達式的“寫法”就是a+b,將'+'寫在a和b的中間。“a加上b再加上c”則寫作a+b+c,我們將'+'寫在a和b之間,然后再將'+'寫在a+b(一個結果、數)和c之間。
其實上面這種常見的“放置操作符位置”的方式或者說寫法是有名稱的,叫做“中綴表達式”,‘綴’意味着“操作符相對於操作數所放置的位置”,“中綴”也就是“操作符放在操作數的中間”。
相應的,世界上肯定也存在前綴表達式和后綴表達式,即了操作符放在兩個操作數的前面和后面的“寫法”。比如中綴表達式a+b的前綴寫法是+ab,后綴寫法是ab+。而a+b+c的前綴寫法是++abc,后綴寫法是abc++。
注意,由於前綴、中綴、后綴表達式(以后可能略掉“表達式”三字)只不過是表達式的不同寫法,所以任一中綴表達式必然存在效果、意義相同的前綴、后綴表達式(類似於用不同語言表達同一意思,如who are you和你是誰都是一個意思,但“寫法”不一樣)。而我們現在想要的,就是那個后綴表達式。為什么我們想要后綴表達式呢?因為后綴表達式相比於中綴表達式有一個非常重要的區別:
后綴表達式是從左向右“依次計算”,沒有優先級的!
而回顧我們之前所說,我們要找的正是一個沒有優先級的等價的表達式,而沒有優先級、等價這兩點后綴表達式都可以做到。最關鍵的是,相比於將有優先級中綴表達式轉換為無優先級中綴表達式,將一個中綴表達式轉換為后綴表達式是比較簡單的。同時,對后綴表達式進行計算也比較簡單。而且,轉換和計算都是利用棧的技術!
在我們講解如何將中綴表達式轉換為后綴表達式之前,我們先來說說對於一個后綴表達式,我們是如何計算的。首先我們確定一點,計算后綴表達式需要一個棧,而且計算過程需要的是一個操作數棧,計算順序就是:將后綴表達式從左到右依次遍歷,如果當前元素為數字則入(操作數)棧,如果為操作符,則pop出棧頂兩個元素(第一次pop出的是右操作數,第二次pop出的是左操作數)進行運算,然后將計算結果再次入棧,直至表達式結束,此時操作數棧內理應只剩一個元素即表達式結果。
比如,與a+b等價的ab+,我們遇到a,入棧
遇到b,入棧
遇到'+',pop得到b作為右操作數,再pop得到a作為左操作數(誰左誰右需要注意,減法、除法時弄反了結果將是錯的),進行a+b運算,得出結果入棧
然后表達式到了結尾,所以pop出棧內唯一元素即結果。
再比如,與a+b*c等價的abc*+,我們遇到a、b、c,依次入棧
遇到'*',pop出c作為右操作數,pop出b作為左操作數,進行b*c運算后將結果入棧
遇到'+',pop出b*c的結果(假設為d)作為右操作數,pop出a作為左操作數,進行a+d運算,然后將結果入棧
此時表達式結束,棧內只剩一個元素,即表達式結果。而且很顯然的,這個結果是對的!
至此,我們已經確定了兩件事情:
1.中綴表達式必然存在后綴表達法
2.后綴表達式不存在優先級問題,只需利用棧進行“從左至右依次計算”即可
為了強化對后綴表達式計算方法的記憶(因為后面還有不少篇幅),我們再說一次后綴表達式的計算方法,就是:將后綴表達式從左到右依次遍歷,如果當前元素為數字則入(操作數)棧,如果為操作符,則pop出棧頂兩個元素(第一次pop出的是右操作數,第二次pop出的是左操作數)進行運算,然后將計算結果再次入棧,直至表達式結束,此時操作數棧內理應只剩一個元素即表達式結果。
其實前綴表達式也沒有優先級問題,但是我們沒有選擇它,原因是實現中綴轉換為前綴以及計算前綴表達式都不太符合我們的習慣,需要將表達式從右往左倒着遍歷,既然我們有符合習慣又不會更差的后綴表達式可用,那么我們就用后綴表達式嘍
既然現在我們已經知道了如何對后綴表達式進行計算,那么我們就可以先寫出計算器程序中的一個模塊來,也就是負責計算后綴表達式的模塊,我們將其命名為calculate()。至於獲取輸入、轉換表達式,我們將它們作為其它模塊,到時再處理。
//兩個表達式數組,Infix開頭的暫存中綴表達式,Postfix開頭的存儲用於計算的后綴表達式 Elem InfixExpression[SIZE]; Elem PostfixExpression[SIZE]; //用於計算后綴表達式的操作數棧,topNum指向操作數棧的棧頂(空位置,不是棧頂那個元素的位置) //棧內元素為double是因為int/int有可能得出小數 double StackNum[SIZE]; size_t topNum = 0; //操作數棧的push void pushNum(double e) { StackNum[topNum++] = e; } //操作數棧的pop double popNum() { return StackNum[--topNum]; } //雖然我們的結構體中使用的是int,但是int/int也有可能出現小數,所以我們返回值使用double double calculate() { //遍歷后綴表達式數組,輸出后綴表達式,沒有特殊目的,只是方便我們“檢查”一下后綴表達式 for (size_t i = 0;i < SIZE;++i) { if (!PostfixExpression[i].IsNum && PostfixExpression[i].oper == '=') { printf("%c\n", '='); break; } else if (PostfixExpression[i].IsNum) printf("%d", PostfixExpression[i].num); else printf("%c", PostfixExpression[i].oper); } //后綴表達式的計算過程,遍歷整個后綴表達式數組,理論上我們中途會遇到'='跳出遍歷,如果沒有,好吧,程序要出錯了 for (size_t i = 0;i < SIZE;++i) { //如果當前元素不是數字且其oper=='=',則說明表達式到末尾了,此時我們彈出棧內元素(理應為唯一一個)作為計算結果返回 if (!PostfixExpression[i].IsNum&&PostfixExpression[i].oper == '=') return popNum(); //如果當前元素為數字,則我們將其轉換為double型並入棧 else if (PostfixExpression[i].IsNum) pushNum((double)(PostfixExpression[i].num)); //如果當前元素不是數字也不符合oper=='='就說明其是一個運算符(不會是括號,后綴表達式沒有括號) //此時我們按計算方式pop出棧頂兩元素進行計算並將結果重新壓入棧 else { //temp用於暫存棧頂兩元素的計算結果 double temp = 0.0; //注意,第一次popNum得到的應作為右操作數,第二次popNum得到的作為左操作數,我們分別記為rear和front double rear = popNum(); double front = popNum(); //根據當前元素選擇應進行的運算,並將結果入棧 switch (PostfixExpression[i].oper) { case '+': temp = front + rear; pushNum(temp); break; case '-': temp = front - rear; pushNum(temp); break; case '*': temp = front*rear; pushNum(temp); break; case '/': temp = front / rear; pushNum(temp); break; } } } //此處的return 0;只是為了防止編譯器報錯 return 0; }
注意一下,上面的代碼中有兩個表達式數組,一個存儲中綴表達式,一個存儲后綴表達式,我們的calculate()只對后綴表達式數組進行操作,中綴表達式數組我們只是暫且先定義出來以備之后要用。
好了,現在我們已經能夠處理后綴表達式了,接下來的問題就是如何由中綴表達式獲得等價的后綴表達式。從程序設計的角度來說,最方便的方法當然是使用者直接鍵入后綴表達式,這樣就只需要獲取、處理輸入,而不需要再進一步轉換。但是這顯然是不可能的,別想了╮(╯_╰)╭
我們之前說過,將中綴轉換為后綴是很簡單的,而且也是利用棧的技術,現在我們就來說說具體是如何利用棧來實現轉換的。
首先確定一點,計算后綴表達式我們用到的棧是操作數棧,而轉換中綴表達式我們需要的是一個操作符棧:
//操作符棧,topOper指向操作符棧的棧頂(即棧中最上面那個元素的上面那個空位置) char StackOper[SIZE]; size_t topOper = 0; //操作符棧的push void pushOper(char oper) { StackOper[topOper++] = oper; } //操作符棧的pop char popOper() { return StackOper[--topOper]; }
有了操作符棧后,我們可以來談談我們是如何將中綴表達式轉換為后綴表達式的了,其實過程是“固定”的:
1.遍歷中綴表達式
2.如果當前中綴元素為操作數,則直接將此操作數“輸出”到后綴表達式尾端
3.如果當前中綴元素為'*','/'或'(',直接push入操作符棧
4.如果當前中綴元素為')',則依次pop出棧頂操作符,“輸出”到后綴表達式尾端,直至pop得到的是一個'('才停止,並丟棄該'('
5.如果當前中綴元素為'+'或'-',則依次pop出棧頂操作符、“輸出”到后綴表達式尾端,直至棧底(棧空)或pop得到了一個'(',若pop得到一個'(',將'('重新push入棧。達到這兩個條件之一后,將此操作符('+'或'-')入棧。
6.如果當前中綴元素為'=',則依次pop出棧頂操作符、“輸出”到后綴表達式尾端,直至棧底(棧空)。
提醒一下,根據4、5兩點,可以看出:只有遍歷到')'才會導致'('彈出,其它操作符均不會使'('彈出。
現在讓我們假設一個中綴表達式a+b*(c-(d+e))=,然后追蹤一下它轉換為后綴表達式的過程:
首先遍歷遇到a,因為是操作數,所以直接輸出至后綴表達式,接着遇到'+',因為棧空所以將其入棧
接着我們遇到了b,同上,輸出至后綴表達式,接着我們遇到‘*’,因為是‘*’、‘/’和‘(’中的一種,所以直接入棧
再接着,我們遇到了‘(’,同上,直接入棧,然后是操作數c,直接輸出至后綴表達式
下一步我們遇到了‘-’,於是我們pop出棧頂元素,發現是‘(’,所以我們將‘(’重新push入棧,然后將‘-’入棧。
接着我們又遇到了‘(’,根據規則3,直接入棧。然后是操作數d,根據規則2,我們將其輸出到后綴表達式
現在我們到了'+'處,因為pop出棧頂元素為‘(’,所以將‘(’重新push,然后將‘+’也push入棧。
再接着我們遇到了操作數e,直接輸出到后綴表達式
接下來我們遇到了一個‘)’,按照規則4,我們依次pop出棧頂操作符並輸出到后綴表達式,直至pop得到的是‘(’(丟棄)。於是我們將棧頂的'+'輸出到了后綴表達式,並丟棄了'+'下面那個'('
接着我們又遇到了一個')',再次依照規則4,我們將'-'輸出到了后綴表達式並丟棄了‘-’下面那個'('
最后,我們遇到了'=',於是我們依次pop出棧內元素並輸出至后綴表達式,直至棧底。
至此,我們完成了對a+b*(c-(d+e))=的轉換,所得后綴表達式為abcde+1*+
有了上述理論和“實踐”,現在我們可以開始着手實現我們程序中的轉換模塊了,我們先假定InfixExpression[]中已經有一個正確的中綴表達式,我們定義一個函數translate(),其功能為將InfixExpression[]中的表達式轉換為后綴表達式並存入PostfixExpression[]中,以供calculate()函數計算。
//用於translate()的一些函數,負責棧操作 //將中綴轉換為后綴時,如果遇到操作符,那么我們需要對操作符進行判斷然后決定相應的(棧)操作 //下面這些函數就是當遇到不同操作符時調用的不同函數,用如其名 //參數意義是“后綴表達式數組的當前尾端下標”,因為*/(都直接入棧所以不需要該參數,=雖然需要知道當前后綴尾端下標,但不需要更改,因為轉換已經要結束了 //其他幾個函數因為可能改變“后綴表達式數組的當前尾端下標”,所以需要接收指針型參數 void IsAdd(size_t *j); void IsSub(size_t *j); void IsMulti(); void IsDiv(); void IsLeft(); void IsRight(size_t *j); void IsEqual(size_t j); //translate()函數的定義,其用途說明在Calculator.h中 void translate() { //遍歷中綴表達式數組,將其中存儲的中綴表達式轉換為后綴表達式並存入后綴表達式數組 //i為中綴表達式數組的“當前下標”(當前所判斷的元素),j為后綴表達式數組的“當前下標”(輸出到后綴的新元素應放入的位置),切記兩者並不同步 for (size_t i = 0, j = 0;i < SIZE;++i) { //若當前中綴(中綴表達式的簡稱)元素為數字則我們直接將其“輸出”到后綴表達式 if (InfixExpression[i].IsNum) { PostfixExpression[j].IsNum = true; PostfixExpression[j++].num = InfixExpression[i].num; } //若當前中綴元素不是數字,則我們需要根據其“值”決定應選擇的棧操作,這里也是中綴下標i和后綴下標j不同步的原因產生之處 else { switch (InfixExpression[i].oper) { case '(': IsLeft(); //當前元素為'('時,我們調用IsLeft(),因為'('必然是直接入棧,所以我們的j不會發生變化 break; case ')': IsRight(&j); //當前元素為')'時調用IsRight(),因為')'可能導致輸出元素至后綴表達式,所以需要知道后綴的下標j,並且j可能會發生變化,我們將j的地址傳遞過去 break; case '+': IsAdd(&j); //當前元素為'+'時調用IsAdd(),因為'+'可能導致輸出元素至后綴表達式,所以需要知道后綴的下標j,並且j可能會發生變化,我們將j的地址傳遞過去 break; case '-': IsSub(&j); //當前元素為'-'時調用IsSub(),因為'-'可能導致輸出元素至后綴表達式,所以需要知道后綴的下標j,並且j可能會發生變化,我們將j的地址傳遞過去 break; case '*': IsMulti(); //當前元素為'*'時調用IsMult(),因為'*'直接入棧,所以j不會發生變化,不需要傳遞 break; case '/': IsDiv(); //當前元素為'/'時調用IsDiv(),因為'/'直接入棧,所以j不會發生變化,不需要傳遞 break; case '=': //當前元素為'='時調用IsEqual(),因為'='會導致輸出元素至后綴表達式,所以需要知道j IsEqual(j); return; } } } } //如果是'('則直接pushOper() void IsLeft() { pushOper('('); } //如果是')'則彈出棧頂元素直至棧頂元素為'(',當棧頂元素為'('時彈出並丟棄 void IsRight(size_t *j) { char oper; //如果是正確的表達式,則遇到)時棧內一定有(,此時循環條件其實沒作用 while (topOper > 0) { //獲取棧頂元素 oper = popOper(); //如果是'('則返回,因為'('被丟棄所以可以不理睬 if (oper == '(') return; //如果不是'('則將該操作符“輸出”至后綴表達式 else { PostfixExpression[(*j)].IsNum = false; PostfixExpression[(*j)++].oper = oper; } } } //如果是'+'則依次pop棧頂操作符,直至pop所得為'('或棧為空,若pop得到'('需要將其重新push入棧 //pop至上述兩種情況之一后,將'+'入棧 void IsAdd(size_t *j) { char oper; while (topOper > 0) { oper = popOper(); if (oper == '(') { pushOper(oper); break; } else { PostfixExpression[(*j)].IsNum = false; PostfixExpression[(*j)++].oper = oper; } } pushOper('+'); } //如果是'-'則依次pop棧頂操作符,直至pop所得為'('或棧為空,若pop得到'('需要將其重新push入棧 //pop至上述兩種情況之一后,將'-'入棧 void IsSub(size_t *j) { char oper; while (topOper > 0) { oper = popOper(); if (oper == '(') { pushOper(oper); break; } else { PostfixExpression[(*j)].IsNum = false; PostfixExpression[(*j)++].oper = oper; } } pushOper('-'); } //'*'和'/'都直接入棧 void IsMulti() { pushOper('*'); } void IsDiv() { pushOper('/'); } //如果是'=',則依次彈出棧頂元素輸出至后綴表達式,直至棧空 void IsEqual(size_t j) { char oper; while (topOper > 0) { oper = popOper(); PostfixExpression[j].IsNum = false; PostfixExpression[j++].oper = oper; } PostfixExpression[j].IsNum = false; PostfixExpression[j].oper = '='; }
至此,我們的整數四則運算計算器已經完成大半了,剩下沒完成的就是負責獲取輸入並將輸入存儲進InfixExpression[]的模塊了。由於該模塊不屬於棧的討論范圍,所以我們就不細說了,需要了解的讀者可以看下述代碼(另外,不支持使用者直接鍵入負數,比如2+(-3)=,但支持這樣的寫法:2+(0-3)=)
//get()函數的定義,get()的用處在Calculator.h頭文件中 bool get() { //用於保存用戶輸入的“字符”(還沒有“翻譯”稱表達式的用戶輸入) char input[SIZE * 10]; //輸出提示信息,如果希望終止本程序則輸入'n' printf("Please enter expression,end with '='\nIf you want to use negative numbers, please write like this:(0-1)\nIf you want to stop calculator,enter 'n':\n"); //通過fgets函數獲取用戶輸入 fgets(input, sizeof(input) / sizeof(char), stdin); //簡單判斷,如果用戶鍵入的是'n'則返回false,主程序會根據get()返回值決定程序走向 if (input[0] == 'n') return false; //若用戶沒有鍵入'n'則默認用戶鍵入正確的中綴表達式 //num用於“轉換”用戶輸入的數字字符,具體用法見下 int num = 0; //遍歷整個input數組,當然,我們一般中途就會跳出 for (size_t i = 0, j = 0;i < SIZE * 10;i++) { //若當前字符為數字字符,則算出當前數字字符與其“左右”的數字字符一起組成了一個什么數 if (isdigit(input[i])) { num = num * 10 + input[i] - '0'; //num表示“當前數字”(初始值為0),所以當“再次”遇到一個數字字符時,顯然需要“當前數字”乘以10(進位)再加上新的數字字符對應的數字 } //若當前字符不是數字字符,則我們需要判斷其是什么字符 else { //若當前字符為'='則表示表達式結束,此時我們需要進行一些特殊判斷 if (input[i] == '=') { //若表達式'='之前的那個字符不是')'則必然是一個數字字符,因此我們需要獲取到那個數字字符與其“左右”數字字符組成的數 if (input[i - 1] != ')') { InfixExpression[j].IsNum = true; InfixExpression[j++].num = num; num = 0; //獲取完數字字符組成的數后,我們要將num重置以用於下一次“轉換”數字字符 } //無論'='前一個字符是數字字符還是')',我們都需要將'='存入中綴表達式數組並跳出對input[]的遍歷 InfixExpression[j].IsNum = false; InfixExpression[j++].oper = '='; break; } //'('是輸入的又一特例,'('的前一個字符理應為運算符,所以我們不用也不該“獲取”num的值 else if (input[i] == '(') { InfixExpression[j].IsNum = false; InfixExpression[j++].oper = '('; } else if (input[i] == ')'&& input[i-1] == ')') { InfixExpression[j].IsNum = false; InfixExpression[j++].oper = ')'; } //除去上述特例,不論是運算符還是')',其前一個字符理應為數字字符,因此我們需要“獲取”num的值,然后將操作符也存起來並重置num else { InfixExpression[j].IsNum = true; InfixExpression[j++].num = num; num = 0; InfixExpression[j].IsNum = false; InfixExpression[j++].oper = input[i]; } } } //以下循環為輔助性的,效果是輸出中綴表達式中存儲的表達式,理論上屏幕輸出應與使用者輸入相同 for (size_t i = 0;i < SIZE;++i) { if (!InfixExpression[i].IsNum&&InfixExpression[i].oper == '=') { printf("%c\n", '='); break; } else if (InfixExpression[i].IsNum) { printf("%d", InfixExpression[i].num); } else { printf("%c", InfixExpression[i].oper); } } //返回true告訴主程序用戶沒有鍵入'n' return true; }
最后,雖然本文中已經有了“完整”的計算器代碼,但終究是片斷的,而且沒有包含頭文件。如果想要查看完整項目代碼,請前往
https://github.com/nchuXieWei/Simple-Calculator
最后的最后,提醒一句:雖然中綴表達式存在優先級問題,但並不意味着它不能用類似的方法求解!對中綴表達式進行求解依然是運用棧的技術。我們的計算器程序中使用了一個操作符棧用於轉換,一個操作符數棧用於計算,而如果對中綴表達式進行求解則是同時利用操作數棧和操作符棧,有興趣的同學可以去了解相關的算法。特意提醒的目的是不希望“目光局限”,這一點很多人很容易犯,就像我之前也一直“默認”表達式應該先轉換為后綴表達式再求解,卻沒想過是否可以轉換為前綴表達式,或者是否能直接對中綴表達式求解。我覺得保持一種“探索”的精神是非常有必要的,不僅僅是為了提升,有時候也會是一種樂趣!