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)