Introduction to Programming (Python)
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 at angle (radians) under gravity . Ignoring air resistance, the horizontal range is
(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 using the first n terms:
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 , with , . 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 . 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
. , ! ? ;(usestr.replaceorstr.stripreasoning), - 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 bumpExplain, 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 , n & (n - 1) clears the lowest set bit of . 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 and x >> k equals 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 tosin_taylor(x, n-1). (2) - Each call adds a stack frame; Python's default recursion limit (~1000). Large
nexceeds 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) radiansconversion inside and doubling angle2*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.4max(..., key=lambda t: t[1])picks max range. (2)- Analytic proof: is maximized when is max, i.e. . Since peaks at . (3)
- Discrepancy: the discrete list only samples multiples of 10°; 45° isn't sampled. 40° and 50° are symmetric about 45° () so both give ≈90.4 m — the winner is whichever
maxencounters, 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; baredict[key]raisesKeyErroron first occurrence, forcing anif key in dictguard.getis 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]sortedwithlambdakey. (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 statementcount = count + 1assigns tocount, so Python markscountas a local variable ofbumpfor the whole function. On the RHS it reads that localcountbefore assignment →UnboundLocalError. The enclosingcountis 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:
nonlocalis clearer and directly expresses intent but needs Python 3; the list/dict trick works even withoutnonlocal(worked in Py2 closures) but obscures intent with indexing. (2)
Question 3 (18)
(a) (7)
- Proof: Let the lowest set bit of be at position , so . Subtracting 1 flips that bit to 0 and all trailing zeros to 1: . AND-ing keeps the higher bits unchanged and zeroes out position 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 guardn > 0(0 gives0 & -1 == 0falsely). (3) correct expression, (2) justification.
(c) (6)
x << k: shifting bits left by multiplies the place value of every bit by , i.e. .x >> k: shifting right discards the low bits = integer division by = for . (2)
Hand evaluations (13 = 1101₂, 6 = 0110₂): (4, ~0.7 each)
13 << 2=13 >> 1=13 & 6=1101 & 0110=0100= 413 | 6=1101 | 0110=1111= 1513 ^ 6=1101 ^ 0110=1011= 11~13= = −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)"}
]