测试程序
我们知道,浮点数运算存在舍入误差。在某些特殊的情况下,舍入误差还可以累计到非常大的地步。让我们来看一下测试程序吧:
1 using System;
2
3 static class DecimalSumTester
4 {
5 static void Main(string[] args)
6 {
7 try
8 {
9 var n = (args.Length > 0) ? int.Parse(args[0]) : 10;
10 for (var i = 0; i < 3; i++) Console.WriteLine(F(n, i));
11 }
12 catch (Exception ex) { Console.WriteLine(ex.Message); }
13 }
14
15 static decimal F(int n, int k)
16 {
17 var z = 0.1m + k * 1000000000000000000000000000m;
18 var w = decimal.Round(z) / 2;
19 while (n-- > 0) z += z / 2 - w;
20 return z - w * 2;
21 }
22 }
在这个程序中:
- 第 19 行通过 while 循环不断进行累加: z += z / 2 - w; 。w 是不变的,而 z 是通过不断累加而增大的。
- 第 9 行读取命令行参数作为 n 的值来决定 while 循环次数。
- 第 15 至 21 行的 F 方法第一个参数 n 就是循环次数。第二个参数 k 决定累加初值的整数部分的大小。
- 在这个程序中参数 k 仅取 0、1 和 2 三种情况。如果算术运算没有误差的话,这三种情况下 F 方法的返回值应该一样。
在 Linux 中编译和运行
在 Arch Linux 64-bit 操作系统的 Mono 3.0.4 环境下编译和运行:
work$ dmcs DecimalSumTester.cs
work$ mono DecimalSumTester.exe 24
1683.4112196028232574462890625
2730.9
2730.9
上述结果中第一行是计算出来的准确值。第二行和第三行的值理论上应该等于第一行。但是由于浮点数运算的舍入误差累计的结果,最终答案的误差相当大。在我的上一篇随笔“浅谈 System.Decimal 结构”中提到,decimal 的算术运算在 Linux 环境下的舍入规则是四舍五入,这可能导致误差累计得比较大。而在 Windows 环境下的舍入规则是四舍六入五向偶。那么我们就接着往下看吧。
在 Windows 中编译和运行
在 Windows 7 SP1 32-bit 操作系统的 Microsoft .NET Framework 4.5 环境下编译和运行:
D:\work> csc DecimalSumTester.cs
Microsoft(R) Visual C# 编译器版本 4.0.30319.17929
用于 Microsoft(R) .NET Framework 4.5
版权所有 (C) Microsoft Corporation。保留所有权利。
D:\work> DecimalSumTester 24
1683.4112196028232574462890625
2097.9
0.1
这个运行结果和在 Linux 环境下大不相同。但是误差也是相当的大。
调试程序
让我们来看看在运算过程中发生了什么吧。在上述测试程序中插入一些调试语句:
1 using System;
2
3 static class DecimalSumDebug
4 {
5 static void Main(string[] args)
6 {
7 var n = (args.Length > 0) ? int.Parse(args[0]) : 10;
8 for (var i = 0; i < 3; i++)
9 Console.WriteLine(F(n, i).ToString().PadRight(77, '-'));
10 }
11
12 static decimal F(int n, int k)
13 {
14 var z = 0.1m + k * 1000000000000000000000000000m;
15 var w = decimal.Round(z) / 2;
16 for (decimal x, y; n-- > 0; z += x)
17 {
18 x = (y = z / 2) - w;
19 Console.WriteLine("{0,-30}: {1,-30}: {2}", z, y, x);
20 }
21 return z - w * 2;
22 }
23 }
这个程序和前面的测试程序的功能是相同,仅仅是运算过程中增加输出中间变量的值的语句。
在 Linux 中调试
在 Arch Linux 的 Mono 环境下编译和运行,输出一大堆调试信息:
work$ dmcs DecimalSumDebug.cs
work$ mono DecimalSumDebug.exe 10
0.1 : 0.05 : 0.05
0.15 : 0.075 : 0.075
0.225 : 0.1125 : 0.1125
0.3375 : 0.16875 : 0.16875
0.50625 : 0.253125 : 0.253125
0.759375 : 0.3796875 : 0.3796875
1.1390625 : 0.56953125 : 0.56953125
1.70859375 : 0.854296875 : 0.854296875
2.562890625 : 1.2814453125 : 1.2814453125
3.8443359375 : 1.92216796875 : 1.92216796875
5.76650390625----------------------------------------------------------------
1000000000000000000000000000.1: 500000000000000000000000000.05: 0.05
1000000000000000000000000000.2: 500000000000000000000000000.1 : 0.1
1000000000000000000000000000.3: 500000000000000000000000000.15: 0.15
1000000000000000000000000000.5: 500000000000000000000000000.25: 0.25
1000000000000000000000000000.8: 500000000000000000000000000.4 : 0.4
1000000000000000000000000001.2: 500000000000000000000000000.6 : 0.6
1000000000000000000000000001.8: 500000000000000000000000000.9 : 0.9
1000000000000000000000000002.7: 500000000000000000000000001.35: 1.35
1000000000000000000000000004.1: 500000000000000000000000002.05: 2.05
1000000000000000000000000006.2: 500000000000000000000000003.1 : 3.1
9.3--------------------------------------------------------------------------
2000000000000000000000000000.1: 1000000000000000000000000000.1: 0.1
2000000000000000000000000000.2: 1000000000000000000000000000.1: 0.1
2000000000000000000000000000.3: 1000000000000000000000000000.2: 0.2
2000000000000000000000000000.5: 1000000000000000000000000000.3: 0.3
2000000000000000000000000000.8: 1000000000000000000000000000.4: 0.4
2000000000000000000000000001.2: 1000000000000000000000000000.6: 0.6
2000000000000000000000000001.8: 1000000000000000000000000000.9: 0.9
2000000000000000000000000002.7: 1000000000000000000000000001.4: 1.4
2000000000000000000000000004.1: 1000000000000000000000000002.1: 2.1
2000000000000000000000000006.2: 1000000000000000000000000003.1: 3.1
9.3--------------------------------------------------------------------------
上述结果中第一组是准确值,没有发生舍入误差。后两组的舍入情况不同,但最终结果居然一样。至于为什么进行这样的舍入,请参阅我的上一篇随笔。
在 Windows 中调试
在 Windows 的 .NET Framework 中编译和运行,也输出一大堆调试信息:
D:\work> DecimalSumDebug 10
0.1 : 0.05 : 0.05
0.15 : 0.075 : 0.075
0.225 : 0.1125 : 0.1125
0.3375 : 0.16875 : 0.16875
0.50625 : 0.253125 : 0.253125
0.759375 : 0.3796875 : 0.3796875
1.1390625 : 0.56953125 : 0.56953125
1.70859375 : 0.854296875 : 0.854296875
2.562890625 : 1.2814453125 : 1.2814453125
3.8443359375 : 1.92216796875 : 1.92216796875
5.76650390625----------------------------------------------------------------
1000000000000000000000000000.1: 500000000000000000000000000.05: 0.05
1000000000000000000000000000.2: 500000000000000000000000000.1 : 0.1
1000000000000000000000000000.3: 500000000000000000000000000.15: 0.15
1000000000000000000000000000.4: 500000000000000000000000000.2 : 0.2
1000000000000000000000000000.6: 500000000000000000000000000.3 : 0.3
1000000000000000000000000000.9: 500000000000000000000000000.45: 0.45
1000000000000000000000000001.4: 500000000000000000000000000.7 : 0.7
1000000000000000000000000002.1: 500000000000000000000000001.05: 1.05
1000000000000000000000000003.2: 500000000000000000000000001.6 : 1.6
1000000000000000000000000004.8: 500000000000000000000000002.4 : 2.4
7.2--------------------------------------------------------------------------
2000000000000000000000000000.1: 1000000000000000000000000000 : 0
2000000000000000000000000000.1: 1000000000000000000000000000 : 0
2000000000000000000000000000.1: 1000000000000000000000000000 : 0
2000000000000000000000000000.1: 1000000000000000000000000000 : 0
2000000000000000000000000000.1: 1000000000000000000000000000 : 0
2000000000000000000000000000.1: 1000000000000000000000000000 : 0
2000000000000000000000000000.1: 1000000000000000000000000000 : 0
2000000000000000000000000000.1: 1000000000000000000000000000 : 0
2000000000000000000000000000.1: 1000000000000000000000000000 : 0
2000000000000000000000000000.1: 1000000000000000000000000000 : 0
0.1--------------------------------------------------------------------------
同样,第一组的结果是准确值,后两组有着不同的舍入误差。第二组的舍入误差比 Linux 中的要好。第三组就很糟糕了,被累加的值直接被舍入到 0 了。
System.Double 数据类型的情况
把前面的测试程序稍加修改,就可用于测试 double 数据类型的舍入误差:
1 using System;
2
3 static class DoubleSumTester
4 {
5 static void Main(string[] args)
6 {
7 try
8 {
9 var e = (args.Length > 0) ? int.Parse(args[0]) : 15;
10 for (var i = 0; i < 3; i++) Console.WriteLine(F(10, i, e));
11 }
12 catch (Exception ex) { Console.WriteLine(ex.Message); }
13 }
14
15 static double F(int n, int k, int e)
16 {
17 var z = 0.1 + k * Math.Pow(10, e);
18 var w = (long)z / 2.0;
19 while (n-- > 0) z += z / 2 - w;
20 return z - w * 2;
21 }
22 }
这时把循环次数固定为 10,命令行参数指定影响舍入误差的整数部分的 10 的幂指数。运行结果如下所示:
work$ dmcs DoubleSumTester.cs
work$ mono DoubleSumTester.exe 0
5.76650390625
5.76650390625
5.76650390625002
work$ mono DoubleSumTester.exe 1
5.76650390625
5.76650390624993
5.76650390625
work$ mono DoubleSumTester.exe 4
5.76650390625
5.76650390631767
5.76650390624854
work$ mono DoubleSumTester.exe 10
5.76650390625
5.76657485961914
5.76650238037109
work$ mono DoubleSumTester.exe 12
5.76650390625
5.761962890625
5.7666015625
work$ mono DoubleSumTester.exe 14
5.76650390625
5.6875
5.0625
work$ mono DoubleSumTester.exe 15
5.76650390625
9
0
work$ mono DoubleSumTester.exe 16
5.76650390625
0
0
这次,Linux 和 Windows 中的运行结果是相同的。同样,每次运行的第一行是准确值,其余两行是不同的舍入误差形成的结果。