Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Chapter 10: Stable Matching - The Gale-Shapley Algorithm

We are often faced with the problem of pairing two sets of agents together:

  • Kidney Exchange: Donors \leftrightarrow Patients.

  • Medical Residency: Doctors \leftrightarrow Hospitals.

  • Dating: Men \leftrightarrow Women (The classic “Stable Marriage” problem).

The Goal: Stability

A matching is Stable if there are no Blocking Pairs.

What is a Blocking Pair?

Imagine Alice is married to Bob, and Charlie is married to Diane.

  • If Alice likes Charlie more than Bob...

  • AND Charlie likes Alice more than Diane...

  • ...then Alice and Charlie will leave their partners and run off together. The system is unstable.

The Solution: Gale-Shapley (Deferred Acceptance)

In 1962, Gale and Shapley proved that a stable matching always exists and can be found using a simple algorithm.

Since we are about to simulate the famous National Resident Matching Program (NRMP), let’s describe the algorithm using Doctors (Residents) and Hospitals:

  1. Apply (Propose): Each Resident applies to their favorite Hospital that hasn’t rejected them yet.

  2. Defer (Tentative Match): Each Hospital looks at all the applications they received (plus the one they were already holding).

    • They hold onto the best applicant (based on the hospital’s rankings).

    • They reject everyone else.

    • Crucial Point: The hospital does not say “Final Yes” yet. They only say “You are my best option so far.”

  3. Repeat: Any Resident who got rejected crosses that hospital off their list and applies to their next favorite choice.

  4. End: When no new applications are made (because everyone is matched or ran out of choices), the tentative matches become Final.

Let’s implement this algorithm to pair up Medical Residents with Hospitals.

import pandas as pd
import copy

def run_gale_shapley():
    # --- SETUP ---
    # 4 Residents (A, B, C, D) and 4 Hospitals (H1, H2, H3, H4)
    
    # Residents' Preferences (Ordered List: 1st choice -> Last choice)
    residents_prefs = {
        'A': ['H1', 'H2', 'H3', 'H4'],
        'B': ['H1', 'H4', 'H3', 'H2'],
        'C': ['H2', 'H1', 'H3', 'H4'],
        'D': ['H4', 'H2', 'H3', 'H1']
    }
    
    # Hospitals' Preferences (Ranking of Residents)
    hospitals_prefs = {
        'H1': ['D', 'C', 'B', 'A'],
        'H2': ['C', 'A', 'D', 'B'],
        'H3': ['A', 'B', 'C', 'D'],
        'H4': ['B', 'A', 'C', 'D']
    }
    
    # TRACKING STATE
    # matches: {'H1': 'A'} means H1 is currently holding A's offer
    matches = {h: None for h in hospitals_prefs.keys()} 
    
    # free_residents: List of residents currently looking for a match
    free_residents = list(residents_prefs.keys())
    
    # proposals_made: Keep track of who has already been asked by whom
    # {'A': 0} means A needs to ask their 0-th index preference next
    proposals_count = {r: 0 for r in residents_prefs.keys()}
    
    print("--- STARTING DEFERRED ACCEPTANCE ALGORITHM ---")
    round_num = 1
    
    while free_residents:
        print(f"\n[Round {round_num}] Free Residents: {free_residents}")
        
        # Take the first free resident
        resident = free_residents.pop(0)
        
        # Who do they want to ask next?
        # Get the index of the next hospital to ask
        pref_index = proposals_count[resident]
        
        # If they ran out of hospitals (shouldn't happen in equal n x n), stop
        if pref_index >= len(residents_prefs[resident]):
            continue
            
        hospital = residents_prefs[resident][pref_index]
        proposals_count[resident] += 1 # Increment for next time
        
        print(f"   -> {resident} proposes to {hospital}...")
        
        # DECISION TIME FOR HOSPITAL
        current_match = matches[hospital]
        
        if current_match is None:
            # Hospital was free, takes the offer (tentatively)
            matches[hospital] = resident
            print(f"      {hospital} accepts {resident} (for now).")
            
        else:
            # Hospital is already matched. Must choose: Current vs New?
            # We look at H's preference list to see who is ranked higher (lower index)
            h_pref_list = hospitals_prefs[hospital]
            
            rank_current = h_pref_list.index(current_match)
            rank_new = h_pref_list.index(resident)
            
            if rank_new < rank_current:
                # New resident is BETTER! Swap.
                print(f"      {hospital} DUMPS {current_match} for {resident}!")
                matches[hospital] = resident
                free_residents.append(current_match) # Old match is free again
            else:
                # New resident is WORSE. Reject.
                print(f"      {hospital} rejects {resident} (prefers current {current_match}).")
                free_residents.append(resident) # Resident stays free
                
        round_num += 1

    # --- FINAL RESULTS ---
    print("\n" + "="*30)
    print("FINAL STABLE MATCHING:")
    for h, r in matches.items():
        print(f"Hospital {h} <---> Resident {r}")
        
    return matches, residents_prefs, hospitals_prefs

final_matches, r_prefs, h_prefs = run_gale_shapley()
--- STARTING DEFERRED ACCEPTANCE ALGORITHM ---

[Round 1] Free Residents: ['A', 'B', 'C', 'D']
   -> A proposes to H1...
      H1 accepts A (for now).

[Round 2] Free Residents: ['B', 'C', 'D']
   -> B proposes to H1...
      H1 DUMPS A for B!

[Round 3] Free Residents: ['C', 'D', 'A']
   -> C proposes to H2...
      H2 accepts C (for now).

[Round 4] Free Residents: ['D', 'A']
   -> D proposes to H4...
      H4 accepts D (for now).

[Round 5] Free Residents: ['A']
   -> A proposes to H2...
      H2 rejects A (prefers current C).

[Round 6] Free Residents: ['A']
   -> A proposes to H3...
      H3 accepts A (for now).

==============================
FINAL STABLE MATCHING:
Hospital H1 <---> Resident B
Hospital H2 <---> Resident C
Hospital H3 <---> Resident A
Hospital H4 <---> Resident D

Analysis: Why is this amazing?

The matching we just found is mathematically guaranteed to be Stable.

Proof by Contradiction: Suppose there is a blocking pair (Resident A, Hospital 1).

  1. This means A prefers H1 over their current match.

  2. This implies A must have proposed to H1 before proposing to their current match (since A asks in order of preference).

  3. If A proposed to H1 earlier and isn’t matched with them now, H1 must have rejected A.

  4. H1 only rejects A if H1 had a better offer.

  5. Since H1’s offers only get better over time (they trade up), H1 must currently have someone they like more than A.

  6. Therefore, H1 does not prefer A over their current match.

  7. Contradiction. H1 and A cannot be a blocking pair.

Interesting Fact: The algorithm is Resident-Optimal (Proposer-Optimal).

  • Every Resident gets the best possible hospital they could ever get in any stable matching.

  • Every Hospital gets the worst possible resident they could get in any stable matching.

  • Lesson: In a matching market, it pays to be the one making the proposals!

Example 2: The Dating Game (Men \leftrightarrow Women)

The “Stable Marriage Problem” is the classic version of this algorithm.

  • Group 1 (Men): They propose.

  • Group 2 (Women): They reject or defer.

The “Proposer’s Advantage”

It might seem like the women have the power because they sit back and judge the offers. Actually, the opposite is true.

The Gale-Shapley Theorem proves that the algorithm is Men-Optimal (Proposer-Optimal).

  • Every man ends up with the best woman who would possibly have him in any stable world.

  • Every woman ends up with the worst man she would have to accept in any stable world.

Lesson: In a matching market, be the one who asks. Sitting back and waiting for offers is mathematically disadvantageous!

Let’s verify this “Proposer Advantage” with Python.

import random

def run_stable_marriage():
    # 1. SETUP NAMES
    men = ['Adam', 'Bob', 'Charlie', 'Dave']
    women = ['Alice', 'Beatrice', 'Cindy', 'Diane']
    
    # 2. CREATE PREFERENCES
    # We fix preferences to show the advantage clearly.
    # Men's Preferences: Everyone loves Alice > Beatrice > Cindy > Diane
    men_prefs = {
        'Adam':   ['Alice', 'Beatrice', 'Cindy', 'Diane'],
        'Bob':    ['Alice', 'Beatrice', 'Cindy', 'Diane'],
        'Charlie':['Alice', 'Beatrice', 'Cindy', 'Diane'],
        'Dave':   ['Alice', 'Beatrice', 'Cindy', 'Diane']
    }
    
    # Women's Preferences: Everyone loves Adam > Bob > Charlie > Dave
    women_prefs = {
        'Alice':   ['Charlie', 'Dave', 'Adam', 'Bob'],    # Prefers Charlie/Dave (Low ranked men)
        'Beatrice':['Charlie', 'Dave', 'Adam', 'Bob'],
        'Cindy':   ['Charlie', 'Dave', 'Adam', 'Bob'],
        'Diane':   ['Charlie', 'Dave', 'Adam', 'Bob']
    }
    
    # 3. RUN GALE-SHAPLEY (Men Propose)
    matches = {w: None for w in women} # Women hold the matches
    free_men = list(men)
    proposals_count = {m: 0 for m in men}
    
    print(f"--- STARTING DATING SIMULATION (Men Propose) ---")
    
    while free_men:
        man = free_men.pop(0)
        # Get next woman on his list
        w_index = proposals_count[man]
        
        # If man has been rejected by everyone (Shouldn't happen here), skip
        if w_index >= len(men_prefs[man]):
            continue
            
        woman = men_prefs[man][w_index]
        proposals_count[man] += 1
        
        print(f"{man} proposes to {woman}...")
        
        current_husband = matches[woman]
        
        if current_husband is None:
            matches[woman] = man
            print(f"   -> {woman} says: 'Maybe' (Accepts {man})")
        else:
            # Woman checks her list: Is Man better than Current Husband?
            w_list = women_prefs[woman]
            rank_new = w_list.index(man)
            rank_curr = w_list.index(current_husband)
            
            if rank_new < rank_curr:
                print(f"   -> {woman} DUMPS {current_husband} for {man}!")
                matches[woman] = man
                free_men.append(current_husband) # Husband is single again
            else:
                print(f"   -> {woman} REJECTS {man} (Stays with {current_husband})")
                free_men.append(man) # Man stays single

    print("-" * 30)
    print("FINAL MARRIAGES:")
    for w, m in matches.items():
        # Check rank of husband in woman's list
        rank = women_prefs[w].index(m) + 1
        print(f"{w} is married to {m} (Her #{rank} choice)")
        
run_stable_marriage()
--- STARTING DATING SIMULATION (Men Propose) ---
Adam proposes to Alice...
   -> Alice says: 'Maybe' (Accepts Adam)
Bob proposes to Alice...
   -> Alice REJECTS Bob (Stays with Adam)
Charlie proposes to Alice...
   -> Alice DUMPS Adam for Charlie!
Dave proposes to Alice...
   -> Alice REJECTS Dave (Stays with Charlie)
Bob proposes to Beatrice...
   -> Beatrice says: 'Maybe' (Accepts Bob)
Adam proposes to Beatrice...
   -> Beatrice DUMPS Bob for Adam!
Dave proposes to Beatrice...
   -> Beatrice DUMPS Adam for Dave!
Bob proposes to Cindy...
   -> Cindy says: 'Maybe' (Accepts Bob)
Adam proposes to Cindy...
   -> Cindy DUMPS Bob for Adam!
Bob proposes to Diane...
   -> Diane says: 'Maybe' (Accepts Bob)
------------------------------
FINAL MARRIAGES:
Alice is married to Charlie (Her #1 choice)
Beatrice is married to Dave (Her #2 choice)
Cindy is married to Adam (Her #3 choice)
Diane is married to Bob (Her #4 choice)

Analysis of the Output

If you check the code’s preference lists, you’ll see something tricky:

  • The Men generally got their top choices.

  • The Women (like Alice) might have preferred Charlie or Dave, but because Adam and Bob proposed early and aggressively, the women were forced to “settle” or were never even asked by their true favorites until it was too late.

This confirms the theory: Proposing is powerful.