在计算机中,任何的数据都是用二进制(0/1)来表示。整数也不例外。生活中的整数 10,用 8 个二进制表示为 00001010。
在 8 位的二进制中,可以表示的数从 00000000,00000001 ······ 11111111。也就是 0 到 255。
但是这只能表示正数和零。怎么表示负数呢?
于是引入符号位的概念。在 8 位的整数中,最高位为符号位,其中 0 代表正数,1 代表负数。所以 -10 就可以用 10001010 来表示。
这就是原码,使用原码会带来一系列问题:
00000000和10000000表示为0和-0,用那个来表示0,还是都是表示为零?- 加法问题。
关于加法问题,试着运算 1 - 1 的值:
- 将
1 - 1转换成1 + (-1) - 转换成二进制:
00000001 + 10000001 - 运算结果为
10000010转换成十进制为-2
很明显,这个结果不对。
为了解决这个问题,在计算机中引入补码(2's complement)来解决。要解释为什么要使用补码,还得从无符号整数开始说起。
为了便于理解和方便,我用 3 位的整数来讲解。但由于 C 语言最小都是 8 个字节,在代码验证方面使用 8 个字节。
无符号整数(unsigned integer)
在 3 位的无符号整数中,可以表示 2^3^ = 8 个数:
000 = 2^2 * 0 + 2^1 * 0 + 2^0 * 0 = 0+0+0 = 0
001 = 2^2 * 0 + 2^1 * 0 + 2^0 * 1 = 0+0+1 = 1
010 = 2^2 * 0 + 2^1 * 1 + 2^0 * 0 = 0+2+0 = 2
011 = 2^2 * 0 + 2^1 * 1 + 2^0 * 1 = 0+2+1 = 3
100 = 2^2 * 1 + 2^1 * 0 + 2^0 * 0 = 4+0+0 = 4
101 = 2^2 * 1 + 2^1 * 0 + 2^0 * 1 = 4+0+1 = 5
110 = 2^2 * 1 + 2^1 * 1 + 2^0 * 0 = 4+2+0 = 6
111 = 2^2 * 1 + 2^1 * 1 + 2^0 * 1 = 4+2+1 = 7
既然是整数,免不了要加减乘除。
在计算机中,只用加法就可以完成的整数的四则运算:
- 减法,就是加上一个负数。
- 乘法,就是不断的做加法。
- 除法,就是不断的做减法,而减法又可以转换成加法。
但是在无符号整数中仅使用加法运算也会遇到问题:
- 两个无符号整数相加,超出了最大的值怎么办?比如:
3 + 7 = 10,3 位的无符号整数最大值为 7。 - 两个无符号整数相减。转换成加上一个负数。既然是无符号整数,哪里来的负数?
要想解决这些问题,抬头看看墙上的钟吧。
时钟系统
假设现在是 4 点钟:
现在要把指针调到 6 点去。可以这么做:
-
顺时针调整 2 个小时:
-
逆时针调整 10 个小时:
-
顺时针调整 14 个小时或者逆时针调整 22 个小时…
把上述过程用加减来表示:顺时针旋转几小时等于加上几个小时。逆时针则为减去几个小时。有:
4 + 2 = 6
4 - 10 = 6
4 + 14 = 6
4 - 22 = 6
时钟的一圈是 12 个小时。也就是说,在时钟系统中,向上溢出和向下溢出,都通过模 12 来解决问题:
可以说成,2,-10,14,-22 同余。
负数怎么取余
整数取余我们都了解,最笨的方法就是一直把被余数减去余数直到小于 0 为止。同理,负数取余我们可以把被余数加上余数直到大于 0 为止。
根据上面思路,很快我们可以写出响应代码:
/**
* number 被余数
* mod 余数
*/
int min_mod(int number, int mod)
{
if (number >= 0) {
while (number - mod >= 0) {
number = number - mod;
}
return number;
}
else {
while (number + mod < 0) {
number = number + mod;
}
return number + mod;
}
}
上面虽然好理解,但是代码执行效率不高,时间复杂度为 O(n)。
换一种思路,我们都知道被除数(divident)除以除数(divisor)等于商(quotient),如果除不尽,剩下的就是余数(remainder)。
将余数加入除法公式中,有:
转换一下:
交换一下顺序,可以得出余数为:
其中商是被除数与除数相除向下取整(floor)的结果:
注: 为向下取整的符号。如,,。
15/12 等于 1.25,向下取整就成了 1;-10/12 等于 -0.833..,向下取整为 -1。
可见,向下取整可以理解为取一个更靠近负无穷的整数。
插一句,C/C++/Java 中,负数除法都是向上取整。也就是
-10/12等于0。python 中,负数除法为向下取整。
结合 1 式和 2 式,可以得出:
将上述公式转换成代码:
int one_step_mod(int number, int mod)
{
return number - (mod * (int)floor(number * 1.0 / mod));
}
别忘了加上
math.h头文件。另外,试着想想
* 1.0起什么作用?不加行不行?
借助公式可以用 O(1) 的时间复杂度,算出最小的正数余数。
借助时钟的实现解决无符号整数问题
理解了时钟的运转与负数的取余运算,我们再来看看无符号的问题:
- 两个无符号整数相加,超出了最大的值怎么办?
- 两个无符号整数相减。转换成加上一个负数。既然是无符号整数,哪里来的负数?
如果把 3 位的无符号整数看成时钟的话,它长这样:
那么无符号整数也同样和钟表一样可以通过同余运算解决上述问题:
相加超过范围而导致上溢出
两个无符号整数相加,如果超出范围,直接模 8 即可:
在 3 位二进制的钟表上,就是将指针顺时针旋转 7 个单位。可以看上图,旋转 7 个单位之后,指针落在 1,这个位置。
加上一个负数
整数相减,相当于加上一个负数。先将负数转换成同余 8 下的最小正整数,在进行加法运算:
在 3 位二进制的钟表上,就是将指针逆时针旋转 7 个单位。也就是 3 这个位置。
总结
计算机用同余运算解决上溢出与负数 。3 位无符号整数的加法运算实际上等价于模 8 的加法运算。
代码验证
talk is cheap, show me your code.
讲了这么多,再通过代码来验证这一过程:
需要注意的是,C 语言中,最小的数据类型为 8 个字节,与上述的 3 个字节有小许差异。
int main()
{
uint8_t two = 2;
uint8_t last = 255;
printf("%d \n", two); // 2
printf("%d \n", last); // 255
uint8_t temp = two + last; // 顺时针旋转
printf("%d \n", temp); // 1
temp = two - last; // 逆时针旋转
printf("%d \n", temp); // 3
temp = -2;
printf("%d \n", temp); // 254
return 0;
}
别忘了引入
stdint.h头文件。
了解了无符号整数的运行机制,看看下面输出什么?
int main()
{
uint8_t a = -128;
uint8_t b = a / -1;
printf("%d", b); // what is it?
return 0;
}
有符号整数(signed integer)
如果仅仅使用符号位(也就是原码)来表示 3 个字节的有符号整数,可以表示 2^3^ -1 = 7 个数:
000 = (2^1 * 0 + 2^0 * 0) * 1 = 0+0 = 0
001 = (2^1 * 0 + 2^0 * 1) * 1 = 0+1 = 1
010 = (2^1 * 1 + 2^0 * 0) * 1 = 2+0 = 2
011 = (2^1 * 1 + 2^0 * 1) * 1 = 2+1 = 3
100 = (2^1 * 0 + 2^0 * 0) * -1 = -(0+0) = 0
101 = (2^1 * 0 + 2^0 * 1) * -1 = -(0+1) = -1
110 = (2^1 * 1 + 2^0 * 0) * -1 = -(2+0) = -2
111 = (2^1 * 1 + 2^0 * 1) * -1 = -(2+1) = -3
前面说过,如果使用原码来表示有符号整数,会有以下问题:
- 引入了两个零。
- 无法通过加法运算得出正确结果。
你会发现原码仅仅可以表示负数,其他什么都干不了。
糟糕的表示方法
要想知道为什么原码引入了这么多问题,将它转换成时钟,可能你就明白了:
从上图中,我们可以发现,原码并不遵循同余运算。
仔细观察 101 也就是原码表示为 -1 的这个地方。101 转换成无符号整数为 5。
为了保证同余运算,只要找到一个负数与 5 关于 8 同余即可。
与 5 关于 8 同余的数有很多:13, 21, -3...。这个数还要满足一个条件:最接近 0 的负数,也就是最大的负数。
有了这两个条件,这个数也确定了:5 - 8 = -3。
也就是说,为了保证同余运算,101 对应的负数应该是 -3 才对。
补码的引入
通过这种转换方式,改成遵循同余运算的表示方法:
这就是补码。计算机使用补码来表示无符号整数。
因为遵循同余定理,补码已经不存在那两个问题了:
- 补码系统中只有一个 0,不存在歧义。
1-1=>1 + (-1)=>001 + 111=>000=>0,加法运算可以得出正确的结果。
所以为什么要有补码?
计算机可以只使用加法来完成四则运算,从而简化电路。而在无符号整数中,为了遵循同余运算,原码并不能很好的表示(虽然它很好理解)。于是引入补码。
可见同余运算就是整个整数运算的核心。
补码的表示
根据上面的图,很好表示补码:
- 正数和零,没有变化,不用修改。
- 而负数,比如
-1,就是逆时针转动一个小时,根据同余定理,逆时针转动一个小时就代表顺时针转动7个小时:
也就是说,当符号位为 1,比如 111,转换成正数为 7,在它的基础上逆时针旋转 8 个单位(也就是减去 8)就是补码:
补码
----
000 = (2^2 * 0 + 2^1 * 0 + 2^0 * 0) = 0+0 = 0
001 = (2^2 * 0 + 2^1 * 0 + 2^0 * 1) = 0+1 = 1
010 = (2^2 * 0 + 2^1 * 1 + 2^0 * 0) = 2+0 = 2
011 = (2^2 * 0 + 2^1 * 1 + 2^0 * 1) = 2+1 = 3
100 = (2^2 * 1 + 2^1 * 0 + 2^0 * 0) - 8 = 4-8 = -4
101 = (2^2 * 1 + 2^1 * 0 + 2^0 * 1) - 8 = 5-8 = -3
110 = (2^2 * 1 + 2^1 * 1 + 2^0 * 0) - 8 = 6-8 = -2
111 = (2^2 * 1 + 2^1 * 1 + 2^0 * 1) - 8 = 7-8 = -1
公式表示为:
其中,
x = 4a + 2b + c。也就是正整数。
如果用程序来表示:
int main()
{
int n;
puts("Please input a number, represent a few bytes: ");
scanf("%d", &n);
int count = 1 << n;
for (int i = 0; i < count; i++) {
if (i >= (1 << n - 1)) { // 如果当前的符号位为 1
printf("%d ", i - count);
}
else printf("%d ", i);
}
return 0;
}
反码(1's complement)的误区
在上面讲解补码的过程中,并没有提到反码。
那为什么有些文章说到补码时,要提到反码?并且说反码解决了原码相加的问题,反码加上一等于补码。
先来看看反码的定义:
正数的反码等于其原码,而负数的反码则可以通过保留其符号位,将原码的数值位取反得到。
在 3 个字节的有符号整数中,有:
原码 反码
---------
000 = 000 = 0
001 = 001 = 1
010 = 010 = 2
011 = 011 = 3
100 = 111 = -0
101 = 110 = -1
110 = 101 = -2
111 = 100 = -3
那反码解决了原码的问题了吗?
很显然的,第一个问题没有解决。反码中也有两个零。
那么加法呢?一些文章中最喜欢举的例子为:
上面的式子可以运算出正确结果。于是有些文章认为反码解决了原码的相加问题,但是没有解决两个零的问题。
但是!再看看看下面这个例子:
问题出现了,这样也无法算出正确结果。
可见,网络上的文章不太靠谱。看文章,抱着怀疑的态度还是很有必要的。
也就是说,反码和原码一样,并不适合作为有符号整数的表示方法。这也是很多人的误区,认为反码与补码有关系。其实一点关系也没有,虽然反码加上一等于补码是正确的。
那反码加上一等于补码,这又是怎么来的呢?
这是一条结论。
反码加上一等于补码
在 3 位二进制中中,我们使用 abc2 来表示原码:
原码转换成反码,负数的符号位不变,其他位取反:
原码转换成补码。
- 正数不变。
- 负数表示为
8 - 原码。比如-3,也就是0 - 3,根据同余定理0 - 3也就是8 - 3。
结合 3 式与 4 式,有:
当符号位为 0,反码等于补码
当符号位为 1 ,也就是负数。因为要保证符号位为 1,所以符号位并不参与计算。
所以反码和补码的计算就转换成了 2 个字节的无符号加法运算。
既然是加法运算,同样遵循同余运算,主要注意的是,这里是关于 4 同余运算。
结合 5 式与 6 式,有:
所以,反码加上一等于补码是这么来的。它并不能作为结论证明反码和补码有任何关系,只是可以通过这种方法,在原码的基础上快速的得出补码而已。
代码验证
int main()
{
int8_t two = 2;
int8_t last = 127;
printf("%d \n", two); // 2
printf("%d \n", last); // 127
int8_t temp = two + last; // 顺时针旋转
printf("%d \n", temp); // -127
temp = two - last; // 逆时针旋转
printf("%d \n", temp); // -125
return 0;
}
上面的思考题换成有符号整数,还是那个结果吗?
int main()
{
int8_t a = -128;
int8_t b = a / -1;
printf("%d", b); // what is it?
return 0;
}