COMP 202 Foundations of Programming • McGill University, Montreal
Corrected exercises: Python foundations for COMP 202 at McGill
This is the corrected exercise set for COMP 202, Foundations of Programming, the first Python course taken at McGill University by students across science, engineering and arts. Part A covers the mechanics that every assignment depends on: the difference between /, // and % and why floor division of a negative number moves left rather than toward zero, the truth value of every kind of empty object, short circuiting in and and or, the half-open convention of range, string slicing against string indexing, and the aliasing of lists. Part B works at midterm level: call frames and the return value that survives them, nested loops over a table of marks, five plausible statements to correct, a file of grades read line by line, and recursion together with the tree that shows why the naive Fibonacci is unusable.
The thread running through the whole set: a function's VALUE and a function's EFFECT are two different things. print produces characters, return produces a value. nums.sort() changes the list and hands back None, while sorted(nums) hands back a list and changes nothing. name.strip() builds a new string and leaves the old one alone, while values.append(0) writes through to the caller's list. Every one of those pairs is the same distinction in a different costume, and a student who can name which side a given call is on stops making the errors that cost the most marks.
The traps named explicitly in the solutions: assigning the result of a method that returns None, comparing floats with ==, removing from a list while iterating over it, building a grid with [[0] * 3] * 3 and getting one row three times, forgetting that a line read from a file still carries its newline, seeding a running maximum with zero, and a mutable default argument that remembers the previous call.
10 corrected exercises • 100 points
• 150 minutes
Course recap
•/ always returns a float, // floors toward minus infinity, and % carries the sign of the divisor: −17//5=−4 and −17%5=3.
•False values: 0, 0.0, '', [], {}, None. Everything else is true, including '0' and [0].
•The logical operators and, or short circuit: the right-hand side is not evaluated when the left already decides. That is what makes a guard clause safe.
•range(a, b) has exactly b−a values and stops BEFORE b. range(n) has n values.
•Strings are immutable: every string method returns a NEW string and none of them changes the original.
•Lists are mutable: b = a gives one object two names, b = a[:] makes a copy. Never mutate a list while iterating over it.
•Methods that mutate return None: append, sort, reverse, remove. Functions that build return the object: sorted, reversed, and the expression a + b.
•A function with no return returns None. print sends characters to the screen and returns None too.
•A default argument is evaluated ONCE at definition time, so a mutable default is shared between calls. Default to None instead.
•A recursive function needs a base case AND a call that moves toward it. Naive Fibonacci is correct and exponential: 2F(n+1)−1 calls.
Part A: the mechanics (/50)
Exercise 1: Numbers, types, and the three division operators
Python has three ways to divide and they return three different things. / always produces a float, even when the division is exact, so 10/5 is 2.0 and not 2. // is floor division: it produces the largest integer that is not greater than the true quotient, which is not the same as chopping off the decimals once a negative number is involved. % is the remainder that goes with //, and Python defines it so that the identity a=(a//b)×b+(a%b) holds in every case, which forces the remainder to carry the sign of the divisor.
The figure shows why the negative case surprises everyone once. Floor division does not move toward zero, it moves LEFT on the number line.
a) Give the value AND the type of each of 7/2, 7//2, 7%2, 8/2 and 2 ** 10.
b) Compute −17//5 and −17%5 by hand. Check that a=(a//b)×b+(a%b) holds.
c) A COMP 202 assignment asks for the number of complete boxes of 12 needed to ship 100 items, and the number left over. Write the two expressions.
d) Explain why round(2.5) gives 2 in Python while round(3.5) gives 4.
e) A student writes if 0.1 + 0.2 == 0.3: and the body never runs. Explain in one sentence, and give a test that does work.
Show the solution
a) 7/2 is 3.5, a float, because / is true division and always returns a float. 7//2 is 3, an int, floor division. 7%2 is 1, an int. 8/2 is 4.0 and NOT 4: this is the one that costs marks, because a function that is supposed to return an integer index and uses / returns a float and then blows up as a list index. 2 ** 10 is 1024, an int, since ** is exponentiation and not a repeated multiplication sign.
b) The true quotient is −3.4. Floor division takes the largest integer not greater than −3.4, which is −4, not −3. So −17//5=−4. The remainder must then satisfy −17=(−4)(5)+r, so r=−17+20=3. Hence −17%5=3, a POSITIVE number. The rule to remember: in Python the remainder carries the sign of the divisor, so a positive divisor always gives a remainder in 0,1,…,b−1. That is why % is safe for cyclic indexing: days[(i + k) % 7] can never go out of range even when i+k is negative.
c) Boxes: 100 // 12 gives 8 full boxes. Left over: 100 % 12 gives 4. If the question had asked how many boxes are needed to ship EVERYTHING, the answer is 9, written (100 + 11) // 12 or math.ceil(100/12). Reading which of the two is being asked is worth more marks on a midterm than the arithmetic.
d) Python uses banker's rounding: an exact half rounds to the nearest EVEN integer. So 2.5 goes to 2 and 3.5 goes to 4. It is not a bug, it is a deliberate choice that stops a long column of halves from drifting upward. If a spec really wants half-up, the answer is math.floor(x + 0.5), and saying so is what an examiner is looking for.
e) 0.1 and 0.2 have no exact binary representation, so their sum is 0.30000000000000004 and the equality is false. Never compare floats with ==. Test abs((0.1 + 0.2) - 0.3) < 1e-9 instead. The trap is that printing the sum often shows 0.3, because printing rounds for display: the value on screen is not the value in memory.
Exercise 2: Conditions: what is true, and what short circuits
A condition in Python is not required to be a boolean. Every value has a truth value: 0, 0.0, the empty string, the empty list and None are false, and everything else is true. That is convenient and it is also the source of a whole family of silent bugs, because a function that returns None by accident makes a condition quietly false instead of raising an error.
The second half of the exercise is about the two logical operators, and plus or. Neither evaluates its right-hand side unless it has to. This is called short circuiting and it is the reason a guard clause can be written on one line.
Python
def bonus(score):
if score > 90:
print('A')
grade = bonus(95)
if grade:
print('bonus awarded')
a) State the truth value of each of 0, 0.0, '', '0', [], [0], None and -1.
b) Run the program above in your head. What is printed, and why is the second message missing?
c) Explain the difference between x == y and x is y. Give a case where they disagree.
d) Rewrite if len(name) > 0 and name[0] == 'A': so that it still works when name is the empty string, and say why the original already does.
e) A student writes if x = 5: and gets a SyntaxError. Explain what the two symbols mean and why Python refuses this line where the C language would accept it.
Show the solution
a) False: 0, 0.0, '', [], None. True: '0' (a one-character string, not empty), [0] (a list of length one, not empty), -1 (non-zero). The two that catch people are '0' and [0]: emptiness is what matters, not the contents.
b) It prints A and nothing else. bonus prints but never returns, so it returns None, grade is None, and if grade: is false. This is the central COMP 202 error and it is worth stating in one sentence: PRINT sends characters to the screen, RETURN hands a value back to the caller. A function that only prints is unusable inside a larger expression. The repair is return 'A' with the caller doing the printing.
c) == asks whether the two values are equal; is asks whether they are the same object in memory. a = [1,2] and b = [1,2] gives a == b true and a is b false, two equal lists at two addresses. Small integers and short strings are cached by CPython, so 256 is 256 happens to be true while 1000 is 1000 may be false: this is why is must only ever be used against None, True and False.
d) The original already works. If name is empty, len(name) > 0 is false, the operator and short circuits, name[0] is never evaluated, so no IndexError is raised. That is exactly what short circuiting buys. Written the other way round, if name[0] == 'A' and len(name) > 0: crashes on an empty string. Order matters, and the guard goes first.
e) = assigns, == compares. In C an assignment is an expression with a value, so if (x = 5) compiles and is always true, a famous class of bug. Python makes assignment a statement, so it cannot appear inside a condition, and the mistake becomes a SyntaxError caught before the program ever runs. Losing a mark to a syntax error is annoying; not losing a night to a silent C-style bug is the trade.
Exercise 3: Loops: the boundaries of range and the accumulator pattern
The call range(a, b, step) starts at a, stops BEFORE b, and moves by the step. The half-open convention is not an arbitrary choice: it makes range(0, n) have exactly n values, and it makes range(a, b) and range(b, c) fit together with no overlap and no gap. Almost every off-by-one error in a first programming course is an argument with this convention.
The accumulator pattern is the other half: a variable initialised before the loop, updated inside it, and read after it. Where the initialisation goes decides whether the answer is right.
a) How many values does range(2, 20, 3) produce? List them. What is the last one, and why is it not 20?
b) Write a loop that adds the integers from 1 to n. Where must total = 0 go, and what happens if it is written inside the loop?
c) for i in range(len(word)): and for ch in word: both walk a string. Give one task where the first is necessary.
d) A while loop reads while n != 1: with n = n // 2 inside. For which starting values of n does it terminate? Give one that loops forever.
e) Write the loop that finds the largest value in a list of positive numbers WITHOUT using max. Why is initialising the running maximum to 0 a bug in general?
Show the solution
a) The values are 2,5,8,11,14,17: six of them. The next would be 20, which is excluded because range stops strictly before its second argument. The count is ⌈(20−2)/3⌉=⌈6⌉=6. Reading the count off the formula rather than by listing is what saves time when the numbers are bigger.
b) total = 0 goes BEFORE the loop. Inside the loop it is reset on every pass, so after the loop total holds only the last term, n, and the answer looks almost right for n=1, which is exactly why the bug survives testing. The pattern is: initialise outside, update inside, read after.
c) When the position itself is needed. Printing word[i] alongside i, comparing word[i] with word[i+1], or building a reversed copy by walking indices backwards all need the index. When only the characters matter, for ch in word: is clearer and cannot go out of range, and an examiner will prefer it.
d) It terminates for every n≥1, because repeated floor division by 2 reaches 1 after about log2n steps. It loops forever for n=0, since 0//2 is 0 and the condition never becomes false, and it also runs forever for any negative n, because −3//2=−2, then −1, then −1 again: floor division of a negative number sticks at −1. Guarding with while n > 1: fixes both.
e) Initialise the running maximum to the FIRST element, not to 0: best = values[0], then loop from index 1. Starting at 0 gives the right answer only when the list contains a positive number; on a list of temperatures like −5,−12,−3 it returns 0, a value that is not even in the list. The general rule for a running extremum is to seed it with a real element of the collection.
Exercise 4: Strings: indexing, slicing, and why nothing changes in place
A string is a sequence of characters and it is immutable: no method ever modifies a string, they all build and return a new one. name.upper() on its own line does nothing at all, and that single fact accounts for a large share of lost marks on the first COMP 202 midterm.
The figure separates the two numbering systems that live on the same string. Indices sit ON the characters, and slice boundaries sit BETWEEN them, which is why word[2:5] has exactly 5−2=3 characters.
a) With w = 'MONTREAL', give w[0], w[-1], w[2:5], w[:3], w[5:] and w[::-1].
b) Why does w[3:3] give the empty string while w[3] gives a character?
c) A student writes w.replace('A', '4') then prints w and sees no change. Explain and repair.
d) w[10] raises an IndexError but w[3:99] does not. Explain the difference in one sentence.
e) Write, with a loop and no built-in reversal, a function is_palindrome(s) that ignores case. State its return type.
Show the solution
a) w[0] is 'M'. w[-1] is 'L', the last character, since −1 counts back from the end. w[2:5] is 'NTR': it starts at index 2 and stops before index 5, so three characters. w[:3] is 'MON', an omitted start meaning zero. w[5:] is 'EAL', an omitted stop meaning the end. w[::-1] is 'LAERTNOM', a step of −1 walking the whole string backwards.
b) A slice with equal boundaries cuts a piece of width zero, so it is ''. An index picks the character sitting in a cell. On the figure the two numbers are drawn on two different rows for this reason: index 3 is a cell, boundary 3 is a cut line. Once that picture is in place, w[a:b] always has b−a characters and the off-by-one questions stop being guesses.
c) The method replace returns a NEW string and leaves the original untouched, because strings are immutable. The line computes a value and throws it away. The repair is w = w.replace('A', '4'). The same reasoning applies to .upper(), .strip(), .lower() and every other string method: if the result is not assigned, nothing happened. Contrast this with list.append, which changes the list in place and returns None, which is the mirror-image trap in the next exercise.
d) An INDEX must land on a real character, so 10 on an 8-character string is an error; a SLICE is clipped silently to the available range, so w[3:99] simply gives 'TREAL'. This asymmetry is deliberate: slicing is meant for taking whatever is there, indexing is meant for reaching a specific position.
e) One correct version compares the two ends walking inward. It returns a BOOLEAN, True or False, and not a printed message, which is the point of the whole set: a predicate hands back a value the caller can use in a condition.
Python
def is_palindrome(s):
s = s.lower()
left = 0
right = len(s) - 1
while left < right:
if s[left] != s[right]:
return False
left = left + 1
right = right - 1
return True
Exercise 5: Lists: one object, two names
Lists are mutable, which is the exact opposite of strings, and the whole difficulty of the chapter lives in that one word. Assigning b = a does not copy anything: it gives a second name to the same object. Anything done through one name is visible through the other, because there is only one list.
The figure puts the two situations side by side. On the left, one list with two names. On the right, a real copy made with a[:].
Python
nums = [1, 2, 3, 4, 5, 6]
for n in nums:
if n % 2 == 0:
nums.remove(n)
print(nums)
a) After a = [1, 2, 9], b = a, b.append(4), what is a? What is a if the second line had been b = a[:]?
b) What does a.append(4) return? What does a = a + [4] do differently?
c) Explain the output of the loop below, then repair it so that every even number is removed.
d) nums.sort() and sorted(nums) both sort. State what each one returns and what each one leaves behind.
e) A function receives a list and does values.append(0). Does the caller see the change? What if the function does values = values + [0] instead?
Show the solution
a) With b = a, a becomes [1, 2, 9, 4]. The two names point at one object, so appending through b is visible through a. With b = a[:], a fresh list is built and a stays [1, 2, 9]. list(a) and a.copy() do the same thing. This is a shallow copy: it duplicates the outer list, not the objects inside it, which matters as soon as the elements are themselves lists.
b) a.append(4) returns None and changes a in place. So a = a.append(4) is a disaster: it destroys the list and leaves None behind, and the error only shows up several lines later. a = a + [4] builds a NEW list and rebinds the name; the old object is untouched, so any other name still pointing at it sees the old contents. Same visible result on one name, completely different effect on a second one.
c) It prints [1, 3, 5] and looks correct, which is why the bug is dangerous. It is not correct in general: removing an element shifts everything left while the loop counter keeps moving right, so elements get skipped. On [2, 4, 6] the same code returns [4]. Never mutate a list while iterating over it. Build a new one instead: nums = [n for n in nums if n % 2 != 0], or iterate over a copy with for n in nums[:]:.
d) nums.sort() sorts in place and returns None. sorted(nums) returns a new sorted list and leaves nums in its original order. So nums = nums.sort() sets nums to None, and sorted(nums) on a line by itself does nothing at all. The pair is a perfect illustration of the thread of this set: one has an effect, the other has a value.
e) Yes, the caller sees values.append(0), because the parameter is a second name for the caller's list. With values = values + [0] the caller sees nothing: the assignment rebinds the LOCAL name to a new list and the caller's object is untouched. Python passes a reference to the object, and whether the caller notices depends entirely on whether the function mutates or rebinds.
Python
# safe: build a new list rather than mutating during the walk
nums = [n for n in nums if n % 2 != 0]
# or iterate over a copy
for n in nums[:]:
if n % 2 == 0:
nums.remove(n)
Part B: problems and reasoning (/50)
Exercise 6: Functions: the value, the effect, and the frame in between
A call creates a frame: a private space holding the parameters and the local variables. The frame disappears when the function returns, and the only thing that survives is the returned value. Names created inside the frame do not leak out, and names outside are visible but not assignable unless declared global.
The figure follows total(3) down the stack and back up. Reading it once is usually enough to stop confusing a printed line with a returned value.
Python
def total(n):
if n == 0:
return 0
return n + total(n - 1)
def add_tax(price, rate=0.15):
return price * (1 + rate)
a) Follow total(3) frame by frame and give the value returned by each one.
b) add_tax(100) and add_tax(100, 0.05) both work. Explain default arguments, and say why def f(a=1, b) is a SyntaxError.
c) A function assigns to a name that also exists outside it. Does the outer name change? Explain with the words local and global.
d) def bad(items=[]): is warned against in every Python course. Write the function, call it twice, and explain the result.
e) State in one sentence the difference between a function that prints and a function that returns, and give one task where printing is the right choice.
Show the solution
a) total(3) cannot finish until total(2) finishes, which waits on total(1), which waits on total(0). Only the deepest frame has a value straight away: it returns 0. Then 1+0=1 comes back, then 2+1=3, then 3+3=6. So total(3) is 6, which is 3+2+1. The stack grows downward on the figure and the values travel back up: four frames existed at once, and the base case is the only reason the stack ever stopped growing.
b) A default argument supplies a value when the caller omits it, so add_tax(100) is 115.0 and add_tax(100, 0.05) is 105.0. Defaults must come LAST, because arguments are matched by position first: if a had a default and b did not, a single-argument call f(7) would be ambiguous about which parameter 7 fills. Python refuses the ambiguity at definition time rather than at call time.
c) The outer name does not change. An assignment anywhere inside a function makes that name local for the WHOLE function body, even on lines above the assignment, which is why reading it before assigning it raises UnboundLocalError rather than showing the global value. To assign to the module-level name, global name is required, and needing it is usually a sign the function should have returned a value instead.
d) The default list is created ONCE, when the function is defined, not once per call. So the second call sees the first call's leftovers: the calls print [1] then [1, 2]. The fix is the standard idiom below: default to None and build a fresh list inside. The general rule is that a mutable default is shared state hiding in a signature.
e) A function that PRINTS produces characters and hands back None; a function that RETURNS produces a value the caller can store, test or pass on. Printing is the right choice only at the outermost layer, where the program talks to the user: a display_menu() or a print_report(data). Everything that computes should return, because a printed value cannot be tested, and a function that cannot be tested cannot be trusted.
Python
def bad(items=[]):
items.append(len(items) + 1)
return items
print(bad()) # [1]
print(bad()) # [1, 2] <- the SAME list came back
def good(items=None):
if items is None:
items = []
items.append(len(items) + 1)
return items
Exercise 7: Nested loops and tables of marks
A table is a list of lists: the outer list holds the rows, and each row is itself a list of values. marks[r][c] reads left to right, so the first index picks the row and the second picks the column inside it. Nothing enforces that the rows have equal length, which is both the flexibility and the danger of the structure.
The figure shows a table of three students and four assignments, with both index systems drawn on it.
a) Give marks[1][3], marks[2][0] and len(marks), len(marks[0]).
b) Write a nested loop that prints each student's total. Which loop is the outer one?
c) Write a loop that gives the average for ASSIGNMENT 2, that is column index 2, across all students.
d) grid = [[0] * 3] * 3 then grid[0][0] = 7. Print grid. Explain what happened and give the correct construction.
e) Give the number of times the innermost line runs in a double loop over r rows and c columns, and say what that means for a table of 1000 by 1000.
Show the solution
a) marks[1][3] is 10: row index 1 is the second student, column index 3 is the fourth assignment. marks[2][0] is 9. len(marks) is 3, the number of ROWS, and len(marks[0]) is 4, the number of columns in the first row. Asking len of the table gives the row count and never the cell count, which is the first thing to check when a nested loop runs the wrong number of times.
b) The outer loop walks the rows, the inner one walks the cells of the current row. Totals are 8+6+9+7=30, 5+5+4+10=24 and 9+9+8+6=32. The accumulator has to be reset INSIDE the outer loop and outside the inner one, which is the one place in the exercise where the indentation carries the whole meaning.
c) Column 2 holds 9, 4 and 8, so the total is 21 and the average is 7.0. The loop walks the rows and reads marks[r][2] each time. Note there is no marks[][2]: a column is not a stored object, it has to be gathered one row at a time. Writing sum([row[2] for row in marks]) / len(marks) says the same thing in one line.
d) It prints [[7, 0, 0], [7, 0, 0], [7, 0, 0]]. Multiplying a list by 3 repeats the REFERENCE three times, so the outer list holds one row object three times over, and writing through any of them is visible through all three. This is the aliasing of the earlier exercise, hidden inside a construction that looks innocent. The correct build is grid = [[0] * 3 for _ in range(3)], which evaluates the inner expression on every pass and therefore makes three separate rows.
e) The inner line runs r×c times. For 1000 by 1000 that is 106 passes, which Python handles in about a second. The point of the count is what happens when a THIRD loop is nested inside: 109 passes is roughly twenty minutes, and no amount of micro-optimisation rescues an algorithm whose loop count is wrong by a factor of a thousand.
Python
for row in marks:
total = 0
for value in row:
total = total + value
print(total)
# column 2, gathered row by row
column_total = 0
for row in marks:
column_total = column_total + row[2]
print(column_total / len(marks))
# the correct 3x3 of zeros: three separate rows
grid = [[0] * 3 for _ in range(3)]
Exercise 8: Five statements to correct
Each statement below is one a student has written in a COMP 202 tutorial. Each one is false, and each one is false for a reason that has a name. Say what is wrong, give the correct statement, and give the one-line example that settles it.
a) "A function without a return returns nothing, so you cannot assign its result to a variable."
b) "name.strip() removes the spaces from the string, so after that line the string is clean."
c) "if x == True: is safer than if x: because it checks the type as well."
d) "Since lists are passed by reference, a function can never leave the caller's list unchanged."
e) "range(1, 10) covers the numbers from 1 to 10, that is ten values."
Show the solution
a) FALSE, and the correction matters. A function without return returns None, a real object, and the assignment x = f() is perfectly legal: x simply holds None. The danger is not that the assignment fails, it is that it SUCCEEDS and the failure surfaces much later, at x + 1, with a TypeError pointing at an innocent line. Correct statement: a function without return returns None, which is a value, not an absence of one.
b) FALSE. The method strip returns a new string and cannot modify the old one, because strings are immutable. The line computes and discards. Correct: name = name.strip(). The test that settles it is to print name before and after: it is unchanged. The mirror-image error is to write nums = nums.sort(), where the method DOES mutate and returns None, so the assignment destroys the list. Learning which methods return and which mutate is not memorisation, it follows from whether the type is mutable.
c) FALSE, and backwards. if x == True: is narrower and more fragile: it is true only when x equals True, so the non-empty string 'yes' fails it while if x: accepts it. It also compares 1== True as true, since booleans are integers in Python, so it does not even check the type. Correct: if x: for a truth test, and if x is True: on the rare occasion the exact object is meant.
d) FALSE. Passing a reference means the function CAN change the caller's list, not that it must. values.append(0) mutates and the caller sees it; values = values + [0] rebinds a local name and the caller sees nothing. Correct statement: the caller sees a change if and only if the function MUTATES the object rather than rebinding the name.
e) FALSE twice over. range(1, 10) stops before 10, so it covers 1 to 9, and that is NINE values, not ten. The reliable count is stop minus start, here 10−1=9. The habit worth building: range(a, b) always has b−a values, and range(n) always has n, which is why loops are written with the half-open convention in the first place.
Exercise 9: Reading a file of marks and reporting on it
A COMP 202 assignment almost always ends with a file. The file arrives as TEXT, every line ends with a newline character, and every field is a string until something converts it. Three quarters of the marks lost on this kind of question are lost between reading the line and having usable numbers.
The file grades.txt has one student per line, a name then four marks out of 10, separated by commas.
grades.txt
Amira,8,6,9,7
Ben,5,5,4,10
Chen,9,9,8,6
a) line.split(',') on the first line gives what exactly? State the type of every piece.
b) The last field of a line read from a file still carries its newline. Of int(parts[4]), parts[4] == '7' and parts[4].isdigit(), which ones go wrong? Give the one-word repair.
c) Write the function read_grades(filename) returning a list of pairs, name and average.
d) The file has a blank line at the end. What breaks, and where does the guard go?
e) Give the average of the whole class from your structure, and say why computing it from the per-student averages is legitimate here but not in general.
Show the solution
a) It gives a list of five STRINGS: 'Amira', '8', '6', '9', '7'. Every one of them is a string, including the marks, because splitting a string can only produce strings. '8' + '6' is '86' and not 14, and that concatenation is exactly the silent bug this question exists to prevent. Nothing is a number until int or float is applied.
b) The line read from the file ends with a newline, so the last field is '7\n'. Only two of the three go wrong, and int is not one of them: int tolerates the whitespace around a number, so int('7\n') is 7. That is exactly what makes the newline dangerous, since a field that is only ever converted looks clean. The comparison fails, parts[4] == '7' is False because the two strings differ by one character, and the test fails too, '7\n'.isdigit() is False for the same reason. A name carrying its newline also prints with a line break nobody asked for. The repair is one word: strip. Either line.strip().split(',') on the whole line, or parts[4].strip() on the field. Stripping the whole line first is better, because it also disposes of trailing spaces and of the carriage return that a file written on Windows carries.
c) The function below opens the file, skips empty lines, splits, converts, averages and returns. It RETURNS the structure rather than printing it, so the caller can sort it, filter it or write it back out. A version that prints inside the loop is worth roughly half the marks and cannot be tested.
d) A blank line splits into [''], so parts[1] raises an IndexError, or float('') raises a ValueError if the indexing happens to survive. The guard goes immediately after stripping, before the split: if line == '': continue. Guarding after the split is too late, and guarding before the strip misses a line that holds only spaces.
e) The averages are 30/4=7.5, 24/4=6.0 and 32/4=8.0, so the mean of the three is 21.5/3≈7.167, and the class total gives 86/12, the same number. Averaging averages is legitimate here ONLY because every student has the same number of marks. With unequal counts the two disagree, and the correct class average is always the total of all marks over the total number of marks, never the mean of the individual means.
Python
def read_grades(filename):
result = []
infile = open(filename, 'r')
for line in infile:
line = line.strip()
if line == '':
continue
parts = line.split(',')
name = parts[0]
marks = [float(p) for p in parts[1:]]
result.append([name, sum(marks) / len(marks)])
infile.close()
return result
Exercise 10: Recursion, and when a loop is the better answer
A recursive function calls itself on a smaller problem and stops at a base case. Two things have to be checked every single time: that the base case exists, and that every recursive call genuinely moves toward it. Missing either one gives a RecursionError after about a thousand frames.
The figure draws the call tree of a naive Fibonacci. It is the standard argument for why correct is not the same thing as usable.
Python
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
a) Give fib(0) through fib(6). How many calls does fib(5) make in total?
b) Count on the figure how many times fib(3) and fib(2) are evaluated. What does that say about the cost of fib(n)?
c) Rewrite Fibonacci with a loop, in one pass and two running variables. State how many additions it performs.
d) Write a recursive count_down(n) that prints and a recursive sum_to(n) that returns. Which one can be used inside an expression?
e) Give a problem where recursion is genuinely the better tool, and say what makes it so.
Show the solution
a) 0,1,1,2,3,5,8. Counting the nodes on the figure and extending it, fib(5) makes 15 calls in all: 1 for fib(5), then the subtrees. The count of calls for fib(n) is 2F(n+1)−1 where F is Fibonacci itself, which is the first hint that the cost grows like the answer does.
b) fib(3) appears twice on the tree and fib(2) three times, and lower down fib(1) appears five times. Those counts are Fibonacci numbers again. The work therefore grows like φn with φ≈1.618: fib(30) is already about 2.7 million calls, and fib(50) would take longer than the course. The function is perfectly correct and completely unusable, which is the distinction the exercise is built on.
c) The loop keeps only the last two values and slides them forward. It performs exactly n−1 additions for n≥2, so fib(50) costs 49 additions instead of billions of calls. Nothing clever happened: the loop simply refuses to recompute what it already knows, which is the same idea memoisation applies to the recursion.
d) count_down prints and returns None, so x = count_down(5) gives None and count_down(5) + 1 raises a TypeError. sum_to returns a number and can be used anywhere a number can: sum_to(4) * 2 is 20. Only the second is composable, and composability is the reason the returning version is the one an examiner expects.
e) Anything defined on a branching structure: walking a directory tree, evaluating nested parentheses, exploring a maze, quicksort and mergesort, or a Tower of Hanoi. What they share is that a sub-problem has SEVERAL sub-problems inside it, so there is no single index to walk with a for. A loop over a linear range is always writable as a loop; a tree is not, without rebuilding the stack by hand.
Python
def fib_loop(n):
if n <= 1:
return n
a, b = 0, 1
for _ in range(n - 1):
a, b = b, a + b
return b
def count_down(n): # prints, returns None
if n < 0:
return
print(n)
count_down(n - 1)
def sum_to(n): # returns a value
if n == 0:
return 0
return n + sum_to(n - 1)