§3Exercises
Ten exercises in four layers: three proofs, two designs, three implementations with milestones, and two read-the-code. Proofs and designs are the same in both languages; the implementation exercises come as templates in PythonJava with tests that fail on purpose, and your job is to make every test pass. Try each exercise before you open its solution.
| # | Layer | Topic | Video |
|---|
| 1 | Proof | Addresses and shifts | 1 |
| 2 | Proof | Growth factors of a dynamic array | 2 |
| 3 | Proof | Pointer updates, and a lower bound for remove-last | 3, 4 |
| 4 | Design | Choose the structure for six workloads | 1–4 |
| 5 | Design | A shrink policy for a dynamic array | 2 |
| 6 | Implement | Array insert and remove with a shift counter | 1 |
| 7 | Implement | Dynamic array with insert, pop and shrinking | 1, 2 |
| 8 | Implement | Linked lists: add, remove, reverse; doubly linked with sentinels | 3, 4 |
| 9 | Read the code | An AI's array insert that loses an item | 1 |
| 10 | Read the code | An AI's linked list with two reference bugs | 3 |
PROOFS
Exercise 1 · Addresses and shifts. (a) An array of 4-byte items starts at address 5000. At which address does item 250 start? Derive the general formula and say why one multiplication and one addition mean that reading item i costs O(1) however large i is. A table with C columns is stored row by row in one array of 8-byte items starting at address 2000, with C = 8. At which address does the cell in row 3, column 5 start? (Rows and columns count from 0.) (b) Inserting at index k into an array that holds n items moves n − k items. If k is equally likely to be any of 0, 1, …, n, what is the average number of items that move? Prove it. (c) Insert n items one after another, each at index 0, into an initially empty array that is big enough. Prove by induction on n that the total number of moves is n(n − 1)/2.
Solution
(a) Item i starts at address(i) = base + i · s, where s is the item size. With base 5000 and s = 4, item 250 starts at 5000 + 4·250 = 6000. It is one multiplication and one addition whatever i is: no loop, no walk past the earlier items, so reading or writing item i is O(1). For the table, row-major order puts cell (r, c) at index r·C + c, so its address is base + (r·C + c)·s. With r = 3, c = 5, C = 8, s = 8 and base 2000: index 3·8 + 5 = 29 and address 2000 + 8·29 = 2232.
(b) There are n + 1 equally likely positions k = 0, …, n, and position k moves n − k items, so the average is (1/(n+1)) · Σₖ (n − k) = (1/(n+1)) · (n + (n−1) + … + 0) = (1/(n+1)) · n(n+1)/2 = n/2. A random insert still moves half the array: Θ(n).
(c) Claim P(n): after n inserts at index 0 into an initially empty array the total number of moves is n(n − 1)/2. Base n = 0: no insert, 0 moves, and 0·(−1)/2 = 0. ✓ Step: assume P(n). The (n+1)-th insert finds n items in the array and moves all of them (n − 0 = n), so the total becomes n(n − 1)/2 + n = (n*² + *n)/2 = (n + 1)n/2, which is P(n + 1). ∎ n inserts at the front cost Θ(*n*²), the same sum as the pair count of chapter 1, and the same shape the stopwatch showed (eight times the items, about eight times the time of one insert).
Exercise 2 · Growth factors. A dynamic array starts with capacity 1 and size 0. When an append finds the array full it multiplies the capacity by an integer g ≥ 2, copies every element once, and then writes. Cost model: 1 per element written, 1 per element copied. (a) Take g = 3 (capacities 1, 3, 9, 27, …). A resize happens at append number 3ʲ + 1 and copies 3ʲ elements. Show that n appends make fewer than 1.5·n copies. (b) For a general integer g ≥ 2 show that n appends make fewer than g·n/(g − 1) copies. What does this give for the amortized cost of an append (writes and copies together)? Check that g = 2 reproduces the "fewer than 3 per append" of chapter 1. (c) Right after a resize the array is as empty as it will ever get while you only append. How empty, as a fraction of the capacity, as a function of g? State the trade-off between copying and wasted memory.
Solution
(a) Let n ≥ 2 and let J be the largest j with 3ʲ + 1 ≤ n, that is 3ᴶ ≤ n − 1. The resizes happen at appends 3⁰ + 1, 3¹ + 1, …, 3ᴶ + 1 and copy 1, 3, …, 3ᴶ elements, in total (3^(J+1) − 1)/2 (geometric series). Since 3^(J+1) = 3·3ᴶ ≤ 3(n − 1), the total is at most (3(n − 1) − 1)/2 = (3*n* − 4)/2 < 1.5·n. For n = 1 there is no copy. Check: n = 10 has resizes at appends 2, 4, 10: 1 + 3 + 9 = 13 < 15; n = 100 has resizes at 2, 4, 10, 28, 82: 1 + 3 + 9 + 27 + 81 = 121 < 150.
(b) The same argument with g in place of 3: copies = (g^(J+1) − 1)/(g − 1) with g*ᴶ ≤ *n − 1, so copies ≤ (g(n − 1) − 1)/(g − 1) < g·n/(g − 1). Adding the n writes, an append costs on average less than 1 + g/(g − 1) = (2*g* − 1)/(g − 1): for g = 2 that is 3 (the "fewer than 3*n* steps" of chapter 1), for g = 3 it is 2.5, for g = 4 about 2.33. The argument also works for a real factor g > 1 (the capacities form a geometric series whose sum is at most g/(g − 1) times its largest term). A factor as small as CPython's roughly 1.125 therefore allows up to about 9 copies per append, against under 2 for doubling: a small factor means frequent resizes.
(c) Right after the resize at append g*ʲ + 1 the capacity is *g^(j+1) and the size is g*ʲ + 1, so the filled fraction is (*g*ʲ + 1)/*g^(j+1) > 1/g. While you only append, the array is never less than 1/g full, so less than (g − 1)/g of the slots are idle: under 1/2 for g = 2 and under 2/3 for g = 3. The trade-off: a bigger factor copies less (under 2 copies per append for g = 2, under 1.5 for g = 3) but can leave more memory unused.
Exercise 3 · Pointer updates and a lower bound. A singly linked list keeps head, tail and size. Call the list well formed if following next from head visits exactly size nodes, the last one is tail, and tail.next is empty (and head, tail are empty exactly when size is 0). (a) The operation add_first(v) is: new node's next ← head; head ← new node; if the list was empty, tail ← new node; size ← size + 1. Prove that it keeps a well formed list well formed. (b) Now do the first two steps in the other order: head ← new node, then new node's next ← head. If the list had m nodes, what is the new node's next afterwards, how many of the old nodes can still be reached, and what does a traversal from head do? (c) Assume the only way a program can reach a node is to follow head, tail or next references. Show that removing the last node of a singly linked list of n ≥ 2 nodes takes at least n − 2 next hops. How many hops does it take in a doubly linked list?
Solution
(a) Let the list have m nodes, well formed. Step 1 (new node n: n.next ← head): starting at n we visit n and then the m old nodes, ending at the old last node, whose next is empty. Step 2 (head ← n): a traversal from head now follows exactly that chain: m + 1 nodes. Step 3: if m = 0, n.next is empty and n is the only node, so tail ← n makes the last node the tail; if m > 0 the last node is still the old tail, which stays correct. Step 4: size = m + 1. So the traversal visits size nodes, the last is tail, tail.next is empty, and head is empty only when size is 0. ∎
(b) After head ← n, the old first node is referenced by nothing: the only reference to it was the old value of head, and no node points at the first node. Then n.next ← head reads the new head, which is n itself, so n.next is n: a cycle of length 1. All m old nodes are unreachable (garbage in Python and Java) although size says m + 1, and a traversal from head visits n, n, n, … forever. (If the second step read the old next field after head had moved, n.next would be empty and the list would shrink to the single node n: that is the bug in Exercise 10.)
(c) To remove the last node t the program must set p.next to empty and tail ← p, where p is the node just before t, the (n − 1)-th node. By assumption the only ways to reach a node are head, tail and next. tail is t, and t has no reference back to p; the only route to p is from head, which is node 1, and node n − 1 is n − 2 next hops later. So at least n − 2 hops, and the loop while p.next is not tail: p = p.next does exactly that many (a 4-node list A → B → C → D takes 2 hops, as in video 4). In a doubly linked list tail.prev is p: 0 hops, and the whole removal is a fixed handful of assignments.
DESIGNS
Exercise 4 · Choose the structure. For each workload choose one of: fixed array, dynamic array, singly linked list (with head and tail), doubly linked list (with sentinels), circular singly linked list (with tail). Name the operations that dominate, give their costs, and say in one sentence why you did not pick the runner-up. There is more than one defensible answer when you justify it. (a) A table of the 366 records for the days of a year, read by day number, never resized. (b) An event log: append at the end, read the i-th event, never delete. (c) A music player's playlist with a cursor on the current track: "next" and "previous" move the cursor, "play next" inserts a track right after the current one, and "remove" deletes the current track. (d) A queue of print jobs: add at the end, take from the front. With a plain array, what would it cost to take all n = 1,000,000 queued jobs out one after another, counting item moves, as in chapter 1? (e) A round-robin scheduler: each process gets one time slice in turn; processes join and finish. (f) A sorted list of n names in which you must look names up by binary search.
Solution
Answers vary; these are defensible ones with their costs.
| Choice | Dominant operations | Why not the runner-up |
|---|
| (a) | fixed array | read by index: O(1) | a dynamic array adds resizing machinery for a table that never grows |
| (b) | dynamic array | append amortized O(1), read i-th O(1) | a singly linked list appends in O(1) too, but reading the i-th event costs i hops, up to n − 1 |
| (c) | doubly linked list with sentinels | next / previous: one reference step; "play next": insert_between O(1); "remove current": delete_node O(1) | a singly linked list cannot go back in O(1) and needs the predecessor (a walk) to delete the current node; an array shifts n − k items for every insert or delete in the middle |
| (d) | singly linked list with head and tail (or circular with tail) | add_last O(1), remove_first O(1) | a dynamic array would shift all remaining items at every removal from the front |
| (e) | circular singly linked list with tail | serve and rotate: tail = tail.next O(1); a finished process: remove_first O(1); a new one: add_last O(1) | with an array every rotation or removal at the front shifts the whole array (a circular array, chapter 3, would also work) |
| (f) | array (dynamic array) | binary search: O(log n) index reads; insert O(n) shifts | a linked list needs n/2 hops to reach the middle, so binary search degrades to O(n) |
For (d): removing from the front of an array with s items shifts s − 1 of them, and the sizes go n, n − 1, …, 1, so the total is Σ(s − 1) = n(n − 1)/2 = 499,999,500,000 moves for n = 1,000,000, against 1,000,000 constant-time removals from the linked list.
Exercise 5 · A shrink policy. A dynamic array doubles its capacity when full. Now it also has pop (remove the last item). Without shrinking, a program that once held a million items keeps a million slots forever, so we want to halve the capacity at some point after a pop. (a) Policy H: halve when size ≤ capacity / 2. Take an array with capacity 8 holding 8 items and perform "append, pop, append, pop, …". How many elements does each operation copy? What is the total for m operations? (b) Policy Q: halve when size ≤ capacity / 4. Right after a shrink from capacity c the array has exactly c/4 items, and a shrink copies c/4 elements. Right after a growth from capacity c the array has c + 1 items and the growth copied c elements. For each of the two cases find the smallest number of operations that must pass before the next resize. Use this to show that every resize copies at most twice as many elements as the number of operations between it and the next resize, and conclude that m operations cost O(m) copies in total. (c) Generalise Policy Q to "halve when size ≤ capacity / t" (growth factor 2, ignore rounding). For which t does the O(m) bound survive? What goes wrong for t = 2, and why is t = 4 the usual choice?
Solution
(a) Capacity 8, 8 items. Append: the array is full, so it grows to 16 and copies 8; size 9. Pop: size 8 ≤ 16/2, so Policy H shrinks to 8 and copies 8; size 8, capacity 8: the state we started from. Every operation copies 8 = n elements, so m operations copy m·n (2000 operations: 16,000 copies, against 8 for Policy Q). With n as large as you like, the cost per operation is Θ(n), not O(1).
(b) After a shrink from capacity c: the capacity is c/2 and the size c/4; the shrink copied c/4. The next shrink needs size ≤ (c/2)/4 = c/8, which takes at least c/4 − c/8 = c/8 pops; the next growth needs the array full and one more append: at least c/4 + 1 appends. So at least c/8 operations pass, and the shrink's cost c/4 is at most 2 per operation. After a growth from capacity c: the capacity is 2*c*, the size c + 1, and the growth copied c. The next shrink needs size ≤ 2*c*/4 = c/2: at least c/2 + 1 pops; the next growth needs the array full (2*c* items) and one more append: at least c appends. So at least c/2 operations pass, and the growth's cost c is at most 2 per operation. Charge each resize's copies to the operations between it and the next resize: every operation lies after exactly one resize and is charged at most 2 copies, so the total is at most 2*m* plus the cost of the last resize, which is at most the current size ≤ m. Altogether at most 3*m* copies for m operations: O(m), amortized O(1) per append or pop. (Rounding at tiny capacities changes nothing: a resize at capacity 1 or 2 copies at most 2 items.)
(c) For "halve when size ≤ capacity/t" (growth factor 2): after a shrink from c the array has c/t items in capacity c/2, so the next shrink needs at least c/(2*t*) pops and the next growth at least c(t − 2)/(2*t*) appends; the shrink copied c/t, at most max(2, 2/(t − 2)) per operation. After a growth from c the next shrink needs at least c(t − 2)/t pops and the next growth c appends: c copies, at most t/(t − 2) per operation. Both stay bounded exactly when t > 2. For t = 2 (Policy H) the array is right at the threshold after a growth: one pop shrinks it again, and every operation copies n. Larger t only lets the array get emptier before it shrinks (wasting memory), so t = 4 is the usual choice: after a shrink the array is exactly half full, the same distance (a factor of 2) from the next shrink and from the next growth, and both ratios equal 2.
IMPLEMENTATIONS (WITH MILESTONES)
Each file lists its milestones at the top. Do them in order and run the tests after each one.
Exercise 6 · Array insert and remove — ex_arrayops.pyExArrayOps.java. A fixed-capacity array whose insert and remove shift items, with a counter of the single-item moves. The tests check the contents after each operation, the error cases and the exact shift counts you derived in Exercise 1, also against a plain list on random operations.
Solution
Reference implementation in solutions/. The loop in insert runs from the first free slot down to index + 1: it moves the last item first, so nothing is overwritten before it has moved. A forward loop would copy the same item into every later slot: inserting X at index 2 of A B C D E that way produces A B X C C C, because C overwrites D and then the copy of C overwrites E before either has moved. The loop runs size − index times, so insert adds size − index shifts; the loop in remove runs from index to size − 2, size − 1 − index times. Both formulas are what the random test accumulates. Appending (index == size) and removing the last item run no loop at all: 0 shifts.
Exercise 7 · Dynamic array with insert, pop and shrinking — ex_growarray.pyExGrowArray.java. Implement resize, append, insert and pop with the growth rule of chapter 2 and the shrink rule of Exercise 5 (Policy Q). The tests check the capacity sequences, the exact copy and shift counts, that alternating append and pop at a growth boundary does not thrash, and the capacity invariant under random operations.
Solution
Reference implementation in solutions/. The numbers the tests encode: appending 9 items gives capacities 1, 2, 4, 4, 8, 8, 8, 8, 16 and 15 copies (resizes at appends 2, 3, 5, 9 copy 1, 2, 4, 8); 17 appends give capacity 32 and 31 copies; inserting 100 items at index 0 shifts 0 + 1 + … + 99 = 4,950 items (Exercise 1(c)) and copies 127 = 1 + 2 + … + 64 (resizes at 2, 3, 5, 9, 17, 33, 65). Popping 9 items from the 9-item array gives capacities 16, 16, 16, 16, 8, 8, 4, 2, 1: the shrinks happen at sizes 4 (16 → 8, copies 4), 2 (8 → 4, copies 2), 1 (4 → 2, copies 1) and 0 (2 → 1, copies 0), so 15 + 4 + 2 + 1 = 22 copies in all. Alternating append and pop with 8 items copies 8 once (the growth to 16) and then nothing; Policy H of Exercise 5 would copy 8 on every operation (16,000 in the test), which is what the thrash test catches. The invariant capacity = 1 or capacity < 4·size (and size ≤ capacity) holds after every operation. Proof: capacities are powers of two. A growth from c leaves capacity 2*c* and size c + 1, and 4(c + 1) > 2*c*; an insert without growth only increases the size; a pop that does not shrink leaves 4·size > capacity or capacity 1 by the shrink condition; a pop that shrinks from c ≥ 8 had 4(size + 1) > c before the pop, so 4·size > c − 4 ≥ c/2, the new capacity; from c = 4 the size is 1 and the new capacity 2 < 4; from c = 2 the new capacity is 1. So the array never keeps more than four slots per item. Note that only append and pop are amortized O(1); insert(i, x) costs n − i shifts.
Exercise 8 · Linked lists — ex_linked.pyExLinked.java and ExDoubly.java. A singly linked list with add_first, add_last, remove(value) and an in-place reverse, then a doubly linked list with sentinels whose delete_node and remove_last change a fixed number of references. The given check() verifies the whole structure and the tests call it after every operation.
Solution
Reference implementation in solutions/. The structure of the cases:
- add_first:
head = Node(value, head) creates the node with the old head as its next and only then moves head (Exercise 3(a)). If tail was empty the new node is also the tail. - remove(value): walk with a trailing reference
prev. Cases: not found (return false, nothing changes); prev empty, so the node is the head (head = node.next); otherwise prev.next = node.next. If the node was the tail, tail = prev (empty when the list had one node, which makes head and tail both empty). Without prev you would have to walk the list a second time to find the predecessor. - reverse: one pass with three references: before overwriting
node.next, save it in following; then node.next = prev, prev = node, node = following. At the end head = prev and tail is the old head. No new node is created and only O(1) extra space is used. - DoublyLinked:
insert_between sets the new node's prev/next first and then pred.next and succ.prev; delete_node makes the neighbours skip the node with two assignments (node.prev.next = node.next, node.next.prev = node.prev); remove_last is delete_node(trailer.prev). Thanks to the sentinels there is no special case for the first, last or only item: the neighbours always exist.
READ THE CODE
An AI assistant wrote each snippet and its tests; the tests pass. The code has a bug. Find a small input that shows it, say exactly what goes wrong (which item or node is lost, which reference is stale), and write the fix. Use the habit from the chapter: trace the code on a list of one or two items, and check the references after every step.
Exercise 9 — insert in read/py/ex_read_shift.pyExReadShift.java.
Solution
The bug: the loop starts at size − 1 instead of size. The first free slot is size, and the item in slot size − 1 has to move into it; the loop never does that move, so slot size − 1 is overwritten by its left neighbour (or by the new value) and slot size is never written. Smallest input: items = ["A", None], size = 1, index = 0: the loop range(0, 0, -1) is empty and items[0] = "X" overwrites A: the result is ["X", None] instead of ["X", "A"]. With ["A","B","C","D","E",None,None], size = 5, index = 2 the result is A B X C D None: E is lost (D overwrote it), and slot 5 is None although the caller now thinks the array holds 6 items. Appending (index == size) works, which is why the bug is easy to miss. Why the AI's test passes: it inserts at index = size − 1 = 2 into ["A","B","C"] and checks only the first three slots, ["A","B","X"]; the lost item C would have been in slot 3, which the test never reads. Test the whole array and the item that should have moved. Fix: for k in range(size, index, -1): (Java: for (int k = size; k > index; k--)). The docstring's "size − index moves" was right; the buggy loop makes size − 1 − index.
Exercise 10 — LinkedList in read/py/ex_read_list.pyExReadList.java (two bugs).
Solution
Bug 1, add_first: the order of the first two steps is wrong. self.head = node moves head first, and then node.next = self.head.next reads the new head's next, which is node.next, which is empty. So the old list is not connected to the new node: all m old nodes become unreachable while size becomes m + 1. Shortest sequence: add_last(1); add_first(0): to_list() is [0], not [0, 1], and size is 2. The AI's test used an empty list for add_first, where there is nothing to lose. Fix: connect the new node to the rest of the list first, then move head: node.next = self.head; self.head = node (Java: node.next = head; head = node;). Bug 2, insert_after: when node is the tail, the new node becomes the last node but tail still points at the old one. Shortest sequence: add_last(1); insert_after(tail, 2); add_last(3): the list is [1, 2] and tail.value is 1; then add_last(3) sets tail.next = 3, which overwrites the link from node 1 to node 2, so to_list() is [1, 3] and 2 is lost. The AI's test inserted after the head of a three-node list, so the tail was never involved. Fix: if node is self.tail: self.tail = new (Java: if (node == tail) tail = created;). The lesson is the one of Exercise 8: after every operation the three facts size = number of reachable nodes, tail = last node reachable, empty list = head and tail both empty must hold. Write a check() like the one in Exercise 8 and call it in the tests; test the empty list, a one-node list and the tail as special cases.