☰
递归函数实战:用Python实现康托尔集与分形可视化
2026/10/5 11:01:09 网站建设 项目流程

最近有朋友学算法卡在递归上,问我有没有什么题目,既能把递归讲透,又不至于复杂到劝退。我第一个想到的就是康托尔集。原因特别简单:这个数学对象天生自带“递归基因”。一段线段切掉中间三分之一,剩下两段各自再切掉各自中间的三分之一,无限重复下去——这句话连续读三遍,递归函数的骨架就已经在脑子里出现了。这篇就来聊聊我用递归函数实现康托尔集的全过程,包括两种典型写法、可视化调试方法,以及几个特别容易踩的坑。不管你是刚接触递归的新手,还是想拿分形练手的爱好者,跟着走一遍都不会亏。

1. 康托尔集和递归函数:为什么这对组合天生一对

1.1 康托尔集:一块被无限“删减”的线段

康托尔集是数学里一个很有意思的构造。拿出一条长度为 1 的线段,从 0 到 1。第一次操作,把中间三分之一挖掉,也就是把开区间 (1/3, 2/3) 去掉,剩下 [0, 1/3] 和 [2/3, 1] 两段。第二次操作,对剩下的每一段再各自挖掉中间三分之一。第三次继续对每一段重复同样的动作,无限次操作之后,最后留下的点集,就是康托尔集。

听起来像把一块饼干按“烙掉中间”的方式无限细分,最后好像什么都没剩下。但实际上康托尔集“剩”得相当多:它包含的点的数量是不可数的,比自然数还要多。更反直觉的是,所有被挖掉的空隙总长度加起来是 1,所以康托尔集本身的“长度”是 0。一边是数量多到离谱,一边是长度小到为零,这正是它迷人的地方。

如果你对二进制有感觉,还可以从另一个角度理解:康托尔集里的点,写成三进制小数时,小数位只包含 0 和 2,不会出现数字 1。这个性质后面写代码时不一定用得上,但能帮你建立“集合”和“位串表示”之间的直觉。

1.2 递归的直觉:把同一个操作不断地套娃

递归的本质,是函数在处理一个小一号的“同类问题”时调用自己。判断一个问题适不适合递归,最直接的信号就是:它有没有“自相似”的结构。所谓自相似,就是整体的一部分,经过缩放之后和整体长得一样。康托尔集就是这样:你随便截取一个保留区间,把它放大三倍,看到的还是康托尔集的构造过程。子问题和父问题结构相同,只是尺度缩小,这不就是递归函数最舒服的施展场景吗?

从执行过程看,康托尔集的每一次构造都对应一棵“树”:根节点是整条线段,两个子节点是左右两段保留区间,每个子节点下面又各自挂两个孙节点。这棵树是一个完美的二叉树。递归函数处理这棵树的方式,就是典型的深度优先遍历:先处理左子树,再处理右子树,逐层深入。你平时打印目录树、遍历二叉查找树,底层做的其实是同一件事。所以练康托尔集,练的不只是数学,更是把“递归思维”扎进肌肉记忆里。

2. 写递归前,先把康托尔集的数学规则翻译成代码

2.1 递归三要素:终止、递推、状态传递

任何递归函数,我心里都会拿着三个问题去过一遍:终止条件在哪?递推关系怎么写?递归时上下文怎么往下传?三个问题缺一个,代码写完不是栈溢出就是结果错。

康托尔集的终止条件非常明确:递归深度到了我们约定的层级,就停下来。深度为 0 时,不执行任何挖空,直接返回整段区间;深度大于 0 时,才继续分裂。为了避免传入负数时出现边界问题,我习惯写成depth <= 0而不是depth == 0。虽然正常情况下你不会传负数,但代码多一道防护,永远不吃亏。

递推关系是康托尔集的核心。对一个从 start 到 end 的区间来说,先用length = end - start算出区间长度,然后左侧保留区间就是[start, start + length/3],右侧保留区间是[end - length/3, end],中间那段(start + length/3, end - length/3)就是被挖空的部分。状态传递则把这两个子区间和新深度depth - 1一起交给下一层递归。这一步本质上就是“把问题缩小一号再扔回自己”。

2.2 一次“三分操作”在代码里长什么样

把上述逻辑落到函数上,第一版通常是这样。这里我用 Python 演示,逻辑在其他语言里完全一样:

def cantor_segments(start, end, depth): # 深度已经耗尽,当前区间是最终保留区间 if depth <= 0: return [(start, end)] length = end - start # 左段和下段的起止坐标 left_seg = (start, start + length / 3) right_seg = (end - length / 3, end) # 递归处理左右两段 left = cantor_segments(start, start + length / 3, depth - 1) right = cantor_segments(end - length / 3, end, depth - 1) return left + right

这里有一点要特别说明:上面代码里我先把坐标算出来,又写了两行看起来冗余的变量left_seg和right_seg,其实是想让你看清坐标关系。实际精简时,可以直接把表达式写进递归调用里。但无论怎么精简,核心只有一个:每层递归永远只处理“自己这一层”的挖空,并把两侧剩余区间继续递交下去。

2.3 如果不让我用递归,我会怎么写

你可能会想:不用递归,用循环也能做吧?确实能,但写起来那个别扭感特别明显。迭代版本要么维护一个队列,要么维护一个栈,每次从里面取一段区间,算出左右两段再塞回去,直到所有区间都处理完。代码如下:

from collections import deque def cantor_iter(start, end, depth): queue = deque([(start, end, depth)]) result = [] while queue: a, b, d = queue.popleft() if d <= 0: result.append((a, b)) continue length = b - a queue.append((a, a + length / 3, d - 1)) queue.append((b - length / 3, b, d - 1)) return result

逻辑没问题,最终结果也和递归版一致。但你对比一下就会发现,递归版把“待处理区间”这个中间状态直接藏在调用栈里,你不需要手动管理队列;迭代版则必须自己记录所有任务。对于“每次对区间做同样的事”这类自相似问题,递归的抽象层次明显更高。当然,迭代也有它的价值,这点我在第 6 章再展开聊。

3. 两种实战写法:字符串版和线段坐标版

3.1 字符串版:打印一行,就能看到递归长出来

如果你的目标不是做计算,而是先理解递归过程,我强烈建议先写一个“字符串版”的康托尔集。它把区间可视化成字符,一次递归调用就对应一行输出,非常直观。

思路是这样的:字符串里的下划线_表示保留区间,空格表示已经被挖掉的空洞。深度为 0 时,返回一个下划线。递归时,遍历上一层的每个字符,如果字符是下划线,就把它替换成"_ _",也就是“保留左段 + 挖掉中段 + 保留右段”;如果字符是空格,就把它替换成三个空格,表示空洞区域继续扩大但不恢复。

def cantor_string(depth): if depth <= 0: return "_" prev = cantor_string(depth - 1) chars = [] for ch in prev: if ch == "_": chars.append("_ _") else: chars.append(" ") return "".join(chars)

调用cantor_string(2),会得到类似"_ _ _ _"的字符串,中间有三个连续空格,一眼就能看出这段是上一轮挖掉的中段。深度每加一,字符串总宽度变成原来的三倍,被挖空的区域也会按比例膨胀。这种写法虽然不能直接用于测量坐标,但对理解“每一层递归到底做了什么”非常有效。

3.2 线段坐标版:为画图和进一步计算打地基

字符串版适合入门,真正要拿康托尔集做图形渲染、算区间长度,或者做更进一步的分形项目,就用线段坐标版。它直接返回所有保留区间的起止坐标,一套数据能喂给画布、SVG、matplotlib 都可以。

刚才第 2.2 节已经给了第一版,这里我想再补充一个“递归 + 层级记录”的写法。有时候我们想画每一层的状态(深度 0 的整段、深度 1 的两段、深度 2 的四段……),这时候只需要在递归函数里多带一个层级数组:

layers = [] def cantor_layers(start, end, depth, level=0): if len(layers) <= level: layers.append([]) layers[level].append((start, end)) if depth <= 0: return length = end - start cantor_layers(start, start + length / 3, depth - 1, level + 1) cantor_layers(end - length / 3, end, depth - 1, level + 1)

注意,这个版本的终止条件判断放在记录区间之后,所以即使 depth 已经为 0,也会先把当前区间记进层列表,再结束递归。这种写法非常适合可视化:layers[0]是整段,layers[1]是两段,依次类推,画图的时候逐层往下排就行。

3.3 深度参数怎么选:数字背后的关系

深度是康托尔集实现里最需要拿捏的参数。深度是 1 时,输出就是两条线段,看不出什么名堂;深度到了 3、4,中间的空洞开始有层次感;深度到 6、7,图形已经明显带有分形的味道。但如果继续增大,问题也来了:保留区间数量是 2^depth 个,最小区间的宽度是 1/3^depth。当这个宽度小于屏幕上单个像素的时候,你画出来的图就会糊成一片。

所以实践里怎么选深度,我一般这样判断:先确定渲染区域的宽度 W,保证3^depth不超过 W,否则最小区段在这个分辨率下已经不可见。比如一张 800 像素宽的图,深度最大大概取 6,因为 3^6 = 729,还能区分;3^7 = 2187,超过宽度,细节就丢了。终端里打印字符串也同理,depth=6时字符串长度是 729,刚好能在一行里放下;depth=8时已经是 6561 个字符,观感就很差了。

3.4 递归调用次数和复杂度估算

写递归之前先估算复杂度,能让你后面少踩很多性能坑。康托尔集的递归过程是一棵满二叉树,树的深度是 n,那么总的递归调用次数是 2^(n+1)-1,保留区间个数是 2^n。递归本身在调用栈上的深度是 n,所以空间复杂度是 O(n)(不计返回值本身)。

举例来说,depth=10时递归调用次数约 2047,看起来不痛不痒。但depth=20时调用次数超过 200 万,depth=30时超过 21 亿,这就不是玩具项目能随便承受的量级了。如果要做高深度可视化,必须提前意识到输出数据量会指数爆炸。我曾经为了画一张高清康托尔图直接跑到 2GB 内存,就是没算这笔账。

4. 可视化排错:把递归过程变成肉眼可见的图

4.1 用递归直接画图:几分钟做出经典分形

写代码调试递归,眼睛盯着终端看数字总归不够直观。我习惯直接把图形画出来。这里给一个用 matplotlib 实现的版本,递归函数里直接调plot,画出来的就是经典的逐层阶梯式康托尔图:

import matplotlib.pyplot as plt def draw_cantor(ax, start, end, depth, y): if depth <= 0: ax.plot([start, end], [y, y], color="black", lw=4) return length = end - start draw_cantor(ax, start, start + length / 3, depth - 1, y - 1) draw_cantor(ax, end - length / 3, end, depth - 1, y - 1) fig, ax = plt.subplots(figsize=(8, 4)) draw_cantor(ax, 0, 1, 5, 0) ax.axis("off") plt.show()

这段代码的核心思路是:每个递归分支只画自己当前这一层能够看到的线段,然后继续往下递归。y 轴每次减 1,让每一层天然错开,视觉上形成了一个像宝塔一样的结构。跑完你会发现,图形每一行都比上一行多出两倍的短线,空洞也越来越密集,这就是康托尔集的形貌。

4.2 打印递归树:一目了然看执行顺序

有时候画图太慢,或者你只想确认函数执行顺序对不对,那就直接打印递归树。我调试递归时最常用的方式,是在函数开头打印当前区间和缩进:

def debug_cantor(start, end, depth, indent=0): print(" " * indent + f"depth={depth}, 区间=[{start:.4f}, {end:.4f}]") if depth <= 0: return length = end - start debug_cantor(start, start + length / 3, depth - 1, indent + 1) debug_cantor(end - length / 3, end, depth - 1, indent + 1)

输出会呈现清晰的树状结构:根节点在最左边,然后一层层缩进,先是左侧整条链,再是右侧整条链。如果你发现输出顺序变成了“先右后左”,或者左右区间交错在一起,那基本就是递归调用的先后顺序写反了,或者坐标计算里的边界错了。

5. 会遇到的坑:栈溢出、浮点误差、顺序错乱

5.1 高频Bug与定位思路:一张表解决大部分问题

下面这些坑是我自己写康托尔集时真实遇到过的,整理成一张速查表,排查的时候对着看能省不少时间。

问题现象可能原因排查方向
RecursionError 栈溢出缺少终止条件或 depth 没递减检查递归调用里 depth 是否传成 depth-1,终止条件是否写成 depth<=0
输出结果为空初始坐标传反了打印 start、end、depth,确认 start 恒小于 end
图形左右颠倒先递归了右段再递归左段调整递归调用顺序,先处理左侧保留区间
线段连成一片,看不到空洞区间边界重叠统一使用左闭右开区间,绘图时右侧端点做微调
坐标漂移,图形越来越歪浮点数多次除 3 后累加误差改用整数坐标递归,最后统一归一化
深度变大后程序极慢结果数量指数增长估算 2^depth,控制深度或改用更稀疏的采样策略

其中最隐蔽的是浮点漂移。你可能会想,每次只是除以 3,能差到哪里去?但递归一深,误差会一层层传递放大。depth=20时的第二次坐标和理论位置差上几个万分位都很正常,画图时短线和短线之间会出现不该有的缝隙。

5.2 用整数坐标绕开浮点误差

绕开浮点误差最干净的办法,是让坐标在整个递归过程中始终保持整数。思路是把区间 [0, 1] 先映射成 [0, 3^depth] 的整数区间,递归时所有三等分操作都用整除来做,最后画图时再除以 3^depth 归一化回真实坐标。

def cantor_int(a, b, depth): if depth <= 0: return [(a, b)] third = (b - a) // 3 left = cantor_int(a, a + third, depth - 1) right = cantor_int(b - third, b, depth - 1) return left + right # 使用方式:depth=5 时,坐标范围是 [0, 3^5] result = cantor_int(0, 3 ** 5, 5)

这里有个前提:区间长度必须是 3 的倍数。好在只要初始长度取 3^depth,每一层递归后子区间长度仍然是 3 的幂,整除永远精确。如果你传入任意整数坐标,建议先加一行assert (b - a) % 3 == 0做保护。整数坐标版不仅规避了浮点噪声,还让排序、去重这些后续操作变得更稳定。

6. 进阶玩法:从康托尔集到分形天线,从递归到迭代

6.1 从数学玩具到工程工具:康托尔结构还在哪里

如果你以为康托尔集只是数学家的玩具,那就小看它了。工程领域里,带有自相似结构的“康托尔类分形”应用非常广泛。最典型的是分形天线:把金属导线按康托尔集的结构进行折叠或分段,可以在更小的物理尺寸上覆盖多个工作频段,因为自相似结构在电磁场中会产生多组谐振频率。单频天线需要四分之一波长,双频天线往往就要叠两种结构,而分形天线利用同一套外形就能做到多频覆盖,这也是为什么你会看到不少贴片天线采用类似锯齿或镂空的结构。

在数字信号处理里,康托尔集还被用作稀疏采样模板。康托尔结构的总长度趋近于 0,却保存了数量非常多的点,这意味着你可以用极少的采样点逼近某些连续信号。在动力系统和混沌理论中,它的三进制位串特性也经常被拿来构造不变集。这些应用背后的关键是:结构虽简单,但“少量区域承载大量信息”的特性极其珍贵。

6.2 从递归到迭代:什么时候该换一种写法

康托尔集用递归写很优雅,但项目一旦进入工程化阶段,就得认真做权衡。递归的优点是表达清晰、贴近数学定义,缺点是函数调用有开销、递归深度受语言栈上限约束。如果你需要在嵌入式环境里实现它,或者深度大到可能触发栈溢出,迭代队列式写法反而是更好的选择。判断标准我一般就两条:一看深度,超过 100 就是危险信号;二看性能,递归如果成为热点路径,就改成迭代。

不过我也想替递归说句公道话:康托尔集这个例子,深度到 30 时迭代和递归性能差距都不大,真正的瓶颈都在结果数据的指数增长上。所以至少在这个场景里,优先考虑代码可读性完全合理。等你真的要用它做大规模渲染或高频采样时,再切换到迭代也不迟。

我个人写代码的习惯是,先拿字符串版把递归结构确认无误,再切成坐标版处理可视化,最后用整数坐标保证精度。调试时不要一上来就画整棵树,先打印前三层,确认边界和顺序没问题,再往深了跑。康托尔集之所以值得反复练,因为它把终止条件、子问题拆分、状态传递三件事全压在一个极短的函数里,这个结构吃透了,后面看二分查找、树遍历、归并排序的递归,都会忽然觉得顺眼很多。最后再分享一个小技巧:如果你在 Python 里实现递归,记得把参数设计成可 hash 的元组形式,虽然康托尔集没有重复子问题,暂时用不上缓存,但这个习惯对你以后写动态规划一定有帮助。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询