数论分块算法精讲:从余数求和到C++高效实现
2026/7/22 11:28:28 网站建设 项目流程

1. 项目概述:从一道经典数论题说起

最近在带学生刷信奥(信息学奥林匹克)题目时,又遇到了P2261 [CQOI2007] 余数求和这道题。这道题可以说是数论分块(整除分块)的入门级经典例题,几乎每个认真准备信奥C++竞赛的同学都会在某个阶段遇到它。题目本身描述很简单:给定两个正整数nk,要求计算∑_{i=1}^{n} (k mod i)的值。这里的mod就是取余运算。乍一看,这不就是一个简单的循环累加吗?从1到n遍历,把k % i的结果加起来不就行了?很多初学者确实会这么想,然后信心满满地写下一个for循环。但只要你把样例或者稍微大一点的数字(比如nk都是10^9级别)代入,程序就会立刻超时,给你一个无情的TLE(时间超限)。这正是这道题的“陷阱”和价值所在——它逼着你不能使用朴素的O(n)算法,必须去寻找数学规律,用O(√k)甚至更优的复杂度来解决。这恰恰是信奥竞赛考察的核心能力之一:将实际问题抽象为数学模型,并利用数学知识进行优化。今天,我就结合自己多年的刷题和教学经验,带你彻底拆解这道题,不仅给出C++实现代码,更重要的是讲清楚背后的数学原理(数论分块)、推导过程、代码细节以及那些容易踩坑的地方。

2. 核心思路拆解:化“余”为“和”的数学魔法

2.1 为什么暴力循环行不通?

我们首先来量化一下暴力方法的问题。题目中nk的范围虽然没有在标题中明确给出,但在原题中通常上限可以达到10^9。一个从1到10^9的循环,即使在当今最快的个人计算机上,也需要数秒甚至更长时间才能跑完,而信奥竞赛题目的时间限制通常是1秒或2秒。在1秒内,C++大约能执行10^8量级的基本操作。10^9的循环远远超出了这个限制,因此暴力法必然超时。这就要求我们必须找到一个时间复杂度远低于O(n)的算法。

2.2 关键数学变换:余数公式的展开

解决这道题的第一步,也是最关键的一步,是利用取余运算的定义进行公式变换。取余运算k mod i的定义是:k mod i = k - i * ⌊k/i⌋。其中⌊k/i⌋表示对k/i的结果向下取整,在C++中就是整数除法k / i的结果(当ki都是整数时)。

于是,我们要求和的式子就可以进行如下变换:G(n, k) = ∑_{i=1}^{n} (k mod i) = ∑_{i=1}^{n} (k - i * ⌊k/i⌋)

这个求和可以拆开:G(n, k) = ∑_{i=1}^{n} k - ∑_{i=1}^{n} (i * ⌊k/i⌋)

第一部分∑_{i=1}^{n} k就是nk相加,等于n * k。 所以,原问题转化为:G(n, k) = n * k - ∑_{i=1}^{n} (i * ⌊k/i⌋)

现在,问题的核心变成了如何高效计算∑_{i=1}^{n} (i * ⌊k/i⌋)。暴力计算它依然是O(n)的。观察⌊k/i⌋这个式子,当i变化时,它的值并不是每次都在变。例如,当k=10时:

  • i=1,⌊10/1⌋=10
  • i=2,⌊10/2⌋=5
  • i=3,⌊10/3⌋=3
  • i=4,⌊10/4⌋=2
  • i=5,⌊10/5⌋=2
  • i=6,⌊10/6⌋=1
  • i=7,⌊10/7⌋=1
  • i=8,⌊10/8⌋=1
  • i=9,⌊10/9⌋=1
  • i=10,⌊10/10⌋=1

你会发现,从i=4i=5⌊k/i⌋的值都是2;从i=6i=10,值都是1。这些值相同的i构成了一个连续的整数区间。数论分块(整除分块)要做的就是快速找到这些区间,然后对每个区间进行整体计算,从而将复杂度从O(n)降低到O(√k)

2.3 数论分块(整除分块)原理详解

数论分块的核心是这样一个结论:对于给定的正整数ki,使得⌊k/i⌋ = qq是一个常数)的i的取值,构成一个连续的区间[l, r]。并且,这个区间的右端点r可以直接由左端点lq计算出来:r = ⌊k / q⌋。但更常用的是另一个等价的公式:r = ⌊k / ⌊k/l⌋⌋

推导过程: 设q = ⌊k/l⌋,即区间左端点l对应的整除值。 我们要找最大的r,使得对于所有i ∈ [l, r],都有⌊k/i⌋ = q。 由整除的定义可知,⌊k/i⌋ = q等价于q ≤ k/i < q+1。 将不等式k/i < q+1变形,得到i > k/(q+1)。 将不等式q ≤ k/i变形,得到i ≤ k/q。 因为i是整数,所以i的上界是⌊k/q⌋。 同时,为了满足i > k/(q+1)i的最小值至少是⌊k/(q+1)⌋ + 1。但我们已经从l开始,所以这个区间的右端点就是⌊k/q⌋。 因此,区间[l, r][l, ⌊k / ⌊k/l⌋⌋]

这个区间的长度(r-l+1)可能很长,尤其是当l比较小的时候。这样,我们就不需要遍历区间内的每一个i,而是可以直接计算这个区间对总和的贡献。

区间[l, r]∑ (i * ⌊k/i⌋)的贡献: 在这个区间内,⌊k/i⌋是常数q。 所以,这个区间的贡献是q * ∑_{i=l}^{r} i。 等差数列求和公式:∑_{i=l}^{r} i = (l + r) * (r - l + 1) / 2。 因此,区间贡献 =q * (l + r) * (r - l + 1) / 2

算法流程

  1. 初始化ans = n * kl = 1
  2. l <= nl <= k时循环(因为当l > k时,⌊k/l⌋ = 0,后续贡献为0,可以提前结束): a. 计算当前区间的q = ⌊k/l⌋。 b. 如果q == 0,则后面所有i的贡献都是0,直接跳出循环。 c. 计算当前区间的右端点r = min(n, ⌊k/q⌋)。这里需要和n取最小值,因为i的上限是n。 d. 计算当前区间对∑ (i * ⌊k/i⌋)的贡献:contrib = q * (l + r) * (r - l + 1) / 2。 e. 从ans中减去这个贡献:ans -= contrib。 f. 将l更新为r + 1,处理下一个区间。
  3. 循环结束后,ans即为所求的G(n, k)

这个算法的时间复杂度是O(√k)。因为⌊k/i⌋的值大约只有2√k种(当i ≤ √k时,⌊k/i⌋√k种取值;当i > √k时,⌊k/i⌋ ≤ √k,最多也有√k种取值),所以循环次数是O(√k)级别的。

注意:在计算contrib时,(l + r) * (r - l + 1)这个乘积可能非常大,在nk10^9时,可能会超过32位整数int的范围。因此,我们必须使用64位整数(在C++中通常是long long)来进行中间计算和存储最终结果,否则会导致溢出,得到错误答案。

3. C++代码实现与逐行解析

理解了数学原理后,我们来看具体的C++代码实现。我会提供两个版本的代码:一个是清晰易懂的基础版本,另一个是考虑了边界情况和溢出风险的稳健版本。

3.1 基础实现代码

#include <iostream> #include <algorithm> using namespace std; typedef long long ll; // 定义long long的别名,方便书写 int main() { ll n, k; cin >> n >> k; ll ans = n * k; // 公式中的 n*k 部分 ll l = 1, r; // l代表当前区间的左端点 // 数论分块主循环 while (l <= n) { if (k / l == 0) break; // 当 k/l 为0时,后面所有项贡献为0,可提前结束 ll q = k / l; // 当前区间统一的 ⌊k/l⌋ 值 r = min(n, k / q); // 计算当前区间的右端点,不能超过n // 计算区间[l, r]的贡献:q * (l + r) * (r - l + 1) / 2 // 注意运算顺序,先乘可能会溢出,但这里用long long且n,k<=1e9时,(l+r)和(r-l+1)都<=2e9,乘积<=4e18,在long long范围(约9e18)内 ll contrib = q * (l + r) * (r - l + 1) / 2; ans -= contrib; // 从总和中减去这部分贡献 l = r + 1; // 移动左端点到下一个区间的起点 } cout << ans << endl; return 0; }

3.2 代码逐行解析与注意事项

  1. 数据类型选择 (typedef long long ll)这是信奥竞赛中处理大数时的常见做法。int通常是32位,范围大约是-2e9 ~ 2e9n*knk都为10^9时达到1e18,远超int范围。long long是64位,范围大约是-9e18 ~ 9e18,足够安全。使用typedef定义别名ll可以让代码更简洁。

  2. 输入与初始化 (cin >> n >> k; ll ans = n * k;)直接读入nk,并初始化答案ansn * k,对应公式变换后的第一部分。

  3. 循环条件与提前退出 (while (l <= n)if (k / l == 0) break;)while (l <= n)确保我们处理所有从1到n的i。 但是,当l > k时,k / l整数除法结果为0。这意味着从当前的l开始,直到n,每一项i * ⌊k/i⌋都等于0(因为⌊k/i⌋=0)。因此,对总和的贡献为0,没有必要继续循环,可以直接break。这是一个重要的优化,尤其当n远大于k时。

  4. 计算右端点 (r = min(n, k / q);)这是实现中最容易出错的一步。根据公式,右端点r理论上等于⌊k / q⌋。但是,这个值可能超过题目给定的n。因为我们的i只求和到n。所以必须用min(n, k / q)来保证右端点不超过n

  5. 计算区间贡献 (ll contrib = q * (l + r) * (r - l + 1) / 2;)直接套用等差数列求和公式。这里涉及三个连续的乘法:q * (l + r) * (r - l + 1)。在极限情况下(例如l=1,r=n=1e9,q=k=1e9),这个乘积会达到1e9 * 2e9 * 1e9 = 2e27,这显然超出了long long的范围。但是,请注意我们的参数范围:题目中nk通常是10^9量级。(l+r)最大约为2e9(r-l+1)最大为1e9,它们的乘积最大约为2e18。再乘以q(最大1e9),理论上限是2e27,确实会溢出。然而,在正确的数论分块中,当l很小(比如1)时,q = k/l会很大,但此时r也会很小(r = k/q)。具体来说,当l=1时,q=kr = min(n, k/k) = 1。所以这个区间只有一项。乘积k * (1+1) * (1) / 2 = k,并不会溢出。实际上,数论分块的性质保证了在计算每个区间时,(l+r)(r-l+1)不会同时很大。更严谨的分析可以证明,中间结果不会超过long long的范围(对于n,k <= 10^12都通常是安全的)。但为了绝对安全,一个更好的写法是:ll contrib = q * ( (l + r) * (r - l + 1) / 2 );或者先除2,但要注意整除问题(因为(l+r)(r-l+1)中一定有一个是偶数,所以(l+r)*(r-l+1)总是偶数,可以先除2):ll contrib = q * ( (l + r) / 2 * (r - l + 1) );// 仅当(l+r)为偶数时正确ll contrib = q * ( (l + r) * ((r - l + 1) / 2) );// 仅当(r-l+1)为偶数时正确 最稳妥的方法是使用__int128(如果编译器支持),或者像下面这样调整计算顺序,并利用整数除法向下取整的特性,但可能会引入精度问题。对于信奥竞赛,n,k <= 10^9long long直接相乘是安全的,但知道这个潜在的溢出风险很重要。

  6. 更新答案与左端点 (ans -= contrib; l = r + 1;)从总和中减去当前区间的贡献,然后将左端点移动到下一个区间的开始,继续循环。

3.3 稳健版本代码(推荐)

下面是一个更加稳健的版本,它显式处理了nk的大小关系,并添加了更清晰的注释。

#include <iostream> #include <algorithm> using namespace std; typedef long long ll; int main() { ll n, k; cin >> n >> k; ll ans = n * k; // 核心:数论分块,i从1开始,但只需要处理到 min(n, k) // 因为当 i > k 时,k / i == 0,贡献为0 ll end = min(n, k); ll l = 1, r; while (l <= end) { ll q = k / l; // 当前块的值 ⌊k/l⌋ r = min(end, k / q); // 当前块的右端点 // 计算区间[l, r]的贡献:值为q的项的和 = q * (l + ... + r) // 等差数列求和公式:sum = (首项 + 末项) * 项数 / 2 ll sum_i = (l + r) * (r - l + 1) / 2; // l到r的i的和 ll contrib = q * sum_i; // 区间总贡献 ans -= contrib; l = r + 1; // 跳到下一个块的起点 } // 注意:如果 n > k,那么 i 从 k+1 到 n 的部分,k mod i = k // 这部分在 ans = n*k - ∑(i*⌊k/i⌋) 中,由于 ⌊k/i⌋=0,所以 ∑部分为0,ans已经包含了 n*k,所以这部分实际上已经被正确计算了。 // 更直观的理解:对于 i > k,k mod i = k,所以总和要加上 (n - k) * k。 // 但在我们的公式 ans = n*k - ∑_{i=1}^{min(n,k)} (i*⌊k/i⌋) 中,当 i>k 时,⌊k/i⌋=0,所以求和项为0,ans就是 n*k,正好等于 (k mod i) 对 i=1..k 的求和加上 (n-k)*k。 // 所以上面的循环只到 min(n,k) 就够了。 cout << ans << endl; return 0; }

这个版本将循环终点明确设为min(n, k),逻辑更清晰。它同样正确处理了所有情况。

4. 实战测试与边界情况分析

理论正确不代表代码正确,尤其是对于算法题,边界情况(Corner Cases)往往是失分的重灾区。我们来设计几组测试数据,验证我们的代码。

4.1 测试用例设计

  1. 样例测试(题目通常会给出):

    • 输入:10 5,输出:29
    • 输入:5 10,输出:20我们可以手动计算或写一个暴力程序验证。
  2. 小数据测试(验证基本逻辑):

    • n=1, k=1->1 mod 1 = 0,输出应为0
    • n=1, k=100->100 mod 1 = 0,输出0
    • n=100, k=1->∑(1 mod i),当i=1时为0,i>1时为1。所以总和是99。公式:ans = 100*1 - ∑_{i=1}^{100} (i * ⌊1/i⌋)。只有当i=1⌊1/1⌋=1,贡献1*1=1i>=2⌊1/i⌋=0。所以ans = 100 - 1 = 99
  3. 相等情况

    • n=k。例如n=100, k=100。可以暴力验证。
  4. n 远大于 k

    • n=1e9, k=1。根据上面分析,结果应为n-1 = 999,999,999。我们的算法循环只到min(n,k)=1,只计算一个区间[1,1],贡献为1 * (1*1) = 1ans = 1e9 * 1 - 1 = 999,999,999。正确。
  5. k 远大于 n

    • n=5, k=1e9。此时k/n很大。我们的算法需要正确处理多个分块。
  6. 极大值测试(检查溢出和时间):

    • n=1e9, k=1e9。这是时间复杂度的极限测试,算法应在O(√1e9) ≈ 31623次循环内完成,远低于1秒。
    • n=1e12, k=1e12(如果题目范围扩大)。这时ans = n*k = 1e24已经超出long long范围(约9e18),需要使用__int128或高精度计算。但原题范围通常是1e9

4.2 常见错误与排查

  1. 答案错误 (Wrong Answer)

    • 最可能的原因:整数溢出。检查所有变量,特别是anscontrib(l+r)*(r-l+1)是否使用了long long。确保输入nk也用long long读取。
    • 检查右端点计算r = min(n, k/q)是否写成了r = k/q?当k/q > n时,必须取n
    • 检查循环条件:如果写了while (l <= n && l <= k),那么当n > k时,循环在l=k+1时停止,这是正确的,因为后面⌊k/i⌋=0。但如果你在循环内用if (q == 0) break,也要确保q的计算是正确的。
  2. 时间超限 (Time Limit Exceeded)

    • 这几乎不可能发生,因为算法是O(√k)的。如果超时,请检查是否写成了while (l <= n)且没有if (k/l == 0) breakend = min(n,k)的优化。当n很大而k很小时(比如n=1e9, k=1),如果没有优化,循环会进行1e9次,必然超时。我们的优化确保了循环最多min(n,k)次,而内部分块又进一步降低了次数。
  3. 运行错误 (Runtime Error)

    • 除零错误:在计算q = k / l时,l在循环中从1开始递增,不会为0。
    • 数组越界:本题不需要数组。

实操心得:在信奥竞赛中,对于数论分块题目,在提交前务必测试nk分别取最小值(如1)、最大值(如1e9)、n>>kk>>nn==k这几种边界情况。自己写一个暴力的for循环程序,用于生成小数据范围内的正确结果,与你的优化算法进行对拍(即用脚本随机生成大量小数据,比较两个程序的输出),这是发现隐蔽错误的最有效方法。

5. 数论分块的延伸与总结

通过这道题,我们深入学习了数论分块(整除分块)技术。它的核心思想是发现⌊n/i⌋的值成块状分布,并利用等差数列求和公式快速计算整块的和。这个技巧不仅用于求余数和,还可以用于快速计算∑_{i=1}^{n} ⌊n/i⌋∑_{i=1}^{n} i * ⌊n/i⌋等形式的和,是解决许多数论问题的重要工具。

最后,分享一个我教学中学生最容易混淆的点:在计算右端点r时,公式r = n / (n / l)是计算∑ ⌊n/i⌋时的标准形式。但在本题中,我们求和的是∑ i * ⌊k/i⌋,并且nk是两个不同的数。所以右端点公式是r = min(n, k / (k / l))。一定要分清分子分母,牢记r不能超过n这个上限。把这个细节记牢,这类题目就基本不会出错了。

刷题不只是为了AC,更是为了理解背后的思想。希望这篇详细的拆解能帮助你真正掌握“余数求和”这道题以及数论分块这个有力的工具。在信奥的道路上,这类题目就像一块块基石,扎实地掌握它们,才能筑起解决更复杂问题的高楼。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询