欢迎来到安息云峰上最富艺术气息、也最烧脑的一个单元。前面你学会了循环、函数、向量——现在要把它们组合成一种全新的思维方式:递归(recursion)。你将用一个"会调用自己的函数",在冰面上绘制出精美的分形雪花(fractal snowflake)。这一单元关卡不多,但它揭示的思想——"用自身定义自身"——是整个计算机科学中最优雅、也最强大的概念之一。
Vector.rotate 旋转向量、用 degreesToRadians 把角度转成弧度;理解分形的自相似性——整体和局部长得一样;并通过让 line 函数调用自身绘制更短的子线段,把一条直线变成锯齿状的分形曲线,再拼成六角雪花。
递归,就是一个函数在自己的定义里调用自己。听起来像绕口令,但它对应着一种很自然的想法:把一个大问题,拆成一个更小的、同类型的问题,再拆,直到小到可以直接解决。比如"画一条分形线"可以拆成"画四条更短的分形线",每条短线又可以继续拆——每一层的"任务"都是同一件事,只是规模越来越小。
分形是一种奇妙的几何图形,它最大的特征是自相似(self-similarity)——无论你放大到哪一部分,看到的形状都和整体相似。雪花、海岸线、树枝、闪电、花椰菜……大自然里到处是分形。本关要画的分形线,就是把一条直线的每一段都替换成一个"凸起的小锯齿",而每个小锯齿的每一段又替换成更小的锯齿,层层嵌套,形成毛茸茸的边缘。
递归的写法出人意料地简单:在函数的定义内部,直接写出这个函数自己的名字来调用它。本关的 line 函数负责"画一条分形线",它在处理完拆分后,会调用四次 line——每次传入一段更短的子线段。这四个 line(...) 调用的就是它自己。
def line(start, end): # ...(先判断、再拆分成 4 段)... line(start, A) # ← 调用自己! line(A, B) # ← 调用自己! line(B, A) # ← 调用自己! line(A, end) # ← 调用自己!
任何正确的递归函数都必须有两个部分,缺一不可。第一是基础情形(base case)——问题已经小到可以直接解决、不再往下拆的情况;这是递归的"刹车"。第二是递归情形(recursive case)——把问题缩小一号,然后调用自己去处理。没有基础情形,递归会无限进行下去直到程序崩溃;没有递归情形,函数就不是递归了。
return"。只有没触发基础情形时,才往下走到递归情形。顺序不能反——否则永远拆不到头。
本关的基础情形,是用线段长度来判断的:每次进入 line,先算出这条线段的总长度,如果它小于阈值 4,就说明"已经够小了,不必再拆",直接画一条实线段并退出。这个阈值就是递归的终点——它保证每拆一次线段就变短,最终必然短到小于 4,从而停下来。
def line(start, end): # 先算出整段向量和它的长度 full = Vector.subtract(end, start) distance = full.magnitude() # 线段有多长 if distance < 4: # 基础情形:够短了 # ...直接画一条实线段... return
在基础情形里,画完实线段后必须写一个 return。它的作用是让当前这一层的 line 函数立即结束、返回到调用它的上一层,绝不会继续往下执行拆分代码。如果忘了 return,即便触发了基础情形,程序也会接着去拆分、继续递归,终止条件就形同虚设。
return,是递归最经典、最致命的 bug。它会让"够小了"的判断白做,函数继续无休止地拆分自己,最终耗尽内存导致程序崩溃(这也是关卡提示里"注意内存泄漏、经常刷新"的原因)。
return 就是往回爬的动作。
要画出锯齿状的"凸起",需要把一段向量转个方向。Vector.rotate(v, angle) 会把向量 v 逆时针旋转 angle 弧度,长度不变、只改方向。本关画分形线时,需要把半段向量旋转 90 度,让它"翘起来",从而算出锯齿尖端 B 点的位置。旋转正是让分形产生弯折、变得毛茸茸的关键操作。
这里有个重要陷阱:我们习惯用"度"(90°、60°),但 Vector.rotate 只接受"弧度"。所以旋转前必须先换算。关卡提供了 degreesToRadians 函数,公式是 Math.PI / 180 * degrees——因为 180 度等于 π 弧度,把度数按这个比例换算即可。旋转 90 度、60 度前,都要先经过它转换。
# 角度转弧度:180° = π 弧度 def degreesToRadians(degrees): return Math.PI / 180 * degrees # 用法:把半段向量旋转 90 度 rotate = Vector.rotate(half, degreesToRadians(90))
Vector.rotate(half, 90),程序会把 90 当成 90 弧度(相当于转了十几圈还多),锯齿方向全乱。凡是给旋转函数传角度,永远先用 degreesToRadians 包一层。
分形线的核心规则是:把一条线段替换成四条更短的线段,拼成一个向上凸起的尖角。具体做法是先求出半段向量 half,据此算出中点附近的两个关键点 A 和 B——A 是起点加半段向量得到的中点,B 是把半段向量旋转 90 度、缩短到 1/3 原长后加到 A 上得到的"尖端"。于是原线段 start→end 被拆成了 start→A、A→B、B→A、A→end 四段。
# 递归情形:把一段拆成 4 段(形成一个尖角) half = Vector.divide(full, 2) # 半段向量 A = Vector.add(half, start) # 中点 A rotate = Vector.rotate(half, degreesToRadians(90)) # 半段转 90° rotate = Vector.multiply(rotate, 2/3) # 缩到 1/3 原长 B = Vector.add(rotate, A) # 尖端 B
魔法就在于:拆出四段后,我们不是直接画这四段直线,而是对每一段再次调用 line 函数。于是每一小段又会被判断长度、再拆成四段、再对子段调用 line……这个过程一层层展开,直到某段短于阈值 4 才真正落笔画实线。最终,一条原本笔直的线,就长满了大大小小、自相似的尖角,成为一条分形曲线。
# 对四个子段递归调用自己(而非直接画直线) line(start, A) line(A, B) line(B, A) line(A, end)
flake 函数把这条边旋转 60 度、重复 6 次(for i in range(6)),六条分形边首尾相接,就围成了一片完整的六角雪花。这里又用到了向量旋转——旋转 60 度得到下一条边的起止点。
这一节聚焦关卡的策略、思路与算法设计,只展示用于说明的关键片段,完整通关程序请你自己动手完成。
本关是一次"艺术编程",目标是在冰面上画出六角形雪花分形。关卡已经给出了递归 line 函数(供你阅读理解递归),你的任务有两部分:读懂 line 如何用递归把直线变成分形,以及补全 flake 函数——让它把一条分形边旋转 60 度、重复六次围成雪花,并在给定的白色/红色坐标点实际调用函数把雪花画出来。
line 的结构是标准的递归模板。基础情形:算出线段长度 distance = full.magnitude(),若 distance < 4,就用 toggleFlowers 配合 moveXY 从 start 画一条实线到 end,然后 return 结束。递归情形:否则求半段 half,算出中点 A 和旋转 90 度得到的尖端 B,把线段拆成四段,对四段分别递归调用 line。理解这个"判断长度→不够短就拆四段递归"的循环往复,是本关的核心。
# 关键片段:line 的基础情形(画实线并 return) full = Vector.subtract(end, start) distance = full.magnitude() if distance < 4: hero.toggleFlowers(False) # 抬笔,移动不画 hero.moveXY(start.x, start.y) hero.toggleFlowers(True) # 落笔,移动即画线 hero.moveXY(end.x, end.y) return # 关键!不 return 会无限递归
flake 要画六条边围成雪花。先求出第一条边向量 side = subtract(end, start),用 a、b 记录当前边的起止点。循环 6 次,每次先 line(a, b) 画一条分形边,然后把边向量旋转 60 度得到下一条边的方向,据此更新 a 和 b(新边的起点是上一条边的终点,终点是起点加上旋转后的边向量)。六次之后,六条分形边首尾相连,一片雪花就成型了。
# 关键片段:flake 旋转 60° 画 6 条分形边(思路示意) def flake(start, end): side = Vector.subtract(end, start) a = start b = end for i in range(6): line(a, b) # 画一条分形边 side = Vector.rotate(side, degreesToRadians(60)) # 边转 60° a = b # 新起点 = 旧终点 b = Vector.add(a, side) # 新终点
flake、line 时,传入的起止点必须是真正的 Vector(x, y) 对象,而不是 {x: .., y: ..} 这种普通对象。因为只有 Vector 对象才带有 subtract、rotate、magnitude 等向量方法,普通对象会报错。
toggleFlowers(False) 表示"抬笔"(移动但不留痕迹),toggleFlowers(True) 表示"落笔"(移动时画出线条)。画每一小段实线前先抬笔移到起点,再落笔移到终点,才能画出连续而不多余的线段。
return,会继续往下拆分、陷入无限递归导致崩溃。distance < 4),否则永远拆不到头。Vector.rotate 只认弧度,传角度前必须用 degreesToRadians 换算,否则转错方向。line 递归,而不是直接画四条直线,否则得不到分形。Vector(x, y) 对象,普通对象没有向量方法会报错。a 设为旧 b、b 设为新终点,忘了更新会原地画同一条边。你在冰面上完成了整个计算机科学 6 里最优雅的一课!你理解了递归——一个在自己内部调用自己的函数,以及它必备的两个部分:作为"刹车"的基础情形(线段长度小于阈值 4 时画实线并 return)和把问题缩小再自我调用的递归情形;你学会了用 Vector.rotate 旋转向量、用 degreesToRadians 把角度转成弧度(记住向量运算只认弧度);你体会了分形的自相似——把一段直线折成四段带尖角的子线段,再对每段递归展开,直线就长成了毛茸茸的分形曲线;最后由 flake 旋转 60 度重复六次,拼出一片完整的六角雪花。来回顾一下核心概念:
| 核心概念 | 关键点 | 作用 |
|---|---|---|
| 递归函数 | 函数内部调用自己 | 把大问题拆成同类小问题 |
| 基础情形 | if distance < 4: ... return | 递归的"刹车",防止无限递归 |
| 递归情形 | 拆 4 段,对每段调用 line | 把问题缩小并自我调用 |
| Vector.rotate | 旋转向量、长度不变 | 制造锯齿凸起、旋转雪花边 |
| degreesToRadians | Math.PI / 180 * 度数 | 把角度换成旋转所需的弧度 |
| 自相似绘制 | 一段→四段,层层重复 | 把直线变成分形曲线 |
| flake 拼雪花 | 旋转 60° 重复 6 次 | 六条分形边围成六角雪花 |
| Vector 对象 | 参数必须是真 Vector | 才能调用向量方法 |
懂得让函数优雅地调用自己、用简单规则的层层重复生成无穷复杂之美的英雄,才算真正触摸到了计算机科学中最深邃的诗意。
向雪山之巅的最后征程进发吧,勇敢的程序员!🏔️❄️🌀
扣哒世界 编程知识 · 计算机科学 6(安息云峰):第五单元
扣哒世界教研团队