Table of Contents
In today’s problem we need to implement a function that accepts a list of strings showing the results of football matches and return a nicely formatted table showing the leaderboard (sorted by points descending where each win is worth 3 points, a draw 1 point and loss 0 points).
In other words, we need to turn this:
[
"Allegoric Alaskans;Blithering Badgers;win",
"Devastating Donkeys;Courageous Californians;draw",
"Devastating Donkeys;Allegoric Alaskans;win",
"Courageous Californians;Blithering Badgers;loss",
"Blithering Badgers;Devastating Donkeys;loss",
"Allegoric Alaskans;Courageous Californians;win"
]
Into this:
Team | MP | W | D | L | P
Devastating Donkeys | 3 | 2 | 1 | 0 | 7
Allegoric Alaskans | 3 | 2 | 0 | 1 | 6
Blithering Badgers | 3 | 1 | 0 | 2 | 3
Courageous Californians | 3 | 0 | 1 | 2 | 1
This is the first medium
difficulty problem on the Elixir track and it’s definitely a fairly in-depth problem. We cover a lot of skills in this solution, including string processing, reduce, complex case
statements and string formatting.
Solution video with explanations
This is my daily live stream video solution to this problem, in which I guide you through each step of the way and explain my logic in approaching the problem and selecting the best Elixir functions for the job.
- Video available here: https://youtu.be/V6LqglUNyNo
- Also streaming on Twitch: https://www.twitch.tv/percygrunwald
Explanation of the solution
I decided early on in solving this problem that I would need to split the solution into 2 parts:
- Translating the lines of results into a “statistics” map with each team as the key
- Converting the statistics map into a list of formatted lines that can be printed with the header line
Convert the results into a statistics map
The input we get is in the form:
[
"Allegoric Alaskans;Blithering Badgers;win",
"Devastating Donkeys;Courageous Californians;draw",
"Devastating Donkeys;Allegoric Alaskans;win",
"Courageous Californians;Blithering Badgers;loss",
"Blithering Badgers;Devastating Donkeys;loss",
"Allegoric Alaskans;Courageous Californians;win"
]
And I would like to convert those lines into a map like this:
%{
"Allegoric Alaskans" => %{
draws: 0,
losses: 1,
matches_played: 3,
points: 6,
wins: 2
},
"Blithering Badgers" => %{
draws: 0,
losses: 2,
matches_played: 3,
points: 3,
wins: 1
},
"Courageous Californians" => %{
draws: 1,
losses: 2,
matches_played: 3,
points: 1,
wins: 0
},
"Devastating Donkeys" => %{
draws: 1,
losses: 0,
matches_played: 3,
points: 7,
wins: 2
}
}
Since we are translating a list into another data structure, my go-to function will always be Enum.reduce/3
.
For each line in the input, we’re going to split the string on ";"
with String.split/3
:
"Allegoric Alaskans;Blithering Badgers;win" => ["Allegoric Alaskans", "Blithering Badgers", "win"]
Splitting the string in this way, we can match on the 3 possible valid outcomes:
[winner, loser, "win"]
[team1, team2, "draw"]
[loser, winner, "loss"]
For each one of these cases, we need to update our accumulator 2 times: once for each team. Here’s the full function before we implement the helper functions to update the accumulator:
defp calculate_stats(input_lines) do
Enum.reduce(input_lines, %{}, fn line, acc ->
line
|> String.split(";")
|> case do
[winner, loser, "win"] ->
acc
|> update_win(winner)
|> update_loss(loser)
[team1, team2, "draw"] ->
acc
|> update_draw(team1)
|> update_draw(team2)
[loser, winner, "loss"] ->
acc
|> update_win(winner)
|> update_loss(loser)
_ ->
acc
end
end)
end
The default path in the case
statement will handle any invalid input that doesn’t fit the expected shape of the results.
The update_...
helper functions do the updates to the accumulator based on the result. For a win we increase the wins
by 1, the matches_played
by 1 and the points
by 3:
defp update_win(acc, winner) do
Map.update(acc, winner, merge_init(%{wins: 1, matches_played: 1, points: 3}), fn
current_stats ->
current_stats
|> Map.update(:wins, 1, fn current_wins -> current_wins + 1 end)
|> Map.update(:matches_played, 1, fn current_matches_played ->
current_matches_played + 1
end)
|> Map.update(:points, 3, fn current_points -> current_points + 3 end)
end)
end
For a loss, we increase the losses
by 1, the matches_played
by 1 and make no modification to points
:
defp update_loss(acc, loser) do
Map.update(acc, loser, merge_init(%{losses: 1, matches_played: 1, points: 0}), fn
current_stats ->
current_stats
|> Map.update(:losses, 1, fn current_losses -> current_losses + 1 end)
|> Map.update(:matches_played, 1, fn current_matches_played ->
current_matches_played + 1
end)
end)
end
For a draw, we increase the draws
by 1, the matches_played
by 1 and increase points
by 1:
defp update_draw(acc, team) do
Map.update(acc, team, merge_init(%{draws: 1, matches_played: 1, points: 1}), fn
current_stats ->
current_stats
|> Map.update(:draws, 1, fn current_draws -> current_draws + 1 end)
|> Map.update(:matches_played, 1, fn current_matches_played ->
current_matches_played + 1
end)
|> Map.update(:points, 1, fn current_points -> current_points + 1 end)
end)
end
To make sure we have all the keys available for each team, we use a merge_init/1
helper function to initialize our map for each team with Map.merge/2
:
defp merge_init(map) do
Map.merge(%{wins: 0, losses: 0, draws: 0, matches_played: 0, points: 0}, map)
end
Convert the statistics map into a list of formatted strings
We now have a standard representation of our statistics for each team:
%{
"Allegoric Alaskans" => %{
draws: 0,
losses: 1,
matches_played: 3,
points: 6,
wins: 2
},
"Blithering Badgers" => %{
draws: 0,
losses: 2,
matches_played: 3,
points: 3,
wins: 1
},
"Courageous Californians" => %{
draws: 1,
losses: 2,
matches_played: 3,
points: 1,
wins: 0
},
"Devastating Donkeys" => %{
draws: 1,
losses: 0,
matches_played: 3,
points: 7,
wins: 2
}
}
Which we need to convert into sorted tabular data (excluding the header row):
Devastating Donkeys | 3 | 2 | 1 | 0 | 7
Allegoric Alaskans | 3 | 2 | 0 | 1 | 6
Blithering Badgers | 3 | 1 | 0 | 2 | 3
Courageous Californians | 3 | 0 | 1 | 2 | 1
Firstly, we can convert the map
into a list
, which we can then sort, using Map.to_list/1
. The output of Map.to_list/1
is this:
[
{"Allegoric Alaskans",
%{draws: 0, losses: 1, matches_played: 3, points: 6, wins: 2}},
{"Blithering Badgers",
%{draws: 0, losses: 2, matches_played: 3, points: 3, wins: 1}},
{"Courageous Californians",
%{draws: 1, losses: 2, matches_played: 3, points: 1, wins: 0}},
{"Devastating Donkeys",
%{draws: 1, losses: 0, matches_played: 3, points: 7, wins: 2}}
]
Because of the complexity of the items in this list, we can sort it with Enum.sort_by/3
and sort on the points
. The second argument we pass to Enum.sort_by/3
will be an anonymous function to extract just the :points
for each item, to which the sorter
is applied.
The default sorter
function for Enum.sort/3
is Kernel.<=/2
, which will sort the list in ascending order. Since we would like the list sorted in descending order, we can either use Enum.reverse/1
to reverse the list after it has been sorted or change the sorter
to Kernel.>=/2
. I prefer changing the sorter:
Enum.sort_by(list, fn {_team, %{points: points}} -> points end, &Kernel.>=/2)
Now all that’s left to do is format each line. Since we are transforming a list into another list of the same length, the default function we should us is Enum.map/2
. Each line needs to follow this format:
"#{team} | #{matches} | #{wins} | #{draws} | #{losses} | #{points}"
The special thing to note is that team
should be trailing padded to maximum width of 30
characters and each of the scores should be leading padded to a maximum width of 2
characters. We can simply use String.pad_leading/3
and String.pad_trailing/3
.
All that’s left to do is join together each line with a newline
("\n"
) and we’re set:
defp get_lines_to_print(stats) do
stats
|> Map.to_list()
|> Enum.sort_by(fn {_team, %{points: points}} -> points end, &Kernel.>=/2)
|> Enum.map(fn {team, stats} ->
matches_played = "#{stats.wins + stats.draws + stats.losses}"
team_string = String.pad_trailing(team, 30)
matches_string = String.pad_leading(matches_played, 2)
wins_string = String.pad_leading("#{stats.wins}", 2)
draws_string = String.pad_leading("#{stats.draws}", 2)
losses_string = String.pad_leading("#{stats.losses}", 2)
points_string = String.pad_leading("#{stats.points}", 2)
"#{team_string} | #{matches_string} | #{wins_string} | #{draws_string} |" <>
" #{losses_string} | #{points_string}"
end)
|> Enum.join("\n")
end
Tying it all together
Now that we have our 2 helper functions to convert the input into stats and stats into formatted lines, we can simply return the formatted lines underneath the static header line and we’re all done:
@spec tally(input :: list(String.t())) :: String.t()
def tally(input) do
lines_to_print =
input
|> calculate_stats()
|> get_lines_to_print()
"""
Team | MP | W | D | L | P
#{lines_to_print}
"""
|> String.trim()
end
Full solution in text form
Here’s the full solution in text form, in case you want to look over the final product:
defmodule Tournament do
@spec tally(input :: list(String.t())) :: String.t()
def tally(input) do
lines_to_print =
input
|> calculate_stats()
|> get_lines_to_print()
"""
Team | MP | W | D | L | P
#{lines_to_print}
"""
|> String.trim()
end
defp get_lines_to_print(stats) do
stats
|> Map.to_list()
|> Enum.sort_by(fn {_team, %{points: points}} -> points end, &Kernel.>=/2)
|> Enum.map(fn {team, stats} ->
matches_played = "#{stats.wins + stats.draws + stats.losses}"
team_string = String.pad_trailing(team, 30)
matches_string = String.pad_leading(matches_played, 2)
wins_string = String.pad_leading("#{stats.wins}", 2)
draws_string = String.pad_leading("#{stats.draws}", 2)
losses_string = String.pad_leading("#{stats.losses}", 2)
points_string = String.pad_leading("#{stats.points}", 2)
"#{team_string} | #{matches_string} | #{wins_string} | #{draws_string} |" <>
" #{losses_string} | #{points_string}"
end)
|> Enum.join("\n")
end
defp calculate_stats(input_lines) do
Enum.reduce(input_lines, %{}, fn line, acc ->
line
|> String.split(";")
|> case do
[winner, loser, "win"] ->
acc
|> update_win(winner)
|> update_loss(loser)
[team1, team2, "draw"] ->
acc
|> update_draw(team1)
|> update_draw(team2)
[loser, winner, "loss"] ->
acc
|> update_win(winner)
|> update_loss(loser)
_ ->
acc
end
end)
end
defp update_win(acc, winner) do
Map.update(acc, winner, merge_init(%{wins: 1, matches_played: 1, points: 3}), fn
current_stats ->
current_stats
|> Map.update(:wins, 1, fn current_wins -> current_wins + 1 end)
|> Map.update(:matches_played, 1, fn current_matches_played ->
current_matches_played + 1
end)
|> Map.update(:points, 3, fn current_points -> current_points + 3 end)
end)
end
defp update_loss(acc, loser) do
Map.update(acc, loser, merge_init(%{losses: 1, matches_played: 1, points: 0}), fn
current_stats ->
current_stats
|> Map.update(:losses, 1, fn current_losses -> current_losses + 1 end)
|> Map.update(:matches_played, 1, fn current_matches_played ->
current_matches_played + 1
end)
end)
end
defp update_draw(acc, team) do
Map.update(acc, team, merge_init(%{draws: 1, matches_played: 1, points: 1}), fn
current_stats ->
current_stats
|> Map.update(:draws, 1, fn current_draws -> current_draws + 1 end)
|> Map.update(:matches_played, 1, fn current_matches_played ->
current_matches_played + 1
end)
|> Map.update(:points, 1, fn current_points -> current_points + 1 end)
end)
end
defp merge_init(map) do
Map.merge(%{wins: 0, losses: 0, draws: 0, matches_played: 0, points: 0}, map)
end
end
Did you have a different solution or think that my solution could be improved? Please hit me up in the comments below!