Potter kata
At the time of writing, I just had, with the Software Crafters Lyon a coding dojo earlier this week.
It was a random kata meaning, each group got a random code kata, we have got Potter.
We did not get to the end of it, and I did not like it.
Let's get the revenge.
The idea of the kata can be sum up with this:
- There are 5 books in the Harry Potter collections
- Each individual book cost 8 EUR
- Partial collections of 2 different books got a 5% discount, 3 books got 10%, 4 books got 20%, and 5 books got 25%
- Partials collections have to be made so they give the smallest amount
Let's start with some boilerplate:
spec =
describe "Potter" $ do
it "no book should be 0" $
priceOf [] `shouldBe` 0
data Book = B1 | B2 | B3 | B4 | B5
deriving stock (Eq, Ord, Enum, Show)
newtype Price
= Price {unPrice :: Double}
deriving newtype (Eq, Ord, Num)
show x = show x.unPrice <> " EUR"
priceOf _ = 0
Then, we have to handle individual book, as follows:
-- forM_ [B1 .. B5] $ \b ->
-- it ("one book (" <> show b <> ") should be 8") $
-- priceOf [b] `shouldBe` 8
priceOf bs = if null bs then 0 else 8
A bit naive and solved handling purchases of the same book multiple times, as follows:
-- forM_ [(2, 16), (3, 24), (4, 32)] $ \(b, p) ->
-- it (show b <> " books should be " <> show p) $
-- priceOf (replicate b B1) `shouldBe` p
priceOf bs = fromIntegral (length bs) * 8
This code simply count the number of books, and multiply them by 8.
Then next step, I have tried to delay, is distinguish collections.
In order to do so, we have to group them by book, which give a matrix, then transpose it (swap rows and columns) to have collections, step by step, it gives the following:
Input
[1, 1, 2, 2, 3]
Grouping
[ [1, 1],
[2, 2],
[3]
]
Transposing
[ [1, 2, 3],
[1, 2]
]
Which gives:
-- forM_ [(2, 15.2), (3, 21.60), (4, 25.60), (5, 30)] $ \(b, p) ->
-- it ("a collection of " <> show b <> " books should be " <> show p) $
-- priceOf (take b [B1 .. B5]) `shouldBe` p
priceOf = sum . map bundlePrice . transpose . group . sort
where
bundlePrice bs =
case length bs of
5 -> 5 * 8 * 0.75
4 -> 4 * 8 * 0.80
3 -> 3 * 8 * 0.90
2 -> 2 * 8 * 0.95
1 -> 8
_ -> error "impossible"
It also work with multiple partial collections as follows:
-- forM_
-- [ ([B1, B1, B2], 23.20),
-- ([B1, B1, B2, B2], 30.40),
-- ([B1, B1, B2, B3, B3, B4], 40.80),
-- ([B1, B2, B2, B3, B4, B5], 38)
-- ]
-- $ \(b, p) ->
-- it (show b <> " books should be " <> show p) $
-- priceOf b `shouldBe` p
Lastly, we have to balance collections to have the lowest price.
Given the actual discounts, the idea is to remove one book from a 5-books collection to put it in a 3-books collection, to create two 4-books collections:
- 5-books collection (
5 * 8 * 0.75 = 30 EUR) + 3-books collection (3 * 8 * 0.90 = 21.60 EUR) =51.60 EUR - 2 * 4-books collection (
4 * 8 * 0.80 = 25.60 EUR) =51.20 EUR
To do so, we need the frequency of collection sizes.
Transposed collection:
[ [1, 2, 3, 4, 5],
[1, 2, 3],
[1, 2, 3],
[1, 2]
]
Frequencies
[ 5,
3,
3,
1
]
Indexed frequencies:
[ 5 => 1,
3 => 2,
1 => 1
]
Balanced indexed frequencies:
[ 4 => 2,
3 => 1,
1 => 1
]
Step by step, it gives the following piece of code:
-- forM_
-- [ ([B1, B1, B2, B2, B3, B3, B4, B5], 51.20),
-- (replicate 5 B1 <> replicate 5 B2 <> replicate 4 B3 <> replicate 5 B4 <> replicate 4 B5, 141.20)
-- ]
-- $ \(b, p) ->
-- it (show b <> " books should be " <> show p) $
-- priceOf b `shouldBe` p
priceOf =
sum
. map (\(bs, freq) -> bundlePrice bs * freq)
. Map.toList
. balance
. Map.fromListWith (+)
. map ((,1) . length)
. transpose
. group
. sort
where
bundlePrice bs =
case bs of
5 -> 5 * 8 * 0.75
4 -> 4 * 8 * 0.80
3 -> 3 * 8 * 0.90
2 -> 2 * 8 * 0.95
1 -> 8
_ -> error "impossible"
balance bs =
let common4Books = Map.findWithDefault 0 5 bs `min` Map.findWithDefault 0 3 bs
upsert n = Map.alter (Just . maybe n (+ n))
in upsert (2 * common4Books) 4 $ upsert (-common4Books) 3 $ upsert (-common4Books) 5 bs
And, we are done... or not!
This snippet has two issues: it is partial (see the error case), and, there are branches.
Two remove both, we have to linearize frequencies (done by fetching frequencies for each collection size) and discounts as follows:
Balanced indexed frequencies:
[ 4 => 2,
3 => 1,
1 => 1
]
Linearized fequencies:
[1, 0, 1, 2, 0]
Collection discounts:
[0, 0.05, 0.10, 0.20, 0.25]
Collection discounted unit prices:
[1, 0.95, 0.90, 0.80, 0.75]
Once done, we can zip and multiply both lists, which gives us the final branchless code:
priceOf =
sum
. zipWith (*) collectionDiscount
. collectionFrequencies
. balance
. Map.fromListWith (+)
. map ((,1) . length)
. transpose
. group
. sort
where
collectionDiscount = (8 *) <$> [1, 0.95, 0.90, 0.80, 0.75]
collectionFrequencies bs = [fromIntegral n * Map.findWithDefault 0 n bs | n <- [1 .. 5]]
balance bs =
let common4Books = Map.findWithDefault 0 5 bs `min` Map.findWithDefault 0 3 bs
upsert n = Map.alter (Just . maybe n (+ n))
in upsert (2 * common4Books) 4 $ upsert (-common4Books) 3 $ upsert (-common4Books) 5 bs