LOJ6807 「THUPC 2022 初赛」最小公倍树
题目大意
给定两个正整数 \(L, R\)。考虑一张 \(R - L + 1\) 个节点的无向图,节点编号分别为 \(L, L + 1, \dots, R\),任意两个节点之间都有连边,边权为两点点权的最小公倍数(\(\mathrm{lcm}\))。求这张图的最小生成树的边权和。
数据范围:\(1\leq L\leq R\leq 10^6\),\(R - L \leq 10^5\)。
本题题解
考虑用 Kruskal 算法求最小公倍数,也就是贪心,每次取出两个端点不在一个连通块的、边权最小的边。问题是,这张图里的边实在是太多了,所以我们要考虑,什么样的边有可能成为被选中的边。从最小公倍数的特殊设定入手。
“最小”公倍数,这个要求太严格了,有点烦人。不妨这样重建一张新图:对于每个正整数 \(k\),在所有点权为 \(k\) 的倍数的点里,两两连边,边权为点权之积除以 \(k\)。也就是说,我们现在只关心“公倍数”,而不关心“最小”的要求。所以,这张新图里会包含原图所有的边,同时多出一些边。不过,多出的边边权一定大于原有的边,所以不会对答案产生影响。接下来我们只需要在这张新图上求最小生成树。
考虑点权为 \(k\) 的倍数的点所产生的边,哪些有可能被当前 Kruskal 的贪心选中呢?一定是点权最小的两个不在同一连通块里的点之间的边。具体来说,是大于等于 \(L\) 的第一个 \(k\) 的倍数,和最小的与它不在同一连通块里的 \(k\) 的倍数,之间的边。这是因为所有边边权都是点权之积除以 \(k\),在 \(k\) 固定的情况下,一定选点权之积最小的边。
当然,上段讨论的是假设 Kruskal 已经在执行的过程中,我们已经知道哪些点在同一个连通块的情况。那么在所有过程开始之前,我们如何知道哪些边有可能被选中呢?仔细看上段的结论,我们发现一个惊人的性质:对于 \(k\),不管怎么选,选出来的边一定有一个端点是大于等于 \(L\) 的第一个 \(k\) 的倍数。也就是说,在开始时,对于每个 \(k\),我们只需要保留以这个点为端点的边就可以了,这样的边大约有 \(\frac{R - L + 1}{k} - 1\) 条,而其他的边可以全部扔掉,不会影响答案!设 \(n = R - L + 1\),那么我们需要的总边数是 \(\mathcal{O}\left(\sum_{k = 1}^{n} \frac{n}{k}\right) = \mathcal{O}(n\log n)\) 级别的(调和级数的结论)。
至此,我们已经可以得到一个简单的做法:把这 \(\mathcal{O}(n\log n)\) 条边拿出来,排序,然后执行 Kruskal 算法。这样做所需的空间复杂度是 \(\mathcal{O}(n\log n)\) 的。还有一种更省空间的写法是,用 \(\texttt{std::priority_queue}\),每次弹出一条边(记下这条边对应的 \(k\)),如果两个端点在同一连通块内,就把右端点挪到该 \(k\) 对应的下一个右端点,得到一条新的边,加入队列。因为同一个 \(k\) 对应的边权是随着右端点的递增而递增的,所以它本质上就是给这 \(n\) 个有序序列做归并排序。
时间复杂度 \(\mathcal{O}(n\log^2 n)\),空间复杂度 \(\mathcal{O}(n)\)。
参考代码
// problem: LOJ6807
#include <bits/stdc++.h>
using namespace std;
#define pb push_back
#define mk make_pair
#define lob lower_bound
#define upb upper_bound
#define fi first
#define se second
#define SZ(x) ((int)(x).size())
typedef unsigned int uint;
typedef long long ll;
typedef unsigned long long ull;
typedef pair<int, int> pii;
template<typename T> inline void ckmax(T& x, T y) { x = (y > x ? y : x); }
template<typename T> inline void ckmin(T& x, T y) { x = (y < x ? y : x); }
const int MAXN = 1e5 + 1;
int L, R;
int f[MAXN + 5], g[MAXN + 5];
int fa[MAXN + 5], sz[MAXN + 5];
int get_fa(int u) {
return (fa[u] == u) ? u : (fa[u] = get_fa(fa[u]));
}
void unite(int u, int v) {
int uu = get_fa(u);
int vv = get_fa(v);
if (uu != vv) {
if (sz[uu] > sz[vv])
swap(uu, vv);
fa[uu] = vv;
sz[vv] += sz[uu];
}
}
int main() {
cin >> L >> R;
priority_queue<pair<ll, int> > que;
for (int i = 1; i <= R - L; ++i) {
f[i] = ((L - 1) / i + 1) * i; // >= L 的第一个 i 的倍数
if (f[i] + i <= R) {
g[i] = f[i] + i;
que.push(mk(-(ll)f[i] / i * g[i], i));
}
}
for (int i = 1; i <= R - L + 1; ++i) {
fa[i] = i;
sz[i] = 1;
}
ll ans = 0;
for (int i = 1; i <= R - L; ++i) {
pair<ll, int> t = que.top();
que.pop();
while (get_fa(f[t.se] - L + 1) == get_fa(g[t.se] - L + 1)) {
g[t.se] += t.se;
if (g[t.se] <= R) {
que.push(mk(-(ll)f[t.se] / t.se * g[t.se], t.se));
}
t = que.top();
que.pop();
}
ans -= t.fi; // 根据默认大根堆的特点,t.fi 是负数
unite(f[t.se] - L + 1, g[t.se] - L + 1);
g[t.se] += t.se;
if (g[t.se] <= R) {
que.push(mk(-(ll)f[t.se] / t.se * g[t.se], t.se));
}
// cerr << "add " << f[t.se] << " " << g[t.se] << endl;
}
cout << ans << endl;
return 0;
}