之前有个《位运算trick》的文章,也是讲位运算的。不过最近灵神发了个位运算的题单。这篇就按题单来了。
一、基础题
1. LC 3145 大数组元素的乘积
2879分,这把高端局。灵神题解:
乘积转换为幂次和
由于bigNums中的元素都是2的幂次,而需要计算的是乘积,也就相当于求幂次之和(底为2)。
bigNums 的幂次数组为
其中每个小数组内的是 1,2,3,4,5,6,7,8,⋯ 对应的强数组的幂次。
此时前缀积转变为前缀和+快速幂。
k个幂次的和-通项公式
现在相当于:前k1个幂次和 减去 前k2个幂次和。
而观察上面这个幂次数组,他是没有什么规律的。所以我们需要推出来一个能表示前k个幂次和的通项公式来。
k幂次对应的强数组
不难发现,数字n的强数组其实就是它的二进制表示。1个二进制的1就代表了1个幂次。所以我们要算的就是从
有多少个1。
那么[1,n-1]内有几个二进制的1呢?不妨来看一个例子:(此时i=4)
数字 | 二进制1个数 | 数位长度 |
---|---|---|
0 | 0 | 0 |
1 | 1 | 1 |
10 | 1 | 2 |
11 | 2 | 2 |
100 | 1 | 3 |
101 | 2 | 3 |
110 | 2 | 3 |
111 | 3 | 3 |
数字 | 二进制1个数 | 数位长度 |
---|---|---|
1000 | 1 | 4 |
1001 | 2 | 4 |
1010 | 2 | 4 |
1011 | 3 | 4 |
1100 | 2 | 4 |
1101 | 3 | 4 |
1110 | 3 | 4 |
1111 | 4 | 4 |
我们按照数位长度对这些数字进行划分。可以发现,数位长度为4的数字,本质上是数位长度≤3的所有数字的1的个数加上1得到的(也即最高位的1),例如:
one(1000) = 1 + one(000) (这里我补了前导零)
也即:
可以看到,后面8个1的个数的数组,就是前面8个1的个数的数组对应位置的数字+1得到的,加的这个1就是最高位新来的1。
那么怎么计算呢?定义:
为数位长度为i的组二进制1的个数。那么:
前面的2^(i-1)是前面的每个数对应+1产生的增量。后面的当然就是前面所有的二进制1的个数的和。
同时,有:
定义:
为数位长度≤i的组的二进制1的个数,那么:
同理可得:
由(1)、(2)式可得:
(3)式两边同时除以2^i可得:
设:
此时:
且:
{Bi}为等差数列,则:
所以:
此时我们希望找一个最大的n,使得ones(n)≤k(如果<k,多出来的部分让后面的强数组去补)
n为2的幂次的情况
先对于一个比较简单的情况,也即存在i≥0,有:
举个例子,当i=2,则[0,3]中,有1+1+2=4个1,也就是4个幂次。
试填法
举个例子,设k=10d = 1010b
整的
- 首先我们不可能在i=4的位置上填1,否则即便每个数只算1个1,那也有15个1了,已经把10给爆了。
- i=3:此时ones(3) = 3*2^(3-1)=12>10。爆了。
- 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个强数组。定义:
为第i个强数组内含有的幂次和。这里还是举之前的例子。
数字 | 幂次和 | 数位长度 |
---|---|---|
0 | 0 | 0 |
1 | 0 | 1 |
10 | 1 | 2 |
11 | 1 | 2 |
100 | 2 | 3 |
101 | 2 | 3 |
110 | 3 | 3 |
111 | 3 | 3 |
数字 | 幂次和 | 数位长度 |
---|---|---|
1000 | 3 | 4 |
1001 | 3 | 4 |
1010 | 4 | 4 |
1011 | 4 | 4 |
1100 | 5 | 4 |
1101 | 5 | 4 |
1110 | 6 | 4 |
1111 | 6 | 4 |
本质上也是之前所有组的对应的幂次和加i(高位填1增加了i次方)。
也即:
定义:
为前i个强数组内含有的幂次和。
则:
设:
则:
所以:
对于(7),两边同时除以2^i可得:
设:
则有:
则:
累加得:
于是:
对于零的,同样可以利用试填法计算带来的幂次和的增量。比方说我们的幂次个数是k=16,定位到了n=10的从低到高第2位上面的1。前面i≤3的强数组全用了,1000和1001也全上了。
此时我们直接套公式,i=3,si=12,此时n=8。目标n=10。
- 高位上面多了1000b-1001b这么些幂次和,总共2^0-0+1=2个。幂次为(i-1)也即3。2*3=6。
- 低位上面多了000b-001b这么些幂次和,总共1个,幂次为0,1*0=0。
- 最后一个n=10多了个010b,总共1个,幂次为1,1*1=1。
则高低位总计多出6+0+1=7个。零的整的总计有12+7=19。也即2**19。
实现
在具体实现上,我们可以通过试填k的各个数位,并套用幂次个数公式,来计算完整强数组带来的幂次和增量:
- 每次计算增量
- 若这个增量≤k,那么更新k、总幂次和,高位幂次和以及高位1的数量,还有n(这是因为我们要求出最后的那个n,然后不停low_bit来补全剩下的k所需要的幂次个数)
- 注意最低位是第一组,但是它的索引是0(也即2^0),所以套i*2^i公式并不适合,因为这样幂次个数增量为0。因此需要单独处理。显然,最低位的区间长度是1(注意0里面没有二进制1不能算),这样对于幂次之和的增量就是当前高位幂次和。、
- 对于余下的幂次个数k,就用当前最后的n的低位去补。Lowbit技巧(x&(-x)),不断Lowbit直到凑够数为止。
- 记得改一下快速幂板子适配取模。
(甚至还记忆化了一下
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)了。
大致思路如下:
- 倒着遍历元素,记录每一个或值及能够达成这个或值的最小右边界
- 把当前元素和记录的或值全部或起来,原地去重
- 或的同时记录最大值,查询这个最大值对应的右边界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;
}
}