Binary Tree and Lowest Common Ancestor | by Shuo Wang | Towards Data Science

It is the lowest level parent shared by two nodes within a tree.
Let’s take a look at an example:
Within the above binary tree, nodes 8 and 10 are highlighted. What are their shared parents?
It’s quite obvious that the shared parents are 5, 7, and 9.
But the shared parent at the lowest level is 9, and it is referred to as the lowest common ancestor(LCA).
Let’s warm up with a binary search tree.
A binary search tree is a special case of a binary tree, where the left subtree only contains smaller nodes and right subtree only contains bigger nodes.
Our example above is a binary search tree. As you can see, at node 3, all nodes in the left subtree(0, 1, 2) are smaller than 3 and all nodes on in the right subtree are bigger (4).
So, how do we go about looking for the LCA in a binary search tree for, say, node 8 and 10?
Actually binary search tree makes the search quite simple. Take a close look at node 9, what makes it different from node 7? I can think one thing:
Node 9 is between 8 and 10, whereas for node 7, both nodes 8 and 10 are bigger.
This is actually enough information to determine that node 9 is the LCA for node 8 and 10 in a binary search tree, thanks to its great property.
Below is an implementation of it:
from collections import dequeclass Node:    def __init__(self, value, left=None, right=None):        self.value = value        self.left = left        self.right = rightdef create_bt(value_queue):    """create binary tree"""    if len(value_queue) <= 0:        return None, None    root = Node(value_queue.popleft())    current_queue = deque()    current_queue.append(root)    while len(current_queue) > 0 and len(value_queue) > 0:        current_node = current_queue.popleft()        left = value_queue.popleft()        if left is not None:            current_node.left = Node(left)            current_queue.append(current_node.left)        right = value_queue.popleft()        if right is not None:            current_node.right = Node(right)            current_queue.append(current_node.right)    return rootdef create_bt_fls(value_list):    """create binary create from list"""    return create_bt(deque(value_list))def lca_bst(bst, v1, v2):    """lowest common ancestor of two nodes       in a binary search tree"""    if v1 <= bst.value <= v2 or v1 >= bst.value >= v2:        return bst    elif bst.value > v1 and bst.value > v2:        return lca_bst(bst.left, v1, v2)    else:        return lca_bst(bst.right, v1, v2)bt1 = create_bt_fls([5, 3, 7, 1, 4, 6, 9, 0, 2, None, None, None, None, 8, 10])lca = lca_bst(bt1, 8, 10)print(lca.value)'''output:9'''
In this implementation, we first create a binary tree data structure from a list of values, and then call the LCA algorithm to find the LCA of node 8 and 10, which returns the correct value of 9.
Let’s take a pause and think about the complexity of this algorithm a little. The algorithm starts at the root, at each node it compares the node’s value against the two input nodes, if the value is between the two nodes return the node as the answer, otherwise move on to the left if the value is greater than both input nodes, or right if the value is smaller than both input nodes.
So during each recursion iteration, the search space is cut in half (half of the tree), this means that if the total number of nodes in the tree is n, the number of iterations needed is log(n) (of base 2), so the complexity is O(log(n)). Not too bad.
That was a nice warm up, now let’s make things slightly more complicated.
Suppose now that the tree is not a binary search tree, but a tree with random node values of no particular structure. How do we find the LCA of two nodes in the tree?
If you try to run the binary search tree LCA algorithm on the above tree, it will fail miserably:
ls1 = [0, 1, 2, 3, 4, 5, 6, 7, 8, None, None, None, None, 9, 10]bt1, node_dict = create_bt_fls(ls1)lca = lca_bst(bt1, 9, 10)print(lca.value)'''output:10 WRONG!!'''
We have to think of another way.
One intuitive way is from the following observation:
As you can see from the graphs above, the path to node 9 is 0, 2, 6, 9, and the path to node 10, is 0, 2, 6, 10. Once we have the paths to each node, all we need to do is find the last matching node in the two paths, which would also be our LCA!
Below is an implementation:
def find_path(tree, value):    """find path from root to value"""    if tree is None:        return None    elif tree.value == value:        return [tree]    res = find_path(tree.left, value)    if res is not None:        return [tree] + res    res = find_path(tree.right, value)    if res is not None:        return [tree] + res    return Nonedef lca_fp(tree, v1, v2):    """find lca of two nodes using find_path"""    path1 = find_path(tree, v1)    path2 = find_path(tree, v2)    cur_idx = 0    while len(path1) > cur_idx and \          len(path2) > cur_idx and \          path1[cur_idx].value == path2[cur_idx].value:        cur_idx = cur_idx + 1    return path1[cur_idx - 1]lca = lca_fp(bt1, 9, 10)print(lca.value)'''output:6'''
Works.
The above algorithm is simple enough, just one thing to notice, the Find Path algorithm searches the entire tree for a path to a target node, so its complexity is O(n), therefore the LCA algorithm using find_path is also O(n). A sacrifice of performance for not constructing the tree as a binary search tree.
The complexity of the Find Path algorithm is not terribly worrying, but what if you need to perform the search repeatedly? Remember there are n choose 2 number of unique pairs of nodes in a tree of size n. that’s:
number of node pairs, if we get the LCA for all of them using Find Path algorithm, that would be O(n2n) or O(n3). Maybe we should try to speed it up a bit.
One way to speed up the calculation of the find path is to store a parent pointer in the node, so that when we try to find the path to a node, all we need to do is follow the parents all the way to the root instead of searching the entire tree for a path.
Below is an implementation of this enhancement to the tree:
class Node:    def __init__(self, value, parent=None, left=None, right=None):        self.value = value        self.parent = parent        self.left = left        self.right = right    def add_to_dict(self, node_dict):        node_dict[self.value] = selfdef create_bt(value_queue):    """create binary tree"""    if len(value_queue) <= 0:        return None, None    node_dict = {}    root = Node(value_queue.popleft())    root.add_to_dict(node_dict)    current_queue = deque()    current_queue.append(root)    while len(current_queue) > 0 and len(value_queue) > 0:        current_node = current_queue.popleft()        left = value_queue.popleft()        if left is not None:            current_node.left = Node(left, parent=current_node)            current_node.left.add_to_dict(node_dict)            current_queue.append(current_node.left)        right = value_queue.popleft()        if right is not None:            current_node.right = Node(right, parent=current_node)            current_node.right.add_to_dict(node_dict)            current_queue.append(current_node.right)    return root, node_dictdef create_bt_fls(value_list):    """create binary create from list"""    return create_bt(deque(value_list))
In this new implementation of the tree, we have stored an additional field, parent, in the node to store the parent node.
We have also created a dictionary of value to nodes so that we can find the nodes corresponding to a value easily.
With these two enhancement, we can implement a new LCA algorithm:
def lca(node1, node2):    parents = set()    cur_parent = node1    while cur_parent is not None:        parents.add(cur_parent)        cur_parent = cur_parent.parent    cur_parent = node2    while cur_parent is not None:        if cur_parent in parents:            return cur_parent        cur_parent = cur_parent.parent    return Nonels1 = [0, 1, 2, 3, 4, 5, 6, 7, 8, None, None, None, None, 9, 10]bt1, node_dict = create_bt_fls(ls1)lca1 = lca(node_dict[9], node_dict[10])print(lca1.value)'''output:6'''
Works again.
With this enhancement, we sacrificed a bit more memory space, O(n), to store the parent pointer, but since tracing from node to root parent is O(log(n)), the complexity of the algorithm reduces to O(log(n)). Running on all node pairs now takes O(n2log(n)), memory for speed.
To be Continued...
Is this the best we can do? Absolutely not, there are many ways to optimize the algorithm. Tarjan’s Off-Line Lowest Common Ancestor algorithm, in particular, can reduce the performance complexity to constant! But since it involves a completely new data structure (disjoint-set), we’ll discuss it in another story.