——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 底层逻辑。