Skip to main content

Python Tuples and Immutability Practice Problems & Exercises

Practice: Tuples and Immutability

12 problems4 Easy5 Medium3 Hard45-60 min
← Back to lesson

#1Single-Element Tuple TrapEasy
tuple basicssyntax

A common Python gotcha is confusing parenthesized expressions with single-element tuples.

Write classify_types that takes a list of values and returns their type names as strings.

Then call it with this specific input to verify you understand which expressions produce tuples:

Python
def classify_types(values):
    """Return a list of type names for each value."""
    return [type(v).__name__ for v in values]

# These five expressions — which ones are actually tuples?
test_values = [
    (1, 2, 3),    # Expression A
    (42),          # Expression B
    (42,),         # Expression C
    ("hello"),     # Expression D
    tuple(),       # Expression E
]

print(classify_types(test_values))
Solution
def classify_types(values):
return [type(v).__name__ for v in values]

test_values = [
(1, 2, 3), # tuple — multiple elements, commas present
(42), # int — parentheses alone do NOT make a tuple
(42,), # tuple — trailing comma makes it a tuple
("hello"), # str — same trap, no comma means just a string
tuple(), # tuple — explicit constructor, empty tuple
]

print(classify_types(test_values))
# ['tuple', 'int', 'tuple', 'str', 'tuple']

Key insight: The comma creates the tuple, not the parentheses. (42) is just the integer 42 grouped in parentheses. You need (42,) for a single-element tuple. The tuple() constructor always returns a tuple, even when empty.

def classify_types(values):
  """Return a list of type names for each value.

  Args:
      values: a list of Python expressions (already evaluated)

  Returns:
      list[str]: the type name of each value
          e.g. ["tuple", "int", "tuple", "str"]
  """
  # TODO: return [type(v).__name__ for each v in values]
  pass
Expected Output
['tuple', 'int', 'tuple', 'str', 'tuple']
Hints

Hint 1: The comma creates a tuple, not the parentheses. (42) is just 42 in parentheses.

Hint 2: A single-element tuple requires a trailing comma: (42,)

Hint 3: tuple() with no arguments creates an empty tuple, which is still a tuple.


#2Tuple Packing and UnpackingEasy
unpackingsplat

Tuple unpacking with the * splat operator is essential for processing variable-length sequences.

Write head_tail that splits a tuple into its first element and a tuple of the remaining elements.

Python
def head_tail(data):
    """Split a tuple into its first element and everything else."""
    first, *rest = data
    return (first, tuple(rest))

print(head_tail((1, 2, 3, 4, 5)))
print(head_tail(("a", "b", "c")))
print(head_tail((42,)))
Solution
def head_tail(data):
first, *rest = data
return (first, tuple(rest))

print(head_tail((1, 2, 3, 4, 5))) # (1, (2, 3, 4, 5))
print(head_tail(("a", "b", "c"))) # ('a', ('b', 'c'))
print(head_tail((42,))) # (42, ())

Key insight: *rest always captures into a list, even when unpacking a tuple. This is by design since the captured portion has variable length. If you need a tuple back, wrap it: tuple(rest). For a single-element input, *rest captures an empty list [], which becomes ().

def head_tail(data):
  """Split a tuple into its first element and everything else.

  Args:
      data: a tuple with at least one element

  Returns:
      tuple: (first_element, rest_as_tuple)
          rest_as_tuple should be a tuple, not a list
  """
  # TODO: use splat (*) unpacking
  # Remember: *rest captures into a list, so convert it
  pass
Expected Output
(1, (2, 3, 4, 5))
('a', ('b', 'c'))
(42, ())
Hints

Hint 1: Use the pattern: first, *rest = data

Hint 2: The starred variable *rest always captures into a list. Convert it with tuple().

Hint 3: For a single-element tuple input, *rest will be an empty list [], which becomes ().


#3Swap Without TempEasy
unpackingtuple packing

Python's tuple packing/unpacking lets you swap variables without temporary storage.

Write rotate_left that rotates three values one position to the left using a single tuple assignment.

Python
def rotate_left(a, b, c):
    """Rotate three values left by one position."""
    a, b, c = b, c, a
    return (a, b, c)

print(rotate_left(1, 2, 3))
print(rotate_left("a", "b", "c"))
print(rotate_left(10, 20, 30))
Solution
def rotate_left(a, b, c):
a, b, c = b, c, a
return (a, b, c)

print(rotate_left(1, 2, 3)) # (2, 3, 1)
print(rotate_left("a", "b", "c")) # ('b', 'c', 'a')
print(rotate_left(10, 20, 30)) # (20, 30, 10)

Key insight: Python evaluates the entire right-hand side b, c, a into a temporary tuple (b, c, a) first, then unpacks it into a, b, c. For simple two-variable swaps, CPython optimizes this with ROT_TWO bytecode that avoids allocating a heap tuple entirely.

def rotate_left(a, b, c):
  """Rotate three values one position to the left using tuple swap.

  Example: rotate_left(1, 2, 3) -> (2, 3, 1)

  Args:
      a, b, c: three values

  Returns:
      tuple: (b, c, a) — rotated left by one position

  Rules:
      - Use a SINGLE tuple pack/unpack statement
      - Do NOT use a temporary variable
  """
  # TODO: one-line tuple swap
  pass
Expected Output
(2, 3, 1)
('b', 'c', 'a')
(20, 30, 10)
Hints

Hint 1: Python evaluates the entire right side before assigning to the left side.

Hint 2: a, b, c = b, c, a is a single tuple pack/unpack — no temp variable needed.

Hint 3: The right side creates a tuple (b, c, a), then unpacks into a, b, c simultaneously.


#4Nested Tuple UnpackingEasy
unpackingnested structures

Python supports nested structural unpacking, matching the shape of the left side to the right.

Write flatten_pairs that takes ((a, b), (c, d)) and returns (a, b, c, d).

Python
def flatten_pairs(pair_of_pairs):
    """Unpack nested pairs into a flat 4-tuple."""
    (a, b), (c, d) = pair_of_pairs
    return (a, b, c, d)

print(flatten_pairs(((1, 2), (3, 4))))
print(flatten_pairs((("a", "b"), ("c", "d"))))
print(flatten_pairs(((0.0, 1.0), (2.0, 3.0))))
Solution
def flatten_pairs(pair_of_pairs):
(a, b), (c, d) = pair_of_pairs
return (a, b, c, d)

print(flatten_pairs(((1, 2), (3, 4)))) # (1, 2, 3, 4)
print(flatten_pairs((("a", "b"), ("c", "d")))) # ('a', 'b', 'c', 'd')
print(flatten_pairs(((0.0, 1.0), (2.0, 3.0)))) # (0.0, 1.0, 2.0, 3.0)

Key insight: Nested unpacking mirrors the structure. The left side (a, b), (c, d) tells Python to expect a 2-element iterable where each element is itself a 2-element iterable. If the shapes do not match, you get a ValueError. This pattern is commonly used when iterating over enumerate(zip(...)) results.

def flatten_pairs(pair_of_pairs):
  """Unpack a tuple of two pairs into four individual values.

  Args:
      pair_of_pairs: ((a, b), (c, d))

  Returns:
      tuple: (a, b, c, d) — all four values flattened
  """
  # TODO: use nested unpacking in a single statement
  pass
Expected Output
(1, 2, 3, 4)
('a', 'b', 'c', 'd')
(0.0, 1.0, 2.0, 3.0)
Hints

Hint 1: Python supports nested unpacking: (a, b), (c, d) = pair_of_pairs

Hint 2: The structure on the left must mirror the structure on the right.

Hint 3: This works in for loops too: for (x, y), (w, z) in list_of_pair_pairs.


#5Hashability CheckerMedium
hashabilityimmutabilityrecursion

A tuple is hashable only when every element — recursively — is also hashable. Write a function that checks deep hashability without relying on hash() raising an exception.

Python
def is_deeply_hashable(obj):
    """Check if an object is deeply hashable (recursively)."""
    if isinstance(obj, (list, dict, set)):
        return False
    if isinstance(obj, tuple):
        return all(is_deeply_hashable(item) for item in obj)
    if isinstance(obj, frozenset):
        return all(is_deeply_hashable(item) for item in obj)
    try:
        hash(obj)
        return True
    except TypeError:
        return False

print(is_deeply_hashable((1, 2, 3)))
print(is_deeply_hashable(((1, 2), (3, 4))))
print(is_deeply_hashable((1, [2, 3])))
print(is_deeply_hashable((1, "hello", 3.14, None, True)))
print(is_deeply_hashable(((1, (2, [3])),)))
print(is_deeply_hashable({"a": 1}))
print(is_deeply_hashable(frozenset({1, 2, 3})))
Solution
def is_deeply_hashable(obj):
"""Check if an object is deeply hashable (recursively)."""
# Mutable containers are never hashable
if isinstance(obj, (list, dict, set)):
return False
# Tuples: hashable only if every element is deeply hashable
if isinstance(obj, tuple):
return all(is_deeply_hashable(item) for item in obj)
# Frozensets: hashable only if every element is deeply hashable
if isinstance(obj, frozenset):
return all(is_deeply_hashable(item) for item in obj)
# Everything else: try hash()
try:
hash(obj)
return True
except TypeError:
return False

print(is_deeply_hashable((1, 2, 3))) # True
print(is_deeply_hashable(((1, 2), (3, 4)))) # True
print(is_deeply_hashable((1, [2, 3]))) # False — list inside
print(is_deeply_hashable((1, "hello", 3.14, None, True)))# True
print(is_deeply_hashable(((1, (2, [3])),))) # False — list nested 3 deep
print(is_deeply_hashable({"a": 1})) # False — dict
print(is_deeply_hashable(frozenset({1, 2, 3}))) # True

Key insight: Python's hash() on a tuple already does this check internally — it calls hash() on each element and propagates TypeError. But building it yourself reveals the recursive nature: hashability is a property of the entire object graph, not just the top-level container. This is exactly why hash((1, (2, [3]))) fails even though the outer tuple and inner tuple are both tuples.

def is_deeply_hashable(obj):
  """Check if an object is deeply hashable (recursively).

  Returns True only if the object AND all nested contents
  are hashable. This means:
    - Tuples: hashable only if ALL elements are deeply hashable
    - Lists, dicts, sets: always return False
    - Frozensets: hashable only if all elements are deeply hashable
    - Scalars (int, float, str, bool, None): always True

  Args:
      obj: any Python object

  Returns:
      bool: True if deeply hashable, False otherwise
  """
  # TODO: implement recursive hashability check
  pass
Expected Output
True
True
False
True
False
False
True
Hints

Hint 1: Check for tuple and frozenset first — they need recursive checking of all elements.

Hint 2: Lists, dicts, and sets are never hashable — return False immediately.

Hint 3: For everything else, try hash(obj) in a try/except TypeError block.

Hint 4: A tuple containing a list at ANY depth is not hashable: ((1, [2]),) fails.


#6Tuple as Composite Dict KeyMedium
dict keyhashabilitycaching

Tuples are hashable (when their contents are), which makes them ideal as composite dictionary keys. This is the canonical pattern for 2D grid indexing.

Build a grid cache and a neighbor lookup function using (row, col) tuple keys.

Python
def build_grid_cache(rows, cols, compute_fn):
    """Build a 2D cache using (row, col) tuple keys."""
    return {(r, c): compute_fn(r, c) for r in range(rows) for c in range(cols)}

def lookup_neighbors(cache, row, col, max_row, max_col):
    """Look up 4-directional neighbors of (row, col)."""
    directions = [("up", -1, 0), ("down", 1, 0), ("left", 0, -1), ("right", 0, 1)]
    result = []
    for name, dr, dc in directions:
        nr, nc = row + dr, col + dc
        if 0 <= nr < max_row and 0 <= nc < max_col:
            result.append((name, cache[(nr, nc)]))
    return result

cache = build_grid_cache(3, 4, lambda r, c: r + c)
print(f"cache[(0,0)] = {cache[(0,0)]}, cache[(2,3)] = {cache[(2,3)]}")
print(f"Neighbors of (1,1): {lookup_neighbors(cache, 1, 1, 3, 4)}")
print(f"Neighbors of (0,0): {lookup_neighbors(cache, 0, 0, 3, 4)}")
Solution
def build_grid_cache(rows, cols, compute_fn):
return {(r, c): compute_fn(r, c) for r in range(rows) for c in range(cols)}

def lookup_neighbors(cache, row, col, max_row, max_col):
directions = [("up", -1, 0), ("down", 1, 0), ("left", 0, -1), ("right", 0, 1)]
result = []
for name, dr, dc in directions:
nr, nc = row + dr, col + dc
if 0 <= nr < max_row and 0 <= nc < max_col:
result.append((name, cache[(nr, nc)]))
return result

cache = build_grid_cache(3, 4, lambda r, c: r + c)
print(f"cache[(0,0)] = {cache[(0,0)]}, cache[(2,3)] = {cache[(2,3)]}")
# cache[(0,0)] = 0, cache[(2,3)] = 5

print(f"Neighbors of (1,1): {lookup_neighbors(cache, 1, 1, 3, 4)}")
# Neighbors of (1,1): [('up', 1), ('down', 3), ('left', 1), ('right', 3)]

print(f"Neighbors of (0,0): {lookup_neighbors(cache, 0, 0, 3, 4)}")
# Neighbors of (0,0): [('down', 1), ('right', 1)]

Key insight: Lists cannot be dict keys because they are unhashable. Tuples of integers are hashable, making (row, col) the standard Python pattern for 2D coordinate keys. This same pattern appears in graph algorithms, game boards, image processing, and sparse matrix representations. The O(1) dict lookup with tuple keys replaces O(n) scanning through a list of coordinate pairs.

def build_grid_cache(rows, cols, compute_fn):
  """Build a 2D cache using (row, col) tuple keys.

  Args:
      rows: number of rows
      cols: number of columns
      compute_fn: callable(row, col) -> value

  Returns:
      dict: mapping (row, col) -> computed value
  """
  # TODO: build dict with tuple keys
  pass


def lookup_neighbors(cache, row, col, max_row, max_col):
  """Look up the 4-directional neighbors of (row, col) in the cache.

  Args:
      cache: dict with (row, col) tuple keys
      row, col: center position
      max_row, max_col: grid dimensions

  Returns:
      list[tuple]: [(direction, value), ...] for valid neighbors
          direction is one of "up", "down", "left", "right"
  """
  # TODO: check each neighbor, return list of (direction, value) tuples
  pass
Expected Output
cache[(0,0)] = 0, cache[(2,3)] = 5
Neighbors of (1,1): [('up', 1), ('down', 3), ('left', 1), ('right', 3)]
Neighbors of (0,0): [('down', 1), ('right', 1)]
Hints

Hint 1: Use (row, col) tuples as dictionary keys — they are hashable because both elements are ints.

Hint 2: For neighbors, define offsets as tuples: (-1,0) for up, (1,0) for down, etc.

Hint 3: Check bounds before looking up: 0 <= new_row < max_row.


#7Named Tuple Record SystemMedium
named tupletyping.NamedTupledata modeling

typing.NamedTuple creates immutable records with named fields, type annotations, and full tuple compatibility.

Define a Student named tuple and a summarize_class function.

Python
from typing import NamedTuple

class Student(NamedTuple):
    name: str
    grade: int
    gpa: float = 0.0

def summarize_class(students):
    """Return (top_student_name, average_gpa, count)."""
    top = max(students, key=lambda s: s.gpa)
    avg = round(sum(s.gpa for s in students) / len(students), 2)
    return (top.name, avg, len(students))

# Test it
s = Student("Charlie", 11, 3.9)
print(s)
print(s.name)
print(s[1])
print(isinstance(s, tuple))

roster = [
    Student("Alice", 10, 3.5),
    Student("Bob", 10, 3.3),
    Student("Charlie", 11, 3.9),
]
top_name, avg_gpa, count = summarize_class(roster)
print(f"Top: {top_name}, Avg GPA: {avg_gpa}, Count: {count}")
Solution
from typing import NamedTuple

class Student(NamedTuple):
name: str
grade: int
gpa: float = 0.0

def summarize_class(students):
top = max(students, key=lambda s: s.gpa)
avg = round(sum(s.gpa for s in students) / len(students), 2)
return (top.name, avg, len(students))

s = Student("Charlie", 11, 3.9)
print(s) # Student(name='Charlie', grade=11, gpa=3.9)
print(s.name) # Charlie
print(s[1]) # 11
print(isinstance(s, tuple)) # True

roster = [
Student("Alice", 10, 3.5),
Student("Bob", 10, 3.3),
Student("Charlie", 11, 3.9),
]
top_name, avg_gpa, count = summarize_class(roster)
print(f"Top: {top_name}, Avg GPA: {avg_gpa}, Count: {count}")
# Top: Charlie, Avg GPA: 3.57, Count: 3

Key insight: typing.NamedTuple gives you type annotations (mypy knows s.name is str), default values, and method support — all while remaining a real tuple under the hood. Named access via s.name is a property descriptor that calls self[0] with zero extra memory per instance. Use _replace() to create modified copies since the original is immutable.

from typing import NamedTuple

# TODO: Define a Student NamedTuple with:
#   name: str
#   grade: int
#   gpa: float (default 0.0)

# TODO: Define a function summarize_class that:
#   - Takes a list of Student records
#   - Returns a tuple of (top_student_name, average_gpa, count)
#   - top_student = highest GPA
#   - average_gpa = mean of all GPAs, rounded to 2 decimals
Expected Output
Student(name='Charlie', grade=11, gpa=3.9)
Charlie
11
True
Top: Charlie, Avg GPA: 3.57, Count: 3
Hints

Hint 1: Use class syntax: class Student(NamedTuple): with typed fields.

Hint 2: Default values go after the type annotation: gpa: float = 0.0

Hint 3: Named tuples are real tuples — isinstance(s, tuple) is True and you can unpack them.

Hint 4: Use max() with a key function to find the top student by GPA.


#8Immutability Mutation PuzzleMedium
immutabilityshallow vs deepTypeError

This is Python's most infamous tuple gotcha. t[0] += [99] both raises TypeError AND mutates the list. Explain why and prove it.

Python
def mutate_experiment(t):
    """Demonstrate the += mutation puzzle."""
    original_len = len(t[0])
    did_raise = False
    try:
        t[0] += [99]
    except TypeError:
        did_raise = True
    was_mutated = len(t[0]) > original_len
    return (did_raise, was_mutated, t[0])

print(mutate_experiment(([1, 2, 3],)))
print(mutate_experiment((['a', 'b'],)))
Solution
def mutate_experiment(t):
original_len = len(t[0])
did_raise = False
try:
t[0] += [99]
except TypeError:
did_raise = True
was_mutated = len(t[0]) > original_len
return (did_raise, was_mutated, t[0])

print(mutate_experiment(([1, 2, 3],))) # (True, True, [1, 2, 3, 99])
print(mutate_experiment((['a', 'b'],))) # (True, True, ['a', 'b', 99])

Why both happen: t[0] += [99] compiles to three bytecode operations:

  1. Load t[0] (the list object) -- succeeds
  2. Call list.__iadd__([99]) which calls extend([99]) in-place on the list -- succeeds, list is now mutated
  3. Store the result back to t[0] via tuple.__setitem__ -- raises TypeError

Step 2 mutates the list before step 3 tries (and fails) to reassign the tuple slot. The lesson: never use += on mutable objects stored inside a tuple. Use t[0].extend([99]) directly instead — it mutates the list without triggering the assignment.

def mutate_experiment(t):
  """Demonstrate the += mutation puzzle on tuples containing lists.

  Given a tuple t containing a list at index 0:
    1. Try t[0] += [99] inside a try/except
    2. Return a tuple of:
       - did_raise: bool (True if TypeError was raised)
       - was_mutated: bool (True if the list at t[0] changed)
       - final_value: the current value of t[0]

  Returns:
      tuple: (did_raise, was_mutated, final_value)
  """
  # TODO: capture the original length, try the +=, check results
  pass
Expected Output
(True, True, [1, 2, 3, 99])
(True, True, ['a', 'b', 99])
Hints

Hint 1: Save len(t[0]) before the operation to detect mutation afterward.

Hint 2: += on a list calls list.__iadd__ (in-place extend) THEN tries to assign back to t[0].

Hint 3: The mutation happens BEFORE the assignment — so the list changes even though TypeError is raised.

Hint 4: This is Python's most infamous gotcha with mutable objects inside tuples.


#9Tuple-Based Function DispatchMedium
tuple as keydispatchpattern

Tuples as dict keys enable clean multi-dimensional dispatch tables — a pattern used in state machines, parsers, and command routers.

Python
def build_dispatch_table():
    return {
        ("validate", "email"):  lambda: "Checking email format",
        ("validate", "phone"):  lambda: "Checking phone format",
        ("sanitize", "email"):  lambda: "Lowercasing email",
        ("sanitize", "phone"):  lambda: "Stripping phone non-digits",
        ("normalize", "email"): lambda: "Trimming email whitespace",
        ("normalize", "phone"): lambda: "Adding country code to phone",
    }

def dispatch(table, operation, type_name):
    key = (operation, type_name)
    handler = table.get(key)
    if handler is None:
        return f"No handler for ({operation}, {type_name})"
    return handler()

table = build_dispatch_table()
print(dispatch(table, "validate", "email"))
print(dispatch(table, "sanitize", "phone"))
print(dispatch(table, "normalize", "phone"))
print(dispatch(table, "delete", "email"))
Solution
def build_dispatch_table():
return {
("validate", "email"): lambda: "Checking email format",
("validate", "phone"): lambda: "Checking phone format",
("sanitize", "email"): lambda: "Lowercasing email",
("sanitize", "phone"): lambda: "Stripping phone non-digits",
("normalize", "email"): lambda: "Trimming email whitespace",
("normalize", "phone"): lambda: "Adding country code to phone",
}

def dispatch(table, operation, type_name):
key = (operation, type_name)
handler = table.get(key)
if handler is None:
return f"No handler for ({operation}, {type_name})"
return handler()

table = build_dispatch_table()
print(dispatch(table, "validate", "email")) # Checking email format
print(dispatch(table, "sanitize", "phone")) # Stripping phone non-digits
print(dispatch(table, "normalize", "phone")) # Adding country code to phone
print(dispatch(table, "delete", "email")) # No handler for (delete, email)

Key insight: This replaces nested if/elif chains with O(1) dict lookups using composite tuple keys. The pattern scales cleanly: adding a new operation-type combination is one dict entry, not a new branch in a conditional chain. This is the foundation of state machine implementations where (current_state, event) tuples map to (next_state, action) handlers.

def build_dispatch_table():
  """Build a dispatch table mapping (operation, type_name) tuples to handler functions.

  The table should handle these combinations:
      ("validate", "email")  -> returns "Checking email format"
      ("validate", "phone")  -> returns "Checking phone format"
      ("sanitize", "email")  -> returns "Lowercasing email"
      ("sanitize", "phone")  -> returns "Stripping phone non-digits"
      ("normalize", "email") -> returns "Trimming email whitespace"
      ("normalize", "phone") -> returns "Adding country code to phone"

  Returns:
      dict: mapping (str, str) -> callable that returns a string
  """
  # TODO: build and return the dispatch dict
  pass


def dispatch(table, operation, type_name):
  """Look up and execute the handler for (operation, type_name).

  Returns:
      str: handler result, or "No handler for (op, type)" if not found
  """
  # TODO: look up the tuple key and call the handler
  pass
Expected Output
Checking email format
Stripping phone non-digits
Adding country code to phone
No handler for (delete, email)
Hints

Hint 1: Each dict key is a tuple of two strings: (operation, type_name).

Hint 2: Values can be lambda functions that return the appropriate string.

Hint 3: Use dict.get() with a default to handle missing combinations gracefully.


#10Deep Freeze: Making Nested Structures HashableHard
recursionhashabilitydeep immutability

To use complex nested structures as dict keys or in sets, you need to convert them to deeply immutable equivalents. Write deep_freeze that recursively converts any structure to its hashable counterpart.

Python
def deep_freeze(obj):
    """Recursively convert nested structure to hashable equivalent."""
    if isinstance(obj, list):
        return tuple(deep_freeze(item) for item in obj)
    elif isinstance(obj, dict):
        return tuple(sorted(
            (deep_freeze(k), deep_freeze(v)) for k, v in obj.items()
        ))
    elif isinstance(obj, set):
        return frozenset(deep_freeze(item) for item in obj)
    elif isinstance(obj, tuple):
        return tuple(deep_freeze(item) for item in obj)
    else:
        return obj

print(deep_freeze([1, 2, [3, 4]]))
print(deep_freeze({"a": 1, "b": [2, 3]}))
print(deep_freeze((1, {"x": 2}, {3, 4})))

# Verify it is actually hashable
frozen = deep_freeze({"users": [1, 2], "tags": {"admin", "viewer"}})
print(isinstance(hash(frozen), int))
Solution
def deep_freeze(obj):
if isinstance(obj, list):
return tuple(deep_freeze(item) for item in obj)
elif isinstance(obj, dict):
return tuple(sorted(
(deep_freeze(k), deep_freeze(v)) for k, v in obj.items()
))
elif isinstance(obj, set):
return frozenset(deep_freeze(item) for item in obj)
elif isinstance(obj, tuple):
return tuple(deep_freeze(item) for item in obj)
else:
return obj

print(deep_freeze([1, 2, [3, 4]]))
# (1, 2, (3, 4))

print(deep_freeze({"a": 1, "b": [2, 3]}))
# (('a', 1), ('b', (2, 3)))

print(deep_freeze((1, {"x": 2}, {3, 4})))
# (1, (('x', 2),), frozenset({3, 4}))

frozen = deep_freeze({"users": [1, 2], "tags": {"admin", "viewer"}})
print(isinstance(hash(frozen), int)) # True

Key insight: Deep freezing is the bridge between Python's mutable-by-default world and the hashability requirement of dicts/sets. This exact pattern appears in production when you need to cache function results that receive complex nested arguments (like JSON payloads). The functools.lru_cache decorator fails on dict/list arguments — deep_freeze is how you solve it. Note that dict keys must also be frozen, and sorting ensures deep_freeze produces the same output regardless of dict insertion order.

def deep_freeze(obj):
  """Recursively convert a nested structure to its hashable equivalent.

  Conversion rules:
      list  -> tuple (recursively freeze contents)
      dict  -> tuple of sorted (frozen_key, frozen_value) pairs
      set   -> frozenset (recursively freeze contents)
      tuple -> tuple (but recursively freeze each element)
      other -> return as-is

  Args:
      obj: any Python object (may contain nested lists, dicts, sets)

  Returns:
      A deeply hashable version of the input
  """
  # TODO: implement recursive deep freeze
  pass
Expected Output
(1, 2, (3, 4))
(('a', 1), ('b', (2, 3)))
(1, (('x', 2),), frozenset({3, 4}))
True
Hints

Hint 1: Handle each mutable type: list -> tuple, dict -> tuple of sorted pairs, set -> frozenset.

Hint 2: Recurse into every container — even tuples might contain lists inside them.

Hint 3: For dicts, freeze both keys and values, then sort for consistent ordering.

Hint 4: Test with hash() on the result to verify it is truly hashable.


#11Memoization Decorator with Tuple Key ConversionHard
decoratorcachinghashabilityclosures

Build a memoization decorator that handles list and dict arguments by converting them to hashable tuple keys internally.

Python
import functools

def make_hashable(obj):
    if isinstance(obj, list):
        return tuple(make_hashable(item) for item in obj)
    elif isinstance(obj, dict):
        return tuple(sorted((make_hashable(k), make_hashable(v)) for k, v in obj.items()))
    elif isinstance(obj, set):
        return frozenset(make_hashable(item) for item in obj)
    elif isinstance(obj, tuple):
        return tuple(make_hashable(item) for item in obj)
    else:
        return obj

def memo(func):
    cache = {}
    hits = 0
    misses = 0

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        nonlocal hits, misses
        h_args = tuple(make_hashable(a) for a in args)
        h_kwargs = tuple(sorted((k, make_hashable(v)) for k, v in kwargs.items()))
        key = (h_args, h_kwargs)
        if key in cache:
            hits += 1
            return cache[key]
        misses += 1
        result = func(*args, **kwargs)
        cache[key] = result
        return result

    def cache_info():
        return {"hits": hits, "misses": misses, "size": len(cache)}

    def cache_clear():
        nonlocal hits, misses
        cache.clear()
        hits = misses = 0

    wrapper.cache_info = cache_info
    wrapper.cache_clear = cache_clear
    return wrapper

@memo
def filter_above(data, threshold):
    print("Computing...")
    return [x for x in data if x > threshold]

r1 = filter_above([1.0, 2.5, 3.0], threshold=2.0)
print(r1)
r2 = filter_above([1.0, 2.5, 3.0], threshold=2.0)  # cache hit
print(r2)
info = filter_above.cache_info()
print(f"hits={info['hits']}, misses={info['misses']}, size={info['size']}")

r3 = filter_above([1.0, 2.5, 3.0], threshold=1.5)  # different threshold
info = filter_above.cache_info()
print(f"hits={info['hits']}, misses={info['misses']}, size={info['size']}")
Solution
import functools

def make_hashable(obj):
if isinstance(obj, list):
return tuple(make_hashable(item) for item in obj)
elif isinstance(obj, dict):
return tuple(sorted((make_hashable(k), make_hashable(v)) for k, v in obj.items()))
elif isinstance(obj, set):
return frozenset(make_hashable(item) for item in obj)
elif isinstance(obj, tuple):
return tuple(make_hashable(item) for item in obj)
else:
return obj

def memo(func):
cache = {}
hits = 0
misses = 0

@functools.wraps(func)
def wrapper(*args, **kwargs):
nonlocal hits, misses
h_args = tuple(make_hashable(a) for a in args)
h_kwargs = tuple(sorted((k, make_hashable(v)) for k, v in kwargs.items()))
key = (h_args, h_kwargs)
if key in cache:
hits += 1
return cache[key]
misses += 1
result = func(*args, **kwargs)
cache[key] = result
return result

def cache_info():
return {"hits": hits, "misses": misses, "size": len(cache)}

def cache_clear():
nonlocal hits, misses
cache.clear()
hits = misses = 0

wrapper.cache_info = cache_info
wrapper.cache_clear = cache_clear
return wrapper

@memo
def filter_above(data, threshold):
print("Computing...")
return [x for x in data if x > threshold]

r1 = filter_above([1.0, 2.5, 3.0], threshold=2.0) # Computing...
print(r1) # [2.5, 3.0]
r2 = filter_above([1.0, 2.5, 3.0], threshold=2.0) # cache hit, no print
print(r2) # [2.5, 3.0]
info = filter_above.cache_info()
print(f"hits={info['hits']}, misses={info['misses']}, size={info['size']}")
# hits=1, misses=1, size=1

r3 = filter_above([1.0, 2.5, 3.0], threshold=1.5) # Computing... new key
info = filter_above.cache_info()
print(f"hits={info['hits']}, misses={info['misses']}, size={info['size']}")
# hits=1, misses=2, size=2

Key insight: functools.lru_cache requires all arguments to be hashable, which fails for lists and dicts. This decorator solves that by converting arguments to hashable equivalents before building the cache key. The nonlocal keyword allows the inner wrapper to mutate the hits and misses counters from the enclosing scope. Sorting kwargs ensures keyword argument order does not affect the cache key. This pattern is used in production systems for caching expensive computations that receive JSON-like arguments.

import functools

def make_hashable(obj):
  """Convert an object to a hashable equivalent for cache keys.

  Rules:
      list -> tuple (recursively)
      dict -> tuple of sorted (key, value) pairs (recursively)
      set  -> frozenset (recursively)
      other -> return as-is
  """
  # TODO: implement (similar to deep_freeze)
  pass


def memo(func):
  """Memoization decorator that handles unhashable arguments.

  - Converts all args/kwargs to hashable equivalents before keying
  - Attaches cache_info() method returning dict with hits, misses, size
  - Attaches cache_clear() method

  Returns:
      wrapped function with caching
  """
  # TODO: implement decorator with cache dict and hit/miss counters
  pass
Expected Output
Computing...
[2.5, 3.0]
[2.5, 3.0]
hits=1, misses=1, size=1
Computing...
hits=1, misses=2, size=2
Hints

Hint 1: Use deep_freeze / make_hashable on all args and kwargs before building the cache key.

Hint 2: The cache key should be a tuple: (frozen_args, frozen_kwargs).

Hint 3: Sort kwargs by key name so f(a=1, b=2) and f(b=2, a=1) produce the same key.

Hint 4: Use functools.wraps to preserve the original function metadata.

Hint 5: Use nonlocal to mutate the hits/misses counters from inside the wrapper.


#12Tuple Memory and Performance BenchmarkHard
performancesys.getsizeoftimeitmemory

Measure the concrete memory and performance differences between tuples and lists. Demonstrate constant folding with bytecode inspection.

Python
import sys
import dis

def compare_memory(n):
    lst = list(range(n))
    tup = tuple(range(n))
    lb = sys.getsizeof(lst)
    tb = sys.getsizeof(tup)
    saved = lb - tb
    pct = round(100 * saved / lb, 1)
    return (lb, tb, saved, pct)

def demonstrate_constant_folding():
    tuple_instrs = list(dis.get_instructions(compile("(1, 2, 3)", "<s>", "eval")))
    list_instrs = list(dis.get_instructions(compile("[1, 2, 3]", "<s>", "eval")))
    tuple_has_lc = any(i.opname == "LOAD_CONST" and isinstance(i.argval, tuple) for i in tuple_instrs)
    list_has_bl = any(i.opname == "BUILD_LIST" for i in list_instrs)
    return (tuple_has_lc, list_has_bl)

lb, tb, saved, pct = compare_memory(100)
print(f"n=100: list={lb}B, tuple={tb}B, saved={saved}B ({pct}%)")

lb, tb, saved, pct = compare_memory(1000)
print(f"n=1000: list={lb}B, tuple={tb}B, saved={saved}B ({pct}%)")

tlc, lbl = demonstrate_constant_folding()
print(f"Tuple literal uses LOAD_CONST: {tlc}")
print(f"List literal uses BUILD_LIST: {lbl}")
Solution
import sys
import dis

def compare_memory(n):
lst = list(range(n))
tup = tuple(range(n))
lb = sys.getsizeof(lst)
tb = sys.getsizeof(tup)
saved = lb - tb
pct = round(100 * saved / lb, 1)
return (lb, tb, saved, pct)

def demonstrate_constant_folding():
tuple_code = compile("(1, 2, 3)", "<s>", "eval")
list_code = compile("[1, 2, 3]", "<s>", "eval")

tuple_instrs = list(dis.get_instructions(tuple_code))
list_instrs = list(dis.get_instructions(list_code))

# Tuple of constants becomes a single LOAD_CONST with the tuple as argval
tuple_has_lc = any(
i.opname == "LOAD_CONST" and isinstance(i.argval, tuple)
for i in tuple_instrs
)
# List always requires BUILD_LIST at runtime
list_has_bl = any(i.opname == "BUILD_LIST" for i in list_instrs)

return (tuple_has_lc, list_has_bl)

lb, tb, saved, pct = compare_memory(100)
print(f"n=100: list={lb}B, tuple={tb}B, saved={saved}B ({pct}%)")

lb, tb, saved, pct = compare_memory(1000)
print(f"n=1000: list={lb}B, tuple={tb}B, saved={saved}B ({pct}%)")

tlc, lbl = demonstrate_constant_folding()
print(f"Tuple literal uses LOAD_CONST: {tlc}")
print(f"List literal uses BUILD_LIST: {lbl}")

Key insight: Three distinct performance advantages of tuples:

  1. Memory: Tuples lack the allocated capacity field and the separate pointer-array allocation. Savings are 16+ bytes per container, which matters at scale (10M coordinate pairs saves ~240 MB).
  2. Creation speed: Constant tuple literals are folded at compile time into a single LOAD_CONST bytecode — typically 4-8x faster than BUILD_LIST. Non-constant tuples (tuple(range(n))) are still slightly faster due to one fewer allocation.
  3. Cache locality: The tuple's pointer array is inline in the struct (flexible array member), meaning one fewer pointer dereference and better CPU cache behavior for small tuples.

Note: sys.getsizeof measures only the container overhead, not the contained objects. Both a list and tuple of the same integers reference the same int objects — the savings come from the container struct itself.

import sys

def compare_memory(n):
  """Compare memory usage of tuple vs list for n integers.

  Args:
      n: number of elements

  Returns:
      tuple: (list_bytes, tuple_bytes, savings_bytes, savings_pct)
          savings_pct is a float rounded to 1 decimal
  """
  # TODO: create list and tuple of range(n), measure with sys.getsizeof
  pass


def compare_creation_speed(n, iterations=1_000_000):
  """Compare creation speed of tuple() vs list() from a range.

  Args:
      n: number of elements
      iterations: how many times to create

  Returns:
      tuple: (list_time, tuple_time, speedup_ratio)
          speedup_ratio = list_time / tuple_time, rounded to 1 decimal
  """
  # TODO: use timeit to measure tuple(range(n)) vs list(range(n))
  pass


def demonstrate_constant_folding():
  """Show that constant tuple literals use LOAD_CONST bytecode.

  Use the dis module to get bytecode for:
      compile("(1, 2, 3)", "<s>", "eval")
      compile("[1, 2, 3]", "<s>", "eval")

  Returns:
      tuple: (tuple_has_load_const, list_has_build_list)
          Both are booleans.
  """
  # TODO: use dis.get_instructions() to check bytecode operation names
  pass
Expected Output
n=100: list=920B, tuple=856B, saved=64B (7.0%)
n=1000: list=8856B, tuple=8048B, saved=808B (9.1%)
Tuple literal uses LOAD_CONST: True
List literal uses BUILD_LIST: True
Hints

Hint 1: sys.getsizeof measures the shallow size of the container, not the contained objects.

Hint 2: Use timeit.timeit with a setup string to avoid measuring range() creation time.

Hint 3: dis.get_instructions() returns Instruction objects with an opname attribute.

Hint 4: Constant folding only works for tuples of literals — tuple(range(n)) is NOT constant-folded.

© 2026 EngineersOfAI. All rights reserved.