Level 5 — MasteryIntroduction to Programming (Python)

Introduction to Programming (Python)

90 minutes60 marksprintable — key stays hidden on paper

Level 5 — Mastery (Cross-domain: Math + Physics + Coding) Time limit: 90 minutes Total marks: 60

Write clean Python 3. You may assume standard library only. Where asked to "prove" or "derive," give rigorous reasoning, not just code. Show trace/state where requested.


Question 1 — Numerical Physics: Projectile Range via Recursion + Comprehensions (20 marks)

A projectile is launched from ground level with speed v0v_0 at angle θ\theta (radians) under gravity gg. Ignoring air resistance, the horizontal range is

R(θ)=v02sin(2θ)g.R(\theta) = \frac{v_0^2 \sin(2\theta)}{g}.

(a) Without using any loop or sin from math in the summation itself, write a recursive function sin_taylor(x, n) that returns the Taylor approximation of sin(x)\sin(x) using the first n terms:

sin(x)k=0n1(1)kx2k+1(2k+1)!.\sin(x)\approx\sum_{k=0}^{n-1}\frac{(-1)^k x^{2k+1}}{(2k+1)!}.

State the base case and recursive case explicitly, and explain why unbounded n risks a RecursionError. (6)

(b) Using a list comprehension and your sin_taylor (with n=10), build a list of (theta_deg, range) tuples for launch angles 10°,20°,,80°10°, 20°, \dots, 80°, with v0=30 m/sv_0 = 30\ \text{m/s}, g=9.81 m/s2g = 9.81\ \text{m/s}^2. Angles must be converted to radians inside the comprehension. (6)

(c) Using built-in functions only (max, sorted, map, or similar — no manual loops), find the angle that gives maximum range from your list of part (b), and prove analytically that the exact optimum is 45°45°. Explain any discrepancy between the analytic optimum and the numeric winner from your discrete list. (8)


Question 2 — Data Structures + Scope: Word-Frequency Engine (22 marks)

You are given a paragraph string. Build a small analytics tool.

(a) Write a function word_freq(text) that:

  • lowercases the text, strips punctuation . , ! ? ; (use str.replace or str.strip reasoning),
  • splits into words,
  • returns a dictionary mapping each word → count.

Use dict.get for accumulation. Explain why dict.get(key, 0) is preferable to a bare dict[key] here. (6)

(b) Using a dictionary comprehension and the built-in sorted with a lambda key, return the top-3 words by frequency as a list of (word, count) tuples, tie-broken alphabetically. (6)

(c) Consider this buggy accumulator:

def make_counter():
    count = 0
    def bump():
        count = count + 1
        return count
    return bump

Explain, using the LEGB rule, exactly why calling the returned function raises UnboundLocalError. Fix it two ways: once with nonlocal, once with a mutable container (list/dict). State the trade-off. (10)


Question 3 — Bitwise + Number Theory: Fast Integer Machinery (18 marks)

(a) Prove that for any non-negative integer nn, n & (n - 1) clears the lowest set bit of nn. Hence write a function popcount(n) that counts set bits using this trick in a while loop, and state its number of iterations in terms of the number of set bits. (7)

(b) Using only bitwise/arithmetic operators (<<, >>, &, |, +, -) — no *, /, **, % — write a function is_power_of_two(n) returning a bool, and justify the one-line condition you use. (5)

(c) Explain why x << k equals x2kx \cdot 2^k and x >> k equals x/2k\lfloor x / 2^k \rfloor for non-negative x. Then evaluate by hand: 13 << 2, 13 >> 1, 13 & 6, 13 | 6, 13 ^ 6, and ~13 (assume Python's arbitrary-precision two's-complement). Show working. (6)

Answer keyMark scheme & solutions

Question 1 (20)

(a) (6)

from math import factorial
 
def sin_taylor(x, n):
    if n == 0:            # base case
        return 0.0
    k = n - 1             # this term's index
    term = (-1)**k * x**(2*k+1) / factorial(2*k+1)
    return term + sin_taylor(x, n - 1)   # recursive case
  • Base case n == 0 → returns 0.0 (empty sum). (2)
  • Recursive case adds the (n-1)-th term to sin_taylor(x, n-1). (2)
  • Each call adds a stack frame; Python's default recursion limit (~1000). Large n exceeds the limit → RecursionError (stack overflow). (2)

(b) (6)

from math import radians
 
v0, g = 30, 9.81
data = [
    (deg, v0**2 * sin_taylor(radians(2*deg), 10) / g)
    for deg in range(10, 81, 10)
]
  • Correct comprehension structure [expr for deg in range(...)]. (2)
  • radians conversion inside and doubling angle 2*deg. (2)
  • Correct range range(10,81,10) → 10..80. (2)

Approx ranges (m): 10°≈31.4, 20°≈59.0, 30°≈79.4, 40°≈90.4, 50°≈90.4, 60°≈79.4, 70°≈59.0, 80°≈31.4.

(c) (8)

best = max(data, key=lambda t: t[1])
# best -> (40, ~90.4) or (50, ...) depending; both ~90.4
  • max(..., key=lambda t: t[1]) picks max range. (2)
  • Analytic proof: R=v02sin2θgR=\frac{v_0^2\sin 2\theta}{g} is maximized when sin2θ\sin 2\theta is max, i.e. 2θ=90°θ=45°2\theta=90°\Rightarrow\theta=45°. Since sin\sin peaks at 90°90°. (3)
  • Discrepancy: the discrete list only samples multiples of 10°; 45° isn't sampled. 40° and 50° are symmetric about 45° (sin80°=sin100°\sin 80°=\sin 100°) so both give ≈90.4 m — the winner is whichever max encounters, and neither is the true optimum. Discretization error, not algorithm error. (3)

Question 2 (22)

(a) (6)

def word_freq(text):
    for ch in ".,!?;":
        text = text.replace(ch, "")
    words = text.lower().split()
    freq = {}
    for w in words:
        freq[w] = freq.get(w, 0) + 1
    return freq
  • Lowercase + punctuation removal + split. (2)
  • Accumulation with get. (2)
  • get(key,0) returns default 0 for unseen keys; bare dict[key] raises KeyError on first occurrence, forcing an if key in dict guard. get is safer/shorter. (2)

(b) (6)

def top3(text):
    freq = word_freq(text)
    ordered = sorted(freq.items(), key=lambda kv: (-kv[1], kv[0]))
    return ordered[:3]
  • sorted with lambda key. (2)
  • Tie-break: (-count, word) → descending count, ascending word. (2)
  • Slice top 3. (2) (A dict comprehension may filter/reshape; sorted list of tuples is the required return type.)

(c) (10)

  • LEGB: name resolution order Local → Enclosing → Global → Built-in. In bump, the statement count = count + 1 assigns to count, so Python marks count as a local variable of bump for the whole function. On the RHS it reads that local count before assignmentUnboundLocalError. The enclosing count is never consulted for read because the local binding shadows it. (4)

Fix 1 — nonlocal:

def make_counter():
    count = 0
    def bump():
        nonlocal count
        count = count + 1
        return count
    return bump

(2)

Fix 2 — mutable container (mutate, don't rebind):

def make_counter():
    count = [0]
    def bump():
        count[0] = count[0] + 1
        return count[0]
    return bump

(2)

  • Trade-off: nonlocal is clearer and directly expresses intent but needs Python 3; the list/dict trick works even without nonlocal (worked in Py2 closures) but obscures intent with indexing. (2)

Question 3 (18)

(a) (7)

  • Proof: Let the lowest set bit of nn be at position jj, so n=1000jn = \dots1\underbrace{00\ldots0}_{j}. Subtracting 1 flips that bit to 0 and all jj trailing zeros to 1: n1=0111jn-1 = \dots0\underbrace{11\ldots1}_{j}. AND-ing keeps the higher bits unchanged and zeroes out position jj and below → lowest set bit cleared. (4)
def popcount(n):
    c = 0
    while n:
        n &= n - 1
        c += 1
    return c
  • Each iteration removes exactly one set bit, so the loop runs exactly (number of set bits) times. (3)

(b) (5)

def is_power_of_two(n):
    return n > 0 and (n & (n - 1)) == 0
  • Condition: a power of two has exactly one set bit; n & (n-1) clears that bit → 0. Must also guard n > 0 (0 gives 0 & -1 == 0 falsely). (3) correct expression, (2) justification.

(c) (6)

  • x << k: shifting bits left by kk multiplies the place value of every bit by 2k2^k, i.e. x2kx\cdot 2^k. x >> k: shifting right discards the low kk bits = integer division by 2k2^k = x/2k\lfloor x/2^k\rfloor for x0x\ge 0. (2)

Hand evaluations (13 = 1101₂, 6 = 0110₂): (4, ~0.7 each)

  • 13 << 2 = 134=5213\cdot4 = 52
  • 13 >> 1 = 13/2=6\lfloor13/2\rfloor = 6
  • 13 & 6 = 1101 & 0110 = 0100 = 4
  • 13 | 6 = 1101 | 0110 = 1111 = 15
  • 13 ^ 6 = 1101 ^ 0110 = 1011 = 11
  • ~13 = (13+1)-(13+1) = −14 (two's complement ~x = -x-1)
[
  {"claim":"sin_taylor(radians(90),10) ~ 1 so 45deg is analytic max of range", "code":"from sympy import sin,pi,diff,solve,symbols\nth=symbols('theta',real=True)\nR=sin(2*th)\nsol=solve(diff(R,th),th)\nresult = any(s==pi/4 for s in sol)"},
  {"claim":"popcount of 13 (1101) is 3", "code":"def pc(n):\n c=0\n while n:\n  n&=n-1\n  c+=1\n return c\nresult = pc(13)==3"},
  {"claim":"is_power_of_two correct for sample set", "code":"def ip(n):\n return n>0 and (n&(n-1))==0\nresult = [ip(k) for k in [1,2,3,4,16,0,6]]==[True,True,False,True,True,False,False]"},
  {"claim":"bitwise hand evaluations for 13,6", "code":"result = (13<<2==52) and (13>>1==6) and (13&6==4) and (13|6==15) and (13^6==11) and (~13==-14)"}
]