提及位运算,对绝大多数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 -> 10110 | x >> 1 |
在最后加一个0 | 101101 -> 1011010 | x << 1 |
在最后加一个1 | 101101 -> 1011011 | x << 1 + 1 |
把最后一位变成1 | 101100 -> 101101 | x | 1 |
把最后一位变成0 | 101101 -> 101100 | x | 1 - 1 |
最后一位取反 | 101101 -> 101100 | x ^ 1 |
把右数第k位变成1 | 101001 -> 101101,k=3 | x | (1 << (k - 1)) |
把右数第k位变成0 | 101101 -> 101001,k=3 | x & ~ (1 << (k - 1)) |
右数第k位取反 | 101001 -> 101101,k=3 | x ^ (1 << (k - 1)) |
取末三位 | 1101101 -> 101 | x & 7 |
取末k位 | 1101101 -> 1101,k = 5 | x & ((1 << k)-1) |
取右数第k位 | 1101101 -> 1,k = 4 | x >> (k-1) & 1 |
把末k位变成1 | 101001 -> 101111,k = 4 | x | (1 << k-1) |
末k位取反 | 101001 -> 100110,k = 4 | x ^ (1 << k-1) |
把右边连续的1变成0 | 100101111 -> 100100000 | x & (x + 1) |
把右起第一个0变成1 | 100101111 -> 100111111 | x | (x + 1) |
把右边连续的0变成1 | 11011000 -> 11011111 | x | (x - 1) |
取右边连续的1 | 100101111 -> 1111 | (x ^ (x + 1)) >> 1 |
去掉右起第一个1的左边 | 100101000 -> 1000 | x & (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 ;
注意事项
- 需要你的DB存储支持位运算,比如MySql是支持的
- 请确保你的字段类型不是char字符类型,而应该是数字类型
- 这种方式它会导致索引失效,但是一般情况下状态值是不需要索引的
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]。因为第一位表示的是符号位,而使用补码表示时又可以多保存一个最小值