Return to Blog

Solving the Stable Matching problem in Python

By John Lekberg on August 22, 2020.


This week's post is about solving the "Stable Matching" problem in Python. You will learn:

Problem statement

You are a high school administrator. Your school offers a foreign exchange program that matches foreign exchange students with host families.

Each of the students and families have written about themselves. Based on these writings:

Your goal is to find a way to pair each student and host family so that there are no instabilities. A pair is unstable when both the paired student and paired host family would rather be with someone else. E.g.

In this scenario, the pairing

Student Bobby paired with Family Smith

is unstable.

NOTE: The number of students and families is the same in this problem. There are n students and n families.

How I represent the data in the problem

I represent the set of students and the set of host families as set objects. E.g.

{"S.Samuel", "S.Bobby", "S.John"}
{"F.Smith", "F.Martinez", "F.Brown"}

I represent the preference lists of the students as dictionaries that map each student to a list of the host families in order of preference, with most preferred coming first and least preferred coming last. E.g.

{
    "S.Samuel": ["F.Smith", "F.Brown", "F.Martinez"],
    "S.Bobby": ["F.Martinez", "F.Brown", "F.Smith"],
    "S.John": ["F.Martinez", "F.Brown", "F.Smith"],
}

I represent the preference lists of the families in the same way. E.g.

{
    "F.Smith": ["S.Samuel", "S.John", "S.Bobby"],
    "F.Martinez": ["S.Samuel", "S.Bobby", "S.John"],
    "F.Brown": ["S.John", "S.Samuel", "S.Bobby"],
}

I have a helper function, pref_to_rank, that turns a preference list into a dictionary that of numerical ranks, with lower numbers meaning more preferred:

def pref_to_rank(pref):
    return {
        a: {b: idx for idx, b in enumerate(a_pref)}
        for a, a_pref in pref.items()
    }


pref_to_rank(
    {
        "F.Smith": ["S.Samuel", "S.John", "S.Bobby"],
        "F.Martinez": ["S.Samuel", "S.Bobby", "S.John"],
        "F.Brown": ["S.John", "S.Samuel", "S.Bobby"],
    }
)
{'F.Smith': {'S.Samuel': 0, 'S.John': 1, 'S.Bobby': 2},
 'F.Martinez': {'S.Samuel': 0, 'S.Bobby': 1, 'S.John': 2},
 'F.Brown': {'S.John': 0, 'S.Samuel': 1, 'S.Bobby': 2}}

I represent a solution as a collection of Pairs:

from collections import namedtuple

Pair = namedtuple("Pair", ["student", "family"])

Pair(student="S.Samuel", family="F.Smith")
Pair(student='S.Samuel', family='F.Smith')

Creating a brute force solution

A simple brute force solution will generate every possible matching and return the first one with no instabilities:

from itertools import permutations


def stable_matching_bf(
    *, students, families, student_pref, family_pref
):
    """Solve the 'Stable Matching' problem using brute force.
    
    students -- set[str]. Set of students.
    families -- set[str]. Set of families.
    student_pref -- dict[str, list[str]]. Student preferences.
    family_pref -- dict[str, list[str]]. Family preferences.
    """
    s_rank = pref_to_rank(student_pref)
    f_rank = pref_to_rank(family_pref)
    #
    s_seq = tuple(students)
    matchings = (
        [
            Pair(student=s, family=f)
            for s, f in zip(s_seq, f_seq)
        ]
        for f_seq in permutations(families)
    )
    for matching in matchings:
        match_s = {pair.student: pair for pair in matching}
        match_f = {pair.family: pair for pair in matching}
        unstable = any(
            (
                s_rank[s][f] < s_rank[s][match_s[s].family] and
		f_rank[f][s] < f_rank[f][match_f[f].student]
            )
            for s in students
            for f in families
            if s != match_f[f].student
            if f != match_s[s].family
        )
        if not unstable:
            return matching


stable_matching_bf(
    students={"S.Samuel", "S.Bobby", "S.John"},
    families={"F.Smith", "F.Martinez", "F.Brown"},
    student_pref={
        "S.Samuel": ["F.Smith", "F.Brown", "F.Martinez"],
        "S.Bobby": ["F.Martinez", "F.Brown", "F.Smith"],
        "S.John": ["F.Martinez", "F.Brown", "F.Smith"],
    },
    family_pref={
        "F.Smith": ["S.Samuel", "S.John", "S.Bobby"],
        "F.Martinez": ["S.Samuel", "S.Bobby", "S.John"],
        "F.Brown": ["S.John", "S.Samuel", "S.Bobby"],
    },
)
[Pair(student='S.John', family='F.Brown'),
 Pair(student='S.Bobby', family='F.Martinez'),
 Pair(student='S.Samuel', family='F.Smith')]

What's the time complexity of this algorithm? For n students and n host families:

As a result, the overall time complexity of this algorithm is:

O(n! n2)

Creating a more efficient solution

There is a more efficient way to solve this problem.

The Gale-Shapley algorithm was created in 1962 by David Gale and Lloyd Shapley. It solves the stable matching problem in O(n2) time.

Here's a Python implementation of the Gale-Shapley algorithm:

from collections import deque


def gale_shapley(*, A, B, A_pref, B_pref):
    """Create a stable matching using the
    Gale-Shapley algorithm.
    
    A -- set[str].
    B -- set[str].
    A_pref -- dict[str, list[str]].
    B_pref -- dict[str, list[str]].

    Output: list of (a, b) pairs.
    """
    B_rank = pref_to_rank(B_pref)
    ask_list = {a: deque(bs) for a, bs in A_pref.items()}
    pair = {}
    #
    remaining_A = set(A)
    while len(remaining_A) > 0:
        a = remaining_A.pop()
        b = ask_list[a].popleft()
        if b not in pair:
            pair[b] = a
        else:
            a0 = pair[b]
            b_prefer_a0 = B_rank[b][a0] < B_rank[b][a]
            if b_prefer_a0:
                remaining_A.add(a)
            else:
                remaining_A.add(a0)
                pair[b] = a
    #
    return [(a, b) for b, a in pair.items()]


gale_shapley(
    A={"S.Samuel", "S.Bobby", "S.John"},
    B={"F.Smith", "F.Martinez", "F.Brown"},
    A_pref={
        "S.Samuel": ["F.Smith", "F.Brown", "F.Martinez"],
        "S.Bobby": ["F.Martinez", "F.Brown", "F.Smith"],
        "S.John": ["F.Martinez", "F.Brown", "F.Smith"],
    },
    B_pref={
        "F.Smith": ["S.Samuel", "S.John", "S.Bobby"],
        "F.Martinez": ["S.Samuel", "S.Bobby", "S.John"],
        "F.Brown": ["S.John", "S.Samuel", "S.Bobby"],
    },
)
[('S.Bobby', 'F.Martinez'), ('S.Samuel', 'F.Smith'), ('S.John', 'F.Brown')]

The time complexity of the Gale-Shapley algorithm is O(n2). See "CSE 331: Introduction to Algorithm Analysis and Design. Gale-Shapley Algorithm" for more information.

I created a Python function, stable_matching_fast, that has the same interface as stable_matching_bf and uses gale_shapley under the hood.

def stable_matching_fast(
    *, students, families, student_pref, family_pref
):
    """Solve the 'Stable Matching problem using the
    Gale-Shapley algorithm.
    
    students -- set[str]. Set of students.
    families -- set[str]. Set of families.
    student_pref -- dict[str, list[str]]. Student preferences.
    family_pref -- dict[str, list[str]]. Family preferences.
    """
    s_f_pairs = gale_shapley(
        A=students,
        B=families,
        A_pref=student_pref,
        B_pref=family_pref,
    )
    return [Pair(student=s, family=f) for s, f in s_f_pairs]


stable_matching_fast(
    students={"S.Samuel", "S.Bobby", "S.John"},
    families={"F.Smith", "F.Martinez", "F.Brown"},
    student_pref={
        "S.Samuel": ["F.Smith", "F.Brown", "F.Martinez"],
        "S.Bobby": ["F.Martinez", "F.Brown", "F.Smith"],
        "S.John": ["F.Martinez", "F.Brown", "F.Smith"],
    },
    family_pref={
        "F.Smith": ["S.Samuel", "S.John", "S.Bobby"],
        "F.Martinez": ["S.Samuel", "S.Bobby", "S.John"],
        "F.Brown": ["S.John", "S.Samuel", "S.Bobby"],
    },
)
[Pair(student='S.Bobby', family='F.Martinez'),
 Pair(student='S.Samuel', family='F.Smith'),
 Pair(student='S.John', family='F.Brown')]

In conclusion...

In this week's post you learned how to solve the "Stable Matching" problem in Python. You learned how to create a brute force solution, and how to create a more efficient solution using the Gale-Shapley algorithm.

In 2012, the Gale-Shapley algorithm won the Nobel Memorial Prize in Economic Sciences. See the press release for more information.

My challenge to you:

How would you solve this problem if a host family could host multiple students? E.g.

If you enjoyed this week's post, share it with your friends and stay tuned for next week's post. See you then!


(If you spot any errors or typos on this post, contact me via my contact page.)