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 = BB ∘ fmap unBT
Exercise: Prove upT
and downT
are inverses where defined.
Answer:
upT ∘ downT
≡ fmap BT ∘ unBB ∘ BB ∘ fmap unBT
≡ fmap BT ∘ fmap unBT
≡ fmap (BT ∘ unBT)
≡ fmap id
≡ id
downT ∘ upT
≡ BB ∘ fmap unBT ∘ fmap BT ∘ unBB
≡ BB ∘ fmap (unBT ∘ BT) ∘ unBB
≡ BB ∘ fmap id ∘ unBB
≡ BB ∘ id ∘ unBB
≡ BB ∘ unBB
≡ id
Consider a perfect binary leaf tree of depth , i.e., an -deep binary tree with each level full and data only at the leaves (where a leaf is depth tree.) We can view such a tree as top-down, or bottom-up, or as a hybrid.
Each of these three views is really views:
- Top-down: a depth tree, or a pair of depth trees, or a pair of pairs of depth trees, etc.
- Bottom-up: a depth tree, or a depth tree of pairs, or a depth tree of pairs of pairs, etc.
- Hybrid: a depth tree of depth trees, or a depth tree of depth trees, or, …, or a depth tree of depth trees.
In the hybrid case, counting from to , the such view is a depth bottom-up tree whose elements (leaf values) are depth top-down trees. When , we have a bottom-up tree whose leaves are all single-leaf trees, and when , we have a single-leaf bottom-up tree containing a top-down tree. Imagine a horizontal line at depth , 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 = FBB ∘ fmap 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 or ). 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 -ary compositions for some .
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 and :
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 = SuccB ∘ fmap 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