Recently, I was solving a tree problem that required finding the k-th ancestor of a node in a rooted tree and LCA (lowest common ancestor) of any two nodes quickly. I knew binary lifting does both of those things in O(log n) time, but I didn't know exactly how it worked. So I tried to think of a different approach, and came up with the following algorithm. The algorithm uses the concept of DFS arrival times and binary search, which I find easier to think about than binary lifting. It finds the k-th ancestor of any node in O(log n) time and LCA of any two nodes in O(log2 n) time (hence the "kind of" in the title).
Implementation:
Let us use the following example tree.

In this example, the DFS arrival times and node indices are the same for simplicity.
Setup:
Run DFS from the root. On arriving each node record the arrival time of that node (tin), the reverse mapping from arrival time to nodes (rev), and the depth of each node (d). Also, for each depth, record which arrival times occur at that depth (arrival_times).
int n = 6; // in this case number of nodes in the tree is 6 (nodes -> {0 .. 5}, 0 is the root)
vector<vector<int>> tree = {{1, 2}, {0}, {0, 3, 4}, {2}, {2, 5}, {4}};
vector<int> tin(n), rev(n), d(n);
vector<vector<int>> arrival_times(n); // however we only need first max_depth elements of this 2D array
int time = 0; // arrival time of the root would be 0
void Dfs (int node, int parent, int depth) {
tin[node] = time;
rev[time] = node;
d[node] = depth;
arrival_times[depth].push_back(time);
time += 1;
for (int child : tree[node]) {
if (child != parent) {
Dfs(child, node, depth + 1);
}
}
}
Dfs(0, -1, 0); // Running Dfs from root
In our example, arrival_times will turn out to be {{0}, {1, 2}, {3, 4}, {5}, {}, {}}.
This setup takes O(n) time and O(n) space.
Finding the kth ancestor of a node x:
Fact 1: Each array in arrival_times is in ascending order.
Fact 2: In DFS traversal, the arrival time of any ancestor of a node is always less than the arrival time of that node.
Fact 3: The depth of the kth ancestor of x is d[x] - k. (the ancestor doesn't exist if depth of x is less than k)
Fact 4: At any depth smaller than or equal to the depth of x, the node with the largest arrival time that is smaller than or equal to the arrival time of x is an ancestor of x.
Given these facts, the kth ancestor can be found using binary search as given below:
int da = d[x] - k; // depth of kth ancestor
int kth_ancestor;
if (da >= 0) {
kth_ancestor = rev[*prev(upper_bound(arrival_times[da].begin(), arrival_times[da].end(), tin[x]))];
}
else {
kth_ancestor = -1; // does not exist
}
Just one binary search operation, so it takes O(log n) time.
Finding LCA of any two nodes x and y:
Run binary search to find the maximum depth such that the ancestors of both x and y at that depth are the same node.
int lo = 0, hi = min(d[x], d[y]);
while (lo < hi) {
int mid = (lo + hi + 1) / 2;
int anc_x = *prev(upper_bound(arrival_times[mid].begin(), arrival_times[mid].end(), tin[x]));
int anc_y = *prev(upper_bound(arrival_times[mid].begin(), arrival_times[mid].end(), tin[y]));
if (anc_x == anc_y) {
lo = mid;
}
else {
hi = mid - 1;
}
}
int lca = rev[*prev(upper_bound(arrival_times[lo].begin(), arrival_times[lo].end(), tin[x]))];
Here, we run binary search and, in each iteration we run another binary search to find ancestors of x and y at depth mid. Hence time complexity would be O(log2 n).
I don’t know if this algorithm is already known or not. In either case, I thought writing this blog might help people who aren’t familiar with any algorithm for finding the k-th ancestor and LCA in a tree efficiently, such as binary lifting, or who find writing the code for it annoying, or who just want to know what other ways exist to achieve the same goal.
Hope you found this interesting :)









Actually this algorithm might be faster than thought!
For example, if you use binary lifting when finding k-th ancestor you should perform exactly $$$\log n$$$ lifts. It may subtract a constant, but I have never seen anyone really write more code for this. But in this algorithm, unless there are really $$$n$$$ nodes in the same layer, the algorithm will not actually search $$$\log n$$$ times. However, there is actually no significant change, since the impact of several constants on $$$\log n$$$ is not significant enough.
And you only have $$$n$$$ nodes on a tree. Assuming your tree is generated randomly with $$$O(\log n)$$$ layers, the actually complexity could be $$$O(\log\log n \times \log(\frac n{\log n}) = O(\log n\log\log n)$$$? I'm not sure about that but anyway it wouldn't go $$$O(\log^2n)$$$. Although trees cannot uniformly have $$$\frac n{\log n}$$$ nodes in each layer, the probability of LCA appearing in the last two layers is also very small. I assume this is the average complexity of this algorithm.
If at the worst scene, the complexity really reach $$$O(\log^2 n)$$$, then it means that this tree may have $$$O(\sqrt n)$$$ layers, each with $$$O(\sqrt n)$$$ nodes (and it looks quite "square") (if you want to make a hack, you can put $$$O(\frac n{\log\sqrt n})$$$ nodes in $$$O(\log\sqrt n)$$$ layers that you repeatedly query). But anyway $$$\log\sqrt n$$$ is always half smaller than $$$\log n$$$, even in the case of $$$n=10^6$$$, running this algorithm locally at my computer is not several times slower than $$$O(\log n)$$$ binary lifting.
Thanks for noticing! I had thought of mentioning that the actual speed might be faster in most cases, however since the case of the “square” tree with repeated similar queries takes it to O(log2 n), I decided to not include that point.
I appreciate u testing the code locally for the “square” case, I hadn’t actually done that xD
Good to hear it’s only a little slower than O(log n)
I tried it and found it nice
An optimization you can make: instead of binary searching for the ancestor of
xand the ancestor ofy, you can just find the ancestor ofxand see if it's an ancestor ofy.Good point! It reduces the constant factor almost by half.
Thanks a lot :)
Thanks for explaining it in code. Really helped me understand what's going on much better. (Also, coming from someone who went and looked at Binary Lifting after reading this blog) I'm a bit confused about what would be the downside of always implementing this instead of the binary lifting
For finding LCA, this algorithm might take O(log2n) time in some spcific cases (as discussed in comments above) which might not be fast enough to pass tight time limits intented to weed out O(log2n) solutions. But it would work in a lot of cases where the time limit is manageable.
For finding the k-th ancestor, this is at par with regular binary lifting, if not faster.
Also, if the problem has tight memory limit that only allows linear memory (happens rarely), this algorithm beats binary lifting which precomputes with space O(nlog n) compared to O(n) here.