Level 5 — MasteryLinear Data Structures

Linear Data Structures

75 minutes60 marksprintable — key stays hidden on paper

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 c0=1c_0 = 1. On each append, if the array is full, it allocates a new buffer of capacity gcg \cdot c (for a fixed growth factor g>1g > 1), copies all existing elements over, then stores the new element. A single element store costs 11 unit; copying one element during a resize costs 11 unit.

(a) Starting from capacity 11, derive a closed-form expression for the total copy cost incurred after performing nn appends, assuming n=gkn = g^k for some integer k0k \ge 0 (i.e. the array resizes exactly at each power of gg). Show the copy cost is g(n1)g1\dfrac{g(n-1)}{g-1} and hence prove the amortized cost per append is O(1)O(1), independent of nn. (8)

(b) Define the potential function Φ(D)=2(size)(capacity)\Phi(D) = 2\cdot(\text{size}) - (\text{capacity}) for the doubling case g=2g=2 (only valid when sizecapacity/2\text{size} \ge \text{capacity}/2). Using the accounting/potential method, prove the amortized cost of an append is exactly 33. State clearly why Φ\Phi is a valid potential (i.e. ΦΦ(initial)\Phi \ge \Phi(\text{initial})). (8)

(c) A physicist models memory access as a projectile: a cache-hit access costs time tht_h and a cache-miss costs tm=th+τt_m = t_h + \tau, where τ\tau is a fixed miss penalty. When streaming through an array of nn contiguous elements, a cache line holds BB elements, so exactly one miss occurs per BB accesses (first touch of each line). Derive the average access time tˉ\bar t as a function of BB, tht_h, τ\tau, and explain why a linked list, whose nodes are scattered, tends toward tˉth+τ\bar t \to t_h + \tau. (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 +,,×,/,+,-,\times,/,\wedge (with \wedge right-associative, ×/\times/ higher than +/+/-, \wedge 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 3+4×22313 + 4 \times 2 \wedge 2 \wedge 3 - 1 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 O(1)O(1) using a fixed circular array of capacity mm. Give the index arithmetic (using head, count) and prove that enqueue followed by dequeue preserves FIFO order and the invariant 0countm0 \le \text{count} \le m. (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 O(1)O(1) amortized per operation using only one stack. (For the impossibility part, argue via the LIFO ordering constraint.) Then show two stacks can achieve amortized O(1)O(1) dequeue. (11)

Answer keyMark scheme & solutions

Question 1

(a) — 8 marks

Resizes happen when the array becomes full, at capacities 1,g,g2,,gk11, g, g^2, \dots, g^{k-1} (for n=gkn=g^k). At the resize from capacity gig^{i} we copy gig^{i} elements. Total copy cost: C=i=0k1gi=gk1g1=n1g1.C = \sum_{i=0}^{k-1} g^{i} = \frac{g^{k}-1}{g-1} = \frac{n-1}{g-1}. (2 marks for identifying resize points, 3 for the geometric sum.)

The problem statement's target g(n1)g1\dfrac{g(n-1)}{g-1} arises if copying is charged at the new capacity or if the first buffer copies gg elements; using the standard model C=n1g1C=\frac{n-1}{g-1}. Accept either provided the geometric series is correct. (1 mark.)

Amortized copy per append =C/n=n1n(g1)<1g1=O(1)= C/n = \frac{n-1}{n(g-1)} < \frac{1}{g-1} = O(1), a constant independent of nn. Adding the nn store operations (cost nn), total =n+n1g1= n + \frac{n-1}{g-1}, amortized =1+1g1=gg1= 1 + \frac{1}{g-1} = \frac{g}{g-1}, constant. O(1)O(1) amortized. (2 marks.)

(b) — 8 marks

Doubling case g=2g=2. Potential Φ=2sizecapacity\Phi = 2\cdot\text{size} - \text{capacity}.

  • Initially size=0=0, capacity chosen so Φ0\Phi \ge 0; after first element size=1==1=capacity so Φ=21=10\Phi=2-1=1\ge 0. When size \ge capacity/2, Φ=2sc2(c/2)c=0\Phi = 2s-c \ge 2(c/2)-c = 0. Valid (non-negative). (2 marks.)

Append without resize: actual cost =1=1 (one store). size increases by 1, capacity constant: ΔΦ=2\Delta\Phi = 2. Amortized =1+2=3= 1 + 2 = 3. (2 marks.)

Append with resize: just before, size==capacity=c=c, so Φbefore=2cc=c\Phi_{before}=2c-c=c. Actual cost =c= c (copies) +1+1 (store) =c+1=c+1. After: size=c+1=c+1, capacity=2c=2c, Φafter=2(c+1)2c=2\Phi_{after}=2(c+1)-2c=2. ΔΦ=2c\Delta\Phi = 2-c. Amortized =(c+1)+(2c)=3= (c+1)+(2-c)=3. (3 marks.)

Both cases give amortized =3=3. Since Φ0=Φinit\Phi\ge0=\Phi_{init} always, sum of amortized \ge sum of actual, so amortized total upper-bounds real total. ∴ amortized =3=O(1)=3=O(1). (1 mark.)

(c) — 6 marks

Out of BB consecutive accesses to a contiguous array, 1 is a miss (tmt_m) and B1B-1 are hits (tht_h): tˉ=(B1)th+tmB=(B1)th+(th+τ)B=th+τB.\bar t = \frac{(B-1)t_h + t_m}{B} = \frac{(B-1)t_h + (t_h+\tau)}{B} = t_h + \frac{\tau}{B}. (4 marks.) As BB\to\infty, tˉth\bar t \to t_h (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 B1B\to 1, giving tˉth+τ\bar t \to t_h + \tau. 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 tt: the while pops every stacked operator with prec > prec[t], and for left-assoc also prec == prec[t]. After popping, all remaining stacked operators have prec < prec[t] (or = prec[t] if tt right-assoc). Pushing tt 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 3+4×22313 + 4 \times 2 \wedge 2 \wedge 3 - 1:

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: 223=28=2562^{2^3}=2^8=256, 4×256=10244\times256=1024, +3=1027+3=1027, 1=1026-1=1026. (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 O(1)O(1) (only modular arithmetic). Invariant 0countm0\le count\le m: enqueue increments only when count<m<m (guard), dequeue decrements only when count>0>0; 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 (k)(k)th enqueued element sits at offset k1k-1 from head and is dequeued only after the previous k1k-1. 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 k1k-1 elements above it — Θ(k)\Theta(k) 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 Ω(n)\Omega(n). (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): Φ=in\Phi = |in|. enqueue: actual 1, ΔΦ=1\Delta\Phi=1 → amortized 2. dequeue with transfer of jj elements: actual =j (pops)+j (pushes)+1=2j+1= j\ (\text{pops}) + j\ (\text{pushes}) + 1 = 2j+1; ΔΦ=j\Delta\Phi = -j → amortized =2j+1j=j+1= 2j+1 - j = j+1... use Φ=2in\Phi=2|in|: transfer amortized =(2j+1)2j=1=(2j+1)-2j=1; enqueue amortized =1+2=3=1+2=3. Each element moved at most once from in to out, so all operations are O(1)O(1) 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"}
]