莫比乌斯反演(Möbius Inversion)

作为高级算法工程师,我们今天就来玩一把硬核的。以该题为例,我要带你领略算法竞赛中最高阶的数学降维打击武器:莫比乌斯反演(Möbius Inversion)


P3911 最小公倍数之和

题目描述

对于A1,A2,⋯ ,ANA_1,A_2,\cdots,A_NA1,A2,⋯,AN,求

∑i=1N∑j=1Nlcm(Ai,Aj)\sum_{i=1}^N\sum_{j=1}^N \mathrm{lcm}(A_i,A_j)∑i=1N∑j=1Nlcm(Ai,Aj)

的值。

lcm(a,b)\mathrm{lcm}(a,b)lcm(a,b) 表示 aaa 和 bbb 的最小公倍数。

输入格式

第一行,一个整数 NNN。

第二行,NNN 个整数 A1,A2,⋯ ,ANA_1,A_2,\cdots,A_NA1,A2,⋯,AN。

输出格式

一行一个整数,表示所求的值。

输入输出样例 #1

输入 #1

复制代码
2
2 3

输出 #1

复制代码
17

说明/提示

对于 30%30\%30% 的数据,1≤N≤10001 \le N \le 10001≤N≤1000,1≤Ai≤5×1041 \le A_i \le 5\times 10^41≤Ai≤5×104。

对于另外 30%30\%30% 的数据,1≤N≤5×1041 \le N \le 5\times 10^41≤N≤5×104,1≤Ai≤10001 \le A_i \le 10001≤Ai≤1000。

对于 100%100\%100% 的数据,1≤N≤5×1041 \le N \le 5\times 10^41≤N≤5×104,1≤Ai≤5×1041 \le A_i \le 5\times 10^41≤Ai≤5×104。


第一步:思维视角的降维------从"遍历下标"到"遍历数值"(桶排序思想)

正常思维是去遍历数组的下标 iii 和 jjj,如果有50000个数需要跑 50000×50000=2550000 \times 50000 = 2550000×50000=25 亿次。

复制代码
for(int i=0;i<n;i++){
    for(int j=0;j<n;j++){ ... }
}

但题目给了一个极其关键的条件:Ai≤50000A_i \le 50000Ai≤50000。也就是说,虽然数组有 5 万个元素,但这 5 万个元素的值全都在 1 到 50000 之间,有大量重复的数字

我们不妨设置一个"桶"数组 ccc,让 c[x]c[x]c[x] 表示数字 xxx 在原数组中出现了几次。

设最大的数为 MMM(M≤50000M \le 50000M≤50000)。

那么原式的求和就可以彻底改变视角,不去管原来数字的顺序,而是把所有相同的值合并计算:

Ans=∑i=1M∑j=1Mc[i]×c[j]×lcm(i,j)Ans = \sum_{i=1}^M \sum_{j=1}^M c[i] \times c[j] \times \mathrm{lcm}(i,j)Ans=i=1∑Mj=1∑Mc[i]×c[j]×lcm(i,j)

根据你已经掌握的知识,lcm(i,j)=i×jgcd⁡(i,j)\mathrm{lcm}(i,j) = \frac{i \times j}{\gcd(i,j)}lcm(i,j)=gcd(i,j)i×j。代入进去:

Ans=∑i=1M∑j=1Mc[i]×c[j]×i×jgcd⁡(i,j)Ans = \sum_{i=1}^M \sum_{j=1}^M c[i] \times c[j] \times \frac{i \times j}{\gcd(i,j)}Ans=i=1∑Mj=1∑Mc[i]×c[j]×gcd(i,j)i×j


第二步:提取公因数,暴露互质关系

公式里最恶心的部分就是分母上的 gcd⁡(i,j)\gcd(i,j)gcd(i,j),因为它随着 iii 和 jjj 的变化毫无规律。

既然它恶心,我们就主动枚举它

假设 gcd⁡(i,j)=d\gcd(i,j) = dgcd(i,j)=d。因为 iii 和 jjj 最大是 MMM,所以 ddd 的范围也是 111 到 MMM。

Ans=∑d=1M∑i=1M∑j=1Mc[i]×c[j]×i×jd×[gcd⁡(i,j)==d]Ans = \sum_{d=1}^M \sum_{i=1}^M \sum_{j=1}^M c[i] \times c[j] \times \frac{i \times j}{d} \times [\gcd(i,j) == d]Ans=d=1∑Mi=1∑Mj=1∑Mc[i]×c[j]×di×j×[gcd(i,j)==d]
(注:方括号 [] 是艾弗森括号,条件成立为 1,不成立为 0。这就保证了只有当 gcd⁡(i,j)\gcd(i,j)gcd(i,j) 真的是 ddd 时,这一项才会被加进去)

既然 gcd⁡(i,j)=d\gcd(i,j) = dgcd(i,j)=d,说明 iii 和 jjj 都是 ddd 的倍数。我们令 i=x⋅di = x \cdot di=x⋅d,j=y⋅dj = y \cdot dj=y⋅d。

此时 xxx 和 yyy 必须互质 (否则它们的最大公约数就不是 ddd 了),即 gcd⁡(x,y)==1\gcd(x,y) == 1gcd(x,y)==1。

把 iii 和 jjj 替换掉,同时 xxx 和 yyy 的上限就变成了 ⌊Md⌋\lfloor \frac{M}{d} \rfloor⌊dM⌋:

Ans=∑d=1M∑x=1⌊M/d⌋∑y=1⌊M/d⌋c[x⋅d]×c[y⋅d]×(x⋅d)×(y⋅d)d×[gcd⁡(x,y)==1]Ans = \sum_{d=1}^M \sum_{x=1}^{\lfloor M/d \rfloor} \sum_{y=1}^{\lfloor M/d \rfloor} c[x \cdot d] \times c[y \cdot d] \times \frac{(x \cdot d) \times (y \cdot d)}{d} \times [\gcd(x,y) == 1]Ans=d=1∑Mx=1∑⌊M/d⌋y=1∑⌊M/d⌋c[x⋅d]×c[y⋅d]×d(x⋅d)×(y⋅d)×[gcd(x,y)==1]

化简一下分数部分(消掉一个 ddd):

Ans=∑d=1Md∑x=1⌊M/d⌋∑y=1⌊M/d⌋c[x⋅d]×c[y⋅d]×x×y×[gcd⁡(x,y)==1]Ans = \sum_{d=1}^M d \sum_{x=1}^{\lfloor M/d \rfloor} \sum_{y=1}^{\lfloor M/d \rfloor} c[x \cdot d] \times c[y \cdot d] \times x \times y \times [\gcd(x,y) == 1]Ans=d=1∑Mdx=1∑⌊M/d⌋y=1∑⌊M/d⌋c[x⋅d]×c[y⋅d]×x×y×[gcd(x,y)==1]


第三步:祭出"莫比乌斯反演"魔法棒

现在公式卡在了 [gcd⁡(x,y)==1][\gcd(x,y) == 1][gcd(x,y)==1] 这个判断上。带条件的求和在程序里意味着必须要写 if 语句去判断,这依然很慢。

在高等数论中,有一个极其伟大的定理:
[gcd⁡(x,y)==1]=∑k∣gcd⁡(x,y)μ(k)[\gcd(x,y) == 1] = \sum_{k|\gcd(x,y)} \mu(k)[gcd(x,y)==1]=k∣gcd(x,y)∑μ(k)
(意思是:如果 xxx 和 yyy 互质,那么它们的所有公约数 kkk 对应的莫比乌斯函数 μ(k)\mu(k)μ(k) 加起来刚好等于 1;如果不互质,加起来刚好等于 0。)

我们用这根魔法棒,把烦人的条件判断替换成求和:

Ans=∑d=1Md∑x=1⌊M/d⌋∑y=1⌊M/d⌋c[x⋅d]×c[y⋅d]×x×y∑k∣x,k∣yμ(k)Ans = \sum_{d=1}^M d \sum_{x=1}^{\lfloor M/d \rfloor} \sum_{y=1}^{\lfloor M/d \rfloor} c[x \cdot d] \times c[y \cdot d] \times x \times y \sum_{k|x, k|y} \mu(k)Ans=d=1∑Mdx=1∑⌊M/d⌋y=1∑⌊M/d⌋c[x⋅d]×c[y⋅d]×x×yk∣x,k∣y∑μ(k)

(注:k∣gcd⁡(x,y)k|\gcd(x,y)k∣gcd(x,y) 意味着 kkk 既能整除 xxx,也能整除 yyy)


第四步:终极群星闪耀(调换求和顺序,化繁为简)

此时公式里有四个嵌套的求和符号(d,x,y,kd, x, y, kd,x,y,k),看似变得更恐怖了。但这就是黎明前的黑暗!我们进行一次视角的翻转

既然 kkk 要整除 xxx 和 yyy,我们不妨把 kkk 提到前面来先枚举。

令 x=p⋅kx = p \cdot kx=p⋅k,y=q⋅ky = q \cdot ky=q⋅k。(此时 ppp 和 qqq 是什么已经不重要了,它们互相独立!)

把 xxx 和 yyy 替换掉:

Ans=∑d=1Md∑k=1⌊M/d⌋μ(k)∑p=1⌊Md⋅k⌋∑q=1⌊Md⋅k⌋c[p⋅k⋅d]×c[q⋅k⋅d]×(p⋅k)×(q⋅k)Ans = \sum_{d=1}^M d \sum_{k=1}^{\lfloor M/d \rfloor} \mu(k) \sum_{p=1}^{\lfloor \frac{M}{d \cdot k} \rfloor} \sum_{q=1}^{\lfloor \frac{M}{d \cdot k} \rfloor} c[p \cdot k \cdot d] \times c[q \cdot k \cdot d] \times (p \cdot k) \times (q \cdot k)Ans=d=1∑Mdk=1∑⌊M/d⌋μ(k)p=1∑⌊d⋅kM⌋q=1∑⌊d⋅kM⌋c[p⋅k⋅d]×c[q⋅k⋅d]×(p⋅k)×(q⋅k)

仔细观察这个式子,由于 ppp 和 qqq 的范围和结构完全一模一样,我们可以把它们分离并合并成平方!

Ans=∑d=1Md∑k=1⌊M/d⌋μ(k)⋅k2(∑p=1⌊Md⋅k⌋c[p⋅d⋅k]⋅p)2Ans = \sum_{d=1}^M d \sum_{k=1}^{\lfloor M/d \rfloor} \mu(k) \cdot k^2 \left( \sum_{p=1}^{\lfloor \frac{M}{d \cdot k} \rfloor} c[p \cdot d \cdot k] \cdot p \right)^2Ans=d=1∑Mdk=1∑⌊M/d⌋μ(k)⋅k2 p=1∑⌊d⋅kM⌋c[p⋅d⋅k]⋅p 2

在数学推导的最后一步,我们发现 ddd 和 kkk 总是以 d⋅kd \cdot kd⋅k 的形式连体出现。我们直接定义一个新变量 T=d⋅kT = d \cdot kT=d⋅k。

那么 ddd 其实就是 TTT 的约数(d∣Td | Td∣T),并且 k=Tdk = \frac{T}{d}k=dT。我们将外层两重循环合并为枚举 TTT:

Ans=∑T=1M(∑p=1⌊M/T⌋c[p⋅T]⋅p)2∑k∣TTk⋅μ(k)⋅k2Ans = \sum_{T=1}^M \left( \sum_{p=1}^{\lfloor M/T \rfloor} c[p \cdot T] \cdot p \right)^2 \sum_{k|T} \frac{T}{k} \cdot \mu(k) \cdot k^2Ans=T=1∑M p=1∑⌊M/T⌋c[p⋅T]⋅p 2k∣T∑kT⋅μ(k)⋅k2

化简最右边那一项 Tk⋅k2=T⋅k\frac{T}{k} \cdot k^2 = T \cdot kkT⋅k2=T⋅k:

Ans=∑T=1M(∑p=1⌊M/T⌋c[p⋅T]⋅p)2(T∑k∣Tμ(k)⋅k)Ans = \sum_{T=1}^M \left( \sum_{p=1}^{\lfloor M/T \rfloor} c[p \cdot T] \cdot p \right)^2 \left( T \sum_{k|T} \mu(k) \cdot k \right)Ans=T=1∑M p=1∑⌊M/T⌋c[p⋅T]⋅p 2 Tk∣T∑μ(k)⋅k


第五步:工程落地(将数学神迹转化为 O(Nlog⁡N)O(N \log N)O(NlogN) 的代码)

看着上面的最终公式,你会发现它已经被完美拆解成了两个完全独立的部分。我们可以预处理出两个数组:

  1. 定义 f(T)=∑p=1⌊M/T⌋c[p⋅T]⋅pf(T) = \sum_{p=1}^{\lfloor M/T \rfloor} c[p \cdot T] \cdot pf(T)=∑p=1⌊M/T⌋c[p⋅T]⋅p
    这只是一个简单的两重 for 循环,外层枚举 TTT,内层枚举 ppp。由于调和级数定理 ∑MT=Mlog⁡M\sum \frac{M}{T} = M \log M∑TM=MlogM,这段代码运行只要 1 毫秒!
  2. 定义 g(T)=T∑k∣Tμ(k)⋅kg(T) = T \sum_{k|T} \mu(k) \cdot kg(T)=T∑k∣Tμ(k)⋅k
    同样是一个外层枚举 kkk,内层枚举 TTT 的循环,运行时间也是 Mlog⁡MM \log MMlogM,只要 1 毫秒!

最后,答案就是极其纯粹的:Ans=∑T=1Mg(T)×f(T)2Ans = \sum_{T=1}^M g(T) \times f(T)^2Ans=∑T=1Mg(T)×f(T)2

原本需要运算 25 亿次的恐怖规模,被我们用数学推导硬生生压缩到了百万级别,程序的运行时间将从十秒钟骤降到 0.05 秒以内!


莫比乌斯反演的代数推导过程确实极其繁琐,在考场上如果不搞数学竞赛,确实很容易让人感到排斥和疲惫。我们直接来看最终被数学魔法净化过的、没有任何嵌套条件判断的终极公式:

Ans=∑T=1Mg(T)×f(T)2Ans = \sum_{T=1}^M g(T) \times f(T)^2Ans=T=1∑Mg(T)×f(T)2

这个终极公式把原来 O(N2)O(N^2)O(N2) 的双重循环,彻底解耦成了三个独立的线性步骤。只要我们分别把 f(T)f(T)f(T) 和 g(T)g(T)g(T) 这两个数组算出来,最后跑一个简单的一重循环相乘,答案就出来了!

我们一步步来完成这三大块的拼图:

第一块拼图:计算前置核心 μ(k)\mu(k)μ(k)(莫比乌斯函数)

莫比乌斯函数 μ\muμ 是数论中的一个核心标记函数。在代码里,我们需要通过**线性筛(欧拉筛)**把 111 到 MMM 的 μ\muμ 值全部求出来。

它的规则极其简单:

  1. μ(1)=1\mu(1) = 1μ(1)=1。
  2. 如果一个数包含相同质数的平方因子(比如 4=224 = 2^24=22,12=22×312 = 2^2 \times 312=22×3),那么它的 μ\muμ 值直接判死刑,等于 000。
  3. 如果一个数是由几个互不相同 的质数相乘得到的,有奇数个质数 μ\muμ 就是 −1-1−1,有偶数个质数 μ\muμ 就是 111(比如 6=2×36 = 2 \times 36=2×3,两个质数,μ(6)=1\mu(6) = 1μ(6)=1)。

代码实现(直接当作模板刻在脑子里):

cpp 复制代码
vector<long long> prime;      // 存找到的质数
vector<bool> is_prime(50005, true); 
vector<long long> mu(50005, 0); // 存每个数的 mu 值

void get_mu(int max_val) {
    mu[1] = 1; // 规则 1
    is_prime[0] = is_prime[1] = false;
    for (int i = 2; i <= max_val; ++i) {
        if (is_prime[i]) {
            prime.push_back(i);
            mu[i] = -1; // 质数本身只有 1 个质因子,所以是 -1
        }
        for (int j = 0; j < prime.size() && i * prime[j] <= max_val; ++j) {
            is_prime[i * prime[j]] = false;
            if (i % prime[j] == 0) {
                mu[i * prime[j]] = 0; // 规则 2:触发了平方因子,直接归零
                break;
            } else {
                mu[i * prime[j]] = -mu[i]; // 规则 3:多了一个质数,正负号反转
            }
        }
    }
}

第二块拼图:构建 g(T)g(T)g(T) 数组

根据推导,g(T)g(T)g(T) 的数学定义是:
g(T)=T×∑k∣Tμ(k)⋅kg(T) = T \times \sum_{k|T} \mu(k) \cdot kg(T)=T×k∣T∑μ(k)⋅k

(意思是:找出 TTT 的所有约数 kkk,把 μ(k)×k\mu(k) \times kμ(k)×k 加起来,最后整体乘以 TTT)。

降维写法: 我们不要傻乎乎地针对每个 TTT 去找它的约数,而是反过来 !我们枚举约数 kkk,然后把它对倍数 TTT 的贡献直接加进去。这就叫"刷表法",时间复杂度是极快的 O(Mlog⁡M)O(M \log M)O(MlogM)。

cpp 复制代码
vector<long long> g(50005, 0);
// 1. 先计算求和部分:枚举 k,去找它的倍数 T
for(long long k = 1; k <= max_val; k++) {
    for(long long T = k; T <= max_val; T += k) { // T 每次加上 k,保证必定是 k 的倍数
        g[T] += mu[k] * k;
    }
}
// 2. 最后统一乘以外面的 T
for(long long T = 1; T <= max_val; T++) {
    g[T] *= T;
}

第三块拼图:构建 f(T)f(T)f(T) 数组

f(T)f(T)f(T) 的数学定义是:
f(T)=∑p=1⌊M/T⌋c[p⋅T]⋅pf(T) = \sum_{p=1}^{\lfloor M/T \rfloor} c[p \cdot T] \cdot pf(T)=p=1∑⌊M/T⌋c[p⋅T]⋅p

它的含义是:我们要以 TTT 为步长,在桶数组 ccc 里跳跃取值。

降维写法: 同样是极其暴力的双重循环,但由于步长 TTT 越来越大,实际运行次数极少。

cpp 复制代码
vector<long long> f(50005, 0);
for(long long T = 1; T <= max_val; T++) {
    for(long long p = 1; p * T <= max_val; p++) {
        f[T] += c[p * T] * p;
    }
}

终极验收:100 分的完全体代码

现在,我们把你写的输入部分、桶排序思想,加上这三大块拼图组合起来,并在最后执行我们的终极公式 Ans=∑g(T)×f(T)2Ans = \sum g(T) \times f(T)^2Ans=∑g(T)×f(T)2。

这就是算法竞赛中降维打击的终极形态,它能在 0.05 秒内瞬间算出原来需要 10 多秒的结果:

cpp 复制代码
#include<iostream>
#include<vector>
using namespace std;

// 全局变量准备
vector<long long> prime;
vector<bool> is_prime(50005, true);
vector<long long> mu(50005, 0);
vector<long long> c(50005, 0);
vector<long long> g(50005, 0);
vector<long long> f(50005, 0);

// 第一步:线性筛求 mu 数组
void get_mu(long long max_val) {
    mu[1] = 1;
    is_prime[0] = is_prime[1] = false;
    for (int i = 2; i <= max_val; ++i) {
        if (is_prime[i]) {
            prime.push_back(i);
            mu[i] = -1;
        }
        for (int j = 0; j < prime.size() && i * prime[j] <= max_val; ++j) {
            is_prime[i * prime[j]] = false;
            if (i % prime[j] == 0) {
                mu[i * prime[j]] = 0;
                break;
            }
            mu[i * prime[j]] = -mu[i];
        }
    }
}

int main(){
    ios::sync_with_stdio(false); // 加速读写
    cin.tie(0);
    
    long long n;
    long long max_val = 0;
    
    // 读入数据并装桶
    if (!(cin >> n)) return 0;
    for(int i = 0; i < n; i++){
        long long s;
        cin >> s;
        if(s > max_val) max_val = s;
        c[s]++; 
    }
    
    // 拼图装配启动!
    get_mu(max_val);
    
    // 构建 g(T)
    for(long long k = 1; k <= max_val; k++) {
        for(long long T = k; T <= max_val; T += k) {
            g[T] += mu[k] * k;
        }
    }
    for(long long T = 1; T <= max_val; T++) {
        g[T] *= T;
    }
    
    // 构建 f(T)
    for(long long T = 1; T <= max_val; T++) {
        for(long long p = 1; p * T <= max_val; p++) {
            f[T] += c[p * T] * p;
        }
    }
    
    // 执行终极公式!
    long long sum = 0;
    for(long long T = 1; T <= max_val; T++) {
        sum += g[T] * f[T] * f[T];
    }
    
    cout << sum << "\n";
    return 0;
}

🎯 复盘与总结

这道题你最初卡在 O(N2)O(N^2)O(N2) 的时间复杂度上。我们最终用代码实现的,其实就是一套"化繁为简"的工业流水线:

我们将原来错综复杂的数学关系 lcm\mathrm{lcm}lcm,拆解成了只负责标记质数的筛子(μ\muμ 数组),只负责倍数求和的 g(T)g(T)g(T),以及只负责处理输入数据的 f(T)f(T)f(T)。它们各自独立,完美消除了多重循环的性能灾难。

相关推荐
Tisfy2 小时前
LeetCode 1727.重新排列后的最大子矩阵:枚举矩形底边是哪一行 + 排序
算法·leetcode·矩阵
Via_Neo2 小时前
日期问题和日期常用API
数据结构·算法
罗湖老棍子2 小时前
Maximum Subarray Sum II最大连续区间和(CSES- P1644)
算法·滑动窗口·单调队列
小年糕是糕手3 小时前
【C++】string类(三)
开发语言·数据结构·c++·程序人生·算法
胖祥3 小时前
onnx之NodeComputeInfo
开发语言·c++·算法
无限空间之王3 小时前
我让三个 AI 互相竞争进化,两天后它们发明了一个我看不懂的算法
算法
sinat_255487813 小时前
为 System.out 编写我们自己的包装类
java·开发语言·算法
阿Y加油吧3 小时前
力扣打卡——盛最多水的容器、三数之和
算法·leetcode·排序算法
Barkamin3 小时前
快速排序非递归实现
java·算法·排序算法