二进制相关概念、运算与应用

1,054 阅读16分钟

参考笔记三,P43.3、P44.1、P64.4。

推荐两篇博文(转发):

  1. 八位二进制数能表示数的范围以及原码、反码和补码含义
  2. 原码、反码、补码知识详细讲解(此作者是我找到的讲的最细最明白的一个)

一些概念(如:机器数)出自于这两篇文章,建议大家先行浏览,这样会更便于阅读本文。

一、二进制相关概念

我暂未整理二进制、原码、反码和补码等概念的理论,本文所阐述是基于我对相应概念的理解。

在研究八位二进制的表示范围之前,需要先了解原码、反码和补码这三个概念。

1.1 原码、反码

原码定义

“原码”指符号位加上真值的绝对值的机器数

反码定义:(摘录自第一篇启发博文)

正数的反码与其原码相同;负数的反码是对其原码逐位取反,但符号位除外。

1.2 补码

先说说补码定义:

正数的补码是其本身的原码;负数的补码是其反码+1后的结果。

在计算机底层,数都是以补码的形式进行表示。

补码是做什么的?(摘录自第一篇启发博文)

引入补码是为了解决计算机中数的表示和数的运算问题,使用补码,可以将符号位和数值域统一处理,即引用了模运算在数理上对符号位的自动处理,利用模的自动丢弃实现了符号位的自然处理,仅仅通过编码的改变就可以在不更改机器物理架构的基础上完成预期的要求。

我用几个示例简单说明一下我这段阐述的理解。

示例1

计算:1 + (-2) = -1

1 + (-2) = 0000 0001 + 1000 0010 = 1000 0011,得:-3

结果显然不对,是因为没有考虑到符号位,符号位不能直接参与运算。那若是考虑符号位,结果如何?如下:

由于 -2 的绝对值大于 1,故:
1 + (-2) = -(2 - 1) = 1(000 0010 - 000 0001) = 1000 0001,得:-1

答案正确,但这显然增加了硬件的开销和复杂性。(PS:本人对硬件了解有限,所以暂且不知此结论的出处)

若引入补码:

求 -2 的补码:-2 的原码 1000 00101111 1101(反码) → 1111 1110(补码)
求 1 的补码:1 是正数,故就是其原码 0000 0001

计算:1 + (-2) = 0000 0001 + 1111 1110 = 1111 1111
由于都是补码,所以 1111 1111 也是补码

由于符号位是 1(负数),故计算 1111 1111 原码的方法是:
1111 111111111 1110(反码) → 1000 0001(原码),真值为:-1

从此示例可看出,补码将符号位和数值域统一处理。换言之,不需要考虑正负大小情况。

示例2

计算:1 + 256 = 1

256是八位无符号二进制的,其机器数为1 0000 0000。这是如何得出的?

八位无符号二进制的最大机器数是1111 1111,进行+1,得1 0000 0000,但只有八位,最高位1自然丢失。得00000000。因此,1 + 256 = 1 + 0 = 1

这就是上文所述的“模的自动丢弃实现了符号位的自然处理”。

示例3:

计算:1 + 128 = 1

128是八位有符号二进制的,其机器数为1000 0000(理论上)。这又是如何得出的?

先透露:八位有符号二进制无法表示 128

无妨,可暂且将其视为无符号二进制进行计算。128 = 127 + 1127的补码是0111 1111,进行+1,得1000 0000

那么,1 + 128 = 0000 0001 + 1000 0000,得1000 0001。不过,我们只是将其视为无符号,实际有符号,故符号位无效,不进行计算。因此,结果为0000 0001,真值为1


扩展一点

在查阅有关补码的资料时,我注意到一些文章中阐述了另一种计算负数补码的方法:

绝对值的原码 → 取反 → +1

据上文所述,补码的计算方法:

原码 → 反码 → +1

另一种方法可行吗?经过验证,可行。

负数绝对值的原码与负数原码的不同唯有符号位,如:1 = 0000 0001-1 = 1000 0001,故负数绝对值的原码取反与负数反码相同。

PS:两种方法都可行,大家自行选择。

二、八位二进制的表示范围

1:无符号二进制

既然“无符号”,则无正负之分,都表示正数。

0000 0000` → 0
1111 1111` → 2^7^ + 2^6^ + ... + 2^1^ + 2^0^ = 255

因此,八位无符号二进制的表示范围是0 ~ 255

2:有符号二进制

关于八位有符号二进制的表示范围,其中细节比较复杂,暂不讨论,我暂且简述,详述可查阅第一篇推荐文章。

分析

八位二进制的范围是0000 0000 ~ 1111 1111

  1. 正区间0000 0000 ~ 0111 1111,即0 ~ 127
  2. 负区间1000 0000 ~ 1111 1111,首先,1111 1111 ~ 1000 0001-127 ~ -1,那1000 0000的真值是多少?

1000 0000 = 1000 0001 - 1(无视符号位,这样等式才成立),可实际上都表示负数,则是+11000 0001-1,故1000 00000

因此,可视0000 0000+01000 0000-0

从上文【示例2】可知,八位有符号二进制的128。因此,-0等同于-128。所以,1000 0000的真值为-128

补充说明

上述推导基于原码,当然也可以使用补码,只是原码更易于理解。如下:

1000 0000 = 1000 0001 - 0000 0001

由于都是补码,故等式也成立,1000 0001-127,则1000 0000-128

为什么是补码,等式就成立?

“引入补码”的目的之一是为了解决数的运算问题,比如:-1 + 2,用原码运算时就要考虑绝对值的大小问题。引入补码后就无需考虑,故可以说“负数的补码是正数”(:负数的补码还是负数,只是视为正数)。

因此,八位有符号二进制的表示范围是-128 ~ 127

三、位运算

3.1 常见位运算符

位运算符含义运算规则
~取反逐位反转,即:0 → 1, 1 → 0
<<左移去高位,补低位,补 0
>>有符号右移(算术右移运算符)去低位,补高位。正数补 0,负数补 1(一直补)
>>>无符号右移(逻辑右移操作符)去低位,补高位,无论正负,都补 0
&按位与当对应二进制位同为 1 时,得 1(不考虑正负)
|按位或当对应二进制位有一个为 1 时,得 1(不考虑正负)
^按位异或当对应二进制位相同时得 0,否则得 1(不考虑正负)

先说明:

  1. 下文中的“高位”指第一位,“低位”指最后一位。(左 → 右)
  2. 二进制在计算机中都是以补码的形式进行表示。因此,以下示例都要遵循补码定义。

1:取反 ~

计算 ~23

23的补码:0001 0111
~23 = 1110 1000
1110 1000 → 反码1110 0111 → 原码1001 1000,真值为-24

扩展说明:在运算规则上,取反~与反码相同。不过,~不考虑符号位,与反码定义不同。

2:左移 <<

计算 23 << 1

23的补码:0001 0111
23 << 1 = 0010 1110,真值为46
计算 -23 << 1

计算-23的补码:-23原码1001 0111 → 反码1110 10001110 1001
-23 << 1 = 1101 0010
11010010 → 反码1101 0001 → 原码1010 1110,真值为-46

3:有符号右移 >>

计算:23 >> 1

23的补码:0001 0111
23 >> 1 = 0000 1011,真值为11
计算:-23 >> 1

-23的补码:1110 1001
-23 >> 1 = 1111 0100
1111 0100 → 反码1111 0011 → 原码1000 1100,真值为-12

4:无符号右移 >>>

计算:23 >>> 1

23的补码:0001 0111
23 >>> 1 = 0000 1011,真值为11
计算:-23 >>> 1

-23的补码:11111111 11111111 11111111 11101001
-23 >>> 1 = 01111111 11111111 11111111 11110100,
真值为Integer.MAX_VALUE - 11 = 2147483636

从上文可知,负数补码的第一个 1 位前都是 1

5:按位与 &

计算:7 & 23

7的补码:0000 0111
23的补码:0001 0111
7 & 2 = 0000 0111,真值为7
计算:7 & -23

7的补码:0000 0111
-23的补码:1110 1001
7 & -23 = 0000 0001,真值为1

6:按位或 |

计算:7 | 23

7的补码:0000 0111
23的补码:0001 0111
7 | 23 = 0001 0111,真值为23
计算:7 | -23

7的补码:0000 0111
-23的补码:1110 1001
7 | -23 = 1110 1111,真值为-7

7:按位异或 ^

计算:7 ^ 23

7的补码:0000 0111
23的补码:0001 0111
7 ^ 23 = 0001 0000,真值为16
计算:7 ^ -23

7的补码:0000 0111
-23的补码:1110 1001
7 ^ -23 = 1110 1110,真值为-18

3.2 扩展

我在看Java-API源码时,遇到一些奇怪的位运算符,如:<<=|=,这些位运算符平日很少见,查阅资料得知,其实就是位运算后赋值,类似+=*=

示例见Integer类的第4.12项,其底层运用位运算|=获取最高位的 1 位的位置。

补充一点

若移动的位数超过了当前类型的最大可移动位数,则编译器会对移动的位数取模(“模”指表示当前类型所用位数)。

如int类型的最大可移动位数是31位,那么,n << 32相当于n << 0n >> 34相当于n >> 2。(“模”是32

PS:我用一些常用正数验证过这一编译器“约定”,对于负数、小数是否同样遵循这一“约定”,我暂未总结,大家有兴趣可自行发掘。

四、位运算运用示例

4.1 左移与算术右移的巧妙运用

先说结论:

<<的结果是原来的2倍>>的结果是原来的1/2(取整,即若是奇数,先-1,再取1/2)。

示例:

1、

计算:23 << 1 = 46

> 23 = 16 + 4 + 2 + 1 = 2^{4} + 2^{2} + 2^{1} + 2^{0} → `0001 0111`;\
> 23 << 1 = `0010 1110` → 2^{5} + 2^{3} + 2^{2} + 2^{1} = 2\*(2^{4} + 2^{2} + 2^{1} + 2^{0})

每1位都左移1位,变成原来的2倍,故总和也是原来的2倍。

2、

计算:23 >> 1 = 11

> 23 >> 1 = `0000 1011`→ 2^{3} + 2^{1} + 2^{0} = (2^{4} + 2^{2} + 2^{1} + 2^{0})/2

当然,这个等式是不成立的,因为等号右边多了个 2^{0},即多了1。不过,大家肯定已经看出来了我这么写的用意。 23是奇数,它的二进制是0001 0111,最低位是1。右移1位,这个1(2^{0})就没了,等式成立;而其他位都变为原来的1/2。因此,总和也变成原来的1/2

3、

计算:-11 >> 1 = -6

计算-11的补码:-11原码1000 1011 → 反码1111 0100 → 补码1111 0101
-11 >> 1 = 1111 1010
1111 1010 → 反码1111 1001 → 原码1000 0110,真值为-6

-11是奇数,同样先-1,再取1/2

4、

计算:-6 << 1 = -12

计算-6的补码:-6原码1000 0110 → 反码1111 1001 → 补码1111 1010
-6 << 1 = 1111 0100
1111 0100 → 反码1111 0011 → 原码1000 1100,真值为-12

之所以计算得这么详细,是为了方便大家观察>>/<<两种位运算的规律,即结果相反。


补充说明

其实负数的位运算不必计算其补码(纯属个人习惯)。示例:

-10的原码:1000 1010
-10 >> 1 = 1000 0101,真值为-5	// 注意:这里右移补的是 0
-5 << 1 = 1000 1010,真值为-10

其实此技巧也不绝对。通过此方法进行计算,比如:-11 >> 1 = -5-15 >> 1 = -7,两个结果显然不对。我还做了其他测试,负奇数和负偶数都有,结果对错皆有。

由于这只是我发现的一个规律,并没有理论支撑,所以未列举出来进行说明。不过,似乎负偶数使用此技巧运算的结果是对的(也算是一个规律吧)。

因此,大家对这个技巧有个印象就行,实际计算还是要严谨。

结语

为什么说上文这个性质巧妙?因为我发现在很多源码中都采用这种方法进行数值翻倍或取半,特别是一些包装类(java.lang.*)或工具类(java.util.*)。例如:

在这里插入图片描述

这是java.util.ArrayList类的扩容方法(第5.6项),很经典的例子。

4.2 实现字符大小写转换

char 类型对应ASCLL码,对字符进行-/+ 32运算即可实现大小写转换。

在查阅关于位运算的资料时,我发现通过位运算也可以实现字符大小写转换。由于位运算的对象是二进制,故效率优于算术运算。好奇测试一下发现,如果都运算一亿次,时间差在几十甚至几微秒之间,实际差距微乎其微。因此,我将此方法记录下来的主要目的是为了扩展思维

1、计算:'A' ^= 32 = 'a'

计算字符'A'的补码:'A' = 65 = 0100 0001
`'A' ^ 32 = 0100 0001 ^ 0010 0000 = 0110 0001,真值为97
2、计算:'a' ^= 32'A'

'a'的补码:0110 0001
'a' ^ 32 = 0110 0001 ^ 0010 0000 = 0100 0001,真值为65
3、计算:'A' |= 32'a'

'A' | 32 = 0100 0001 | 0010 0000 = 0110 0001,真值为97
4、计算:'a' &= -33'A'

计算-33的补码:-33的原码1010 0001 → 反码1101 1110 → 补码1101 1111
'a' & -33 = 0110 0001 & 1101 1111 = 0100 0001,真值为65

4.3 生成 IP 地址

可能大家没有注意这一点,认为IP地址一开始就是222.186.18.199这样的格式,实则不然。

java.net.Inet4Address(IPv4)的构造方法为例:

Inet4Address(String hostName, byte addr[]) {
    holder().hostName = hostName;
    holder().family = IPv4;
    if (addr != null) {
        if (addr.length == INADDRSZ) {
            int address  = addr[3] & 0xFF;
            address |= ((addr[2] << 8) & 0xFF00);
            address |= ((addr[1] << 16) & 0xFF0000);
            address |= ((addr[0] << 24) & 0xFF000000);
            holder().address = address;
        }
    }
    holder().originalHostName = hostName;
}

可见,起初IP地址是byte.byte.byte.byte,经过位运算才转换成我们所熟知的样子。

PS:大家不妨自己试试,挺有意思的。

5、进制间转换

  1. 八进制以0(零)开头,十六进制以0x(字母x)开头。
  2. 八进制以0 ~ 7这8个数表示,十六进制以0 ~ 9 a ~ f这10个数和5个字母表示。
  3. 进制中不区分大小写。如:0x/0X都是八进制的标志,十六进制中的aA相同。
  4. 其他进制<- ->十进制、二进制<- ->十进制,两者运算方式相同。(<- ->表示相互转换)
  5. 十六进制->二进制的方法:将每一位转换成4位二进制,然后合并。(注:4位二进制最大值为1111,得15;十六进制的最大值为f,即15示例:(1)、0xa1010的二进制是0000 1010;(2)、0x14201的二进制是00014的二进制是0100,合并得0001 0100,即20
  6. Java-API 中对进制转换的支持:Integer类。(1)、toBinaryString()/toOctalString()/toHexString():分别可将十进制数转换成二进制 / 八进制 / 十六进制数;(2)、parseUnsignedInt(str, <进制>):可将字符串中指定进制数转换成十进制。

最后

本文中的例子为了阐述这七种常见位运算符的运算步骤、方便大家理解而简单举例的,23-23是任意取的数,没有特别意义。

PS:单纯的位运算最大的作用就是帮助我们掌握位运算的基础,没有太大实用价值。大家可以偶尔去看看一些源码中对位运算的运用,很多真的很巧妙,而且还能查漏补缺。 本文能有如此规模,得益于我对二进制、位运算的理解,以及平日解析源码时频繁运用位运算。

本文完结。

下一篇:浮点数(小数)在计算机中如何用二进制存储?