资讯动态

递归CTE与树结构查询:从LeetCode 3482到组织层级实战

发布时间:2026/10/14 17:28:43 来源:尧图企业网站定制
刚处理完月度组织报表又被一个需求绊住了领导想知道每个员工在组织里待在第几层最好还要能看到每个人从上到下的完整汇报链。这种需求在SQL里基本就是一道经典题型也就是很多人刷过的LeetCode 3482“分析组织层级”。别看题目只有短短几行它把自引用表、树结构、递归CTE这三个高频知识点全串起来了是数据岗笔试面试里非常典型的“看着简单、实际容易翻车”的题。这道题我一开始以为只是算个层级深度写个自连接就完事。真动手才发现组织架构的深度不固定普通JOIN根本支撑不了七八层的查询。只要你没接触过递归CTE第一次写基本都会卡住。这篇就围绕这道题展开把我自己拆题、写SQL、踩坑、迁移到真实业务的过程完整讲一遍无论你是在准备面试还是正在处理组织架构类的报表需求应该都能直接抄作业。1. 先把题目翻译成人话1.1 我理解的题目长这样去掉LeetCode的英文壳题目核心其实就是一张员工表CREATE TABLE Employees ( employee_id INT PRIMARY KEY, employee_name VARCHAR(100), manager_id INT NULL );manager_id 指向另一个员工的 employee_id也就是“我的上级是谁”。如果某个员工的 manager_id 为 NULL说明他就是这家公司的最高层通常是CEO或者创始人。题目要求的输出我按常见版本还原一下查出每个员工处于组织的第几层最高层记为0层他的直接下级是1层下级的下级是2层以此类推。输出 employee_id、employee_name、level最后按 level 和 employee_id 排序。为了后面演示方便造一组最简单的数据employee_idemployee_namemanager_id1林总NULL2陈经理13王经理14赵主管25孙主管26周专员37吴专员4这组数据里林总是根节点层级0陈经理和王经理是层级1赵主管、孙主管、周专员是层级2吴专员是层级3。1.2 为什么这题值得反复刷很多人觉得这题不就是求个深度吗递归CTE一把梭就结束了不值得花时间。但我实际面试和带人的感受是这题背后藏了三个高频考点错过任何一个都会在真实业务里吃亏。第一是自引用表的理解。一张表的外键指向自己这张表的主键这在人员组织、商品分类、评论回复里到处都是。很多人建表没问题一查询就懵因为脑子里的SQL还停留在“几张表打平JOIN”的思维上。第二是树结构的SQL表达。组织层级本质上是一棵倒挂的树根在顶部叶子在最下面。SQL是结构化查询语言天然擅长处理“行”和“表”但对“树”这种不确定深度的结构并不友好需要一个特殊工具来解决。第三就是递归CTE。这是解决不确定深度层级查询的标准方案也是这题的核心考点。它不是SQL里可有可无的花活而是处理任意层级数据的通用武器。把这一题吃透商品分类树、评论楼层、路由权限继承这些需求你都能顺手拿下。2. 组织层级本质是一棵倒挂的树2.1 为什么普通JOIN搞不定层级我第一次看到这种需求第一反应是自连接两次查出来的只有“员工—上级—上上级”撑死三层。当时我还觉得挺够用直到实际业务里出现了一个7层深的架构我才意识到这条路根本走不通。打个比方。你想查一个人的家谱爷爷辈、太爷爷辈、高祖辈每一代都要写一个字段问题是每个人的家族深度不一样有些人往上数只有两代有些人能数七代。用JOIN的写法等于预设“我就查三代”那深度超过三代的人就直接被截断了。组织架构也一样。一家创业公司可能只有CEO和员工两层一家大集团可能有集团总裁、事业部总经理、区域总监、部门经理、主管、一线员工六到七层。你不可能预先知道最大层数更不可能为每个可能的层级写一个LEFT JOIN。这时候递归CTE的价值就出来了。它不预设层数而是从根节点出发一层一层往下钻直到把所有员工都找完为止。这是“按逻辑描述”而非“按固定行数”解决问题的典型场景。2.2 递归CTE到底是怎么工作的递归CTE完整语法是 WITH RECURSIVE核心分两部分锚点成员和递归成员。锚点成员负责找到递归的起点也就是“从哪一层开始钻”。在组织层级题目里起点就是所有 manager_id 为 NULL 的员工即最高层。递归成员负责定义“怎么从当前层走到下一层”。它会把上一轮查到的结果当作临时表和员工表做连接找出这些人的直接下级然后继续下一轮。整个过程有点像滚雪球。第一轮查到林总第二轮顺着林总找到陈经理和王经理第三轮顺着这两个人找到赵主管、孙主管和周专员第四轮找到吴专员。直到某轮递归查询结果为空递归自动终止。这里有个关键点递归部分使用的是 UNION ALL不是 UNION。因为我们要保留每一轮产生的所有员工记录层级又不能重复合并UNION会去重可能导致数据丢失所以必须用UNION ALL。这个细节虽然简单但很多人就是在这里翻车。3. 手写递归CTE从零到一实现解法3.1 基础版计算每个员工的层级深度先给出最核心的SQLWITH RECURSIVE emp_level AS ( -- 锚点找到最高层 SELECT employee_id, employee_name, manager_id, 0 AS level FROM Employees WHERE manager_id IS NULL UNION ALL -- 递归找每一层的直接下级 SELECT e.employee_id, e.employee_name, e.manager_id, el.level 1 FROM Employees e INNER JOIN emp_level el ON e.manager_id el.employee_id ) SELECT employee_id, employee_name, level FROM emp_level ORDER BY level, employee_id;这段SQL我拆开解释一下。锚点部分里的0 AS level是定义根节点的层级这一行不会有任何上级。递归部分里e.manager_id el.employee_id的意思是“员工e的经理恰好是上一轮查出来的某个人”这样就能保证每次往下钻一层。level 字段每次加1恰好反应了树中的实际深度。为什么这里用 INNER JOIN 而不是 LEFT JOIN我的习惯是正常组织数据里如果员工存在那么他的manager_id应该指向一个真实存在的上级。INNER JOIN 可以过滤掉“上级不存在”的脏数据。但这也会带来一个隐藏问题如果确实有员工的上级是离职或被误删的INNER JOIN会直接把这个人丢弃。这点在真实业务里要格外注意后面排查章节我会专门讲。3.2 进阶版顺便输出完整汇报链LeetCode原题可能只要求层级深度但真实需求里“这个员工上面有哪些人”其实是比层级深度更高频的问题。我建议刷题时顺手把汇报链也实现一遍一题两用。WITH RECURSIVE emp_path AS ( SELECT employee_id, employee_name, manager_id, 0 AS level, CAST(employee_name AS VARCHAR(500)) AS path FROM Employees WHERE manager_id IS NULL UNION ALL SELECT e.employee_id, e.employee_name, e.manager_id, el.level 1, CONCAT(el.path, - , e.employee_name) FROM Employees e INNER JOIN emp_path el ON e.manager_id el.employee_id ) SELECT employee_id, employee_name, level, path FROM emp_path ORDER BY level, employee_id;这里多了一个path字段。锚点部分的path就是最高层自己的名字递归部分把上级已有的path拼上当前员工的名字用-连接形成一条从顶层到当前员工的可读汇报链。跑出来之后结果大概是这样的employee_idemployee_namelevelpath1林总0林总2陈经理1林总 - 陈经理4赵主管2林总 - 陈经理 - 赵主管7吴专员3林总 - 陈经理 - 赵主管 - 吴专员如果需求要求从当前员工往上看也就是倒过来的链路我建议在递归阶段就直接采用倒拼写法把新名字拼在左边CONCAT(e.employee_name, - , el.path)这小细节坑过我好几次。如果递归时正着拼最后想用REVERSE函数倒过来会把分隔符里的箭头也反转结果一团糟。所以递归方向在设计初期就要想清楚后面反转只有后悔的份。3.3 变体统计每个层级的员工数如果把上面的CTE包一层聚合就能解决另一个常见需求“公司每个层级各有多少人”。WITH RECURSIVE emp_level AS ( SELECT employee_id, manager_id, 0 AS level FROM Employees WHERE manager_id IS NULL UNION ALL SELECT e.employee_id, e.manager_id, el.level 1 FROM Employees e INNER JOIN emp_level el ON e.manager_id el.employee_id ) SELECT level, COUNT(*) AS employee_cnt FROM emp_level GROUP BY level ORDER BY level;这个变体的核心价值是它让我意识到递归CTE查出来的结果跟普通表完全一样可以继续做聚合、过滤、排序没有任何限制。很多人以为CTE只能作为最终结果输出不知道它还能再包一层这是理解不到位。4. 实现这道题时的坑与排查技巧4.1 循环引用递归停不下来的元凶我拿这道题练手的时候为了省事直接生成了一批随机组织数据。结果递归CTE一直执行不停数据库CPU飙升。排查之后发现问题出在数据上两条记录互相设置成对方的上级。比如employee_id1的manager_id是2employee_id2的manager_id是1。这种环结构在真实组织架构里几乎不可能出现但在测试数据、导入异常、错误更新时完全可能发生。递归CTE一旦遇到环递归部分永远能查到新行SQL会一直循环下去直到数据库把他杀停或者内存爆掉。应对办法有几个。最简单的是在递归部分加一个深度上限例如INNER JOIN emp_level el ON e.manager_id el.employee_id WHERE el.level 20el.level 20就是一层保险最多跑到20层就强制停止。但这是治标不治本如果数据本身有环真实的业务数据还可能继续无限增长。更正规的做法是喂数据前做环检测。比如用程序遍历一遍员工表检查是否存在“A管B、B管A”这种环形链路。生产环境里组织架构表通常有独立的校验任务就是为了防止这种脏数据流入下游。4.2 根节点有多人时千万不要慌我在本地调试时一开始只往表里插了一个CEO跑得很顺。后来模拟一家双创始人公司插了两个manager_id为NULL的人递归CTE直接从两个根节点同时往下遍历结果完全正常。所以锚点部分返回多行是合法的递归CTE支持多个起点。这反而是处理“多业务线组织”的基础每条业务线有自己的负责人它们的manager_id都是NULL各自往下展开成一条独立的树。但要注意一个实际问题如果最高层的manager_id不是NULL而是0很多人会用WHERE manager_id 0来写锚点。万一员工表中恰好存在一个employee_id为0的虚拟用户就会查出不干净的数据。我建议统一约定根节点字段用NULL表达不要让0混进来。4.3 断链数据怎么处理上一章我提过INNER JOIN会丢弃断链员工。真实业务里员工离职后manager_id没有及时更新或者上级被误删都会导致断链。如果排查时发现人数对不上可以把INNER JOIN改成LEFT JOIN让断链员工保留下来level字段设为NULLLEFT JOIN emp_level el ON e.manager_id el.employee_id这样查询结果里会出现manager_id指向不存在上级的员工。接下来用一条SQL单独查出来确认问题的根源SELECT e.employee_id, e.employee_name, e.manager_id FROM Employees e LEFT JOIN Employees m ON e.manager_id m.employee_id WHERE e.manager_id IS NOT NULL AND m.employee_id IS NULL;这算是我个人经验的补充。LeetCode题目本身不会给你脏数据但面试官很可能在你的简历上追问一句“你处理过组织数据吗如果有人没有上级怎么办”知道怎么识别和排查比单纯AC这道题更值钱。4.4 其他高频问题速查表问题现象常见原因解决思路递归执行不结束数据存在循环引用加深度上限或提前做环检测查询结果少人断链数据被INNER JOIN丢弃改用LEFT JOIN后单独排查路径里箭头方向反了递归阶段拼接方向设计错误一开始就决定从上往下还是从下往上字符串报错或超长path字段长度不够CAST为足够长的VARCHAR或TEXT员工层数整体偏移根节点level定义不一致明确约束最高层为0还是1全链路统一递归结果顺序乱没加排序字段按level, employee_id排序稳定可读5. 题干之外的价值真实业务里的组织层级5.1 汇报线变更与断链检测LeetCode题目把组织架构简化成了一张静态表但真实世界里组织架构是高频变化的。某经理离职他的十几个直接下属需要临时挂到更高层领导名下某部门拆分一批员工要批量变更manager_id。这些操作落到SQL上其实特别简单就是批量UPDATEUPDATE Employees SET manager_id 1 WHERE manager_id 2;但这只是第一步。更麻烦的是如果离职人员的下属一直被挂在“已离职”的人身上组织树就出现了断链。我用递归CTE跑全量层级时最容易发现的表现就是某些员工永远查不到因为上游断了。所以我在实际做组织数据管理时会把断链检测做成一个定期任务每天跑一遍查出所有manager_id指向不存在上级的员工。这个任务本质上就是前面提过的LEFT JOIN排查SQL。LeetCode虽不考这些但你理解了数据质量对层级查询的影响比死记SQL答案更有价值。5.2 从题目到生产环境的升级路径LeetCode里的表通常只有几千行跑递归CTE毫无压力。但真实企业里几百万员工的全球组织架构不一定都能用同一种模型。业界的树结构存储方案主要有三种邻接表就是现在这种表结构每行只记录manager_id直观、维护简单缺点是查询任意深度数据时需要递归性能依赖数据库递归能力。LeetCode教的就是这种模型下的处理方式。闭包表是单独维护一张“祖先—后代”关系表每一对上下级关系都预先算好并存起来。查询子孙、祖先都很快但维护成本高每次组织变更都要同步更新关系表。适合查询频繁、变更较少的稳定架构。物化路径则是在每个节点存一个完整路径字段例如“/1/2/7/”查询前缀就能找到子树。实现简单但路径变更时维护复杂。适合分类层级不一定适合动态组织。我个人在真实项目里的经验是小于十万节点的组织架构邻接表加递归CTE完全够用再加一条manager_id索引就很稳。如果节点量更大查询性能吃紧再考虑迁移到闭包表而不是一开始就上复杂模型。最后再分享两个小技巧如果你正打算刷这道题我强烈建议别只AC一遍。先把基础版写出来然后把汇报链版本也写了再试试统计每个层级人数。这三版覆盖了面试中最常见的三种问法一次吃透比刷三道同类型题更值钱。另外递归CTE的深度限制问题不同数据库差异很大。在MySQL 8.0里默认有cte_max_recursion_depth参数PostgreSQL也有对应的递归迭代上限。我在本地测试时深度超过默认值就直接报错第一反应是SQL写错了折腾半天才发现是参数问题。所以遇到递归CTE异常中断先看一眼数据库的递归深度配置这个坑比语法错误更隐蔽也更浪费时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑