外观数列这个题,我在帮几位准备复试的同学模拟上机时反复讲过,也在某高校复试历年题里见过好几次变体。它看起来简单,核心代码不过十来行,但涉及的字符串处理、边界条件、复杂度意识,恰好是复试上机最容易考察的基础功底。这篇文章把这类题从题目理解、实现方案到常见误区完整拆一遍,最后附上一点针对复试场景的做题策略,希望对正在准备复试的你有实在帮助。
1. 这道题到底在问什么
1.1 题目描述与核心逻辑
外观数列的题目描述通常不长,大致如下:
给定一个正整数 n,输出外观数列的第 n 项。
外观数列是一个整数序列,从数字 1 开始,序列中的每一项都是对前一项的描述。
前五项如下:
- 1
- 11
- 21
- 1211
- 111221
第一项是数字 1。
描述前一项,这个数是 1 即“一个 1”,记作 11。
描述前一项,这个数是 11 即“两个 1”,记作 21。
描述前一项,这个数是 21 即“一个 2 + 一个 1”,记作 1211。
描述前一项,这个数是 1211 即“一个 1 + 一个 2 + 两个 1”,记作 111221。
我第一次见这个题的时候,其实困惑了一小会儿:为什么要叫“外观数列”?等看懂这个描述过程就明白了——每一项是对上一项的“外观”做描述,而不是对数值做数学运算。它本质上是在描述“上一个字符串里有哪些连续相同字符,各有多少个”。
这里有个很关键的区分:它不关心数字的数学大小,只关心数字字符的排列。比如第 4 项是 1211,你把它当成一千二百一十一去看就错了,它应该被看成“1、2、1、1”四个独立的字符。这个视角转换是理解整道题的第一步。
1.2 这个题的常见考法变体
在复试上机和笔试里,这道题常见的出现形式有四种:
- 标准版:给定 n,输出第 n 项的字符串。这是最基础的考法,一般要求用迭代或递归实现。
- 求前 n 项:要求输出从第 1 项到第 n 项的全部结果。这个变体对代码结构要求更清晰,因为你要把每一项都存下来。
- 指定起点版:把“1”作为第一项的设定改掉,比如指定第一项是某个字符串,再往下生成。此时你需要理解,题目的核心算法是不变的,只是初始值变了。
- 求第 n 项的长度:不要求输出完整字符串,只要求输出长度。这个变体如果把 n 给得很大,就涉及一个重要的性质——外观数列的长度增长速度是指数级的,后面我会详细展开。
很多同学备考时只准备了标准版,忽略了变体,结果在考场上看到“起点改成 2”或者“只问长度”就慌了。其实核心逻辑都一样,只要把“根据上一项生成下一项”这个函数写出来,所有变体都能套。
1.3 前置知识要求
做这个题需要的基础非常少,只要具备下面几个能力就能解决:
- 字符串的基本操作:遍历、索引、拼接。几乎所有主流语言都支持这些操作。
- 双指针或游标思想:用一个位置指针扫描当前字符串,遇到字符变化就记录一段连续相同字符的数量。这个思想在字符串处理类题目里是基础中的基础,后面我会详细演示。
- 循环与递归的基本概念:用迭代(从第 1 项逐步生成到第 n 项)或递归(递归生成前一项再描述它)实现。
所以这道题很适合复试上机,因为它不考察冷门 API,不依赖复杂数据结构,纯粹看你能不能把逻辑想清楚并写成干净的代码。很多复试上机题目都是这个风格——难度不大,但要求你在有限时间内写出无 bug 的代码,最好还能答出复杂度。
2. 解题思路与两种核心实现
2.1 最直观的迭代解法:逐项生成
思路可以这样描述:
当前已知第 i 项的值,要生成第 i+1 项,就扫描第 i 项的字符串,把连续相同的字符归为一组,统计每组字符的个数和字符本身,按“个数 + 字符”的格式拼接,得到第 i+1 项。
举个例子,假设当前项是 1211,我从左到右扫描:
- 位置 0 是字符 '1',向后看,位置 1 是 '2',不相同,所以连续的 '1' 只有 1 个,输出 "11"(一个 1);
- 位置 1 是字符 '2',向后看,位置 2 是 '1',不相同,所以连续的 '2' 只有 1 个,输出 "12"(一个 2);
- 位置 2 是字符 '1',向后看,位置 3 是 '1',相同,继续向后看已经到末尾,所以连续的 '1' 有 2 个,输出 "21"(两个 1)。
把三次输出拼接起来就是 "11" + "12" + "21" = "111221",正好就是第 5 项。
这个逻辑写代码时有一个很常见的实现套路:不是去“向后看”那么多次,而是维护一个计数器,相同字符就加一,发现不同字符就把计数器记录的个数和当前字符拼接进结果,然后重置计数器。
很多第一次写的人会纠结一个问题:为什么最后还要在循环外面再处理一次?因为循环里真正触发拼接的时机是“发现不同字符”,而字符串的最后一个连续段后面没有不同字符了,它不会在循环内被触发拼接,所以循环结束后需要手动补上最后一段。这是初学者最容易漏掉的点。
2.2 迭代实现(Java 示例)
我平时最常用 Java 写这种题,因为复试考场多要求 Java 或 C++,Java 的字符串操作和 StringBuilder 用起来很顺手。先看代码:
public String countAndSay(int n) { // 第一项固定是 "1" String current = "1"; // 从第 2 项开始生成,直到第 n 项 for (int i = 2; i <= n; i++) { StringBuilder next = new StringBuilder(); int count = 1; // 遍历当前项的每个字符 for (int j = 1; j < current.length(); j++) { if (current.charAt(j) == current.charAt(j - 1)) { // 和上一个字符相同,计数器加一 count++; } else { // 发现不同字符,把上一段连续字符的描述拼进去 next.append(count).append(current.charAt(j - 1)); // 重置计数器,当前字符作为新连续段的第一位 count = 1; } } // 循环结束时,最后一段连续字符还没有处理,补上 next.append(count).append(current.charAt(current.length() - 1)); // 将生成的字符串作为当前项,进入下一轮 current = next.toString(); } return current; }细节说明:
current初始为"1",表示第一项。- 外层循环
for (int i = 2; i <= n; i++)从第 2 项开始生成。如果 n 是 1,直接返回初始的"1",不需要进入循环。 - 内层循环从 1 开始遍历,实际上是在和
j - 1位置比较,这是一种“与前一个字符比较”的写法。也可以写成双指针风格:用start标记当前连续段的起点,j向后扫描直到字符变化,然后段长度是j - start。两种写法各有特点,下面会说到。 StringBuilder的必要性:Java 的 String 是不可变对象,频繁用+拼接字符串会产生大量中间对象。在复试现场虽然 n 通常给得不大,但养成用StringBuilder的习惯能避免无谓的性能问题和面试官追问时的尴尬。
2.3 递归实现与递归思路
递归解法本质上和迭代是一样的逻辑,只是用递归的方式从第 n 项往前推:要求第 n 项,就先求第 n-1 项,然后描述第 n-1 项。
public String countAndSay(int n) { // 递归终止条件:第 1 项就是 "1" if (n == 1) { return "1"; } // 获取第 n-1 项 String previous = countAndSay(n - 1); // 描述第 n-1 项,得到第 n 项 StringBuilder result = new StringBuilder(); int count = 1; for (int i = 1; i < previous.length(); i++) { if (previous.charAt(i) == previous.charAt(i - 1)) { count++; } else { result.append(count).append(previous.charAt(i - 1)); count = 1; } } result.append(count).append(previous.charAt(previous.length() - 1)); return result.toString(); }递归代码看起来更贴近题目定义,可读性好,但要注意:递归深度是 n,如果 n 很大(比如上千),会造成栈溢出。复试机试的 n 范围通常是 1 到 30 左右,递归没有问题,但如果你在面试中被追问“能不能不用递归”,你应该能说出迭代方案。
我个人在实际做题时会优先写迭代。原因很简单:迭代不需要考虑栈深度的问题,而且代码结构是“从已知推到未知”,不容易出现递归边界写错的情况。如果你对递归更熟练,写递归也没问题,关键是要把终止条件和递归关系写对。
2.4 两种方案的对比
| 对比维度 | 迭代解法 | 递归解法 |
|---|---|---|
| 代码结构 | 外层循环累乘项数,内层扫描字符串 | 递归调用直到第 1 项,回溯时逐层描述 |
| 可读性 | 稍微工程化,需要理解“从第 2 项开始生成” | 更贴近数学定义,一眼能看出递推关系 |
| 栈深度风险 | 无 | n 过大时可能栈溢出 |
| 适用场景 | 所有范围都适用,推荐默认使用 | 适合 n 较小且强调递归思维的面试场景 |
复试现场我建议:先写出一种能保证正确的解法,如果时间富余,再和面试官讨论另一种方案。不要一上来就纠结哪种写法最好——在考场上,跑得通、讲得清,比“最优美”重要得多。
3. 代码实现的几个关键细节与性能考量
3.1 为什么用 StringBuilder 而不是直接拼接字符串
我见过不少同学在复试上机时图省事,写出这样的代码:
next = next + count + current.charAt(i - 1);在小数据量下,这段代码完全能跑出正确答案。但如果你在面试环节被问到“这个操作的时间复杂度是多少”,就需要理解 Java 中字符串拼接的代价:String是不可变的,每次+拼接都会创建新的字符串对象,并把原字符串的内容复制一遍。也就是说,如果结果字符串长度是 L,每次拼接的代价是 O(L),而外观数列的长度会随 n 指数增长,累加起来的开销会明显高于使用StringBuilder的版本。
而StringBuilder内部是一个可变的字符数组,追加字符时通常只需要在数组末尾写入,只有在容量不够时才扩容。整体时间复杂度是 O(结果字符串总长度),避免了反复复制整个字符串的浪费。
这算是一个基础但重要的习惯。复试上机判题通常不会因为你用StringBuilder或+产生性能差异而卡你(数据量往往不会设计得那么大),但代码风格会影响面试官对你工程素养的判断。
3.2 边界情况分析
这道题的边界情况不算复杂,但有几个点值得留意,也是容易在考场上翻车的地方:
- n = 1:直接返回 "1"。如果你的代码外层循环写的是
for (int i = 1; i < n; i++),那么当 n = 1 时循环不执行,返回初始的 "1",同样正确。但要注意不要写成for (int i = 1; i <= n; i++),那样会多做一步,把第一项 "1" 又描述一遍,得到 "11" 而不是 "1"。 - n = 0:严格来说,题目给的 n 是正整数,不会出现 0。但如果面试官突然问你“n = 0 应该返回什么”,你最好能回答“按照题目定义,第一项是 1,n 从 1 开始,因此 n = 0 不在讨论范围内,如果非要处理,可以做参数校验抛异常或返回空串”。这种细节展现出你对题目定义的理解程度,而不仅仅是背代码。
- 当前字符串长度为 1:也就是 current = "1" 时,内层循环
for (int i = 1; i < current.length(); i++)一次都不会进入,直接执行循环后的next.append(count).append(current.charAt(0)),得到 "11",正好是第二项。所以循环后的那段补尾代码非常重要,缺少它时长度为 1 的情况会直接出错——count没有被使用,结果变成空串。
3.3 一个容易理解的实现变体:双指针扫描
很多教科书的参考写法是用双指针start和i,语义更清晰:
public String countAndSay(int n) { String current = "1"; for (int i = 1; i < n; i++) { StringBuilder next = new StringBuilder(); int start = 0; for (int j = 0; j < current.length(); j++) { if (j + 1 < current.length() && current.charAt(j) == current.charAt(j + 1)) { // 如果下一个字符和当前相同,继续 continue; } // 当前字符和后一个不同,说明从 start 到 j 是一个连续段 int length = j - start + 1; next.append(length).append(current.charAt(j)); start = j + 1; } current = next.toString(); } return current; }这种写法把“连续段的起点”用start变量显式保存,“相同则继续,不同则结算”的逻辑更直观。它和“与前一个字符比较”的写法的核心思想相同,取舍在于:
- 与前一个字符比较:代码短,计数器在循环外多结算一次,需要理解“最后一段在循环外处理”。
- 双指针显式管理段起点:每次遇到字符变化的位置就结算,不需要循环外单独补尾,代码语义更接近“分组”这个概念。
两种写法我都推荐在实际练习中至少手写一遍。不是要背,而是要理解两种写法在“什么时候结算一段”上的差异,这样面试时无论面试官用哪种方式解释,你都能快速接上。
3.4 复杂度分析
时间复杂度上,生成第 i 项需要扫描第 i-1 项一次,扫描耗时与第 i-1 项长度成正比。整体耗时是“所有项长度之和”的量级。
空间复杂度上,如果只返回第 n 项,迭代过程中只需要保存当前项和下一项,是 O(当前项长度);如果要保存所有前 n 项结果,则是 O(所有项长度之和)。
这里有一个值得知道的数学性质:外观数列各项的长度增长是近似指数级的。大致上,项长度会以每个约 1.3 倍的速度增长(实际增长因子与数字分布有关)。也就是说,第 30 项的长度就已经达到上万量级。这个性质在题目里如果给出 n 非常大(比如 n = 100),你就要意识到,直接按定义生成字符串是不可能完成的——输出本身就长得不可接受。这种题一般会换一个问法,比如只问第 n 项里某个字符的个数或者第 n 项的长度,那就要用到一些更深入的规律分析,复试中较少出现,但如果你遇到 n 特别大的情况,要怀疑题目是不是有更巧妙的考法。
4. 复试实战:从读题到通过的完整过程
4.1 拿到题目后的前 5 分钟该做什么
复试上机通常有一定时间压力,我的建议是拿到题目后不要立刻写代码,先做两件事:
第一,手动推演前几项。你可以在草稿纸上写:
第 1 项:1 第 2 项:11 第 3 项:21 第 4 项:1211 第 5 项:111221这个推演过程有两个作用:一是确认你正确理解了题目(尤其是“描述前一项”不是“对数字加一”);二是为后面验证代码正确性提供测试用例。
第二,确定边界条件。也就是想清楚 n = 1 时应该输出什么,n = 2 时应该输出什么。这两个值会作为最小的测试用例,帮你快速定位代码逻辑错误。
我见过不少同学在这道题上翻车,不是因为算法不会,而是因为没想清楚就动手。比如有人把 1211 理解成“一个 1、一个 2、两个 1”之后,生成第 5 项时写出了 111221,但代码里把字符比较写成了数字比较,导致 10 以上的情况全错——因为数字字符 '1' 和 '0' 是分开的两个字符,不是数字 10。这个问题在输入 n 较大时一定会暴露出来,但在小 n 的测试用例上很难发现。所以手动推演之后,最好再手动推一项数字稍大的,比如第 6 项。
第 5 项是 111221,从左到右分组:
- 连续三个 '1':3 个 1,记为 "31"
- 连续两个 '2':2 个 2,记为 "22"
- 连续一个 '1':1 个 1,记为 "11"
所以第 6 项是 "312211"。你可以用这个结果验证你的代码。多推一项不是为了显示你算得有多快,而是确保你的“分组”逻辑对连续多个相同字符的情况是正确的,而不只是对“相邻且不同”的情况正确。
4.2 从思路到代码的逐步转化
当你确认思路之后,写代码的过程可以分三个层次:
第一层,先写一个“描述字符串”的辅助方法,不要急着写完整流程。比如:
// 给定一个描述对象字符串,返回它的外观描述 public String describe(String s) { StringBuilder result = new StringBuilder(); int count = 1; for (int i = 1; i < s.length(); i++) { if (s.charAt(i) == s.charAt(i - 1)) { count++; } else { result.append(count).append(s.charAt(i - 1)); count = 1; } } result.append(count).append(s.charAt(s.length() - 1)); return result.toString(); }第二层,用辅助方法封装主流程:
public String countAndSay(int n) { String current = "1"; for (int i = 1; i < n; i++) { current = describe(current); } return current; }第三层,在确保正确之后,如果你愿意,再把辅助方法内联到主流程中,减少一次函数调用——但这其实不是必要的。复试代码更看重结构清晰,拆成两个方法反而容易讲清楚逻辑。
4.3 现场验证与自查清单
写完代码后,建议按这个顺序验证:
- n = 1,输出 "1";
- n = 2,输出 "11";
- n = 3,输出 "21";
- n = 4,输出 "1211";
- n = 5,输出 "111221";
- n = 6,输出 "312211"。
如果这六个用例全过,逻辑基本没有问题。再额外试一个稍大的,比如 n = 10,虽然不能手算验证,但你可以确认程序能正常跑完,没有越界异常。
这里的自查清单包括:
- 是否处理了 n = 1 的情况?
- 循环结束后,最后一段连续字符是否正确拼接?
- 如果用 char 拼接,是否注意了
StringBuilder.append可以接收char也可以接收int的差异? - 是否出现了把字符当数字处理的逻辑错误?
第 4 点值得单独说。在 Java 中,next.append(current.charAt(i - 1))如果写成了next.append(current.charAt(i - 1) - '0'),就会把字符 '1' 转成数字 1 再拼进去,结果是 "1" 而不是 "11" 中想要的 "1" 字符。这种细节错误在字符串拼接时不容易一眼发现,但输出会非常奇怪。避免的方法就是始终用字符拼接,不进行数值转换。
4.4 时间和空间开销的现场估算
在复试现场,如果你用的语言是 Java,判题环境配置的栈空间和堆内存一般是够的。但你可以快速估算一下:n = 30 时,第 30 项长度大约是一万多个字符,总共生成 30 个字符串,累计长度大约在几万到十万级别,时间开销是微秒到毫秒级,完全不用担心。如果你用 Python,注意字符串是不可变的,使用列表收集片段再 join 是更高效的做法。
Python 版的核心代码大概是这样的:
def countAndSay(self, n: int) -> str: current = "1" for _ in range(1, n): parts = [] count = 1 for i in range(1, len(current)): if current[i] == current[i - 1]: count += 1 else: parts.append(str(count)) parts.append(current[i - 1]) count = 1 parts.append(str(count)) parts.append(current[-1]) current = "".join(parts) return currentPython 里用列表 +join代替字符串+,原因和 Java 用StringBuilder类似:避免反复生成新的字符串对象。这个习惯在 Python 刷题中非常重要,不只是这道题。
5. 常见错误、调试技巧与避坑清单
5.1 经典错误一:循环多一次或少一次
很多人在写外层循环时搞混了“生成第几项”的边界。用for (int i = 2; i <= n; i++)表示从第 2 项生成到第 n 项;用for (int i = 1; i < n; i++)表示“执行了 n-1 次描述操作”。这两种写法只要保持一致,都是对的。容易出错的是混用:比如初始化current = "1",却写for (int i = 1; i <= n; i++),那就会多做一次,输出变成第 n+1 项的内容。
排查的方法是打印中间结果。如果你发现结果比预期多一位,几乎可以肯定是循环次数问题。这时检查一下循环条件是比较和等号的关系,很快就能定位。
5.2 经典错误二:忘记拼接最后一段
这是这道题最经典的 bug。很多人的内层for循环在遇到不同字符时拼接一段,但字符串扫描结束后,最后一段连续字符没有触发“遇到不同字符”这个条件,于是被漏掉。结果是输出长度总是少一点,或者每次都少最后一段。
调试这个 bug 的方法是拿 "21" 作为输入手动走一遍代码逻辑。处理 "21" 时,位置 0 是 '2',位置 1 是 '1',不同,拼接 "12",循环结束。如果没有循环后的拼接代码,返回结果是 "12",而正确答案应该是 "1211"(一个 2 + 一个 1)。加一行next.append(count).append(current.charAt(current.length() - 1))之后,结果为 "12" + "11" = "1211",正确。
5.3 经典错误三:用数字比较代替字符比较
遇到字符串中有大于 9 的数字时,有同学会想着把字符转成整数来比较和计数。但在外观数列中,数字 '0'、'1' 等只作为字符存在。比如第 5 项是 "111221",其中只有'1'和'2'两种字符,不会出现 "10" 作为一个独立数字的情况。实际上,外观数列中永远只可能出现 '1'、'2'、'3' 这三个字符(这是这个数列的一个性质),因此直接用字符比较就行,完全不需要数值化。
有个很偏门的现象:外观数列中永远不会出现数字 4 及以上的字符。这个性质的证明并不复杂,但复试一般不考证明。知道这一点能帮你排除一些看似“应该出现更大数字”的疑虑。
5.4 经典错误四:面试被问复杂度时答不上来
在复试面试环节,面试官可能会在你写完代码之后追问:
- “这个算法的时间复杂度是多少?”
- “空间复杂度呢?”
- “如果 n 很大,瓶颈在哪里?”
- “StringBuilder 和 String 拼接有什么区别?”
如果只背代码不理解原理,这几个问题很容易被问倒。建议在准备这道题时,主动把复杂度公式推导一遍:设 L_i 表示第 i 项的长度,则生成第 i+1 项需要 O(L_i) 的时间。总时间复杂度是 O(L_1 + L_2 + ... + L_n),也就是 O(L_n) 级别的(因为长度指数增长,最后一项占主导)。空间复杂度如果不保存中间结果,是 O(L_n);如果保存所有项,是 O(L_1 + ... + L_n)。
5.5 避坑 TIPS 汇总
根据我帮同学复盘和实际考试反馈,整理一份高频易错点清单:
| 易错点 | 表现 | 解决办法 |
|---|---|---|
| 外层循环边界 | 结果正好是前一项或后一项 | 用 n=2 和 n=3 测试边界 |
| 最后一段漏拼 | 输出长度总少一截 | 循环后单独 append 最后一段 |
| 字符与数字混用 | 输出有缺失或异常 | 始终用 char 类型拼接,不做数值转换 |
| 直接用 String 拼接 | 性能差,面试被追问难回答 | 用 StringBuilder/列表 join |
| 递归深度过大 | 栈溢出 | n 较大时改用迭代 |
| 没有考虑 n=1 | 空指针或错误输出 | 初始化 current 为 "1",循环条件从第 2 项开始 |
6. 如果题目变形了怎么办
复试题目不会总是一成不变。这道题在历年复试里出现过几个变形,这里展开讲一讲,避免考场遇到时措手不及。
6.1 变形一:要求输出前 n 项
这种考法在机试中也很常见,意思是把从第 1 项到第 n 项的每一行都输出。实现上只需要把每次生成的结果保存到一个列表里,最后统一输出:
public List<String> generateAll(int n) { List<String> result = new ArrayList<>(); String current = "1"; result.add(current); for (int i = 2; i <= n; i++) { StringBuilder next = new StringBuilder(); int count = 1; for (int j = 1; j < current.length(); j++) { if (current.charAt(j) == current.charAt(j - 1)) { count++; } else { next.append(count).append(current.charAt(j - 1)); count = 1; } } next.append(count).append(current.charAt(current.length() - 1)); current = next.toString(); result.add(current); } return result; }注意,这里result存的是每一项的引用。由于current每次都被重新赋值为新的 String 对象,之前的项不会被改变,所以可以放心存。
6.2 变形二:给定初始项
有些题目会把起点从 "1" 改成别的,比如给定初始字符串 "2",问第 n 项是多少。此时核心算法完全不变,代码只用把current的初始值改成输入参数即可:
public String countAndSayFrom(String start, int n) { String current = start; for (int i = 1; i < n; i++) { current = describe(current); } return current; }这个变体其实比标准版还简单,因为不需要记住第一项是 "1" 这个隐藏条件。但它的迷惑性在于,有的同学会被“外观数列”这个名字带偏,以为必须从 1 开始,导致对题目变形无从下手。如果你理解了算法的本质是“描述字符串”,那么初始值是什么根本不重要。
6.3 变形三:只问第 n 项的长度
这个变体就有意思了。如果题目不要求输出完整字符串,只要求输出长度,你当然可以从第 1 项一直生成到第 n 项再返回长度。但如果 n 很大(比如 100),完整生成就不可行了,因为长度指数增长。
此时需要知道外观数列的一些数学规律。一个著名性质是:外观数列中,特定字符序列的演化是确定的,长度增长存在一个特征值。不过这个规律推导起来比较复杂,复试中出现“只问长度且 n 很大”的概率不高。如果你的目标是稳妥过复试,记住:标准生成题用 O(L_n) 的迭代解法足够了;如果真遇到 n 超过几百的情况,可以尝试用“分段计数”或者“长度递推矩阵”的方法来解决,但这不在复试基础要求内。
6.4 变形四:任意语言的实现差异
复试上机时,你可能会遇到指定语言的情况。不同语言的字符串处理方式会影响代码写法,但核心逻辑不变,我简单对比一下:
- Java:用
StringBuilder拼接,遍历用charAt。 - Python:用列表收集片段,遍历用下标或直接迭代字符。
- C++:用
std::string,可以直接+=拼接,但要注意字符追加和字符串追加的差异。 - JavaScript:字符串不可变,用数组收集再
join('')更高效。
不管用哪种语言,先写辅助函数describe再写主流程,思路会清晰很多。
7. 一道题的启发:上机准备的方法论
外观数列这道题本身不算难,但它很适合用来检视你的上机准备工作是否到位。我帮人做复试模拟时,经常用这道题来观察一个考生的做题习惯,包括:
- 是否愿意在写代码前先确认理解题意;
- 是否会主动考虑边界条件;
- 是否知道如何快速定位 bug;
- 是否能在写完代码后清晰讲解思路和时间复杂度。
这四个习惯,比“能不能 AC”更反映真实的编程功底。很多收到复试通知的同学在准备阶段疯狂刷题,但方法比较盲目——看一眼题,知道怎么写,然后直接背代码。这样做短期内看起来效率高,但复试的面试环节一问原理就露馅。
我的建议是:每道题至少分三轮过。第一轮,不看任何参考答案,自己独立思考 20 分钟,能写多少写多少;第二轮,对照参考实现,找出你没想到的思路和细节;第三轮,隔两天不看书,重新手写一遍,并尝试口头向别人讲解这道题的思路和复杂度。三轮下来,这道题才算真正掌握。
外观数列这个题尤其适合用这个方法来练。因为它有三层“为什么”值得你挖掘:为什么循环结束后要额外处理一段?为什么用StringBuilder?为什么复杂度是 O(L_n) 而不是 O(n^2)?这三个问题能回答清楚,说明你是真的理解了,而不只是背会了。
8. 写在最后的一点实际体会
外观数列这个题目,在我接触过的复试上机题里算是“温和”的那一类——不考冷门算法,不考复杂数据结构,但它的“门槛”恰恰在细节里。字符串处理本来就是编程基础里最容易出错的地方,而这道题把“分组统计连续字符”这个核心操作考察得很彻底。许多考完的同学会跟我反馈,说上机时这题花了最多的时间不是在想算法,而是在调试那种“答案只差一点点”的 bug。这类 bug 往往就是漏了最后一段拼接,或者外层循环多了一次。
我个人在实际操作中的体会是:像这种代码量不大的题目,在考场上宁可写得稍微啰嗦一点,也要保证逻辑的可读性。比如把描述逻辑单独抽成函数,把循环边界写成容易验证的形式,都值得。不要为了展示“我很懂简洁的代码”而去压缩写法,最后反而把自己绕进去。复试上机更多是考察你在规定时间内稳定解决问题的能力,而不是追求最短代码。
如果你正在准备复试,建议把外观数列当作一道热身题,认真走一遍上面提到的三轮练习法。它会帮你建立字符串处理题的解题框架:理解题意、确定边界、选择数据结构、写代码、验证、讲复杂度。这套框架比这道题本身更有价值,能直接迁移到复试里其他很多题目上。最后再分享一个小技巧:在调试这道题时,把每一轮生成的结果打印成一行,几行下来,哪个环节出错通常一眼就能看穿。这比断点调试或盲目试错要快得多。希望这篇拆解对你有帮助,也祝你复试顺利。