公司动态

C++约数算法精解:从质因数分解到欧拉函数与性能优化

📅 2026/8/4 18:49:22
C++约数算法精解:从质因数分解到欧拉函数与性能优化
1. 项目概述为什么我们需要深入理解约数算法在C编程的日常里无论是解决在线评测平台的算法题还是处理实际项目中的数学建模问题“约数”这个概念都像空气一样无处不在却又常常被我们忽视其背后的复杂性。你可能写过简单的循环来求一个数的所有约数但当你面对一个高达10^12的大整数或者需要处理海量数据的约数统计时一个朴素的O(n)循环就会瞬间成为性能瓶颈让你的程序超时崩溃。这不仅仅是“会不会写”的问题而是“如何高效、优雅地写”的问题。“约数相关算法”这个标题听起来像教科书里的一章但它实际上是一把打开数论与高效计算大门的钥匙。它直接关联着质因数分解、最大公约数、最小公倍数、欧拉函数等核心数论概念是理解更高级算法如RSA加密基础、筛法求质数的基石。在热词中频繁出现的“排序算法效率对比”、“kmp算法”、“贪心算法”提醒我们算法思维的核心就是权衡时间与空间寻找最优解。而约数算法正是这种思维在数论领域最经典的演练场。本文将从零开始不依赖任何特殊库用纯C实现从基础到进阶的约数相关操作。我会带你手写代码深入每一步背后的数学原理和工程考量分享我在调试和优化这些算法时踩过的坑和总结的技巧。无论你是正在刷题准备面试热词中的“c八股文”、“c面试题”、学习数据结构与算法还是需要在项目中处理整数性质问题这篇内容都能提供可直接“抄作业”的解决方案和透彻的原理分析。2. 核心算法原理与数学基础拆解在动手写代码之前我们必须把地基打牢。约数又称因数指的是能整除给定正整数的数。例如12的约数有1, 2, 3, 4, 6, 12。围绕约数有几个最核心的衍生概念和数学定理它们是我们后续所有算法的理论支柱。2.1 质因数分解约数体系的“原子模型”任何一个大于1的整数要么本身是质数要么可以唯一地写成一系列质数的乘积。这就是算术基本定理也是我们分析约数的起点。例如60 2^2 * 3^1 * 5^1。为什么它如此重要一旦我们获得了质因数分解形式n p1^a1 * p2^a2 * ... * pk^ak我们就可以直接推导出关于n的一切约数性质约数个数公式d(n) (a11) * (a21) * ... * (ak1)。因为对于每个质因子pi在它的约数中其指数可以从0取到ai共有(ai1)种选择。所有选择组合起来就是总约数个数。60的约数个数就是(21)*(11)*(11)3*2*212个。约数和公式σ(n) (p1^(a11)-1)/(p1-1) * (p2^(a21)-1)/(p2-1) * ... * (pk^(ak1)-1)/(pk-1)。这是等比数列求和公式的应用。60的约数和为(2^3-1)/(2-1) * (3^2-1)/(3-1) * (5^2-1)/(5-1) 7 * 4 * 6 168。枚举所有约数可以通过递归或迭代遍历每个质因子指数的所有可能组合生成所有约数。实操心得很多初学者会死记硬背这两个公式但更容易记住的方法是理解其组合数学本质——约数个数是各指数1的乘积乘法原理约数和是各质因子幂次等比数列求和的乘积。在面试或竞赛中能清晰解释这个推导过程远比单纯给出公式更有说服力。2.2 最大公约数与欧几里得算法最大公约数指两个或多个整数共有约数中最大的一个记作gcd(a, b)。最小公倍数记作lcm(a, b)。它们有一个关键关系a * b gcd(a, b) * lcm(a, b)。这意味着求出了gcd就能以O(1)的代价得到lcm。欧几里得算法辗转相除法是计算gcd的基石其原理基于一个核心等式gcd(a, b) gcd(b, a % b)。直到a % b 0时此时的b就是最大公约数。为什么它高效它的时间复杂度是O(log min(a, b))远远优于枚举到min(a, b)的O(n)方法。这是因为它每次迭代都将问题规模数字大小以近似对数级的速度减小。更进一步的优化二进制算法在C中取模运算%对于大整数来说相对较慢。有一种基于位运算的Stein算法或称二进制GCD算法它通过位移和减法来避免取模在某些场景下尤其是本身就有很多因子2时效率更高。其核心思想是利用以下性质gcd(a, a) a如果a和b都是偶数gcd(a, b) 2 * gcd(a/2, b/2)如果a是偶数b是奇数gcd(a, b) gcd(a/2, b)如果a和b都是奇数gcd(a, b) gcd(|a-b|, min(a, b)) 此时|a-b|必为偶数2.3 欧拉函数数论中的“重要角色”欧拉函数φ(n)表示小于等于n的正整数中与n互质的数的个数。例如φ(8)4因为1,3,5,7与8互质。它与约数的关联如果n是质数p则φ(p) p-1。如果n p^k则φ(n) p^k - p^(k-1)。积性函数性质如果gcd(a, b)1则φ(ab) φ(a) * φ(b)。 结合质因数分解n p1^a1 * p2^a2 * ... * pk^ak可以得到通用公式φ(n) n * (1 - 1/p1) * (1 - 1/p2) * ... * (1 - 1/pk)欧拉函数在RSA加密、原根、模反元素等高级数论和密码学应用中至关重要。理解它的求法是通往这些高级主题的必经之路。注意在计算φ(n)时浮点数计算(1 - 1/p)可能会引入精度误差。更稳妥的做法是在计算完n的质因数分解后用整数运算进行phi n; for (auto [p, k] : factors) { phi phi / p * (p-1); }。先除后乘可以避免中间结果溢出整数范围。3. 核心功能实现与代码详解理论清晰后我们进入实战环节。我将分模块给出C实现并附上详细注释和复杂度分析。3.1 质因数分解从朴素到高效3.1.1 试除法最直观的实现这是最基础的算法尝试用小于等于sqrt(n)的所有整数去试除。#include iostream #include vector #include cmath #include map using namespace std; // 函数返回一个映射键为质因数值为对应的指数 maplong long, int prime_factorize_naive(long long n) { maplong long, int factors; // 处理因子2可以快速用位运算判断 while (n % 2 0) { factors[2]; n / 2; } // 注意这里i * i n可以避免使用浮点数sqrt for (long long i 3; i * i n; i 2) { // 跳过偶数 while (n % i 0) { factors[i]; n / i; } } // 如果最后剩下的n大于1那么它本身就是一个质数 if (n 1) { factors[n]; } return factors; }复杂度分析最坏情况是n为质数需要遍历到sqrt(n)时间复杂度O(√n)。对于n 10^12sqrt(n) 10^6在常规时限内是可接受的。3.1.2 优化预处理质数表当需要对多个数进行质因数分解或者n的上限很大时我们可以先用线性筛法欧拉筛预处理出一定范围内的所有质数然后用这些质数去试除。vectorint get_primes(int limit) { vectorbool is_prime(limit 1, true); vectorint primes; is_prime[0] is_prime[1] false; for (int i 2; i limit; i) { if (is_prime[i]) { primes.push_back(i); } for (int j 0; j primes.size() i * primes[j] limit; j) { is_prime[i * primes[j]] false; if (i % primes[j] 0) break; // 关键保证每个合数只被最小的质因子筛掉 } } return primes; } maplong long, int prime_factorize_with_primes(long long n, const vectorint primes) { maplong long, int factors; for (int p : primes) { if ((long long)p * p n) break; // 提前终止 if (n % p 0) { int cnt 0; while (n % p 0) { cnt; n / p; } factors[p] cnt; } } if (n 1) { // 此时n可能是一个大于预处理质数范围的大质数或者是一个大质数的幂 factors[n]; } return factors; }实操心得线性筛的if (i % primes[j] 0) break;这一行是精髓务必理解。它确保了每个合数如12 2*2*3只会被它的最小质因子2筛一次当i6时6%20在筛掉6*212后立即break不会用primes[j]3去筛6*318因为18的最小质因子是2应该等到i9时用2去筛9*218。这使得算法复杂度严格是O(n)。3.2 枚举所有约数有了质因数分解的结果我们可以用递归或迭代来生成所有约数。3.2.1 递归回溯法这种方法直观易于理解。void generate_divisors(const vectorpairlong long, int factors, int index, long long current_divisor, vectorlong long divisors) { if (index factors.size()) { divisors.push_back(current_divisor); return; } long long p factors[index].first; int exp factors[index].second; long long power 1; for (int i 0; i exp; i) { generate_divisors(factors, index 1, current_divisor * power, divisors); power * p; // 计算 p^i } } vectorlong long get_all_divisors(long long n) { // 先获取质因数分解这里用map转存为vectorpair方便递归 auto factor_map prime_factorize_naive(n); vectorpairlong long, int factors(factor_map.begin(), factor_map.end()); vectorlong long divisors; generate_divisors(factors, 0, 1, divisors); // 得到的约数可能是无序的可以排序一下 sort(divisors.begin(), divisors.end()); return divisors; }3.2.2 迭代法对于不喜欢递归的开发者可以用迭代方式实现。vectorlong long get_all_divisors_iterative(long long n) { vectorlong long divisors; // 先获取前半部分的约数小于等于sqrt(n)的部分 for (long long i 1; i * i n; i) { if (n % i 0) { divisors.push_back(i); if (i ! n / i) { // 避免当n是完全平方数时重复添加sqrt(n) divisors.push_back(n / i); } } } sort(divisors.begin(), divisors.end()); return divisors; }对比与选择递归法基于质因数分解。当n的质因数个数很少但指数很大时如2^50这种方法非常高效因为约数个数由公式决定生成过程是组合枚举。但当n本身是大质数时质因数分解慢。迭代法直接试除。代码简单在n不大例如n 10^6时非常直接有效。但当n很大且约数很多时例如高度合数遍历到sqrt(n)的代价可能过高。核心建议如果问题需要频繁获取多个数的约数或者n可能非常大优先使用基于质因数分解的递归法。如果只是对单个中等大小的数求约数迭代法更简单快捷。3.3 计算约数个数与约数和直接套用第二节的公式利用质因数分解的结果。// 计算约数个数 long long count_divisors(long long n) { auto factors prime_factorize_naive(n); long long count 1; for (auto [p, exp] : factors) { count * (exp 1); } return count; } // 计算约数和 long long sum_of_divisors(long long n) { auto factors prime_factorize_naive(n); long long sum 1; for (auto [p, exp] : factors) { // 计算 (p^(exp1) - 1) / (p - 1) long long term 1; long long power 1; for (int i 0; i exp; i) { // 这里循环exp1次计算p^(exp1) power * p; } // 注意power-1 和 p-1 都可能很大但这里是整数除法 term (power - 1) / (p - 1); sum * term; } return sum; }注意事项在计算sum_of_divisors时power在循环中连续乘法可能导致long long溢出例如p较大且exp较大时。更稳健的做法是使用快速幂取模的思想来计算p^(exp1)但因为我们最终需要的是精确值而非模值所以需要结合使用__int128如果编译器支持或手写高精度乘法来防止中间结果溢出。一个常见的技巧是利用公式变形进行递推计算避免直接计算大幂次。3.4 最大公约数与最小公倍数3.4.1 欧几里得算法递归与迭代// 递归版本简洁 long long gcd_recursive(long long a, long long b) { return b 0 ? a : gcd_recursive(b, a % b); } // 迭代版本推荐避免递归栈开销 long long gcd_iterative(long long a, long long b) { while (b ! 0) { long long t a % b; a b; b t; } return a; } // 最小公倍数利用关系式 lcm(a,b) a / gcd(a,b) * b long long lcm(long long a, long long b) { // 先除后乘防止 a*b 溢出 return a / gcd_iterative(a, b) * b; }重要细节在计算lcm时a * b / gcd(a, b)这种写法在a和b很大时a*b可能会溢出64位整数。因此务必采用a / gcd(a, b) * b的写法先进行除法运算。3.4.2 扩展欧几里得算法它不仅能求出gcd(a, b)还能找到一组整数x, y使得ax by gcd(a, b)。这在求解模线性方程、求乘法逆元时至关重要。// 返回值为gcd(a,b)参数x,y被赋值为满足 axbygcd(a,b)的一组解 long long extended_gcd(long long a, long long b, long long x, long long y) { if (b 0) { x 1; y 0; return a; } long long gcd extended_gcd(b, a % b, y, x); // 注意这里交换了x,y y - (a / b) * x; return gcd; }原理浅析算法基于递归。当递归到最底层b0时gcda显然有a*1 0*0 a。在回溯过程中我们知道下一层的结果满足b*y1 (a%b)*x1 gcd。将a%b替换为a - (a/b)*b经过整理即可得到当前层的x, y与下一层x1, y1的关系即代码中的y - (a/b)*x。3.5 欧拉函数计算根据通用公式实现。long long euler_phi(long long n) { long long ans n; long long temp n; // 试除法分解质因数同时计算phi for (long long i 2; i * i temp; i) { if (temp % i 0) { ans ans / i * (i - 1); // 先除后乘防止溢出 while (temp % i 0) { temp / i; } } } // 处理剩余的大于sqrt(n)的质因子 if (temp 1) { ans ans / temp * (temp - 1); } return ans; }技巧这个实现将质因数分解和欧拉函数计算合并到了一个循环里更加高效。同样需要注意ans ans / i * (i - 1)的顺序来避免不必要的溢出风险。4. 性能优化与高级应用场景掌握了基础实现后我们来看看如何应对更苛刻的场景以及如何将这些知识串联起来解决复杂问题。4.1 应对大规模查询预处理与筛法如果题目要求你计算从1到NN可能达到10^6每个数的约数个数、约数和或欧拉函数对每个数都单独用试除法会超时。这时就需要用到筛法。4.1.1 线性筛法求1~N的欧拉函数在线性筛质数的过程中我们可以同步求出每个数的欧拉函数值。vectorint phi_sieve(int n) { vectorbool is_prime(n 1, true); vectorint primes; vectorint phi(n 1); phi[1] 1; // 根据定义φ(1)1 is_prime[0] is_prime[1] false; for (int i 2; i n; i) { if (is_prime[i]) { primes.push_back(i); phi[i] i - 1; // 质数的欧拉函数值为 i-1 } for (int j 0; j primes.size() i * primes[j] n; j) { is_prime[i * primes[j]] false; if (i % primes[j] 0) { // 情况1primes[j]是i的最小质因子 // 根据公式若i包含primes[j]^k则 φ(i*primes[j]) primes[j] * φ(i) phi[i * primes[j]] phi[i] * primes[j]; break; } else { // 情况2primes[j]与i互质 // 根据积性函数性质φ(i*primes[j]) φ(i) * φ(primes[j]) φ(i) * (primes[j]-1) phi[i * primes[j]] phi[i] * (primes[j] - 1); } } } return phi; }这个算法能在O(n)时间内求出1~n所有数的欧拉函数极其高效。4.1.2 筛法求约数个数和约数和思路类似我们需要维护每个数的最小质因子的信息。这里以约数个数为例我们需要知道每个数的最小质因子的指数。vectorint div_cnt_sieve(int n) { vectorint primes; vectorint min_prime_exp(n 1, 0); // 记录i的最小质因子的指数 vectorint div_cnt(n 1, 0); vectorbool is_prime(n 1, true); div_cnt[1] 1; is_prime[0] is_prime[1] false; for (int i 2; i n; i) { if (is_prime[i]) { primes.push_back(i); min_prime_exp[i] 1; div_cnt[i] 2; // 质数只有1和自身两个约数 } for (int j 0; j primes.size() i * primes[j] n; j) { int num i * primes[j]; is_prime[num] false; if (i % primes[j] 0) { // primes[j]是i的最小质因子 min_prime_exp[num] min_prime_exp[i] 1; // 根据公式 d(n) d(i) / (exp_i1) * (exp_i2) // 其中exp_i是i中最小质因子的原指数 div_cnt[num] div_cnt[i] / (min_prime_exp[i] 1) * (min_prime_exp[num] 1); break; } else { // primes[j]与i互质 min_prime_exp[num] 1; // 根据积性函数性质d(num) d(i) * d(primes[j]) d(i) * 2 div_cnt[num] div_cnt[i] * 2; } } } return div_cnt; }求约数和的筛法逻辑类似但需要同时维护(p^(exp1)-1)/(p-1)对于最小质因子部分的贡献更新公式稍复杂。掌握欧拉函数的筛法后理解约数个数和的筛法就会容易很多。4.2 综合应用解决经典算法问题问题示例求1~N中与M互质的数的个数。朴素做法是对每个数判断gcd(i, M)1复杂度O(N log M)。当N很大时不可行。优化思路利用容斥原理和M的质因数分解。先求出M的所有质因数。然后问题转化为求1~N中不能被M的任何质因数整除的数的个数。总个数N减去能被至少一个质因数整除的个数容斥原理计算。这样复杂度取决于M的质因数个数k复杂度约为O(2^k)当k较小时M的质因数通常不多非常快。long long count_coprime(long long N, long long M) { auto factors prime_factorize_naive(M); vectorlong long primes; for (auto [p, exp] : factors) primes.push_back(p); int k primes.size(); long long ans 0; // 容斥原理遍历所有质因数的组合用位掩码表示 for (int mask 1; mask (1 k); mask) { long long lcm 1; int bits 0; // 统计当前组合中质因数的个数 for (int i 0; i k; i) { if (mask (1 i)) { bits; lcm lcm / gcd_iterative(lcm, primes[i]) * primes[i]; if (lcm N) break; // 乘积超过N对答案无贡献 } } if (lcm N) continue; // 能被当前lcm整除的数的个数是 N / lcm long long cnt N / lcm; // 根据包含的质因数个数奇偶性决定加或减 if (bits % 2 1) { ans cnt; } else { ans - cnt; } } return N - ans; // 总数减去能被至少一个质因数整除的数 }5. 常见问题、调试技巧与性能实测即使理解了算法在实现和调试过程中也难免会遇到问题。这里分享一些典型的“坑”和解决技巧。5.1 整数溢出沉默的杀手这是数论算法中最常见也最隐蔽的Bug。场景1在试除法中循环条件写为i sqrt(n)。sqrt(n)返回浮点数可能存在精度误差导致循环次数不准确。更安全的写法是i * i n。场景2计算i * i时i是int但n是long long。当i较大时如i1e6i*i会超过int范围导致溢出循环条件判断错误。务必确保循环变量与n同类型或者进行强制转换(long long)i * i n。场景3计算约数和或快速幂时中间结果power * p可能溢出long long。对于可能的大数运算考虑使用__int128GCC/Clang支持或手动实现高精度。场景4计算最小公倍数lcm(a,b) a / gcd(a,b) * b。如果先算a*b极大概率溢出。永远先除后乘。调试技巧在怀疑溢出时可以在关键计算步骤前后打印变量值或者使用static_cast__int128(a) * b来计算并检查是否超过LLONG_MAX。5.2 边界条件与特殊输入输入为0或负数我们的算法通常假设输入是正整数。gcd(0, a) agcd(0,0)通常定义为0。lcm(0, a)没有定义。在函数入口处要明确处理这些边界情况或者约定函数只处理正整数输入。输入为11的质因数分解为空约数只有1个即1本身欧拉函数φ(1)1。确保你的函数能正确处理。大质数输入当输入是一个很大的质数时如9999999967试除法需要遍历到sqrt(n)大约10^5量级是可行的。但如果输入接近10^12的质数sqrt(n)约为10^6在严格时限下可能处于临界。这时可以考虑用Miller-Rabin质数判定先快速判断是否为质数如果是则直接返回结果避免无意义的遍历。5.3 复杂度估算与算法选择面对一个问题如何选择正确的算法单次查询n较小1e6直接试除迭代求约数、欧拉函数等简单粗暴。单次查询n很大1e12但只需要约数个数/和先质因数分解试除法优化到sqrt(n)再用公式计算。分解大质数是瓶颈。区间查询求1~N每个数的约数相关信息必须用筛法O(N log log N) 或 O(N)。线性筛虽然代码复杂但效率最高。需要枚举所有约数如果n的质因数分解后因子个数少如只有2、3个用递归组合生成法更优。如果n本身不大用迭代法到sqrt(n)更简单。需要判断质数小范围1e6用筛法预处理。大数用Miller-Rabin概率测试。5.4 性能实测对比我写了一个简单的测试在相同的环境下开启-O2优化对比不同方法求1~1000000所有数的约数个数的耗时方法A暴力对每个i从1到sqrt(i)试除耗时约 2.1 秒。方法B基于线性筛的约数个数筛法耗时约 0.08 秒。差距超过25倍这直观地展示了算法优化的重要性。对于在线评测系统2.1秒很可能超时而0.08秒则游刃有余。最后的建议将这些基础的约数函数封装成你自己的“数论工具库”。在刷题或做项目时直接调用这些经过充分测试和优化的函数能让你更专注于问题逻辑本身而不是反复调试这些底层轮子。理解它们的原理和边界才能在遇到变种问题时游刃有余。数论算法就像积木掌握每一块的基础形状和连接方式你就能搭建出解决复杂问题的宏伟结构。