The C Path — learn C, visually

📈 Algorithms & Complexity

Stacks & queues: LIFO meets FIFO

⏱ 14 min · free interactive lesson · quizzes, visualizations & a real compiler

▶ Open the interactive lesson — free, no signup
Why you're learning this

Ctrl+Z, your browser's back button, and the printer's job line all run on the same two tiny structures — about thirty lines of C each.

You'll build both from scratch, then use one to do what your code editor does every time you type a closing bracket: instantly check that every ( and [ has its matching partner.

Arrays let you touch any element any time. Sometimes that freedom is exactly wrong — you want discipline. A stack only lets you touch the newest item (that's the "last in, first out" — LIFO — of the title); a queue only the oldest ("first in, first out" — FIFO). These two constraints turn out to run half of computing: your function calls, your undo history, your print jobs, the part of the operating system that decides which program runs next.

The stack: Last In, First Out

Picture a stack of plates. You can push a plate on top, pop the top one off, or peek at it. The one you get back is always the most recently added — LIFO. An array plus one integer is all it takes:

stack.c
#include <stdio.h>
#include <stdbool.h>

#define CAP 16

typedef struct {
    int data[CAP];
    int top;              /* count; index of next free slot */
} Stack;

void st_init(Stack *s) { s->top = 0; }

bool st_push(Stack *s, int v) {
    if (s->top == CAP) return false;      /* full: overflow  */
    s->data[s->top++] = v;
    return true;
}

bool st_pop(Stack *s, int *out) {
    if (s->top == 0) return false;        /* empty: underflow */
    *out = s->data[--s->top];
    return true;
}

bool st_peek(const Stack *s, int *out) {
    if (s->top == 0) return false;
    *out = s->data[s->top - 1];           /* look, don't take */
    return true;
}

int main(void) {
    Stack s; st_init(&s);
    st_push(&s, 10); st_push(&s, 20); st_push(&s, 30);
    int v;
    while (st_pop(&s, &v)) printf("popped %d\n", v);
    return 0;
}
terminal
$ gcc stack.c -o stack && ./stack
popped 30
popped 20
popped 10
# pushed 10,20,30 — got them back REVERSED. That's LIFO.

Note the elegant symmetry: push is data[top++] (store, then step up), pop is data[--top] (step down, then read). top always equals the number of elements, and every operation is a single index tweak — O(1).

🧠 Checkpoint: You push 1, push 2, pop, push 3, pop, pop. In what order did the pops come out?

  • 1, 2, 3
  • 2, 3, 1
  • 3, 2, 1
  • 2, 1, 3
Show answer

2, 3, 1 — Pop #1 takes the newest (2). Push 3, then pop #2 takes 3. Pop #3 finally reaches 1. Always the most recent survivor first.

Stacks are everywhere (you've been using one all along)

Why does a stack solve bracket matching? Because a closing bracket must match the most recently opened, not-yet-closed one — "most recent first" is literally the stack's job description. Step through checking "([])":

▶ This spot has an interactive trace widget — open the interactive lesson to play with it.

⚠️

Two easy-to-forget failure modes: a closer arriving when the stack is empty (like ")(" — popping nothing is an underflow bug!), and leftovers at the end (like "(((" — balanced means the stack finishes empty). The trace above skips the empty-check for brevity; your exercise version must not.

🧠 Checkpoint: Using the stack method, why is "([)]" UNbalanced?

  • It has an odd number of brackets
  • When ')' arrives, the top of the stack is '[', not '('
  • The stack overflows
  • It ends with ']'
Show answer

When ')' arrives, the top of the stack is '[', not '(' — After pushing '(' then '[', the ')' arrives — but the most recently opened bracket is '[', and a closer must match the TOP of the stack. Mismatched pop → reject. (Bracket count is even; that alone proves nothing.)

The queue: First In, First Out

A queue is the line at the bakery: enqueue at the tail, dequeue from the head, and the first one in is the first one out — FIFO. Naive array version: dequeue from index 0 and shift everything left — O(n), yuck. The classy fix is the circular buffer: let head and tail chase each other around a fixed array, wrapping with modulo:

queue.c
#include <stdio.h>
#include <stdbool.h>

#define CAP 8

typedef struct {
    int data[CAP];
    int head;    /* index of the oldest element        */
    int tail;    /* index where the next enqueue lands */
    int count;
} Queue;

void q_init(Queue *q) { q->head = q->tail = q->count = 0; }

bool enqueue(Queue *q, int v) {
    if (q->count == CAP) return false;    /* full          */
    q->data[q->tail] = v;
    q->tail = (q->tail + 1) % CAP;        /* wrap around!  */
    q->count++;
    return true;
}

bool dequeue(Queue *q, int *out) {
    if (q->count == 0) return false;      /* empty         */
    *out = q->data[q->head];
    q->head = (q->head + 1) % CAP;        /* wrap around!  */
    q->count--;
    return true;
}

int main(void) {
    Queue q; q_init(&q);
    for (int i = 1; i <= 5; i++) enqueue(&q, i * 10);
    int v;
    dequeue(&q, &v); printf("first out: %d\n", v);
    dequeue(&q, &v); printf("then     : %d\n", v);
    enqueue(&q, 60); enqueue(&q, 70); enqueue(&q, 80);
    enqueue(&q, 90);      /* tail wraps: 7 -> 0 ! */
    while (dequeue(&q, &v)) printf("%d ", v);
    printf("\n");
    return 0;
}
terminal
$ gcc queue.c -o queue && ./queue
first out: 10
then     : 20
30 40 50 60 70 80 90
# in: 10..90, out: 10..90 — same order. That's FIFO.

Here's the buffer from that program at the moment right after enqueue(90) — the tail ran off the end of the array and wrapped around to slot 0:

▶ This spot has an interactive memgrid widget — open the interactive lesson to play with it.

🧠 Checkpoint: Why bother with the circular buffer instead of dequeuing from index 0 and shifting everything left?

  • Shifting would reverse the order
  • It saves memory
  • Shifting makes dequeue O(n); the ring keeps every operation O(1)
  • Plain arrays can’t be dequeued
Show answer

Shifting makes dequeue O(n); the ring keeps every operation O(1) — Shifting n−1 elements on every dequeue is Θ(n) each time. Moving the head index instead is one increment and one modulo — O(1), no matter how big the queue. Same data, smarter bookkeeping.

Scoreboard & variants

operationstack (array)queue (circular)
insert (push / enqueue)O(1)O(1)
remove (pop / dequeue)O(1)O(1)
peekO(1)O(1)
search for a valueO(n) — and it's rudeO(n) — ditto

Both can also be built on linked lists (push/pop at the list head; enqueue at a tail pointer): still O(1) per operation and never "full", at the cost of a heap allocation per element and worse cache behavior. Array-backed wins for a known size bound; linked wins for unbounded growth. Circular buffers in particular are the backbone of real systems — keyboard input, audio streaming, network packet rings.

🎉

Party trick: recursion and stacks are interchangeable. Any recursive algorithm can be rewritten iteratively with an explicit stack — that's literally what the compiler was doing for you with the call stack. Iterative quicksort? Push the (lo, hi) ranges onto your own stack instead of recursing.

▶ This spot has an interactive editor widget — open the interactive lesson to play with it.

Stacks and queues organize data by when it arrived — but to find things by what they are, in O(1), you need one more trick: hashing.

▶ Practice this lesson interactively (with live gcc)