考试通知
用Python玩转L-system:从字符串重写到分形树绘制 第一次看到屏幕上长出一棵树的瞬间我以为是某种魔法——明明只给了几条字符串规则程序跑完却出现了一棵和真实植物轮廓非常接近的分形图形。这背后就是LS文法L-system在做核心工作。简单说它用“字符串重写”的方式描述自相似结构在Python自学路线里非常适合作为分形图形入门案例。它解决的问题很实在如何用一小段规则生成看起来非常复杂的自然形态而不是一行一行手动画坐标。从一条简单的初始字符串出发按几条替换规则反复迭代再把最终字符串翻译成绘图指令。我刚学完Python基础、刚接触递归的时候用这个项目把循环、列表、函数、字符串操作全串起来了收获很大如果你想理解程序化生成是怎么回事它也是很好的起点。1. LS文法到底是什么一条字符串如何长出一幅图1.1 四个零件组成一套重写系统先说清楚L-system的底层构造。它由四样东西组合起来字母表允许出现的字符集合比如F、、-。公理axiom最开始的那条初始字符串也就是种子。产生式production rules把某个字符替换成另一段字符串的规则。迭代次数把产生式反复套用到字符串上的轮数。举个例子。字母表是{F, , -}公理是F产生式是F - FF--FF。迭代一次字符串变成FF--FF迭代两次把里面每一个F都替换成FF--FF结果会变成一个比较长的字符串FF--FFFF--FF--FF--FFFF--FF。如果这时候把F解释为“画一条线段”解释为“左转60度”-解释为“右转60度”字符串就变成一幅由折线组成的图案。这就是L-system最核心的思想图形不是被直接画出来的而是被“生成出来的”。规则只管字符串的膨胀图形只是字符串的视觉解释结果。我第一次理解这个点的时候有种豁然开朗的感觉——原来代码里最该写的不是绘图逻辑而是生长规则本身。1.2 它和普通递归的关系一次同时替换 vs 层层调用很多教程会把L-system归类成“递归的变体”这个说法对但不完全对。普通递归是这样一个函数调用自己每次处理更小的子问题最后把结果一层层拼回来比如算阶乘。L-system也一直在“重复应用规则”但它有两点差别第一替换必须是同时的。一轮迭代里所有能被匹配的字符都在同一时刻被替换。如果像字符串replace那样边替换边继续扫描后面新生成的字符又被当成旧字符处理顺序就乱了。所以实现的时候正确做法是遍历原字符串的每个字符把结果写进一个新列表一轮结束后生成完整新字符串再开始下一轮。第二图形结构不是靠函数调用栈形成的而是靠字符串里的状态标记形成的。后面讲分形树的时候你就能看到[和]起的作用就是保存和恢复位置它模拟的是“画完一个枝丫后回到分叉点”的动作。1.3 为什么这种方法能模拟自然形态人类画一棵树会先画主干、再画分支、再画细枝每一层的形状都比上一层小一点。L-system把这种逻辑压缩成了一条产生式规则把一段线段替换成“主干加两段支干”然后反复迭代每一轮都在更小的尺度上复制同样的结构。雪花、蕨菜、海岸线这类自然物都有类似的自相似特性——放大看局部和整体长得差不多。所以用L-system写分形图形本质上不是“编程画图”而是“为某种形态编写生长语言”。你不需要精确计算每个点的坐标只需要回答一个问题一小段结构要被替换成什么更小的结构剩下的交给迭代。2. 第一个能跑的案例用Python画一棵分形树2.1 环境准备只需要标准库这个项目完全不需要第三方依赖Python自带的turtle模块就够了。它是标准库里的海龟绘图工具画线、转向、移动一套齐全非常适合做分形图形入门。运行时有两件事要提前注意。第一尽量用脚本文件运行而不是在交互式终端一行一行敲因为窗口显示需要事件循环。第二turtle默认显示速度比较慢后面我会给出一套加速参数不然迭代几轮之后你会等到怀疑人生。2.2 先把“生成字符串”和“画图”拆开我的习惯是永远写两个函数一个只负责把初始字符串迭代成最终字符串另一个只负责把字符串翻译成绘图指令。这样拆开的好处是每次想尝试新的分形图形只需要换规则绘图代码一个字符都不用改。字符串生成函数长这样def expand(axiom, rules, iterations): current axiom for _ in range(iterations): next_str [] for ch in current: if ch in rules: next_str.append(rules[ch]) else: next_str.append(ch) current .join(next_str) return current注意看for ch in current这一行。我遍历的是旧字符串current替换结果全部追加到新的列表next_str里等整轮遍历结束才让current .join(next_str)。这就是前面说的“同时替换”不能在一个字符串里改一个字符马上又去扫描它那样第二轮会把第一轮刚生成的字符再替换一次等于多迭代了一轮产生式数量对不上图形也会变形。rules是一个字典键是待替换字符值是要替换成的字符串。没有匹配规则的字符比如、-、[、]原样保留。这一点尤其重要这些符号不是摆设它们是后面绘图时的动作指令。2.3 定义绘图解释器接下来写绘图函数。它遍历字符串按字符含义执行动作F向前画一条线段。向左转固定角度。-向右转固定角度。[保存当前位置和朝向压入栈。]从栈弹出之前保存的位置和朝向把海龟移回去。代码是这样的def draw(commands, length, angle): stack [] for ch in commands: if ch F: turtle.pendown() turtle.forward(length) elif ch : turtle.left(angle) elif ch -: turtle.right(angle) elif ch [: stack.append((turtle.position(), turtle.heading())) elif ch ]: pos, heading stack.pop() turtle.penup() turtle.goto(pos) turtle.setheading(heading) turtle.pendown()这里有几个细节必须讲清楚。turtle.position()返回的是海龟当前的二维坐标turtle.heading()返回当前朝向的角度。压栈时把两样一起存恢复时也要一起恢复。我只存坐标不存朝向的教训不是没有经历过——结果就是分支画完之后下一根枝条以错误的角度长出去整棵树变成一团乱麻。[之后不需要抬笔因为进入新枝之后要继续画线。]恢复位置时先用penup()把笔抬起来移动到旧位置后用pendown()重新落笔。如果少了最后这句pendown()后续所有F都是在“空中移动”屏幕上一片空白你还以为代码写错了。打个比方这就好比你在纸上画树画完一个枝丫要把笔尖抬起来挪回刚才的分叉点再换个方向落笔画下一根。栈就是你的记忆帮你记住分叉点在哪、刚才面朝哪。2.4 组合起来跑一次分形树用到的是经典规则公理就一个F产生式稍微长一点tree_rules { F: FF[F-F-F]-[-FFF] }这条规则的意思是一段线段被替换成“两段更短的线段加两个方向相反的小分支”。角度用22.5度迭代深度建议从4开始试。import turtle turtle.setup(1200, 900) turtle.speed(0) turtle.hideturtle() turtle.tracer(0) turtle.penup() turtle.goto(0, -350) turtle.pendown() turtle.setheading(90) commands expand(F, tree_rules, 5) draw(commands, 6, 22.5) turtle.update() turtle.done()初次运行如果画面溢出屏幕优先缩短步长length而不是改坐标。我给这组参数排过一个简单的对照表迭代深度步长建议效果描述410树枝稀疏轮廓清楚适合观察规则56树冠饱满是最容易出效果的一档63很密接近真实树影但绘制变慢调参这件事第一次跑的时候不用追求完美。我当时反复调角度15度、30度、45度各画一遍最后才发现角度决定树冠的开合程度小角度树形挺拔大角度树冠散开。这种经验看文档永远学不会画几遍自然就懂了。3. 换三个经典分形雪花的封闭轮廓、三角的骨架、龙形的拐弯3.1 一套框架三种规则上一节的expand和draw不用改直接换规则就能画出完全不同的图形。这背后就是L-system的魅力绘图逻辑和形态逻辑彻底分离。我把三个经典案例放在一起对比你只需要改公理、产生式和角度。案例公理产生式角度建议迭代深度直观效果Koch雪花F--F--FF - FF--FF604-5每个边不断锯齿化整体仍是封闭环谢尔宾斯基三角形AA - B-A-BB - ABA606-7三角形内部镂空层层缩小龙曲线FXX - XYFY - -FX-Y908-10折线反复折叠形成龙头轮廓3.2 Koch雪花从等边三角形到六角冰晶Koch雪花是L-system最经典的入门案例。公理F--F--F本身就是个大三角形初始朝向朝右画一条边右转120度两次三条边闭合。产生式把每条边中间替换成“一个V字形的小突起”FF--FF的意思就是前进、左转60度、前进、右转120度、前进、左转60度、前进。这样每条原来的直线边都被折成四段面积变大轮廓出现锯齿。koch_rules {F: FF--FF} commands expand(F--F--F, koch_rules, 5) draw(commands, 5, 60)跑完你会发现不管迭代多少次图形始终是闭合的而且整体轮廓还是那个三角形只是边界越来越密。原因很直接产生式只改变了边的形状没有改变起点和终点的位置闭合性自然保留。3.3 谢尔宾斯基三角形字母可以只当“状态标记”谢尔宾斯基三角形的规则很有意思它没有一个字符是直接画线的F而是用两个字母在互相替换sierpinski_rules { A: B-A-B, B: ABA } commands expand(A, sierpinski_rules, 6)绘图时把A和B都当作前进加画线处理向左转60度-向右转60度。这就要稍微改一下解释器把判断条件并进去elif ch in FAB: turtle.forward(length)为什么能达到镂空效果你可以这样理解这条规则把一条线段替换成一个带中间转折的折线但转折点的方向相反导致新生成的图形在某些位置会“折回去”形成三角空隙。这个例子教会我一件事——字母表里的字符不一定都要直接对应绘图动作很多字符只是参与重写、参与状态变化的占位符。这个思想在后面的龙曲线里体现得更明显。3.4 龙曲线占位符只参与生成不参与绘图龙曲线的规则看起来更抽象dragon_rules { X: XYF, Y: -FX-Y } commands expand(FX, dragon_rules, 10)绘图解释器遇到F才画线遇到X和Y直接跳过和-分别左右转90度。这里的关键是X和Y的作用是在字符串生成阶段“记住”当前位置应该怎么拐弯但最终绘图时它们不产生任何动作。这叫占位符机制。为什么需要这种机制因为有些形态的生成单靠F的替换表达不出来。龙曲线的核心是“每次折叠都在上一段方向的垂直方向反转”这个“上一段方向”是动态的只能靠X、Y在生成时把方向信息编码进字符串里。迭代到第10轮左右折线会密集到形成一条龙的侧面轮廓非常神奇。4. 从对称到生命力给分形树加入随机性4.1 确定性规则为什么一眼假你如果用第2节的规则多画几棵树会发现每棵树的朝向、角度、分支疏密完全一样像用圆规画出来的对称图案。自然界里没有这种树。真实植物每一根枝条的长度差一点、角度歪一点大风一吹整个树冠往一侧偏这些微小的扰动才是生命感的来源。确定性L-system适合做几何分形但要想做“像植物”的程序化生成必须引入随机性。4.2 随机产生式同一套规则每棵树都不同随机化的做法是在字符串生成阶段给同一个字符准备多个备选产生式每个带一个概率。比如F有三条规则分别对应不同形态的枝干扩展import random random_tree_rules { F: [ (0.5, FF[F-F-F]-[-FFF]), (0.3, F[F]-[-FF]), (0.2, F[F][-F]), ] } def expand_random(axiom, rules, iterations): current axiom for _ in range(iterations): next_str [] for ch in current: if ch not in rules: next_str.append(ch) continue entries rules[ch] r random.random() acc 0.0 chosen entries[0][1] for prob, rule in entries: acc prob if r acc: chosen rule break next_str.append(chosen) current .join(next_str) return current这段代码判断概率的方式是生成一个0到1之间的随机数然后按顺序累加概率。如果随机数落进某个概率区间就选那条产生式。用random.random()而不是random.randint是为了让概率选择更接近“按比例抽取”也方便将来调整权重。这里有几个坑值得注意。所有概率加起来最好等于1否则当随机数落在所有区间之外程序会走默认分支表现可能跟你预期不符而且很难查出来。调试时可以打印一下选中次数做统计确认三条规则的实际使用比例接近设定值。另外随机选择发生在一轮迭代的最开始一旦选好这一轮的替换就确定了不会有中途变化的可能。4.3 绘制阶段的角度扰动除了让规则本身随机化还可以在绘图阶段给转向加一点噪声。具体做法是把原来的精确角度换成一个小范围波动的角度elif ch : turtle.left(angle random.uniform(-6, 6)) elif ch -: turtle.right(angle random.uniform(-6, 6))这个技巧效果立竿见影角度扰动让枝条不再沿着绝对的22.5度方向生长树冠出现细微的不对称。扰动幅度要克制5到8度之间比较自然如果你开成15度树的骨架会开始扭曲个别枝干甚至反向生长整棵树看起来就“疯”了。我通常的做法是规则随机化决定树的整体形态角度扰动决定树的细节质感。两者配合使用而不是只做一个。4.4 随机不是乱画引入随机之后每棵树都独一无二但整套规则仍然决定“这棵树长什么样”。你把短枝比例调高树就会偏灌木把深分支比例调高树就偏乔木。随机性只是让形态在同一个风格区间内波动不是推倒重来。如果你画出的树左右严重不对称、或者一整侧没有枝条说明产生式的概率设计有问题而不是随机性本身出了问题。想清楚“确定性规则定义风格随机性定义个体差异”这一点程序化生成就算是入门了。5. 实战中的坑迭代爆炸、画布溢出、栈写反了5.1 迭代深度不是线性增长是指数爆炸L-system最隐蔽的坑就是迭代深度的增长方式。以分形树那条规则为例一条F会被替换成一长串包含多个F的字符串下一轮每个新的F又各自替换成同样一长串。粗略算下来字符数量的增长是乘性的迭代8轮轻松变成几十万字符。具体感受一下迭代深度字符串大致长度画线命令数量约画面体验4约几千几百立刻出图很流畅6约十几万上万开始明显等待8百万级几十万卡顿甚至无响应10千万级几百万不建议尝试会卡到怀疑人生解决办法有两个方向。第一预览阶段控制迭代深度分形树最多5到6层先确认规则和角度没问题再考虑更细的层次。第二如果确实需要高深度不要用字符串存全部指令改成递归函数直接绘制边递归边画不生成完整字符串这样内存占用会小很多。这个优化方向是后期进阶的事入门阶段只要记得“深度每加1代价翻几倍”就够了。5.2 turtle显示优化画得慢不是代码问题第一次跑分形树很多人会遇到窗口卡成PPT甚至以为死机了。其实不是代码问题是turtle默认每画一小段就刷新一下屏幕线段数量一多刷新开销就成了瓶颈。三行代码解决turtle.tracer(0) # 关闭逐帧动画 turtle.speed(0) # 最大速度 turtle.hideturtle() # 隐藏海龟图标减少绘制开销关闭tracer之后画布看起来会一直空白这不是卡住是在后台闷头画。全部画完以后调一次turtle.update()整幅图会一次性刷新出来。注意tracer(0)和update()必须配对使用忘了update()就只能看到一片空白。如果想保存图片可以用一行代码导出矢量格式turtle.getcanvas().postscript(filetree.eps)这个格式不需要额外安装库文件也能被很多工具识别。后续你可以再转成其他格式我就不展开讲了。5.3 中括号栈的常见错误画出来一团乱麻中括号机制是分形树的核心也是最容易出bug的地方。常见症状有两个第一个症状是报错IndexError: pop from empty list。原因是字符串里出现了多余的]。出现这种情况先别急着看绘图代码回到字符串生成阶段检查在expand结束后打印commands.count([)和commands.count(])如果数量不一致问题大概率出在产生式规则写错了。第二个症状是画面不报错但有枝条之间出现莫名其妙的连接线。这种问题几乎都是恢复状态时没有落笔pendown()。海龟从旧位置飞回新位置的那段距离如果笔没落下来不会画线但如果恢复后忘了落笔后面所有F都变成“空中滑行”屏幕上一段线都长不出来。排查方法也很粗暴只在draw函数里留下F、、-删掉所有[、]处理画一遍看基本形状对不对然后再加回分支逻辑逐步缩小问题范围。5.4 画布位置不规划树冠一半在屏幕外调整初始位置是新手很容易忽略的一步。默认海龟从屏幕中央开始、朝右画这画出来分形树肯定是歪的。我的做法是把海龟移到画布偏下方然后朝上turtle.penup() turtle.goto(0, -350) turtle.pendown() turtle.setheading(90)setheading(90)让海龟面朝正上方树的主干从下往上长树冠尽可能保持在画面里。画完如果树冠溢出顶部缩短步长如果树冠偏左或偏右微调初始goto的 x 坐标比如改成(30, -350)。不要一开始就开setworldcoordinates强行拉伸坐标系那会把画布比例搞乱后续调步长会很别扭。5.5 调试技巧永远先跑小迭代我的自查顺序是迭代1层看字符串迭代2层看图形迭代4层看形态。从第1层就能看出公理对不对第2层能看出规则方向有没有写反第4层才开始感受真实效果。一上来直接迭代6层既慢又难定位问题。你可以在字符串生成后打印commands[:80]肉眼快速扫一遍确认没有不对的字符组合再决定要不要画图。6. 还能玩到什么程度从二维树到程序化生成6.1 用深度信息给树枝上色画完黑白线条之后下一步可以给树枝加颜色。最简单的方式是跟踪海龟进入括号的深度每遇到一个[深度加1每遇到一个]深度减1。深度越深枝条越细颜色越浅这正好模拟植物从深色主干到浅色末梢的渐变。depth 0 for ch in commands: if ch [: depth 1 elif ch ]: depth - 1 elif ch in F: color (0.3 0.05 * depth, 0.6 - 0.04 * depth, 0.2) turtle.pencolor(color) turtle.forward(length)这里给的是一个思路不一定照抄。你可以根据自己审美调颜色区间也可以把颜色值换成HSL模型效果更柔和。关键是理解[深度代表“层次越深越细小”颜色和步长都可以随深度变化。6.2 二维分形图形只是开始掌握了二维L-system后面能延伸的方向很多。常见思路是把、-从“绕竖直轴旋转”扩展成“绕三维空间任意轴旋转”每个符号对应一个旋转矩阵。这样箭头标记保存的三元组坐标就变成了三维向量同样一套L-system规则可以生成三维植物这也是很多程序化建模工具背后的原理。游戏行业里有不少植被资源就是用这套思路批量生成的同一套规则配不同随机种子瞬间长出几千棵形态不一的树。你入门阶段不需要一上来就做三维把二维规则玩透理解字符串重写和状态栈等你需要时再上手三维只是加个坐标维度而已。6.3 用规则库的思路积累自己的分形图形库熟练之后可以把自己写过的规则整理成一个字典每个分形图形一条记录公理、产生式、角度、推荐步长、推荐深度。这样以后再想画分形图形不用翻代码直接查表调用。我自己的规则库里到现在还留着当初调过的几十条废规则有些角度值已经忘了当初为什么那么设但一画出来还是能勾起当时的调试记忆。LS文法这个项目给我最大的收获不是“会用turtle画图”而是建立了“先设计结构再写代码”的思维习惯。字符串重写和栈操作这些概念在枯燥的语法练习里很难真正理解但当你亲眼看到一条规则迭代六次长成一棵树之前所有的困惑都会变成肌肉记忆。如果你正在自学Python认真把这个项目从头到尾跑一遍比刷几十道语法题管用得多。