Skip to content

Beginner

This tutorial covers foundational data structures and algorithms through 28 heavily annotated examples in C, Go, Python, and Java. Each example is self-contained and runnable. Coverage spans arrays, linked lists, stacks, queues, deques, basic sorting algorithms, linear search, basic recursion, and Big-O basics — building the mental models every software engineer needs.

Arrays and Lists

Example 1: Creating and Accessing Arrays

Arrays (Python lists) store elements in contiguous memory locations with zero-based indexing. Accessing any element by index is O(1) — the engine jumps directly to the memory address without scanning.

// Example 1: Creating and Accessing Arrays

#include <stdio.h>

int main(void) {
    // Create an array with five integers
    int numbers[] = {10, 20, 30, 40, 50};
    // => numbers stores {10, 20, 30, 40, 50}
    // => C allocates contiguous memory on the stack for these values

    // Access elements by zero-based index
    int first = numbers[0];
    // => first is 10 — index 0 is the leftmost element
    // => Access is O(1): the CPU computes address = base + (index * sizeof(int))

    int length = sizeof(numbers) / sizeof(numbers[0]);
    // => length is 5 — computed at compile time from total size / element size

    int last = numbers[length - 1];
    // => last is 50 — C has no negative indexing; use length-1 for the final element

    int middle = numbers[2];
    // => middle is 30 — direct jump to index 2, no scanning needed

    printf("%d %d %d\n", first, last, middle);
    // => Output: 10 50 30

    // Read the length of the array
    printf("%d\n", length);
    // => Output: 5

    return 0;
}

Key Takeaway: Array indexing is O(1) because the CPU computes the memory address directly. Negative indices count from the end.

Why It Matters: In production, most data pipelines start with arrays. Knowing that indexing is O(1) while scanning is O(n) shapes every decision about when to use index-based lookups versus iteration. Python’s list is a dynamic array that underpins lists, stacks, and many queue implementations throughout the standard library.


Example 2: Modifying Arrays — Append, Insert, and Delete

Dynamic arrays grow automatically when you append. Insert and delete at arbitrary positions cost O(n) because Python must shift elements to maintain contiguous ordering.

// Example 2: Modifying Arrays — Append, Insert, and Delete

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// Simple dynamic string array
typedef struct {
    char **items;
    int size;
    int capacity;
} StringArray;

void init(StringArray *a, int cap) {
    a->items = malloc(cap * sizeof(char *));
    a->size = 0;
    a->capacity = cap;
}

void append(StringArray *a, const char *s) {
    // Append adds to the end — amortized O(1)
    if (a->size == a->capacity) {
        a->capacity *= 2;
        a->items = realloc(a->items, a->capacity * sizeof(char *));
        // => Double capacity when full — amortized O(1) over many appends
    }
    a->items[a->size++] = strdup(s);
}

void insert_at(StringArray *a, int idx, const char *s) {
    // Insert at index — O(n) because elements shift right
    append(a, "");  // => Ensure space
    free(a->items[a->size - 1]);
    for (int i = a->size - 1; i > idx; i--) {
        a->items[i] = a->items[i - 1];
        // => Shift elements right to make room
    }
    a->items[idx] = strdup(s);
}

void remove_by_value(StringArray *a, const char *s) {
    // Remove by value — O(n) scan + O(n) shift
    for (int i = 0; i < a->size; i++) {
        if (strcmp(a->items[i], s) == 0) {
            free(a->items[i]);
            for (int j = i; j < a->size - 1; j++) {
                a->items[j] = a->items[j + 1];
                // => Shift remaining elements left to fill the gap
            }
            a->size--;
            return;
        }
    }
}

char *pop_end(StringArray *a) {
    // Pop from the end — O(1), no shifting needed
    return a->items[--a->size];
}

char *pop_at(StringArray *a, int idx) {
    // Pop from an arbitrary index — O(n) due to shifting
    char *removed = a->items[idx];
    for (int i = idx; i < a->size - 1; i++) {
        a->items[i] = a->items[i + 1];
    }
    a->size--;
    return removed;
}

void print_array(StringArray *a) {
    printf("[");
    for (int i = 0; i < a->size; i++) {
        if (i > 0) printf(", ");
        printf("%s", a->items[i]);
    }
    printf("]\n");
}

int main(void) {
    StringArray fruits;
    init(&fruits, 4);
    append(&fruits, "apple");
    append(&fruits, "banana");
    append(&fruits, "cherry");
    // => fruits is ["apple", "banana", "cherry"]

    append(&fruits, "date");
    // => fruits is ["apple", "banana", "cherry", "date"]
    print_array(&fruits);
    // => Output: [apple, banana, cherry, date]

    insert_at(&fruits, 1, "avocado");
    // => "avocado" goes to index 1; others shift right
    // => fruits is ["apple", "avocado", "banana", "cherry", "date"]
    print_array(&fruits);
    // => Output: [apple, avocado, banana, cherry, date]

    remove_by_value(&fruits, "banana");
    // => fruits is ["apple", "avocado", "cherry", "date"]
    print_array(&fruits);
    // => Output: [apple, avocado, cherry, date]

    char *popped = pop_end(&fruits);
    // => popped is "date"; fruits is ["apple", "avocado", "cherry"]
    printf("%s ", popped);
    print_array(&fruits);
    // => Output: date [apple, avocado, cherry]
    free(popped);

    char *removed = pop_at(&fruits, 0);
    // => removed is "apple"; fruits is ["avocado", "cherry"]
    printf("%s ", removed);
    print_array(&fruits);
    // => Output: apple [avocado, cherry]
    free(removed);

    // Cleanup
    for (int i = 0; i < fruits.size; i++) free(fruits.items[i]);
    free(fruits.items);
    return 0;
}

Key Takeaway: Append and end-pop are O(1). Insert, remove, and pop at arbitrary positions are O(n) due to element shifting.

Why It Matters: Production code that inserts or deletes frequently at the beginning or middle of a large list pays O(n) per operation. Recognizing this cost early drives engineers toward deques, linked lists, or index-based compaction strategies before performance degradation occurs in production.


Example 3: Array Slicing

Slicing creates a new list containing a contiguous subrange. The syntax list[start:stop:step] follows the half-open interval convention — stop is excluded.

// Example 3: Array Slicing

#include <stdio.h>

void print_array(int *arr, int len) {
    printf("[");
    for (int i = 0; i < len; i++) {
        if (i > 0) printf(", ");
        printf("%d", arr[i]);
    }
    printf("]\n");
}

int main(void) {
    int data[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
    // => data holds integers 0 through 9 inclusive
    int n = 10;

    // Basic slice: indices 2 through 4 (index 5 excluded)
    int subset[3];
    for (int i = 0; i < 3; i++) subset[i] = data[2 + i];
    // => subset is {2, 3, 4}
    // => Manual copy into a NEW array — O(k) where k is slice length
    print_array(subset, 3);
    // => Output: [2, 3, 4]

    // First four elements (head)
    int head[4];
    for (int i = 0; i < 4; i++) head[i] = data[i];
    // => head is {0, 1, 2, 3}
    print_array(head, 4);
    // => Output: [0, 1, 2, 3]

    // Last three elements (tail)
    int tail[3];
    for (int i = 0; i < 3; i++) tail[i] = data[7 + i];
    // => tail is {7, 8, 9}
    print_array(tail, 3);
    // => Output: [7, 8, 9]

    // Step parameter: every other element
    int evens[5];
    for (int i = 0; i < 5; i++) evens[i] = data[i * 2];
    // => Step 2 selects indices 0, 2, 4, 6, 8
    // => evens is {0, 2, 4, 6, 8}
    print_array(evens, 5);
    // => Output: [0, 2, 4, 6, 8]

    // Reverse an array
    int reversed_data[10];
    for (int i = 0; i < n; i++) reversed_data[i] = data[n - 1 - i];
    // => Traverse right-to-left
    // => reversed_data is {9, 8, 7, 6, 5, 4, 3, 2, 1, 0}
    print_array(reversed_data, n);
    // => Output: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

    return 0;
}

Key Takeaway: Slicing returns a shallow copy; changes to the slice do not affect the original. The slice operation is O(k) where k is the number of elements copied.

Why It Matters: Slicing underpins data preprocessing, windowed computations, and batch splitting. Understanding that it creates a new object (not a view) prevents subtle bugs when engineers mutate slices expecting to modify the original data.


Example 4: Iterating Over Arrays

Python provides multiple iteration patterns. Choose the one that best communicates intent: for x in list for values, enumerate for index-value pairs, and range(len(...)) only when index arithmetic is unavoidable.

// Example 4: Iterating Over Arrays

#include <stdio.h>

int main(void) {
    double temperatures[] = {22.1, 18.5, 30.0, 25.3, 19.8};
    // => five daily temperature readings in Celsius
    int n = 5;

    // Pattern 1: value-only iteration
    printf("Temperatures:\n");
    for (int i = 0; i < n; i++) {
        // => Each iteration: temperatures[i] gives the next value in sequence
        printf("  %.1f°C\n", temperatures[i]);
        // => Output: 22.1°C, 18.5°C, 30.0°C, 25.3°C, 19.8°C (one per line)
    }

    // Pattern 2: index + value
    printf("Indexed:\n");
    for (int i = 0; i < n; i++) {
        // => i is the index, temperatures[i] is the value
        printf("  Day %d: %.1f°C\n", i, temperatures[i]);
        // => Output: Day 0: 22.1°C, Day 1: 18.5°C, ...
    }

    // Pattern 3: building a transformed array
    double celsius[] = {22.1, 18.5, 30.0};
    // => three Celsius values
    double fahrenheit[3];
    for (int i = 0; i < 3; i++) {
        fahrenheit[i] = (celsius[i] * 9.0 / 5.0) + 32.0;
    }
    // => (22.1*9/5)+32 = 71.78, (18.5*9/5)+32 = 65.3, (30.0*9/5)+32 = 86.0
    printf("[%.2f, %.1f, %.1f]\n", fahrenheit[0], fahrenheit[1], fahrenheit[2]);
    // => Output: [71.78, 65.3, 86.0]

    // Pattern 4: accumulate a running total
    double total = 0;
    for (int i = 0; i < n; i++) {
        // => Add each temperature to the running sum
        total += temperatures[i];
        // => After each iteration: 22.1, 40.6, 70.6, 95.9, 115.7
    }
    double average = total / n;
    // => average is 115.7 / 5 = 23.14
    printf("Average: %.2f°C\n", average);
    // => Output: Average: 23.14°C

    return 0;
}

Key Takeaway: Use for x in list when you need values, enumerate when you need both index and value, and list comprehensions to build transformed copies.

Why It Matters: Clear iteration patterns signal intent to reviewers and reduce bugs. Choosing list comprehensions over manual accumulation also keeps logic concise and often runs faster due to Python’s internal optimizations, which matters when processing large datasets in data pipelines or analytics services.


Example 5: Two-Dimensional Arrays

A 2D array is a list of lists. Row access is O(1); cell access grid[row][col] is two consecutive O(1) lookups. Nested loops visit every cell in O(rows × cols) time.

// Example 5: Two-Dimensional Arrays

#include <stdio.h>

int main(void) {
    // Create a 3x3 grid representing a tic-tac-toe board
    char board[3][3] = {
        {'X', 'O', 'X'},   // => Row 0
        {'O', 'X', 'O'},   // => Row 1
        {'X', 'O', 'X'},   // => Row 2
    };
    // => board is a 2D array with 3 rows of 3 characters each

    // Access a single cell: row 1, column 2
    char cell = board[1][2];
    // => board[1] retrieves row 1 — O(1)
    // => board[1][2] retrieves 'O' from that row — second O(1)
    printf("%c\n", cell);
    // => Output: O

    // Modify a cell
    board[0][1] = 'X';
    // => board[0] is now {'X', 'X', 'X'} — row 0 updated

    // Iterate over all rows and columns
    printf("Full board:\n");
    for (int r = 0; r < 3; r++) {
        for (int c = 0; c < 3; c++) {
            printf("  [%d][%d] = %c\n", r, c, board[r][c]);
            // => Prints every cell's coordinates and value
        }
    }

    // Create a 4x4 zero matrix
    int matrix[4][4] = {0};
    // => All elements initialized to 0

    matrix[2][3] = 99;
    // => Only row 2, column 3 changes to 99
    printf("[%d, %d, %d, %d]\n", matrix[2][0], matrix[2][1], matrix[2][2], matrix[2][3]);
    // => Output: [0, 0, 0, 99]

    return 0;
}

Key Takeaway: Use [[val] * cols for _ in range(rows)] to create 2D arrays — not [[val] * cols] * rows, which creates aliased rows sharing the same list object.

Why It Matters: 2D arrays model matrices, game boards, images (pixel grids), and adjacency matrices for graphs. The aliasing pitfall with * rows is one of the most common subtle bugs in Python; understanding memory layout prevents hours of debugging.


Linked Lists

Example 6: Singly Linked List — Structure and Traversal

A singly linked list stores elements in nodes, each holding a value and a pointer to the next node. Traversal is O(n); there is no direct index access.

    graph LR
    H["head"] --> A["Node: 10"]
    A --> B["Node: 20"]
    B --> C["Node: 30"]
    C --> D["None"]

    %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
    style H fill:#CA9161,stroke:#000,color:#fff
    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#0173B2,stroke:#000,color:#fff
    style C fill:#0173B2,stroke:#000,color:#fff
    style D fill:#808080,stroke:#000,color:#fff
  
// Example 6: Singly Linked List — Structure and Traversal

#include <stdio.h>
#include <stdlib.h>

// A single element in a singly linked list
typedef struct Node {
    int value;            // => value holds the data for this node
    struct Node *next;    // => next points to the following Node, or NULL if tail
} Node;

// Create a new node on the heap
Node *new_node(int value) {
    Node *n = malloc(sizeof(Node));
    n->value = value;
    n->next = NULL;
    return n;
}

// Add a new node at the end of the list — O(n)
void append(Node **head, int value) {
    Node *nn = new_node(value);
    // => Allocate a new Node with the given value

    if (*head == NULL) {
        // => Empty list: new node becomes the head
        *head = nn;
        return;
    }

    Node *current = *head;
    // => Start at head, walk to the last node
    while (current->next != NULL) {
        // => Keep moving until we find a node whose next is NULL
        current = current->next;
    }
    current->next = nn;
    // => Attach the new node as the tail's successor
}

// Return all values by printing them — O(n)
void traverse(Node *head) {
    printf("[");
    Node *current = head;
    // => Begin at head; current will walk each node in turn
    while (current != NULL) {
        if (current != head) printf(", ");
        printf("%d", current->value);
        // => Print the value before advancing
        current = current->next;
        // => Move to the next node; stops when current becomes NULL
    }
    printf("]\n");
}

// Free all nodes
void free_list(Node *head) {
    while (head != NULL) {
        Node *tmp = head;
        head = head->next;
        free(tmp);
    }
}

int main(void) {
    // Build a list: 10 -> 20 -> 30
    Node *linked = NULL;
    append(&linked, 10);   // => head -> Node(10) -> NULL
    append(&linked, 20);   // => head -> Node(10) -> Node(20) -> NULL
    append(&linked, 30);   // => head -> Node(10) -> Node(20) -> Node(30) -> NULL

    traverse(linked);
    // => Output: [10, 20, 30]

    free_list(linked);
    return 0;
}

Key Takeaway: Linked list traversal is O(n) because every access starts from the head and follows pointers. There is no O(1) index jump like arrays provide.

Why It Matters: Linked lists appear in OS kernels (process scheduling queues), browser history navigation, and undo/redo chains. Understanding pointer-chasing costs drives decisions about when O(n) traversal is acceptable versus when an array’s O(1) random access is worth the memory allocation overhead.


Example 7: Singly Linked List — Prepend and Search

Prepend (inserting at the head) is O(1) because only the head pointer needs updating. Search is O(n) because we must walk nodes sequentially.

// Example 7: Singly Linked List — Prepend and Search

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>

typedef struct Node {
    int value;
    struct Node *next;
} Node;

Node *new_node(int value) {
    Node *n = malloc(sizeof(Node));
    n->value = value;
    n->next = NULL;
    return n;
}

// Insert a new node before the current head — O(1)
void prepend(Node **head, int value) {
    Node *nn = new_node(value);
    // => Create the new node first

    nn->next = *head;
    // => New node points to the old head (even if head is NULL)

    *head = nn;
    // => Update head to the new node — single pointer update, O(1)
}

// Return true if target exists in the list — O(n)
bool search(Node *head, int target) {
    Node *current = head;
    // => Start at head; must walk every node in worst case

    while (current != NULL) {
        if (current->value == target) {
            // => Found a match — return immediately
            return true;
        }
        current = current->next;
        // => Advance to next node
    }
    return false;
    // => Exhausted list without finding target
}

void traverse(Node *head) {
    printf("[");
    Node *cur = head;
    while (cur != NULL) {
        if (cur != head) printf(", ");
        printf("%d", cur->value);
        cur = cur->next;
    }
    printf("]\n");
}

void free_list(Node *head) {
    while (head) { Node *t = head; head = head->next; free(t); }
}

int main(void) {
    // Build list by prepending: each new element goes to the front
    Node *ll = NULL;
    prepend(&ll, 30);   // => head -> Node(30) -> NULL
    prepend(&ll, 20);   // => head -> Node(20) -> Node(30) -> NULL
    prepend(&ll, 10);   // => head -> Node(10) -> Node(20) -> Node(30) -> NULL

    traverse(ll);
    // => Output: [10, 20, 30]

    printf("%s\n", search(ll, 20) ? "true" : "false");
    // => Walk: 10 (no), 20 (yes!) — returns true after 2 steps
    // => Output: true

    printf("%s\n", search(ll, 99) ? "true" : "false");
    // => Walk: 10, 20, 30, NULL — exhausted — returns false
    // => Output: false

    free_list(ll);
    return 0;
}

Key Takeaway: Prepend is O(1) because only the head pointer changes. Search is O(n) because there is no shortcut — every node must be checked in worst case.

Why It Matters: Prepend-heavy workloads (log prepending, newest-first feeds) use linked lists to avoid the O(n) shifting that arrays require. Search inefficiency motivates adding hash maps alongside linked lists for O(1) lookup — the pattern used in Python’s OrderedDict and LRU cache implementations.


Example 8: Singly Linked List — Delete a Node

Deleting a node by value requires finding its predecessor so we can re-link around it. Special handling is needed when the target is the head node.

// Example 8: Singly Linked List — Delete a Node

#include <stdio.h>
#include <stdlib.h>

typedef struct Node {
    int value;
    struct Node *next;
} Node;

Node *new_node(int value) {
    Node *n = malloc(sizeof(Node));
    n->value = value;
    n->next = NULL;
    return n;
}

void append(Node **head, int value) {
    Node *nn = new_node(value);
    if (*head == NULL) { *head = nn; return; }
    Node *cur = *head;
    while (cur->next) cur = cur->next;
    cur->next = nn;
}

// Remove the first node with value == target — O(n)
void delete(Node **head, int target) {
    if (*head == NULL) {
        // => Empty list: nothing to delete
        return;
    }

    if ((*head)->value == target) {
        // => Target is the head node — special case
        Node *old = *head;
        *head = (*head)->next;
        // => Advance head one step; old head is freed
        free(old);
        return;
    }

    Node *prev = *head;
    Node *current = (*head)->next;
    // => prev trails one step behind current so we can re-link

    while (current != NULL) {
        if (current->value == target) {
            // => Found the node to remove
            prev->next = current->next;
            // => prev now points past current to current's successor
            free(current);
            // => Free the removed node's memory
            return;
        }
        prev = current;
        current = current->next;
        // => Both pointers advance together, maintaining the one-step gap
    }
}

void traverse(Node *head) {
    printf("[");
    Node *cur = head;
    while (cur) {
        if (cur != head) printf(", ");
        printf("%d", cur->value);
        cur = cur->next;
    }
    printf("]\n");
}

void free_list(Node *head) {
    while (head) { Node *t = head; head = head->next; free(t); }
}

int main(void) {
    Node *ll = NULL;
    int vals[] = {10, 20, 30, 40};
    for (int i = 0; i < 4; i++) append(&ll, vals[i]);
    // => ll: 10 -> 20 -> 30 -> 40 -> NULL

    delete(&ll, 20);
    // => prev=Node(10), current=Node(20) — match found
    // => Node(10).next = Node(30) — re-links around the deleted node
    // => ll: 10 -> 30 -> 40 -> NULL
    traverse(ll);
    // => Output: [10, 30, 40]

    delete(&ll, 10);
    // => Head is target — head becomes Node(30)
    // => ll: 30 -> 40 -> NULL
    traverse(ll);
    // => Output: [30, 40]

    free_list(ll);
    return 0;
}

Key Takeaway: Deletion requires a trailing pointer (prev) to re-link around the removed node. Head deletion is a special case requiring direct head-pointer update.

Why It Matters: The trailing-pointer pattern appears throughout linked list operations (reverse, cycle detection, nth-from-end). Mastering it here prepares engineers for LeetCode-style interview problems and production systems where efficient in-place list manipulation reduces memory allocation overhead.


Stacks

Example 9: Stack Using a List — Push and Pop

A stack enforces Last-In First-Out (LIFO) access. Python lists implement stacks efficiently: append pushes to the top (O(1)) and pop removes from the top (O(1)).

    graph TD
    T["TOP"] --> A["30 (last pushed)"]
    A --> B["20"]
    B --> C["10 (first pushed, BOTTOM)"]

    %% Stack diagram — Blue nodes, Orange for TOP marker
    style T fill:#DE8F05,stroke:#000,color:#fff
    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#0173B2,stroke:#000,color:#fff
    style C fill:#0173B2,stroke:#000,color:#fff
  
// Example 9: Stack Using an Array — Push and Pop

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>

#define MAX_SIZE 100

typedef struct {
    int data[MAX_SIZE];  // => data is the underlying array; end = top of stack
    int top;             // => top is the index of the topmost element (-1 = empty)
} Stack;

void stack_init(Stack *s) {
    s->top = -1;
}

void push(Stack *s, int item) {
    // Add item to the top — O(1)
    if (s->top >= MAX_SIZE - 1) {
        fprintf(stderr, "stack overflow\n");
        exit(1);
    }
    s->data[++s->top] = item;
    // => Increment top then store — item is now the stack's top
}

int pop(Stack *s) {
    // Remove and return the top item — O(1)
    if (s->top < 0) {
        fprintf(stderr, "pop from empty stack\n");
        exit(1);
        // => Guard prevents popping an empty stack
    }
    return s->data[s->top--];
    // => Return value at top, then decrement — O(1)
}

int peek(Stack *s) {
    // Return the top item without removing it — O(1)
    if (s->top < 0) {
        fprintf(stderr, "peek at empty stack\n");
        exit(1);
    }
    return s->data[s->top];
}

bool is_empty(Stack *s) {
    return s->top < 0;
}

int size(Stack *s) {
    return s->top + 1;
}

int main(void) {
    Stack stack;
    stack_init(&stack);

    push(&stack, 10);   // => data is [10]
    push(&stack, 20);   // => data is [10, 20]
    push(&stack, 30);   // => data is [10, 20, 30] — 30 is top

    printf("%d\n", peek(&stack));  // => Output: 30 (top, not removed)
    printf("%d\n", size(&stack));  // => Output: 3

    int top = pop(&stack);  // => top is 30; data is [10, 20]
    printf("%d\n", top);    // => Output: 30

    top = pop(&stack);       // => top is 20; data is [10]
    printf("%d\n", peek(&stack)); // => Output: 10

    printf("%s\n", is_empty(&stack) ? "true" : "false"); // => Output: false

    return 0;
}

Key Takeaway: Push and pop at the list’s end are O(1). Never pop from the front of a list (O(n)) when implementing a stack.

Why It Matters: Stacks power function call frames, undo/redo in editors, expression evaluation, and backtracking algorithms (DFS). Python’s own call stack is a stack. Implementing one correctly demonstrates understanding of LIFO semantics, which interviews frequently probe through balanced-parentheses and next-greater-element problems.


Example 10: Stack Application — Balanced Parentheses

Checking whether parentheses, brackets, and braces are balanced is a classic stack application. Every opening symbol pushes to the stack; every closing symbol must match the top.

// Example 10: Stack Application — Balanced Parentheses

#include <stdio.h>
#include <stdbool.h>
#include <string.h>

#define MAX_SIZE 256

bool is_balanced(const char *expression) {
    // Return true if every opening symbol has a matching closing symbol
    // in the correct nesting order — O(n) time, O(n) space
    char stack[MAX_SIZE];
    int top = -1;
    // => Stack accumulates unmatched opening symbols

    for (int i = 0; expression[i] != '\0'; i++) {
        char ch = expression[i];
        // => Process each character left-to-right

        if (ch == '(' || ch == '[' || ch == '{') {
            stack[++top] = ch;
            // => Opening symbol: push onto stack, wait for matching close
        } else if (ch == ')' || ch == ']' || ch == '}') {
            // => Closing symbol encountered
            if (top < 0) {
                // => No unmatched opening — structure is broken
                return false;
            }
            char expected;
            if (ch == ')') expected = '(';
            else if (ch == ']') expected = '[';
            else expected = '{';

            if (stack[top] != expected) {
                // => Top of stack doesn't match this closing symbol
                return false;
            }
            top--;
            // => Matched pair — remove the opening symbol from stack
        }
    }

    return top == -1;
    // => If stack is empty, every opening symbol was matched
}

int main(void) {
    printf("%s\n", is_balanced("({[]})") ? "true" : "false");
    // => Output: true

    printf("%s\n", is_balanced("([)]") ? "true" : "false");
    // => Output: false

    printf("%s\n", is_balanced("{[}") ? "true" : "false");
    // => Output: false

    printf("%s\n", is_balanced("") ? "true" : "false");
    // => Output: true

    return 0;
}

Key Takeaway: The stack’s LIFO property naturally enforces correct nesting: the most recently opened symbol must close before any earlier one.

Why It Matters: This exact algorithm runs inside code editors (syntax highlighting), compilers (parse tree construction), and JSON/XML validators. Interviewers use it to probe whether candidates understand LIFO semantics and can handle edge cases (empty stack before pop, leftover elements at end).


Example 11: Stack Application — Reverse a String

Stacks reverse sequences naturally — push all characters, then pop them all. The first character pushed is the last popped.

// Example 11: Stack Application — Reverse a String

#include <stdio.h>
#include <string.h>

void reverse_string(const char *text, char *out) {
    // Reverse a string using a stack — O(n) time, O(n) space
    int len = strlen(text);
    char stack[256];
    int top = -1;
    // => Stack will hold individual characters

    for (int i = 0; i < len; i++) {
        // => Push every character left-to-right
        stack[++top] = text[i];
        // => After "hello": stack is ['h','e','l','l','o']
        // => 'o' is the top
    }

    int idx = 0;
    while (top >= 0) {
        // => Pop until stack is empty — LIFO reverses the order
        out[idx++] = stack[top--];
        // => First pop: 'o', second: 'l', third: 'l', fourth: 'e', fifth: 'h'
    }
    out[idx] = '\0';
    // => Null-terminate the reversed string
}

int main(void) {
    char result[256];

    reverse_string("hello", result);
    printf("%s\n", result);
    // => Push: h,e,l,l,o — Pop: o,l,l,e,h
    // => Output: olleh

    reverse_string("Python", result);
    printf("%s\n", result);
    // => Push: P,y,t,h,o,n — Pop: n,o,h,t,y,P
    // => Output: nohtyP

    reverse_string("", result);
    printf("%s\n", result);
    // => Empty string: no pushes, no pops — Output: (empty string)

    reverse_string("a", result);
    printf("%s\n", result);
    // => Push: a — Pop: a — Output: a (single char reverses to itself)

    return 0;
}

Key Takeaway: Pushing a sequence then popping it yields the sequence in reverse — LIFO is inherently a reversal mechanism.

Why It Matters: This pattern appears in palindrome checking, undo-chain traversal, and recursive call unwinding. While Python’s slice syntax is faster for strings, the explicit stack model builds intuition for why recursive algorithms automatically reverse order when unwinding — a key insight for understanding depth-first search, backtracking, and postorder tree traversal.


Queues

Example 12: Queue Using collections.deque — Enqueue and Dequeue

A queue enforces First-In First-Out (FIFO) access. collections.deque provides O(1) append on the right and O(1) popleft — the correct implementation for production queues.

    graph LR
    ENQ["Enqueue (rear)"] --> C["30"]
    C --> B["20"]
    B --> A["10"]
    A --> DEQ["Dequeue (front)"]

    %% Queue diagram — Teal for enqueue side, Orange for dequeue side
    style ENQ fill:#029E73,stroke:#000,color:#fff
    style C fill:#0173B2,stroke:#000,color:#fff
    style B fill:#0173B2,stroke:#000,color:#fff
    style A fill:#0173B2,stroke:#000,color:#fff
    style DEQ fill:#DE8F05,stroke:#000,color:#fff
  
// Example 12: Queue Using an Array — Enqueue and Dequeue

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>

#define MAX_SIZE 100

typedef struct {
    char *data[MAX_SIZE];
    int front;  // => Index of the front element
    int rear;   // => Index past the last element
    int count;  // => Number of elements currently in queue
} Queue;

void queue_init(Queue *q) {
    q->front = 0;
    q->rear = 0;
    q->count = 0;
}

void enqueue(Queue *q, const char *item) {
    // Add item to the rear — O(1)
    if (q->count >= MAX_SIZE) {
        fprintf(stderr, "queue overflow\n");
        exit(1);
    }
    q->data[q->rear] = strdup(item);
    q->rear = (q->rear + 1) % MAX_SIZE;
    // => Circular buffer: wrap rear around using modulo
    q->count++;
}

char *dequeue(Queue *q) {
    // Remove and return the front item — O(1)
    if (q->count == 0) {
        fprintf(stderr, "dequeue from empty queue\n");
        exit(1);
    }
    char *item = q->data[q->front];
    q->front = (q->front + 1) % MAX_SIZE;
    // => Circular buffer: advance front — O(1)
    // => No shifting needed unlike a naive array approach
    q->count--;
    return item;
}

const char *peek(Queue *q) {
    // Return the front item without removing — O(1)
    if (q->count == 0) {
        fprintf(stderr, "peek at empty queue\n");
        exit(1);
    }
    return q->data[q->front];
}

bool is_empty(Queue *q) { return q->count == 0; }
int size(Queue *q) { return q->count; }

int main(void) {
    Queue q;
    queue_init(&q);

    enqueue(&q, "first");    // => queue: ["first"]
    enqueue(&q, "second");   // => queue: ["first", "second"]
    enqueue(&q, "third");    // => queue: ["first", "second", "third"]

    printf("%s\n", peek(&q));    // => Output: first (front, not removed)
    printf("%d\n", size(&q));    // => Output: 3

    char *item = dequeue(&q);   // => Removes "first"
    printf("%s\n", item);        // => Output: first
    free(item);

    item = dequeue(&q);          // => Removes "second"
    printf("%s\n", item);        // => Output: second
    free(item);

    printf("%s\n", is_empty(&q) ? "true" : "false"); // => Output: false

    // Cleanup remaining
    while (!is_empty(&q)) free(dequeue(&q));
    return 0;
}

Key Takeaway: Always use collections.deque for queues, not a plain list with pop(0). The deque’s O(1) popleft versus the list’s O(n) pop(0) makes a critical performance difference at scale.

Why It Matters: Queues model task schedulers, print spoolers, BFS graph traversal, and producer-consumer pipelines. Using list.pop(0) in a queue serving millions of requests per day would cause O(n) degradation for every dequeue — the deque avoids this entirely with its doubly-linked block structure.


Example 13: Queue Application — First-Come First-Served Simulation

A simple simulation where customers arrive in order and are served one at a time demonstrates queue semantics in a real-world context.

// Example 13: Queue Application — First-Come First-Served Simulation

#include <stdio.h>
#include <string.h>

#define MAX_SIZE 100

typedef struct {
    char data[MAX_SIZE][64];
    int front, rear, count;
} Queue;

void queue_init(Queue *q) { q->front = 0; q->rear = 0; q->count = 0; }
void enqueue(Queue *q, const char *s) {
    strcpy(q->data[q->rear], s);
    q->rear = (q->rear + 1) % MAX_SIZE;
    q->count++;
}
const char *dequeue_str(Queue *q) {
    const char *item = q->data[q->front];
    q->front = (q->front + 1) % MAX_SIZE;
    q->count--;
    return item;
}

void simulate_service(const char *customers[], int n) {
    // Simulate a single-server queue — O(n)
    Queue queue;
    queue_init(&queue);
    for (int i = 0; i < n; i++) {
        enqueue(&queue, customers[i]);
    }
    // => Initialize queue with all customers in arrival order

    while (queue.count > 0) {
        // => Continue until all customers have been served
        const char *current = dequeue_str(&queue);
        // => Dequeue the customer who waited longest (FIFO)
        printf("  Serving: %s\n", current);
        // => In a real system, this is where processing logic would go
    }
}

int main(void) {
    const char *customers[] = {"Alice", "Bob", "Charlie", "Diana", "Eve"};
    // => Alice arrived first and will be served first (FIFO)

    printf("Service order:\n");
    simulate_service(customers, 5);
    // => Alice served first, then Bob, then Charlie, then Diana, then Eve

    printf("\nFIFO preserved: arrival order = service order\n");
    return 0;
}

Key Takeaway: A queue guarantees fairness — every item is processed in the exact order it arrived. No item is skipped or reordered.

Why It Matters: FIFO queues underpin message brokers (RabbitMQ, Kafka consumer groups), operating system CPU schedulers, and HTTP request handling in web servers. Understanding FIFO semantics prevents bugs where developers accidentally use stacks (LIFO) when they need fair, ordered processing.


Deques

Example 14: Deque — Double-Ended Operations

A deque (double-ended queue) supports O(1) push and pop at both ends. Python’s collections.deque implements this with a doubly-linked list of memory blocks.

// Example 14: Deque — Double-Ended Operations

#include <stdio.h>

#define MAX_SIZE 100

typedef struct {
    int data[MAX_SIZE];
    int front; // => Index of front element
    int rear;  // => Index past last element
    int count;
} Deque;

void deque_init(Deque *d) { d->front = 0; d->rear = 0; d->count = 0; }

void append_right(Deque *d, int val) {
    // Add to the right end — O(1)
    d->data[d->rear] = val;
    d->rear = (d->rear + 1) % MAX_SIZE;
    d->count++;
}

void append_left(Deque *d, int val) {
    // Add to the left end — O(1) with circular buffer
    d->front = (d->front - 1 + MAX_SIZE) % MAX_SIZE;
    d->data[d->front] = val;
    d->count++;
}

int pop_right(Deque *d) {
    // Remove from the right end — O(1)
    d->rear = (d->rear - 1 + MAX_SIZE) % MAX_SIZE;
    d->count--;
    return d->data[d->rear];
}

int pop_left(Deque *d) {
    // Remove from the left end — O(1)
    int val = d->data[d->front];
    d->front = (d->front + 1) % MAX_SIZE;
    d->count--;
    return val;
}

void print_deque(Deque *d) {
    printf("[");
    for (int i = 0; i < d->count; i++) {
        if (i > 0) printf(", ");
        printf("%d", d->data[(d->front + i) % MAX_SIZE]);
    }
    printf("]\n");
}

int main(void) {
    Deque dq;
    deque_init(&dq);

    // Add to the right end
    append_right(&dq, 10);  // => dq: [10]
    append_right(&dq, 20);  // => dq: [10, 20]
    append_right(&dq, 30);  // => dq: [10, 20, 30]

    // Add to the left end — O(1) with circular buffer
    append_left(&dq, 5);    // => dq: [5, 10, 20, 30]
    append_left(&dq, 1);    // => dq: [1, 5, 10, 20, 30]
    print_deque(&dq);
    // => Output: [1, 5, 10, 20, 30]

    // Remove from the right end
    int right = pop_right(&dq);
    // => right is 30; dq: [1, 5, 10, 20]
    printf("%d\n", right);
    // => Output: 30

    // Remove from the left end
    int left = pop_left(&dq);
    // => left is 1; dq: [5, 10, 20]
    printf("%d\n", left);
    // => Output: 1

    print_deque(&dq);
    // => Output: [5, 10, 20]

    // Bounded deque: sliding window of last 3 values
    Deque window;
    deque_init(&window);
    int vals[] = {1, 2, 3, 4, 5};
    for (int i = 0; i < 5; i++) {
        append_right(&window, vals[i]);
        if (window.count > 3) pop_left(&window);
        // => Manually evict oldest when size exceeds 3
    }
    print_deque(&window);
    // => Output: [3, 4, 5]

    return 0;
}

Key Takeaway: deque with maxlen implements a sliding window automatically — new elements push old ones out without any manual deletion code.

Why It Matters: Sliding window problems (moving average, recent N events, rate limiting) are common in real-time systems and data streams. The maxlen deque eliminates manual eviction logic and reduces bugs. Interview problems on sliding window maximum and minimum also map directly to deque operations.


Example 15: Deque Application — Palindrome Check

A palindrome reads the same forwards and backwards. A deque lets us compare the front and back characters simultaneously by popping from both ends.

// Example 15: Deque Application — Palindrome Check

#include <stdio.h>
#include <stdbool.h>
#include <string.h>
#include <ctype.h>

bool is_palindrome(const char *text) {
    // Check whether text is a palindrome (case-insensitive, ignores spaces) — O(n)
    char cleaned[256];
    int len = 0;

    // Normalize: lowercase, remove spaces
    for (int i = 0; text[i] != '\0'; i++) {
        if (text[i] != ' ') {
            cleaned[len++] = tolower(text[i]);
        }
    }
    // => "Race Car" -> "racecar"

    int left = 0;
    int right = len - 1;

    while (left < right) {
        // => Stop when pointers meet (0 or 1 char remaining)
        if (cleaned[left] != cleaned[right]) {
            // => Mismatch found — not a palindrome
            return false;
        }
        left++;
        right--;
        // => Move both ends inward — equivalent to popleft + pop
    }

    return true;
    // => All pairs matched — text is a palindrome
}

int main(void) {
    printf("%s\n", is_palindrome("racecar") ? "true" : "false");
    // => Output: true

    printf("%s\n", is_palindrome("hello") ? "true" : "false");
    // => h vs o — mismatch
    // => Output: false

    printf("%s\n", is_palindrome("A man a plan a canal Panama") ? "true" : "false");
    // => Cleaned: "amanaplanacanalpanama" — Output: true

    printf("%s\n", is_palindrome("a") ? "true" : "false");
    // => Single character — Output: true

    printf("%s\n", is_palindrome("ab") ? "true" : "false");
    // => a vs b — mismatch — Output: false

    return 0;
}

Key Takeaway: Simultaneous O(1) access to both ends makes the deque ideal for bidirectional comparison problems without array index arithmetic.

Why It Matters: Palindrome checking is a foundational interview question, but the deque pattern generalizes to any problem requiring simultaneous front-back processing: two-pointer comparisons on linked lists, verifying mirror-image structures, and bidirectional BFS meeting-in-the-middle optimizations.


Linear Search

Example 16: Linear Search — Unsorted Array

Linear search scans every element from left to right until the target is found or the array is exhausted. Time complexity is O(n) worst case.

// Example 16: Linear Search — Unsorted Array

#include <stdio.h>

int linear_search(int arr[], int n, int target) {
    // Search for target in arr, returning its index or -1 if not found.
    // Time: O(n) — must examine every element in the worst case.
    // Space: O(1) — no extra data structures needed.
    for (int i = 0; i < n; i++) {
        // => i iterates from 0 to n-1
        if (arr[i] == target) {
            // => Found the target at index i
            return i;
            // => Return immediately — no need to scan further
        }
    }
    return -1;
    // => Scanned every element without finding target
}


int main(void) {
    int data[] = {64, 25, 12, 22, 11};
    // => Unsorted array — binary search cannot be used here

    printf("%d\n", linear_search(data, 5, 22));
    // => Checks: 64 (no), 25 (no), 12 (no), 22 (yes!) — found at index 3
    // => Output: 3

    printf("%d\n", linear_search(data, 5, 99));
    // => Checks all 5 elements without finding 99 — worst case O(n)
    // => Output: -1

    return 0;
}

Key Takeaway: Linear search is the only option for unsorted data. It is O(n) in worst and average case, and O(1) best case (first element matches).

Why It Matters: Most real-world data is unsorted or arrives dynamically. Linear search is the fallback when no sorted index or hash map is available. Understanding its O(n) cost motivates building indexes (sorted structures, hash maps) for frequently queried fields — a core database design principle.


Example 17: Linear Search — Finding All Occurrences

Rather than stopping at the first match, collecting all matching indices requires scanning the entire array. This is still O(n) but returns a list of positions.

// Example 17: Linear Search — Finding All Occurrences

#include <stdio.h>

int find_all(int arr[], int n, int target, int indices[], int *count) {
    // Return all indices where target appears — O(n).
    // Unlike find-first, this always scans the entire array.
    *count = 0;
    for (int i = 0; i < n; i++) {
        if (arr[i] == target) {
            // => Another occurrence found
            indices[(*count)++] = i;
            // => Add index to results; do NOT break — keep scanning
        }
    }
    return *count;
    // => 0 if target not present; count of indices otherwise
}

void print_indices(int indices[], int count) {
    printf("[");
    for (int i = 0; i < count; i++) {
        if (i > 0) printf(", ");
        printf("%d", indices[i]);
    }
    printf("]\n");
}

int main(void) {
    int scores[] = {85, 92, 78, 92, 65, 92, 88};
    // => The value 92 appears at indices 1, 3, and 5
    int indices[10], count;

    find_all(scores, 7, 92, indices, &count);
    print_indices(indices, count);
    // => Scans all 7 elements; matches at index 1, 3, 5
    // => Output: [1, 3, 5]

    find_all(scores, 7, 100, indices, &count);
    print_indices(indices, count);
    // => Scans all 7 elements; no matches found
    // => Output: []

    find_all(scores, 7, 65, indices, &count);
    print_indices(indices, count);
    // => Match at index 4
    // => Output: [4]

    find_all(scores, 7, 92, indices, &count);
    printf("92 appears %d times\n", count);
    // => Output: 92 appears 3 times

    return 0;
}

Key Takeaway: Finding all occurrences always costs O(n) because the entire array must be scanned regardless of where matches are found.

Why It Matters: Collecting all occurrences appears in log analysis (find all error lines), database full-table scans (no index on column), and text search. The performance cost motivates inverted indexes (used by search engines and databases) that map values to their positions for O(1) lookup instead of O(n) scanning.


Big-O Basics

Example 18: Big-O — O(1), O(n), and O(n²) in Practice

Big-O notation classifies how an algorithm’s running time grows as input size n increases. These three complexities are the foundation of algorithmic analysis.

    graph TD
    O1["O(1) Constant\nArray index access"]
    ON["O(n) Linear\nArray scan / linear search"]
    ON2["O(n²) Quadratic\nNested loop comparison"]

    O1 --> ON
    ON --> ON2

    %% Complexity hierarchy diagram
    style O1 fill:#029E73,stroke:#000,color:#fff
    style ON fill:#DE8F05,stroke:#000,color:#fff
    style ON2 fill:#CC78BC,stroke:#000,color:#fff
  
// Example 18: Big-O — O(1), O(n), and O(n²) in Practice

#include <stdio.h>

// -----------------------------------------------------------
// O(1) — Constant time: work does not grow with input size
// -----------------------------------------------------------
int get_first(int arr[], int n) {
    // Always exactly one operation regardless of array length
    return arr[0];
    // => Index access: single memory read — O(1)
}

// -----------------------------------------------------------
// O(n) — Linear time: work grows proportionally with input size
// -----------------------------------------------------------
int find_max(int arr[], int n) {
    // Scan entire array to find the maximum — O(n)
    int maximum = arr[0];
    // => Start with first element as the current maximum
    for (int i = 1; i < n; i++) {
        // => Visit every element — n comparisons for n elements
        if (arr[i] > maximum) {
            maximum = arr[i];
        }
    }
    return maximum;
    // => Double the input size -> double the work
}

// -----------------------------------------------------------
// O(n²) — Quadratic: work grows as square of input size
// -----------------------------------------------------------
void find_duplicates(int arr[], int n) {
    // Check every pair for duplicates using nested loops — O(n²)
    for (int i = 0; i < n; i++) {
        // => Outer loop: n iterations
        for (int j = i + 1; j < n; j++) {
            // => Inner loop: up to n-1, n-2, ... 1 iterations
            // => Total pairs: n*(n-1)/2 — grows as n²
            if (arr[i] == arr[j]) {
                printf("(%d, %d) ", i, j);
            }
        }
    }
    printf("\n");
}

int main(void) {
    int data[] = {3, 1, 4, 1, 5, 9, 2, 6, 5};
    int n = 9;

    printf("%d\n", get_first(data, n));
    // => Output: 3 — one operation, always O(1)

    printf("%d\n", find_max(data, n));
    // => Output: 9 — scanned all 9 elements, O(n)

    find_duplicates(data, n);
    // => Compares 9*8/2 = 36 pairs; finds (1,3)->value 1, (4,8)->value 5
    // => Output: (1, 3) (4, 8)

    return 0;
}

Key Takeaway: O(1) is ideal, O(n) is acceptable for most tasks, and O(n²) becomes painful for large n. A 1000-element input means 1 operation (O(1)), 1,000 operations (O(n)), or 1,000,000 operations (O(n²)).

Why It Matters: Understanding these three complexity classes is the foundation of every performance conversation in software engineering. Code reviews, architecture decisions, and system capacity planning all hinge on recognizing O(1) vs O(n) vs O(n²) patterns. The nested-loop duplicate-finder above becomes impractical at n=100,000 — a hash set reduces it to O(n).


Basic Sorting Algorithms

Example 19: Bubble Sort

Bubble sort repeatedly compares adjacent elements and swaps them if they are out of order. Large elements “bubble up” to the end. Time complexity is O(n²) average and worst case, O(n) best case (already sorted with early termination).

    graph LR
    P1["Pass 1\n[64,25,12,22,11]"] --> P2["Pass 2\n[25,12,22,11,64]"]
    P2 --> P3["Pass 3\n[12,11,22,25,64]"]
    P3 --> P4["Sorted\n[11,12,22,25,64]"]

    %% Bubble sort passes — each pass places one more element correctly
    style P1 fill:#0173B2,stroke:#000,color:#fff
    style P2 fill:#DE8F05,stroke:#000,color:#fff
    style P3 fill:#029E73,stroke:#000,color:#fff
    style P4 fill:#CC78BC,stroke:#000,color:#fff
  
// Example 19: Bubble Sort

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

void bubble_sort(int arr[], int n) {
    // Sort an array in-place using bubble sort — O(n²) average, O(n) best
    for (int i = 0; i < n; i++) {
        // => i counts completed passes; after pass i, the last i elements are sorted
        bool swapped = false;
        // => Track whether any swap occurred in this pass

        for (int j = 0; j < n - i - 1; j++) {
            // => Inner loop: compare adjacent pairs, shrinking range each pass
            // => n-i-1 because the last i positions are already sorted
            if (arr[j] > arr[j + 1]) {
                // => Out-of-order pair found: swap them
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
                swapped = true;
                // => Mark that we did work this pass
            }
        }

        if (!swapped) {
            // => No swaps means the array is fully sorted — exit early
            // => This optimization gives O(n) best case for sorted input
            break;
        }
    }
}

void print_array(int arr[], int n) {
    printf("[");
    for (int i = 0; i < n; i++) {
        if (i > 0) printf(", ");
        printf("%d", arr[i]);
    }
    printf("]\n");
}

int main(void) {
    int data[] = {64, 25, 12, 22, 11};
    int n = 5;

    bubble_sort(data, n);
    print_array(data, n);
    // => Output: [11, 12, 22, 25, 64]

    // Already sorted — early termination kicks in
    int sorted_data[] = {1, 2, 3, 4, 5};
    bubble_sort(sorted_data, 5);
    print_array(sorted_data, 5);
    // => Pass 1: no swaps — break immediately — O(n)
    // => Output: [1, 2, 3, 4, 5]

    return 0;
}

Key Takeaway: Bubble sort is the simplest sorting algorithm but rarely used in production due to O(n²) average complexity. The swapped flag optimization saves time on nearly-sorted data.

Why It Matters: Bubble sort teaches the fundamental sorting intuition: repeated passes progressively “settle” elements into place. While production code uses Python’s built-in Timsort (O(n log n)), understanding bubble sort builds the mental model for why comparison-based sorting has lower bounds and motivates learning better algorithms.


Example 20: Selection Sort

Selection sort repeatedly finds the minimum element in the unsorted portion and swaps it to the front. Unlike bubble sort, it makes at most n-1 swaps — useful when swapping is expensive. Time complexity is always O(n²).

// Example 20: Selection Sort

#include <stdio.h>

void selection_sort(int arr[], int n) {
    // Sort an array in-place by repeatedly selecting the minimum — O(n²)
    for (int i = 0; i < n - 1; i++) {
        // => Positions 0..i-1 are already sorted
        int min_idx = i;
        // => Assume current position i holds the minimum initially

        for (int j = i + 1; j < n; j++) {
            // => Scan the unsorted portion for a smaller element
            if (arr[j] < arr[min_idx]) {
                min_idx = j;
                // => Found a new minimum; record its index
            }
        }

        if (min_idx != i) {
            // => Minimum is not already in place — swap it to position i
            int temp = arr[i];
            arr[i] = arr[min_idx];
            arr[min_idx] = temp;
            // => One swap per outer iteration at most — total swaps <= n-1
        }
    }
}

void print_array(int arr[], int n) {
    printf("[");
    for (int i = 0; i < n; i++) {
        if (i > 0) printf(", ");
        printf("%d", arr[i]);
    }
    printf("]\n");
}

int main(void) {
    int data[] = {64, 25, 12, 22, 11};
    selection_sort(data, 5);
    print_array(data, 5);
    // => Output: [11, 12, 22, 25, 64]

    return 0;
}

Key Takeaway: Selection sort minimizes the number of swaps to O(n), making it preferable over bubble sort when write operations are costly (e.g., writing to slow flash storage).

Why It Matters: Selection sort’s O(n) write characteristic appears in embedded systems and EEPROM wear-leveling where each write reduces component lifespan. Understanding which metric (comparisons vs swaps vs memory) to minimize for a given hardware context is the kind of practical algorithmic reasoning that separates senior engineers from junior developers.


Example 21: Insertion Sort

Insertion sort builds a sorted portion from left to right, inserting each new element into its correct position. It is efficient for small arrays and nearly-sorted data, with O(n) best case and O(n²) worst case.

// Example 21: Insertion Sort

#include <stdio.h>

void insertion_sort(int arr[], int n) {
    // Sort an array in-place using insertion sort — O(n²) worst, O(n) best
    for (int i = 1; i < n; i++) {
        // => Consider arr[0..i-1] already sorted; insert arr[i] into that region
        int key = arr[i];
        // => key is the element we want to insert into the sorted left portion

        int j = i - 1;
        // => j starts at the last element of the sorted portion

        while (j >= 0 && arr[j] > key) {
            // => Shift element one position to the right
            arr[j + 1] = arr[j];
            j--;
            // => Move left through the sorted portion
        }

        arr[j + 1] = key;
        // => Place key in its correct sorted position
    }
}

void print_array(int arr[], int n) {
    printf("[");
    for (int i = 0; i < n; i++) {
        if (i > 0) printf(", ");
        printf("%d", arr[i]);
    }
    printf("]\n");
}

int main(void) {
    int data[] = {12, 11, 13, 5, 6};
    insertion_sort(data, 5);
    print_array(data, 5);
    // => Output: [5, 6, 11, 12, 13]

    int nearly[] = {1, 2, 3, 5, 4};
    insertion_sort(nearly, 5);
    print_array(nearly, 5);
    // => Only one element out of place — minimal shifting — near O(n)
    // => Output: [1, 2, 3, 4, 5]

    return 0;
}

Key Takeaway: Insertion sort is optimal for nearly-sorted arrays and small n. Python’s own Timsort uses insertion sort internally for short subarrays, validating its practical utility.

Why It Matters: Real-world data is often nearly sorted (log files with timestamps, live feeds arriving almost in order). Insertion sort’s adaptive nature — O(n) for nearly-sorted input — makes it faster than quicksort or mergesort in these conditions. Recognizing input characteristics and choosing accordingly is the hallmark of experienced algorithm selection.


Example 22: Comparing Sorting Algorithms

Seeing all three sorting algorithms process the same input side-by-side reinforces their behavioral differences and helps build intuition for choosing the right one.

// Example 22: Comparing Sorting Algorithms

#include <stdio.h>
#include <stdbool.h>
#include <string.h>

void bubble_sort(int arr[], int n) {
    // Bubble sort — O(n²), many swaps
    for (int i = 0; i < n; i++) {
        bool swapped = false;
        for (int j = 0; j < n - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                int t = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = t;
                swapped = true;
            }
        }
        if (!swapped) break;
    }
}

void selection_sort(int arr[], int n) {
    // Selection sort — O(n²), at most n swaps
    for (int i = 0; i < n - 1; i++) {
        int min_idx = i;
        for (int j = i + 1; j < n; j++) {
            if (arr[j] < arr[min_idx]) min_idx = j;
        }
        if (min_idx != i) {
            int t = arr[i]; arr[i] = arr[min_idx]; arr[min_idx] = t;
        }
    }
}

void insertion_sort(int arr[], int n) {
    // Insertion sort — O(n²) worst, O(n) best
    for (int i = 1; i < n; i++) {
        int key = arr[i];
        int j = i - 1;
        while (j >= 0 && arr[j] > key) {
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = key;
    }
}

void print_result(const char *name, int arr[], int n) {
    printf("%s: [", name);
    for (int i = 0; i < n; i++) {
        if (i > 0) printf(", ");
        printf("%d", arr[i]);
    }
    printf("]\n");
}

int main(void) {
    int test_data[] = {64, 34, 25, 12, 22, 11, 90};
    int n = 7;
    int copy[7];

    // Apply each algorithm to a fresh copy of the same test data
    memcpy(copy, test_data, sizeof(test_data));
    bubble_sort(copy, n);
    print_result("Bubble Sort", copy, n);

    memcpy(copy, test_data, sizeof(test_data));
    selection_sort(copy, n);
    print_result("Selection Sort", copy, n);

    memcpy(copy, test_data, sizeof(test_data));
    insertion_sort(copy, n);
    print_result("Insertion Sort", copy, n);

    // => All three produce: [11, 12, 22, 25, 34, 64, 90]
    printf("\nAll three produce identical sorted output.\n");
    return 0;
}

Key Takeaway: All three algorithms produce correct sorted output; they differ in number of comparisons, swaps, and best-case behavior — not in correctness.

Why It Matters: Algorithm selection is a practical engineering skill. Picking bubble sort for a large unsorted array in a hot path is a real performance bug. Understanding when each algorithm excels prevents premature optimization and avoids the opposite mistake: using an overly complex algorithm for a 10-element array that insertion sort would sort faster.


Basic Recursion

Example 23: Factorial — Recursive vs Iterative

Factorial n! = n × (n-1) × … × 1 is the canonical recursion example. Every recursive solution has a base case (n ≤ 1) and a recursive case (n × factorial(n-1)).

    graph TD
    F5["factorial(5)\n= 5 × factorial(4)"]
    F4["factorial(4)\n= 4 × factorial(3)"]
    F3["factorial(3)\n= 3 × factorial(2)"]
    F2["factorial(2)\n= 2 × factorial(1)"]
    F1["factorial(1)\n= 1  BASE CASE"]

    F5 --> F4
    F4 --> F3
    F3 --> F2
    F2 --> F1

    %% Recursion tree — each level gets a different color
    style F5 fill:#0173B2,stroke:#000,color:#fff
    style F4 fill:#DE8F05,stroke:#000,color:#fff
    style F3 fill:#029E73,stroke:#000,color:#fff
    style F2 fill:#CC78BC,stroke:#000,color:#fff
    style F1 fill:#CA9161,stroke:#000,color:#fff
  
// Example 23: Factorial — Recursive vs Iterative

#include <stdio.h>

long factorial_recursive(int n) {
    // Compute n! recursively — O(n) time, O(n) space (call stack depth)
    if (n <= 1) {
        // => Base case: factorial(0) = 1, factorial(1) = 1 by definition
        // => Without a base case, recursion never stops — stack overflow
        return 1;
    }
    return n * factorial_recursive(n - 1);
    // => Recursive case: defer to factorial(n-1), multiply by n when returning
}

long factorial_iterative(int n) {
    // Compute n! iteratively — O(n) time, O(1) space (no call stack growth)
    long result = 1;
    // => Accumulate the product starting from 1
    for (int i = 2; i <= n; i++) {
        // => Multiply by 2, 3, 4, ..., n in sequence
        result *= i;
    }
    return result;
}

int main(void) {
    printf("%ld\n", factorial_recursive(5));
    // => 5->4->3->2->1(base)->returns 1->2*1=2->3*2=6->4*6=24->5*24=120
    // => Output: 120

    printf("%ld\n", factorial_iterative(5));
    // => 1*2=2, 2*3=6, 6*4=24, 24*5=120
    // => Output: 120

    printf("%ld\n", factorial_recursive(0));
    // => Base case hit immediately — Output: 1

    printf("%ld\n", factorial_iterative(10));
    // => Output: 3628800

    return 0;
}

Key Takeaway: Every recursive function needs a base case (stops recursion) and a recursive case (reduces the problem). Iterative versions save stack space and avoid Python’s recursion limit.

Why It Matters: Recursion is foundational to tree/graph traversal, divide-and-conquer algorithms (merge sort, quicksort), and parsing nested structures. Python’s default 1000-frame recursion limit means production code for deep recursion must use iteration or explicit stacks. Understanding the call stack depth cost drives when to prefer tail-call-optimized loops.


Example 24: Fibonacci — Naive Recursion and Memoization

Naive Fibonacci recursion recomputes the same values exponentially. Memoization caches results, reducing complexity from O(2^n) to O(n).

// Example 24: Fibonacci — Naive Recursion and Memoization

#include <stdio.h>

// -------------------------------------------
// Naive recursion — O(2^n) exponential time
// -------------------------------------------
int fib_naive(int n) {
    // Compute the nth Fibonacci number naively — exponential O(2^n)
    if (n <= 1) {
        // => Base cases: fib(0)=0, fib(1)=1
        return n;
    }
    return fib_naive(n - 1) + fib_naive(n - 2);
    // => Two recursive calls — binary tree of calls, O(2^n) total work
}

// -------------------------------------------
// Memoized recursion — O(n) time, O(n) space
// -------------------------------------------
long cache[100];
int cached[100]; // => 0 = not cached, 1 = cached

long fib_memo(int n) {
    // Compute nth Fibonacci with memoization — O(n) time, O(n) space
    if (n <= 1) return n;
    // => Base cases: no caching needed

    if (cached[n]) {
        return cache[n];
        // => Cache hit: return stored result without re-computing — O(1)
    }

    cache[n] = fib_memo(n - 1) + fib_memo(n - 2);
    // => Compute result from sub-problems (each computed only once)
    cached[n] = 1;
    // => Store result before returning to avoid future recomputation

    return cache[n];
}

int main(void) {
    for (int i = 0; i < 8; i++) {
        printf("fib(%d) = %d\n", i, fib_naive(i));
        // => fib(0)=0, fib(1)=1, fib(2)=1, ... fib(7)=13
    }

    printf("\nMemoized (same results, much faster for large n):\n");
    for (int i = 0; i < 100; i++) { cached[i] = 0; } // reset cache
    for (int i = 0; i < 8; i++) {
        printf("fib_memo(%d) = %ld\n", i, fib_memo(i));
    }

    // fib_naive(35) takes seconds; fib_memo(35) is near-instant
    for (int i = 0; i < 100; i++) { cached[i] = 0; }
    printf("\nfib_memo(35) = %ld\n", fib_memo(35));
    // => Output: fib_memo(35) = 9227465

    return 0;
}

Key Takeaway: Memoization transforms overlapping-subproblem recursion from exponential to linear by caching each unique result. This is the foundation of dynamic programming.

Why It Matters: The naive-to-memoized Fibonacci transformation illustrates the core dynamic programming insight: overlapping subproblems computed repeatedly without caching turn linear-looking code into exponential execution. This pattern appears in route optimization, resource scheduling, and sequence alignment in bioinformatics — all production DP problems.


Example 25: Recursive Sum and Binary Search (Recursive)

Applying recursion to a sorted array with binary search demonstrates divide-and-conquer: split the problem in half each time, reducing O(n) to O(log n).

// Example 25: Recursive Sum and Binary Search (Recursive)

#include <stdio.h>

int recursive_sum(int arr[], int n) {
    // Sum all elements recursively — O(n) time, O(n) stack space
    if (n == 0) {
        // => Base case: empty array, contribution is 0
        return 0;
    }
    return arr[0] + recursive_sum(arr + 1, n - 1);
    // => First element plus sum of the rest (pointer arithmetic)
}

int binary_search_recursive(int arr[], int target, int low, int high) {
    // Search sorted arr[low..high] for target — O(log n)
    if (low > high) {
        // => Search space exhausted — target not in array
        return -1;
    }

    int mid = (low + high) / 2;
    // => Integer division gives the middle index

    if (arr[mid] == target) {
        // => Found the target at the midpoint
        return mid;
    } else if (arr[mid] < target) {
        // => Target is in the right half
        return binary_search_recursive(arr, target, mid + 1, high);
    } else {
        // => Target is in the left half
        return binary_search_recursive(arr, target, low, mid - 1);
    }
}

int main(void) {
    // Recursive sum
    int numbers[] = {1, 2, 3, 4, 5};
    printf("%d\n", recursive_sum(numbers, 5));
    // => 1 + sum([2,3,4,5]) -> ... -> 1+2+3+4+5 = 15
    // => Output: 15

    // Recursive binary search
    int sorted_arr[] = {2, 5, 8, 12, 16, 23, 38, 56, 72, 91};
    int n = 10;

    int idx = binary_search_recursive(sorted_arr, 23, 0, n - 1);
    printf("%d\n", idx);
    // => Call 1: mid=4, arr[4]=16 < 23 -> search [5..9]
    // => Call 2: mid=7, arr[7]=56 > 23 -> search [5..6]
    // => Call 3: mid=5, arr[5]=23 == 23 -> return 5
    // => Output: 5

    idx = binary_search_recursive(sorted_arr, 99, 0, n - 1);
    printf("%d\n", idx);
    // => 99 > all elements, search space exhausted
    // => Output: -1

    return 0;
}

Key Takeaway: Binary search’s O(log n) comes from halving the search space each call. Ten doublings of input size add only one extra call — a massive efficiency gain over linear search.

Why It Matters: Binary search appears everywhere sorted data exists: database index lookups, Git bisect for bug hunting, binary search on the answer space in optimization problems. Understanding the recursive structure builds intuition for divide-and-conquer algorithms like merge sort and quicksort that achieve O(n log n) by applying the same halving idea.


Stacks and Recursion Connection

Example 26: Simulating Recursion with an Explicit Stack

Every recursive function implicitly uses the call stack. Converting recursion to an explicit stack demonstrates this equivalence and removes Python’s recursion depth limit.

// Example 26: Simulating Recursion with an Explicit Stack

#include <stdio.h>

long factorial_explicit_stack(int n) {
    // Compute n! using an explicit stack instead of call stack recursion
    if (n <= 1) return 1;

    int stack[1000];
    int top = -1;
    // => stack simulates the call frames the OS would allocate

    // Phase 1: "push" all values from n down to 2
    int current = n;
    while (current > 1) {
        stack[++top] = current;
        // => Each push corresponds to one pending recursive call
        current--;
    }

    // Phase 2: "pop" and multiply (returning phase)
    long result = 1;
    while (top >= 0) {
        result *= stack[top--];
        // => Multiply in reverse push order: 2, 3, 4, ..., n
    }

    return result;
}

int main(void) {
    printf("%ld\n", factorial_explicit_stack(5));
    // => Push: [5, 4, 3, 2]; Pop: 1*2=2, 2*3=6, 6*4=24, 24*5=120
    // => Output: 120

    printf("%ld\n", factorial_explicit_stack(10));
    // => Output: 3628800

    printf("%ld\n", factorial_explicit_stack(20));
    // => Output: 2432902008176640000 (within long range)

    return 0;
}

Key Takeaway: Recursion and explicit stacks are equivalent mechanisms — recursion uses the CPU call stack implicitly while the explicit version uses a heap-allocated list. Choosing between them is a space and recursion-limit trade-off.

Why It Matters: Python’s default recursion limit (~1000) is a practical concern. Production parsers, tree traversals on deep trees, and DFS on large graphs require explicit stacks. Converting a naturally recursive algorithm to iterative form is a common interview ask and a necessary skill for writing production-grade graph traversal code.


Searching and Sorting Together

Example 27: Linear Search on Structured Data

Real data is rarely flat integers. Linear search on a list of dictionaries (records) demonstrates how to apply the same O(n) scan to structured objects using a key function.

// Example 27: Linear Search on Structured Data

#include <stdio.h>
#include <string.h>

typedef struct {
    int id;
    char name[32];
    char category[32];
    int price;
} Product;

// Find the first product matching a field — O(n)
const Product *search_by_name(Product products[], int n, const char *name) {
    for (int i = 0; i < n; i++) {
        if (strcmp(products[i].name, name) == 0) {
            return &products[i];
        }
    }
    return NULL;
    // => No record matched — return NULL
}

const Product *search_by_id(Product products[], int n, int id) {
    for (int i = 0; i < n; i++) {
        if (products[i].id == id) return &products[i];
    }
    return NULL;
}

// Find ALL products matching a category — O(n)
int search_all_by_category(Product products[], int n, const char *cat,
                           const Product *results[], int max) {
    int count = 0;
    for (int i = 0; i < n && count < max; i++) {
        if (strcmp(products[i].category, cat) == 0) {
            results[count++] = &products[i];
        }
    }
    return count;
}

int main(void) {
    Product products[] = {
        {1, "Laptop",     "Electronics", 999},
        {2, "Desk Chair", "Furniture",   299},
        {3, "Monitor",    "Electronics", 349},
        {4, "Keyboard",   "Electronics",  79},
        {5, "Bookshelf",  "Furniture",   149},
    };
    int n = 5;

    // Search for a product by name
    const Product *p = search_by_name(products, n, "Monitor");
    if (p) printf("Found: id=%d name=%s price=%d\n", p->id, p->name, p->price);
    // => Output: Found: id=3 name=Monitor price=349

    // Search by ID
    p = search_by_id(products, n, 4);
    if (p) printf("Found: id=%d name=%s price=%d\n", p->id, p->name, p->price);
    // => Output: Found: id=4 name=Keyboard price=79

    // Find all in a category
    const Product *results[10];
    int count = search_all_by_category(products, n, "Electronics", results, 10);
    printf("Electronics count: %d\n", count);
    // => Output: Electronics count: 3

    // Not found
    p = search_by_name(products, n, "Phone");
    printf("%s\n", p ? p->name : "NULL");
    // => Output: NULL

    return 0;
}

Key Takeaway: Linear search applies equally to any sequence of records. The record.get(field) pattern handles missing keys gracefully without exceptions.

Why It Matters: This exact pattern runs in thousands of production systems: searching config lists, matching event records, filtering API responses before indexing. Understanding that O(n) linear search on a 1,000-record in-memory list is acceptable (microseconds) while the same scan on a 50-million-row database table is catastrophic drives when to add an index.


Example 28: Sorting Structured Data with a Key Function

Python’s built-in sorted() accepts a key argument — a function that extracts the comparison value. This separates the sorting algorithm from the data structure, enabling O(n log n) sorting on any structured data.

// Example 28: Sorting Structured Data with a Key Function

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    char name[16];
    int age;
    int salary;
    char department[16];
} Employee;

// Comparator: sort by age ascending
int cmp_by_age(const void *a, const void *b) {
    return ((Employee *)a)->age - ((Employee *)b)->age;
    // => Negative if a < b, zero if equal, positive if a > b
}

// Comparator: sort by salary descending
int cmp_by_salary_desc(const void *a, const void *b) {
    return ((Employee *)b)->salary - ((Employee *)a)->salary;
    // => Reversed comparison for descending order
}

// Comparator: sort by department asc, then salary desc
int cmp_by_dept_then_salary(const void *a, const void *b) {
    const Employee *ea = (const Employee *)a;
    const Employee *eb = (const Employee *)b;
    int dept = strcmp(ea->department, eb->department);
    if (dept != 0) return dept;
    // => Within same department, sort by salary descending
    return eb->salary - ea->salary;
}

void print_employees(Employee arr[], int n, const char *label) {
    printf("%s:\n", label);
    for (int i = 0; i < n; i++) {
        printf("  %s: age=%d salary=$%d dept=%s\n",
               arr[i].name, arr[i].age, arr[i].salary, arr[i].department);
    }
}

int main(void) {
    Employee employees[] = {
        {"Alice",   30, 85000, "Engineering"},
        {"Bob",     25, 72000, "Marketing"},
        {"Charlie", 35, 95000, "Engineering"},
        {"Diana",   28, 78000, "Marketing"},
        {"Eve",     32, 91000, "Engineering"},
    };
    int n = 5;
    Employee sorted[5];

    // Sort by age ascending
    memcpy(sorted, employees, sizeof(employees));
    qsort(sorted, n, sizeof(Employee), cmp_by_age);
    // => qsort uses an O(n log n) algorithm internally
    print_employees(sorted, n, "Sorted by age");
    // => Bob:25, Diana:28, Alice:30, Eve:32, Charlie:35

    // Sort by salary descending
    memcpy(sorted, employees, sizeof(employees));
    qsort(sorted, n, sizeof(Employee), cmp_by_salary_desc);
    print_employees(sorted, n, "\nSorted by salary (highest first)");
    // => Charlie:$95000, Eve:$91000, Alice:$85000, Diana:$78000, Bob:$72000

    // Sort by department, then salary desc
    memcpy(sorted, employees, sizeof(employees));
    qsort(sorted, n, sizeof(Employee), cmp_by_dept_then_salary);
    print_employees(sorted, n, "\nSorted by department, then salary desc");

    // Original unchanged
    printf("\nOriginal first: %s\n", employees[0].name);
    // => Output: Alice

    return 0;
}

Key Takeaway: Python’s sorted() with a key function sorts any structured data in O(n log n) using Timsort. The key function avoids writing a custom comparator for every data shape.

Why It Matters: Sorting structured records appears in every reporting dashboard, API response ordering, and data pipeline. The key function pattern is idiomatic Python and generalizes to any field. Understanding that sorted() returns a new list (non-destructive) while .sort() mutates in place prevents subtle data corruption bugs in pipelines that share list references across multiple processing stages.

Last updated