只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

5、位运算

内容来自王争 Java 编程之美

在开始学习本节的内容之前,我们先来看一段代码,如下所示
其中 countOneBits() 函数用来统计 num 在计算机中表示为二进制之后,为 1 的二进制位的个数
仔细分析下面的代码,你觉得这段代码的运行结果是什么?

public class Demo5_1 {
public static void main(String[] args) {
int count = countOneBits(-3);
System.out.println(count);
}
public static int countOneBits(int num) {
int count = 0;
while (num != 0) {
if ((num & 1) == 1) count++;
num >>= 1;
}
return count;
}
}

上述代码会一直执行 while 循环,不结束,这是为什么呢?
如果我们把代码中的 >> 改为 >>> 便可以顺利退出 while 循环,打印结果为 31,这又是为什么?
代码看似简单,但要想正确分析运行结果,需要我们具有夯实的底层基本功
带着这个问题,我们就来学习本节的内容:整数在计算机中的二进制表示法以及位运算

1、如何将十进制数转换为二进制数

人类习惯用十进制来计数,逢十进一,这跟人类有十根手指有很大关系
而计算机采用二进制来计数,逢二进一,这跟计算机的硬件电路实现有很大关系
在了解整数在计算机中如何存储的之前,我们先来了解一下二进制数

十进制表示法用一串数字表示一个整数,左边叫做高位,右边为低位
每一位只能是 0 到 9 之间的数字,并且每一位对应一个权值,权值为 10 ^ k,最低位的权值为 1(10 ^ 0),第二位是 10 ^ 1,以此类推
从低位到高位,权值依次乘 10,我们把每一位数字跟权值相乘的结果加起来,就是最终要表示的十进制数
二进制的表示法跟十进制表示法类似,区别在于每一位只允许是 0 或 1,每位的权值是 2 ^ k,从低位到高位,权值依次为 1、2、2 ^ 2、2 ^ 3 ... 以此类推
image
抛开计算机来看,我们人类是如何将一个十进制数转换为二进制数呢?

1.1、十进制整数 -> 十进制数组

我们先来看,如果我们想要把一个整数转化成一个十进制数组,也就是把整数的每个数字分离出来,存储到一个数组中,例如,整数 123 转化成数组 {1, 2, 3},应该怎么来实现呢?

我们可以如下图所示,循环处理,每次对整数 a 除 10 求余,余数放入数组中,商重新赋值给 a
这样操作之后,就相当于将 a 的最后一位数字剥离出来放入数组,并且将 a 的最后一位数字从 a 中的移除
继续上述除 10 求余的操作,直到 a 为零后结束
此时,整数中的所有的数字就都分离出来,并且放入了数组
image

// a = 5194 => {5, 1, 9, 4}
public int[] convertToDecimalArray(int a) {
int[] arr = new int[10]; // int 类型最大 2147483647, 10 位数
int i = 0;
while (a != 0) {
arr[i++] = a % 10; // 余数放入数组 {4, 9, 1, 5}
a = a / 10; // 商重新赋值给 a
}
swap(arr); // 将 {4, 9, 1, 5} 翻转为 {5, 1, 9, 4}
return arr;
}
public void swap(int[] arr) {
int p1 = 0;
int p2 = arr.length - 1;
while (p1 < p2) {
int k = arr[p1];
arr[p1] = arr[p2];
arr[p2] = k;
p1++;
p2--;
}
}

1.2、十进制整数 -> 二进制数组

借助整数转化成十进制数组的方法,我们将整数转化成二进制数组
我们每次拿 a 除 2 求余,余数放入数组,商重新赋值给 a,重复这个操作,直到 a 为零后结束,此时,数组中存储的就是整数的二进制数组了
image

// a = 1011(二进制表示) => {1, 0, 1, 1}
public int[] convertToBinaryArray(int a) {
int[] arr = new int[32]; // int 型数据 32 位
int i = 0;
while (a != 0) {
arr[i++] = a % 2; // 余数放入数组 {1, 1, 0, 1}
a = a / 2; // 商重新赋值给 a
}
swap(arr); // 将 {1, 1, 0, 1} 翻转为 {1, 0, 1, 1}
return arr;
}
public void swap(int[] arr) {
int p1 = 0;
int p2 = arr.length - 1;
while (p1 < p2) {
int k = arr[p1];
arr[p1] = arr[p2];
arr[p2] = k;
p1++;
p2--;
}
}

1.3、二进制数组 -> 十进制整数

刚刚我们讲的是,将十进制整数转换成二进制数组,反过来,二进制数组也可以转化成十进制整数
转化的其中一种方法,如下图所示,将每一位与其对应的权值相乘,得到的结果加起来,就是最终的十进制整数
image

// {1, 0, 1, 1} => 1011(二进制表示), k = 4 表示二进制位个数
public int convertToDecimal(int[] binaryArr, int k) {
int res = 0; // 结果
int weight = 1; // 权值
for (int i = k - 1; i >= 0; i--) {
res += binaryArr[i] * weight;
weight *= 2;
}
return res;
}

当然,将二进制数组转换为十进制整数还有其他方法,如下图所示
从高位到低位,每次先将 a 乘以 2,再取出数组中的一个数,加到 a 中,循环直到数组中的所有的数都加到 a 中结束,此时,a 中存储的就是二进制数组对应的十进制整数
image

// {1, 0, 1, 1} => 1011(二进制表示), k = 4 表示二进制位个数
public int convertToDecimal(int[] binaryArr, int k) {
int res = 0;
for (int i = 0; i < k; i++) {
res = res * 2 + binaryArr[i];
}
return res;
}

1.4、更多进制

前面讲了十进制和二进制,实际上,比较常用的进制还有八进制和十六进制
八进制中每位的数字只能是 0 ~ 7,十六进制中每位的数字可以是 0 ~ 9、A、B、C、D、E、F,其中 A ~ F 分别对应 10 ~ 15
为了区分八进制、十六进制与十进制,我们在八进制数据前面加 0,在十六进制数据前面加 0X 或 0x

int x = 12; // 十进制
int y = 012; // 八进制
int z = 0x12; // 十六进制
System.out.println("" + x); // 输出 12
System.out.println("" + y); // 输出 10
System.out.println("" + z); // 输出 18

八进制跟十进制以及十六进制跟十进制的转换方法,与二进制跟十进制的转换方法类似,这里就不重复介绍了
当然,我们也可以从二进制转化为八进制和十六进制
转换方法如下图举例所示,三位二进制数转化成一位八进制数,四位二进制数转换为一位十六进制数
image

2、如何在计算机中表示整数:补码

2.1、原码

刚刚我们都是拿正数来举例讲解,从讲解中,我们可以发现,正数的二进制表示是比较简单的,那负数在计算机中如何呢?

计算机并没有专门的硬件来存储数字的正负号,它只能识别 0、1 这样的二进制数
所以,计算机使用一串二进制数的最高位作为符号位,其余位作为数值位,符号位为 0 表示正数,符号位为 1 表示负数
我们拿长度为 1 个字节的 byte 类型数据和长度为 4 字节的 int 类型数据来举例,如下图所示
image
以上二进制表示法叫做原码表示法
在这种表示法中,对于长度为 1 个字节的 byte 类型的数据来说,取值范围是 -127(1111 1111 十六进制为 0xFF)到 127(0111 1111 十六进制为 0x7F)
比较特殊的是,0 有两种表示方法 +0(0000 0000)和 -0(1000 0000)
image

2.2、补码

原码表示法简单直接,容易理解,但计算机并不是用原码来存储整数的
原因是使用原码表示整数,加法运算比较简单,但减法运算比较复杂,需要设计有别于加法的新的电路来实现
对于加法运算,例如 3 + 5,表示成二进制原码(假设数据类型是 byte,长度为 1 字节)为:0000 0011 + 0000 0101,我们只要像十进制加法运算一样,从低位向高位逐位相加逢二进一即可
但对于减法运算,例如 5 - 3,如果想要复用加法电路,我们可以将其转化为:5 + (-3),表示成二进制原码为:0000 0101 + 1000 0011,如果不区分符号位,继续按照加法的逻辑来运算,得到的结果为:1000 1000,也就是 -8,显然结果是不对
如果我们区分符号位,那计算逻辑就复杂了很多,需要实现新的电路来实现正数 + 负数这种运算,也就是减法运算

于是,聪明的科学家发明了一种新的整数的二进制表示法:补码表示法,利用补码,减法可以转换为加法,利用同一套电路来实现

反码的意思是在原码的基础上,符号位不变,数值位按位取反

  • 正数的补码跟原码相同
  • 负数的补码是在原码的基础上先求反码,然后再 + 1

image

补码跟原码虽然有一定的关系,但它们是两套不同的二进制编码方式,在补码表示法中,有两个比较特殊的地方

  • 其一是:0 不再像原码那样有 +0 和 -0 的区分,-0 没有对应的补码
  • 其二是:对于长度为 n(n 个二进制位)的数据类型,"最高位为 1、数值位全为 0 的二进制数" 为 -2 ^ (n - 1) 的补码,此补码没有对应的原码

例如,对于 byte 类型,1000 0000 为 -128 的补码, 没有对应的原码,实际上,我们也可以理解为,把 -0 的补码挪做去表示 -128 了
所以,对于长度为 n(n 个二进制位)的数据类型
原码的表示范围是 [-2 ^ (n - 1) + 1,2 ^ (n - 1) - 1]
而补码的表示范围是 [-2 ^ (n - 1),2 ^ (n - 1) - 1],补码表示范围比原码表示范围大 1
image

2.3、补码实现加减法

了解了补码的编码方式之后,我们来看下如何用补码实现加减法

因为正数的补码跟原码相同,所以,加法的运算逻辑不变,仍然是按位求和,逢二进一
对于减法,例如 5 - 3,表示为加法就相当于:5 + (-3)
用补码表示就是:0000 0101 + 1111 1101(假设数据类型为 byte,长度为 1 个字节)
对于补码的加法,计算机不单独区分符号位和数值位,所有的二进制位一把梭,一律按照加法的运算逻辑来运算
如下图所示,得到的结果为:1 0000 0010,最前面的 1 溢出,被截断丢弃,所以最终结果为:0000 0010,也是补码表示,对应的整数值为 2
image
前面我们讲到,对于长度为 1 个字节的 byte 类型,在补码表示法中,-0 的补码(1000 0000)挪做表示 -128
这种安排并不是随意的,而是因为这样做,正好满足刚刚讲的补码的减法运算规则
例如 5 - 128,表示成加法为:5 + (-128),用补码表示为:0000 0101+1000 0000,逐位相加,最终结果为:1000 0101,正好为 -123 的补码

你可能会说,把 -0 的补码挪作表示 -128,那 5 - 0 这样的式子如何计算呢?在补码表示中,0 不分正负,换种说法,+0、-0 的补码都一样,都是 0000 0000
所以,我们仍然可以将 5 - 0 转化为 5 + (-0) 来计算

2.4、证明补码运算的正确性

接下来,我们通过理论分析,证明补码运算的正确性

补码的加法运算(两个正数相加)的正确性不言而喻,因为两加数都为正数,正数的补码跟原码相同,加法操作就是普通的按位求和,逢二进一
我们重点来看补码的减法运算(两个正数相减)的正确性,我们拿长度为 1 个字节的 byte 类型的数据来举例讲解

我们先来看两个非常重要的前置知识点

  • 如果两数 a、b 相加的结果 c 超过 127,也就是,c 包含 9 位二进制位,最高位是 1
    那么,c 的高位溢出,只保留低 8 位的值,这个操作就相当于拿 c 跟 2 ^ 8 求模
  • 一个负数的补码和这个数的绝对值的原码按位相加(不区分处理符号位),得到的结果为 2 ^ 8
    比如,-10 的绝对值的原码为 0000 1010,-10 的补码为 1111 0110,不区分处理符号位,两数相加为:1 0000 0000,正好为 2 ^ 8
    也就是说,如果 x 是一个负数,假设其补码为 y,那么 -x + y = 2 ^ 8,那么 y = 2 ^ 8 + x,也就是说,负数 x 的补码为 2 ^ 8 + x

有了这两个前置知识,我们再来看减法操作,我们分两种情况来分析

  • 情况一:a - b = c,c >= 0(a、b 都是正数)
    因为 a - b >= 0,所以,a - b = a - b + 2 ^ 8,毕竟多加的 2 ^ 8 会溢出被截断
    从而可以推导出:a - b = a - b + 2 ^ 8 = c,稍微转换一下,也就是:a - b = a + (2 ^ 8 - b) = c
    我们分析式子中的后面一个等式 a + (2 ^ 8 - b) = c,其中,a 就是 a 的补码,2 ^ 8 - b 就是 -b 的补码(根据前置知识),c 就是 c 的补码
    所以,当我们要计算 a - b 时,我们只需要将 a 和 -b 表示成补码,就变成了两个补码的加法操作
    image

  • 情况二:a - b = c,c < 0(a、b 都是正数)
    因为 a - b < 0,所以,a - b + 2 ^ 8 会是一个正数,因此,a - b != a - b + 2 ^ 8
    不过,我们可以得到另一个等式:a - b + 2 ^ 8 = c + 2 ^ 8,进一步转化为: a + (2 ^ 8 - b) = 2 ^ 8 + c
    也就是,a 的补码加上 -b 的补码等于 c 的补码,当我们要计算 a - b 时,我们就可以转化成 a 的补码加上 -b 的补码,最终得到的结果就是 c 的补码
    image

2.5、溢出和自动类型转换

以上利用补码将减法转换为加法运算的正确性的证明比较复杂,如果实在看不懂,可以暂时不用深究
至此,我们已经掌握了整数在计算机中的表示(或存储)方法:补码,接下来,我们再来看两个补码知识点的应用:溢出和自动类型转换

2.5.1、溢出

下面这段代码输出是什么值?

int a = 2147483647; // int 类型的最大值 0x7FFFFFFF
int b = 1;
int c = a + b;
System.out.println(c);
int max = Integer.MAX_VALUE;
int min = Integer.MIN_VALUE;
System.out.println(max); // 2147483647
System.out.println(min); // -2147483648
System.out.println(max + 1); // -2147483648
System.out.println(min - 1); // 2147483647

答案是 -2147483648
a 在计算机中使用补码表示,因为是正数,所以,补码跟原码相同,用十六进制表示为:0x7FFFFFFF
b 也为正数,补码用十六进制表示为:0x00000001,两数相加为 0x80000000,此二进制数最高位为 1,其余位为 0,是特殊值 -2 ^ 31的补码
所以,这段程序打印的结果是 -2 ^ 31,也就是 -2147483648

2.5.2、如何判断溢出

如何避免计算的过程中溢出呢?有的同学像如下这样来判断,你觉得对不对呢?

public int sum(int a, int b) {
if (a + b > Integer.MAX_VALUE) { // Integer.MAX_VALUE = 2147483647
throw new RuntimeException("Overflow");
}
return a + b;
}

答案是不对的,因为当 a + b 结果大于 2147483647 时,就会溢出截断,截断后的值仍然小于 2147483647,所以,这个函数永远都不会抛出异常
正确的方法是如下所示,这里还需要考虑 a 和 b 都为负数的时候,是否和超过 int 的最小值(Integer.MIN_VALUE = -2147483648)

public int sum(int a, int b) {
boolean upOverflow = a > 0 && b > 0 && a > Integer.MAX_VALUE - b;
boolean downOverflow = a < 0 && b < 0 && a < Integer.MIN_VALUE - b;
if (downOverflow || upOverflow) throw new RuntimeException("Overflow");
return a + b;
}

2.5.3、自动类型转换

当 byte 类型的数据,赋值给 short 类型变量时,会触发自动类型转换
byte 类型的数据对应的二进制数,会拷贝到 short 类型变量的低字节中,那么 short 类型的变量的高字节会怎么补全?

答案是,如果 byte 类型的数据是正数,那么高字节用 0 补全,如果 byte 类型的数据为负数,那么高字节用 1 补全,这正是因为整数在计算机中是用补码来表示的

我们拿 -5 举例,-5 的原码为 1000 0101,-5 的补码为 1111 1011
当赋值给 short 类型的变量时,为了保证值不变,我们在高字节处补 1,结果就变成了 1111 1111 1111 1011
此补码对应的原码为1000 0000 0000 0101,也就是 -5

这里告诉你一个从补码反推原码的小技巧:补码的补码就是原码,如果感兴趣的话,读者可以自己证明一下其正确性

3、计算机如何操作二进制位:位运算

如何来操作一个数中的某个或某些二进制位呢? 比如,将数中的第三位二进制位取反(如果是 0,则变为 1;如果是 1,则变为 0)

我们可以按照本节开头讲述的方法,先将这个数转化成二进制数组,数组中每一个位置存储一个二进制位
对数组元素进行操作,将操作之后的数组再转换为十进制数,这样就完成了对这个数中某个或某些二进制位的操作,但是,这样做比较繁琐,需要来回转换,执行效率不高
接下来,我们来介绍更加高效的操作二进制位的方法,那就是:位运算

常见的位运算有与(&)、或(|)、取反(!)、异或(^)、移位
其中,前四个运算就是逐位进行逻辑运算与、或、取反、异或,逻辑运算的真值表如下所示
两个数执行位运算,就等于两个数的补码执行位运算,例如 -3 & 2 = 1111 1101 & 0000 0010 = 0000 0000 = 0
image
我们重点来看移位操作,移位操作分为算术位移和逻辑位移,两种运算操作的对象也是数据的补码

逻辑位移不区分符号位,整体往左或往右移动,并且在后面或前面补全 0
算术左移跟逻辑左移操作相同,对于算术右移,正数整体右移之后前面补 0,负数整体右移之后前面补 1
不管逻辑位移还是算术位移,超出范围的二进制位会被舍弃
image

  • 算术左移相当于乘以 2,我们常常利用位运算来替代乘以 2 的运算,以提高运算速度
    不过,当数据被左移之后,超过了可以表示的数据范围时(比如 byte 整型值范围为 -128 ~ 127),就有可能导致数据从负数变成正数,或从正数变成负数
  • 算术右移相当于除以 2,对于正数,不停算术右移,最终值为 0
    不过,对于负数来说,不停算术右移,永远都不会为 0,最终值停留在 -1 不变,这也是开篇的代码死循环的原因

为了方便你查看,我们将开篇的代码重新拷贝到下面,其中,num = -3 表示成补码为 11111111111111111111111111111101
在 Java 中 >> 表示算术右移,负数前面补 1,最终 num 变为所有的二进制位全为 1,也就是 -1 的补码
如果将算术右移 >> 换为逻辑右移 >>>,右移之后前面补 0,最终 num 的值将会变为 0,所以,循环可以结束,打印正确结果 31

public class Demo5_1 {
public static void main(String[] args) {
int count = countOneBits(-3);
System.out.println(count);
}
public static int countOneBits(int num) {
int count = 0;
while (num != 0) {
if ((num & 1) == 1) count++;
num >>>= 1; // 注意
}
return count;
}
}

4、课后思考题

1、编程将一个十进制数组(如 {2, 3, 1})转化为十进制整数 132

public int convert(int[] arr) {
int res = 0;
for (int i = arr.length - 1; i >= 0; i--) {
res = res * 10 + arr[i];
}
return res;
}

2、如何证明补码的补码就是原码?

posted @   lidongdongdong~  阅读(107)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开