ryanzidago

ryanzidago

How would you implement the Gale-Shapley algorithm in Elixir for solving the Stable Marriage Problem?

I’m trying to implement the Gale-Shapley algoritm in Elixir to solve the Stable Marriage Problem.

I managed to come up with an implementation of the algorithm in Ruby but I can’t achieve a solution with Elixir/the functional programming way; especially due to immutability.

Note that I’m trying to first implement this algorithm sequentially (without actors) so an implementation like this is not what I’m trying to achieve right now.

I have found this implementation in Elixir, but it does not pass Rosetta Code’s test case.

Here’s what I have so far.

How would you tackle this problem? :thinking:

Most Liked

bradley

bradley

Prior to seeing this blog post I wrote an implementation that passes the RosettaCode checks.

defmodule GaleShapley do
  @moduledoc """
  Implements the Gale–Shapley algorithm for the stable marriage problem.

  Wikipedia: https://en.wikipedia.org/wiki/Stable_marriage_problem
  Reference video: https://www.youtube.com/watch?v=Qcv1IqHWAzg&t=6s
  """

  @spec run(%{term() => [term()]}, %{term() => [term()]}) :: %{term() => term()}
  def run(proposers, recipients) do
    proposals = propose(proposers)
    engagements = become_engaged(proposals, recipients)

    if stable_pair?(engagements, recipients) do
      engagements
    else
      proposals
      |> recipient_choices()
      |> reject_proposals(proposers, engagements)
      |> run(recipients)
    end
  end

  # If the amount of tentative engagements is the same
  # as the number of recipients we have found all
  # stable pairs and the algorithm can terminate
  @spec stable_pair?(%{term() => term()}, %{term() => [term()]}) :: boolean()
  defp stable_pair?(engagements, recipients),
    do: map_size(recipients) == map_size(engagements)

  # Creates a data structure that shows what options
  # recipients have after they've been proposed to
  @spec recipient_choices(%{term() => term()}) :: %{term() => [term()]}
  defp recipient_choices(proposals) do
    Enum.group_by(
      proposals,
      fn {_proposer, recipient} -> recipient end,
      fn {proposer, _recipient} -> proposer end
    )
  end

  # All proposers propose to their first best option
  @spec propose(%{term() => [term()]}) :: %{term() => term()}
  defp propose(proposers) do
    Enum.into(proposers, %{}, fn {name, _prefs} ->
      {name, List.first(proposers[name])}
    end)
  end

  # Recipients become tenatively engaged with their most preferred proposer
  # Some recipients may not have engagements yet
  @spec become_engaged(%{term() => term()}, %{term() => [term()]}) :: %{term() => term()}
  defp become_engaged(proposals, recipients) do
    Enum.reduce(recipients, %{}, fn {name, prefs}, recipients_acc ->
      first_pick = Enum.find(prefs, fn pref -> proposals[pref] == name end)

      if first_pick do
        Map.put(recipients_acc, name, first_pick)
      else
        recipients_acc
      end
    end)
  end

  # Reject each proposer that's not the recipient's top pick and update their preferences
  # so they won't pick them again in the future.
  @spec reject_proposals(%{term() => [term()]}, %{term() => [term()]}, %{term() => term()}) :: %{
          term() => [term()]
        }
  defp reject_proposals(recipient_proposals, proposers, tentative_engagements) do
    Enum.reduce(recipient_proposals, proposers, fn {name, proposals}, updated_proposers ->
      rejected_proposals =
        Enum.reject(proposals, fn proposal -> proposal == tentative_engagements[name] end)

      Enum.reduce(rejected_proposals, updated_proposers, fn rejected_proposers, acc ->
        new_proposers = List.delete(updated_proposers[rejected_proposers], name)
        Map.merge(acc, %{rejected_proposers => new_proposers})
      end)
    end)
  end
end

Looking back I should have ported an existing RosettaCode algorithm because the one I have is recursive and could hang if invalid data is passed in. I do use a form of this in production though so it’s been reliable so far!

Also, I couldn’t get @Qqwy’s solution to work with the RosettaCode checks otherwise I would have used it over mine!

dimitarvp

dimitarvp

This is equivalent to Map.take(state, men_keys)

ityonemo

ityonemo

this might help:

https://erlang.org/doc/man/digraph.html

especially note:

A digraph is a mutable data structure.

Also unrelated pedantry (sorry, this is a pet obession of mine) is_ functions should be reserved for guards; postifx sigil ...? (like ruby) is the idiomatic way to indicate a boolean function in elixir.

ryanzidago

ryanzidago

But thank you for pointing me in the right direction, I actually found the culprit code:

  def match(men, _women, state, true) do
    state
    |> Map.to_list()
    |> Enum.slice(0..(length(Map.keys(men)) - 1))
  end

Since I have both men and women in state, I cannot simply discard the state to only keep men-pairs like this. This works for the first test case but not the second one.

I ended up changing it like so:

  def match(men, _women, state, true) do
    men_keys = Map.keys(men)
    for {k, v} <- state, k in men_keys, do: {k, v}
  end

So from the state, I only keep the men keys and put them into a tuple with thier fiancée.

And now the Rosetta Code’s test case passes!

:pray:

Qqwy

Qqwy

TypeCheck Core Team

This is how I would tackle this problem.
This implementation is based quite a bit on the F# implementation on RosettaCode, but then translated to idiomatic Elixir and documented.

defmodule GaleShapley do
  @moduledoc """
  An implementation of the Gale-Shapley algorithm to the 'stable marriage ' problem.

  Based on the F#-implementation found on RosettaCode: https://rosettacode.org/wiki/Stable_marriage_problem#F.23


  As currently written, expects the men/women to be strings, numbers, or other datastructures where 'identity' 'and 'equality' is the same notion.
  """

  defmodule State do
    @moduledoc """
    Stores the state threaded through the Gale-Shapley algorithm.

    - `men`/`women`: List of all men resp. (names)
    - `preferences`: Map containing a `men:` and `women:` field, following the guidelines set in `new/2`.
    - `proposed:` Keeps track for all men whom they have proposed to so far (because they only propose to a woman once.)
    - `wife_of`/`husband_of`: Keeps track of the current pairings.
    """
    defstruct [
      men: [],
      women: [],
      preferences: %{men: %{}, women: %{}},
      proposed: %{},
      wife_of: %{},
      husband_of: %{}
    ]

    @doc """
    Construct a new state based on the given preferences.

    Expects:
    - `men_preferences` to be a map of strings to list-of-strings,
    - `women_preferences` to be a map of strings to list-of-strings,
    - All keys of one map need to be contained in all of the lists of the values of the other, and vice-versa
    (i.e., all men have sorted all women according to their preference, and all women have sorted all men according to their preference,
    without any people being left out.)
    """
    def new(men_preferences, women_preferences) do
      men = Map.keys(men_preferences) |> Enum.sort()
      women = Map.keys(women_preferences) |> Enum.sort()

      %__MODULE__{
        men: men,
        women: women,
        preferences: %{men: men_preferences, women: women_preferences},
        proposed: %{},
        wife_of: %{},
        husband_of: %{}
      }
    end

    @doc """
    Keeps track of a pending engagement between a man and a woman.

    Returns the altered state
    """
    def engage(state, man, woman) do
      state
      |> put_in([Access.key(:wife_of), man], woman)
      |> put_in([Access.key(:husband_of), woman], man)
    end

    @doc """
    Removes a pending engagement between a man and a woman.

    Returns the altered state
    """
    def disengage(state, woman) do
      man = state.husband_of[woman]

      state
      |> pop_in_([Access.key(:wife_of), man])
      |> pop_in_([Access.key(:husband_of), woman])
    end

    # Helper function because we are not interested in the removed values
    defp pop_in_(data, keys) do
      {_, altered_data} = pop_in(data, keys)
      altered_data
    end

    def store_proposal(state, man, woman) do
      update_in(state, [Access.key(:proposed), Access.key(man, [])], &[woman | &1])
    end
  end

  @doc """
  True if `man` is not currently engaged (as seen in `state`)
  """
  def free_man?(state, man) do
    state.wife_of[man] == nil
  end

  @doc """
  True if `woman ` is not currently engaged (as seen in `state`)
  """
  def free_woman?(state, woman) do
    state.husband_of[woman] == nil
  end

  @doc """
  True if `man` has proposed to `woman ` (as seen in `state`), false otherwise.
  """
  def proposed_to?(state, man, woman) do
    state.proposed
    |> Map.get(man, [])
    |> Enum.member?(woman)
  end

  @doc """
  Returns the list of all women in `women` not yet proposed to by `man` (as seen in `state`)
  """
  def unproposed_women(state, man, women) do
    Enum.reject(women, &proposed_to?(state, man, &1))
  end

  @doc """
  Checks the preferences of `subject`.
  True if `subject` prefers `candidate1` over `candidate2`, false otherwise
  """
  def prefers(preferences, subject, candidate1, candidate2) do
    candidate_score = fn candidate -> Enum.find_index(preferences[subject], &(&1 == candidate)) end

    candidate_score.(candidate1) > candidate_score.(candidate2)
  end

  @doc """
  Specialized version of prefers/4 for men
  """
  def prefers_first_woman?(state, man, woman1, woman2) do
    prefers(state.preferences.men, man, woman1, woman2)
  end

  @doc """
  Specialized version of prefers/4 for women
  """
  def prefers_first_man?(state, woman, man1, man2) do
    prefers(state.preferences.women, woman, man1, man2)
  end

  @doc """
  All women `man` prefers over his current fiancée.
  """
  def women_to_leave_fiancee_for(state, man) do
    fiancee = state.wife_of[man]

    state.women
    |> Enum.filter(&prefers_first_woman?(state, man, &1, fiancee))
  end

  @doc """
  True iff there is a better woman (c.f. `women_to_leave_fiancee_for`),
  which will also prefer `man` over her current fiancée
  """
  def better_match_exists?(state, man) do
    state
    |> women_to_leave_fiancee_for(man)
    |> Enum.any?(&prefers_first_man?(state, &1, man, state.husband_of(&1)))
  end

  def stable_problem?(state) do
    state.men
    |> Enum.any?(&better_match_exists?(state, &1))
    |> Kernel.not()
  end

  def propose(state, man, woman) do
    state
    |> State.store_proposal(man, woman)
    |> do_propose(man, woman)
  end

  defp do_propose(state, man, woman) do
    cond do
      free_woman?(state, woman) ->
        State.engage(state, man, woman)

      prefers_first_man?(state, woman, man, state.husband_of[woman]) ->
        state
        |> State.disengage(woman)
        |> State.engage(man, woman)

      true ->
        state
    end
  end

  @doc """
  All elegible bachelors
  are men who currently are not engaged.
  """
  def bachelors(state) do
    state.men
    |> Enum.filter(&free_man?(state, &1))
    |> Enum.filter(&unproposed_women(state, &1, state.women))
  end

  @doc """
  Runs a single step of the Gale-Shapley algorithm,
  in which a single man proposes to a single woman.

  Should maybe be called for debugging. `run/2` is a higher-level wrapper around this function.

  NOTE: This function currently is relatively slow because:
  - _all_ bachelors are calculated, but only the first one is used
  - _all_ candidates of this bachelor are looked up, but only the first one is used.
  Even though this could be improved, in practice (benchmark to be sure!) it probably still only is a constant time overhead
  w.r.t. the full running time of the algorithm.
  """
  def run_step(state) do
    case bachelors(state) do
      [] ->
        {:done, state}

      [bachelor | _other_bachelors] ->
        candidate = state
        |> unproposed_women(bachelor, state.preferences.men[bachelor])
        |> hd

        {:next, propose(state, bachelor, candidate)}
    end
  end

  defp run_to_completion(state) do
    case run_step(state) do
      {:done, state} -> state
      {:next, state} -> run_to_completion(state)
    end
  end

  @doc """
  Runs the Gale-Shapley algorithm, given two maps of preferences.

  See `State.new/2` for the expected format of these preference maps.
  """
  def run(men_preferences, women_preferences) do
    State.new(men_preferences, women_preferences)
    |> run_to_completion
  end

  defmodule Example do
    def men_preferences do
      %{
        "abe" => ["abi", "eve", "cath", "ivy", "jan", "dee", "fay", "bea", "hope", "gay"],
        "bob" => ["cath", "hope", "abi", "dee", "eve", "fay", "bea", "jan", "ivy", "gay"],
        "col" => ["hope", "eve", "abi", "dee", "bea", "fay", "ivy", "gay", "cath", "jan"],
        "dan" => ["ivy", "fay", "dee", "gay", "hope", "eve", "jan", "bea", "cath", "abi"],
        "ed" => ["jan", "dee", "bea", "cath", "fay", "eve", "abi", "ivy", "hope", "gay"],
        "fred" => ["bea", "abi", "dee", "gay", "eve", "ivy", "cath", "jan", "hope", "fay"],
        "gav" => ["gay", "eve", "ivy", "bea", "cath", "abi", "dee", "hope", "jan", "fay"],
        "hal" => ["abi", "eve", "hope", "fay", "ivy", "cath", "jan", "bea", "gay", "dee"],
        "ian" => ["hope", "cath", "dee", "gay", "bea", "abi", "fay", "ivy", "jan", "eve"],
        "jon" => ["abi", "fay", "jan", "gay", "eve", "bea", "dee", "cath", "ivy", "hope"]
      }
    end

    def women_preferences do
      %{
        "abi" => ["bob", "fred", "jon", "gav", "ian", "abe", "dan", "ed", "col", "hal"],
        "bea" => ["bob", "abe", "col", "fred", "gav", "dan", "ian", "ed", "jon", "hal"],
        "cath" => ["fred", "bob", "ed", "gav", "hal", "col", "ian", "abe", "dan", "jon"],
        "dee" => ["fred", "jon", "col", "abe", "ian", "hal", "gav", "dan", "bob", "ed"],
        "eve" => ["jon", "hal", "fred", "dan", "abe", "gav", "col", "ed", "ian", "bob"],
        "fay" => ["bob", "abe", "ed", "ian", "jon", "dan", "fred", "gav", "col", "hal"],
        "gay" => ["jon", "gav", "hal", "fred", "bob", "abe", "col", "ed", "dan", "ian"],
        "hope" => ["gav", "jon", "bob", "abe", "ian", "dan", "hal", "ed", "col", "fred"],
        "ivy" => ["ian", "col", "hal", "gav", "fred", "bob", "abe", "ed", "jon", "dan"],
        "jan" => ["ed", "hal", "gav", "abe", "bob", "jon", "col", "ian", "fred", "dan"]
      }
    end
  end
end

Where Next?

Popular in Questions Top

dotdotdotPaul
Okay, I'm having a heck of a time trying to figure out how to best handle the validation of belongs_to associations in Ecto. I'm sure I'...
New
itssasanka
Hi all, Trying to get some more clarity over utc_datetime and naive_datetime for Ecto: https://hexdocs.pm/ecto/Ecto.Schema.html#module-...
New
_russellb
I want to try my hand at web scraping. What tools/libraries do I need to use. I’m hoping to turn this into something professional so don’...
New
jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod -- where is this set? Thanks.
New
polypush135
As many of you may have realized by now (sorry for all the posts here) I’ve been working on a db problem where I’m trying to aggregate a ...
New
nsuchy
Hi. I’ve noticed that Windows Powershell has it’s own IEX command and you cannot access Elixir’s IEX due to the conflict. This isn’t a cr...
New
Exadra37
Sometimes I want to check if the input into a function is not a blank string. My first approach: defmodule Example do def do_stuff(s...
New
Mooodi
Given a string, how can I get access to its character by index? Enum.at("my_string", 2) doesn't work. Or rather, not char, but a substr...
New
9mm
I am constructing a JSON object (map) and I need to conditionally set a field. I’m trying to write proper elixir-way code… and I’m at a l...
New
vrod
I am using the Starship cross-shell prompt – it seems pretty nice, but I get some errors: [WARN] - (starship::utils): Executing command ...
New

Other popular topics Top

bsollish-terakeet
Credo is smart enough to check for (something like) this: assert length(the_list) == 0 with this response: Checking if an enum is empt...
New
JorisKok
I have a server on AWS, and was running a load test using artillery. When looking at the Phoenix dashboard I see the Ports going to 100% ...
New
sergio_101
I am VERY much an elixir newbie. I have taken one elixir course and one phoenix course on Udemy. During that course, I saw the instructor...
New
Jim
As a follow up to my earlier question: I have the code compiling and running but not getting a successful login from the rest server. ...
New
vertexbuffer
Hello, can anybody help here..? I have a list of players and I what to delete an element, but every for loop the list is reverting to ori...
New
script
If I have a string “1000 cfu/ml” . I want to remove the characters and / and space . So the string is like this "1000" What is the ...
New
mgjohns61585
Could someone help me? I'm making my first elixir program, number guessing game. I can't figure out how to convert the user's guess from ...
New
chensan
I have a User schema with a :from_id field set to type :string: defmodule TweetBot.Repo.Migrations.CreateUsers do use Ecto.Migration ...
New
baxterw3b
Hi guys, i’m new in the Elixir world, and i have to say, that i love it! i’m having some problem to understand anonymous functions with ...
New
magnetic
Hey :wave:t3: Elixir community, I’ve been learning Elixir, and working on some side projects. My editor of choice is VSCode, and althoug...
New

We're in Beta

About us Mission Statement