一串“12312132123123”扔过来,第一眼大概率觉得是乱码。但我在算法题里泡得久了,脑子里马上蹦出一个词:外观数列(Look-and-say sequence)。就是那种“读一读,说出来”的数列,比如说上一项是“1”,就看它是“一个1”,于是写“11”;再读“两个1”,写“21”;再读“一个2一个1”,写“1211”。循环往复。所以我当时的第一反应是:这个数字串是不是某个外观数列的某一项?或者它能不能被拆成一个合法外观词的片段?
为了把这个问题彻底搞清楚,我干脆写了一个命令行工具,围绕外观数列的生成、校验、反推做了一整套功能。折腾了一晚上,最后结论挺有意思:这个串不是经典外观数列(从“1”出发)里的任何一个完整项,但它确实是一个合法的“外观描述”,它描述了一个长度超过两千多字符的巨大父串。这个过程里踩了不少坑,也把外观数列的底层逻辑盘明白了。这篇文章就把整个思路、代码实现、验证过程和排坑经验完整记录下来,适合想学 Python 字符串处理、刷算法题时遇到过外观数列、或者纯粹对数字模式感兴趣的朋友参考。
1. 项目整体设计与思路拆解
1.1 核心需求解析
这个项目的需求很明确:拿到一个不确定语义的数字串,要能回答三个问题。
第一,如果按外观数列的规则继续演化,它的下一项是什么。第二,这个串本身是否“合法”——也就是说,它能否被分割成若干“数量 + 数字”的片段,并且每个片段恰好描述父串中的一个连续区间。第三,如果能分割,能不能找到某个父串,让这个数字串成为父串的外观描述。
第三点是整个项目里最有价值的部分。因为外观数列的生成方向是从父串到子串,也就是对父串做一次外观变换。而我们现在拿到的是子串,想做的是反推,这就从一个简单的字符串处理问题,变成了一个组合搜索问题。搜索的关键在于:每个片段的“数量”不一定是单个字符,它可能是多位数。比如数字串里出现“1231”,就可以理解成“123 个 1”,而不是“1 个 2,3 个 1,再补一个 1”。这么一想,反推的空间就大了很多。
1.2 最终交付的东西
最终我做了一个 Python 工具,主要包含三个核心函数。
第一个函数next_term(s),输入任意一个数字串,输出它按外观规则演化后的下一项。第二个函数is_valid_description(s),判断输入串能不能被拆成若干“(数量, 数字)”块,且这些块的“数字”部分不能出现相邻重复。第三个函数find_parent(s),在判断合法的基础上,用深度优先搜索找到第一个合法的父串,并输出它的紧凑表示,比如“1×2 3×1 21×3”这种形式。
工具本身做成命令行入口,可以直接跑:
python look_and_say.py --analyze 12312132123123输出会依次给出长度、下一项、合法性判断结果和父串紧凑表示。我把输出格式设计成了一种“一眼能看懂”的风格,而不是只吐一个布尔值,这样调试和教学都方便。
1.3 为什么选外观数列而不是别的数列
我看到数字串的第一反应其实是好几个候选方向:可能是棋盘坐标压缩编码,可能是某个 ID 生成器输出的随机串,也可能是某些数据压缩算法的中间结果。但为什么最终锁定外观数列?因为这个字符串只用到了 1、2、3 三种数字,而且没有明显的重复规律。
外观数列有个非常著名的性质:从任意只含 1、2、3 的串出发,经过一次外观变换后,结果依然只含 1、2、3。这是因为外观变换只输出两类信息:数量和数字。数量可能产生任何数字字符,数字部分则继承原串的字符。如果原串只含 1、2、3,那下一项的数字部分也只会是 1、2、3,而数量部分在数字较小的迭代里通常也是 1、2、3。这个串用到的数字全集恰好就是 1、2、3,不符合“教科书式”的 1、11、21、1211 序列典型项,但非常符合外观变换的产物特征。所以拿它做外观数列的分析对象,比拿其他随机串更有说服力。
2. 外观数列的规则与数学背景
2.1 基本规则和演化示例
外观数列的本质可以写成一句话:对一个字符串,从左往右扫描,记录“连续相同字符的个数 + 这个字符本身”,然后把这些记录拼起来。
举个例子,从“1”开始:
- “1” 读作“一个 1”,下一项是“11”
- “11” 读作“两个 1”,下一项是“21”
- “21” 读作“一个 2,一个 1”,下一项是“1211”
- “1211” 读作“一个 1,一个 2,两个 1”,下一项是“111221”
- “111221” 读作“三个 1,两个 2,一个 1”,下一项是“312211”
不断迭代,得到:
1 11 21 1211 111221 312211 13112221 1113213211 ...这个数列有个很直观的解释:每一项都是上一项的“速记描述”。它不需要预先知道任何全局信息,只要扫描当前串就能生成下一项,所以非常适合用线性时间算法实现。
2.2 为什么经典数列里只会出现 1、2、3
这是 John Conway 研究这个数列时的一个重要结论:从“1”出发的外观数列,从第 4 项开始,所有数字字符只会在 1、2、3 里打转。原因很简单,去看第 4 项是“1211”,它里面只有 1 和 2;生成下一项时,扫描得到的是“1个1、1个2、2个1”,写出来就只会用到 1 和 2。而“连续出现 3 次以上的相同字符”,在外观描述里体现为“31”“32”这类片段,数字部分还是 1、2、3。
但如果从其他数字出发,情况就不一样了。比如从“4444”出发,第一项就会写成“44”——这里数量是 4,数字也是 4,所以 4 可能出现在特定迭代里。Conway 的伟大之处在于他证明了:从经典种子“1”出发,能保持只含 1、2、3,这是一个非常强的结构稳定性。后续的“宇宙进化论”里,他还发现这个序列可以分解成 92 个互相独立的“原子”,每个原子有自己的进化路径,整个序列的增长速度由一个特殊常数控制。
2.3 增长速率与 Conway 常数
外观数列的长度增长非常快,但并不是指数爆炸那种失控式增长。Conway 发现,从某种意义上讲,这个序列每迭代一次,平均长度乘上一个常数约等于 1.303577269034...,这个数被称作 Conway 常数,也是某个 71 次多项式的实数根。也就是说,第 n 项的大致长度可以用一个指数函数拟合:
长度 ≈ C × 1.30357^n这个特性直接影响代码设计。如果只算一次next_term,任何长度都能轻松处理。但如果要连续迭代几百次,字符串长度会呈指数增长,几十轮之后就已经是天文数字。Python 虽然能处理大整数,但字符串拼接的代价也会迅速变大,所以做批量迭代时一定要控制轮数。
2.4 合法外观词的约束条件
判断一个数字串是不是“合法外观词”,比单纯生成下一项难度高。核心约束是:解析出的片段,其数字部分不能相邻重复。
我来解释一下为什么。假设某个串能拆成“3 个 1”和“2 个 1”,对应片段就是“31”和“21”。这表示父串里有一个连续的“111”区间,紧接着又一个连续的“11”区间。但这两个区间在父串里是相邻的,合起来其实是“11111”,也就是 5 个连续的 1。5 个连续 1 的正确外观描述应该是“51”,而不是“3121”。所以“3121”这种“3个1再接2个1”的写法,不可能出现在任何外观变换的合法结果里。
放到代码层面,这个约束就变成了:在深度优先搜索中,记录上一个片段的数字,如果新片段的数字和它相等,直接剪枝。这是反推父串时最重要的一个判断条件,也是新手写这种搜索最容易漏掉的细节。
3. 核心代码实现与关键步骤
3.1 正向生成函数 next_term
第一个函数没什么难度,但写对细节很重要:
def next_term(s: str) -> str: if not s: return "" parts = [] i = 0 n = len(s) while i < n: j = i while j < n and s[j] == s[i]: j += 1 parts.append(str(j - i)) parts.append(s[i]) i = j return "".join(parts)这个实现使用手动双指针扫描,时间复杂度 O(n),空间复杂度 O(n)。手工循环的好处是能清楚控制每一步,方便加调试信息。也可以用itertools.groupby写一个非常精简的版本:
from itertools import groupby def next_term_groupby(s: str) -> str: return "".join( str(sum(1 for _ in g)) + k for k, g in groupby(s) )但注意,len(list(g))会一口气把整个分组装进列表,内存占用会比sum(1 for _ in g)大。在超长字符串上测试时,两者差距会很明显,尤其是连续相同字符数量特别大时。我实测过,对几百万字符的串做生成,list(g)版本慢很多,因为分配了大量临时列表。
3.2 合法性判断 is_valid_description
判断合法性的本质是:把输入串分成若干块,每块由“数量字符串 + 一个数字字符”组成。数量可以是一位数,也可以是多位数。
实现思路是用深度优先搜索。从位置pos开始,枚举下一个块的数字字符位置end,其中s[pos:end]是数量的十进制表示,s[end]是数字字符。然后递归地从end + 1继续,同时记录当前数字字符。
关键判定条件有三个:
- 数量字符串不能为空,且首位不能是 0,因为外观描述里的数量是正整数,正整数的十进制表示不以 0 开头。
- 单个字符“0”作为数量也不允许,因为不会有 0 个某字符这种描述。
- 相邻片段的数字字符不能相同,这个前面已经解释过。
def is_valid_description(s: str) -> bool: n = len(s) memo = {} def dfs(pos: int, last_digit: str) -> bool: if pos == n: return True key = (pos, last_digit) if key in memo: return memo[key] for end in range(pos + 1, n): count_str = s[pos:end] if len(count_str) > 1 and count_str[0] == "0": continue if count_str == "0": continue digit = s[end] if digit == last_digit: continue if dfs(end + 1, digit): memo[key] = True return True memo[key] = False return False return dfs(0, "")这里我加了memo做记忆化,避免同样的(pos, last_digit)被重复计算。在输入串很长、候选片段很多的情况下,记忆化能把指数级搜索压成多项式级,实测效果非常明显。
3.3 反推父串 find_parent
有了合法性判断,反推父串就顺理成章了。只需要在 DFS 过程中把走过的块记录下来,递归成功时一次性输出即可:
def find_parent(s: str) -> tuple[bool, list]: n = len(s) blocks = [] def dfs(pos: int, last_digit: str) -> bool: if pos == n: return True for end in range(pos + 1, n): count_str = s[pos:end] if len(count_str) > 1 and count_str[0] == "0": continue if count_str == "0": continue digit = s[end] if digit == last_digit: continue blocks.append((int(count_str), digit)) if dfs(end + 1, digit): return True blocks.pop() return False if dfs(0, ""): return True, blocks return False, []这个方法找到的是第一个可行的父串,而不是所有父串。如果需求是要枚举所有可能的父串,可以改成收集所有成功路径,但要注意分支数量可能非常大,实际项目里几乎没有必要。
3.4 命令行入口与输出设计
为了让工具用起来顺手,我加了一个简单的命令行入口,用argparse解析参数:
def main(): import argparse parser = argparse.ArgumentParser(description="Look-and-say analyzer") parser.add_argument("--analyze", required=True, help="digit string to analyze") args = parser.parse_args() s = args.analyze print(f"input : {s}") print(f"length: {len(s)}") print(f"next : {next_term(s)}") ok, blocks = find_parent(s) print(f"valid : {ok}") if ok: parent_str = " + ".join(f"{cnt}×{d}" for cnt, d in blocks) print(f"parent: {parent_str}") if __name__ == "__main__": main()输出里最有用的是父串的紧凑形式,比如1×2 + 3×1 + 21×3,它比直接打印几千字符的完整父串清爽得多。我最初直接把完整父串打印出来,结果一眼望去全是同一个数字,根本没法核对,改成紧凑表示以后,验证和讲解都方便多了。
4. 运行验证:手把手拆解“12312132123123”
4.1 下一项计算过程
拿到输入串,首先肉眼扫一遍:12312132123123里没有连续相同的字符,每个字符都是孤立的 run。所以按外观规则改写时,每个 run 的长度都是 1,对应的下一项就是:
11 12 13 11 12 11 13 12 11 12 13 11 12 13去掉空格,合并成完整串:
1112131112111312111213111213长度正好是 14 × 2 = 28。程序跑出来的结果也一样,我手动核对了一遍,没有出入。这里有个小技巧:手算时可以先用空格把每个片段隔开,确认每个片段形式都是“一个数 + 一个数字”,再合并。很多人手算出错,是因为直接连线合并,写着写着就乱了。
4.2 合法性验证的搜索结果
接着用is_valid_description和find_parent去验证这个串。搜索过程会按“块长度从小到大的顺序”尝试,最终的合法路径是:
12 | 31 | 213 | 21 | 23123逐段解释一下:
- “12” 表示父串开头有 1 个 2;
- “31” 表示接下来有 3 个 1;
- “213” 表示接下来有 21 个 3;
- “21” 表示接下来有 2 个 1;
- “23123” 表示接下来有 2312 个 3。
这五个片段的数字部分分别是 2、1、3、1、3,相邻片段没有重复,所以完全合法。这个结果非常关键:它证明“12312132123123”不是一个随便拼出来的乱码,而是某个特定父串的合法外观描述。
4.3 反推出来的巨大父串
把上面的片段还原成父串,表示为紧凑形式:
1×2 + 3×1 + 21×3 + 2×1 + 2312×3完整展开的话,父串长度是:
1 + 3 + 21 + 2 + 2312 = 2339也就是说,标题中那串 14 个字符,实际上压缩描述了一个 2339 字符的字符串。这种“一个很短的串描述一个很长的串”的现象,外观数列里非常常见:当描述数字是多位数时,单个片段就能覆盖父串中大量的重复字符。这也是为什么这个数列看起来简单,细琢磨却很有信息论味道的原因——它本质上是一种面向连续重复内容的极端压缩表示。
4.4 人性化验证:正着推回去对不对
很多看过代码的人会问:反推出来了,怎么能确定没推错?最好的验证方式,就是对找到的父串再做一次正向生成,看看结果是不是等于原输入串。
我对照检查了一遍:
1×2 -> "12" 3×1 -> "31" 21×3 -> "213" 2×1 -> "21" 2312×3 -> "23123"把这些正推结果依次拼接:
"12" + "31" + "213" + "21" + "23123" = "12312132123123"和输入串完全一致。这个验证步骤我在代码里也内置了,find_parent返回结果后,工具会执行一次next_term(parent_str) == s的断言式检查,避免因为 DFS 记录错误而输出一个错误的父串。这种“双向核对”的做法写不了几行代码,但能省掉大量手动排查时间。
5. 实操中遇到的典型问题与排查技巧
5.1 递归深度过大导致搜索失败
find_parent本质上是递归搜索,递归深度等于最终块的个数。在一个全是单字符块的串上,递归深度会等于字符串长度。Python 默认递归上限一千层,一旦输入串超过一千个字符且每个字符都是独立 run,就会直接抛RecursionError。
我的解决方案分两步。第一步,在命令行入口里手动调高递归上限:
import sys sys.setrecursionlimit(1000000)第二步,在核心函数里加了记忆化,避免同一个(pos, last_digit)状态被反复展开。实际测试下来,一个一万字符的随机串也能在几十毫秒内完成合法性判断。如果输入继续增大,就得把 DFS 改成显式栈的迭代版本,但那个代码复杂度会高不少,普通场景下没必要。
5.2 把“31”误判为“三十一”以外的含义
从“311312...”这类外观词里看,“31”有时候是“三个 1”,但如果后面紧跟着一个数字,比如“312”,就有可能被拆成“31”+“2”(31 个 2),而不是“3”+“12”。这个歧义正是反推问题的核心难点。
我在初版代码里犯过一个错:枚举时直接从当前位置取两位,默认认为数量只有一位数。结果面对“12312132123123”这种串,搜索空间被严重缩小,很多合法拆分根本没进候选集。后来改成枚举数量字符串的结束位置,也就是允许数量有多位数字,问题才彻底解决。所以写相关算法时一定要记住:外观描述中的数量,长度是任意的。
5.3 相邻数字相同导致的无限循环
另一次排错经历是,我写的find_parent第一版没有检查“数字部分相邻重复”这个约束。结果在用“111111”做测试时,程序返回了一个奇怪的父串:3 个 1 再接 3 个 1。这个父串在物理上是说不通的,因为它实际上是 6 个连续的 1,正确描述应该是“61”。这类问题用正向验证next_term(parent) == s很容易暴露,所以在最终代码里我同时保留了约束检查和多轮验证,双保险。
5.4 性能优化技巧
如果只是分析单个 14 字符的串,代码怎么写都无所谓。但如果要对超长串做批量分析,有三个优化点很值得注意。
第一,字符串拼接不要用+在循环里累加,要放进parts列表最后一次性join。第二,DFS 的枚举要从小到大,因为大多数情况下数量较短的拆分更容易命中,早命中早返回。第三,memo的 key 尽量用简单的元组(pos, last_digit),不要塞大对象,否则哈希开销会抵消记忆化收益。
我做了个简单测试:对一个 10 万字符的串做合法性判断,优化前要十几秒,优化后不到一秒,差距主要来自记忆化和字符串拼接方式的改进。
6. 从这个小工具还能扩展出什么
这个项目虽然起源于一串看似无意义的数字,但做完之后我发现它的扩展价值不小。
最直接的一个扩展,是把工具改成“外观数列进化模拟器”。输入任意初始串,连续迭代若干轮,输出每轮的长度和数字分布。因为 Conway 常数告诉我们长度会指数增长,所以可以加一个轮数上限控制,比如最多迭代 50 轮,防止字符串长度爆炸到无法显示。再加一个“75 轮后统计 1、2、3 各出现多少次”的功能,会发现它们的占比逐渐趋向一个稳定值,这个现象解释起来也是非常好的数学科普素材。
另一个有意思的扩展方向是把它应用在简单抖动检测上。外观描述天然对连续重复敏感:如果一个时间序列在某段时间内连续出现相同状态,外观描述会把它压缩成“数量 + 状态值”。反过来,如果状态频繁变化,描述长度会显著变长。这种特性在某些轻量级的信号特征提取场景里可以当做一个粗糙的“重复度指标”来用。
我个人更推荐的做法,是把find_parent的输出结果做成可视化。每个块画成一个矩形,宽度对应当前块中重复的字符数量,颜色对应当前数字字符。这样“12312132123123”这类串会呈现出非常有规律感的条形图,从视觉上一眼就能看出“虽然有大量重复,但结构是分段均匀的”。这个视觉化扩展对理解外观描述的压缩逻辑帮助很大。
最后分享一个实际体会:写这种“反推 + 验证”类型的算法题,最忌讳的就是只写正向生成、不写反向搜索。因为正向生成太简单了,一行正则或者双指针就搞定,真正考验算法思维的恰恰是反向搜索里的约束挖掘。你能不能在动手写代码前想清楚“相邻数字不能相同”这件事,决定了你的搜索是不是正确的。想清楚以后,这项目就不是一个三分钟练习题,而是一个能拿得出手的算法小工具了。