——LAMBDA 内功收官篇:一个函数,自己调用自己

引子:你早就会递归,只是没意识到

先给定义:递归,就是函数在计算过程中调用自己——把大问题拆成同款小问题,一层层剥,直到碰到底就往回返。

听着玄,其实你做合并报表时天天在干这件事:子公司下面还有孙公司,抵销分录要一层层做上去;底稿索引从一级科目挂到二级、挂到明细账、挂到凭证——沿着同一条规则不断下钻,直到钻到底。这就是递归,只不过从前靠人肉,一层一层翻。

LAMBDA 递归补上的,正是 Excel 此前二十多年一直缺的一块:处理“不知道有多深”的结构。VLOOKUP 只会跳一跳,嵌套写死二十层也应付不了未知的深度;而递归让公式自己往下走,走到没有下一层为止。BOM 展开这类经典的树形问题,如今一条公式就能反查全部母项。

今天用财务部最典型的两个场景,把这块最后拼图按进去。

一、递归只有两件事:底和推

所有递归函数,剥到最简只剩两个零件:

  • 底(基准情形):什么时候停。这是递归的命门——没有底的递归不会算出答案,只会把 Excel 拖进“计算中”的深渊;
  • 推(递归调用):怎么变。每次调用自己时,参数必须比上一轮更接近底——问题规模逐步下降,才保证有限步内触底。

白话版就是那个“报数”案例:你坐在最后一排想知道自己是几号,问前一位;前一位也不知道,再往前问;问到第一排——他知道答案,这就是底;然后逐层往回传,每人加一——这就是推。整排人没人看得见全局,但答案照样精确传回。

递归的难点从来不是“想象整个展开过程”(千万别在脑子里展开,会晕),而是把底和推这两句话写清楚。写清楚了,剩下的交给机器。


二、案例一:股权穿透——一条链,钻到底

数据与条文

股权表三行起底:

A 列(公司) B 列(直接股东)
C 公司 B 公司
B 公司 A 公司
A 公司 张三

条文一句话:给定一家公司,沿“直接股东”逐级上溯,直到股东栏为空——那个人就是最终控制人。

这就是实际控制人认定的机器版。审计里穿透核查股权结构,以前靠一层层翻工商信息,现在一条公式钻到底。

代码(名称管理器版,函数名:最终控制人)

=LAMBDA(公司,
    LET(
        股东, XLOOKUP(公司, 股权表!A:A, 股权表!B:B, ""),
        IF(股东 = "", 公司, 最终控制人(股东))
    )
)

读一遍就是那两句话:底——股东查不到(返回空串),当前这家就是终点;推——否则对“我的股东”再做一遍同样的事。注意 XLOOKUP 第四参数写的是 "" 而不是报错值:查不到不是异常,是出口,这是穿透类函数的设计关键。

带路径的升级版,把链路留在结果里,方便底稿留痕:

=LAMBDA(公司,
    LET(
        股东, XLOOKUP(公司, 股权表!A:A, 股权表!B:B, ""),
        IF(股东 = "", 公司, 公司 & " ← " & 最终控制人(股东))
    )
)
=最终控制人("C公司")   →  张三
=最终控制人V2("C公司") →  C公司 ← B公司 ← A公司 ← 张三

递归走查(一行行看它怎么钻)

步骤 当前节点 查到的股东 动作
1 C 公司 B 公司 未触底,下钻
2 B 公司 A 公司 未触底,下钻
3 A 公司 张三 未触底,下钻
4 张三 (空) 触底,返回“张三”
5 逐层回传 — 链路拼接完成

彩蛋:不建名称管理器的“自引用”写法

有个语法细节值得单独交代:在 LET 里现写的 LAMBDA 看不见自己的名字,没法直接递归。解法是把函数自己当作第一个参数传进去,调用时 f(f, …) 成对出现——绕一道,就让匿名函数获得了自我引用的能力:

=LET(
    f, LAMBDA(f, x,
        LET(
            股东, XLOOKUP(x, A:A, B:B, ""),
            IF(股东 = "", x, x & " ← " & f(f, 股东))
        )
    ),
    f(f, C2)
)

临时用、原型验证用这条写法很顺手;要进部门函数库,还是名称管理器正式备案——试验品和准则,分开管。


三、案例二:科目树展开——一条链变成一棵树

股权穿透是一条单链,每个公司只有一个股东;科目树不一样,一个上级下面挂着 N 个下级,钻到底之后还要把整棵树收回来。这是递归的进阶形态:下钻 + 堆叠。

数据与条文

A 列(上级科目) B 列(下级科目)
1002 1002.01
1002 1002.02
1002.01 1002.01.01

条文:给定一个科目,列出它本身及其全部子孙科目,从上到下堆成一列。多级科目表、合并范围内单位树、组织架构图——凡是“一对多、层层挂”的结构,全是这一条的变体。

代码(名称管理器版,函数名:科目树)

=LAMBDA(父级,
    LET(
        子级们, FILTER(科目表!B:B, 科目表!A:A = 父级, NA()),
        IF(ISNA(@子级们), 父级,
            REDUCE(父级, 子级们,
                LAMBDA(acc, 子, VSTACK(acc, 科目树(子))))
        )
    )
)
=科目树("1002")
→ 1002 / 1002.01 / 1002.01.01 / 1002.02(一列溢出)

三个零件拆开看:

  • FILTER 的第三参数填 NA():查不到子级时故意返回 #N/A,再用 ISNA(@子级们) 判断触底——比返回空串更不容易和真实数据混淆,这是把“没有”和“空”区分开,老读者应该眼熟,又是防静默失败的那一套;
  • REDUCE:把每个子级的递归结果依次堆叠(VSTACK)到累计器上——树形结构的“收网”动作;
  • 递归点:科目树(子)——对每个子级做同样的事。

选型判断顺带说清:凡是“一层一层剥、不知道有多深”的树形问题,用递归;凡是“一行一行过、范围是固定的”折叠问题,用 REDUCE 就够。两者不是对手,是两个计算深度上的不同工具。

递归走查

步骤 当前节点 找到的子级 动作
1 1002 1002.01、1002.02 压入 1002,先下钻 1002.01
2 1002.01 1002.01.01 压入,继续下钻
3 1002.01.01 无 触底,返回
4 回到 1002.02 无 触底,返回
5 收网 — VSTACK 堆出整棵树

注意结果顺序:1002 → 1002.01 → 1002.01.01 → 1002.02——深度优先,先钻到底再回头。做缩进式的科目明细表时,这正好是你要的阅读顺序。


四、风险控制:递归必须会停,内控必须有底

递归的威力有多大一目了然,风险也同样是明码标价的。三类风险,逐一给防护:

风险 成因 后果 防护
死循环 A 的股东是 B,B 的股东是 A(交叉持股、循环引用) 计算卡死、无响应 深度上限参数
深度过深 结构真实存在几十层 爆栈、内存耗尽 同上
静默失败 触底条件写错(如空串与空格混淆) 结果缺失但不报错 条文写死 + 边界测试

核心防护:给函数加一个“步数”参数——每递归一层加一,超过上限强制亮牌。这不只是技术防护,更是内控逻辑:递归不肯停,说明数据本身有问题(交叉持股、循环挂接),必须人工介入,而不是让机器无限消耗:

=LAMBDA(公司, 步数,
    IF(步数 > 50, "⚠超限:疑似循环持股",
    LET(
        股东, XLOOKUP(公司, 股权表!A:A, 股权表!B:B, ""),
        IF(股东 = "", 公司, 最终控制人(股东, 步数 + 1))
    )
    )
)

调用时写 =最终控制人("C公司", 0) 即可。上限取 50 还是 100 是职业判断,但必须有个上限——递归必须会停,内控必须有底。这条防护思想,和上篇“月末锁定” 是同一个家族:机器负责执行,人负责划定机器的边界。

顺带一个校验习惯:穿透结果出来后,用 LEN(路径)-LEN(SUBSTITUTE(路径,"←",""))+1 数一数链路层数,和手工抽查两笔对一对——边界抽样那套流程,递归函数照样适用。


结语:人肉递归的时代,到此为止

回头看这个系列走过的路:从函数是“封装好的加工车间”,到电子表格是“关系维护机器”,到 LAMBDA 把封装权交还用户,到十条函数攒出部门准则体系——最后这一篇,递归,补上了“处理无限深度”的能力。

而这恰恰是审计师最擅长的事。合并抵销一层层做上去,是人肉递归;长投往下穿透核查,是人肉递归;底稿索引从总账钻到凭证,是人肉递归。审计方法论里早就写满了递归思想,缺的从来不是逻辑,是把逻辑交给机器的那一步。

四篇连载,到此收官。母题其实只有一句话:会计人精通的从来不是工具,是业务——工具只是把你的判断,变成资产。

系列目录:《Excel 函数的本质》 → 《LAMBDA 实战》 → 《部门函数库十讲》 → 本篇。合起来,是一部写给会计人的 Excel 底层逻辑。