2. 开场:这次又栽在线性基上了?
如果你刷过牛客的寒假训练营,应该对“线性基”这个词不陌生。2025年2月8日牛客寒假3这场,有一道题让我在赛场上卡了很久,赛后复盘时发现思路其实不复杂,就是朴素的“区间内若干个数异或和最大”问题,但我在如何高效维护区间线性基这个点上绕了弯路。这篇博客就把这件事彻底掰开揉碎讲清楚。
先说结论:线性基本质是一种贪心思想下的二进制向量空间压缩。它把一堆数的异或组合能力浓缩成至多几十个基底向量,任何能被原集合凑出来的异或值,都能被这组基底唯一或非唯一地表示。无论你的原始数组有多大,一旦压缩成线性基,所有查询都变成几十次位运算,复杂度立刻降到可接受范围。
这篇文章适合三种人:第一种是初学线性基、只会背模板但不知道原理的人;第二种是已经会写基础插入和最大值查询、但遇到“区间查询”“合并多个线性基”这类变形就蒙的人;第三种是纯粹想通过一道牛客真题,把线性基的推导、代码、调试、优化整个串一遍的人。
我会先讲清楚线性基的数学含义,再给出标准模板代码,然后用这场比赛的真题做完整推导,最后把我在调试和测试中踩过的坑、用过的验证技巧全部列出来。保证你读完不只是多背一道题,而是以后遇到任何线性基变形题都能自己推出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
3. 线性基核心思路:为什么它能把集合压缩成几十个数
3.1 从“异或运算”到“向量空间”的抽象过程
异或运算有个很反直觉的性质:它满足交换律、结合律、自反律,而且每个数跟自己异或会变成0。你可能在书上见过“异或就是二进制下不进位的加法”,这个说法很准确,但很少有人顺着这个思路继续往下想。
如果把每个数看作一个二进制向量,比如 5 就是 101,异或就是对每一位做模 2 加法,那么“从一个集合里选若干个数求异或和”就等价于“选若干个向量做模 2 加法”。你会发现这完全符合线性代数里向量空间的加法规则,只是在模 2 意义下,系数只能是 0 或 1。
这给了我们一个极其重要的启示:一组数能凑出的所有异或结果,本质上构成了一个模 2 线性空间。既然是线性空间,就一定存在一组基。基的数量最多不超过二进制位数,也就是最多 60 多位。哪怕原始集合有一百万个 int,它们能凑出的不同异或值,也最多由 60 个基底向量完全决定。
这里的关键是“压缩不丢失能力”。原集合能凑出的每一个异或值,线性基都能凑出来;线性基能凑出的每一个值,原集合也一定能凑出来。两者在“能凑出哪些数”这个维度上完全等价,但大小一个是一百万,一个是几十,这就是我们做压缩的核心动力。
3.2 为什么线性基的插入规则长这样
线性基最经典的实现方式是用一个数组 p[i] 表示“最高位为 i 的基向量”。插入一个新数 x 时,从高位往低位扫,如果 x 某一位是 1,就先判断当前线性基有没有最高位在这的基向量。
如果没有,直接把 x 放进这个位置,插入结束。如果有,就执行 x ^= p[i],把这个高位消掉,继续往低位走。这个过程很像高斯消元中的“初等行变换”:用已有基向量消去当前向量的最高位,直到消成一个最高位空闲的向量,或者消成 0。
等你理解了这个过程,就明白线性基为什么叫“基”了。它保留了原集合张成的整个空间,却只记录线性无关的那部分向量。插入新数时,如果它已经被现有基向量表示出来,异或到最后会变成 0,这说明它不是新的基,直接丢弃即可。
实践中我还习惯多维护一个 cnt 记录基向量的数量。这个数量有个重要含义:如果 cnt == 位数,说明这组数字覆盖了完整的二进制空间,任何小于 2^位数 的数都能被凑出来。在很多第 k 小查询题里,这个判断是前置条件。
3.3 查询最大异或和的贪心为什么一定正确
拿到线性基后,查询最大异或和的函数几乎是每一道线性基题都会用到的。代码长这样:
cpp复制long long queryMax() {
long long res = 0;
for (int i = MAXB; i >= 0; --i) {
if ((res ^ p[i]) > res) res ^= p[i];
}
return res;
}
很多初学者不理解:为什么从高位到低位试一遍,遇到能让 res 变大的就异或,最后结果就一定是最大值?
注意一个容易忽略的事实:高位优于一切低位之和。二进制第 k 位的权值是 2^k,而所有低于 k 位的权值加起来也只有 2^k - 1。所以判断一个决策是否值得,只需要看它会不会让更高位从 0 变成 1,而不需要担心它把低位搞乱。
从最高位往最低位逐一决策,每一位只要能让 res 变大就选,这实际上是一个贪心过程。因为线性基里所有基底向量都是线性无关的,当前位的选取不会影响已经确定的高位结果,所以局部最优可以累积成全局最优。我见过有人试图用搜索去枚举所有组合,那在 n 大的时候肯定是死路,线性基的优势就在这。
4. 线性基标准模板:插入、求最大值、求第 k 小
4.1 完整可复用的 C++ 模板代码
先给出一份我习惯使用的模板,包含了线性基最常见的几个操作。这两个函数基本覆盖了 90% 的线性基基础题。
cpp复制class LinearBasis {
private:
static const int MAXB = 60;
long long p[MAXB + 1];
int cnt;
public:
LinearBasis() {
memset(p, 0, sizeof(p));
cnt = 0;
}
// 插入一个数
void insert(long long x) {
for (int i = MAXB; i >= 0; --i) {
if (((x >> i) & 1) == 0) continue;
if (!p[i]) {
p[i] = x;
++cnt;
break;
}
x ^= p[i];
}
}
// 查询最大异或和
long long queryMax() {
long long res = 0;
for (int i = MAXB; i >= 0; --i) {
if ((res ^ p[i]) > res) res ^= p[i];
}
return res;
}
};
这份模板有几个地方值得说明。MAXB 取 60 是因为大多数题目给的数是 long long 范围,最高位不超过 60。如果你处理的是 int 数据,可以改成 MAXB = 31 来节省一点点循环次数,但通常差别不大,统一用 60 更省心。
插入时从高位向低位扫,遇到 x 当前位为 0 就跳过的原因很简单:这位不管,因为反正 x 在这位上没有贡献。遇到当前位为 1 时优先检查是否已有基向量,如果已有就异或掉,确保新插入的向量不会和已有基线性相关。这个“消高位”的过程是插入的核心,我每次写模板都会在注释里标注一下,防止自己几个月后看不懂。
4.2 求第 k 小的异或值:需要先做高斯消元
基础模板做完后,马上会遇到一类进阶题:给一个集合,求能凑出的异或值中第 k 小。牛客很多线性基题都是这个考法,所以务必把这一步也掌握。
先解释一个容易理解错的地方:线性基中每个基底向量最高位不同,但它们之间并不一定是“对角化”的。比如 p[3] 可能是 1001,p[0] 可能是 0001,这时两个向量能凑出 1000 和 1001,如果你直接从低位往高位枚举基底去构造第 k 小,会得到错误结果,因为低位向量还可能影响高位。
解决办法是先对线性基做“高斯消元”,把每个基底向量的非最高位全部消掉,得到一个对角化程度更高的线性基。代码如下:
cpp复制void rebuild() {
for (int i = MAXB; i >= 0; --i) {
if (!p[i]) continue;
for (int j = i - 1; j >= 0; --j) {
if (((p[i] >> j) & 1) && p[j]) p[i] ^= p[j];
}
}
}
消完之后,每个 p[i] 只有第 i 位是 1,其他位都是 0。这时候再求第 k 小就简单了:把 k 的二进制每一位拿出来,如果某一位是 1,就把对应的基底向量异或进答案。注意这里要去掉那些为 0 的向量,并把所有非零向量重新按位映射到 0 到 cnt-1 的位置。
这部分的细节比较绕,我在第 5 节实战里会结合具体题目再走一遍完整流程。
4.3 合并两个线性基以及复杂度控制
线性基还有一个很常见的操作是合并。合并的本质就是把另一个线性基里的每个向量依次插入当前线性基。由于每个线性基最多 61 个向量,所以合并一次的复杂度是 O(logV * logV),约 3600 次位运算,非常快。
cpp复制void merge(const LinearBasis &other) {
for (int i = 0; i <= MAXB; ++i) {
if (other.p[i]) insert(other.p[i]);
}
}
别小看这个合并操作,它在“区间查询最大异或和”这类题里是核心。你可以用线段树维护每个区间的线性基,查询时把 O(log n) 个区间线性基合并起来再求最大值。虽然听起来有点重,但在 n、q 都是 1e5 级别的数据下,总复杂度 O((n + q) * log n * logV * logV),跑起来也只是几百毫秒的事。
相比直接用前缀线性基或者可持久化线性基,线段树合并的思路更直观,代码也更好调试。它唯一的缺点是常数略大,但如果题目不卡常数,这是最不容易写错的方案。
5. 牛客寒假3实战:区间异或和最大值的完整推导
5.1 题目大意的还原与分析
这场比赛中我遇到的线性基题目大体可以归纳成以下形式:给定一个长度为 n 的数组 a,下标从 1 到 n,然后有 q 次询问,每次询问给定区间 [l, r],要求计算从 a[l] 到 a[r] 之间任选若干个数,它们的异或和的最大值是多少。
n 和 q 的范围都在 1e5 级别,a 中的每个数都是 1e18 以内的正整数。如果没有这个规模限制,最暴力的做法是对每个询问枚举区间内所有子集,那是指数级的复杂度,完全不可行。另一种朴素做法是把区间里的数全部插入线性基,再求最大值,每个询问 O(len * logV),最坏情况下总复杂度 O(nq),也会超时。
所以这道题真正考察的能力是:如何高效地把“任意区间”的线性基预处理出来,让每次询问不必重新构建线性基。这也是我在赛场上卡住的地方——我一开始只想到了线段树合并,没有意识到离线按右端点扫描可以用更巧妙的办法维持更小的常数。
需要说明的是,我这里的题面描述是赛后归纳整理的版本,实际赛题在输入输出格式上可能还有细微差异,但核心问题模型完全一致。解题时第一件事就是做这种抽象:从冗长题面中揪出“异或、区间、最大值”这三个关键词。
5.2 关键优化技巧:离线扫描 + 位置贪心
如果你不满足于线段树合并,想追求更优的写法,可以试试这种更精巧的思路:离线所有询问,按右端点 r 从小到大排序,然后从左到右逐个把 a[i] 插入一个“位置感知”的线性基。
普通线性基只存基底向量本身,我们额外加一个数组 pos[i],记录当前最高位为 i 的基底向量是由原数组中的哪个位置贡献的。插入新数 x 时,假设它来自位置 idx,也要把这个 idx 带进去一起比较。插入规则变成:如果某一位没有基向量,直接插入;如果已有基向量,就比较 pos 和 idx,优先保留位置更靠右的向量,把位置较左的向量继续往下异或。
这样维护出的线性基有一个性质:它是最新、最靠近右端点的。查询区间 [l, r] 时,因为我们已经处理到 r 位置,线性基里全是前 r 个数产生的基底,只要在求最大值的循环里加一个判断——只有 pos[i] >= l 的基向量才能参与异或——就能保证答案只使用区间内的数。
这个思路非常巧妙,它把问题从“每问一次构建一次线性基”变成了“线性基随扫描动态更新,每次查询只看位置是否合法”,复杂度降为 O((n + q) * logV)。我后来复盘时感叹,这个技巧和“01字典树求最大异或对”里的离线贪心有异曲同工之妙,都是把区间限制转化到时间轴上处理。
5.3 实际 AC 代码与关键注释
下面这份代码是我赛后调试通过的版本,我在关键位置加了注释,方便你直接照着复现。
cpp复制#include <bits/stdc++.h>
using namespace std;
const int MAXB = 60;
long long p[MAXB + 1];
int pos[MAXB + 1];
void insert(long long x, int idx) {
for (int i = MAXB; i >= 0; --i) {
if (((x >> i) & 1) == 0) continue;
if (!p[i]) {
p[i] = x;
pos[i] = idx;
return;
}
// 优先保留更靠右的位置
if (pos[i] < idx) {
swap(p[i], x);
swap(pos[i], idx);
}
x ^= p[i];
}
}
long long queryMax(int l) {
long long res = 0;
for (int i = MAXB; i >= 0; --i) {
if (pos[i] >= l && (res ^ p[i]) > res) {
res ^= p[i];
}
}
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n, q;
cin >> n >> q;
vector<long long> a(n + 1);
for (int i = 1; i <= n; ++i) cin >> a[i];
vector<tuple<int, int, int>> queries(q);
for (int i = 0; i < q; ++i) {
int l, r;
cin >> l >> r;
queries[i] = {r, l, i};
}
sort(queries.begin(), queries.end());
vector<long long> ans(q);
int cur = 1;
for (auto [r, l, idx] : queries) {
while (cur <= r) {
insert(a[cur], cur);
++cur;
}
ans[idx] = queryMax(l);
}
for (int i = 0; i < q; ++i) {
cout << ans[i] << '\n';
}
return 0;
}
这里有三个细节需要注意。第一个是 swap(p[i], x) 和 swap(pos[i], idx) 的顺序,它不能写反,因为只有基底和位置同时交换,后面的 x ^= p[i] 才能保持正确的数学含义。第二个是查询最大值时判断条件里的 pos[i] >= l 必须放在最前面,否则可能拿一个不在区间内的向量去更新答案,导致结果偏大。第三个是处理完离线排序后,要用一个 cur 指针保证每个数只插入一次,不要每次询问都从头插,否则复杂度会退化。
5.4 另一种实现:线段树合并线性基,什么时候用它
和离线扫描相比,线段树合并线性基是一种更“暴力”但更通用的做法。思路是把数组建成线段树,每个节点维护的是一段连续区间的线性基。查询区间 [l, r] 时,用标准的线段树区间查询把覆盖区间的 O(log n) 个节点取出来,把这些节点的线性基合并到一起,再求最大值。
这种写法最大的优点是思维量小,几乎不需要想什么位置贪心,只要会写线段树和会写线性基合并就行。它在处理“强制在线”的题目时比离线扫描省心很多,因为你不需要把所有询问事先存下来排序。
缺点也很明显:如果题目卡常数,线段树合并的方案可能过不了。每个节点存一个 61 长度的 long long 数组,n=1e5 时内存大约为 61 * 8 * 4 * 1e5,接近 200 MB,有些题目内存限制只有 256 MB,会非常紧张。而且每次查询还要做 log n 次线性基合并,也就是 O(log n * logV * logV),常数比离线扫描大不少。
所以我的选择标准是:能离线就先写离线扫描,因为代码反而更短、更快;题目强制在线再考虑线段树,毕竟正确性优先,过题之后再优化不迟。
6. 常见问题与排查技巧实录
6.1 类型溢出和位数范围:最常见的错因
线性基题最容易在类型上翻车。如果题目给出的 ai 最大值是 1e9,那 31 位足够;如果最大值是 1e18,就必须开 long long 并让 MAXB 到 60。很多人在 MAXB 设成 31 时插入 1e18 的数据,高位的 1 直接被忽略,答案自然不对。
我排查这种问题时有一个习惯:先看数据范围,然后检查 MAXB 的值。如果题目说 ai ≤ 1e18,那么 MAXB 一定要设成 60(因为 2^60 ≈ 1.15e18);如果题目说 ai 是 int 范围,设成 31 就够。不要偷懒统一设 60 就以为万事大吉,涉及第 k 小题目的输出可能会是 long long,这时候答案变量也必须是 long long。
另外,异或操作的优先级比较低,跟 == 和 & 混在一起时很容易写错。我在写 ((x >> i) & 1) == 0 时都会把括号加满,宁可丑一点,也不要在这种地方浪费一次提交。
6.2 用随机暴力验证正确性:最推荐的自测方式
线性基题的错误往往不是编译错误,而是逻辑边界错误,比如插入时没有处理 0、查询时忘了判断位置、第 k 小问题忘了对基底做对角化。这些错误靠肉眼很难发现,最有效的方式是写一个暴力程序对拍。
我会写一个最朴素的函数,对每个区间直接枚举所有子集求最大异或和。如果区间长度不超过 15,这个暴力是能跑的。然后随机生成 n 不超过 15、q 不超过 100 的小数据,把暴力结果和线性基结果比对,一旦不一致就能快速定位是哪一组数据出了问题。
这种对拍方式帮我抓出过至少三次逻辑 bug。印象最深的是有一次我忘了在 swap(pos[i], idx) 之后把 idx 继续传下去,导致位置信息错乱,某些询问的答案偏大。如果只靠手算样例,这种错误几乎不可能发现。
对拍代码本身很简单,核心就是暴力枚举所有子集。你只需要保证暴力的逻辑和题面完全一致,剩下的交给随机数据就行。
6.3 第 k 小题目的特殊坑:别忘了重建线性基
最后单独提醒一下第 k 小异或查询的坑。如果你直接用插入后的原始线性基去构造答案,结果几乎必然错误。原因前面说过,原始线性基里某个低位基向量可能带有高位的 1,它在构造小值时会干扰高低位的独立性。
一定要先做一次 rebuild() 对角化,然后统计非零基向量数量 cnt。如果 k 大于等于 (1LL << cnt),说明能凑出的数的个数不够,答案是 -1,这是很多题的要求。另外,如果集合能凑出 0,那么 0 也要算进第 k 小的序列里,不同的题目对“第 k 小”是否包含 0 的定义不同,务必在代码里区分对待。
我在牛客这道题上虽然没有踩第 k 小的坑,但之前在别的题上踩过,所以写在这里提醒你。考试时遇到类似题,先花三十秒确认清楚题目到底问的是“第 k 小”还是“第 k 大”,以及 0 是否计入排名,比什么都重要。
6.4 一套百试百灵的调试顺序
当一个线性基题提交 WA 之后,我建议按下面的顺序排查,而不是盲目换算法。
第一步,确认数据类型和 MAXB 是否符合数据范围,这是最容易定位也是最容易忽略的。第二步,写一个极小的样例,用手算推出答案,然后跑代码看结果是否一致。第三步,如果小样例过了,立刻写对拍程序,随机生成多组数据对比暴力结果。第四步,如果对拍也过,但题目仍然 WA,大概率是题面理解有偏差,比如下标从 0 开始还是从 1 开始、区间左闭右开还是全闭、是否允许选择 0 个数。
这套顺序帮我解决了许多棘手的 WA。很多时候问题根本不在线性基本身,而在输入输出格式或者题意细节上。越早确认这些,越能节省宝贵的比赛时间。
7. 一些个人经验
做线性基题最忌讳的是只会背模板。我见过不少选手能把插入和查询最大值背得滚瓜烂熟,但题目稍微改成区间查询、改成第 k 小、改成合并多个集合,就完全不知道从哪下手。原因在于他们没有理解线性基背后的“向量空间”这一层抽象。
我的建议是,学线性基时一定要先花半小时理解为什么插入时要把高位消掉,为什么查询最大值从高往低扫是贪心且正确的。弄懂这两个为什么,你就能自己推导出所有变形题的写法,而不是每次拿到题都去翻模板。
如果你也想彻底吃透这块内容,强烈推荐配合 3blue1brown 的《线性代数的本质》中关于向量空间和基的那几集一起看。视频里的几何直观能帮你建立“线性无关”“张成空间”的直觉,回来再看代码,会觉得一切都顺理成章。
最后分享一个刷题小技巧:牛客寒假训练营的题解区通常有大佬的多解法分享,但别直接看 AC 代码,先看他们是怎么从题目抽象到线性基的。这个抽象过程才是你真正需要复习的东西。我自己每次补题都会在笔记里单独记一行“这题为什么能想到线性基”,积累多了之后,再遇到新题就能快速判断该不该往这个方向想。
