8

JavaScript 浮點數無誤差四捨五入改良版

 2 years ago
source link: https://blog.darkthread.net/blog/js-float-round-function/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
JavaScript 浮點數無誤差四捨五入改良版-黑暗執行緒

上回試著克服 JavaScript 四捨五入浮點誤差,小看了浮點數誤差的千變萬化,只手動測完幾組數字就妄想過關,不意外地,被抓出兩個問題:

  1. 原先用整數跟小數總計 16 位判斷出現 999X 或 0000X 近似值誤差,但 JavaScript Number 規格位數上限是 17 位(A Number only keeps about 17 decimal places of precision),發生近似值誤差時的位數可能是 16 位或 17 位,例如:
    1.1 + 3.2 = 4.300000000000001 不含小數點共 16 位
    4.17 * 3.251 = 13.556669999999999 不含小數點共 17 位
  2. 即使簡單的 * 100 也可能出現浮點數誤差,例如:1.005 * 100 = 100.49999999999999,在計算 Math.round(n * 100) / 100 取小數兩位時完全沒考慮到這點

總之,原來的版本遇到某些數字會出錯,必須改良,但這回我改變做法,先寫好檢測程序再來改程式,函式要經過檢驗才能出廠。我想到的做法是用亂數產生十萬組測試數字(依實際應用情境,四捨五入小數位數抓 0 到 4 位),用 C# decimal 算出標準答案,與 JavaScript 版本比對,以確保函式不存在已知錯誤。改良前先用舊版本測試,驗證它能抓出問題:

<%@Page Language="C#"%>
<script runat="server">
class TestCase
{
	public decimal a { get; set; }
	public decimal b { get; set; }
	public int n { get; set; }
	public decimal ans => 
		(decimal)Math.Round(a + b, n, MidpointRounding.AwayFromZero);
}
string TestCasesJson() 
{
	var cases = new List<TestCase>();
	var rnd = new Random(9527);
	for (int i = 0; i < 100000; i++) 
	{
		cases.Add(new TestCase 
		{
			a = (decimal)Math.Round(
					rnd.NextDouble() * 100, rnd.Next(5), MidpointRounding.AwayFromZero),
			b = (decimal)Math.Round(
					rnd.NextDouble() * 100, rnd.Next(5), MidpointRounding.AwayFromZero),
			n = rnd.Next(5)
		});
	}
	return new System.Web.Script.Serialization.JavaScriptSerializer() {
		MaxJsonLength = int.MaxValue
	}.Serialize(cases);
}
</script>

<!DOCTYPE html>
<html>
<head>
	<meta charset="utf-8">
</head>
<body>
<div id=status></div>
<div id=msg>
</div>
<script>
function safeRound(v, n) {
    // 含小數達16位數時,小數部分四捨五入減少一位
    // 注意:若該精確度極限數字非浮點運算產生時會被喪失一位精確度
    let p = v.toString().split('.');
    if (p.length == 2) {
      let intLen = p[0].length;
      let decLen = p[1].length;
      if (intLen + decLen >= 16) {
        let tt = Math.pow(10, decLen - 1);
        v = Math.round(v * tt) / tt;
      }
    }
    let t = Math.pow(10, n);
    return Math.round(v * t) / t;
}
var testCases = <%=TestCasesJson()%>;
var count = 0;
testCases.forEach(function(test, i) {
	var r = safeRound(test.a + test.b, test.n);
	if (r != test.ans) {
		document.getElementById('msg').innerHTML += 
			'<li>' + (++count) + '. TEST FAILED: Round(' + test.a + ' + ' + test.b + ', ' + test.n + ') = ' 
				+ r + ' (' + test.ans + ' expected)</li>';
	}
	document.getElementById('status').innerText = 
		count + '/' + (i + 1) + ' (' + (count / (i + 1) * 100) + '%)';
});
</script>
<body>
</html>

使用固定亂數種子產生十萬筆隨機數字,舊函式實測共出錯 57 筆,錯誤率 0.057%,相當於萬分之 5.7。(另外我也試了不指定亂數種子,new Random(9527) 改為 new Random() 又測了數十次,十萬筆錯誤數落在 40 到 68 之間)

借用讀者 citypig 分享的兩則技巧: float_number % 1 !== 0 測試是否含小數、toPrecision(15) 直接抓 15 位精準度,我將 safeRound 函式改寫如下:

function safeRound(v, n) {
    if (v % 1 !== 0) {
        v = parseFloat(v.toPrecision(15));
    }
    var t = Math.pow(10, n);
    var n = v * t;
    if (n % 1 !== 0) {
		n = parseFloat(n.toPrecision(15));
	}
    return Math.round(n) / t;
}

不指定亂數種子做了二十次十萬筆測試,錯誤數均為零:

有出廠檢驗把關,希望這版 safeRound() 能可靠一些,大家如發現問題歡迎再回饋給我。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK