04 Java API:arraylist實現算術表達式的解析

java API包羅萬象,細節層出不窮,要在一篇筆記裏總結好實在是很有挑戰性。筆者思前想後,決定還是用畢老師視頻中講到的API知識實現一個小項目,並把思考和編碼過程記錄在下面,在這個過程中練習API的使用。這個項目是實現一個算術表達式解析器。網上有很多針對此項目的程序,不過大多要用到中綴表達式的轉換算法和棧結構。筆者在查閱這些資料的時候感到因爲不符合手算的經驗,這些代碼都難以理解。能不能用小學生算術課上的分步化簡的辦法來完成呢?也就是說解析器在運算過程中操作數和操作符不會變換相對位置,僅僅用計算、替換、刪除來實現化簡,最終表達式被化簡爲一個操作數,即答案。

筆者用兩天時間完成了這個程序。用到的API主要有包裝類,String類和ArrayList框架類。

1. 概述

算術表達式解析器能夠按照算術運算的規則來計算一段以字符串形式表述的算術表達式的值。比如:
String expr = "1 + 2 * 4 - 3";
這個表達式的值應當是6。
考慮更復雜的表達式:
String expr = "( - 2)*(2 - 2*(3 + 4) + 9*2)";
計算結果是 - 12。

2. 需求

輸入:
一個字符串,該字符串表示一個完整正確的算術表達式
該表達式可以包括括號,包括正負零三種範圍的整數或小數操作數,包括加減乘除四種運算
輸出:
該表達式的double類型計算結果
其他要求:不使用棧,不對操作符進行重新排序,仿照手算的順序按照計算、替換、刪除的方式不斷化簡直到得出答案,代碼儘可能精簡

3. 設計

3.1 選擇一種框架類來存儲表達式

項目需求中要求按照手算順序不斷化簡得出答案。手算例子中的表達式可以計算如下:
( - 2)*(2 - 2*(3 + 4) + 9*2)
 = ( - 2)*(2 - 2*(7) + 9*2)
 = ( - 2)*(2 - 2*7 + 9*2)
 = ( - 2)*(2 - 14 + 9*2)
 = ( - 2)*(2 - 14 + 18)
 = ( - 2)*( - 12 + 18)
 = ( - 2)*(6)
 = ( - 2)*6
 =  - 12
我們要設計的程序每一步消去一個操作符。因爲操作數的個數始終比操作符多一個,到最後會剩下一個操作數就是答案。
要模仿這個分步計算的過程,這個框架類作爲表達式的容器,應當滿足:
爲了方便定位操作符和操作數,能夠按索引查找元素
爲了方便替換和刪除操作符和操作數,能夠在遍歷中按索引更改刪除元素
爲了刪除功能,容器的元素總數可以變化
按索引查找,可以用List類的容器實現。按索引改刪,可以用ArrayList。

3.2 決定框架類中存儲什麼類型的元素

ArrayList中存儲的元素是什麼呢?很明顯是表達式的操作數和操作符。每一個操作數或操作符都應當作爲一個元素儲存在ArrayList中。
 =  =  = 元素怎樣表示操作符和操作數
需求要求我們精簡代碼,所以應該將操作符和操作數存儲在同一個ArrayList中。一個操作數可以存成一個double型變量,可是加減乘除和括號這樣的操作符怎樣用double型變量表示,同時不會引起歧義呢?因爲字符串表達式中很難出現高精度無理數,所以可以選一個作爲表示操作符的常量。筆者選的是:
//用來標識操作符的常數
interface OperatorTag
{
    double ADD =  Math.PI;      //加
    double SUB =  Math.PI + 1;  //減
    double MUL =  Math.PI + 2;  //乘
    double DIV =  Math.PI + 3;  //除
    double LBR =  Math.PI + 4;  //左括號
    double RBR =  Math.PI + 5;  //右括號
}
如果arraylist中的某個元素是上述常數值,則它應當是個操作符。

3.3 按什麼順序存儲元素

這個比較明確,按照表達式中出現的順序存儲元素。對於表達式( - 2)*(2 - 2*(3 + 4) + 9*2),它應當存儲成:
ArrayList[0] : LBR
ArrayList[1] : SUB
ArrayList[2] : 2.0
ArrayList[3] : RBR
ArrayList[4] : MUL
ArrayList[5] : LBR
ArrayList[6] : 2.0
ArrayList[7] : SUB
ArrayList[8] : 2.0
ArrayList[9] : MUL
ArrayList[10]: LBR
ArrayList[11]: 3.0
ArrayList[12]: ADD
ArrayList[13]: 4.0
ArrayList[14]: RBR
ArrayList[15]: ADD
ArrayList[16]: 9.0
ArrayList[17]: MUL
ArrayList[18]: 2.0
ArrayList[19]: RBR
爲此我們需要從字符串表達式中提取操作數和操作符,並存到ArrayList中去。

3.4 將字符串表達式轉換爲ArrayList中的一系列元素

爲此我們需要從字符串中提取:
//提取字符串expr中的操作符和操作數並存儲到ArrayList<Double> al中
public static void parse(String expr)
{
    //去掉所有空格
    String str = expr.replace(" ", "");

    //標記單目操作符負號位置
    int minus_pos = 0;

    //將表達式中的所有單目運算符負號轉化成雙目運算符減號,補充上左操作數0
    while( (minus_pos = str.indexOf("(-")) !=  - 1)
    {
        str = str.substring(0, minus_pos + 1) + "0" + str.substring(minus_pos + 1);
    }

    //一個操作數、操作符的開始位置
    int pos_start = 0;  
    //一個操作數、操作符的結束位置
    int pos_end = 0;    
    //將字符串轉換爲字符數組
    char[] arr = str.toCharArray();

    //逐個提取表達式中的操作數和操作符
    while(pos_start != arr.length )
    {
        pos_end = pos_start;
        while( Character.isDigit(arr[pos_start]) == Character.isDigit(arr[pos_end]) || arr[pos_end] == '.')
        {
            pos_end++;
            if(pos_end == arr.length)
            {
                break;
            }
        }
        
        //提取到的操作符或操作數字符串
        String tmp = new String(arr, pos_start, pos_end - pos_start);
        
        if(Character.isDigit(arr[pos_start]))
        {
            //將提取出的操作數放入arraylist
            al.add(Double.parseDouble(tmp));
        }
        //將提取出的操作符放入arraylist
        else
        {
            char[] op = tmp.toCharArray();
            for(char c : op)
            {
                if(c == '+')
                    al.add(ADD);
                else if(c == '-')
                    al.add(SUB);
                else if(c == '*')
                    al.add(MUL);
                else if(c == '/')
                    al.add(DIV);
                else if(c == '(')
                    al.add(LBR);
                else if(c == ')')
                    al.add(RBR);
                else
                    throw new RuntimeException("Operator not allowd : " + tmp);
            }
        }
        pos_start = pos_end;
    }
}
這一步中使用了String對象的拼接來補全操作數,包裝類Double將字符串轉換爲它所表示的雙精度值,字符數組來定位操作數或操作符的開始結束位置。
我們還要能反向將ArrayList表達式轉換回字符串表達式,這樣可以方便顯示每一步運算結果:
//顯示化簡到當前一步的, 以arraylist形式存儲的表達式al
public static void display()
{
    //輸出提取到的所有元素‘
    for(int i = 0; i < al.size(); i++)
    {
        if(al.get(i) == ADD)
            System.out.print(" + ");
        else if(al.get(i) == SUB)
            System.out.print(" - ");
        else if(al.get(i) == MUL)
            System.out.print(" * ");
        else if(al.get(i) == DIV)
            System.out.print(" / ");
        else if(al.get(i) == LBR)
            System.out.print("( ");
        else if(al.get(i) == RBR)
            System.out.print(" )");
        else
            System.out.print(al.get(i));
    }
        System.out.println("\n");
}

3.5 化簡無括號的表達式

我們現在已經將字符串表達式全部轉換成名叫al的arraylist中的元素了。下面應當運算al中的元素,將結果寫回al,刪除al中運算過的元素,這三步就是化簡。爲了化簡有括號的表達式,先要化簡無括號的表達式。每化簡一步,就是運算一個操作符,將結果寫回正確位置並刪除用過的多餘操作數:
//計算不含括號的表達式的值, 該表達式表示爲al中從start到end的一段元素
public static void eval(int start, int end)
{
    //元素在當前化簡表達式中的位置
    int i = start;
    //化簡的停止位置
    int stop = end;

    //從左向右計算乘除法,每消去一個操作符則更新al
    while(i <= stop)
    {
        double element = al.get(i);
        //臨時存放結果
        double rslt = 0;
        
        //如果是乘除操作符,則計算該符結果並更新表達式爲化簡後的
        if(element == MUL)    
        {
            rslt = al.get(i - 1) * al.get(i + 1);
            //刪除操作符和右操作數,左操作數用本步計算結果代替
            al.remove(i + 1);
            al.remove(i);
            al.set(i - 1, rslt);
            stop = stop - 2;
            display();
        }
        else if(element == DIV)    
        {
            rslt = al.get(i - 1) / al.get(i + 1);
            al.remove(i + 1);
            al.remove(i);
            al.set(i - 1, rslt);
            stop = stop - 2;
            display();
        }
        else
        {
            i++;
        }
    }

    i = start;
    //從左向右計算加減法,每消去一個操作符則更新al
    while(i <= stop)
    {
        double element = al.get(i);
        //臨時存放結果
        double rslt = 0;
        
        //如果是加減操作符,則計算該符結果並更新表達式爲化簡後的
        if(element == ADD)    
        {   rslt = al.get(i - 1) + al.get(i + 1);
            al.remove(i + 1);
            al.remove(i);
            al.set(i - 1, rslt);
            stop = stop - 2;
            display();
        }
        else if(element == SUB)    
        {
            rslt = al.get(i - 1) - al.get(i + 1);
            al.remove(i + 1);
            al.remove(i);
            al.set(i - 1, rslt);
            stop = stop - 2;
            display();
        }
        else
        {
            i++;
        }
    }
}
這個功能用到了arraylist的remove操作。要特別注意刪除arraylist元素會改變arraylist長度,而且索引對應的元素也會有變動。刪除元素的順序不同,索引變動也會不同。如果要將索引變動範圍限制在remove掉的元素後面而不影響前面,應當從較大的索引開始刪除,再刪較小的索引。上面的程序在一遍遍歷中計算所有乘除法並更新,然後在另一遍遍歷中計算所有加減法並更新,到此無括號表達式化簡完畢,得到答案。

3.6 化簡有括號的表達式

括號是有嵌套關係的,所以確定多組括號的運算順序就很麻煩。不過我們可以按照無括號表達式化簡的思路,化簡完一組括號就將其刪除,其內部的表達式替換爲計算結果。那如何找到最內層的括號呢?按我們的化簡替換方法,表達式中最右邊的左括號所在的括號組,一定保證其內部的表達式不含括號了。當我們計算替換完了這組括號,在更新的表達式中繼續找最右邊的左括號,直到所有括號都算完。這時只剩下一個無括號的表達式,用上一步化簡無括號表達式的方法就可以了。
括號內部表達式的起止位置作爲參數傳給無括號表達式的化簡方法,就可以實現有括號方法對無括號方法的調用了:
//左右括號位置
int lbr_pos = 0;
int rbr_pos = 0;

//臨時存放括號對內表達式計算結果
double rslt = 0;

//如果表達式中還有括號的話,就從右向左繼續計算各個括號對裏表達式的值,並替換化簡
while(al.contains(LBR))
{
    //最右邊的左括號位置
    lbr_pos = al.lastIndexOf(LBR);
    //其對應的右括號
    rbr_pos = lbr_pos + 1;
    while(al.get(rbr_pos) != RBR)   rbr_pos++;
    //計算該括號對內表達式的值, 並替換化簡
    eval(lbr_pos + 1, rbr_pos - 1);
    //刪除括號對
    al.remove(lbr_pos + 2);
    al.remove(lbr_pos);
    display();
}

//計算所有括號化簡掉後的表達式的值
eval(0, al.size() - 1);

//化簡到最後剩下的唯一一個操作數就是答案
return  al.get(0);
這段代碼不難理解,就是不斷尋找下一步要計算的括號,調用無括號方法計算完畢用結果替換後,再尋找更新的表達式中下一步要算的括號。

4. 算術表達式解析器的完整代碼和輸出

import java.util.*;
import java.math.*;

//用來標識操作符的常數
interface OperatorTag
{
    double ADD =  Math.PI;      //加
    double SUB =  Math.PI + 1;  //減
    double MUL =  Math.PI + 2;  //乘
    double DIV =  Math.PI + 3;  //除
    double LBR =  Math.PI + 4;  //左括號
    double RBR =  Math.PI + 5;  //右括號
}

public class Test implements OperatorTag 
{   
    //按表達式中出現的次序存儲提取出的操作數和操作符
    static ArrayList<Double> al = new ArrayList<Double>();

    public static void main(String[] args) 
    {
        //要計算的字符串表達式
        String expr = "( - 2.1)*(2 - 2*(3.1 + 4) + 9*2)";
        double rslt = evalbr(expr);
        System.out.println(rslt);
    }

    //計算含括號的表達式的值
    public static double evalbr(String expr)
    {
        //解析這個expr表達式,結果存入arraylist
        parse(expr);

        //顯示解析後的arraylist中的表達式
        display();
        
        //左右括號位置
        int lbr_pos = 0;
        int rbr_pos = 0;
        
        //臨時存放括號對內表達式計算結果
        double rslt = 0;

        //如果表達式中還有括號的話,就從右向左繼續計算各個括號對裏表達式的值,並替換化簡
        while(al.contains(LBR))
        {
            //最右邊的左括號位置
            lbr_pos = al.lastIndexOf(LBR);
            //其對應的右括號
            rbr_pos = lbr_pos + 1;
            while(al.get(rbr_pos) != RBR)   rbr_pos++;
            //計算該括號對內表達式的值, 並替換化簡
            eval(lbr_pos + 1, rbr_pos - 1);
            //刪除括號對
            al.remove(lbr_pos + 2);
            al.remove(lbr_pos);
            display();
        }

        //計算所有括號化簡掉後的表達式的值
        eval(0, al.size() - 1);

        //化簡到最後剩下的唯一一個操作數就是答案
        return  al.get(0);
    }

    //計算不含括號的表達式的值, 該表達式表示爲al中從start到end的一段元素
    public static void eval(int start, int end)
    {
        //元素在當前化簡表達式中的位置
        int i = start;
        //化簡的停止位置
        int stop = end;

        //從左向右計算乘除法,每消去一個操作符則更新al
        while(i <= stop)
        {
            double element = al.get(i);
            //臨時存放結果
            double rslt = 0;
            
            //如果是乘除操作符,則計算該符結果並更新表達式爲化簡後的
            if(element == MUL)    
            {
                rslt = al.get(i - 1) * al.get(i + 1);
                //刪除操作符和右操作數,左操作數用本步計算結果代替
                al.remove(i + 1);
                al.remove(i);
                al.set(i - 1, rslt);
                stop = stop - 2;
                display();
            }
            else if(element == DIV)    
            {
                rslt = al.get(i - 1) / al.get(i + 1);
                al.remove(i + 1);
                al.remove(i);
                al.set(i - 1, rslt);
                stop = stop - 2;
                display();
            }
            else
            {
                i++;
            }
        }

        i = start;
        //從左向右計算加減法,每消去一個操作符則更新al
        while(i <= stop)
        {
            double element = al.get(i);
            //臨時存放結果
            double rslt = 0;
            
            //如果是乘除操作符,則計算該符結果並更新表達式爲化簡後的
            if(element == ADD)    
            {   rslt = al.get(i - 1) + al.get(i + 1);
                al.remove(i + 1);
                al.remove(i);
                al.set(i - 1, rslt);
                stop = stop - 2;
                display();
            }
            else if(element == SUB)    
            {
                rslt = al.get(i - 1) - al.get(i + 1);
                al.remove(i + 1);
                al.remove(i);
                al.set(i - 1, rslt);
                stop = stop - 2;
                display();
            }
            else
            {
                i++;
            }
        }
    }

    //提取字符串中的操作符和操作數並存儲到al中
    public static void parse(String expr)
    {
        //去掉所有空格
        String str = expr.replace(" ", "");

        //標記單目操作符負號位置
        int minus_pos = 0;

        //將表達式中的所有單目運算符負號轉化成雙目運算符減號,補充上左操作數0
        while( (minus_pos = str.indexOf("(-")) !=  - 1)
        {
            str = str.substring(0, minus_pos + 1) + "0" + str.substring(minus_pos + 1);
        }

        //一個操作數、操作符的開始位置
        int pos_start = 0;  
        //一個操作數、操作符的結束位置
        int pos_end = 0;    
        //將字符串轉換爲字符數組
        char[] arr = str.toCharArray();

        //逐個提取表達式中的操作數和操作符
        while(pos_start != arr.length )
        {
            pos_end = pos_start;
            while( Character.isDigit(arr[pos_start]) == Character.isDigit(arr[pos_end]) || arr[pos_end] == '.')
            {
                pos_end++;
                if(pos_end == arr.length)
                {
                    break;
                }
            }
            
            //提取到的操作符或操作數字符串
            String tmp = new String(arr, pos_start, pos_end - pos_start);
            
            if(Character.isDigit(arr[pos_start]))
            {
                //將提取出的操作數放入arraylist
                al.add(Double.parseDouble(tmp));
            }
            //將提取出的操作符放入arraylist
            else
            {
                char[] op = tmp.toCharArray();
                for(char c : op)
                {
                    if(c == '+')
                        al.add(ADD);
                    else if(c == '-')
                        al.add(SUB);
                    else if(c == '*')
                        al.add(MUL);
                    else if(c == '/')
                        al.add(DIV);
                    else if(c == '(')
                        al.add(LBR);
                    else if(c == ')')
                        al.add(RBR);
                    else
                        throw new RuntimeException("Operator not allowd : " + tmp);
                }
            }
            pos_start = pos_end;
        }
    }

    //顯示化簡到當前一步的表達式
    public static void display()
    {
        //輸出提取到的所有元素‘
        for(int i = 0; i < al.size(); i++)
        {
            if(al.get(i) == ADD)
                System.out.print("+");
            else if(al.get(i) == SUB)
                System.out.print("-");
            else if(al.get(i) == MUL)
                System.out.print("*");
            else if(al.get(i) == DIV)
                System.out.print("/");
            else if(al.get(i) == LBR)
                System.out.print("(");
            else if(al.get(i) == RBR)
                System.out.print(")");
            else
                System.out.print(al.get(i));
        }
            System.out.println("\n");
    }
}
輸出:
(-2.1)*(2-2*(3.1+4)+9*2)
(0.0-2.1)*(2.0-2.0*(3.1+4.0)+9.0*2.0)
(0.0-2.1)*(2.0-2.0*(7.1)+9.0*2.0)
(0.0-2.1)*(2.0-2.0*7.1+9.0*2.0)
(0.0-2.1)*(2.0-14.2+9.0*2.0)
(0.0-2.1)*(2.0-14.2+18.0)
(0.0-2.1)*(-12.2+18.0)
(0.0-2.1)*(5.800000000001)
(0.0-2.1)*5.800000000001
(-2.1)*5.800000000001
-2.1*5.800000000001
-12.1800000000001

5. 總結

從上面程序的輸出明顯可以看出double型變量的減法出現了問題,出現了一個很小的餘數。這是由double型變量的存儲原理導致的。double型變量作爲十進制的雙精度變量,在計算機中是用一串二進制數字來存儲的。這串二進制數字通過雙精度公式可以運算得到一個在值上非常接近變量賦值的近似值,並在計算時使用這個近似值。所以double型變量的運算是不精確的。本質的原因是double型變量只是試圖用二進制加法得到的近似值去接近用戶的賦值,而不是按十進制位逐位保存用戶的賦值。爲了解決,可以用Math.BigDecimal包裝類來代替double型。爲了簡化代碼突出重點,筆者沒有這麼做,畢竟解析器的算法和容器都證明是無誤的。
從這個小項目可見,利用arraylist的可變容量和索引查找這兩個優勢,用它來表示表達式大大簡化了編碼和理解的難度。Java API是一本百科全書,限於能力筆者選擇arraylist爲例來做練習。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章