ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

蓝桥杯Sine之舞:从字符串递归构造到AC代码全解析

蓝桥杯Sine之舞:从字符串递归构造到AC代码全解析

1. 开胃:Sine之舞到底是个什么题

Sine之舞是蓝桥杯练习系统里非常经典的一道基础题,看起来像数学题,实际上是一道不折不扣的“字符串构造 + 递归/递推”题。题面不长,输入只有一个自然数 n,输出是一个特别长的、由 sin、数字、加号、括号组成的字符串。很多第一次碰到它的人,第一反应是去算三角函数值,或者被输出样例里那一大串括号绕晕。其实它根本不要求你算任何数值,只要求你按照规律把字符串原样拼出来。这个“规律”有两个层次:一个是 A_n 内部正弦嵌套的规律,另一个是 S_n 外部拼接的规律。把这两层拆开看,这道题就是送分题。

我强烈建议准备蓝桥杯、正在学递归、或者想练习“根据规则生成字符串”的同学,认认真真把这道题独立做一遍。它的价值不在于算法有多高级,而在于它能训练你两件事:一是从几项样例里反推模式,二是把递归形式转化成代码时不出括号错误。这两个能力在后面的很多动态规划、表达式解析题里都用得上。

题目给的定义大概是:
A_n = sin(1 - sin(2 + sin(3 - sin(4 + ... sin(n))...)))
S_n = (A_1 + n) - (A_2 + n-1) - ... - (A_n + 1)

输入 n,输出 S_n 对应的那个字符串。官方样例 n=3 的输出长这样:

((sin(1)+3)sin(1-sin(2))+2)sin(1-sin(2+sin(3)))+1

记住这个样例,后面所有的推导都会围绕它展开。

2. 剖开 A_n:先解决正弦那层皮

2.1 先把前几项写出来,规律自己就蹦出来了

遇到这类题,第一步永远是“手写前几项”,别急着敲代码。A_n 的前几项是:

A1 = sin(1) A2 = sin(1-sin(2)) A3 = sin(1-sin(2+sin(3))) A4 = sin(1-sin(2+sin(3-sin(4)))) A5 = sin(1-sin(2+sin(3-sin(4+sin(5)))))

从这五项的对比里能看出三件事:

  • 不管 n 多大,开头固定是sin(1。
  • 每增加一项,就会在原来的内部再包一层sin(i)。
  • 连接 sin(1) 和 sin(2) 的运算符是减号,连接 sin(2) 和 sin(3) 的运算符是加号,sin(3) 和 sin(4) 之间又是减号,sin(4) 和 sin(5) 之间又是加号。也就是说,运算符在减号和加号之间交替,而且第一个一定是减号。

用一句话归纳:在 A_n 中,sin(i) 和 sin(i+1) 之间的运算符,当 i 是奇数时取减号,当 i 是偶数时取加号。这个规律是整个 A_n 生成的核心。

2.2 用递归定义把规律变成公式

理解了规律后,我们要把它写成可计算的递归形式。可以定义一个函数A(k, n),它表示“从 sin(k) 开始,一直嵌套到 sin(n) 为止的字符串”:

A(n, n) = "sin(n)" A(k, n) = "sin(k" + 运算符 + A(k+1, n) + ")" 其中 k < n

运算符怎么选?就看 k 是奇数还是偶数。k 为奇数取减号,k 为偶数取加号。

举个例子。当 k=1、n=3 时:

A(1,3) = sin(1 - A(2,3)) A(2,3) = sin(2 + A(3,3)) A(3,3) = sin(3)

一层层代入,得到:

A(1,3) = sin(1 - sin(2 + sin(3)))

和题面里的 A3 一模一样。

为什么要以 k 的奇偶性来判断,而不是以 n 或别的变量判断?因为运算符出现在“当前这一层”和“下一层”之间,它在字符串里紧跟在第 k 个 sin 后面。递归每次往下一层时,k 加 1,奇偶性翻转,运算符自然交替。

2.3 两种代码实现:递归版和迭代版

按照上面的递归定义,Python 可以写成:

def gen_a_recursive(k, n): if k == n: return f"sin({k})" op = '-' if k % 2 == 1 else '+' return f"sin({k}{op}{gen_a_recursive(k + 1, n)})"

调用时传gen_a_recursive(1, n)就是 A_n。

不过实际做题时,我身边不少同学更习惯迭代写法,因为少一层思考递归出口的负担。迭代的思路是:先用"sin(1"开头,然后从 2 开始逐个往后追加-sin(i)或+sin(i),最后统一补 n 个右括号:

def gen_a_iterative(n): s = "sin(1" for i in range(2, n + 1): if i % 2 == 0: s += "-sin(" + str(i) else: s += "+sin(" + str(i) s += ")" * n return s

注意这里判断i为偶数时追加的是减号,和递归里判断k为奇数用减号并不矛盾。因为迭代里当前要追加的是“紧跟在 sin(i-1) 后面的运算符”,而声明里的减号是“紧跟在 sin(k) 后面的运算符”,角度不同,判断方式就镜像了。自己写的时候建议固定一种思路,千万不要两种混着看,很容易绕晕。

2.4 括号数量怎么自查

A_n 这个字符串的括号必须是完全配对的,而且左右括号数量都等于 n。原因很简单:每出现一次sin(,就会有一个左括号;代码最后补了 n 个右括号,正好一一对应。

如果你手工推演或者程序调试时发现括号对不上,先检查两处:一是追加运算符时有没有漏写+或-,二是最后补右括号的数量是不是 n。还有一个常见低级错误,就是把str(i)写成了str(i - 1),导致输出sin(0),这种错误在最内层的地方很难一眼看出来,建议先用 n=1 到 n=5 的小样例逐个打印验证。

3. 再战 S_n:外层拼接才是重头戏

3.1 题面公式和输出样例之间的矛盾

A_n 只是第一步,真正让很多人卡住的是 S_n。题面里的定义是:

S_n = (A_1 + n) - (A_2 + n-1) - ... - (A_n + 1)

如果照这个公式直接拼接,n=3 会得到类似:

((sin(1)+3)-(sin(1-sin(2))+2))-(sin(1-sin(2+sin(3)))+1)

但官方样例输出却是:

((sin(1)+3)sin(1-sin(2))+2)sin(1-sin(2+sin(3)))+1

减号全部消失了。第一次看到这个差异的时候,我一度以为是样例印错了。后来反复对照题目描述里“输出 S_n 这个表达式所对应的字符串”这句话才知道,输出格式里把“- (”这两部分都省掉了,只保留每个括号组的右括号和加号。换句话说,题目要的不是带完整运算符的数学式,而是经过整理的紧凑字符串。

3.2 从样例反推真正的输出规则

与其纠结题面怎么描述,不如直接记住这个输出流程:

  1. 先输出 n-1 个左括号。
  2. 对于 i 从 1 到 n:
    • 输出A_i;
    • 输出+;
    • 输出数字n - i + 1;
    • 如果 i 还没到 n,再输出一个右括号。

把这个流程写成模板就是:

"(" * (n-1) + A1 + "+" + n + ")" + A2 + "+" + (n-1) + ")" + ... + An + "+1"

用 n=3 套一遍:

先输出 "" ? 不对,是 n-1=2 个左括号: "(" "(" i=1:A1 是 sin(1),输出 "+3",因为是第 1 项,后面加 ")": "((sin(1)+3)" i=2:A2 是 sin(1-sin(2)),输出 "+2",后面加 ")": "((sin(1)+3)sin(1-sin(2))+2)" i=3:A3 是 sin(1-sin(2+sin(3))),输出 "+1",最后一项不加右括号: "((sin(1)+3)sin(1-sin(2))+2)sin(1-sin(2+sin(3)))+1"

结果和官方样例完全一致。所以写代码时千万别画蛇添足地去补减号。题面里的减号是数学含义,输出规则里的紧凑字符串才是评测机真正比对的东西。

3.3 为什么前导左括号是 n-1 个

这一步很多题解都直接给结论,但我想解释一下原因,因为理解了才不会背错。

观察样例:

  • n=1 时,输出是sin(1)+1,前面没有括号。
  • n=2 时,输出是(sin(1)+2)sin(1-sin(2))+1,前面有 1 个括号。
  • n=3 时,输出是((sin(1)+3)...,前面有 2 个括号。

规律就是 n-1 个前导左括号。这些括号本质上是在给每个“非最后一项”的分组做左边界,因为每一组 i(除了最后一组)末尾都会输出一个右括号,这个右括号需要一个对应的左括号。第 1 项的右括号其实对应的就是最外层前导括号,第 2 项、第 3 项依次往后推。因此前导括号数恰好等于“后面要输出右括号的组数”,也就是 n-1。多打印一个左括号,最终的字符串括号数就不平衡;少打印一个,又会少一层包裹。调试的时候如果你发现字符串的左右括号总数不等,优先检查这里。

3.4 配合 A_n 生成 S_n 的代码

现在把 gen_a 和 S_n 拼接逻辑合在一起。Python 完整版可以这样写:

def gen_a(n): s = "sin(1" for i in range(2, n + 1): if i % 2 == 0: s += "-sin(" + str(i) else: s += "+sin(" + str(i) s += ")" * n return s def gen_s(n): res = [] res.append("(" * (n - 1)) for i in range(1, n + 1): res.append(gen_a(i)) res.append("+") res.append(str(n - i + 1)) if i < n: res.append(")") return "".join(res) n = int(input()) print(gen_s(n))

这里有一个实现细节:不要用res += ...反复拼接字符串,尤其当 n 稍大时,Python 字符串是不可变对象,每做一次+=都会生成一个新的字符串,复杂度容易被放大。更稳妥的做法是用列表把所有片段装起来,最后"".join(res)一次拼完。C++ 里string的+=一般有优化,但用ostringstream或者先放进 vector 再拼接也是好习惯。

3.5 S_n 的另一种理解方式:递归版本

如果你想练习递归,S_n 也可以写成递归形式:

def gen_s_recursive(i, n): if i == n: return gen_a(n) + "+1" return "(" + gen_s_recursive(i + 1, n) + ")" # 需要小心

不过注意,这种“从右往左”递归写起来很容易乱,因为它还要处理前导括号和右括号的位置。我个人建议:如果只是为了拿分,用上面那段迭代拼接的代码就够了,清晰、不容易错;递归版本留作理解 A_n 用即可。S_n 的拼接本质上是线性过程,强行套递归反而增加出错概率。

4. 完整 AC 代码与复杂度分析

4.1 C++ 参考实现

蓝桥杯的练习系统里 C++ 提交量最大,很多学校教学也用 C++。参考实现如下:

#include <iostream> #include <string> using namespace std; string genA(int n) { string s = "sin(1"; for (int i = 2; i <= n; i++) { if (i % 2 == 0) s += "-sin(" + to_string(i); else s += "+sin(" + to_string(i); } for (int i = 0; i < n; i++) s += ")"; return s; } string genS(int n) { string s; for (int i = 0; i < n - 1; i++) s += "("; for (int i = 1; i <= n; i++) { s += genA(i); s += "+"; s += to_string(n - i + 1); if (i < n) s += ")"; } return s; } int main() { int n; cin >> n; cout << genS(n) << endl; return 0; }

4.2 Java 参考实现

import java.util.*; public class Main { static String genA(int n) { StringBuilder sb = new StringBuilder("sin(1"); for (int i = 2; i <= n; i++) { if (i % 2 == 0) sb.append("-sin(").append(i); else sb.append("+sin(").append(i); } for (int i = 0; i < n; i++) sb.append(")"); return sb.toString(); } static String genS(int n) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < n - 1; i++) sb.append("("); for (int i = 1; i <= n; i++) { sb.append(genA(i)); sb.append("+").append(n - i + 1); if (i < n) sb.append(")"); } return sb.toString(); } public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = sc.nextInt(); System.out.println(genS(n)); } }

Java 这里我用StringBuilder而不是 String 拼接,主要就是为了避免创建大量中间字符串。虽然这道题 n=200 时用 String 也能过,但养成良好的拼接习惯没有坏处。

4.3 复杂度分析:n=200 到底会不会超时

很多第一次做题的人会担心输出太长。我们来实际估算一下。

A_i的长度大概是O(i)量级。比如 i=200 时,字符串里有 200 个sin(片段、200 个数字、200 个运算符和 200 个右括号,再加上数字本身的位数,总长度大概在 1000 多字符。S_n 要把A_1到A_n全部拼接起来,总长度大约是n * 所有 A_i 的平均长度,也就是O(n^2)级别。n=200 时,总字符数大概也就几十万级别。这个规模无论 Python 还是 C++ 都能轻松应付。

真正值得注意的是输出时间。几十万字符一次性输出没问题,但如果你在循环里用cout <<或System.out.print逐字符输出,IO 开销会变大。稳妥做法是先拼成完整字符串,再一次输出。代码里就是这么做的。

5. 实战中的坑与排查

5.1 高频错误速查表

错误现象常见原因解决方法
输出里出现sin(0)数字从 0 开始计数确认循环从 1 开始
字符串最后多一个右括号最后一项也加)只在i < n时加右括号
前导括号数量不对写了 n 个而不是 n-1 个先手工推 n=2 样例验证
运算符全是减号或全是加号奇偶判断写反手写 A2、A3 对比
输出比样例多出一堆减号照着题面数学式硬拼记住输出规则省略减号
A_i 本身括号不平衡右括号补了 n-1 个而不是 n 个数一遍 A3 的括号数量

这六条错误基本覆盖了大部分人在这个题上踩过的坑。尤其是“画蛇添足补减号”那条,几乎每个第一次接触这道题的人都会犯。我的建议是:拿到任何输出题,先拿样例输入把预期输出抄在纸上,然后让自己的程序输出和纸上完全对齐,不要凭感觉。

5.2 调试技巧:先单独验证 A_n

我调试这道题时的习惯是分两步走。

第一步,只写gen_a,然后分别打印 n=1、2、3、4、5 的结果,和手写的前几项对比:

A1 = sin(1) A2 = sin(1-sin(2)) A3 = sin(1-sin(2+sin(3)))

只要这一步对了,说明运算符规律没问题。

第二步,再写 S_n 的拼接逻辑,打印 n=1、2、3 的结果,对比官方样例。n=3 是你最好的测试用例,因为它的前导括号、右括号、A2、A3全都覆盖到了。如果 n=3 能对上,这道题基本就稳了。

5.3 递归版本常见问题:栈溢出?

这道题 n 最大只有 200,递归深度最多也就是 200 层,完全不会栈溢出,放心用。有些同学可能因为被“200”这个数字吓到,担心递归太深。实际上 200 层递归在默认栈空间下绰绰有余,几个毫秒就能跑完。真正需要担心的是如果 n 范围扩大到几万,递归就未必合适了,到时候再用迭代写法也不迟。

5.4 一个容易被忽略的细节:n=1

n=1 是个很特殊的边界。按输出规则,n-1=0 个前导括号,S_n 就是A_1 + "+1",也就是sin(1)+1。有些同学写 S_n 时,因为循环里的“非最后一项加右括号”判断写得不对,导致 n=1 时输出变成sin(1)+1)或者(sin(1)+1,这就错了。所以提交前一定要单独测一次 n=1。还有一点,输入数据保证 n 是正整数,不需要处理 n=0 的情况,但如果你自己扩展练习,可以把 n=0 的防御也写上。

6. 这类“字符串构造题”的通用心得

做完 Sine之舞,我觉得最值得沉淀的不是这道题本身,而是它背后的一套通用解题方法。以后遇到类似的“按规则生成字符串”的题,你都可以照着这个套路走:

第一步,手写前几项,把规律可视化。不要直接上代码。S_n 的规律如果不写前几项,你很难意识到减号被省略了。

第二步,把复杂结构拆成两层。A_n 是一层,S_n 是另一层。每一层单独验证,不要等两层拼完再整体对拍。两道锅叠一起,出了问题你连是哪层错了都分不清。

第三步,用样例输出反推格式,而不是用题面公式直接翻译。竞赛题的输出格式常常和数学定义有细微差异,样例是唯一权威。

我自己第一次做这道题时,就是照着题面的(A_1+n)-(A_2+n-1)硬写,结果输出里全是减号,样例怎么都对不上。后来把样例拆开,发现减号根本不存在,才反应过来题目输出的是一个“压缩后的表达式字符串”。从那以后,我对所有输出类题目都会对样例做一次逐字符比对,不再想当然。

如果你正在备赛,建议把这道题和斐波那契数列的递归版本放在一起整理进笔记。它们都体现了同一个核心思想:把大问题拆成小问题,把小问题的输出作为大问题的一部分。区别只是 Fibonacci 返回数字,Sine之舞返回字符串。会处理字符串递归之后,很多类似题都能一通百通。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进