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 pairsdef 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 matchA 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]] = jStable 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])