资讯动态

用Python彻底搞懂完全剩余系:模运算可视化与密码学基石

发布时间:2026/10/20 9:33:05 来源:尧图企业网站定制
我琢磨模运算的时候总觉得课本上讲得有点绕。“完全剩余系”这几个字一出来很多人第一反应是先背定义然后再去套题目。定义背得滚瓜烂熟可真到写Python模拟、做Jupyter Notebook可视化、或者去理解密码学和哈希表里那些取模逻辑时反而不知道它到底在说什么。这篇内容就想用代码把“完全剩余系”从头拆一遍把一个抽象概念变成能跑、能画、能拖拽交互的实验。如果你正在学数论、准备算法竞赛、或者刚接触RSA这类密码方案又或者是写代码时经常被%的奇怪行为搞懵这篇文章应该对你有用。我不打算把数学课本重新抄一遍而是带着你从生成、验证、可视化到踩坑把模运算的本质用Python演一遍。看完之后你再遇到“完全剩余系”这四个字脑子里浮现的不再是定义而是一张图、一段代码、一组可以拖动参数的滑块。1. 先搞清楚完全剩余系到底在说什么1.1 模运算不是“求余数”这么简单很多人第一次接触%以为它就是“求余数”。这个理解不算错但太浅了。模运算真正在做的事情是把全体整数按照“模n之后的余数”来分类然后在这些类之间重新定义加减乘。你可以用星期几来想这个问题今天是星期几并不重要重要的是“今天到某个参考点之间隔了多少天然后模7之后落在哪一格”。所有相隔7天的日子在“星期”这个视角下是等价的。数学上就说它们是模7同余的。正是这种“等价”产生了剩余类。模7可以把所有整数分成7类余0的那一类、余1的那一类、余2的那一类……一直到余6的那一类。而“完全剩余系”就是在这一堆分类里每一类挑一个代表出来凑成一整套样本。1.2 剩余类、完全剩余系、简化剩余系的分工这里顺便把三个容易混淆的概念摆在一起看。概念含义元素个数剩余类所有模n同余的整数构成的集合无限个分成n类完全剩余系从每个剩余类中取一个代表组成的集合n个简化剩余系只从与n互素的剩余类中取代表组成的集合φ(n)个φ是欧拉函数举个例子。模5的完全剩余系最常用的写法是{0,1,2,3,4}。但我也可以写成{5,6,7,8,9}因为5和0同余、6和1同余以此类推。甚至可以写{-4,-3,-2,-1,0}因为-4 mod 5 1-3 mod 5 2。你会发现代表的选择可以五花八门但必须满足两条一是一共n个元素二是任意两个元素模n不同余。简化剩余系则是另一回事。它讨论的是“哪些余数跟n互素”。比如模10完全剩余系是{0,1,2,3,4,5,6,7,8,9}但简化剩余系只有{1,3,7,9}欧拉函数φ(10)4。这个概念在密码学里更常见但完全剩余系是它的地基简化剩余系本质上就是完全剩余系里“挑出互素的那几位”。1.3 这套概念会影响哪些真实场景我见很多开发者觉得数论离业务代码很远其实完全剩余系就藏在最常用的数据结构里。第一个场景是哈希表。hash(key) % table_size决定了键值落到哪个桶。如果键的分布恰好让余数覆盖了0..table_size-1那么每个桶都被均匀使用但如果某个因子导致大量键的余数重复某些桶就会一直空闲。完全剩余系关心的是“覆盖性”哈希表关心的是“均匀性”两者是同一件事的两面。第二个场景是循环队列和环形缓冲区。下标(pos 1) % capacity每走一圈就回到起点。这里的0..capacity-1就是一个完全剩余系取模操作把所有整数“折叠”到了一个环上。第三个场景是密码学。RSA里的模幂运算、Diffie-Hellman密钥交换里的群运算背后都依赖“模n乘法”在特定条件下是一个良定义的双射。而要理解“为什么某些k能用、某些k不能用”完全剩余系就是第一块基石。2. Python模拟的总体设计怎么用代码翻译定义2.1 从“生成一个完全剩余系”开始定义说“从每个剩余类取一个代表”那最简单的生成方式就是直接取0..n-1这一串。在Python里这甚至不需要动用什么工具库。def complete_residue_system(n, start0): return list(range(start, start n)) print(complete_residue_system(5)) # [0, 1, 2, 3, 4] print(complete_residue_system(5, 1)) # [1, 2, 3, 4, 5]start参数看着多余但很有用它允许我们从任意起点切出长度为n的连续整数段。切出来之后每个元素模n的余数一定是0..n-1的一个排列而且是不重不漏的。这里有个我特别想强调的设计选择返回值是一个普通的Python列表而不是set。原因很简单完全剩余系虽然强调“不同余”但允许出现不同的数字比如{0,1,2,3,14}对模5来说依然有效因为14 mod 5 4。如果直接丢进set反而丢掉了“原数”的信息后续做乘法、平移的可视化就不方便了。2.2 如何检查一个集合真的是完全剩余系生成容易验证才是关键。判断一个列表是不是完全剩余系只看三件事元素个数是不是n、所有元素模n之后有没有缺失、以及有没有重复。一个比较严谨的验证函数可以这样写def verify_complete_system(vec, n): if len(vec) ! n: raise ValueError(f元素个数应为{n}实际是{len(vec)}) residues {x % n for x in vec} missing set(range(n)) - residues if missing: raise ValueError(f缺少余数: {sorted(missing)}) return True我用的是ValueError而不是assert。因为assert在Python优化模式-O下会被直接吞掉而写个明确报错的函数无论什么时候跑都能告诉你问题在哪。base complete_residue_system(5) verify_complete_system(base, 5) # True这个函数后面会反复用到。每次我改了一组代表、做了一个平移或者乘法变换都会拿它过一遍。虽然数学上可以证明结论但代码验证能让你亲眼看到结论成立这对建立直觉非常有帮助。2.3 换一组代表再试代表本来就不唯一完全剩余系最容易让人困惑的一点就是“没有唯一形态”。{0,1,2,3,4}行{5,6,7,8,9}也行甚至负数开头的区间也行。我们用代码扫一遍starts [-10, -5, 0, 1, 7, 100] for start in starts: vec complete_residue_system(5, start) residues sorted(x % 5 for x in vec) print(fstart{start:4d}: {vec} - 余数为 {residues})输出会非常统一不管起点在哪余数都是[0, 1, 2, 3, 4]。这就是完全剩余系的“不变性”代表可以换但覆盖的余数集合永远不变。很多密码算法在设计时利用的就是这一点——只要保证处理的是完整剩余系代表形式无论怎么变运算结构都不会坏。3. 用代码验证完全剩余系的两个关键性质3.1 平移不改变“完全性”完全剩余系第一个值得动手验证的性质是整体加上一个常数后它仍然是完全剩余系。什么意思呢{0,1,2,3,4}加1变成{1,2,3,4,5}模5之后还是[1,2,3,4,0]依然完整。数学上这对应的是“每个剩余类同时平移一个固定偏移类别之间的完整对应关系不会被打乱”。代码验证很容易n 5 base complete_residue_system(n) def shifted(system, n, c): return [(x c) % n for x in system] for c in range(-20, 20): verify_complete_system(shifted(base, n, c), n) print(所有平移后的结果都是完全剩余系)你甚至可以扩大范围从-100测到100。结果都一样。这个性质在环形队列里特别明显队首指针往前挪多少位队列中元素所覆盖的下标集合依然是完整的。3.2 乘法性质互素条件是怎么跳出来的平移容易理解乘法就有意思了。把{0,1,2,3,4}整体乘以某个k再取模结果还是完全剩余系吗答案不是“无条件成立”而是“当且仅当k与n互素”。用代码验证这个结论比空看公式直观得多from math import gcd def scaled(system, n, k): return [(k * x) % n for x in system] n 8 for k in range(1, 8): vec scaled(base_8 : complete_residue_system(n), n, k) try: verify_complete_system(vec, n) ok True except ValueError: ok False print(fk{k}, gcd(k,8){gcd(k,8)}, 是否仍为完全剩余系{ok})输出结果基本是k1、3、5、7可行k2、4、6不行。而这些可行的k恰好是gcd(k,8)1的那些数。用人话说如果k和模数不互素乘法就会把某些余数“撞”在一起。比如模8乘40*4和2*4都等于01*4和3*4都等于4整个集合被压扁成了{0,4}缺了6个数。你要是不先算gcd就很难解释为什么好好的集合突然就塌了。3.3 从乘法性质走向逆元乘法性质还有一个非常自然的推论如果gcd(k,n)1那么必然存在一个整数使得k * inv ≡ 1 (mod n)这个inv就是k在模n下的逆元。因为k把完全剩余系变成完全剩余系那么必然有一个元素x满足k*x落到余数1那一类里。Python 3.8以后的内置函数pow可以直接算逆元for k in [3, 5, 7]: inv pow(k, -1, 8) print(fk{k}, 逆元{inv}, 验证: {k} * {inv} mod 8 {(k * inv) % 8}) # 如果尝试 pow(4, -1, 8)会抛 ValueError因为 gcd(4,8) ! 1这行代码比手写扩展欧几里得好用多了。我建议你亲手跑一下不可逆的情况让程序当面报个错比死记“互素才可逆”要深刻得多。这也是从“完全剩余系”过渡到“循环群”的必经之路因为群里的每个元素都必须能找到逆元。4. Jupyter Notebook 可视化实操让模运算“转起来”4.1 初始化环境与基础绘图模拟是一回事可视化又是另一回事。Jupyter Notebook做这个实验几乎是完美的因为它支持%matplotlib inline和ipywidgets交互组件。开头的初始化代码我一般这么写%matplotlib inline import numpy as np import matplotlib.pyplot as plt from ipywidgets import interact plt.rcParams[figure.figsize] (8, 4) # 如果中文标题显示为方框把下面这行改成你系统里有的中文字体 plt.rcParams[font.sans-serif] [SimHei, WenQuanYi Zen Hei, Noto Sans CJK SC] plt.rcParams[axes.unicode_minus] False很多人在这一步会栽跟头图能画出来但中文全变成方块。那是因为Matplotlib默认字体覆盖不了中文需要手动指定一个支持中文的字体。字体名不一定每个人系统不一样上面给的是常见几个替换一下就好。第一张图我习惯画“模n的周期锯齿”。横轴是连续整数纵轴是x % nn 8 xs list(range(-10, 20)) ys [x % n for x in xs] plt.figure(figsize(9, 3)) plt.plot(xs, ys, markero, linestyle-) plt.xlabel(x) plt.ylabel(x mod 8) plt.title(模8的周期锯齿) plt.grid(True) plt.show()这张图非常简单但信息量不小你一眼能看到取模把数轴折叠成了周期重复的锯齿。所有“同一纵坐标”的点就是同一个剩余类里的成员。完全剩余系就是在这条锯齿上每个高度只选一个点。4.2 画乘法映射一眼看出双射与重复接下来把乘法映射也画出来。这里我用的是“折线图”可以直观看到哪些k是双射、哪些k会把集合压扁。def plot_kx_mod_n(n, k): xs list(range(n)) ys [(k * x) % n for x in xs] plt.figure(figsize(7, 3)) plt.plot(xs, ys, markero, linestyle-) plt.xlabel(x) plt.ylabel(f{k} * x mod {n}) plt.title(fk{k}, gcd({k},{n}){gcd(k, n)}) plt.ylim(-1, n) plt.grid(True) plt.show() plot_kx_mod_n(8, 3) plot_kx_mod_n(8, 4)当你看到k3的图曲线把所有整数点0..7完整地映射到了0..7它是一个重排再看k4的图只有0和4两个高度所有点都坍塌到这俩值上。同样的代码只是k不同图就完全不同。这就是互素条件在图像上的含义。4.3 用 ipywidgets 做交互滑块静态图只能看两组结果交互滑块才是这个项目最好玩的部分。用interact把n和k变成两个滑块拖一下看实时变化from ipywidgets import interact interact(n(2, 20, 1), k(1, 20, 1)) def interactive_multiply(n5, k3): kk k % n xs list(range(n)) ys [(kk * x) % n for x in xs] unique_count len(set(ys)) plt.figure(figsize(8, 3)) plt.plot(xs, ys, markero, linestyle-) plt.xlabel(x) plt.ylabel(f{kk} * x mod {n}) plt.title(fn{n}, k{kk}, gcd{gcd(kk, n)}, 不同值数量{unique_count}) plt.ylim(-1, n) plt.grid(True) plt.show()我在Notebook里拖这个滑块时最直观的感受是当gcd(kk, n)开始不是1折线的“高度层数”立刻变少。gcd越大层数塌得越狠。这个体验完全不需要看公式拖几次就记住了。一个小细节代码里我把k先做了k % n而不是直接用原始滑块值。不然滑块拖到k11时语义会和k3重复图像还会让你误以为规律变了。其实模n乘法只关心k的余类不关心k本身多大。4.4 用表格和热力图透视乘法表滑块适合探索单条序列但如果你想整体观察“哪些k能让完全剩余系保持完整”表格和热力图更好用。import pandas as pd n 8 df pd.DataFrame( {fk{k}: [(k * x) % n for x in range(n)] for k in range(1, n)} ) df.index [fx{x} for x in range(n)] print(df)输出后你会很清楚看到k3那一列是[0,3,6,1,4,7,2,5]是完整重排k4那一列是[0,4,0,4,0,4,0,4]惨不忍睹。热力图更直观。把一个n x n的矩阵画出来行是k列是x颜色表示(k*x)%n的余数def multiplication_heatmap(n): grid np.zeros((n, n), dtypeint) for k in range(1, n): for x in range(n): grid[k][x] (k * x) % n plt.figure(figsize(7, 6)) plt.imshow(grid, cmapviridis, aspectauto) plt.colorbar(label余数) plt.xlabel(x) plt.ylabel(k) plt.title(f模{n}乘法表热力图) plt.show() multiplication_heatmap(8)热力图里出现“完整彩虹条纹”的那些行就是k和n互素的行出现局部色块、颜色大量重复的行就是有碰撞的行。如果你把n调到12再看模式更明显。这个矩阵本质上就是有限环Z/nZ的乘法表而完全剩余系性质决定了这张表每一行是否是“置换”。5. 踩坑记录Python取模语义、负数和浮点数5.1 Python 的 % 与 C 的 % 方向不同写代码模拟数学概念最容易踩的第一个坑不是数学难而是不同语言里%的语义不一样。Python里-7 % 3的结果是2因为Python的整除是向下取整。而C语言里-7 % 3的结果是-1因为C的整除是向零取整。对完全剩余系教学来说Python的这个行为其实更友好因为它天然把任何整数都映射到0..n-1这个“标准完全剩余系”里。你如果是从C/C移植一段算法过来很容易栽在这里。比如同一套“模8”逻辑在C里可能出现负余数然后你的集合验证函数就会报缺失。知道这个差异排查起来就快多了。5.2 负数为代表时的“补正”技巧虽然Python的%是非负的但有时候你拿到的是负数结果。比如某个第三方库返回的是C风格余数或者你在其他语言环境下调试再或者你手算时习惯用带符号余数。这时候最好有一个统一函数把它修正成非负余数def mod_positive(a, n): return (a % n n) % n这个函数对Python来说是冗余的因为(-7) % 3已经等于2再加n再取模不会变。但它是一道保险万一你换了环境或者数据来源奇怪它能保证结果落在0..n-1里。我在写验证函数时如果这个函数将来可能被别的人拿去用就会把这种防御性写法放进去。5.3 浮点数别来参合完全剩余系是整数领域的概念不要试图拿浮点数验证。Python的%也支持浮点数比如5.5 % 2会得到1.5看起来像模运算但浮点误差的存在会让“余数等于某个值”的断言变得非常脆弱。更麻烦的是math.fmod和%在负数上的行为还不完全一致。在我做这个模拟项目时一直坚持只对Python的int使用%。如果你有浮点数要处理请先定义好精度阈值而不是直接调用verify_complete_system。否则巨大的误差会告诉你什么叫“看着像验证不过”。5.4 大模数场景用生成器代替列表完全剩余系定义里要列出n个代表。如果n只有8、12那把整个列表放内存毫无压力。但如果你一时冲动想验证n1000000的完全剩余系生成一个百万元素的列表只会让Notebook卡死而且毫无必要。更好的做法是用生成器def residue_generator(n, start0): for i in range(start, start n): yield i for i in residue_generator(5): print(i) # 0 1 2 3 4生成器不会一次性把所有元素放进内存适合做流式处理。不过我得说句实在话当n足够大时“完全剩余系”这个概念本身就倾向于用数学证明而不是穷举来验证。代码模拟的意义在于小模数教学和直觉建立别拿它硬扛天文数字。5.5 问题排查速查表我把这个项目里常遇到的问题整理成一张表方便你对照排查现象原因处理方式verify报“缺少余数”代表集合里有重复余数覆盖不全检查区间长度是否正好n或先统一取非负余数乘法后元素大量重复k与n不互素用math.gcd提前判断或者改成k % n再看C移植代码结果有负数向零取整的%语义用(a % n n) % n补正pow(k, -1, n)抛错k在模n下不可逆确认gcd(k,n)1Notebook中文乱码缺少中文字体设置rcParams[font.sans-serif]为系统已有字体n很大时内存爆掉生成了完整的n元素列表改用生成器或直接数学判定6. 两个完整演示模5与模126.1 模5从不同区间取代表模5是入门实验里最舒服的数字因为数小、规律明显。我建议先从不同起点生成几个“版本”的完全剩余系看看它们的模5余数是否稳定n 5 for start in [-10, -5, 0, 1, 7, 100]: vec complete_residue_system(n, start) print(f{vec} - {sorted(x % n for x in vec)})不管起点是-10还是100余数都是[0,1,2,3,4]。这个例子就是要告诉你完全剩余系不是一个固定的数字组合而是一种“覆盖模式”。接着再看模5的乘法表。5是质数所以除了k0以外的每个kgcd(k,5)都等于1。这也就意味着模5的乘法表里除第0行外每一行都是完整重排。以后你看到这种“每一行都是置换”的表格就可以联想到它背后是质数模数也自然会联想到有限域GF(5)。6.2 模12乘法表的重复与缺失模12比模5有趣因为12是合数并且因数很多。我们挑几个k看谁能让完全剩余系保持完整n 12 for k in [1, 3, 5, 7, 11, 6, 4, 9, 10]: vec [(k * x) % n for x in range(n)] unique_count len(set(vec)) print(fk{k:2d}, gcd(k,12){gcd(k, n):2d}, 不同值数量{unique_count:2d}, f是否完整{unique_count n})输出大致是k1,5,7,11不同值数量12完整重排k3,9gcd为3最后只剩4个不同值k4gcd为4只剩3个不同值k6gcd为6只剩2个不同值k10gcd为2只剩6个不同值。这里体现了影响范围你看到的“缺失数”恰恰是n / gcd(k,n)。这也是为什么在真实系统里如果底层的模数可以被人为选择往往会优先选质数或者至少选一个不容易和k产生公因子的数。否则房间里明明摆着12个桶最后只能用到其中一小部分浪费事小安全事大。6.3 从演示回看定义模12的实验做完再回头看定义就顺了完全剩余系关注的是“每个剩余类有没有代表、是不是恰好一个”。加法性质保证了平移不会破坏覆盖乘法性质则取决于k和n是否互素。这两个性质不是孤立的数学结论它们分别对应着“循环队列的下标循环”和“密码算法中为什么某些乘法映射可逆”。我从一开始就说这个项目的意义不在背定义而在于把定义变成可交互的实验。操作层面我强烈建议你动手点一遍交互滑块并且把那些“不完整”的k都记录一下比如模12下k6输出什么。当你亲眼看到一行从12个不同值缩水成2个值比任何教材注释都有说服力。我自己的体会是以前总觉得自己懂模运算但真正开始用Python模拟完全剩余系之后才发现负数取模、乘法碰撞、互素条件这些东西全都有前置依赖。代码的价值不是“证明”数学定理而是把抽象的等价关系翻译成可以观察和操作的对象让直觉先建立起来。如果你也想试试建议从模8的乘法表开始这个数字既小又有多种因数结构滑块拖几轮就能建立条件反射。最后分享一个写代码时的小习惯验证函数里不要只放一个总断言把长度检查、余数覆盖、互素判断拆开各自返回具体错误信息。这样以后跑出问题你能一眼知道是“代表不够”还是“乘法碰撞”而不是面对一个笼统的AssertionError发呆。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑