Quiz 2

01. Python Refresher — Classes, Exceptions, Timing, Recursion

2413 words
12 min read
Python Week 1: the first filter for runtime behavior
Visual companion
Python
Type and operator map

Python Week 1: the first filter for runtime behavior

View
Revision summary

What this note is really saying

Short form

# 01. Python Refresher — Classes, Exceptions, Timing, Recursion > **What problem does this solve?** This course assumes you can write Python.

01. Python Refresher — Classes, Exceptions, Timing, Recursion

What problem does this solve? This course assumes you can write Python. This module bridges the gap between basic Python and the DSA-focused Python used throughout the rest of the course: classes for implementing data structures, exception handling for robust code, timing for performance measurement, and recursion as the foundation of divide-and-conquer algorithms.

1. Classes & Objects — The Building Blocks of Data Structures

Mental Model

A class is a blueprint. An object is an instance of that blueprint — it lives in memory with its own copy of the class's data. Think of a class as a cookie cutter and objects as the cookies.

Syntax Reference

python
# runnable
class Node:
    """A node in a linked list."""
    def __init__(self, data):
        self.data = data      # instance variable
        self.next = None      # default value
    def __repr__(self):
        return f"Node({self.data})"
# Create objects
n1 = Node(10)
n2 = Node(20)
n1.next = n2
print(n1)       # Node(10)
print(n1.next)  # Node(20)

5 Progressively Complex Examples

Example 1: Simple Counter Class
python
# runnable
class Counter:
    def __init__(self):
        self.count = 0
    def increment(self):
        self.count += 1
    def reset(self):
        self.count = 0
    def value(self):
        return self.count
c = Counter()
c.increment()
c.increment()
print(c.value())  # 2
c.reset()
print(c.value())  # 0
Example 2: Stack Class (LIFO)
python
# runnable
class Stack:
    def __init__(self):
        self._items = []  # underscore = "private by convention"
    def push(self, item):
        self._items.append(item)
    def pop(self):
        if self.is_empty():
            raise IndexError("pop from empty stack")
        return self._items.pop()
    def peek(self):
        if self.is_empty():
            raise IndexError("peek from empty stack")
        return self._items[-1]
    def is_empty(self):
        return len(self._items) == 0
    def __len__(self):
        return len(self._items)
s = Stack()
s.push(1)
s.push(2)
s.push(3)
print(s.pop())      # 3
print(len(s))       # 2
print(s.peek())     # 2
Example 3: Class with __str__ and __repr__
python
# runnable
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __repr__(self):
        return f"Point({self.x}, {self.y})"
    def __str__(self):
        return f"({self.x}, {self.y})"
    def distance_from_origin(self):
        return (self.x**2 + self.y**2) ** 0.5
p = Point(3, 4)
print(repr(p))   # Point(3, 4)
print(str(p))    # (3, 4)
print(p)         # (3, 4) — calls __str__
print(p.distance_from_origin())  # 5.0
Example 4: Inheritance for Specialized Nodes
python
# runnable
class TreeNode:
    def __init__(self, value):
        self.value = value
        self.left = None
        self.right = None
class BSTNode(TreeNode):
    """TreeNode with BST property."""
    def insert(self, value):
        if value < self.value:
            if self.left is None:
                self.left = BSTNode(value)
            else:
                self.left.insert(value)
        elif value > self.value:
            if self.right is None:
                self.right = BSTNode(value)
            else:
                self.right.insert(value)
        # Equal → don't insert (no duplicates)
root = BSTNode(10)
root.insert(5)
root.insert(15)
root.insert(3)
print(root.left.value)    # 5
print(root.left.left.value)  # 3
Example 5: Dunder Methods for a Custom List-like Class
python
# runnable
class ArrayList:
    def __init__(self):
        self._data = []
    def __getitem__(self, index):
        return self._data[index]
    def __setitem__(self, index, value):
        self._data[index] = value
    def __len__(self):
        return len(self._data)
    def append(self, value):
        self._data.append(value)
    def __repr__(self):
        return f"ArrayList({self._data})"
arr = ArrayList()
arr.append(10)
arr.append(20)
arr.append(30)
print(arr[1])      # 20
arr[1] = 99
print(arr)         # ArrayList([10, 99, 30])
print(len(arr))    # 3

Comparison: Class vs dict vs namedtuple

FeatureClassdictnamedtuple
Attribute accessobj.fieldd["key"]obj.field
Method definitionsYesNoNo
MutableYesYesNo (tuple-like)
Memory overheadModerateLowLow
SerializationManualBuilt-in JSONManual
Best forDS with behaviorSimple key-valueLightweight records

Common Bugs

python
# BUG 1: Forgetting self
class Bad:
    def __init__(self, x):
        self.x = x
    def double():  # Missing self!
        return self.x * 2
# TypeError: double() takes 0 positional arguments but 1 was given
# FIX:
class Good:
    def __init__(self, x):
        self.x = x
    def double(self):
        return self.x * 2
# BUG 2: Mutable default arguments
class BuggyStack:
    def __init__(self, items=[]):  # Same list shared by all instances!
        self.items = items
# >>> a = BuggyStack(); b = BuggyStack()
# >>> a.items.append(1); print(b.items)  # [1] — WRONG!
# FIX:
class CorrectStack:
    def __init__(self, items=None):
        self.items = items if items is not None else []
# BUG 3: Modifying class variable through instance creates instance variable
class Node:
    count = 0  # class variable
    def __init__(self):
        Node.count += 1
n1 = Node()
n2 = Node()
n1.count = 99  # Creates INSTANCE variable — doesn't change class var
print(Node.count)  # 2 (correct count)
print(n1.count)    # 99 (shadowed)

2. Exception Handling

Mental Model

When an error occurs, Python raises an exception. If you don't catch it, the program crashes. You catch exceptions with try/except blocks, similar to how you'd catch a ball thrown at you.

Syntax Reference

python
try:
    risky_operation()
except SomeError as e:
    # Handle the error
    print(f"Error: {e}")
finally:
    # Always runs (cleanup)
    cleanup_code()

5 Examples

Example 1: Basic try/except
python
# runnable
def divide(a, b):
    try:
        result = a / b
        return result
    except ZeroDivisionError:
        return "Cannot divide by zero!"
print(divide(10, 2))   # 5.0
print(divide(10, 0))   # Cannot divide by zero!
Example 2: Multiple Exception Types
python
# runnable
def safe_int_conversion(value):
    try:
        return int(value)
    except ValueError:
        return f"'{value}' is not a valid integer"
    except TypeError:
        return f"'{value}' has wrong type ({type(value).__name__})"
print(safe_int_conversion("42"))    # 42
print(safe_int_conversion("abc"))   # 'abc' is not a valid integer
print(safe_int_conversion([1,2]))   # '[1, 2]' has wrong type (list)
Example 3: try/except/else/finally
python
# runnable
def read_config(filename):
    try:
        f = open(filename, 'r')
    except FileNotFoundError:
        return None
    else:
        # Only runs if no exception occurred
        content = f.read()
        return content
    finally:
        # Always runs
        if 'f' in locals() and not f.closed:
            f.close()
print(read_config("nonexistent.txt"))  # None
Example 4: Raising Custom Exceptions
python
# runnable
class EmptyStackError(Exception):
    """Raised when trying to pop from an empty stack."""
    pass
class Stack:
    def __init__(self):
        self._data = []
    def pop(self):
        if not self._data:
            raise EmptyStackError("Cannot pop from empty stack")
        return self._data.pop()
s = Stack()
try:
    s.pop()
except EmptyStackError as e:
    print(f"Error: {e}")  # Error: Cannot pop from empty stack
Example 5: Context Manager (with statement)
python
# runnable
# The 'with' statement automatically handles cleanup
with open("demo.txt", "w") as f:
    f.write("Hello, exceptions!")
# File is automatically closed even if an error occurs
with open("demo.txt", "r") as f:
    print(f.read())  # Hello, exceptions!

Common Bugs

python
# BUG 1: Catching too broadly
try:
    user_input = input("Enter a number: ")
    result = 10 / int(user_input)
except Exception:  # Catches EVERYTHING — hides bugs
    pass
# Never know what went wrong!
# FIX: Be specific
try:
    result = 10 / int(user_input)
except ValueError:
    print("Not a number!")
except ZeroDivisionError:
    print("Can't divide by zero!")
# BUG 2: Forgetting that except order matters
try:
    risky_code()
except Exception:
    print("Caught")
except ValueError:  # This is unreachable!
    print("ValueError")
# ValueError is a subclass of Exception, caught by first block
# BUG 3: Not finally for resource cleanup
f = open("file.txt")
try:
    f.read()
except:
    return  # Returns without closing f!
finally:
    f.close()  # This always runs

3. Timing Code Execution

Mental Model

To measure an algorithm's performance, we don't use wall-clock time (too variable). Instead, we count basic operations or use Python's timeit module which runs code multiple times and gives statistical results.

Syntax Reference

python
import time
start = time.time()
# code to time
end = time.time()
elapsed = end - start
# OR using timeit (more accurate):
import timeit
timeit.timeit("code_string", number=1000)

3 Examples

Example 1: Manual Timing
python
# runnable
import time
def sum_upto_n(n):
    total = 0
    for i in range(n + 1):
        total += i
    return total
start = time.time()
result = sum_upto_n(1000000)
end = time.time()
print(f"Result: {result}, Time: {end - start:.4f} seconds")
Example 2: Using timeit
python
# runnable
import timeit
# Time list creation
setup = "n = 1000"
stmt1 = "[i**2 for i in range(n)]"
stmt2 = "list(map(lambda i: i**2, range(n)))"
t1 = timeit.timeit(stmt1, setup, number=10000)
t2 = timeit.timeit(stmt2, setup, number=10000)
print(f"List comprehension: {t1:.3f}s")
print(f"map + lambda: {t2:.3f}s")
Example 3: Timing Sorting Algorithms (Preview)
python
# runnable
import time, random
def timer(func, arr):
    start = time.time()
    func(arr)
    return time.time() - start
data = [random.randint(0, 1000) for _ in range(1000)]
# We'll see these sorting algorithms in detail later

4. Recursion — The Foundation of DSA

Mental Model

Recursion is when a function calls itself to solve a smaller instance of the same problem. Like Russian nesting dolls: each doll contains a smaller version of itself. Every recursive function needs:
  1. Base case — when to stop
  2. Recursive case — the function calls itself on a smaller input

Syntax Reference

python
def recursive_function(n):
    # Base case
    if n <= 1:
        return 1
    # Recursive case
    return n * recursive_function(n - 1)

5 Progressively Complex Examples

Example 1: Factorial
python
# runnable
def factorial(n):
    """Return n! = n * (n-1) * ... * 1"""
    if n <= 1:          # Base case
        return 1
    return n * factorial(n - 1)  # Recursive case
print(factorial(5))  # 120
# Trace:
# factorial(5) = 5 * factorial(4)
#             = 5 * 4 * factorial(3)
#             = 5 * 4 * 3 * factorial(2)
#             = 5 * 4 * 3 * 2 * factorial(1)
#             = 5 * 4 * 3 * 2 * 1 = 120
Example 2: Fibonacci (Inefficient)
python
# runnable
def fib(n):
    """Return the nth Fibonacci number (0-indexed)."""
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)
print(fib(10))  # 55
# Problem: fib(5) calls fib(4) and fib(3), which overlap massively!
# Complexity: O(2^n) — exponential! We'll fix this with DP later.
Example 3: Binary Search (Recursive)
python
# runnable
def binary_search(arr, target, left=0, right=None):
    """Return index of target in sorted arr, or -1 if not found."""
    if right is None:
        right = len(arr) - 1
    if left > right:          # Base case: empty range
        return -1
    mid = (left + right) // 2
    if arr[mid] == target:    # Found
        return mid
    elif arr[mid] > target:   # Search left half
        return binary_search(arr, target, left, mid - 1)
    else:                      # Search right half
        return binary_search(arr, target, mid + 1, right)
print(binary_search([1, 3, 5, 7, 9, 11, 13], 7))   # 3
print(binary_search([1, 3, 5, 7, 9, 11, 13], 4))   # -1
Example 4: Towers of Hanoi
python
# runnable
def hanoi(n, source, target, auxiliary):
    """Move n disks from source to target using auxiliary."""
    if n == 1:
        print(f"Move disk 1 from {source} to {target}")
        return
    hanoi(n - 1, source, auxiliary, target)
    print(f"Move disk {n} from {source} to {target}")
    hanoi(n - 1, auxiliary, target, source)
print("Solution for 3 disks:")
hanoi(3, 'A', 'C', 'B')
# Output shows all 7 moves for 3 disks
Example 5: Recursive vs Iterative — Call Stack Visualization
python
# runnable
import sys
def recursive_sum(n):
    """Recursive sum of 0..n."""
    if n == 0:
        return 0
    return n + recursive_sum(n - 1)
def iterative_sum(n):
    """Iterative sum of 0..n."""
    total = 0
    for i in range(n + 1):
        total += i
    return total
print(f"Recursive: {recursive_sum(100)}")  # 5050
print(f"Iterative: {iterative_sum(100)}")  # 5050
# Note: recursive_sum(1000) might hit RecursionError due to stack depth!

Comparison: Recursion vs Iteration

AspectRecursionIteration
Code clarityElegant for self-similar problemsStraightforward
MemoryUses call stack (O(n) memory)Usually O(1) memory
PerformanceFunction call overheadFaster (no overhead)
Infinite loopsStack overflow (crash)Infinite loop (hang)
Base caseNeeded to terminateLoop condition
Best forTrees, graphs, divide & conquerLinear operations

Common Bugs

python
# BUG 1: Missing base case — infinite recursion
def bad_factorial(n):
    return n * bad_factorial(n - 1)  # No base case!
# >>> bad_factorial(5)
# RecursionError: maximum recursion depth exceeded
# BUG 2: Base case never reached
def bad_power(x, n):
    if n == 1:
        return x
    return x * bad_power(x, n)  # n never decreases!
# BUG 3: Not returning the recursive result
def bad_sum(n):
    if n <= 1:
        return n
    bad_sum(n - 1) + n  # Missing return!
    # Returns None for n > 1

Practice Questions

Q1. Write a class BankAccount with deposit(), withdraw(), and get_balance() methods. Include proper exception handling for insufficient funds. Q2. What is the output?
python
def mystery(n):
    if n <= 0:
        return 0
    return n + mystery(n - 2)
print(mystery(6))
Q3. Fix the bug:
python
class Team:
    members = []  # What's wrong?
    def __init__(self, name):
        self.name = name
    def add_member(self, person):
        self.members.append(person)
Q4. Write a recursive function is_palindrome(s) that checks if a string reads the same forward and backward. Q5. Time the following two functions using timeit:
python
def method1(n):
    return sum(range(n))
def method2(n):
    return n*(n-1)//2
Which is faster and why? Q6. What does this exception handler print?
python
try:
    print(1/0)
except ZeroDivisionError:
    print("A")
except ArithmeticError:
    print("B")
except:
    print("C")
Q7. Write a class Queue using a list with enqueue(item) and dequeue() methods. Raise IndexError on empty dequeue. Q8. Trace the recursive calls for hanoi(2, 'A', 'C', 'B'). Q9. Convert this recursive function to iterative:
python
def countdown(n):
    if n <= 0:
        return
    print(n)
    countdown(n - 1)
Q10. Explain why recursive_sum(2000) might fail while iterative_sum(2000) works fine.
Answers
A1.
python
class BankAccount:
    def __init__(self):
        self._balance = 0

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

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal must be positive")
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount

    def get_balance(self):
        return self._balance
A2. 12 (6 + 4 + 2 + 0 = 12)
A3. members is a class variable shared by all instances. Move it into __init__ as self.members = [].
A4.
python
def is_palindrome(s):
    if len(s) <= 1:
        return True
    if s[0] != s[-1]:
        return False
    return is_palindrome(s[1:-1])
A5. method2 is O(1), method1 is O(n). For large n, method2 is dramatically faster.
A6. AZeroDivisionError is caught first. ArithmeticError is its parent class but the child exception handler matched first.
A7.
python
class Queue:
    def __init__(self):
        self._data = []
    def enqueue(self, item):
        self._data.append(item)
    def dequeue(self):
        if not self._data:
            raise IndexError("dequeue from empty queue")
        return self._data.pop(0)
A8.
sql
Move disk 1 from A to B
Move disk 2 from A to C
Move disk 1 from B to C
A9.
python
def countdown_iter(n):
    while n > 0:
        print(n)
        n -= 1
A10. Python has a recursion limit (default ~1000). Each recursive call consumes stack frame memory. recursive_sum(2000) exceeds this limit. The iterative version uses a single while loop with no stack growth. Join Discord Next02. Algorithm Analysis & Big-O Notation
Document outline

Keep your place and jump directly to a heading.

Table of Contents
System Normal // Awaiting Context

Intelligence Hub

Navigate the knowledge graph to generate context. The Hub adapts dynamically to surface backlinks, related notes, and metadata insights.