线性基学习笔记

【前言】

在往上翻了翻 Dalao 们的文章,感觉都过于简略或深奥(因为我太弱了)

所以决定还是自己写一篇比较好。

线性基是向量空间的一组基,通常可以解决有关异或的一些题目。

简单点说,线性基不过是一个特别构造出来的集合,满足一些特殊性质。

利用这些特殊性质,我们可以利用线性基很好地求解异或最大值相关问题。

【前置芝士】

严格来说,线性基不需要任何前置知识,因为一个集合的构造和使用都过于简单且清晰。

但是究其本质,欲深刻了解线性基为何如此构造,为何有那些奇妙的性质,还是简单了解一下为好。

【线性空间与基】

这里只给出有关线性空间最简单的定义。

线性空间是一个关于以下两个运算封闭的向量集合:

  1. 向量加法 \(a+b\),其中 \(a,b\) 均为向量。
  2. 向量乘法 \(k\times a\),也称数乘运算,其中 \(a\) 为向量,\(k\) 为标量。

至于什么是线性相关线性无关生成子集等,还请读者自行查阅资料。

但是不得不提的是,有关的概念:

线性空间的极大线性无关子集被称为

然而,我们一般谈论的线性基都指的是异或空间中的基。

下面是重点。

【异或空间与基】

线性基具有以下几个性质:

  1. 线性基的元素能相互异或得到原集合的元素的所有相互异或得到的值。
  2. 线性基是满足性质 1 的最小的集合。
  3. 线性基没有异或和为 0 的子集。
  4. 线性基中每个元素的异或方案唯一,也就是说,线性基中不同的异或组合异或出的数都是不一样的。
  5. 线性基中每个元素的二进制最高位互不相同。

想要证明这些性质是十分简单的,但是想要理解这些性质的由来却不那么容易。

根据如上线性空间的定义,对于线性空间的生成子集,运算是不改变线性空间本身的。

由此来说,我们可以利用两者来缩小子集大小,直至它变为一个基。

恰好,初等行变换中利用的,也是上述两者。

那么高斯消元与基是否有什么联系呢?答案时肯定的。

简单来说,高斯消元本身就是构造线性基的过程,待系数矩阵变为简化阶梯矩阵时,我们就成功构造出了一个基。

异或空间下也是一样,我们可以将每个数看为二进制,将每个数看做矩阵的一行,转化为一个 01 矩阵。

那么只需要对其进行高斯消元,就可以得到一组线性基。

所以这也不难理解上述的最后一个性质了,那恰好也是简化阶梯矩阵的定义之一。

究其本质,构造线性基本身就是不断高斯消元的过程

【主要思想】

【构造】

有了上述基础,线性基的构造变得十分简单。

改变一下高斯消元的顺序,构造变得十分简单而清晰。

void Insert(int x){
	for(int i=31; i>=0 && x; i--)
		if(x >> i & 1){
			if(p[i]) x ^= p[i];
			else {p[i] = x; return;}
		}
}

从高位往低位枚举二进制下的每一个为 \(1\) 的位,如果没有数就插入,否则异或上那个数。

【查询最大值】

这是线性基的一个最简单的应用。

利用线性基的性质,我们不难想到一个贪心算法。

int ans = 0;
for(int i=31; i>=0; i--)
    if(ans < (ans ^ p[i])) ans ^= p[i];

同样从高位向低位枚举,如果异或上比原来大就异或。

【其他常见的操作】

  1. 查询 \(k\) 大异或和:简单的二进制拆分,如果 \(k\) 的第 \(i\) 位为 \(1\) 说明选了线性基中从小到大第 \(i\) 个数。
  2. 删除(很常见吗):可以记录下每一位 \(i\) 的“来源数字”,同时还可以记录下因为 \(i\) 而被筛掉的数字,删除掉第 \(i\) 位的“来源数字”同时可以让被筛掉的顶替上来,如果数字本身就没能插入到线性基中显然就不必处理了。

【常见套路】

  1. 根据上述性质 4,线性基能够产生的数的总数为 \(2^n\),其中 \(n\) 为线性基中的数的个数。
  2. 根据异或运算满足交换律和结合律,可以通过改变插入线性基的顺序,实现某些贪心算法。
  3. 根据异或偶数次等于没有异或的特殊性质求解问题。

【简单例题】

本文例题中所有给出的代码均为不完整代码,在保留代码核心部分的前提下尽量删减。

【例题一】

彩灯

\(n\) 个数能异或出的数的个数。

直接就是套路一。

一个小细节是 1 << i 如果超过 int 范围要写成 1LL << i

typedef long long LL;
const int N = 55;
const LL MOD = 2008;

int n, m, ans;
LL p[N];
char s[N];

void Insert(LL x){
	for(int i=50; i>=0; i--)
		if(x >> i & 1){
			if(p[i]) x ^= p[i];
			else {ans ++; p[i] = x; break;}
		}
}

int main(){
	scanf("%d %d", &n, &m);
	for(int i=1; i<=m; i++){
		scanf("%s", s);
		LL x = 0;
		for(int i=0; i<n; i++)
			if(s[i] == 'O') x |= (1LL << i);
		Insert(x);
	}	
	printf("%lld\n", (1LL << ans) % MOD);
	return 0;
}

【例题二】

元素

给定 \(n\) 个数,每个数有一个价值 \(c\)

要求从中选出某些数,使选出的数在不能异或出 \(0\) 的前提下价值总和最大,求价值之和的最大值。

套路二。

将数字按照价值从大到小排序,贪心的插入,看是否能插入线性基中。

不严谨的证明:

假设 \(val_b>val_c\),且 \(a,b\)\(a,c\) 组合均不能异或出 \(0\),但 \(a,b,c\) 可以。

因为 \(a\ {\rm xor}\ b\ {\rm xor}\ c=a\ {\rm xor}\ c\ {\rm xor}\ b\),所以不论怎么插入,结果都是最后一个不能选。

那么显然把最小的留到最后就好了。

bool cmp(node a, node b){return a.val > b.val;}

bool Insert(LL x){
	for(int i=63; i>=0; i--)
		if(x >> i & 1){
			if(p[i]) x ^= p[i];
			else {p[i] = x; return true;}
		}
	return false;
}

int main(){
	n = read();
	for(int i=1; i<=n; i++)
		a[i].num = read(), a[i].val = read();
	sort(a+1, a+n+1, cmp);
	int ans = 0;
	memset(p, 0, sizeof(p));
	for(int i=1; i<=n; i++)
		if(Insert(a[i].num)) ans += a[i].val;
	printf("%d\n", ans);
	return 0;
}

和这一题套路一样的还有:

新Nim游戏

给出 \(n\) 个数,选出某些数使得剩下的数无法异或为 \(0\),在这个前提下使得所选数之和最小。

做法为将 \(n\) 个数从大到小排序,依次插入线性基,不能插入就累计入答案。

【例题三】

Ivan and Burgers

给定一个长度为 \(n\) 的序列 \(a_1,a_2,\cdots,a_n\),有 \(q\) 个询问,每次给出 \((l,r)\)

求在 \(a_l\)\(a_r\) 中任选几个数,使异或和最大。\(n,m\leq 5\times 10^5\)

没有那么套路的题目。

针对每一次询问都重构一次线性基,复杂度为 \(O(n^2\log n)\)

用线段树套线性基,每次合并时暴力 \(O(\log n)\) 合并,复杂度可以达到 \(O(n\log^3 n)\)

因为最大异或和同样满足区间可加性,

所以分块、分治等解决 \(\rm RMQ\) 问题的算法套线性基可能可以达到 \(O(n\log^2 n)\)

但是其实是有 \(O(n\log n)\) 做法的。

对于离线下来的询问按 \(r\) 从小到大排序,每次插入序号不高于当前询问的 \(r\) 的数字。

在线性基中记录 \(p(i)\) 的同时记录一个 \(pos(i)\) 表示这个数从序号为 \(pos(i)\) 的数字来。

每次询问 \((l,r)\) 只要 \(pos(i)\geq l\) 就可以异或上这个数字。

证明是根据贪心思想,如果一个数字的“存活时间”比它长,又和它同样有用,那么原数显然被单调队列了。

但是被单调队列的数字可能可以替代掉更低位的数字,这是值得注意的细节。

这样每个数字只插入一次,时间复杂度为 \(O(n\log n)\)

然而确乎是可以在线的,对于每个 \(r\) 都预处理一遍就好了。(就是用空间换时间)

typedef long long LL;
const int N = 500010;

int n, m;
int a[N], ans[N];
int pos[30], p[30];
struct Query{int l, r, id;}b[N];

bool cmp(Query a, Query b){return a.r < b.r;}

void Insert(int x, int c){
	for(int i=20; i>=0; i--)
		if(x >> i & 1){
			if(!p[i]) {p[i] = x; pos[i] = c; break;}
			if(pos[i] < c) swap(pos[i], c), swap(p[i], x);
			x ^= p[i];
		}
}

int Ask(int l){
	int ans = 0;
	for(int i=20; i>=0; i--)
		if(pos[i] >= l && ans < (ans ^ p[i])) ans ^= p[i];
	return ans;
}

int main(){
	n=read();
	for(int i=1; i<=n; i++) a[i] = read();
	m=read();
	for(int i=1; i<=m; i++)
		b[i].l=read(), b[i].r=read(), b[i].id = i;
	sort(b+1, b+m+1, cmp);
	memset(p, 0, sizeof(p));
	int now = 1;
	for(int i=1; i<=m; i++){
		while(now <= b[i].r){
			Insert(a[now], now);
			now ++;
		}
		ans[b[i].id] = Ask(b[i].l);
	}
	for(int i=1; i<=m; i++) printf("%d\n", ans[i]);
	return 0;
}

【例题四】

装备购买

太长了,自己看

套路还是和【例题二】一样的套路呢。

那为什么单独拿出来说呢,因为它从整数域变为了实数域。

那怎么办呢,回归线性基本质,用高斯消元处理就好了。

根据之前的套路,每次找到第 \(i\) 位不为 \(0\) 且代价最小的作为主元去消元就好啦。

所以学算法还是要从本质开始呢。

P.S. 这题卡精度,用了 long double 才过的...。

int n, m;
int c[N];
double t;
long double a[N][N], eps= 1e-8;

int main(){
    scanf("%d %d", &n, &m);
    for(int i=1; i<=n; i++)
        for(int j=1; j<=m; j++){
            scanf("%lf", &t);
            a[i][j] = t;
        }
    for(int i=1; i<=n; i++) scanf("%d", &c[i]);
    int cnt=0, ans=0;
    for(int i=1; i<=m; i++){
        int k=0;
        for(int j=cnt+1; j<=n; j++)
            if(fabs(a[j][i]) > eps && (!k || c[j] < c[k])) k = j;
        if(!k) continue;
        cnt ++; ans += c[k];
        for(int j=1; j<=m; j++) swap(a[cnt][j], a[k][j]);
        swap(c[cnt], c[k]);
        for(int j=1; j<=n; j++)
            if(fabs(a[j][i]) > 0 && j != cnt){
                long double tmp = a[j][i] / a[cnt][i];
                for(int l=i; l<=m; l++)
                    a[j][l] -= tmp * a[cnt][l];
            }
    }
    printf("%d %d\n", cnt, ans);
    return 0;
}

【例题五】

最大XOR和路径

\(n\) 个点 \(m\) 条边的无向图上节点 \(1\)\(n\) 中路径中的最大异或路径。

可以重复经过某条路径,\(n\leq 5\times 10^4,m\leq 10^5\)

显然路径可以拆成一条简单路径和几个简单的环。

我们称那条简单路径为主路,那么确定了主路之后,目标就是求主路和环的最大异或路径。

或许我们可以把所有环都丢到线性基中去,现在问题变为确定某条主路。

或许可以任意指定一条主路,因为两条不同的主路显然成环。

异或上那个换之后,相当于选择了另一条主路,于是问题被等效替代了。

其实利用了套路三呢。

主要流程:

  1. 确定所有环的异或值并丢到线性基中。
  2. 同时统计节点的异或前缀和 \(sum(i)\)。(即从 \(1\) 到这个节点的一条简单路径的异或和)
  3. \(ans=sum(n)\),然后开始尝试用线性基刷新 \(ans\)

时间复杂度是很优秀的 \(O((n+m)\log n)\)

typedef long long LL;
const int N = 50010;
const int M = 100010;

int n, m;
int head[N], cnt=0;
LL sum[N], p[100];
bool vis[N];
struct Edge{int nxt, to; LL val;}ed[M << 1];

void add(int u, int v, LL w){
	ed[++cnt] = (Edge){head[u], v, w};
	head[u] = cnt;
}

void Insert(LL x){
	for(int i=63; i>=0; i--)
		if(x >> i & 1){
			if(!p[i]) {p[i] = x; return;}
			x ^= p[i];
		}
}

void dfs(int u, int fa, LL res){
	sum[u] = res;
	vis[u] = true;
	for(int i=head[u]; i; i=ed[i].nxt){
		int v=ed[i].to;LL w = ed[i].val;
		if(v == fa) continue;
		if(!vis[v]) dfs(v, u, res ^ w);
		else Insert(res ^ w ^ sum[v]);
	}
}

int main(){
	n=read(), m=read();
	for(int i=1; i<=m; i++){
		int u=read(), v=read();
		LL w; scanf("%lld", &w);
		add(u, v, w);
		add(v, u, w);
	}
	dfs(1, 0, 0);
	LL ans = sum[n];
	for(int i=63; i>=0; i--)
		if(ans < (ans ^ p[i])) ans ^= p[i];
	printf("%lld\n", ans);
	return 0;
}

【例题六】

幸运数字

多次询问,求树上两点之间路径的最大异或和。

方法多样,但都逃不过线性基。

显然只要维护两个线性基并暴力合并就好。

那么如何快速处理树上两点之间路径呢?方法很多。

  1. 树上倍增,类似于倍增求 LCA。\(O(n\log^2 n)\)
  2. 点分治,离线下来后每次暴力合并两点之间路径。\(O(n\log^2 n)\)
  3. 贪心,类似例题五,如果 LCA 的深度不大于 \(pos(i)\) 就可以插入。\(O(n\log^2 n)\)
  4. 树剖 + 线段树套线性基维护。\(O(n\log^4 n)\)

常数最小的是 3,常数最大的显然是 4。

但是 4 的好处是修改很方便,线段树暴力往上 push up 就好了。

这里给出 2,3 的代码:

//点分治
typedef long long LL;
const int N = 20010;
const int M = 200010;

int n, m;
int head[N], cnt;
LL a[N], p[N][70];
LL ans[M];
bool Ask[M];
vector<pair<int, int> > q[N];
struct Edge{int nxt, to;}ed[N<<1];

void add(int u, int v){
	ed[++cnt]=(Edge){head[u], v};
	head[u] = cnt;
}

int rt;
int sz[N], G[N], sum;
bool vis[N];

void Get_Size(int u,int fa){
	G[++sum] = u;
	sz[u] = 1;
	for(int i=head[u]; i; i=ed[i].nxt){
		int v=ed[i].to;
		if(vis[v] || v == fa) continue;
		Get_Size(v, u);
		sz[u] += sz[v];
	}
}

int Get_root(int u){
	sum = 0;
	Get_Size(u, 0);
	for(int i=sum; i>=1; i--)
		if(sz[G[i]] >= (sum / 2)) return G[i]; 
}

int tot;
int Col[N], Pos[N];

void Insert(LL p[70], LL x){
	for(int i=63; i>=0 && x; i--)
		if(x >> i & 1){
			if(!p[i]) {p[i] = x; return;}
			x ^= p[i];
		}
}

void Dfs_Build(int u, int fa, int C){
	Col[u] = C;
	Pos[++tot] = u;
	for(int i=0; i<=63; i++) p[u][i] = p[fa][i];
	Insert(p[u], a[u]);
	for(int i=head[u]; i; i=ed[i].nxt){
		int v=ed[i].to;
		if(vis[v] || v == fa) continue;
		Dfs_Build(v, u, C);
	}
}

LL tmp[70];

LL work(int l, int r){
	for(int i=0; i<=63; i++) tmp[i] = p[l][i];
	for(int i=0; i<=63; i++) Insert(tmp, p[r][i]);
	LL ans = 0;
	for(int i=63; i>=0; i--)
		if(ans < (ans ^ tmp[i])) ans ^= tmp[i];
	return ans;
}

void Calc(int u){
	vis[u] = true;
	for(int i=0; i<=63; i++) p[u][i] = 0;
	Insert(p[u], a[u]);
	Col[u] = 0;
	tot = 0;
	Pos[++tot] = u;
	for(int i=head[u]; i; i=ed[i].nxt){
		int v=ed[i].to;
		if(vis[v]) continue;
		Dfs_Build(v, u, v);
	}
	for(int i=1; i<=tot; i++){
		int l = Pos[i];
		for(int j=0; j<(int)q[l].size(); j++){
			int r = q[l][j].first;
			int id = q[l][j].second;
			if(Ask[id] || Col[l] == Col[r]) continue;
			Ask[id] = true;
			ans[id] = work(l, r);
		}
	}
}

void dfs(int u){
	vis[u] = true;
	Calc(u);
	for(int i=head[u]; i; i=ed[i].nxt){
		int v = ed[i].to;
		if(vis[v]) continue;
		rt = Get_root(v);
		dfs(rt);
	}
}

int main(){
	n=read(), m=read();
	for(int i=1; i<=n; i++) a[i] = read();
	for(int i=1; i<n; i++){
		int u=read(), v=read();
		add(u, v), add(v, u);
	}
	memset(Ask, false, sizeof(Ask));
	memset(ans, 0, sizeof(ans));
	for(int i=1; i<=m; i++){
		int u=read(), v=read();
		if(u == v){
			Ask[i] = true;
			ans[i] = a[u];
			continue;
		}
		q[u].push_back(make_pair(v, i));
		q[v].push_back(make_pair(u, i));
	}
	rt =  Get_root(1);
	dfs(rt);
	for(int i=1; i<=m; i++) 
		printf("%lld\n", ans[i]);
	return 0;
}

方法三更好写而且跑得飞快:

//贪心
typedef long long LL;
const int N = 20010;
const int M = 200010;

int n, m;
int head[N], cnt;
int dep[N];
LL a[N], p[N][70], G[70];
int pos[N][70];
struct Edge{int nxt, to;}ed[N<<1];

void add(int u, int v){
	ed[++cnt]=(Edge){head[u], v};
	head[u] = cnt;
}

struct LCA{
	int son[N], fa[N], top[N], sz[N];
	void dfs1(int u, int Fa){
		dep[u] = dep[Fa] + 1;
		fa[u] = Fa;
		sz[u] = 1; 
		for(int i=head[u]; i; i=ed[i].nxt){
			int v=ed[i].to;
			if(v == Fa) continue;
			dfs1(v, u);
			sz[u] += sz[v];
			if(sz[v] > sz[son[u]]) son[u] = v;
		}
	}
	void dfs2(int u, int Top){
		top[u] = Top;
		if(!son[u]) return;
		dfs2(son[u], Top);
		for(int i=head[u]; i; i=ed[i].nxt){
			int v=ed[i].to;
			if(v == fa[u] || v == son[u]) continue;
			dfs2(v, v);
		}
	}
	void Pre(){dfs1(1, 0); dfs2(1, 1);}
	int Lca(int x, int y){
		while(top[x] != top[y]){
			if(dep[top[x]] < dep[top[y]]) swap(x, y);
			x = fa[top[x]];
		}
		if(dep[x] > dep[y]) swap(x, y);
		return x; 
	}
} T;

void Insert(int u, LL p[70], int pos[70]){
	LL x = a[u];
	for(int i=63; i>=0 && x; i--)
		if(x >> i & 1){
			if(!p[i]) {p[i] = x; pos[i] = u; break;}
			if(dep[u] > dep[pos[i]]) 
				swap(u, pos[i]), swap(x, p[i]);
			x ^= p[i];
		}
}

void dfs(int u, int fa){
	for(int i=0; i<=63; i++) 
		p[u][i] = p[fa][i], pos[u][i] = pos[fa][i];
	Insert(u, p[u], pos[u]);
	for(int i=head[u]; i; i=ed[i].nxt){
		int v=ed[i].to;
		if(v == fa) continue;
		dfs(v, u);
	}
}

int main(){
	n=read(), m=read();
	for(int i=1; i<=n; i++) a[i] = read();
	for(int i=1; i<n; i++){
		int u=read(), v=read();
		add(u, v), add(v, u);
	}
	T.Pre();
	dfs(1, 0);
	while(m--){
		int x=read(), y=read();
		int l = T.Lca(x, y);
		for(int i=0; i<=63; i++)
			if(dep[pos[x][i]] >= dep[l]) G[i] = p[x][i];
			else G[i] = 0;
		for(int i=0; i<=63; i++)
			if(dep[pos[y][i]] >= dep[l]){
				LL x = p[y][i];
				for(int j = i; j>=0 && x; j--)
					if(x >> j & 1){
						if(!G[j]) {G[j] = x; break;}
						x ^= G[j];
					}
			}
		LL ans = 0;
		for(int i=63; i>=0; i--)
			if(ans < (ans ^ G[i])) ans ^= G[i];
		printf("%lld\n", ans);	
	}
	return 0;
}

【总结】

线性基本质上就是一个高斯消元处理后的集合。

因为其独特的性质而被广泛运用于异或问题的最值当中。

入门笔记至此结束。

引用资料&特别鸣谢:

  1. OI-wiki 线性基部分
  2. 题解 CF1100F 【Ivan and Burgers】
  3. 题解 P3292 【[SCOI2016]幸运数字】
  4. [SCOI2016] 幸运数字
  5. 线性基详解
  6. 《算法竞赛进阶指南》

完结撒花

posted @ 2021-03-12 00:27  LPF'sBlog  阅读(190)  评论(0编辑  收藏  举报