首先由一道题目引入

面试题 17.04. 消失的数字

题目描述:
数组nums包含从0到n的所有整数,但其中缺了一个。请编写代码找出那个缺失的整数。你有办法在O(n)时间内完成吗?
示例 1:输入:[3,0,1] 输出:2
自己第一次提交代码:建立哈希表,需O(n)辅助空间

class Solution {
    public int missingNumber(int[] nums) {
        int i, n = nums.length;
        int[] hash = new int[n+1];
        for(i = 0; i<n; i++){
            hash[nums[i]] = 1;
        }
        for(i = 0; i<=n; i++){
            if(hash[i]==0)
                return i;
        }
        return 0;
    }
}

参考题解有两种更灵活巧妙的思路:
解法1:按照题意,将预期的所有整数相加,减去给定数组的所有整数的和,得到的就是消失的数字。

class Solution {
    public int missingNumber(int[] nums) {
        int sum = (nums.length + 1) * nums.length / 2;
        int arraySum = 0;
        for (int i = 0; i < nums.length; i++) arraySum += nums[i];
        return sum - arraySum;
    }
}

解法2:根据异或运算的特性去解题。res = res ^ x ^ x。
利用异或的特性,res = res ^ x ^ x。值res对同一个值异或两次,那么结果等于它本身,所以我们对res从0-nums.length进行异或,同时对nums数组中的值进行异或,出现重复的会消失,所以最后res的值是只出现一次的数字,也就是nums数组中缺失的那个数字。

class Solution {
    public int missingNumber(int[] nums) {
        int res = 0;
        for (int i = 0; i < nums.length; ++i) {
            res ^= i;
            res ^= nums[i];
        }
        res ^= nums.length;
        return res;
    }
}

一、异或^运算的特性

异或(“ ^ ”)对运算符两侧数的每一个二进制位,同值取0,异值取1。

  • x^0 = x 且 x^x = 0 【消去偶数次出现的数】
  • 交换律:x^y = y^x
  • 结合律:(x^y)^z = x^(y^z)
  • 自反性:x^y^y = x 【得到唯一单数次出现的数】

自反性的应用

题型一:交换两个变量的值

如果使用异或,就可以不使用中间变量:设有A,B两个变量,存储的值分别为a,b,则以下三行表达式将互换他们的值【表达式(值)】 :
A = A ^ B (a ^ b)
B = B ^ A (b ^ a ^ b = a)
A = A ^ B (a ^ b ^ a = b)
类似地,该运算还可以应用在加密,数据传输,校验等等许多领域。

题型二:唯一出现单数次的数

(思路:性质 自反性部分红色字标出)

题型三:巧用位运算:解题小Tips

  1. 判断一个数的二进制表示全为1

     boolean res = (temp & (temp+1))==0
  2. 不用 + 加号计算 a+b

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

题型四:(进阶)A集合里拿掉数x、y得到B集合,求x和y

令XOR(X)表示将X集合内所有的数做异或
首先按上一个的办法可以推导出xor(A)^xor(B) = xor(B)^xor(B)^x^y = 0^x^y = x^y
x^y的二进制结果,第n位为1,说明x和y的第n位不相同

根据第n位是否为0把A里所有的数分成A1和A0两个数组(A1里的数的二进制第n位都是1,A0都是0),A1和A0应该各包含了a或者b(这样第n位才能异或出1),同理可以把B分成B1和B0两个数组,可以得到第一个数 x = A1^B1,第二个数可以y = A0^B0,当然也可以用x^y^x求得。
另外如果x^y为0,即x == y,令SUM(X)为X集合内所有数求和,(SUM(A) - SUM(B)) / 2 = x。

没有^操作时候实现异或,只用&与 |或 ~非
转换方法有很多,如 x ^ y == (~x & y) | (x & ~y)

二、移位运算的特性

移位运算时间效率比乘除法高很多

Java中整型数据的存储

在应用移位操作来解决问题之前,我们先明确一下java整形数据是如何以二进制形式存储,从而更好地理解移位的定义。
1. 符号位
首先,没有用unsigned定义的整型默认为有符号整型,正数符号位为0,负数为1,通过测试代码取符号位:

//取符号位(与int存储为32/64位无关)
class dailyPractice {
    public static void main(String[] args){
        int a = -1, b = 1, c;
        c = (a>>(Integer.SIZE - 1))&1;
        System.out.println(c);  //输出1
        c = (b>>(Integer.SIZE - 1))&1;
        System.out.println(c);  //输出0
    }
}

2. 补码存储
计算机中存储整型数用补码进行存储,位运算也是用补码直接进行位运算
反码表示法:正数的反码与其原码相同;负数的反码是(除符号位外)对其原码逐位取反。
补码表示法:正数的补码与其原码相同;负数的补码是在其反码的末位加1。
以负数-5为例:
先将-5的绝对值转换成二进制,即为0000 0101;然后求该二进制的反码,即为 1111 1010;最后将反码加1,即为:1111 1011
复习了这些最最基本的东西再来看移位的定义就对"补符号位"有更直观的感受了。

Java中没有“无符号整形”这种数据类型

移位规则

左移(<<) : m<<n表示 把m左移n位,最左边的n位会被丢弃,同时最右边补上n个0;
右移(>>):m>>n表示 把m右移n位 最右边的n为会被丢弃,如果数值原来是个正数,用0补上最左边的n位,如果是一个负数,则最左边补上n个1;

    int a = 8, b = -8;
    System.out.println(a<<1); //16
    System.out.println(b<<1); //-16
    System.out.println(a>>1); //4
    System.out.println(b>>1); //-4

由实际代码输出可以看出java中的负数右移并没有带着符号位右移并且在前面部符号位,实际上是做的算数右移。JAVA 和 Python中:算术右移 A >> B , 逻辑右移 A >>> B
算术右移:将A的二进制表示的每一位向右移B位,右边超出的位截掉,左边不足的位补符号位的数。
逻辑右移:将A的二进制表示的每一位向右移B位,右边超出的位截掉,左边不足的位补0。

测试代码:

//算术右移
class dailyPractice {
    public static void main(String[] args){
        int i, cnt=0, a = -5, b = a>>1, s = 4*8;
        int[] store = new int[s];
        System.out.println("右移前:a="+a);
        while(cnt++<s){
            store[s-cnt] = a&1;
            a = a>>1;
        }
        for(i = 0; i<s; i++)
            System.out.print(store[i]);
        cnt = 0;
        System.out.println("\n右移后: a="+b);
        while(cnt++<s){
            store[s-cnt] = b&1;
            b = b>>1;
        }
        for(i = 0; i<s; i++)
            System.out.print(store[i]);
    }
}

输出:

(a=-5)
右移前:a=-5
11111111111111111111111111111011
右移后: a=-3
11111111111111111111111111111101
(a=5)
右移前:a=5
00000000000000000000000000000101
右移后: a=2
00000000000000000000000000000010

题型一:二进制中1的个数

题目描述:实现一个函数,输入一个整数,输出这个数字二进制中表示1的个数。如,把9表示成二进制是1001,有2个1。因此输入9,函数输出为2。

思路一: 利用循环,每次将数字n与1做位与(&)运算,检验最优以为是不是1,然后将数字n右移一位,循环终止条件为n为0;
【这种思想的问题在于,如果n为负数,在右移操作时,最左边将会用1填充,则会陷入死循环中。】

思路二(避免死循环):设置一个mask=1,把数字n与mask进行位与运算,判断n的mask位是不是1,,然后mask左移一位,再和n位与,判断n的次低位是不是1;
【这样的想法,循环的次数是整数二进制的位数;循环的次数有点儿多啊】
改进一下,n中有几个1就循环几次的算法:
一个规律,一个整数减去1,再与原整数做与运算,即:n &(n-1) 会将该整数的从右边数第一个1变成0 ,这样该整数中有几个1就循环几次,直至把该整数中的1都变成0;

题型二:一条语句判断一个整数是不是2的整数次方

方法:利用 n&(n-1)
利用上一个题中最后的思路,因为分析可知,如果一个整数是2的整数次方,则其二进制表示中,有且仅有一个1,所以,只要利用 n&(n-1) 判断其值是否为0即可;
进一步,给定一个整数 (32 位有符号整数),请编写一个函数来判断它是否是 4 的幂次方。

class Solution {
    //如果只有一位是1且每次右移两位能够遇到则返回true
    public boolean isPowerOfFour(int num) {
        if(num==0 || n&(n-1)==0)
            while(num!=0){
                if(num%2==1)
                    return true;
                num = num >> 2;
            }
        return false;
    }
}

题型三:判断一个数二进制形式存储中某一位的值

方法:移位后&1

//取num的二进制存储形式(由右向左数)第bit位的值 
public int valueAtBit(int num, int bit) { 
    return (num >> (bit -1)) & 1;
}

以(128,8)为例分析:
128->1000 0000;要想将第八位上的数字取出,可以将它移到第一位上,也即是向右移动7位变成0000 0001,怎样将第一位上的1拿出来呢,可以利用与操作,让0000 0001,与1(0000 0001)与,除了第一位上的数是它本身,其余位全部变成了0,这样就将第一位取了出来。

题型四:一个数字改变几位得到另一个数字

方法:利用异或的概念
题目描述:输入两个整数m和n,计算需要改变m的二进制中的多少位能得到n。比如10的二进制表示为1010,13的二进制位1101,需要改变3位。
解决该问题可以分为两步,首先m和n进行异或,然后统计异或的结果中有多少个1,方法见题型二。

接着由一道题进阶

LeetCode 137. 只出现一次的数字 II

题目描述:给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现了三次。找出那个只出现了一次的元素。(你的算法应该具有线性时间复杂度。 你可以不使用额外空间来实现吗?)
示例 1: 输入: [2,2,3,2] 输出: 3
如何用位运算来做呢?
【请不要偷懒直接看下面的讲解,停在这里自己思考完成一次思维训练呀】

通用解法

统计所有数字中每个位中1出现的总数,那么对于某个位,1出现的次数一定是3的倍数+1或0,那么对这个数%3得到的结果就是目的数字在该位上的值。思想简单、通用 (很容易推广到“其余数字出现n次”),但时间复杂度比较高,比位运算多一层循环。

class Solution {
    public int singleNumber(int[] nums) {
        int ret = 0;
        for (int i = 0; i < 32; i++) {
            int mask = 1 << i;
            int cnt = 0;
            for (int j = 0; j < nums.length; j++) {
                if ((nums[j] & mask) != 0) {
                    cnt++;
                }
            }
            if (cnt % 3 != 0) {
                ret |= mask;
            }
        }
        return ret;
    }
}

位运算解法

本题的解法思路与136题类似,在136题中我们使用异或运算使得相同的两个数运算的结果为0,在本题中,我们需要思考的问题是,我们是否能够定义一种运算(代替异或),使得三个数运算的结果为 0 呢?
答案是肯定的,在这里我们使用一种数字电路设计的思路来设计。

重新回到本题,我们的目标是使得三个数的运算结果为 0。你会发现三进制不计进位的加法恰好符合我们的要求:三个1相加结果为0。因此我们需要设计一个三进制的计数器。

首先,三进制需要2个bit来表示(00,01,10,11),因此我们用 one 和 two 这两个变量分别存放我们的 2 个 bit。因为我们要求的是三进制,因此我们只需要 00, 01, 10 这三个状态,首先我们列出状态转移方程:

这个表格的意思是,当下一个数的对应bit为1时,one和two的值会进入下一个状态,例如,当one和two都为0时,下一个数对应bit的值为1,那么one和two就会变为他们的下一个状态01,one,two会经历00->01->10->00的循环。

接下来,我们需要通过状态转移表来得出newOne 和 one,two,num以及newTwo和one,two,num直接的逻辑关系。为了简化这种逻辑推断的复杂度,我们使用卡诺图来做,首先画出newOne的卡诺图:

我们先画出这样一张空表,顶排的 00,01,11,10代表one当前bit和two当前bit的真或者假,左侧的0,1代表num目前bit的真或假,根据状态转移方程填表:
因为我们的表中没有 11 这一项(不需要)因此填入X,代表这一位的真假我们并不关心。接下来在卡诺图上画圈,画圈的具体规则是:
  • 区域必须是长方形(正方形);
    卡诺图上下左右是联通的;
    区域内只能包含1或X;
    区域的大小必须是2的幂(2,4,8……);
    每个区域尽可能地大;
    画完后不能有1在区域外;

因此我们得出的结果是:

然后我们便可以通过圈来得出newOne 和 one,two,num直接的逻辑关系。首先我们观察上方的圈,圈住的区域one一直为真,而two则真假都有,说明其值并不影响,同时num一直为假,因此我们得出这个圈代表的逻辑是:one & ~num。 同理,我们可以得出下方的圈代表的逻辑是:two & num, 将这些圈或起来,我们得出: newOne = (one & ~num) | (two & num)。 使用相同的方法,我们也可以得出: newTwo = (~one & ~two & num) | (two & ~num)。

这样我们就创造出了我们新的运算,代码就很简单啦:

代码

class Solution {
    public int singleNumber(int[] nums) {
        int one = 0, two = 0, temp = 0;
        for (int num : nums) {
            temp = (two & num) | (one & ~num);
            two = (~one & ~two & num) | (two & ~num);
            one = temp;
        }
        return two;
        // 这里return two的原因是第二个状态为 01,one 为 0,two 为 1
    }
}

位运算时间复杂度优化Tips:

1.以优于 为目标

方法一:按位统计

明确识别出用位运算来做的题目,如果写出来复杂度为,就还是不够好,很可能会超时。如果思路要用双层循环O(n^2),可以考虑用按位统计的思路将复杂度降到32*n。

Leetcode.477汉明距离总和
题目描述:两个整数的 汉明距离 指的是这两个数字的二进制数对应位不同的数量。计算一个数组中,任意两个数之间汉明距离的总和。

class Solution {
    public int totalHammingDistance(int[] nums) {
        int i, j, n = nums.length, sum = 0;
        for(i = 0; i<n; i++){
            for(j = 0; j<n; j++){
                sum += Integer.bitCount(nums[i]^nums[j]);
            }
        }
        return sum>>1;
    }
}

代码超时了,实际上是的复杂度。以为自己用上了位运算就是面试官想要的代码了,就还太飘(还是要习惯于勤于思考)。

class Solution {
    public int totalHammingDistance(int[] nums) {
        int i, n = nums.length, res = 0;
        int[] cnt = new int[32];
        for(int num: nums){
            for(i = 0; i<32; i++){
                cnt[i] += num & 1;
                num = num>>1;
            }
        }
        for(i = 0; i<32; i++){
            res += cnt[i]*(n-cnt[i]);
        }
        return res;
    }
}

《 java 自带的计算二进制“1”的位数等一些函数是如何实现的?是否是效率最高的方式》