ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

二叉树右视图:从层序遍历到DFS的两种解法

二叉树右视图:从层序遍历到DFS的两种解法 1. 先说清楚这道题到底在考什么hot100 的 199. 二叉树的右视图我刷第一遍的时候其实没太当回事觉得无非就是层序遍历每层取最后一个节点。后来面试被追问了几次才发现这道题里藏着的点比想象中多。它不光是考层序还会延伸到 DFS 的变体写法、边界条件的处理、以及对二叉树遍历本质的理解。你要是能把这道题吃透hot100 里后面那些带“层序”“深度”“视图”字眼的题基本都能顺手解决。题目本身不复杂给定一棵二叉树想象自己站在它的右侧按照从顶部到底部的顺序返回从右侧能看到的所有节点值。所谓“右视图”说白了就是每一层最右边的那个节点。层序遍历是个直观思路深度优先搜索其实也能写而且写法更简洁。一个小例子1 / \ 2 3 \ \ 5 4从右侧看第一层看到 1第二层看到 3第三层看到 4所以结果是 [1, 3, 4]。注意第二层的节点 2 被 3 挡住了第三层的节点 5 被 4 挡住了所以不会出现在结果里。这道题适合谁来刷呢我觉得是两种人一种是刚开始刷 hot100、想要系统掌握二叉树遍历的人另一种是已经会层序遍历、但想在 DFS 思路上补短板的人。它不是最难的题但作为二叉树的“视图类”入门题性价比非常高。先说结论右视图的实质就是“在每一层中优先选择最右侧节点”。理解了这句话下面所有写法都顺了。2. 层序遍历最直觉的解法也是面试官最爱让你手写的第一版2.1 层序思路怎么“看到”每一层最右边的节点层序遍历用的是队列标准 BFS 流程。核心点在于每次进入下一层之前先记录当前队列的长度 size然后只循环 size 次把这层的节点全部弹出。弹出的过程中最后一个弹出的节点就是这一层的最右侧节点。这个思路看起来简单但有一个细节是新手很容易踩坑的循环里千万不要直接写queue.size()作为循环上限因为这个值是会变的。你每弹出一个节点又会往队列尾部压入它的左右孩子size 会不断增大最终导致一次循环把整棵树都遍历完层与层之间就完全分不清了。正确做法是先把当前队列长度存到一个变量里循环固定这个长度。这就是“按层处理”和“按节点处理”的区别。BFS 的队列天然是先进先出但如果你不锁定每层的入口长度队列里的元素会跨层混在一起层序就变成了普通的广搜失去了“层”的概念。2.2 BFS 代码逐行拆解C 版class Solution { public: vectorint rightSideView(TreeNode* root) { vectorint ans; if (!root) return ans; // 空树直接返回别犹豫 queueTreeNode* q; q.push(root); while (!q.empty()) { int size q.size(); // 关键锁定当前层的节点个数 for (int i 0; i size; i) { TreeNode* node q.front(); q.pop(); if (i size - 1) { // 最后一个节点就是右视图的节点 ans.push_back(node-val); } if (node-left) q.push(node-left); if (node-right) q.push(node-right); } } return ans; } };这里面几个细节值得说if (!root) return ans;这行写在最前面不是凑代码量。二叉树的遍历题里空指针判断是第一优先级。面试时如果你忘了判空后面的node-left直接就是一个空指针访问运行时直接崩溃。q.size()记录下来之后Node* node q.front(); q.pop();的顺序一定不能反也一定不能漏。弹出之后节点指针就失效了你要是转过头再去访问front()就是未定义行为。判断i size - 1这个位置写在哪里决定了你存的是哪一层。如果你把ans.push_back(node-val)放在循环体最前面那你存的就是最左边的节点也就是左视图。所以这两个视图之间其实就是一行代码的区别。Python 版本也顺手贴一下思路完全一样只是语法上有点差异from collections import deque class Solution: def rightSideView(self, root: TreeNode) - List[int]: ans [] if not root: return ans q deque([root]) while q: size len(q) for i in range(size): node q.popleft() if i size - 1: ans.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) return ans注意Python 里len(q)放在for i in range(size)之前和 C 的int size q.size()同理一旦进入循环后队列长度变了你还在按原来的 size 遍历就会漏层或者跨层。这一点两种语言都是一个坑。2.3 为什么“每层最后一个”恰好就是最右侧的节点有人可能会问BFS 是按从左到右的顺序遍历一层的那最右边的节点当然就是最后一个弹出的节点。这没问题。但更深一层的原因是层序遍历天然保证了我们在每个层级上从左到右访问所有节点而这个“左到右”的顺序是由入队时先 left 后 right 决定的。所以如果你想改成“左视图”有两个方案一是入队时先 right 后 left然后仍然取每层最后一个二是入队顺序不变但取每层第一个。两种方式都可以但最容易记的还是改取法而不是改顺序因为改顺序会影响你对整棵树访问顺序的理解容易把后面的题也带偏。我在第一次刷这道题时其实没有立刻想到这里而是直接背代码。后来做“二叉树的层序遍历 II”从叶子到根输出每层的时候才意识到“锁定 size 逐层处理”是一个通用模板它能解决所有层序相关的问题。所以我的建议是把这段 BFS 模板刻在脑子里不只是为了这一题而是为了后面那三四道层序变体题。3. 递归 DFS另一种更优雅的解法但坑也更多3.1 为什么右视图可以用先序遍历来写先用 DFS 写最核心的思路是递归时先访问右子树再访问左子树。当递归深度 depth 第一次等于当前结果数组的长度时说明这是这一层第一次被访问到的节点由于我们先走右子树这个节点一定是最右侧的那个。换句话说DFS 版本依赖一个隐藏条件同一深度下先被访问到的节点就是该层最右边的节点。所以递归的参数要带depth结果数组ans的长度天然对应已经“看到”过的层级数。如果depth ans.size()说明当前层还没有记录任何节点那当前节点就是这一层的右视图节点。这里有个容易晕的点DFS 的递归顺序是“根 - 右子树 - 左子树”但很多人写的时候习惯先写左再写右结果最后得到的视图变成了左视图或者乱序。所以我建议记一个口诀右视图先右后左左视图先左后右。换言之你想从哪边看就把哪边的递归调用写在前面。这个解法的优点很明显不需要额外的队列空间空间复杂度取决于树的深度递归栈深度。缺点也很明显递归深度如果很大极端情况下比如树退化成一个链可能会爆栈。这一点在 LeetCode 上大多数测试用例不会踩到但在面试中如果你主动提出来会加分不少。3.2 DFS 代码实操C 递归版class Solution { public: void dfs(TreeNode* root, int depth, vectorint ans) { if (!root) return; if (depth ans.size()) { // 这一层第一次访问到且一定是最右节点 ans.push_back(root-val); } dfs(root-right, depth 1, ans); // 先右 dfs(root-left, depth 1, ans); // 后左 } vectorint rightSideView(TreeNode* root) { vectorint ans; dfs(root, 0, ans); return ans; } };这里depth从 0 开始。如果根节点不为空第一次进入时depth 0ans.size() 0条件成立把根节点放进答案。然后进入右子树此时depth 1如果右子树不为空且ans.size() 1条件成立把右子树的根放进去。如果右子树为空则递归左子树此时左子树的根就是第二层最右边的节点了。这个逻辑妙就妙在它不关心当前层到底有多少节点只关心“这一层的第一个被访问节点”。由于先递归右子树所以第一个被访问的一定是最右边的节点。但有个细节需要注意ans.size()是全局的也就是说如果右子树不存在左子树会把第二层“补上”如果右子树存在但左子树更深左子树里更深层的节点也会被正确记录。因为递归是深度驱动每一层只会记录一次。这跟 BFS 的“每层取最后一个”在结果上是完全一致的。3.3 BFS 和 DFS到底选哪个面试的时候我建议两层都提。先给 BFS因为它直观、不容易出错然后说“其实也可以用 DFS 写思路是先右后左配合深度判断”。这样一来你既展示了基础能力又展示了思维的灵活性。从实际应用场景来说如果题目只是求右视图BFS 更容易写对也不容易爆栈推荐优先。如果题目需要你同时输出左视图和右视图DFS 可以在一趟递归里分别处理两个方向代码更紧凑。如果树的深度可能非常大比如 10 万层的链表结构BFS 的队列空间是 O(width)DFS 的递归栈是 O(height)两者在极端情况下都可能有风险但 BFS 更可控因为队列不会触发系统栈溢出。所以我的建议是面试默认先写 BFS再在追问下补充 DFS。你要是直接写 DFS也行但一定要明确说出“递归深度可能带来栈溢出风险”这个权衡。这两个解法的复杂度都是 O(n)一个在时间上一个都不能省没有谁压倒谁。4. 写二叉树程序为什么总是报运行时错误从这道题看常见坑热词里出现频率最高的就是这句话——“写二叉树程序时为什么总是报运行时错误”。这个问题我在群里被问了无数遍尤其新人刷 hot100 二叉树的题报错基本上都是以下几个原因之一。我结合 199 题的实际场景把最典型的几类列出来对照着排查大部分问题都能当场解决。4.1 空指针访问二叉树报错的头号元凶二叉树题里最经典也最冤的错误就是访问了NULL - left或NULL - right。比如这样一段代码if (node-left) q.push(node-left); if (node-right) q.push(node-right);如果你忘了判断node本身是否为空那么在node为NULL时node-left就是一次空指针解引用运行时直接段错误。还有一个隐蔽版本递归 DFS 里如果 base case 没写好或者root传入时就是空指针那么函数一开始的if (!root) return;就缺失了继续往下走就会崩。不光是 199所有二叉树题目排查的第一步都是检查所有可能为空的指针是否都判空了。包括递归入口、左右子树入队、左右子树递归调用。4.2 循环边界写错把 size 看成动态值刚才说过BFS 里必须先把int size q.size();存下来。如果你在循环里直接用了q.size()每一轮弹出和压入都会改变它循环次数就会失控轻则结果错误重则死循环或越界访问。我见过很多人这样写for (int i 0; i q.size(); i) { // ... q.push(node-left); // q.size() 又变大了 }只要队列里还有节点这个循环就永远结束不了最后可能出现 vector 越界或者队列无限增长。这种 bug 非常难用肉眼发现因为它只在运行时报错而且报错位置常常在 STL 内部不是你的业务代码。排查技巧看到“heap-buffer-overflow”或“AddressSanitizer”报错优先怀疑所有的循环边界条件尤其是用了动态 size 的地方。4.3 递归栈溢出树退化成长链如果你用 DFS 解法而树恰好是一个左单支或者右单支比如每个节点只有右孩子递归深度就是节点个数 N。当 N 超过系统栈大小通常是几万到几十万层时程序会直接爆栈退出报“stack overflow”。这种情况并不罕见LeetCode 的测试数据不一定包含极端情况但如果你自己构造一条 10 万层的链DFS 就会当场崩溃。解决方案改用 BFS或者把递归改写成显式栈的迭代 DFS。显式栈虽然代码长一点但栈空间在堆上可以承受更大的深度。4.4 STL 容器使用不当空队列取 front() / pop()这也是一个特别常见的坑。层序遍历中如果根节点为空你没有提前判空就q.front()队列是空的调用 front() 是未定义行为在 LeetCode 上会触发运行时错误而在本地编译器上可能“碰巧”不出来。具体到 199 题如果你写成queueTreeNode* q; q.push(root); while (!q.empty()) { TreeNode* node q.front(); // 如果 root 为 NULLq 不为空但 node 是 NULL q.pop(); // 下面没有对 node 判空就直接 node-left }注意这里队列不一定为空但队列里存的是 NULL 指针。front()返回的是一个空指针访问node-left照样崩。所以最稳妥的写法是先判root NULL再入队在访问节点前再判一次该节点是否为空。双重保险不嫌多。4.5 排查二叉树运行时错误的实战顺序我自己的排查顺序是固定的你可以直接抄作业先看报错类型stack overflow 优先怀疑递归过深heap-buffer-overflow / segfault 优先怀疑空指针或下标越界。检查所有-left/-right/-val之前有没有判空。检查 BFS 循环里有没有把size写成动态值。检查递归 base case 是否覆盖了空节点和叶子节点两种情况。检查 vector / 数组下标是否可能越界尤其是ans[depth]这种写法199 题里一般不建议用下标直接赋值用 push_back 更安全。本地调试时打印每一步访问的节点值和深度肉眼确认遍历顺序是否符合预期。这张速查表请重点收藏报错类型常见原因对应排查方向segmentation fault空指针解引用检查所有 node 判空stack overflow递归深度过大改迭代或显式栈heap-buffer-overflowSTL 容器越界检查循环边界、vector 下标死循环 / 超时BFS 中 size 动态变化先存 size再 for 固定层数结果错乱递归顺序写反右视图必须先右后左5. 面试追问从右视图到视图类题型的延展单元测试过了以后面试官通常不会就这么放你走他会开始追问。最常见的是这三类一是让你说说两版解法的复杂度二是让你改成左视图或二叉树的俯视图三是把“右视图”变成“层序输出所有节点”。5.1 复杂度分析怎么说才专业BFS 版时间 O(n)每个节点恰好入队出队一次空间 O(n)队列最多同时容纳一层的节点数最坏情况是满二叉树的最后一层节点数约 n/2。DFS 版时间 O(n)每个节点恰好访问一次空间 O(h)h 是树的高度。最坏情况 h n链状树最好情况 h log n满二叉树。一个容易丢分的点很多人说递归空间是 O(n)其实不够精确。准确说是 O(h)如果树是链状O(h) O(n)如果不是链状O(h) 会小于 O(n)。这个区别面试官一眼就看出来了。5.2 左视图、俯视图、层序遍历变体左视图怎么写很多人说“右视图改成左视图只要把层序里每层取最后一个改成取第一个”。这个说法没问题但不全面。如果按这个思路改入队顺序不变取第一个即可。但如果你想用 DFS 写左视图递归顺序就要改成先左后右条件仍然是depth ans.size()。这样才符合“优先访问最左边节点”的逻辑。那俯视图呢这是另一道经典题牛客网上很多LeetCode 上也有类似题它要求你从上往下、从左到右输出每一层第一个看到的节点。这个就不能只用层序了需要记录每个节点的水平坐标然后对同一水平坐标的节点只保留最上面的。这就是“视图类”题型的进阶版用到了哈希表和排序复杂度从 O(n) 变成了 O(n log n)。如果你能把右视图从 BFS 到 DFS 都搞清楚再做俯视图思路会清晰很多因为本质上都是“某一维度下的第一个节点”问题。5.3 迭代 DFS 怎么写不用递归也优雅如果你面试时主动提到“递归有爆栈风险我可以改成迭代”那一瞬间你的印象分会拉高不少。迭代 DFS 的写法基于显式栈class Solution { public: vectorint rightSideView(TreeNode* root) { vectorint ans; if (!root) return ans; stackpairTreeNode*, int stk; stk.push({root, 0}); while (!stk.empty()) { auto [node, depth] stk.top(); stk.pop(); if (!node) continue; if (depth ans.size()) { ans.push_back(node-val); } stk.push({node-left, depth 1}); // 注意压栈顺序 stk.push({node-right, depth 1}); // 右子树后进栈先处理 } return ans; } };这里的关键点栈是后进先出所以我们先把左子树压入栈再把右子树压入栈。这样一来右子树会先被弹出并处理从而保证了同一层里右子树先被访问depth ans.size()的判断逻辑依然成立。提示如果把压栈顺序反过来会得到左视图这一点可以自己试一下。写迭代 DFS 的时候最容易错的不是 stack 操作而是栈的访问顺序和递归顺序不一致。只要记住“想先访问谁就让谁后入栈”就不会乱。6. 一行小技巧如何用状态压缩省掉 depth 参数最后再分享一个小技巧。BFS 版本里可以不用带 depth因为每层的边界已经由for (int i 0; i size; i)决定了。但 DFS 版本如果不想带 depth 参数还有一种做法递归时把ans.size()作为隐含深度判断。什么意思呢你可以把递归函数定义成int dfs(TreeNode* root, int depth, vectorint ans) { if (!root) return depth; if (depth ans.size()) ans.push_back(root-val); return max(dfs(root-right, depth 1, ans), dfs(root-left, depth 1, ans)); }但这样写其实没有意义因为 depth 还是要传。我真正想说的是你不需要让递归函数返回深度直接在递归内部判断depth ans.size()就够了。这个“用数组长度代表已访问层数”的思路很多树的题目都能用到尤其是输出“每一层的第一个节点”这类题。我个人在实际刷题中的体会是199 这道题第一次接触的人常常觉得 BFS 解法才是“正宗”不太理解 DFS 解法为什么也能得到正确答案。其实两种方法从不同角度回答了同一个问题——BFS 是从横向切面找最右点DFS 是从纵向路径找第一个到达该层的点。吃透这一题对后续理解二叉树的深度、层序、视图类问题会有很大帮助。我在 hot100 做题时养成的习惯是每道题都写两种解法并用一个极端用例和一个空用例去测。右视图这一题我会用空树测试返回[]用单节点树测试返回[root.val]再用链状树测试是否会爆栈。这些边界情况在面试时都是加分项。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表