递归不是玄学,它是一种把大问题拆成形状相同、规模更小的子问题的思维习惯。而谢尔宾斯基地毯、谢尔宾斯基三角形、分段线,正好是理解递归最直观的“视觉化教材”——你不需要去背复杂的递推公式,盯着一张图不断放大,看到它每一部分都和整体长得一模一样,递归的“分治”本质就自然浮现了。
这篇文章写给三类人:刚学递归但总在“递推公式”里绕晕的初学者、需要给学生或组员讲清楚“递归和分形关系”的开发者、以及想在 Canvas 或前端页面上快速实现分形效果的爱好者。我会直接从一个最朴素的递归函数讲起,然后分别拆解三种经典分形的构造逻辑、代码实现和避坑要点,全程用 JavaScript Canvas 来验证效果。
1. 内容整体设计与思路拆解
1.1 为什么用递归画分形是“天然匹配”的
分形的核心定义是自相似:整体放大后,局部和整体具有相同的形状或统计特征。谢尔宾斯基三角形和地毯就是典型的严格自相似图形——一个大三角形由三个小三角形构成,每个小三角形又由三个更小的三角形构成,无限迭代下去。
递归函数的核心特征是什么?函数调用自身,并且每次调用的规模都在缩小。这跟自相似结构的生成逻辑几乎是逐字对应的:画一个谢尔宾斯基三角形,本质上就是“画三个更小的谢尔宾斯基三角形”。你不需要手动计算每一个小三角形的位置,只需要定义“当前三角形怎么拆”,把剩下的事交给递归栈去调度。
拿现实中的例子打个比方:你要给一群人通知开会,不想一个一个喊,就只通知几个小组长,让小组长再通知各自的组员。每个“小组长”做的事就是“把通知传给他的下一级”,而“下一级”又做同样的事。递归分形也一样,每个层级只关心自己这层的拆分规则,下一层由下一层的调用自己去处理。
1.2 三种图形的递推关系对比
很多文章把三者分开讲,容易让人觉得是三个孤立问题。实际放到一起,结构共性非常清楚:
| 图形 | 基本拆分规则 | 每次递归调用数 | 形状特征 |
|---|---|---|---|
| 分段线(康托尔集) | 一条线段去掉中间三分之一,保留左右两段 | 2 | 一维线条,离散状 |
| 谢尔宾斯基三角形 | 取三边中点连接,挖掉中间倒三角形,保留三个角上的三角形 | 3 | 二维三角填充,空隙率递增 |
| 谢尔宾斯基地毯 | 正方形的九宫格去掉中心格,保留周围八格 | 8 | 二维方形,格状自相似 |
注意一个细节:调用数就是下一层的数量,而每层的边长/尺寸缩放比例是由几何规则决定的。三角形的缩放是 1/2(每个保留的小三角边长是原来的 1/2),地毯的缩放是 1/3(每个保留的小方格边长是原来的 1/3),分段线的缩放同样分两种:线段长度缩放 1/3,但数量乘以 2。
这三个例子从左到右,恰好对应递归“分叉”的三种经典形态:二分支、三分支、八分支。看懂了这三个,再看二叉树递归、四叉树分割、八叉树空间划分,基本都是一路货色。
1.3 迭代深度与“无限”的实际边界
理论上分形要无限迭代才成立,但计算机不可能无限画下去。这里涉及递归深度、绘制调用次数、渲染性能三者之间的平衡。
以谢尔宾斯基三角形为例,递归深度 n 层时,需要绘制的三角形个数是 3^n。深度 5 就是 243 个,深度 10 是 59049 个,深度 15 是 14348907 个——直接突破千万级。而每帧渲染这么多三角形,Canvas 的路径绘制未必扛得住。
实际使用中,深度控制在 5~8 会有一个很好的视觉效果与性能平衡。地毯每次调用是 8 个分支,数量膨胀得更快,8^n 的曲线比 3^n 陡峭得多,所以地毯的深度一般控制在 3~5 层就足够了。分段线相对温和,2^n 的增长,画到 12 层也就四千多条线段,浏览器无压力。
2. 核心细节解析与实操要点
2.1 递归终止条件的把控
画分形最容易犯的错误不是递归写不出来,而是终止条件设错导致栈溢出。栈溢出的本质是函数无限调用自身,直到内存耗尽。
终止条件有三个常用判断维度:
- 按深度:
depth <= 0停止递归,最常用,也最容易预测绘制量。 - 按尺寸:
width < 3或height < 3停止,适合在屏幕上动态观察缩放效果。 - 按面积:
w * h < MIN_AREA停止,适合需要保证最小视觉粒度的场景。
实际项目里我会优先用深度,因为它跟上面的 3^n、8^n 函数直接挂钩,你可以提前算出最坏情况的绘制量,避免页面卡顿。尺寸判断更适合做“自适应缩放”,比如用户把窗口拉大后自动增加层级。
一个容易被忽略的点:终止条件的位置要放在函数最前面,先判断再处理。有不少人习惯先计算中点、再做判断,结果在递归出口那层多做了好几次无用的数学运算,虽然在分形这类简单场景影响不大,但养成“先出口后逻辑”的习惯,对以后写更复杂的递归(比如树形数据结构遍历)有好处。
2.2 坐标计算中的精度陷阱
画分形,坐标计算全是浮点数运算。JavaScript 里浮点误差在 10 层以内几乎看不见,但如果你做“动态逐层放大”交互,放大倍数很高时,误差会被指数级放大。
举个例子:三角形取中点时,某次计算得出 0.3333333333333333,下次再取中点,可能变成 0.16666666666666666。肉眼不可见,但如果你的代码里写死了某个坐标值去判断是否重合,就可能出错。
我踩过这样一个坑:实现地毯时,用“中心点坐标偏移量”来定位九宫格,计算时多次四舍五入,低层递归生成的点出现微小偏移。看起来是几个像素的差距,但高分辨率截图后就会发现图形边缘不齐。
规避方法是:
- 不要逐层四舍五入,让浮点误差自然累积,画布会在最终绘制时自行抗锯齿。
- 直接用比例计算,比如子网格的 x 坐标始终用
x + (i * unitWidth * depthFactor)推导,而不是基于上一层的“结果坐标”再来回相加。 - 使用整数坐标和位运算辅助分块,比如用
(x / 3) | 0代替浮点除法,这对地毯这类网格分形很有效。
2.3 分层绘制策略
前文提到过绘制性能问题。分形的自相似性带来一个额外好处:可以先画底层的粗糙图形,再逐步叠加高层细节。
比如画地毯,可以先画深度为 2 的图形作为预览,等用户停止调节深度参数后,再画深度为 6 的完整图形。这样 UI 响应不会卡顿。配合requestAnimationFrame做逐帧加深,还能做出一层层“生长”的动效,观感上非常加分。
对 Canvas 绘制还有一些基础优化:
- 使用
ctx.beginPath()一次绘制多个子图形,不要每个图形单独开辟路径。 - 对同色填充,合并路径后再统一
ctx.fill()。 - 如果只需要展示黑色镂空效果,不用实际绘制所有小方块,可以反着画:先画一个大正方形,再在上面“挖洞”。
第三条值得多说两句。谢尔宾斯基地毯的视觉是“大正方形内部不断挖去中心小块”。正向递归写法是递归绘制周围 8 个格子,但还有一种反向写法:从黑色背景开始,每次递归在对应位置画一个白色方块(即被挖掉的部分)。这种反写法只需要递归绘制 1 个点,而不是 8 个点,性能差了一个数量级。代价是逻辑上多绕一层——你需要清楚知道当前递归层级挖掉的是哪个位置的方块。
3. 实操过程与核心环节实现
3.1 环境准备
为了快速验证所有效果,直接用一个 HTML 文件,引入 Canvas,用 JavaScript 纯浏览器运行,不需要任何第三方库。
原型验证环境: - HTML + Canvas 2D - 纯 JavaScript,无外部依赖 - Chrome 浏览器 F12 开发者工具辅助调试 - 可选:Vue/React 项目里抽成独立 component如果是在 Vue 或 React 项目里做,建议把分形绘制逻辑封装成一个纯函数,接收ctx、坐标、深度作为参数,便于在useEffect或watch中调用重绘。
3.2 谢尔宾斯基三角形的递归实现
先看最容易上手的三角形。核心思想:已知一个三角形的三个顶点 A、B、C,取三边中点,得到三个新的小三角形,分别以 A、B、C 为其中的一个角顶,中间被挖掉的倒三角形不画。
用一个标准的drawTriangle函数接收三个顶点和递归深度:
function drawSierpinski(ctx, x1, y1, x2, y2, x3, y3, depth) { if (depth === 0) { // 画当前这个三角形 ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x3, y3); ctx.closePath(); ctx.fill(); return; } // 计算三条边的中点 const ax = (x1 + x2) / 2; const ay = (y1 + y2) / 2; const bx = (x2 + x3) / 2; const by = (y2 + y3) / 2; const cx = (x3 + x1) / 2; const cy = (y3 + y1) / 2; // 递归绘制三个小三角形 drawSierpinski(ctx, x1, y1, ax, ay, cx, cy, depth - 1); drawSierpinski(ctx, ax, ay, x2, y2, bx, by, depth - 1); drawSierpinski(ctx, cx, cy, bx, by, x3, y3, depth - 1); }外部调用时,用初始大三角形:
const canvas = document.querySelector('#canvas'); const ctx = canvas.getContext('2d'); ctx.fillStyle = '#000'; // 假设画布 800x600,画一个等边三角形放置在中央 const topX = 400, topY = 50; const leftX = 100, leftY = 550; const rightX = 700, rightY = 550; drawSierpinski(ctx, topX, topY, leftX, leftY, rightX, rightY, 6);关于绘制效果:这段代码最终得到的是实心三角形填充,中间那些空白区域是因为递归到深处时没有三角形被填充,自然形成了黑洞。你不需要手动“挖洞”,这是递归推演的一个精妙之处——所有镂空效果都是不绘制的结果,而不是擦除的结果。
这里有个细节值得留意:因为每次递归都调用了三次drawSierpinski,而深度为 0 时执行一次beginPath、fill,所以整棵树一共会调用3^depth次beginPath。如果深度设为 8,就是 6561 次路径开始和填充。单次看并不高,但如果配合动画循环就会很费。想优化的话,可以做一个“先四面连通”的填充方案:不逐个小三角形填充,而是把所有当前应填充的三角形点都压入同一条 Path 中,最后统一填充。实现起来也不复杂,用一个数组收集所有三角形坐标,递归完成后遍历画路径。
3.3 谢尔宾斯基地毯的两种写法
地毯比三角形多了一个维度的分块,基础网格是九宫格。假设当前矩形区域是(x, y, w, h),九宫格中每个小格子的宽是w/3、高是h/3。中心格子的范围是:
const cellW = w / 3; const cellH = h / 3; const centerX = x + cellW; const centerY = y + cellH;正向写法(递归画周围 8 格):
function drawCarpet(ctx, x, y, w, h, depth) { if (depth === 0) { ctx.fillRect(x, y, w, h); return; } const cellW = w / 3; const cellH = h / 3; // 遍历九宫格,跳过中心格 for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { if (i === 1 && j === 1) continue; drawCarpet(ctx, x + i * cellW, y + j * cellH, cellW, cellH, depth - 1); } } }这段代码出来后,视觉上已经能看到地毯形状。中心始终留着空洞,外围八格继续递归。
反向写法(每次只挖中心格):
// 先画一个完整大正方形背景,然后递归挖洞 function drawCarpetHole(ctx, x, y, w, h, depth) { if (depth === 0) return; const cellW = w / 3; const cellH = h / 3; const centerX = x + cellW; const centerY = y + cellH; // 把中心格用背景色盖掉 ctx.clearRect(centerX, centerY, cellW, cellH); // 递归处理周围 8 格 for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { if (i === 1 && j === 1) continue; drawCarpetHole(ctx, x + i * cellW, y + j * cellH, cellW, cellH, depth - 1); } } }但这里要说明:反向写法的递归调用数并没有减少,仍然要递归 8 格。它唯一的优化是省掉了深处小方块的fillRect,因为深层的中心洞都被clearRect提前挖走了。实际性能提升有限,真正的大优化在于“只计算被挖掉的洞,然后一次性 clearRect”。
我实际应用里最常用的做法是:收集所有洞孔的坐标数组,最后统一绘制。递归函数只负责把当前层级的中心洞坐标推到数组里,核心逻辑变成“生成洞列表”,绘制放在递归结束之后。这样即使深度设到 8 以上,渲染部分也能保持稳定。代码大概长这样:
function collectCarpetHoles(x, y, w, h, depth, holes) { if (depth === 0) return; const cellW = w / 3; const cellH = h / 3; const cx = x + cellW; const cy = y + cellH; holes.push({ x: cx, y: cy, w: cellW, h: cellH }); for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { if (i === 1 && j === 1) continue; collectCarpetHoles(x + i * cellW, y + j * cellH, cellW, cellH, depth - 1, holes); } } }这样,递归与绘制完全解耦。你可以在 Worker 线程中计算 holes 列表,主线程只负责按列表画矩形,性能体验会好非常多。
3.4 分段线的递归实现
分段线是三个分形里最不起眼但最有教学价值的。它也叫康托尔集。规则很简单:取一条线段,分成三段,去掉中间那段,保留左右两段。然后对保留的两段分别递归。
代码逻辑比较直接:
function drawCantor(ctx, x1, x2, depth) { const len = x2 - x1; if (depth === 0) { ctx.beginPath(); ctx.moveTo(x1, 0.5); // 基准线 y=0.5,这里用常量示意 ctx.lineTo(x2, 0.5); ctx.stroke(); return; } const third = len / 3; // 左段 drawCantor(ctx, x1, x1 + third, depth - 1); // 右段 drawCantor(ctx, x2 - third, x2, depth - 1); }当然,实际画的时候不会让所有线段挤在同一水平线上,不然只能看到一堆重叠的横线。通常的做法是:每一层递归向下偏移一段距离 y += offset,形成“阶梯状”的分形图。下移量一般为当前线段高度的两倍左右。
常见的视觉呈现是:
- 第 0 层:一条长度为 L 的横线。
- 第 1 层:两条长度为 L/3 的横线,位于第二条横线下方。
- 第 2 层:四条长度为 L/9 的横线,进一步下移。
这个坐落在二维平面上的效果,能直观展示“每次去掉中间一块”,如果你调整参数“去掉的不是中间 1/3,而是 1/4 或 1/5”,图形的疏密间隔就会改变,这是最容易做参数实验的一个分形。
3.5 代码整合与交互增强
把三个图形放到同一个页面,用一个切换器控制显示哪种分形,是很好的演示方案。我习惯这样组织:
<canvas id="canvas" width="800" height="600"></canvas> <select id="typeSelect"> <option value="triangle">谢尔宾斯基三角形</option> <option value="carpet">谢尔宾斯基地毯</option> <option value="cantor">分段线</option> </select> <input type="range" id="depthRange" min="0" max="8" value="5">所以切换和重绘逻辑就集中在render()方法里,按当前选择调用不同绘制函数。深度 slider 的max值可以按需调整:三角形撑到 8 没问题,地毯 8 已经很细密,分段线 8 也只有 256 条线段,视觉上会很舒朗。
4. 常见问题与排查技巧实录
4.1 递归层级过深导致栈溢出
这是新手最容易踩的坑,症状是页面直接崩溃或控制台报RangeError: Maximum call stack size exceeded(不同浏览器提示略有差异)。
原因通常是depth没有递减,或者终止条件写成了depth > 0才递归,而depth初值传了负数。排查思路很明确:打印 depth 的日志。在递归函数开头加一句控制台输出(只在开发阶段加),观察 depth 是否逐层减小到 0。
有一个更隐蔽的情况:递归深度本身合理(比如 20),但每个递归函数内部还做了大量同步计算,导致调用栈过深。这种问题在分形绘制里很少见,但在复杂数据处理场景常见。解决办法有两个:把递归改成迭代(用显式栈),或者用 Web Worker 异步处理。
实际我很少因为绘制分形而栈溢出,因为上面说过的原理——8 层就是千万级绘制量,根本到不了 20 层就已经卡死了。所以分形栈溢出的主因,99% 是代码 bug,而不是深度太大。
4.2 绘制空白或消失
可能出现这种情况:函数执行了,但屏幕上什么都看不到。常见原因有三个:
- 坐标超出了 canvas 范围。比如初始三角形顶点不在画布内,或者递归计算产生 NaN 坐标。
- strokeStyle/fillStyle 颜色设置为白色或透明。
- 没有调用
ctx.fill()或ctx.stroke()。这是最简单的低级错误——路径建立了,但忘了填充或描边。
我建议调试时先在depth = 0的情况下渲染单个基础图形,确认能画出。然后再慢慢增大深度,观察边界问题。这样定位问题的范围会被大幅缩小。
4.3 页面卡顿与性能优化
一旦深度较大,页面会掉帧甚至假死。前面已经提过“收集洞列表再绘制”的方案,这里再补充一个优化点:不要在动画循环中做递归计算。
如果你要做分形动画(比如让深度逐步加深的动态效果),可以:
- 把每一层深度的图形预先离线渲染到离屏 canvas(OffscreenCanvas),切换时直接
drawImage。 - 或者用“最高深度递归一次,结果缓存为图片”,参数变化时重新渲染,但渲染期间 show 一个 loading 状态。
实测下来,离屏渲染 + 缓存深度方案可以把交互帧率从个位数提升到 60 帧稳定。代价是内存占用增加——但一张 800x600 的分形图点阵撑死也就几 MB,远比持续重新计算划算。
4.4 坐标误差导致图形不闭合
这种问题在三角形分形中比较容易观察:放大后某些小三角形与其他小三角形之间有细微裂缝或重叠。
原因前面说过,是浮点累积误差。解决方法是按 1/2、1/3 这种“整数比”计算,避免非整除的尺寸。如果画布宽度不是 3 的倍数,w/3往往是无限小数。这时可以做一个“坐标对齐”操作:
function floorDivision(value, divisor) { return Math.floor(value / divisor) * divisor; }对所有中间点坐标做向下取整,能让图形边缘对齐。代价是图形会缺失个把像素的精确度,但在视觉呈现上几乎无感。
4.5 深浅颜色渐变效果
有时候在演示中给不同深度的三角形填充不同颜色,能显著增强自相似结构的辨识度。实现方法并不复杂:给递归函数加一个colorIndex参数,或者直接用depth计算颜色。
const hue = (depth * 30 + baseHue) % 360; ctx.fillStyle = `hsl(${hue}, 70%, 50%)`;用 HSL 的好处是颜色连续变化,而且不同层级的色相差异大,能一眼看出递归分了几层。很多分形网站上的“彩虹分形”就是这么实现的。这个技巧只影响视觉层,不改变递归逻辑,很适合新手体验“递归 + 渲染参数”之间的相互作用。
5. 进阶扩展与经验心得
5.1 从二维到三维:体素化的地毯
谢尔宾斯基地毯有一个很自然的 3D 扩展:把二维的九宫格换成立方体的三阶体素网格(每个方向三等分,共 27 个小立方体),去掉中心的那个小立方体,对剩余 26 个递归。这就是门格海绵(Menger Sponge)。
如果你已经实现了二维地毯的递归,三维版只是把“遍历 3x3 跳过中心”改成“遍历 3x3x3 跳过中心”,其余思想完全一致。我在做体素场景原型时,就是直接复用二维地毯的洞收集思想,把洞口坐标换成 3D 坐标,然后用单位立方体拼接渲染。
5.2 用递归生成 SVG 矢量分形
Canvas 之外,还可以把递归结果输出为 SVG。SVG 的好处是无限缩放不模糊,适合打印、海报设计或文档插图。实现思路是:递归函数不再是往ctx上画,而是输出 SVG 标签字符串,或者直接操作 DOM 节点。
function sierpinskiSVG(x1, y1, x2, y2, x3, y3, depth) { if (depth === 0) { return `<polygon points="${x1},${y1} ${x2},${y2} ${x3},${y3}" fill="black"/>`; } // 计算中点递归拼接字符串 }需要注意:字符串拼接方式在深度较大时会产生超大 DOM 字符串,建议最终一次性注入,不要频繁操作 DOM。深度 8 的三角形 SVG 文件大小很可观,但用来做静态矢量图完全没问题。
5.3 递归的另一种理解:从“画图”到“数据处理”
最后分享一个真实体会:很多人学递归时,会尝试背诵“递归三要素”——终止条件、递归调用、问题拆分。这没错,但一旦题目抽象(比如树遍历、排列组合),就不知道怎么套了。我经过几种分形实现后,发现递归的关键在于当前层只负责当前层的“确定性任务”,下一层是一个“假定已经做好的黑箱”。
就像谢尔宾斯基三角形的实现:当前层只需要确定三个中点和递归三个小三角形,至于左下角那个小三角形里面又被切成什么样,完全不用我操心,下一层会自己处理。这是一种极其省力的思考方式,也是分形能让人快速产生“递归直觉”的原因。
如果你还在为递归头疼,我的建议是先别刷题,去把谢尔宾斯基三角形实现一遍,加上深度 slider 和颜色渐变,然后地毯,然后分段线。等这三个图形都能流畅画出来,再去碰二叉树的递归遍历,你会发现自己突然“开悟”了——因为树的遍历本质上就是另一种分形:每个节点都像一个小三角形,递归处理子节点,就像递归处理子三角形。
这几年陆陆续续给不少人讲过递归,发现大家理解上的瓶颈不是语法,而是“脑子里没法想象递归的执行过程”。分形图形正好提供了这个“可视化思维工具”:每次递归调用的树上,每个节点都对应图上的一小块,图形画到哪里,递归就走到哪里。一旦视觉和代码对应上,理解就容易多了。这个思路也适合用在自己排查表达式求值、目录遍历这类递归程序时对照调试。下次卡在递归逻辑上,不如先把当前问题画成一棵“形状相同、规模递减”的图,很多坑一眼就看清了。