移位密码,也就是大家常说的恺撒密码,几乎是每个学编程的人都绕不过去的一道题。它的规则简单到一句话就能说完:把每个字母在字母表上往后挪固定的位数。但真让你从零写一个能用、好维护、还能自我验证的加解密程序,里面能踩的坑一点都不少——负数取模在不同语言里表现不同、非字母字符被误伤、大小写丢失、密钥越界、中文变乱码,这些问题我在带新人的时候几乎每年都要重复讲一遍。这篇就把这套东西从头到尾捋一遍:移位密码的数学本质是什么、字符集和密钥该怎么定规矩、Python 和 C 以及 JavaScript 三个版本怎么落地、写完怎么用测试和暴力枚举证明它是对的,最后再聊聊怎么从它自然过渡到仿射密码这类稍微强一点的古典方案。适合刚接触密码学或者字符处理的新手照着做,也适合已经工作几年、想把这些底层细节重新夯实一遍的朋友当参考。
1. 先搞清楚这个“老古董”到底在做什么
1.1 移位密码的本质:一次模26的加法
很多人初学的时候会把移位密码理解成“查表替换”,于是写代码的时候真的去建一张 26 个字符的映射表,加密查一遍、解密再建一张反向表查一遍。这么做当然能跑,但它把问题复杂化了,而且一旦你想支持大小写混合,就得维护两张表,代码立刻翻倍。移位密码真正的数学模型只有一行:
- 加密:
C = (P + K) mod 26 - 解密:
P = (C - K) mod 26
这里 P 是明文字母在字母表中的序号(a=0, b=1, …, z=25),K 是密钥,C 是密文序号。所谓“移位”,本质就是在 26 个格子的环形跑道上往前走 K 步,走到头就绕回来。这个“绕回来”的动作,就是取模运算在做的事。
你可以把它想象成一个 26 格的转盘。明文是起点,密钥是你要往前拨的格数,拨过 25 之后再拨一格会回到 0。转盘这个比喻很土,但它能帮你记住一件事:所有越界的处理,都是模运算在工作,而不是特判。新手最常见的写法是if (index + k > 25) index = index + k - 26;,这行代码在 k 小于 26 的时候勉强能用,但只要密钥是 30 或者 -5,立刻出错。用%一行搞定,还不会有边界漏洞。
理解了这一点,你就会明白为什么移位密码的密钥空间只有 25 个有效值(1 到 25),因为 K=0 等于没加密,K=26 又回到了原点,所有密钥在数学上是模 26 同余的。这个结论后面讲暴力破解的时候会直接用到——只要循环 26 次,就一定能试出正确答案,没有任何悬念。
1.2 为什么它今天还值得亲手写一遍
有人会问,这东西早就没任何安全性可言了,写它有什么意义。我的看法是,它的价值从来不在“加密强度”,而在于它是一个完美的工程练手载体。它足够小,小到你可以在一百行代码里塞进字符集处理、边界校验、接口设计、单元测试、性能优化这些真实项目里天天要面对的东西;它又足够具体,具体到你没法用“大概那样”糊弄过去,错一个字符就是错。
我在带新人的时候经常拿它当第一道题,原因有三个。第一,它能逼你想清楚“输入输出契约”——函数接收什么、返回什么、遇到不认识的字符怎么办,这是写任何工具函数之前必须先回答的问题。第二,它能暴露语言层面的细节差异,比如 C 语言的负数取模是向零取整,Python 是向负无穷取整,同一个-3 % 26在两个语言里结果完全不同,这种坑你不亲自踩一次,看文档是记不住的。第三,它天然带一个“验证”环节:加完密再解一次密,如果拿不回原文,说明你某处写错了。这种自校验的特性,让它特别适合用来训练测试意识。
再往深一层说,移位密码是理解“密钥空间”和“可破解性”最好的入口。25 个密钥意味着什么?意味着攻击者最多试 25 次就能拿到明文,一次尝试的代价是遍历一遍字符串。这个数量级小到可以忽略不计,于是你就直观地感受到:安全性不是“别人看不懂”,而是“别人要花多少代价才能看懂”。这个认知,比背一百条安全原则都管用。
1.3 拿张纸手工推一遍,比看十页公式管用
在敲代码之前,我强烈建议你拿张纸手工推两三组。不是为了学会,是为了建立一个“预期结果”,后面测试的时候你才有基准去比对。比如密钥取 3:
| 明文 | 逐字母推演 | 密文 |
|---|---|---|
| hello | h→k, e→h, l→o, l→o, o→r | khoor |
| world | w→z, o→r, r→u, l→o, d→g | zruog |
| xyz | x→a(绕回), y→b, z→c | abc |
| Attack | A→H, t→a, t→a, a→h, c→j, k→r | Haahjr |
注意xyz那一行,这是最典型的绕回场景。如果你写的程序在xyz上崩了或者输出了奇怪的东西,八成是取模写错了。再看Attack,大写 A 变成了大写 H,小写字母保持小写,这就是所谓的“保留大小写形态”——绝大多数场景下我们都希望密文看起来还是正常文本的形状,而不是全部变成大写或者全部变乱。
还有一个容易被忽略的点:空格和标点怎么处理。传统做法是原样保留,不参与移位。理由是如果把它们也编码进去,一来没有对应的“字母序号”,二来会让密文的可读性彻底崩掉。所以你的程序里必须有一个分支:是字母才处理,不是字母就原样输出。这个判断看起来简单,但它是后面所有 bug 的高发区,第 5 章会专门讲。
2. 动手之前先定规矩:字符集、密钥与接口
2.1 字符集范围决定了你后面所有的坑
写这类工具函数,第一个要拍板的不是算法,而是“我只处理哪些字符”。这个决定会像地基一样影响后面每一行代码。常见的三种选择差别很大:
| 方案 | 处理范围 | 优点 | 代价 |
|---|---|---|---|
| 只处理小写 a-z | 26 个字符 | 实现最简单,验证最快 | 遇到大写直接跳过或报错,实用性差 |
| 处理大小写 a-z / A-Z | 52 个字符 | 保留形态,输出自然 | 需要分别做两套偏移基准 |
| 处理全部 Unicode 字母 | 数百个字符 | 通用性强 | 不同语言对“字母”的定义不一致,极易出错 |
我的建议是选第二种,也就是同时支持 ASCII 小写和大写,其余字符原样透传。理由很实在:真实文本里大写字母占比不低(句首、专有名词、缩写),只支持小写会让工具变得没法直接用;而支持全 Unicode 又会掉进一个大坑——中文、日文、带重音的拉丁字母在不同语言里的“字母”判断规则完全不同,Python 的isalpha()认为“中”是字母,C 的isalpha()在默认 locale 下只认 ASCII,JavaScript 的正则\w又不包括中文。你要真去统一这些语义,工作量会翻好几倍,而且跟这个练习的初衷跑偏了。
所以最终的字符集契约可以写成一句话:a 到 z 和 A 到 Z 参与移位,其他所有字符原样输出。这句话看起来平平无奇,但它至少帮你挡掉了三个隐患:不用纠结中文怎么处理、不用担心标点被改、不用考虑空格的序号。后面所有代码都围绕这条契约展开。
2.2 密钥归一化:负数和超过26怎么办
密钥这块,很多人的第一反应是“加个校验,不合法就报错”。我不太赞成这个做法,因为对使用者来说,密钥传 -3 和传 23 在语义上其实是同一件事——都是往前拨 23 格(也就是往后拨 3 格)。直接把不合法的值拒掉,用起来会很别扭。
更好的做法是入口处做一次归一化,把任意整数映射到 0 到 25 这个区间:
k = key % 26 # Python 里 -3 % 26 == 23,直接就是对的C 和 Java 就没这么省心了,因为它们的%是“向零取整”,-3 % 26结果是-3,不是23。所以在这两个语言里必须写成:
int k = ((key % 26) + 26) % 26;先取模把绝对值压到 25 以内,加上 26 保证是正数,再取一次模把可能超过 25 的结果拉回来。这三步缺一不可,我第一次写的时候只写了前两步,结果 key 是 26 的倍数时算出了 26,数组越界,排查了半天。
归一化之后再考虑解密。解密其实就是往反方向走,有两种等价写法:一是k_dec = (26 - k) % 26,二是k_dec = (-k) % 26。我偏好第二种,因为它在 Python 里天然正确,在 C 里加一层+26也能统一,而且从语义上讲“解密就是负的移位”更直观。注意当 k 为 0 时,(26 - 0) % 26 = 0,(-0) % 26 = 0,两种写法都不会出问题,这点要确认一下,因为有些实现会漏掉这个分支导致密钥 0 解密时报错。
2.3 加解密共用内核:一个函数两种模式
接口设计上,我看到过两种风格。一种是拆成两个函数encrypt(text, key)和decrypt(text, key),另一种是合成一个shift_cipher(text, key, mode)。前者语义清晰,后者代码复用度高。我的选择是内核只写一份,对外暴露两个薄封装,这样两边的好处都能拿到。
具体做法是:核心函数接收一个已经归一化并且已经确定方向的shift值,只负责“把每个字母往前推 shift 格”这一件事;encrypt传入k,decrypt传入-k,封装层负责归一化和方向转换。这样核心逻辑只有一份,不可能出现“加密对了但解密写错”的情况,因为压根就没有第二份实现。
这个设计还有个隐性好处:将来你要加“暴力枚举所有密钥”的功能,直接复用核心函数循环 26 次就行,不用再写第三份逻辑。我在第 4 章会用到这一点。
顺便说一句参数顺序和默认值。mode我一般给个默认值"encrypt",因为绝大多数调用场景是加密,这样调用方可以少写一个参数。但我不建议把key设默认值 3,虽然历史上流传最广的就是移位 3,可一旦有了默认值,调用方很容易忘记传密钥,最后排查半天发现用的是默认值——这种坑我踩过不止一次。
3. 三个版本的完整实现
3.1 Python版本:从能跑到好维护
先给一个最朴素能跑的版本,再讲怎么改。朴素版本大概长这样:遍历字符串,判断是不是字母,是的话算出新字符,拼到结果里。能跑,但有两个问题:字符串用+=拼接在长文本上性能很差,以及大小写要写两遍几乎一样的逻辑。
改进后的版本:
def _shift_core(text: str, shift: int) -> str: """核心:shift 已归一化到 0-25,字母按此值前移,其余透传""" out = [] for ch in text: code = ord(ch) if 97 <= code <= 122: # a-z out.append(chr((code - 97 + shift) % 26 + 97)) elif 65 <= code <= 90: # A-Z out.append(chr((code - 65 + shift) % 26 + 65)) else: out.append(ch) return ''.join(out) def shift_cipher(text: str, key: int, mode: str = "encrypt") -> str: k = key % 26 # Python 的取模天然返回非负 if mode == "decrypt": k = (-k) % 26 return _shift_core(text, k)三个细节值得说。第一个是用ord直接判数值区间,而不是ch.isalpha()。原因是isalpha()会把中文、希腊字母、带重音的拉丁字母全判成 True,然后你后面的(code - 97) % 26就会算出一个完全无意义的位置,把好好的中文变成乱码。用数值区间判断,等于把字符集硬编码在代码里,虽然不够优雅,但绝对安全。
第二个是用list收集再用join合并,而不是result += ch。Python 的字符串是不可变对象,每次+=都会新建一个对象并拷贝一遍,文本长度上去之后是平方级的时间复杂度。这个差别在几十个字符上感觉不出来,但你要是拿它去处理一篇几千字的文章,差距会非常明显。
第三个是_shift_core前面加了下划线,表示内部函数,外部别直接调。这是个小习惯,但能让接口边界变清晰——外部只需要知道shift_cipher一个入口,不用操心 shift 有没有归一化。
3.2 C语言版本:数组、指针与负数取模
C 版本的价值在于它逼你直面内存和类型。字符串在 C 里就是char数组,加解密可以直接原地修改,省掉一次分配。麻烦的地方在于三处:字符必须按unsigned char处理、负数取模要手动修正、字符串长度要自己管。
#include <stdio.h> #include <string.h> void shift_core(char *s, int shift) { for (size_t i = 0; s[i] != '\0'; i++) { unsigned char c = (unsigned char)s[i]; if (c >= 'a' && c <= 'z') { s[i] = (char)((c - 'a' + shift) % 26 + 'a'); } else if (c >= 'A' && c <= 'Z') { s[i] = (char)((c - 'A' + shift) % 26 + 'A'); } /* 其他字符不动 */ } } int caesar(char *s, int key, int decrypt) { int k = ((key % 26) + 26) % 26; if (decrypt) k = (26 - k) % 26; shift_core(s, k); return 0; }为什么要转成unsigned char再算?因为char在多数平台上是有符号的,当文本里出现非 ASCII 字节(比如 UTF-8 编码的中文,单个字节可能是 0xE4 这种大于 127 的值),有符号char会把它解释成负数,c >= 'a'这个比较就会出现意料之外的结果。虽然我们的判断条件本身能挡住它,但转成无符号是更稳妥的写法,也符合标准库函数(如isdigit)的参数约定。
原地修改带来一个副作用:调用方如果还想要原始字符串,得自己先拷贝一份。这个坑很常见,尤其是把密文和明文放在一起对比的时候,解密之后发现明文和密文一模一样,慌了半天,其实是同一块内存被改了两次。所以我在实际项目里更倾向于提供一个返回新缓冲区的版本,或者至少在函数注释里用大字标明“原地修改”。
还值得一提的是(26 - k) % 26这个写法。当 k 为 0 时,(26 - 0) % 26 = 0,正确;当 k 为 3 时,结果是 23,也就是往前 3 格等于往后 23 格,正确。如果偷懒写成26 - k,k 为 0 的时候会得到 26,移位 26 等于没移,结果碰巧还是对的,但逻辑上已经越界了,将来一旦有人改动取模的位置就会崩。这种“碰巧正确”的代码最难排查,能一次写对就别留隐患。
3.3 JavaScript版本:Unicode与遍历方式的差异
JS 这边有两个专属的注意点。一个是遍历方式,for (let i = 0; i < s.length; i++)是按下标访问 UTF-16 码元,遇到 emoji 这类需要用两个码元表示的字符会被拆成两半;另一个是字符串拼接的性能,现代引擎对+=做了很多优化,但用数组push再join仍然更稳。
function shiftCore(text, shift) { const out = []; for (const ch of text) { // 按码点迭代,不会被拆开 const code = ch.codePointAt(0); if (code >= 97 && code <= 122) { out.push(String.fromCharCode((code - 97 + shift) % 26 + 97)); } else if (code >= 65 && code <= 90) { out.push(String.fromCharCode((code - 65 + shift) % 26 + 65)); } else { out.push(ch); } } return out.join(''); } function shiftCipher(text, key, mode = 'encrypt') { let k = ((key % 26) + 26) % 26; if (mode === 'decrypt') k = (26 - k) % 26; return shiftCore(text, k); }for...of相比传统的下标循环,在字符处理这块优势明显:它按 Unicode 码点迭代,emoji 和生僻字都算一个完整字符。虽然我们的算法只处理 ASCII 字母,其他字符原样透传,所以拆不拆其实不影响结果——但这是个好习惯,将来你把这段逻辑复制到别的工具函数里,它能帮你省掉一次诡异的 bug。
还有一个隐藏细节:68 % 26在 JS 里是 16,负数则是-68 % 26 = -16,跟 C 一样是向零取整。所以归一化那行((key % 26) + 26) % 26在 JS 里同样不能省。我见过有人从 Python 抄代码到 JS,把归一化简化成key % 26,测试的时候用的都是正数密钥,一切正常,上线之后有人传了个负数,密文直接错乱成乱码,这种问题排查起来特别费劲。
3.4 三版对照与验证
三个版本写完之后,最该做的一件事是拿同一组用例交叉验证。同样输入"Attack at dawn!"、密钥 7,三个版本必须输出完全一致的"Haahjr ha khdu!"。只要有一个不一致,就说明某处的取模或者字符集判断出了问题。
| 用例 | 输入 | 密钥 | 期望输出 | 考察点 |
|---|---|---|---|---|
| 基础移位 | hello | 3 | khoor | 常规路径 |
| 大小写混合 | HeLLo | 3 | KhOOr | 形态保留 |
| 绕回 | xyz | 3 | abc | 模运算 |
| 密钥为零 | test | 0 | test | 边界值 |
| 反向密钥 | hello | -3 | ebiil | 负数归一化 |
| 超范围密钥 | hello | 29 | khoor | 等价于 3 |
| 非字母透传 | a-b c! | 1 | b-c d! | 字符集分支 |
| 空字符串 | (空) | 5 | (空) | 空输入 |
这张表我建议你直接抄下来当回归测试。尤其是“反向密钥”和“超范围密钥”这两行,它们是区分“能跑”和“写对了”的分水岭。很多实现只测正数密钥、只测纯小写单词,跑起来一切正常,一旦换个输入就露馅。
还有个细节:绕回测试一定要用xyz这种末尾的字母,别用abc,因为abc往后移不会触发绕回,测了等于没测。同理,测试字符集透传要用真实句子,比如带空格、逗号、感叹号的,别用单个字母。
4. 验证与破译:让程序自己证明它是对的
4.1 边界用例怎么设计
写测试这件事,新手最容易犯的毛病是“只测正常路径”。正常路径当然要测,但它只能证明你的程序在理想状态下能跑,证明不了它是对的。真正有价值的测试,是围绕边界和异常设计的。对着移位密码这个场景,我一般会从四个维度去切:
第一个维度是密钥取值,要覆盖 0、1、25、26、27、-1、-26 这些点。0 和 26 是等价类,验证归一化;25 是最大有效值,验证取模没写错;负数验证符号处理。第二个维度是字符类型,纯小写、纯大写、混合、带标点、带数字、带空格、空字符串、纯非字母(比如全是符号)。第三个维度是长度,单个字符、短串、长串(几百字以上,顺便验性能)。第四个维度是往返一致性——加密后再解密,必须拿回原文,这一条对所有输入都必须成立。
往返一致性这条特别值得单独拎出来。它的逻辑是:decrypt(encrypt(text, k), k) == text,对任意文本、任意密钥都成立。你可以把它写成一个断言,然后跑几百组随机数据:
import random, string def test_roundtrip(): for _ in range(500): k = random.randint(-100, 100) n = random.randint(0, 80) pool = string.ascii_letters + string.digits + " ,.!?-\n" text = ''.join(random.choice(pool) for _ in range(n)) enc = shift_cipher(text, k, "encrypt") dec = shift_cipher(enc, k, "decrypt") assert dec == text, f"失败: key={k}, text={text!r}"这种随机测试的杀伤力比手写十个用例还大,因为它能覆盖到你根本想不到的组合。我第一次跑这个的时候,五百组里挂了七组,全是密钥为 -26 的倍数时出的问题——归一化之后 k 变成了 0,但方向转换那一步漏了处理,导致解密变成了加密。人工写用例,我大概率想不起来测 -26 这个值。
4.2 26次暴力枚举与自动评分
移位密码的密钥空间只有 25 个有效值,这意味着破解它不需要任何技巧,把所有可能试一遍就行。这个特性让它可以用来演示“穷举攻击”的基本形态:先定义搜索空间,再定义评分函数,最后取分数最高的那个。
搜索部分很好写:
def brute_force(cipher_text): results = [] for k in range(26): plain = shift_cipher(cipher_text, k, "decrypt") results.append((score(plain), k, plain)) results.sort(reverse=True) return results真正需要动脑子的是评分函数score。最简陋的版本是“统计结果里出现了多少个常见英文单词”,但这依赖词表,而且对短文本不友好。稍微靠谱一点的做法是字母频率匹配:正常英文文本里 e 出现的频率大概是 12.7%,t 是 9.1%,a 是 8.2%,如果某个候选明文的字母分布跟这个先验分布最接近,它就是最可能的答案。
这里可以直接用卡方检验来量化“接近程度”:
import string FREQ = { 'a': 0.0817, 'b': 0.0149, 'c': 0.0278, 'd': 0.0425, 'e': 0.1270, 'f': 0.0223, 'g': 0.0202, 'h': 0.0609, 'i': 0.0697, 'j': 0.0015, 'k': 0.0077, 'l': 0.0403, 'm': 0.0241, 'n': 0.0675, 'o': 0.0751, 'p': 0.0193, 'q': 0.0010, 'r': 0.0599, 's': 0.0633, 't': 0.0906, 'u': 0.0276, 'v': 0.0098, 'w': 0.0236, 'x': 0.0015, 'y': 0.0197, 'z': 0.0007, } def chi_square(text): letters = [c for c in text.lower() if 'a' <= c <= 'z'] n = len(letters) if n == 0: return float('inf') count = {c: 0 for c in string.ascii_lowercase} for c in letters: count[c] += 1 total = 0.0 for c, p in FREQ.items(): expected = n * p total += (count[c] - expected) ** 2 / expected return total卡方的值越小,说明实际分布和理论分布越贴合,也就越可能是正确明文。所以排序的时候要按分数升序,取第一个。这个思路我在短文本上实测过,二十来个字母的密文基本能一次命中;但如果密文只有五六个字母,频率统计完全没意义,这时候只能靠人工看候选列表,或者结合词表兜底。
这里有个实用的补充:暴力破解的结果不要只输出第一名,要输出前五名让用户自己挑。因为频率分析在短文本上并不可靠,有时候正确答案排第二。把候选列表打出来,人眼扫一下就能判断,这比让程序猜要靠谱得多。
4.3 频率分析:从“能解”到“能自动解”
上一节的卡方方法,本质上是频率分析的一个自动化变种。理解它的原理比会调 API 重要,所以我多讲两句。英文里字母频率的不均衡,是单表替换密码(包括移位密码)的致命弱点:不管你怎么替换,e 还是那个出现最多的字母,t 还是第二多,这个统计规律不会变。所以攻击者只要统计密文里的字母频率,找出出现最多的那个,它大概率就是 e 的化身,两者的位置差就是密钥。
举个具体的例子。假设密文里出现频率最高的是 h,而我们知道明文里最高频的通常是 e,那么 h 对应的明文大概率是 e,也就是从 e 到 h 移动了 3 位,密钥就是 3。这个推理过程不需要任何计算,但它是整个古典密码分析的基础。
当然,实际文本不会这么理想。文本太短、内容太特殊(比如全是专有名词、或者一段代码),频率分布就会失真。所以工程上我会做三层兜底:第一层用卡方挑出前三名;第二层用一个小词表检查候选明文里有没有常见词(the、and、that、you 这类);第三层把候选全部列出来交给人工。三层下来,基本不会出现解不出来的情况。
还有个小技巧值得一提:在统计频率之前,先把密文里的非字母字符过滤掉,并且统一转成小写。这一步看着不起眼,但如果不做,大写字母会被当成另一类,统计结果直接失真。我见过一个实现,统计的时候忘了转小写,结果所有大写字母都被算进了“其他”,二十六个桶里只有小写那部分有数据,频率分析彻底跑偏。
5. 踩坑实录与排查速查表
5.1 结果少了一个字母、大小写变了
最常见的两类 bug,一类是“输出比输入短”,一类是“大小写全丢了”。前者通常是分支写漏了,比如if判断了小写、else if判断了大写、else忘了把原字符加进去,于是标点和空格全被吃掉。这种 bug 在纯字母输入下完全看不出来,一旦输入真实句子立刻暴露。排查方法很直接:拿一个带标点的短句跑一遍,逐字符对比输入输出长度,长度不一致就顺着分支找。
后者多半是“统一转小写再处理”造成的。有些实现为了简化逻辑,先text.lower()再加密,结果是密文里一个大小写都不剩。这个做法在某些场景下可以接受(比如只关心字母序列的分析任务),但作为通用工具就不合格了。正确做法是在处理每个字符时就地判断大小写,分别用 65 和 97 作为基准,这样形态天然保留。
还有一种更隐蔽的情况:输出里字母数量对了,但位置错了一位。这通常是取模写成了“先加后减”,比如(code - 97 + shift - 26) % 26 + 97,多减了一个 26。当 shift 比较大时,这个多余的操作会让结果偏移。排查的时候盯着(x + shift) % 26这一行看就行,中间不该有任何额外的加减。
5.2 负数取模在不同语言里的表现
这个坑值得单独列一节,因为它的表现太有迷惑性了。同一段逻辑,Python 写出来是对的,翻译成 C 或 Java 就崩,而且崩得不明显——只有负数密钥才出问题,正数测试全过。
| 语言 | -3 % 26结果 | 说明 |
|---|---|---|
| Python | 23 | 结果符号跟随除数,天然非负 |
| C / C++ | -3 | 向零取整,符号跟随被除数 |
| Java | -3 | 与 C 一致 |
| JavaScript | -3 | 与 C 一致 |
| Go | -3 | 与 C 一致 |
从表里能看出来,只有 Python 是“特例”。所以从 Python 往其他语言移植的时候,归一化那行必须补上+26修正。反过来,从 C 往 Python 移植时,那行修正留着也无害,只是多余。
我一般的写法是统一用这个模式,不管什么语言都这么写:
int norm(int key) { int k = key % 26; return k < 0 ? k + 26 : k; }比((key % 26) + 26) % 26少一次取模,可读性也更好。两种写法结果一样,看个人习惯。
5.3 中文与其他字符被误伤
这是我觉得最值得警惕的一类问题,因为它不一定报错,而是悄悄把数据改坏。典型场景是这样的:程序里用了isalpha()判断是不是字母,然后对“是字母”的字符统一做(code - 97) % 26。当输入里出现中文,isalpha()返回 true,于是“中”这个字符的码点被拿去减 97,再模 26,算出一个完全随机的偏移量,最后拼回去变成了另一个毫不相干的字符。
这种损坏有个特点:单看输出,它还是一串“看起来像字”的东西,不仔细看根本发现不了。等你发现的时候,原始数据可能已经被覆盖了(如果用的是原地修改版本)。
防这个的办法很简单,也是我反复强调的那条:用明确的数值区间判断,不要用语言提供的“是不是字母”判断。97 <= code <= 122和65 <= code <= 90这两个条件,把范围锁死在 ASCII 之内,中文、日文、emoji 全部走“原样透传”分支,绝无可能被误伤。
下面这张表可以贴在显示器旁边,出问题的时候按顺序过一遍:
| 现象 | 最可能的原因 | 快速验证 |
|---|---|---|
| 输出比输入短 | 某个分支漏了透传 | 打印输入输出长度 |
| 大小写全没了 | 入口处调了转小写 | 搜索代码里的.lower() |
| 负数密钥结果乱 | 没做归一化修正 | 直接跑-3 % 26看结果 |
| 中文变乱码 | 用了isalpha判断 | 换成数值区间试试 |
| 密钥 0 解密报错 | 反向换算漏了 0 分支 | 单独跑一次密钥 0 |
| 长文本卡顿 | 字符串用+=拼接 | 换成数组加join |
| 密文和明文一样 | 原地修改被调了两次 | 检查是否复用了同一块内存 |
6. 把玩具做成小工具:命令行与扩展
6.1 命令行参数设计
一个函数躺在文件里,用起来总归不方便。花二十分钟包一层命令行,这个东西的实用性会翻好几倍。设计上我建议支持四种用法:直接传文本、从标准输入读、指定密钥、开启暴力破解。
import argparse, sys def main(): ap = argparse.ArgumentParser(description="简单移位密码工具") ap.add_argument("text", nargs="?", help="待处理文本,省略则从标准输入读取") ap.add_argument("-k", "--key", type=int, default=3, help="密钥,可为负数") ap.add_argument("-d", "--decrypt", action="store_true", help="解密模式") ap.add_argument("-b", "--brute", action="store_true", help="暴力枚举所有密钥") args = ap.parse_args() text = args.text if args.text is not None else sys.stdin.read().rstrip("\n") if args.brute: for score, k, plain in sorted( ((chi_square(shift_cipher(text, i, "decrypt")), i, shift_cipher(text, i, "decrypt")) for i in range(26)) ): print(f"[key={k:2d}] {plain}") return mode = "decrypt" if args.decrypt else "encrypt" print(shift_cipher(text, args.key, mode)) if __name__ == "__main__": main()这个工具有个细节值得说:nargs="?"让位置参数变成可选,配合后面的标准输入读取,就能支持管道用法。比如echo "hello" | python caesar.py -k 3和python caesar.py "hello" -k 3都能工作。前者在处理长文本时特别有用,因为不用把整段文字塞进命令行参数里(还会被 shell 转义规则折腾)。
暴力破解的输出格式我也改过好几版。最早的版本只打印第一名,后来发现短文本经常不准,就改成打印全部 26 行,让人自己扫。再后来觉得太吵,又改成只打印前 5 名加一行提示。这种小地方的反复,其实就是工具好不好用的分水岭。
6.2 从移位密码到仿射密码
写完移位密码,顺着往下走一步就是仿射密码。它把“加一个常数”升级成“乘一个常数再加一个常数”:
- 加密:
C = (a * P + b) mod 26 - 解密:
P = a⁻¹ * (C - b) mod 26
这里的 a 不能随便取,必须满足gcd(a, 26) = 1,否则乘法不可逆,解密就无从谈起。26 的质因数是 2 和 13,所以只要 a 不是偶数、也不是 13 的倍数就行。符合条件的 a 一共 12 个,配合 26 个 b,密钥空间是 312。
| a | 模 26 逆元 | 验证 |
|---|---|---|
| 1 | 1 | 1×1 = 1 |
| 3 | 9 | 3×9 = 27 ≡ 1 |
| 5 | 21 | 5×21 = 105 ≡ 1 |
| 7 | 15 | 7×15 = 105 ≡ 1 |
| 9 | 3 | 9×3 = 27 ≡ 1 |
| 11 | 19 | 11×19 = 209 ≡ 1 |
| 15 | 7 | 15×7 = 105 ≡ 1 |
| 17 | 23 | 17×23 = 391 ≡ 1 |
| 19 | 11 | 19×11 = 209 ≡ 1 |
| 21 | 5 | 21×5 = 105 ≡ 1 |
| 23 | 17 | 23×17 = 391 ≡ 1 |
| 25 | 25 | 25×25 = 625 ≡ 1 |
这张逆元表我建议直接背下来,或者至少知道怎么推。推法很简单:从 1 开始逐个试,找到a * x % 26 == 1的最小正整数 x 就是逆元。因为只有 26 个数,手算最多试 25 次,写个循环也就一瞬间。
仿射密码的破解思路和移位密码一脉相承:密钥空间只有 312,直接穷举。评分函数完全复用上一节的卡方,一行代码都不用改。这就是我为什么说移位密码是个好的起点——你把破解框架搭好之后,换成仿射密码只需要改加密函数,其他部分原封不动。
6.3 什么时候该换更强的算法
最后聊一个容易被忽略的问题:这段代码能用在什么地方,不能用在什么地方。移位密码的密钥空间是 25,仿射是 312,维吉尼亚(多表替换)在密钥足够长的情况下会大不少,但它们有一个共同点——都扛不住现代计算能力的穷举。25 次尝试对计算机来说是瞬间的事,312 次也一样。
所以这类算法的正确定位是:教学工具、逻辑练习、字符处理的基本功训练,以及一些非安全场景下的轻度混淆(比如给一段文本做个简单的变形,防止被随手一眼看穿)。凡是涉及真实敏感信息的场景,不管数据量多小,都该用标准库里的对称加密方案,而不是自己手搓一个古典密码。
我在实际项目里的做法是:把它放在工具库里当“字符串变形函数”用,名字都不叫 encrypt,叫rotate_letters,从命名上就避免误导。这个习惯挺重要的,因为代码的命名会暗示它的用途,一旦名字里带了“加密”,后来接手的人就可能真的把它当加密用。
顺带说一句性能上的体会。这类字符级处理的函数,瓶颈几乎永远在“每个字符都做一次分支判断”上。如果你的文本量特别大(比如日志处理场景,一次几百万字符),可以考虑先把字符串按字符集切分,只对纯字母段做批量处理;但对绝大多数日常用途来说,这点优化完全没必要,可读性比那点性能重要得多。我在这上面折腾过一次,为了省 20% 的时间把代码写成了一团乱麻,三个月后自己都看不懂了,最后还是改回来了。
最后分享一个我自己的习惯:每写完一个这样的工具函数,我都会拿一段真实的中英混排文本跑一遍,然后把输入和输出并排贴在一起看。这个动作花不到一分钟,但能立刻发现“某个字符被吃掉”“大小写变了”“中文变乱码”这三类问题。比起事后排查,这个一分钟的投入实在太划算了。