I'm looking at two solutions to find the first common ancestor of two nodes in a binary tree (not necessarily a binary search tree). I've been told the second solution provides a better worst-case run time, but I can't figure out why. Can someone please enlighten me?
Solution 1:
import com.foo.graphstrees.BinaryTreeNodeWithParent;
/*
Find the first common ancestor to 2 nodes in a binary tree.
*/
public class FirstCommonAncestorFinder {
public BinaryTreeNodeWithParent find(BinaryTreeNodeWithParent p, BinaryTreeNodeWithParent q) {
int delta = depth(p) - depth(q);
BinaryTreeNodeWithParent first = delta > 0 ? q: p; // use shallower node
BinaryTreeNodeWithParent second = delta > 0 ? p: q; //use deeper
second = goUp(second, delta); // move up so they are level, if 1 node is deeper in the tree than the other, their common ancestor obviously cannot be below the shallower node, so we start them off at the same height in the tree
//keep going up the tree, once first == second, stop
while(!first.equals(second) && first !=null && second !=null) {
first = first.getParent();
second = second.getParent();
}
return first == null || second == null ? null : first;
}
private int depth(BinaryTreeNodeWithParent n) {
int depth = 0;
while (n != null) {
n = n.getParent();
depth++;
}
return depth;
}
private BinaryTreeNodeWithParent goUp(BinaryTreeNodeWithParent node, int delta) {
while (delta > 0 && node != null) {
node = node.getParent();
delta--;
}
return node;
}
}
Solution 2:
import com.foo.graphstrees.BinaryTreeNodeWithParent;
public class FirstCommonAncestorImproved {
public BinaryTreeNodeWithParent find(BinaryTreeNodeWithParent root,
BinaryTreeNodeWithParent a,
BinaryTreeNodeWithParent b) {
if (!covers(root, a) || !covers(root, b)) {
return null;
} else if (covers(a, b)) {
return a;
} else if (covers(b, a)) {
return b;
}
var sibling = getSibling(a);
var parent = a.getParent();
while (!covers(sibling, b)) {
sibling = getSibling(parent);
parent = parent.getParent();
}
return parent;
}
private BinaryTreeNodeWithParent getSibling(BinaryTreeNodeWithParent node) {
if (node == null || node.getParent() == null) return null;
var parent = node.getParent();
return node.equals(parent.getLeft()) ? node.getRight() : node.getLeft();
}
private boolean covers(BinaryTreeNodeWithParent root,
BinaryTreeNodeWithParent node) {
if (root == null) return false;
if (root.equals(node)) return true;
return covers(root.getLeft(), node) || covers(root.getRight(), node);
}
}
It depends on the structure of the problem.
If the starting nodes are deep in a big tree, and the ancestor is close by, then the first algorithm will still need to traverse the entire path to the root to find the depths. The second will succeed by inspecting only a small subtree.
On the other hand, if the nodes are deep and the common ancestor is very near the root, then the first will only traverse two paths to the root, while the second may explore the entire tree.
Note that as is often the case you can get an asymptotically faster algorithm by trading space for speed. Maintain a set of nodes. Traverse upward in alternating steps from both starting nodes, adding to the set until you find one that's already there. That's the common ancestor. Given the set operations are O(1), this algorithm is O(k) where k is the path length from the common ancestor to the most distant start node. You can't do better.
Set<Node> visited = new HashSet<>();
while (a != null && b != null) {
if (visited.contains(a)) return a;
if (visited.contains(b)) return b;
visited.add(a);
visited.add(b);
a = a.parent();
b = b.parent();
}
while (a != null) {
if (visited.contains(a)) return a;
a = a.parent();
}
while (b != null) {
if (visited.contains(b)) return b;
b = b.parent();
}
return null;