Free constructions #3 - Magmas & Binary Finger Trees
This is part 3 of a series on free constructions. You can find part 2 here.
Not all operations are associative! In fact I’d think that given the infinite space of all possible binary operations, 100% of them are not. But tbh I wouldn’t really know about that and I’m just talking out of my ass.
But, take subtraction of numbers for example. Parentheses matter!
⟮1 - 2) - 3; // -4
1 - (2 - 3); // 2
In the previous posts we were able to store these kinds of expressions in multisets and lists, but, dropping the associativity requirement, we can no longer do that.
We can see that by naively constructing the list [1, 2, 3]
for
the expressions above — how would our evaluate
function know
whether to return -4
or 2
? It can’t, because lists do not track
the way in which way operands were grouped.
We call this — a non-associative monoid if you will — a magma. And basically, any set with any binary operation meets the definition of a magma, so it’s a very general concept.
Binary Finger Trees
If we want to construct a data structure that can store expressions
for any magma, a tree-like structure will do. You can imagine
“pinching” the outermost -
in our expression above and then letting
the rest of the expression dangle from it.
"A tree for (1 - 2) - 3:"
-
/ \
- 3
/ \
1 2
"And a tree for 1 - (2 - 3):"
-
/ \
1 -
/ \
2 3
We call this tree binary, because each node has two children; and we call it finger, because only leaf nodes have values, and all the intermediate nodes contain no data.
Don’t ask my why they call them finger trees. Maybe some kid named “finger” invented them or something.
Let’s jot down the type definition, and our evaluate
function:
type BFT<T> =
| { type: "leaf"; value: T }
| { type: "node"; left: BFT<T>; right: BFT<T> };
type BinaryOperation<T> = (a: T, b: T) => T;
const evaluate = <T>(tree: BFT<T>, op: BinaryOperation<T>): T => {
if (tree.type === "leaf") {
return tree.value;
}
return op(evaluate(tree.left, op), evaluate(tree.right, op));
};
We can now construct a tree for our subtraction example like so:
const expression = {
type: "node",
left: {
type: "node",
left: { type: "leaf", value: 1 },
right: { type: "leaf", value: 2 },
},
right: { type: "leaf", value: 3 },
};
const result = evaluate(expression, (a, b) => a - b); // -4
A free magma
Since we’ve done this two times before, it shouldn’t surprise you
that BFTs themselves are a magma. We can define a merge
operation that concatenates two trees, and returns a new tree:
const merge = <T>(a: BFT<T>, b: BFT<T>): BFT<T> => {
return { type: "node", left: a, right: b };
};
And of course, just like before, we can express (1 - 2) - 3
only
in terms of binary finger trees and their merge
operation:
const lift = <T>(value: T): BFT<T> => ({ type: "leaf", value });
const expression = merge(merge(lift(1), lift(2)), lift(3));
Neat!
Exercises
-
Go ahead an convince yourself that
expression
and our original concatenation problem are equivalent. To do that, replace each call tomerge
with-
, andlift
with nothing. -
Convince yourself that the
merge
operation is not associative. Do that by taking some multisetsa
,b
, andc
, and showing thatmerge(merge(a, b), c)
is not the same asmerge(a, merge(b, c))
. -
Convince yourself that the
merge
operation is not commutative. Do that by taking some multisetsa
andb
, and showing thatmerge(a, b)
is not the same asmerge(b, a)
.
Final thoughts
There it is, the free magma on T
, the last of the three
free constructions we will be looking at in this series.
(This blog post is continued in part 4 - which is work in progress.)