1. 第一眼以为是乱码,第二眼才看出门道
1.1 先做最笨的统计,再谈聪明的主意
前几天有人在技术群里丢了一串数字:1111111155555555599999999999,后面没带任何解释。第一反应是"手滑了吧",第二反应是"这不会是从哪复制的验证码吧"。但我多看了两秒,发现事情不简单——这串 27 位的数字根本不是随机噪声,而是三个整整齐齐的连续重复段:8 个 1、9 个 5、10 个 9。
处理任何来路不明的字符串,我的习惯都是先做最笨的统计:数一数每个字符出现几次,有没有连续重复的片段,这些片段按什么顺序排列。先不急着猜含义,把"它到底长什么样"这件事彻底搞清楚,后面才不会跑偏。拿这串数字来说,统计结果一眼就能看明白:
| 数字 | 出现次数 | 连续成段的长度 | 占比 |
|---|---|---|---|
| 1 | 8 | 8 | 29.6% |
| 5 | 9 | 9 | 33.3% |
| 9 | 10 | 10 | 37.0% |
三个数字的"出现次数"和"单独成段的长度"完全一致,这说明整串字符串的分段结构和总频次是一回事。真正的随机数很难长成这样:连续 8 个 1 的随机概率是 10⁻⁸ 量级,再叠上连续 9 个 5、连续 10 个 9,概率低到可以忽略。所以基本可以断定,这串数字是被人为构造出来的,而不是键盘误碰或者随手乱按。
1.2 分段的顺序,也藏着信息
光有重复还不够,顺序同样重要。三段依次是 1、5、9,而不是 9、5、1 或者 5、1、9。把每个重复段看成整体,整个字符串就是11111111 | 555555555 | 9999999999三段接龙:段内是同一个数字,段与段之间的数字按某种规律切换。
这种"先重复、后切换、再重复"的结构,在信息论里叫游程结构(run-length structure),也是**游程编码(RLE)**的天然产物。一段连续相同的字符就是一个 run,而这串 27 位数字可以被压缩成一个非常短的描述:"1 重复 8 次,5 重复 9 次,9 重复 10 次"。注意,连"重复次数"本身都像是有规律的,我下一章专门拆这一点。
2. 两层等差数列:1→5→9 和 8→9→10
2.1 数字部分:公差为 4 的等差数列
把每段的代表数字拎出来:1、5、9。相邻两项之差都是 4,这是一个干净的等差数列。为什么是 1、5、9,而不是 1、5、8?因为等差数列意味着"匀速推进":5 刚好是 1 和 9 的平均数,9 又是 5+4。这种设计感,在纯手工打出来的字符串里几乎不可能出现。
人眼对这种"等差推进"其实非常敏感。心理学上有个现象叫顺势感知:大脑会自动补全规律,看到 1、5 就预期 9,看到 8、9 就预期 10。这串数字能被一眼锁定,恰恰因为它迎合了人对规律的预期。顺着这个规律继续推,下一段的代表数字应该是 13(9+4)。这里冒出一个很有意思的问题:1、5、9 都是单个字符,而 13 是两位数。如果继续按"每段重复 11 次"来扩展,写出来就不是"13 个 13",而是一串1313...的交替序列,原有的"连续段"视觉结构瞬间就破了。也就是说,这套规律在个位数范围内是自洽的,跨进两位数之后就变得不再好看。做编码规则设计的人,对这种边界条件应该格外敏感。
2.2 段长部分:公差为 1 的等差数列
再看每段的长度:8、9、10。同样是个等差数列,公差为 1。两套等差数列叠在一起,整串数字就可以用一句话描述:"以 1 开头,每次数字加 4、重复次数加 1,连写三段。"
这句话才是这串数字真正的"源码",27 位可见字符串只是它的一次实例化。工程上这叫模板化生成:只要给定规则,程序就能无限造出类似的数据。写出来也就是一行 Python:
parts = [] d, n = 1, 8 for _ in range(3): parts.append(str(d) * n) d += 4 n += 1 print(''.join(parts)) # 1111111155555555599999999999这里有个值得记住的思维切换:看到重复结构,先问"生成规则是什么",而不是"这段话是什么意思"。很多协议字段、序列号、优惠码,本质上都是"规则 + 参数"的产物,解开了规则,数据就不再神秘。
2.3 顺着规律外推,会得到什么
按规则继续推,第四段应该是"13 重复 11 次"。如果把 13 当成一个符号重复 11 次,输出是1313131313131313131313(11 组,共 22 位),拼上原来的 27 位,整串变成11111111555555555999999999991313131313131313131313。
这个外推实验说明两件事。第一,"识别规律"和"用规律生成数据"是同一枚硬币的两面:只要找到了生成规则,后续内容就完全可预测,这在数据解压、协议字段解析、测试数据构造里都是核心思想。第二,任何规则都有适用范围,跨过个位数边界后,原来的漂亮结构会退化。这也是为什么校验位算法、编码协议设计时都会严格限制位数和进制——规则越紧,结构越稳。
3. 这些重复数字在数学上有什么脾气
3.1 身世:repunit 与 repdigit
全部由同一个数字组成的整数,数学上叫repdigit(repeated digit 的合成词),其中全由 1 组成的又叫repunit。11111111 就是第 8 个 repunit,通常记作 R₈,通项公式是 Rₙ = (10ⁿ - 1) / 9。验证一下:10⁸ - 1 = 99999999,除以 9 正好是 11111111。
我们这串数字的三段,分别是 1×R₈、5×R₉、9×R₁₀,相当于一个"repunit 家族"的缩放版。repunit 之所以被反复研究,是因为它们身上背着大量整除规律,而整除特性恰恰是判断一个数字"有没有被精心构造"的快速试纸。
3.2 整除脾气速查:谁和这三段"合得来"
判断一个数能不能被 11 整除,有个经典技巧:从右往左,奇数位数字之和减去偶数位数字之和,差是 11 的倍数则可整除。对 8 位的 11111111,1-1+1-1+1-1+1-1 = 0,0 是 11 的倍数,所以能被 11 整除——实际一算,11111111 ÷ 11 = 1010101,干干净净。
再看 555555555。它是 9 位数,奇数位比偶数位多一个 5,交替和是 5,所以不能被 11 整除。但它有另外两个明显特征:以 5 结尾,能被 5 整除;数位和是 9×5 = 45,是 9 的倍数,所以能被 9 整除。555555555 ÷ 9 = 61728395,一样是整除。
最后看 9999999999。10 位全 9 数,交替和是 0,能被 11 整除:9999999999 ÷ 11 = 909090909。同时数位和 90 是 9 的倍数,也能被 9 整除:9999999999 ÷ 9 = 1111111111。把三段的整除属性汇总一下:
| 数字 | 长度 | 数位和 | 交替和 | 能整除的数 |
|---|---|---|---|---|
| 11111111 | 8 | 8 | 0 | 11 |
| 555555555 | 9 | 45 | 5 | 5、9 |
| 9999999999 | 10 | 90 | 0 | 9、11 |
这里还藏着一个一般规律:10ⁿ - 1(也就是 n 个 9)必定被 9 整除;n 为偶数时,因为交替和恰好是 0,还必定被 11 整除。三段里,两段撞上 11,两段撞上 9。与其说是巧合,不如说是"长度和数字都被等差数列约束"之后,自然长出来的属性。
3.3 整串 27 位,反而"不完美"了
把三段拼回一整串,27 个数位之和是 8×1 + 9×5 + 10×9 = 143。143 = 11×13,而它的数位和 1+4+3=8,说明整串既不能被 3 整除,也不能被 9 整除。用 Python 验证也就是一句话:
s = "1111111155555555599999999999" total = sum(int(c) for c in s) print(total, total % 3, total % 9) # 143 2 8这个结果很有意思:三段各自都有漂亮的整除属性,合并成一个整数之后反而不完美了。它提醒我,分析数据时分段统计和总体统计要分开做,局部的规律未必能简单叠加成更大的规律。
4. 用代码解剖:正则、groupby 与规律校验
4.1 正则一行,拿下所有连续段
工程上提取这类连续段,最常用的是正则。核心表达式是(\d)\1*:\d匹配一个数字,括号把它捕获成组 1,\1反向引用组 1,*表示贪婪匹配后面所有相同字符。注意要用finditer而不是findall——findall在有捕获组时默认返回的是分组内容,而不是整个匹配:
import re s = "1111111155555555599999999999" runs = [m.group(0) for m in re.finditer(r"(\d)\1*", s)] print(runs) # ['11111111', '555555555', '9999999999']这个写法在处理日志、解析协议数据时非常常用。比如从混合文本里提取所有"被重复的字符段",或者判断一段数据里有没有高重复区域,一行就能搞定。我自己在排查"某个字段是不是被错误地填充成了同一个值"这类问题时,也常拿它做第一道筛子。
4.2 groupby 方案:不背正则也能拆
如果对反向引用不熟,标准库itertools.groupby是更直白的方案。它的语义就是"把相邻且相等的元素合并成一组":
from itertools import groupby s = "1111111155555555599999999999" runs = [''.join(g) for _, g in groupby(s)] print(runs) # ['11111111', '555555555', '9999999999']groupby返回 (key, iterator) 对,key 是当前组的代表元素,iterator 是该组里的连续元素列表,用''.join(g)拼回字符串即可。两种方案怎么选?我的经验是:正则适合"从大文本里抓特定模式",groupby 适合"把整个字符串按相邻重复分段",可读性更好,也不容易踩捕获组的坑。处理测试数据时我绝大多数时候写 groupby,因为几乎不可能写错。
4.3 把"看起来是规律"变成脚本证据
前面说了那么多规律,真正严谨的做法是写代码验证,而不是拍脑袋说"这看着像等差数列"。下面的代码把分段、取代表数字、取段长、判断等差、验证整除一次做完:
from itertools import groupby s = "1111111155555555599999999999" runs = [''.join(g) for _, g in groupby(s)] digits = [int(r[0]) for r in runs] lengths = [len(r) for r in runs] def is_arith(seq): if len(seq) < 2: return True d = seq[1] - seq[0] return all(seq[i] - seq[i - 1] == d for i in range(2, len(seq))) print(runs) # ['11111111', '555555555', '9999999999'] print(digits, is_arith(digits)) # [1, 5, 9] True print(lengths, is_arith(lengths)) # [8, 9, 10] True n1, n5, n9 = (int(r) for r in runs) print(n1 % 11, n5 % 9, n9 % 11, n9 % 9) # 0 0 0 0这套"先提出假设,再转成可判定条件,最后跑脚本验证"的流程,几乎可以套用到任何不明格式的数据上。养成这个习惯之后,遇到神秘字符串就不会再靠猜。
5. 真实工程里,这种字符串到底从哪来
5.1 测试数据里的边界怪胎
长串重复字符在测试领域太常见了。接口测试、正则性能压测、存储边界测试,经常需要造"极端输入",而'1'*8 + '5'*9 + '9'*10这种模板可以几秒钟生成大量有规律、易辨认的样本。如果某个测试用例里出现这类字符串,多半不是随手打的,而是在测"长输入截断""重复字符匹配性能""序列化长度上限"这些点。
我之前排查过一个诡异问题:某模块把 27 位以内的输入当成"无害短串"跳过检查,而测试组正好塞进来一个 27 位的高重复数字串,把边界条件打穿了。后来翻数据才发现,那是用'9'*27之类的模板批量生成的压测样本。这种"长度 + 规则"双重卡边界的数字串,就是专门用来踩阈值的。
5.2 密码安全的反面教材
重复数字字符串在密码世界里名声很差。历次泄露的密码榜单里,"111111""000000""123456"这类结构常年霸榜,原因很简单:人脑容易记住"按住同一个键",攻击者的字典里也正好收录了所有"重复数字 + 简单递增"的组合。
别觉得1111111155555555599999999999有 27 位就安全。它的生成规则一旦被看穿——数字是 1、5、9 的等差数列,长度是 8、9、10 的等差数列——整串就能被预测。密码安全从来不只看长度,更看"不可预测性",也就是信息论里的熵。一个能被简单规则压缩的密码,熵低得吓人,和"短但乱"的随机密码完全不在一个安全级别。真要生成高强度密码,老老实实用密码管理器生成的随机串,比任何"有规律的超长数字"都靠谱。
5.3 培养"字符串直觉":先统计,再解码,最后下结论
最后聊点习惯层面的事情。收到来路不明的字符串,我的默认流程永远是三步:先统计(频次、连续段、长度分布),再解码(正则、分组、进制换算、校验位计算),最后才决定是丢弃还是深入解析。这套流程帮我避开了无数次"过早下结论"的坑——很多人拿到一串数字就开始猜含义,方向猜错了,后面全是白费。
个人体会,识别"设计过的结构"和"真正的随机噪声"之间的区别,是工程直觉里很值钱的一部分。这串数字如果不是刻意构造的,几乎不可能同时满足两套等差数列、三段又都撞上整除特征。但反过来,遇到真随机字符串也别硬凑规律——过拟合是分析数据时最容易犯的错。判断标准只有一个:你找到的规则,能不能稳定地预测下一段数据。能预测,才是真规律。