0.8-0.7 != 0.7-0.6

wangfengye發表於2019-01-02

在進行大數運算時,遇到了運算精度不夠的問題,稍作了一下整理

0.8-0.7 != 0.7-0.6原因 二進位制表示十進位制數造成的精度缺失,從而導致計算出現誤差;至於精度缺失的原因就要從二進位制如何表示小數說起:

十進位制小數轉化二進位制

乘2至無小數

舉個例子

初始值(取整) 變換後(x2) 二進位制部分
0.8 1.6 0.1
0.6 1.2 0.11
0.2 0.4 0.110
0.4 0.8 0.110
死迴圈

找到了原因,接下來該解決問題:

  • 首先想到的就是放大資料,用整數來表示小數,就不會有精度缺失了,
  • 實際上Java本身就提供了BigDecimal類來處理大數運算,我們可以將數值轉換為BigDecimal物件,呼叫它的方法進行高精度運算

BigDecimal原始碼

  1. 首先看一下它儲存了哪些資料,從中可以猜測該類也是通過,縮放數值來實現精確運算的;
 private final BigInteger intVal;// 未縮放的值(即真實值)
 private final int scale;// 縮放比例  真實值 = 整數化的值 / (10^scale)
 private transient int precision;//數值的位數
 private final transient long intCompact;//數值(如果數值大於Long.MAX_VALUE,值為Long.MIN_VALUE)
複製程式碼
  1. 新建一個BigDecimal物件,構造方法可以傳入多種型別的資料,一般建議傳入string型別的值來防止資料精度的損失,尤其是public BigDecimal(double val)原始碼中有一段註釋it is generally recommended that the {@linkplain #BigDecimal(String)<tt>String</tt> constructor} be used in preference to this one大致意思是建議使用string型別引數代替double型別引數,原因顯然是double型別帶來的精度丟失
  2. 接下來研究下建構函式,BigDecimal(String val)最終呼叫的方法是BigDecimal(str.toCharArray(),0,val.length(),MathContext.UNLIMITED),以下是我對改建構函式的簡化
public void BigDecimal(char[] in, int offset, int len, MathContext mc) {
        int prec = 0;//數值位數(不計數值前面的0)
        int scl = 0;//放大比例  真實值 = 記錄的數值
        long rs = 0;//數值
        BigInteger rb = null;//傳入數值長度超過18,會使用改物件儲存資料
        // 取符號位
        boolean isneg = false;          // assume positive
        if (in[offset] == '-') {
            isneg = true;               // leading minus means negative
            offset++;
            len--;
        } else if (in[offset] == '+') { // leading + allowed
            offset++;
            len--;
        }
        // 取數值
        boolean dot = false;//是否到小數部分
        long exp = 0;//指數
        char c;
        boolean isCompact = len <= 18;
        int idx = 0;
        if (isCompact) {// 有足夠的空間計算
            for (; len > 0; offset++, len--) {
                c = in[offset];
                if ((c == '0')) {
                    if (prec == 0) prec = 1;
                    else if (rs != 0) {
                        rs *= 10;
                        ++prec;
                    }
                    if (dot) ++scl;//記錄小數位數
                } else if (c >= '1' && c <= '9') {
                    int digit = c - '0';
                    if (prec != 1 || rs != 0) ++prec;
                    rs = rs * 10 + digit;
                    if (dot) ++scl;
                } else if (c == '.') {//之後是小數位
                    if (dot) throw new NumberFormatException();//存在兩個及以上的小數點
                    dot = true;
                } else if (Character.isDigit(c)) {//判斷char是否為數值
                    //嘗試轉換字元為數字
                } else if ((c == 'e') || (c == 'E')) {
                    exp = parseExp(in, offset, len);//指數計算
                    if ((int) exp != exp) // overflow
                        throw new NumberFormatException();
                    break;//指數計算完成,跳出迴圈
                } else {
                    throw new NumberFormatException();
                }
            }
            if (prec == 0)//無數值
                throw new NumberFormatException();
            if (exp != 0) scl = adjustScale(scl, exp);// scl和exp抵消
            rs = isneg ? -rs : rs;
            int mcp = mc.getPrecision();//獲取精度位數
            int drop = prec - mcp;
            if (mcp > 0 && drop > 0) {
                while (drop > 0) {
                    // 檢查縮放位數是否超出限制
                    scl = checkScaleNonZero((long) scl - drop);
                    // 三個引數一次為:數值,10^drop(表示精確到哪一位) , 舍入模式對應的常量,返回結果為舍入後的值
                    rs = divideAndRound(rs, LONG_TEN_POWERS_TABLE[drop], mc.roundingMode.oldMode);
                    // 計算舍入後數值的位數
                    prec = longDigitLength(rs);
                    //再次計算需要丟棄的位數,直至符合精度要求
                    drop = prec - mcp;
                }
            }else {
                // 省略,邏輯相似,但考慮到數值超int上限,使用char[]儲存數值,
                // 完成後將char[]轉為BigInteget型別
            }
        }
        // 賦值
        this.scale = scl;
        this.precision = prec;
        this.intCompact = rs;
        this.intVal = rb;
    }
複製程式碼
  1. 剛才的建構函式中有一個引數MathContext沒有解釋,字面上看是指數學上下文,應該是儲存些運算相關的環境變數,在BigDecimal中我們用到了兩個,一個是精度precision(即參與運算的最大位數),一個是舍入模式roundingMode,BigDecimal預設的上下文是MathContext.UNLIMITED:銀行家舍入,無限精度;簡單介紹下Java中的舍入模式

    1. ROUND_UP

    向遠離零的方向舍入。捨棄非零部分,並將非零捨棄部分相鄰的一位數字加一。

    1. ROUND_DOWN

    向接近零的方向舍入。捨棄非零部分,同時不會非零捨棄部分相鄰的一位數字加一,採取擷取行為。

    1. ROUND_CEILING

    向正無窮的方向舍入。如果為正數,舍入結果同ROUND_UP一致;如果為負數,舍入結果同ROUND_DOWN一致。注意:此模式不會減少數值大小。

    1. ROUND_FLOOR

    向負無窮的方向舍入。如果為正數,舍入結果同ROUND_DOWN一致;如果為負數,舍入結果同ROUND_UP一致。注意:此模式不會增加數值大小。

    1. ROUND_HALF_UP

    向“最接近”的數字舍入,如果與兩個相鄰數字的距離相等,則為向上舍入的舍入模式。如果捨棄部分>= 0.5,則舍入行為與ROUND_UP相同;否則舍入行為與ROUND_DOWN相同。這種模式也就是我們常說的我們的“四捨五入”。

    1. ROUND_HALF_DOWN

    向“最接近”的數字舍入,如果與兩個相鄰數字的距離相等,則為向下舍入的舍入模式。如果捨棄部分> 0.5,則舍入行為與ROUND_UP相同;否則舍入行為與ROUND_DOWN相同。這種模式也就是我們常說的我們的“五舍六入”。

    1. ROUND_HALF_EVEN

    向“最接近”的數字舍入,如果與兩個相鄰數字的距離相等,則相鄰的偶數舍入。如果捨棄部分左邊的數字奇數,則舍入行為與 ROUND_HALF_UP 相同;如果為偶數,則舍入行為與 ROUND_HALF_DOWN 相同。注意:在重複進行一系列計算時,此舍入模式可以將累加錯誤減到最小。此舍入模式也稱為“銀行家舍入法”,主要在美國使用。四捨六入,五分兩種情況,如果前一位為奇數,則入位,否則捨去。

    1. ROUND_UNNECESSARY

    斷言請求的操作具有精確的結果,因此不需要舍入。如果對獲得精確結果的操作指定此舍入模式,則丟擲ArithmeticException

    PS: Java預設的Math.round()實現的是四捨五入,而.net,和python3中的運算包的round()方法預設實現的銀行家舍入,使用新語言的時候感覺需要注意下他的舍入函式的舍入模式;避免採坑.

  2. 瞭解了BigDecimal的資料構造過程,如何運算就顯而易見了, 詳情自己看原始碼吧;

    1. 加減法,將兩個數的scale放大至相同進行加減運算
    2. 乘除法,數值部分乘除運算,scale部分加減運算