ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

二叉树的右视图:BFS与DFS两种解法详解

二叉树的右视图:BFS与DFS两种解法详解 1. 这道题到底在问什么从“站在右边看”到树的层级透视图1.1 题目原意拆解右视图不是“右子树视图”LeetCode hot100 里二叉树题目不少199题“二叉树的右视图”是其中辨识度很高的一道。简单说题目给你一棵二叉树要你想象自己站在树的右侧从顶部到底部依次返回“每层你能看到的最右边的那个节点值”。这里最关键的一个认知误区也是这道题真正的考点右视图不是把右子树上的节点一路收集起来。很多人第一眼看到“右视图”三个字下意识以为答案就是把右分支全部走一遍比如根节点有右孩子就一直往右下走没右孩子了就往右下下走认为收集到的路径就是答案。这个思路错得离谱而且错得很有代表性。举个最典型的反例一棵树只有一个根节点和一条极深的左子树完全没有右子树。站在右侧看你看到的节点不是“根节点就结束了”而是沿着左子树一路深入每一层都能看到那个最靠右的节点。也就是说那棵看似“藏在左边”的子树在某一层如果没有其他节点挡在前面它就是你视野里的最右节点。所以右视图的本质是每层所有节点中位置最靠右的那个而不是“右子树的节点”。1.2 为什么“看不见”的节点不重要问题的核心是层次最右把二叉树想成一个二维结构每一层是一排节点站在最右侧每一排你能看到的是这一排里最后一个节点。至于这排的右边还有没有别的节点或者这一排的节点是不是右子树里的统统不重要。你需要关注的是“层”而不是“路径”。这个“层”的概念直接决定了两个主流的解题方向用层序遍历BFS一行一行地扫每到一层的末尾记录一下当前层最后弹出的节点。用深度优先遍历DFS优先访问右子树保证每层第一个被访问到的节点就是从右边能看见的节点。理解了“层”之后这道题你已经会了一半。剩下的一半是代码层面的事情。1.3 用超市货架理解层与剪影不需要知道货架内部是什么我经常拿超市货架来打比方。你站在一排货架的右侧走廊目光顺着货架方向扫过去你能看见的是这一排货架最右边的一件商品。下一排货架可能比这一排深也可能比这一排浅但你永远只关心“这一排最右的那个商品”是什么。在这个例子中货架就是树的层级货架上摆放的商品就是这一层的节点你作为观察者站的位置决定了你只取每层最右的那一个值。这听起来简单但真正在写二叉树程序时“层”的边界常常被忽略尤其是当一个节点只有左孩子、而它的兄弟节点位置为空的时候很多人就开始含糊了。199题恰好用最直接的方式逼着你去面对“层”的边界问题。2. 解法一层序遍历BFS——最直观、最少出错的思路2.1 BFS为什么天然匹配“每层的最右侧”如果你想按“层”来拿最右节点最简单的做法就是用队列做层序遍历也就是广度优先遍历。BFS的特性就是严格按照树的深度从上到下、从左到右地把所有节点“扫”一遍。既然是按层扫那么每一层什么时候算结束就是一个可以被精确控制的事件。在二叉树的右视图这道题里你只需要做一件事在每一层的节点全部从队列中弹出之前把这一层最后一个弹出的节点值记录下来。这个“最后一个弹出的节点”就是站在右侧能看到的那一个。为什么BFS版本不容易出错因为它不需要判断“当前节点是从右边还是左边过来的”也不需要记录复杂的深度信息。它只是朴素地把树拆成一排一排的节点然后告诉你每一排最右边的值是什么。逻辑非常线性几乎没有给“灵机一动”的错误留下空间。2.2 代码实现用 size 固定当前层的边界BFS 层序遍历实现右视图最常见的写法是“size 法”。核心逻辑是在每一轮循环开始时先记下当前队列的长度这个长度就是当前层的节点总数。接下来只处理这个数量的节点每处理一个就把它左孩子右孩子入队处理到当前层最后一个节点时把这个节点的值加入答案。from collections import deque class Solution: def rightSideView(self, root: Optional[TreeNode]) - List[int]: if not root: return [] res [] q deque([root]) while q: level_size len(q) for i in range(level_size): node q.popleft() if i level_size - 1: res.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) return res为什么要用level_size len(q)先把长度存下来因为你在for循环体内部还会执行q.append如果直接在for i in range(len(q))里取长度这个长度会在循环过程中不断变化导致当前层还没处理完就已经混入了下一层的节点。这是层序遍历里最经典的坑前面提到的“写二叉树程序时为什么总是报运行时错误”很多就是由这类问题引发的。先把level_size固定住循环的次数就等于当前层节点数干净利落。2.3 其他流派双队列法和哨兵法面试官有时会追问如果不用len(q)怎么知道一层结束了这时候你可以说出两种替代写法作为知识储备。第一种是双队列法。准备两个队列current和next。从current里不断弹出节点把子节点放进next当current空了说明这一层结束了此刻next里装的就是下一层的全部节点交换两个队列继续下一轮。这个写法稍微繁琐但“当前层清空即一层结束”的概念更古朴。第二种是哨兵法。在队列里塞入一个特殊标记值例如None作为层的分隔符。每次弹出None时说明当前层遍历完毕。这种写法写起来短但要注意None不能被当成真正的节点去访问属性否则直接空指针报错。相比这两者size法在代码可读性和易维护性上都有明显优势所以我个人建议无论面试还是平时刷题主用size法就够了其他写法知道原理即可。2.4 复杂度分析与边界情况BFS版本的时间复杂度是 O(n)因为每个节点恰好入队一次、出队一次。空间复杂度是 O(n)最坏情况出现在完全二叉树的最后一层队列里需要同时容纳约 n/2 个节点。这个空间占用对于二叉树题目来说是可接受的但如果面试官对空间有更高要求你可以顺势引出DFS版本。边界情况主要就三件事空树直接返回空列表if not root这一行就能挡住。只有一个根节点返回包含根节点值的单元素列表。左子树特别深、右子树很浅右视图的后半部分来自左子树深处的节点。这时BFS版本不会错因为它每一层都取“最后一个节点”跟这个节点属于左子树还是右子树没有任何关系。这道题的所有边界情况里就数这个最值得自己画图验证。3. 解法二DFS右路优先——空间复杂度更优的递归思路3.1 递归遍历顺序的巧妙转变先走右子树每层第一个访问的节点就是答案如果你不想用队列或者面试官希望你展示递归功底那么DFS版本同样优雅。这个版本的思路是一个很有技巧性的转变既然我们要的是“每层最右侧的节点”那就在递归时永远优先访问右子树再访问左子树。这样一来对于每一层来说第一个被访问到的节点必然是该层最右侧的节点。为什么想象一下递归的访问顺序从根开始先下到右子树的最深处把每一层的最右节点访问完再绕回左子树。当递归第一次进入某一深度时这个深度的res里还没有值说明还没有任何节点在这一层被记录过那么当前这个节点一定是这一层最靠右的如果res在这一层已经有值了说明更右边的节点早就被记下来了当前节点不需要再管。3.2 代码实现递归版用一个辅助函数dfs(node, depth)depth表示当前节点所在的层数。每次进入一个新的深度如果depth len(res)说明这个深度第一次被访问到直接把当前节点加入结果。class Solution: def rightSideView(self, root: Optional[TreeNode]) - List[int]: res [] def dfs(node, depth): if not node: return if depth len(res): res.append(node.val) dfs(node.right, depth 1) dfs(node.left, depth 1) dfs(root, 0) return res注意观察递归顺序先递归node.right再递归node.left。如果把这两行调换顺序代码就不再是右视图而变成了某种“左视图”。所以这行顺序就是整个DFS解法的灵魂。很多人在面试时一紧张就会写成先左后右结果答案莫名其妙变成了左视图这个细节一定要死记。3.3 迭代版用栈模拟预处理顺序递归虽然简洁但递归深度在极端情况下可能爆栈。如果你想在空间上做得更可控可以把递归改成显式栈。这里有个很容易踩的坑因为栈是后进先出你想要“先访问右子树”入栈时就要先压左子树再压右子树这样弹出时右子树才会先被处理顺序才能和递归保持一致。class Solution: def rightSideView(self, root: Optional[TreeNode]) - List[int]: res [] stack [(root, 0)] while stack: node, depth stack.pop() if not node: continue if depth len(res): res.append(node.val) stack.append((node.left, depth 1)) stack.append((node.right, depth 1)) return res这里栈里保存的是(node, depth)二元组当弹出栈顶节点时如果当前深度等于len(res)说明这个深度还没有被记录过记录下来即可。在整个过程中我们并不需要维护复杂的“当前层宽度”因为深度信息已经足够抽象了这也是DFS版本代码往往比BFS版本更短的原因。3.4 两种解法的取舍面试时怎么选我一个比较实用的建议是两道题都写熟面试时优先展示你觉得更能讲清楚的解法。但如果你只想背一个我建议优先背BFS版本因为它的思路更贴近题目本身“层”的概念面试官追问“边界情况怎么处理”的时候BFS版本的变量在哪儿、逻辑在哪儿都比较清晰容易展开讲。下面是两种解法的对比供你复习时参考维度BFS层序遍历DFS右路优先核心思想层末尾记录最后一个节点每层第一个被访问的节点实现难度思路简单代码略长递归版本短但顺序易搞反空间复杂度O(n)队列同时装一层节点递归栈平均O(log n)最坏O(n)面试追问方向如何划分层的边界为什么先递归右子树会不会被空指针坑结构清晰不易踩递归里一旦访问顺序或判空出错直接崩至于怎么选我个人的习惯是如果这棵树可能非常深比如退化成一条链表那么BFS永远安全而递归DFS可能在到达第1000层时触发语言默认的递归深度限制。所以面试时如果面试官问“这棵树可能有一万层”你就该意识到该用BFS或DFS迭代版本。4. 写二叉树程序时为什么总是报运行时错误199题现场踩坑复盘4.1 最常见的运行时错误空指针解引用网上搜“写二叉树程序时为什么总是报运行时错误”十个有八个都栽在空指针上。二叉树程序到处是node.left、node.right、node.val一旦某个节点是None你却继续访问它的属性运行时立刻抛异常。在199题里这个坑最容易出现在DFS递归版本中。比如有的人写出这样的代码def dfs(node, depth): if depth len(res): res.append(node.val) dfs(node.right, depth 1) dfs(node.left, depth 1)没有if not node这个递归出口。当递归走到叶节点的下一层时node已经变成了None下一层递归调用函数体里的node.val自然报AttributeError: NoneType object has no attribute val。这样的报错信息在LeetCode上见得特别多。解决办法很固定递归函数的第一句话永远是空的判断。先if not node: return再做任何其他操作。记住这个顺序二叉树递归题的运行时错误能减少八成。4.2 递归深度爆栈树退化成链表时的问题第二个高频错误是栈溢出。二叉树在题目里往往看上去很“丰满”但测试用例里完全可能包含极端情况一棵退化成了链表的树深度等于节点数。如果树有一万个节点递归深度就有一万层而Python默认的递归深度限制大约在1000层左右超了就是RecursionError。这个坑在199题的DFS递归写法里很容易出现。解决办法有三个改用BFS解法不使用调用栈从根本上避开递归深度问题。改用DFS迭代栈显式控制栈空间。如果必须用递归在代码开头调用sys.setrecursionlimit(10000)但这只是把限制调大不是根治。我建议优先选择第一种或第二种。因为这类题考的往往是遍历逻辑而不是你调高递归限制的熟练度。4.3 层序遍历中“长度动态变化”的隐蔽索引问题还有一个很隐蔽的BFS错误前面提过但值得单独复盘。有人会写出这样的代码while q: for i in range(len(q)): node q.popleft() # ... q.append(node.left) q.append(node.right)表面上看for i in range(len(q))是正常的但实际上len(q)在循环过程中是动态变化的。每执行一次q.popleft()队列长度减一每执行一次q.append(...)队列长度又增加。结果就是这个for循环根本不会按照“当前层节点数”来执行它可能只处理了当前层的一部分节点也可能把下一层的节点也混进来处理了最终导致res里记录的“最右节点”并不是真正的最右节点。正确写法就是前面代码里的level_size len(q)。把长度先固化下来循环次数就等于进入循环那一刻队列的长度也就是当前层的真实节点数。这个问题的排查其实很简单——用一个小例子在纸上画一下队列的变化比盯着代码看半天有效得多。4.4 复盘后的通用防错清单把上面三个坑总结成一份清单我写二叉树代码时会按顺序过一遍入口处先判空root为空直接返回。递归函数的第一句必须是空节点判断绝不允许在None上访问属性。BFS层序遍历时先level_size len(q)固定当前层宽度不要在循环里直接用len(q)。DFS版本中的递归顺序要想清楚右视图是先右后左左视图是先左后右。深度从0开始还是从1开始前后要保持一致测试用例至少跑一个“根节点只有左子树的树”。每次做完一道二叉树题拿这个清单对着代码过一遍基本能杜绝大多数运行时错误。5. 一道题带出的一串变体左视图、之字形与垂直遍历5.1 左视图怎么改一行代码的事做完了右视图左视图就非常简单了。如果你用的是BFS解法只需要把if i level_size - 1:改成if i 0:意思是记录当前层的第一个节点也就是站在左侧能看到的最左节点。如果你用的是DFS解法把递归顺序改回“先左后右”即先递归node.left再递归node.right这样每一层第一个被访问到的节点就是最左节点。这两个改法我在面试时被问过很多次每次讲完“右视图”之后面试官会顺嘴问一句“那左视图呢”。这其实是在考察你是否真的理解了代码里的每一行而不是背模板。所以建议你写右视图时顺手在草稿纸上把左视图的版本也写一遍加深记忆。5.2 之字形遍历和右视图的组合题二叉树的热门题里之字形遍历也叫锯齿形遍历经常和右视图放在一起讨论。之字形遍历要求奇数层从左往右访问偶数层从右往左访问。如果这时候题目变成“返回之字形遍历中的每一层最右节点”需要小心一个点在偶数层访问方向变成了从右往左那么这一层的“最右节点”其实是第一个被访问到的节点。这个题目一旦组合起来很多人的第一反应是乱了。正确做法还是回到定义右视图就是每一层最右边的节点。不管遍历方向是从左往右还是从右往左你只需要确定“这一层节点里最右的那个是谁”。BFS解法在这种情况下依然可靠因为你是先把整层节点全部拿到再取最右的那个而不是在遍历过程中顺便记录。5.3 进阶延伸垂直遍历与列优先思维如果你想把199题理解得更透可以去看看二叉树垂直遍历Vertical Order Traversal。那道题需要给每个节点记录列号根节点为0列左孩子列号减一右孩子列号加一最后按列分组输出。其实右视图和垂直遍历有一种奇妙的联系一棵二叉树的右视图某种意义上就是从正右侧看过去那些“列号最大的节点”的剪影。当你建立起“层”和“列”的坐标感之后你会发现二叉树题目从抽象的空间想象变成了坐标计算难度会下降一个档次。当然这个延伸属于进阶内容如果你是在准备面试的早期阶段先把右视图的两种解法吃透更重要。等199题完全通关再花时间去啃垂直遍历不迟。5.4 从“hot100题”谈这类题的刷题策略hot100 之所以叫 hot100是因为这些题目浓缩了大多数公司面试中最高频的考点和套路。199题在 hot100 的二叉树分类里位置不算靠前但它很典型考察遍历顺序、层级概念、边界处理这几个要素组合在一起几乎是一张二叉树入门的试金石。我的建议是刷这类题时不要追求“只写对一次”而是追求“能给别人讲明白”。每做完一题合上代码自己口述一遍思路说清楚为什么取这个节点、为什么用这个遍历顺序、边界条件有哪些。如果你能在5分钟内讲清楚199题的两种解法那你的hot100刷题质量会明显提升。真正面试的时候你也不大可能一字不差地把代码背出来但你能把思路讲清楚面试官就很满意了。写到这里我再分享一个自己的小习惯。我最初做199题的时候先写的是“一路向右”的错误版本提交后没通过当时我觉得题目很冤枉人明明叫右视图。后来我画了一棵“只有左子树、没有右子树”的树看着那棵树我才彻底想明白右视图不是“右子树的视图”。从那以后我刷树相关的题第一件事就是画一棵不对称的树比如根节点只有一个左孩子把所有候选解法在这棵树上先跑一遍能挡住大量低级错误。这个习惯后来帮我避开过不少运行时错误和逻辑错误你也值得试试。
返回列表