I came across a nice post on SO by @amalloy while looking for hylomorhism examples, that illustrate recursion scheme (RS) usage with useful discussion and full implementation:
{-# LANGUAGE DeriveFunctor #-}
import Control.Arrow ( (>>>), (<<<) )
newtype Term f = In {out :: f (Term f)}
type Algebra f a = f a -> a
type Coalgebra f a = a -> f a
cata :: (Functor f) => Algebra f a -> Term f -> a
cata fn = out >>> fmap (cata fn) >>> fn
ana :: (Functor f) => Coalgebra f a -> a -> Term f
ana f = In <<< fmap (ana f) <<< f
hylo :: Functor f => Algebra f b -> Coalgebra f a -> a -> b
hylo alg coalg = ana coalg >>> cata alg
data ChangePuzzle a = Solved Cent
| Pending {spend, forget :: a}
deriving Functor
type Cent = Int
type ChangePuzzleArgs = ([Cent], Cent)
coins :: [Cent]
coins = [50, 25, 10, 5, 1]
divide :: Coalgebra ChangePuzzle ChangePuzzleArgs
divide (_, 0) = Solved 1
divide ([], _) = Solved 0
divide (coins@(x:xs), n) | n < 0 = Solved 0
| otherwise = Pending (coins, n - x) (xs, n)
conquer :: Algebra ChangePuzzle Cent
conquer (Solved n) = n
conquer (Pending a b) = a + b
waysToMakeChange :: ChangePuzzleArgs -> Int
waysToMakeChange = hylo conquer divide
The code works as expected. Despite having some vague intuition for the RS aspect already, I am still wondering:
- since this is about counting combinations, why
Solved Centand notSolved Int? (This may sound like a nitpic, if it is even a reasonable question, but I am hoping it may be the root of the rest of the uncertainty, below, although I suspect I missed something more fundamental!). - since we're later summing, in
divide,Solved0/1 presumably signifies failure/success? - in
conquer, what does it mean to add,aandb, ofPending? What do those 2 values (asCents) signify, and what would their sum mean in this context? - in
conquer, I would have expected we just need to sum theSolveds, and the author touches on this, but it's not clear, yet, how thePendingcase is contributing (eg fixingconquer (Pending a b) = 11does have an adverse impact on functionality, and it is probably a clue thatwaysToMakeChangereturns11, or whatever constant that case is fixed to). - in
conquer,aandbareCents, whereas individethey'reChangePuzzleArgs(aka([Cent], Cent)) - where does that transformation occur?
Note: being new to SO, I was not able to comment below the original answer, which may have been more appropriate, but I hope this is also useful as is.
I would also use
Inthere.Yes, but it's slightly more than that.
Solved 0means "there are exactly 0 ways to generate that change amount" (i.e., failure), whileSolved 1means "there is exactly 1 way to generate that change amount" (i.e., success). In the latter case, not only we mean "success", but we also report that there is only one way to solve the task.Essentially,
Pending a bwitha,b::Intmeans "the number of ways to generate that change amount can be split into two disjoint sets, the first one havingaelements, and the second one havingbelements".When we
divide, we returnPending ... ...to split the problem into two disjoint subcases,(coins, n - x)and(xs, n). Herecoins=(x:xs). We split according to whether we want to use coinxat least one time (hence we need to generaten-xwith all the coins), or we don't want to use it at all (hence we need to generatenwith the other coins, only).Summing all the
Solved ...is what we do. Thecatamagic essentially replaces the straightforward recursive sumwith
cata conquerwhereThe magic of
catamakes it so that insidePending, we do not find subtrees upon which we want to recurse, but the result of the recursion, already computed.This can be indeed subtle at first. I'll provide only a rough intuition.
After
ana dividewe produce a result in a fixed point of the functorChangePuzzle. Note howanaat the end returnsTerm ChangePuzzle, which is the fixed point. There, the pair([Cent], Cent)magically disappears.Dually, the
Intreappears when we usecata, even if we started fromTerm ChangePuzzle.Very roughly, you can think of
Term ChangePuzzleas the infinite nestingwhich is coherent with the fact that such a tree might be arbitrarily nested. There, the "argument" of
ChangePuzzleessentially disappears.How do we get the final
Intthen? Well, we get that sinceSolvedalways takes anIntargument, and not anaargument. This provides that base case that makes the finalcatarecursion work.