ExperienceSkillsWritingBooksContact
Java

DSA: The Complete Guide

AD
Ayoub Ait DahmaneSep 19, 202632 min read

Every system I've built in payments — from ISO 20022 validators to Raft-backed settlement ledgers — is, underneath the domain logic, a handful of data structures wired together with a handful of algorithms. This is the reference I wish I'd had early on: not a list of definitions to memorize, but a working understanding of what each structure is for, what it costs, and when to reach for it. Every example is in Java 25, the language this whole site's stack is built on.

Why Data Structures and Algorithms Actually Matter

It's tempting to treat this as interview trivia — something you cram before a FAANG on-site and never think about again. In production, it shows up differently: it's the difference between a settlement service that holds its 10-second SLA at 10x load and one that falls over, because someone reached for a List.contains() (O(n)) in a hot path where a HashSet.contains() (O(1)) was sitting right there. The structures don't change; only your fluency with them does.

Big O Notation: How to Reason About Cost

Big O describes how an algorithm's cost (time or memory) grows as the input grows — not the exact number of operations, but the *shape* of the growth curve. It's the vocabulary the rest of this article is written in.

  • O(1) — constant. Array index access, HashMap get/put (average case). Cost doesn't change with input size.
  • O(log n) — logarithmic. Binary search, balanced tree operations. Each step eliminates half the remaining problem.
  • O(n) — linear. A single pass over a list or array. Cost grows proportionally with input size.
  • O(n log n) — linearithmic. Efficient comparison sorts (mergesort, heapsort, quicksort's average case).
  • O(n²) — quadratic. Nested loops over the same input — bubble sort, naive duplicate detection with two loops.
  • O(2ⁿ) — exponential. Naive recursive Fibonacci, brute-force subsets. Blows up fast; almost always a sign you need memoization or a different approach.
⚖️

Time vs. space is a trade, not a rule

A hash set turns an O(n) membership check into O(1) by spending O(n) extra memory to store what you've already seen. Almost every optimization in this article is that same trade in a different costume — you're rarely making something free, you're deciding what to spend instead.

How Cost Grows With Input Size

Same 10x growth in input, wildly different growth in work — this is the whole point of Big O

input size (n) →operations →O(n²)keeps climbingO(n log n)O(n)O(log n)O(1)

Amortized Analysis: When "Sometimes Slow" Is Still Fast

Big O usually describes a single operation, but some structures have operations that are cheap almost every time and occasionally expensive — and the honest cost is the *average over a long sequence of operations*, not the worst single one. That average is amortized cost.

ArrayList.add() is the textbook case: appending is O(1) until the backing array is full, at which point it allocates a new array (typically double the size) and copies everything over — an O(n) operation. That sounds like it makes add() O(n), but that expensive copy only happens O(log n) times over n insertions, and each element only ever gets copied a bounded number of times total. Spread across all n insertions, the average cost per add() is still O(1) — that's what "O(1) amortized" in the Arrays section earlier actually means.

🔍

Amortized isn't the same as average case

Average case is about the distribution of inputs (what happens on a *typical* input). Amortized is about a *sequence of operations* on the same structure — it's a guarantee that holds no matter what the inputs are, because the expensive operations are provably rare enough to spread their cost thin.

Arrays

The array is the structure everything else is built from: a contiguous block of memory, indexed directly. That contiguity is exactly what makes it fast for some things and slow for others.

Java
int[] settlements = new int[1_000];
settlements[42] = 150_00; // O(1) — direct index into a contiguous block

// Insertion in the middle means shifting everything after it — O(n)
int[] withGap = new int[settlements.length + 1];
System.arraycopy(settlements, 0, withGap, 0, 20);
withGap[20] = 999_00;
System.arraycopy(settlements, 20, withGap, 21, settlements.length - 20);
  • Read by index: O(1)
  • Search (unsorted): O(n)
  • Insert/delete at the end: O(1) amortized (with a resizable array like ArrayList)
  • Insert/delete in the middle: O(n) — everything after the gap has to shift

Linked Lists

Where an array trades flexibility for fast indexing, a linked list trades indexing for flexibility: each node points to the next, so insertion and removal are cheap wherever you already have a reference — but getting to that reference in the first place means walking the list from the start.

Java
class Node<T> {
    T value;
    Node<T> next;
    Node(T value) { this.value = value; }
}

class SinglyLinkedList<T> {
    Node<T> head;

    void prepend(T value) {
        Node<T> node = new Node<>(value);
        node.next = head;
        head = node; // O(1) — no shifting, unlike an array
    }

    boolean contains(T value) {
        for (Node<T> curr = head; curr != null; curr = curr.next) {
            if (curr.value.equals(value)) return true;
        }
        return false; // O(n) — no random access, has to walk the chain
    }
}
  • Singly linked: each node points forward only — cheap prepend, can't walk backward.
  • Doubly linked (java.util.LinkedList): each node also points to its predecessor — cheap removal from the middle once you're already there, at the cost of an extra pointer per node.
  • Access by index: O(n) — this is why a LinkedList is a poor substitute for an ArrayList when you're indexing into it.

Array vs. Linked List: Why Access Cost Differs

Contiguous memory makes indexing free; a chain of pointers makes it a walk

Array — contiguous memory12478913a[0]a[1]a[2]a[3]a[4]a[3] jumps straight there — O(1)Linked List — scattered nodes1247891targetwalk 12 → 47 → 8 → 91 to get there — O(n)

Stacks and Queues

Same underlying idea — a linear sequence — with opposite access disciplines. A stack is last-in-first-out (LIFO): think of the call stack itself, or undo history. A queue is first-in-first-out (FIFO): think of a message queue processing payments in the order they arrived.

Java
Deque<String> stack = new ArrayDeque<>();
stack.push("pacs.008 received");
stack.push("validated");
stack.pop(); // "validated" — last one in, first one out

Deque<String> queue = new ArrayDeque<>();
queue.offer("payment-1");
queue.offer("payment-2");
queue.poll(); // "payment-1" — first one in, first one out
💡

Prefer ArrayDeque over Stack/LinkedList

java.util.Stack is a legacy class synchronized for a concurrency model nobody uses anymore, and LinkedList pays a per-node allocation cost ArrayDeque doesn't. ArrayDeque implements both Deque and Queue and is faster for almost every real use case.

Hash Tables

A hash table is what makes O(1) lookup possible for non-indexed data: a hash function turns a key into a bucket index, so get and put don't need to search — they compute where the value should be and go straight there.

Java
Map<String, PaymentStatus> ledger = new HashMap<>();
ledger.put("INSTR-4471", PaymentStatus.SETTLED); // O(1) average
ledger.get("INSTR-4471");                        // O(1) average — no scan
  • Average case: O(1) for get/put/contains, given a well-distributed hash function.
  • Worst case: O(n) if every key collides into the same bucket — why a good hashCode() implementation matters more than it looks like it should.
  • LinkedHashMap keeps insertion order at a small overhead; TreeMap keeps sorted order at O(log n) per operation via a red-black tree underneath.

Trees: Binary Trees and BSTs

A tree is a hierarchy: each node has at most a fixed number of children. A binary search tree (BST) adds an ordering invariant — everything in the left subtree is smaller, everything in the right subtree is larger — which is what turns search into a binary-search-like O(log n), *if the tree stays balanced*.

Java
class BSTNode {
    int value;
    BSTNode left, right;
    BSTNode(int value) { this.value = value; }
}

boolean contains(BSTNode node, int target) {
    if (node == null) return false;
    if (node.value == target) return true;
    return target < node.value
        ? contains(node.left, target)
        : contains(node.right, target); // discard half the remaining tree each step
}
⚠️

The catch: an unbalanced BST degrades to a linked list

Insert already-sorted data into a plain BST and every node ends up with only a right child — search becomes O(n), not O(log n). Self-balancing trees (AVL, red-black — what TreeMap uses underneath) rebalance on every insert to guarantee O(log n) stays true regardless of insertion order.

Searching a Binary Search Tree for 60

Each comparison discards an entire half of what's left — that's the O(log n)

60 > 50 → go right60 < 70 → go leftdiscarded after step 1 — never visited30204080507060found — 3 comparisons for 7 nodes

Tries (Prefix Trees)

A trie stores strings character by character down a tree, where every node's children are indexed by the next character — so all words sharing a prefix share the same path from the root. Autocomplete, spell-checkers, and IP routing tables all lean on this shape.

Java
class TrieNode {
    Map<Character, TrieNode> children = new HashMap<>();
    boolean isWord;
}

class Trie {
    TrieNode root = new TrieNode();

    void insert(String word) {
        TrieNode node = root;
        for (char c : word.toCharArray()) {
            node = node.children.computeIfAbsent(c, k -> new TrieNode());
        }
        node.isWord = true;
    }

    boolean startsWith(String prefix) {
        TrieNode node = root;
        for (char c : prefix.toCharArray()) {
            node = node.children.get(c);
            if (node == null) return false; // no word in the trie has this prefix
        }
        return true; // O(length of prefix) — independent of how many words are stored
    }
}

The payoff: checking whether *any* stored word starts with a given prefix costs O(length of the prefix) — not O(number of words) the way scanning a list or even a hash set of full words would. A HashSet can tell you if a whole word exists; only a trie can efficiently tell you if anything *starts with* it.

String Pattern Matching

Finding a substring the naive way — try every starting position, compare character by character — is O(n·m) for a text of length n and a pattern of length m. That's fine for short strings, but it's the wrong tool for searching a large log file or a genome. Two classic algorithms bring it down to O(n + m).

  • Rabin-Karp — hash the pattern once, then slide a rolling hash across the text; only compare characters when the hashes match. The rolling hash is the trick: updating it for the next window is O(1), not a full recompute, because sliding the window just drops one character's contribution and adds another's.
  • KMP (Knuth-Morris-Pratt) — precomputes, from the pattern alone, how far to safely skip ahead on a mismatch instead of restarting from scratch. The insight: if a mismatch happens after partially matching, you already know something about the text you just scanned — KMP is what makes sure that knowledge isn't thrown away.
⚙️

This is what Java's String.contains() approximates

The JDK doesn't literally run KMP internally for every case, but the principle — don't restart from scratch on a mismatch when you already have information — is exactly why production string search is never the naive nested loop.

Heaps and Priority Queues

A heap is a tree with a weaker invariant than a BST — only that every parent is smaller (min-heap) or larger (max-heap) than its children, not a full ordering. That's exactly enough structure to always answer "what's the smallest/largest element?" in O(1), with O(log n) insert and remove.

Java
PriorityQueue<Integer> pq = new PriorityQueue<>(); // min-heap by default
pq.offer(50);
pq.offer(10);
pq.offer(30);
pq.poll(); // 10 — always the smallest, without a full sort

This is the structure behind Dijkstra's shortest path, behind merging k sorted streams, and behind any "process the highest-priority item next" queue — including, not coincidentally, how a real settlement engine might prioritize which pending instruction to process next under load.

Graphs

A graph generalizes a tree: nodes (vertices) connected by edges, with no restriction on cycles or hierarchy. Payment networks, service dependency maps, and the SEPA/SWIFT correspondent-banking network itself are all graphs.

Java
Map<String, List<String>> graph = new HashMap<>();
graph.put("BankA", List.of("BankB", "BankC"));
graph.put("BankB", List.of("BankD"));

// Breadth-first search — explores level by level, finds the shortest path in an unweighted graph
Set<String> visited = new HashSet<>();
Queue<String> queue = new ArrayDeque<>();
queue.offer("BankA");
visited.add("BankA");

while (!queue.isEmpty()) {
    String node = queue.poll();
    for (String neighbor : graph.getOrDefault(node, List.of())) {
        if (visited.add(neighbor)) queue.offer(neighbor); // add() returns false if already present
    }
}
  • BFS (breadth-first search): queue-based, explores nearest nodes first — the right tool for shortest path in an unweighted graph.
  • DFS (depth-first search): stack-based (or recursive), goes as deep as possible before backtracking — the right tool for cycle detection, topological sort, and exploring all paths.
  • Weighted shortest path: Dijkstra's algorithm (non-negative weights) — a BFS with a priority queue instead of a plain queue.

Topological Sort, Minimum Spanning Trees, and Shortest Paths

BFS and DFS are the traversal primitives; most graph problems that actually show up are one of a handful of named algorithms built on top of them.

  • Topological sort — order the nodes of a directed acyclic graph (DAG) so every edge points from earlier to later. This is DFS with a twist: append each node to the front of the result only after all its dependents have been visited. It's how a build system orders compilation steps, or how a service dependency graph decides startup order.
  • Minimum spanning tree (Kruskal's) — connect every node with the least total edge weight, no cycles. Sort edges by weight, add each one unless it would create a cycle (checked with Union-Find — the structure covered earlier isn't just a toy, this is its real job).
  • Dijkstra's shortest path — a greedy BFS variant using a priority queue instead of a plain queue, always expanding the closest unvisited node next. Correct only for non-negative edge weights.
  • Bellman-Ford — slower (O(V·E) vs. Dijkstra's O(E log V)) but handles negative edge weights, and can detect negative-weight cycles, which Dijkstra can't do at all.
Java
// Topological sort via DFS — post-order, then reverse
static List<String> topoSort(Map<String, List<String>> graph) {
    Deque<String> result = new ArrayDeque<>();
    Set<String> visited = new HashSet<>();
    for (String node : graph.keySet()) {
        if (!visited.contains(node)) dfs(node, graph, visited, result);
    }
    return new ArrayList<>(result); // already in correct order — pushed to the front
}

static void dfs(String node, Map<String, List<String>> graph, Set<String> visited, Deque<String> result) {
    visited.add(node);
    for (String dep : graph.getOrDefault(node, List.of())) {
        if (!visited.contains(dep)) dfs(dep, graph, visited, result);
    }
    result.addFirst(node); // this node depends on everything already placed
}

Sorting Algorithms

Java's Collections.sort()/Arrays.sort() already gives you a well-tuned sort (dual-pivot quicksort for primitives, a stable TimSort for objects) — you rarely hand-roll one in production. But knowing what's underneath explains *why* the built-in sort is fast, and lets you reason about which one to reach for when the default doesn't fit.

  • Quicksort — O(n log n) average, O(n²) worst case. Picks a pivot, partitions around it, recurses. Fast in practice, not stable, worst case hits on already-sorted or adversarial input.
  • Mergesort — O(n log n) guaranteed, stable. Splits, sorts each half, merges. Predictable cost, but needs O(n) extra space — why Java's object sort uses a variant of this (TimSort) rather than quicksort: correctness and stability matter more than saving that memory.
  • Heapsort — O(n log n) guaranteed, in-place. Builds a heap, repeatedly extracts the max. No extra memory, but not stable and typically slower in practice than a well-tuned quicksort.
  • Insertion sort — O(n²), but O(n) on nearly-sorted input. This is why production sorts switch to insertion sort for small sub-arrays (TimSort's actual runs) — the constant factor is tiny and it's genuinely fast when the data is almost in order already.
Java
static void quicksort(int[] a, int lo, int hi) {
    if (lo >= hi) return;
    int pivot = a[hi], i = lo;
    for (int j = lo; j < hi; j++) {
        if (a[j] < pivot) { swap(a, i, j); i++; }
    }
    swap(a, i, hi);
    quicksort(a, lo, i - 1);
    quicksort(a, i + 1, hi);
}

static void swap(int[] a, int i, int j) {
    int tmp = a[i]; a[i] = a[j]; a[j] = tmp;
}

Searching Algorithms

Linear search (O(n)) is the only option on unsorted data. The moment data is sorted, binary search turns that into O(log n) by discarding half the remaining search space on every comparison.

Java
static int binarySearch(int[] sorted, int target) {
    int lo = 0, hi = sorted.length - 1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2; // avoids overflow vs. (lo + hi) / 2
        if (sorted[mid] == target) return mid;
        if (sorted[mid] < target) lo = mid + 1;
        else hi = mid - 1;
    }
    return -1;
}

The same halving idea generalizes past plain "find this value" — binary search over a *condition* (find the first index where some predicate becomes true) is one of the most underused tools in everyday problem-solving, well beyond textbook searching.

Two Pointers and Sliding Window

A huge class of array and string problems has a shape that doesn't need a new data structure at all — just two indices moving through the same array with a rule for when each one advances. It turns an apparent O(n²) (check every pair, or re-scan every window) into O(n) — one pass, no nested loop.

Java
// Two pointers: does a sorted array contain a pair summing to target?
static boolean hasPairSum(int[] sorted, int target) {
    int lo = 0, hi = sorted.length - 1;
    while (lo < hi) {
        int sum = sorted[lo] + sorted[hi];
        if (sum == target) return true;
        if (sum < target) lo++;   // sum too small — only a bigger left value helps
        else hi--;                 // sum too big — only a smaller right value helps
    }
    return false;
}

// Sliding window: length of the longest run without a repeated character
static int longestUniqueSubstring(String s) {
    Set<Character> window = new HashSet<>();
    int left = 0, best = 0;
    for (int right = 0; right < s.length(); right++) {
        while (window.contains(s.charAt(right))) {
            window.remove(s.charAt(left)); // shrink from the left until valid again
            left++;
        }
        window.add(s.charAt(right));
        best = Math.max(best, right - left + 1);
    }
    return best;
}

The tell: "contiguous subarray/substring satisfying some condition" is almost always sliding window; "pair or triplet in a sorted array satisfying some condition" is almost always two pointers moving toward each other.

Bit Manipulation

Working directly on a number's binary representation is faster than almost any other operation — no branching, no memory access beyond a register. It shows up more than it looks like it should: flags, permissions, set membership for small fixed universes, and a category of interview problems that have a one-line bitwise solution once you see it.

Java
int flags = 0;
flags |= (1 << 3);        // set bit 3
boolean isSet = (flags & (1 << 3)) != 0; // check bit 3
flags &= ~(1 << 3);       // clear bit 3
flags ^= (1 << 3);        // toggle bit 3

// A classic: find the single number that appears once in an array where
// every other number appears exactly twice — XOR cancels out pairs.
static int singleNumber(int[] nums) {
    int result = 0;
    for (int n : nums) result ^= n; // a ^ a == 0, so every pair vanishes
    return result;
}
  • n & (n - 1) clears the lowest set bit — the basis of a fast "count set bits" loop and a one-line power-of-two check (n > 0 && (n & (n - 1)) == 0).
  • n & -n isolates the lowest set bit — used in Fenwick trees (binary indexed trees) for range-sum queries.
  • Bitmasks represent a subset of a small fixed universe as a single integer — 1 << i means "element i is in the set," which turns subset enumeration and small-set DP into integer operations instead of actual Set objects.

Recursion and Backtracking

Recursion is a function solving a problem by solving a smaller version of the same problem, with a base case that stops the descent. Backtracking is recursion with an escape hatch: try a choice, recurse, and if it doesn't pan out, undo the choice and try the next one.

Java
static List<List<Integer>> permutations(List<Integer> nums) {
    List<List<Integer>> result = new ArrayList<>();
    backtrack(nums, new ArrayList<>(), new boolean[nums.size()], result);
    return result;
}

static void backtrack(List<Integer> nums, List<Integer> current, boolean[] used, List<List<Integer>> result) {
    if (current.size() == nums.size()) {
        result.add(new ArrayList<>(current));
        return;
    }
    for (int i = 0; i < nums.size(); i++) {
        if (used[i]) continue;
        used[i] = true;
        current.add(nums.get(i));
        backtrack(nums, current, used, result);   // choose, explore
        current.remove(current.size() - 1);        // un-choose — the "back" in backtracking
        used[i] = false;
    }
}
⚠️

Every recursive call has a real cost

Each call adds a frame to the call stack — deep unbounded recursion (unbounded by input size, not by a fixed small depth) is a stack overflow waiting for large-enough input. Java doesn't guarantee tail-call optimization, so a recursive algorithm over genuinely large input often needs an explicit stack/loop instead.

Dynamic Programming

Dynamic programming is recursion plus memory: when a recursive solution recomputes the same subproblem many times, cache the result instead of recomputing it. It turns exponential blowups into polynomial time by trading memory for time — the same trade from the Big O section, just with a name of its own.

Java
// Naive recursive Fibonacci: O(2ⁿ) — recomputes fib(n-2) inside both fib(n-1) and fib(n)
static long fibNaive(int n) {
    if (n <= 1) return n;
    return fibNaive(n - 1) + fibNaive(n - 2);
}

// Memoized: O(n) — each subproblem solved exactly once
static long fibMemo(int n, Map<Integer, Long> cache) {
    if (n <= 1) return n;
    if (cache.containsKey(n)) return cache.get(n);
    long result = fibMemo(n - 1, cache) + fibMemo(n - 2, cache);
    cache.put(n, result);
    return result;
}
  • Top-down (memoization): write the natural recursive solution, add a cache. Easiest to reason about, closest to the problem statement.
  • Bottom-up (tabulation): build the answer iteratively from the smallest subproblems up, usually in an array. No recursion overhead, often the faster real-world choice.
  • The tell that a problem wants DP: overlapping subproblems (the same smaller input shows up more than once) plus optimal substructure (the best answer is built from the best answers to subproblems).

Greedy Algorithms

A greedy algorithm makes the locally-best choice at each step and never reconsiders it — no backtracking, no exploring alternatives. It's fast and simple, but only correct for problems where the locally-best choice genuinely leads to the globally-best answer, which is a real constraint, not a given.

  • Activity selection: always pick the next activity that finishes earliest — provably optimal for this specific problem.
  • Dijkstra's shortest path: always expand the closest unvisited node next — a greedy choice that works because edge weights are non-negative (it breaks with negative weights, which is what Bellman-Ford exists for).
  • Huffman coding: always merge the two least-frequent nodes — builds a provably optimal prefix-free encoding.
  • The failure mode: greedy applied to a problem without that provable structure (the classic counterexample is coin change with a non-canonical coin system) silently gives a wrong answer, not a slow one — this is the one category here where "it ran and returned something" isn't evidence it's correct.

Union-Find (Disjoint Set)

Union-Find tracks a collection of non-overlapping sets and answers one question fast: are these two elements in the same set? With path compression and union-by-rank, both find and union run in near-O(1) (technically O(α(n)), the inverse Ackermann function — for any input size that could ever exist, that's effectively a small constant).

Java
class UnionFind {
    int[] parent, rank;

    UnionFind(int n) {
        parent = new int[n];
        rank = new int[n];
        for (int i = 0; i < n; i++) parent[i] = i;
    }

    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]); // path compression
        return parent[x];
    }

    void union(int a, int b) {
        int rootA = find(a), rootB = find(b);
        if (rootA == rootB) return;
        if (rank[rootA] < rank[rootB]) { int tmp = rootA; rootA = rootB; rootB = tmp; }
        parent[rootB] = rootA;
        if (rank[rootA] == rank[rootB]) rank[rootA]++;
    }
}

This is the structure behind Kruskal's minimum spanning tree algorithm, cycle detection in undirected graphs, and — closer to home — detecting whether a set of payment accounts belong to the same settlement group without walking a full graph traversal every time.

Choosing the Right Data Structure: A Cheat Sheet

The question that actually matters day to day isn't "what's the definition of a red-black tree" — it's "which structure fits the access pattern I actually have." A rough map:

  • Need O(1) lookup by key, don't care about order? HashMap / HashSet.
  • Need O(1) lookup and insertion order preserved? LinkedHashMap / LinkedHashSet.
  • Need sorted order maintained automatically, with O(log n) operations? TreeMap / TreeSet.
  • Need fast add/remove at both ends? ArrayDeque, as a stack or a queue.
  • Need "give me the smallest/largest, repeatedly"? PriorityQueue.
  • Need fast random access by index? ArrayList, not LinkedList.
  • Need to know if two things are connected, across many merges? Union-Find.
  • Need to find every stored string starting with a given prefix? Trie, not a HashSet of full strings.
  • Scanning for a contiguous run/window satisfying a condition? Two pointers or a sliding window before reaching for anything heavier.
  • Ordering tasks with dependencies, or finding the cheapest way to connect everything? Topological sort or a minimum spanning tree — both graph problems, neither needs brute force.
  • Recomputing the same subproblem more than once? That's your signal for memoization/DP, before you optimize anything else.
🎯

The real skill isn't memorizing every structure

It's pattern-matching an access pattern to a cost profile fast enough to catch the wrong choice before it ships — the HashSet vs. List.contains() gap above is a two-character difference that becomes a production incident at scale. Everything else in this article is in service of building that instinct.

Algorithms are just decisions about what work to do now versus what work to avoid doing later — every structure in this article is a different answer to that same question.
AD

Written by Ayoub Ait Dahmane

Tech Lead Java — building SEPA Instant / ISO 20022 payments infrastructure and writing about the hard parts of shipping it.