Cambridge 9618 · Paper 4 · Python

The Complete Practical
Python Guide

Chapters 19 & 20 — everything you write in the exam room

SearchingSorting Stack · Queue · Linked List Binary TreeRecursion OOP & Inheritance File I/OExceptions Big-O
Paper 4 — What to expect
100% practical Python coding on a computer, 2.5 hours, 75 marks. No theory questions, no pseudocode. You write real working programs. Every section of this guide maps directly to something you can be asked to implement.
01Searching Algorithms
🔍 Linear Search

Checks every element in order until found or exhausted. Works on any list — sorted or unsorted. Simple but slow for large data: O(n).

linear_search.py
def linear_search(data, target):
    # Check every element one by one
    for index, value in enumerate(data):
        if value == target:
            return index    # found — return position
    return -1             # -1 means not found

# ── Example usage ─────────────────────────────
scores = [45, 72, 13, 88, 56, 34]
pos = linear_search(scores, 88)

if pos != -1:
    print(f"Found at index {pos}")   # Found at index 3
else:
    print("Not found")
How it works
enumerate gives both the index and value together. The -1 sentinel is the standard Python convention for “not found” — always check for it after calling.
🎯 Binary Search

Repeatedly halves the search space by comparing the middle element. List must be sorted first. Dramatically faster than linear search on large data: O(log n).

▸ BINARY SEARCH — step through the algorithm · target = 55
Click Step to begin
Search space
Mid pointer
Found!
binary_search.py
# ── Iterative version (preferred in exams) ─────
def binary_search(data, target):
    low  = 0
    high = len(data) - 1

    while low <= high:
        mid = (low + high) // 2          # integer division

        if   data[mid] == target: return mid
        elif data[mid] <  target: low  = mid + 1   # go right
        else:                      high = mid - 1   # go left
    return -1

# ── Recursive version ──────────────────────────
def binary_search_rec(data, target, low, high):
    if low > high: return -1           # base case: not found
    mid = (low + high) // 2
    if   data[mid] == target: return mid
    elif data[mid] <  target:
        return binary_search_rec(data, target, mid+1, high)
    else:
        return binary_search_rec(data, target, low, mid-1)

# ── Usage ──────────────────────────────────────
nums = [10, 23, 35, 42, 55, 67, 80, 91]
print(binary_search(nums, 55))  # 4
print(binary_search(nums, 99))  # -1 (not found)
AlgorithmSorted?BestWorstUse when
Linear SearchNot neededO(1)O(n)Small / unsorted data
Binary Search✅ RequiredO(1)O(log n)Large sorted data
02Sorting Algorithms
🫧 Bubble Sort

Repeatedly compares adjacent pairs and swaps if out of order. After each full pass, the largest unsorted element “bubbles” to its correct position at the end.

▸ BUBBLE SORT — step through pass by pass
Ready
Comparing
Swapping
Sorted
bubble_sort.py
def bubble_sort(data):
    n = len(data)
    for pass_num in range(n - 1):
        swapped = False                   # early-exit flag

        for i in range(n - 1 - pass_num): # window shrinks each pass
            if data[i] > data[i + 1]:
                # Python tuple swap — no temp variable needed
                data[i], data[i + 1] = data[i + 1], data[i]
                swapped = True

        if not swapped:                   # no swaps = already sorted
            break
    return data

print(bubble_sort([64, 34, 25, 12, 22, 11, 90]))
# [11, 12, 22, 25, 34, 64, 90]
The swapped flag
Without the swapped flag, bubble sort always does n-1 passes even if the list sorted itself early. Adding it makes best-case O(n) for already-sorted input.
📥 Insertion Sort

Builds a sorted sub-list left-to-right. Each element is taken and shifted into the correct position within the already-sorted portion. Like sorting playing cards in your hand.

insertion_sort.py
def insertion_sort(data):
    for i in range(1, len(data)):
        key = data[i]     # element to place correctly
        j   = i - 1       # look backwards into sorted part

        # Shift elements right while they're larger than key
        while j >= 0 and data[j] > key:
            data[j + 1] = data[j]
            j -= 1

        data[j + 1] = key  # drop key into the gap
    return data

# Trace for [5, 3, 8, 1, 2]:
# i=1 key=3: [3, 5, 8, 1, 2]
# i=2 key=8: [3, 5, 8, 1, 2]  (no shift needed)
# i=3 key=1: [1, 3, 5, 8, 2]
# i=4 key=2: [1, 2, 3, 5, 8]  ✓
When to use which
Both are O(n²) worst case. Use bubble sort when simplicity matters. Use insertion sort on nearly-sorted data — it’s faster in practice and is stable (equal elements keep their original order).
03Stack, Queue & Linked List
📚 Stack — LIFO

Last In, First Out. Push adds to the top, Pop removes from the top. Used in: undo history, browser back button, recursion call stack, RPN evaluation.

▸ STACK — push/pop items (top is left)
← TOP ————————————————— BOTTOM →
Enter a value and push it
stack.py
class Stack:
    def __init__(self, max_size=10):
        self._items    = []
        self._max_size = max_size

    def push(self, item):
        if self.is_full():
            raise OverflowError("Stack is full")
        self._items.append(item)

    def pop(self):
        if self.is_empty():
            raise IndexError("Stack is empty")
        return self._items.pop()     # removes AND returns top

    def peek(self):
        if self.is_empty():
            raise IndexError("Stack is empty")
        return self._items[-1]      # view top without removing

    def is_empty(self): return len(self._items) == 0
    def is_full(self):  return len(self._items) == self._max_size
    def size(self):     return len(self._items)

    def __str__(self):
        return f"Stack(top→{self._items[::-1]})"
rpn_calculator.py — practical use of stack
def rpn_evaluate(expression):
    """Evaluate Reverse Polish Notation using a stack"""
    s = Stack()
    for token in expression.split():
        if token.lstrip('-').isdigit():
            s.push(int(token))
        else:
            b, a = s.pop(), s.pop()
            if   token == '+': s.push(a + b)
            elif token == '-': s.push(a - b)
            elif token == '*': s.push(a * b)
            elif token == '/': s.push(a // b)
    return s.pop()

print(rpn_evaluate("3 4 + 5 *"))   # (3+4)*5 = 35
🚶 Queue — FIFO

First In, First Out. Enqueue adds to the rear, Dequeue removes from the front. Used in: print queues, task scheduling, BFS graph traversal.

▸ QUEUE — enqueue/dequeue items
← Dequeue from FRONT ————————————— Enqueue to REAR →
Enqueue/dequeue items
queue.py
from collections import deque   # O(1) front removal vs list's O(n)

class Queue:
    def __init__(self, max_size=10):
        self._items    = deque()
        self._max_size = max_size

    def enqueue(self, item):
        if self.is_full():
            raise OverflowError("Queue is full")
        self._items.append(item)       # add to rear

    def dequeue(self):
        if self.is_empty():
            raise IndexError("Queue is empty")
        return self._items.popleft()   # remove from front — O(1)

    def peek(self):     return self._items[0]
    def is_empty(self): return len(self._items) == 0
    def is_full(self):  return len(self._items) == self._max_size
    def size(self):     return len(self._items)
🔗 Linked List

Each Node holds data and a pointer to the next node. No fixed size. Efficient insertion/deletion — no shifting elements. Inefficient random access.

▸ LINKED LIST — append nodes / delete head
Add nodes to build the list
linked_list.py
class Node:
    def __init__(self, data):
        self.data = data
        self.next = None    # pointer to next node

class LinkedList:
    def __init__(self):
        self.head = None

    # Append to end
    def append(self, data):
        new_node = Node(data)
        if not self.head:
            self.head = new_node; return
        curr = self.head
        while curr.next:
            curr = curr.next
        curr.next = new_node

    # Insert at front — O(1)
    def insert_at_head(self, data):
        node      = Node(data)
        node.next = self.head
        self.head = node

    # Delete first match
    def delete(self, target):
        if not self.head: return
        if self.head.data == target:
            self.head = self.head.next; return
        curr = self.head
        while curr.next:
            if curr.next.data == target:
                curr.next = curr.next.next  # bypass the node
                return
            curr = curr.next

    # Search — returns True/False
    def find(self, target):
        curr = self.head
        while curr:
            if curr.data == target: return True
            curr = curr.next
        return False

    # Convert to Python list for printing
    def to_list(self):
        res, curr = [], self.head
        while curr:
            res.append(curr.data)
            curr = curr.next
        return res
04Binary Search Tree

Each node has at most two children. Left child is smaller, right child is larger. Searching, inserting, and deleting are all O(log n) on a balanced tree.

binary_tree.py
class TreeNode:
    def __init__(self, data):
        self.data  = data
        self.left  = None
        self.right = None

class BST:
    def __init__(self):
        self.root = None

    # ── Insert ────────────────────────────────────
    def insert(self, data):
        self.root = self._insert(self.root, data)

    def _insert(self, node, data):
        if not node:
            return TreeNode(data)              # base case: empty spot
        if data < node.data:
            node.left  = self._insert(node.left,  data)
        elif data > node.data:
            node.right = self._insert(node.right, data)
        return node

    # ── Search ────────────────────────────────────
    def search(self, data):
        return self._search(self.root, data)

    def _search(self, node, data):
        if not node:           return False
        if data == node.data:  return True
        if data <  node.data:  return self._search(node.left,  data)
        return self._search(node.right, data)

    # ── In-order: Left → Node → Right (sorted output)
    def inorder(self, node=None):
        if node is None: node = self.root
        if not node: return []
        return self.inorder(node.left) + [node.data] + self.inorder(node.right)

    # ── Pre-order: Node → Left → Right (copy/serialise)
    def preorder(self, node=None):
        if node is None: node = self.root
        if not node: return []
        return [node.data] + self.preorder(node.left) + self.preorder(node.right)

# ── Usage ─────────────────────────────────────
tree = BST()
for v in [50, 30, 70, 20, 40, 60, 80]:
    tree.insert(v)

print(tree.inorder())    # [20, 30, 40, 50, 60, 70, 80]
print(tree.search(40))   # True
print(tree.search(99))   # False
Three traversal orders
In-order (L→N→R) gives sorted output. Pre-order (N→L→R) copies/serialises the tree. Post-order (L→R→N) deletes or evaluates expression trees.
05Dictionary ADT

Maps keys → values. Python’s built-in dict is a hash table — O(1) average lookup, insert, delete. One of the most useful data structures in any exam question.

dictionary.py
# ── Creating ───────────────────────────────────
grades = {"Alice": 91, "Bob": 78, "Charlie": 85}
empty  = {}

# ── Insert / update ────────────────────────────
grades["Diana"] = 95         # insert new key
grades["Bob"]   = 82         # update existing key

# ── Safe lookup with .get() ────────────────────
print(grades.get("Alice", "Not found"))  # 91
print(grades.get("Zara",  "Not found"))  # Not found

# ── Membership test — use 'in' on keys ─────────
if "Alice" in grades:
    print("Alice is in the dict")

# ── Delete ─────────────────────────────────────
del grades["Bob"]

# ── Iterating ──────────────────────────────────
for name, score in grades.items():    # key-value pairs
    print(f"{name}: {score}")

for name  in grades.keys():           # just keys
    print(name)

for score in grades.values():         # just values
    print(score)

# ── Useful patterns ────────────────────────────
# Count frequency of items
text   = "hello world"
counts = {}
for ch in text:
    counts[ch] = counts.get(ch, 0) + 1

# Group items by category
students = [("Alice","A"), ("Bob","B"), ("Carol","A")]
by_grade = {}
for name, grade in students:
    by_grade.setdefault(grade, []).append(name)
# {'A': ['Alice', 'Carol'], 'B': ['Bob']}
06Big-O Notation

Big-O describes how time or memory grows relative to input size n. Drop constants, keep the dominant term.

NotationNamen=100 opsn=1000 opsExample
O(1)Constant11dict lookup, stack push
O(log n)Logarithmic710Binary search
O(n)Linear1001 000Linear search, one loop
O(n log n)Linearithmic70010 000Merge sort
O(n²)Quadratic10 0001 000 000Bubble/insertion sort
O(2ⁿ)Exponential10³⁰astronomicalNaive Fibonacci
complexity_patterns.py
# O(1) — single operation regardless of n
def get_first(lst): return lst[0]

# O(n) — one loop through all n items
def find_max(lst):
    m = lst[0]
    for x in lst: m = max(m, x)
    return m

# O(n²) — nested loops, both iterate over n
def has_duplicate_slow(lst):
    for i in range(len(lst)):
        for j in range(i + 1, len(lst)):
            if lst[i] == lst[j]: return True
    return False

# O(n) — same task, using a set instead
def has_duplicate_fast(lst):
    return len(lst) != len(set(lst))

# How to count: 3 nested loops → O(n³)
# Two independent loops → O(n + n) = O(n)
# Always drop constants: O(5n) → O(n)
07Recursion
Two essential ingredients
Base case — the condition that stops the recursion (returns without calling itself). Recursive case — the function calls itself with a simpler/smaller version of the problem. Missing either one causes infinite recursion → stack overflow.
factorial.py
def factorial(n):
    if n == 0 or n == 1:
        return 1                         # BASE CASE
    return n * factorial(n - 1)        # RECURSIVE CASE

# ── Call stack for factorial(4) ────────────────
# factorial(4) → 4 * factorial(3)   [frame 1]
#   factorial(3) → 3 * factorial(2) [frame 2]
#     factorial(2) → 2 * factorial(1) [frame 3]
#       factorial(1) → 1             [BASE CASE]
# Stack unwinds:
#   factorial(2) = 2 * 1 = 2
#   factorial(3) = 3 * 2 = 6
#   factorial(4) = 4 * 6 = 24  ✓
fibonacci.py
# Naive — O(2ⁿ), recalculates same values repeatedly
def fib(n):
    if n <= 1: return n
    return fib(n-1) + fib(n-2)

# Memoised — O(n), caches results to avoid repetition
def fib_memo(n, memo={}):
    if n in memo: return memo[n]
    if n <= 1:   return n
    memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
    return memo[n]

# Or use Python's built-in decorator:
from functools import lru_cache

@lru_cache(maxsize=None)
def fib_cached(n):
    if n <= 1: return n
    return fib_cached(n-1) + fib_cached(n-2)
recursion_patterns.py
# ── Sum of a list ──────────────────────────────
def rec_sum(lst):
    if not lst: return 0                   # base case: empty list
    return lst[0] + rec_sum(lst[1:])       # head + sum of tail

# ── Power (x to the n) ─────────────────────────
def power(x, n):
    if n == 0: return 1                    # base: x⁰ = 1
    return x * power(x, n - 1)

# ── Flatten nested list ────────────────────────
def flatten(nested):
    result = []
    for item in nested:
        if isinstance(item, list):
            result.extend(flatten(item))   # recurse on sub-list
        else:
            result.append(item)
    return result

print(flatten([1, [2, [3, 4], 5], 6]))   # [1,2,3,4,5,6]

# ── Tower of Hanoi ─────────────────────────────
def hanoi(n, source, target, aux):
    if n == 1:
        print(f"Move disk 1: {source}{target}")
        return
    hanoi(n-1, source, aux,    target)   # move n-1 to aux
    print(f"Move disk {n}: {source}{target}")
    hanoi(n-1, aux,    target, source)   # move n-1 from aux
Recursion vs iteration
Recursion is elegant and matches the structure of recursive problems (trees, graphs, divide-and-conquer). But each call adds a stack frame — Python’s default limit is ~1000 calls. For deep recursion, switch to an iterative approach with an explicit stack.
08Object-Oriented Programming
bank_account.py
class BankAccount:
    interest_rate = 0.03    # class variable — shared by all instances

    def __init__(self, owner, balance=0):
        self._owner   = owner      # _ prefix = private (convention)
        self._balance = balance

    # Getter via @property — access like an attribute
    @property
    def balance(self):
        return self._balance

    # Setter with validation
    @balance.setter
    def balance(self, value):
        if value < 0: raise ValueError("Balance cannot be negative")
        self._balance = value

    def deposit(self, amount):
        if amount <= 0: raise ValueError("Amount must be positive")
        self._balance += amount

    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount

    def apply_interest(self):
        self._balance *= (1 + BankAccount.interest_rate)

    def __str__(self):
        return f"{self._owner}: £{self._balance:.2f}"

# ── Usage ──────────────────────────────────────
acc = BankAccount("Alice", 1000)
acc.deposit(500)
acc.withdraw(200)
acc.apply_interest()
print(acc)              # Alice: £1339.00
print(acc.balance)      # 1339.0  (via @property)
inheritance.py
# ── Parent class ───────────────────────────────
class Shape:
    def __init__(self, colour):
        self.colour = colour

    def area(self):
        return 0    # meant to be overridden

    def describe(self):
        return f"{self.colour} shape, area = {self.area():.2f}"

# ── Child classes ──────────────────────────────
class Circle(Shape):
    def __init__(self, colour, radius):
        super().__init__(colour)        # call parent __init__
        self.radius = radius

    def area(self):                     # override parent method
        return 3.14159 * self.radius ** 2

class Rectangle(Shape):
    def __init__(self, colour, width, height):
        super().__init__(colour)
        self.width  = width
        self.height = height

    def area(self):
        return self.width * self.height

# ── Polymorphism — same method, different result
shapes = [Circle("red", 5), Rectangle("blue", 4, 6)]
for s in shapes:
    print(s.describe())
# red shape, area = 78.54
# blue shape, area = 24.00

# isinstance — check type safely
for s in shapes:
    if isinstance(s, Circle):
        print(f"Radius: {s.radius}")
aggregation.py
class Address:
    def __init__(self, street, city, postcode):
        self.street   = street
        self.city     = city
        self.postcode = postcode
    def __str__(self):
        return f"{self.street}, {self.city} {self.postcode}"

class Person:
    def __init__(self, name, address):
        self.name    = name
        self.address = address    # Person HAS-A Address
    def __str__(self):
        return f"{self.name} at {self.address}"

addr = Address("10 Baker St", "London", "NW1 6XE")
p    = Person("Holmes", addr)
print(p)   # Holmes at 10 Baker St, London NW1 6XE
09File Processing
ModeMeaningFile existsFile missing
'r'Read onlyOpens fineFileNotFoundError
'w'Write (overwrite)Clears contentCreates new file
'a'AppendAdds to endCreates new file
'r+'Read + writeOpens at startFileNotFoundError
text_files.py
# ── Write ──────────────────────────────────────
with open("students.txt", "w") as f:   # 'with' auto-closes file
    f.write("Alice,91\n")
    f.write("Bob,78\n")
    f.write("Charlie,85\n")

# ── Read line by line ──────────────────────────
with open("students.txt", "r") as f:
    for line in f:                        # memory-efficient
        name, score = line.strip().split(",")
        print(f"{name} scored {score}")

# ── Append ─────────────────────────────────────
with open("students.txt", "a") as f:
    f.write("Diana,95\n")

# ── Update a specific record ───────────────────
def update_score(filename, name_to_update, new_score):
    with open(filename, "r") as f:
        lines = f.readlines()          # read all into memory

    with open(filename, "w") as f:   # rewrite entire file
        for line in lines:
            name, _ = line.strip().split(",")
            if name == name_to_update:
                f.write(f"{name},{new_score}\n")
            else:
                f.write(line)
csv_files.py
import csv

# ── Write CSV ──────────────────────────────────
with open("records.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["Name", "Age", "Grade"])   # header row
    writer.writerow(["Alice", 17, "A"])
    writer.writerow(["Bob",   18, "B"])

# ── Read CSV as dicts (column names as keys) ───
with open("records.csv", "r") as f:
    reader = csv.DictReader(f)
    for row in reader:
        print(row["Name"], row["Grade"])
10Exception Handling

Exceptions are runtime errors. Instead of crashing, Python lets you catch and handle them. Every file operation and user input should be wrapped in exception handling.

exceptions.py
try:
    x = int(input("Enter a number: "))
    result = 100 / x

except ValueError:
    print("That wasn't a valid integer")

except ZeroDivisionError:
    print("Cannot divide by zero")

except Exception as e:               # catch-all fallback
    print(f"Unexpected error: {e}")

else:                                 # runs ONLY if no exception was raised
    print(f"Result: {result}")

finally:                              # ALWAYS runs — use for cleanup
    print("Done.")

# ── Common exception types ─────────────────────
# ValueError        int("abc"), float("x")
# ZeroDivisionError 5 / 0
# IndexError        lst[100] when len < 100
# KeyError          d["missing_key"]
# FileNotFoundError open("no_file.txt")
# TypeError         "hi" + 5
# OverflowError     very large numbers
custom_exceptions.py
# ── Define a custom exception ──────────────────
class InsufficientFundsError(Exception):
    def __init__(self, amount, balance):
        self.amount  = amount
        self.balance = balance
        super().__init__(
            f"Cannot withdraw £{amount}, balance is £{balance}")

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(amount, balance)
    return balance - amount

try:
    withdraw(100, 250)
except InsufficientFundsError as e:
    print(e)   # Cannot withdraw £250, balance is £100

# ── Validated input loop (very common in exams)
def get_positive_int(prompt):
    while True:
        try:
            n = int(input(prompt))
            if n <= 0: raise ValueError
            return n
        except ValueError:
            print("Please enter a positive integer.")

# ── Safe file read ─────────────────────────────
def safe_read(filename):
    try:
        with open(filename, "r") as f:
            return f.read()
    except FileNotFoundError:
        print(f"File '{filename}' not found")
        return None
⚡ Paper 4 exam patterns — what you’ll actually be asked to code:
  • Implement linear or binary search on a list of records read from a file
  • Sort a list using bubble sort or insertion sort, then write results back to file
  • Build a Stack or Queue class and use it to solve a specific problem (e.g. RPN, BFS)
  • Write a BST with insert + search + in-order traversal
  • Write a recursive function — always identify the base case first
  • Design a class hierarchy with at least one child class overriding a method
  • Read from a CSV/text file, process/filter/update the data, write back
  • Wrap all file operations and user input in try/except — always use with open()

Stop wrestling with confusion.

Join thousands of students mastering Computer Science without the academic jargon.

From syntax to systems. We break down the hardest ideas in computer science so you can actually build things.

© 2026 Painless Programming. Built for students.
Scroll to Top