这篇文章上次修改于 794 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

前言

最近,经常有人邀请我回答关于浮点数的精度的问题。可能是因为我在这种简单问题下的回答太多了,也可能是因为我的水平只能答这种问题了。

冷.jpg

不管怎么样,每一个问问题的人都是应该尊重的,但我也不想一个问题写一篇长答,于是统一成一篇文章,一个问题贴一个链接。

注意,这将是一篇比较长的文章,从二进制讲起,浮点数的很多细节我都会涉及。


二进制与小数

众所周知,计算机是基于二进制的。二进制表示整数很简单,比如 $233=11101001_2$ 。

但是如果要表示一个小数呢?

也许可以这样:用整数部分的二进制,跟着小数点,然后跟上小数部分的二进制。就像这样: $13.14 \rightarrow 1101.1110_2$ 。

但仍有问题。 $13.140 \rightarrow 1101.10001100_2$ 。它和 13.14 在十进制下一眼就能看出相等,但是二进制下根本没什么关系。

让我们回归小数在十进制下的含义:

(以下内容包含大量数学公式,不过我会尽量写得明白一些)

一个小数包括整数部分和小数部分。小数部分是一个小于1的小数,简单起见,我们只讨论小于1的小数。这样的小数在小数点之后就是一个数字序列。e.g. $0.141592653589 \rightarrow [1,4,1,5,9,2,6,5,3,5,8,9]$

记一个小数 $a$ 的小数点后第 $n $ 位为 $a_n$ ,那么小数 $a$ 的值可以表示为:

$\bbox[#C0FFFF,5px,border:2px solid #90A0FF]{ a:= \sum_{n=1}^\infty \frac{a_n}{10^n} }\\$

$\color{#808080}{\small{(感谢 \,@酱紫君\, 教会我 \texttt{\bbox} \, 命令)}}$

把这个式子展开,就是 $a := 0.1\times a_1 +0.01\times a_2+0.001\times a_3+\dots$

再直观一点:

$\begin{array}{c|cccc} 小数点后&第一位&第二位&第三位&\cdots\\ \hline 0.1 \times&a_1 &&&\\ 0.01 \times && a_2 &&\\ 0.001 \times &&& a_3 &\\ \dots&&&&\ddots \end{array}$

(好吧我承认我是来练 $\LaTeX$ 的)

其实这个含义很简单,如果看不懂,我建议回去补一补数学。

下面推广到 $n$ 进制。

记一个 $k$ 进制小数 $a$ 的小数点后第 $n $ 位为 $a_n$ ,那么小数 $a$ 的值可以表示为:

$\bbox[#C0FFFF,5px,border:2px solid #90A0FF]{ a:= \sum_{n=1}^\infty \frac{a_n}{k^n} }\\$

基本没变,只是把 $10$ 变成了 $k$ 。

也很好理解: $\color{#A03070}{10进制下就用10,k进制下就用k。}$

由此可以得到2进制版本:

$\bbox[#C0FFFF,5px,border:2px solid #90A0FF]{ a:= \sum_{n=1}^\infty \frac{a_n}{2^n} }\\$

利用这一个式子,可以把2进制小数化为10进制。e.g.

$\begin{align} 0.1011_2 &= \sum_{n=1}^\infty \frac{a_n}{2^n} \\ &= \frac{1}{2}+\frac{0}{4}+\frac{1}{8}+\frac{1}{16}\\ &= \frac{11}{16}\\ &= 0.6875 \end{align}$


10进制小数转2进制

我™怎么挖了这么个天坑……

接下来又是神(wu)奇(liao)的数学推导时间,大家可以散了。

考察二进制小数的定义:$a:= \sum_{n=1}^\infty \frac{a_n}{2^n}=\frac{a_1}{2}+\frac{a_2}{4}+\frac{a_3}{8}+\cdots$ 。

由于 $a_n$ 只能取 0 或 1,所以第一项 $\frac{a_1}{2}<1$ ,第二项 $\frac{a_2}{4}<\frac{1}{2}$ ,依次类推。

显然,如果一个小数 $a>\frac{1}{2}$ ,那么它的第一项 $a_1 $ 一定是1;将 $a-0.5$ 乘2,可以得到一个新的小数,这个小数的二进制表示相当于 $a$ 去掉第一位。

如果 $a<\frac{1}{2}$ ,那么第一项 $a_1$ 为0;将 $a$ 乘2,可以得到一个新的小数,这个小数的二进制表示相当于 $a$ 去掉第一位。

如果 $a=\frac{1}{2}$ ,那么 $a=0.1_2$ 。

emmm,上面说的我自己也看不懂。

举个栗子:

$\begin{align} 0.6875>\frac{1}{2} &\rightarrow 0.1\cdots_2 \\ \color{#808080}{\small{(0.6875-\frac{1}{2})\times 2=0.375}}\\ 0.375<\frac{1}{2} &\rightarrow 0.10\cdots_2 \\ \color{#808080}{\small{0.375\times 2=0.75}}\\ 0.75>\frac{1}{2} &\rightarrow 0.101\cdots_2 \\ \color{#808080}{\small{(0.75-\frac{1}{2})\times 2=0.5}}\\ 0.5=\frac{1}{2} &\rightarrow 0.1011_2 \\ \end{align}$

$\color{red}{∴\large{0.6875=0.1011_2}}$

是不是明白了很多呢?

再来看另一个例子:

$\begin{align} 0.6>\frac{1}{2} &\rightarrow 0.1\cdots_2 \\ \color{#808080}{\small{(0.6-\frac{1}{2})\times 2=0.2}}\\ 0.2<\frac{1}{2} &\rightarrow 0.10\cdots_2 \\ \color{#808080}{\small{0.2\times 2=0.4}}\\ 0.4<\frac{1}{2} &\rightarrow 0.100\cdots_2 \\ \color{#808080}{\small{0.4\times 2=0.8}}\\ 0.8>\frac{1}{2} &\rightarrow 0.1001\cdots_2 \\ \color{#808080}{\small{0.8-\frac{1}{2}\times 2=0.6}}\\ 0.6>\frac{1}{2} &\rightarrow 0.10011\cdots_2 \\ \end{align}$

算到这里,聪明的同学可能就发现了:算了一圈,又回到0.6了。

这说明什么?

0.6根本无法用2进制在有限位数内精确表示,实际上, $0.6=0.\dot100\dot1_2$ ,是个无限循环小数。

数学上,我们可以点个点表示无限循环,但是计算机可不欢迎无限


误差的起源

那么,0.6 这样的数在计算机里怎么表示呢?

很简单,舍入保留有限位数。相对于十进制的“四舍五入”,二进制的“0舍1入”更简单。

所以,如果保留12个二进制位, $0.6 \approx 0.100110011010_2$ 。但是如果再把它转化成十进制, $0.100110011010_2=0.600098$ 。

误差就是从这里产生的。


二进制小数的计算机表示

但是,即使是有限的二进制小数,也是不能直接在计算机里保存的。

想一想,记录一个二进制小数需要什么?看起来只是0和1,其实还有一个重要的组成部分:小数点。

让我们再请回老朋友 $0.6875$ ,再做一次现场示范。

$0.6875=\bbox[#F0F0F0, border: 1px solid #A0A0A0]{\begin{array}{c|c} 0&.&1&0&1&1 \end{array}}$

关键的一点在于如何处理小数点。

一种很简单的做法是:既然有三种数据,那就用00、01、10表示,占用2个bit。还有一个11没用,就当数据校验了。如图:

$0.6875=\bbox[#F0F0F0, border: 1px solid #A0A0A0]{\begin{array}{c|c} 0&.&1&0&1&1 \\ \hline 00&10&01&00&01&01 \end{array}}$

这样,表示0.6875用了12个2进制位:001001000101。

上面只是来搞笑的,实际应用里,我们不会做“把一个二进制位用两个二进制位写”的事的。

再仔细看一下小数,它们有一个共同点:在一个小数里,小数点只有一个。

这不是一句废话。因为这个特性,我们完全可以把小数点挪掉,然后用另一个数据标示小数点在第几位。e.g.

$0.6875=\bbox[#F0F0F0, border: 1px solid #A0A0A0]{\begin{array}{c|c} 0&.&1&0&1&1&\textrm{offset} \\ \hline 0&&1&0&1&1&2=10_2 \\ \end{array}}$

$\textrm{offset}$ 表示小数点在小数的第几位。

这样用两个数据表示0.6875:[01011, 10],总计占用7个二进制位。


定点数

接下来,我要做一件神圣而伟大的事,那就是:

$\bbox[#FFE0E0,5px,border:2px solid #FF8080]{歪个楼}$

先不说定点数,来讨论一个重要的观点:数据对齐。

在计算机里,并不能直接把一个二进制位拿来用,实际上,最小的单位是 Byte,字节,就是8个二进制位。即使是一个布尔量,只有 True 和 False 两种状态,也要用8个二进制位。

这看起来很浪费,但是这是计算机性能提高的一个关键。整齐划一的数据更有利于内存的寻址,而浪费的7个字节对于上G的内存不算什么。在 SSE 指令集中,为了性能甚至要求数据16字节对齐,这就意味着如果用 SSE 处理布尔运算,需要浪费127个二进制位(不过,没人会这么做,因为 SSE 是用来算浮点数的)

那么,这和定点数有什么关系呢?

没有任何关系。

其实我是为了说明:任何计算机数据,都要装在8位、16位或32位的“框架”里。所以,刚才的数据表示法,又不能用了。

那怎么办?

能不能把储存小数点位置的量扔掉呢?用16位表示一个二进制小数,假设小数点在第8位,前8位是整数部分,后8位是小数部分。

$\begin{align} 0.6875&=\bbox[#F0F0F0, border: 1px solid #A0A0A0]{\begin{array}{c|c} &&&&&&&0.&1&0&1&1 \\ \hline 0&0&0&0&0&0&0&0&1&0&1&1&0&0&0&0 \\ \end{array}}\\ 26.7265625&=\bbox[#F0F0F0, border: 1px solid #A0A0A0]{\begin{array}{c|c} &&&1&1&0&1&0.&1&0&1&1&1&0&1 \\ \hline 0&0&0&1&1&0&1&0&1&0&1&1&1&0&1&0 \\ \end{array}} \end{align}$

如果小数部分再多一点,写不开了怎么办?

没关系,我们有暴力美学:四舍五入,不对,是0舍1入

$0.6\approx\bbox[#F0F0F0, border: 1px solid #A0A0A0]{\begin{array}{c|c} &&&&&&&0.&1&0&0&1&1&0&0&1 \\ \hline 0&0&0&0&0&0&0&0&1&0&0&1&1&0&0&1 \\ \end{array}}$

于是误差又来了。

$\begin{align} \color{blue}{0.10011001}_2&=0.59765625\\ &=0.6-\color{red}{0.00234375} \end{align}$

减小这里的误差有几种方法。比如增大数据容量,从16位变成32位;或者改变小数点位置的约定,增大小数部分的空间,但这样整数部分的范围就小了。

示范图没有,因为我懒得打,毕竟16位版本的 $\texttt{code}$ 就已经是这样了:
$\texttt{\begin{align} 0.6875&=\bbox[#F0F0F0, border: 1px solid #A0A0A0]{\begin{array}{c|c} &&&&&&&0.&1&0&1&1 \\\\ \hline 0&0&0&0&0&0&0&0&1&0&1&1&0&0&0&0 \\\\ \end{array}}\\\\ 26.7265625&=\bbox[#F0F0F0, border: 1px solid #A0A0A0]{\begin{array}{c|c} &&&1&1&0&1&0.&1&0&1&1&1&0&1 \\\\ \hline 0&0&0&1&1&0&1&0&1&0&1&1&1&0&1&0 \\\\ \end{array}} \end{align}}\\$

好了,这种固定小数点位置的方式,就叫做定点数(fix point)


浮点数

似乎已经拖了两个月了呢。 再拖下去就不是日经答疑而是年度答疑了。

定点数有一个很大的问题:如果要表示的数字很小,比如 0.00123456,16位定点数依然会拿出8个二进制位表示整数部分的0,而小数部分的精确数据可能被舍弃。而如果用更多的数据存放小数部分,有不能储存整数部分太大的数

鱼,我所欲也;熊掌,亦我所欲也。二者不可得兼,舍鱼而取熊掌者也。

——《孟子·告子上》

但是,聪明的程序猿总是有办法的。

要解决这个问题,我们首先从较弱的情况开始讨论:如何储存大于等于1小于2的正数?或者说,如何储存整数部分为1的小数?

非常简单,因为整数部分都是1,所以可以不用储存,只储存小数部分就可以了。

再次请出1+0.6875酱~

$ 1.6875=\begin{array}{l}1.\\ {}\end{array} \ \bbox[#F0F0F0, border: 1px solid #A0A0A0]{\begin{array}{c|c} 1&0&1&1&&&&&&&& \\ \hline 1&0&1&1&0&0&0&0&0&0&0&0&0&0&0&0 \\ \end{array}}$

一些截断的误差也会减小。

$\begin{align} \color{blue}{1.6}&\approx\begin{array}{l}1.\\ {}\end{array} \ \bbox[#F0F0F0, border: 1px solid #A0A0A0]{\begin{array}{c|c} 1&0&0&1&1&0&0&1&1&0&0&1&1&0&0&1 \\ \hline 1&0&0&1&1&0&0&1&1&0&0&1&1&0&0&1 \\ \end{array}}\\ &=1.5999908447265625\\ &=1.6-\color{red}{0.0000091552734375}\end{align}$

高精度数值运算真麻烦。

那么,这样有什么用呢?

还记得科学记数法吗?它可以把任何一个十进制小数表示为一个大于0小于10的数和一个整数。比如:
$0.6875=\color{#800080}{6.875} \times 10^{\color{#000080}{-1}}$ 。

这种好东西,直接推广到二进制吧。

l:公式1

图中紫色的部分称为尾数,蓝色的部分称为指数。

可以看到,二进制科学记数法的尾数的整数部分都是1,称为前导1。有没有想到什么?不要说刚看过去的内容就忘了。

这样,要储存一个二进制小数,只需要知道3个量:尾数(不含前导1)、指数符号。这三项都是能用有限个二进制位表示出来的(虽然尾数有误差),所以只要把它们排在一起就能表示一个二进制小数。这种储存方式叫做浮点数。

为什么叫浮点数呢?

因为爱情(误

在科学记数法中,指数可以看作表示小数点的位置。这样,浮点数的小数点位置是不固定的,所以叫“float”,就是“浮点”。

现在,通行的浮点数的标准是 IEEE 754。它主要规定了2种浮点数:

  • 32 位的 Single(又叫 float)。第1位是符号位,第2~9位是指数,第10~32位是尾数。
  • 64 位的 Double。第1位是符号位,第2~12位是指数,第13~64位是尾数。

这两种标准中,浮点数的每一部分除了数据长度差异以外,没有其他差别。所以我们以 Double 为例,讲解一下浮点数的组成。


双精度浮点数

过年了,真的拖成年度文章了~(逃

Double 又叫双精度浮点数,对应地,Single 叫单精度浮点数。双精度浮点数由三部分组成:

l:公式2

接下来,分别说一说这三部分的构成。

  • 符号位

符号位很简单,0 代表正数或数字0,1 代表负数。

  • 指数

按照 IEEE 的规定,指数采用“移码”的方式储存。移码其实很简单,就是把原始数据都加上一个定值,使其大于0,比如双精度的指数有11位,就给数据都加上 $2^{11-1}-1=1023$ ,然后就可以表示 $-2^{10}+2 $ 到 $2^{10}-1$ 的所有整数了( $-2^{10}+1$ 和 $2^{10}$ 有别的用途,下文会提到)。

为什么要采用“移码”的形式呢?因为我们在比较两个数的时候,肯定要先比较数量级,然后再去比较尾数。采用移码的形式,能够像比较无符号整数一样直接比较正指数和负指数,加快运算的效率。

  • 尾数

尾数就和上一节讲的一样,将二进制小数转为科学计数法,再去掉默认的前导1,就得到了尾数部分。

那么,浮点数与定点数相比,有什么优势呢

就像科学计数法一样,浮点数保留的是有效数字的精度,也就是说,它把相对误差控制在了一个固定的范围内。当数字很大时,它可以舍去低数字位的精度,保证高位的基本准确;而当数字很接近0的时候,它又可以变得很精确。

当然,之前我们提到的计算机储存小数的固有缺陷还是存在的,也就是无限不循环小数依然不能精确储存


浮点数的运算

浮点数使用了比较复杂的格式储存,虽然提高了精度,但也让浮点数的运算变得麻烦了许多。

浮点数的加减法运算通常有以下几个步骤:

  • 对阶

对阶也就是“让阶码对齐”。如果两个操作数的阶码不同,是不能直接运算的,要先经过对阶操作,让两个阶码相同,再进行下一步运算。

一般在对阶时,采用的是“小阶对大阶”,也就是让阶码较小的数来对齐较大的数,将其阶码加上两者的阶码差,并将尾数右移对应的位数(右移的时候会补充隐藏的前导1)。右移时溢出的部分可以暂时保存,以便后面的规格化及舍入操作。

  • 尾数计算

这一步就相对简单了,直接将两个尾数相加(相减),得到运算结果。

  • 规格化

两个尾数运算的结果,不一定满足浮点数尾数的要求。规格化就是调整结果的符号、指数、尾数,以满足要求的过程。比如如果尾数小于1,就要将尾数左移并降低指数;如果尾数大于2,就要将其右移并增加指数。

  • 舍入

经过上述运算之后,还要将尾数通过舍入操作限制在规定的位数之内。IEEE 754 规定了多种舍入标准,有舍入到最接近(0舍1入,如果只有一个1就舍入到偶数)、向下舍入、向上舍入、向0舍入。选择的舍入标准取决于具体实现。

  • 处理溢出

有的时候得到的结果并不是能用普通的格式表示的浮点数,比如超出上界或下界的情况,这时候就要用特殊的表示方法来表示这些数。关于这种情况,会在下文详细讲解。


一些特殊的浮点数

我们先来看一个数:0。如果你试着把0写成科学计数法,就会发现不管怎么写,都没法弄出一个前导1。这就说明,0不能用上面的方法储存为浮点数

当然 IEEE 不会想不到这一点,在制定标准时,预留了阶码为最大值和最小值的两个区段,来表示一些特殊的浮点数。

由于 IEEE 754 规定阶码用移码储存,所以最小值和最大值分别是阶码全0和全1的时候,IEEE 754 规定,这两种情形分别用来表示零和非规约数、无穷大和NaN。

当阶码和尾数全部为0的时候,表示浮点数0。但是这一条并不要求符号位,所以在 IEEE 754 的标准中,有两个0,分别是正零和负零。这两个0的大多数表现是一致的,而且也是相等的,但是有一个有趣的小规定: $\sqrt{+0}=+0$ ,而 $\sqrt{-0}=-0$ 。

  • 非规约数

当阶码全为0但尾数不全为0时,表示一个非规约数。非规约数的尾数没有前导1,其指数固定为 $-2^{n-1}+2$ ( $n$ 为阶码的位数,比如对于 Double, $-2^{n-1}+2=-1022$ )。引入非规约数是为了解决最小的正浮点数和0的差值过大的问题。非规约数正好填补了两者之间的空缺,让这一部分数可以平滑过渡。

  • 无穷大

当阶码全为1且尾数全为0时,表示无穷大。根据符号位不同,有正无穷和负无穷。

  • NaN

当阶码全为1,但尾数不全为0时,表示 NaN(Not a Number,用于表示异常的计算结果,比如0/0)。NaN 有一个神奇的特性,就是 NaN 不和任何浮点数相等,包括 NaN,也就是说,NaN == NaN 是 false


写在最后

时隔两年多,我终于把这篇文章写完了……

写作这一篇文章其实也是我自己学习的过程,我第一次深入地理解浮点数及其具体实现,但是,也难免存在疏漏或谬误。欢迎各位在评论区指正。