【9*】线性基学习笔记
前言
线性基是一个好用的东西,多用于维护异或类操作。以后碰到异或类题目,可以考虑线性基,好写好调。
此类知识点大纲中并未涉及,所以【9】是我自己的估计,后带星号表示估计,仅供参考。
线性基
定义
向量:向量是一种既有大小又有方向的量。
线性组合:设 \(a_1,a_2,\dots a_e(e\ge1)\) 是域 \(P\) 上线性空间 \(V\) 中的有限个向量。若 \(V\) 中向量 \(a\) 可以表示为 \(a=k_1a_1+k_2a_2+\dots+k_ea_e(k\in P)\),则 \(a\) 是向量组 \(a_1,a_2,\dots a_e\) 的一个线性组合。也就是说,\(a\) 可由向量组 \(a_1,a_2,\dots a_e\) 线性表示。
例如,在三维线性空间 \(P3\) 中,向量 \(a=(x_1,x_2,x_3)\) 可由向量组 \(a_1=(1,0,0),a_2=(0,1,0),a_3=(0,0,1)\) 线性表示成 \(a=x_1a_1+x_2a_2+x_3a_3\),\(a\) 是向量组 \(a_1=(1,0,0),a_2=(0,1,0),a_3=(0,0,1)\) 的一个线性组合。
线性相关:在线性代数里,向量空间的一组元素中,若没有向量可以用组内有限个其他向量的线性组合所表示,则称为线性无关,否则称为线性相关。
例如:\((4,1,9),(1,4,2),(5,5,11)\) 这一组向量线性相关,因为 \((4,1,9)+(1,4,2)=(5,5,11)\)。而 \((0,1,0),(2,1,0),(0,1,1)\) 这一组向量线性无关,因为没有向量可以用组内有限个其他向量的线性组合所表示。
线性基:线性空间 \(V\) 的一个极大线性无关组为 \(V\) 的一组线性基,简称基。
通俗来讲,就是在线性空间 \(V\) 中找到一组包含元素最多的线性无关组。
性质
性质 \(1\):\(n\) 维的线性空间 \(V\) 中任意 \(n+1\) 个向量线性相关。
性质 \(2\):\(n\) 维的线性空间 \(V\) 中任意 \(n\) 个线性无关的向量构成一组基。
性质 \(3\):若 \(V\) 中的任意向量均可被向量组 \(a_1,a_2,\dots,a_n\) 线性表示,则其是 \(V\) 的一个基。
性质 \(4\):\(V\) 中任意线性无关向量组 \(a_1,a_2,\dots,a_m\) 均可通过插入一些向量使得其变为 \(V\) 的一个基。
实现方式
贪心构造法
由性质 \(1,2\),我们得到线性基中有且仅有 \(n\) 个元素。
我们考虑对于每个维度进行维护。由性质 \(3\),线性空间内任何元素都可以由线性基表示出。因此,线性基中包含的元素中,在每一个维度上,至少存在一个元素在该维度的向量不为 \(0\)。
我们把线性基中不为 \(0\) 的最高维为 \(i\) 的元素记录在 \(bas[i]\) 中。如果 \(bas[i]\ne0\),那么就至少存在一个元素在维度 \(i\) 的向量不为 \(0\)。
由性质 \(4\),我们把每个依次元素插入线性基。
对于插入的元素,我们从高维度往低维度遍历。假设现在遍历到维度 \(i\),即当前向量不为 \(0\) 的最高维为 \(i\)。
如果 \(bas[i]=0\),那么我们直接将这个向量存入 \(bas[i]\) 中。当前向量不为 \(0\) 的最高维为 \(i\),符合 \(bas[i]\) 的条件。
如果 \(bas[i]\ne0\),那么我们利用 \(bas[i]\) 消去当前元素的第 \(i\) 维。已经存在一个符合条件的向量了,没有必要再存储一个。但是,这个元素可能在更低维存在不为 \(0\) 的向量,所以我们消去最高维,使较低维称为最高维,继续重复这个过程,直到能被插入或者遍历完所有维。这样做不会导致错误,因为当我们使用消去过的结果时,只需要把它还原成原始数据加上一些用于消去的数据即可,这两个数据都在线性空间中。
void insert(long long x)
{
for(int i=1;i<=m;i++)
if(fabs(a[x].v[i])>=eps)
{
if(fabs(bas[i][i])>=eps)
{
double div=a[x].v[i]/bas[i][i];
for(int j=1;j<=m;j++)a[x].v[j]-=bas[i][j]*div;
}
else
{
for(int j=1;j<=m;j++)
bas[i][j]=a[x].v[j];
q[i]=a[x].c;
break;
}
}
}
时间复杂度为 \(O(n)\)。
应用
维护选取任意个数的最大异或和:例题 \(1,4,5,6\)。
维护线性空间大小:例题 \(2,7\)。
维护向量:例题 \(3\)。
维护异或和之和:例题 \(7\)。
例题
例题 \(1\):
我们把一个整数的 \(n\) 为二进制看作一个 \(n\) 维的向量,我们维护这些向量的异或线性基。唯一不同的是这里消去可以直接使用异或,减少了不少码量。
这是一个非常常用的 trick,几乎所有的线性基维护异或类题目都使用了这个 trick。
选取任意个数的最大异或和,相当于在线性基中选取一些数,使它们异或和最大。我们贪心地从高位往低位,如果结果第 \(i\) 位上为 \(0\),则异或 \(bas[i]\)。这个贪心是对的,因为根据线性基的构造过程,\(bas[i]\) 的第 \(i+1\sim n\) 位均为 \(0\),不会影响高位的选择。
#include <bits/stdc++.h>
using namespace std;
long long n,a,bas[100],ans=0;
void insert(long long x)
{
for(int i=50;i>=0;i--)
if((x>>i)&1)
{
if(bas[i]!=0)x^=bas[i];
else
{
bas[i]=x;
break;
}
}
}
int main()
{
scanf("%lld",&n);
for(int i=1;i<=n;i++)scanf("%lld",&a),insert(a);
for(int i=50;i>=0;i--)
if(!((ans>>i)&1))ans^=bas[i];
printf("%lld",ans);
return 0;
}
例题 \(2\):
依旧使用例题 \(1\) 的 trick,维护异或线性基。
我们发现,如果 \(bas[i]\ne0\),那么我们一定可以通过控制 \(bas[i]\) 的选取或者不选取来控制第 \(i\) 位为 \(0\) 或 \(1\)。只要 \(bas[i]\ne0\),一定存在一种 \(bas[i]\) 的选法使第 \(i\) 位为 \(0\),也一定存在一种 \(bas[i]\) 的选法使第 \(i\) 位为 \(1\)。
根据线性基的构造过程,\(bas[i]\) 的第 \(i+1\sim n\) 位均为 \(0\),不会影响高位的选择,所以从高位遍历到低位,每一位的选法彼此独立,使用乘法原理即可。如果存在 \(k\) 个 \(bas[i]\ne0\),则答案为 \(2^k\)。
#include <bits/stdc++.h>
using namespace std;
long long n,m,bas[100],ans=1,mod=2008;
char s[100];
void insert(long long x)
{
for(int i=50;i>=0;i--)
if((x>>i)&1)
{
if(bas[i]!=0)x^=bas[i];
else
{
bas[i]=x;
break;
}
}
}
int main()
{
scanf("%lld%lld",&n,&m);
for(int i=1;i<=m;i++)
{
long long a=0;
scanf("%s",s+1);
for(int i=1;i<=n;i++)
if(s[i]=='O')a=(a<<1)|1;
else if(s[i]=='X')a=(a<<1);
insert(a);
}
for(int i=50;i>=0;i--)
if(bas[i])ans=ans*2%mod;
printf("%lld",ans);
return 0;
}
例题 \(3\):
线性基模板题,不多赘述。
#include <bits/stdc++.h>
using namespace std;
struct val
{
double v[600];
long long c;
}a[600];
long long n,m,ans1=0,ans2=0,q[600];
double bas[600][600],eps=1e-5;
char s[600];
bool cmp(struct val a,struct val b)
{
return a.c<b.c;
}
void insert(long long x)
{
for(int i=1;i<=m;i++)
if(fabs(a[x].v[i])>=eps)
{
if(fabs(bas[i][i])>=eps)
{
double div=a[x].v[i]/bas[i][i];
for(int j=1;j<=m;j++)a[x].v[j]-=bas[i][j]*div;
}
else
{
for(int j=1;j<=m;j++)
bas[i][j]=a[x].v[j];
q[i]=a[x].c;
break;
}
}
}
int main()
{
scanf("%lld%lld",&n,&m);
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
scanf("%lf",&a[i].v[j]);
for(int i=1;i<=n;i++)scanf("%lld",&a[i].c);
sort(a+1,a+n+1,cmp);
for(int i=1;i<=n;i++)insert(i);
for(int i=1;i<=m;i++)
if(fabs(bas[i][i])>=eps)ans1++,ans2+=q[i];
printf("%lld %lld",ans1,ans2);
return 0;
}
例题 \(4\):
题目要求使异或和尽可能大,自然想到线性基。由于题目中有单点修改,区间操作,自然考虑线段树或树状数组。
为了便于理解,我使用了线段树,对于 P 哥的每一个桶,开一个线性基。在桶里添加一个球,相当于在线性基中插入一个元素。区间查询只需要合并出最后的线性基,即可查询。
对于线段树的每一个节点,我们维护 \([l,r]\) 的桶合并后的线性基,相当于合并两个子区间的线性基。
接下来,就是一个重要且常用的知识点:线性基合并。合并线性基 \(y\) 到线性基 \(x\),我们只需要把 \(y\) 中的每个元素插入到 \(x\) 中。这个过程的时间复杂度是 \(O(\log^2a)\),其中 \(a\) 为值域。
总的时间复杂度为 \(O(n\log n\log^2a)\)。
#include <bits/stdc++.h>
using namespace std;
struct node
{
long long bas[50];
}tr[400000];
long long n,m,op,l,r,lc[400000],rc[400000],root=0,cnt=0;
void insert(long long x,struct node &p)
{
for(int i=32;i>=0;i--)
if((x>>i)&1)
{
if(p.bas[i])x^=p.bas[i];
else
{
p.bas[i]=x;
break;
}
}
}
void pushup(long long x)
{
tr[x]=tr[lc[x]];
for(int i=32;i>=0;i--)
if(tr[rc[x]].bas[i])insert(tr[rc[x]].bas[i],tr[x]);
}
struct node merge(struct node a,struct node b)
{
struct node res=a;
for(int i=32;i>=0;i--)
if(b.bas[i])insert(b.bas[i],res);
return res;
}
void build(long long &now,long long l,long long r)
{
now=++cnt;
if(l==r)return;
long long mid=(l+r)>>1;
build(lc[now],l,mid),build(rc[now],mid+1,r);
pushup(now);
}
void update(long long now,long long l,long long r,long long x,long long p)
{
if(l==r)
{
insert(x,tr[now]);
return;
}
long long mid=(l+r)>>1;
if(p<=mid)update(lc[now],l,mid,x,p);
else if(p>=mid+1)update(rc[now],mid+1,r,x,p);
pushup(now);
}
struct node query(long long now,long long l,long long r,long long lx,long long rx)
{
if(l>=lx&&r<=rx)return tr[now];
long long mid=(l+r)>>1;
if(lx>=mid+1)return query(rc[now],mid+1,r,lx,rx);
if(rx<=mid)return query(lc[now],l,mid,lx,rx);
return merge(query(lc[now],l,mid,lx,rx),query(rc[now],mid+1,r,lx,rx));
}
long long getmx(long long l,long long r)
{
long long ans=0;
struct node res=query(root,1,n,l,r);
for(int i=32;i>=0;i--)
if(!((ans>>i)&1))ans=ans^res.bas[i];
return ans;
}
int main()
{
scanf("%lld%lld",&m,&n);
build(root,1,n);
for(int i=1;i<=m;i++)
{
scanf("%lld%lld%lld",&op,&l,&r);
if(op==1)update(root,1,n,r,l);
else if(op==2)printf("%lld\n",getmx(l,r));
}
return 0;
}
例题 \(5\):
同样的,需要维护最大异或值,考虑线性基。又由于静态树上查询路径,考虑倍增。
我们用倍增求出查询点 \(x,y\) 的 LCA, 并记录 \(bas[x][i]\) 表示从点 \(i\) 开始,它以及它的 \(2^i\) 级祖先的线性基。我们很容易得到下面转移方程:
其中 \(merge\) 操作表示使用线性基合并操作合并两个线性基。
在查询时,我们只需要将路径上倍增查询到的的所有线性基合并起来,直接在合并后的线性基求答案即可。注意容易被卡空间。时间复杂度为 \(O(n\log n\log^2 a)\)。
这不是最优的做法,可以参考这篇 题解 学习更优做法。
#include <bits/stdc++.h>
using namespace std;
struct edge
{
int v,nxt;
}e[60000];
struct lb
{
long long bas[61];
}b[20001][21];
int n,m,x,y,dep[30000],f[30000][30],h[30000],cnt=0;
long long a[30000];
void add_edge(int u,int v)
{
e[++cnt].nxt=h[u];
e[cnt].v=v;
h[u]=cnt;
}
void insert(long long x,struct lb &p)
{
for(int i=60;i>=0;i--)
if((x>>i)&1)
{
if(p.bas[i])x^=p.bas[i];
else
{
p.bas[i]=x;
break;
}
}
}
struct lb merge(struct lb a,struct lb b)
{
struct lb res=a;
for(int i=60;i>=0;i--)
if(b.bas[i])insert(b.bas[i],res);
return res;
}
void dfs(int x,int fa)
{
f[x][0]=fa,insert(a[x],b[x][0]);
for(int i=1;i<=15;i++)
if(f[x][i-1])f[x][i]=f[f[x][i-1]][i-1],b[x][i]=merge(b[x][i-1],b[f[x][i-1]][i-1]);
else break;
for(int i=h[x];i;i=e[i].nxt)
{
if(e[i].v==fa)continue;
dep[e[i].v]=dep[x]+1;
dfs(e[i].v,x);
}
}
int lca(int x,int y)
{
if(dep[x]>dep[y])swap(x,y);
long long c=dep[y]-dep[x];
for(int i=15;i>=0;i--)
if((c>>i)&1)y=f[y][i];
if(x==y)return x;
for(int i=15;i>=0;i--)
if(f[x][i]!=f[y][i])x=f[x][i],y=f[y][i];
return f[x][0];
}
struct lb query(int x,int y)
{
struct lb ans=b[x][0];
long long c=dep[y]-dep[x];
for(int i=15;i>=0;i--)
if((c>>i)&1)ans=merge(ans,b[y][i]),y=f[y][i];
return ans;
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)scanf("%lld",&a[i]);
for(int i=1;i<=n-1;i++)
{
scanf("%d%d",&x,&y);
add_edge(x,y),add_edge(y,x);
}
dfs(1,0);
for(int i=1;i<=m;i++)
{
scanf("%d%d",&x,&y);
long long l=lca(x,y),ans=0;
struct lb res=merge(query(l,x),query(l,y));
for(int i=60;i>=0;i--)
if(!((ans>>i)&1))ans^=res.bas[i];
printf("%lld\n",ans);
}
return 0;
}
例题 \(6\):
线性基在图中的运用。
我们发现,从一条路径上走到一个环,再原路返回,经过的非环路径不会影响结果。因为过去经过一次,回来经过一次,刚好抵消。
所以,最终的路径为一条 \(1\to n\) 的路径和若干个环组成。我们利用 DFS 求出每一个环的边权异或和,丢进线性基,再以任意一条 \(1\to n\) 的路径作为查询初始值(具体见代码,证明见例题 \(7\)),查询最大异或和。
这条 \(1\to n\) 的路径可以随便选。假设路径 \(A\) 比路径 \(B\) 优秀一些,而我们最开始选择了路径 \(B\)。显然,\(A\) 与 \(B\) 共同构成了一个环(\(1\to n\to1\))。如果我们发现路径 \(A\) 要优秀一些,那么我们用 \(B\) 异或上这个大环,就会得到我们想要的 \(A\)。
#include <bits/stdc++.h>
using namespace std;
struct edge
{
long long v,nxt,d;
}e[300000];
long long n,m,u,v,d,h[300000],bas[300000],book[300000],dis[300000],cnt=1,ans=0;
void add_edge(long long u,long long v,long long d)
{
e[++cnt].nxt=h[u];
e[cnt].v=v;
e[cnt].d=d;
h[u]=cnt;
}
void insert(long long x)
{
for(int i=64;i>=0;i--)
if((x>>i)&1)
{
if(bas[i]!=0)x^=bas[i];
else
{
bas[i]=x;
break;
}
}
}
void dfs(long long now,long long pre,long long dn)
{
book[now]=1,dis[now]=dn;
for(int i=h[now];i;i=e[i].nxt)
if(i!=(pre^1))
{
if(book[e[i].v])insert(dis[now]^dis[e[i].v]^e[i].d);
else dfs(e[i].v,i,dn^e[i].d);
}
}
int main()
{
scanf("%lld%lld",&n,&m);
for(int i=1;i<=m;i++)
{
scanf("%lld%lld%lld",&u,&v,&d);
add_edge(u,v,d),add_edge(v,u,d);
}
dfs(1,0,0);
ans=dis[n];
for(int i=64;i>=0;i--)
if(!((ans>>i)&1))ans^=bas[i];
printf("%lld",ans);
return 0;
}
例题 \(7\):
CF724G Xor-matic Number of the Graph
最难的一题。
直接维护异或和之和,显然不可能。由于二进制位相互独立,我们考虑维护每一个二进制位的出现次数,再乘以位权加入结果。
对于路径的维护,我们参考例题 \(6\),分成主路径和若干个环。记路径 \(1\to n\) 的边的异或和为 \(dis[i]\),任意两点 \(x,y\) 之间的路径的边的异或和可以由 \(dis[x]\oplus dis[y]\) 得到。如果 \(1\to x\) 和 \(1\to y\) 在点 \(z\) 前有重复部分,异或后重复部分消去,留下 \(x\to z\to y\)。否则,如果没有重复部分,则异或后变为 \(x\to 1\to y\)。结论成立。
如果有环的二进制第 \(i\) 位为 \(1\),那么我们肯定可以使第 \(i\) 位为 \(1\)。如果目前答案第 \(i\) 位为 \(0\),直接异或,否则不异或。因此,任意一条 \(x\to y\) 的简单路径都第 \(i\) 位都可以为 \(1\),有 \(C_{n}^2\) 中选法。除了第 \(i\) 位,其他位可以任意选择。这样就转化为了例题 \(2\),方案数为 \(2^{x-1}\),其中 \(x\) 为线性基大小,即 \(bas[i]\ne0\) 的数的数量。运用乘法原理,再乘上权值,这一位的贡献为:
如果没有环的二进制第 \(i\) 位为 \(1\),那么走环对于这一位没有影响。此时线性基可以随便选,方案数为 \(2^x\),其中 \(x\) 为线性基大小。我们只需要统计有多少条简单路径的异或和的第 \(i\) 位为 \(1\) 即可。
结合路径的计算式,\(dis[x]\oplus dis[y]\),我们继续进行拆位。对每一个 \(dis\),如果第 \(i\) 位为 \(1\),则将 \(w_{i,1}\) 加 \(1\);如果第 \(i\) 位为 \(0\),则将 \(w_{i,0}\) 加 \(1\)。\(w_{i,1}\) 表示第 \(i\) 位为 \(1\) 的 \(dis\) 的个数,\(w_{i,0}\) 表示第 \(i\) 位为 \(0\) 的 \(dis\) 的个数。如果要让一条路径的权值 \(dis[x]\oplus dis[y]\) 第 \(i\) 位为 \(1\),则必须在 \(w_{i,1}\) 中和 \(w_{i,0}\) 中各选一个。根据乘法原理,方案数为 \(w_{i,0}\times w_{i,1}\)。运用乘法原理,再乘上权值,这一位的贡献为:
代码中这一部分实现方式略微不同,其中 \(ct[i]\) 表示 \(w_{i,1}\),\(pos-ct[i]\) 表示 \(w_{i,0}\)。
注意图不一定联通,对于每个联通块分别计算即可。
#include <bits/stdc++.h>
using namespace std;
struct edge
{
long long v,nxt,d;
}e[800000];
long long n,m,u,v,d,h[800000],bas[100],book[800000],dis[800000],ct[100],p[800000],cnt=1,ans=0,siz=0,pos=0;
const long long mod=1e9+7;
void add_edge(long long u,long long v,long long d)
{
e[++cnt].nxt=h[u];
e[cnt].v=v;
e[cnt].d=d;
h[u]=cnt;
}
void insert(long long x)
{
for(int i=63;i>=0;i--)
if((x>>i)&1)
{
if(bas[i]!=0)x^=bas[i];
else
{
siz++,bas[i]=x;
break;
}
}
}
void dfs(long long now,long long pre,long long dn)
{
book[now]=1,dis[now]=dn,pos++;
for(int i=63;i>=0;i--)
if((dn>>i)&1)ct[i]++;
for(int i=h[now];i;i=e[i].nxt)
if(i!=(pre^1))
{
if(book[e[i].v])insert(dis[now]^dis[e[i].v]^e[i].d);
else dfs(e[i].v,i,dn^e[i].d);
}
}
int main()
{
scanf("%lld%lld",&n,&m);
p[0]=1;
for(int i=1;i<=63;i++)p[i]=p[i-1]*2%mod;
for(int i=1;i<=m;i++)
{
scanf("%lld%lld%lld",&u,&v,&d);
add_edge(u,v,d),add_edge(v,u,d);
}
for(int i=1;i<=n;i++)
if(!book[i])
{
memset(bas,0,sizeof(bas)),memset(ct,0,sizeof(ct));
pos=siz=0;
dfs(i,0,0);
long long flag=0;
for(int j=63;j>=0;j--)flag|=bas[j];
for(int j=63;j>=0;j--)
if((flag>>j)&1)ans=(ans+pos*(pos-1)%mod*500000004%mod*p[siz-1]%mod*p[j]%mod)%mod;
else ans=(ans+ct[j]*(pos-ct[j])%mod*p[siz]%mod*p[j]%mod)%mod;
}
printf("%lld",ans);
return 0;
}
后记
我们发现,线性基什么都可以套。把线性基当做元素,就可以套进很多题目。