牛客寒假训练营第3场打完,线性代数专题也终于翻到第三篇,这次的主角是“线性基”。说实话,我最早看到线性基这三个字的时候,心里是有点发怵的——这玩意儿到底是线性代数还是算法题?后来把异或空间、线性无关这些概念串起来,才发现它本质上就是线性代数里“基”的思想在二进制运算下的落地,而且竞赛里的考法极其固定:给你一堆数,让你快速回答各种和异或有关的查询。这篇文章我按自己的刷题节奏来写,从数学原理讲到模板代码,再拆一道典型的区间最大异或和题目,争取让你看完就能直接上手写题。
适合谁看?如果你会写最基础的异或,听说过线性基但一直没啃明白;或者你刷题时遇到“任选若干个数求最大异或和”“求能异或出的第k小值”这类题,只能老老实实枚举子集;又或者你马上要上牛客这类平台打比赛,想提前备好一套现成的套路,那这篇就是写给你的。代码用 C++ 实现,逻辑上换到 Python 也成立,只是竞赛里我强烈建议用 C++ 写,常数小、好调试,后面我会解释为什么。
1. 线性基解决什么问题:从暴力枚举到空间压缩
1.1 异或运算的本质:不进位的模2加法
想理解线性基,第一步要把异或运算的底层认识掰清楚。异或在二进制下本质是不进位加法,更严格地说,是每一个二进制位独立做模2加法:1 + 1 = 0,1 + 0 = 1,但不同位之间完全不产生进位。这跟普通加法有本质区别,普通加法里某一位满了会影响高位,异或不会。
举个例子,3 ^ 5 换成二进制是 011 ^ 101,从高位到低位逐位异或,结果是 110,也就是 6。每一位只跟自己的同位打交道,互不干扰。正因为每一位独立,一个整数才能被看成一个由 0/1 组成的二进制向量,而“对若干个数求异或”就等价于“对这些二进制向量做模2线性组合”。
这个视角一旦打开,很多问题的本质就暴露出来了。假设给定 n 个数,问你能异或出多少个不同的值、最大值是多少、某个数能不能被表示出来,最朴素的做法是枚举全部子集,复杂度是 2 的 n 次方。但换个角度,这些数张成的“异或空间”其实是一个有限维线性空间,维度不超过最大数的二进制位数——对于 64 位整数,最多 60 多位。既然空间维度只有这么多,那理论上只需要保留一组规模极小的“基”,就能完整描述这个空间。
1.2 线性基的数学身份:异或空间的一组基
这里我强烈建议有过一点线性代数基础的人,把脑子里那套“向量空间”的概念搬过来用。三维空间里,任意一个向量都可以用三个不共线的基向量线性组合出来,你不需要把空间里的所有向量都存进内存,只需要存一组基。异或空间完全同理:每个数看成一个二进制向量,向量的每一位是一个维度,整个张成空间的维度最多就是 60,因此最多只需要 60 个“基向量”就能表达出原集合所有能异或出的结果。
线性基就是一组特殊的数,它满足三个条件:第一,原集合中任意一些数异或出来的结果,都可以由线性基中某几个数异或得到;第二,线性基是极小的,去掉任何一个基元素,它就再也凑不出原来的全部异或结果;第三,在通常的构造方式下,每个基元素的最高位互不相同,也就是说第 i 个基元素的最高位是第 i 位。这第三个性质是线性基能被高效维护的关键,也是后面所有查询操作的基础。
有个很直观的类比:线性基就像一套压缩工具。原本 n 个数可能是几十万个,但它们能异或出的结果种类却完全由空间的维数决定。压缩之后,我们丢掉冗余信息,只保留一套“最小生成元”,所有关于异或空间的查询都在这套生成元上做,复杂度从指数级直接降到 O(n log V)。
1.3 三个关键性质,理解后续操作的“总纲”
为了方便后面阅读,我把线性基最核心的三个性质摊开讲一下。第一,线性基内元素彼此线性无关。意思是任何一个基元素,都不能由其他基元素异或出来。这一点和线性代数里的“线性无关”完全一致,只是运算从普通加法换成了异或。第二,原集合的异或空间等于线性基的异或空间。也就是说你丢掉的那些数全部是冗余的,它们能异或出的任何值,基里的数也能异或出来。第三,线性基的元素个数不超过二进制位数,实际数量等于原集合张成的异或空间的维数。
这三个性质直接回答了“为什么线性基能做到这么快”。前面说了暴力枚举是 2 的 n 次方,但空间维度最多 60,所以压缩之后需要处理的元素最多 60 个。无论是插入、查询最大值还是查询第 k 小值,都只需要在这 60 个数上扫一遍,单次操作的复杂度严格控制在 O(60)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线性基的构造:插入操作从零开始实现
2.1 高位优先的贪心消元设计
线性基的核心操作只有一个:插入。插入一个数 x,就是尝试把 x 加入到现有的基集合里,同时保持“每个基元素最高位互不相同”这个性质不破坏。实现思路和线性代数里的消元法非常像:从高位到低位扫描 x,如果 x 的当前位是 0 就跳过,如果是 1,就看这一位上有没有基元素。没有,说明 x 在当前空间里引入了一个新维度,直接把它放在这一位;有,说明这一维已经被表示过了,用已有的基元素把 x 的这一位消掉,继续往下处理。
为什么要从高位往低位扫,而不是从低位往高位?这背后是贪心的直觉:高位在数值大小上的权重远大于低位。一个 2 的 60 次方位上的 1,比低 60 位全部取 1 加起来还要大,所以维护基的时候优先保证高位的独立性,能让后续查询最大值时直接采取高位优先的贪心策略。这也是线性基模板里很少被人点破但非常关键的设计哲学。
用一个不严谨但好记的类比:你整理书架的时候,肯定先按类别分大区,再在类目内部细分。高位就是大区,低位是细目。如果大区没理清楚,后续所有操作都会乱套。
2.2 插入操作的完整实现
直接上代码,这是线性基最基础也是最重要的部分:
cpp复制const int MAXB = 60;
long long basis[MAXB + 1];
bool insert(long long x) {
for (int i = MAXB; i >= 0; i--) {
if ((x >> i & 1) == 0) continue;
if (basis[i] == 0) {
basis[i] = x;
return true;
}
x ^= basis[i];
}
return false;
}
我说一下每一行的作用。循环变量 i 从 60 递减到 0,对应二进制位从高到低。(x >> i & 1) 是取出 x 第 i 位的值,如果是 0 说明这一位不用处理。如果这一位是 1,先看 basis[i] 是否为空;为空说明目前的空间里还没有最高位是第 i 位的基元素,x 带来了新的维度,直接存进去返回;不为空就把 x 异或上 basis[i],这一步的作用是把 x 的第 i 位清零,同时不引入新的垃圾位,然后继续往下处理更低的位。
这个循环结束后如果函数还没有返回,说明 x 在处理过程中被消成了 0。发生这种情况意味着 x 已经能被当前线性基表示出来,它没有给空间增加任何新维度,所以返回 false。
2.3 插入失败不代表没用
很多人会忽略插入失败的意义,觉得这个数“没用”,直接不管了。但真实竞赛里,插入失败的标志意义非常大:它说明 x 可以表示为当前集合中若干数的异或和,这就是“判断一个数是否在某个异或空间内”的判定依据。同时,如果存在任何一个数插入失败,说明原集合中存在一个非空子集异或结果为 0,这个结论在查询最小值时会用到。
另外一个细节是,插入操作不一定只能拿原数组里的数来做,它也可以拿任意一个待判断的数来做。比如你已经有了一组数的线性基,现在想判断新数字 y 能不能由这组数异或出来,直接对 y 执行一遍插入流程,如果返回 false,说明可以;如果返回 true,说明 y 引入了一个全新维度,所以它不能被表示。这个技巧在不少题目里是隐藏得分点。
3. 线性基的三大查询操作
3.1 查询最大异或和
有了线性基之后,查询整个空间里的最大值是最常见的操作。思路一句话概括:从高位往低位扫,如果异或上当前基元素能让答案变大,就异或上去。
cpp复制long long queryMax() {
long long res = 0;
for (int i = MAXB; i >= 0; i--) {
if ((res ^ basis[i]) > res) {
res ^= basis[i];
}
}
return res;
}
为什么这个贪心是对的?关键在前面提到的那条数值性质:第 i 位的一个 1,价值大于第 0 到第 i-1 位全部取 1 的价值之和。因为 2 的 i 次方大于 2 的 i 次方减 1。所以在从高到低的扫描过程中,只要异或上某个基元素能让第 i 位变成 1,就一定要异或,哪怕低位的值会被改变也没关系——高位多一个 1 带来的收益,永远超过低位所有可能变化的损失。这就是贪心策略安全的根本原因。
实测下来这个写法非常稳,模板几乎一字不改会用很多年。如果题目求的是与指定初始值异或后的最大值,只需要把 res 初始化为那个指定值就行,整体逻辑不变。
3.2 查询最小值与零的特殊地位
查询异或空间的最小值就有一点小弯了。先说结论:如果插入过程中出现过某个数被消成 0(也就是有某个子集异或为 0),那么最小值就是 0;否则最小值就是线性基里非零元素中的最小者,而在这个构造方式下,它其实就是最低位的那个 basis[i]。
原因也不复杂。线性基里所有非零基元素的最高位互不相同,任意两个基元素异或,结果的最高位会是两者中较大的那个最高位,这只会让二进制位数变大或不变,所以两个非零基元素的异或结果绝不会小于其中最大者的最高位对应的数值。换句话说,基内元素本身已经是这个空间里“最小的一批”候选值了,再互相组合只会变大,所以直接找最小的基元素就行。
实现上我习惯额外维护一个 bool hasZero 标记,每次插入返回 false 就置为 true:
cpp复制long long queryMin() {
if (hasZero) return 0;
for (int i = 0; i <= MAXB; i++) {
if (basis[i]) return basis[i];
}
return 0;
}
要注意,如果集合里本来就有 0,也要把 hasZero 置为 true。因为 0 是可以被空集异或出来的,但在“任意非空子集”的语境下,原数组里出现 0 意味着这个值本身就能被表示。
3.3 查询第k小异或和
第 k 小查询是线性基里最华丽的一个操作,但也最容易写错。它有一个前置步骤:把线性基重构成“最简梯度”,让每个基元素除了自己的最高位是 1 之外,其余所有位上都尽量是 0,尤其是要消除其他基元素占据的那些高位位置。这个过程很像线性代数里的行最简形。
重构代码:
cpp复制void rebuild() {
for (int i = MAXB; i >= 0; i--) {
for (int j = i - 1; j >= 0; j--) {
if ((basis[i] >> j & 1) == 1) {
basis[i] ^= basis[j];
}
}
}
int cnt = 0;
for (int i = 0; i <= MAXB; i++) {
if (basis[i]) {
p[cnt++] = basis[i];
}
}
}
这里的 p 数组是从小到大排列的非零基元素。重构完成之后,空间里所有能异或出的值,都可以像二进制拆位一样按第 k 小组合出来:把 k 的二进制表示拆开,如果第 i 位是 1 就异或上 p[i]。这样做的前提是,重构后的每个 p[i] 都只在自己的最高位上有贡献,不会干扰其他位。
查询部分:
cpp复制long long kth(long long k) {
if (hasZero) k--;
if (k >= (1LL << cnt)) return -1;
long long res = 0;
for (int i = 0; i < cnt; i++) {
if (k >> i & 1) {
res ^= p[i];
}
}
return res;
}
这里有个非常容易踩的坑:k 的语义。如果异或空间里包含 0,那么第 1 小就是 0,而枚举基组合时默认得到的第一个值是 0,所以要先把 k 减 1。另外,如果 k 大于等于 2 的 cnt 次方,说明查询的第 k 小根本不存在,返回 -1。
3.4 判断一个数能否被异或表示
这个操作其实隐藏在前面已经讲过的插入函数里。总结一下就是:把要判断的数 x 对着线性基做一遍消元,如果最后变成 0,说明 x 在这个空间里;否则不在。这个结论也反过来证明了插入返回 false 的含义——插入失败等于“x 可以被表示”。
在实际题目里,这个判定功能经常配合统计方案数使用。比如问你某个数能被多少种子集异或出来,正常思路会用到线性基的“自由度”概念:假设原集合大小是 n,线性基大小是 m,那么每个可表示的数值对应 2 的 (n-m) 次方种子集。这个结论推导略长,但本质还是线性基的空间维度思想。
4. 牛客寒假3实战:区间最大异或和的前缀线性基
4.1 题目模型还原
牛客寒假训练营里的线性基题,大多数不会只问你“整个数组的最大异或和”,而是会套一层区间限制。我按这类比赛最常出现的模型还原一下:给定长度为 n 的数组,q 次询问,每次给一个区间 [l, r],问在这个区间内任选若干个数,能异或出的最大值是多少。n 和 q 都能到 10 的 5 次方量级,数字范围在 10 的 18 次方以内。
如果对每个询问都重新构建一次线性基,复杂度是 O(q * n * 60),完全不可能跑完。或者说,这题真正考的并不是“线性基怎么写”,而是在“当线性基遇上区间限制时,怎么利用前缀信息快速回答”。
最直接的思路是前缀和思想:能不能预处理出所有前缀的线性基,然后查询 [l, r] 时,用前缀 [1, r] 的线性基减去前缀 [1, l-1] 的影响?异或运算没有减法,异或空间的基也不能像普通前缀和那样直线相减。所以需要换一种维护方式,让线性基里的每个元素都记住自己来自哪个位置。
4.2 前缀线性基的设计思路
这一步是整个区间线性基问题的核心。定义每个前缀 [1, i] 维护一组线性基,但每个基元素额外记录一个位置信息 pos,表示这个基元素在原数组中的下标。插入操作需要改造一下:当新元素 x 在第 i 位遇到已经有基元素时,不是直接异或完事,而是做一个位置比较。
如果当前基元素的 pos 比新元素的下标更小,就把这两者交换。换句话说,让新来的、位置更靠右的元素优先成为基元素,而位置更旧的那个元素被换到 x 里,继续往下消元。这样做的目的是保证同一维度上保留的是最靠右的基元素。查询区间 [l, r] 时,我们用第 r 个前缀线性基做贪心,但只使用那些 pos >= l 的基元素,这样剩下的基元素恰好张成区间 [l, r] 的异或空间。
这个技巧的思路还能再往深处想一想:它本质上是在保证线性基“尽可能新鲜”。前缀 [1, r] 里有些基元素来自 l 左边,它们在当前询问里已经过期了,要淘汰掉。通过交换位置法,我们可以让每个维度上的基元素尽量来自更右的位置,从而让“淘汰过期元素”这个操作变成一次简单的 pos 判断。
4.3 完整代码与复杂度分析
我直接给出这个模型的完整实现,代码结构可以直接搬到比赛里用:
cpp复制#include <bits/stdc++.h>
using namespace std;
const int MAXB = 60;
struct PrefixBasis {
long long basis[MAXB + 1];
int pos[MAXB + 1];
PrefixBasis() {
memset(basis, 0, sizeof(basis));
memset(pos, 0, sizeof(pos));
}
void insert(long long x, int id) {
for (int i = MAXB; i >= 0; i--) {
if ((x >> i & 1) == 0) continue;
if (basis[i] == 0) {
basis[i] = x;
pos[i] = id;
return;
}
if (pos[i] < id) {
swap(basis[i], x);
swap(pos[i], id);
}
x ^= basis[i];
}
}
long long queryMax(int l) {
long long res = 0;
for (int i = MAXB; i >= 0; i--) {
if (pos[i] >= l && (res ^ basis[i]) > res) {
res ^= basis[i];
}
}
return res;
}
};
const int MAXN = 100005;
PrefixBasis pre[MAXN];
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
long long x;
cin >> x;
pre[i] = pre[i - 1];
pre[i].insert(x, i);
}
int q;
cin >> q;
while (q--) {
int l, r;
cin >> l >> r;
cout << pre[r].queryMax(l) << endl;
}
return 0;
}
整个算法的时间复杂度分成三块:预处理每个前缀线性基,每次插入是 O(60),总共 O(60n);每个查询是 O(60),总共 O(60q)。总复杂度 O(60(n+q)),在 10 的 5 次方级别下完全没压力。空间复杂度是 O(60n),因为每个前缀位置都存了一份 61 个 long long 和 61 个 int 的线性基。
这里有一个必须提醒的点:pre[i] = pre[i - 1]; pre[i].insert(x, i); 这两行是深拷贝还是浅拷贝,取决于结构体的实现。上面代码里的 PrefixBasis 没有任何指针或动态数组,所以 pre[i] = pre[i - 1] 是完整的深拷贝,可以放心用。但如果结构体里今后加入了 vector 之类的动态资源,就得小心了。
4.4 场景延伸:线段树维护区间线性基
前缀线性基只适合处理前缀性质的问题,它要求所有操作都能用“第 r 个版本减掉左边部分”来表达。但如果你遇到的题目是动态区间修改,或者询问区间不再是 [1, r] 而是任意在线给出的区间,前缀线性基就不好使了。
我第一反应会换成线段树方案:每个线段树节点存一个线性基,查询时把区间对应的 O(log n) 个节点的线性基逐个合并,得到最终的线性基。合并两个线性基的方法也简单——把其中一个线性基的每个元素插入到另一个里。代价是单次查询的常数会大一截,大约是 O(60 log n log V),但胜在灵活,支持在线查询,也支持带修改的变体(配合单点更新)。
先想清楚题目的限制再选方案。如果只是静态区间查询,前缀线性基的代码短、常数小、好调试,优先选它;如果题目里带了修改、在线、强制在线之类的条件,线段树方案往往才是正解。这两种方案在牛客和同类平台的题里都出现过,建议把两种模板都准备好,考场上按需取用。
5. 常见问题与调试技巧实录
5.1 位数的边界问题
线性基循环到底从 60 开始还是从 61 或 62 开始,这个问题看似小,但真能让人调半天。我的建议是:根据题目给的数据范围决定,不要贪图省事先定一个 60 就完事。如果数字范围是 10 的 18 次方级别,那么二进制位数到 60 足够了,因为 2 的 60 次方约等于 1.15 乘以 10 的 18 次方;如果题目用了 unsigned long long,最大能到 2 的 64 次方减 1,那么循环就应当从 63 开始。
有一个我在比赛里踩过的坑:1LL << 60 在 long long 范围内是安全的,但如果你不小心写成 1LL << 63,这是未定义行为,轻则得到负数,重则整个程序行为异常。安全起见,循环边界宁可多开不能少开,从 63 或 62 开始循环,多扫的几次判断不会带来明显性能损失,但能避免一半以上的边界 bug。
5.2 插入失败与 zero 标记
很多初学者实现线性基时只写了插入函数,完全没有维护插入失败标记。这会导致查询最小值和第 k 小值两个操作直接出错,而且很难查出来。我自己的调试经验是:先构造一个非常小的测试用例,比如数组 [1, 2, 3],手动算出所有异或结果,再用模板跑一次对比。这个用例很小,但能覆盖很多边界情况。
另外,如果你在题里遇到的是“非空子集”的表述,要特别小心 0 的处理。原数组里有 0,或者存在某几个数异或为 0,都会导致“空集”和“非空子集”对最小值的判断产生分歧。我的习惯是统一用一个布尔变量记录是否有办法异或出 0,然后针对题目语义做微调,而不是每个函数里都重新判断一遍。
5.3 贪心查询为什么不会错的完整解释
有个问题我陆续被问过很多次:查询最大异或和时,当前这一步异或上去会让答案更大,但会不会导致后面的低位选择变得更差?答案是不会,原因就是之前反复提到的那条不等式:高位的单个 1 比低位所有 1 加起来还大。只要第 i 位从 0 变成 1,就算后面所有位全变 0,结果也一定比原来第 i 位是 0 的任何情况更大。
这一点也可以反过来说,如果某个基元素异或上去没有让第 i 位产生变化,那它就不是最优解的一部分。所以从高到低逐位判断的贪心是严格正确且十分高效的。想加深理解的话,建议自己手写一个例子,把所有可能组合列出来对比贪心结果,这个步骤做过一次之后就不会再怀疑了。
5.4 线性基与线性代数学习的交叉点
写到这我多说一句,3blue1brown 那个《线性代数的本质》系列对建立几何直觉的帮助确实很大,但看的时候要有意识地把“普通加法”替换成“异或运算”再去理解基、维数、线性无关这些概念。眼睛看到的是几何空间里的箭头,脑子里要同步想的是二进制位上的 0/1 组合。
如果你习惯用 Python 刷题,线性基逻辑完全相同,核心区别在于 Python 的 int 是任意精度的,循环边界可以写大一点,但常数也更大。牛客和 Codeforces 这类平台的线性基题,n 和 q 经常拉到 10 的 5 次方甚至 10 的 6 次方,Python 版本很容易被时间限制卡死。所以我个人还是推荐先用 C++ 把模板吃透,Python 只用来做小规模验证。
走到这里,线性基最核心的构造、查询和区间应用就算讲完了。我个人在实际操作中的感受是,线性基这个知识点不适合死记模板,因为它的变体很多——有前缀线性基、可持久化线性基、带删除操作的线性基、线性基求交求并等等,每个变体都是在基础插入操作之上加一点额外信息。你只要把“异或空间”“基”“贪心”这三个关键词真正内化,遇到新变体时再推导就快得多。最后再分享一个小技巧:下次遇到线性基题,先别急着写代码,花三十秒问自己三个问题——这个空间的维度上限是多少?插入时需要维护什么额外信息?查询是全局还是区间?想清楚这三件事,一半以上的坑都提前避开了。
