注:本篇文章所有代码都是作者自己写的,保证能通过对应的测试。
一、前言
这篇文章主要讲述异或线性基,读者最好对线性代数有一定的基础,具备位运算的基本知识和一些算法基础,能够阅读C++代码。
讲述流程 : 1.简单介绍线性基的概念。因为高等代数的严格概念都非常难懂和复杂,所以我尽量用通俗的语言讲述异或线性基的概念,知道什么时候需要用异或线性基,以及异或线性基的基本功能
2.会给出线性基的基本操作(插入、求并集、求最大值、求最小值, 第k小线性基)
3.介绍前缀线性基(一种有点难懂的线性基)
4.给出异或线性基的扩展的应用: 只能选择奇数个/偶数个数字的情况下求最大异或值,线性基构造方案(需要求出选出了原序列的哪些元素)(偏难)
5.给出一些例题(牛客、CF、力扣)
二、 异或线性基
定义 :给定数集 S,以异或运算张成的数集与 S 相同的极大线性无关集,称为原数集的一个线性基。
通俗地说,线性基是一个数的集合。每个序列都拥有至少一个线性基。取线性基中若干个数异或起来可以得到原序列中的任何一个数。
性质 :1. 线性基可以异或出原数组能异或出的所有值(0除外), 0需要单独记录(后文会说)
2. 如果线性基的维数为n(线性基里一共有n个向量), 那么一共可以异或出(2^n - 1)种不同的值,
也就是(2^n - 1)个非空子集
3. 线性基的个数不会超过logV个, V = max(nums), 一般就是30/60,根据数据范围决定
4.线性基是线性无关的,可以理解为极大线性无关组
5.线性基里一定不可能异或出0(非空子集)
反证法证明: 假设base1 ^ base2 ^ base3 = 0, 那么就有base1 ^ base2 = base3,也就是base3可以被base1 和 base2表示出来,那么根据线性无关性, base3是不能进入线性基的
三、线性基的基本操作
1.插入(贪心)
如果插入失败就表示原来的数组可以异或出0,用一个变量zero标记
auto insert = [&](int x) -> bool{
//把x插入线性基
//从高位向低位插入线性基,方便后面贪心求最大值
for(int j = 30; j >= 0; j--){
if(x >> j & 1){
if(b[j] == 0){
b[j] = x;
return true;//如果j位是0就直接插入,然后返回true表示插入成功
}
x ^= b[j];//消元,再看是否能插入到后面的位置
}
}
return false;
};
for(int i = 1; i <= n; i++){
if(!insert(a[i])){
zero = true;
}//原来是否有0
}
2.插入(高斯消元)
熟悉线性代数的读者应该可以看出上述插入很像高斯消元,当然高斯消元也可以解决线性基的插入,也可以记录原数组是否能异或出0,并且高斯消元的插入适合求第k小异或值,因为高斯消元是标准形式,每一列保证只有一个1,所以可以二进制拆位计算第k小
bool zero = false;//记录原数组是否能异或出0
int base[31];//假设最多30位
int a[100001];//原数组
int len = 1;//表示下一次插入的行数
for(int j = 50; j >= 0; j--){
for(int i = len; i <= n; i++){
if(a[i] >> j & 1){
swap(a[i], a[len]);
break;
}
}
if(a[len] >> j & 1){
for(int i = 1; i <= n; i++){
if(i == len)continue;
if((a[i] >> j & 1) == 0)continue;
a[i] ^= a[len];
}
len++;
}
//if(len > n)break;
}
len -= 1;
zero = len != n;
3.求最值
普通消元求最大值
int ans = 0;
for(int j = 30; j >= 0; j--){
ans = max(ans, ans ^ b[j]);
}
高斯消元求第k小
ll k = read();
if(zero && k == 1){
cout << 0 << '\n';
continue;
}
if(zero){
k -= 1;
}
ll ans = 0;
if(k >= (1LL << len)){
cout << -1 << '\n';
continue;
}
//高斯消元求第k小
for(int j = 50; j >= 0; j--){
if(k >> j & 1){
ans ^= a[len - j];//最小值
}
}
cout << ans << '\n';
4.判断某个值是否能被异或出来
auto is_in = [&](int x) -> bool{
for(int j = 30; j >= 0; j--){
if(x >> j & 1){
if(b[j] == 0){
return false;//这一位是1但是没有对应的基,说明不存在
}
x ^= b[j];//消元
}
}
return true;//全部检查完,说明存在
}
5.合并两个线性基(就是把一个线性基插入到另一个线性基中)
vector<ll>a(31);
vector<ll>b(31);
vector<ll>res(31);
res = b;//先继承一个
for(int j = 30; j >= 0; j--){
if(a[j]){
ll x = a[j];
for(int k = 30; k >= 0; k--){
if(x >> k & 1){
if(res[k] == 0){
res[k] = x;
break;
}
x ^= res[k];
}
}
}
}
四、前缀线性基
首先这题可以用线段树 / ST表暴力合并区间,因为时限给了3s,所以(n + q) * (logV)^2 的复杂度是可以接受的,但是可以省一个logV,这就需要用到前缀线性基。前缀线性基可以理解为时间戳 + 贪心,非常巧妙的一个算法。
具体实现流程:
首先定义两个数组b[i][j]和pos[i][j], b[i][j]表示考察前i个数字的线性基,j就是每一位。pos[i][j]表示在前i位的线性基中能通过消元使得j这一位为1的尽量靠右的左边界位置,也就是时间戳更晚的位置
1.首先原数组从左到右按照原来的顺序插入线性基,把数组的下标当成时间戳。在插入b[i][j]前需要继承之前一个位置的线性基,因为是前缀,所以前面的线性基和位置是需要继承的
for(int j = 0; j < 31; j++){
b[i][j] = b[i - 1][j];
pos[i][j] = pos[i - 1][j];
}
2.然后和普通线性基一样从高位到低位模拟插入,如果遇到b[i][j] == 0,直接插入.如果b[i][j] != 0,那么我们要对原来的值x进行消元,但是在消元之前我们需要考虑位置的问题,如果pos[i][j] < P就说明pos[i][j]可以被刷新的更大,直观理解就是我可以用更紧的区间就获得这一位的基,所以swap(pos[i][j], P), swap(b[i][j], x);既然交换了位置,那么对应的值也需要被交换,因为这样才能让这一位获得正确的基底。因为我们需要尽量靠后的基底,所以必须交换,不然可能因为查询的区间而导致无法使用到这一位的基底!但是交换会改变张成的空间吗?答案是不会。
下面证明swap(b[i][j], x)不会改变原来的张成的空间
设 : b[i][j] = old, x = new, 那么原来的张成的空间为 {old, 其他基底} + new, 交换完是{new, 其他基底} + (new ^ old); 能够表示的向量完全相同,所以这种交换是合理的。
int P = i;
for(int j = 20; j >= 0; j--){
if(val >> j & 1){
if(b[i][j] == 0){
b[i][j] = val;
pos[i][j] = P;
break;
}
if(pos[i][j] < P){
swap(pos[i][j], P);
swap(b[i][j], val);
}
val ^= b[i][j];
}
}
3.最后查询:
如果我们需要查询[l, r]的最大异或值,那么我们还是按照之前的从高位开始,但是我们需要判断位置。也就是如果这一位的基底是< l的那就无法使用,这就是为什么一定要尽量选靠后的。
int query(int l, int r){
int res = 0;
for(int j = 20; j >= 0; j--){
if(b[r][j]){
if(pos[r][j] >= l){
res = max(res, res ^ b[r][j]);
}
}
}
return res;
}
完整代码: Submission #368354111 - Codeforces
五、一些题目
(由于代码和题目太长,代码和题目均以链接的形式给出)
以下选出一些我个人觉得比较好的题目,可以加深对线性基的理解,包括一些线性基的扩展qwq.
1. XOR(A) + XOR(C)最大
3630. 划分数组得到最大异或运算和与运算之和 - 力扣(LeetCode)
读题可以分析出我们需要把原数组划分为三个部分,但是显然不能暴力枚举,因为3^n肯定超时, 但是我们可以只枚举&的部分,剩余的A, C观察性质。
在枚举掉AND的部分后,剩余的数字要么分在A部分,要么分在C部分,拆位来看如果剩余的数字在j位为1的个数是奇数个,那么XOR(A) + XOR(C)在j位必然是1, 因为奇数只能拆分为奇数和偶数,这样一个0一个1相加保证这一位为1.如果第j位1的个数为偶数,可以拆成奇数 + 奇数或者偶数 + 偶数, 但是无论是哪种XOR(A) 和 XOR(C)在这一位永远相等。
形式化的,令S1为参与AND的子集, 那么 S2 = (U ^ S1)是参与异或的子集, 记 xr = XOR(S2)为整体的异或值,这样恰好能表示每一位为奇数个的异或值,因为偶数个为0,只有奇数个被保留了.令XOR(A1) = XOR(ai & ~xr), XOR(C1) = XOR(ci & ~xr), XOR(A1) = XOR(C1), 所以我们只需要把S2中的所有元素&(~xr) 然后插入线性基,求出最大异或值,记为MAX_XOR
那么 ans = max(ans, 2 * MAX_XOR + xr + AND(S1)) 枚举S1子集
时间复杂度 O(2 ^ n * n * logU) U = max(a);
AC代码 : 划分数组得到最大异或运算和与运算之和 - 力扣(LeetCode)
2.线性基构造输出方案
这个题目还是有点难度的,首先我们需要思考出一种方式能够比较方便的知道我们选了哪些数字.这个时候就可以使用到异或的性质,一开始a数组全选,也就是 a1 ^ a2 ^ a3 ^...^ an, 记为A.然后构造ab[i] = a[i] ^ b[i], 这样如果我们选择了ab[i], 就代表只选择b[i],如果我们不选择ab[i],就代表我们只选择了a[i],这样就把两个数组的问题转化为一个数组ab的问题。
转化成功之后我们需要 假设选出的ab[i]的异或和为S, 那么根据题意我们需要 A ^ S = 0, 也就是需要S = A。问题就被转化为, 在ab数组中选择任意个数字,需要异或和等于已知的值A.
这样问题其实就是判断某个值是否能被异或出来,上面的模版也有,所以现在最大的问题就是怎么优雅的记录构造的方案?
下面提供一种简单的方法: 首先给线性基编个号, cnt = 0, 1, 2, 3..., 30定义数组mask[j]:表示第j位为1的线性基需要插入哪些线性基, id[i] = j:表示第i个线性基是由原数组第j个数字通过一系列消元得到的。这样只需要用mask就可以知道从S中异或出A的方案
这题需要对线性基充分理解才能写出回溯方案
时间复杂度 O(n * logU) U = max(a[i] ^ b[i]);
3.线性基树上合并
比较模版的一题,思维含量较低可以训练代码能力
主要思路就是倍增 / 线段树merge线性基,把普通的线性基合并变成树上的线性基合并,可以算是最简单的一题~.
时间复杂度 O((n + q) * (logU) ^ 2) U = max(a);
4.图上线性基的转化
这题需要一条边的两个端点不同色,不妨把颜色定义为{0, 1},每个点的颜色a[i] ∈ {0, 1} 那么ans = XOR((ai ^ aj) ^ eij),需要求ans的最大值. 注意到 (ai ^ aj) ^ eij = (ai * eij) ^ (aj * eij) , 所以原来的式子就可以写成 ans = XOR((ai * eij) ^ (aj * eij)) = XOR(ai * eij) ^ XOR(aj * eij) = XOR(ai * XOR(w))其中w是与ai直接相连的边的权值,我们需要最大化这个值. ai不是1就是0,那么就表示XOR(w)选或者不选。 那我们构造的线性基每次插入的元素应该是每个点与之相连的边的异或和,然后按照上面求最大值的模版求最大值就行。
时间复杂度 O(m + n * logU) U = max(eij);
5.只能选择奇数 / 偶数个数字并且需要求异或最大值
这两个其实最终可以转化成一个问题,也就是小标题中写的!
先看G题: 这题可以算作奇偶线性基的板子
因为只能选择奇数或者偶数个数字,所以我们需要一个技巧:所有数字左移1位,然后空出的第0位用来记录这个数字的选择是否会影响奇偶性,一开始因为没有选择数字,所以选择任何一个数字都会改变奇偶性,所以所有数字的第0位都是1。然后按照普通插入的方式一样,插入完成后先求出不分奇偶的情况异或的最大值(也就是之前的板子).每个线性基的第0位同样表示选择这个线性基是否影响奇偶性.假设求出的全局最大值为mx.
如果mx[0] == 0说明全局最大值就是选择偶数个的最大值, 下面解决如果mx[0] == 0怎么解决选择奇数个的最大值.依旧是按照贪心的思想,这次我们从低位开始修改,找到尽量靠近低位的并且能改变奇偶性的基底去修改mx. 找到这个基底之后还需要贪心回去,假设到第k位才找到能改变奇偶性的基底,那么就说明[1, k - 1]位都不能改变奇偶性,所以在异或掉第k个基底改变奇偶性之后还需要检查[1, k - 1]的基底是否能把数字变得更大.这样就构造出了奇数个异或的最大值, mx[0] == 1的情况同理。
G题代码: 代码查看
再看I题:首先这题可以不用奇偶线性基,也可以转化成奇偶线性基
方法一: 不使用奇偶线性基
题意是说在一棵树上从a -> b的非简单路径, 只需要保证起点是a, 终点是b,中间的过程不用管,使得经过的所有点的异或和最大,如果一个点走过多次也要算多次。其实这可以算是个脑筋急转弯,因为树这个结构比较特殊。首先,树上是没有环的,所以自己画图可以发现,如果a - > b -> a只能走回头路,一来一回中间的点一定会走偶数次,提供不了异或值,能提供异或值只有b这个点。那么对于这个题目,我们一开始把 a -> b简单路径的点权的异或和算出来,因为简单路径是一定要走的。然后我们考虑走其他"多余"的路,假设一开始在简单路径点A上,走了一段距离到达点B, 然后原路返回到点A.那么这一次的贡献是B ^ A,因为一开始在A上已经算过了,所以最后回到A要算一次。每一次的折返都可以看成两个点的异或值,如果我们把所有边的两个端点的异或值加入线性基,那么每一次的折返一定都可以被表示,并且可以表示所有折返的情况。
形式化的: 记 xr = 简单路径所有点的异或值, 然后把每条边对应的两个点的异或值加入线性基(可以理解为由边拼接路径). 然后从最高位开始尝试能不能把xr变得更大,和求最大值的贪心一样。
I题方法一的代码 : 代码查看
方法二: 转化为奇偶线性基
通过方法一我们知道, 一来一回相当于不断地翻转两点的奇偶性。计算从 a -> b 的简单路径的点的总个数,记为cnt. 最后只有走过奇数次的点的才会被算上, 但是一次折返是同时改变两个点的奇偶性,奇数的个数减少2个, 不变, 或者增加两个。所以最后贡献的点个数的奇偶性等于cnt的奇偶性,并且最后贡献的点可能是任意的奇数个或者偶数个(因为我们可以随便走总能构造出各种符合奇偶性的贡献数)
所以如果cnt是奇数等价于求出在所有点中选出奇数个点使得最终的异或和最大,偶数同理。这样就成功转化成G题。
I题方法二的代码: 代码查看
我目前对线性基的学习和理解差不多就到这个程度,如果后续有好的题目或者更深入的理解我还会发文章的喵~
手写代码和文章不易,欢迎大家点赞~

825

被折叠的 条评论
为什么被折叠?



