位运算

基本概念

对于 8 位二进制,能表示的总数是 $2^8=256$,为了表示负数,将第一位作为符号位,并使用补码表示法:

  • 正数:从 00000000 到 01111111,也就是从 0 到 127,一共 128 个值(包括 0)。

  • 负数:从 10000000 到 11111111,代表 -128 到 -1,也是 128 个值。

由于 00000000 和 10000000 都能表示 0(一个正0,一个负0),但 0 只需一个编码,所以,负数可以“借”来这个位置,多表示一个数(就是 -128)。

负数的补码表示 = 该数的正数的反码 + 1,因为 1 + (-1) 要等于 0,而只改变符号的话,它们的二进制数相加并不等于 0,以 1 的二进制表示 00000001 为例,若使 1 - 1 = 0 成立,则 -1 应该为 11111111,而这正是 00000001 的反码 11111110 加 1 得到的。

对于 8 位二进制,只有 -128 不满足 负数的补码表示 = 该数的正数的反码 + 1,因为第一位已经被用来表示符号位,这时就不存在 +128,最大只能到 +127,而 0 只需一个表示就行,所以 10000000 就留给了 -128。

常见位运算

常见的位运算如下:

  • ~:按位取反,0 变成 1,1 变成 0,如 ~1010 = 0101

  • &:按为与运算, 1 与 1 方为 1

  • |:按位或运算,只要有一个 为 1 ,则该位置 为 1

  • ^:按位异或,不相同的值则为 1,比如 0 异或 1,或者 1 异或 0

  • <<:带符号左移,比如 35(00100011),左移一位为 70(01000110), -35(11011101)左移一位为 -70(10111010)

  • >>:带符号右移,比如 35(00100011),右移一位为 17(00010001), -35(11011101)左移一位为 -18(11101110)

  • <<<:无符号左移,比如 35(00100011),左移一位为 70(01000110)

  • >>>:无符号右移,比如 -35(11011101),右移一位为 110(01101110)

  • x ^= y; y ^= x; x ^= y;:x 与 y 交换

  • s &= ~(1 << k):第 k 位置为 0

位运算实现加法

如果给两个正数 a 和 b,怎么使用位运算求出它们的和。

a 与 b 的每一位相加,如果相加时没有产生进位,结果就是异或操作(a ^ b)的值,但是如果产生了进位,分为两部分,一部分是 a 和 b 不进位的和,另外一部分是 a 和 b 进位的结果,两部分相加即可。如果此时相加还是会有进位问题,那么就循环直到进位等于 0,即不产生进位为止:

  • 当前位的和值等于 a ^ b

  • 进位的结果为 a & b,但是进位是往前每次往前进 1 位,叠加在前面一位上,因此需要左移一位 a & b << 1

将两者相加,在循环中直到进位为 0。代码如下:

public static int add(int a, int b) {
    while (b != 0) {
        int sum = a ^ b;
        int carry = (a & b) << 1;
        a = sum;
        b = carry;
    }
    return a;
}

统计二进制整数中1的数量

给一个整数,计算出该整数在二进制表示中 1 的个数。可以直接使用标准库中的 Integer.bitCount() 方法,该方法使用了位运算优化策略。常见的方法是使用循环,逐位检查。

任何一位与 1 进行与运算,最终结果都是它本身,那么一个数与 1 进行与运算(&),如果结果是 1 ,则说明最后一位 是 1。知道了最后一位是否为 1,只要把数字不断向右移位和 1 进行与计算,结果为 1 则说明最后一位是 1,循环直到数字为 0 即可。注意这里需要使用无符号右移,即 >>>,代码如下:

public static int countBits(int num) {
    int count = 0;
    while (num != 0) {
        count += num & 1; // 检查最低位是否为 1
        num >>>= 1; // 无符号右移一位
    }
    return count;
}

位运算找字符串的差异

若有两个字符串 a 和 b,都是大小写字母,b 是由 a 字符串打乱字符顺序之后,再随机位置加上 1 个字符,转换而成,该如何找出这个字符。比如 a = "lasda" , b = "daldas" ,不同的字符是 "d"。

最简单的方法就是统计两个字符串中每个字符出现的次数,对比一下就可以找出多出来的字符到底是哪一个。

每个字符对应一个 ASCII 码,ASCII 码使用指定的 7 位或 8 位二进制数组合来表示 128 或 256 种可能的字符。标准 ASCII 码也叫基础 ASCII 码,使用 7 位二进制数(剩下的 1 位二进制为 0)来表示所有的大写和小写字母,数字 0 到 9、标点符号,以及在美式英语中使用的特殊控制字符。另一种方式就是我们将每个字符的 ASCII 全部加起来,差值就是多出来字符的 ASCII 码,ASCII 码的数值是 int ,可以转换成为字符。

也可以使用位运算解决,首先,可以注意到 a^b^a = b,即任何一个字符,与偶数个其他字符进行异或运算,最终结果等于本身。所以两个字符串的所有字符都进行异或,多出来的字符数量必定是奇数,其他的字符数量是偶数。因此,只需要对所有字符进行异或计算,就可以得到最终结果。代码如下:

public static char findDifferentChar(String a, String b) {
    int ret = 0;
    for(char c : a.toCharArray())
        ret ^= c;
    for(char c : b.toCharArray())
        ret ^= c;
    return (char) ret;
}

二进制中的汉明距离

信息论中,Hamming Distance(汉明距离)表示两个等长字符串在对应位置上不同字符的数目,从另外一个方面看,汉明距离度量了通过替换字符的方式将字符串 x 变成 y 所需要的最小的替换次数。比如:

"KathSam" 和 "kothran" 的汉明距离是 3
1111101 和 1001001 的汉明距离是 3

这里的问题是计算两个整数 a 和 b ,他们的二进制表示中不同的位置的数量(汉明距离)。

可以分为两步:

  • 先异或计算,获取到数值不同的位置

  • 计算数值不同的位的数量,正好使用之前统计二进制整数中1的数量的代码。

代码如下:

public static int hammingDist(int a, int b) {
    int s = a ^ b, count = 0;
    while(s != 0) {
        count += s & 1;
        s >>>= 1;
    }
    return count;
}

对于该循环计算1数量的代码,即使是 0 位置我们也会循环,每次都要循环完 32 位。有一个Brian Kernighan优化算法,该算法使用 x 与 x-1 做与运算,得到的结果恰好为 x 删去其二进制表示中最右侧的 1 的结果。即循环中每次 x &= x-1 操作都会删去最右侧的一个 1,直到等于 0,循环的次数就是 1 的个数:

public static int hammingDist_opt(int a, int b) {
    int s = a ^ b, count = 0;
    while(s != 0) {
        s &= s - 1;
        count++;
    }
    return count;
}

布隆过滤器

布隆过滤器的误判率是可以预测的,与位数组的大小,以及 hash 函数的个数等相关。

假设位数组的大小是 m,一共有 k 个 hash 函数,那么每一个 hash 函数,只能 hash 到 m 位中的一个位置,所以某一位没有被 hash 到的概率是 $1-1/m$,k 个 hash 函数都 hash 之后,该位还是没有被 hash 到的概率为 $(1-1/m)^k$。

如果此时插入了 n 个元素,即位数组已经被 hash 了 n*k 次,该位还是没有被 hash 到的概率是 $(1-1/m)^{kn}$,那么该位已被 hash 变成 1 的概率为 $1-(1-1/m)^{kn}$。

如果需要检测某一个元素是不是在集合中,也就是该元素对应的 k 个位 hash 出来的值都为 1,即元素本不存在,但该元素对应的所有位都被 hash 变成 1 的概率是 $(1-(1-1/m)^{kn})^k$,当 m 很大时可近似为 $(1-e^{-kn/m})^k$。

所以,随着位数组大小 m 和 hash 函数个数的增加,误判率会下降,随着插入的元素 n 的增加,概率会上升。

最后还可以根据自己期待的误判率 P 和期待添加的个数 n,来大致计算出布隆过滤器的位数组的长度:$m=-\frac{n\ln P}{(\ln 2)^2}$。

下面实现一个简单的布隆过滤器,需要考虑的点:

  • 位数组空间大小,其他不变,位空间越大,hash 冲突的可能性越小。

  • hash 函数的实现,为避免冲突,应使用多个不同的质数来当种子。

  • 实现两个方法:添加元素和判断某元素是否存在。

下面用了简单的 hash 函数,主要是使用 hash 值得高低位进行异或,然后乘以种子,再对位数组大小进行取余数:

public class MyBloomFilter {
    private static final int DEFAULT_SIZE = Integer.MAX_VALUE; // 默认大小

    private static final int MIN_SIZE = 1000;

    private int SIZE = DEFAULT_SIZE;

    // hash函数的种子因子
    private static final int[] HASH_SEEDS = new int[]{3, 5, 7, 11, 13, 17, 19, 23, 29, 31};

    // 位数组,表示特征
    private BitSet bitSet = null;

    // hash 函数
    private HashFunction[] hashFunctions = new HashFunction[HASH_SEEDS.length];

    public MyBloomFilter() {
        init();
    }

    public MyBloomFilter(int size) {
        if(size > MIN_SIZE) {
            SIZE = size;
        }
        init();
    }

    private void init() {
        bitSet = new BitSet(SIZE);
        for(int i = 0; i < HASH_SEEDS.length; i++) {
            hashFunctions[i] = new HashFunction(SIZE, HASH_SEEDS[i]);
        }
    }

    public void add(Object value) {
        for(HashFunction hf : hashFunctions)
            bitSet.set(hf.hash(value)); // 将hash计算出来的位置为true
    }

    public boolean contains(Object value) {
        boolean result = true;
        for(HashFunction hf : hashFunctions) {
            result = bitSet.get(hf.hash(value));
            // hash函数只要有一个计算出为false,则直接返回
            if(!result)
                return result;
        }
        return result;
    }

    public static class HashFunction {
        int size;
        int seed;

        public HashFunction(int size, int seed) {
            this.size = size;
            this.seed = seed;
        }

        public int hash(Object value) {
            if(value == null)
                return 0;
            else {
                int hash1 = value.hashCode();
                int hash2 = hash1 >>> 16; // 高位的hash值
                int combine = hash1 ^ hash2; // 合并hash值(相当于把高低位的特征结合)
                return Math.abs(combine * seed) % size; // 相乘再求余
            }
        }
    }

    public static void main(String[] args) {
        Integer num1 = 12321;
        Integer num2 = 12345;
        MyBloomFilter myBloomFilter =new MyBloomFilter();
        System.out.println(myBloomFilter.contains(num1));
        System.out.println(myBloomFilter.contains(num2));

        myBloomFilter.add(num1);
        myBloomFilter.add(num2);

        System.out.println(myBloomFilter.contains(num1));
        System.out.println(myBloomFilter.contains(num2));
    }

    // BitSet 应用于找素数的例子
    private static List<Integer> primes(int n) {
        BitSet isPrime = new BitSet(n + 1);
        isPrime.set(2, n + 1); // 默认全部设为true
        // 埃拉托色尼筛法
        for(int i = 2; i * i <= n; i++) {
            if(isPrime.get(i)) {
                for(int j = i * i; j <= n; j += i)
                    isPrime.clear(j);
            }
        }
        List<Integer> res = new ArrayList<>();
        for(int i = 2; i <= n; i++) {
            if(isPrime.get(i))
                res.add(i);
        }
        return res;
    }
}
/* output
false
false
true
true  */