算法入门 · 教学页

什么是递归?
为什么决策树的生成本质上就是递归?

递归 = 把大问题拆成逻辑相同、规模更小的子问题,直到遇到终止条件,再逐层返回结果。 阶乘是它最经典的单链形态;决策树则是它最经典的多分支形态。

递归两要素:基线条件(停)  +  递归调用(往下拆)

01递归的两大核心要素

任何递归函数都必须同时具备这两块。缺了基线条件,程序会无限递归直到栈溢出;缺了递归调用,它就只是一个普通函数。

基线条件(Base Case)

当问题已经小到可以直接给出答案,不再继续往下拆。

例如:0! = 1;决策树里"样本已全属同一类"。

递归调用(Recursive Case)

把原问题交给和自己逻辑完全一样的子任务去处理,只是输入规模变小。

例如:n! = n × (n-1)!;决策树里"对每个子集继续建树"。

递归 = "递" + "归"
:一路向下拆问题,每拆一层就把当前状态压入调用栈;
:碰到基线条件后开始回弹,每返回一层就从栈顶弹出一帧,并把子结果汇总给上一层。

02经典案例:阶乘(单链递归)

阶乘是入门递归的第一个例子:每次只产生一个子调用,调用栈是一条直线,最容易看清"递"和"归"。

数学定义

0! = 1  (基线条件)  |   n! = n × (n-1)!  (递归规则)

标准伪代码

def fact(n):
    if n == 0:              # 基线条件:到这里停止往下拆
        return 1
    return n * fact(n - 1)   # 递归调用:规模减 1

调用过程 fact(4)

递(往下拆,压栈):
fact(4) → 4 * fact(3)
fact(3) → 3 * fact(2)
fact(2) → 2 * fact(1)
fact(1) → 1 * fact(0)
fact(0) → return 1        ← 基线触发

归(往回返,弹栈):
fact(1) = 1 * 1 = 1
fact(2) = 2 * 1 = 2
fact(3) = 3 * 2 = 6
fact(4) = 4 * 6 = 24
易出问题点:如果传入 fact(-1),永远碰不到 n == 0,递归不会停止——这就是栈溢出。真实代码里要加输入保护。

03决策树的生成:多分支递归

决策树不是"算出一个数",而是"长出一棵树"。每处理一个节点,它会把数据集拆成多个子集,再对每一个子集调用自己——这是和阶乘最大的不同。

决策树递归伪代码

def build_tree(D):
    # —— 基线条件:满足任意一条,就生成叶子节点,不再分裂 ——
    if D 中样本全属于同一类:
        return 叶子节点(该类别)
    if 没有剩余特征可用于划分:
        return 叶子节点(多数投票类别)

    # —— 递归步骤:选特征、切数据集 ——
    选择最优划分特征 A            # 信息增益 / 基尼系数
    将 D 按 A 的取值切成子集 D₁, D₂, …, D_k

    # —— 多分支递归:每个子集都重新调用 build_tree ——
    for Di in [D₁, D₂, …, D_k]:
        子节点 = build_tree(Di)

    # —— 归:所有子树都建好后,组装成当前内部节点 ——
    return 内部节点(A, [子节点₁, 子节点₂, …])

对照递归两要素

为什么决策树容易过拟合? 因为递归分裂没有及时停——每个子集都被切到"纯纯的叶子"为止,树深度过大。剪枝(pre-pruning / post-pruning)本质就是在合适的深度提前触发基线条件

04阶乘递归 vs 决策树递归

两者共享同一套递归骨架,差别只在"一次拆出几个子问题"和"返回值是什么"。

对比维度阶乘(单链递归)决策树(多分支递归)
一次拆出几个子问题1 个:fact(n-1)多个:build_tree(D₁), build_tree(D₂), …
返回值是什么一个数字(运算结果)一个节点对象(叶子 / 内部节点)
"归"阶段做什么把数字乘起来把子节点挂到当前内部节点上
调用栈形态一条直线(线性)一棵分叉的树
终止条件n == 0样本纯 / 无特征可用
递归深度等于 n 本身等于树的深度(过拟合时过深)

05交互演示 · 亲手推一遍递归

下面两个小工具可以让你单步观察调用栈的变化。点击"单步"按钮,看每一帧是怎么压栈、怎么回弹的。

A. 阶乘调用栈可视化
输入 n =
调用栈(栈顶在最上方)
运行日志
点击"单步执行"开始,每点一次走一步。绿色帧表示触发了基线条件。
B. 决策树递归分裂动画

西瓜例子(11 条样本,3 个特征:纹理 / 根蒂 / 敲声)。本例中三个特征都参与了分类: 根按纹理、清晰分支按根蒂、蜷缩分支再按敲声分裂。

编号纹理根蒂敲声好瓜?
1模糊蜷缩浊响
2模糊硬挺沉闷
3模糊蜷缩沉闷
4清晰硬挺浊响
5清晰硬挺沉闷
6清晰硬挺浊响
7清晰蜷缩浊响
8清晰蜷缩浊响
9清晰蜷缩沉闷
10清晰蜷缩浊响
11模糊硬挺浊响
12清晰蜷缩沉闷

统计:好瓜 6 条(4、5、6、9、10、12),坏瓜 6 条(1、2、3、7、8、11),好坏对半。根节点 Gini = 0.5; 经计算,根选纹理(模糊全坏直接成叶子),清晰分支选根蒂(硬挺全好成叶子),蜷缩分支再选敲声——三个特征各用一次。

模糊 清晰 蜷缩 硬挺 浊响 沉闷 纹理? 坏瓜 根蒂? 敲声? 好瓜 坏瓜 好瓜
分裂判断(基尼增益比较)
点击"单步分裂":观察调用栈如何压栈、如何在碰到纯子集时回弹。橙色发光表示当前正在 build_tree 的节点。
运行日志

06易错点 & 必背骨架

三个高频错误

  • 忘了写基线条件 → 无限递归、栈溢出。
  • 递归调用没有让问题变小 → 同样会无限递归。
  • 决策树里"样本全同类"和"无特征可用"这两个基线条件漏掉任何一个,都会报错。

通用递归万能骨架

def solve(problem):
    # 1. 先判终止
    if 足够简单:
        return 直接答案
    # 2. 拆子问题并递归
    sub = 拆解(problem)
    result = solve(sub)
    # 3. 归:组装返回
    return 结合(result)
Tips:递归不是"技巧",而是一种看待问题的方式——只要你能把"当前问题"描述成"更小版本的自己",你就已经会写递归了。决策树只是把这个思想用在了"切数据集"上而已。