为什么会产生精度问题?
我们带着这个问题去探寻浮点数二进制的存储原理
浮点数是怎么存在计算机中的?
浮点数在计算机中的表示通常遵循IEEE 754标准。其基本概念如下:
-
结构:浮点数由三部分组成:
- 符号位(1位):表示数值的正负。
- 指数部分(通常8位或11位,取决于单精度或双精度):表示数值的大小范围。
- 尾数部分(也称为有效数字,通常23位或52位):表示数值的精确度。
-
表示方式:浮点数的值可以表示为:F = (-1^s) × (1.M) × (2^e) 其中,尾数通常以“隐式1”形式存储(即在规范化数中,尾数前总是有一个1)。
-
精度与范围:
- 单精度浮点数(32位):可表示的范围大约是 1.5×10−451.5×10−45 到 3.4×10383.4×1038,精度约为7位十进制数字。
- 双精度浮点数(64位):可表示的范围大约是 5.0×10−3245.0×10−324 到 1.7×103081.7×10308,精度约为15位十进制数字。
内存结构图如下
以浮点数 124.34
为例,其在计算机中的表示过程如下:
整数部分
-
不断除以2:将整数部分不断除以2,并记录每次的余数,直到商为0。
- 124 ÷ 2 = 62,余数为0
- 62 ÷ 2 = 31,余数为0
- 31 ÷ 2 = 15,余数为1
- 15 ÷ 2 = 7,余数为1
- 7 ÷ 2 = 3,余数为1
- 3 ÷ 2 = 1,余数为1
- 1 ÷ 2 = 0,余数为1
-
记录余数:从最后一次除法的余数开始,逆序读取余数,即可得到二进制表示。
所以,
124
的二进制表示为:- 从下到上读取余数:
1111100
。
- 从下到上读取余数:
小数部分
-
- 0.34 × 2 = 0.68 → 0
- 0.68 × 2 = 1.36 → 1
- 0.36 × 2 = 0.72 → 0
- 0.72 × 2 = 1.44 → 1
- 0.44 × 2 = 0.88 → 0
- 0.88 × 2 = 1.76 → 1
- 0.76 × 2 = 1.52 → 1
- 0.52 × 2 = 1.04 → 1
- 0.04 × 2 = 0.08 → 0
- 0.08 × 2 = 0.16 → 0
- 0.16 × 2 = 0.32 → 0
- 0.32 × 2 = 0.64 → 0
- 0.64 × 2 = 1.28 → 1
- 0.28 × 2 = 0.56 → 0
- 0.56 × 2 = 1.12 → 1
- 最终小数部分约为
0.010101011...
(循环)。
所以 124.34
的二进制表示大致为 1111100.010101011...
。
标准化:
将其表示为 1.111100010101011... × 2^6
分配字段:
- 符号位:
0
(正数:0,负数:1)。 - 指数:二进制整数的长度:
6
加上偏移量(对于单精度是127),即6 + 127 = 133
,二进制为10000101
。 - 尾数:去掉隐式的
1
,后面跟随111100010101011...
,只取有效位。
最终表示:
- 符号位:
0
- 指数部分:
10000101
- 尾数部分:
111100010101011...
(填充至23位)。
综上所述,124.34
在计算机中的浮点数表示为:
0 10000101 11110001010101100000000
开篇问题解答
浮点数在计算机中可能产生精度问题的原因主要有以下几点:
-
有限的位数:
- 浮点数使用固定的位数来表示数值(如单精度32位或双精度64位),这意味着只能表示有限数量的数字和小数位。某些十进制数在二进制中无法精确表示。
-
舍入误差:
- 当将十进制数转换为二进制数时,某些数(如
0.1
)无法精确表示,计算机只能用近似值来存储。这种近似会导致在进行算术运算时产生舍入误差。
- 当将十进制数转换为二进制数时,某些数(如
-
运算顺序:
- 浮点运算的顺序会影响结果。例如,先加后乘与先乘后加的结果可能不同,尤其是在涉及多个浮点数时,累计的舍入误差会更加明显。
-
标准化和偏移:
- 浮点数的标准化过程使得小数点位置的变化可能导致精度损失,特别是在处理非常大或非常小的数时,这种损失可能更加明显。
-
累积误差:
- 在多次运算中,误差会逐渐累积,导致最终结果偏离真实值。
所以不管是float 还是double 都会存在精度问题。
如何处理精度问题?
简单方法
由一台机器决定计算结果,只计算一次,且认定这个值为准确值,把这个值传递给其他设备或模块,只用这个变量结果进行判断,也省去了多次计算浪费CPU内存空间。
改用int或long类型来替代浮点数
浮点数和整数的计算方式都是一样的,只是小数点部分不同而已。我们完全可以通过把浮点数乘以10的幂次得到更准确的整数,也就是把自己需要的精度用整数表示。比如保留3位精度,所有浮点数都乘以10 000(因为第4位不是很准确),1.5变成15 000的整数,9.9变成99 000的整数存储。这样,整数15 000乘以99 000得到的结果与整数30 000除以2再乘以99 000得到的结果是完全相等的。再举例,原来2.5/3.1×5.1与0.8064×5.1都只是约等于4.1126,用整数替代,2500/31×51与80×51,等于4080,把4080看成4.08,虽然精度出现问题,但是前两者结果不一致,而后两者结果完全相同,使用整数来代替小数,使得一致性得到了保证。如果你觉得用整数做计算的精度问题比较大,则可以再扩大数值到10的幂次,扩大后如果是250 000/31×51,就等于411 290,是不是发现精度提高了?但问题又来了,若通过乘以10的幂次来提高精度,当浮点数值比较大时,就会超出整数的最大上限2^32-1或者2^64-1。
如果你觉得精度可以接受,并且数值计算的范围肯定会被确定在32位或64位整数范围内,则可以用int和long类型的方式来代替浮点数。
用定点数保持一致性并缩小精度问题
浮点数在计算机中是用V=(-1)^s×(1.M)×2^(e)公式表示的,也就是说,浮点数的表达其实是模糊的,它用了另一个数乘以2的幂次来表示当前的数。定点数则不同,它把整数部分和小数部分拆分开来,都用整数的形式表示,这样计算和表达都使用整数的方式。由于整数的计算是确定的,因此就不会存在误差,缺点是由于拆分了整数和小数,两个部分都要占用空间,所以受到存储位数的限制,占用字节多了就会使用64位的long类型整数结构来存储定点数,这会导致计算的范围相对缩小。与浮点数不同,使用定点数做计算能保证在各设备上计算结果的一致性。C#有一种叫decimal的整数类型,它并非基础类型,是基础类型的补充类型,是C#额外构造出来的一种类型,可以认为是构造了一个类作为数字实例并重载了操作符,它拥有更高的精度,却比float范围小。它的内部实现就是定点数的实现方式,我们完全可以把它看成定点数。
C#的decimal类型数值有几个特点需要我们重点关注,它占用128位的存储空间,即一个decimal变量占用16字节,相当于4个int型整数大小,或2个long型长整数大小,比double型还要大1倍。它的数值范围在±1.0×10^28到±7.9×10^28之间,这么大的占用空间却比float的取值范围还小。decimal精度比较大,精度范围为28个有效位,另外任何与它一起计算的数值都必须先转化为decimal类型,否则就会编译报错,数值不会隐式地自动转换成decimal。
看起来好用的decimal却不是大部分游戏开发者的首选。使用C#自带的decimal定点数存在诸多问题。最大问题在于无法与浮点数随意互相转换,因此在计算上需要进行一定的封装,要么提前对float处理,要么在decimal的基础上封装一层外壳(类)以适应所有数值的计算。精度过大导致CPU计算消耗量大,128位的变量、28位的精度范围,计算起来有比较大的负荷,如果大量用于程序内的逻辑计算,则CPU就会不堪重负。内存也是如此,大量使用会使得堆栈内存直线飙升,这也间接增大了CPU的消耗。因此它只适用于财务和金融领域的软件,对于游戏和其他普通应用来说不太合适,其根源是不需要这么高的精度,浪费了诸多设备资源。
实际上,大部分项目都会自己实现定点数,具体实现如前面所说的那样:把整数和小数拆开来存储,用两个int整数分别表示整数部分和小数部分,或者用long长整型存储(前32位存储整数,后32位存储浮点数),long型存储会更好,它便于存储和计算。这样,无论是整数部分还是小数部分,都用整数表示,并封装在类中。因此我们需要重载(override)所有的基本计算和比较符号,包括+、-、*、/、==、!=、>、<、>=、<=,这些符号都需要重载,重载范围包括float(浮点数)、double(双精度)、int(整数)、long(长整数)等。除了以上这些,为了能更好地融合定点数与外部数据的逻辑计算,还需要为此编写额外的定点库,包括定点数坐标类、定点数Quaternion类等来扩展定点数。
这看起来比较困难,其实并不复杂,只要静下心来编写,就会发现不是难事。将定点数与其他类型数字的加减乘除做运算符重载,如果涉及更多的数学运算,则再建立一个定点数数学库,存放一些数学运算的函数,再用编写好的定点数类去写些用于扩展的逻辑类,仅此而已,都是只要花点时间就能搞定的事,Github上也有很多定点数开源的代码,可以下载下来参考,或者把它从头到尾看一遍,将它改成适合自己项目的工具库。
用字符串代替浮点数
如果想要精确度非常高,定点数和浮点数无法满足要求,那么就可以用字符串代替浮点数来计算。但它的缺点是CPU和内存的消耗特别大,只能做少量高精度的计算。我在大学里做算法竞赛题目时,就遇到过这种检验程序员的逻辑能力和考虑问题全面性的题目,题目很简单,A×B或A-B或A+B或A/B输出结果,精度要求在小数点后100位。我们把中小学算术的笔算方式写入程序里,把字符串转化为整数,并用整数计算当前位置,接着用字符串形式存储数字,这样的计算方式完全不需要担心越界问题,还能自由控制精度。缺点是很消耗CPU和内存,比如对于123 456.789 123 45×456 789.234 567 8这种类型的计算,使用字符串代替浮点数,一次的计算量相当于计算好几万次的普通浮点数。所以,如果程序中对精度要求很高,且计算的次数不多,这种方式可以放在考虑范围内。