二进制运算

提及位运算,对绝大多数Java程序员来说,是一种既熟悉又陌生的感觉。熟悉是因为你在学JavaSE时肯定学过,并且在看一些开源框架(特别是JDK源码)时都能看到它的身影;陌生是因为大概率我们不会去使用它。当然,不能“流行”起来是有原因的:不好理解,不符合人类的思维,阅读性差……

位运算在low-level的语言里使用得比较多,但是对于Java这种高级语言它就很少被提及了。虽然使用得很少但Java也是支持的,毕竟很多时候使用位运算才是最佳实践

位运算在日常开发中使用得较少,但是巧妙的使用位运算可以大量减少运行开销,优化算法。一条语句可能对代码没什么影响,但是在高重复,大数据量的情况下将会节省很多开销

二进制

二进制是计算技术中广泛采用的一种数制。二进制数据是用0和1两个数码来表示的数。它的基数为2,进位规则是逢二进一,借位规则是借一当二。因为它只使用0、1两个数字符号,非常简单方便,易于用电子方式实现

小贴士:半导体开代表1,关代表0,这也就是CPU计算的最底层原理

二进制与编码

计算机能识别的只有1和0,也就是二进制,1和0可以表达出全世界的所有文字和语言符号。那如何表达文字和符号呢?这就涉及到字符编码了。字符编码强行将每一个字符对应一个十进制数字(请注意字符和数字的区别,比如0字符对应的十进制数字是48),再将十进制数字转换成计算机理解的二进制,而计算机读到这些1和0之后就会显示出对应的文字或符号

  • 一般对英文字符而言,一个字节表示一个字符,但是对汉字而言,由于低位的编码已经被使用(早期计算机并不支持中文,因此为了扩展支持,唯一的办法就是采用更多的字节数)只好向高位扩展
  • 字符集编码的范围utf-8>gbk>iso-8859-1(latin1)>ascll。ascll编码是美国标准信息交换码的英文缩写,包含了常用的字符,如阿拉伯数字,英文字母和一些打印符号共255个(一般说成共128个字符问题也不大)

UTF-8:一套以 8 位为一个编码单位的可变长编码,会将一个码位(Unicode)编码为1到4个字节(英文1字节,大部分汉字3字节)

Java中的二进制

在Java7以前,是不支持直接书写除十进制以外的其它进制字面量。但这在Java7就允许了:

  • 二进制:前置0b/0B
  • 八进制:前置0
  • 十进制:默认的,无需前置
  • 十六进制:前置0x/0X
// 二进制
int i = 0B101;
System.out.println(i); //5
// 八进制
i = 0101;
System.out.println(i); //65
// 十进制
i = 101;
System.out.println(i); //101
// 十六进制
i = 0x101;
System.out.println(i); //257

说明:System.out.println()会先自动转为10进制后再输出的;toBinaryString()表示转换为二进制进行字符串进行输出

如何证明Long是64位的

有个最简单的方法:拿到Long类型的最大值,用2进制表示转换成字符串看看长度就行了,代码如下:

long l = 100L;
//如果不是最大值 前面都是0  输出的时候就不会有那么长了(所以下面使用最大/最小值示例)
System.out.println(Long.toBinaryString(l)); //1100100
System.out.println(Long.toBinaryString(l).length()); //7
//正数长度为63为(首位为符号位,0代表正数,省略了所以长度是63)
//111111111111111111111111111111111111111111111111111111111111111
Long l = Long.MAX_VALUE; // 2的63次方 - 1
System.out.println(Long.toBinaryString(l).length()); // 63

// 负数长度为64位(首位为符号位,1代表负数)
//1000000000000000000000000000000000000000000000000000000000000000
l = Long.MIN_VALUE; // -2的63次方
System.out.println(Long.toBinaryString(l).length()); //64

说明:在计算机中,负数以其正值的补码的形式表达

Java中的位运算

在这里插入图片描述

Java语言支持的位运算符还是非常多的,列出如下:

  • &:按位与
  • |:按位或
  • ~:按位非
  • ^:按位异或
  • <<:左位移运算符
  • >>:右位移运算符
  • >>>:无符号右移运算符

以 外,其余均为二元运算符,操作的数据只能是整型(长短均可)或者char字符型。针对这些运算类型,下面分别给出示例,一目了然

说明:1、本示例中所有的字面值使用的都是十进制表示,理解的时候请用二进制思维去理解;2、关于负数之间的位运算本文章统一不做讲述

简单运算

简单运算,顾名思义,一次只用一个运算符

&:按位与

操作规则:同为1则1,否则为0。仅当两个操作数都为1时,输出结果才为1,否则为0

int i = 0B100; // 十进制为4
int j = 0B101; // 十进制为5

System.out.println("二进制结果:" + Integer.toBinaryString(i & j)); // 100
System.out.println("十进制结果:" + (i & j)); // 4
|:按位或

操作规则:同为0则0,否则为1。仅当两个操作数都为0时,输出的结果才为0

int i = 0B100; // 十进制为4
int j = 0B101; // 十进制为5

System.out.println("二进制结果:" + Integer.toBinaryString(i | j)); // 101
System.out.println("十进制结果:" + (i | j));  // 5
~:按位非

操作规则:0为1,1为0。全部的0置为1,1置为0

小贴士:请务必注意是全部的,别忽略了正数前面的那些0哦

int i = 0B100; // 十进制为4

// 11111111111111111111111111111011
System.out.println("二进制结果:" + Integer.toBinaryString(~i));
System.out.println("十进制结果:" + (~i));  // -5
^:按位异或

操作规则:相同为0,不同为1。操作数不同时(1遇上0,0遇上1)对应的输出结果才为1,否则为0

int i = 0B100; // 十进制为4
int j = 0B101; // 十进制为5

System.out.println("二进制结果:" + Integer.toBinaryString(i ^ j));  // 1
System.out.println("十进制结果:" + (i ^ j));   // 1
<<:按位左移

操作规则:把一个数的全部位数都向左移动若干位

int i = 0B100; // 十进制为4

System.out.println("二进制结果:" + Integer.toBinaryString(i << 2));  // 100000
System.out.println("十进制结果:" + (i << 3));   // 32 = 4 * (2的3次方)

左移用得非常多,理解起来并不费劲。x左移N位,效果同十进制里直接乘以2的N次方就行了,但是需要注意值溢出的情况,使用时稍加注意

>>:按位右移

操作规则:把一个数的全部位数都向右移动若干位

int i = 0B100; // 十进制为4

System.out.println("二进制结果:" + Integer.toBinaryString(i >> 1));  // 10
System.out.println("十进制结果:" + (i >> 1));  // 2
int i = -0B100; // 十进制为-4

// 二进制结果:11111111111111111111111111111110
System.out.println("二进制结果:" + Integer.toBinaryString(i >> 1));
System.out.println("十进制结果:" + (i >> 1)); // -2

右移用得也比较多,也比较理解:操作其实就是把二进制数右边的N位直接砍掉,然后正数右移高位补0,负数右移高位补1

>>>:无符号右移

注意:没有无符号左移,并没有<<<这个符号的

它和>>有符号右移的区别是:无论是正数还是负数,高位通通补0。所以说对于正数而言,没有区别;那么看看对于负数的表现

int i = -0B100; // 十进制为-4

// 二进制结果:11111111111111111111111111111110(>>的结果)
// 二进制结果:1111111111111111111111111111110(>>>的结果)
System.out.println("二进制结果:" + Integer.toBinaryString(i >>> 1));
System.out.println("十进制结果:" + (i >>> 1)); // 2147483646

复合运算

广义上的复合运算指的是多个运算嵌套起来,通常这些运算都是同种类型的。这里指的复合运算指的就是和=号一起来使用,类似于+=/-=

int i = 0B110; // 十进制为6
i &= 0B11; // 效果同:i = i & 3

System.out.println("二进制结果:" + Integer.toBinaryString(i));  // 10
System.out.println("十进制结果:" + (i));   // 2

位运算使用场景示例

位运算除了高效,还有一个特点在应用场景下不容忽视:计算的可逆性。通过这个特点我们可以用来达到隐蔽数据的效果,并且还保证了效率

在JDK的原码中。有很多初始值都是通过位运算计算的。最典型的如HashMap:

static final int tableSizeFor(int cap) {
  int n = cap - 1;
  n |= n >>> 1;
  n |= n >>> 2;
  n |= n >>> 4;
  n |= n >>> 8;
  n |= n >>> 16;
  return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

判断两个数字符号是否相同

用位运算符处理来得更加直接(效率最高):

int i = 100;
int j = -2;

System.out.println(((i >> 31) ^ (j >> 31)) == 0);  // false

j = 10;
System.out.println(((i >> 31) ^ (j >> 31)) == 0);  // true

int类型共32bit,右移31位那么就只剩下1个符号位了(因为是带符号右移动,所以正数剩0负数剩1),再对两个符号位做^异或操作结果为0就表明二者一致

取余运算

对于 a % b,当b为2的n次方的时候,a % b 等价于 a & (b - 1)

int型变量操作

功能示例位运算
去掉最后一位101101 -> 10110x >> 1
在最后加一个0101101 -> 1011010x << 1
在最后加一个1101101 -> 1011011x << 1 + 1
把最后一位变成1101100 -> 101101x | 1
把最后一位变成0101101 -> 101100x | 1 - 1
最后一位取反101101 -> 101100x ^ 1
把右数第k位变成1101001 -> 101101,k=3x | (1 << (k - 1))
把右数第k位变成0101101 -> 101001,k=3x & ~ (1 << (k - 1))
右数第k位取反101001 -> 101101,k=3x ^ (1 << (k - 1))
取末三位1101101 -> 101x & 7
取末k位1101101 -> 1101,k = 5x & ((1 << k)-1)
取右数第k位1101101 -> 1,k = 4x >> (k-1) & 1
把末k位变成1101001 -> 101111,k = 4x | (1 << k-1)
末k位取反101001 -> 100110,k = 4x ^ (1 << k-1)
把右边连续的1变成0100101111 -> 100100000x & (x + 1)
把右起第一个0变成1100101111 -> 100111111x | (x + 1)
把右边连续的0变成111011000 -> 11011111x | (x - 1)
取右边连续的1100101111 -> 1111(x ^ (x + 1)) >> 1
去掉右起第一个1的左边100101000 -> 1000x & (x ^ (x - 1))
判断奇数(x & 1) == 1
判断偶数(x & 1) == 0

消去 n 最后的一位 1

在 n 的二进制表示中,如果对 n 执行n = n & (n - 1)那么可以把 n 最右边的 1 消除掉

这个公式有哪些用处呢?

其实还是有挺多用处的,在做题的时候也是会经常碰到,下面我列举几道经典、常考的例题

判断一个正整数 n 是否为 2 的幂次方

如果一个数是 2 的幂次方,意味着 n 的二进制表示中,只有一位是1,其他都是0

boolean judege(int n){
  return (n & (n - 1)) == 0;
}
判断正整数n的二进制表示中有多少个 1

例如 n = 13,那么二进制表示为 n = 1101,那么就表示有 3 个1,这道题常规做法还是把 n 不停着除以 2,然后统计除后的结果是否为奇数,是则 1 的个数加 1,否则不需要加1,继续除以 2

不过对于这种题,可以用不断着执行 n & (n - 1),每执行一次就可以消去一个 1,当 n 为 0 时,计算总共执行了多少次即可,代码如下:

int NumberOf1(int n) {
  int count = 0;
  while (n != 0) {
    count++;
    n = (n - 1) & n;
  }
  return count;
}

位操作进行高低位交换

给定一个 16 位的无符号整数,将其高 8 位与低 8 位进行交换,求出交换后的值,如:

34520的二进制表示:
10000110 11011000

将其高8位与低8位进行交换,得到一个新的二进制数:
11011000 10000110
其十进制为55430

从上面移位操作我们可以知道,只要将无符号数 a>>8 即可得到其高 8 位移到低 8 位,高位补 0;将 a<<8 即可将 低 8 位移到高 8 位,低 8 位补 0,然后将 a>>8 和 a<<8 进行或操作既可求得交换后的结果

a = (a >> 8) | (a << 8);

位操作进行二进制逆序

将无符号数的二进制表示进行逆序,求取逆序后的结果,如:

数34520的二进制表示:
10000110 11011000

逆序后则为:
00011011 01100001
它的十进制为7009

在字符串逆序过程中,可以从字符串的首尾开始,依次交换两端的数据。在二进制中使用位的高低位交换会更方便进行处理,这里我们分组进行多步处理

# 第一步:以每 2 位为一组,组内进行高低位交换
交换前: 10 00 01 10 11 01 10 00
交换后: 01 00 10 01 11 10 01 00
# 第二步:在上面的基础上,以每 4 位为 1 组,组内高低位进行交换
交换前: 0100 1001 1110 0100
交换后: 0001 0110 1011 0001
# 第三步:以每 8 位为一组,组内高低位进行交换
交换前: 00010110 10110001
交换后: 01100001 00011011
# 第四步:以每16位为一组,组内高低位进行交换
交换前: 0110000100011011
交换后: 0001101101100001

对于上面的方法依次以 2 位作为一组,再进行组内高低位交换,这样处理起来比较繁琐,下面介绍另外一种方法进行处理

# 先分别取原数 10000110 11011000 的奇数位和偶数位,将空余位用0填充
原 数:  10000110 11011000
奇数位:  10000010 10001000
偶数位:  00000100 01010000
# 再将奇数位右移一位,偶数位左移一位,此时将两个数据相或即可以达到奇偶位上数据交换的效果:
原数:      10000110 11011000
奇数位右移一位: 01000001 01000100
偶数位左移一位: 00001000 10100000
两数相或得到:  01001001 11100100

上面的方法用位操作可以表示为:

取a的奇数位并用 0 进行填充可以表示为:a & 0xAAAA
取a的偶数为并用 0 进行填充可以表示为:a & 0x5555 
因此,上面的第一步可以表示为:a = ((a & 0xAAAA) >> 1) | ((a & 0x5555) << 1)
同理,可以得到其第二、三和四步为:a = ((a & 0xCCCC) >> 2) | ((a & 0x3333) << 2)a = ((a & 0xF0F0) >> 4) | ((a & 0x0F0F) << 4)a = ((a & 0xFF00) >> 8) | ((a & 0x00FF) << 8)因此整个操作为:
a = ((a & 0xAAAA) >> 1) | ((a & 0x5555) << 1);
a = ((a & 0xCCCC) >> 2) | ((a & 0x3333) << 2);
a = ((a & 0xF0F0) >> 4) | ((a & 0x0F0F) << 4);
a = ((a & 0xFF00) >> 8) | ((a & 0x00FF) << 8);

交换两个数的值(不借助第三方变量)

这是一个很古老的面试题了,交换A和B的值。本题如果没有括号里那几个字,是一道大家都会的题目,可以这么来解:

int a = 3, b = 5;
a = a + b;
b = a - b;
a = a - b;
System.out.println(a + "-------" + b);

使用这种方式最大的好处是:容易理解。最大的坏处是:a+b,可能会超出int型的最大范围,造成精度丢失导致错误,造成非常隐蔽的bug。所以若你这样运用在生产环境的话,是有比较大的安全隐患

因为这种方式存在重大安全隐患。所以介绍一种安全的替代方式,借助位运算的可逆性来完成操作:

// 这里使用最大值演示,以证明这样方式是不会溢出的
int a = Integer.MAX_VALUE, b = Integer.MAX_VALUE - 10;
a = a ^ b;
b = a ^ b;
a = a ^ b;
System.out.println(a + "-------" + b);

由于全文都没有对a/b做加法运算,因此不能出现溢出现象,所以是安全的。这种做法的核心原理依据是:位运算的可逆性,使用异或来达成目的

位运算用在数据库字段上(重要)

这个使用case是极具实际应用意义的,因为在生产上我已用过多次,感觉不是一般的好

业务系统中数据库设计的尴尬现象:通常数据表中可能会包含各种状态属性, 例如 blog表中,需要有字段表示其是否公开,是否有设置密码,是否被管理员封锁,是否被置顶等等。 也会遇到在后期运维中,策划要求增加新的功能而造成你需要增加新的字段,这样会造成后期的维护困难,字段过多,索引增大的情况, 这时使用位运算就可以巧妙的解决

举个例子:在网站上进行认证授权的时候,一般支持多种授权方式,比如:

  • 个人认证 0001 -> 1
  • 邮箱认证 0010 -> 2
  • 微信认证 0100 -> 4
  • 超管认证 1000 -> 8

这样就可以使用1111这四位来表达各自位置的认证与否。要查询通过微信认证的条件语句如下:

-- 只通过微信认证
select * from xxx where status = status & 4;
select * from xxx where status | 4 = 4;
-- 通过了微信认证
select * from xxx where status = status | 4;
select * from xxx where status & 4 = 4;
-- 此用户开通微信认证
UPDATE xxx SET status = status | 4 where ;
-- 此用户取消微信认证(首先要确保此用户已开通微信认证)
UPDATE xxx SET status = status ^ 4 where ; ^相同为0,不同为1
-- 此用户取消微信认证
UPDATE xxx SET status = (status | 4) ^ 4 where ;

要查询既通过了个人认证,又通过了微信认证的:

select * from xxx where status = status & 5;

当然你也可能有排序需求,形如这样:

select * from xxx order by status & 1 desc

某用户被同时授予个人认证和微信认证

SELECT sum(num) sum FROM test WHERE num IN ('个人认证','微信认证');
UPDATE xxx SET status = status | num where ;
注意事项
  1. 需要你的DB存储支持位运算,比如MySql是支持的
  2. 请确保你的字段类型不是char字符类型,而应该是数字类型
  3. 这种方式它会导致索引失效,但是一般情况下状态值是不需要索引的
public enum RegisterFormat {
  PRIVATE(0),
  MAIL(1),
  WETCHAT(2),
  SUPER(3);
  private Integer state;

  RegisterFormat(Integer state) {
    this.state = state;
  }

  public static List<String> getRegisterFormat(int flags) {
    return Arrays.stream(values()).filter(format -> (flags | format.getMask()) == flags).map(Enum::name).collect(Collectors.toList());
  }

  public boolean enableIn(int flags) {
    return (flags | getMask()) == flags;
  }

  public int getMask(int flags) {
    return flags | getMask();
  }

  private int getMask() {
    return 1 << (getState() - 1);
  }
}

附录

原码、反码、补码详细

机器数和真值

机器数

一个数在计算机中的二进制表示形式,叫做这个数的机器数。机器数是带符号的,在计算机用一个数的最高位存放符号:正数为0,负数为1

比如,十进制中的数 +3 ,计算机字长为8位,转换成二进制就是00000011。如果是 -3 ,就是 10000011

那么,这里的 00000011 和 10000011 就是机器数

真值

因为第一位是符号位,所以机器数的形式值就不等于真正的数值。例如上面的有符号数 10000011,其最高位1代表负,其真正数值是 -3 而不是形式值131(10000011转换成十进制等于131)。所以,为区别起见,将带符号位的机器数对应的真正数值称为机器数的真值

例:0000 0001的真值 = +000 0001 = +1,1000 0001的真值 = –000 0001 = –1

原码, 反码, 补码的基础概念和计算方法

对于一个数,计算机要使用一定的编码方式进行存储。原码、反码、补码是机器存储一个具体数字的编码方式

原码

原码就是符号位加上真值的绝对值,即用第一位表示符号,其余位表示值。比如如果是8位二进制:

[+1]= 0000 0001
[-1]= 1000 0001

第一位是符号位。因为第一位是符号位,所以8位二进制数的取值范围就是:

[1111 1111 , 0111 1111][-127 , 127]
反码

反码的表示方法是:正数的反码是其自身(正数与其反码相加为255)

负数的反码是在其原码的基础上,符号位不变,其余各个位取反

[+1] = [00000001]= [00000001][-1] = [10000001]= [11111110]

可见如果一个反码表示的是负数,是无法直观的看出来它的数值。通常要将其转换成原码再计算

补码

补码的表示方法是:正数的补码就是其本身

负数的补码是原码符号位不变,其余各位取反,最后+1(即在反码的基础上+1)

[+1] = [00000001]= [00000001]= [00000001][-1] = [10000001]= [11111110]= [11111111]

对于负数,补码表示方式也是人脑无法直观看出其数值的。通常也需要转换成原码在计算其数值

为何要使用原码, 反码和补码

现在知道计算机可以有三种编码方式表示一个数。对于正数三种编码方式的结果都相同。但是对于负数则结果不相同

[+1] = [00000001]= [00000001]= [00000001][-1] = [10000001]= [11111110]= [11111111]

可见原码、反码和补码是完全不同的。既然原码才是被人脑直接识别并用于计算表示方式,为何还会有反码和补码呢?

因为人可以知道第一位是符号位,在计算的时候会根据符号位,选择对真值区域的加减。但是对于计算机,加减乘数已经是最基础的运算,要设计的尽量简单。计算机辨别"符号位"显然会让计算机的基础电路设计变得十分复杂!于是人们想出了让符号位也参与运算的方法。根据运算法则减去一个正数等于加上一个负数,即: 1-1 = 1 + (-1) = 0 , 所以机器可以只有加法而没有减法,这样计算机运算的设计就更简单了

于是开始探索将符号位参与运算,并且只保留加法的方法。首先来看原码:

1 - 1 = 1 + (-1) = [00000001]+ [10000001]= [10000010]= -2

如果用原码表示,让符号位也参与计算,显然对于减法来说,结果是不正确的。这也就是为何计算机内部不使用原码表示一个数

为了解决原码做减法的问题,出现了反码:

1 - 1 = 1 + (-1) = [0000 0001]+ [1000 0001]= [0000 0001]+ [1111 1110]= [1111 1111]= [1000 0000]= -0

发现用反码计算减法,结果的真值部分是正确的。而唯一的问题其实就出现在"0"这个特殊的数值上。虽然人们理解上+0和-0是一样的,但是0带符号是没有任何意义的。而且会有[0000 0000]原和[1000 0000]原两个编码表示0

于是补码的出现,解决了0的符号以及两个编码的问题:

1-1 = 1 + (-1) = [0000 0001]+ [1000 0001]= [0000 0001]+ [1111 1111]= [0000 0000]=[0000 0000]

这样0用[0000 0000]表示,而以前出现问题的-0则不存在了。而且可以用[1000 0000]表示-128:

(-1) + (-127) = [1000 0001]+ [1111 1111]= [1111 1111]+ [1000 0001]= [1000 0000]

-1-127的结果应该是-128,在用补码运算的结果中,[1000 0000]补 就是-128。但是注意因为实际上是使用以前的-0的补码来表示-128,所以-128并没有原码和反码表示(对-128的补码表示[1000 0000]补算出来的原码是[0000 0000]原,这是不正确的)

使用补码,不仅仅修复了0的符号以及存在两个编码的问题,而且还能够多表示一个最低数。这就是为什么8位二进制,使用原码或反码表示的范围为[-127, +127],而使用补码表示的范围为[-128, 127]

因为机器使用补码,所以对于编程中常用到的32位int类型,可以表示范围是: [-231, 231-1]。因为第一位表示的是符号位,而使用补码表示时又可以多保存一个最小值

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

①笶侕濄

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值