BitWise-Operation

本文介绍了几个关于位运算的编程题目,包括检查尾随零、最小翻转次数、最长子数组、数组分割成子数组问题、优雅子数组以及最大或值的计算。作者提供了多种解决方案,涉及数据结构(哈希表)、优化策略(贪心和动态规划)和时间复杂度分析。
摘要由CSDN通过智能技术生成

之前有个《位运算trick》的文章,也是讲位运算的。不过最近灵神发了个位运算的题单。这篇就按题单来了。

一、基础题

1. LC 3145 大数组元素的乘积

2879分,这把高端局。灵神题解:

. - 力扣(LeetCode)

乘积转换为幂次和

由于bigNums中的元素都是2的幂次,而需要计算的是乘积,也就相当于求幂次之和(底为2)。

bigNums 的幂次数组为

[0] + [1] + [0,1] + [2] + [0,2] + [1,2] + [0,1,2] + [3] + ......

其中每个小数组内的是 1,2,3,4,5,6,7,8,⋯ 对应的强数组的幂次。

此时前缀积转变为前缀和+快速幂。

k个幂次的和-通项公式

现在相当于:前k1个幂次和 减去 前k2个幂次和。

而观察上面这个幂次数组,他是没有什么规律的。所以我们需要推出来一个能表示前k个幂次和的通项公式来。

k幂次对应的强数组

不难发现,数字n的强数组其实就是它的二进制表示。1个二进制的1就代表了1个幂次。所以我们要算的就是从

[2^0,n-1]

有多少个1。

那么[1,n-1]内有几个二进制的1呢?不妨来看一个例子:(此时i=4)

数字二进制1个数数位长度
000
111
1012
1122
10013
10123
11023
11133
数字二进制1个数数位长度
100014
100124
101024
101134
110024
110134
111034
111144

我们按照数位长度对这些数字进行划分。可以发现,数位长度为4的数字,本质上是数位长度≤3的所有数字的1的个数加上1得到的(也即最高位的1),例如:

one(1000) = 1 + one(000) (这里我补了前导零)

也即:

[1,2,2,3,2,3,3,4] = 1 .+ [0,1,1,2,1,2,2,3]

可以看到,后面8个1的个数的数组,就是前面8个1的个数的数组对应位置的数字+1得到的,加的这个1就是最高位新来的1。

那么怎么计算呢?定义:

f_i

为数位长度为i的组二进制1的个数。那么:

f_i = 2^{i-1} + \sum_{j=1}^{i-1} f_j

前面的2^(i-1)是前面的每个数对应+1产生的增量。后面的当然就是前面所有的二进制1的个数的和。

同时,有:

f_0 = 0 \\ f_1 = 1

定义:

ones_i

为数位长度≤i的组的二进制1的个数,那么:

ones_i = \sum_{j=1}^{i} f_j \\ = 2^{i} - 1 + \sum_{j=1}^{i-1} S_j\cdot\cdot\cdot\cdot\cdot\cdot(1)

同理可得:

ones_{i-1} = 2^{i-1} - 1 + \sum_{j=1}^{i-2} S_j\cdot\cdot\cdot\cdot\cdot\cdot(2)

由(1)、(2)式可得:

(1)-(2) \\ \Leftrightarrow \\ ones_i - ones_{i-1} = 2^{i-1} + ones_i-1 \\ \Leftrightarrow \\ ones_i = 2^{i-1} + 2*ones_{i-1}\cdot\cdot\cdot\cdot\cdot\cdot(3)

(3)式两边同时除以2^i可得:

\frac{ones_i}{2^i} = \frac{1}{2} + \frac{ones_{i-1}}{2^{i-1}}

设:

B_i = \frac{ones_i}{2^i}

此时:

B_1 = \frac{ones_1}{2^1} = \frac{1}{2}

且:

B_i = \frac{1}{2} + B_{i-1}

{Bi}为等差数列,则:

B_i = \frac{i}{2} \\ \Leftrightarrow \\ \frac{ones_i}{2^i} = \frac{i}{2}

所以:

ones_i = i*2^{i-1}

此时我们希望找一个最大的n,使得ones(n)≤k(如果<k,多出来的部分让后面的强数组去补)

n为2的幂次的情况

先对于一个比较简单的情况,也即存在i≥0,有:

n = 2^i

举个例子,当i=2,则[0,3]中,有1+1+2=4个1,也就是4个幂次。

试填法

举个例子,设k=10d = 1010b

整的
  1. 首先我们不可能在i=4的位置上填1,否则即便每个数只算1个1,那也有15个1了,已经把10给爆了。
  2. i=3:此时ones(3) = 3*2^(3-1)=12>10。爆了。
  3. i=2:此时ones(2) = 2*2^(2-1) =4≤10,可以填1。

那么i=2是我们能找到的最大的”组数“,2组可以,3组爆掉。相当于upper_bound。

从第二组开始,之后的第三组需要挑选一小部分来补满10。

零的

此时n = 2^i = 2^2 = 4d = 100b。如果在从低到高第2位上填1,那么为110b = 6d。那么此时相当于多出来了6-4+1=3或者说2^1-0+1=3个数字。具体来说就是[4,6]。那么此时多了多少个1呢?

由于高位始终为1,那么就是3*1=3个。低位上面,从00到10,总共2个。则高低位总计3+2=5个,和之前的ones(2)加起来是4+5=9个,≤10。继续。

如果再在从低到高第1位上填1,那么为111b = 7d。这个时候首先多了高位的1,低位方面从10到了11,多了2个1,那么高低位总计3个1。此时9+3=12>10,不行,所以仅缺的1个幂次,从7的低位抽出来补。也即111b的从低到高的第1位。

在具体计算方式上,可以发现,零的部分的新增幂次个数,等于高位1的个数乘以低位区间长度,再加上低位套用幂次个数公式得到的1的个数。

幂次和计算

当我们有了整的(强数组)和零的幂次个数,就可以计算幂次和了。

n为2的幂次的情况

对于整的,也即n=2^i,对应前i个强数组。定义:

a_i

为第i个强数组内含有的幂次和。这里还是举之前的例子。

数字幂次和数位长度
000
101
1012
1112
10023
10123
11033
11133
数字幂次和数位长度
100034
100134
101044
101144
110054
110154
111064
111164

本质上也是之前所有组的对应的幂次和加i(高位填1增加了i次方)。

也即:

a_i = (i-1)*2^{i-1} + \sum_{j=1}^{i-1}a_j

定义:

s_i

为前i个强数组内含有的幂次和。

则:

s_i = \sum_{j=1}^{i} a_i \\ = \sum_{j=0}^{i-1} j*2^{j} + \sum_{j=1}^{i-1} s_j

设:

p_i = \sum_{j=0}^{i-1} j*2^{j} \cdot\cdot\cdot\cdot\cdot\cdot(3)

则:

2p_i= \sum_{j=1}^{i} j*2^j \cdot\cdot\cdot\cdot\cdot\cdot(4)\\ (4)-(3)\\ \Leftrightarrow \\ p_i = (i-1)*2^{i} - \sum_{j=2}^{i-1} 2^{j-1} \\ = (i-1)*2^{i} - 2^{i} + 4

所以:

s_i = (i-1)*2^{i} - 2^{i} + 4 + \sum_{j=1}^{i-1} s_j \cdot\cdot\cdot\cdot\cdot\cdot(5)\\ s_{i-1} = (i-1-1)*2^{i-1} - 2^{i-1} + 4 + \sum_{j=1}^{i-1-1}s_j \cdot\cdot\cdot\cdot\cdot\cdot(6)\\ (5)-(6) \\ \Leftrightarrow \\ s_i - s_{i-1} = (i-1)*2^{i-2} + s_{i-1} \\ \Leftrightarrow \\ s_i = (i-1)2^{i-1} + 2s_{i-1} \cdot\cdot\cdot\cdot\cdot\cdot(7)

对于(7),两边同时除以2^i可得:

\frac{s_i}{2^i} = \frac{i-1}{2} + \frac{s_{i-1}}{2^{i-1}}

设:

C_i = \frac{s_i}{2^i}\\ C_1 = 0

则有:

C_i - C_{i-1} = \frac{i-1}{2}

则:

\begin{cases} C_i - C_{i-1} = \frac{i-1}{2}\\ C_{i-1} - C_{i-2} = \frac{i-2}{2}\\ ......\\ C_2 - C_1 = \frac{1}{2} \end{cases}

累加得:

C_i - C_1 = \frac{i*(i-1)}{2} \\ \Leftrightarrow \\ C_i = \frac{i*(i-1)}{2}

于是:

s_i = \frac{i*(i-1)}{2} * 2^i

对于零的,同样可以利用试填法计算带来的幂次和的增量。比方说我们的幂次个数是k=16,定位到了n=10的从低到高第2位上面的1。前面i≤3的强数组全用了,1000和1001也全上了。

此时我们直接套公式,i=3,si=12,此时n=8。目标n=10。

  1. 高位上面多了1000b-1001b这么些幂次和,总共2^0-0+1=2个。幂次为(i-1)也即3。2*3=6。
  2. 低位上面多了000b-001b这么些幂次和,总共1个,幂次为0,1*0=0。
  3. 最后一个n=10多了个010b,总共1个,幂次为1,1*1=1。

则高低位总计多出6+0+1=7个。零的整的总计有12+7=19。也即2**19。

实现

在具体实现上,我们可以通过试填k的各个数位,并套用幂次个数公式,来计算完整强数组带来的幂次和增量:

  1. 每次计算增量
  2. 若这个增量≤k,那么更新k、总幂次和,高位幂次和以及高位1的数量,还有n(这是因为我们要求出最后的那个n,然后不停low_bit来补全剩下的k所需要的幂次个数)
  3. 注意最低位是第一组,但是它的索引是0(也即2^0),所以套i*2^i公式并不适合,因为这样幂次个数增量为0。因此需要单独处理。显然,最低位的区间长度是1(注意0里面没有二进制1不能算),这样对于幂次之和的增量就是当前高位幂次和。、
  4. 对于余下的幂次个数k,就用当前最后的n的低位去补。Lowbit技巧(x&(-x)),不断Lowbit直到凑够数为止。
  5. 记得改一下快速幂板子适配取模。

(甚至还记忆化了一下

from typing import List
from functools import cache

class Solution:
    def findProductsOfElements(self, queries: List[List[int]]) -> List[int]:
        
        @cache
        def sum_pow(k:int)->int:
            res,sprev,cnt,bicon = 0,0,0,0
            
            """
            索引0单独处理
            """
            for i in range(k.bit_length()-1,0,-1):

                """
                高位1个数乘以区间长度得到高位幂次个数增量
                i*2^(i-1)公式得到低位幂次个数增量
                """
                diff = (cnt<<i) + (i<<(i-1))
                
                if diff<=k:
                    k -= diff
                    """
                    高位1幂次和乘以区间长度得到高位幂次和增量
                    i*(i-1)/2 * 2^(i-1)公式得到低位幂次和增量
                    """
                    res += (sprev<<i) + ((i*(i-1)//2)<<(i-1))
                    sprev += i
                    cnt += 1
                    bicon |= 1<<i # 试填成功
            
            """
            最低位单独处理
            区间长度为1
            高位幂次个数更新在cnt中
            高位幂次和更新在sprev中
            """
            if cnt <= k:
                k -= cnt
                res += sprev
                bicon |= 1
            
            """
            余量从n的lowbit里面补
            """
            for _ in range(k):
                low_bit = bicon&(-bicon)
                res += low_bit.bit_length()-1 # 幂次从0开始
                bicon ^= low_bit # n的这一位为1,现在要征用,以便得到下一个low_bit,因此2个1异或下改成0
            
            return res
        
        def fast_pow(a:int,b:int,mod:int)->int:
            res = 1
            while b:
                if b&1:
                   res = (res*a)%mod
                a = (a*a)%mod
                b >>= 1
            return res%mod
        
        ans = []
        for f,t,m in queries:
            t = t+1
            ans.append(
                fast_pow(2,sum_pow(t)-sum_pow(f),m)
            )
        
        return ans

二、&/|

1. LC 2980 检查按位或是否存在尾随零

查是否能有至少两个偶数即可。

class Solution {
    public boolean hasTrailingZeros(int[] nums) {
        int cnt = 0;
        for (int num : nums) {
            if(num%2==0){
                cnt++;
                
                if(cnt==2){
                    return true;
                }
            }
        }
        
        return false;
    }
}

2. LC 1318 或运算的最小翻转次数

这题就给了1383分,但我写得巨抽象。

开了俩哈希表记录每个位置上的1的出现次数,如果a|b和c的对应哈希表中某个位置上的1的个数有且仅有一个为0,那么就查谁是0,a|b的是0那就改一次变成1就行;否则得改cnt1[i]次变成0。

class Solution {
    public int minFlips(int a, int b, int c) {
        int[] cnt1 = new int[32];
        int[] cnt2 = new int[32];

        updateCnt(a,cnt1);
        updateCnt(b,cnt1);
        updateCnt(c,cnt2);

        int ans = 0;
        for (int i = 0; i < 32; i++) {
            if((cnt1[i]*cnt2[i]==0)&&(cnt1[i]+cnt2[i]!=0)){
                if(cnt1[i]==0){
                    ans++;
                }else{
                    ans+=cnt1[i];
                }
            }
        }

        return ans;
    }

    private void updateCnt(int num, int[] cnt){
        int i = 0;
        while(num>0){
            cnt[i++]+=(num&1);
            num>>>=1;
        }
    }
}

看着写了一坨代码,实际上是32*4=128次常数次操作。O(1)的。

3. LC 2419 按位与最大的最长子数组

毁了,这题乱套公式常数时间垫底把自己道心干碎了。

总体来说&和|这俩都是越操作越xx的,比如&就是大,|就是小。所以照旧维护出现过的&值,并维护最小的左边界。遇到更大的&值就更新最大值和长度,遇到相同的就更新长度。

class Solution {
    public int longestSubarray(int[] nums) {
        int[][] and = new int[32][2];

        int len = 0;
        int max = 0;
        int ans = 1;
        for (int i = 0; i < nums.length; i++) {
            and[len][0] = Integer.MAX_VALUE;
            and[len++][1] = i;

            int j = 0;
            for (int k = 0; k < len; k++) {
                and[k][0] &= nums[i];

                if(and[j][0]!=and[k][0]){
                    and[++j][0] = and[k][0];
                    and[j][1] = and[k][1];
                }else{
                    and[j][1] = Math.min(and[j][1],and[k][1]);
                }

                if(max==and[j][0]){
                    ans = Math.max(ans,i-and[j][1]+1);
                }else if(max<and[j][0]){
                    max = and[j][0];
                    ans = 1;
                }
            }

            len = j+1;
        }

        return ans;
    }
}

但其实都看到越&越小的性质了,这个问题就变成了找最大值的连续串的最大长度的问题了。

class Solution {
    public int longestSubarray(int[] nums) {
        int max = 0;
        int ans = 0;
        int len = 0;

        for (int num : nums) {
            if(num>max){
                max = num;
                len = ans = 1;
            }else if(num==max){
                len++;
                ans = Math.max(ans,len);
            }else{
                len = 0;
            }
        }

        return ans;
    }
}

其实复杂度都是O(n),但后者常数好。

4. LC 2871 将数组分割成最多数目的子数组

先来说一个我的比较朴素的思路:

因为越&越小,所以我们先看一下整个数组&完的那个值是多少。假设&完为target,那么如果target不为0,说明答案肯定是1。因为这代表了任何一个子数组的分数不为0,所以但凡分割成超过一个子数组,那分数和一定到不了最小。

如果为0,就可以分组循环尝试把位与的值降到≤target。可能会担心说前面的都能降到≤target,后面的降不到怎么办?很简单,合并到前面的就可以了。

class Solution {
    public int maxSubarrays(int[] nums) {
        int target = Integer.MAX_VALUE;
        for (int num : nums) {
            target &= num;
        }

        if(target!=0){
            return 1;
        }

        int i = 0;
        int n = nums.length;
        int ans = 0;
        while(i<n){
            int tmp = Integer.MAX_VALUE;
            while(i<n&&tmp>target){
                tmp &= nums[i];
                i++;
            }
            ans++;
            if(i==n&&tmp>target){
                ans--;
            }
        }

        return ans;
    }
}

然后就有个优化的写法。你不是位与等于0才能下一段分组吗?我上来直接对全1串位与,如果遇到0给答案增一,不是0就直接一直往下跑,到最后返回答案就行,如果整个数组位与完都不是0,那么就返回1。

class Solution {
    public int maxSubarrays(int[] nums) {
        int a = -1;
        int ans = 0;
        for (int num : nums) {
            a &= num;
            if (a == 0) {
                ans++;
                a = -1;
            }
        }
        return Math.max(ans, 1);
    }
}

这里利用-1的补码是全1串,所以令a=-1。

这俩方法复杂度都是O(n),但是后面的常数时间好。前面的思路更加清晰。

5. LC 2401 最长优雅子数组

这题我的想法一开始是分组循环。这里对任意两个元素位与为0的优化是利用一个bitmap来存储各个位是否是1,这样新来的数是否和之前的任意一个数位与不为0等价于他和这个bitmap位与不为0。但是这个和分组循环不同的是,你nums[i:j]是一个组的话,不代表nums[i+1:j+x]不是一个合法的组。所以外层循环的时候要把i=j改成i++。

class Solution {
    public int longestNiceSubarray(int[] nums) {
        int ans = 1;
        int i = 0;
        int n = nums.length;

        while(i<n){
            int j = i+1;
            int bitmap = nums[i];
            while(j<n){
                if((bitmap&nums[j])!=0){
                    break;
                }else{
                    bitmap |= nums[j];
                }
                j++;
            }

            ans = Math.max(j-i,ans);
            i++;
        }

        return ans;
    }
}

这样最坏其实是O(n²)的,按1e5基本上是T了,但不知道这题为啥没卡。

比较好的写法是,不定长的滑动窗口,维护一个左边界一个右边界,如果bitmap和新来的位与不为0就增加左边界。然后增加右边界,这样滑窗就行。

可能会担心一个位上有多个数的1,这不用担心,因为每次我们都是确保了和新来的位与不为0才把新来的|到bitmap的,所以不可能有多个重复的1。

class Solution {
    public int longestNiceSubarray(int[] nums) {
        int ans = 0;
        for (int left = 0, right = 0, or = 0; right < nums.length; right++) {
            while ((or & nums[right]) > 0) // 有交集
                or ^= nums[left++]; // 从 or 中去掉集合 nums[left]
            or |= nums[right]; // 把集合 nums[right] 并入 or 中
            ans = Math.max(ans, right - left + 1);
        }
        return ans;
    }
}

6. LC 2680 最大或值

这个是个贪心,但也不太明显。把左移机会全给一个数才有可能达到或值最大。这样维护前后缀或值然后枚举左移k次的数就可以。

class Solution {
    public long maximumOr(int[] nums, int k) {
        int n = nums.length;
        if(n==1){
            return ((long)nums[0])<<k;
        }
        long[] prefix = new long[n];
        long[] suffix = new long[n];

        prefix[0] = nums[0];
        suffix[n-1] = nums[n-1];

        for (int i = 1; i < n; i++) {
            prefix[i] = prefix[i-1]|nums[i];
        }

        for (int i = n-2; i >= 0; i--) {
            suffix[i] = suffix[i+1]|nums[i];
        }

        long ans = Math.max(((long)nums[0]<<k)|suffix[1],((long)nums[n-1]<<k)|prefix[n-2]);
        for (int i = 1; i < n-1; i++) {
            ans = Math.max(ans, ((long) nums[i] <<k)|prefix[i-1]|suffix[i+1]);
        }

        return ans;
    }
}

假设存在序列I={i1,i2,i3,…,in},其中∑I=k,且ip≥0(1≤p≤n)。那么把k分散到这n个数上,一定比不上对n个数中最大的左移k次或完的值要大,因为从0-1串的长度上来说已经输了。

这题有人用DP做的,他定义f[i][j]表示nums[0:i]个数用掉j次左移能得到的最大值。那么f[n-1][k]就是答案,状态转移方程为:

import static java.lang.Math.max;

class Solution {
    public long maximumOr(int[] nums, int k) {
        int n = nums.length;
        if(n==1){
            return ((long)nums[0])<<k;
        }

        long[][] f = new long[n][k + 1];

        for (int i = 0; i <= k; i++) {
            f[0][i] = ((long) nums[0]) << i;
        }

				// 状态转移方程
        for (int i = 1; i < n; i++) {
            for (int j = 0; j <= k; j++) {
                for (int z = 0; z <= j; z++) {
                    f[i][j] = max(f[i][j], f[i - 1][z] | (((long) nums[i]) << (j - z)));
                }
            }
        }

        return f[n-1][k];
    }
}

这个做法假了。例如:

[10,8,4]

按他的做法来说,前两个数用掉0次左移可以最大得到10,用掉1次左移可以最大得到28(20|8),但是实际上的最大是(10|16|4),而不是(20|8|4)。这个状态转移是错的。基于当前的最大不一定能得到下一次的最大。

7. LC 2411 按位或最大的最小子数组长度

跟LC 3097差不多。不过我这个选择的是倒着遍历的。因为他要求最大么,正着遍历的话不知道后面是否会出现更大的,但是倒着遍历就把后面元素全记录了,这样就是O(n)了。

大致思路如下:

  1. 倒着遍历元素,记录每一个或值及能够达成这个或值的最小右边界
  2. 把当前元素和记录的或值全部或起来,原地去重
  3. 或的同时记录最大值,查询这个最大值对应的右边界r,r-i+1即为ans[i]
class Solution {
    public int[] smallestSubarrays(int[] nums) {
        int n = nums.length;

        int[][] ors = new int[32][2];
        int m = 0;
        int[] ans = new int[n];

        for (int i = n-1; i >= 0; i--) {
            ors[m][0] = 0;
            ors[m++][1] = i;

            int j = 0;
            int max = 0;
            for (int k = 0; k < m; k++) {
                ors[k][0] |= nums[i];

                if(ors[k][0]!=ors[j][0]){
                    ors[++j][0] = ors[k][0];
                }

                ors[j][1] = ors[k][1];
                if(ors[max][0]<ors[j][0]){
                    max = j;
                }
            }

            m = j+1;

            ans[i] = ors[max][1]-i+1;
        }

        return ans;
    }
}

但这题还有个做法,就是正着遍历的过程中倒着遍历。可以这么想,如果正着遍历到一个新元素,导致之前以某个位置开始的或值变得更大,就可以更新这个位置的长度。如果不会造成更大,那么更之前的也不会更大(因为卡在了当前这里),再之前的也统统不用更新。

class Solution {
    public int[] smallestSubarrays(int[] nums) {
        int[] ans = new int[nums.length];
        for (int i = 0; i < nums.length; i++) {
            ans[i] = 1;
            for (int j = i - 1; j >= 0 && (nums[j] | nums[i]) != nums[j]; j--) {
                nums[j] |= nums[i];
                ans[j] = i - j + 1;
            }
        }
        return ans;
    }
}

  • 27
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值