Overview
A stack is a LIFO container supporting two primary operations:
-
PUSH(x): placexon the top. -
POP(): remove and return the top element.
Correct stacks maintain simple but strict invariants (top index, size bounds, element order). This note implements push/pop for array-backed and linked-list–backed stacks, clarifies invariants, covers resizing and underflow protection, and shows how to test edge cases.
Note
Notation: arrays are 0-indexed with
A[0..cap-1]. The stack size isn. The top element is at indexn-1whenn>0.
Structure Definition
Two common representations:
Array-backed
struct StackArray:
A: array[0..cap-1] of T
n: integer // current size, 0 <= n <= cap
-
Top at
A[n-1]whenn>0. -
Empty iff
n == 0.
Linked-list–backed (singly)
struct Node: { value: T, next: Node* }
struct StackList:
head: Node* // top node or NIL
n: integer
-
Top at
head. -
Empty iff
head == NIL.
Tip
Prefer the array version when you can estimate an upper bound or accept amortized resizing. Choose the list version for frequent pushes/pops with unknown growth and no need for random access.
Core Operations
Array-backed PUSH / POP (with geometric growth)
function PUSH_ARRAY(S, x):
if S.n == S.cap:
RESIZE(S, new_cap = max(1, 2 * S.cap))
S.A[S.n] = x
S.n = S.n + 1
function POP_ARRAY(S):
if S.n == 0: error "underflow"
S.n = S.n - 1
x = S.A[S.n]
// optional: S.A[S.n] = undefined // clear for GC/debug
if S.n > 0 and S.n == S.cap / 4:
RESIZE(S, new_cap = S.cap / 2) // shrink to control memory
return x
function RESIZE(S, new_cap):
B = new array[new_cap]
for i in 0..S.n-1: B[i] = S.A[i]
S.A = B
S.cap = new_capInvariants (array)
-
0 <= n <= cap -
If
n > 0, top isA[n-1]and elements occupyA[0..n-1].
List-backed PUSH / POP
function PUSH_LIST(S, x):
node = new Node(x, S.head)
S.head = node
S.n = S.n + 1
function POP_LIST(S):
if S.head == NIL: error "underflow"
node = S.head
S.head = node.next
S.n = S.n - 1
x = node.value
free(node) // or let GC reclaim
return xInvariants (list)
-
n == 0 <=> head == NIL -
Following
nextpointers fromheadvisits exactlynnodes.
Example (Stepwise)
Array-backed trace
Initial: cap=4, n=0, A=[_,_,_,_]
PUSH(7) → n=1, A=[7,_,_,_] (top=7)
PUSH(5) → n=2, A=[7,5,_,_] (top=5)
POP() → return 5, n=1, A=[7,_,_,_] (top=7)
POP() → return 7, n=0, A=[_,_,_,_] (empty)
POP() → underflow (error)
Complexity and Performance
-
Array-backed:
PUSHandPOPare amortizedO(1)with geometric resizing; worst-caseO(n)only when resizing. Excellent cache locality. -
List-backed:
PUSHandPOPare worst-caseO(1)with predictable cost; extra per-node allocation and pointer chasing hurts locality.
Memory:
-
Array uses
Theta(cap)elements; doubling/halving keeps wasted space ⇐ 75% between resizes and ⇐ 50% on average. -
List stores one pointer per element plus allocator overhead; total
Theta(n)but larger constants.
Implementation Details or Trade-offs
Resizing Policy (arrays)
-
Grow factor: x2 is standard; x1.5 reduces spikes in large arrays at the cost of more resizes.
-
Shrink threshold: halve when
n == cap/4to avoid thrashing near 1/2 boundary. -
Zero-capacity start: use
cap=1after first push to simplify edge conditions.
Clearing Slots
-
In GC languages, write
A[n] = nullonPOPto break references. -
In systems languages, consider leaving data intact for speed and rely on
nas the boundary; enable a debug build that scrubs freed slots to catch bugs.
Error Handling
-
On underflow, either throw/return an error code or expose a
TRY_POPthat returns(ok, value)without raising. -
On overflow (fixed-capacity version), reject
PUSHwith an error and document capacity semantics.
Iteration Contract
Stacks usually do not guarantee stable iteration order beyond LIFO semantics. If you expose iterators, iterate from top down to 0.
Thread Safety
-
A single global lock around
PUSH/POPserializes access. -
For high throughput, use work-stealing deques (not a simple stack) or per-thread stacks with periodic merging.
Tip
If you only ever need the top k items, cap capacity at
kand drop the oldest when full (a ring or bounded stack). This keeps memory stable without general resizing.
Practical Use Cases
-
Call stacks / stack traces: model function activation records (frames) and simulate unwinding.
-
Parsing & evaluation: expression evaluation (postfix), delimiter matching, tree traversals’ explicit stacks.
-
Backtracking: DFS, undo operations in editors/solvers, state checkpoints.
Example
Simulate a tiny stack trace
main - foo - bar POP -> returns "bar"; next POP -> "foo"
Limitations / Pitfalls
Warning
Off-by-one on
top. In array stacks, the top index isn-1whenn>0. AccessingA[n]afterPUSHbut before incrementing or afterPOPbut before decrementing is a classic bug—updatenin the right order.
Warning
Underflow/overflow. Always guard
POPon empty andPUSHon full when capacity is fixed.
Warning
Memory thrash. Shrinking too aggressively causes repeated grow/shrink around thresholds; use
cap/4to halve and hysteresis to stabilize.
Warning
Iterator invalidation. Any
PUSHcan reallocate the array and invalidate external references toA. Avoid exposing raw pointers to internal storage.
Implementation Notes or Trade-offs
-
Fixed-capacity variant (e.g., embedded systems): remove resizing and return explicit overflow errors.
-
Type constraints: when
Tis large or nontrivial to copy, consider storing pointers/handles instead of values in an array stack. -
Sentinel methods: provide
PEEK()to read the top without popping andEMPTY()to check underflow conditions. -
Testing hooks: compile-time flags to force frequent resizes (e.g., grow to
cap+1) help reveal bugs inRESIZE.
Summary
Stacks give LIFO access with minimal API: PUSH, POP, and PEEK. Array-backed stacks offer amortized constant-time operations and good locality; list-backed stacks give strict constant-time operations with higher overhead. Clean implementations hinge on three rules: guard underflow, maintain the top invariant (top=n-1), and manage resizing predictably. With those in place, stacks are a robust building block for parsers, traversal algorithms, backtracking, and runtime call modeling.