☰
大整数运算:2的N次方高精度与Miller-Rabin/Pollard Rho分解
2026/10/3 10:24:22 网站建设 项目流程

前两天有个做密码学方向的朋友问我,说他用C++写了个循环想算2的10000次方,结果一运行,输出了一串根本没法念的数字,而且明显不对。这问题听着很简单,其实信息量很大:他只是想要一个大整数结果,但C++里最大的unsigned long long连2的64次方都装不下,更别提一万次方了。类似的问题还有一个版本,就是拿着一两百位的数问怎么分解因子,说“不就是从2开始一个个除嘛”。

这两个问题放到一起看特别有意思——它们都是中学数学里讲过的东西,但一旦数字变大,就变成算法和数据结构的问题了。这篇就专门聊聊“计算2的N次方”和“大整数的因子”,从最朴素的做法一路说到Miller-Rabin素性检测和Pollard Rho分解,中间穿插一些我实际写代码时踩过的坑。适合正在刷算法题的初学者、准备蓝桥杯或PAT的选手,以及工作中需要处理大数运算的开发人员。

如果你手头遇到的是Python环境,那大部分问题直接pow(2, N)和sympy.factorint就能解决,但如果你是C/C++/Java党,或者想知道这些函数背后在干什么,那这篇应该能给你一个比较完整的答案。

1. 先看清问题:2^N和大数分解,难点到底在哪

1.1 为什么“2的N次方”会是一个问题

对任何一个会写循环的人来说,求2的N次方的第一反应是:

long long ans = 1; for (int i = 0; i < N; i++) ans *= 2;

这代码一个问题:溢出。long long最大能表示约9.22×10^18,也就是2^63 - 1。你N稍微超过63,结果就变成未知数了。有人会说用unsigned long long,那也就撑到2^64 - 1。N等于100呢?教科书告诉我们2^100大约是1.27×10^30,远远超出64位整数范围。

理论上N可以很大,但结果是“高精度数”而不是“整数变量能装下的数”,这才是核心难点。

还有一个容易被忽略的点:即使语言支持任意精度,比如Python的int是无限精度的,但如果你在一个循环里做ans *= 2一万次,Python也还好,换到C语言手动实现时就需要数组了。所以你真正要设计的是一个“能表示任意长度数字,并支持不断乘2”的结构。

1.2 大整数的“因子”到底指什么

“大整数的因子”这句话有歧义。日常工作里经常听到“因子”这个词,但它可能指三件完全不同的事:

  • 求出大整数的所有因数,比如12的因数是1, 2, 3, 4, 6, 12;
  • 求大整数的质因数分解,比如360 = 2^3 × 3^2 × 5;
  • 求一个数的连续因子序列,比如630的最长连续因子是5×6×7,这是PAT L1-006那道题考的东西。

后面我们提到“因子”时,大多数情况指质因数分解,因为一旦拿到质因数,所有因子都能组合出来。但完整因数列表和连续因子这两个分支也很实用,所以会分开讲。

1.3 先分清“真因子”和“素因子”的边界

很多新手会混淆“因子”和“质因子”。比如有人问“2^1000的因子有多少个”,这其实是数论题,答案是1001个(因为2^1000的因子形如2^k,k从0到1000),但它和“把一个大整数分解成若干质数相乘”完全是两个方向。

在工程和算法竞赛里,最常遇到的是:给你一个N(N可能高达10^18甚至更大),让你判断它是不是质数,或者把它分解成质因数。这种需求在RSA加密相关场景里非常常见,因为RSA的安全性就建立在“大整数分解很困难”之上。

所以在动手写之前,必须明确你要的是“因子个数”“因子列表”还是“质因数分解”。这三者需要的算法复杂度天差地别。

2. 计算2的N次方:从竖式到压位优化的完整实现

2.1 乘数只有2,高精度可以简化成“翻倍加进位”

高精度乘法的通用思路是用数组模拟竖式计算,每一位存一个0到9的数字。例如数字123在数组里存成[3, 2, 1](低位在前),要乘以2时,从低位向高位逐位做:

  • 当前位数字乘以2,加上来自低位的进位;
  • 新数字 = 结果 % 10;
  • 下一位进位 = 结果 / 10。

因为乘数只有2,所以每一位的结果最大是9×2+1=19,进位最多是1。整个过程的循环次数等于数组长度。

C++代码大概长这样,initList表示数组:

#include <iostream> #include <vector> using namespace std; vector<int> mul2(const vector<int>& a) { vector<int> b(a.size() + 1, 0); // 多留一位,应对最高位进位 int carry = 0; for (int i = 0; i < a.size(); i++) { int tmp = a[i] * 2 + carry; b[i] = tmp % 10; carry = tmp / 10; } if (carry) b[a.size()] = carry; else b.pop_back(); return b; } int main() { int N = 1000; vector<int> num(1, 1); for (int i = 0; i < N; i++) num = mul2(num); // 逆序输出 for (int i = num.size() - 1; i >= 0; i--) cout << num[i]; cout << endl; return 0; }

这个写法简单直观,但性能一般。设N次乘法,每次数组长度约等于0.301×N,所以时间复杂度大约是O(N^2)。N在10000以内没问题,N到100000时,最内层循环次数大概在3亿级别,C++勉强能跑,Java就有点悬了。

2.2 压位写法:用1e9进制减少取模开销

既然每隔一次循环就要做一次除法取模,干脆把数组每个位置存一个较大的“块”,比如base = 1000000000(10^9)。这样每位数字不再是0-9,而是0-999999999,乘法时用long long处理进位就不会溢出。

改进后的代码:

#include <iostream> #include <vector> using namespace std; typedef long long ll; const ll BASE = 1000000000LL; // 10^9 vector<ll> mul2(const vector<ll>& a) { vector<ll> b(a.size() + 1, 0); ll carry = 0; for (int i = 0; i < a.size(); i++) { ll tmp = a[i] * 2 + carry; b[i] = tmp % BASE; carry = tmp / BASE; } if (carry) b[a.size()] = carry; else b.pop_back(); return b; } void printBig(const vector<ll>& a) { cout << a.back(); // 最高块不带前导零 for (int i = (int)a.size() - 2; i >= 0; i--) { // 每个块必须输出9位,不足补前导零 cout.width(9); cout.fill('0'); cout << a[i]; } cout << endl; }

用10^9作基底,块数大概是十进制位数的九分之一,而每次乘法仍是一遍遍历,所以常数优化非常可观。N=100000时,块数大约3344个,循环总量降到3亿除以9,C++下基本秒出结果。

这里有个容易错的地方:输出时中间的块必须补前导零。比如有一个块是42,它在完整数字里应该显示成000000042,否则就是错位数字。我用cout.width(9)配合cout.fill('0')处理,你也可以用printf("%09lld", a[i])。

2.3 位数估算与边界处理

实际写之前最好先算一下结果有多少位。公式很简单:

digits = floor(N * log10(2)) + 1

因为log10(2)≈0.30103,所以N=1000时结果有302位,N=100000时结果有30103位。知道了位数就可以预分配vector容量,避免反复扩容。

边界情况主要是N=0。2的0次方等于1,很多新手会漏掉。另外N为负数的情况在整数计算里没有意义,一般直接不处理或按题目要求报错。

3. 大整数的因子:试除、Miller-Rabin与Pollard Rho

3.1 小数据量直接试除,注意跳过合数

如果数字比较小,比如在10^12以内,直接试除就能用,关键优化是“只试到平方根”。

vector<long long> trial_division(long long n) { vector<long long> factors; for (long long d = 2; d * d <= n; d++) { while (n % d == 0) { factors.push_back(d); n /= d; } } if (n > 1) factors.push_back(n); return factors; }

这个代码的问题在于:如果n是一个接近10^12的质数,循环要到10^6次,还能接受;但如果n接近10^18,循环要到10^9次,那就超时了。进一步优化可以先筛出小质数表,只尝试除以质数,能省掉一半左右的合数判断,但仍然解决不了根本问题。

真正优雅的解法是组合拳:先用小质数表试除掉一万以内的因子,剩下的部分交给Miller-Rabin判断质数,再用Pollard Rho找出剩余因子。

3.2 Miller-Rabin素性检测:费马小定理不够,还得加二次探测

Miller-Rabin的基础是费马小定理:如果n是质数,那么对任意a(a不是n的倍数),都有a^(n-1) ≡ 1 (mod n)。反过来,如果某个a使得a^(n-1)不余1,那么n一定是合数。但问题来了——有一些合数(比如Carmichael数561)对几乎所有a都满足这个条件,单靠费马检验会误判。

Miller-Rabin的改进在于引入“二次探测”。把n-1写成d×2^s的形式,然后检查:

  • 如果a^d ≡ 1 (mod n),认为通过;
  • 否则连续平方,看是否存在某个r使得a^(d×2^r) ≡ -1 (mod n);
  • 如果都不满足,确定是合数。

这个测试对多个随机基底重复,错误概率会指数级下降。对于64位以内的整数,只要测试基底取2、3、5、7、11、13、17、19、23、29、31、37,结果就是确定的。

C++实现里有个大坑:求模幂时a * b % n,如果a和b都是long long,乘积会溢出。所以要么用__int128做中间量,要么用快速乘(二进制拆解)。我在比赛里习惯了用__int128,GCC和Clang都支持,但要注意不是所有OJ都开-std=gnu++17,有的环境要用__int128_t。

3.3 Pollard Rho随机分解:生日悖论带来的高效

Pollard Rho的核心是生日悖论。随机从[0, n-1]里取数,约sqrt(n)次后出现重复的概率就超过一半。如果构造一个序列,让相邻两个数的差和n求最大公约数,一旦gcd大于1,就找到了一个非平凡因子。

常用序列是:

x_{i+1} = (x_i^2 + c) mod n

配合Floyd判圈算法,快指针每次走两步,慢指针每次走一步,两者差的绝对值与n求gcd。当gcd等于n时,说明这次随机序列选得不好,换一个常数c重新试。

这个算法期望时间复杂度是O(n^(1/4)),对于64位以内的合数,几乎瞬间就能分解。说个直观的对比:一个10^18量级的数,试除法要10亿次,Pollard Rho大约几万次操作就能出来。

实现时的完整组合是:先试除小因子,再用Miller-Rabin判断剩余数字是否质数,如果是质数直接加入因子列表,否则用Pollard Rho拆,然后递归分解两个子问题。

3.4 模板代码与实际效果

下面给一份我常用的C++模板,包含Miller-Rabin和Pollard Rho,适合1到2^64 - 1范围内的整数。

#include <bits/stdc++.h> using namespace std; typedef long long ll; ll mul_mod(ll a, ll b, ll mod) { return (ll)((__int128)a * b % mod); } ll pow_mod(ll a, ll b, ll mod) { ll res = 1; while (b) { if (b & 1) res = mul_mod(res, a, mod); a = mul_mod(a, a, mod); b >>= 1; } return res; } bool isPrime(ll n) { if (n < 2) return false; for (ll p : {2LL, 3LL, 5LL, 7LL, 11LL, 13LL, 17LL, 19LL, 23LL, 29LL, 31LL, 37LL}) { if (n == p) return true; if (n % p == 0) return false; } ll d = n - 1, s = 0; while (d % 2 == 0) d /= 2, s++; for (ll a : {2LL, 3LL, 5LL, 7LL, 11LL, 13LL, 17LL, 19LL, 23LL, 29LL, 31LL, 37LL}) { ll x = pow_mod(a, d, n); if (x == 1 || x == n - 1) continue; bool ok = false; for (int r = 1; r < s; r++) { x = mul_mod(x, x, n); if (x == n - 1) { ok = true; break; } } if (!ok) return false; } return true; } ll pollardRho(ll n) { if (n % 2 == 0) return 2; if (n % 3 == 0) return 3; ll c = 1; while (true) { ll x = 2, y = 2, d = 1; while (d == 1) { x = (mul_mod(x, x, n) + c) % n; y = (mul_mod(y, y, n) + c) % n; y = (mul_mod(y, y, n) + c) % n; d = __gcd(llabs(x - y), n); } if (d != n) return d; c++; } } void factor(ll n, vector<ll>& fac) { if (n == 1) return; if (isPrime(n)) { fac.push_back(n); return; } ll d = pollardRho(n); factor(d, fac); factor(n / d, fac); }

实测下来,对一个60位的十进制数做完整分解,这个模板能在一秒内完成。如果目标数字是100位以上,就得借助yafu、msieve这种专门工具了,它们会综合运用二次筛和数域筛,不是几行代码能搞定的。

4. 实战:PAT L1-006 连续因子是怎么解出来的

4.1 题目理解与思路

这道题和“找所有因子”不一样,它要求的是序列连续,并且整个序列的乘积能整除N。题目给了一个很有代表性的例子:N=630时,最长连续因子是5×6×7,因为5×6×7=210,而210是630的因子;另一组3×5×6×7虽然乘积等于630,但序列不连续。

一个关键结论是:连续因子序列的起点一定不超过sqrt(N)。因为如果起点x大于sqrt(N),那么x×(x+1)就已经大于N了,不可能整除N。

另一个结论是:序列长度非常有限。N最大到2^31,约2.1×10^9,就算从2开始连续乘,2×3×4×5...的长度也不会超过31,因为13!就已经超过2^31了。因此暴力枚举起点和长度完全可行,时间复杂度大约O(sqrt(N) × 31)。

4.2 枚举实现与代码

核心思路是:枚举起点start,从2到sqrt(N),然后从start开始不断累积乘积,每次检查乘积是否能整除N,记录最长的一段。

#include <bits/stdc++.h> using namespace std; int main() { int n; cin >> n; if (n == 1) { cout << 1 << endl << 1 << endl; return 0; } int limit = sqrt(n) + 1; int bestStart = 0, bestLen = 0; for (int start = 2; start <= limit; start++) { long long prod = 1; for (int len = 0; start + len <= limit + 1; len++) { prod *= (start + len); if (prod > n) break; // 乘积超过n,长度不可能更长 if (n % prod == 0) { if (len + 1 > bestLen) { bestLen = len + 1; bestStart = start; } } } } if (bestLen == 0) { cout << 1 << endl << n << endl; } else { cout << bestLen << endl; for (int i = 0; i < bestLen; i++) { if (i) cout << '*'; cout << bestStart + i; } cout << endl; } return 0; }

4.3 这题常见的坑

第一个坑是N本身是质数,比如N=17。此时没有任何长度大于1的连续因子,标准输出是:

1 17

因为17自身是它的因子,序列长度只能是1。

第二个坑是乘积溢出。start从2开始,就算长度只有十几,用int存乘积也可能溢出,所以在代码里用了long long。

第三个坑是limit的设定。有人直接把枚举上限定为sqrt(n),但如果起点等于sqrt(n)附近时,乘积可能刚好略大于sqrt(n)却能整除N,所以要加一个偏移量+1,避免漏掉边界情况。

第四个坑是N=1。理论上1的“因子”只有1,标准输出是1\n1,但有些版本题目并不会给到1,建议还是兜底处理,保证程序不崩。

5. 常见问题排查与效率对比

5.1 高精度计算的常见问题

高精度乘法最容易犯的错是忘记清除最高位进位。比如乘以2后结果多了一位,数组长度要相应增加,不然结果会丢一位。

第二个常见问题是输出顺序。我在vector里习惯低位在前,也就是个位在a[0],输出时要从末尾倒着循环。新手很容易直接正序输出,把结果整个反过来。

第三个问题是压位时的前导零。用10^9进制时,中间块不足9位要补零。有人直接按十进制一位的方法输出,结果数字位数对不上,看起来就像乱码。

我自己的经验是:写完输出函数后,先拿N=10测一遍,期望结果是1024;再拿N=100和Python的pow(2,100)对一下,心里就有底了。

5.2 Miller-Rabin和Pollard Rho的坑

Miller-Rabin最阴间的坑是选取的基底不能完全覆盖所有合数。如果只用前几个质数做基底,虽然错误概率极低,但对某些精心构造的数字仍然可能误判。64位以内用前面列出的37个质数中的12个作为基底基本是公认安全的,但如果数字更大,建议用随机基底,多测几轮。

Pollard Rho最常见的坑是随机序列陷入死循环。我刚开始写时没有判圈,程序卡住不退出。后来用Floyd判圈解决才稳定。如果换c之后仍然找不到因子,就再换一个c;理论上多试几次总能成功,实测一般第一次或第二次就能出结果。

还有乘法溢出问题。mul_mod函数里用了__int128,如果在不支持它的环境下,就得自己写快速乘,逻辑类似快速幂,但用加法累加替代乘法。这一点很容易被忽略,因为小数字测不出溢出,一上大数直接错。

5.3 不同语言和库的选型对比

方案适用场景优点缺点
C++ 手写高精度算法竞赛、学习原理可控性强、速度快代码量大、易出错
Java BigInteger工程开发API丰富、可读性好速度比C++慢,大数分解仍需手写
Python int快速验证无限精度、代码极短性能上限低
Python sympy.factorint不用重复造轮子内置Pollard Rho改进版依赖第三方库,不适合竞赛
GMP / boost::multiprecisionC++工程性能接近极限库较大,OJ不一定支持

如果只是临时验证数据,我第一反应是Python,三行代码出结果。如果是长期的竞赛训练,还是建议手写一遍C++高精度和分解模板,因为这些代码在底层思维上能打通很多概念。

另外顺带提醒一句:网上和题目里偶尔会看到“因子图优化”“因子分析法”“影响因子”这些词。它们和本文的“大整数因子”完全不是一回事——因子图是概率图模型里的一种表示,影响因子是期刊评价指标,因子分析法是一种统计学降维工具。搜资料的时候别混了,不然会绕很远。

6. 提速技巧与扩展思路

6.1 预处理小质数表能带来多大收益

在调用Miller-Rabin和Pollard Rho之前,先试除一万以内的质数,这个习惯非常管用。对大多数合数来说,它们往往有一个比较小的质因子,试除阶段就能直接剥掉,根本不需要走复杂算法。试除完后剩下的数如果不是1,再用Miller-Rabin判断,能省下大量无用功。

我实际测试过:对一组随机生成的40位十进制数,只调Pollard Rho平均用时大约几十毫秒,先试除小质数表后,平均用时能降到几毫秒级别。差距主要是因为Pollard Rho对“因子较大的合数”更擅长,而小因子它反而要绕很多圈。

6.2 模幂场景:不需要完整结果时用快速幂

很多实际需求不是“打印2的N次方”,而是“计算2的N次方模M”。这种场景根本不需要高精度数组,用快速幂就行:

ll pow_mod(ll a, ll b, ll mod) { ll res = 1; while (b) { if (b & 1) res = res * a % mod; a = a * a % mod; b >>= 1; } return res; }

时间复杂度从O(N^2)降到了O(log N),而且数字永远不会溢出(在mul_mod的保护下)。如果N超过10^9,还要注意a*a可能溢出long long,同样需要__int128。

6.3 连续因子思路还能用来做什么

连续因子的思路本质上是“在枚举区间内找一个子区间,使得乘积满足整除关系”。这种模式可以迁移到很多题目里,比如找最长连续子数组使得乘积是某个质数的幂,或者找连续正整数序列的和等于目标值。

关键启发是:当题目涉及“连续”“整除”“乘积”这三个条件时,先估算区间上界和长度上界,不要一上来就暴力枚举所有可能区间。数学上先收紧范围,往往比优化代码本身更有效。

我在实际使用中发现,写高精度和大数分解这类代码,最重要的是准备一套“对拍工具”。自己写完代码后,拿Python的内置能力跑同样输入对拍,两边结果一致再收工。这样不仅验证了正确性,还能顺手发现压位输出的前导零、进位移位等细碎问题。

最后再分享一个不常有人提的经验:Pollard Rho里那个常数c,我见过有人固定用1,有人固定用随机数。其实更稳的做法是每次失败后让c自增,从1开始试,最多试到几十次基本必出结果。而判断质数时,基底顺序也有讲究,固定先测2、3、5这几个小质数,能提前筛掉大量偶数和三倍数,省去后面几轮模幂计算。这些细节积累多了,代码的稳定性和速度都会明显上一个台阶。

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

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

立即咨询