去做题

第五单元:递归分形 · 自相似与雪花绘制

Unit 5: Recursive Fractals — Self-Similarity & Drawing Snowflakes
计算机科学 6 · 安息云峰 Cloudrip Mountain · 冰上的雪花

5.1 递归分形概述

Overview of Recursive Fractals

欢迎来到安息云峰上最富艺术气息、也最烧脑的一个单元。前面你学会了循环、函数、向量——现在要把它们组合成一种全新的思维方式:递归(recursion)。你将用一个"会调用自己的函数",在冰面上绘制出精美的分形雪花(fractal snowflake)。这一单元关卡不多,但它揭示的思想——"用自身定义自身"——是整个计算机科学中最优雅、也最强大的概念之一。

本单元主线:理解递归函数——一个在自己内部调用自己的函数;给递归设置终止条件(线段长度小于阈值时停止),避免无限递归;用 Vector.rotate 旋转向量、用 degreesToRadians 把角度转成弧度;理解分形的自相似性——整体和局部长得一样;并通过让 line 函数调用自身绘制更短的子线段,把一条直线变成锯齿状的分形曲线,再拼成六角雪花。

5.1.1 什么是递归(What Is Recursion)

5.1.1.1 自己调用自己 A function that calls itself

递归,就是一个函数在自己的定义里调用自己。听起来像绕口令,但它对应着一种很自然的想法:把一个大问题,拆成一个更小的、同类型的问题,再拆,直到小到可以直接解决。比如"画一条分形线"可以拆成"画四条更短的分形线",每条短线又可以继续拆——每一层的"任务"都是同一件事,只是规模越来越小。

🪆 俄罗斯套娃的比喻
递归就像俄罗斯套娃:打开一个娃娃,里面是一个一模一样但更小的娃娃,再打开还是……直到最小的那个实心娃娃(不能再打开了,这就是"终止条件")。递归函数处理问题的方式完全一样——每一层都做同样的事、把问题缩小一号,直到抵达最小的、可以直接完成的那一层。

5.1.2 什么是分形(What Is a Fractal)

5.1.2.1 放大局部,还是同一个图案 Zoom into a part, and it looks like the whole

分形是一种奇妙的几何图形,它最大的特征是自相似(self-similarity)——无论你放大到哪一部分,看到的形状都和整体相似。雪花、海岸线、树枝、闪电、花椰菜……大自然里到处是分形。本关要画的分形线,就是把一条直线的每一段都替换成一个"凸起的小锯齿",而每个小锯齿的每一段又替换成更小的锯齿,层层嵌套,形成毛茸茸的边缘。

自相似:整体的形状在局部重复出现 ① 一条直线 → ② 变成带尖的锯齿 → ③ 每段再变锯齿…… 每一层都重复"直线→锯齿"的规则,这就是递归分形
递归与分形是天生一对:分形"整体由缩小的自己组成",递归"函数由缩小规模的自己调用组成"——两者的结构完全吻合。所以画分形最自然的工具就是递归函数:让画线的函数在内部调用自己去画更短的线。

5.2 递归函数结构

The Structure of a Recursive Function

5.2.1 调用自身(Calling Itself)

5.2.1.1 在函数体里写出函数名 Write the function's own name inside it

递归的写法出人意料地简单:在函数的定义内部,直接写出这个函数自己的名字来调用它。本关的 line 函数负责"画一条分形线",它在处理完拆分后,会调用四次 line——每次传入一段更短的子线段。这四个 line(...) 调用的就是它自己。

def line(start, end):
    # ...(先判断、再拆分成 4 段)...
    line(start, A)   # ← 调用自己!
    line(A, B)       # ← 调用自己!
    line(B, A)       # ← 调用自己!
    line(A, end)   # ← 调用自己!

5.2.2 递归的两个必备部分(The Two Essential Parts)

5.2.2.1 基础情形 + 递归情形 Base case + recursive case

任何正确的递归函数都必须有两个部分,缺一不可。第一是基础情形(base case)——问题已经小到可以直接解决、不再往下拆的情况;这是递归的"刹车"。第二是递归情形(recursive case)——把问题缩小一号,然后调用自己去处理。没有基础情形,递归会无限进行下去直到程序崩溃;没有递归情形,函数就不是递归了。

进入递归函数,先做判断 够小了?基础情形 直接画线并 return 还太大?递归情形 拆成 4 段,各自调用自己
一定要先写基础情形:递归函数的开头几乎总是"先检查是否满足基础情形,满足就处理并 return"。只有没触发基础情形时,才往下走到递归情形。顺序不能反——否则永远拆不到头。

5.3 终止条件

The Termination Condition

5.3.1 长度阈值(The Length Threshold)

5.3.1.1 线段短到一定程度就停手 Stop when the segment gets short enough

本关的基础情形,是用线段长度来判断的:每次进入 line,先算出这条线段的总长度,如果它小于阈值 4,就说明"已经够小了,不必再拆",直接画一条实线段并退出。这个阈值就是递归的终点——它保证每拆一次线段就变短,最终必然短到小于 4,从而停下来。

def line(start, end):
    # 先算出整段向量和它的长度
    full = Vector.subtract(end, start)
    distance = full.magnitude()   # 线段有多长
    if distance < 4:              # 基础情形:够短了
        # ...直接画一条实线段...
        return

5.3.2 用 return 退出(Exiting with return)

5.3.2.1 return 让这一层立即返回 return ends this level right away

在基础情形里,画完实线段后必须写一个 return。它的作用是让当前这一层的 line 函数立即结束、返回到调用它的上一层,绝不会继续往下执行拆分代码。如果忘了 return,即便触发了基础情形,程序也会接着去拆分、继续递归,终止条件就形同虚设。

忘记 return = 无限递归:基础情形里少写一个 return,是递归最经典、最致命的 bug。它会让"够小了"的判断白做,函数继续无休止地拆分自己,最终耗尽内存导致程序崩溃(这也是关卡提示里"注意内存泄漏、经常刷新"的原因)。
🛑 终止条件是递归的"刹车"
想象你在往下挖井,每挖一层都问一句"到底了吗?"。终止条件就是"到底了"的信号——一旦触发,你就停下并往回爬。没有这个信号,你会一直往下挖到天荒地老。递归里,"长度 < 4"就是那个到底的信号,return 就是往回爬的动作。

5.4 向量旋转

Rotating Vectors

5.4.1 Vector.rotate 旋转向量(Rotating a Vector)

5.4.1.1 让方向转一个角度 Turn a direction by an angle

要画出锯齿状的"凸起",需要把一段向量转个方向。Vector.rotate(v, angle) 会把向量 v 逆时针旋转 angle 弧度,长度不变、只改方向。本关画分形线时,需要把半段向量旋转 90 度,让它"翘起来",从而算出锯齿尖端 B 点的位置。旋转正是让分形产生弯折、变得毛茸茸的关键操作。

Vector.rotate 把向量转一个角度(长度不变) 原向量 旋转 90° 后 90°

5.4.2 度转弧度(Degrees to Radians)

5.4.2.1 向量运算只认弧度 Vector math speaks in radians

这里有个重要陷阱:我们习惯用"度"(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 包一层。

5.5 自相似绘制

Drawing with Self-Similarity

5.5.1 一段变四段(One Segment Becomes Four)

5.5.1.1 把直线折成一个凸起 Fold a straight line into a bump

分形线的核心规则是:把一条线段替换成四条更短的线段,拼成一个向上凸起的尖角。具体做法是先求出半段向量 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
start→end 被拆成 4 段,凸起一个尖角 start A(中点) end B(尖端) start→A A→B B→A A→end

5.5.2 递归展开(The Recursion Unfolds)

5.5.2.1 每一子段又重复整个过程 Each sub-segment repeats the whole process

魔法就在于:拆出四段后,我们不是直接画这四段直线,而是对每一段再次调用 line 函数。于是每一小段又会被判断长度、再拆成四段、再对子段调用 line……这个过程一层层展开,直到某段短于阈值 4 才真正落笔画实线。最终,一条原本笔直的线,就长满了大大小小、自相似的尖角,成为一条分形曲线。

# 对四个子段递归调用自己(而非直接画直线)
line(start, A)
line(A, B)
line(B, A)
line(A, end)
递归展开:一层层拆成更小的 line line(长) line(中) line(中) line(中) line(中) ……继续拆,直到某段 < 4 触发基础情形,落笔画实线
六角雪花怎么来的:画好一条分形线后,flake 函数把这条边旋转 60 度、重复 6 次(for i in range(6)),六条分形边首尾相接,就围成了一片完整的六角雪花。这里又用到了向量旋转——旋转 60 度得到下一条边的起止点。

5.6 实战关卡精讲

Level Strategy & Analysis

这一节聚焦关卡的策略、思路与算法设计,只展示用于说明的关键片段,完整通关程序请你自己动手完成。

关卡 · 冰上的雪花

❄️ 冰上的雪花 —— 用递归绘制分形雪花

扣哒世界 冰上的雪花关卡 - koudashijie.com
图2:冰上的雪花——用递归的 line 函数画出分形线,再由 flake 旋转 6 次拼成六角雪花,在指定坐标处绘制图案

策略:读懂递归的 line,补全旋转的 flake

本关是一次"艺术编程",目标是在冰面上画出六角形雪花分形。关卡已经给出了递归 line 函数(供你阅读理解递归),你的任务有两部分:读懂 line 如何用递归把直线变成分形,以及补全 flake 函数——让它把一条分形边旋转 60 度、重复六次围成雪花,并在给定的白色/红色坐标点实际调用函数把雪花画出来。

思路拆解一:line 函数的递归逻辑

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 的旋转循环

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)            # 新终点
必须用真正的 Vector 对象:关卡特别强调——调用 flake、line 时,传入的起止点必须是真正的 Vector(x, y) 对象,而不是 {x: .., y: ..} 这种普通对象。因为只有 Vector 对象才带有 subtract、rotate、magnitude 等向量方法,普通对象会报错。
注意内存、经常刷新:递归会同时展开很多层调用,非常消耗资源。关卡提示"经常刷新以避免内存泄漏和崩溃"。这从侧面说明:递归虽优雅,但代价不小——每一层调用都占用内存,层数过深会撑爆内存。这也是设置合理终止阈值的重要意义。
toggleFlowers 是"画笔开关":本关用 toggleFlowers(False) 表示"抬笔"(移动但不留痕迹),toggleFlowers(True) 表示"落笔"(移动时画出线条)。画每一小段实线前先抬笔移到起点,再落笔移到终点,才能画出连续而不多余的线段。

5.7 常见错误

Common Mistakes

5.7.1 新手最容易踩的坑(Pitfalls for Beginners)

5.7.1.1 递归分形的典型问题 Typical issues with recursion and fractals

扣哒世界 递归分形常见错误速查 - koudashijie.com
图3:忘记 return、没有终止条件、角度没转弧度、传错对象类型,是本单元最常见的失误
易错点清单:
  • 基础情形忘写 return——画完实线不 return,会继续往下拆分、陷入无限递归导致崩溃。
  • 根本没有终止条件——递归函数必须有基础情形(如 distance < 4),否则永远拆不到头。
  • 角度没转弧度——Vector.rotate 只认弧度,传角度前必须用 degreesToRadians 换算,否则转错方向。
  • 旋转角度用错——line 内的凸起转 90 度、flake 拼边转 60 度,两者别混淆。
  • 递归情形直接画直线——应对四个子段调用 line 递归,而不是直接画四条直线,否则得不到分形。
  • 传入普通对象而非 Vector——参数必须是 Vector(x, y) 对象,普通对象没有向量方法会报错。
  • 忘记更新 a、b——flake 循环里每次要把 a 设为旧 b、b 设为新终点,忘了更新会原地画同一条边。
  • 递归层数过深——阈值设得太小会让递归层数暴增、耗尽内存,注意关卡提示的刷新与内存问题。

本单元小结 ✨

你在冰面上完成了整个计算机科学 6 里最优雅的一课!你理解了递归——一个在自己内部调用自己的函数,以及它必备的两个部分:作为"刹车"的基础情形(线段长度小于阈值 4 时画实线并 return)和把问题缩小再自我调用的递归情形;你学会了用 Vector.rotate 旋转向量、用 degreesToRadians 把角度转成弧度(记住向量运算只认弧度);你体会了分形的自相似——把一段直线折成四段带尖角的子线段,再对每段递归展开,直线就长成了毛茸茸的分形曲线;最后由 flake 旋转 60 度重复六次,拼出一片完整的六角雪花。来回顾一下核心概念:

核心概念关键点作用
递归函数函数内部调用自己把大问题拆成同类小问题
基础情形if distance < 4: ... return递归的"刹车",防止无限递归
递归情形拆 4 段,对每段调用 line把问题缩小并自我调用
Vector.rotate旋转向量、长度不变制造锯齿凸起、旋转雪花边
degreesToRadiansMath.PI / 180 * 度数把角度换成旋转所需的弧度
自相似绘制一段→四段,层层重复把直线变成分形曲线
flake 拼雪花旋转 60° 重复 6 次六条分形边围成六角雪花
Vector 对象参数必须是真 Vector才能调用向量方法

懂得让函数优雅地调用自己、用简单规则的层层重复生成无穷复杂之美的英雄,才算真正触摸到了计算机科学中最深邃的诗意。
向雪山之巅的最后征程进发吧,勇敢的程序员!🏔️❄️🌀

扣哒世界 编程知识 · 计算机科学 6(安息云峰):第五单元

扣哒世界教研团队