news 2026/10/22 5:17:01

用位掩码表示集合:解前缀公共数组与位运算基础

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用位掩码表示集合:解前缀公共数组与位运算基础

刷题碰到“2657. 找到两个数组的前缀公共数组”这道题时,我第一反应是哈希表计数,扫一遍就能出答案。但它被归在“位运算-基础”分类里,我就坐下来认真研究了一遍位掩码解法。研究完才发现,这道题看起来简单,实际上非常合适用来理解“用二进制位表示集合”这个核心思维,因为题目本身就是让你反复求两个前缀集合的交集大小。我会从题目语义讲起,把暴力法、哈希计数、位掩码三种方案都过一遍,然后重点拆解位运算的关键步骤和容易踩的坑。如果你正在刷题、准备面试,或者想把位运算基础彻底弄明白,这篇文章应该能帮你省不少时间。

1. 先看懂题目:两个前缀数组到底在求什么

1.1 题目原文与输入输出形态

题目给两个长度相同的数组A和B,长度记为n,并且A、B都是0到n-1的一个排列。排列的意思就是:每个数字出现且只出现一次,两个数组内容相同,只是顺序可能不同。

要求你返回一个同样长度为n的答案数组ans。对于每个下标i,ans[i]表示:A[0]到A[i]这一段数字,和B[0]到B[i]这一段数字,公共元素的个数。

注意“公共元素”是按值算的,不是按下标算。也就是说只要数字x同时出现在两个前缀段里,不管它分别在什么位置,都算一个公共元素。

举个例子,A = [1, 3, 2, 4],B = [3, 1, 2, 4],正确答案是[0, 2, 3, 4]。我后面会一步步手动推演这个例子,你会很清楚它为什么不是[1, 2, 3, 4]这种无脑递增的结果。

1.2 从样例手推一遍,确认语义

先看i = 0。此时只看A[0] = 1和B[0] = 3,两个前缀集合分别是{1}和{3},交集为空,所以ans[0] = 0。

再看i = 1。A的前缀是[1, 3],对应集合{1, 3};B的前缀是[3, 1],对应集合{3, 1}。两个集合完全一样,公共元素有1和3两个,所以ans[1] = 2。

到i = 2。A前缀为[1, 3, 2],B前缀为[3, 1, 2],集合分别是{1, 3, 2}和{3, 1, 2},公共元素三个,ans[2] = 3。

到i = 3,两个数组整个拿出来都是{1, 2, 3, 4},交集就是四个,ans[3] = 4。

这个例子想说明一件事:前缀公共数组不是单调地从某个起点累加,而是每走一步都把两个集合重新求一次交集。只是因为前缀只会越来越长,所以答案一定是不递减的。

1.3 这个题为什么天生适合位运算

如果只看数据范围,n最大是1000,数字范围也被限制在0到n-1,这意味着每个数字都能对应到一个唯一的二进制位。一个集合是否包含数字x,完全可以用“第x位是否为1”来表示。

这种“数字范围连续且有限”的条件,就是位掩码的最佳使用场景。0到1000的连续数字,用1001个二进制位就能装下所有可能的集合状态。而两个集合的交集大小,直接等于两个位掩码做按位与之后,数一下结果里有几个1。

哈希表当然也能做,但位运算把“集合交集”这个看起来需要遍历的操作,压缩成了几个极快的底层指令。这也是为什么 LeetCode 会把这道题放在位运算分类里。

2. 三种解法:暴力、哈希计数、位掩码的取舍

2.1 暴力法:思路最简单,但复杂度不够优雅

暴力做法是:对于每个下标i,先把A[0..i]的所有数字放进一个集合,然后遍历B[0..i],数一数有多少个数字已经在集合里。

def findThePrefixCommonArray(A, B): n = len(A) ans = [] for i in range(n): seen_a = set(A[:i + 1]) cnt = sum(1 for x in B[:i + 1] if x in seen_a) ans.append(cnt) return ans

这个写法在n = 1000时不会超时,但每轮都要构建新集合、遍历一遍前缀,总复杂度是O(n^2)。如果n变成10^5,就完全跑不动了。作为热身可以,作为正解有点浪费。

2.2 哈希计数法:把“公共”翻译成“出现次数达到 2”

因为A和B都是排列,每个数字在A中出现一次,在B中出现一次。从全局角度看,一个数字x如果是当前前缀的公共元素,那么它到目前为止,在A和B中被访问到的总次数恰好是2。

所以可以用一个长度为n + 1的计数数组cnt,遍历过程中同时处理A[i]和B[i]两个数字。每次让对应计数加一,如果加完之后计数等于2,说明这个数字第一次同时出现在两个前缀里,公共计数加一。

def findThePrefixCommonArray(A, B): n = len(A) ans = [] cnt = [0] * (n + 1) common = 0 for i in range(n): cnt[A[i]] += 1 if cnt[A[i]] == 2: common += 1 cnt[B[i]] += 1 if cnt[B[i]] == 2: common += 1 ans.append(common) return ans

这里有个隐藏细节:如果A[i] == B[i],同一个数字会被连续加两次。第一次从1变2,触发common += 1;第二次从2变3,等于2的判断不成立,不会重复计数。所以不需要特判。

这个解法时间复杂度O(n),空间复杂度O(n),是这道题最稳妥的正解。

2.3 位掩码视角:集合就是位图,交集就是按位与

位掩码解法不关心某个数字到底出现了几次,只关心它“有没有出现过”。维护两个掩码变量maskA和maskB:

  • maskA的第x位为1,表示数字x已经在A的前缀中出现过;
  • maskB同理。

每走一步,把A[i]对应位置为1,再把B[i]对应位置为1。此时,maskA & maskB中为1的位,就是两个前缀都出现过的数字;统计这个结果里1的个数,就是当前答案。

以A = [1, 3, 2, 4]、B = [3, 1, 2, 4]为例,逐步看掩码变化:

imaskAmaskBmaskA & maskB1的个数
0第1位=1第3位=100
1第1,3位=1第1,3位=1第1,3位=12
2第1,2,3位=1第1,2,3位=1第1,2,3位=13
3第1,2,3,4位=1第1,2,3,4位=1第1,2,3,4位=14

可以清楚看到,每一步的答案就是(maskA & maskB)的二进制中1的个数,也就是popcount。这种写法把“集合求交”变成一条指令,非常直观。

3. 位运算核心知识点拆解

3.1 用二进制位表示一个集合

位掩码的核心思想:一个整数本身是很多二进制位的组合,每一个位都可以代表一个独立状态。比如数字5的二进制是101,可以认为它同时拥有“第 0 位”和“第 2 位”。

放到集合场景里,就是约定第x位表示数字x是否在集合中。数字x对应的位是1 << x,把x加入集合就是mask | (1 << x)。因为1 << x得到的是一个只有第x位为1的整数,与mask做按位或,只会让第x位变成1,不会影响其他位。

判断x是否在集合中,就是看mask & (1 << x)是否不为0,这也是位运算中最常用的技巧之一。

3.2 左移、或运算、按位与、popcount 四件套

做这道题只需要四个位运算操作:

  • 左移:1 << x,得到一个“只有第 x 位是 1”的数。
  • 按位或:mask |= 1 << x,把x加入集合。
  • 按位与:maskA & maskB,得到两个集合的交集。
  • 统计 1 的个数:popcount,也就是数交集大小。

如果觉得抽象,可以想象成影厅座位表。电影院里座位编号从 0 开始,有人坐就是 1,没人坐就是 0。maskA是 A 队坐过的座位,maskB是 B 队坐过的座位。maskA & maskB就是找出两边都坐过的座位,数一遍就是公共人数。

3.3 不同语言怎么取二进制中 1 的个数

统计1的个数在不同语言里方法不太一样,写题时最容易卡在这一步。

语言方法说明
Python 3.10+x.bit_count()直接统计二进制中 1 的个数
Python 旧版本bin(x).count("1")先转成二进制字符串再数
JavaInteger.bitCount(x)/Long.bitCount(x)基础库自带,按 int 或 long 范围统计
C++__builtin_popcount(x)GCC 内置函数,int 版本
C++std::bitset<N>::count()位集对象直接统计
Gobits.OnesCount64(x)math/bits 包

bit_count这类函数底层通常有硬件指令加速,性能很高,但要注意它统计的位数上限和变量的位宽一致。比如 Java 的Integer.bitCount只能统计int的 32 位,Long.bitCount只能统计 64 位。

3.4 位宽到底够不够:int、long、大整数怎么选

这道题的n最大是 1000,所以你立刻会遇到一个问题:int只有 32 位,long只有 64 位,装不下 1000 个状态。这时候有三种选择:

  • 如果n <= 31,一个int足够;
  • 如果n <= 63,一个long足够;
  • 如果n更大,就需要用long[]、BitSet、std::bitset或者 Python 的大整数。

Python 在这种题里有个天然优势:int是任意精度,1 << 1000根本不会溢出,所以你可以直接用一个整数当位集,不需要额外的数据结构。Java 和 C++ 则需要选择合适的大位集容器。

4. 手把手实现:从把样例跑通到处理边界

4.1 Python:几行代码搞定,适合用来理解思路

如果环境是 Python 3.10 及以上,用大整数配合bit_count,代码可以写得非常简洁:

class Solution: def findThePrefixCommonArray(self, A: List[int], B: List[int]) -> List[int]: mask_a = 0 mask_b = 0 ans = [] for x, y in zip(A, B): mask_a |= 1 << x mask_b |= 1 << y ans.append((mask_a & mask_b).bit_count()) return ans

这段代码的每一行都值得说清楚。

mask_a = 0初始集合为空。循环里mask_a |= 1 << x,意思是把数字x对应的二进制位置为 1。mask_b同理。然后(mask_a & mask_b)得到两个集合的交集,bit_count()统计这个交集中有多少个 1,也就是公共元素个数。

如果 Python 版本比较旧,没有bit_count,可以改成:

ans.append(bin(mask_a & mask_b).count("1"))

功能一样,但性能略慢。

4.2 Java:用 long[] 模拟“大位集”的完整实现

Java 没有 Python 那样无限大的整数,但可以把多个long拼成一个位集。每个long是 64 位,n = 1000时只需要(1000 + 63) / 64 = 16个long。

class Solution { public int[] findThePrefixCommonArray(int[] A, int[] B) { int n = A.length; int wordSize = (n + 63) >> 6; long[] maskA = new long[wordSize]; long[] maskB = new long[wordSize]; int[] ans = new int[n]; for (int i = 0; i < n; i++) { setBit(maskA, A[i]); setBit(maskB, B[i]); int common = 0; for (int j = 0; j < wordSize; j++) { common += Long.bitCount(maskA[j] & maskB[j]); } ans[i] = common; } return ans; } private void setBit(long[] mask, int x) { mask[x >> 6] |= 1L << (x & 63); } }

这里x >> 6相当于x / 64,算出数字x落在第几个long桶里;x & 63相当于x % 64,算出在桶内的第几位。1L << (x & 63)必须带后缀L,否则 Java 会按int运算。

这段代码每次循环都要扫描全部 16 个桶,总操作量大约是16 * 1000次,对n = 1000来说完全没问题。如果n更大,比如10^5,就不推荐这种写法了,因为扫描成本会变成O(n^2 / 64)。

Java 还有一种更省事的写法:直接用java.util.BitSet。

import java.util.BitSet; class Solution { public int[] findThePrefixCommonArray(int[] A, int[] B) { int n = A.length; BitSet maskA = new BitSet(n); BitSet maskB = new BitSet(n); int[] ans = new int[n]; for (int i = 0; i < n; i++) { maskA.set(A[i]); maskB.set(B[i]); BitSet tmp = (BitSet) maskA.clone(); tmp.and(maskB); ans[i] = tmp.cardinality(); } return ans; } }

BitSet的好处是自动扩容,不需要手动算桶。clone()会做深拷贝,不会影响原来的maskA。cardinality()返回置位个数,等价于popcount。

4.3 C++:std::bitset 的可读写法

C++ 的std::bitset在编译期就要确定大小。这道题n <= 1000,可以直接把大小设为 1000,非常省事:

class Solution { public: vector<int> findThePrefixCommonArray(vector<int>& A, vector<int>& B) { int n = A.size(); vector<int> ans(n); bitset<1000> bsA, bsB; for (int i = 0; i < n; ++i) { bsA[A[i]] = 1; bsB[B[i]] = 1; ans[i] = (bsA & bsB).count(); } return ans; } };

bitset支持下标赋值和按位与,count()直接返回 1 的个数,可读性非常好。需要注意bitset<1000>是编译期固定的,如果题目数据范围改了,要记得同步调整。

4.4 复杂度与适用场景对比

三种方案各有适用场景,放在一起对比更清楚:

方案时间复杂度空间复杂度适用场景
暴力法O(n^2)O(n)只求理解题意,不推荐提交
哈希计数法O(n)O(n)通用性最强,n 很大也稳
位掩码法(Python 大整数)O(n)(位宽受限于 n/字长)O(1) 概念上可视为整数字长数字范围连续,用大整数语言最爽
位掩码法(Java long[]/BitSet)每步要扫描位集,总约 O(n^2 / 64)O(n)n 在几千以内,常数极小

哈希计数法在理论上是最优的,时间复杂度稳定在O(n),所以面试时如果只要求写出正解,哈希计数是最安全的。位运算的价值更多在于锻炼“状态压缩”的思维,以及在某些特定场景下让你不需要维护频次数组,直接一个整数搞定问题。

5. 我踩过的坑和排查技巧

5.1 左移溢出:1 << x 不是你以为的那样

Java 中int左移只取低 5 位作为移动位数,long左移只取低 6 位。也就是说,1 << 32在 Java 中等于1 << 0,1L << 64等于1L << 0。如果你写了1L << 64,你并没有得到第 64 位是 1 的结果,而是原地打转,位集状态会错乱。

C++ 中1 << 100则是未定义行为,可能得到任何值。我最初在本地测试时用了个超过位宽的数字,结果答案飞到了天边,排查了半天才发现是左移越界。

所以用固定位宽语言时,要么先用x >> 6分桶,再用x & 63限制桶内偏移;要么直接用官方封装的BitSet。不要手写越界左移。

5.2 & 的优先级坑:maskA & maskB == 0 会出问题

很多需要写判断的地方,按位与&的优先级比等于号==低。比如想判断两个位集交集是否为空,直觉上写成:

if (maskA & maskB == 0) { ... }

实际上会先计算maskB == 0,再与maskA做按位与,结果完全不对。正确写法是:

if ((maskA & maskB) == 0) { ... }

这个坑不只影响这道题,很多位运算题目里都会出现。规则很简单:位运算里除了~比较高,&、|、^的优先级都比比较运算符低,所以只要混合使用,就老老实实加括号。

5.3 A[i] == B[i] 时的重复计数问题

哈希计数法里有一个细节:如果A[i]和B[i]是同一个数字,先++cnt[A[i]]触发== 2,common加一;再++cnt[B[i]]变成 3,不会触发。这正好保证了只计一次。

但如果你改成“cnt[x] >= 2就加一”,遇到A[i] == B[i]时就会重复计数,结果偏大。所以判断条件写== 2还是>= 2要想清楚。在这个题目约束下,== 2是最精确的。

5.4 什么时候别用位运算

位运算不是万能的。如果题目不保证数组是0..n-1的排列,数字范围可能很大,比如10^9,用位掩码表示集合就完全不现实了,因为一个整数装不下那么大的位宽。

另外如果n特别大,Java 里每步扫描整个long[]会让复杂度退化到O(n^2 / 64)。虽然常数小,但遇到n = 10^5级别的数据,还是应该老老实实回到哈希计数。

有一类通用性更好的哈希做法:维护两个频次数组分别记录A前缀和B前缀中每个数字的出现次数,每次更新后如果某个数字在两个频次数组里都大于 0,则公共计数加一。这样即使数组不是排列,也不会出错。不过对这道题而言,用单数组cnt等于2的判断就已经充分且正确了。

5.5 快速自测清单

写完代码后,我一般会跑这几组数据验证:

  • A = [0],B = [0],期望输出[1]。
  • A = [0, 1],B = [1, 0],期望输出[0, 2]。
  • A = [0, 1, 2],B = [0, 1, 2],期望输出[1, 2, 3]。
  • A = [0, 1, 2],B = [2, 1, 0],期望输出[0, 1, 3]。

如果你实现的位运算版本这四个都对,基本可以放心提交了。特别是第一组n = 1的边界情况,很多代码在其他用例没问题,到最小输入上反而容易出错。

我个人在实际刷题中的体会是:先用哈希计数法快速拿到 AC,再用位运算重写一遍,对比两种实现,收益远大于死记硬背某一套解法。位运算的熟练度,本质上就是“能不能把一个集合塞进一个数里”的意识。以后遇到权限位、布隆过滤器雏形、状态压缩 DP、甚至简单的用户标签交集统计,你都能用上这套思维。如果想进一步练手,可以把这道题扩展到“三个数组的前缀公共数组”,这时位运算的优势会更明显,因为多维护一个maskC就行了,而哈希计数法则要多维护一个频次状态。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 5:16:55

用打DOOM的思路做自动化测试:AI Agent决策模型设计与实践

1. 从游戏AI到测试Agent&#xff1a;一个反直觉的起点第一次看到"用打DOOM的思路做自动化测试"这个说法&#xff0c;我脑子里蹦出来的第一个念头是&#xff1a;这俩东西八竿子打不着吧&#xff1f;一个是1993年的第一人称射击游戏&#xff0c;一个是软件工程里最枯燥…

作者头像 李华
网站建设 2026/10/22 5:15:29

CGAN动漫头像生成实战:从条件控制原理到PyTorch可复现代码

1. 项目概述&#xff1a;用条件生成对抗网络复现李宏毅课程中的动漫头像生成实践你有没有试过&#xff0c;只输入“戴猫耳、蓝发、微笑”几个词&#xff0c;就让模型当场画出一张风格统一、细节自然的动漫角色头像&#xff1f;这不是 Stable Diffusion 的专属能力——早在扩散模…

作者头像 李华
网站建设 2026/10/22 5:15:27

大模型标称窗口与有效窗口差距解析及工程优化策略

1. 标称窗口和实际可用窗口&#xff0c;为什么差这么多很多做AI应用的朋友都遇到过这个场景&#xff1a;模型文档上写着支持128K甚至200K的上下文窗口&#xff0c;你兴冲冲地把一份十几万字的文档丢进去&#xff0c;结果模型要么直接报错&#xff0c;要么回答得驴唇不对马嘴&am…

作者头像 李华
网站建设 2026/10/22 5:14:12

从Hinton RSI论文到ChatGPT伪造签名:AI能力与信任的速度差

1. 三条新闻背后的共同信号&#xff1a;AI圈正在分裂成两个阵营2026年10月6日这天&#xff0c;AI圈子里同时炸出了三条看起来八竿子打不着的消息&#xff0c;但如果你在这个行业里待得够久&#xff0c;就会发现它们其实指向同一个深层矛盾。第一条&#xff0c;被称为“深度学习…

作者头像 李华
网站建设 2026/10/22 5:13:37

大模型面试RAG 6连问:从检索增强生成原理到工程落地优化

1. 为什么RAG成了面试里的“必答题”这两年只要简历上写了“大模型应用”“知识库问答”“智能客服”这类关键词&#xff0c;面试官几乎一定会往RAG方向追问。原因不复杂&#xff1a;微调成本高、周期长&#xff0c;而RAG&#xff08;Retrieval-Augmented Generation&#xff0…

作者头像 李华