知多一点二进制中的负数

7,557 阅读8分钟

hello~亲爱的看官老爷们新年好~相信不少同学知道,如果要将一个数字转换为它的相反数,在 Javascript 中,除了在它前面加个-号之外,还可以对该数字进行取反,之后再加 1。前者(本质是 0 减去对应的数字)可以得到相反数,完全符合我们的直觉,但为何取反加一也可以,这看起来不太科学,本文将带你一探究竟~

友情提示,计算机科班的童鞋,对此应该是烂熟于心了,可能对你帮助有限。但不清楚其中细节的同学,希望本文能满足你好奇心之余,了解多一点二进制的知识。以下是正文~

从减法开始

估计各位童鞋肯定听过以下这条法则:

减去一个数,等于加上这个数的相反数。

同时也应该了解,为了区分正负数,二进制中的最高位是符号位,其中正数的符号位是 0,而负数的符号位为 1。以 Java 的 byte 类型(8位,范围是 -128 ~ 127,即 0000000011111111)为例,如果按照直觉,既然最高位为符号位,那么 -1 应该表示为 10000001。想法很美好,现实却很骨感。

思考以下问题,尽管负数不确定,但我们肯定正数 1 表示为 00000001,如果需要得到算式 1-1 的结果 ,按照上面的法则,可以将算式转化为 1+(-1) 进行运算,但是 10000001 + 00000001,无论怎么进行运算,似乎都很难得到 00000000 这个值吧?由此可见,计算机中储存负数的值,并不是那么简单的~

拨动时钟

负数的探索似乎陷入了死胡同,那让我们先把目光转移一下,从屏幕转到墙上的时钟之上~假设这个钟时针和分针可以分别进行调整,互不影响,且现在时钟显示的时间是 10:40,但对比北京时间,快了 10 分钟,那么我们改如何调整时钟呢?简单地做个算式:

    45
-   10
----------
    35

得出的答案是 35,那么将分针调整到 35 的位置即可,也不需要调整时针。很简单对吧?那如果现在时间也是 10:40,但时间快了 15 分钟,那该如何调整?你心中估计会想,那还不简单,竖式一样能算出来~但这里加一个需求,不希望竖式中出现借位(也就是 运算 40-15 时,由于被减数个位不如减数个位大,需要被减数从十位中借 1 到个位之上),那该如何实现呢?

emmmmmmmm,似乎比较麻烦~借位是由于被减数的某一位不够减数大而导致的,那如果被减数足够大,似乎就能解决这问题。原式是 40-15,可以等价改写为 40+(59-15)-59,答案是完全一致的。但这里还是有问题,运算两次之后,剩下的算式为 84-59,仍然要借位不是吗?那我们再改写一下:40+(59-15)+1-60。这样的算式运算下来,不再需要借位了。换成时钟操作,就是直接将分针调整到 25 即可。

看到这里,估计你心中会多了一点明悟,但似乎其中还有一些说不过去的地方对吧?比如这个例子:现在的时间是 10:15,现在的时间比北京时间快了 40 分钟,那该如何进行运算呢?按照上面的例子,可以依样写下这条算式 15+(59-40)+1-60,但问题来了,算到最后,是 35-60,还是要借位不是吗?

是,但可以不是。-60 在时钟上的本质是,将时针回拨一下。那么 35-60,其实可以分解为两个操作,时针拨动到 35 的位置,时针回拨到 9 的位置,现在的时间为 09:35,难道不是正确的答案吗?

不那么一样的数轴

在恍然大悟前先停一下,还有一点点东西需要了解。相信数轴的概念铭刻在各位心中,它是完全符合直觉的,且数轴是无穷的,相信大家也都知道,一般印象中的数轴如下:

-60, -59, -58, -57···, -2, -1, 0, 1, 2···57, 58, 59

但如果数轴是有范围的话,假设总共只有 120 个整数在数轴上,如果我们想消除负号,那么用正数表示负数,也未尝不可,比如以 59 为分界,大于 59 的数均为负数。即 -1 表示为 119,-40 表示为 80,-25 表示为 95 等:

60, 61, 62, 63···118, 119, 0, 1, 2···57, 58, 59

那么之前时钟的例子,是以 59 作为避免借位的被减数,但这是为了方便时钟拨动,这里我们再往前跨一步,刚才时钟的运算:15-40 可以表示为 15+80,运算结果是 95,对表查询可得,95 的值代表的是 -25,运算正确!

你可能会吐槽减法是借位了,但主要是为了方便映照时钟的例子,换成 999 作为避免借位的被减数,就是标准对 9 的补数。不妨在纸上画一下,此时 -1 表示为 999,-40 表示为 959,-25 表示为 974,15-40015+959,运算结果是 974,也是完全对应上的。

重回二进制

有了上面的铺垫,二进制的负数已经呼之欲出啦~符号位天然可以作为正负数的分割点。还是以 Java 的 byte 类型为例,8 位一共可以表示 256 个数字,由于 0 表示为 00000000,首位符号位也是 0,因而正数少一位,按照上一节的例子,推出当前序列为:

10000001, 10000002···11111101, 11111110, 11111111, 00000000, 00000001···01111101, 01111110, 01111111

二进制数 十进制数
10000000 -128
10000001 -127
10000002 -126
··· ···
11011000 -40
··· ···
11100111 -25
··· ···
00001111 15
··· ···
01111111 127

那么还是这条算式:15-40,二进制中即为 00001111+11011000,稍微数一下手指,得出答案是 11100111,对应的值为 -25!然而,这个表背起来是没意义的,那该如何运算呢?思考一下,之前运算 15-40 时,我们转换为 15+(59-40)+1-60,59 为足够大的被减数,在 byte 类型中,最大的数不会超过 11111111(即 255),因而可以转换为:

    11111111(255)
-   00101000(40)
+   00000001(1)
+   00001111(15)
-  100000000(256)

二进制其实十分有趣,先观察首先需要运算的 11111111-00101000,结果是 11010111,也就是各位取反,下一步是加一,得到结果是 11011000,不就是表中 -40 对应的值了么?所以该表的推导是有严格的数学意义的,并不是随便编造一个表,到这里,应该明白为何在计算机中,取某个数的相反数是取反再加一了吧~

算式最后一步是减去 256,计算机中这步都可以省下了,因为 256 已经超过 byte 的范围,直接忽略。同理,如果计算如 -1+1 之类的算式时,会得到答案是 256 (即 100000000),但由于超过范围,首位 1 直接被忽略不计,因而得出的答案是 0 。以上运算,符号位均参与运算,并不需要区别对待,这对计算机而言是非常友好的。

小结

为严谨起见,补充两个条件:

  1. 运算数均为整数。
  2. 计算结果均不溢出。

以上就是全文的内容啦,二进制相关的知识,其实是相当有意思的,也许了解它们并不会使我们的代码能力突飞猛进,但保持好奇心,不断探索未知的领域,一定是一个良好的习惯~

感谢各位看官大人看到这里,知易行难,希望本文对你有所帮助~谢谢!

参考资料

补码

《编码:隐匿在计算机软硬件背后的语言》

程序是怎样跑起来的