Skip to content

Commit 3593628

Browse files
Add:
- 'Lowest Common Ancestor of a Binary Search Tree' - 'Lowest Common Ancestor of a Binary Tree'
1 parent 313be6e commit 3593628

6 files changed

+311
-0
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ A collection of LeetCode solutions
3232

3333
[Longest Increasing Subsequence](./src/longest_increasing_subsequence.py)
3434

35+
[Lowest Common Ancestor of a Binary Search Tree](./src/lowest_common_ancestor_of_a_binary_search_tree.py)
36+
37+
[Lowest Common Ancestor of a Binary Tree](./src/lowest_common_ancestor_of_a_binary_tree.py)
38+
3539
[Maximum Depth of Binary Tree](./src/maximum_depth_of_binary_tree.py)
3640

3741
[Maximum Subarray](./src/maximum_subarray.py)

TODO.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@
1212
- [ ] Add recursive approach to 'Merge Two Sorted Lists'
1313
- [ ] Add a *divide and conquer* approach to 'Merge k Sorted Lists'. This approach has O(nlog n) time complexity, but O(1) space complexity.
1414
- [ ] Add self-balancing BST approach to 'Find Median from Data Stream'
15+
- [ ] Add iterative without parent pointers approach to 'Lowest Common Ancestor of a Binary Tree'
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""
2+
235. Lowest Common Ancestor of a Binary Search Tree
3+
4+
https://leetcode.com/problems/lowest-common-ancestor-of-a-binary-search-tree
5+
6+
NOTES
7+
* This problem can also be conceptualized as "lowest common root". Given the
8+
definition of a binary search tree:
9+
10+
>A binary search tree (BST) is a binary tree in which the key of each
11+
internal node is greater than all the keys in the node's left subtree
12+
and less than all the keys in the node's right subtree.
13+
14+
we simply need to find the deepest root node of the subtree containing both
15+
p and q. This node marks the root of the small subtree containing both p
16+
and q.
17+
18+
Initially, I solved this problem using a set of visited nodes when traversing
19+
from root to p, then found where the paths diverged when traversing from root
20+
to q. This can be optimized, however, using the strategy given above.
21+
22+
It is always important to leverage the unique properties of a data structure.
23+
For example, finding the lowest common ancestor of a binary tree (not binary
24+
search tree), requires a different approach.
25+
"""
26+
27+
from src.classes import TreeNode
28+
29+
30+
class Solution:
31+
def lowestCommonAncestor(self, root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
32+
while True:
33+
if root.val > p.val and root.val > q.val and root.left:
34+
root = root.left
35+
continue
36+
if root.val < p.val and root.val < q.val and root.right:
37+
root = root.right
38+
continue
39+
return root
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""
2+
236. Lowest Common Ancestor of a Binary Tree
3+
4+
https://leetcode.com/problems/lowest-common-ancestor-of-a-binary-tree
5+
6+
NOTES
7+
8+
Wikipedia has an excellent article on lowest common ancestor:
9+
10+
The lowest common ancestor (LCA) (also called least common ancestor) of two
11+
nodes v and w in a tree or directed acyclic graph (DAG) T is the lowest
12+
(i.e., deepest) node that has both v and w as descendants, where we define
13+
each node to be a descendant of itself (so if v has a direct connection from
14+
w, w is the lowest common ancestor).
15+
16+
The LCA of v and w in T is the shared ancestor of v and w that is located
17+
farthest from the root. Computation of lowest common ancestors may be useful,
18+
for instance, as part of a procedure for determining the distance between
19+
pairs of nodes in a tree: the distance from v to w can be computed as the
20+
distance from the root to v, plus the distance from the root to w, minus
21+
twice the distance from the root to their lowest common ancestor.
22+
23+
In a tree data structure where each node points to its parent, the lowest
24+
common ancestor can be easily determined by finding the first intersection of
25+
the paths from v and w to the root. In general, the computational time
26+
required for this algorithm is O(h) where h is the height of the tree (length
27+
of longest path from a leaf to the root). However, there exist several
28+
algorithms for processing trees so that lowest common ancestors may be found
29+
more quickly.
30+
31+
Unlike with 'Lowest Common Ancestor of a Binary Search Tree' we cannot leverage
32+
the fact that the tree is a binary search tree. This makes the problem slightly
33+
more challenging and increases the time complexity from O(log n) to O(n). This
34+
problem can be solved recursively and iteratively.
35+
36+
Recursive Approach
37+
------------------
38+
In order to visualize the recursive solution, imagine moving down the tree in
39+
a depth-first manner. Each recursive call postpones the processing of the
40+
current node until calls higher in the stack (or lower in the tree) have
41+
returned. The first recursive call is the last to fully execute and returns the
42+
solution, which is "passed up" from previous calls. Now, let's recall that the
43+
lowest common ancestor of two nodes is the deepest node of the subtree that
44+
contains both nodes. Therefore, we need to develop a base condition and
45+
recursive logic such that the final recursive call returns the lowest common
46+
ancestor, which may have been determined somewhere within the recursion.
47+
48+
Base Case:
49+
50+
>If the node is a leaf (root == None) or root is p or q, return root.
51+
52+
Basically, this passes information up the recursive call stack (or up the
53+
tree). Either, no, the current path terminated without finding p or q, or, yes,
54+
p or q was found. Within this conditional is an implicit boolean, which is used
55+
later in the recursive logic.
56+
57+
Recursive Logic:
58+
59+
>Recurse into the left and right subtrees. If both subtrees find the target,
60+
return root. Otherwise, return the result of either the left or right
61+
subtree.
62+
63+
This is where the solution is "passed up". It is also where the both macro- and
64+
microstructure of the recursive solution is codified. If either of the subtrees
65+
returns a non-null, this value is returned up the call stack until both
66+
subtrees return a non-null. The final return call returns the solution.
67+
68+
It is probably helpful here to consider two cases:
69+
1. The LCA is contained in the left or right subtree of root.
70+
2. The LCA is the root itself.
71+
72+
As you can see, the recursive invariant holds for all subtrees.
73+
74+
```
75+
# The LCA is the root itself.
76+
if left and right:
77+
return root
78+
79+
# The LCA is contained in the left or right subtree of root.
80+
return left if left else right
81+
```
82+
83+
Iterative Approach
84+
------------------
85+
The iterative approach is more intuitive, since it uses the fact that the node
86+
at which the path from the root to p diverges (or converges if viewed from
87+
bottom-up) from the path from the root to q is the lowest common ancestor.
88+
Since nodes do not inherently contain a pointer to their parent (trees are
89+
typically directed from parent to child), a hash map is used to maintain a
90+
mapping of child->parent for all nodes traversed while searching for p and q.
91+
We can then trace back from p to root using the hash map, producing a set of
92+
nodes (ancestors) for p. Tracing back from q to root, the first node contained
93+
in ancestors is the lowest common ancestor for p and q.
94+
"""
95+
96+
from collections import deque
97+
98+
from src.classes import TreeNode
99+
100+
101+
class Solution:
102+
"""
103+
Returns the LCA of `p` and `q` for the tree rooted at `root` recursively.
104+
"""
105+
106+
def lowestCommonAncestor(self, root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
107+
# Base Case: If root is a leaf (None) or if root is p or q, return
108+
# root.
109+
if not root or root in (p, q):
110+
return root
111+
112+
# Recurse into the left and right subtrees of root.
113+
#
114+
# left and right may be one of the following:
115+
# 1. root.left and root.right if root is the LCA.
116+
# 2. The LCA of the left subtree and None, if the LCA was found in
117+
# the left subtree.
118+
# 3. None and the LCA of the right subtree, if the LCA was found in
119+
# the right subtree.
120+
#
121+
# This invariant holds for all subtrees.
122+
left = self.lowestCommonAncestor(root.left, p, q)
123+
right = self.lowestCommonAncestor(root.right, p, q)
124+
125+
# root is the LCA
126+
if left and right:
127+
return root
128+
129+
# left or right is the LCA (or p or q was found)
130+
return left if left else right
131+
132+
133+
class IterativeSolution:
134+
"""
135+
Returns the LCA of `p` and `q` for the tree rooted at `root` iteratively.
136+
"""
137+
138+
def lowestCommonAncestor(self, root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
139+
stack: deque[TreeNode] = deque([root])
140+
parents: dict[TreeNode, TreeNode | None] = {root: None}
141+
curr: TreeNode = root
142+
143+
# Traverse the tree until both p and q are found. This populates the
144+
# `parents` hash map and requires O(n) time complexity in the
145+
# worst-case.
146+
while stack:
147+
curr = stack.pop()
148+
# NOTE: For pre-order traversal, the right child is pushed onto the
149+
# stack first.
150+
if curr.right:
151+
stack.append(curr.right)
152+
parents[curr.right] = curr
153+
if curr.left:
154+
stack.append(curr.left)
155+
parents[curr.left] = curr
156+
157+
# Using the populated `parents` hash map, create a set of ancestors by
158+
# tracing the path from `p` to `root`.
159+
ancestors: set[TreeNode] = set()
160+
161+
# NOTE: Remember to add p! This handles the case where the node itself,
162+
# (p or q) is the LCA:
163+
#
164+
# >...we define each node to be a descendant of itself (so if v has a
165+
# direct connection from w, w is the lowest common ancestor).
166+
while p:
167+
ancestors.add(p)
168+
p = parents[p]
169+
170+
# Now, trace the path from `q` to `root`. The first ancestor in the
171+
# path that is shared with `p` (is part of the set of ancestors) is the
172+
# LCA of `p` and `q`.
173+
while q:
174+
if q in ancestors:
175+
return q
176+
q = parents[q]
177+
178+
return root
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""
2+
235. Lowest Common Ancestor of a Binary Search Tree
3+
4+
https://leetcode.com/problems/lowest-common-ancestor-of-a-binary-search-tree
5+
"""
6+
7+
from unittest import TestCase
8+
9+
from src.lowest_common_ancestor_of_a_binary_search_tree import Solution
10+
from tests.utils import create_bfs_list_from_binary_tree, create_binary_tree_from_list
11+
12+
13+
class TestSolution(TestCase):
14+
def test_1(self):
15+
exp = 6
16+
root = create_binary_tree_from_list([6, 2, 8, 0, 4, 7, 9, None, None, 3, 5])
17+
l = create_bfs_list_from_binary_tree(root=root, values_only=False)
18+
p, q = l[1], l[2] # p = 2, q = 8
19+
assert Solution().lowestCommonAncestor(root, p, q).val == exp
20+
21+
def test_2(self):
22+
exp = 2
23+
root = create_binary_tree_from_list([6, 2, 8, 0, 4, 7, 9, None, None, 3, 5])
24+
l = create_bfs_list_from_binary_tree(root=root, values_only=False)
25+
p, q = l[1], l[4] # p = 2, q = 4
26+
assert Solution().lowestCommonAncestor(root, p, q).val == exp
27+
28+
def test_3(self):
29+
exp = 2
30+
root = create_binary_tree_from_list([2, 1])
31+
l = create_bfs_list_from_binary_tree(root=root, values_only=False)
32+
p, q = l[0], l[1] # p = 2, q = 1
33+
assert Solution().lowestCommonAncestor(root, p, q).val == exp
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
236. Lowest Common Ancestor of a Binary Tree
3+
4+
https://leetcode.com/problems/lowest-common-ancestor-of-a-binary-tree
5+
"""
6+
7+
from unittest import TestCase
8+
9+
from src.lowest_common_ancestor_of_a_binary_tree import IterativeSolution, Solution
10+
from tests.utils import create_bfs_list_from_binary_tree, create_binary_tree_from_list
11+
12+
13+
class TestSolution(TestCase):
14+
def test_1(self):
15+
exp = 3
16+
root = create_binary_tree_from_list([3, 5, 1, 6, 2, 0, 8, None, None, 7, 4])
17+
l = create_bfs_list_from_binary_tree(root=root, values_only=False)
18+
p, q = l[1], l[2] # p = 5, q = 1
19+
assert Solution().lowestCommonAncestor(root, p, q).val == exp
20+
21+
def test_2(self):
22+
exp = 5
23+
root = create_binary_tree_from_list([3, 5, 1, 6, 2, 0, 8, None, None, 7, 4])
24+
l = create_bfs_list_from_binary_tree(root=root, values_only=False)
25+
p, q = l[1], l[8] # p = 5, q = 4
26+
assert Solution().lowestCommonAncestor(root, p, q).val == exp
27+
28+
def test_3(self):
29+
exp = 2
30+
root = create_binary_tree_from_list([2, 1])
31+
l = create_bfs_list_from_binary_tree(root=root, values_only=False)
32+
p, q = l[0], l[1] # p = 1, q = 2
33+
assert Solution().lowestCommonAncestor(root, p, q).val == exp
34+
35+
36+
class TestIterativeSolution(TestCase):
37+
def test_1(self):
38+
exp = 3
39+
root = create_binary_tree_from_list([3, 5, 1, 6, 2, 0, 8, None, None, 7, 4])
40+
l = create_bfs_list_from_binary_tree(root=root, values_only=False)
41+
p, q = l[1], l[2] # p = 5, q = 1
42+
assert IterativeSolution().lowestCommonAncestor(root, p, q).val == exp
43+
44+
def test_2(self):
45+
exp = 5
46+
root = create_binary_tree_from_list([3, 5, 1, 6, 2, 0, 8, None, None, 7, 4])
47+
l = create_bfs_list_from_binary_tree(root=root, values_only=False)
48+
p, q = l[1], l[8] # p = 5, q = 4
49+
assert IterativeSolution().lowestCommonAncestor(root, p, q).val == exp
50+
51+
def test_3(self):
52+
exp = 2
53+
root = create_binary_tree_from_list([2, 1])
54+
l = create_bfs_list_from_binary_tree(root=root, values_only=False)
55+
p, q = l[0], l[1] # p = 1, q = 2
56+
assert IterativeSolution().lowestCommonAncestor(root, p, q).val == exp

0 commit comments

Comments
 (0)