删点成林 图解题解
这道题到底在问什么
- 输入
- root=[1,2,3,4,5,6,7], to_delete=[3,5]
- 输出
- [[1,2,null,4],[6],[7]]
- 输入
- root=[1,2,3], to_delete=[2]
- 输出
- [[1,null,3]]
先想最直接的笨办法
80 已移除,50 的右边断开。60 和 70 现在浮了出来、不再连着任何父亲。挨个看它们,它俩的 isRoot 都是真。(动画第 17 步)
最优解:一步一步想明白
- 3记住「被删节点的存活孩子自立成新根;靠 isRoot 标志一路往下传、谁的父亲被删谁就可能是根」,下面逐帧套它。
- 4先看这棵树:根 50,左边 30 挂着 20 和 40,右边 80 挂着 60 和 70。要删的是 40 和 80。我们从根 50 开始一次 DFS,边走边删、边收集森林的根。
- 5第一步,把删除集 {40, 80} 放进一个哈希集合,这样判断任意一个节点要不要删都是 O(1)。准备好就开始递归。
- 6到根 50。根天生是「一棵树的根」,isRoot 为真。先看它在不在删除集里:50 不在,要保留。
- 750 没被删、又是根,所以把它收进答案,它就是森林里第一棵树的根(标橙)。接着递归它的左、右孩子,因为 50 没被删,传给孩子的 isRoot 是「假」。
- 8进到 50 的左孩子 30。它的 isRoot 是假(父亲 50 没被删,它还挂在 50 下面)。30 不在删除集里,保留。
- 930 保留(标绿),它仍然挂在 50 的左边、不是新根。继续递归 30 的两个孩子。
- 10进到 30 的左孩子 20。20 不在删除集里,保留。
- 1120 保留(标绿),它是叶子,这一支到底了。回到 30,去看它的右孩子 40。
- 12进到 30 的右孩子 40。40 在删除集里,要删掉。
- 1340 被删除。它是叶子、没有任何孩子,所以不会产生新的森林根,只是 30 的右边这条边断开。
- 1440 已经从树上移除,30 现在只剩左孩子 20。第一棵树这边处理完,回到根 50,去看它的右孩子 80。
- 15进到 50 的右孩子 80。80 在删除集里,要删掉。注意 80 可不是叶子,它下面还挂着 60 和 70。
- 1680 被删除。关键来了:80 一旦删掉,它的孩子 60 和 70 就失去了父亲。递归到它们时,传下去的 isRoot 是「真」(父亲 80 被删了),所以 60、70 只要存活,就各自成为森林里一棵新树的根。
- 1780 已移除,50 的右边断开。60 和 70 现在浮了出来、不再连着任何父亲。挨个看它们,它俩的 isRoot 都是真。
- 18先看 60。它的 isRoot 是真,且不在删除集里。
- 1960 没被删、又是根,收进答案。它就是森林里第二棵树的根(标橙),这棵树只有它一个节点。
- 20再看 70。同样 isRoot 为真、不在删除集里。
- 2170 没被删、又是根,收进答案。它是森林里第三棵树的根(标橙),也只有它自己一个节点。
- 22整棵树删完了,裂成了一片森林,共三棵树:第一棵是 50(左边挂 30、30 下面挂 20),第二棵是单独的 60,第三棵是单独的 70。三个橙色节点 50、60、70 就是要返回的森林根。
- 23回头看:把删除集放进集合 O(1) 判删;一次 DFS,每到一个节点先判删,谁的父亲被删、谁自己又存活,就靠 isRoot 标志认定为新根收进答案;被删的节点返回空、让父亲断开这条边。一遍 O(n) 就把森林全收齐了。
⚠️ 容易写错的地方
✗ 错:删了节点却不把存活的孩子收成新根
✓ 对:父被删时,递归孩子传 isRoot=真,孩子存活就收进答案
森林的根正是「父亲被删、自己没被删」的那些节点,漏收就丢了整棵子树
✗ 错:删节点后忘了断开父亲指向它的边
✓ 对:被删节点返回空,父亲用返回值重接(node.left = dfs(...))
不断开的话父亲还指着被删的节点,树没真正裂开
✗ 错:用列表线性查找判断是否要删
✓ 对:把 to_delete 放进集合,判删 O(1)
每个节点都要判一次,线性查找会让总复杂度退化到 O(n·k)
完整代码(Python / C++ / Java)
Python
from typing import List, Optional
class TreeNode:
def __init__(self,val=0,left=None,right=None):
self.val=val; self.left=left; self.right=right
class Solution:
def delNodes(self, root: Optional[TreeNode], to_delete: List[int]) -> List[TreeNode]:
dead=set(to_delete); ans=[]
def dfs(node,is_root):
if not node: return None
deleted=node.val in dead
if is_root and not deleted: ans.append(node)
node.left=dfs(node.left,deleted)
node.right=dfs(node.right,deleted)
return None if deleted else node
dfs(root,True)
return ansC++
#include <algorithm>
#include <functional>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
using namespace std;
struct TreeNode{int val;TreeNode* left;TreeNode* right;TreeNode():val(0),left(nullptr),right(nullptr){} TreeNode(int x):val(x),left(nullptr),right(nullptr){} TreeNode(int x,TreeNode*l,TreeNode*r):val(x),left(l),right(r){}};
class Solution{public: vector<TreeNode*> delNodes(TreeNode* root,vector<int>& to_delete){ unordered_set<int> dead(to_delete.begin(),to_delete.end()); vector<TreeNode*> ans; function<TreeNode*(TreeNode*,bool)> dfs=[&](TreeNode*n,bool isRoot){ if(!n)return (TreeNode*)nullptr; bool del=dead.count(n->val); if(isRoot&&!del)ans.push_back(n); n->left=dfs(n->left,del); n->right=dfs(n->right,del); return del?nullptr:n;}; dfs(root,true); return ans; }};Java
import java.util.*;
class TreeNode{int val;TreeNode left,right;TreeNode(){} TreeNode(int val){this.val=val;} TreeNode(int val,TreeNode left,TreeNode right){this.val=val;this.left=left;this.right=right;}}
class Solution{ public List<TreeNode> delNodes(TreeNode root,int[] to_delete){ Set<Integer> dead=new HashSet<>(); for(int x:to_delete)dead.add(x); ArrayList<TreeNode> ans=new ArrayList<>(); dfs(root,true,dead,ans); return ans;} TreeNode dfs(TreeNode n,boolean isRoot,Set<Integer> dead,List<TreeNode> ans){ if(n==null)return null; boolean del=dead.contains(n.val); if(isRoot&&!del)ans.add(n); n.left=dfs(n.left,del,dead,ans); n.right=dfs(n.right,del,dead,ans); return del?null:n;} }复杂度
时间
O(n)
每个节点恰好被访问一次,判删是哈希集合 O(1);n 是节点数
空间
O(n)
删除集合 O(k)、递归栈最坏 O(h)(树退化成链时到 O(n))、答案列表最坏 O(n);合起来 O(n)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 删点成林 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么用后序的思路(先递归孩子、再返回自己)而不是先断开?+
递归 dfs 返回的是「这棵子树处理后真正的根」:节点被删就返回空、没删就返回自己。父亲必须用这个返回值重新接回 node.left / node.right,才能把被删的节点从树上摘掉。所以是「先递归孩子拿到返回值,再用返回值重接、最后返回自己」,本质是自底向上把指针接对。
答案里要不要包含被删节点的子树?+
要,只要那些子树里的节点没被删。被删节点的每个存活孩子都会以 isRoot=真 被递归到,从而作为新树的根收进答案;它们各自的子树跟着这个根一起留在森林里。只有被删的那些节点本身不在任何返回的树上。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄声线把 删点成林 一步步讲透(全站 0 节语音精讲,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。