11

0. 从一个BUG说起

(注:没有编程经验或者只想从二进制看起的朋友,可以跳过这一节,直接看1. 认识二进制章节。)

若干年前,在我开发的一个项目中,出现了一个奇怪的问题,用一段代码抽象出来大概是这个样子:

i = 0
while i < 2:
    if i == 0.9:
        print(0.9)
    elif i == 1.2:
        print(1.2)
    elif i == 1.5:
        print(1.5)
    i += 0.1

猜这段代码最终会输出什么?嗯,1.2,这段代码只输出了1.2。也就是说,在这段代码中,i == 0.9i == 1.5这两个判断都是false,因此没有执行接下来各自块内的print代码。

这就很令人费解了,因为这段代码的逻辑非常简单,即便是刚学编程的新人,也能看出来,这段代码在逻辑上没有任何问题:i0开始,每次+0.1,那么i必然会有过i == 0.9i == 1.5这两个值。这个逻辑清晰,数学上也正确,并且代码输出了1.2,说明代码一定也走过了0.9,但为什么在我们的代码里,0.91.5都没有输出呢?

实际上,如果我们把代码改一下,让它每次循环时,都输出当前的值,我们就能发现一些端倪:

# 代码
i = 0
while i < 2:
    print(i)
    i += 0.1

# 结果
0
0.1
0.2
0.30000000000000004
0.4
0.5
0.6
0.7
0.7999999999999999
0.8999999999999999
0.9999999999999999
1.0999999999999999
1.2
1.3
1.4000000000000001
1.5000000000000002
1.6000000000000003
1.7000000000000004
1.8000000000000005
1.9000000000000006

看到了吗?很奇怪对不对?这是我在Python 3.6下的输出结果,有兴趣的朋友可以试试其他语言,看看是不是这样的结果。

这样的结果就自然而然地引发了我们的疑问:

为什么是这样?

这一切,都和我们这篇系列文章所谈论的主题————二进制有关。

0.1. 0.30000000000000004

其实上面的这个问题,在整个计算机界都是非常出名的一个问题,它有一个更为人熟知的描述:0.1 + 0.2 = 0.30000000000000004,甚至还有一个专门的网站Floating Point Math统计了各个语言中0.1 + 0.2的结果。而这个问题的根源,在于计算机中浮点数的精度。1989年,计算机科学家William Kahan凭借浮点运算的数值分析研究拿到了图灵奖,同时,William Kahan也是浮点运算标准IEEE 754IEEE 854的主要设计师。

那么这个所谓的精度问题到底是怎么回事呢?由于这一节是整篇文章抛砖引玉的一节,这篇文章的重点在二进制本身,因此这里先用几句话简单地解释一下这个精度问题。简单说,就是:

计算机中所有的运算、存储,都是以二进制进行的,二进制可以精确地表示整数,但却无法精确表示一些分数(小数)。所以,我们看到的0.1在计算机中实际存储的并不是那个我们熟知的1/10,它只是一个与0.1非常接近的近似值,而这个值,可能是0.10000000000000001,也可能是0.1000000000000000055511151231257827021181583404541015625,但它终究只是一个近似值。同理,0.20.3等小数也是近似值,因而在文章一开始的代码中,我们用于判断的0.9,和由程序循环计算出来的0.9并不是同一个0.9,它们在我们看不到的位数上有着精度的差异,就像上面的两个0.1的近似值,而这种差异在程序做数值比较时,被判定为了false,不相等,于是有了文章开头看到的程序“BUG”。

那么为什么二进制无法精确表示某些小数呢?这个就是二进制的具体计算方式导致的了,我们留到后面再讲。

解释完了精度问题的大概原因,我们再回过头来看看文章开头的那段代码,既然这个BUG的源头是计算机的问题,那么我们在代码中有办法解决么?其实是有的。

许多语言都提供了高精度运算的工具,用这些高精度运算方法,就可以解决上面代码中的问题了。依然以Python为例,在Python的标准库中,有一个叫做decimal的库,用它就可以完美解决我们的问题,修改后的代码如下:

from decimal import Decimal

i = Decimal('0')
while i < Decimal('2'):
    if i == Decimal('0.9'):
        print(0.9)
    elif i == Decimal('1.2'):
        print(1.2)
    elif i == Decimal('1.5'):
        print(1.5)
    i += Decimal('0.1')

这样之后,0.91.21.5就都可以按照代码逻辑打印在屏幕上了。

如果我们的语言没有这种方便的高精度库呢?这里再提供一种方法:

epsilon = 0.01
i = 0
while i < 2:
    if abs(i - 0.9) < epsilon:
        print(0.9)
    elif abs(i - 1.2) < epsilon:
        print(1.2)
    elif abs(i - 1.5) < epsilon:
        print(1.5)
    i += 0.1

这种方法同样可以修复这段代码的BUG。对高等数学熟悉的朋友可能注意到了,这种方法其实就是借鉴了高数中极限的定义。放在这段代码中就是说,即使这个i并不能真正等于0.9,但它在计算机中总是有一个精度,也就是在这个精度范围内无限接近0.9,那么我们设置一个epsilon(也就是数学中常见的希腊字母ε),令epsilon足够小,至少比i的变化值要小,但又大于存储的精度,用它来判断i是否趋近于0.9,如果趋近了,我们就认为i此时已经可以看作是0.9

1. 认识二进制

简单解决了上文中的问题后,我们来看看今天的主角,二进制。

1.1 进位计数制

平时我们所说的二进制、八进制、十进制以及十六进制等等各种进制,其实本质上是一种计数方式。即所谓进制,就是进位计数制,不同进制之间的区别,也仅仅是进位的方式的区别。

人类在日常生活中,多使用十进制,即由09十个数字组成的数,计数时逢十进一。举个计数的例子,我们从00开始(补充十位上的0),接着是0102,一直到09,然后下一个数字是第十一个数字,超出了十进制的的限制,因此我们在第二位(从右数,即十位)上进一,变成1,个位回归0,最终组成了10。据说,人类之所以选择了十进制,是因为人类有十根手指,在做计算时非常方便。那人类文明进化的过程中,有使用其他进制嘛?有的,包括二进制、五进制甚至二十进制都曾在历史上出现过。比如我们中国在唱票时常见的字计数法,就是一种五进制。

我们知道,计算机内部采用的是二进制,即01组成的数字,逢二进一。那么计算机为什么会采用二进制?道理和上面人类的十进制类似,因为二进制最符合电子元器件的二元逻辑,即高电平和低电平,或者理解为开(通)和关(不通)。二进制在计数上和十进制一样,最右边为最低位,越往左位数越高,唯一不同的是“逢二进一”。举个例子,我们从0开始,00,然后是01,到这里为止,二进制和十进制表示的数字一样,就是01。但此时已经计了两个数了,1的下一个数应该是2,而二进制里只有01,于是01的下一个数,第二位(从右往左)进一,变成1,第一位回归0,组合后为10

看到了吗,正如前文所述,二进制和十进制的计数非常相似,除了进位不同,整个计数过程完全一样。除了它们俩之外,另外两个我们经常遇到的进制,八进制和十六进制,在计数这个基础功能上,过程也和他们一模一样。甚至如果我们随意设计一个进制,譬如三进制,四进制,他们的计数方式也依然是上文描述的方式。

上面两个进制的例子中,另外一个我们要注意的是,无论是二进制还是十进制,它们在第一次进位后,都变成了10,但这个10在两个进制中表示的数字却并不相同。十进制中的10就是我们平时所知道的10,而二进制中的10则表示的是十进制中的2,或者说,是我们在计数时从0开始后的第三个数。根据这个规律,我们可以隐约猜到,如果我们用八进制和十六进制,它们各自的10,也就是第一次进位的数字,应该分别代表十进制中的816

我们似乎窥见了一丝进制转换,尤其是其他进制转换成十进制的方法和规律,那么这种转换具体是怎么进行的呢?我们继续往下看。

2. 进制的转换(整数)

2.1 十进制分解

由于我们平时都用十进制,而且阿拉伯数字的设计本身也符合十进制的特点,所以我们在长年累月的潜移默化下,整个数字思维也变成了十进制思维,除非经过特殊训练,否则普通人在对数字进行相关操作时,大脑总是以一种十进制的思想去执行。可以这么说,我们的大脑被我们训练成了一台十进制的计算机。

也正因为如此,将其他进制的数字转换成十进制,是一种相对容易,且符合我们思维习惯的一种做法————将一种我们不熟悉的东西,转化成我们熟悉的东西。

在讲进制转换之前,我们先来审视一下我们最熟悉的十进制。

我们随便给一个十进制整数,譬如123好了,这个数字,我们在中文中读作一百二十三,也就是说,123这个数字,是由100203这三个数字相加结合而成的。这非常地显而易见,因为百位上是1,也就是1 x 100,十位上是2,即2 x 10,个位上是33 x 1,最终1 x 100 + 2 x 10 + 3 x 1 = 123。尽管我们平时不会真的这么去算123,因为这个数字本身已经是一个能够深入脑海的十进制数了,但当我们把123这个数字的组成方式拆开来看时,它的整个过程也不会让我们有任何的不适感,因为,个、十、百、千、万,我们日常见到的十进制数都是这么组成的,这种组合方式是非常自然而然的。

既然上面已经拆解了一个十进制数,那么我们可不可以更进一步,用一个数学公式去表示他?

依然是123这个例子,我们观察一下拆解后的1 x 100 + 2 x 10 + 3 x 1 = 123,我们发现,这是一个典型的多项式123是系数,后面的100101则是10的指数,即\(10^n\),于是,上面的式子就变成了:
$$1\times10^2+2\times10^1+3\times10^0=123$$

更一般地,我们将多项式的系数用\(a_n\)代替,将最终结果123用大写字母S表示,然后把上式中的顺序稍微变一下,便得到了这么一个式子:$$S=a_1\times10^0+a_2\times10^1+a_3\times10^2$$

这就是一个标准的十进制数字的组成了,于是我们开始思考:这个多项式每项的系数是我们看到的数字,这个很好理解,但后面的指数部分的底数,为什么是10?许多朋友肯定会说了:因为是十进制啊!

没错,因为是十进制。我们再剖析一下123这个数字,第一位(个位)上是3,而个位并没有进位,它们全都小于10,因此个位上的指数是\(10^0\)。然后是第二位,十位,十位上是2,但十位上的所有数字都是个位进位后得到的,所以当十位上出现非0的数字时,整个数字最小就是10,再进位变成20,一直到十位上变成9,十位就到头了,再变就得进位到第三位(百位)上了,于是,第二位的指数部分自然而然地就是\(10^1\),按照刚才的推论,第三位的指数部分就是\(10^2\)。

对编程敏感的朋友可能注意到了,上面的这个计数 + 进位的过程,是不是很像一个多重循环?

# 可以输出0到999所有1000以下的数
for third in range(10):
    for second in range(10):
        for first in range(10):
            print(int('{0}{1}{2}'.format(third, second, first)))

2.2 其他进制转换成十进制

重点来了,上文里我们说到,十进制数拆分后的多项式指数部分的底数之所以是10,是因为我们用的是十进制,那么如果是二进制呢?这个底数是什么?会不会是……2!我们来试一试。

首先,我们随便取一个二进制整数,譬如101,第一位(右边开始)和第三位都是1,中间是0,我们先按照原始的计数法来数一下,这个数字是几(十进制)。

000开始,接着是001010011100101,后面这四个数用十进制计数后,分别是:12345,所以,101这个二进制数字实际上是5。我们再按照前面所说的多项式来计算一下,看到底是不是5。注意了,此时多项式的指数底数已不再是10,而是本节一开头所猜测的2,得到式子和结果如下:
$$1\times2^2+0\times2^1+1\times2^0=5$$

真的是5!所以这个多项式的指数底数真的就是2,我们的猜测似乎是正确的?自信点,把似乎拿掉,我们的猜测就是正确的,大家感兴趣的可以手动试试其他整数的计算。

那么这样一来,我们已经顺利地完成了二进制到十进制的转化……等一下,为什么我可以这么笃定地说这样的转化结果就是十进制了?是不是太草率了一点?为什么不是别的进制结果?

这是个好问题,我第一次接触二进制的转换计算时,也发出过这样的疑问。这个问题的答案其实也很简单:多项式还是那个多项式,计算的结果取决于我们计算时用的是什么进制。什么意思呢?也就是说,因为我们在计算这个多项式时使用的是十进制,所以我们得到的结果自然也是十进制的。换言之,如果上面的两个例子,我们使用其他进制进行计算,得到的数字结果会是其他进制的结果了。当然,在二进制101这个例子里,出现的所有数字都小于等于5,因此,最终得到的5这个结果,同样也是八进制和十六进制的结果。

原理是这样没错,但实际上我们手动转换时却并不能这么操作,会有各种问题和陷阱,譬如十进制123这个数,我们想用二进制去计算它的多项式,得到的式子是这样:
$$1\times1010^{10}+10\times1010^1+11\times1010^0=?$$

嗯,没错,这个二进制的式子我们自己不会算,只能借助编程语言或者其他工具来计算:

s = int('1', 2) * int('1010', 2) ** int('10', 2) + int('10', 2) * int('1010', 2) ** int('1', 2) + int('11', 2) * int('1010', 2) ** int('0', 2)
print(s, bin(s)[2:])

# 结果
123, 1111011

Python帮我们计算出了这个数字,的确是123,但它会默认以十进制的形式表示,因此这里我们用二进制的转换函数bin()来转换一下,就得到了二进制的结果1111011

再譬如,我们把123这个十进制数的多项式表达式用八进制计算一下:
$$1\times12^2+2\times12^1+3\times12^0=?$$

相比较上面二进制的式子,这个就顺眼多了,因为它长得很十进制。当然了,仅仅是像十进制,它并不是十进制,只是八进制。此时,如果我们下意识地开始计算,就会再次掉入进制陷阱:我们在用十进制的计算方式计算八进制。
$$1\times12^2+2\times12^1+3\times12^0=171(×)$$

$$1\times12^2+2\times12^1+3\times12^0=173(√)$$

上面第一个式子是我们直接去计算的结果,也就是用十进制来计算的八进制,结果显然是错误的;第二个式子则是用八进制的方式进行的计算,得到的结果173正是123的八进制表示。尽管171这个结果是错的,但我们可以很明显地注意到,171和正确值173非常接近,然而这种接近仅仅是因为123这个原数字并不大,而且十进制和八进制又相对接近,所以得到的结果误差很小。甚至有时候我们用错误的方式计算八进制,也会得到正确的答案,譬如十进制正整数12,它的八进制数是14,我们用十进制的方式来做一下八进制的计算:
$$1\times12^1+2\times12^0=14$$

结果确实是14,似乎是正确的,但这仅仅是小整数下的巧合。

好了,我们已经可以将其他进制的数字(整数)正确转换成十进制了,我们把前面用到的这些式子再归纳一下,组成一个最终的数学公式:
$$\sum_{i=1}^{n}a_ix^{i-1}$$

上式中,n为该数字的位数,\(a_i\)是每一位上的数,即多项式每项的系数,\(x^{i-1}\)则为指数部分,其中\(x\)为该数字的进制数,譬如二进制做转换时,\(x\)就是2。根据这个公式计算出来的结果,就是其他进制的整数转换成十进制的结果了。

2.3 十进制转其他进制

本文从这里开始,如果没有什么特殊情形,将减少或不再讲解二进制和十进制以外的进制了,因为从前文我们可以发现,不同进制的计数、计算等操作都非常相似,就像本章开头所说,它们仅仅是进位的方式不同,因此大家后面可以非常容易地举一反三,通过二进制的一些计算和特性,来推算其他进制的情况。

回归正文。由于我们人类平时的计算方式和习惯都是基于十进制的,因此我们如果想要手动把任意进制转换成其他非十进制,除非精通其他进制的各种计算,否则就只能通过十进制中转了。那么十进制要如何正确转换成其他进制呢?

我们回看一下前文,其他进制的整数在转换成十进制时,主要做了乘法和加法的操作,假设十进制转其他进制是上面操作的逆操作,那么是否意味着,我们将一个整数从十进制转成其他进制,需要使用除法和减法?

没错,你猜对了。

不知道大家有没有注意到,前文中我们拆解十进制123这个数字时,凭感觉直接将整个数字拆解成了110100和各自系数相乘累加的多项式,其实这一节我们要讨论的所谓逆运算,原理和这个拆解很像。

我们依然拿十进制数123做文章,将它用十进制的另一种方式把各个位上的数字一个一个拆解出来,怎么拆呢?既然是十进制,那么我们让123除以10来看一下能得到什么结果:
$$123\div10=12......3$$

结果是123,这个余数3,就是我们的个位上的数字。此时,123没有了个位,变成了12,也就是拆解个位时得到的结果。我们以此类推,再来把12中的2给拆解出来:
$$12\div10=1......2$$

这样我们又得到了十位上的2123由这次拆解后,只剩下了百位的1,我们接着拆解:
$$1\div10=0......1$$

只看这三个式子的余数,我们经过三次运算,依次得到了321,由于第一次运算时的余数3是个位的余数,因此它应该在个位上,我们以一种队列的形式(先进先出),把这三个数组合在一起,就变成了123,也就是拆解之前的原数。相对于2.1章节中凑数式的暴力分解,这种拆解方式是不是就温和,也数学得多呢?

为什么能这么拆解?

其实原理显而易见:因为我们要得到十进制,因此用10去除。换句话说,就是,这种拆解方式符合十进制的计数方式,即,逢十进一。逢十进一的意思是说,这个整数,它进了几次位,就必然经历过几个10,数字中就有几个10

举个例子,13,这个数字是由9进位到10,然后再计数到13,它只进位了一次,还没有进行第二次进位,因此这个数中就只有一个10。那么我们的123这个数呢?它在计数的过程中,一共进位了12次,最后的3是在第十二次进位后的计数,所以当我们第一次除以10时,就拿到了12这个数字,拆出了3这个余数。而进位的12次中,又可以再细分成n10,于是就再次做除法和减法(余数),最终拆到结果为0,就意味着这个数字中已经没有10了。

还记得章节 1.中提到的中国的字计数法吗?我们在唱票完统计结果时,通常会直接去数有几个,再把的数量乘5,然后加上最后没有组成的字的笔画数。譬如我们数了21个正,最后还剩了一个,那么这个计数结果就是:\(21\times5+1=106\)。

咦?这个式子,不就是上面我们拆分123时第一个式子的逆运算吗?5是除数,1是余数,21是结果。那按照123的拆解法,我们是不是可以把这里的21再用5拆解呢?
$$21\div5=4......1$$

$$4\div5=0......4$$

我们再次得到了三个余数114,所以我们把这三个数组成一个新的整数:411,这个整数难道就是五进制中的106吗?我们用上一章的方法验证一下:
$$4\times5^2+1\times5^1+1\times5^0=106$$

完全正确!

好了,我们果然又通过十进制的分解,找到了十进制转化到其他进制的算法和规律了,接下来就轮到二进制了。

给一个十进制正整数13,我们用2去除:
$$13\div2=6......1$$

$$6\div2=3......0$$

$$3\div2=1......1$$

$$1\div2=0......1$$

结果是1101,验证一下:
$$1\times2^3+1\times2^2+0\times2^1+1\times2^0=13$$

果然是13。这样一来,十进制整数如何转换成其他进制的方法,我们也完全掌握了,大家有兴趣的可以去试试这里没提到的八进制、十六进制等等,正如前文中所提到的,它们的原理都一样,计算方式也仅仅是进位方式不同。

3. 小数的转换

这篇文章第一次截稿的时候并没有写这一章,因为前面内容实在有点多,打算把小数留到下一篇。后来思来想去,决定还是把小数的转换写在这篇的结尾,以和章节 0.相呼应。

3.1 转成十进制

在讲解其他进制转换成十进制之前,我们遵从前面的习惯,这里依然从解析十进制数自身开始,接着推出其他进制的转换法。

取一个十进制小数,0.123好了,有了前面整数的详细推演过程,我们这里可以直接把0.123拆解了。记得前面123的第一次拆解么?123是由110021031组成,对于十进制来说,分别是\(1\times10^2\)、\(2\times10^1\)、\(3\times10^0\)。我们注意到,在个位时,10的幂已经从百位上的2递减成了0,那么我们可以猜测,如果再往右移,到了小数部分,指数n再递减,就会变成-1-2等等,也就是说,0.123的小数部分,可以写成:
$$1\times10^{-1}+2\times10^{-2}+3\times10^{-3}=1\times0.1+2\times0.01+3\times0.001=0.123$$

由此推演出的十进制小数的计算,很符合我们的直觉。那么既然十进制小数的拆解是这个样子,那么按照前面我们整数的推演流程,如果我们要把一个二进制转换成十进制,是否只需要将上面式子中的10都换成2就好了呢?譬如这里有一个二进制小数0.101,按照我们的推演,它如果要转换成十进制,应该是:
$$1\times2^{-1}+0\times2^{-2}+1\times2^{-3}=\frac{1}{2}+0+\frac{1}{8}=0.5+0.125=0.625$$

由于Python中没有直接把二进制小数转十进制的内置函数,我们可以在网上找一个在线转换进制的工具,自己试一下,会发现上面我们推演的这个式子是成立的,也就是说,二进制数0.101确实就是十进制数0.625

这就是二进制小数转十进制的方法了,那么如果一个数字既包含小数又包含整数部分呢,譬如11.01?只要把整数部分和小数部分分别计算后结果再相加就行了:

$$\sum_{i=1}^{n}a_ix^{i-1}+\sum_{j=1}^{m}a_jx^{-j}$$

其实写到这里我们发现,我们在将二进制转化成十进制时,完全没有必要去太在意它的整数和小数的计算,我们只要累加这个组合:\(a_ix^{i-1}\),从小数点开始,小数点左边的位数i>=0,右边的位数i<0,从小数点往两边i的绝对值越来越大,这样一来,直接将一个浮点数看成一个整体就可以了。

同样的,如果是其他进制转成十进制,方法也是如此,这里就不再展开了。

3.2 十进制转二进制

好了,我们终于来到了进制转换的最后一部分,十进制小数转成二进制。我们依然从十进制入手,用另一种分解方法来分解一个十进制小数,譬如……0.123

前面我们在第二次分解十进制整数时,用到了除法,即无限次除以10,直到商为0为止。小数部分,原理和整数部分类似,但过程稍微有些不同。我们先让0.123除以0.1,然后把商的整数和小数部分给分开:
$$0.123\div0.1=1.23=0.23+1$$

我们得到了0.231两部分,这里的1就类似于前面整数分解时的余数,0.23相当于商,于是我们继续分解这个0.23,直到最终式子的小数部分变成0
$$0.23\div0.1=2.3=0.3+2$$

$$0.3\div0.1=3=0+3$$

扔掉三个式子的结果的小数部分,只取整数部分,再把它们依次排序,放到0.的后面,就得到了0.123这个结果。emmm……有点小问题,之前处理整数部分时,在最后组合的时候,这个组合顺序似乎是倒过来的?第一个解析出来的是个位(最右边),然后是十位、百位,都在个位的左边。而这里我们把第一个解析出来的1放到了结果的最左边,然后往右组合。这种差异容易让我们在计算时产生错误,现在因为计算的是十进制,符合我们的直觉,我们可能会觉得没什么,但如果我们接下来计算二进制,这样的差异性极有可能给我们带来麻烦。

要解决这个问题,我们只需要更新一个概念即可。即,一个任意浮点数,譬如321.123,我们抛弃前面说的那些所有的什么十位、百位,左边,右边第几位等等乱七八糟的概念,而将他们的位数简单分成高位低位两部分,怎么分呢?数字的小数点并不是一个位数,我们以它为一个基准,将它看做位数最低点,以小数点开始,往两边位数逐渐升高,也就是使整体呈现一个V字形。

有了这个概念后,我们再来看拆解和组合:无论是整数拆解还是刚刚进行的小数拆解,第一个拆出来的数字,放在属于它的最低位,第二个放在次低位,以此类推,直到整个数字组合完成。这样一来,他们组合的方式不会再让我们困扰,整个过程非常自然,从低位到高位。

然后我们再来看十进制的小数部分如何转成二进制。

上面十进制的拆分,我们除数部分用的是10,也就是\(10^{-1}\),二进制应该使用\(2^{-1}\),但无论我们用\(2^{-1}\)还是0.5,在写式子并计算的时候,都不是非常直观,所以这里简单做个调整,我们把\(\div2^{-1}\)换成\(\times2\)。整个计算过程的原理是一样的,我们也是把结果分成整数和小数两部分,然后取整数部分,从低位到高位组合起来。我们取上一节用过的一个十进制小数0.625,然后开始计算:
$$0.625\times2=1.25=0.25+1$$

$$0.25\times2=0.5=0.5+0$$

$$0.5\times2=1=0+1$$

我们得到了最终结果:0.101,这个结果就不用再验证了,我们在上一节刚刚用过这个数字。

3.3 小数导致的问题

了解了十进制小数如何转化成二进制后,我们就来看看本篇文章开头部分所提到的一个问题:二进制无法精确表示十进制的小数。

上面我们使用0.625成功转化成了二进制的0.101,但并不是所有小数都可以这么成功的转化,举一个非常简单的例子:0.1(十进制),我们看看如果将它转换成二进制,会变成什么:
$$0.1\times2=0.2=0.2+0$$

$$0.2\times2=0.4=0.4+0$$

$$0.4\times2=0.8=0.8+0$$

$$0.8\times2=1.6=0.6+1$$

$$0.6\times2=1.2=0.2+1$$

$$(0.2再次出现,式子进入循环)$$

$$0.2\times2=0.4=0.4+0$$

$$0.4\times2=0.8=0.8+0$$

$$0.8\times2=1.6=0.6+1$$

$$0.6\times2=1.2=0.2+1$$

$$......$$

我们看到了,这个式子在解析的过程中,无法让小数部分清零,出现了无限循环。可以预见,照这么继续计算下去,0.1的二进制的最终结果会是0.0001100110011...永远没有尽头。这就是为什么二进制小数无法总是精确表示十进制小数的原因了,同样的,0.30.9这样的数,也无法真正转换成二进制。因为不精确,所以计算机会给一个近似值,而近似值在计算的过程中,误差逐渐超过比较时的精度,最终造成了0.1 + 0.2 != 0.3BUG

4. 结尾

这篇文章字数有点多,能看到这里的都是真的猛士。接下来可能还会再写个两到三篇关于二进制的系列文章(计划),后面会讲一下二进制的一些运算,以及它们在计算机里的特殊情况。后面每篇的字数应该会比这一篇少很多,也让大家看着不那么费力。


程序员小杜
1.3k 声望37 粉丝

会写 Python,会写 Go,正在学 Rust。