我们知道,在十进制中,数字前面用减号来表示负数,而计算机只有 0 和 1,没有其他任何符号,它怎么表示负数呢?

这就涉及到编码的问题了。

计算机中的编码


计算机内部用补码表示数字,高位为符号位,0开头表示正数,1开头表示负数。

二进制中的原码、反码和补码描述如下。

  • 原码:就是数字本身。比如 1110 的原码就是 1110。

  • 反码:将原码除符号位按位取反就是反码。比如 1110 的反码是 1001。

  • 补码:将反码加 1 就是补码。比如 1110 的反码是 1001,补码就是 1010。

正数的原码、反码、补码都相同,都等于原码;负数的反码等于除符号位按位取反,负数的补码等于反码末位加1。

比如0110,是以0开头的,就是正数,它的原码、反码、补码都是0110;再比如上述的1110,它是以1开头的,就是负数,所以就用上述方式计算它的反码和补码。

这听着真费劲,那为什么要有补码呢?

为了简化减法运算!使用补码可以将减法变成加法。这怎么理解呢?

十进制的补码等于用 9 减去各个位置的数字后末位再加 1,比如 1 的补码是 9,2 的补码是 8……以此类推。

先来看个问题:比如:9-4,4 的补码是 6,我们就变成 9+6,结果是 15,我们再去掉最高位的 1,结果是 5,所以 9-4=5。换句话说就是:数 A 减去数 B 等于数 A 加上数 B 的补码,然后砍掉最高位

再比如:81-21=81+(78+1)=81+79=160,去掉最高位的 1,就是 60,结果也是成立的。

有人说,这不对啊,你这计算补码的时候,要用 9 减去各个位子的数字,这本身就做了一次减法啊。是!但是这是 10 进制,二进制就不需要了,二进制只需要按位取反,末位再加 1,就行了。

比如:二进制的减法:0110−00010110-00010110−0001,-0001的原码是1001,那反码就是 1110,补码就是 1111。换成补码计算就是 0110+11110110+11110110+1111,结果是:10101,去掉最高位就是 0101,也就是 5,答案明显是对的。

唉,等等等等,那就是说:0110-0001=0110+1111,而我们又知道:0110-0001=0110+(-0001),那是不是意味着 -0001 就等于 1111 呢。

聪明!

如果用补码表示二进制,那么高位就表示符号位置:如果高位是 1,就表示负数;如果高位是 0,就表示正数

所以 1111 就表示负数,它对应的原码等于对它再次求补码,1111 的反码是 1000,补码就是 1001,高位是 1,表示负数,所以就是 -0001,所以就有:1111=(-0001)。这就是二进制的神奇之处

所以,为什么要用补码呢?

因为可以避免负数和减法,利用高位溢出,将减法变成加法。这样,计算机就不用设计减法器了,直接用加法器就可以做减法了。

计算机只有加法器,没有减法器。

那么,只有负数使用补码表示吗?

不是,在计算机中,所有数字都使用补码表示,只不过正数的补码等于原码,负数的补码等于对应的正数按位取反后末位加1,所以,我们一般的计算中,只需要注意负数就行了。

计算机用补码表示所有数字。正数的反码和补码都等于原码,负数的反码等于除符号位外其他位按位取反,负数的补码等于反码加 1。

二进制 反码 补码
正数 等于原码 等于原码
负数 除符号位按位取反 反码加1

现在我们知道了二进制的加法和减法,那么二进制的乘除怎么计算呢?

那就是位运算了。

位运算

按位与(&)

按位与运算就是:将各个数位的数字进行逻辑与,都是 1 才为 1,否则为 0。

比如:110&011=010 110 。因为只有第二位都是 1,那么其他位都为 0,所以就是 010。

再比如:1111&111=0111,由于 111 是三位数,所以高位补个 0,那么结果就是 0111。

你说这些我都知道了,所以这有什么用?

使用与运算可以消除指定位置的数字。也可以判断是否具有某个标记。

比如:我要取一个 8 位数的高 4 位,那么我就让它和 1111 0000 进行按位与运算,这样,它的低 4 位一定都是 0,高 4 位原来是 1 还是 1,原来是 0 还是 0,这样也就等于:消除了它的低 4 位,只取它的高 4 位。

好,这我也知道了,所以这又有啥用呢?要不上个代码我看看?

好,上代码,现在来模拟某奇艺的会员制度:

int vip = 0000 0001 // 表示vip 能看收费剧了
int svip = 0000 0011 // 表示svip 能看提前播放的剧了
int vipp = 0000 0111 // 表示vip中p // 能跳过vip广告了
int svipp = 0000 1111 // 表示svip中p // 能跳过svip广告了
int tvip = 0001 1111 // 表示电视vip,能投屏了。

// 是否是vip
boolean isVip(int flag) {
    return vip&flag == vip; 
}

// 是否是svip
boolean isSvip(int flag) {
    return svip&flag == svip;
}

// 是否是tvip
boolean isTvip(int flag) {
    return tvip&flag == tvip;
}

...

现在假设你的 vip 标记和我的 vip 标记如下:

int uFlag = 0000 0011 // 你的vip标记

int mFlag = 1111 1111 // 我的vip标记

好,现在拿着你的 vip 标记uFlag去试试,是不是 vip 呢?计算了下是,而且也是 svip。但是也仅仅是 svip 了。拿着我的去呢?直接挑最大的 tvip 试试,发现是的,那么也是其他的 vip 了。

这里你可以看到,一个 int 型的 flag 值,就可以表示这么多 vip 了,这不比你用一个个的布尔值表示来得香?

代码 x 格直接上升了一个 level,可以去跟比人装 x 了。

按位或(|)

按位或运算就是:将各个数位的数字进行逻辑或,都是 0 才为 0,否则为 1。

理解了上述的按位与运算,这个就很好理解了。

比如还是上述的两个数:110∣011=111,1111∣111=1111,011∣001=011。

这里就不再解释了。那么它有啥子用呢?

使用或运算可以给指定位置添加标记。 说白了就是,可以给指定位置设置为 1。

比如:0011 1001 这个数字,我要将它的低 4 位全部设置成 1,而高 4 位不变。那么就让它跟 0000 1111 进行或运算:00111001||00001111=00111111,因为跟 0 进行或运算,结果是本身,跟 1 进行或运算,结果是 1,所以答案就不解释了。

嗯,你又知道了,这又有啥子用呢?

上代码。还是上述的 vip,比如你现在充了 10086 块钱,开了个视频 vip,那么我怎么给你开通呢?很简单:

...
int tvip = 0001 1111 // 表示电视vip,能投屏了。

// 开通tvip
int openTvip(int flag){
    return tvip|flag;
}

假设你啥都不是,只要进行了openTvip()操作,就直接是 tvip 了,这就是或运算的添加标记功能

现在,你已经能用与运算和或运算来实现:添加、删除、判断,这些操作了。后面如果有类似的需求,请大胆地使用它们。

异或(^)

异或运算就是:将各个位置数字进行异或,相同为 0,不同为 1。

比如:1111 ^ 1111 = 0000,0000 ^ 1111 = 1111。

说白了就是:将相同的除去。

一个数和 0 异或等于它本身。

如果你原来是 1,那么就和 0 不同,不同的两个数异或等于 1,就是你本身;如果是 0,就和 0 相同,相同的两个数异或等于 0,还是你本身,所以:一个数和 0 异或等于它本身

一个数和自身异或等于 0。

这个就不解释了,因为相同,异或肯定是 0。

那这有啥用呢?除去相同的。

我们先来看一个骚操作:交换两个变量的值。

一般的写法:

int temp = a;
a = b;
b = temp;

二般的写法:

a = a + b;
b = a - b;
a = a - b;

三般的写法:

a = a ^ b;
b = a ^ b;
a = a ^ b;

二般的写法就不说了,虽然是对的,但是有溢出的风险。而且也容易看懂。

三般的这里解释下:

  • a = a ^ b; 此时 a 就是 a^b;

  • b = a ^ b; 也就是 b = (a^b)^b = a^b^b = a^(b^b);而相同的数字异或等于 0,那就是 a^0,也就是 a,此时 b=a 了。

  • a = a ^ b; 此时经过第一步运算 a=a^b,经过第二步运算 b=a,所以 a = (a^b)^a = a^a^b = 0^b,也就是 b。

好,这里就不废话了。

我们再来看实际的应用,比如:我们开发了个打卡机,每天要进行签到签退,每次签到签退都会记录员工号,那么既然如此,一个人的工号肯定出现偶数次,如果是奇数次,就肯定有缺卡记录,那么怎么找出来呢?

很简单,异或啊!

我们直接遍历所有的员工号,挨个进行异或,那么最后得出的那个数字,就是缺卡的那个员工。因为其他的都经过两次异或变为 0 了。

代码如下:

int findNumber(int[] nums) {
   int result = nums[0];
   for (int i = 1; i < nums.Length; i++){
       result ^= nums[i];
   }
   return result;
}

这里就不废话了,直接看代码即可。

非(!)

按位非就是:将所有位置的数字取反,原来是 0 就变为 1,原来是 1 就变为 0。

那么它有啥用途呢?

首先我们能想到的就是:求反码。

再比如:开关操作。

考察如下代码:

if(a = false) {
    a = true;
} else {
    a = false;
}

这看着可真别扭啊,直接改成:

a = !a;

简单明了。

左移(«)

左移运算就是:将所有数字向左边移动 n 位,右边补 0。

比如我们先看 10 进制的左移,100,向左边移动 2 位,100__ ,然后右边补 0:10000,等于变为原来的 100 倍了。

同样的二进制也是这样,比如 0011,左移 3 位就变成 11000,原来是 3,现在是 24,变为原来的 8 倍了,所以可以得出结论,在 2 进制中,左移动 n 位,就等于乘以 2^n 倍

这个结论可以用于乘法运算,比如,要计算:a×15,考虑到 15=16-1,16 是 2^4,所以就可以计算为:a«4−a,这样就不用计算乘法了(计算机计算乘法的效率比较低)。

左移运算还有个用途,就是可以丢掉高位,比如一个 8 位数:1000 1111,左移 4 位就变成:1111 0000,原来的高 4 位,被移出去了,只剩下低 4 位了。

左移运算在不溢出的情况下等于乘法,在溢出的情况下等于裁掉高位。

右移(»)

右移运算就是:将所有数字向右边移动 n 位,左边补符号位,正数就补 0,负数就补 1。

这个也很好理解,比如:0111>>1=0011,因为是正数,所以左边补 0。

切记,对于负数的右移要转为补码来计算

比如:要计算-5右移1位,

  • 先计算-5的补码,也就是1011
  • 执行位运算: 1011>>1=1101,因为1011是负数,所以高位补1
  • 再把1101求一次补码,得到原码: 1011,因为1101是负数,所以就是-3。

这就有问题了,-5右移1位不应该也是除以2吗,那就应该是-2啊,怎么是-3呢? 没错,它就是不等于,原因就在于高位补符号位置了,这个不用纠结了,即使强迫症晚上睡不着觉也没法。

那么,负数的右移不等于除法,我要怎么快速计算呢?很简单,先使用正数做右移,完了再转成负数就行了

比如,还是-5,我们想除以2,我们可以先计算5的右移:

0101>>1=0010,也就是2,也就是0010,我们把最高位符号位改为1,就是1010,也就是-2的原码,这样我们就计算出了-5除以2的结果。

其实你不用纠结,这个原理我们只要知道就行,至于这些计算的事情,让计算机去干!

正数的右移运算等于做除法,负数的右移不等于做除法。

比如,上述的 0111>>1,因为最低位的 1 被移出去了,就变成变为 011,又因为符号位为 0,所以高位补 0,就变成了 0011,也就是 3,而原来的 0111 是 7,对于整型来说:7/2=3,等于除以 2。

所以,在 2 进制中,正数右移 n 位,就等于除以 2n倍2^n 倍2n倍

右移运算在任何情况下都等于裁掉低位。

上述与运算我们说到,按位与可以消除指定位,如果配上右移,那就更合适了。比如我们用 1111 0100,这个数字的高 4 位表示年龄,低 4 位表示成绩,现在要知道这个人的年龄和成绩分别是多少。

  • 用与运算获取成绩:11110100&00001111 = 0000 0100,得到成绩。
  • 用右移运算获取年龄:11110100>>4=11111111,然后再用按位与运算去掉高 4 位的符号位,就得到 00001111,也就是年龄。

有人说,计算年龄有点费劲,我们有更简单的计算方法:无符号右移

无符号右移(»>)

无符号右移跟右移一样,只不过左边永远补 0。

还是上面获取年龄的例子,1111 0100,无符号右移 4 位,高位补 0,就成了 0000 1111,直接就获取到了年龄。

移位运算分为逻辑移位和算数移位,如果你用二进制表示数值,那么高位就是符号位,此时为算术移位,右移则高位补符号位。如果你用二进制表示逻辑,比如上述的flag,那此时就是逻辑移位,右移则高位永远补0.

总结

本节我们讲了逻辑位运算和算术位运算,其实逻辑位运算就是一张真值表。

p q p与q p或q !p
0 0 0 0 1
1 0 0 1 0
0 1 0 1 1
1 1 1 1 0

至于怎么使用,就像海贼王的果实开发一样,全靠个人想象。

  • 左移:低位补 0,等于乘法。
  • 右移:高位补符号位,正数等于除法,负数可以先按正数计算,然后转成负数。
  • 无符号右移:高位补 0。

最后,一定要多使用逻辑位运算的添加、删除、判断 flag 位的功能,以及使用移位运算的乘除功能。