Tournament - Exercism Elixir Solution & Tutorial


Percy Grunwald's Profile Picture

Written by Percy Grunwald

— Last Updated April 6, 2024

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.

Explanation of the solution

I decided early on in solving this problem that I would need to split the solution into 2 parts:

  1. Translating the lines of results into a “statistics” map with each team as the key
  2. 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:

  1. [winner, loser, "win"]
  2. [team1, team2, "draw"]
  3. [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!

Comment & Share