bigywater.tech
算法与数据结构 · LeetCode 38

LeetCode 38. 外观数列

外观数列的第一项是 1,之后每一项是对前一项的"读法":把前一项里连续相同的数字分成一组,读作"个数加数字"。1 读作"一个 1",所以第二项是 11;11 读作"两个 1",第三项是 21;21 读作"一个 2,一个 1",第四项是 1211。给定 n,返回第 n 项。做法就是按定义模拟 n - 1 轮。这道题没有算法上的窍门,值得实测的是这个数列本身的增长规律。本文用真实数据验证数列的增长比例和数字构成,给出五种语言的实现,并把生成过程做成分步演示。

35
组随机对拍,与暴力解的结果全部一致
16 步
分步演示,每一帧都来自解法的真实执行,另附 5 种语言实现

题意与示例

完整题目见官方页面(链接在文末)。基准解用正则表达式 (\d)\1* 一次性匹配每一段连续相同的数字,再替换成"长度加数字",与手写的双指针分组是两种独立的写法。一共 12 组用例,n 取 1 到 7、10、15、20、25、30,都以基准为准逐项比对整个字符串:

真实实测 · 本机执行输出
输入 [1]
输出 "1"
输入 [2]
输出 "11"
输入 [3]
输出 "21"
输入 [4]
输出 "1211"
输入 [5]
输出 "111221"
输入 [6]
输出 "312211"

数列增长得有多快?

我用同一个模拟函数生成了前 50 项,统计每一项的长度、与上一项长度的比值,以及每一项里出现过的数字:

真实实测 · 本机执行输出
n | 第 n 项的长度 | 与上一项长度之比 | 出现过的数字
 1 |             1 |                - | 1
 2 |             2 |          2.00000 | 1
 3 |             2 |          1.00000 | 12
 4 |             4 |          2.00000 | 12
 5 |             6 |          1.50000 | 12
10 |            20 |          1.42857 | 123
20 |           302 |          1.33628 | 123
30 |          4462 |          1.30850 | 123
40 |         63138 |          1.30423 | 123
50 |        894810 |          1.30316 | 123
(康威常数约 1.30357727,本表最后几行的比值可以对照。)

比值逐渐稳定:第 50 项与第 49 项长度之比约为 1.30316,已经很接近数学上已知的康威常数 1.30357727。第 50 项的长度就有 894810,n 再大一点,字符串长度就是百万级、千万级。另一个观察是:从第 3 项起,数列里出现过的数字始终只有 1、2、3,前 50 项里没有出现过 4 及以上的数字;这里我只验证了 n 不超过 50,没有更一般的证明。

模拟:五种语言的实现

每一轮从头扫描当前字符串:从位置 i 开始,向右找到第一个与 s[i] 不同的位置 j,这一段的长度是 j - i,把"长度加数字"追加到结果,然后从 j 继续。n 次迭代的总代价约等于所有项长度之和,因为长度约每项乘 1.3,总和是最后一项长度的常数倍。Python、Java、Go、JavaScript、C++ 都是真实编译运行的。

Python · 解法代码def solve(n): s = "1" for _ in range(n - 1): # 每一轮:把上一项按"连续相同数字"分组,读作"个数 + 数字" parts = [] i = 0 while i < len(s): j = i while j < len(s) and s[j] == s[i]: j += 1 parts.append(str(j - i) + s[i]) i = j s = "".join(parts) return s
真实实测 · Python · 前几组用例的输出
$ python3 solution.py   # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON
[1]  →  "1"
[2]  →  "11"
[3]  →  "21"
[4]  →  "1211"
[5]  →  "111221"
[6]  →  "312211"
Java · 解法代码class Solution { public String countAndSay(int n) { String s = "1"; for (int k = 1; k < n; k++) { // 每一轮:把上一项按"连续相同数字"分组,读作"个数 + 数字" StringBuilder sb = new StringBuilder(); int i = 0; while (i < s.length()) { int j = i; while (j < s.length() && s.charAt(j) == s.charAt(i)) j++; sb.append(j - i).append(s.charAt(i)); i = j; } s = sb.toString(); } return s; } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl
[1]  →  "1"
[2]  →  "11"
[3]  →  "21"
[4]  →  "1211"
[5]  →  "111221"
[6]  →  "312211"
Go · 解法代码import "strconv" func countAndSay(n int) string { s := "1" for k := 1; k < n; k++ { // 每一轮:把上一项按"连续相同数字"分组,读作"个数 + 数字" buf := make([]byte, 0, len(s)*2) i := 0 for i < len(s) { j := i for j < len(s) && s[j] == s[i] { j++ } buf = strconv.AppendInt(buf, int64(j-i), 10) buf = append(buf, s[i]) i = j } s = string(buf) } return s }
真实实测 · Go · 前几组用例的输出
$ go build -o bin . && ./bin < cases.jsonl
[1]  →  "1"
[2]  →  "11"
[3]  →  "21"
[4]  →  "1211"
[5]  →  "111221"
[6]  →  "312211"
JavaScript · 解法代码function countAndSay(n) { let s = "1"; for (let k = 1; k < n; k++) { // 每一轮:把上一项按"连续相同数字"分组,读作"个数 + 数字" const parts = []; let i = 0; while (i < s.length) { let j = i; while (j < s.length && s[j] === s[i]) j++; parts.push(String(j - i) + s[i]); i = j; } s = parts.join(""); } return s; }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js < cases.jsonl
[1]  →  "1"
[2]  →  "11"
[3]  →  "21"
[4]  →  "1211"
[5]  →  "111221"
[6]  →  "312211"
C++ · 解法代码#include <string> using namespace std; class Solution { public: string countAndSay(int n) { string s = "1"; for (int k = 1; k < n; k++) { // 每一轮:把上一项按"连续相同数字"分组,读作"个数 + 数字" string t; size_t i = 0; while (i < s.size()) { size_t j = i; while (j < s.size() && s[j] == s[i]) j++; t += to_string(j - i); t += s[i]; i = j; } s = t; } return s; } };
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl
[1]  →  "1"
[2]  →  "11"
[3]  →  "21"
[4]  →  "1211"
[5]  →  "111221"
[6]  →  "312211"
真实实测 · 本机执行输出
共 12 组用例(含边界),以暴力解的结果为基准逐组比对:
  Python      通过 12/12
  Java        通过 12/12
  Go          通过 12/12
  JavaScript  通过 12/12
  C++         通过 12/12

耗时实测

n 取 20、30、40、45,对比手写分组与正则的耗时,每个规模取三次中最快的一次:

真实实测 · 本机执行输出
n | 暴力解 | 本文解法
        20 |     0.167 ms | 0.101 ms
        30 |     2.531 ms | 1.696 ms
        40 |    35.805 ms | 24.649 ms
        45 |   135.116 ms | 94.708 ms

两种做法的耗时都随 n 指数级增长(大约每增加 10 项,耗时增加十几倍(1.3 的 10 次方约 13.8),与长度每项约乘 1.3 一致),手写分组比正则快约 1.4 到 1.7 倍,差别不大。n = 45 时,第 45 项是长度 237746 的字符串,Python 耗时 94.7ms。这说明耗时主要由输出的长度决定,而不是算法细节。

随机对拍

Python 版本另外对 n 从 1 到 35 都和正则版本做了对拍:

真实实测 · 本机执行输出
随机对拍 35 组(n 从 1 到 35 的随机值),solve 与 brute 的结果全部一致。

另外四种语言只在上面 12 组固定用例上验证过,n 最大到 30。

分步演示:怎么读上一项

下面的演示生成第 6 项,从 1 开始逐项推演。每一帧都是把上面的解法在这组输入上真实执行记录下来的,运行器还检查了演示的最终结果与 solve 的返回值一致。每一项都是先给出上一项,再一段一段地"读"出来:演示的最终结果是第 6 项 312211。

外观数列:n = 6
当前分组

    参考与说明

    • 官方题目页:LeetCode 38. 外观数列。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚;耗时对比只用 Python 测量。
    • 没有覆盖:康威常数的数学推导;数列里只出现 1、2、3 三个数字的证明(本文只验证了前 50 项);n 很大时的内存占用。
    京ICP备2026067502号-1京公网安备11010502063487号