1. 先搞清楚:质数、因数、质因数分解到底是啥
很多人看到“求质数因数”这个题目,第一反应是:这是不是让我判断一个数是不是质数?其实不完全是。这个题的核心是质因数分解,就是把一个合数拆成若干个质数相乘的形式。比如 12 拆成 2 × 2 × 3,30 拆成 2 × 3 × 5,100 拆成 2 × 2 × 5 × 5。每一步拆出来的 2、3、5 这些数,本身就是质数,同时它们也是原数的因数,所以叫“质数因数”。
先把这个概念拆开揉碎讲清楚。
质数是大于 1 的自然数里,只能被 1 和它自身整除的数。2、3、5、7、11、13 这些都是质数。注意 1 不是质数,这个很多新手容易记错。
因数就更好理解了,一个数能被另一个数整除,那后者就是前者的因数。6 的因数有 1、2、3、6,其中 2 和 3 是质数,所以 2 和 3 就是 6 的质因数。
质因数分解,就是把这个数拆到不能再拆为止。拆到最后的每一个因子都是质数,这个分解过程在数学上叫“唯一分解定理”——任何一个大于 1 的自然数,都可以唯一地分解成有限个质数的乘积。这个唯一性很重要,意味着不管你用什么顺序去拆,最后得到的质因数集合是一样的,只是顺序可能不同。
那你可能要问了:为什么一定要拆成质数?拆成合数不行吗?比如 24 可以拆成 4 × 6,但 4 和 6 本身还能继续拆,4 = 2 × 2,6 = 2 × 3,最终还是会拆到 2 × 2 × 2 × 3。质因数分解本质上是在问:一个数到底是由哪些“最小不可分割的因子”组成的。就像化学里把物质拆成元素,质数就是数论里的“原子”。
C++ 圈子里最近常提到“质数口袋”这个说法,其实很形象——你从小到大判断一个数是不是质数,是的话就“装进口袋”里,最后口袋里收了一堆质数,再拿这些质数去试除目标数。这个思路后面写代码的时候会用到,先记住这个概念。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 判断质数是基本功:从试除法到 sqrt 优化
做质因数分解之前,先得会判断一个数是不是质数。虽然分解过程不一定要显式调用“判断质数”的函数,但理解了判断质数的逻辑,才能理解分解算法的每一步在干什么。
2.1 朴素做法写起来很爽,跑起来很痛
最直觉的判断方法是:从 2 一直试到 n-1,只要有一个数能整除 n,那 n 就不是质数。
cpp复制bool isPrime(int n) {
if (n < 2) return false;
for (int i = 2; i < n; i++) {
if (n % i == 0) return false;
}
return true;
}
这段代码逻辑完全没问题,但效率极差。假设 n = 1000000,这个循环要跑将近一百万次才算完。如果题目要你判断一万个数,那一亿次运算就出去了,妥妥超时。
2.2 为什么只检查到 sqrt(n) 就够了
这是我刚学的时候最困惑的地方,当年老师直接抛结论,没讲为什么。这里我用自己的话给你讲明白。
假设 n 是一个合数,那它一定可以写成 n = a × b 的形式,其中 a 和 b 都大于 1。如果 a 和 b 都大于 sqrt(n),那么 a × b 就会大于 n,矛盾。反过来想:a 和 b 不可能同时比 sqrt(n) 大,所以至少有一个因子是小于等于 sqrt(n) 的。
举个例子,n = 100,sqrt(100) = 10。100 的因数有 2、4、5、10、20、25、50,你会发现 100 = 10 × 10,而 2、4、5 这些都在 10 以内。如果我们从 2 检查到 10,就能找到 2 这个因子,从而判断 100 不是质数,根本不需要去试 20、25、50。
所以优化后的判断函数长这样:
cpp复制bool isPrime(int n) {
if (n < 2) return false;
for (int i = 2; i * i <= n; i++) {
if (n % i == 0) return false;
}
return true;
}
注意这里用 i * i <= n 而不是 i <= sqrt(n),因为 sqrt 函数有浮点精度问题,而且每次都调用 sqrt 也会拖慢速度,直接比较整数的平方反而更稳妥。
再往细里说,还有一个常见的微优化:先把 2 单独判断,然后从 3 开始,每次加 2,只检查奇数。因为偶数除了 2 以外都不可能是质数,这样循环次数直接减半。这个优化在小数据量上无所谓,但养成习惯对以后写复杂算法有好处。
cpp复制bool isPrime(int n) {
if (n < 2) return false;
if (n == 2) return true;
if (n % 2 == 0) return false;
for (int i = 3; i * i <= n; i += 2) {
if (n % i == 0) return false;
}
return true;
}
2.3 质数口袋:先把小质数都装进去
回到“质数口袋”这个说法的应用场景。如果题目不仅要求你分解一个数,还要求你先找出某个范围内的所有质数,这时候你可以先把这些质数存到一个数组或 vector 里,这个容器就是“口袋”。
比如要找出 1 到 100 之间所有的质数,用上面优化后的 isPrime 一个个判断,是质数就 push_back 进 vector,最后得到的 primes 数组就是你的质数口袋。这个口袋里的质数可以直接用来做后续的质因数分解,不用每次都重新判断。
cpp复制vector<int> primes;
for (int i = 2; i <= 100; i++) {
if (isPrime(i)) primes.push_back(i);
}
当然,如果你要筛的区间特别大,比如 1 到 10^7,一个一个用试除法判断还是慢,那就得换用埃氏筛或者欧拉筛。但那是另一个话题了,这里先不展开,把质因数分解的主线讲完。
3. 质因数分解的核心算法:从小到大试着除
质因数分解的经典做法非常朴素:从最小的质数 2 开始,不断把 n 除到不能再除为止,然后换下一个数继续试。整个过程就像“剥洋葱”,一层一层把质因子剥出来。
3.1 核心思想就是“能除就除,除到不能除”
假设要分解 n = 84:
- n 能被 2 整除,84 ÷ 2 = 42,记录一个 2。还能继续,42 ÷ 2 = 21,再记录一个 2。21 不能被 2 整除了。
- 换到 3,21 能被 3 整除,21 ÷ 3 = 7,记录一个 3。7 不能被 3 整除。
- 换到 4,但 7 小于 4 的平方根?其实 4 这一步根本不需要试,因为 4 不是质数,而且在检查 2 的时候已经把所有因子 2 都“榨干”了,4 不可能再整除 7。
- 换到 5、6 都不用试,直接到 7,7 能被 7 整除,7 ÷ 7 = 1,记录一个 7。结束。
最终得到 84 = 2 × 2 × 3 × 7。
这里有一个关键点:用试除法分解时,循环的范围其实不需要到 n,到 sqrt(原来的 n) 就可以。因为当你把 2 到 sqrt(n) 之间所有可能的质因子都除掉之后,剩下的 n 要么是 1,要么是一个大于 sqrt(n) 的质数。这个结论和判断质数的原理是对应的。
3.2 那循环到底怎么写?
直接看代码:
cpp复制#include <iostream>
using namespace std;
void factorize(int n) {
// 先处理因子 2
while (n % 2 == 0) {
cout << 2 << " ";
n /= 2;
}
// 然后从 3 开始,只检查奇数
for (int i = 3; i * i <= n; i += 2) {
while (n % i == 0) {
cout << i << " ";
n /= i;
}
}
// 如果最后 n 大于 1,说明剩下的 n 本身是一个质数
if (n > 1) {
cout << n << " ";
}
}
int main() {
int n;
cin >> n;
factorize(n);
return 0;
}
这个代码有几个细节值得单独拿出来说:
第一,先单独处理 2 是因为 2 是唯一的偶质数。如果从 1 开始循环,i += 1 的话,效率会低一倍以上;如果从 3 开始但步长还是 1,那会去检查 4、6、8 这些合数,虽然结果没错,但浪费时间。从 3 开始每次加 2,天然避开了所有偶数。
第二,for 循环的条件是 i * i <= n,这里的 n 是不断变化的。每除掉一个因子,n 就在变小,所以循环的上限也在不断缩窄。很多人一开始会写成 i * i <= originalN,那样也能跑,但会做很多无用功。写 i * i <= n 是更聪明的做法,因为一旦 n 已经缩小到 i 的平方以下,剩下的部分就不可能再有大于 i 的复合因子了。
第三,最后那个 if (n > 1) 是整段代码的灵魂。很多人漏掉这一步,导致输出里少最后一个质因子。举个例子:n = 97,97 本身是质数,2、3、5、7 都不能整除它,循环跑完 n 还是 97,如果没有最后的判断,输出就是空的——这显然不对。有了 if (n > 1),97 才会被正确地输出。
3.3 为什么要从最小的质因子开始试?
你可能好奇,为什么不从大到小试?比如先把 n 除以所有比 sqrt(n) 大的可能因子?
从小的开始试,是因为每个质因子可能在 n 里出现多次,只有从小到大挨个除干净,才能保证不漏。而且小的质因子更容易整除 n,除完以后 n 迅速变小,后续的试除范围也跟着缩小,整体效率更高。
打个比方,你要把一箱苹果按照大小分类,是从最小的开始挑还是一口气抓最大的?肯定是从小的开始,因为最小的苹果最容易分辨,而且挑走最小的一批之后,剩下的苹果重新排列,更大的苹果也更容易被识别出来。算法也是这个道理。
4. 每一步都讲透:C++ 实现与细节优化
上面那版代码已经能解决基本问题了,但实际应用中还有好几个变体和坑,我把它们都整理出来。
4.1 带次幂输出的版本
有时候题目不要求你把所有质因数都列出来,而是要你输出“质因数 + 出现次数”的格式,比如 12 输出 2^2 * 3^1,这就需要在循环里加一个计数器。
cpp复制void factorizeWithCount(int n) {
int cnt = 0;
while (n % 2 == 0) {
cnt++;
n /= 2;
}
if (cnt > 0) cout << "2^" << cnt << " ";
for (int i = 3; i * i <= n; i += 2) {
cnt = 0;
while (n % i == 0) {
cnt++;
n /= i;
}
if (cnt > 0) cout << i << "^" << cnt << " ";
}
if (n > 1) cout << n << "^1" << endl;
}
这个版本的好处是输出更紧凑,而且题目如果问“一个数有多少个因子”,你可以直接用质因数的指数加一再相乘来算。比如 12 = 2^2 × 3^1,因子个数就是 (2+1) × (1+1) = 6,对应 1、2、3、4、6、12。这个性质在数论题里非常常见。
4.2 大数据范围的坑:int 不够用
如果你分解的数超过 2^31 - 1(也就是约 21 亿),int 类型就会溢出。这时要把所有变量换成 long long,尤其是 n 和 i * i 这一部分。
注意 i * i <= n 这个条件本身也有溢出的风险:如果 i 是 int,i * i 用的还是 int 运算,一旦 i 超过 46340 左右,i * i 就会溢出变成负数,循环条件立刻出问题。解决办法要么把 i 声明为 long long,要么写成 i <= n / i 的形式。我个人的习惯是直接用 long long,省心。
cpp复制long long n;
cin >> n;
for (long long i = 2; i * i <= n; i++) {
while (n % i == 0) {
cout << i << " ";
n /= i;
}
}
if (n > 1) cout << n << endl;
4.3 用质数口袋优化:先筛出质数再试除
前面提到“质数口袋”,这里讲一个更高级的玩法:先用埃氏筛筛出 1 到 sqrt(MaxN) 的所有质数,然后只用这些质数去试除 n。
cpp复制#include <iostream>
#include <vector>
#include <cmath>
using namespace std;
vector<int> sieve(int limit) {
vector<bool> isPrime(limit + 1, true);
vector<int> primes;
isPrime[0] = isPrime[1] = false;
for (int i = 2; i <= limit; i++) {
if (isPrime[i]) {
primes.push_back(i);
for (int j = i * 2; j <= limit; j += i) {
isPrime[j] = false;
}
}
}
return primes;
}
void factorizeWithPrimes(long long n, const vector<int>& primes) {
for (int p : primes) {
if (1LL * p * p > n) break;
while (n % p == 0) {
cout << p << " ";
n /= p;
}
}
if (n > 1) cout << n << endl;
}
int main() {
long long n;
cin >> n;
int limit = sqrt(n) + 1;
vector<int> primes = sieve(limit);
factorizeWithPrimes(n, primes);
return 0;
}
这样做的好处是,如果你要分解很多个数,就不用每个数都从头开始试除所有奇数,而是直接复用质数口袋。比如你要分解 1000 个数,每个数最大 10^12,那质数口袋只要筛到 10^6 就好,每个数试除的候选因子数量大幅减少。实测下来,在多次查询场景下比单纯试除法快 5 到 10 倍。
4.4 关于统计质因数个数的一个经典问题
有些题目会问你一个数的质因数总共有多少个不同的,而不是每个质因子出现几次。这个统计也有一个常见坑:循环里每次除以同一个质因子的时候,计数器只能加一次,不能加在 while 里面。更多人会犯的错误是,把“因子个数”和“质因子种数”搞混,被题目绕进去。
写一个不同的质因子个数统计:
cpp复制int countDistinctPrimeFactors(long long n) {
int count = 0;
if (n % 2 == 0) {
count++;
while (n % 2 == 0) n /= 2;
}
for (long long i = 3; i * i <= n; i += 2) {
if (n % i == 0) {
count++;
while (n % i == 0) n /= i;
}
}
if (n > 1) count++;
return count;
}
这段代码的核心逻辑是:找到一个能整除 n 的因子,计数加一,然后用 while 把这个因子从 n 里全部除掉,确保下一个找到的因子一定是新的质因子。理解了这段代码,你就掌握了质因数分解的“形态”——任何变体都逃不出这个框架。
5. 代码跑起来后,我踩过的那些坑
这部分是我真心想跟你分享的,因为当年我学这个题的时候,代码写得对,但一到线上评测就出各种奇奇怪怪的问题。这里整理几个高频坑,你以后遇到了能直接对症下药。
5.1 输入为 1 的情况
题目如果说“给你一个正整数 n”,那 n 可能等于 1。1 既不是质数也不是合数,没有任何质因数。你的代码在 n = 1 的时候,最前面的 while 和 for 都不会执行,最后 if (n > 1) 也不成立,输出为空——这其实是对的。但如果你在程序里加了“输出 1”的逻辑,那就错了。建议在代码开头加一个特判:
cpp复制if (n == 1) {
cout << "No prime factors" << endl;
return 0;
}
5.2 大质数输入导致超时
如果输入一个很大的质数,比如 9999999967(这其实是一个质数),试除法要一直试到 sqrt(9999999967) ≈ 99999,也就是要循环约 5 万次。单次运行没问题,但如果你用了一个很差的实现,循环里每次还要调用 isPrime(i) 去判断 i 是不是质数,那复杂度直接飙升到 O(sqrt(n) * sqrt(i)),很容易超时。
正确做法是:试除法中根本不需要判断 i 是不是质数。 因为当你从 2 开始挨个试除时,如果 i 是合数,它的质因子一定已经在前面被处理掉了,所以 i 不可能再整除当前剩下的 n。换句话说,合数因子根本没机会“刷存在感”,直接用 i 去试除就行。
5.3 循环条件里 i * i 递归变化引发的迷思
有一部分初学者会误以为 for (int i = 3; i * i <= n; i += 2) 中的 n 在循环体内被修改后,会导致循环提前退出,从而漏掉某个大因子。
其实不会漏。我的理解方式是这样的:循环变量 i 表示当前试除的因子候选,n 表示“还没分解完的剩余部分”。当 i * i > n 时,剩余的 n 必然是一个质数,因为如果它还是合数,它至少有一对因子 a × b,且 a 和 b 都不大于 sqrt(n),那在 i 到达 sqrt(n) 之前就应该被找到了。所以循环提前退出是算法的设计,不是 bug。
5.4 输出格式的坑
很多评测系统对空格和换行有严格要求。如果你输出的是 2 3 5 和 2 3 5 (末尾多一个空格),可能一个被判对,一个被判错。建议统一用这种写法:
cpp复制bool first = true;
while (n % i == 0) {
if (!first) cout << " ";
cout << i;
first = false;
n /= i;
}
这样就不会有多余空格的问题了。
5.5 多层 while 嵌套导致死循环
有时候你会看到有人这样写:
cpp复制while (n % 2 == 0) {
cout << 2 << " ";
}
忘了写 n /= 2,结果 n 一直是偶数,程序永远跑不完。这种低级错误在考试紧张的时候真的会出现,一定要在每次输出之后把 n 更新。
6. 质数口袋的扩展玩法:不只是分解
讲完了基本算法,我再补充一些跟“质数口袋”相关的扩展思路。有些题目虽然不直接叫“求质因数”,但本质上是同一套东西,提前掌握这些套路,解题速度会快很多。
6.1 用质因数分解求最大公约数和最小公倍数
求两个数的最大公约数(GCD)和最小公倍数(LCM),大家习惯用辗转相除法,这确实快。但有时候题目要求你输出“两个数共有的质因数”,或者要求你基于质因数分解求 GCD,那就得用这套思路了。
比如 84 = 2^2 × 3 × 7,90 = 2 × 3^2 × 5。两者的公共质因子是 2 和 3,取每个公共质因子指数的最小值:2^1 × 3^1 = 6,所以 GCD 是 6。LCM 则是取每个质因子指数的最大值:2^2 × 3^2 × 5 × 7 = 1260。
6.2 统计区间内所有数的质因数个数
有些题会给你一个区间 [L, R],让你统计每个数有多少个不同的质因子,或者每个数总共出现了多少个质因子(重复的也算)。如果用暴力对每个数做一次分解,区间长的时候会很慢。
更快的方式是用类似筛法的思路:开一个数组 cnt[R+1],对每个质数 p,枚举 p 的所有倍数,让这些倍数对应的计数加一。这样一次筛完,就能得到区间内所有数的不同质因子个数。这个思想在“埃氏筛求质因子个数”问题里非常管用,也是“质数口袋”的进阶用法。
cpp复制vector<int> distinctPrimeFactorsCount(int limit) {
vector<int> cnt(limit + 1, 0);
vector<bool> isPrime(limit + 1, true);
vector<int> primes;
for (int i = 2; i <= limit; i++) {
if (isPrime[i]) {
primes.push_back(i);
// 枚举 i 的所有倍数
for (int j = i; j <= limit; j += i) {
cnt[j]++;
isPrime[j] = false? // 这里不能直接置 false!
}
}
}
return cnt;
}
注意第 11 行的问题:如果直接把 isPrime[j] 置为 false,那在 j 还没被自己的较小质因子筛过的时候,计数就错了。正确写法是不跟埃氏筛混在一起,单独用质数列表去枚举倍数。
cpp复制vector<int> primes = sieve(limit);
vector<int> cnt(limit + 1, 0);
for (int p : primes) {
for (int multiple = p; multiple <= limit; multiple += p) {
cnt[multiple]++;
}
}
这种做法的思路很简单:每个质数 p 都是它所有倍数的质因子,所以每遇到一个倍数,就给它计数加一。跑一遍就能统计出所有数有多少个不同的质因子,时间复杂度大概是 O(n log log n),比逐个分解高效得多。
6.3 判断 n 是否能表示为两个质数的和
这是“哥德巴赫猜想”的弱化版题目:给定一个偶数 n,判断它能不能拆成两个质数之和。做法是先筛出 n 范围内的所有质数,然后遍历质数口袋,对每个质数 p,检查 n - p 是不是质数。这本质上也是用口袋预处理的典型场景。
cpp复制bool canSumOfTwoPrimes(int n) {
auto primes = sieve(n);
unordered_set<int> primeSet(primes.begin(), primes.end());
for (int p : primes) {
if (primeSet.count(n - p)) return true;
}
return false;
}
这个例子告诉你,“质数口袋”在竞赛里是一种常用预处理手段,先把质数表打出来,后面查询就是 O(1) 或 O(m) 的事,而不是每次现算。
7. 优化到极致:压榨质因数分解的性能
前面给出的代码已经足够应付大多数场景了,但如果你追求极致性能,还有几个方向可以压榨。
7.1 减少取模运算的代价
取模运算 % 在 CPU 层面时比加减乘慢不少,频繁 while (n % i == 0) 里大量取模会拖慢速度。当 i 比较小的时候,比如 2、3、5,可以用直接减除的方式来模拟取模?实际上编译器已经对常数的除法取模做了优化,比如 n % 2 == 0 会被优化成 (n & 1) == 0。所以没必要刻意抠这个。
7.2 特殊因子直接跳过
如果 n 是负数,题目一般不会给出,但如果你要处理补码表示的负数,可以先取绝对值。浮点数更麻烦,因为质因数分解只对整数有意义,输入必须保证是 int 或 long long。
7.3 用 Pollard Rho 算法对付超大数
如果 n 特别大,比如 10^18 级别,试除法到 sqrt(n) 也就是 10^9,无论如何都要超时。这时得用 Pollard Rho 算法,配合 Miller-Rabin 素性测试,能在近似 O(n^(1/4)) 的时间复杂度内完成分解。
这个算法对新手来说有点难,我暂不展开贴代码,但你要知道它存在的意义:试除法是质因数分解的“步兵”,Pollard Rho 是“特种兵”。打竞赛到后期,很多大整数分解题都要靠它。
7.4 思考:质因数分解为什么难?
聊到这里,我想多说一句。质因数分解在数学和密码学里之所以重要,是因为乘起来容易,拆回去难。给你两个大质数,比如 10 位长的,你相乘得到一个大合数,很容易;但给你这个合数,你要把它的两个质因子找出来,就得费很大劲。试除法在数据小的时候没问题,一旦数据变大,时间的增长是指数级的。RSA 加密算法就是建立在“大整数分解很难”这个基础上的。
学这个题的时候,顺便了解一下它背后的数学意义,对培养算法直觉很有帮助。
8. 实战演练:完整代码和输出演示
最后,我把一个完整的、带输入输出的程序放在这里,可以拿去直接用。
cpp复制#include <iostream>
#include <vector>
using namespace std;
void factorize(long long n) {
if (n == 1) {
cout << "1 = 1" << endl;
return;
}
cout << n << " = ";
bool first = true;
// 处理因子 2
int cnt = 0;
while (n % 2 == 0) {
cnt++;
n /= 2;
}
if (cnt > 0) {
cout << "2^" << cnt;
first = false;
}
// 处理奇数因子
for (long long i = 3; i * i <= n; i += 2) {
cnt = 0;
while (n % i == 0) {
cnt++;
n /= i;
}
if (cnt > 0) {
if (!first) cout << " * ";
cout << i << "^" << cnt;
first = false;
}
}
// 剩余的质因子
if (n > 1) {
if (!first) cout << " * ";
cout << n << "^1";
}
cout << endl;
}
int main() {
long long n;
while (cin >> n) {
factorize(n);
}
return 0;
}
运行示例:
code复制输入:
12
输出:
12 = 2^2 * 3^1
输入:
84
输出:
84 = 2^2 * 3^1 * 7^1
输入:
97
输出:
97 = 97^1
输入:
1
输出:
1 = 1
特别说明一下,我在代码里特意把输出格式调成了 2^2 * 3^1 这种,因为比起 2 2 3,这种输出更接近数学表达,也更容易对照检查结果。如果你的题目要求输出空格分隔的质因子序列,把输出格式改成 cout << i << " " 就行。
一点个人体会
写这个题的时候我突然想到,质因数分解其实是最适合“理解循环边界”的练手题目。“什么时候循环结束”“为什么最后剩一个大于 1 的数就一定是个质数”“变量在循环体里变了之后,下一个判断还成不成立”——这些问题你在这个题里全都会遇到。把这道题吃透,你对循环、整除、边界条件的理解会上一个台阶,后面再去碰筛法、快速幂、大数分解,都会顺很多。
最后再分享一个小技巧:写这类数论题,养成“用纸笔先推一遍小例子”的习惯。我每次遇到边界情况,都会拿 12、84、97、100 这几个经典数在草稿纸上走一遍流程,代码写完再对着纸核对,基本不会翻车。
