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

前言

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

以下图 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 的浮点数格式有什么用吗?好像也没什么特别的用处,只是觉得能从浮点数的格式来探究误差的成因很有趣而已,感觉离真相又近了一点点

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

相关推荐
wzdark5 分钟前
多核架构下算法并行化的瓶颈与突破点4
算法·架构
阿明616 分钟前
Leetcode: 283. 移动零
算法·leetcode·职场和发展
南京兴帝文化传媒有限公司18 分钟前
基于地图平台的本地商户信息优化:药店夜间服务标注与客户转化实操
前端·javascript·数据库·人工智能·geo 优化·geo优化避坑·ai搜索获客
八荒启·交互动画19 分钟前
Web特效04——GPUvs CPU,为什么图形计算要交给GPU,什么是“并行计算”
前端
葫三生31 分钟前
三生原理与《涌现:从简单规则到复杂世界》在“简单规则生成复杂系统”核心思路上存在理论呼应?
人工智能·科技·算法·机器学习·开源
呃呃呃呃ex36 分钟前
2026年古法编程的末法时代,如何评估自己完成迅速转行
前端·后端
南京兴帝文化传媒有限公司37 分钟前
地图SEO与AI搜索优化结合实践:宁国摄影工作室本地获客案例分析
大数据·前端·人工智能·geo 优化·geo优化避坑
用户9210802628644 分钟前
前端 Vue 专栏 07:模板编译、虚拟 DOM、Diff 与 key
前端
八荒启_交互动画1 小时前
Web特效03——像素VS顶点:图形处理的的两种基本单位
javascript
光影少年1 小时前
react navite process.nextTick 和 Promise.then 的执行顺序
前端·react native·react.js