数论基础:质因数分解与约数个数公式的算法实现与应用
2026/8/29 2:33:56 网站建设 项目流程

1. 从一道模板题说起:为什么约数个数值得专门学习?

最近在AcWing上刷题,又碰到了那道经典的870题——“约数个数”。题目本身不难理解:给你一个正整数n,要求输出它的约数个数。很多刚接触数论或者正在准备算法竞赛的朋友,可能会觉得这题直接暴力枚举从1到n不就行了?但稍微看一眼数据范围,或者想想实际应用,就会发现事情没那么简单。当n很大,比如接近10^9甚至更大时,O(n)的暴力枚举在时间上根本不可行。这道题被标记为“模板题”,恰恰说明它背后蕴含的是一种高效、通用的数学思想,而不仅仅是某道题的解法。

我在最初学习时也走过弯路,觉得数论公式枯燥,不如动态规划、图论来得“实在”。直到后来在解决一些实际问题,比如设计一个计算器来高效分解大数的因数,或者在密码学相关编程中需要快速判断数的某些性质时,才深刻体会到这个“模板”的价值。它就像一把钥匙,帮你打开了一类问题的大门。今天,我们就来彻底拆解这个“求约数个数”的模板,不仅告诉你C++代码怎么写,更要讲清楚它背后的数学原理、为什么这样设计,以及在实际编码中会遇到哪些坑,怎么绕过去。

2. 核心原理拆解:算术基本定理与约数公式

要高效求约数个数,暴力循环是下策,我们必须借助数学工具。这里最核心的就是算术基本定理,也叫唯一分解定理。这个定理说的是:任何一个大于1的自然数n,都可以唯一地分解成若干个质因数的乘积。比如12 = 2^2 * 3^1100 = 2^2 * 5^2

这个分解形式对我们求约数个数有什么帮助呢?我们来看一个约数是怎么产生的。以12为例,它的约数有1, 2, 3, 4, 6, 12。我们可以把这些约数也用质因数分解的形式写出来:

  • 1 = 2^0 * 3^0
  • 2 = 2^1 * 3^0
  • 3 = 2^0 * 3^1
  • 4 = 2^2 * 3^0
  • 6 = 2^1 * 3^1
  • 12 = 2^2 * 3^1

发现规律了吗?12的任何一个约数,其质因数分解中,2的指数只能是0、1或2(共3种可能),3的指数只能是0或1(共2种可能)。并且,2的指数和3的指数的每一种合法组合,都唯一对应12的一个约数。

这就引出了我们的核心公式:如果一个数n被质因数分解为n = p1^a1 * p2^a2 * ... * pk^ak,其中p1, p2, ..., pk是不同的质数,a1, a2, ..., ak是对应的指数(都是正整数),那么n的约数个数d(n)为:d(n) = (a1 + 1) * (a2 + 1) * ... * (ak + 1)

为什么是(指数 + 1)相乘?因为对于每一个质因子pi,在构造n的约数时,我们可以选择其指数为0, 1, 2, ..., ai中的任意一个,一共有(ai + 1)种选择。所有质因子的选择相互独立,根据乘法原理,总的组合数(即约数个数)就是各个(ai + 1)的乘积。

所以,我们求解的思路就从“枚举所有可能的除数”转变为了“对n进行质因数分解,并统计每个质因子的指数”。后者的时间复杂度可以优化到O(sqrt(n)),对于n <= 10^9的情况绰绰有余,对于更大的数也有更高效的算法(如Pollard Rho)。

2.1 质因数分解的算法实现细节

知道了公式,下一步就是如何得到这个质因数分解的结果。最常用的方法是试除法。我们从最小的质数2开始,判断它是否能整除n。如果能,就不断地用n除以2,直到不能整除为止,统计除的次数就是质因子2的指数。然后我们用同样的方法去试3、5、7…… 但这里有一个非常重要的优化:我们只需要试到sqrt(n)即可。

为什么?因为如果n有一个大于sqrt(n)的质因子,那么它必然对应一个小于sqrt(n)的质因子(否则两个大于sqrt(n)的质因子相乘会大于n)。当我们用所有小于等于sqrt(n)的数去试除之后,剩下的n如果还大于1,那么它一定是一个大于sqrt(n)的质数,且指数为1。

这个逻辑用C++实现起来非常简洁:

unordered_map<int, int> primes; // 哈希表,存储质因子和对应的指数 for (int i = 2; i <= n / i; i++) { // 等价于 i*i <= n, 防止溢出 while (n % i == 0) { primes[i]++; n /= i; } } if (n > 1) primes[n]++; // 处理最后剩下的大于sqrt的质因子

这里使用unordered_map来存储质因子和其指数,非常直观。循环条件i <= n / i是判断i <= sqrt(n)的经典写法,避免了直接计算平方根和可能的数据溢出。

注意:在循环内部,n的值在不断减小,所以sqrt(n)也在动态变化。但我们的循环条件i <= n / i完美地适应了这个变化,确保了效率。这是试除法中一个非常精妙的细节。

3. 代码实现与逐行解析

理解了原理和分解过程,我们就可以动手写出完整的AC代码了。下面我将给出两种风格的实现:一种是面向过程、适合初学者的清晰版本;另一种是封装成函数、便于复用的工程版本。我们会逐行分析关键点。

3.1 基础清晰版实现

这个版本直接在主函数中处理输入、分解、计算和输出,逻辑一目了然。

#include <iostream> #include <unordered_map> using namespace std; int main() { int n; cin >> n; unordered_map<int, int> prime_factors; // 质因子 -> 指数 // 质因数分解 for (int i = 2; i <= n / i; i++) { while (n % i == 0) { prime_factors[i]++; n /= i; } } // 处理可能剩余的唯一一个大于sqrt的质因子 if (n > 1) { prime_factors[n]++; } // 计算约数个数 long long res = 1; // 使用long long防止结果溢出 for (auto &[p, a] : prime_factors) { // C++17结构化绑定 res = res * (a + 1); } cout << res << endl; return 0; }

关键点解析:

  1. 头文件与命名空间<unordered_map>用于存储分解结果。使用using namespace std;在竞赛中很常见,可以简化代码,但在大型项目中应避免。
  2. 循环条件i <= n / i:如前所述,这是试除法的核心优化,比i * i <= n更安全,避免了i*i可能导致的整数溢出。
  3. while (n % i == 0):这个内层循环用于彻底除掉当前质因子i,并统计其次数。比如n=12,当i=2时,会连续执行两次循环,得到prime_factors[2]=2,同时n变为3。
  4. if (n > 1):这个判断至关重要。试想n=13,它是一个质数,大于sqrt(13)(约3.6)。循环从i=2开始,23都无法整除13,循环结束后n仍然是13且大于1,此时它本身就是那个唯一的质因子。
  5. 结果变量res的类型:这里使用了long long。为什么?考虑一个极端情况,n是前8个质数的乘积:2*3*5*7*11*13*17*19 = 9699690。它的每个质因子指数都是1,根据公式,约数个数是2^8 = 256,还在int范围内。但如果n是像2^30这样的数,虽然n本身巨大,但约数个数是30+1=31,也很小。看起来int似乎够了?其实不然。题目可能不会直接给出一个约数个数巨大的n,但在后续的扩展题目中(比如求多个数乘积的约数个数),中间结果(a+1)的连乘很容易超出int范围。使用long long是一个安全且良好的习惯。
  6. C++17 结构化绑定for (auto &[p, a] : prime_factors):这行代码遍历哈希表,将键(质因子p)和值(指数a)直接解包到变量pa中,代码非常清晰。如果你的编译环境不支持C++17,可以写成传统的for (auto &it : prime_factors),然后用it.firstit.second访问。

3.2 函数封装与工程化版本

将核心逻辑封装成函数,可以提高代码的复用性和可读性,也更符合软件工程的实践。

#include <iostream> #include <unordered_map> using namespace std; // 函数:对整数x进行质因数分解,结果存储在哈希表factor_map中 void prime_factorization(int x, unordered_map<int, int>& factor_map) { for (int i = 2; i <= x / i; i++) { while (x % i == 0) { factor_map[i]++; x /= i; } } if (x > 1) { factor_map[x]++; } } // 函数:根据质因数分解结果计算约数个数 long long count_divisors(const unordered_map<int, int>& factor_map) { long long res = 1; for (const auto &pair : factor_map) { res *= (pair.second + 1); } return res; } int main() { int n; cin >> n; unordered_map<int, int> factors; prime_factorization(n, factors); long long ans = count_divisors(factors); cout << ans << endl; return 0; }

这个版本的优点是将“分解”和“计算”两个步骤解耦。prime_factorization函数只负责分解,它修改传入的factor_mapcount_divisors函数是只读的,它接收一个常量引用,根据映射表计算结果。这样的设计使得每个函数职责单一,更容易测试和维护。例如,你可以单独测试prime_factorization函数是否正确分解了100{2:2, 5:2}

4. 边界条件、易错点与性能分析

即使理解了算法,在实现时依然可能踩坑。下面我结合自己的经验,总结几个常见的易错点和需要特别注意的边界情况。

4.1 输入为1的情况

这是最容易忽略的边界条件。根据定义,1的约数只有它本身,所以约数个数是1。但是,我们的质因数分解逻辑对n=1会怎样?

  • 循环for (int i = 2; i <= n / i; i++):初始条件i=2,n=1,判断2 <= 1/2?显然不成立,循环直接跳过。
  • 后续判断if (n > 1)n=1不大于1,条件不成立。
  • 此时,存储质因子的哈希表prime_factors是空的。
  • 进入计算约数个数的循环:res初始化为1,遍历空哈希表,循环体一次都不执行。
  • 最终res保持为1,输出正确。

所以,我们的算法天然地正确处理了n=1的情况,不需要额外特判。这是一个很好的性质。

4.2 关于质数判断的误区

有同学可能会问:循环中的i从2开始递增,如果i不是质数(比如4, 6, 8...),会不会被错误地当作质因子除进去?答案是:不会。因为当我们用i=2试除时,已经把所有因子2都除干净了。所以当i递增到4时,n已经不可能再被4(即2^2)整除了。同理,n也不可能被任何合数整除。因此,虽然我们循环遍历了所有整数,但真正能进入while循环的i一定是质数。这是一种隐式的“用质数试除”的效果,代码更简洁。

4.3 时间复杂度分析

我们主要分析质因数分解部分的时间复杂度。

  • 最好情况n是2的幂次,如n=2^k。循环中i=2时,while循环会执行k次(因为要除k次2)。之后n变为1,外层for循环结束。时间复杂度为O(log n)
  • 最坏情况n本身是一个质数,或者它是两个大质数的乘积(如n = 999983 * 999979,两者都是质数)。这时,我们需要从i=2一直试除到i=sqrt(n)才能确定n是质数(或找到较小的那个质因子)。时间复杂度为O(sqrt(n))
  • 平均情况:远优于O(sqrt(n))。因为大多数合数都有较小的质因子,很快就会被分解掉。

对于题目常见的数据范围n <= 10^9sqrt(10^9) ≈ 31623,这个循环次数是完全可接受的。如果n更大,达到10^12甚至10^18,就需要更高效的算法,如 Miller-Rabin 素性测试和 Pollard Rho 因数分解算法,但这已经超出了本题模板的范围。

4.4 结果溢出的问题

前面提到用long long存储结果。这里再强调一下乘法溢出的风险。假设我们计算(a1+1)*(a2+1)*...,即使最终结果在long long范围内,但中间的连乘过程也可能发生溢出吗?在C++中,两个int相乘,结果还是int,如果超出int范围就会溢出,即使你把它赋值给long long变量,溢出的结果也已经错了。

int a = 1000000; int b = 1000000; long long c = a * b; // 错误!a*b在int乘法中已经溢出,c得到的是溢出后的错误值。 long long d = 1LL * a * b; // 正确!1LL是long long类型,它使整个表达式提升为long long乘法。

在我们的代码中,a(即指数)是int(a+1)也是intreslong long。计算res = res * (a + 1)时,reslong long(a+1)int,根据C++的算术转换规则,int会被提升为long long,然后进行long long乘法,所以不会发生中间溢出。因此我们之前的写法是安全的。但如果你将res也声明为int,那就非常危险了。

5. 模板的扩展与应用场景

掌握这个模板,不仅仅是解开了AcWing 870这一道题。它是一把万能钥匙,可以解决一系列衍生问题。下面我举几个例子,看看如何灵活运用。

5.1 求多个数乘积的约数个数

这是非常常见的变体。例如,给你n个数a1, a2, ..., an,求它们乘积的约数个数。最笨的方法是先算出所有数的乘积,然后再对这个巨大的乘积进行质因数分解。但乘积可能非常巨大,远超long long范围,导致无法直接计算。

正确的做法是:分别对每一个数ai进行质因数分解,然后将它们的所有质因子指数累加。因为乘积的质因数分解,等于每个乘数质因数分解的指数相加。

假设我们有两个数:12 = 2^2 * 3^118 = 2^1 * 3^2。 它们的乘积12 * 18 = 216。 直接分解216 = 2^3 * 3^3。 我们也可以分别分解后指数相加:2的指数2+1=33的指数1+2=3。结果一致。

代码实现上,我们只需要一个全局的unordered_map<int, int>,在分解每个数ai时,将得到的质因子指数累加到这个全局映射表中即可。最后再用公式计算。

5.2 求约数之和

另一个紧密相关的模板是“约数之和”。公式也非常优美: 如果n = p1^a1 * p2^a2 * ... * pk^ak,则约数之和σ(n)为:σ(n) = (p1^0 + p1^1 + ... + p1^a1) * (p2^0 + p2^1 + ... + p2^a2) * ... * (pk^0 + pk^1 + ... + pk^ak)

这个公式同样基于乘法原理。对于每个质因子pi,在构造约数时,我们可以选择其指数为0, 1, ..., ai,这分别对应了pi^0, pi^1, ..., pi^ai这些“因子块”。所有约数就是这些因子块的所有组合乘积之和,化简后就是上面的乘积形式。

在代码实现上,我们在得到质因数分解结果后,对于哈希表中的每一个(p, a)对,计算sum_i = (p^0 + p^1 + ... + p^a)。这个等比数列求和可以用循环计算,也可以用公式(p^(a+1) - 1) / (p - 1)快速计算(注意取模时的乘法逆元)。最后将所有sum_i相乘即可。

5.3 在算法竞赛中的实际应用

在算法竞赛中,单独考“求一个数的约数个数”的题不多,但它经常作为子问题出现。

  • 数论题:比如判断一个数是否为“高度合成数”(即约数个数比任何小于它的正整数都多),就需要频繁计算约数个数。
  • 组合数学与计数题:有些计数问题可以转化为求某个大数的约数个数,或者求满足特定条件的约数个数。
  • 构造题:给你约数个数的要求,反推构造出这个数。

更重要的是,质因数分解本身是一个极其基础且重要的操作。它在求最大公约数(GCD)、最小公倍数(LCM)、欧拉函数、模运算的逆元等问题中都是前置步骤。熟练掌握试除法分解质因数,是深入数论学习的必经之路。

6. 从模板到精通:优化与替代方案

我们目前采用的试除法是O(sqrt(n))的。对于极大的n(比如10^18),这个复杂度仍然不够。下面介绍两种更高级的算法,供学有余力的朋友参考。

6.1 预处理素数表优化

试除法中,我们循环了所有i从2到sqrt(n)。但其中大约一半是偶数(除了2都不是质数)。我们可以先进行特判,除掉所有的因子2,然后从i=3开始,每次循环i+=2,只检查奇数。这样可以减少近一半的循环次数。

unordered_map<int, int> primes; // 处理因子2 while (n % 2 == 0) { primes[2]++; n /= 2; } // 从3开始,只检查奇数 for (int i = 3; i <= n / i; i += 2) { while (n % i == 0) { primes[i]++; n /= i; } } if (n > 1) primes[n]++;

更进一步,我们可以预先用埃氏筛或欧拉筛求出一定范围内(比如sqrt(MAX_N))的所有质数,存到一个数组里。在分解时,只用这些预处理的质数去试除。这样避免了用合数去试除的判断开销。当需要多次对不同的数进行质因数分解时,这种预处理的优势非常明显。

6.2 应对极大整数的算法:Miller-Rabin 与 Pollard Rho

对于long long范围内的极大整数(10^18),O(sqrt(n))的算法(sqrt(10^18)=10^9)是不可接受的。这时需要概率性算法。

  • Miller-Rabin 素性测试:一个快速判断一个数是否为质数的概率算法。如果测试通过,我们可以直接得出结论(质数的约数个数为2)。
  • Pollard Rho 因数分解算法:一个用于快速找到大整数非平凡因子的概率算法。它的期望时间复杂度是O(n^{1/4}),对于大数来说比试除法快得多。

这两个算法组合使用,可以高效地对大整数进行质因数分解。它们的实现相对复杂,涉及模幂运算、随机数、最大公约数等。在一般的算法竞赛中(如ACM、蓝桥杯国赛)可能会遇到需要用到这些知识点的题目。对于AcWing 870这样的模板题,掌握试除法已经完全足够,但了解这些更高级的工具能让你在面对更复杂问题时游刃有余。

7. 调试技巧与常见问题排查

即使代码逻辑正确,在编写和调试时也可能遇到一些令人困惑的问题。这里分享几个我调试数论代码时的经验。

问题1:程序对某些数输出错误,比如n=9输出2(正确应为3)。

  • 排查:首先检查质因数分解部分。n=9,循环i=22 <= 9/2成立,但9%2 !=0i=33 <= 9/3成立,进入while循环:9%3==0,质因子3计数加1,n变为3;继续while循环:3%3==0,质因子3计数变为2,n变为1。循环结束。if (n>1)不成立。此时prime_factors中记录的是{3:2}。计算约数个数:(2+1)=3。如果输出2,说明可能是在计算res时,遍历哈希表漏了条目,或者res初始化为了0?检查代码,发现res初始化为1,遍历也正确。那问题可能出在哈希表的遍历上。如果你在分解过程中修改了n,但循环条件或循环变量依赖于n,可能会引起逻辑错误。不过我们的代码逻辑是标准的,应该没问题。再检查输出语句,是不是误输出了其他变量?最终发现,可能是之前测试时,错误地在计算完res后又对其进行了某种操作(比如res--)。仔细检查代码逻辑流。

问题2:程序对非常大的数运行超时。

  • 排查n有多大?如果n10^9量级的质数,那么试除法需要循环大约31623次,这在现代CPU上应该是瞬间完成的。如果超时,首先检查是否有死循环。比如while (n % i == 0)这个循环,如果i始终为0(由于某些错误赋值),或者n在某次除法后变成了0,可能会导致%运算出错或死循环。确保循环变量in在循环中正确变化。其次,检查输入是否合法,程序是否在处理非法输入时陷入异常逻辑。

问题3:结果比预期大很多,疑似溢出。

  • 排查:立刻检查所有变量的类型。
    1. 用于存储结果的res是否是long long
    2. 在计算res = res * (a + 1)时,(a+1)是否是int?与long longres相乘,是否会先以int乘法进行导致溢出?正如前面分析的,不会,因为long long * int会将int提升为long long
    3. 如果resint,那几乎肯定会溢出。将其改为long long
    4. 还有一种隐蔽的溢出:如果题目要求对结果取模(比如1e9+7),那么在连乘的过程中,每次乘法后都要立即取模,防止中间结果溢出long long。虽然本题没有取模要求,但这是一个重要的好习惯。

提示:在写数论代码时,对于涉及乘法的变量,无脑使用long long通常是更安全的选择,除非你非常确定数据范围。空间开销可以忽略不计,但能避免很多隐蔽的错误。

最后,学习算法模板,切忌死记硬背。一定要自己动手推导一遍公式,理解每一个循环、每一个判断背后的数学原理。然后找几个典型的测试用例(如n=1,n=质数,n=完全平方数,n=多个质数乘积)手动模拟代码执行过程,或者用调试器一步步跟踪。当你能够清晰地解释代码的每一行为什么这样写时,这个模板才真正属于你。AcWing 870这道题只是一个起点,它所代表的质因数分解思想,会在你后续学习算法的道路上反复出现,成为一个强有力的工具。

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

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

立即咨询