Skip to main content

Python while Loops Practice Problems & Exercises

Practice: while Loops and Control Flow

10 problems3 Easy4 Medium3 Hard40–55 min
← Back to lesson

Easy

#1Sum Until SentinelEasy
whilesentinelaccumulator

Implement a function that sums numbers from a list until a sentinel value is reached. Return a tuple of (total_sum, count_of_numbers_summed). The sentinel itself is not included in the sum.

Python
def sum_until_sentinel(numbers, sentinel=-1):
    """
    Sum all numbers in the list until the sentinel value is encountered.
    If the sentinel is never found, sum all numbers.
    Return (total_sum, count_of_numbers_summed).
    """
    pass


# Test
print(sum_until_sentinel([10, 20, 30, -1, 50, 60]))
print(sum_until_sentinel([5, 10, 15, 20]))
print(sum_until_sentinel([-1, 100, 200]))
print(sum_until_sentinel([1, 2, 3], sentinel=3))
print(sum_until_sentinel([], sentinel=0))
Solution
def sum_until_sentinel(numbers, sentinel=-1):
total = 0
count = 0
i = 0
while i < len(numbers) and numbers[i] != sentinel:
total += numbers[i]
count += 1
i += 1
return (total, count)

Why a while loop and not for? A for loop iterates over every element. Here we need to stop early when we hit the sentinel. While you could use for with break, the while loop makes the two termination conditions explicit in a single expression: bounds check and sentinel check.

Key insight: The sentinel pattern is foundational in systems programming — reading input until EOF, processing packets until a terminator, parsing tokens until a delimiter. The while loop's ability to encode multiple exit conditions in one place is exactly what makes it the right tool.

def sum_until_sentinel(numbers, sentinel=-1):
    """
    Sum all numbers in the list until the sentinel value is encountered.
    If the sentinel is never found, sum all numbers.
    Return (total_sum, count_of_numbers_summed).
    """
    pass


# Test
print(sum_until_sentinel([10, 20, 30, -1, 50, 60]))
print(sum_until_sentinel([5, 10, 15, 20]))
print(sum_until_sentinel([-1, 100, 200]))
print(sum_until_sentinel([1, 2, 3], sentinel=3))
print(sum_until_sentinel([], sentinel=0))
Expected Output
(60, 3)\n(50, 4)\n(0, 0)\n(3, 2)\n(0, 0)
Hints

Hint 1: Use a while loop with an index variable, checking both bounds and sentinel.

Hint 2: Increment a counter alongside the running sum.

Hint 3: An empty list or immediate sentinel means (0, 0).

#2Count Digits in a NumberEasy
whiledigitsinteger-math

Count the number of digits in an integer using a while loop and integer division. Do not convert to a string.

Python
def count_digits(n):
    """
    Count the number of digits in an integer using a while loop.
    Handle negative numbers and zero correctly.
    Return the digit count.
    """
    pass


# Test
print(count_digits(12345))
print(count_digits(0))
print(count_digits(-987))
print(count_digits(1000000))
print(count_digits(7))
Solution
def count_digits(n):
if n == 0:
return 1
n = abs(n)
count = 0
while n > 0:
n //= 10
count += 1
return count

Why not len(str(n))? Converting to a string works but hides the algorithm. In interviews and systems code, you often need the digit-extraction pattern: n % 10 gives the last digit, n // 10 removes it. This loop is O(d) where d is the digit count — the same as the string approach but without memory allocation.

Edge case: Zero is special because the loop body never executes (0 > 0 is False), but zero has exactly one digit. Always handle it explicitly.

def count_digits(n):
    """
    Count the number of digits in an integer using a while loop.
    Handle negative numbers and zero correctly.
    Return the digit count.
    """
    pass


# Test
print(count_digits(12345))
print(count_digits(0))
print(count_digits(-987))
print(count_digits(1000000))
print(count_digits(7))
Expected Output
5\n1\n3\n7\n1
Hints

Hint 1: Handle zero as a special case — it has exactly 1 digit.

Hint 2: Use abs() to handle negative numbers, then repeatedly divide by 10.

Hint 3: The loop runs while the number is greater than zero.

#3Countdown TimerEasy
whilecountdownstring-formatting

Implement a countdown function that prints T-N... for each step, then Liftoff! at the end. Return how many steps were counted.

Python
def countdown(start):
    """
    Print a countdown from start to 1, then print 'Liftoff!'.
    If start <= 0, print 'Liftoff!' immediately.
    Return the number of steps counted.
    """
    pass


# Test
steps = countdown(5)
print(f"Steps: {steps}")
print("---")
steps = countdown(0)
print(f"Steps: {steps}")
print("---")
steps = countdown(1)
print(f"Steps: {steps}")
Solution
def countdown(start):
steps = 0
current = start
while current > 0:
print(f"T-{current}...")
current -= 1
steps += 1
print("Liftoff!")
return steps

Why this is a while problem: The countdown has a clear "keep going while the condition holds" structure. The loop variable (current) is modified inside the loop body and checked in the condition — the defining characteristic of a while loop versus a for loop.

Design note: Separating the step counter from the countdown variable makes the function both print output and return useful data. This separation of concerns — side effects (printing) and return values — is a pattern you will see repeatedly in production code.

def countdown(start):
    """
    Print a countdown from start to 1, then print 'Liftoff!'.
    If start <= 0, print 'Liftoff!' immediately.
    Return the number of steps counted.
    """
    pass


# Test
steps = countdown(5)
print(f"Steps: {steps}")
print("---")
steps = countdown(0)
print(f"Steps: {steps}")
print("---")
steps = countdown(1)
print(f"Steps: {steps}")
Expected Output
T-5...\nT-4...\nT-3...\nT-2...\nT-1...\nLiftoff!\nSteps: 5\n---\nLiftoff!\nSteps: 0\n---\nT-1...\nLiftoff!\nSteps: 1
Hints

Hint 1: Use a while loop that decrements the counter each iteration.

Hint 2: Print the countdown format T-N... inside the loop.

Hint 3: Print Liftoff! after the loop exits.


Medium

#4Exponential Backoff RetryMedium
whileretrybackoffsystems

Implement exponential backoff retry logic. The function should attempt the operation, and on failure, wait with exponentially increasing delays (capped at max_delay) before retrying. Track all delay values that would be used (do not actually sleep).

Python
def retry_with_backoff(operation, max_retries=5, base_delay=1.0, max_delay=32.0):
    """
    Retry an operation with exponential backoff.

    Args:
        operation: callable that returns (success: bool, result: any)
        max_retries: maximum number of retry attempts
        base_delay: initial delay in seconds
        max_delay: maximum delay cap in seconds

    Returns:
        dict with keys:
            'success': bool,
            'result': the operation result (or None),
            'attempts': total attempts made,
            'delays': list of delay values that would have been used
    """
    pass


# Test 1: Succeeds on 3rd attempt
attempt_count = 0
def flaky_operation():
    global attempt_count
    attempt_count += 1
    if attempt_count >= 3:
        return (True, "data_payload")
    return (False, None)

result = retry_with_backoff(flaky_operation)
print(f"success={result['success']}, result={result['result']}, attempts={result['attempts']}")
print(f"delays={result['delays']}")

# Test 2: Always fails
print("---")
def always_fails():
    return (False, None)

result2 = retry_with_backoff(always_fails, max_retries=4, base_delay=2.0, max_delay=10.0)
print(f"success={result2['success']}, attempts={result2['attempts']}")
print(f"delays={result2['delays']}")
Solution
def retry_with_backoff(operation, max_retries=5, base_delay=1.0, max_delay=32.0):
attempts = 0
retries_used = 0
delays = []

while True:
attempts += 1
success, result = operation()

if success:
return {
'success': True,
'result': result,
'attempts': attempts,
'delays': delays,
}

if retries_used >= max_retries:
return {
'success': False,
'result': None,
'attempts': attempts,
'delays': delays,
}

# Calculate delay for this retry
delay = min(base_delay * (2 ** retries_used), max_delay)
delays.append(delay)
# In real code: time.sleep(delay)
retries_used += 1

Why while True here? This is one of the canonical uses of an infinite loop with internal exit conditions. The loop has two exit paths (success or exhausted retries), and the logic between them (delay calculation) makes a single while condition awkward. The while True + return pattern is cleaner than trying to encode both conditions in the loop header.

Exponential backoff in production: Real implementations add jitter (randomness) to prevent the thundering herd problem — when many clients retry at exactly the same exponentially-spaced intervals, they all hit the server simultaneously. The standard approach is delay = min(base * 2^n + random(0, base), max_delay). AWS, Google Cloud, and gRPC all use this pattern.

Delay sequence: With base_delay=1.0: 1.0, 2.0, 4.0, 8.0, 16.0, 32.0 (capped). The first attempt has no delay — you only wait after a failure.

def retry_with_backoff(operation, max_retries=5, base_delay=1.0, max_delay=32.0):
    """
    Retry an operation with exponential backoff.

    Args:
        operation: callable that returns (success: bool, result: any)
        max_retries: maximum number of retry attempts
        base_delay: initial delay in seconds
        max_delay: maximum delay cap in seconds

    Returns:
        dict with keys:
            'success': bool,
            'result': the operation result (or None),
            'attempts': total attempts made,
            'delays': list of delay values that would have been used
    """
    pass


# Test 1: Succeeds on 3rd attempt
attempt_count = 0
def flaky_operation():
    global attempt_count
    attempt_count += 1
    if attempt_count >= 3:
        return (True, "data_payload")
    return (False, None)

result = retry_with_backoff(flaky_operation)
print(f"success={result['success']}, result={result['result']}, attempts={result['attempts']}")
print(f"delays={result['delays']}")

# Test 2: Always fails
print("---")
def always_fails():
    return (False, None)

result2 = retry_with_backoff(always_fails, max_retries=4, base_delay=2.0, max_delay=10.0)
print(f"success={result2['success']}, attempts={result2['attempts']}")
print(f"delays={result2['delays']}")
Expected Output
success=True, result=data_payload, attempts=3\ndelays=[1.0, 2.0]\n---\nsuccess=False, attempts=5\ndelays=[2.0, 4.0, 8.0, 10.0]
Hints

Hint 1: The first attempt has no delay. Delays only happen between retries.

Hint 2: Delay doubles each retry: base_delay * 2^retry_number, capped at max_delay.

Hint 3: Total attempts = 1 (initial) + up to max_retries retries.

Hint 4: Only record the delay if a retry actually happens (i.e., the previous attempt failed and we have retries left).

#5Number Guessing Game EngineMedium
whilegame-logicbinary-searchstate

Build the engine for a number guessing game. Process each guess, give feedback (too low, too high, or correct), and stop as soon as the player guesses correctly or runs out of guesses.

Python
def guessing_game(secret, guesses):
    """
    Simulate a number guessing game.

    Args:
        secret: the target number (1-100)
        guesses: list of guesses the player makes

    Returns:
        dict with keys:
            'won': bool,
            'attempts': number of guesses used,
            'history': list of (guess, hint) tuples where
                       hint is 'too low', 'too high', or 'correct'
    """
    pass


# Test 1: Win scenario
result = guessing_game(42, [50, 25, 37, 44, 40, 42])
print(f"won={result['won']}, attempts={result['attempts']}")
for guess, hint in result['history']:
    print(f"  {guess} -> {hint}")

# Test 2: Run out of guesses
print("---")
result2 = guessing_game(73, [50, 80, 60])
print(f"won={result2['won']}, attempts={result2['attempts']}")
for guess, hint in result2['history']:
    print(f"  {guess} -> {hint}")
Solution
def guessing_game(secret, guesses):
history = []
won = False
i = 0

while i < len(guesses):
guess = guesses[i]
if guess == secret:
history.append((guess, "correct"))
won = True
i += 1
break
elif guess < secret:
history.append((guess, "too low"))
else:
history.append((guess, "too high"))
i += 1

return {
'won': won,
'attempts': len(history),
'history': history,
}

Why while and not for? We need early termination on a correct guess. A for loop with break would also work, but the while loop makes the dual exit conditions (correct guess or exhausted list) visible at the loop header level. More importantly, this pattern mirrors real game loops where you process events until a win/loss condition — those are always while loops.

Connection to binary search: Notice how the optimal strategy for this game is binary search — each "too low"/"too high" hint cuts the remaining range in half. The guessing game is binary search dressed up as a game, which is why it appears in every CS 101 course.

def guessing_game(secret, guesses):
    """
    Simulate a number guessing game.

    Args:
        secret: the target number (1-100)
        guesses: list of guesses the player makes

    Returns:
        dict with keys:
            'won': bool,
            'attempts': number of guesses used,
            'history': list of (guess, hint) tuples where
                       hint is 'too low', 'too high', or 'correct'
    """
    pass


# Test 1: Win scenario
result = guessing_game(42, [50, 25, 37, 44, 40, 42])
print(f"won={result['won']}, attempts={result['attempts']}")
for guess, hint in result['history']:
    print(f"  {guess} -> {hint}")

# Test 2: Run out of guesses
print("---")
result2 = guessing_game(73, [50, 80, 60])
print(f"won={result2['won']}, attempts={result2['attempts']}")
for guess, hint in result2['history']:
    print(f"  {guess} -> {hint}")
Expected Output
won=True, attempts=6\n  50 -> too high\n  25 -> too low\n  37 -> too low\n  44 -> too high\n  40 -> too low\n  42 -> correct\n---\nwon=False, attempts=3\n  50 -> too low\n  80 -> too high\n  60 -> too low
Hints

Hint 1: Use a while loop with an index into the guesses list.

Hint 2: Stop when the guess is correct OR guesses are exhausted.

Hint 3: Build the history list as you go — append (guess, hint) each iteration.

#6Newton's Method for Square RootMedium
whileconvergencenumericalmath

Implement Newton's method (also called the Babylonian method) for computing square roots. The algorithm starts with an initial guess and repeatedly refines it using the formula: guess = (guess + n / guess) / 2. Stop when the guess converges (the squared error is below the tolerance).

Python
def sqrt_newton(n, tolerance=1e-10):
    """
    Compute square root of n using Newton's method.

    Newton's formula: guess = (guess + n / guess) / 2
    Stop when |guess^2 - n| < tolerance.

    Args:
        n: non-negative number
        tolerance: convergence threshold

    Returns:
        dict with keys:
            'root': the computed square root,
            'iterations': number of iterations taken,
            'history': list of successive guesses (rounded to 10 decimal places)
    """
    pass


# Test
result = sqrt_newton(2)
print(f"sqrt(2) = {result['root']:.10f}")
print(f"iterations = {result['iterations']}")
print(f"history = {result['history']}")

print("---")
result2 = sqrt_newton(144)
print(f"sqrt(144) = {result2['root']:.10f}")
print(f"iterations = {result2['iterations']}")

print("---")
result3 = sqrt_newton(0)
print(f"sqrt(0) = {result3['root']:.10f}")
print(f"iterations = {result3['iterations']}")
Solution
def sqrt_newton(n, tolerance=1e-10):
if n == 0:
return {'root': 0.0, 'iterations': 0, 'history': []}

guess = 1.0
iterations = 0
history = [round(guess, 10)]

while abs(guess * guess - n) >= tolerance:
guess = (guess + n / guess) / 2
iterations += 1
history.append(round(guess, 10))

return {
'root': guess,
'iterations': iterations,
'history': history,
}

Why this is the definitive while loop example: Newton's method is a convergence loop — you do not know in advance how many iterations you need. The loop continues until the answer is "good enough" (within tolerance). This is fundamentally different from iterating over a known collection, which is why for loops cannot express this naturally.

Convergence speed: Newton's method has quadratic convergence — the number of correct digits roughly doubles each iteration. For sqrt(2), we go from 1.0 to 1.4142135624 in just 5 iterations. This is why CPUs use a variant of Newton's method for hardware square root.

The initial guess matters: Starting with 1.0 works but is not optimal. Starting with n/2 is better for large n. Production implementations (like math.sqrt) use lookup tables for the initial guess to minimize iterations.

def sqrt_newton(n, tolerance=1e-10):
    """
    Compute square root of n using Newton's method.

    Newton's formula: guess = (guess + n / guess) / 2
    Stop when |guess^2 - n| < tolerance.

    Args:
        n: non-negative number
        tolerance: convergence threshold

    Returns:
        dict with keys:
            'root': the computed square root,
            'iterations': number of iterations taken,
            'history': list of successive guesses (rounded to 10 decimal places)
    """
    pass


# Test
result = sqrt_newton(2)
print(f"sqrt(2) = {result['root']:.10f}")
print(f"iterations = {result['iterations']}")
print(f"history = {result['history']}")

print("---")
result2 = sqrt_newton(144)
print(f"sqrt(144) = {result2['root']:.10f}")
print(f"iterations = {result2['iterations']}")

print("---")
result3 = sqrt_newton(0)
print(f"sqrt(0) = {result3['root']:.10f}")
print(f"iterations = {result3['iterations']}")
Expected Output
sqrt(2) = 1.4142135624\niterations = 5\nhistory = [1.0, 1.5, 1.4166666667, 1.4142156863, 1.4142135624]\n---\nsqrt(144) = 12.0000000000\niterations = 9\n---\nsqrt(0) = 0.0000000000\niterations = 0
Hints

Hint 1: Start with an initial guess of n/2 (or 1.0 if n is small).

Hint 2: Each iteration: new_guess = (guess + n / guess) / 2.

Hint 3: Stop when abs(guess * guess - n) < tolerance.

Hint 4: Handle n=0 as a special case — the answer is 0 with 0 iterations.

#7Simple TokenizerMedium
whiletokenizerparsingstring-processing

Build a tokenizer that processes a string character by character using a while loop. Recognize numbers (int and float), operators (+ - * / ( )), and identifiers (variable names). Skip whitespace and raise errors for unknown characters.

Python
def tokenize(expression):
    """
    Tokenize a simple arithmetic expression into a list of tokens.

    Token types:
        ('NUMBER', value)   - integer or float
        ('OP', value)       - one of + - * / ( )
        ('IDENT', value)    - variable name (letters/underscores)

    Skip whitespace. Raise ValueError for unknown characters.

    Args:
        expression: string to tokenize

    Returns:
        list of (type, value) tuples
    """
    pass


# Test
print(tokenize("3 + 42 * x"))
print(tokenize("(a + b) / 2.5"))
print(tokenize("result_1 - 100"))
try:
    tokenize("3 @ 5")
except ValueError as e:
    print(e)
Solution
def tokenize(expression):
tokens = []
i = 0

while i < len(expression):
ch = expression[i]

# Skip whitespace
if ch == ' ':
i += 1
continue

# Numbers: digits or leading dot
if ch.isdigit() or (ch == '.' and i + 1 < len(expression) and expression[i + 1].isdigit()):
j = i
has_dot = False
while j < len(expression) and (expression[j].isdigit() or expression[j] == '.'):
if expression[j] == '.':
if has_dot:
break # second dot — stop
has_dot = True
j += 1
num_str = expression[i:j]
value = float(num_str) if has_dot else int(num_str)
tokens.append(('NUMBER', value))
i = j
continue

# Identifiers: start with letter or underscore
if ch.isalpha() or ch == '_':
j = i
while j < len(expression) and (expression[j].isalnum() or expression[j] == '_'):
j += 1
tokens.append(('IDENT', expression[i:j]))
i = j
continue

# Operators
if ch in '+-*/()':
tokens.append(('OP', ch))
i += 1
continue

# Unknown character
raise ValueError(f"Unexpected character: '{ch}' at position {i}")

return tokens

Why tokenizers use while loops: A tokenizer must advance through the input by variable amounts — a number might be 1 character or 10, an identifier might be 3 or 30. The while loop with a manually controlled index i gives you this flexibility. A for loop with enumerate cannot easily skip ahead by multiple positions.

This is how real parsers work: Python's own tokenizer (tokenize module), JSON parsers, and compiler frontends all use this exact pattern — a while loop over an index with conditional advancement. The key insight is that different token types consume different numbers of characters, so the loop step is not constant.

Error handling: The raise ValueError at the end is the catch-all. In a production tokenizer, you might instead emit an ('ERROR', ch) token and continue, allowing the parser to report multiple errors in a single pass.

def tokenize(expression):
    """
    Tokenize a simple arithmetic expression into a list of tokens.

    Token types:
        ('NUMBER', value)   - integer or float
        ('OP', value)       - one of + - * / ( )
        ('IDENT', value)    - variable name (letters/underscores)

    Skip whitespace. Raise ValueError for unknown characters.

    Args:
        expression: string to tokenize

    Returns:
        list of (type, value) tuples
    """
    pass


# Test
print(tokenize("3 + 42 * x"))
print(tokenize("(a + b) / 2.5"))
print(tokenize("result_1 - 100"))
try:
    tokenize("3 @ 5")
except ValueError as e:
    print(e)
Expected Output
[('NUMBER', 3), ('OP', '+'), ('NUMBER', 42), ('OP', '*'), ('IDENT', 'x')]\n[('OP', '('), ('IDENT', 'a'), ('OP', '+'), ('IDENT', 'b'), ('OP', ')'), ('OP', '/'), ('NUMBER', 2.5)]\n[('IDENT', 'result_1'), ('OP', '-'), ('NUMBER', 100)]\nUnexpected character: '@' at position 2
Hints

Hint 1: Use a while loop with an index variable — advance the index by different amounts depending on the token type.

Hint 2: For numbers: scan forward while digits or dot, then convert with int() or float().

Hint 3: For identifiers: scan forward while letters, digits, or underscores.

Hint 4: For operators: consume a single character.


Hard

#8Vending Machine State MachineHard
whilestate-machinedesignsystems

Build a vending machine simulator as a state machine. The machine transitions between states (idle, accepting_money, dispensing) based on events. Handle inserting money, selecting items, canceling, restocking, and going out of stock.

Python
def vending_machine(events):
    """
    Simulate a vending machine using a state machine with a while loop.

    States: 'idle', 'accepting_money', 'dispensing', 'out_of_stock'

    Events (list of tuples):
        ('insert', amount)    - insert coins (only in idle or accepting_money)
        ('select', item)      - select an item (only in accepting_money)
        ('cancel',)           - cancel and refund (in accepting_money)
        ('restock', item, qty) - restock an item (any state)

    Inventory starts as: {'cola': 2, 'chips': 1, 'water': 3}
    Prices: {'cola': 1.50, 'chips': 1.00, 'water': 0.75}

    Returns:
        list of log messages (strings) describing what happened.
    """
    pass


# Test
events = [
    ('insert', 1.00),
    ('insert', 0.50),
    ('select', 'cola'),
    ('insert', 0.50),
    ('insert', 0.50),
    ('select', 'chips'),
    ('insert', 2.00),
    ('select', 'cola'),
    ('insert', 1.50),
    ('select', 'cola'),
    ('insert', 0.50),
    ('cancel',),
    ('restock', 'cola', 5),
    ('insert', 1.50),
    ('select', 'cola'),
]

log = vending_machine(events)
for entry in log:
    print(entry)
Solution
def vending_machine(events):
inventory = {'cola': 2, 'chips': 1, 'water': 3}
prices = {'cola': 1.50, 'chips': 1.00, 'water': 0.75}
state = 'idle'
balance = 0.0
log = []
i = 0

while i < len(events):
event = events[i]
action = event[0]

if action == 'restock':
_, item, qty = event
inventory[item] = inventory.get(item, 0) + qty
log.append(f"[{state}] Restocked {item}: {qty} units")
i += 1
continue

if action == 'insert':
amount = event[1]
balance = round(balance + amount, 2)
log.append(f"[{state}] Inserted ${amount:.2f} (balance: ${balance:.2f})")
if state == 'idle':
state = 'accepting_money'
i += 1
continue

if action == 'select' and state == 'accepting_money':
item = event[1]
if inventory.get(item, 0) <= 0:
log.append(f"[{state}] {item} is out of stock")
i += 1
continue
price = prices[item]
if balance >= price:
change = round(balance - price, 2)
inventory[item] -= 1
state = 'dispensing'
log.append(
f"[{state}] Dispensed {item}. "
f"Change: ${change:.2f}. "
f"Stock: {inventory[item]}"
)
balance = 0.0
state = 'idle'
else:
log.append(
f"[{state}] Insufficient funds for {item}. "
f"Need ${price:.2f}, have ${balance:.2f}"
)
i += 1
continue

if action == 'cancel' and state == 'accepting_money':
log.append(f"[{state}] Cancelled. Refunded ${balance:.2f}")
balance = 0.0
state = 'idle'
i += 1
continue

# Invalid event for current state
log.append(f"[{state}] Invalid action: {action}")
i += 1

return log

Why state machines use while loops: A state machine processes events one at a time, with the current state determining how each event is handled. The while loop over the event queue is the natural structure because: (1) processing one event can change the state that affects the next event, and (2) in real systems, the event queue can grow dynamically (new events arrive while processing).

State transitions are explicit: Each branch ends with a state assignment (state = 'idle', state = 'accepting_money', etc.). This makes the machine's behavior auditable — you can trace exactly which state you are in at any point.

Production patterns: Real vending machines, network protocol handlers (TCP state machine), UI components (React's useReducer), and game engines all use this exact pattern. The while loop + state variable + event dispatch is one of the most important patterns in systems programming.

def vending_machine(events):
    """
    Simulate a vending machine using a state machine with a while loop.

    States: 'idle', 'accepting_money', 'dispensing', 'out_of_stock'

    Events (list of tuples):
        ('insert', amount)    - insert coins (only in idle or accepting_money)
        ('select', item)      - select an item (only in accepting_money)
        ('cancel',)           - cancel and refund (in accepting_money)
        ('restock', item, qty) - restock an item (any state)

    Inventory starts as: {'cola': 2, 'chips': 1, 'water': 3}
    Prices: {'cola': 1.50, 'chips': 1.00, 'water': 0.75}

    Returns:
        list of log messages (strings) describing what happened.
    """
    pass


# Test
events = [
    ('insert', 1.00),
    ('insert', 0.50),
    ('select', 'cola'),
    ('insert', 0.50),
    ('insert', 0.50),
    ('select', 'chips'),
    ('insert', 2.00),
    ('select', 'cola'),
    ('insert', 1.50),
    ('select', 'cola'),
    ('insert', 0.50),
    ('cancel',),
    ('restock', 'cola', 5),
    ('insert', 1.50),
    ('select', 'cola'),
]

log = vending_machine(events)
for entry in log:
    print(entry)
Expected Output
[idle] Inserted $1.00 (balance: $1.00)\n[accepting_money] Inserted $0.50 (balance: $1.50)\n[dispensing] Dispensed cola. Change: $0.00. Stock: 1\n[idle] Inserted $0.50 (balance: $0.50)\n[accepting_money] Inserted $0.50 (balance: $1.00)\n[dispensing] Dispensed chips. Change: $0.00. Stock: 0\n[idle] Inserted $2.00 (balance: $2.00)\n[dispensing] Dispensed cola. Change: $0.50. Stock: 0\n[idle] Inserted $1.50 (balance: $1.50)\n[accepting_money] cola is out of stock\n[accepting_money] Inserted $0.50 (balance: $0.50)\n[accepting_money] Cancelled. Refunded $0.50\n[idle] Restocked cola: 5 units\n[idle] Inserted $1.50 (balance: $1.50)\n[dispensing] Dispensed cola. Change: $0.00. Stock: 4
Hints

Hint 1: Use a state variable and a while loop over the events list.

Hint 2: Each state determines which events are valid and what transitions occur.

Hint 3: Track balance as a float and inventory as a dict.

Hint 4: After dispensing, always transition back to idle.

Hint 5: Round monetary values to 2 decimal places to avoid floating-point display issues.

#9Producer-Consumer SimulationHard
whileproducer-consumersimulationbuffersystems

Simulate a producer-consumer system with a bounded buffer. Each tick, the producer adds items and the consumer removes items. If the buffer is full, excess items are dropped. If the buffer is empty, the consumer starves. Track all metrics.

Python
def simulate_producer_consumer(producer_schedule, consumer_schedule, buffer_size=5):
    """
    Simulate a producer-consumer system with a bounded buffer.

    Args:
        producer_schedule: list of ints — items produced each tick
        consumer_schedule: list of ints — items consumed each tick
        buffer_size: maximum buffer capacity

    Each tick:
        1. Producer tries to add items (drops excess if buffer full)
        2. Consumer tries to remove items (starves if buffer empty)

    Returns:
        dict with keys:
            'log': list of strings describing each tick,
            'total_produced': items successfully buffered,
            'total_consumed': items successfully consumed,
            'total_dropped': items dropped (buffer full),
            'total_starved': items consumer wanted but buffer was empty,
            'final_buffer': items left in buffer
    """
    pass


# Test
producer = [3, 2, 4, 0, 1, 3]
consumer = [1, 1, 2, 3, 2, 1]

result = simulate_producer_consumer(producer, consumer, buffer_size=5)
for entry in result['log']:
    print(entry)
print(f"Produced: {result['total_produced']}, Consumed: {result['total_consumed']}")
print(f"Dropped: {result['total_dropped']}, Starved: {result['total_starved']}")
print(f"Final buffer: {result['final_buffer']}")
Solution
def simulate_producer_consumer(producer_schedule, consumer_schedule, buffer_size=5):
buffer = 0
total_produced = 0
total_consumed = 0
total_dropped = 0
total_starved = 0
log = []

total_ticks = max(len(producer_schedule), len(consumer_schedule))
tick = 0

while tick < total_ticks:
# Get this tick's production and consumption targets
to_produce = producer_schedule[tick] if tick < len(producer_schedule) else 0
to_consume = consumer_schedule[tick] if tick < len(consumer_schedule) else 0

# Produce: add to buffer, drop if full
space_available = buffer_size - buffer
actually_buffered = min(to_produce, space_available)
dropped = to_produce - actually_buffered
buffer += actually_buffered

# Consume: remove from buffer, starve if empty
actually_consumed = min(to_consume, buffer)
starved = to_consume - actually_consumed
buffer -= actually_consumed

# Update totals
total_produced += actually_buffered
total_consumed += actually_consumed
total_dropped += dropped
total_starved += starved

log.append(
f"Tick {tick + 1}: produce={to_produce}, consume={to_consume} | "
f"buffered={actually_buffered}, consumed={actually_consumed}, "
f"dropped={dropped}, starved={starved} | "
f"buffer={buffer}/{buffer_size}"
)

tick += 1

return {
'log': log,
'total_produced': total_produced,
'total_consumed': total_consumed,
'total_dropped': total_dropped,
'total_starved': total_starved,
'final_buffer': buffer,
}

Why this matters: The producer-consumer pattern is everywhere in systems programming — message queues (Kafka, RabbitMQ), I/O buffers, thread pools, and network packet processing. Understanding buffer overflow (dropped) and underflow (starved) is critical for sizing systems correctly.

The while loop as a simulation clock: Each iteration represents one discrete time step. The loop variable tick is the simulation clock. This is the standard pattern for discrete event simulation — games, network simulators, and physics engines all work this way.

Key metrics: In production, you would monitor dropped / total_produced (overflow rate) and starved / total_consumed_target (underflow rate). If overflow is high, increase the buffer. If underflow is high, the producer is too slow. These metrics drive capacity planning decisions.

def simulate_producer_consumer(producer_schedule, consumer_schedule, buffer_size=5):
    """
    Simulate a producer-consumer system with a bounded buffer.

    Args:
        producer_schedule: list of ints — items produced each tick
        consumer_schedule: list of ints — items consumed each tick
        buffer_size: maximum buffer capacity

    Each tick:
        1. Producer tries to add items (drops excess if buffer full)
        2. Consumer tries to remove items (starves if buffer empty)

    Returns:
        dict with keys:
            'log': list of strings describing each tick,
            'total_produced': items successfully buffered,
            'total_consumed': items successfully consumed,
            'total_dropped': items dropped (buffer full),
            'total_starved': items consumer wanted but buffer was empty,
            'final_buffer': items left in buffer
    """
    pass


# Test
producer = [3, 2, 4, 0, 1, 3]
consumer = [1, 1, 2, 3, 2, 1]

result = simulate_producer_consumer(producer, consumer, buffer_size=5)
for entry in result['log']:
    print(entry)
print(f"Produced: {result['total_produced']}, Consumed: {result['total_consumed']}")
print(f"Dropped: {result['total_dropped']}, Starved: {result['total_starved']}")
print(f"Final buffer: {result['final_buffer']}")
Expected Output
Tick 1: produce=3, consume=1 | buffered=3, consumed=1, dropped=0, starved=0 | buffer=2/5\nTick 2: produce=2, consume=1 | buffered=2, consumed=1, dropped=0, starved=0 | buffer=3/5\nTick 3: produce=4, consume=2 | buffered=2, consumed=2, dropped=2, starved=0 | buffer=3/5\nTick 4: produce=0, consume=3 | buffered=0, consumed=3, dropped=0, starved=0 | buffer=0/5\nTick 5: produce=1, consume=2 | buffered=1, consumed=1, dropped=0, starved=1 | buffer=0/5\nTick 6: produce=3, consume=1 | buffered=3, consumed=1, dropped=0, starved=0 | buffer=2/5\nProduced: 11, Consumed: 9\nDropped: 2, Starved: 1\nFinal buffer: 2
Hints

Hint 1: Use a while loop over the tick count — the simulation runs for max(len(producer), len(consumer)) ticks.

Hint 2: Each tick: first produce (capped by buffer space), then consume (capped by buffer contents).

Hint 3: Track dropped = attempted - actually_buffered, and starved = wanted - actually_consumed.

Hint 4: Treat missing schedule entries as 0 (producer or consumer can have different lengths).

#10Token Bucket Rate LimiterHard
whilerate-limitertoken-bucketsystemsalgorithm

Implement a token bucket rate limiter. The bucket starts full and tokens refill at a constant rate. Each request consumes one token. When the bucket is empty, requests are rejected until enough tokens have refilled. This is the algorithm used by AWS, Stripe, and most API rate limiters.

Python
def token_bucket_limiter(requests, bucket_capacity=5, refill_rate=1.0):
    """
    Simulate a token bucket rate limiter.

    The bucket starts full. Tokens refill at refill_rate tokens per second.
    Each request consumes 1 token. Requests with no available tokens are rejected.

    Args:
        requests: list of (timestamp, request_id) tuples, sorted by timestamp
        bucket_capacity: max tokens the bucket can hold
        refill_rate: tokens added per second

    Returns:
        dict with keys:
            'log': list of (request_id, 'allowed'|'rejected', tokens_remaining),
            'total_allowed': int,
            'total_rejected': int
    """
    pass


# Test: Burst of requests then steady
requests = [
    (0.0, "req-1"),
    (0.1, "req-2"),
    (0.2, "req-3"),
    (0.3, "req-4"),
    (0.4, "req-5"),
    (0.5, "req-6"),   # bucket should be empty
    (0.6, "req-7"),   # still empty (only 0.1s = 0.1 tokens refilled)
    (2.0, "req-8"),   # 1.4s later -> ~1.4 tokens refilled -> 1 available
    (5.0, "req-9"),   # 3.0s later -> 3 tokens refilled
    (5.1, "req-10"),  # 0.1s later -> ~0.1 tokens refilled
]

result = token_bucket_limiter(requests, bucket_capacity=5, refill_rate=1.0)
for req_id, status, tokens in result['log']:
    print(f"{req_id}: {status} (tokens: {tokens:.1f})")
print(f"Allowed: {result['total_allowed']}, Rejected: {result['total_rejected']}")
Solution
def token_bucket_limiter(requests, bucket_capacity=5, refill_rate=1.0):
tokens = float(bucket_capacity) # Start full
last_time = 0.0
log = []
total_allowed = 0
total_rejected = 0
i = 0

while i < len(requests):
timestamp, req_id = requests[i]

# Refill tokens based on elapsed time
elapsed = timestamp - last_time
tokens = min(tokens + elapsed * refill_rate, float(bucket_capacity))
last_time = timestamp

# Try to consume a token
if tokens >= 1.0:
tokens -= 1.0
tokens_display = round(tokens, 1)
log.append((req_id, 'allowed', tokens_display))
total_allowed += 1
else:
tokens_display = round(tokens, 1)
log.append((req_id, 'rejected', tokens_display))
total_rejected += 1

i += 1

return {
'log': log,
'total_allowed': total_allowed,
'total_rejected': total_rejected,
}

How token bucket works:

  1. The bucket starts with capacity tokens.
  2. Tokens refill continuously at refill_rate per second, capped at capacity.
  3. Each request costs 1 token. If available, allow and deduct. If not, reject.

The key insight is lazy refill — you do not actually run a background timer. Instead, when a request arrives, you calculate how many tokens would have accumulated since the last request: elapsed * rate. This makes the algorithm stateless between requests, which is why it works so well in distributed systems.

Why while and not for: In production, this loop runs indefinitely — it processes requests as they arrive from a network socket or message queue. The while loop models this correctly: "keep processing while there are requests." In a real server, this would be while True with requests arriving asynchronously.

Production usage: AWS API Gateway, Stripe, GitHub API, Cloudflare, and nginx all use token bucket (or the closely related leaky bucket) for rate limiting. The parameters bucket_capacity controls burst tolerance and refill_rate controls sustained throughput — these two knobs let you independently tune burst and steady-state behavior.

Variants:

  • Leaky bucket: tokens drain at a constant rate regardless of requests — smooths traffic but adds latency.
  • Sliding window: counts requests in a time window — simpler but no burst control.
  • Token bucket with cost: different requests consume different numbers of tokens (e.g., a database write costs 5 tokens, a read costs 1).
def token_bucket_limiter(requests, bucket_capacity=5, refill_rate=1.0):
    """
    Simulate a token bucket rate limiter.

    The bucket starts full. Tokens refill at refill_rate tokens per second.
    Each request consumes 1 token. Requests with no available tokens are rejected.

    Args:
        requests: list of (timestamp, request_id) tuples, sorted by timestamp
        bucket_capacity: max tokens the bucket can hold
        refill_rate: tokens added per second

    Returns:
        dict with keys:
            'log': list of (request_id, 'allowed'|'rejected', tokens_remaining),
            'total_allowed': int,
            'total_rejected': int
    """
    pass


# Test: Burst of requests then steady
requests = [
    (0.0, "req-1"),
    (0.1, "req-2"),
    (0.2, "req-3"),
    (0.3, "req-4"),
    (0.4, "req-5"),
    (0.5, "req-6"),   # bucket should be empty
    (0.6, "req-7"),   # still empty (only 0.1s = 0.1 tokens refilled)
    (2.0, "req-8"),   # 1.4s later -> ~1.4 tokens refilled -> 1 available
    (5.0, "req-9"),   # 3.0s later -> 3 tokens refilled
    (5.1, "req-10"),  # 0.1s later -> ~0.1 tokens refilled
]

result = token_bucket_limiter(requests, bucket_capacity=5, refill_rate=1.0)
for req_id, status, tokens in result['log']:
    print(f"{req_id}: {status} (tokens: {tokens:.1f})")
print(f"Allowed: {result['total_allowed']}, Rejected: {result['total_rejected']}")
Expected Output
req-1: allowed (tokens: 4.0)\nreq-2: allowed (tokens: 3.1)\nreq-3: allowed (tokens: 2.2)\nreq-4: allowed (tokens: 1.3)\nreq-5: allowed (tokens: 0.4)\nreq-6: rejected (tokens: 0.5)\nreq-7: rejected (tokens: 0.6)\nreq-8: allowed (tokens: 0.0)\nreq-9: allowed (tokens: 2.0)\nreq-10: allowed (tokens: 1.1)
Hints

Hint 1: Track the current token count (float) and the timestamp of the last request.

Hint 2: Before each request, refill tokens: tokens += elapsed_time * refill_rate, capped at bucket_capacity.

Hint 3: If tokens >= 1.0, allow and subtract 1. Otherwise reject.

Hint 4: Use round() to avoid floating-point display artifacts.

© 2026 EngineersOfAI. All rights reserved.