二叉树最近公共祖先算法详解:从递归到迭代的面试实战解析

📅 2026/8/24 1:30:40
二叉树最近公共祖先算法详解:从递归到迭代的面试实战解析
1. 项目概述一次典型的技术面试复盘最近刚结束了Brix这家公司的技术面试流程从笔试到技术面整个过程下来感触颇深。Brix作为一家在特定技术领域颇有建树的公司其面试流程非常能考察候选人的基本功和临场解决问题的能力。我这次面试的岗位是后端开发整个流程涵盖了算法、数据结构、系统设计以及项目深挖等环节。今天这篇文章我就来详细复盘一下我的面试经历并重点分享几道让我印象深刻的笔试题特别是其中一道关于二叉树的题目我会给出详细的解题思路、代码实现以及多种可能的优化路径。无论你是正在准备校招还是社招希望这份一手经验能帮你避开一些坑更高效地备战。2. 面试全流程拆解与核心考察点2.1 笔试环节算法与基础的试金石Brix的笔试是在线进行的时长90分钟总共4道编程题。题型非常经典没有偏题怪题但每道题都暗藏玄机非常考验对基础数据结构和算法的理解深度以及编码的严谨性。2.1.1 笔试环境与题型特点笔试平台是常见的在线评测系统允许使用本地IDE调试但最终需在线提交。题目描述清晰但边界条件需要自己仔细揣摩。题型分布上通常包括一道简单的字符串或数组操作题用于热身考察基本编码能力和细心程度。一道中等难度的数据结构题通常是链表、栈、队列或哈希表的综合应用。一道经典的动态规划或深度优先搜索/广度优先搜索题考察对经典算法模型的掌握和迁移能力。一道有挑战性的题目可能涉及树、图的高级操作或者需要巧妙的数学思维。这种分布旨在全面评估候选人的编程能力层次。简单题不能错这是底线中等题要快速、优雅地解决难题则能区分出优秀和卓越的候选人即使不能完全ACAccept通过所有测试用例清晰的解题思路和部分正确的实现也能赢得好感。2.1.2 核心考察能力分析透过笔试题目面试官主要想考察以下几点代码基本功语法是否熟练代码风格是否清晰命名是否规范。数据结构掌握度是否能在实际问题中快速选取最合适的数据结构如用哈希表优化查找、用堆处理Top K问题。算法思维能否将问题抽象成已知的算法模型如DFS、BFS、DP、滑动窗口、二分查找。边界条件与异常处理是否考虑了输入为空、数值溢出、特殊数据结构如单节点树等情况。时间与空间复杂度分析是否具备优化意识能否在编码前或编码后分析自己方案的效率。注意很多候选人在简单题上翻车不是因为不会而是因为急躁没有充分测试边界情况。比如题目说“非空数组”但你的代码开头是否还是习惯性地写了if nums is None: return这种细节在在线笔试中会被无情扣分。2.2 技术面试环节从点到面的深度考察通过笔试后我经历了两轮技术面试。面试官会围绕笔试题目、简历项目以及计算机基础知识进行深入提问。2.2.1 项目经验深挖这是每一轮技术面都绕不开的环节。面试官会挑选你简历上最具代表性的1-2个项目让你介绍。这里的关键不是流水账式地叙述你做了什么而是体现你的思考深度和技术决策能力。STAR法则进阶不仅要讲清情境(Situation)、任务(Task)、行动(Action)、结果(Result)更要重点阐述在“行动”中遇到的技术挑战、所做的权衡Trade-off以及为什么选择A方案而不是B方案。例如“当时为了提升接口响应速度我考虑了引入Redis缓存和优化数据库查询两种方案。经过压测发现在数据更新频率不高的场景下引入Redis能将平均响应时间从200ms降低到20ms虽然增加了系统复杂性但收益显著。这是基于……数据分析后做的决定。”追问细节面试官可能会就你提到的某个技术点深入追问。比如你说用了Kafka做消息队列他可能会问“你们的消息可靠性是如何保证的是at-least-once还是exactly-once消费者挂了如何避免消息丢失” 因此对自己项目里用到的每一项技术都必须知其然也知其所以然。2.2.2 系统设计能力对于有一定经验的候选人系统设计题是必考项。题目可能是“设计一个短链接系统”或“设计一个抢购系统”。回答这类问题切忌一开始就陷入技术细节。澄清需求先和面试官确认系统的核心功能Functional Requirements和非功能需求Non-functional Requirements如QPS、延迟、一致性要求。问清楚“做什么”比“怎么做”更重要。估算规模进行简单的量级估算Back-of-the-envelope calculation。例如设计一个微博Feed流可以先估算日活用户、人均发帖数、人均关注数从而推算出读写的峰值QPS和数据存储量。这体现了你的工程素养。高层设计画出系统的主要组件框图API网关、业务服务、缓存、数据库、消息队列等并说明数据流。深入细节就某个核心模块深入讨论比如“Feed流如何实现推拉结合”“数据库分库分表策略如何设计”“缓存穿透、雪崩、击穿如何应对”权衡与评估讨论不同方案的优缺点并给出你的选择和建议。2.2.3 基础知识“八股文”虽然常被调侃但计算机基础知识的考察永远不会过时。Brix的面试中这部分问题通常与你的项目和技术栈紧密结合而不是孤立地问概念。对于Java候选人可能会从你的Spring Boot项目问到Spring Bean的生命周期、AOP原理再引申到JVM内存模型、垃圾回收算法最后可能落到一道关于HashMap并发问题的场景题。对于网络相关项目可能会从TCP三次握手、四次挥手问到HTTPS的握手过程再深入到你的项目中如何配置和使用HTTP连接池。对于数据库索引原理为什么用B树、事务隔离级别、锁机制乐观锁、悲观锁是高频考点通常会结合你项目中遇到的真实性能问题来问。实操心得准备“八股文”时最好的方法不是死记硬背而是建立知识之间的联系。例如学习MySQL索引的B树结构时可以联想对比Redis中Sorted Set的跳表Skip List结构思考它们各自的适用场景和优缺点。这样在面试中被问到“为什么MySQL不用哈希索引而用B树”时你就能从磁盘I/O特性、范围查询效率、排序需求等多个维度侃侃而谈。3. 核心笔试题详解二叉树相关难题剖析下面我重点分享笔试中遇到的一道关于二叉树的题目这道题很好地融合了基础遍历和算法思维。3.1 题目描述与初步分析题目给定一棵二叉树的根节点root以及两个树中的节点p和q。请编写一个函数找到这两个节点的最近公共祖先Lowest Common Ancestor, LCA。定义最近公共祖先是同时为节点p和q后代的最深节点一个节点也可以是它自己的后代。示例输入: root [3,5,1,6,2,0,8,null,null,7,4], p 5, q 1 输出: 3 解释: 节点 5 和节点 1 的最近公共祖先是节点 3。示例图略可想象一棵标准的二叉树初步分析 这是一道非常经典的二叉树题目在LeetCode上编号为236。它考察了对二叉树遍历递归/迭代的熟练掌握以及分治算法的思想。暴力解法是记录从根到p和q的路径然后找最后一个公共节点但需要额外空间。更优的解法是在一次递归遍历中完成判断。3.2 递归解法深度优先搜索与分治思想这是最直观和优美的解法时间复杂度 O(N)空间复杂度 O(N)递归栈深度。3.2.1 算法思路我们从根节点开始进行深度优先搜索后序遍历是一个自然的选择如果当前节点是nullptr空或者等于p或者等于q那么直接返回当前节点。因为如果当前节点是p或q那么它就有可能是LCA如果另一个节点在它的子树中。递归地在左子树和右子树中寻找p和q。得到左右子树的返回结果后进行判断如果左子树和右子树的返回结果都不为空说明p和q分别位于当前节点的左右子树中那么当前节点就是它们的LCA。如果左子树结果为空右子树不为空说明p和q都在右子树中返回右子树的结果。如果右子树结果为空左子树不为空说明p和q都在左子树中返回左子树的结果。如果都为空返回空。这个思路的核心是分治将在大树中找LCA的问题分解为在左子树和右子树中找子问题然后合并结果。3.2.2 代码实现Pythonclass TreeNode: def __init__(self, x): self.val x self.left None self.right None class Solution: def lowestCommonAncestor(self, root: TreeNode, p: TreeNode, q: TreeNode) - TreeNode: # 递归终止条件 if not root or root p or root q: return root # 分治在左右子树中寻找 left self.lowestCommonAncestor(root.left, p, q) right self.lowestCommonAncestor(root.right, p, q) # 合并结果 if left and right: # p和q分居左右子树当前root是LCA return root # 如果一边为空说明LCA在另一边或者另一边找到了p/q return left if left else right3.2.3 代码实现Javapublic class Solution { public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { // 递归终止条件 if (root null || root p || root q) { return root; } // 分治在左右子树中寻找 TreeNode left lowestCommonAncestor(root.left, p, q); TreeNode right lowestCommonAncestor(root.right, p, q); // 合并结果 if (left ! null right ! null) { return root; // p和q分居两侧当前root是LCA } // 如果一边为空LCA就在另一边或者另一边找到了p/q return left ! null ? left : right; } }3.2.4 关键点与易错点分析递归终止条件if not root or root p or root q这行代码是精髓。它不仅处理了空节点更重要的是一旦我们找到了p或q就立即返回不再深入其子树。因为如果p是q的祖先那么p就是LCA我们不需要再去找q。返回值理解递归函数的返回值需要仔细理解。它返回的是在当前子树中p和q的LCA如果都存在或者**p/q中的一个如果只找到一个或者null如果都没找到**。这个定义是递归正确性的基础。空间复杂度最坏情况下树退化成链表递归深度为N空间复杂度O(N)。这是递归解法的固有开销。3.3 迭代解法利用父指针映射如果面试官要求非递归解法或者想进一步考察对数据结构的运用我们可以使用迭代法借助哈希表来存储每个节点的父节点然后通过“爬坡”的方式找到LCA。3.3.1 算法思路遍历并记录父节点从根节点开始使用栈或队列进行遍历如DFS或BFS。同时用一个哈希表parent_map记录每个节点的父节点根节点的父节点为None。找到p的所有祖先集合从节点p开始利用parent_map不断向上回溯到根节点将路径上的所有节点存入一个集合ancestors中。查找q的最近公共祖先从节点q开始同样向上回溯。对于q的每一个祖先节点检查它是否在ancestors集合中。第一个出现在ancestors集合中的节点就是p和q的LCA。3.3.2 代码实现Pythonclass Solution: def lowestCommonAncestor(self, root: TreeNode, p: TreeNode, q: TreeNode) - TreeNode: if not root: return None # 步骤1使用栈进行DFS记录每个节点的父节点 stack [root] parent {root: None} # 遍历直到找到p和q的父节点关系都记录完毕 # 注意我们只需要记录到p和q即可但通常遍历整棵树更简单 while stack: node stack.pop() if node.left: parent[node.left] node stack.append(node.left) if node.right: parent[node.right] node stack.append(node.right) # 步骤2构建p的祖先集合 ancestors set() while p: ancestors.add(p) p parent[p] # 向上回溯 # 步骤3查找q的祖先中第一个出现在p祖先集合中的节点 while q: if q in ancestors: return q q parent[q] # 向上回溯 return None # 理论上不会走到这里因为p和q一定在树中3.3.3 复杂度与适用场景分析时间复杂度O(N)。需要遍历整棵树来构建父节点映射以及最多回溯树高O(N)来查找公共祖先。空间复杂度O(N)。哈希表存储了所有节点的父节点最坏情况是O(N)ancestors集合存储了从p到根的路径也是O(N)。适用场景这种方法思路直观且当需要多次查询同一棵树中不同节点对的LCA时可以预先计算好父节点映射后续每次查询只需O(H)时间H为树高比递归法每次O(N)更高效。这在某些场景下是优势。3.4 进阶思考与变种问题面试官可能会基于这道题进行拓展考察你的思维灵活性。3.4.1 如果树是二叉搜索树BST如果题目明确是二叉搜索树那么问题会大大简化。利用BST左子树所有节点值 根节点值 右子树所有节点值的性质如果p.val和q.val都小于root.val则LCA在左子树。如果p.val和q.val都大于root.val则LCA在右子树。否则即p.val和q.val一个大于等于一个小于等于root.val当前root就是LCA。 这可以写出非常简洁的迭代或递归代码时间复杂度O(H)空间复杂度O(1)迭代法。3.4.2 如果要求输出从根节点到LCA的路径这可以结合迭代法中记录父节点的方法。在找到LCA节点后我们可以从该节点反向回溯到根节点即可得到路径需要反转。递归法也可以修改在递归过程中记录路径但实现稍复杂。3.4.3 如果树节点中包含指向父节点的指针如果每个TreeNode都有parent引用那么问题就退化成了“求两个单链表的第一个交点”。我们可以分别从p和q向上走到根记录路径长度然后让较长的路径先走差值步再一起走第一个相同的节点就是LCA。这是经典的“双指针”解法。4. 面试准备策略与实战技巧基于这次Brix面试和其他多次经验我总结了一套比较实用的准备策略。4.1 算法题的系统性训练刷题是必要的但要有策略地刷避免陷入“刷了忘忘了刷”的循环。4.1.1 按知识点分类刷题不要随机刷题。将LeetCode或《剑指Offer》的题目按数据结构数组、链表、字符串、栈、队列、树、图和算法二分、排序、DFS、BFS、回溯、DP、贪心、滑动窗口、双指针进行分类。每个类别集中攻克10-15道经典题目确保理解透彻。树专题必须熟练掌握前中后序的递归和迭代遍历、层序遍历、DFS/BFS的应用、二叉搜索树操作、以及LCA、直径、路径和等高频难题。动态规划专题从简单的斐波那契、爬楼梯开始理解状态定义、转移方程、初始条件、遍历顺序。然后攻克背包问题、子序列问题、字符串编辑距离等经典模型。4.1.2 重视“一题多解”与“多题一解”对于像“二叉树最近公共祖先”这样的经典题要掌握递归和迭代两种解法并理解各自的优缺点。同时要善于总结比如“双指针”技巧可以解决数组两数之和、链表判环、滑动窗口等多个问题。建立这种联系能极大提升解题效率。4.1.3 模拟面试环境练习在LeetCode上做题时要给自己设定时间如30分钟一道中等题并且口头解释思路。可以录下来自己听检查表达是否清晰、逻辑是否连贯。很多同学代码能写出来但讲不明白这在面试中是致命的。4.2 项目经验的梳理与表达4.2.1 构建“亮点故事库”从你做过的项目中提炼出3-5个最能体现你技术能力、解决问题能力和成长性的“故事”。每个故事用STAR法则包装并准备好可能被追问的细节。例如故事一性能优化XX系统接口响应慢Situation我的任务是将其降到100ms内Task。我通过Arthas定位到慢SQL通过增加复合索引、引入Redis缓存热点数据、优化业务逻辑批量查询Action最终将P99响应时间从2s降到80msResult。追问准备索引为什么选这几个字段Redis缓存策略过期时间、淘汰策略怎么定的缓存和数据库一致性如何保证故事二线上故障处理XX服务在促销时频繁Full GCSituation需要快速恢复并根治Task。我通过分析GC日志和堆转储发现是某个大对象列表被全局缓存且不断增长Action。临时方案是重启并扩容长期方案是改用分页加载和弱引用缓存Action。故障在30分钟内恢复后续再未发生Result。追问准备如何分析GC日志弱引用缓存是如何实现的有没有考虑过其他缓存策略4.2.2 量化你的成果尽可能用数字说话。“提升了性能”不如“将QPS从1000提升到5000延迟从50ms降低到20ms”。“解决了问题”不如“通过引入熔断器将下游服务不稳定导致的系统宕机次数从每月5次降为0”。4.3 行为问题与软技能准备技术面之后通常会有HR或主管面考察软技能和文化匹配度。常见问题有“你遇到过的最大技术挑战是什么如何解决的”“你和同事有过意见分歧吗如何处理”“你的职业规划是什么”“你为什么想加入我们公司”回答这类问题要真诚、积极、以团队为导向。准备几个体现你主动性、协作精神、抗压能力和学习能力的例子。5. 面试后的复盘与持续提升无论面试结果如何复盘都至关重要。5.1 即时记录面试一结束立刻找个地方凭记忆把被问到的所有问题包括笔试题、技术问题、行为问题记录下来特别是那些你没答好或完全没答上来的问题。5.2 深度研究针对记录下来的问题尤其是不会的花时间彻底搞懂。如果是算法题去LeetCode找原题或类似题用多种方法实现。如果是系统设计题查阅相关的技术博客、论文如Amazon Dynamo DB论文、Google Bigtable论文等形成自己的知识体系。如果是基础概念回头翻看《深入理解计算机系统》、《设计数据密集型应用》等经典书籍。5.3 更新知识库将这次面试学到的新知识、新思路补充到你的个人笔记或知识管理系统中。面试是一个双向学习的过程即使没通过你也接触到了对方公司关注的技术点和问题视角这对你未来的成长很有价值。5.4 保持沟通与心态如果通过了积极准备后续面试。如果没通过可以礼貌地询问HR或面试官是否可以提供一些反馈虽然很多公司政策不允许。无论结果如何保持专业和礼貌。求职是双向选择一次失败不代表什么重要的是从每次经历中汲取养分让自己变得更强大。面试就像一场精心准备的演出既要台下十年功的扎实积累也要台上几分钟的完美呈现。希望我的这份Brix面试复盘能为你照亮备战路上的一些角落。记住所有的努力都不会白费你刷过的每一道题、深挖的每一个技术点都会在未来的某个时刻成为你从容应对挑战的底气。