位运算

位运算算是leetcode各类算法里最奇妙的一种,它能解决的问题,一般来说,用其他方法也能解决,只是两种方法对比效率,位运算会优秀极多。同时,位运算的解题也折射出高中数学以及部分高等数学的思想。

位运算判断重复

位运算的一个最直接的应用就是高效地判断重复。原理很简单,或运算进行位数上的改变,与运行判断位数是否有改变。以面试题 01.01. 判定字符是否唯一 为例。这个题目算是位运算判断重复的最简单的板子题。解法可以形成套路:

        public boolean isUnique(String astr) {
        char[] cs = astr.toCharArray();
        int mask = 0;
        for(char c:cs){
            int temp = (mask >> (c-'a')) & 1;//与运算判断指定位置是否已经存在
            if(temp == 1)return false;
            mask |=(1 << (c-'a'));//或运算将制定位置设置为1
        }

        return true;
    }


这个套路可以继续拓展应用。leetcode有很多系列题目,有些很热门,比如打家劫舍系列,有些则较为冷僻,其中以只出现一次的数字系列最鲜为人知。这类题目基本都可以用hashset解决,所以即使题目暗示、明示了用位运算,实际解题者也都以set简单跳过。原因无他,只是位运算在面试中出现的可能性极小。
系列三个题目均可以用位运算解决,第一题最简单,数组依次亦或运算即可。第二题较难,利用了位运算解题常见的一种思路:把一个数字放在定长(往往是32)的二进制树上进行逐个位子的运算,最终得到结果。 第三题 260. 只出现一次的数字 III 难度要高出不少。前两题仅要求解出一个数字,这个题目要求解出2个数字。其实思路反而和第一个题目一致,即把数组分为2部分,分别亦或运算即可得到2个数字。不过能识别出两个数字的特征是难点。大部分位运算用到的是亦或。这一步却用到与运算。利用与运算找到第一个不同的位数(亦或运算完为1则说明不同,只要找到与1与运算不为0的那个位数即是亦或结果为1也就是2个数字不同的地方)。随后将整个数组依次进行与运算,得到0和1的即是两个子数组部分,逐位亦或即可。

                    int[] ans = new int[2];

        int k = 0;
        for (int num:nums)
            k ^= num;
        int div = 1;
        while ((div & k) == 0)
            div <<= 1;
        for(int num:nums){
            if ((num & div) == 0)
                ans[0] ^= num;
            else
                ans[1] ^= num;
        }

        return ans;

状态压缩

位运算的另外一大用途是状态压缩。
前缀和类的题目,以前缀和来解决,往往需要n^2的复杂度,在数据量比较大的情况下会超时,此时可以用位运算进行状态压缩进行优化。 318. 最大单词长度乘积 难度要高出不少。

public int maxProduct(String[] words) {
        int len = words.length;
        int[][] available = new int[len][26];
        for(int i=0;i<len;++i){
            char[] cs=words[i].toCharArray();
            for(char c:cs){
                available[i][c-'a']++;
            }
        }

        int ans = 0;

        for(int i=0;i<len-1;++i){
            char[] cs=words[i].toCharArray();
            loop:for(int j=i+1;j<len;++j){
                for(char c:cs){
                    if(available[j][c-'a']>0)continue loop;
                }
                ans = Math.max(words[i].length() * words[j].length(),ans);
            }
        }
        return ans;
    }

这个解法已经是常规优化的版本,数组的效率已经是常规情况下最高的。
用位运算则快很多:

   public int mask(String word){
        int ans = 0;
        char[] cs=word.toCharArray();
        for(char c:cs){
            ans |=(1 << (c-'a'));//或运算回避了重复添加的问题
        }

        return ans;

    }


    public int maxProductBit(String[] words) {
        int len = words.length;
        int[] available = new int[len];
        for(int i=0;i<len;++i)available[i] = mask(words[i]);

        int ans = 0;

        for(int i=0;i<len-1;++i){
            for(int j=i+1;j<len;++j){
                if((available[i] & available[j]) >0) continue;//与运算判断重复
                ans = Math.max(words[i].length() * words[j].length(),ans);
            }
        }
        return ans;
    }

1915. 最美子字符串的数目 则是状态压缩的更好运用。这个题目用传统的前缀和会超时,必须进行位运算优化:

 public long wonderfulSubstringsZip(String word) {
         int len = word.length();
         if(len==1)return 1;
         long ans = 0;
         int mask = 0;
 
         long[] freq = new long[1 << 10];
         freq[0] = 1;//相当于偶数初始化有1个
 
         char[] chars = word.toCharArray();
         for (int i = 0; i < len; i++) {
             mask ^= (1<<(chars[i]-'a'));//之前如果是奇数则现在变偶数
             ans += freq[mask];//之前的奇偶性完全一样的数量,即偶数(奇数-奇数=偶数;偶数-偶数=偶数),偶数为0
             for (int j = 0; j < 10; j++) {
                 ans += freq[mask ^ (1 << j)];//之前奇偶性差距一个的数量,允许差距1个
             }
             freq[mask]++;
 
         }
         return ans;
     }

同样的题目也有: 1542. 找出最长的超赞子字符串

完成任务的最少工作时间段 是 用二进制状态压缩来替代蛮力穷举法的最好应用。状态压缩从某种意义上来说,并没有改变编程的思路,只是将原有的思路用一种高效的方法来事件:
为了方便理解,可以将解题方法分成2个步骤,第一步是初始化数据,也是最难理解的部分:

  /**
     * 初始化
     * tasks是穷举所有可能的情况
     * 比如[1,2,3],可能是[1,0,0][1,1,0],[1,0,1]----[1,1,1]等7种情况的数组,压缩到二进制里就是7个数字,
     * 数组的长度可以直接用1>>3(即是8)来表示
     * @param tasks
     */
    public int[]  initialize(int[] tasks,int sessionTime){
        int len = tasks.length;
        int max = 1<<len;
        int[] dp = new int[max];
        final int INF = 20;//最大长度为14,故设置为20即可
        Arrays.fill(dp, INF);

        for (int i = 1; i < max; i++) {//比如i=2,为011
            int spend =0;
            int cur = i;int cur_index = 0;
            while (cur>0 && spend<=sessionTime){
                int temp_bit = cur &1;//取当前位置,如果是1,表示选中
                if(temp_bit==1)spend+=tasks[cur_index];
                cur>>=1;//查看下一个位置
                cur_index++;
            }
            if(spend<=sessionTime)dp[i] = 1;//一个时间周期可以做完,比如011,是第一个和第二个任务这种的,可以做完
        }

        return dp;


    }

第二步是状态转移。


public int minSessions(int[] tasks, int sessionTime) {

        int len = tasks.length;
        int max = 1<<len;
        int[] dp = initialize(tasks,sessionTime);
        for (int i = 1; i < max; i++) {
            if(dp[i]==1)continue;
            //一个周期无法覆盖的情况
            for (int j =i;j >0;j=i&(j-1)){
                dp[i] = Math.min(dp[i],dp[j] + dp[j^i]);
                //比如dp[1111] = dp[1001] + dp[0110],通过互补,全部选中
            }
        }
        return dp[max-1];//即1111

    }

可以看到,二进制主要是优化了第一步。第一步可以用二维数组进行蛮力处理,空间和效率都极高。用二进制将一个数组压缩成一个数字,二维数组压缩成一维数组,结合位运算的优势,效率和空间高出太多。同时,在第二步,位运算(异或)也可以更快地找到互补的数据。


Is this article useful to you? How about buy me a coffee ?