2026-01-20的LeetCode周赛430,我印象最深的不是压轴题,反而是这道编号3314的“构造最小位运算数组I”。看到“位运算”和“构造”两个词凑在一起,我第一反应是又要被按位操作折磨了,结果静下心推了十分钟才发现,这题的思路可以说是周赛里少见的“纸老虎”:核心不是复杂公式,而是能不能想到“把必须为1的位先铺下去,再统一检查”。这篇文章我就把当时的完整推导、代码落地和赛后复盘写下来,给同样被位运算构造题搞到头大的朋友一个参考。适合三类人看:每周打周赛的选手、正在准备面试位运算专题的人、以及想搞懂“逐位思考”这种底层方法的人。
先交代一下我对题面的理解。给定一个长度为n的非负整数数组nums,要求构造一个长度为n+1的非负整数数组arr,使得对每个下标i,都满足 nums[i] = arr[i] & arr[i+1]。如果存在多个合法的arr,返回数值最小的那个;如果不存在合法构造,返回[-1]。题名里的“I”意味着这是系列第一题,通常这种带编号的题,第一版都会把约束搞得比较温和,重点考验基础思维而不是刁钻算法。
1. 题目到底在说什么:一个按位与的逆向构造问题
1.1 从等式结构看约束关系
先看清数据形态。nums有n个元素,arr却有n+1个元素,也就是说nums的每个元素对应arr中相邻两个元素的一次“按位与”。这是个典型的链式结构:arr[0]和arr[1]一起决定nums[0],arr[1]和arr[2]一起决定nums[1],以此类推。中间位置的arr[i]会同时出现在两个等式里,它既要配合左边满足nums[i-1],又要配合右边满足nums[i],这是整道题最难处理的地方。
如果只要求“找一个可行解”,事情其实很简单:把arr所有位置都设成全1,或者设成INT_MAX,然后逐个等式去凑。但题目要求的是所有合法解中的最小值,这就完全变了性质。你必须在每个二进制位上精确控制0和1的分布,任何多余的1都会让结果变大,而缺少必要的1又会让等式不成立。
1.2 先手推几个小例子找感觉
我建议所有读这篇文章的人,看到这类题都先手推两个例子再想算法。拿最简单的 nums = [1] 来说,arr长度是2,需要 arr[0] & arr[1] = 1。按位与要得到最低位的1,必须两个数的最低位都是1,所以arr[0]和arr[1]都至少是1,答案就是[1,1]。
再看一个稍微长一点的 nums = [2,0]。arr长度为3。第一个等式要求arr[0]和arr[1]的二进制第1位(也就是值为2的那一位)都必须是1;第二个等式要求arr[1]和arr[2]的与结果是0,但arr[1]已经被前一个等式逼着在第1位是1了,想让arr[1] & arr[2]的第1位变成0,唯一办法是让arr[2]的第1位为0。于是可以构造 arr = [2,2,0],验证:2 & 2 = 2,2 & 0 = 0,完全正确。
也可以举一个无解的例子。nums = [2,1,2] 就无解。第一个等式把arr[0]和arr[1]的第1位都钉死为1,第三个等式把arr[2]和arr[3]的第1位都钉死为1,结果中间那个等式 arr[1] & arr[2] = 1 要求第1位必须是0,但arr[1]和arr[2]的第1位都被钉成了1,矛盾。这个例子在我后面讲两阶段验证法时会反复用到。
1.3 “最小”二字带来的关键转变
如果把题目的“最小”拿掉,一个很自然的暴力做法是:枚举arr所有可能取值,检查是否满足所有等式,然后取最小值。但arr是非负整数数组,取值范围太大,显然不可能枚举。
“最小”这两个字真正的价值,是逼着你去思考一个更本质的问题:每个二进制位上的1,哪些是躲不掉的?哪些是可以不存在的?只要能把“躲不掉的1”全部找出来,并且确认它们之间没有冲突,剩下的位全部填0,得到的就是最小解。这也是整个解法最核心的直觉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么必须按“位”而不是按“数值”思考
2.1 位运算是天然的“位独立”运算
按位与、按位或、按位异或,这三种运算有一个共同特质:结果的第b位只取决于两个操作数的第b位,和其他任何位都无关。这是位运算题最容易被忽略、也最值得利用的性质。
把等式 nums[i] = arr[i] & arr[i+1] 按二进制展开,假设数字是30位以内的非负整数,那么整个问题就变成了30个互不干扰的小问题,每个小问题只关心这一位上的0/1分布。这就像一条流水线上有30条独立的生产线,每条生产线上有n+1个开关,相邻两个开关之间需要满足一个“与”关系。生产线之间唯一的共同点,是它们最终要拼回到同一个arr数组的同一个位置上。
这个观察极其重要。因为它意味着你不需要同时考虑“这个数整体等于多少”,只需要考虑“这个数的这一位到底该不该是1”。把整体拆到位,是解决这类构造题的第一步,也是最关键的一步。
2.2 盯着十进制数值思考为什么容易翻车
不少人包括我一开始都会犯一个毛病:盯着十进制数值去凑。比如看到 nums[0] = 2,就想arr[0]能不能等于3、arr[1]能不能等于2,这样2 = 3 & 2。这种凑法有时候能碰上正确答案,但本质上是在猜,而且猜的复杂度会随着数组变长急剧上升。
举一个很小的例子:nums = [2,1],十进制的2和1看起来毫无关系,但如果拆成二进制就非常清楚:2是二进制10,1是二进制01,这两个数在第0位和第1位上根本没有重叠。所以arr[1]可以同时满足“第1位是1”和“第0位是1”,换句话说arr[1] = 3完全可行。你如果盯着10进制,很难意识到这种“不同位上约束叠加”的灵活性。把数字拆成二进制后,每一位的约束都是独立收集、独立判断的,整体数值只是这些独立判断的拼装结果。
2.3 约束在链条上是如何传播的
还有一个容易忽略的地方:这种链式约束会传播。arr[1]既受nums[0]影响,又受nums[1]影响;而arr[2]既受nums[1]影响,又受nums[2]影响。如果你从前往后一个个决定arr[i]的数值,会发现arr[i]一旦被某个nums[j]逼着在某一位上变成1,这个1就可能通过等式影响到arr[i+1]甚至更远的位置。
还是拿 nums = [2,1,2] 举例。nums[0]=2把arr[0]和arr[1]的第1位都设成1,nums[2]=2把arr[2]和arr[3]的第1位都设成1。这两个“2”看起来离得很远,中间隔着一个nums[1]=1,但它们通过链条把arr[1]和arr[2]的第1位都逼成了1,最终导致中间等式arr[1] & arr[2] = 1在第1位上必然得到1,和需要的0冲突。这就是传播带来的后果。
3. 两阶段构造法:先播下必须的1,再查多余的1
3.1 第一阶段:把所有“躲不掉的1”全部铺下去
想清楚位独立性和传播机制之后,算法其实就两句话。
第一阶段,遍历所有i,把nums[i]里所有为1的位,同时播给arr[i]和arr[i+1]。用代码写就是两行:
cpp复制ans[i] |= nums[i];
ans[i + 1] |= nums[i];
为什么这样做是“必要”的?因为按位与的性质决定了:如果结果nums[i]在某一位上是1,那么两个操作数arr[i]和arr[i+1]在该位上都必须是1,少一个都不行。所以nums[i]里每个为1的位,都会同时强制arr[i]和arr[i+1]对应位置为1。
遍历完整个数组后,arr里所有位置的每一位,都已经包含了所有可能的“强制1”。任何合法的解,在这位上都至少要有这个1。在这个阶段,我完全不关心某一位是否被重复设置了很多次,也不关心会不会多出一些1,只做一件事:把必要条件全部收集齐。
3.2 第二阶段:逐一验证等式是否真的成立
第一阶段把强制1铺完之后,再扫描一遍所有等式,计算 ans[i] & ans[i+1],看它和nums[i]是否完全相等。如果全部相等,说明构造成功;只要有一个不相等,说明出现了“多余的1”,直接返回[-1]。
这里需要解释一个看起来很奇怪的问题:为什么不能把多出来的1“掰掉”?答案在于,第一阶段铺下去的每一个1都是被某个等式强制要求的。对于nums = [2,1,2]这个例子,arr[1]的第1位是1,是因为nums[0]=2要求它必须是1;arr[2]的第1位是1,是因为nums[2]=2要求它必须是1。你掰掉任何一个,都会立刻破坏对应的等式,所以这些位根本动不了。既然动不了,中间等式在那一位上又必须是0,那唯一的结论就是整个问题无解。
3.3 为什么存在合法解时,这个构造一定是最小解
这是整个算法最妙的地方,也是我现场花了最多时间确认的地方。
假设存在另一个合法解arr'。对于任意位置k和任意二进制位b,如果我的构造中 ans[k] 的b位为1,那么这一位必定来自某个nums[i](i等于k-1或k)的b位为1。而合法解arr'要满足 nums[i] = arr'[i] & arr'[i+1],所以arr'在arr'[i]和arr'[i+1]上,对应位置中的任意一个位置如果被要求为1,那么该位置在这位上必然也是1。具体到k,因为nums[i]的b位为1,所以arr'[k]的b位也必须为1。
换句话说:我构造出的每一个1,在任何合法解里都必然是1。合法解不可能比我的构造少了任何一个1,自然也不可能比我的构造更小。所以只要通过第二阶段验证,得到的arr就是同时最小化每一个二进制位的结果,数值最小和字典序最小是同时成立的。
3.4 复杂度其实只有O(n)
整个过程就是两个循环,每个循环O(n)次迭代,每次迭代里做常数次位运算。对于30位或32位整数来说,位运算本身是O(1)的,所以总体复杂度就是O(n)。即使n到10^5、10^6级别也毫无压力,空间复杂度O(n)用来存答案数组,也没有任何优化压力。
其实这个算法还可以再压缩一层思考:它在做的事,本质上就是“先求出所有必要1的并集,再检查这个并集是不是已经满足所有等式”。因为按位与等式是单调的——把更多的位设为1,结果只可能多出1而不会减少1。所以从全0出发逐步加必要1,如果最后答案里出现了违反约束的额外1,那必然没有别的路可走。想通这一点,算法的正确性就非常稳固了。
4. 代码落地:C++实现与几个容易踩的坑
4.1 核心代码可以直接背
cpp复制class Solution {
public:
vector<int> constructArray(vector<int>& nums) {
int n = nums.size();
vector<int> ans(n + 1, 0); // 全0起步,保证后续只加必要1
for (int i = 0; i < n; ++i) {
ans[i] |= nums[i]; // nums[i]中的1同时是ans[i]的强制1
ans[i + 1] |= nums[i]; // 也是ans[i+1]的强制1
}
for (int i = 0; i < n; ++i) {
if ((ans[i] & ans[i + 1]) != nums[i]) {
return {-1}; // 出现多余1,无解
}
}
return ans;
}
};
如果你更习惯Python,写起来几乎是逐行翻译:
python复制class Solution:
def constructArray(self, nums: List[int]) -> List[int]:
n = len(nums)
ans = [0] * (n + 1)
for i, x in enumerate(nums):
ans[i] |= x
ans[i + 1] |= x
for i, x in enumerate(nums):
if (ans[i] & ans[i + 1]) != x:
return [-1]
return ans
4.2 坑一:初始化必须是全0,不能是全1
这个坑我现场差点踩进去。如果一开始把ans数组全设成1(或者INT_MAX),再试图通过“去掉多余1”的方式得到最小解,你会发现自己陷入一个完全不同的难题:哪个1能被删除?删了之后会不会影响这个等式?会不会影响隔壁的等式?这本质上是个非常复杂的组合优化问题。
而全0起步,配合“只加必要1”的策略,每个位置上的1都有明确的来源和依据,算法是确定的,不存在任何选择余地。这也是为什么“最小”问题往往要从0开始加必要项,而不是从最大值开始减多余项。这个经验不只是这一道题有用,很多构造题都遵循这个原则。
4.3 坑二:n=1时的边界行为
当nums长度为1时,ans长度为2。第一阶段循环里,i=0时访问ans[0]和ans[1],都不会越界。第二阶段也一样。所以代码不需要任何特殊判断,这是初始化长度用n+1带来的好处。
有一种容易写错的替代方案是“先初始化ans长度为n,再resize”,结果就会在n=1时产生混乱,甚至数组越界。建议直接初始化n+1,省心。
4.4 坑三:返回{-1}和返回空数组完全不是一回事
题目要求“不存在合法解时返回[-1]”,注意是长度为1、唯一元素为-1的数组,不是返回空数组。LeetCode对于这种判定型返回特别敏感,写错的话样例能过但提交会吃Wrong Answer。C++里直接return {-1};就行了,不要画蛇添足。
4.5 坑四:C++有符号整数的位运算陷阱
这题nums的元素是非负整数,用int类型完全够。但如果你在本地写测试数据时手滑加了负数,就会撞上有符号整数的坑:负数在C++里以补码形式存储,符号位是1,参与位运算时会影响结果,尤其是右移运算时是算术右移还是逻辑右移取决于实现。虽然这题不涉及,但位运算专题里这个点非常容易踩,建议时刻记住:涉及位运算的数组,尽量用无符号整数或者提前确认数据范围非负。
5. 举一反三:如果换成|、^,这套思路还成立吗?
5.1 换成按位或:约束方向完全反过来
如果把等式改成 nums[i] = arr[i] | arr[i+1],问题性质就完全变了。按位或的性质是:nums[i]某位为0时,arr[i]和arr[i+1]该位必须都为0;而nums[i]某位为1时,只需要arr[i]和arr[i+1]至少一个为1。
此时“强制1”不再是必要条件了,因为一个1可以由两端任意一方承担。为了让整个数组最小,一个直观的想法是:让1尽量往数组右侧放,因为越靠前的元素对“较小”的贡献权重越高。具体做法是,从左往右扫描,对于每个nums[i]中为1的位,先看arr[i+1]有没有被之前的约束强制在这一位为0,如果没被强制为0,就让arr[i+1]去承担这个1;如果arr[i+1]这一位已经被强制为0,那就只能由arr[i]来承担;如果arr[i]也被强制为0,那就无解。这和“先播必要1”的思路刚好镜像。
5.2 换成按位异或:递推比两阶段更合适
如果等式改成 nums[i] = arr[i] ^ arr[i+1],性质又变了。异或有自己的特点:给定arr[0]之后,整条链是唯一确定的,因为 arr[i+1] = nums[i] ^ arr[i]。那么问题就变成了“找最小的合法arr[0]”。
这时候“两阶段播种”就不再适用了,更合适的做法是从高位到低位逐位决策arr[0]:先假设当前位为0,把整条链推出来,检查推导过程中是否有元素不满足非负性或者权值限制;如果可以就保留0,否则就置1。这本质上是一个带约束的最小化决策问题。
5.3 通用方法论:先问三个问题
从这道题、以及它的两个变体里,能提炼出一个非常通用的方法论。遇到位运算构造题,尤其是带“最小”“字典序最小”这种优化目标的,先别急着枚举,而是按位问自己三个问题:
- 哪些位是被强制为1的?
- 哪些位是被强制为0的?
- 剩下可以自由选择的位,怎么分配才能让整体结果最小?
这三个问题通常能按位独立回答,然后再把答案拼装起来。对按位与来说,强制1来自nums[i]的1位;对按位或来说,强制0来自nums[i]的0位;对异或来说,往往没有太多自由位,整条链都是递推出来的。想清楚这个框架,面对新的变体就不会慌了。
6. 赛后复盘:我在现场卡住的原因与调试技巧
6.1 我一开始陷入了局部贪心的误区
说实话,我在现场第一版思路不是两阶段法,而是想把“数组整体最小”转化成“每一个arr[i]局部最小”。我试图在决定arr[i]时,同时考虑左边的nums[i-1]和右边的nums[i],让arr[i]尽量小。
这个想法听起来很合理,做起来却异常痛苦。因为arr[i]设得越小,右侧的arr[i+1]越可能需要承担更多的1来补全nums[i],而arr[i+1]同时又要满足nums[i+1]的约束,牵一发而动全身。我在这条思路上来回改了三四版,始终没法保证全局最优。后来才反应过来:局部最小和全局最小在位运算构造题里不是一回事,因为一个1可能同时服务于多个等式,局部最优很容易挡住全局必要的1。
6.2 从“让每个数最小”转向“让每个位最小”
真正让我豁然开朗的,是换了个提问方式:不去想“arr[i]最小是多少”,而是想“arr[i]的某个位最小能不能是0”。一旦把问题降到单个二进制位,答案就清晰多了:如果这个位被任何nums中的1强制要求,那它只能是1,否则就是0。
这个视角的转换看起来只是描述上的变化,实际上是把一个“向量优化问题”拆成了30个独立的“布尔可满足性问题”。位运算的神奇之处就在于此:整体上的纠缠,拆到单个位上就变成了干净的逻辑关系。这也是我说“按位思考”是位运算题第一性原理的原因。
6.3 调试技巧:把“期望值”和“实际值”对拍
如果你在本地调试时遇到返回-1的情况,我建议写一个辅助函数,把每个i对应的 nums[i]、ans[i] & ans[i+1] 都用二进制打印出来,对拍一下到底差在哪一位。
比如 nums = [2,1,2] 这个无解样例,打印出来你会看到:
- i=0:期望二进制10,实际2&3=10,正确
- i=1:期望01,实际3&3=11,第1位多了1
- i=2:期望10,实际3&2=10,正确
看到第二个等式第1位多出的那个1,再有针对性地问“这个1是谁带来的”,答案就非常直观:左边被nums[0]钉死的1和右边被nums[2]钉死的1,在中间撞车了。这种对拍调试法比对着代码发呆有效得多。
6.4 周赛策略分享:暴力枚举不一定没用
最后分享一个我自己常用的周赛策略:如果一道构造题卡了15分钟还没有清晰思路,不要硬憋。只要n足够小,可以写一个暴力枚举所有可能arr的代码,跑几个样例看看最小解长什么样。这个过程往往能让你直接“看穿”模式。
就拿这题来说,如果你暴力枚举了nums=[2,0]的所有解,你会发现最小解恰好就是把nums所有位“广播”到相邻位置的结果。看到这个规律后,再反过来想“为什么要广播”,两阶段算法就自然浮出水面了。暴力代码不是用来提交的,是用来看清问题结构的显微镜。以后遇到位运算构造题,我都会先问自己一句:哪些1是我躲不掉的?想清楚这个,至少能省下二十分钟试错时间。
