☰
Python实现凯撒密码加解密:从原理到封装,避开负数取模的坑
2026/10/14 19:49:03 网站建设 项目流程

凯撒密码算是古典密码里最"出圈"的一个了。哪怕完全没接触过密码学的人,多半也在某部电影或者某节计算机导论课上听过它的名字——把字母表往后挪几位,A 变成 D,B 变成 E,简单到用一张纸就能手算。但真到了要写代码实现它的时候,很多人第一反应是"这有什么难的",然后写出来的东西要么处理不了大小写混排,要么遇到非字母字符直接崩掉,要么解密时负数取模算错方向。我自己第一次写凯撒加解密的时候,就因为在 Python 里对负数做取模运算踩了个不大不小的坑,排查了半天才反应过来问题出在哪。

这篇内容就是围绕"用 Python 实现凯撒密码加解密算法"这件事展开的。我会从最朴素的实现讲起,把字符映射的数学原理、大小写处理、非字母字符的保留策略、负数取模的坑、暴力破解的思路,一直到怎么把它封装成一个能直接复用的模块,全部拆开讲清楚。不管你是刚学 Python 想找个练手项目的新手,还是想复习一下字符串处理和模运算的老手,应该都能从里面找到点有用的东西。代码我会尽量写得直白,注释给足,每一步为什么这么写都会说清楚,而不是甩一段代码就完事。

1. 凯撒密码到底在做什么:从字母表位移说起

1.1 一个生活化的类比:转盘上的字母

想象你有一个圆盘,边缘均匀地刻着 26 个英文字母,从 A 到 Z 首尾相接围成一圈。圆盘中间有一个固定的指针,指向某个字母。现在你在圆盘上再叠一个可以转动的环,环上也刻着 26 个字母。当你把可动环顺时针转动 3 格的时候,原本指针指向的 A 位置,现在对应的是可动环上的 D。这就是凯撒密码加密的全部动作——把整个字母表整体平移固定的位数。

解密就是把环往反方向转回去同样的格数。这个"转盘"的比喻很重要,因为它天然解释了凯撒密码最核心的两个性质:第一,位移是循环的,Z 往后移 3 位会绕回到 C,不会溢出;第二,加密和解密用的是同一个位移量,只是方向相反。理解了这两点,后面写代码时关于取模运算和方向处理的所有细节,都会变得顺理成章。

在实际的密码学语境里,这个位移量通常叫做"密钥"(key)。凯撒本人据传用的是位移 3,也就是 A→D、B→E 这样的映射。但作为算法实现,密钥可以是 0 到 25 之间的任意整数。密钥为 0 时等于没加密,密钥为 13 时有个有趣的性质——加密和解密是同一个操作,因为 13 是 26 的一半,转两下正好回到原点,这个特例在 ROT13 里被广泛使用。

1.2 数学表达:为什么必须用取模运算

把转盘的直觉翻译成数学语言,加密的过程就是:对于明文中的每个字母,找到它在字母表中的序号(A=0, B=1, ..., Z=25),加上密钥,然后对 26 取模,得到密文字母的序号。

用公式写出来就是:

密文序号 = (明文序号 + 密钥) mod 26 明文序号 = (密文序号 - 密钥) mod 26

这里的mod 26就是取模运算,它保证了结果永远落在 0 到 25 这个范围内,对应到 26 个字母上。如果没有取模,当明文是 Z(序号 25)而密钥是 3 时,25+3=28,就找不到对应的字母了。取模之后 28 mod 26 = 2,对应 C,正好就是转盘绕回去的效果。

解密公式里出现了减法,这就引出了 Python 中一个非常容易踩的坑:负数取模。在数学上,-1 mod 26 应该等于 25(因为 -1 = -1×26 + 25),但在某些编程语言里,负数取模的结果可能是 -1。Python 在这方面其实是"友好"的,它遵循数学定义,-1 % 26的结果就是 25。但如果你是从 C 或 Java 转过来的,习惯了那边负数取模返回负值的行为,写 Python 时反而可能因为"想太多"而画蛇添足地加一堆修正逻辑,把本来正确的代码改错。这一点我在后面讲解密实现时会再展开。

1.3 凯撒密码的强度:为什么它只能当玩具

有必要说清楚一件事:凯撒密码在现代意义上没有任何安全性可言。它的密钥空间只有 26 种可能(严格说是 25 种有效密钥,因为 0 等于不加密),暴力枚举一遍连一毫秒都用不了。哪怕不知道密钥,只要拿到一段密文,用频率分析的方法也能很快猜出位移量——英文里字母 E 出现频率最高,找到密文里出现最多的字母,它大概率对应明文的 E,两者序号之差就是密钥。

那为什么还要学它、实现它?因为它是理解密码学基本概念的最佳载体。替换、位移、密钥、加密解密对称性、暴力破解、频率分析,这些概念在凯撒密码里都有最朴素的体现。把它写一遍,你对"加密"这件事的直觉会清晰很多,之后再去看维吉尼亚密码、仿射密码这些进阶的古典密码,会发现它们不过是在凯撒的基础上加了点变化而已。所以我的建议是:别指望它保护什么重要信息,但一定要亲手实现一遍,把它当成理解密码学思维的入门砖。

2. 最朴素的实现:先跑通,再优化

2.1 用 ord 和 chr 做字符与序号的转换

Python 里处理字符和它对应数值之间转换的两个内置函数是ord()和chr()。ord('A')返回 65,ord('a')返回 97,chr(65)返回 'A'。这两个函数是 ASCII 编码的直接体现,也是我们实现凯撒密码的基础工具。

有了它们,字母序号的计算就很直接了。对于大写字母,序号 =ord(ch) - ord('A');对于小写字母,序号 =ord(ch) - ord('a')。加密后再用chr(序号 + ord('A'))或chr(序号 + ord('a'))转回字符。这里的关键是大小写要分开处理,因为大写 A 和小写 a 在 ASCII 表里差了 32,如果混在一起算序号,结果就全乱了。

我先给一个最基础、只处理大写字母的版本,方便你看清核心逻辑:

def caesar_encrypt_upper(text, key): result = [] for ch in text: if 'A' <= ch <= 'Z': # 计算字母序号,位移,取模,再转回字符 shifted = (ord(ch) - ord('A') + key) % 26 result.append(chr(shifted + ord('A'))) else: # 非大写字母原样保留 result.append(ch) return ''.join(result)

这段代码虽然简单,但已经把凯撒密码的骨架搭好了:遍历每个字符、判断是不是目标字母、算序号、位移取模、转回字符、拼接结果。后面所有的优化,本质上都是在这个骨架上做扩展。

2.2 大小写混排的处理策略

真实文本里大小写是混着来的,所以必须同时处理 'A'-'Z' 和 'a'-'z' 两个区间。有两种常见写法。

第一种是分别判断,用两个分支:

def caesar_encrypt(text, key): result = [] for ch in text: if 'A' <= ch <= 'Z': result.append(chr((ord(ch) - ord('A') + key) % 26 + ord('A'))) elif 'a' <= ch <= 'z': result.append(chr((ord(ch) - ord('a') + key) % 26 + ord('a'))) else: result.append(ch) return ''.join(result)

第二种是统一处理,先判断是不是字母,是的话记录基准值(大写用 'A',小写用 'a'),然后走同一套位移逻辑:

def caesar_encrypt(text, key): result = [] for ch in text: if ch.isalpha() and ch.isascii(): base = ord('A') if ch.isupper() else ord('a') result.append(chr((ord(ch) - base + key) % 26 + base)) else: result.append(ch) return ''.join(result)

我个人更推荐第二种,因为逻辑更集中,扩展起来也方便。但这里有个细节要注意:ch.isalpha()对于中文、日文等非 ASCII 字母也会返回 True,所以必须加上ch.isascii()的判断,否则遇到中文字符时ord(ch) - base会算出一个巨大的数,虽然取模后不会报错,但结果毫无意义,还会把中文替换成乱七八糟的字母。这个坑我在第一次处理带中文的文本时踩过,密文里突然冒出一堆英文字母,排查了好一会儿才想到是isalpha()的锅。

2.3 非字母字符:保留还是丢弃

凯撒密码只对字母做变换,那空格、标点、数字这些非字母字符怎么办?有两种处理方式:保留原样,或者直接丢弃。

保留原样是更常见的做法,因为这样密文的结构和明文一致,解密后能完整还原原文,包括标点和空格。上面代码里的else: result.append(ch)就是保留策略。丢弃策略则是遇到非字母字符就跳过,这样密文更"干净",但解密后原文的空格和标点全没了,可读性很差。

从实用角度,我强烈建议保留。除非你有特殊需求(比如只关心字母序列本身),否则保留非字母字符能让整个加解密过程可逆且无损。这也是为什么我在上面的代码里始终坚持else分支原样追加字符。

提示:如果你要做的是"只加密字母、其他全部删掉"的场景,记得在解密后无法还原原文,这一点在写测试用例时要想清楚,别拿一段带标点的原文去断言解密结果等于原文。

3. 解密实现与那个绕不开的负数取模

3.1 解密就是反向位移

加密是加 key,解密自然就是减 key。把上面加密函数里的+ key改成- key,就得到了解密函数:

def caesar_decrypt(text, key): result = [] for ch in text: if ch.isalpha() and ch.isascii(): base = ord('A') if ch.isupper() else ord('a') result.append(chr((ord(ch) - base - key) % 26 + base)) else: result.append(ch) return ''.join(result)

看起来很简单,但这里藏着 Python 取模的一个特性,值得单独拎出来说。

3.2 Python 负数取模的行为:和 C/Java 不一样

在 Python 里,-1 % 26的结果是 25,不是 -1。这是因为 Python 的取模运算遵循"结果符号与除数一致"的规则,而 26 是正数,所以结果总是非负的。这个特性恰好帮了我们大忙——解密时ord(ch) - base - key完全可能是负数,比如密文是 'A'(序号 0),key 是 3,那么 0 - 3 = -3,-3 % 26在 Python 里等于 23,对应 'X',正好是 A 往前推 3 位的正确结果。

但如果你是从 C、Java、JavaScript 这些语言转过来的,可能会下意识地写这样的"防御性"代码:

# 这是画蛇添足的写法,Python 里不需要 shifted = (ord(ch) - base - key) % 26 if shifted < 0: shifted += 26

在 Python 里,%的结果永远不会是负数(只要除数是正数),所以这个if判断永远不成立,纯属多余。更糟的是,有些人为了"保险",会写成((ord(ch) - base - key) % 26 + 26) % 26这种双重取模,虽然结果正确,但完全没必要,反而让代码看起来像是作者没搞懂 Python 的取模规则。

我自己的经验是:在 Python 里写凯撒解密,直接用(序号 - key) % 26就行,不用加任何修正。如果你不确定,可以在交互式环境里敲一下-3 % 26和-30 % 26看看结果,验证一次就记住了。

3.3 加解密互逆的验证方法

写完加密和解密,一定要验证它们互为逆运算。最直接的方法就是:对一段文本加密再解密,看结果是否等于原文。

original = "Hello, World! 123" encrypted = caesar_encrypt(original, 3) decrypted = caesar_decrypt(encrypted, 3) assert decrypted == original, f"加解密不互逆: {decrypted} != {original}"

这个断言能帮你抓住绝大多数实现错误。如果断言失败,常见原因有几个:大小写基准值搞混了、非字母字符被错误处理了、取模方向反了。我建议把这段验证代码固定下来,每次改动加解密逻辑后都跑一遍,比肉眼检查靠谱得多。

另外可以顺手验证一个性质:用密钥 k 加密,再用密钥 26-k 加密,结果应该等于原文。因为加 k 再加 26-k 等于加 26,而 26 mod 26 = 0,等于没动。这个性质在理解密钥空间时挺有用。

4. 暴力破解:26 种可能全试一遍

4.1 穷举所有密钥

凯撒密码的密钥空间只有 26 种,暴力破解就是把这 26 种密钥全试一遍,把结果都打印出来,用人眼挑出有意义的那条。实现起来非常简单:

def brute_force(ciphertext): for key in range(26): candidate = caesar_decrypt(ciphertext, key) print(f"key={key:2d}: {candidate}")

跑一下就能看到 26 行输出,其中只有一行是通顺的英文,其余都是乱码。这就是凯撒密码"不安全"的最直观体现——破解成本几乎为零。

4.2 用频率分析自动挑出最可能的明文

人眼看 26 行输出虽然不累,但如果要自动化处理,可以用频率分析。英文里字母 E 出现的频率最高(大约 12.7%),其次是 T、A、O、I、N。所以思路是:对每个候选明文统计字母频率,找出频率最高的字母,看它是不是 E,或者计算整个文本的字母频率分布和标准英文分布的吻合度,选吻合度最高的那个。

一个简化版的自动破解:

def auto_crack(ciphertext): best_key = 0 best_score = -1 for key in range(26): candidate = caesar_decrypt(ciphertext, key) # 统计字母频率,用 E 的占比作为简单评分 letters = [c.lower() for c in candidate if c.isalpha() and c.isascii()] if not letters: continue score = letters.count('e') / len(letters) if score > best_score: best_score = score best_key = key return best_key, caesar_decrypt(ciphertext, best_key)

这个评分函数很粗糙,只看了 E 的占比,但对于足够长的英文文本已经够用了。如果想更准,可以统计全部 26 个字母的频率,和标准频率表做卡方检验或者余弦相似度,选相似度最高的。不过对于凯撒密码这种级别的加密,粗糙的方法完全够用,没必要上重型武器。

注意:频率分析对短文本效果很差。如果密文只有几个字母,E 的占比可能完全不能反映真实分布,这时候还是老老实实把 26 种结果都打印出来人眼看吧。

4.3 暴力破解给我们的启示

写暴力破解的过程,其实是在建立一种"攻击者视角"。当你亲手用几行代码就把自己刚写的加密破解掉时,对"密钥空间"这个概念的理解会深刻很多。凯撒密码的密钥空间是 26,暴力破解是 O(26);如果换成密钥长度可变的维吉尼亚密码,密钥空间就变成了 26 的 n 次方,n 稍微大一点就暴力不动了。这就是为什么现代密码学强调密钥长度——不是算法本身多复杂,而是要让暴力枚举在计算上不可行。

所以我的建议是:实现完凯撒密码后,一定顺手把暴力破解也写了。它花不了你十分钟,但能让你对"加密强度"这件事有切身体会,比看十篇科普文章都管用。

5. 封装成可复用模块:从脚本到工具

5.1 函数签名设计:key 的默认值和类型校验

把散落的函数整理成一个模块时,首先要考虑接口设计。我习惯给 key 一个默认值 3(致敬凯撒本人),同时加上类型和范围校验:

def caesar_encrypt(text: str, key: int = 3) -> str: if not isinstance(key, int): raise TypeError("key 必须是整数") key = key % 26 # 把任意整数归一化到 0-25 ...

这里key = key % 26是个很实用的小技巧。它让函数能接受任意整数作为密钥,比如 key=29 等价于 key=3,key=-3 等价于 key=23。这样调用方不用自己保证 key 在 0-25 范围内,用起来更省心。而且这个归一化对负数也有效,-3 % 26在 Python 里等于 23,正好是往前推 3 位的正确密钥。

5.2 支持批量处理和文件读写

实际用的时候,往往不是处理一个字符串,而是处理一个文件。可以加两个辅助函数:

def encrypt_file(input_path, output_path, key=3): with open(input_path, 'r', encoding='utf-8') as f: content = f.read() encrypted = caesar_encrypt(content, key) with open(output_path, 'w', encoding='utf-8') as f: f.write(encrypted) def decrypt_file(input_path, output_path, key=3): with open(input_path, 'r', encoding='utf-8') as f: content = f.read() decrypted = caesar_decrypt(content, key) with open(output_path, 'w', encoding='utf-8') as f: f.write(decrypted)

文件读写这里有个容易忽略的点:编码。一定要显式指定encoding='utf-8',否则在不同操作系统上默认编码可能不一样,Windows 上默认是 GBK,读一个 UTF-8 编码的文件就可能报 UnicodeDecodeError。这个坑我在跨平台跑脚本时踩过,明明在自己电脑上好好的,换台机器就崩,最后发现是编码问题。

5.3 命令行入口:用 argparse 做成小工具

如果想让它更像一个"工具"而不是"脚本",可以加个命令行入口:

import argparse def main(): parser = argparse.ArgumentParser(description="凯撒密码加解密工具") parser.add_argument("mode", choices=["encrypt", "decrypt", "crack"]) parser.add_argument("text", help="要处理的文本") parser.add_argument("-k", "--key", type=int, default=3, help="密钥,默认 3") args = parser.parse_args() if args.mode == "encrypt": print(caesar_encrypt(args.text, args.key)) elif args.mode == "decrypt": print(caesar_decrypt(args.text, args.key)) else: key, plain = auto_crack(args.text) print(f"推测密钥: {key}") print(f"明文: {plain}") if __name__ == "__main__": main()

这样就能在终端里直接python caesar.py encrypt "Hello" -k 3了。虽然功能简单,但加上命令行入口后,整个东西的"完成度"会高很多,也更方便做自动化测试。

6. 那些我踩过的坑和实测经验

6.1 中文和 emoji 的处理

前面提过isalpha()对中文返回 True 的问题,这里再补充一个 emoji 的情况。emoji 字符在 Python 里是单个字符,但它的码点很大,ord()返回的值可能超过 26 很多倍。如果代码里只判断了isalpha()而没判断isascii(),emoji 虽然不会被isalpha()判定为字母(emoji 的isalpha()返回 False),但某些特殊符号可能会出问题。最稳妥的写法就是同时判断ch.isalpha() and ch.isascii(),双保险。

我实测过一段中英混排加 emoji 的文本,用isalpha() and isascii()的组合,中文和 emoji 都能原样保留,只有英文字母被加密,效果符合预期。

6.2 密钥为 0 和 26 的边界情况

key=0 时加密等于不加密,这是符合预期的。key=26 时,因为26 % 26 = 0,也等于不加密。key=25 时相当于往前推 1 位,key=-1 时因为-1 % 26 = 25,效果和 key=25 一样。这些边界情况在归一化之后都自动处理了,不用特殊判断。但写测试用例时最好覆盖一下,确保归一化逻辑没写错。

6.3 性能:字符串拼接用 join 而不是 +=

如果处理很长的文本,字符串拼接方式会影响性能。Python 里字符串是不可变的,用result += ch每次都会创建新字符串,复杂度是 O(n²)。正确做法是用列表收集字符,最后''.join(result),复杂度是 O(n)。对于凯撒密码这种逐字符处理的场景,这个差异在长文本上会很明显。我实测过处理一篇几万字的文本,用+=要好几秒,用join几乎是瞬间完成。

6.4 一个容易忽略的测试用例:空字符串和纯非字母文本

写测试时别忘了空字符串和纯标点/数字的输入。空字符串加密后应该还是空字符串,纯数字文本加密后应该原样返回。这两个用例能帮你确认边界处理没问题。我见过有人写的实现,遇到空字符串会报错,原因是在函数开头做了text[0]之类的访问,这种低级错误用边界测试一测就出来了。

7. 从凯撒往外延伸:还能玩出什么花样

7.1 ROT13:密钥固定为 13 的特例

ROT13 就是密钥为 13 的凯撒密码,它的特点是加密和解密是同一个操作,因为 13 + 13 = 26 ≡ 0。Python 标准库里的codecs模块甚至直接支持:

import codecs encoded = codecs.encode("Hello", "rot13") # 得到 "Uryyb" decoded = codecs.decode(encoded, "rot13") # 得到 "Hello"

自己实现的话,直接调用caesar_encrypt(text, 13)就行。ROT13 常被用在论坛里隐藏剧透或者谜题答案,因为它一眼看上去是乱码,但懂的人随手就能解,属于"防君子不防小人"的典型。

7.2 维吉尼亚密码:多密钥的凯撒

维吉尼亚密码可以理解为"每个字母用不同的凯撒密钥"。它用一个关键词作为密钥,关键词的每个字母决定对应位置明文字母的位移量。比如密钥是 "KEY",那么第一个字母移 K(10)位,第二个移 E(4)位,第三个移 Y(24)位,第四个又回到 K,如此循环。实现上就是在凯撒的基础上,把固定的 key 换成一个循环取值的密钥序列。理解了凯撒,维吉尼亚就是水到渠成的事。

7.3 仿射密码:把位移换成线性变换

仿射密码的加密公式是E(x) = (ax + b) mod 26,其中 a 和 b 是密钥,且 a 必须与 26 互质(否则不可逆)。凯撒密码其实是仿射密码在 a=1 时的特例。仿射密码的密钥空间比凯撒大不少(a 有 12 种选择,b 有 26 种,共 312 种),但依然扛不住暴力破解。从凯撒过渡到仿射,能让你理解"线性变换"在密码里的作用,以及为什么 a 必须和 26 互质——因为解密需要求 a 的模逆元,不互质就没有逆元。

这三个延伸方向,我建议至少把 ROT13 和维吉尼亚实现一遍。ROT13 一行代码的事,维吉尼亚能让你把凯撒的代码复用起来,体会一下"密钥序列"的概念。仿射密码稍微绕一点,涉及模逆元的计算,但对理解数论在密码学里的应用很有帮助。

写到这里,关于凯撒密码的加解密实现基本就聊透了。从最朴素的字符位移,到大小写和非字母字符的处理,再到负数取模的坑、暴力破解、模块封装和延伸方向,这套东西看起来简单,但每个环节都有值得琢磨的细节。我自己在反复写这个算法的过程中,最大的收获不是凯撒密码本身,而是对"字符编码""模运算""密钥空间"这些基础概念的直觉变得越来越清晰。如果你也在学 Python,强烈建议别只是复制粘贴一段代码跑通就完事,把上面提到的边界情况、暴力破解、性能优化都亲手试一遍,收获会大得多。

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

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

立即咨询