codeforces 888G 题解+做题心得 (最小生成树,trie,按位)

题意

给定 \(n\) 和长度为 \(n\) 的序列 \(a_i\)

对于 \(i,j\),给他们连一条权值为 \(a_i\oplus a_j\)\(\oplus\) 代表异或

求这个图的最小生成树

\(n\le 2e5, a_i\le 1e9\)

碎碎念

上次做这题是 6 年前,彼时我还是小朋友

那时候我想不出来,看题解,说要用一个 boruvka 算法

事实上,你可以不需要用 boruvka,只需要你对生成树的性质有一些底层原理的理解就可以了

做法

首先,是 MST 的性质

MST 性质之:割性质

什么是割?

定义:对于连通图 \(G=(V,E)\),我们把 \(V\) 划分为 \(A,B\) 两部分,那么,连接 \(A,B\) 两部分的边集就是一个割,我们记为 \(C(A,B)\)

等价的:割是一个边集 \(E\) 的子集,使得把这些边删除后,点会被分成不连通的两个部分

接下来是 MST 的割性质

性质:对任意割 \(C(A,B)\),其中最小的边必然位于 MST 中

证明:

首先 MST 至少要包含 \(C(A,B)\) 里面的其中一条边,否则,\(A,B\) 将不能连通,而树至少得是个连通的

反证,假设生成树 \(T\) 是最小的,且它没有包含割 \(C(A,B)\) 中的最小边 \(e\),即,它包含的割中的边都比 \(e\) 要大

我们把 \(e\) 添加到 \(T\) 中,会形成一个环,假设 \(e\) 两端点为 \(u,v\),这个环由两部分组成:(1)边 \(e\) (2)原先 \(T\)\(u\)\(v\) 路径,设为 \(p\)

由于 \(e\) 是割 \(C(A,B)\) 的边,所以 \(u,v\) 分居 \(A,B\) 两侧。那么这个路径 \(p\),必然包含一个 \(C(A,B)\) 中的边(不然没法跨区),假设为 \(e'\),由上,\(e'>e\)

那么:我们可以把 \(T\) 中的 \(e'\) 删掉换成 \(e\),它依然是一个生成树,并且更小。矛盾!

所以最小生成树不能不包含割中最小边。证毕。

怎样利用割性质,做这个题?

对于这种位运算的题,按位考虑贡献 是惯用手法

我们要让总和最小,常用的是从高到低贪心:每一位,能尽量少就尽量少。这是因为,\(2^k\) 位的贡献要比 \(0\cdots k-1\) 位的全部贡献加起来还大,所以它具有统治性的优先级。

考虑从高到低枚举 \(k\),对于第 \(k\) 位,有些 \(a_i\)\(0\),有些 \(a_i\)\(1\)。如果只有一种,那么无论怎样连边,这一位的异或都为 \(0\),那就不用管了

否则:有 \(0\)\(1\)。我们把点分为两部分,一块是第 \(k\) 位为 \(0\) 的,另一块是为 \(1\) 的,设为 \(A,B\) 两部分。这两部分之间,必然要连一条边沟通两块。连谁呢?

我们发现这是一个,上面提到的, 的结构。由刚才的 MST 割性质:我们选择 \(A,B\) 的最小的边 连起来

换言之,就是找 \(i\in A,j\in B,a_i\oplus a_j\) 的最小值,而且还需要加上 \(2^k\),因为跨 \(A,B\) 的边无论如何都会在 \(k\) 位上有一个 \(1\) 的贡献,这是固定的

然后,关于 \(A,B\) 内部,我们递归处理即可,继续按照第 \(k-1\) 位为 \(0\)\(1\),再分成更细的两部分,然后不断的加细,递归到只剩一个点,结束

这个思路有点类似分治,就是把所有的边分成:

  • \(0\)\(0\) 内部,\(1\)\(1\) 内部

  • 跨越 \(0,1\)

前者用递归解决,后者根据割性质,选最小的

具体实现:trie 树

简单思考发现,这个递归结构简直就是一个 trie 树。这句话是面向萌新的,但凡有点经验的选手,听完上面的描述,脑子里自动就跑出一个 trie 来了,非常自然。

因此我们考虑在 trie 树上进行这个递归。在 trie 树上,对于 trie 节点 \(u\), 记 \(S(u)\) 代表集合 \(\{i|a_i\text{插入时经过 }u\}\),它是 \(\{1,2,3\cdots n\}\) 的一个子集,代表着 \(u\) 子树里包含哪些 \(i\)

我们令 dfs(u) 为:处理到 \(u\) 这个点,求 \(S(u)\) 的生成树。显然,对于根 \(r\) 而言,\(S(r)\) 就是整个点集,所以 dfs(r) 就是答案

那显然 dfs(u) 可以分为:递归处理两个儿子 u_0u_1, 求出跨两个儿子的点集的最小边

后者是一个经典的 trie 树问题:求两个集合 \(A,B\) 里取俩元素异或的最小值

通常的做法是:枚举 \(A\) 中的元素 \(x\),然后在 \(B\) 的 trie 树中从上到下的 walk,尽量走相同的

这里特殊点是:我们的 \(A,B\) 两集合都是原来的 trie 的子树,可以直接两点同时 walk 来求这个最小值,伪代码大概是这样:

f(a,b) { 
  // a,b 是两个节点, 求在两个子树里选俩数异或的最小值
  如果能走相同的: (a0b0 或者 a1b1)
    return min(f(a0,b0), f(a1,b1))
  否则, 只能走不同的:
    return min(f(a0,b1), f(a1,b0)) + (1<<k)
    // 这边要加一个 (1<<k), 因为这一步走的不同,这一位有 1 的贡献
    // 实际代码里面还额外维护了这个 k
}

复杂度分析

对于每次 dfs(u),上述这个求两边异或最小值的 walk, 复杂度为:\(O(w|S(u)|)\)\(w\) 是位长,这里是 \(32\)

然后它还会不断往两边递归直到叶子

我们可以认为:上述这个 walk,是 \(S(u)\) 中的每个点都提供了 \(O(w)\) 的贡献

那么,考虑一个 \(a_i,1\le i\le n\),它会贡献到多少个 \(u\) 呢?自然是它的祖先个数,也就是它的深度,是 \(O(w)\)

每贡献一次是一个 \(O(w)\) 复杂度,所以对于每个 \(i\),贡献 \(O(w^2)\) 复杂度

总复杂度就是:\(O(nw^2)\)

参考实现

#include<iostream>
#include<cstring>

const int N   = 200005;
const int INF = 0x7fffffff;
// 这是: 0 后面全 1, 即 2147483647
// 这里不涉及加法, 不会有 INF+INF 问题, 并且 0x3f3f3f3f 在这不够用

#define i64 long long

struct trie {
	static const int trie_N = N*32;
	int ch[trie_N][2]; int sz[trie_N];
	int n;

	// trie 树: 
	// n 表示节点数
	// 1 是固定的根节点, 因此 n 初始为 1
	// ch[u][0/1]	代表 走0/走1 的边
	// sz[u] 记录 u 底下有多少个 i, 即 |S(u)|

	void clear() {
		n=1;
		memset(ch, 0, sizeof(ch));
		memset(sz, 0, sizeof(sz));
	}
	void insert(int x) {
		int u=1; ++sz[1];
		for(int i=31;i>=0;i--) {
			int c=(x>>i)&1;
			if (!ch[u][c]) ch[u][c]=++n;
			u=ch[u][c]; ++sz[u];
		}
	}
	int min_xor(int k,int a,int b) {
		// 这个函数参考伪代码里的 f(a,b), 不过多维护了 k, 
		// 并且处理了一些子树不存在的 border case
		if (k==-1) return 0;

		int a0=sz[ch[a][0]],a1=sz[ch[a][1]],b0=sz[ch[b][0]],b1=sz[ch[b][1]];	
		if ((a0&&b0) || (a1&&b1)) {
			int x=(a0&&b0)?(min_xor(k-1,ch[a][0],ch[b][0])):INF;
			int y=(a1&&b1)?(min_xor(k-1,ch[a][1],ch[b][1])):INF;
			return std::min(x,y);
		}
		else {
			int x=(a0&&b1)?(min_xor(k-1,ch[a][0],ch[b][1])):INF;
			int y=(a1&&b0)?(min_xor(k-1,ch[a][1],ch[b][0])):INF;
			return std::min(x,y) + (1<<k);
		}
	}
	i64 dfs(int k,int u) {
		if (k==-1) return 0;

		int a=ch[u][0],b=ch[u][1];
		if (sz[a] && !sz[b]) return dfs(k-1, a);
		if (sz[b] && !sz[a]) return dfs(k-1, b);
		
		return (i64)(1<<k)+(i64)min_xor(k-1,a,b)+dfs(k-1,a)+dfs(k-1,b);
		// (1<<k)+min_xor 是跨两半的最小边长
		// 两个 dfs 是递归处理两半内部的 MST
		// 它们加起来构成 u 子树的 MST
	}
	i64 solve() {
		return dfs(31,1);
		// 从 root=1 开始, 初始 k=31
	}
}T;

void input() {
	T.clear();
	int n; std::cin>>n;
	for(int i=1;i<=n;i++) {
		int x; std::cin>>x;
		T.insert(x);
	}
}
void solve() {
	std::cout << T.solve() << std::endl;
}

int main() {
	input();
	solve();
	return 0;
}
posted @ 2026-06-29 15:30  Flandre-Zhu  阅读(4)  评论(0)    收藏  举报