编程之美:位运算应用集萃

Bits are everything.


引子

位是程序世界的本源。一切软件计算不过是位的相加与复制。位向量(位的顺序数组)的空间效率以及位运算的高效,使得位运算深受资深程序员的喜爱。看见几行位运算,不由得会生出几分崇敬之情。本文来盘点一下位运算的应用。

基本操作

位的基本操作包括:

  • 位的检测与置位:检测位是否为 0 或 1 , 将某一位置 0 或 置 1。通常用于位向量中,可以用来排序或者过滤。
  • 与或非、异或。与或非运算通常用来实现掩码,获取某个或多个标识位;异或可用于计算、去重、交换。
  • 算术左移或算术右移,逻辑左移或逻辑右移:结合与或非运算,用于定位某个位。
public class BitOp {

    // 取 n 的第 m 位
    public static int getBit(int n, int m){
        return (n >> (m-1)) & 1;
    }

    // 将 n 的第 m  位置 1
    public static int setBitToOne(int n, int m){
        return n | (1 << (m-1));
    }

    // 将 n 的第 m 位置 0
    public static int setBitToZero(int n, int m){
        return n & ~(1 << (m-1));
    }

    public static String toBinaryString(int n) {
        StringBuilder s = new StringBuilder();
        for (int i=32; i >0; i--) {
            s.append(getBit(n, i));
        }
        return s.toString();
    }

    static class BitOpTester {
        public static void main(String[]args) {
            test(999);
            test(-999);
        }
    }

    public static void test(int n) {
        testUnit(n);
        int n1 = setBitToOne(n, 29);
        testUnit(n1);
        int n2 = setBitToZero(n, 6);
        testUnit(n2);
    }

    public static void testUnit(int num) {
        String standardBinaryStr = Integer.toBinaryString(num);
        System.out.println("Standard: " + standardBinaryStr);
        String myOwnBinaryStr = toBinaryString(num);
        System.out.println("My Own: " + myOwnBinaryStr);
        assert standardBinaryStr.equals(myOwnBinaryStr);
    }
}

获取不小于 cap 的 2 的幂次数(java.util.HashMap):

    /**
     * Returns a power of two size for the given target capacity.
     */
    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;
    }

状态位组合标识

位的最常见使用,莫过于状态位组合标识了。 每个位代表某个含义,而这些含义是可组合的。


文件权限

Linux 系统的文件读/写/执行权限,使用了三位来标识:

  • 读取权限: r = 100
  • 写入权限 w = 010
  • 执行权限 x = 001

Java 文件读写权限封装在 FilePermission 这个类里。获取 mask 对应的读写执行权限描述在 getActions 这个方法里。

public final class FilePermission extends Permission implements Serializable {

    private final static int EXECUTE = 0x1;
    private final static int WRITE   = 0x2;
    private final static int READ    = 0x4;
    private final static int DELETE  = 0x8;
    private final static int READLINK    = 0x10;
    private final static int NONE    = 0x0;

    private transient int mask;

    private final static int ALL     = READ|WRITE|EXECUTE|DELETE|READLINK;

方法标识

Java 方法的修饰符标识是采用位来标识。

    public static final int PUBLIC           = 0x00000001;
    public static final int PRIVATE          = 0x00000002;
    public static final int PROTECTED        = 0x00000004;
    public static final int STATIC           = 0x00000008;
    public static final int FINAL            = 0x00000010;
    public static final int SYNCHRONIZED     = 0x00000020;
    public static final int VOLATILE         = 0x00000040;
    public static final int TRANSIENT        = 0x00000080;
    public static final int NATIVE           = 0x00000100;
    public static final int INTERFACE        = 0x00000200;
    public static final int ABSTRACT         = 0x00000400;
    public static final int STRICT           = 0x00000800;

要知道某位是否存在,可以使用与运算,因为与运算有检测位的作用。

    public static boolean isStrict(int mod) {
        return (mod & STRICT) != 0;
    }

要知道有哪些组合含义,可以使用或运算,因为或运算有保留位的作用。

   /**
     * The Java source modifiers that can be applied to a class.
     * @jls 8.1.1 Class Modifiers
     */
    private static final int CLASS_MODIFIERS =
        Modifier.PUBLIC         | Modifier.PROTECTED    | Modifier.PRIVATE |
        Modifier.ABSTRACT       | Modifier.STATIC       | Modifier.FINAL   |
        Modifier.STRICT;

联合或运算与左移或右移操作,可以用来对多个位进行拼接,比如 SnowFlake 算法里的实现(可参阅 “Snowflake Java”):


    /**
     * 起始的时间戳
     */
    private final static long START_STMP = 1480166465631L;

    /**
     * 每一部分占用的位数
     */
    private final static long SEQUENCE_BIT = 12; //序列号占用的位数
    private final static long MACHINE_BIT = 5;   //机器标识占用的位数
    private final static long DATACENTER_BIT = 5;//数据中心占用的位数

    /**
     * 每一部分的最大值
     */
    private final static long MAX_DATACENTER_NUM = -1L ^ (-1L << DATACENTER_BIT);
    private final static long MAX_MACHINE_NUM = -1L ^ (-1L << MACHINE_BIT);
    private final static long MAX_SEQUENCE = -1L ^ (-1L << SEQUENCE_BIT);

    /**
     * 每一部分向左的位移
     */
    private final static long MACHINE_LEFT = SEQUENCE_BIT;
    private final static long DATACENTER_LEFT = SEQUENCE_BIT + MACHINE_BIT;
    private final static long TIMESTMP_LEFT = DATACENTER_LEFT + DATACENTER_BIT;

    private long datacenterId;  //数据中心
    private long machineId;     //机器标识
    private long sequence = 0L; //序列号
    private long lastStmp = -1L;//上一次时间戳
   /**
     * 产生下一个ID
     *
     * @return
     */
    public synchronized long nextId() {
        long currStmp = getNewstmp();
        if (currStmp < lastStmp) {
            throw new RuntimeException("Clock moved backwards.  Refusing to generate id");
        }

        if (currStmp == lastStmp) {
            //相同毫秒内,序列号自增
            sequence = (sequence + 1) & MAX_SEQUENCE;
            //同一毫秒的序列数已经达到最大
            if (sequence == 0L) {
                currStmp = getNextMill();
            }
        } else {
            //不同毫秒内,序列号置为0
            sequence = 0L;
        }

        lastStmp = currStmp;

        return (currStmp - START_STMP) << TIMESTMP_LEFT //时间戳部分
                | datacenterId << DATACENTER_LEFT       //数据中心部分
                | machineId << MACHINE_LEFT             //机器标识部分
                | sequence;                             //序列号部分
    }


标签

在应用程序里,常常用位的枚举来实现标签作用。

打包数据

比如 Java 线程池实现 ThreadPoolExecutor 将线程池状态和工作线程数打包在一个整数里。

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
    private static final int COUNT_BITS = Integer.SIZE - 3;
    private static final int CAPACITY   = (1 << COUNT_BITS) - 1;

    // runState is stored in the high-order bits
    private static final int RUNNING    = -1 << COUNT_BITS;
    private static final int SHUTDOWN   =  0 << COUNT_BITS;
    private static final int STOP       =  1 << COUNT_BITS;
    private static final int TIDYING    =  2 << COUNT_BITS;
    private static final int TERMINATED =  3 << COUNT_BITS;

    // Packing and unpacking ctl
    private static int runStateOf(int c)     { return c & ~CAPACITY; }
    private static int workerCountOf(int c)  { return c & CAPACITY; }
    private static int ctlOf(int rs, int wc) { return rs | wc; }


检测存在性

位图排序

位图可以将大量 key 映射到一个占用空间较少的位向量上,从而根据位向量来判断 key 的存在性。一个典型应用是,可以用来对稠密的很大的不重复整数列表进行排序,可节省不少空间。位图实现可阅 “位图排序(位图技术应用)”

由于 Redis 提供了位数组功能,实现不重复数组排序的代码如下所示:

    public void sort(int[] list) {
        for (int i: list) {
           jedis.setbit("arr", i, true);
        }
        int max = max(list);
        for (int i=0; i <= max; i++) {
            if (jedis.getbit("arr", i)) {
                System.out.printf(String.format("%d ", i));
            }
        }
    }

布隆过滤器

可以使用位运算来实现布隆过滤器。可以确定性地判断某个 key 不在某个集合里。布隆过滤器使用多个哈希函数,将 key 映射在多个位上(将相应位置一)。查找 key 时,如果发现某个位为 0 ,则表示该 key 不存在。

com.google.common.hash 包下的 BloomFilter 和 BloomFilterStrategies 给出了一个实现。BloomFilter 主要是起封装和入口作用,而 BloomFilterStrategies 是核心实现。注意到,由于 bitArray 是 long 型数组,因此使用 data[index >> 6] & (1L << index) 来获取 index 在这个 long 型数组所代表的位向量中的位置。这里给出了在一个整数数组代表的位向量里如何定位某个位的技巧。

enum BloomFilterStrategies implements BloomFilter.Strategy {
  /**
   * See "Less Hashing, Same Performance: Building a Better Bloom Filter" by Adam Kirsch and
   * Michael Mitzenmacher. The paper argues that this trick doesn't significantly deteriorate the
   * performance of a Bloom filter (yet only needs two 32bit hash functions).
   */
  MURMUR128_MITZ_32() {
    @Override public <T> boolean put(T object, Funnel<? super T> funnel,
        int numHashFunctions, BitArray bits) {
      long hash64 = Hashing.murmur3_128().hashObject(object, funnel).asLong();
      int hash1 = (int) hash64;
      int hash2 = (int) (hash64 >>> 32);
      boolean bitsChanged = false;
      for (int i = 1; i <= numHashFunctions; i++) {
        int nextHash = hash1 + i * hash2;
        if (nextHash < 0) {
          nextHash = ~nextHash;
        }
        bitsChanged |= bits.set(nextHash % bits.bitSize());
      }
      return bitsChanged;
    }

    @Override public <T> boolean mightContain(T object, Funnel<? super T> funnel,
        int numHashFunctions, BitArray bits) {
      long hash64 = Hashing.murmur3_128().hashObject(object, funnel).asLong();
      int hash1 = (int) hash64;
      int hash2 = (int) (hash64 >>> 32);
      for (int i = 1; i <= numHashFunctions; i++) {
        int nextHash = hash1 + i * hash2;
        if (nextHash < 0) {
          nextHash = ~nextHash;
        }
        if (!bits.get(nextHash % bits.bitSize())) {
          return false;
        }
      }
      return true;
    }
  };

  // Note: We use this instead of java.util.BitSet because we need access to the long[] data field
  static class BitArray {
    final long[] data;
    int bitCount;

    BitArray(long bits) {
      this(new long[Ints.checkedCast(LongMath.divide(bits, 64, RoundingMode.CEILING))]);
    }

    // Used by serialization
    BitArray(long[] data) {
      checkArgument(data.length > 0, "data length is zero!");
      this.data = data;
      int bitCount = 0;
      for (long value : data) {
        bitCount += Long.bitCount(value);
      }
      this.bitCount = bitCount;
    }

    /** Returns true if the bit changed value. */
    boolean set(int index) {
      if (!get(index)) {
        data[index >> 6] |= (1L << index);
        bitCount++;
        return true;
      }
      return false;
    }

    boolean get(int index) {
      return (data[index >> 6] & (1L << index)) != 0;
    }

    /** Number of bits */
    int bitSize() {
      return data.length * Long.SIZE;
    }

    /** Number of set bits (1s) */
    int bitCount() {
      return bitCount;
    }

    BitArray copy() {
      return new BitArray(data.clone());
    }

    /** Combines the two BitArrays using bitwise OR. */
    void putAll(BitArray array) {
      checkArgument(data.length == array.data.length,
          "BitArrays must be of equal length (%s != %s)", data.length, array.data.length);
      bitCount = 0;
      for (int i = 0; i < data.length; i++) {
        data[i] |= array.data[i];
        bitCount += Long.bitCount(data[i]);
      }
    }

    @Override public boolean equals(Object o) {
      if (o instanceof BitArray) {
        BitArray bitArray = (BitArray) o;
        return Arrays.equals(data, bitArray.data);
      }
      return false;
    }

    @Override public int hashCode() {
      return Arrays.hashCode(data);
    }
  }
}

哈希与取模

由于位运算非常高效,通常都会用在哈希与取模运算中。比如 HashMap 的哈希值映射:

hash = (h = key.hashCode()) ^ (h >>> 16);   // 高位与低位异或,充分使用到高位和低位
index = (n - 1) & hash;  // n = 2^m

异或运算用在 FNV 哈希算法里。可以在 GitHub 里搜索 FNV 得到各种语言的实现。 FNV 的一个 Java 实现可参见: “fnv-java”

去重与交换

异或操作符合交换律、结合律和自反性质,可用于去重和交换。 a^a = 0, a^0 = a , abc = a(bc) = a(cb)。 这使得异或有一些妙用。

一个经典的面试题是:假如你有一个用 1001 个整数组成的数组,这些整数是任意排列的,但是你知道所有的整数都在 1 到 1000 之间(包括 1000 )、此外,除了一个数字出现两次外,其他的数字只出现了一次。使用异或的解法是:先将所有数异或一遍,再依次异或 1-1000 的数即可。 用个简单的例子,假设含有 [1,2,3,2] ,那么方法是: 1232123 = 2 重复数就是 2.

一个变形的面试题是:一个数组存放若干整数,一个数出现奇数次,其余数均出现偶数次,找出这个出现奇数次的数?实际上道出了上一题的本质。

异或可以用来交换两个变量。

a=a^b;  b=a^b;  a=a^b;

科学计算

补码

使用位运算实现科学计算,即是从位的角度来分析数值计算。绝大多数机器上,整数是通过补码来表示的。补码的定义如下:


加法

异或可以实现无进位加法。对于有进位的加法,可以分解为两部分:

  • 无进位的部分:使用异或运算 a^b = c ;
  • 有进位的部分:使用 (a & b) << 1 可以得到进位的和 d ;
  • 相加:将 c 和 d 相加,这时候会递归调用加法运算。

如下代码所示:


// 位运算实现加法
int bitAdd(int a,int b)
{
    if(b==0)
        return a;
    int sum = a^b;
    int carry =(a&b)<<1;
    return bitAdd(sum,carry);
}

对于平均值的计算,思路类似。只是进位 d 除以 2 正好等于 a & b。


    // 平均值
    public static int avg(int a, int b) {
        return ((a ^ b) >> 1) + (a & b);
    }

其它使用位运算进行一些计算的例子如下(由于字长是有限的,计算机表示的整数范围是有限的,因此计算要谨防溢出。在根据加减运算来判断大小时,也要谨防溢出导致的错误):

(x ^ y) >= 0;  // 判断两个数的符号是否相同(实际上是高位异或,如果相同就是 0 ,不同就是 1)
x & 1 == 0 ?  false : true;  // 判断奇偶,即判断最低位是 0 还是 1 
x & (x-1) ;  // 可以去掉最右边的 1 ,计算某个数有多少个二进制位 1 

    // 绝对值,当取最小负整数时没有对应绝对值。
    public static int abs(int n) {
        return (n ^ (n >> 31)) - (n >> 31);
    }

    public static int max(int a, int b) {
        if ((a^b) > 0) {
            // 同符号时 a-b 不会溢出,避免错误
            return b & ((a-b) >> 31) | a & (~(a-b) >> 31);
        }
        return ((a ^ (1 << 31)) < 0) ? a : b;   // 不同符号取非负即可
    }

    public static int min(int a, int b) {
        if ((a^b) > 0) {
            // 同符号时 a-b 不会溢出,避免错误
            return a & ((a-b) >> 31) | b & (~(a-b) >> 31);
        }
        return ((a ^ (1 << 31)) >= 0) ? a : b;    // 不同符号取负即可
    }

    // 判断正负,即判断最高位是 0 还是 1
    public static boolean isNotNegative(int x) {
        return (x ^ (1 << 31)) >= 0 ? false : true;
    }


小结

位是一切程序的起点。位运算的使用主要体现在位向量和位运算的高效上,广泛应用于科学计算、哈希计算、存在性检测、状态位组合标识等场景。掌握位运算,就像携带了一把匕首,能够有效解决不少问题。


参考资料


posted @ 2020-11-23 18:33  琴水玉  阅读(285)  评论(0编辑  收藏  举报