有趣的位运算

有趣的位运算

  计算机的终极程序其实只有0和1,转化成集成电路的低电压和高电压来进行存储和运算。若是你是计算机相关专业出身或者是一名软件开发人员即便不对计算机体系结构如数家珍,至少也要达到可以熟练使用位运算的水平,要否则仍是称为代码搬运工比较好:),位运算很是简单,很是容易理解并且颇有趣,在平时的开发中应用也很是普遍,特别是须要优化的大数据量场景。你所使用的编程语言的+-*/实际上底层也都是用位运算实现的。在面试中若是你能用位运算优化程序、进行集合操做是否是也能加分呀。花费不多的时间就能带来很大的收获何乐而不为。本文总结了位运算的基本操做、经常使用技巧和场景实践,但愿能给你带来收获。java

原码、反码和补码

  在讨论位运算以前有必要补充一下计算机底层使用的编码表示,计算机内部存储、计算的任何信息都是由二进制(0和1)表示,而二进制有三种不一样的表示形式:原码反码补码。计算机内部使用补码来表示。git

  原码,就是其二进制表示(注意,有一位符号位)
  反码,正数的反码就是原码,负数的反码是符号位不变,其他位取反
  补码,正数的补码就是原码,负数的补码是反码+1程序员

  符号位,最高位为符号位,0表示正数,1表示负数。在位运算中符号位也参与运算。github

位运算的基本操做

  这里只涉及编程语言中拥有运算符号的位运算,其余运算不在讨论范围内。经常使用的位运算主要有6种:按位与、按位或、左移、右移、按位取反、按位异或。最后补充一种逻辑右移。面试

按位与操做 &编程

  按位与&操做是指对两操做数进行按位与运算,其中两位都为1结果为1,其余状况为0。按位与是二目运算符。数组

1 & 1 = 1
1 & 0 = 0
0 & 1 = 0
0 & 0 = 0

  例如:3 & 17 = 1编程语言

  3=00000011大数据

  17=00010001优化

  &=00000001

  注意,这里表示二进制不足的位用0补足。

按位或操做 |

  按位或 | 操做是指对两个操做数进行按位或运算,其中有至少有1位为1结果就为1,两位都为0结果为0。按位或运算是二目运算符。

1 | 1 = 1
1 | 0 = 1
0 | 1 = 1
0 | 0 = 0  

  例如:3 | 17 = 19

  3=00000011

  17=00010001

  | =00010011

 按位非操做 ~

  按位非操做 ~ 就是对操做数进行按位取反,原来为1结果为0,原来为0结果为1。按为非操做是单目运算符。

  例如:~33=-34

  33= 00000000000000000000000000100001  (整数为32位)

  ~33=11111111111111111111111111011110=-34    (补码表示,符号位也参与运算)

左移操做 <<

  左移操做 << 是把操做数总体向左移动,左移操做是二目运算符。

  例如:33 << 2 = 100001 << 2 = 10000100 = 132

  -2147483647 << 1 = 10000000000000000000000000000001 << 1 = 10 = 2 (符号位也参与运算)

  技巧:a << n = a * 2^n (a为正数)

右移操做 >>

  右移操做 >> 是把操做数总体向右移动,右移操做是二目运算符。

  例如:33 >> 2 = 100001 >> 2 = 001000 = 8

  -2147483647 >> 1 = 10000000000000000000000000000001 << 1 = 11000000000000000000000000000000 = -1073741824 (符号位也参与运算,补足符号位)

  技巧:a >> n = a / 2^n (a为正数)

  补充:逻辑右移 >>> 

  逻辑右移和右移的区别是,右移将A的二进制表示的每一位向右移B位,右边超出的位截掉,左边不足的位补符号位的数(好比负数符号位是1则补充1,正数符号位是0则补充0),因此对于算术右移,原来是负数的,结果仍是负数,原来是正数的结果仍是正数。逻辑右移将A的二进制表示的每一位向右移B位,右边超出的位截掉,左边不足的位补0。因此对于逻辑右移,结果将会是一个正数。

  例如上面的-2147483647 >>> 1 = 01000000000000000000000000000000 = 1073741824 (补足0)。

按位异或操做 ^

  按位异或  ^ 操做是把两个操做数作按位异或操做,其中两位相同则为0,不一样则为1,按位异或是二目运算符,又称为不进位加法。

1 ^ 1 = 0
1 ^ 0 = 1
0 ^ 1 = 1
0 ^ 0 = 0

  例如:33 ^ 12 = 45

  33=00100001

  12=00001100

  ^ = 00101101

  技巧:异或是不进位加法,两个数作加法,把进位舍去。

 

位运算的技巧应用

 不进位加法-异或 ^

  异或是按位相同为0不一样为1,其实就是作加法的过程把进位舍去了。这样一来咱们就能够利用这个性质解决问题。想想加法是怎么实现的呢?

  若是两数相加没有进位是否是直接可使用异或了?那若是有进位呢?那就把进位加上。

a + b = (a ^ b) + 进位

 

  考虑一下进位如何实现,有进位的地方就是两个位都为1的地方,这就能够利用按位与运算了,与运算两个都为1结果为1其余状况为0,把两数相与的结果左移一位就是进位的结果。

a + b = (a ^ b) + ((a & b) << 1)

  这样就完了吗?没有啊,这个仍是使用了+号啊。不使用+号那就递归直到进位为0,或者使用循环一直对进位作不进位加法直到进位为0。

  加法有了,-*/还会远吗

public int plus(int a, int b) {
        if (b == 0)
            return a;
        int _a = (a ^ b);
        int _b = ((a & b) << 1);
        return plus(_a, _b);
}

public int plus(int a, int b) {
        while (b != 0) {
            int _a = (a ^ b);
            int _b = ((a & b) << 1);
            a = _a;
            b = _b;
        }
        return a;
} 

 异或两次等于没有异或 a ^ b ^ b = a

  基于两个相同的数异或结果为0,一个数和0异或结果不变那么就是异或两次等于没有异或。a ^ b ^ b = a。

  技巧应用:给一个数组,数组中的数字只有一个出现了一次,其余的都出现了两次,找出这个只出现一次的数字。

  这个问题就能够巧妙的使用异或运算,把数组的数字所有异或一遍,获得的结果就是只出现一次的数字。

public int singleNumber(int[] nums) {
        int result = 0, n = nums.length;
        for (int i = 0; i < n; i++)
        {
            result ^= nums[i];
        }
        return result;
}

  相似的问题还有不少,统一来讲就是数组中只有一个数字出现了m次,其余的都出现了k次,找出出现m次的数字。这一类问题基本上均可以考虑使用异或来解决。有兴趣能够参考:http://www.lintcode.com/en/problem/single-number,连接后能够再加-ii,-iii,-iv。

取a最后一位1的位置a & (-a)

  在机器中都是采用补码形式存在,负数的补码是反码+1。所以a & (-a)是取最后一位1。

  例如:33 & (-33) = 1

  33 = 00000000000000000000000000100001

  -33=11111111111111111111111111011111

  & = 00000000000000000000000000000001

  技巧应用:给一个数组,只有两个数出现了一次,剩下的都出现了两次,找出出现一次的两个数字。

  这个问题能够拆解成两个问题,把数组分红两部分,没一部分都知足只有一个数出现了一次剩下的都出现了两次,找出只出现一次的数字。这个问题就能够利用异或来解决了。关键就是怎么把数组分红这样的两部分。那就先把全部数字异或起来最后结果就是至关于只出现一次的两个数字异或的结果,对这个结果取最后一个1,那么在这一位这两个数确定是不一样的,接下来就能够根据这一位是否是1来把全部数字分到两个数组中。

  本身体会。

public int[] singleNumber(int[] nums) {
        //用于记录,区分“两个”数组
        int diff = 0;
        for(int i = 0; i < nums.length; i ++) {
            diff ^= nums[i];
        }
        //取最后一位1
        diff &= -diff;
        
        int[] rets = {0, 0}; 
        for(int i = 0; i < nums.length; i ++) {
            //分属两个“不一样”的数组
            if ((nums[i] & diff) == 0) {
                rets[0] ^= nums[i];
            }
            else {
                rets[1] ^= nums[i];
            }
        }
        return rets;
    }

去掉a的最后一位1 a & (a - 1)

  两个相同的数相与结果不变,那么a & (a - 1)就获得了a去掉最后一位1的数,这很是好理解。

  例如:33 & (33 - 1) = 33 & 32 = 100001 & 100000 = 100000 = 32

  技巧应用I:判断一个数是不是2的次幂。从二进制的角度思考,一个数若是是2的次幂,那么须要知足这个数大于0,这个数的二进制表示有且只有一个1.

  直接把这个惟一的1消去看是否为0就能够了。

public boolean isPowerOf2(int n) {
       return n > 0 && (n & (n - 1)) == 0;  
}

  技巧应用II:求一个整数的二进制表示的1的个数。有了这个技巧这个问题就很是简单了,把1所有消去,看消了几回就能够了。

public int countOnes(int num) {
        int count = 0;
        while (num != 0) {
            num = num & (num - 1);
            count++;
        }
        return count;
}

  技巧应用III:求一个整数转化为另外一个整数须要改变多少位。这个问题也就是求两个整数有多少位不一样就好了,改变不一样的位置就能变成另外一个数。使用异或很是简单的求出有多少位不一样,而后问题就变成了上一个问题,求异或结果的1的个数。

public int countOnes(int num) {
        int count = 0;
        while (num != 0) {
            num = num & (num - 1);
            count++;
        }
        return count;
}
public int bitSwapRequired(int a, int b) {
        return countOnes(a ^ b);
}

使用bit表示状态

  在解决一个问题的时候,一般须要记录一些数据的状态和枚举,可使用整数、布尔类型或者数组来表示,可是当状态多了以后就会占用大量的存储空间。这时候就能够把状态压缩成bit来表示。

  例如:求一个集合的全部子集。这是一个NP问题,一般状况下使用回溯递归来解决。

public List<List<Integer>> subsets(int[] nums) {
        List<List<Integer>> ret = new ArrayList<>();
        if(nums == null || nums.length == 0) return ret;
        List<Integer> list = new ArrayList<>();
        dfs(ret, list, nums, 0);
        return ret;
    }
    
private void dfs(List<List<Integer>> ret, List<Integer> list, int[] nums, int start) {
        if (start > nums.length)
            return;
        ret.add(new ArrayList<Integer>(list));
        for (int i=start; i<nums.length; i++) {
            list.add(nums[i]);
            dfs(ret, list, nums, i+1);
            list.remove(list.size()-1);
        }
}

  换一个角度,使用一个正整数二进制表示的第i位是1仍是0来表明集合的第i个数取或者不取。因此从0到2^n-1总共2^n个整数,正好对应集合的2^n个子集。若是集合为{1,2,3}则

0 000 {}
1 001 {1}
2 010 {2}
3 011 {1,2}
4 100 {3}
5 101 {1,3}
6 110 {2,3}
7 111 {1,2,3}
public List<List<Integer>> subsets(int[] nums) {
        List<List<Integer>> ret = new ArrayList<>();
        int n = nums.length;
        for (int i=0; i<(1 << n); i++) {
            List<Integer> subset = new ArrayList<Integer>();
            for (int j=0; j<n; j++) {
                if ((i & (1<<j)) != 0) //检查是不是1
                    subset.add(nums[j]);
            }
            ret.add(subset);
        }
        return ret;
}

  虽然在时间复杂度上没有优化可是这个位运算的解法仍是比递归快了整整1ms(在leetcode上)。

位运算的工程实践

  接下来看一个工程实践的例子,给两个8G的文件,文件每行存储了一个正整数,求这两个文件的交集,存储在另外一个文件中,要求最多只能使用4G内存。你可能会想到把大文件分割成小文件,分批对比,这样比较麻烦,若是想到使用bit来压缩状态表示的话这个问题就变得简单了。

  使用一个bit来表示这个整数存在或不存在,存在置1,不存在置0。先遍历一个文件,把全部整数的状态置位,而后遍历另外一个文件,读取整数的bit若是为1则存储在结果文件中,若是为0则继续。这样就求出了交集。若是用一个bit表示一个整数的状态的话,4G内存能够表示34359738368个整数。若是一个整数存储在文件中平均占用6个字符的话4G内存所表示的整数可以存满192GB的空间。这样看起来,这种解法在时间和空间上都是知足要求的,而且思路清晰简单。

  那么问题来了,怎么把二进制的某一位置1或者置0呢?好比,将a的第n位置1,首先经过1 << n获得只有第n位为1的数,而后进行按位或运算a | (1 << n)。同理若是把第n位置0,获得只有第n位为1的数后取反,而后作按位与运算a & ~(1 << n)。

  那么若是相判断某一位是否为1呢?一样的先经过1 << n获得只有第n位为1的数而后作按为与运算,若是结果为0则原来位上为0不然为1, a & (1 << n)。

  例如:

  33 | (1 << 3) = 100001 | 001000 = 101001
  33 & ~(1 << 5) = 100001 & 000001 = 000001

//伪代码
bit=0
while (num1 = readLine(file1)) {
    bit |= (1 << num1)
}
while (num2 = readLine(file2)) {
    if ((bit & (1 << num2)) == 0)
        continue
    else
        writeLine(file3, num2)
}

  其实这个就是BitSet的简单实现,能够看一下Java中BitSet源码的几个关键方法:

public void set(int bitIndex) {
        if (bitIndex < 0)
            throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);

        int wordIndex = wordIndex(bitIndex);
        expandTo(wordIndex);

        words[wordIndex] |= (1L << bitIndex); // Restores invariants

        checkInvariants();
}
public void clear(int bitIndex) {
        if (bitIndex < 0)
            throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);

        int wordIndex = wordIndex(bitIndex);
        if (wordIndex >= wordsInUse)
            return;

        words[wordIndex] &= ~(1L << bitIndex);

        recalculateWordsInUse();
        checkInvariants();
}
public boolean get(int bitIndex) {
        if (bitIndex < 0)
            throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);

        checkInvariants();

        int wordIndex = wordIndex(bitIndex);
        return (wordIndex < wordsInUse)
            && ((words[wordIndex] & (1L << bitIndex)) != 0);
}

  这个问题还有许多拓展问题,例如求差集、并集、对海量的URL去重、网页去重、垃圾邮件过滤等等,这些问题均可以用相似的思路去解决,只不过在真实的工程实践中不少代码能够不用写的这么底层。可使用已经实现好的BitSet、Redis Bit和Bloomfilter。关于Bloomfilter能够参考下面的开源项目,集成了Java的BitSet和Redis,有很好的扩展性。能够直接使用Maven引入依赖,使用也很简单。

  项目地址:https://github.com/wxisme/bloomfilter

总结

  熟悉和蔼于使用位运算会将一些问题简单化而且可以提高效率和空间利用率,在某些特定场景下也是必须的,还能够帮助你去阅读包含位运算的源代码。一样也不能滥用位运算,有时候会增长问题复杂度并且会让你的代码变得阅读性不好,例如上面的工程问题,若是整数的数量不多,那你大可没必要用bitset解决,使用最简单的方法还能够避免一些未知的问题(坑)。

补充:浮点数的二进制表示

  浮点数不在本文讨论范围,浮点数的表示要比整数复杂一些,计算机中的浮点数自己就是有偏差的,而且须要比较多的CPU运算,所以尽可能使用整数类型,关于浮点数的表示能够参考:程序员必知之浮点数运算原理详解

  推荐阅读书籍:《深刻理解计算机系统(原书第3版)》

    

  若是文章对你有帮助,请点击推荐鼓励做者 :)

相关文章
相关标签/搜索