1. 从一道“国赛模拟”题说起:当求和遇上数论天花板
最近在整理一些算法竞赛的经典题目,翻到了这道被圈内人称为“数论劝退题”的国赛模拟题。它的核心就一个词:求和。但别被这个词骗了,这可不是简单的1+2+3+...+n。题目要求计算的是一个定义在正整数上的函数f(n)的前n项和,而这个f(n)本身,往往又是一个与数论函数(比如欧拉函数、除数函数)相关的复杂表达式。当n的规模达到10^10甚至更高时,任何O(n)的暴力循环都是痴人说梦,甚至连O(sqrt(n))的常规数论分块都可能力不从心。这道题,几乎是把算法竞赛中数论方向最硬核的两块内容——莫比乌斯反演和Min_25筛——强行焊在了一起,最后还贴心地附赠了一个“卡常”的标签,堪称“优雅”与“暴力”的终极结合体。
我最初看到这个标题时,感觉就像有人告诉我:“请用微积分和量子力学的方法,精确计算一下你从家到公司路上踩死了多少只蚂蚁。” 荒谬,但又让人忍不住想去挑战。莫比乌斯反演负责将题目中天书般的求和式,转化为我们相对熟悉、可处理的数论函数形式,这是“优雅”的理论部分。而 Min_25筛,则是处理超大范围(1e10量级)数论函数前缀和的“暴力”数值工具,其原理复杂,实现精巧。至于“卡常”,则是这道题最后的现实考验:即便你理论全对,筛法也会写,但如果不把时间和空间优化到极致,依然会在巨大的数据规模面前超时或超内存。
这篇文章,我就来拆解这道题背后的完整解题链条。我们不会停留在“套公式”的层面,而是深入每一步“为什么这么做”,并分享在实现 Min_25筛以及应对“卡常”时,那些在标准题解里不会写的、血泪换来的经验。无论你是正在备赛的选手,还是对数论算法感兴趣的开发者,相信这篇融合了理论推导与工程实践细节的长文,都能给你带来收获。
2. 破题之钥:莫比乌斯反演如何化简求和式
面对一个复杂的求和问题,直接硬刚往往是死路一条。莫比乌斯反演在这里扮演的角色,就像一个高明的“翻译官”,把一句晦涩难懂的“古文”(原求和式),翻译成一句结构清晰、词汇简单的“现代文”(反演后的式子)。
2.1 理解题设中的f(n)与目标
通常,这类题目的f(n)会与数的因子结构强相关。例如,一个非常经典的套路是定义f(n) = ∑_{d|n} g(d) * h(n/d),或者与最大公约数、最小公倍数挂钩。我们的最终目标是计算S(n) = ∑_{i=1}^{n} f(i)。
第一步永远不是急着写代码,而是拿起纸笔,对f(n)进行形式化的推导。假设我们遇到一个典型情况:f(n) = ∑_{d|n} μ(d) * d,其中μ(d)是莫比乌斯函数。这看起来已经很简单了?但直接求前缀和S(n) = ∑_{i=1}^{n} ∑_{d|i} μ(d) * d依然是O(n log n)的复杂度,对于n=1e10不可行。
这时,我们需要运用莫比乌斯反演最核心的技巧:交换求和顺序。这是将双重求和简化的关键。
S(n) = ∑_{i=1}^{n} ∑_{d|i} μ(d) * d = ∑_{d=1}^{n} μ(d) * d * ∑_{i=1}^{n} [d|i] // [d|i] 在 d整除i时为1,否则为0 = ∑_{d=1}^{n} μ(d) * d * floor(n/d)看,通过交换求和顺序,我们把一个需要对每个i枚举其因子的问题,转化为了对每个因子d,计算其贡献的问题。式子变成了∑ μ(d) * d * floor(n/d)。这已经是一个可以用数论分块(整除分块)优化的标准形式了,因为floor(n/d)的值是成段不变的。复杂度可以优化到O(sqrt(n))。
注意:这是最理想的情况。实际题目中的
f(n)可能更复杂,反演后得到的函数可能不是μ(d)*d这么简单,而是一个需要单独计算前缀和的函数,比如∑ μ(d)*σ(d)(σ是约数和函数)。这时,O(sqrt(n))的数论分块依然需要那个函数的前缀和,而该函数前缀和的计算本身,就可能需要用到 Min_25筛。这就是两者产生联系的地方。
2.2 反演后的常见结构分析与应对策略
经过反演,我们通常得到形如S(n) = ∑_{g(d)} * floor(n/d)的式子,其中g(d)是一个积性函数,或者可以拆分成积性函数的组合。此时,计算S(n)的瓶颈就转移到了如何快速计算g(d)的前缀和G(n) = ∑_{i=1}^{n} g(i)上。
根据g(d)的性质和n的大小,我们有几种策略:
- 线性筛预处理:如果
n <= 1e7,我们可以用欧拉筛(线性筛)在O(n)时间内预处理出所有g(i)的值及其前缀和。这是最快最直接的方法。 - 杜教筛:如果
g(d)能找到另一个合适的积性函数h,使得g与h的狄利克雷卷积(g*h)的前缀和很容易计算,那么可以使用杜教筛,在O(n^{2/3})的时间复杂度内计算出G(n)。这对于n <= 1e10通常是可行的。 - Min_25筛:当
g(d)不是经典的可杜教筛函数,或者题目为了“卡”你而故意将n设置得极大(如1e11,1e12),或者g(d)在素数幂处的表达式比较复杂时,杜教筛可能失效或难以构造。这时,Min_25筛就成为了最后的“重型武器”。它能够以大约O(n^{3/4} / log n)的复杂度,计算一大类积性函数的前缀和。
在这道“国赛模拟”题中,出题人将n设置到1e10量级,并且设计的g(d)函数往往刻意避开了简单的杜教筛构造,因此Min_25筛成为了必须掌握的解题环节。这也正是标题中点出“Min_25筛”的原因——它不是可选项,而是通关的必选项。
3. Min_25筛核心原理拆解:不是“黑盒”,而是“施工图”
很多人把 Min_25筛当作一个板子来背,这其实非常危险。一旦题目稍有变化,或者需要优化,背板子的人就会束手无策。我们必须理解其背后的“施工图”。
Min_25筛的核心思想是分类统计:把所有数按最小质因子来分类处理。它通过两步来求解积性函数f(x)的前缀和S(n)。
3.1 第一步:计算质数处的贡献(SP)
我们定义g(n, j)表示:在2~n的所有整数中,满足“是质数”或者“最小质因子大于第j个质数P_j”的数的f函数值之和。这里f被替换成了一个完全积性函数f',这个f'需要在所有质数p处的值与原积性函数f(p)相等。
为什么要引入这个f'?因为完全积性函数f'(ab) = f'(a)f'(b)的性质,使得我们可以用类似埃氏筛的方法进行递推。初始时g(n, 0)就是所有数(包括合数)的f'值之和,这通常可以用一个多项式等价的公式快速计算(例如,求质数和时,f'(x)=x,那么g(n,0) = ∑_{i=2}^{n} i,可以用等差数列求和公式O(1)算得)。
然后,我们从j=1开始,用第j个质数P_j去“筛”掉那些最小质因子恰好是P_j的数。递推公式为:
g(n, j) = g(n, j-1) - f'(P_j) * [ g(floor(n/P_j), j-1) - g(P_j-1, j-1) ]
这个公式怎么理解?
g(n, j-1)是还没用P_j筛之前的结果。- 我们要减去的,是那些最小质因子等于
P_j的数。这些数可以写成P_j * t,其中t的最小质因子>= P_j(否则P_j就不是最小质因子了),并且t >= P_j。 g(floor(n/P_j), j-1)代表了所有满足“是质数或最小质因子>= P_j”的t的f'值之和。但这里包含了t是质数且< P_j的情况(此时P_j * t的最小质因子是t,不是P_j),所以我们需要减去这部分,即g(P_j-1, j-1)。- 因为
f'是完全积性的,所以f'(P_j * t) = f'(P_j) * f'(t)。
通过这个递推,当P_j^2 > n时,g(n, j)就不再变化,此时g(n, j)的值就是所有<=n的质数的f'(p)之和,也就是我们需要的质数处原函数f(p)的和,记为SP(n)。
实操心得1:
g(n, j)中的n在实际计算中只会取到floor(n / i)这种形式,不同的i只有O(sqrt(n))个。我们需要预处理出这O(sqrt(n))个“关键点”的g值。通常用两个数组ind1(对应i <= sqrt(n)) 和ind2(对应n/i <= sqrt(n)) 来映射这些关键点,这是实现高效存储和查询的基础,也是容易出错的地方。
3.2 第二步:计算所有数的贡献(S)
有了质数部分的贡献,我们还需要加上合数部分的贡献。我们定义S(n, j)表示:在2~n的所有整数中,最小质因子大于等于P_j的数的f函数值之和。显然,最终答案Ans = S(n, 1) + f(1)(通常f(1)=1或根据题目定义)。
S(n, j)可以通过递归计算:S(n, j) = SP(n) - sum_{i=1}^{j-1} f(P_i) + sum_{k>=j} sum_{e>=1, P_k^{e+1} <= n} [ f(P_k^e) * S(floor(n / P_k^e), k+1) + f(P_k^{e+1}) ]
这个公式分两部分:
- 质数部分:
SP(n) - sum_{i=1}^{j-1} f(P_i)。这就是所有大于等于P_j的质数的贡献。 - 合数部分:枚举最小质因子
P_k(k>=j),再枚举这个质因子的指数e。合数可以表示为P_k^e * t,其中t的最小质因子> P_k。因此贡献是f(P_k^e) * S(floor(n / P_k^e), k+1)。另外,P_k^{e+1}本身也是一个合数(且最小质因子就是P_k),但它对应的t=1,而S(..., k+1)中不包含1,所以需要单独加上f(P_k^{e+1})。
递归的边界条件是:当n < P_j时,S(n, j) = 0。
实操心得2:这个递归看似复杂,但有效状态数并不多。可以用记忆化搜索来实现。递归的深度不会太深,因为
P_k^{e+1} <= n这个条件限制了枚举范围。一个至关重要的剪枝是:当P_k * P_k > n时,合数部分不可能再有贡献(因为最小的合数至少是P_k^2),此时S(n, j)就等于质数部分的贡献。这个剪枝能极大提升效率。
4. 从理论到实践:实现Min_25筛的工程细节与“卡常”实战
理解了原理,实现起来依然处处是坑。“卡常”不仅仅是简单的快读快写,更是对算法每个环节的极致压榨。
4.1 存储优化与离散化
这是Min_25筛实现的第一道坎。我们注意到,所有计算中出现的n都是floor(n / i)的形式。这样的值大约有2 * sqrt(n)个。
- 我们预处理一个数组
val[1...m]来存储所有这些不同的floor(n/i)。 - 同时,我们需要建立从
x(x是某个floor(n/i))到数组下标的映射。通常这样实现:
// 假设 n <= 1e10 ll n, sqrt_n; int m = 0; for (ll l = 1, r; l <= n; l = r + 1) { r = n / (n / l); val[++m] = n / l; // 存储值 if (val[m] <= sqrt_n) ind1[val[m]] = m; // 小值直接映射 else ind2[n / val[m]] = m; // 大值通过 n/val[m] 映射 }在g(n, j)的递推中,我们访问的是g(floor(n/P_j), j-1),我们需要通过floor(n/P_j)这个值快速找到它在val数组中的下标,从而获取对应的g值。ind1和ind2就是用来做这个O(1)查询的。
4.2 递推g数组的细节与滚动数组
g(n, j)是一个二维状态,直接开数组g[m][j]内存会爆炸。观察递推式:g(n, j)只依赖于g(..., j-1)。因此我们可以用滚动数组,只保留当前j和上一轮j-1的结果。
通常我们定义两个数组g1和g2(如果f'(p)需要多个多项式拟合,可能需要更多),分别对应当前轮和上一轮。在每一轮用质数P_j筛的时候,我们从大到小遍历val中的n'(即val[i])。因为递推公式中g(n, j)依赖于g(floor(n/P_j), j-1),而floor(n/P_j)一定小于等于n。从大到小遍历可以保证在计算g(val[i], j)时,所需要的g(floor(val[i]/P_j), j-1)已经被更新为当前轮 (j) 的值?不,这里是个关键点!
仔细看公式:g(n, j) = g(n, j-1) - f'(P_j) * [ g(floor(n/P_j), j-1) - g(P_j-1, j-1) ]等式右边所有的g(..., j-1)都应该是上一轮的值。因此,如果我们从大到小遍历i,计算g(val[i], j)时,g(floor(val[i]/P_j), j-1)这个值对应的下标idx,其val[idx]一定小于等于val[i]。由于我们从大到小遍历,下标idx可能大于i(因为val数组是递减存储的,值越小下标越大),但g(val[idx], j)可能在本轮已经被计算更新了!这就污染了我们需要用的“上一轮”的值。
所以,正确的做法是:在每一轮j,我们使用上一轮完整的g数组(记为g_prev)来计算本轮新的g数组(记为g_curr)。或者,更节省空间的做法是,只用一个g数组,但从大到小遍历i时,确保用到的g(floor(val[i]/P_j))是本轮尚未被覆盖的旧值。由于floor(val[i]/P_j) <= val[i],且val数组递减,所以floor(val[i]/P_j)对应的下标k一定>= i。如果我们从大到小(i从m到1)遍历,那么当处理到i时,下标k (>=i)的位置存储的还是上一轮的值(因为k >= i,我们还没处理到它)。这样就保证了数据的正确性。这是Min_25筛实现中非常精妙且容易出错的一个循环顺序细节。
4.3 递归计算S的优化与记忆化
计算S(n, j)采用递归+记忆化。记忆化的键值对(n, j)同样,n只有O(sqrt(n))种可能,可以用类似ind1/ind2的方法映射。j是质数序号,范围是1到小于等于 sqrt(n)的质数个数。
几个关键优化点:
- 边界剪枝:
if (n < P[j]) return 0; - 质数剪枝:
if ((ll)P[j] * P[j] > n) { ... // 直接返回质数部分贡献 }这个剪枝效果极好。 - 预处理小范围:当
n比较小的时候(比如n <= 1e6),可以直接用线性筛预处理出f(i)的前缀和,在递归中直接O(1)返回。这能避免大量递归到最底层的情况。 - 避免重复计算
SP(n):SP(n)就是第一步中我们最终得到的g(n, maxj)。我们可以把它提前算好,存储在sum_prime数组中(同样离散化存储),在递归中直接查询。
4.4 “卡常”的终极手段:微积分估算与循环展开
当n大到1e11量级时,即使是O(n^{3/4} / log n)的 Min_25筛也可能在时间边缘徘徊。此时需要一些“邪道”优化。
- 整数除法优化:代码中会出现大量的
n / i和n / (n/i)。确保使用64位整数(long long)。在C++中,/运算符对于整数是直接截断的,就是我们要的整除效果。 - 预处理开方与质数:
sqrt(n)需要多次计算,可以先算好存起来。质数表用线性筛预处理到sqrt(n)即可。 - 内存访问连续化:
g数组、sum_prime数组等,访问时尽量顺序访问,利用CPU缓存。 - 递归改迭代?对于
S(n, j)的计算,递归形式直观,但函数调用有开销。有一种用栈模拟递归的迭代版Min_25筛,但代码极其复杂,可读性差,除非万不得已(如对递归深度有严格限制的环境),一般不建议使用。竞赛中通常递归版本足够。 - 微积分估算复杂度:Min_25筛的复杂度约为
O(n^{3/4} / log n)。对于n=1e10,n^{3/4} = 1e7.5 ≈ 3.16e7,再除以log(1e10)≈23,运算量级在1e6左右,这是可接受的。但对于n=1e11,运算量级可能到1e7,就需要非常精细的优化。这时,可以分析递归树,发现大部分时间花在j很小(即用很小的质数去筛)的阶段。可以尝试手动展开最外几层循环,比如手动计算j=1,2,3(对应质数2,3,5)时的贡献,从而减少递归调用次数。这是一种非常硬核的优化,需要对代码和算法有极深的理解。
5. 完整解题框架与调试心得
将莫比乌斯反演和 Min_25 筛结合,解决这类“求和”题的完整框架如下:
- 解析题目,定义
f(n):明确题目所求。 - 莫比乌斯反演或狄利克雷卷积化简:将
∑f(i)转化为∑g(d) * floor(n/d)的形式,确定需要求前缀和的函数g(d)。 - 分析
g(d)的性质:确认g(d)是否为积性函数?在质数p、质数幂p^k处的表达式是什么?这是使用 Min_25筛的前提。 - 设计 Min_25 筛中的辅助函数:
- 完全积性函数
f1(p):用于第一步筛质数。需要满足对于任意正整数a,b,f1(ab)=f1(a)f1(b),且f1(p) = g(p)。通常g(p)是一个关于p的多项式,我们可以将其拆成多个单项式(如p^0, p^1, p^2),对每个单项式分别进行第一步的筛法,因为f1(p)=p^k是完全积性的。最后再将结果线性组合起来。 - 原积性函数
g(p^k):用于第二步递归计算S。需要能快速计算g(p^k)的值。
- 完全积性函数
- 实现 Min_25 筛:
- 预处理
sqrt(n)以内的质数。 - 离散化,得到
val,ind1,ind2。 - 实现第一步,计算
g数组(滚动数组),并得到sum_prime(质数处g(p)的和)。 - 实现第二步,递归计算
S(n, 1),注意剪枝和记忆化。
- 预处理
- 整合求解:利用 Min_25 筛求出
G(m) = ∑_{i=1}^{m} g(i)对于任意m = floor(n/d)的值。然后使用数论分块计算∑ g(d) * floor(n/d)。
调试心得与坑点:
- 数据溢出:这是最大的坑。
n在1e10级别,n*n就会溢出64位。在计算P_j * P_j、P_k^{e+1}时,务必在乘法前判断是否超过n或LLONG_MAX,或者使用__int128进行中间计算。 - 边界条件:
f(1)的值根据题目定义处理。在 Min_25 筛中,我们通常统计从2开始的数,最后再加上f(1)。 - 多项式拟合的系数:当
g(p)是多项式时,比如g(p) = p^2 + p,我们需要分别用f1_a(p)=p^2和f1_b(p)=p做两次第一步筛法,得到sum_prime_a和sum_prime_b,然后sum_prime = sum_prime_a + sum_prime_b。初始化g(n,0)时,也要分别计算∑i^2和∑i的公式。 - 记忆化
S(n,j)的j维度:j是质数下标,范围不大,可以直接作为数组第二维。但要注意,当n很小但j很大时,状态(n,j)可能不合法(n < P[j]),记忆化数组需要初始化一个非法值(如-1)并在递归开头判断。 - 验证:先用小数据(
n=1e6)跑一遍 Min_25 筛,结果与线性筛暴力计算的结果对比。再用中等数据(n=1e8)测试,确保在可接受时间内结果正确。最后挑战大数据。
这道“国赛模拟”题,就像一场综合性的登山训练。莫比乌斯反演是规划路线图,让你知道山在哪、方向在哪;Min_25筛是攀登陡峭岩壁的专业装备和技术;而“卡常”则是调整呼吸、优化每一个动作,确保你在体力耗尽前登顶。整个过程充满挑战,但一旦走通,你对数论算法和代码优化的理解将会达到一个新的层次。