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

前言

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

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

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

相关推荐
凌肖战几秒前
力扣上刷题之C语言实现(数组)
c语言·算法·leetcode
小白小白从不日白11 分钟前
react 组件通讯
前端·react.js
罗_三金21 分钟前
前端框架对比和选择?
javascript·前端框架·vue·react·angular
秋夫人27 分钟前
B+树(B+TREE)索引
数据结构·算法
Redstone Monstrosity28 分钟前
字节二面
前端·面试
东方翱翔35 分钟前
CSS的三种基本选择器
前端·css
Fan_web1 小时前
JavaScript高级——闭包应用-自定义js模块
开发语言·前端·javascript·css·html
梦想科研社1 小时前
【无人机设计与控制】四旋翼无人机俯仰姿态保持模糊PID控制(带说明报告)
开发语言·算法·数学建模·matlab·无人机
Milo_K1 小时前
今日 leetCode 15.三数之和
算法·leetcode
yanglamei19621 小时前
基于GIKT深度知识追踪模型的习题推荐系统源代码+数据库+使用说明,后端采用flask,前端采用vue
前端·数据库·flask