Purpose

Given companies and students, where each side ranks the other, we want a matching nobody wants to defect from. This note defines stability, walks the Gale-Shapley propose-and-reject algorithm, and proves its correctness and its optimality structure (proposers get their best valid partner, receivers get their worst). The algorithm and the existence guarantee come from Gale and Shapley (1962).

Problem

Given a list of companies and a list of students , each company ranks all students in order of preference, and each student ranks all companies.

Find a stable matching: a matching where no company and student would prefer each other over their current matches.

  • Perfect matching: every company and every student is matched with exactly one partner.
  • Stable: there is no pair not matched to each other where prefers over its current match and prefers over their current match.

A stable matching is a matching that is both perfect and stable. In other words, no company-student pair has an incentive to break their current matches for each other.

You can confirm a matching is stable by checking every non-matched pair for whether both sides prefer each other. With matched pairs, that is pairs to check.

Stable matchings always exist, and the algorithm below is the constructive proof.

Propose and Reject Algorithm (Gale-Shapley)

Initialize all companies and students to be free
 
while some company is free and hasn't proposed to all students:
    c = first such company
    s = highest-ranked student c has not yet proposed to
    if s is free:
        (c, s) become paired
    else if s prefers c to current match c':
        c' becomes free
        (c, s) become paired
    else:
        s rejects c
 
return the set of pairs
def gale_shapley(company_prefs, student_prefs):
    # company_prefs[c]: list of students in decreasing preference
    # student_prefs[s]: list of companies in decreasing preference
    free = list(company_prefs)
    match = {}                              # student -> company
    next_prop = {c: 0 for c in company_prefs}
    rank = {s: {c: i for i, c in enumerate(prefs)}
            for s, prefs in student_prefs.items()}
 
    while free:
        c = free.pop()
        s = company_prefs[c][next_prop[c]]
        next_prop[c] += 1
        if s not in match:
            match[s] = c
        elif rank[s][c] < rank[s][match[s]]:
            free.append(match[s])
            match[s] = c
        else:
            free.append(c)
 
    return match

A run with two companies

Take preferences , , , . Both companies want , so one of them gets bumped:

sequenceDiagram
    participant c1 as c1
    participant c2 as c2
    participant s1 as s1
    participant s2 as s2

    c1->>s1: propose
    Note over s1: free, accepts c1
    c2->>s1: propose
    Note over s1: prefers c2, drops c1
    c1->>s2: propose
    Note over s2: free, accepts c1
    Note over c1,s2: output {(c2, s1), (c1, s2)}

The run shows both directions of movement at once: trades up from to , while walks down its list from to .

Properties

  • Companies propose to students in decreasing order of preference.
  • Each company proposes to each student at most once.
  • Once a student is matched, they never become unmatched, only “trade up”.

Proof of Correctness

Two obligations: the algorithm terminates in reasonable time, and its output is a stable matching.

Termination: each of the companies proposes to each of the students at most once, so there are at most proposals, and the algorithm runs in time.

Output is perfect: suppose some company has no match after termination. Matches are one-to-one at every step, so some student is also unmatched:

A company only ends unmatched after proposing to and being rejected by every student. A student only ends unmatched by never receiving a proposal. But students keep a match once they have one, so a student proposed to by cannot end unmatched. Every student received a proposal from , so no student is unmatched, a contradiction.

Output is stable: suppose for contradiction the output contains an unstable pair, i.e. there exist and where prefers over and prefers over .

Since proposes in decreasing order of preference and ended with , it proposed to earlier and was rejected. Students only reject in favor of companies they prefer, and only trade up afterward, so ‘s final match satisfies . That contradicts preferring over .

GS Solution Properties

The invariant behind both results

Each proposer walks down its preference list, so its situation only worsens over the run, while each receiver only trades up, so its match only improves. This asymmetry drives everything below: whichever side proposes gets its best valid partner, and the receiving side gets its worst.

Company Optimal Assignments

  • Valid partner: company is a valid partner of student if some stable matching pairs them.
  • Best valid partner : the valid partner prefers most.

Claim: GS matches every company with its best valid partner. In particular the output is the same regardless of proposal order.

Proof: by contradiction. Since companies propose in decreasing order of preference, if some company misses its BVP, there is a first rejection of a company by its best valid partner during the run. Say rejects in favor of , so prefers over .

Since is a valid partner of , some stable matching pairs . In , is paired with some other student . Because ‘s rejection of is the first rejection by a best valid partner, had not yet been rejected by at that moment, and proposes in decreasing order, so , meaning prefers over .

So in , prefers over its partner , and prefers over its partner . The pair is unstable for , contradicting the stability of .

Applicant Pessimality

Claim: each student receives their worst valid partner .

Proof: let be the output of GS. Suppose for contradiction but .

Say . Since is a valid partner of , some stable matching pairs . Let .

By company optimality, , so prefers over . And since is ‘s worst valid partner while is also valid, prefers over . Then is unstable for , a contradiction.

Efficient Implementation

GS runs in time with the right data structures.

Name companies and students , each with a preference list of the other side. The one trick: precompute an inverse array of each student’s preference list, so “does prefer to ” is an array lookup instead of a list scan.

for i in range(n):
    for j in range(n):
        inverse[i][pref[i][j]] = j

Stable Roommate Problem

Given people, each person ranks the other in order of preference. Find a stable matching among them. Unlike the bipartite version, a stable matching is no longer guaranteed to exist.

Does a stable match always include at least one person’s top choice?

No. Brute force over every stable matching instance with companies and applicants turns up 12 examples where nobody is matched with their top choice. The script below enumerates all preference profiles, finds their stable matchings, and counts how many participants got their first pick.

from itertools import permutations, product
 
def is_stable_matching(company_prefs, applicant_prefs, matching):
    imatching = { v:k for k, v in matching.items() }
    for company, applicant in matching.items():
        company_index = company_prefs[company].index(applicant)
        for other_applicant in company_prefs[company][:company_index]:
            if applicant_prefs[other_applicant].index(company) < applicant_prefs[other_applicant].index(imatching[other_applicant]):
                return False
    return True
 
def find_stable_matchings(company_prefs, applicant_prefs):
    matchings = []
    for perm in permutations(applicant_prefs.keys()):
        matching = dict(zip(company_prefs.keys(), perm))
        if is_stable_matching(company_prefs, applicant_prefs, matching):
            matchings.append(matching)
    return matchings
 
 
A1, A2, A3 = 'A1', 'A2', 'A3'
C1, C2, C3 = 'C1', 'C2', 'C3'
 
def generate_preferences():
 
    company_labels = [C1, C2, C3]
    applicant_labels = [A1, A2, A3]
 
    cperms = list(permutations(company_labels))
    aperms = list(permutations(applicant_labels))
 
    cprod = product(cperms, cperms, cperms)
    aprod = product(aperms, aperms, aperms)
 
    c = [ dict(zip(applicant_labels, c)) for c in cprod ]
    a = [ dict(zip(company_labels, a)) for a in aprod ]
 
    return c, a
 
all_c, all_a = generate_preferences()
 
data = []
res = []
for c in all_c:
  for a in all_a:
 
    stable_matchings = find_stable_matchings(c, a)
 
    for matching in stable_matchings:
      imatching = { v: k for k, v in matching.items() }
      match_dict = dict(matching)
      match_dict.update(imatching)
 
      data.append((c, a, matching))
      curr = 0
      for co, pref in c.items():
        if pref[0] == match_dict[co]:
          curr += 1
 
      for ap, pref in a.items():
        if pref[0] == match_dict[ap]:
          curr += 1
 
      res.append(curr)
 
 
candidates = []
 
for i in range(len(res)):
  if res[i] == 0:
    candidates.append(data[i])
    print(data[i])