原码、补码和反码

原码、补码和反码是计算机中表示数字的几种方式,他们是为了达成不同的目的而被创建的。

我们根据数字的大小通常会使用不同的位数(8,16,32)来表示数字。下面的示例的话如果没有特殊说明,都使用8位的二进制来表示。

原码

True form

计算规则

原码是人脑最容易理解和计算的表示方式。 表示规则是,使用最高位作为符号位,1表示负数,0表示正数,剩余的n-1位表示数字。

1000 0001 = -1
0000 0001 = 1
1111 1111 = -127
0111 1111 = 127

表示范围

所以一个n位的原码二进制数表示的范围是 -($2^{n-1}$-1) 到 ($2^{n-1}$-1) 比如8位的范围是 -($2^{7}$-1) 到 ($2^{7}$-1) = -127 到 127 同理16和32位的也是这样。

存在的问题

无法进行计算

原码存在的最大问题就是,计算机无法使用原码直接进行计算,就拿 1 + (-1) = 0 这个来说。在计算机中如果直接使用原码计算得到的结果是-2,和预期的结果并不一致。

  1000 0001 
+ 0000 0001 
------------
  1000 0010 = -2

+0和-0的问题

通过原码的表示规则,我们得到一个+0和一个-0,这个明显不符合数学常识

1000 0000 = -0
0000 0000 = +0

反码

ones' complement 反码的出现就是人们为了解决原码不能直接进行计算的问题的。

计算规则

原码转反码 * 正数的反码是其本身,(正数的反码和其原码相同) * 负数的反码是在其原码的基础上, 符号位不变,其余各个位取反.

反码转原码

先看是不是负数,如果是负数除符号位外按位取反

正数
    0000 0001 = 1
负数
    1000 0001[原] = -1
    1111 1110[反] = -1

这时候我们再来计算 1 + (-1)

  0000 0001[反] 
+ 1111 1110[反]
------------
  1111 1111[反] = 1000 0000[原] = -0

所以反码的出现就解决的原码不能参与计算的问题。 但是+0和-0的问题还是没有解决。

补码

补码又叫做 Two's Complement 补码就是上面的问题的解决方式。

计算规则

  • 正数的补码还是其本身,(正数的反码和其原码相同)
  • 负数的补码:负数的补码则是将其对应正数按位取反再加1。或者说是负数的补码是在其原码的基础上, 符号位不变, 其余各位取反, 最后+1. (即在反码的基础上+1)

转换过程

0000 0001[原] =  0000 0001[补] = 1
1000 0001[原] =  1111 1111[补] = -1

计算过程
1. 对应的正数按位取反
1000 0001 (对应的正数)-> 0000 0001 (取反)-> 1111 1110 (加1)-> 1111 1111

补码解决运算问题

我们再来进行上面提到的 1 + (-1) 的操作,这次使用补码来计算

  0000 0001
+ 1111 1111
------------
 10000 0000

我们看到结果得到是一个9位的二进制数,但是因为我们都是8位二进制数,所以在获取结果的时候会取低位的8位,忽略掉最高位的1,这样得到的结果就是 0000 0000,对应的就是0.

负数的表示问题

十进制的数据符号有0、1、2、3、4、5、6、7、8、9,二进制有0、1,我们加入一个-负号对于人来说是很理所当然的事情,但是对于计算机来说并不是。 简单来说就是

  • 一个数 减去一个负数,实际上应该是加上应该是加上这个数的真值(去掉了符号的值)。
  • 一个数 加上一个负数,实际上应该是减去这个数的真值

这样的运算规则,会使得计算机运算电路的设计变得复杂很多。 所以最好的办法就是让计算机不要理会符号位,然后把减法转化为等价的加法;

所以在这里就可以利用到补数可以把减法转化为加法的特性来解决上面的问题。

重点来了

拿8位二进制来说明,8位二进制可以表示 0-255共256个数字。 既然我们想要忽略掉符号位,那么就需要把这256个数字中的一部分,用来表示负数。 我们把这 256个数,已128为界分为2部分, [0 -> 127] 128 [129 -> 255]

  • 0 就表示0,
  • 1-127,表示本身,
  • 129-255,表示 -127到-1。
  • 128 表示 -128

为什么这么表示呢? 首先只有一个0,所以我们就用 0来表示。

然后我们用这个数对应的补数,表示和这个数绝对值相同的负数。

1表示 +1,1的补数是255,所以 255就用来表示 -1

127表示 +127,127的补数是129,所以用129来表示 -127

这样当我们计算 7 - 1,就依赖补数的规则,完美的转换成了 7 + 255,然后忽略掉进位,依然还能得到正确的结果6

   0000 0111
 + 1111 1111      
 ------------    忽略进位
 1 0000 0110    =========>  0000 0110  = 6 

抛去0之后,还剩下255个数,所以要么负数多一个,要么正数多一个。1到127 和-127到 -1是没有疑问的,多出来的这个数就是128。128这个数就比较特殊了,因为他的补数也是 128,所以其实这个数既可以表示+128,也可以表示-128。那么我们为什么人为规定让128来表示-128呢。那是因为让 128表示 -128我们正好可以得到一个便利,就是可以通过第一位是0还是1来分辨这个数是正数还是负数。

我们通过二进制的分为来说明一下,就会比较明朗了

1             127
0000 0001 到 0111 1111  表示 1 到 127
----------------------------------------
128           255
1000 0000 到 1111 1111 表示 -128到 -1

采用补码,虽然简化了计算机的运算,但是会导致一个不好的问题,就是无法让人们直接看出这个数的原码是多少,所以如果有这个便利能让人类直观的知道这个数是正数还是负数,也是好的。

补码转原码

因为计算机中计算和存储都是使用的补码形式,所以在某些情况下的输出,会给我们造成一些误解。所以我们同时也需要掌握补码转原码的方式。

因为根据上面的规则,我们可以知道虽然,补码计算的时候是忽略掉符号位的,但是我们依然可以使用补码的首位来,快速的判断这个补码对应的真实的数,是正数还是负数。

  • 如果是正数,则不需要变
  • 如果是负数,除去第一位其余各位取反,然后再整个数加1

比如计算补码 1111 1111,对应的原码

                除符号位按位取反
    1111 1111    ==========》 1000 0000 
    
    然后在加1
    1000 0000  + 0000 0001 = 1000 0001 = -1
   

负数补码转原码再思考

首先我们已经明白,负数的补码,就是负数的绝对值的补数。

既 -1的补码就是 1的补数,就是 255

那现在反过来,我们现在有个补码 255(1111 1111),想要求出它对应的原码。

我们先求出255所对应的补数 1(0000 0001)。这个1肯定就是他所对应的那个原码的绝对值。因为我们可以通过补码的首位来判断这个数的正负。所以我们这道这个数就是-1。而原码的规则就是通过首位来表示正负的,所以我们把首位在变成表示负数的1就得到了最终的原码 (1000 0001)。

整个过程我们用算式来表述一下

已知补码 x[补] =  255(1111 1111),求对应的原码x[原]

1. 求x[补]的补数(一个数的补数,就是用模减去这个数)
  
    1 0000 0000             1111 1111
-     1111 1111           - 1111 1111
-----------------  等价于  --------------  + 1  = 0000 0001
      0000 0001             0000 0000  

2.把结果的符号位改为表示负数的1  => 1000 0001 

所以简单的来说就是,我们直接取这个补码本身所代表的无符号数的补数,然后转乘负数就可以了。 因为负数的补码和负数的绝对值是互为补数的。

总结

补码就是采用一个数的补数,作为和这个数绝对值相同的负数的一种数值表示方式。 通过这种转换,我们可然去掉计算机的减法运算,简化计算电路的复杂度。

可以这么认为,就是正式因为利用了补数的这个特性,所以把这种利用补数来表示负数的形式,称作补码。

补码本身是没有符号位的,我们通过补码的首位来判断这个数的正负,只不过它恰好(有一部分也是出于人为的因素)满足这一个条件,这并不能算是补码的一个特性

所以很多时候我们看到补码的时候会比较别扭,因为它和人们直接用原码计算,并且有正负概念的固有模式不一致。但是不可否认,这是最符合计算机的一种数字表达方式。那补码的可读性差,可能可以算作补码唯一的缺点了吧

计算机中的补码

我们来看一段代码,这也是我想要了解补码的初衷

public static void main(String[] args) throws UnsupportedEncodingException {
    String str = "一";
    byte[] bytes = str.getBytes("utf-8");
    for (byte b : bytes){
        System.out.println(b);
    }
}

这段代码最终的输出结果是

-28
-72
-128

我们来分一下一下问什么会输出这样的结果。

1.首先我们要获取汉字utf-8编码

通过查询得知汉字的unicode编码为

  • 19968(十进制)
  • 4E00 (16进制)

UTF-8编码为

  • 14989440(十进制)
  • E4B880(16进制)
  • 00000000 11100100 10111000 10000000(二进制)

这里顺便说一下,utf-8是uncide的一种编码方式。作用是减少不必要的空间浪费

2.把编码转成字节数组

byte[] bytes = str.getBytes("utf-8");

所以这段代码的操作就行,查询码表,得到的UTF-8编码,然后忽略掉为0的高位,转换为字节数组

[1110 0100]
[1011 1000]
[1000 0000]

3.输出字节 这里还涉及到一个问题,就是Java中的自动转型。所有的byte,short,char型的值将被提升为int型 所以这里打印的时候,是把每一个byte强转成int,然后输出的。

因为计算机中存储和计算都是使用补码,所以会把字节,比如第一个字节[1110 0100],当做补码转换成int,然后在转为对应的十进制。 这里用我上面的补码转原码的规则转一下就ok了

   除符号位按位取反
   1110 0100     ========>  1001 1011

   然后在加1
   1001 1011 
+  0000 0001 =  1001 1100 = -28

参考

补码计算

补数到底是个什么玩意儿?从根儿上理解一下

utf-8编码查询