4 ms·
There's a simple recipe for arithmetically encoding recursive algebraic data types (in the functional programming sense) which is related to this. What you mig
by psykotic 8y ago
There's a simple recipe for arithmetically encoding recursive algebraic data types (in the functional programming sense) which is related to this.
What you might have seen is Goedel numbering where a finite sequence of natural numbers a_0, a_1, ..., a_n (where n isn't fixed but can vary per sequence) is mapped bijectively onto p_0^a_0 a_1^p_1 ... a_n^p_n where p_0, p_1, ... is an enumeration of the primes.
However, if you want to represent trees instead of sequences, you have a better, simpler option. The key is the existence of a bijective pairing function between N^2 and N, which you can write as <m, n> for m, n in N.
You have a lot of choices for how to construct the pairing function. But a curious fact is that there is essentially one polynomial pairing function and it's the one you saw in class when you learned that the rationals are countable: https://en.wikipedia.org/wiki/Fueter%E2%80%93P%C3%B3lya_theorem https://en.wikipedia.org/wiki/Fueter%E2%80%93P%C3%B3lya_theo....
From this perspective it should be clear that bit interleaving provides one such pairing function.
Here's a larger example of how you can use this for encoding algebraic data types. The data type in this article is an unlabeled binary tree:
data Tree = Leaf | Node Tree Tree
I will use square brackets to denote the encoding. Then
[Leaf] = 0
[Node a b] = 1 + 2*<[a], [b]>
So the tag for the sum type is the residue modulo 2, and if the tag is 1 you can divide by 2 (shift right by one bit) to extract the encoded pair of subtrees. If the sum type had three terms you could use 3 instead of 2:
data Tree = WhiteLeaf | BlackLeaf | Node Tree Tree
[WhiteLeaf] = 0
[BlackLeaf] = 1
[Node a b] = 2 + 3*<[a], [b]>
This also works with labeled trees:
data Tree = Leaf N | Node Tree Tree
[Leaf n] = 0 + 2*n
[Node a b] = 1 + 2*<[a], [b]>
If the label type wasn't a natural number, you'd just recursively encode it.
In the above I treated the <m, n> pairing as a black box that can be implemented in different ways (e.g. Cantor pairing, bit interleaving). You can abstract out the encoding of sums as well. You're looking for a bijection between N and Inl N | Inr N and I made the particular choice
[Inl n] = 0 + 2*n
[Inr n] = 1 + 2*n
But you have other options as well, though this is probably the simplest. And once you have an encoding for sums and products you can apply them recursively to encode any recursive algebraic data type.
Note that the sum and pair encoding functions are bijective on the natural numbers, but the corresponding encodings for algebraic data types aren't necessarily bijective, only injective. For the unlabeled binary tree example, the number 2 has a tag of 0 (leaf) but isn't the encoding of any tree. However, the encoding for labeled binary trees is bijective if the label type is bijectively encoded. The problem with this kind of recursively constructed encoding that terminates in finite types is that you obviously cannot have a bijection between a finite set and an infinite set like N.
To tie it back into the article, he uses this encoding:
data Tree = Leaf | Node Tree Tree
[Leaf] = 0
[Node a b] = 1 + <[a], [b]>
You can extend this to my example with two kinds of leaves:
data Tree = WhiteLeaf | BlackLeaf | Node Tree Tree
[WhiteLeaf] = 0
[BlackLeaf] = 1
[Node a b] = 2 + <[a], [b]>
Note that the tag ordering is all-important here. This only works when the tag ordering is "tail recursive". If you used 0 as the tag for Node, then there'd be an ambiguity between leaves and proper subtrees; you wouldn't know if 1 corresponded to a leaf or 0 + encoded_subtree_pair.
So, this kind of prefix-sum encoding is bijective but can only accommodate one unbounded term, which must be assigned the final tag. No tag ordering can work for this:
data Tree = Leaf | WhiteNode Tree Tree | BlackNode Tree Tree
[Leaf] = 0
[WhiteNode a b] = 1 + <[a], [b]>
[BlackNode a b] = 2 + <[a], [b]> // Wrong!
I haven't thought about ordinals for a long time, but this feels related to the fact that 1 + ω = ω is not equal to ω + 1.
- espeed 8y agoRe: pairing functions, two of my favorite are: 1. Moser–de Bruijn sequence https://en.wikipedia.org/wiki/Moser%E2%80%93de_Bruijn_sequence https://en.wikipedia.org/wiki/Moser%E2%80%93de_Bruijn_sequen... n = x + 2y x = n & 0x55555555 y = (n - x) / 2 2. A 'Binary' System for Complex Numbers, by Walter Penny https://www.nsa.gov/news-features/declassified-documents/tech-journals/assets/files/a-binary-system.pdf https://www.nsa.gov/news-features/declassified-documents/tec... Previous: https://news.ycombinator.com/item?id=10648847 https://news.ycombinator.com/item?id=10648847
- psykotic 8y agoYeah, if you use the Moser-de Bruijn sequence for constructing a pairing function you just get bit interleaving. You define the map expand(x_0, x_1, ..., x_n) = (x_0, 0, x_1, 0, ..., 0, x_n), apply it to x and y, multiply expand(y) by 2 to shift it over, and add them to merge the now disjoint even/odd positions. Incidentally, this decomposition of the problem is exactly how you efficiently implement bit interleaving in software except you don't even need addition: z = interleave(x, y) = expand(x) | (expand(y) << 1) For the inverse you define the map compact(x_0, x_1, ..., x_2n) = (x_0, x_2, ..., x_2n) and then uninterleave(z) = (compact(z), compact(x >> 1)) = (x, y) The expand map can be implemented efficiently as a logarithmic number of shift-and-merge stages, and compact can be constructed by inverting each stage separately and composing them in reverse order. A stage has this form: x' = x | (x << n) You sometimes also see it written with xor. That's not necessary. What you care about is that the operation is bitwise (e.g. no carries from addition) and it must have 0 as an identity. If x has 2n bits then this is reversible: the lower n bits in x and x' are the same, and the upper n bits in x and (x' >> n) are the same, so you can recover all 2n bits of x from x' with low(x') | high(x' >> n). You can cascade stages like this if you alternate them with appropriate masking to allow data-parallel operation. The net effect of the first stage, after masking, is to shift the upper n bits of x up by n bits while leaving the lower n bits in place. The next stage then does the same thing on the two halves in parallel with a shift of n/2, and so on until you're done. The masking prevents cross-talk between subvectors so you can operate on a single vector as if it were a set of independent subvectors. Or put another way, it enforces the precondition for the reversibility of each stage. Anyway, just some random connections to low-level hacking.