1. 项目概述:从一道经典数论题说起
最近在带学生刷信奥(信息学奥林匹克)题目时,又遇到了P2261 [CQOI2007] 余数求和这道题。这道题可以说是数论分块(整除分块)的入门级经典例题,几乎每个认真准备信奥C++竞赛的同学都会在某个阶段遇到它。题目本身描述很简单:给定两个正整数n和k,要求计算∑_{i=1}^{n} (k mod i)的值。这里的mod就是取余运算。乍一看,这不就是一个简单的循环累加吗?从1到n遍历,把k % i的结果加起来不就行了?很多初学者确实会这么想,然后信心满满地写下一个for循环。但只要你把样例或者稍微大一点的数字(比如n和k都是10^9级别)代入,程序就会立刻超时,给你一个无情的TLE(时间超限)。这正是这道题的“陷阱”和价值所在——它逼着你不能使用朴素的O(n)算法,必须去寻找数学规律,用O(√k)甚至更优的复杂度来解决。这恰恰是信奥竞赛考察的核心能力之一:将实际问题抽象为数学模型,并利用数学知识进行优化。今天,我就结合自己多年的刷题和教学经验,带你彻底拆解这道题,不仅给出C++实现代码,更重要的是讲清楚背后的数学原理(数论分块)、推导过程、代码细节以及那些容易踩坑的地方。
2. 核心思路拆解:化“余”为“和”的数学魔法
2.1 为什么暴力循环行不通?
我们首先来量化一下暴力方法的问题。题目中n和k的范围虽然没有在标题中明确给出,但在原题中通常上限可以达到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的结果(当k和i都是整数时)。
于是,我们要求和的式子就可以进行如下变换: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就是n个k相加,等于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⌋=10i=2,⌊10/2⌋=5i=3,⌊10/3⌋=3i=4,⌊10/4⌋=2i=5,⌊10/5⌋=2i=6,⌊10/6⌋=1i=7,⌊10/7⌋=1i=8,⌊10/8⌋=1i=9,⌊10/9⌋=1i=10,⌊10/10⌋=1
你会发现,从i=4到i=5,⌊k/i⌋的值都是2;从i=6到i=10,值都是1。这些值相同的i构成了一个连续的整数区间。数论分块(整除分块)要做的就是快速找到这些区间,然后对每个区间进行整体计算,从而将复杂度从O(n)降低到O(√k)。
2.3 数论分块(整除分块)原理详解
数论分块的核心是这样一个结论:对于给定的正整数k和i,使得⌊k/i⌋ = q(q是一个常数)的i的取值,构成一个连续的区间[l, r]。并且,这个区间的右端点r可以直接由左端点l和q计算出来: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。
算法流程:
- 初始化
ans = n * k,l = 1。 - 当
l <= n且l <= 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,处理下一个区间。 - 循环结束后,
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)这个乘积可能非常大,在n和k为10^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 代码逐行解析与注意事项
数据类型选择 (
typedef long long ll)这是信奥竞赛中处理大数时的常见做法。int通常是32位,范围大约是-2e9 ~ 2e9。n*k在n和k都为10^9时达到1e18,远超int范围。long long是64位,范围大约是-9e18 ~ 9e18,足够安全。使用typedef定义别名ll可以让代码更简洁。输入与初始化 (
cin >> n >> k; ll ans = n * k;)直接读入n和k,并初始化答案ans为n * k,对应公式变换后的第一部分。循环条件与提前退出 (
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时。计算右端点 (
r = min(n, k / q);)这是实现中最容易出错的一步。根据公式,右端点r理论上等于⌊k / q⌋。但是,这个值可能超过题目给定的n。因为我们的i只求和到n。所以必须用min(n, k / q)来保证右端点不超过n。计算区间贡献 (
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的范围。但是,请注意我们的参数范围:题目中n和k通常是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=k,r = 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^9用long long直接相乘是安全的,但知道这个潜在的溢出风险很重要。更新答案与左端点 (
ans -= contrib; l = r + 1;)从总和中减去当前区间的贡献,然后将左端点移动到下一个区间的开始,继续循环。
3.3 稳健版本代码(推荐)
下面是一个更加稳健的版本,它显式处理了n和k的大小关系,并添加了更清晰的注释。
#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 测试用例设计
样例测试(题目通常会给出):
- 输入:
10 5,输出:29 - 输入:
5 10,输出:20我们可以手动计算或写一个暴力程序验证。
- 输入:
小数据测试(验证基本逻辑):
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=1;i>=2时⌊1/i⌋=0。所以ans = 100 - 1 = 99。
相等情况:
n=k。例如n=100, k=100。可以暴力验证。
n 远大于 k:
n=1e9, k=1。根据上面分析,结果应为n-1 = 999,999,999。我们的算法循环只到min(n,k)=1,只计算一个区间[1,1],贡献为1 * (1*1) = 1,ans = 1e9 * 1 - 1 = 999,999,999。正确。
k 远大于 n:
n=5, k=1e9。此时k/n很大。我们的算法需要正确处理多个分块。
极大值测试(检查溢出和时间):
n=1e9, k=1e9。这是时间复杂度的极限测试,算法应在O(√1e9) ≈ 31623次循环内完成,远低于1秒。n=1e12, k=1e12(如果题目范围扩大)。这时ans = n*k = 1e24已经超出long long范围(约9e18),需要使用__int128或高精度计算。但原题范围通常是1e9。
4.2 常见错误与排查
答案错误 (Wrong Answer):
- 最可能的原因:整数溢出。检查所有变量,特别是
ans、contrib、(l+r)*(r-l+1)是否使用了long long。确保输入n和k也用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的计算是正确的。
- 最可能的原因:整数溢出。检查所有变量,特别是
时间超限 (Time Limit Exceeded):
- 这几乎不可能发生,因为算法是
O(√k)的。如果超时,请检查是否写成了while (l <= n)且没有if (k/l == 0) break或end = min(n,k)的优化。当n很大而k很小时(比如n=1e9, k=1),如果没有优化,循环会进行1e9次,必然超时。我们的优化确保了循环最多min(n,k)次,而内部分块又进一步降低了次数。
- 这几乎不可能发生,因为算法是
运行错误 (Runtime Error):
- 除零错误:在计算
q = k / l时,l在循环中从1开始递增,不会为0。 - 数组越界:本题不需要数组。
- 除零错误:在计算
实操心得:在信奥竞赛中,对于数论分块题目,在提交前务必测试
n和k分别取最小值(如1)、最大值(如1e9)、n>>k、k>>n、n==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⌋,并且n和k是两个不同的数。所以右端点公式是r = min(n, k / (k / l))。一定要分清分子分母,牢记r不能超过n这个上限。把这个细节记牢,这类题目就基本不会出错了。
刷题不只是为了AC,更是为了理解背后的思想。希望这篇详细的拆解能帮助你真正掌握“余数求和”这道题以及数论分块这个有力的工具。在信奥的道路上,这类题目就像一块块基石,扎实地掌握它们,才能筑起解决更复杂问题的高楼。