位运算本质上不是一种算法,而是一种trick,用来节约时间/空间的trick。背后常常有集合论、状态压缩等思想的支撑。这里探讨的位运算指的是其背后的指导思想而不是trick本身。因此对trick本身的证明就略过了。
位运算各种trick的详解请参照灵神的教学贴:leetcode.cn/circle/discuss/CaOJ45/
如果想获取位运算的知识图谱,以及集合论的一些基础知识。我在子集状压DP篇收录了相关图片(搬运别人的),可以在该博客找到。
一、集合论
常见的有枚举已知集合的子集、判断子集元素、集合交并补(对称)差等。
1.1. LC 78 子集
这里涉及最简单的枚举已知集合的子集。我们可以为集合中的每个元素编号,假设集合的势为n,则编号为0~(n-1),那么子集就可以写作一个0-1串,选取则为1,不选则为0。采用小端法。
例如,共计4个元素,选取第0个和第2个,就是0101。我们把这个0-1串看成二进制,则它在十进制下就是5。而这种映射显然是一一对应的,双射。因此5就代表了子集{0,2},用一个4字节整型就压缩了一个子集。
现在既然要枚举子集,那么就要确定子集的范围,很明显是全零到全一,也就是[ 0 , 2^n-1 ]。对于每个数我们判断子集元素即可。
那么如何判断子集元素?每次位于1,若为1则说明末位为1,应选取,然后整个数右移更新末位,循环直至整个0-1串被遍历即可。(或者可以把1移位,这个看个人喜好)
另外这一题是一道比较经典的题,他还能训练深搜回溯的基础算法。这里强调位运算集合论,就不放了。
复杂度O(n*2^n):共O(2^n)个子集,每个移位判断O(n)次。
import java.util.ArrayList;
import java.util.List;
class Solution {
public List<List<Integer>> subsets(int[] nums) {
// 位运算写法枚举子集
int n = nums.length;
List<List<Integer>> ans = new ArrayList<>();
// [ 0 , 1 << n - 1 ] 不选对应0
int j = 1 << n;
for (int i = 0; i < j; i++) {
ArrayList<Integer> sub = new ArrayList<>();
int tmp = i;
int index = 0;
while(tmp>0){
if((tmp&1)==1){
sub.add(nums[index]);
}
index++;
tmp >>= 1;
}
ans.add(sub);
}
return ans;
}
}
2.1. LC 2732 找到矩阵中的好子集
这题思路比较明确(我看提示了(笑哭)),就是任何一个合法子集中每一列至少有一个0。(如果全1那么和行数一样了,肯定没一半大)这样我们就能把子集压缩到最大行数为2的情况。
- 如果某一行全是0,直接返回这一行索引
- 如果两行&完是0,返回这两行索引
我的实现是:
-
先对每行位运算状压,作为一个int放到列表
-
对于每一个int a,生成一个列表[int],这个列表中的所有int b满足
- b的有效位长度和a一致,也即矩阵列数
- a&b = 0
例如 a = binary(1101) 则合法的b有 [binary(0010),binary(0000)] 可以发现,由于n<=5,因此至多生成2^n=32个b
-
开一个hash表维护每个int a对应的索引,遍历之前生成的列表,查找这个列表中的值对应的索引j,如果有,返回[i,j]
from typing import List
class Solution:
def goodSubsetofBinaryMatrix(self, grid: List[List[int]]) -> List[int]:
arr = []
def map_binary(row:List[int])->int:
res = 0
for i,x in enumerate(row):
res |= (x<<i)
return res
def match_generate(row:int)->List[int]:
res = []
def dfs(row:int,index:int,prefix:int):
if index >= len(grid[0]):
res.append(prefix)
return
dfs(row,index+1,prefix|(0<<index))
if (row>>index)&1==0:
dfs(row,index+1,prefix|(1<<index))
dfs(row,0,0)
return res
for row in grid:
arr.append(map_binary(row))
n = len(arr)
rec = {}
for i in range(n):
if arr[i]==0:
return [i]
match = match_generate(arr[i])
# print(match)
for res in match:
if res in rec:
return [rec[res],i]
rec[arr[i]] = i
return []
这题我有个运行时间上的问题,详情见于:每日一题测评运行时间问题:LC 2732 找到矩阵中的好子集 - 力扣(LeetCode)
二、 位计数
2.1. LC 2997 使数组异或和等于K的最少操作次数
这题一看提交里的解答,我直接小脑萎缩。
class Solution {
public int minOperations(int[] nums, int k) {
for(int x:nums){
k^=x;
}
return Integer.bitCount(k);
}
}
我写了60+行,人家4行结束了。意思也很明了,先把已有的异或匹配,然后数剩下多少1就完事了。我写的那60+行就是模拟了这个过程。
2.2. LC 3097 或值至少为K的最短子数组Ⅱ
举个例子:
nums = [ 1,2,3,4,5 ]
假设i是我们遍历数组时的索引
- i=0 只有一个或值:1
- i=1 两个或值:3,2(1和新来的2或,得到3;2自己或,得到2)
- i=2 一个或值:3,3,3(3和新来的3或,得到3;2和新来的3或,得到2;3自己或,得到3)
- i=3 两个或值:7,4(3和4;4自己)
那么此时想要得到7的话,最短的子数组就是[3,4]。那么怎么知道从哪里开始最短呢?显然是i=2时产生的3个3中左端点最大的,也即[3](而非[1,2],[1,2,3])
所以我们要维护的就是(或值,产生该或值的最大左端点)的kv对。那么最多有多少个这样的KV对呢?注意数据范围没有超过int类型,所以最多是32个或值(0个1,1个1,…,31个1)。这样我们开一个长度为32的静态数组去维护这个KV对集合即可。
class Solution {
public int minimumSubarrayLength(int[] nums, int k) {
int ans = Integer.MAX_VALUE;
int[][] ors = new int[32][2];
int m = 0;
for(int i=0;i<nums.length;i++){
ors[m][0] = 0;
ors[m++][1] = i;
int j = 0;
for (int idx = 0; idx < m; idx++) {
ors[idx][0] |= nums[i];
if(ors[idx][0]>=k){
ans = Math.min(ans,i-ors[idx][1]+1);
}
if(ors[idx][0]!=ors[j][0]){
ors[++j][0] = ors[idx][0];
}
ors[j][1] = ors[idx][1];
}
m = j+1;
}
return ans==Integer.MAX_VALUE?-1:ans;
}
}
这里m相当于KV对集合的大小,而j代表了本次更新中遍历到的KV对,而
if(ors[idx][0]!=ors[j][0]){
ors[++j][0] = ors[idx][0];
}
意味着,如果当前遍历到的KV对的或值不等于目前KV对的或值,说明我们找到了一个新的KV对,j往后移动,某则就要合并这两个或值相同的KV对了,取左端点的较大值,也即:
2.3. LC 1542 找出最长的超赞字符串
定义pre[i]为前i个数的异或和。
那么对于一个子串:
- 如果是偶数长度,那么开始位置i和结束位置j有:pre[i] = pre[j]
- 如果是奇数长度,那么pre[i]^pre[j] = 2^k(也即只有一个位置能是奇数个数)
而因为要求的是最长,那么显然可以利用哈希表维护达成某个pre的最早的索引。
对于任意索引i,ans = max(ans, i-pos[pre], max( i-pos[pre^(i<<d)] for d in range(10) ))
class Solution:
def longestAwesome(self, s: str) -> int:
alphabet = 10
n = len(s)
ans = pre = 0
pos = [n]*(1<<alphabet) # 起初还未计算异或和对应最早的索引
pos[0] = -1
for i,x in enumerate(map(int,s)):
pre ^= 1<<x
ans = max(ans,i-pos[pre],max(i-pos[pre^(1<<d)] for d in range(alphabet))) # 偶数前后异或和相等,奇数可以选其中一个位置异或完不为0
# 只需要最靠前的记录
if pos[pre]==n:
pos[pre] = i
return ans
2.4. LC 3133 数组最后一个元素的最小值
把n-1插空插到x里面即可:
class Solution:
def minEnd(self, n: int, x: int) -> int:
n = n-1
l = 0
while n>0:
if not (x>>l)&1: # 这一位为0,可以用
x |= (n&1) << l # n的低位赋值给这一位
n >>= 1
l += 1
return x
三、 状态压缩
3.1. LC 3181 执行操作可获得的最大总奖励Ⅱ
首先排序是显然的,肯定不能先选大的再选小的,这个时候小的就选不了了。
令dp(i,j)表示到以rewardValues[i]为结尾的子数组能否构造出总奖励为j的方案。那么:
当且仅当rewardValues[i]≤j<2*rewardValues[i]-1。
说人话就是如果不到i就能构造出来,那么到i肯定也能够造出来。如果j比rewardValues[i]大且转移源j-rewardValues[i]<rewardValues[i](满足题意仅当当前总奖励小于rewardValues[i]才能选择rewardValues[i]加入总奖励),那么如果转移源dp(i-1,j-rewardValues[i])能被构造出来,则dp(i,j)也能被构造出来。
但是rewardValues的长度和元素大小都已经到了5*1e4的地步,即便滚动数组优化空间之后也是包TLE的。
这里就需要状态压缩,把dp(j)压缩成一个长整型f。f的二进制第i位(从0开始)代表i是否能被构造出来。
那么就有:
其中v为rewardValues[i]
举个例子,v=2,那么转移源为0或1的情况下我们可以选择2。(1<<2)-1 = 0b11。但是f的低两位可能不全是1,当且仅当f的低v位中的某一位为1,那么该位左移v位才能设置为1。
- 低v位是为了满足题意中比v小才能选v
- 左移v位是因为转移至多转移到2*v-1
在细节上,首先我们可以对元素进行去重,因为选了v就不可能再选v了。另外如果最大值-1也在列表中,则可以取到上限2*max-1。
class Solution:
def maxTotalReward(self, rewardValues: List[int]) -> int:
mx = max(rewardValues)
if mx-1 in rewardValues:
return 2*mx-1
rewardValues = sorted(set(rewardValues))
f = 1
for r in rewardValues:
f |= (f&((1<<r)-1))<<r
return f.bit_length()-1