浮点数运算中的误差为何难以规避?

前言

很多人在初学写程序时都会遇到所谓的浮点误差,如果你到目前都还没被浮点误差坑过,那只能说你真的很幸运。

以下图 Python 的例子来说 0.1 + 0.2 并不等于 0.38.7 / 10 也不等于 0.87 ,而是 0.869999... ,真的很不可思议。

但这绝对不是什么神 bug,也不是 Python 设计得不好,而是浮点数在做运算时必然的结果,所以即便是到了 Node.js 或其他语言也都是一样的。

电脑如何储存一个整数(Integer)

在讲为什么会有浮点误差之前,先来谈谈电脑是怎么用 0 跟 1 来表示一个 整数 ,大家应该都知道二进制这个东西:像 101 代表 2² + 2⁰ 也就是 5、 1010 代表 2³ + 2¹ 也就是 10

如果是一个 unsigned 的 32 bit 整数,代表他有 32 个位置可以放 0 或 1,所以最小值就是 0000...0000 也就是 0,而最大值 1111...1111 代表 2³¹ + 2³⁰ + ... + 2¹ + 2⁰ 也就是 4294967295

从排列组合的角度来想,因为每一个 bit 都可以是 0 或 1,整个变数值有 2³² 种可能性,所以可以 精确的 表达出 0 到 2³²-1 中任一个值,不会有任何误差

浮点数(Floating Point)

虽然从 0 到 2³²-1 之间有很多很多个整数,但数量终究是 有限 的,就是 2³² 个那么多而已;但浮点数就大大的不同了,大家可以这样想:在 1 到 10 这个区间中只有十个整数,但却有 无限多个 浮点数,譬如说 5.1、5.11、5.111 等等,再怎么数都数不完

但因为在 32 bit 的空间中就只有 2³² 种可能性,为了把所有浮点数都塞在这个 32 bit 的空间里面,许多 CPU 厂商发明了各种浮点数的表示方式,但若各家 CPU 的格式都不一样也很麻烦,所以最后是以 IEEE 发布的 IEEE 754 作为通用的浮点数运算标准,后来的 CPU 也都遵循这个标准进行设计

IEEE 754

IEEE 754 里面定义了很多东西,其中包括单精度(32 bit)、双精度(64 bit)跟特殊值(无穷大、NaN)的表示方式等等

规格化

以 8.5 这个符点数来说,如果要变成 IEEE 754 格式的话必须先做规格化:把 8.5 拆成 8 + 0.5 也就是 2³ + 1/2¹,接著写成二进位变成 1000.1,最后再写成 1.0001 x 2³,跟十进位的科学计数法差不多的

单精度浮点数

在 IEEE 754 中 32 bit 浮点数被拆成三个部分,分别是 sign、exponent 跟 fraction,加起来总共是 32 个 bit

  • sign:最左侧的 1 bit 代表正负号,正数的话 sign 就为 0,反之则是 1
  • exponent:中间的 8 bit 代表正规化后的次方数,采用的是 超 127 格式,也就是 3 还要加上 127 = 130
  • fraction:最右侧的 23 bit 放的是小数部分,以 1.0001 来说就是去掉 1. 之后的 0001

所以如果把 8.5 表示成 32 bit 格式的话就会是这样:

什么情况下会不准呢?

刚刚 8.5 的例子可以完全表示为 2³+ 1/2¹,是因为 8 跟 0.5 刚好都是 2 的次方数,所以完全不需要牺牲任何精准度

但如果是 8.9 的话因为没办法换成 2 的次方数相加,所以最后会被迫表示成 1.0001110011... x 2³,而且还会产生大概 0.0000003 的误差,好奇结果的话可以到 IEEE-754 Floating Point Converter 网站上玩玩看

双精度浮点数

上面讲的单精度浮点数只用了 32 bit 来表示,为了让误差更小,IEEE 754 也定义了如何用 64 bit 来表示浮点数,跟 32 bit 比起来 fraction 部分大了超过两倍,从 23 bit 变成 52 bit,所以精准度自然提高许多

以刚刚不太准的 8.9 为例,用 64 bit 表示的话虽然可以变得更准,但因为 8.9 无法完全写成 2 的次方数相加,到了小数下 16 位还是出现误差,不过跟原本的误差 0.0000003 比起来已经小了很多

类似的情况还有像 Python 中的 1.00.999...999 是相等的、 123122.999...999 也是相等的,因为他们之间的差距已经小到无法放在 fraction 里面,所以就二进制的格式看来他们每一个 bit 都一样

解决方法

既然无法避免浮点误差,那就只好跟他共处了(打不过就加入?),这边提供两个比较常见的处理方法

设定最大允许误差 ε (epsilon)

在某些语言里面会提供所谓的 epsilon,用来让你判断是不是在浮点误差的允许范围内,以 Python 来说 epsilon 的值大概是 2.2e-16

所以你可以把 0.1 + 0.2 == 0.3 改写成 0.1 + 0.2 --- 0.3 <= epsilon ,这样就能避免浮点误差在运算过程中作怪,也就可以正确比较出 0.1 加 0.2 是不是等于 0.3

当然如果系统没提供的话你也可以自己定义一个 epsilon,设定在 2 的 -15 次方左右

完全使用十进位进行计算

之所以会有浮点误差,是因为十进制转二进制的过程中没办法把所有的小数部分都塞进 fraction,既然转换可能会有误差,那干脆就不要转了,直接用十进制来做计算!!

在 Python 里面有一个 module 叫做 decimal ,它可以帮你用十进位来进行计算,就像你自己用纸笔计算 0.1 + 0.2 绝对不会出错、也不会有任何误差(其他语言也有类似的库)

自从我用了 Decimal 之后不只 bug 不见了,连考试也都考一百分了呢!

虽然用十进位进行计算可以完全躲掉浮点误差,但因为 Decimal 的十进位计算是模拟出来的,在最底层的 CPU 电路中还是用二进位在进行计算,所以跑起来会比原生的浮点运算慢非常多,所以也不建议全部的浮点运算都用 Decimal 来做

总结

回归到这篇文章的主题:「为什么浮点误差是无法避免的?」,相信大家都已经知道了

至于你说知道 IEEE 754 的浮点数格式有什么用吗?好像也没什么特别的用处,只是觉得能从浮点数的格式来探究误差的成因很有趣而已,感觉离真相又近了一点点

而且说不定哪天会有人问我「为什么浮点运算会产生误差而整数不会」,那时我就可以有自信的讲解给他听,而不是跟他说「反正浮点运算就是会有误差,背起来就对了」

相关推荐
hackeroink31 分钟前
【2024版】最新推荐好用的XSS漏洞扫描利用工具_xss扫描工具
前端·xss
迷雾漫步者2 小时前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-3 小时前
验证码机制
前端·后端
燃先生._.4 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
XH华4 小时前
初识C语言之二维数组(下)
c语言·算法
南宫生4 小时前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
不想当程序猿_4 小时前
【蓝桥杯每日一题】求和——前缀和
算法·前缀和·蓝桥杯
高山我梦口香糖4 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235245 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式