LeetCode 1110 - Delete Nodes And Return Forest
This problem gives us the root of a binary tree and a list of node values that must be deleted from the tree. Every node value in the tree is unique, which is important because it means we can identify nodes directly by value without ambiguity.
Difficulty: 🟡 Medium
Topics: Array, Hash Table, Tree, Depth-First Search, Binary Tree
Solution
LeetCode 1110 - Delete Nodes And Return Forest
Problem Understanding
This problem gives us the root of a binary tree and a list of node values that must be deleted from the tree. Every node value in the tree is unique, which is important because it means we can identify nodes directly by value without ambiguity.
When a node is deleted, it is completely removed from the tree. Its parent loses the connection to it. However, the deleted node’s children are not automatically deleted unless their values also appear in to_delete. If those children remain valid, they become new roots in the resulting forest.
The final output is not a single tree, but a collection of disconnected trees. This collection is called a forest. We must return the root node of every remaining tree after all deletions are complete.
For example, consider this tree:
1
/ \
2 3
/ \ / \
4 5 6 7
If we delete nodes 3 and 5:
- Node
3is removed. - Its children
6and7are not deleted, so they become new roots. - Node
5is removed. - Node
1remains the root of the original tree.
The resulting forest contains:
1 6 7
/
2
/
4
The constraints are relatively small:
- At most
1000nodes - Node values are between
1and1000 - Values are distinct
Because the tree size is small, an O(n) or O(n log n) solution is more than sufficient. However, we still want an efficient traversal because repeatedly searching the delete list could become unnecessarily expensive.
Several edge cases are important:
- The root itself may be deleted.
- Every node may be deleted.
- No nodes may be deleted.
- A deleted node may have surviving children that must become new roots.
- Leaf nodes may be deleted.
- The tree may be highly unbalanced.
The problem guarantees unique node values, which allows efficient lookup using a hash set.
Approaches
Brute Force Approach
A straightforward solution is to process each value in to_delete one at a time. For every deletion value, we could traverse the entire tree to find the node and its parent, disconnect the node, and then determine whether its children should become new roots.
This works because eventually every node marked for deletion gets removed correctly. However, the inefficiency comes from repeatedly scanning the tree.
If there are n nodes and d deletion values, we may end up traversing the tree d times. In the worst case:
n = 1000d = 1000
This leads to O(n * d) complexity, which becomes O(n²) in the worst case.
Although the constraints are small enough that this could still pass, it is unnecessarily inefficient and more complicated to implement correctly because tree structure changes during repeated deletions.
Optimal Approach
The key insight is that deletions naturally fit a postorder tree traversal.
When deciding whether to delete a node, we first need to know what happened to its children. If a child was deleted, the current node’s pointer to that child should become None. This means children must be processed before parents.
That is exactly what postorder traversal does:
- Process left subtree
- Process right subtree
- Process current node
We also use a hash set for to_delete so membership checks are O(1).
During DFS:
-
Recursively process children first.
-
Update child pointers after recursion.
-
If the current node should be deleted:
-
Add surviving children to the forest.
-
Return
Noneto the parent. -
Otherwise return the current node.
Finally, if the original root is not deleted, it must be included in the forest.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n²) | O(h) | Repeatedly traverses tree for each deletion |
| Optimal | O(n) | O(h + d) | Single DFS traversal with hash set lookups |
Here:
nis the number of nodeshis tree heightdis the size ofto_delete
Algorithm Walkthrough
- Convert
to_deleteinto a hash set.
We need to frequently check whether a node should be deleted. A hash set gives average O(1) lookup time, which is much faster than scanning a list repeatedly.
2. Create an empty result list called forest.
This list stores the roots of all remaining trees after deletions. 3. Define a recursive DFS function.
The DFS function processes a subtree and returns the updated root after deletions. 4. Recursively process the left child.
The returned value may be:
- The original child node if it survives
Noneif the child was deleted
We assign this returned value back to node.left.
5. Recursively process the right child.
The same logic applies to the right subtree. 6. Check whether the current node should be deleted.
If node.val exists in the delete set:
- Any surviving left child becomes a new forest root.
- Any surviving right child becomes a new forest root.
- Return
Noneso the parent disconnects this node.
- If the current node is not deleted, return the node itself.
This preserves the subtree connection. 8. Process the original root with DFS.
The returned value tells us whether the original root survives. 9. If the root survives, add it to the forest.
Since it has no parent, it remains a valid tree root. 10. Return the forest list.
Why it works
The algorithm works because postorder traversal guarantees that children are fully processed before their parent is evaluated. By the time we decide whether to delete a node, we already know:
- Which children survived
- Which children were removed
- Which children should become new roots
Returning None cleanly removes deleted nodes from the tree, while adding surviving children to the forest preserves all disconnected subtrees correctly.
Python Solution
# Definition for a binary tree node.
# 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]:
delete_set = set(to_delete)
forest = []
def dfs(node: Optional[TreeNode]) -> Optional[TreeNode]:
if not node:
return None
node.left = dfs(node.left)
node.right = dfs(node.right)
if node.val in delete_set:
if node.left:
forest.append(node.left)
if node.right:
forest.append(node.right)
return None
return node
root = dfs(root)
if root:
forest.append(root)
return forest
The implementation begins by converting to_delete into a set for efficient lookup. This prevents repeated linear searches during traversal.
The recursive dfs function handles all subtree processing. The base case returns None when the node is empty.
The recursive calls process the left and right subtrees first. Their returned values are reassigned back to node.left and node.right. This is important because deleted children return None, effectively disconnecting them from the parent.
After processing children, the algorithm checks whether the current node should be deleted.
If the node must be removed:
- Surviving children are added to the forest because they become independent trees.
- The function returns
None, signaling the parent to disconnect this node.
If the node survives, it is returned unchanged.
Finally, after DFS finishes, the original root is added to the forest only if it still exists.
Go Solution
/**
* Definition for a binary tree node.
* type TreeNode struct {
* Val int
* Left *TreeNode
* Right *TreeNode
* }
*/
func delNodes(root *TreeNode, to_delete []int) []*TreeNode {
deleteSet := make(map[int]bool)
for _, val := range to_delete {
deleteSet[val] = true
}
forest := []*TreeNode{}
var dfs func(node *TreeNode) *TreeNode
dfs = func(node *TreeNode) *TreeNode {
if node == nil {
return nil
}
node.Left = dfs(node.Left)
node.Right = dfs(node.Right)
if deleteSet[node.Val] {
if node.Left != nil {
forest = append(forest, node.Left)
}
if node.Right != nil {
forest = append(forest, node.Right)
}
return nil
}
return node
}
root = dfs(root)
if root != nil {
forest = append(forest, root)
}
return forest
}
The Go implementation follows the same recursive logic as the Python version.
Instead of a Python set, Go uses a map[int]bool for constant time membership checks.
Go uses nil instead of Python’s None. Returning nil from DFS disconnects deleted nodes from their parents.
Slices are dynamically expanded using append, which is how the forest list is maintained.
Because the constraints are small, recursion depth is safe within typical Go stack limits.
Worked Examples
Example 1
Input:
root = [1,2,3,4,5,6,7]
to_delete = [3,5]
Initial tree:
1
/ \
2 3
/ \ / \
4 5 6 7
Delete set:
{3, 5}
DFS Traversal Order
Postorder traversal visits:
4 → 5 → 2 → 6 → 7 → 3 → 1
Step-by-Step Trace
| Current Node | Deleted? | Action | Forest |
|---|---|---|---|
| 4 | No | Return node 4 | [] |
| 5 | Yes | Delete node 5 | [] |
| 2 | No | Left=4, Right=None | [] |
| 6 | No | Return node 6 | [] |
| 7 | No | Return node 7 | [] |
| 3 | Yes | Add 6 and 7 to forest | [6, 7] |
| 1 | No | Left=2, Right=None | [6, 7] |
After traversal:
- Root
1survives - Add
1to forest
Final forest:
[1, 6, 7]
Resulting trees:
1 6 7
/
2
/
4
Example 2
Input:
root = [1,2,4,null,3]
to_delete = [3]
Initial tree:
1
/ \
2 4
\
3
Delete set:
{3}
DFS Traversal Order
3 → 2 → 4 → 1
Step-by-Step Trace
| Current Node | Deleted? | Action | Forest |
|---|---|---|---|
| 3 | Yes | Delete node 3 | [] |
| 2 | No | Right becomes None | [] |
| 4 | No | Return node 4 | [] |
| 1 | No | Tree remains connected | [] |
Root 1 survives, so it is added to forest.
Final forest:
[1]
Resulting tree:
1
/ \
2 4
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(n) | Each node is visited exactly once |
| Space | O(h + d) | Recursion stack plus delete set |
The DFS traversal processes every node one time, giving linear time complexity.
The recursion stack requires O(h) space, where h is the height of the tree. In the worst case of a skewed tree, this becomes O(n).
The delete set stores up to d values from to_delete.
Test Cases
# Helper functions for testing
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def serialize(root):
if not root:
return None
return (
root.val,
serialize(root.left),
serialize(root.right)
)
# Solution implementation
class Solution:
def delNodes(self, root, to_delete):
delete_set = set(to_delete)
forest = []
def dfs(node):
if not node:
return None
node.left = dfs(node.left)
node.right = dfs(node.right)
if node.val in delete_set:
if node.left:
forest.append(node.left)
if node.right:
forest.append(node.right)
return None
return node
root = dfs(root)
if root:
forest.append(root)
return forest
sol = Solution()
# Test 1: Example 1
root = TreeNode(1,
TreeNode(2,
TreeNode(4),
TreeNode(5)
),
TreeNode(3,
TreeNode(6),
TreeNode(7)
)
)
result = [serialize(tree) for tree in sol.delNodes(root, [3, 5])]
assert len(result) == 3 # verifies forest split
# Test 2: Example 2
root = TreeNode(1,
TreeNode(2, None, TreeNode(3)),
TreeNode(4)
)
result = [serialize(tree) for tree in sol.delNodes(root, [3])]
assert result == [
(1, (2, None, None), (4, None, None))
] # verifies leaf deletion
# Test 3: Delete root
root = TreeNode(1, TreeNode(2), TreeNode(3))
result = [serialize(tree) for tree in sol.delNodes(root, [1])]
assert len(result) == 2 # children become new roots
# Test 4: Delete nothing
root = TreeNode(1, TreeNode(2), TreeNode(3))
result = [serialize(tree) for tree in sol.delNodes(root, [])]
assert result == [
(1, (2, None, None), (3, None, None))
] # original tree remains unchanged
# Test 5: Delete all nodes
root = TreeNode(1, TreeNode(2), TreeNode(3))
result = sol.delNodes(root, [1, 2, 3])
assert result == [] # forest should be empty
# Test 6: Single node survives
root = TreeNode(1)
result = [serialize(tree) for tree in sol.delNodes(root, [])]
assert result == [
(1, None, None)
] # single-node tree remains
# Test 7: Single node deleted
root = TreeNode(1)
result = sol.delNodes(root, [1])
assert result == [] # deleting only node leaves empty forest
# Test 8: Skewed tree
root = TreeNode(1,
TreeNode(2,
TreeNode(3,
TreeNode(4)
)
)
)
result = [serialize(tree) for tree in sol.delNodes(root, [2])]
assert len(result) == 1 # verifies subtree promotion
# Test 9: Delete internal node with surviving children
root = TreeNode(1,
TreeNode(2,
TreeNode(4),
TreeNode(5)
),
TreeNode(3)
)
result = [serialize(tree) for tree in sol.delNodes(root, [2])]
assert len(result) == 3 # children 4 and 5 become roots
Test Case Summary
| Test | Why |
|---|---|
| Example 1 | Verifies multiple deletions and forest splitting |
| Example 2 | Verifies leaf deletion |
| Delete root | Ensures children become new roots |
| Delete nothing | Confirms original tree remains intact |
| Delete all nodes | Verifies empty forest handling |
| Single node survives | Tests smallest non-empty tree |
| Single node deleted | Tests smallest empty result |
| Skewed tree | Verifies recursion on unbalanced trees |
| Delete internal node | Ensures surviving children become roots |
Edge Cases
Deleting the Root Node
One of the most important edge cases occurs when the root itself must be deleted. A naive implementation may accidentally lose access to the remaining subtrees.
For example:
1
/ \
2 3
If 1 is deleted, then 2 and 3 must become independent roots in the forest.
The implementation handles this correctly because when a deleted node is processed, any surviving children are immediately added to the forest before returning None.
Deleting Every Node
Another tricky case occurs when all nodes are deleted.
Example:
root = [1,2,3]
to_delete = [1,2,3]
The final forest should be empty.
A buggy implementation might accidentally keep references to deleted roots or append deleted nodes to the result list.
This solution avoids that problem because only surviving nodes are added to the forest. Since every DFS call returns None, no nodes remain.
Highly Unbalanced Trees
A skewed tree behaves more like a linked list:
1
\
2
\
3
\
4
Recursive tree algorithms sometimes fail on skewed structures if child references are not updated carefully after recursion.
This implementation correctly reconnects subtrees because each recursive call returns the updated subtree root. Whether a child survives or is deleted, the parent always receives the correct updated pointer.