A friend of mine was asked this question in an interview.
Given two binary trees, explain how would you create a diff such that if you have that diff and either of the trees you should be able to generate the other binary tree. Implement a function createDiff(Node tree1, Node tree 2) returns that diff.
Tree 1
4
/ \
3 2
/ \ / \
5 8 10 22
Tree 2
1
\
4
/ \
11 12
If you are given Tree 2 and the diff you should be able to generate Tree 1.
My solution:
Convert both the binary trees into array where left child is at 2n+1 and right child is at 2n+2and represent empty node by -1. Then just do element-wise subtraction of the array to create the diff. This solution will fail if tree has -1 as node value and I think there has to be a better and neat solution but I'm not able to figure it out.
Think of them as direcory tres and print a sorted list of the path to every leaf item
Tree 1 becomes:
4/2/10
4/2/22
4/3/5
4/3/8
These list formats can be diff'ed and the tree recreated from such a list.
There are many ways to do this.
I would suggest that you turn the tree into a sorted array of triples of (parent, child, direction). So start with tree1:
4
/ \
3 2
/ \ / \
5 8 10 22
This quickly becomes:
(None, 4, None) # top
(4, 3, L)
(3, 5, L)
(3, 8, L)
(4, 2, R)
(2, 10, L)
(2, 22, R)
Which you sort to get
(None, 4, None) # top
(2, 10, L)
(2, 22, R)
(3, 5, L)
(3, 8, L)
(4, 2, R)
(4, 3, L)
Do the same with the other, and then diff them.
Given a tree and the diff, you can first turn the tree into this form, look at the diff, realize which direction it is and get the desired representation with patch. You can then reconstruct the other tree recursively.
The reason why I would do it with this representation is that if the two trees share any subtrees in common - even if they are placed differently in the main tree - those will show up in common. And therefore you are likely to get relatively small diffs if the trees do, in fact, match in some interesting way.
Edit
Per point from #ruakh, this does assume that values do not repeat in a tree. If they do, then you could do a representation like this:
4
/ \
3 2
/ \ / \
5 8 10 22
becomes
(, 4)
(0, 3)
(00, 5)
(01, 8)
(1, 2)
(10, 10)
(11, 22)
And now if you move subtrees, they will show up as large diffs. But if you just change one node, it will still be a small diff.
(The example from the question(/interview) is not very helpful in not showing any shared sub-structure of non-trivial size. Or the interview question outstanding for initiating a dialogue between customer and developer.)
Re-use of subtrees needs a representation allowing to identify such. It seems useful to be able to reconstruct the smaller tree without walking most of the difference. Denoting "definition" of identifiable sub-trees with capital letters and re-use by a tacked-on ':
d e d--------e
c b "-" c b => C B' C' b
b a a b a a B a a
a a a
(The problem statement does not say diff is linear.)
Things to note:
there's a sub-tree B occurring in two places of T1
in T2, there's another b with one leaf-child a that is not another occurrence of B
no attempt to share leaves
What if now I imagine (or the interviewer suggests) two huge trees, identical but for one node somewhere in the middle which has a different value?
Well, at least its sub-trees will be shared, and "the other sub-trees" all the way up to the root. Too bad if the trees are degenerated and almost all nodes are part of that path.
Huge trees with children of the root exchanged?
(Detecting trees occurring more than once has a chance to shine here.)
The bigger problem would seem to be the whole trees represented in "the diff", while the requirement may be
Given one tree, the diff shall support reconstruction of the other using little space and processing.
(It might include setting up the diff shall be cheap, too - which I'd immediately challenge: small diff looks related to editing distance.)
A way to identify "crucial nodes" in each tree is needed - btilly's suggestion of "left-right-string" is good as gold.
Then, one would need a way to keep differences in children & value.
That's the far end I'd expect an exchange in an interview to reach.
To detect re-used trees, I'd add the height to each internal node. For a proof of principle, I'd probably use an existing implementation of find repeated strings on a suitable serialisation.
There are many ways to think of a workable diff-structure.
Naive solution
One naive way is to store the two trees in a tuple. Then, when you need to regenerate a tree, given the other and the diff, you just look for a node that is different when comparing the given tree with the tree in the first tuple entry of the diff. If found you return that tree from the first tuple entry. If not found, you return the second one from the diff tuple.
Small diffs for small differences
An interviewer would probably ask for a less memory consuming alternative. One could try to think of a structure that will be small in size when there are only a few values or nodes different. In the extreme case where both trees are equal, such diff would be (near-)empty as well.
Definitions
I define these terms before defining the diff's structure:
Imagine the trees get extra NIL leaf nodes, i.e. an empty tree would consist of 1 NIL node. A tree with only a root node, would have two NIL nodes as its direct children, ...etc.
A node is common to both trees when it can be reached via the same path from the root (e.g. left-left-right), irrespective of whether they contain the same value or have the same children. A node can even be common when it is a NIL node in one or both of the trees (as defined above).
Common nodes (including NIL nodes when they are common) get a preorder sequence number (0, 1, 2, ...). Nodes that are not common are discarded during this numbering.
Diff structure
The difference could be a list of tuples, where each tuple has this information:
The above mentioned preorder sequence number, identifying a common node
A value: when neither nodes is a NIL node, this is the diff of the values (e.g. XOR). When one of the nodes is a NIL node, the value is the other node object (so effectively including the whole subtree below it). In typeless languages, either information can fit in the same tuple position. In strongly typed languages, you would use an extra entry in the tuple (e.g. atomicValue, subtree), where only one of two would have a significant value.
A tuple will only be added for a common node, and only when either their values differ, and at least one of both is a not-NIL node.
Algorithm
The diff can be created via a preorder walk through the common nodes of the trees.
Here is an implementation in JavaScript:
class Node {
constructor(value, left, right) {
this.value = value;
if (left) this.left = left;
if (right) this.right = right;
}
clone() {
return new Node(this.value, this.left ? this.left.clone() : undefined,
this.right ? this.right.clone() : undefined);
}
}
// Main functions:
function createDiff(tree1, tree2) {
let i = -1; // preorder sequence number
function recur(node1, node2) {
i++;
if (!node1 !== !node2) return [[i, (node1 || node2).clone()]];
if (!node1) return [];
const result = [];
if (node1.value !== node2.value) result.push([i, node1.value ^ node2.value]);
return result.concat(recur(node1.left, node2.left), recur(node1.right, node2.right));
}
return recur(tree1, tree2);
}
function applyDiff(tree, diff) {
let i = -1; // preorder sequence number
let j = 0; // index in diff array
function recur(node) {
i++;
let diffData = j >= diff.length || diff[j][0] !== i ? 0 : diff[j++][1];
if (diffData instanceof Node) return node ? undefined : diffData.clone();
return node && new Node(node.value ^ diffData, recur(node.left), recur(node.right));
}
return recur(tree);
}
// Create sample data:
let tree1 =
new Node(4,
new Node(3,
new Node(5), new Node(8)
),
new Node(2,
new Node(10), new Node(22)
)
);
let tree2 =
new Node(2,
undefined,
new Node(4,
new Node(11), new Node(12)
)
);
// Demo:
let diff = createDiff(tree1, tree2);
console.log("Diff:");
console.log(diff);
const restoreTree2 = applyDiff(tree1, diff);
console.log("Is restored second tree equal to original?");
console.log(JSON.stringify(tree2)===JSON.stringify(restoreTree2));
const restoreTree1 = applyDiff(tree2, diff);
console.log("Is restored first tree equal to original?");
console.log(JSON.stringify(tree1)===JSON.stringify(restoreTree1));
const noDiff = createDiff(tree1, tree1);
console.log("Diff for two equal trees:");
console.log(noDiff);
Related
Given an N-ary tree, I have to generate all the leaf to leaf paths in an n-array tree. The path should also denote the direction. As an example:
Tree:
1
/ \
2 6
/ \
3 4
/
5
Paths:
5 UP 3 UP 2 DOWN 4
4 UP 2 UP 1 DOWN 6
5 UP 3 UP 2 UP 1 DOWN 6
These paths can be in any order, but all paths need to be generated.
I kind of see the pattern:
looks like I have to do in order traversal and
need to save what I have seen so far.
However, can't really come up with an actual working algorithm.
Can anyone nudge me to the correct algorithm?
I am not looking for the actual implementation, just the pseudo code and the conceptual idea would be much appreciated.
The first thing I would do is to perform in-order traversal. As a result of this, we will accumulate all the leaves in the order from the leftmost to the rightmost nodes.(in you case this would be [5,4,6])
Along the way, I would certainly find the mapping between nodes and its parents so that we can perform dfs later. We can keep this mapping in HashMap(or its analogue). Apart from this, we will need to have the mapping between nodes and its priorities which we can compute from the result of the in-order traversal. In your example the in-order would be [5,3,2,4,1,6] and the list of priorities would be [0,1,2,3,4,5] respectively.
Here I assume that our node looks like(we may not have the mapping node -> parent a priori):
class TreeNode {
int val;
TreeNode[] nodes;
TreeNode(int x) {
val = x;
}
}
If we have n leaves, then we need to find n * (n - 1) / 2 paths. Obviously, if we have managed to find a path from leaf A to leaf B, then we can easily calculate the path from B to A. (by transforming UP -> DOWN and vice versa)
Then we start traversing over the array of leaves we computed earlier. For each leaf in the array we should be looking for paths to leaves which are situated to the right of the current one. (since we have already found the paths from the leftmost nodes to the current leaf)
To perform the dfs search, we should be going upwards and for each encountered node check whether we can go to its children. We should NOT go to a child whose priority is less than the priority of the current leaf. (doing so will lead us to the paths we already have) In addition to this, we should not visit nodes we have already visited along the way.
As we are performing dfs from some node, we can maintain a certain structure to keep the nodes(for instance, StringBuilder if you program in Java) we have come across so far. In our case, if we have reached leaf 4 from leaf 5, we accumulate the path = 5 UP 3 UP 2 DOWN 4. Since we have reached a leaf, we can discard the last visited node and proceed with dfs and the path = 5 UP 3 UP 2.
There might be a more advanced technique for solving this problem, but I think it is a good starting point. I hope this approach will help you out.
I didn't manage to create a solution without programming it out in Python. UNDER THE ASSUMPTION that I didn't overlook a corner case, my attempt goes like this:
In a depth-first search every node receives the down-paths, emits them (plus itself) if the node is a leaf or passes the down-paths to its children - the only thing to consider is that a leaf node is a starting point of a up-path, so these are input from the left to right children as well as returned to the parent node.
def print_leaf2leaf(root, path_down):
for st in path_down:
st.append(root)
if all([x is None for x in root.children]):
for st in path_down:
for n in st: print(n.d,end=" ")
print()
path_up = [[root]]
else:
path_up = []
for child in root.children:
path_up += child is not None and [st+[root] for st in print_root2root(child, path_down + path_up)] or []
for st in path_down:
st.pop()
return path_up
class node:
def __init__(self,d,*children):
self.d = d
self.children = children
## 1
## / \
## 2 6
## / \ /
## 3 4 7
## / / | \
## 5 8 9 10
five = node(5)
three = node(3,five)
four = node(4)
two = node(2,three,four)
eight = node(8)
nine = node(9)
ten = node(10)
seven = node(7,eight,nine,ten)
six = node(6,None,seven)
one = node(1,two,six)
print_leaf2leaf(one,[])
So I am not asking diagonal view of a tree, which fortunately I already know. I am asking if I view a tree from 45-degree angle only a few nodes should be visible. So there is a plane which at an angle of 45-degrees from the x-axis. so we need to print all the nodes which are visible from that plane.
For example:
1
/ \
2 3
/ \ / \
4 5 6 7
So if I look from that plane, I will only see nodes [4, 6, 7] as 5 and 6 overlaps each other. If I add another node at 6, now it will hide 7. How to do that? I searched on internet but couldn't find the answer.
Thanks!
I am giving you an abstract answer as the question is not language specific.
The problem with logging trees like this is the use of recursion.
By that I mean the traversal is going down nodes and up nodes.
What if you wrote a height helper which would return the depth of the current node.
For each depth level, you place the value in an array.
Then, write the values of the array.
Then you could grab the length of the last array and determine the amount of spaces each node needs.
Allow the arrays to hold empty values or else you will have to keep track of which nodes dont have children.
int total_depth = tree.getTotalHeight();
int arr[total_depth] = {};
for(int i = total_depth; i--;){
// there is a formula for the max number of nodes at a given depth of a binary tree
arr[i] = int arr[maximum_nodes_at_depth]
}
tree.inorderTraverse(function(node){
int depth = node.getHeightHelper();
// check if item is null
if( node!=nullptr && node.Item != NULL)
{
arr[depth].push(node.Item)
}
else
{
arr[depth].push(NULL)
}
})
So now you would have to calculate the size of your tree and then dynamically calculate how many spaces should prefix each node. The lower the depth the more prefixed spaces to center it.
I apologize but the pseudocode is a mix of javascript and c++ syntax.... which should never happen lol
Fenwick tree is a data-structure that gives an efficient way to answer to main queries:
add an element to a particular index of an array update(index, value)
find sum of elements from 1 to N find(n)
both operations are done in O(log(n)) time and I understand the logic and implementation. It is not hard to implement a bunch of other operations like find a sum from N to M.
I wanted to understand how to adapt Fenwick tree for RMQ. It is obvious to change Fenwick tree for first two operations. But I am failing to figure out how to find minimum on the range from N to M.
After searching for solutions majority of people think that this is not possible and a small minority claims that it actually can be done (approach1, approach2).
The first approach (written in Russian, based on my google translate has 0 explanation and only two functions) relies on three arrays (initial, left and right) upon my testing was not working correctly for all possible test cases.
The second approach requires only one array and based on the claims runs in O(log^2(n)) and also has close to no explanation of why and how should it work. I have not tried to test it.
In light of controversial claims, I wanted to find out whether it is possible to augment Fenwick tree to answer update(index, value) and findMin(from, to).
If it is possible, I would be happy to hear how it works.
Yes, you can adapt Fenwick Trees (Binary Indexed Trees) to
Update value at a given index in O(log n)
Query minimum value for a range in O(log n) (amortized)
We need 2 Fenwick trees and an additional array holding the real values for nodes.
Suppose we have the following array:
index 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
value 1 0 2 1 1 3 0 4 2 5 2 2 3 1 0
We wave a magic wand and the following trees appear:
Note that in both trees each node represents the minimum value for all nodes within that subtree. For example, in BIT2 node 12 has value 0, which is the minimum value for nodes 12,13,14,15.
Queries
We can efficiently query the minimum value for any range by calculating the minimum of several subtree values and one additional real node value. For example, the minimum value for range [2,7] can be determined by taking the minimum value of BIT2_Node2 (representing nodes 2,3) and BIT1_Node7 (representing node 7), BIT1_Node6 (representing nodes 5,6) and REAL_4 - therefore covering all nodes in [2,7]. But how do we know which sub trees we want to look at?
Query(int a, int b) {
int val = infinity // always holds the known min value for our range
// Start traversing the first tree, BIT1, from the beginning of range, a
int i = a
while (parentOf(i, BIT1) <= b) {
val = min(val, BIT2[i]) // Note: traversing BIT1, yet looking up values in BIT2
i = parentOf(i, BIT1)
}
// Start traversing the second tree, BIT2, from the end of range, b
i = b
while (parentOf(i, BIT2) >= a) {
val = min(val, BIT1[i]) // Note: traversing BIT2, yet looking up values in BIT1
i = parentOf(i, BIT2)
}
val = min(val, REAL[i]) // Explained below
return val
}
It can be mathematically proven that both traversals will end in the same node. That node is a part of our range, yet it is not a part of any subtrees we have looked at. Imagine a case where the (unique) smallest value of our range is in that special node. If we didn't look it up our algorithm would give incorrect results. This is why we have to do that one lookup into the real values array.
To help understand the algorithm I suggest you simulate it with pen & paper, looking up data in the example trees above. For example, a query for range [4,14] would return the minimum of values BIT2_4 (rep. 4,5,6,7), BIT1_14 (rep. 13,14), BIT1_12 (rep. 9,10,11,12) and REAL_8, therefore covering all possible values [4,14].
Updates
Since a node represents the minimum value of itself and its children, changing a node will affect its parents, but not its children. Therefore, to update a tree we start from the node we are modifying and move up all the way to the fictional root node (0 or N+1 depending on which tree).
Suppose we are updating some node in some tree:
If new value < old value, we will always overwrite the value and move up
If new value == old value, we can stop since there will be no more changes cascading upwards
If new value > old value, things get interesting.
If the old value still exists somewhere within that subtree, we are done
If not, we have to find the new minimum value between real[node] and each tree[child_of_node], change tree[node] and move up
Pseudocode for updating node with value v in a tree:
while (node <= n+1) {
if (v > tree[node]) {
if (oldValue == tree[node]) {
v = min(v, real[node])
for-each child {
v = min(v, tree[child])
}
} else break
}
if (v == tree[node]) break
tree[node] = v
node = parentOf(node, tree)
}
Note that oldValue is the original value we replaced, whereas v may be reassigned multiple times as we move up the tree.
Binary Indexing
In my experiments Range Minimum Queries were about twice as fast as a Segment Tree implementation and updates were marginally faster. The main reason for this is using super efficient bitwise operations for moving between nodes. They are very well explained here. Segment Trees are really simple to code so think about is the performance advantage really worth it? The update method of my Fenwick RMQ is 40 lines and took a while to debug. If anyone wants my code I can put it on github. I also produced a brute and test generators to make sure everything works.
I had help understanding this subject & implementing it from the Finnish algorithm community. Source of the image is http://ioinformatics.org/oi/pdf/v9_2015_39_44.pdf, but they credit Fenwick's 1994 paper for it.
The Fenwick tree structure works for addition because addition is invertible. It doesn't work for minimum, because as soon as you have a cell that's supposed to be the minimum of two or more inputs, you've lost information potentially.
If you're willing to double your storage requirements, you can support RMQ with a segment tree that is constructed implicitly, like a binary heap. For an RMQ with n values, store the n values at locations [n, 2n) of an array. Locations [1, n) are aggregates, with the formula A(k) = min(A(2k), A(2k+1)). Location 2n is an infinite sentinel. The update routine should look something like this.
def update(n, a, i, x): # value[i] = x
i += n
a[i] = x
# update the aggregates
while i > 1:
i //= 2
a[i] = min(a[2*i], a[2*i+1])
The multiplies and divides here can be replaced by shifts for efficiency.
The RMQ pseudocode is more delicate. Here's another untested and unoptimized routine.
def rmq(n, a, i, j): # min(value[i:j])
i += n
j += n
x = inf
while i < j:
if i%2 == 0:
i //= 2
else:
x = min(x, a[i])
i = i//2 + 1
if j%2 == 0:
j //= 2
else:
x = min(x, a[j-1])
j //= 2
return x
I have the edges and i want to build a tree with it.
The problem is that i can construct my tree structure only when edges are in specific order.
Example of orders:
(vertex, parent_vertex)
good: bad:
(0, ) <-top (3, 2)
(1, 0) (1, 0)
(2, 1) (3, 2)
(3, 2) (0, ) <-top
I iterate throw the edges and for current vertex trying to find it's parent in created tree, then i construct the node and insert it.
result tree:
0 - 1 - 2 - 3
So there is always must exist a parent in the tree for the new added vertex.
The question is how to sort the input edges. Voices tells me about the topological sort, but it's for vertexes. Is it possible to sort it right?
#mirt thanks for pointing out the optimizations on my approach, have you got any better?
i will put the below algo for ref
initially construct a hash map to store elements that are there in tree : H, add the root (null in your case/ or anything that represent that root)
taking the pair (_child, _parent)
loop through the whole list.
in the list. (each pair is the element)
for each pair, see if the _child and _parent is there in the hash map H, if you dont find, create the tree node for the missing ones and add them to H , and link them with the parent child relationship.
you will be left with the tree at the end of iteration.
complexity is O(n).
I have multiple binary trees stored as an array. In each slot is either nil (or null; pick your language) or a fixed tuple storing two numbers: the indices of the two "children". No node will have only one child -- it's either none or two.
Think of each slot as a binary node that only stores pointers to its children, and no inherent value.
Take this system of binary trees:
0 1
/ \ / \
2 3 4 5
/ \ / \
6 7 8 9
/ \
10 11
The associated array would be:
0 1 2 3 4 5 6 7 8 9 10 11
[ [2,3] , [4,5] , [6,7] , nil , nil , [8,9] , nil , [10,11] , nil , nil , nil , nil ]
I've already written simple functions to find direct parents of nodes (simply by searching from the front until there is a node that contains the child)
Furthermore, let us say that at relevant times, both all trees are anywhere between a few to a few thousand levels deep.
I'd like to find a function
P(m,n)
to find the lowest common ancestor of m and n -- to put more formally, the LCA is defined as the "lowest", or deepest node in which have m and n as descendants (children, or children of children, etc.). If there is none, a nil would be a valid return.
Some examples, given our given tree:
P( 6,11) # => 2
P( 3,10) # => 0
P( 8, 6) # => nil
P( 2,11) # => 2
The main method I've been able to find is one that uses an Euler trace, which turns the given tree (Adding node A as the invisible parent of 0 and 1, with a "value" of -1), into:
A-0-2-6-2-7-10-7-11-7-2-0-3-0-A-1-4-1-5-8-5-9-5-1-A
And from that, simply find the node between your given m and n that has the lowest number; For example, to find P(6,11), look for a 6 and an 11 on the trace. The number between them that is the lowest is 2, and that's your answer. If A (-1) is in between them, return nil.
-- Calculating P(6,11) --
A-0-2-6-2-7-10-7-11-7-2-0-3-0-A-1-4-1-5-8-5-9-5-1-A
^ ^ ^
| | |
m lowest n
Unfortunately, I do believe that finding the Euler trace of a tree that can be several thousands of levels deep is a bit machine-taxing...and because my tree is constantly being changed throughout the course of the programming, every time I wanted to find the LCA, I'd have to re-calculate the Euler trace and hold it in memory every time.
Is there a more memory efficient way, given the framework I'm using? One that maybe iterates upwards? One way I could think of would be the "count" the generation/depth of both nodes, and climb the lowest node until it matched the depth of the highest, and increment both until they find someone similar.
But that'd involve climbing up from level, say, 3025, back to 0, twice, to count the generation, and using a terribly inefficient climbing-up algorithm in the first place, and then re-climbing back up.
Are there any other better ways?
Clarifications
In the way this system is built, every child will have a number greater than their parents.
This does not guarantee that if n is in generation X, there are no nodes in generation (X-1) that are greater than n. For example:
0
/ \
/ \
/ \
1 2 6
/ \ / \ / \
2 3 9 10 7 8
/ \ / \
4 5 11 12
is a valid tree system.
Also, an artifact of the way the trees are built are that the two immediate children of the same parent will always be consecutively numbered.
Are the nodes in order like in your example where the children have a larger id than the parent? If so, you might be able to do something similar to a merge sort to find them.. for your example, the parent tree of 6 and 11 are:
6 -> 2 -> 0
11 -> 7 -> 2 -> 0
So perhaps the algorithm would be:
left = left_start
right = right_start
while left > 0 and right > 0
if left = right
return left
else if left > right
left = parent(left)
else
right = parent(right)
Which would run as:
left right
---- -----
6 11 (right -> 7)
6 7 (right -> 2)
6 2 (left -> 2)
2 2 (return 2)
Is this correct?
Maybe this will help: Dynamic LCA Queries on Trees.
Abstract:
Richard Cole, Ramesh Hariharan
We show how to maintain a data
structure on trees which allows for
the following operations, all in
worst-case constant time. 1. Insertion
of leaves and internal nodes. 2.
Deletion of leaves. 3. Deletion of
internal nodes with only one child. 4.
Determining the Least Common Ancestor
of any two nodes.
Conference: Symposium on Discrete
Algorithms - SODA 1999
I've solved your problem in Haskell. Assuming you know the roots of the forest, the solution takes time linear in the size of the forest and constant additional memory. You can find the full code at http://pastebin.com/ha4gqU0n.
The solution is recursive, and the main idea is that you can call a function on a subtree which returns one of four results:
The subtree contains neither m nor n.
The subtree contains m but not n.
The subtree contains n but not m.
The subtree contains both m and n, and the index of their least common ancestor is k.
A node without children may contain m, n, or neither, and you simply return the appropriate result.
If a node with index k has two children, you combine the results as follows:
join :: Int -> Result -> Result -> Result
join _ (HasBoth k) _ = HasBoth k
join _ _ (HasBoth k) = HasBoth k
join _ HasNeither r = r
join _ r HasNeither = r
join k HasLeft HasRight = HasBoth k
join k HasRight HasLeft = HasBoth k
After computing this result you have to check the index k of the node itself; if k is equal to m or n, you will "extend" the result of the join operation.
My code uses algebraic data types, but I've been careful to assume you need only the following operations:
Get the index of a node
Find out if a node is empty, and if not, find its two children
Since your question is language-agnostic I hope you'll be able to adapt my solution.
There are various performance tweaks you could put in. For example, if you find a root that has exactly one of the two nodes m and n, you can quit right away, because you know there's no common ancestor. Also, if you look at one subtree and it has the common ancestor, you can ignore the other subtree (that one I get for free using lazy evaluation).
Your question was primarily about how to save memory. If a linear-time solution is too slow, you'll probably need an auxiliary data structure. Space-for-time tradeoffs are the bane of our existence.
I think that you can simply loop backwards through the array, always replacing the higher of the two indices by its parent, until they are either equal or no further parent is found:
(defun lowest-common-ancestor (array node-index-1 node-index-2)
(cond ((or (null node-index-1)
(null node-index-2))
nil)
((= node-index-1 node-index-2)
node-index-1)
((< node-index-1 node-index-2)
(lowest-common-ancestor array
node-index-1
(find-parent array node-index-2)))
(t
(lowest-common-ancestor array
(find-parent array node-index-1)
node-index-2))))