大家好!当我昨天在写关于浮点数的文章时,我发现我对下面这个使用 64 位浮点数的计算很好奇:
1 | 0.1 + 0.2 |
我意识到我并不完全理解它的工作原理。我的意思是,我知道浮点数计算是不精确的,并且我知道你不能准确地在二进制中表示0.1
,但是:有一个浮点数比 0.30000000000000004
更接近 0.3
!那么为什么我们得到的答案 0.30000000000000004
呢?
如果你不想读完这篇充满计算的文章,比较简短的答案是:0.1000000000000000055511151231257827021181583404541015625 + 0.200000000000000011102230246251565404236316680908203125
正好介于 0.299999999999999988897769753748434595763683319091796875
(通常显示为0.3
)和 0.3000000000000000444089209850062616169452667236328125
(通常显示为 0.30000000000000004
)这两个浮点数之间。最终结果是0.30000000000000004
(第二个),因为它的有效数是偶数。
浮点数加法是如何工作的
下面是浮点数加法的大致工作方式:
- (以额外的精度)将数字加到一起
- 将结果四舍五入到最近的浮点数
让我们使用这些规则来计算 0.1 + 0.2
。我昨天刚学会浮点数加法的工作原理,所以在这篇文章中我可能犯了一些错误,但最终我得到了我所期望的答案。
第一步:找出 0.1 和 0.2 是什么
首先,让我们使用 Python 找出 0.1
和 0.2
作为 64 位浮点数时的确切值。
1 | f"{0.1:.80f}" |
这些就是准确的值:因为浮点数以 2
为基数,你可以用十进制表示它们,只是有时需要很多数字。 :)
第二步:将数字相加
接下来,让我们将这些数字相加。我们可以将小数部分作为整数相加以得到准确的答案:
1 | 1000000000000000055511151231257827021181583404541015625 + 2000000000000000111022302462515654042363166809082031250 |
所以这两个浮点数的准确和是 0.3000000000000000166533453693773481063544750213623046875
。
这还不是我们的最终答案,因为 0.3000000000000000166533453693773481063544750213623046875
不是一个 64 位浮点数。
第三步:找出最接近的浮点数
现在,让我们看看 0.3
附近的浮点数。这是最接近 0.3
的浮点数(通常写成 0.3
,即使这不是它的准确值):
1 | f"{0.3:.80f}" |
我们可以通过使用 struct.pack
将 0.3
序列化为 8 字节,加 1
,然后使用 struct.unpack
来找出 0.3
之后的下一个浮点数:
1 | "!d", 0.3) struct.pack( |
当然,你也可以使用 math.nextafter
:
1 | 0.3, math.inf) math.nextafter( |
所以0.3
附近的两个 64 位浮点数是0.299999999999999988897769753748434595763683319091796875
和0.3000000000000000444089209850062616169452667236328125
。
第四步:找出哪一个值离我们的结果最近
事实证明,0.3000000000000000166533453693773481063544750213623046875
正好在 0.299999999999999988897769753748434595763683319091796875
和 0.3000000000000000444089209850062616169452667236328125
之间。
你可以通过这个计算看到:
1 | 3000000000000000444089209850062616169452667236328125000 + 2999999999999999888977697537484345957636833190917968750) // 2 == 3000000000000000166533453693773481063544750213623046875 ( |
所以它们都不是最接近的。
它怎么知道要四舍五入到哪一个?
在浮点数的二进制表示中,有一个数叫做“有效数”(尾数)。在这种情况下(当结果正好介于两个连续的浮点数之间时),它将四舍五入到有效数是偶数的那个。
在这种情况下是 0.300000000000000044408920985006261616945266723632812500
。
我们实际上已经看到了这个数字的有效数:
struct.unpack('!d', b'?\xd3333334')
的结果是0.30000000000000004
struct.unpack('!d', b'?\xd3333333')
的结果是0.3
0.30000000000000004
的大端十六进制表示的最后一位是4
,所以它是具有偶数有效数的浮点数(因为有效数是结尾的数字)。
让我们也用二进制计算整个过程
我们上面用十进制做了计算,因为这样阅读起来更直观一些。但当然,计算机不是用十进制来做这些计算的 —— 它们用的是二进制。所以我也想了解一下那是怎么工作的。
我认为这篇文章的二进制计算部分不是特别清楚,但把它写出来对我很有帮助。这一部分真的有很多数字,阅读起来可能很糟糕。
64位浮点数是如何工作的:指数和有效数字
64位浮点数由两个整数表示:一个指数和一个有效数以及一个 1 位的符号。
这是将指数和有效数对应为一个实际的浮点数的公式:
$$\text{符号} \times 2^{\text{指数}} (1 + \frac{\text{有效数}}{2^{52}})$$
例如,如果指数是 1
,有效数字是 2**51
,符号是正的,我们将得到:
$$2^{1} (1 + \frac{2^{51}}{2^{52}})$$
这等于 2 * (1 + 0.5)
,或者 3
。
第一步:获取 0.1
和 0.2
的指数和有效数
我写了一些低效的函数来获取 Python 中正浮点数的指数和有效数:
1 | def get_exponent(f): |
我忽略了符号位(第一位),因为我们只需要这些函数可以在两个数字(0.1
和 0.2
)上工作,而这两个数字都是正的。
首先,让我们获取 0.1
的指数和有效数。我们需要减去 1023
来得到实际的指数,因为浮点数就是这么工作的。
1 | 0.1) - 1023 get_exponent( |
这些数字的组合方式得到 0.1
的方法是 2**指数 + 有效数 / 2**(52 - 指数)
。
这是在 Python 中的计算:
1 | 2**-4 + 2702159776422298 / 2**(52 + 4) |
(你可能会担心这个计算中的浮点数精度问题,但在这种情况下我相当确定它是没问题的,因为这些数字按定义没有精度问题 —— 从 2**-4
开始的浮点数以 1/2**(52 + 4)
为步长递增)
我们对 0.2
也可以做同样的事情:
1 | 0.2) - 1023 get_exponent( |
这是指数和有效数如何组合得到 0.2
的方式:
1 | 2**-3 + 2702159776422298 / 2**(52 + 3) |
(顺便说一下 0.1
和 0.2
有相同的有效数并不是巧合 —— 因为 x
和 2*x
总是有相同的有效数)
第二步:重写 0.1
以获得更大的指数
0.2
的指数比 0.1
大 —— 因为指数是 -3
而不是 -4
。
所以我们需要重写
1 | 2**-4 + 2702159776422298 / 2**(52 + 4) |
为 X / (2**52 + 3)
如果我们解出 2**-4 + 2702159776422298 / 2**(52 + 4) = X / (2**52 + 3)
中的 X
,我们将得到:
X = 2**51 + 2702159776422298 /2
我们可以很容易地在 Python 中计算这个:
1 | 2**51 + 2702159776422298 // 2 |
第三步:添加有效数
现在我们尝试做这个加法
1 | 2**-3 + 2702159776422298 / 2**(52 + 3) + 3602879701896397 / 2**(52 + 3) |
所以我们需要将 2702159776422298
和 3602879701896397
加在一起
1 | 2702159776422298 + 3602879701896397 |
很好。但是 6305039478318695
超过了 2**52 - 1
(有效数的最大值),所以我们遇到了个问题:
1 | 6305039478318695 > 2**52 |
第四步:增加指数
现在我们的答案是
1 | 2**-3 + 6305039478318695 / 2**(52 + 3) |
首先,让我们减去 2**52
得到
1 | 2**-2 + 1801439850948199 / 2**(52 + 3) |
这几乎完美了,但是结尾那里的 2**(52 + 3)
需要是一个 2**(52 + 2)
。
所以我们需要将 1801439850948199
除以 2
。这就是我们丢失精度的地方 —— 1801439850948199
是奇数!
1 | 1801439850948199 / 2 |
它正好在两个整数之间,所以我们四舍五入到最近的偶数(这是浮点数规格说明要做的事情),所以我们的最终浮点数结果是:
1 | 2**-2 + 900719925474100 / 2**(52 + 2) |
这是我们期望的答案:
1 | 0.1 + 0.2 |
这可能不是硬件中确切的工作方式
我在这里描述的操作方式并不是当你做浮点数加法时确切发生的事情(例如 “解出 X
” 并不是计算步骤之一),我相信有很多高效的方法。但我认为这大概是同一个想法。
打印出的浮点数非常奇怪
我们之前说过浮点数 0.3
不等于 0.3
。它实际上是这个数字:
1 | f"{0.3:.80f}" |
那么当你打印出这个数字时,为什么会显示 0.3
呢?
计算机实际上并没有打印出数字的确切值,而是打印出最短的十进制数 d
,其中我们的浮点数 f
是最接近 d
的浮点数。
事实证明,能够有效地做到这一点并不简单,有很多学术论文都在讨论这个问题,比如 《快速准确地打印浮点数》 或 《如何准确地打印浮点数》。
如果计算机打印出浮点数的确切值会更直观吗?
四舍五入到一个整洁的十进制值很好,但我觉得如果计算机只是打印出浮点数的确切值 —— 这可能会让你在得到奇怪的结果时感到不那么惊讶。
对我来说,0.1000000000000000055511151231257827021181583404541015625 +0.200000000000000011102230246251565404236316680908203125 = 0.3000000000000000444089209850062616169452667236328125
感觉比 0.1 + 0.2 = 0.30000000000000004
更不那么令人惊讶。
这可能不是个好主意,它肯定会占用很多屏幕空间。
关于 PHP 的快速说明
有人在评论中指出 <?php echo (0.1 + 0.2 );?>
可以打印出 0.3
。这是否意味着 PHP 中的浮点数数学有所不同?
我认为答案是否 —— 如果我在这个页面上运行 <?php echo (0.1 + 0.2 )- 0.3);?>
,我了得到和 Python 中相同的结果 5.5511151231258E-17
。所以看起来底层的浮点数数学是相同的。
我认为 0.1 + 0.2
在 PHP 中打印出 0.3
的原因是 PHP 显示浮点数的算法和 Python 的比不太精确 —— 即使那个数字不是最接近 0.3
的浮点数,它也会显示 0.3
。
就这些!
我有点怀疑是否有人有耐心看完上面所有的算术运算,但把它写下来对我有帮助,所以我还是发布了这篇文章。希望这其中一些内容是有意义的。