
最近有朋友学算法卡在递归上问我有没有什么题目既能把递归讲透又不至于复杂到劝退。我第一个想到的就是康托尔集。原因特别简单这个数学对象天生自带“递归基因”。一段线段切掉中间三分之一剩下两段各自再切掉各自中间的三分之一无限重复下去——这句话连续读三遍递归函数的骨架就已经在脑子里出现了。这篇就来聊聊我用递归函数实现康托尔集的全过程包括两种典型写法、可视化调试方法以及几个特别容易踩的坑。不管你是刚接触递归的新手还是想拿分形练手的爱好者跟着走一遍都不会亏。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, level0): 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超过宽度细节就丢了。终端里打印字符串也同理depth6时字符串长度是 729刚好能在一行里放下depth8时已经是 6561 个字符观感就很差了。3.4 递归调用次数和复杂度估算写递归之前先估算复杂度能让你后面少踩很多性能坑。康托尔集的递归过程是一棵满二叉树树的深度是 n那么总的递归调用次数是 2^(n1)-1保留区间个数是 2^n。递归本身在调用栈上的深度是 n所以空间复杂度是 O(n)不计返回值本身。举例来说depth10时递归调用次数约 2047看起来不痛不痒。但depth20时调用次数超过 200 万depth30时超过 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], colorblack, lw4) 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, indent0): print( * indent fdepth{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终止条件是否写成 depth0输出结果为空初始坐标传反了打印 start、end、depth确认 start 恒小于 end图形左右颠倒先递归了右段再递归左段调整递归调用顺序先处理左侧保留区间线段连成一片看不到空洞区间边界重叠统一使用左闭右开区间绘图时右侧端点做微调坐标漂移图形越来越歪浮点数多次除 3 后累加误差改用整数坐标递归最后统一归一化深度变大后程序极慢结果数量指数增长估算 2^depth控制深度或改用更稀疏的采样策略其中最隐蔽的是浮点漂移。你可能会想每次只是除以 3能差到哪里去但递归一深误差会一层层传递放大。depth20时的第二次坐标和理论位置差上几个万分位都很正常画图时短线和短线之间会出现不该有的缝隙。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 # 使用方式depth5 时坐标范围是 [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 的元组形式虽然康托尔集没有重复子问题暂时用不上缓存但这个习惯对你以后写动态规划一定有帮助。