Linear Data Structures
Level: 5 — Mastery (cross-domain: math + physics + coding, build/prove, exam style) Time limit: 75 minutes Total marks: 60
Instructions: Give rigorous arguments. Pseudocode may be used but must be precise. Where an amortized or probabilistic bound is claimed, prove it. Partial credit awarded for correct method.
Question 1 — Amortized cost of dynamic arrays and the physics analogy (22 marks)
A dynamic array starts empty with capacity . On each append, if the array is full, it allocates a new buffer of capacity (for a fixed growth factor ), copies all existing elements over, then stores the new element. A single element store costs unit; copying one element during a resize costs unit.
(a) Starting from capacity , derive a closed-form expression for the total copy cost incurred after performing appends, assuming for some integer (i.e. the array resizes exactly at each power of ). Show the copy cost is and hence prove the amortized cost per append is , independent of . (8)
(b) Define the potential function for the doubling case (only valid when ). Using the accounting/potential method, prove the amortized cost of an append is exactly . State clearly why is a valid potential (i.e. ). (8)
(c) A physicist models memory access as a projectile: a cache-hit access costs time and a cache-miss costs , where is a fixed miss penalty. When streaming through an array of contiguous elements, a cache line holds elements, so exactly one miss occurs per accesses (first touch of each line). Derive the average access time as a function of , , , and explain why a linked list, whose nodes are scattered, tends toward . (6)
Question 2 — Build & prove: infix-to-postfix and stack invariants (20 marks)
(a) Write precise pseudocode for the shunting-yard algorithm converting a fully-parenthesized-optional infix expression over operators (with right-associative, higher than , highest) into postfix. Handle parentheses. (8)
(b) State and prove the loop invariant that guarantees correctness: at the point just before reading each token, every operator currently on the stack has strictly lower precedence than... — complete and prove the invariant that ensures operators are popped in correct precedence order. (7)
(c) Convert to postfix using your algorithm, showing the stack and output at each step. Give the final postfix string. (5)
Question 3 — Queue/Deque design proof (18 marks)
(a) You must implement a queue supporting enqueue, dequeue, and size in worst-case using a fixed circular array of capacity . Give the index arithmetic (using head, count) and prove that enqueue followed by dequeue preserves FIFO order and the invariant . (7)
(b) Prove that a deque (double-ended queue) can simulate both a stack and a FIFO queue, but that a single stack cannot simulate a FIFO queue in amortized per operation using only one stack. (For the impossibility part, argue via the LIFO ordering constraint.) Then show two stacks can achieve amortized dequeue. (11)
Answer keyMark scheme & solutions
Question 1
(a) — 8 marks
Resizes happen when the array becomes full, at capacities (for ). At the resize from capacity we copy elements. Total copy cost: (2 marks for identifying resize points, 3 for the geometric sum.)
The problem statement's target arises if copying is charged at the new capacity or if the first buffer copies elements; using the standard model . Accept either provided the geometric series is correct. (1 mark.)
Amortized copy per append , a constant independent of . Adding the store operations (cost ), total , amortized , constant. ∴ amortized. (2 marks.)
(b) — 8 marks
Doubling case . Potential .
- Initially size, capacity chosen so ; after first element sizecapacity so . When size capacity/2, . Valid (non-negative). (2 marks.)
Append without resize: actual cost (one store). size increases by 1, capacity constant: . Amortized . (2 marks.)
Append with resize: just before, sizecapacity, so . Actual cost (copies) (store) . After: size, capacity, . . Amortized . (3 marks.)
Both cases give amortized . Since always, sum of amortized sum of actual, so amortized total upper-bounds real total. ∴ amortized . (1 mark.)
(c) — 6 marks
Out of consecutive accesses to a contiguous array, 1 is a miss () and are hits (): (4 marks.) As , (perfect locality amortizes the penalty). For a scattered linked list, each node lives on its own line, so essentially every access is a miss: effective , giving . Thus arrays exploit spatial cache locality that pointer-chasing lists cannot. (2 marks.)
Question 2
(a) — 8 marks
prec: +,- → 1 ; *,/ → 2 ; ^ → 3
rightAssoc(^) = true, else false
function toPostfix(tokens):
out = []; op = emptyStack
for t in tokens:
if t is operand: out.append(t)
elif t == '(': op.push('(')
elif t == ')':
while op.top != '(': out.append(op.pop())
op.pop() # discard '('
else: # t is an operator
while (not op.empty) and op.top != '(' and
( prec[op.top] > prec[t] or
(prec[op.top]==prec[t] and not rightAssoc(t)) ):
out.append(op.pop())
op.push(t)
while not op.empty: out.append(op.pop())
return out
(4 for structure, 2 for correct precedence comparison, 2 for right-associativity handling.)
(b) — 7 marks
Invariant: Just before reading each token, reading top-to-bottom, the operators on the stack (ignoring ( markers) appear in non-increasing precedence order, with strict ordering enforced except that equal-precedence right-associative operators may stack. Equivalently: every operator already emitted to out was popped before any operator of strictly lower precedence that governs the same operands.
Proof (induction on tokens):
- Base: empty stack — vacuously true. (1)
- Operand/
(: pushing(inserts a barrier; operands don't touch operator ordering — invariant holds. (1) - Operator : the
whilepops every stacked operator withprec > prec[t], and for left-assoc alsoprec == prec[t]. After popping, all remaining stacked operators haveprec < prec[t](or= prec[t]if right-assoc). Pushing therefore keeps the stack non-increasing. Because higher/equal-precedence operators were emitted first, they act on their operands before lower-precedence ones — this is exactly correct evaluation order. (3) ): pops everything down to matching(, which preserves the order property within that parenthesized subexpression. (1) ∴ At termination every operator is popped in correct precedence/associativity order, giving valid postfix. (1)
(c) — 5 marks
Convert :
| token | stack (bottom→top) | output |
|---|---|---|
| 3 | 3 | |
| + | + | 3 |
| 4 | + | 3 4 |
| × | + × | 3 4 |
| 2 | + × | 3 4 2 |
| ^ | + × ^ | 3 4 2 |
| 2 | + × ^ | 3 4 2 2 |
| ^ (right-assoc, equal prec: don't pop) | + × ^ ^ | 3 4 2 2 |
| 3 | + × ^ ^ | 3 4 2 2 3 |
| − (pop ^,^,× then + equal→pop) | − | 3 4 2 2 3 ^ ^ × + |
| 1 | − | 3 4 2 2 3 ^ ^ × + 1 |
| end | 3 4 2 2 3 ^ ^ × + 1 − |
Final postfix: 3 4 2 2 3 ^ ^ × + 1 −
Value check: , , , . (3 for trace, 2 for final string.)
Question 3
(a) — 7 marks
Fields: head (index of front), count, array A[0..m-1].
enqueue(x): if count==m: overflow
A[(head+count) mod m] = x; count += 1
dequeue(): if count==0: underflow
x = A[head]; head = (head+1) mod m; count -= 1; return x
size(): return count
(3 marks.) Both are (only modular arithmetic).
Invariant : enqueue increments only when count (guard), dequeue decrements only when count; both stay in range. (2)
FIFO: enqueue writes at logical position head+count (tail); dequeue reads at head (front) then advances. The first element written is at head and is read first; the th enqueued element sits at offset from head and is dequeued only after the previous . Hence order preserved. (2)
(b) — 11 marks
Deque simulates stack: use only pushFront/popFront (or only back operations) → LIFO. (1)
Deque simulates queue: pushBack to enqueue, popFront to dequeue → FIFO. (1)
So a deque is strictly more general. (1)
Single stack cannot give FIFO in O(1): A stack exposes only its top. To dequeue the oldest (bottom) element while elements remain above it, you must remove and later restore all elements above it — work, and this cannot be amortized away with a single stack because after restoring, the same worst case recurs on the next dequeue interleaved with enqueue (adversary alternates enqueue/dequeue keeping one element resident, forcing repeated full transfers). No potential can absorb this since the LIFO exposure order is the exact reverse of the required removal order and there is no second buffer to hold the reversed sequence. Hence worst-case amortized cost is . (4)
Two stacks achieve amortized O(1): Keep in and out.
enqueue(x): in.push(x) — O(1).
dequeue(): if out empty, pop all of in into out (reversing order → oldest on top), then out.pop().
(2) Amortized proof (potential): . enqueue: actual 1, → amortized 2. dequeue with transfer of elements: actual ; → amortized ... use : transfer amortized ; enqueue amortized . Each element moved at most once from in to out, so all operations are amortized. (2)
[
{"claim":"Total copy cost sum_{i=0}^{k-1} g^i = (g^k-1)/(g-1), n=g^k=8,g=2 -> 7",
"code":"g=2;k=3;n=g**k;C=summation(g**i,(i,0,k-1));result=(C==(n-1)) and (simplify(C-(g**k-1)/(g-1))==0)"},
{"claim":"Amortized append cost with potential 2*size-capacity equals 3 in both cases",
"code":"c=symbols('c',positive=True); no_resize=1+2; resize=(c+1)+((2)-c); result=(no_resize==3) and (simplify(resize-3)==0)"},
{"claim":"Average access time (B-1)*th + tm over B with tm=th+tau equals th+tau/B",
"code":"th,tau,B=symbols('th tau B',positive=True); tbar=((B-1)*th+(th+tau))/B; result=simplify(tbar-(th+tau/B))==0"},
{"claim":"Postfix of 3+4*2^2^3-1 evaluates to 1026",
"code":"val=3+4*(2**(2**3))-1; result=val==1026"}
]