素数与模运算:从数学建模到RSA加密的算法基石

发布时间:2026/9/9 3:43:15
素数与模运算:从数学建模到RSA加密的算法基石 1. 素数为什么这个基础概念能撑起半边天提到素数也叫质数很多人的第一反应是“小学课本里那个只能被1和自身整除的数”。我当初也以为这东西除了做数学题没有任何用直到后来刷数据结构、写密码学demo、参加数学建模才发现素数简直是算法的地基之一。可以说整个现代加密体系的底座里就压着素数RSA、ElGamal这些经典算法全都跑在素数之上。素数配合模运算又构成了校验码、哈希散列、伪随机数生成等数学建模算法的常用工具。说得再直白一点你要是能把素数和模运算搞明白就能看懂一大半数论相关的算法代码。这篇内容适合三类人一是准备数学建模竞赛、想快速补充基础算法的同学二是做后端或安全方向、被各类加密算法绕得头晕的开发者三就单纯想搞懂素数检测和取模到底怎么玩的编程爱好者。我会从原理解起结合可以直接跑的代码再到实际踩坑记录争取让你看完就能自己动手。1.1 素数的定义远没有想象中简单先说定义。素数是大于1且只能被1和自身整除的自然数。2是最小的素数也是唯一一个偶数素数。这里有个容易踩的概念坑1不是素数0更不是。很多初写判断函数的人会漏掉这个边界导致检测结果从“质数”变成“合数”或者直接数组越界。另外需要区分“素因数分解”和“互素”。互素也叫互质指的是两个数的最大公约数为1并不要求这两个数本身是素数。比如8和9一个2的幂一个平方数但它们互素。这个区别在做模逆元计算时特别关键我后文会展开。从直觉上看素数分布好像很“稀疏”100以内有25个1000以内有168个1000000以内有78498个。实际上素数的密度是缓慢下降的著名的素数定理告诉我们不超过x的素数个数大约等于x除以ln(x)。这个规律在工程上很有用如果你要找一个100位的大素数随机挑一个奇数然后用素性检测去验证平均大概每ln(10^100)≈230个数里就有一个素数。这也是很多加密算法生成密钥时“随机选一个、测一下、不行再选”的理论依据。1.2 判断一个数是不是素数不能靠暴力裸奔判断单个数是否为素数最朴素的实现就是从2试到它的平方根。为什么只需要试到平方根因为任何一个合数n都可以写成 a*b 的形式其中a和b至少有一个不大于√n。如果这两个因子都存在那么较小那个必然落在 [2, √n] 区间里。所以只需检查这个区间内是否存在能整除n的数就够了。import math def is_prime_trial(n): if n 2: return False if n in (2, 3): return True if n % 2 0: return False limit int(math.isqrt(n)) 1 for i in range(3, limit, 2): if n % i 0: return False return True这段代码用math.isqrt而不是int(n ** 0.5)是为了避免浮点数精度误差。比如 n 是 999999999999999999 这种接近浮点数精度上限的大整数用** 0.5可能会得到错误的平方根下取整结果进而导致漏判或误判。这种细节在工程里很常见看起来不起眼真出了问题排查半天。如果是批量判断比如求出100万以内的所有素数再用单点试除就很亏了。这时候应该上筛法。1.3 埃氏筛与线性筛批量求素数的两个主流方案埃拉托斯特尼筛简称埃氏筛的核心思想是“从2开始把每个素数的倍数都标记成合数”。每找到一个还没被标记的数它就是素数然后把它所有的倍数筛掉。复杂度大致是 O(n log log n)对于100万以内这种规模几乎是瞬间完成。def sieve_of_eratosthenes(n): is_prime [True] * (n 1) is_prime[0] is_prime[1] False for i in range(2, int(n ** 0.5) 1): if is_prime[i]: step i start i * i for j in range(start, n 1, step): is_prime[j] False return [i for i, p in enumerate(is_prime) if p]这里有个优化细节从 ii 开始筛而不是从 2i 开始筛。因为对于 i 来说2i 早在 i2 的时候就被筛过了3i 在 i3 的时候筛过直到 (i-1)i 都已经被更小的素因子筛过了。直接从 ii 开始能节省不少重复标记。还有一种更快的线性筛欧拉筛它保证每个合数只会被它的最小素因子筛掉一次所以复杂度是 O(n)。在1000万以上规模时线性筛的优势会明显一些。def linear_sieve(n): primes [] is_prime [True] * (n 1) for i in range(2, n 1): if is_prime[i]: primes.append(i) for p in primes: if i * p n: break is_prime[i * p] False if i % p 0: break return primes关键在if i % p 0: break这一行。它的意思是一旦当前素数 p 能整除 i就停下来否则后面的合数会被重复标记。比如 i4p2 时4%20此时如果不 break会继续用 p3 把12标记掉但12的最小素因子是2应该在 i6p2 时被筛掉这里筛就属于多余操作。所以这行 break 是线性筛的灵魂。2. 模运算计算机里每天都在用的“时钟算术”如果说素数是数学算法里的一颗珍珠那模运算就是连接这颗珍珠和工程应用的线。模运算说穿了就是“求余数”但在数论中它衍生出了一整套同余理论是整个公钥密码、哈希散列、校验算法都绕不开的底层工具。2.1 用钟表来理解模运算比公式快得多想象一个12小时的钟表。13点在表盘上显示的是1点因为 13 mod 12 1。23点显示11点因为 23 mod 12 11。这就是模运算的直观含义把一个数“绕”回0到模数减1的范围内。很多编程新手会困惑为什么哈希表存数据要取模因为内存数组是定长的你想把任意大的键映射到0到N-1这个区间里最自然的办法就是取模。这种“把大空间折叠到小空间”的思路在算法里出现的频率极高。模运算有一个和普通运算不太一样的地方它更适合“同余”的概念。如果 (a-b) 能被 m 整除就记作 a ≡ b (mod m)读作“a与b在模m下同余”。比如 17 ≡ 5 (mod 6)因为 17-512 能被6整除。这给了我们很多灵活操作的空间在模运算中你可以随时用一个同余的小数替换大数计算结果不变。2.2 模逆元、费马小定理这两个概念必须一起记什么是模逆元求满足 a*x ≡ 1 (mod m) 的 x这个x就叫 a 在模 m 下的逆元记作 a⁻¹ mod m。它类比的是普通除法里的“倒数”在实数里a乘以1/a等于1在模运算里a乘以它的逆元等于模意义下的1。但注意模逆元不是随便什么 a 和 m 都有解的。只有当 gcd(a, m) 1互素时a 才存在模 m 的逆元。比如 mod 6 下面2和6的最大公约数是2你找不到任何整数 x 让 2*x ≡ 1 (mod 6)因为左边永远是偶数右边是奇数。这个条件看起来简单实际上很多方案设计失败都是因为没检查互素。费马小定理是求模逆元的一条捷径如果 p 是素数且 a 不被 p 整除那么 a^(p-1) ≡ 1 (mod p)。左右两边同除以 a得到 a^(p-2) ≡ a^(-1) (mod p)。也就是说当模数是素数时可以直接用快速幂计算 a^(p-2) 来求逆元不需要写扩展欧几里得。当我需要频繁求逆元、而且模数恰好是质数时通常优先用费马小定理方案代码短不容易写错。2.3 快速幂模幂运算的核心算法模幂运算就是计算 base^exp mod mod。很多方案第一反应是先把 base^exp 完整算出来再取模这在指数稍微大一点时直接爆炸。比如 2^1000 是一个302位数内存还能撑一下但如果是 2^100000连十进制位数都要上万完全没有计算的必要。正确姿势是快速幂。它基于一个很朴素的原理指数可以拆成二进制每一步平方底数遇到二进制位为1时累乘结果。同时每一步都取模保证中间数字始终小于 mod 的平方级大小。def pow_mod(base, exp, mod): result 1 base % mod while exp 0: if exp 1: result (result * base) % mod base (base * base) % mod exp 1 return result举一组参数计算 3^101 mod 7。101的二进制是1100101算法会依次处理每一位。最终结果是5。你可以手动验证3^29≡23^4≡43^8≡2依次叠上去最后得到5。整个过程最多执行的乘法次数是二进制位数加上二进制中1的个数也就是 O(log exp)从“完全算不出”变成“瞬间算完”。Python 里还有个更省事的函数pow(base, exp, mod)C语言层面实现了快速模幂速度比自己写还要快。但如果为了学习算法逻辑或者需要迁移到其他语言手写快速幂还是值得掌握。3. 数学建模算法中素数与模运算的经典应用场景搜索结果里反复出现“数学建模算法”这个热词说明很多人正在备赛或者做课题研究。素数加模运算在数学建模里绝对不只是理论题而是一大堆实用算法的小零件。这里挑三个我实际用过的场景来拆。3.1 校验码与哈希散列同余思维的日常化一个最简单的应用是ISBN-13校验码。标准的ISBN-13共13位前12位是信息位第13位是校验位。校验位的计算方式是前12位从第1位开始奇数位权重1偶数位权重3加权求和后取模10再用10减去这个结果再取模10。def isbn13_check_digit(first12): s 0 for i, ch in enumerate(first12): digit int(ch) if i % 2 0: s digit else: s digit * 3 return (10 - s % 10) % 10为什么用 10 - s % 10 还要再做一次 % 10因为当 s % 10 恰好是0时10 - 0 10但校验位只能是0。再加一个 %10 就把10纠正回0。这个细节很典型如果不处理第一次实现就会在部分isbn上算出10这个非法校验码。哈希散列也是同理。Java里String的hashCode就是s[0]*31^(n-1) ...的形式本质上就是模 2^32 的线性运算。而为什么选31作为权重因为31是素数且31 * i 在JVM中可以用(i 5) - i来代替既利用素数性质减少哈希碰撞又兼顾了位运算速度。你看素数和模运算就这么悄悄藏在Java基础库里。3.2 伪随机数发生器线性同余法中的“素数选择”数学建模赛题中随机模拟是家常便饭。比如蒙特卡洛模拟求π或者排队论问题中的随机到达时间。很多人直接用语言自带的random但如果你需要复现实验结果就必须自己实现一个可控的伪随机序列。经典实现是线性同余生成器LCG公式为X_{n1} (a * X_n c) mod m参数的选择对序列质量影响极大。常见的一组参数是 a1103515245, c12345, m21474836482^31。其中a和c的选择背后有数论条件要求c和m要互素a-1要能被m的所有素因子整除如果m是4的倍数a-1也得是4的倍数。这些条件本质上就是在用素因子分析来实现“周期最大化”。我自己做赛题的时候遇到过随机序列周期太短导致模拟结果周期性抖动的情况。后来检查发现是直接抄了网上一个没写参数来源的LCGm选小了周期只有几千。换了满足数论条件的参数后模拟结果才稳定。如果你要在建模里用LCG务必先用小样本统计检验一下分布别直接跑大数量模拟。3.3 RSA公开密钥加密从数论到应用的最短路径RSA是我认为最能体现“素数模运算”组合价值的算法。它的数学基础只有三个点大整数分解困难、欧拉函数、模逆元。流程是第一步找到两个大素数 p 和 q计算 n pq。n的欧拉函数 φ(n) (p-1)(q-1)。第二步选一个与 φ(n) 互素的整数 e通常直接用 65537它本身是素数而且二进制是10000000000000001方便做快速幂。然后计算 e 在模 φ(n) 下的逆元 d。第三步公钥是 (n, e)私钥是 (n, d)。加密时计算 c m^e mod n解密时 m c^d mod n。核心正确性证明依赖费马小定理的扩展也就是欧拉定理。我用一个很小的demo演示过整个过程。p61, q53n3233φ(n)3120e17用扩展欧几里得求出 d2753。加密数字 65对应大写A65^17 mod 3233 2790解密 2790^2753 mod 3233 65。当年第一次看到数字被加密又还原回来确实很震撼。实际工程中n通常是2048位p和q是1024位的素数。这个规模下用我前面说的试除法判断素性是完全不可行的必须用Miller-Rabin这类概率素性检测算法。RSA的安全性不在这里展开但你要理解素性检测的速度决定了密钥生成能不能用而模幂运算的速度决定了加解密性能。两者都跑在素数和模运算这两根支柱上。4. 实操过程用Python实现素性检测与模运算工具箱前面讲了不少理论背景这一节我直接分享一个我自己整理的工具箱代码顺便把参数选择过程、为什么这样设计都讲到位。4.1 素性检测的工程取舍试除、Miller-Rabin与概率参数实际工程中最难的是判断一个大数比如几百位的整数是否为素数。试除法只能在“小数字”上用。Miller-Rabin是工业标准方案核心思路是把 n-1 拆解为 d * 2^s 的形式随机选若干个底数 a验证一组等式。如果所有验证都通过n很可能是素数只要有一个不通过n一定是合数。import random def is_prime_mr(n, k8): if n 2: return False # 先处理小素因子避免对小数字做无意义的大运算 small_primes [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37] for p in small_primes: if n % p 0: return n p # 把 n-1 写成 d * 2^s 的形式 d n - 1 s 0 while d % 2 0: d // 2 s 1 for _ in range(k): a random.randrange(2, n - 1) x pow(a, d, n) if x 1 or x n - 1: continue for _ in range(s - 1): x (x * x) % n if x n - 1: break else: return False return Truek是测试轮数。对于常见场景k取8到16足够。有关Miller-Rabin的结论对任意奇数合数n随机选一个a能把它检测出来的概率至少是3/4。所以k轮全部误判的概率不超过 (1/4)^k。k8时误判率大约6e-5k16时大约是2e-10。如果配合固定小素数检测集如前12个素数的组合在64位整数范围内甚至可以做到确定性判断。4.2 扩展欧几里得求模逆元如果 a 和 m 互素模逆元可用扩展欧几里得求。扩展欧几里得不仅求最大公约数还能顺带得出方程 ax by gcd(a, b) 的一组整数解。当 gcd(a, b)1 时x 就是 a 在模 b 下的逆元。def exgcd(a, b): if b 0: return a, 1, 0 g, x1, y1 exgcd(b, a % b) return g, y1, x1 - (a // b) * y1 def mod_inv(a, m): a % m g, x, _ exgcd(a, m) if g ! 1: raise ValueError(f{a} 在模 {m} 下不存在逆元) return x % m调试这个递归函数时最容易懵的是系数如何回代。我当年就是死记硬背后来发现一个更直观的记忆方式在递归返回过程中当前位置的解可以看作是由下一层解线性组合得到的。只要用一组具体数字比如 a17, m3120手推一遍就能记住规律。在 RSA demo 中我用mod_inv(17, 3120)得到了 2753。验证结果17 * 2753 46801而 46801 / 3120 15 余 1也就是说 46801 ≡ 1 (mod 3120)正确。4.3 一个可以直接复制的完整工具箱把上面的函数拼到一起再补一个 RSA demo就是一个可以跑通的完整脚本。我建议你保存一份做数学建模或实验时直接改参数调用。import math import random # ---------- 素数生成 ---------- def generate_large_prime(bits, rounds16): while True: candidate random.getrandbits(bits) # 确保是奇数、足够大、不是2的幂附近 candidate | (1 (bits - 1)) | 1 if is_prime_mr(candidate, rounds): return candidate # ---------- 哈希散列的简单线性变换 ---------- def simple_hash(key, table_size): # 用素数和模运算把key映射到[0, table_size) h key # 37在参数选取上有较好的分布特性 h (h * 37) % table_size return h这里还有一个之前没提的经验生成大素数时不能直接用随机数再判断而是要在循环里不断重新随机并测试。理论上平均试 ln(2^bits) 次才能命中一个素数。如果是64位期望仅40多次如果目标2048位期望约1400多次。实际运行中比理论快因为提前排除了偶数和其他小因子倍数。5. 常见问题与排查技巧实录这一节我把自己实际操作中踩过的坑整理成速查表每个问题都附带排查思路和解决方案方便你遇到同类问题时直接定位。现象根因解决方案筛法在n很大时内存占用暴涨布尔列表每个元素占1字节以上n太大直接占满内存用bytearray或分段筛只保留当前段的标记取模结果出现负数编程语言对负数的取模定义不同例如Python取模结果非负但C系语言取决于截断方式在C/C中统一写((a % m) m) % mMiller-Rabin对小素数误判测试底数随机不够或有重复边界处理不到位先试除小素数列表再进入Miller-Rabin主流程大整数乘法溢出某些语言里乘法结果超过类型上限导致结果异常Python不用管其他语言使用大数库或乘前取模错误使用费马小定理求逆元模数不是素数时直接套用费马公式得到错误结果先判断模数是否为素数否则走扩展欧几里得5.1 筛法内存崩溃用分段筛解决求2亿以内的素数时直接用布尔列表需要约2亿字节也就是200MB本地开发还能忍放到在线OJ或者赛题环境就可能超内存。解决方案很简单分段筛。每段只筛1万个左右的区间每段单独用埃氏筛处理这样内存占用变成O(√n 段长)。分段筛的实现细节在于每一段里标记合数时起始位置不是 i*i而是max(i*i, ((start i - 1) // i) * i)也就是从区间内第一个被i整除的位置开始。如果不做这个对齐分段筛的结果会和整体筛不一致漏掉某些区间里的素数。5.2 负数取模一个跟语言强相关的大坑在Python里-7 % 3的结果是2因为Python保证结果符号与除数一致。但在C/C/Java中-7 % 3的结果通常实现为 -1。这会导致同样一条数学公式在两个语言里得到完全不同的结果。尤其在实现哈希函数时如果key是负数直接取模会得到一个负数索引数组直接越界。我写跨语言算法题时养成了一个习惯所有取模操作统一封装成一个mod(a, m)函数内部实现((a % m) m) % m。这样无论数字是正还是负结果永远落在 [0, m-1] 区间。5.3 Miller-Rabin轮数怎么选从安全与效率平衡出发很多人纠结k到底取多少。我的经验是分场景如果是数学建模做题k8足够速度快误差忽略不计如果是在写密钥生成器或加密库建议k16及以上并且配合确定性底数集合。还要注意random.randrange(2, n-1) 在 n 特别大时可能生成重复底数。如果担心这个可以使用固定的小素数底数集合比如 [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37]这不仅避免了随机性带来的重复而且在 3.4 * 10^14 范围内能保证确定性判断正确。对于更大数字再叠加随机底数。5.4 大整数精度与溢出问题Python的整数是无上限的但这个特性会掩盖一个性能问题如果不及时取模中间结果会变得巨大乘法复杂度急剧上升。比如计算 2^10000000 mod 99991如果先算 2^10000000 再取模结果是一个几百万位的整数内存和时间都不可接受但如果在快速幂每一步都取模中间数字始终不超过 99991^2计算量小得多。在Java或C里则要注意int/long 的乘法可能溢出。比如模数接近2^63时两个数相乘会超过64位。这种情况要么使用大数类要么在乘法前先基于模数调整因子比如把base拆成低32位和高32位分别乘。6. 我的一些实战体会与扩展建议经验这东西只有自己踩过坑才有说服力。我最初学这些算法时只知道背模板遇到“求逆元”“快速幂”写出来了但根本不知道为什么。后来有一次做RSA相关课题在生成密钥时发现每次都要等很久这才回头看Miller-Rabin的原理意识到之前写的是“先傻乎乎地试除再随机检测”白白浪费了大量时间。从那以后我对任何算法都保持一个习惯不求最快记住写法而是先问一句“这个剪枝/这个优化依赖什么数学性质”。如果你现在刚开始学素数和模运算我个人建议按这个顺序练先手写一遍快速的单个素数判断再写埃氏筛再用线性筛对比性能然后实现快速幂最后用扩展欧几里得求逆元。等这四样都熟了再去碰RSA和哈希散列会发现所有代码都像积木一样自然拼起来。最后再分享一个小技巧以后在设计校验码、哈希、随机数生成这类需求时先想想“模数能不能选一个素数”。素数模数在很多场景下能带来更好的分布特性比如有限域 GF(p) 的性质会让你的散列或随机序列更均匀。这不是绝对的优化但在大多数情况下它不会让你失望。

相关新闻