A third view on trees

A few recent posts have played with trees from two perspectives. The more commonly used I call "top-down", because the top-level structure is most immediately apparent. A top-down binary tree is either a leaf or a pair of such trees, and that pair can be accessed without wading through intervening structure. Much less commonly used are "bottom-up" trees. A bottom-up binary tree is either a leaf or a single such tree of pairs. In the non-leaf case, the pair structure of the tree elements is accessible by operations like mapping, folding, or scanning. The difference is between a pair of trees and a tree of pairs.

As an alternative to the top-down and bottom-up views on trees, I now want to examine a third view, which is a hybrid of the two. Instead of pairs of trees or trees of pairs, this hybrid view is of trees of trees, and more specifically of bottom-up trees of top-down trees. As we’ll see, these hybrid trees emerge naturally from the top-down and bottom-up views. A later post will show how this third view lends itself to an in-place (destructive) scan algorithm, suitable for execution on modern GPUs.

Edits:

  • 2011-06-04: "Suppose we have a bottom-up tree of top-down trees, i.e., t ∷ TB (TT a). Was backwards. (Thanks to Noah Easterly.)
  • 2011-06-04: Notation: "f ➶ n" and "f ➴ n".

The post Parallel tree scanning by composition defines "top-down" and a "bottom-up" binary trees as follows (modulo type and constructor names):

data TT a = LT a | BT { unBT  Pair (TT a) } deriving Functor

data TB a = LB a | BB { unBB TB (Pair a) } deriving Functor

So, while a non-leaf TT (top-down tree) has a pair at the top (outside), a non-leaf TB (bottom-up tree) has pairs at the bottom (inside).

Combining these two observations leads to an interesting possibility. Suppose we have a bottom-up tree of top-down trees, i.e., t ∷ TB (TT a). If t is not a leaf, then t ≡ BB tt where tt is a bottom-up tree whose leaves are pairs of top-down trees, i.e., tt ∷ TB (Pair (TT a)). Each of those leaves of type Pair (TT a) can be converted to type TT a (single tree), simply by applying the BT constructor. Moreover, this transformation is invertible. For convenience, define a type alias for hybrid trees:

type TH a = TB (TT a)

Then the two conversions:

upT    TH a  TH a
upT = fmap BT ∘ unBB

downT TH a TH a
downT = BBfmap unBT

Exercise: Prove upT and downT are inverses where defined.

Answer:

  upT ∘ downT
fmap BT ∘ unBB ∘ BBfmap unBT
fmap BTfmap unBT
fmap (BT ∘ unBT)
fmap id
id

downT ∘ upT
BBfmap unBT ∘ fmap BT ∘ unBB
BBfmap (unBT ∘ BT) ∘ unBB
BBfmap id ∘ unBB
BBid ∘ unBB
BB ∘ unBB
id

Consider a perfect binary leaf tree of depth n, i.e., an n-deep binary tree with each level full and data only at the leaves (where a leaf is depth 0 tree.) We can view such a tree as top-down, or bottom-up, or as a hybrid.

Each of these three views is really n+1 views:

  • Top-down: a depth n tree, or a pair of depth n-1 trees, or a pair of pairs of depth n-2 trees, etc.
  • Bottom-up: a depth n tree, or a depth n-1 tree of pairs, or a depth n-2 tree of pairs of pairs, etc.
  • Hybrid: a depth n tree of depth 0 trees, or a depth n-1 tree of depth 1 trees, or, …, or a depth 0 tree of depth n trees.

In the hybrid case, counting from 0 to n, the kth such view is a depth n-k bottom-up tree whose elements (leaf values) are depth k top-down trees. When k=n, we have a bottom-up tree whose leaves are all single-leaf trees, and when k=0, we have a single-leaf bottom-up tree containing a top-down tree. Imagine a horizontal line at depth k, dividing the bottom-up outer structure from the top-down inner structure. The downT function moves the dividing line downward, and the upT function moves the line upward. Both functions are partial.

Generalizing

The role of Pair in the tree types above is simple and regular. We can abstract out this particular type constructor, generalizing to an arbitrary functor. I’ll call this generalization "functor trees". Again, there are top-down and bottom-up versions:

data FT f a = FLT a | FBT { unFBT  f (FT f a) } deriving Functor

data FB f a = FLB a | FBB { unFBB FB f (f a) } deriving Functor

And a hybrid version, with generalized versions of upT and downT:

type FH f a = FB f (FT f a)

upH Functor f FH f a FH f a
upH = fmap FBT ∘ unFBB

downH Functor f FH f a FH f a
downH = FBBfmap unFBT

These definitions specialize to the ones (for binary trees) by substituting Pair for the parameter f.

Depth-typing

The upward and downward view-changing functions above are partial, as they can fail at extreme tree views (at depth 0 or n). We could make this partiality explicit by changing the result type to Maybe (TH a) for binary hybrid trees and to Maybe (FH f a) for the functor generalization. Alternatively, make the tree sizes explicit in the types, as in a few recent posts, including A trie for length-typed vectors. (In those posts, I used the terms "right-folded" and "left-folded" in place of "top-down" and "bottom-up", reflecting the right- or left-folding of functor composition. The "folded" terms led to some confusion, especially in the context of data type folds and scans.) In the depth-typed versions, "leaves" are zero-ary compositions, and "branches" are (m+1)-ary compositions for some m.

Top-down:

data (➴)  (*  *)  *  (*  *) where
ZeroT a (f ➴ Z) a
SuccT IsNat n f ((f ➴ n) a) (f ➴ S n) a

unZeroT (f ➴ Z) a a
unZeroT (ZeroT a) = a

unSuccT (f ➴ S n) a f ((f ➴ n) a)
unSuccT (SuccT fsa) = fsa

instance Functor f Functor (f ➴ n) where
fmap h (ZeroT a) = ZeroT (h a)
fmap h (SuccT fs) = SuccT ((fmap∘fmap) h fs)

Bottom-up:

data (➶)  (*  *)  *  (*  *) where
ZeroB a (f ➶ Z) a
SuccB IsNat n (f ➶ n) (f a) (f ➶ S n) a

unZeroB (f ➶ Z) a a
unZeroB (ZeroB a) = a

unSuccB (f ➶ S n) a (f ➶ n) (f a)
unSuccB (SuccB fsa) = fsa

instance Functor f Functor (f ➶ n) where
fmap h (ZeroB a) = ZeroB (h a)
fmap h (SuccB fs) = SuccB ((fmap∘fmap) h fs)

Hybrid:

type H p q f a = (f ➶ p) ((f ➴ q) a)

Upward and downward shift become total functions, and their types explicitly describe how the line shifts between (p+1)/q and p/(q+1):

up    (Functor f, IsNat q)  H (S p) q f a  H p (S q) f a
up = fmap SuccT ∘ unSuccB

down (Functor f, IsNat p) H p (S q) f a H (S p) q f a
down = SuccBfmap unSuccT

So what?

Why care about the multitude of views on trees?

  • It’s pretty.
  • A future post will show how these hybrid trees enable an elegant formulation of parallel scanning that lends itself to an in-place, GPU-friendly implementation.

Leave a comment