DSA in Java — Concept Notes

Data structures, algorithms and the problem-solving patterns behind interview questions, in easy words and full depth. Every solution is in Java and has been compiled and run. Print this page for a PDF.

Part 1 — Foundations

1. How to Approach a DSA Problem

In plain words

A coding interview is not a test of whether you have memorised the answer. It is a test of how you think: can you understand a fuzzy problem, find a correct solution, make it fast, and write clean code while explaining yourself?

The trick is to follow the same steps every time. A fixed routine stops you from panicking, stops you from coding the wrong thing, and shows the interviewer a calm, organised engineer.

Like a pilot's pre-flight checklist. Experienced pilots still walk through it every single time — not because they forget how to fly, but because the checklist catches the mistakes that confidence hides.

The eight-step framework

1 Clarify ask questions until the problem is unambiguous | 2 Examples write 2-3 inputs with outputs, including edge cases | 3 Brute force say the simplest correct idea + its Big-O (do not code yet) | 4 Bottleneck which part is slow or repeated? what work is wasted? | 5 Optimise pick a better data structure / pattern; state new Big-O | 6 Code clean, named variables, small helpers | 7 Dry-run trace your code by hand on one example | 8 Test edge cases: empty, one element, duplicates, negatives, max size

Budget roughly: 5 minutes for steps 1–2, 5–10 minutes for 3–5, 15–20 minutes to code, 5 minutes to dry-run and test. If you jump straight to step 6, you will often solve the wrong problem.

Step 1 — Clarify

Problems are left vague on purpose. The interviewer wants to see you ask. Good questions:

  • Input: What type and size? Can it be empty? Can values be negative, zero, duplicate? Is the array sorted?
  • Output: Return indices or values? Any valid answer or all of them? What if there is no answer — return -1, empty list, or throw?
  • Constraints: How big is n? (This tells you the target complexity — see section 2.) Does it fit in memory? Can I modify the input?

Step 2 — Examples and edge cases

Write a normal example and then attack the corners. An edge case is an input at the boundary of what is allowed, where code usually breaks:

Input typeEdge cases to try
Array / listempty, one element, two elements, all equal, already sorted, reverse sorted
Numbers0, negative, Integer.MAX_VALUE (overflow), Integer.MIN_VALUE
Stringempty "", one char, all same char, spaces, upper vs lower case
Tree / linked listnull root/head, single node, fully skewed (a "tree" that is really a line)
Graphdisconnected parts, cycles, self-loops, a single node

Step 3 — Brute force first

Say the most obvious correct solution out loud, with its complexity: "The simplest thing is to try every pair, which is O(n²)." This proves you understand the problem, gives you a fallback answer, and gives you something to improve. You usually do not code it — ask "should I code this or look for something faster?"

Steps 4–5 — Find the bottleneck, then optimise

Look at the brute force and ask: what work am I repeating? Most optimisations are one of these moves:

If you see…Try…Typical gain
"Have I seen X before?" inside a loopHashMap / HashSetO(n²) → O(n)
Sorted array, or can sortTwo pointers or binary searchO(n²) → O(n log n) or O(n)
Contiguous subarray / substringSliding window or prefix sumsO(n²) → O(n)
"Top k", "smallest", "next best"Heap (PriorityQueue)O(n log n) → O(n log k)
Same subproblem solved again and againMemoisation / dynamic programmingO(2ⁿ) → O(n²) or better
"All combinations / permutations"Backtracking (it is exponential; prune early)Cuts branches, not the class

A very common trade: spend memory to save time. A HashMap costs O(n) extra space but turns an O(n) search into an O(1) lookup.

Step 6 — Code

  • Use clear names: left, right, seen, maxLen — not a, b, x.
  • Pull repeated logic into a helper (isValid(r, c), swap(arr, i, j)).
  • Handle the edge cases you listed at the top of the method.
  • If you are unsure of a library method, say so and pick a sensible name: "I'll assume a helper that returns the neighbours."

Steps 7–8 — Dry-run and test

Do not say "I think it works." Pick a small example and trace the variables line by line, out loud. Then run through your edge cases. Finding your own bug is a positive signal — it shows you test your work. Finish by restating the time and space complexity.

How to talk while coding

  • Narrate intent, not syntax. Say "now I shrink the window from the left until it's valid again," not "i plus plus."
  • Announce trade-offs. "I'll use extra O(n) space for the map to get linear time."
  • Check in at decision points. "Does that approach sound good before I code it?"
  • It is fine to go quiet for 20–30 seconds to think — just say "let me think about this for a moment" first.

What to do when you are stuck

  1. Go back to a tiny example and solve it by hand. Watch what your brain does — that is often the algorithm.
  2. Ask "what if the input were sorted?" or "what if I had a HashMap of everything?"
  3. Walk the pattern table above: two pointers? sliding window? BFS? heap? DP?
  4. Solve a simpler version first (1D before 2D, no duplicates before duplicates, k = 1 before general k).
  5. Say what you are thinking. Interviewers give hints to candidates who are visibly reasoning, not to silent ones.
  6. If time runs low, code the brute force cleanly. A working O(n²) beats a broken O(n).

Worked example — Two Sum, end to end

Problem: given an array nums and a target, return the indices of the two numbers that add up to target.

1. Clarify. "Is there always exactly one answer? Can I use the same element twice? Can values be negative? Is it sorted?" Assume: exactly one answer, not the same index twice, negatives allowed, not sorted.

2. Examples. [2,7,11,15], 9 → [0,1]. [3,2,4], 6 → [1,2] (note: not [0,0] — cannot reuse index 0). [3,3], 6 → [0,1] (duplicates).

3. Brute force. Try every pair (i, j) with j > i. That is about n²/2 pairs, so O(n²) time and O(1) space.

// Brute force: try every pair. O(n^2) time, O(1) space.
static int[] twoSumBrute(int[] nums, int target) {
    for (int i = 0; i < nums.length; i++) {
        for (int j = i + 1; j < nums.length; j++) {
            if (nums[i] + nums[j] == target) return new int[]{i, j};
        }
    }
    return new int[]{-1, -1};              // no pair found
}

4. Bottleneck. For each nums[i], the inner loop is really asking one question: "does the number target - nums[i] exist somewhere I've already been?" That is a lookup, and we are doing it with a slow linear scan.

5. Optimise. Remember every number we have passed in a HashMap from value to index. Then the lookup is O(1) on average, and one pass is enough.

import java.util.*;

class Solution {
    // One pass with a HashMap. O(n) time, O(n) space.
    public int[] twoSum(int[] nums, int target) {
        Map<Integer, Integer> seen = new HashMap<>();   // value -> index
        for (int i = 0; i < nums.length; i++) {
            int need = target - nums[i];                 // the partner we want
            if (seen.containsKey(need)) {
                return new int[]{seen.get(need), i};
            }
            seen.put(nums[i], i);                        // store AFTER checking
        }
        return new int[]{-1, -1};
    }
}

Why "store after checking"? If we stored first, then for [3,2,4], target 6 the element 3 would find itself (6 - 3 = 3) and return [0,0]. Checking first means we only ever pair with an earlier index.

7. Dry-run on nums = [2,7,11,15], target = 9:

inums[i]need = 9 - nums[i]seen before stepAction
027{}7 not in map; put 2 → 0
172{2=0}2 is in map → return [0, 1]

8. Test. Running the code gives: [2,7,11,15], 9 → [0, 1]; [3,2,4], 6 → [1, 2]; [3,3], 6 → [0, 1] (the second 3 finds the first); [1,2], 7 → [-1, -1] (no answer).

Complexity: O(n) time — one pass, each map operation O(1) on average. O(n) space — the map may hold every element.

Follow-up the interviewer may ask: "What if the array is sorted?" Then use two pointers: left = 0, right = n - 1; if the sum is too small move left right, if too big move right left. That is O(n) time and O(1) space — better on memory than the map. Recognising when a different constraint unlocks a different pattern is exactly what they want to see.

2. Big-O & Complexity

In plain words

Big-O describes how the running time (or memory) of an algorithm grows as the input gets bigger. It ignores the exact seconds, the machine, and small details, and keeps only the shape of the growth: does doubling the input double the work, quadruple it, or barely change it?

Time complexity counts steps. Space complexity counts extra memory. Both are written as a function of n, the input size.

Handing out invitations. Walking to each of n guests is O(n). Having every guest shake hands with every other guest is O(n²). Shouting once in the hall is O(1) — the same effort whether 10 or 10,000 people are there.

What Big-O really means

Formally, f(n) = O(g(n)) means that for large enough n, f(n) is at most some constant times g(n). In practice this gives two rules:

  • Drop constants. 3n + 5 steps is O(n). Two separate passes over the array is still O(n).
  • Keep only the biggest term. n² + 100n + 7 is O(n²), because when n is a million, the n² part dwarfs everything else.

Related words you may hear: Big-Omega (Ω) is a lower bound ("at least this much"), Big-Theta (Θ) is a tight bound (both). In interviews, people say "Big-O" and mean the tight worst-case bound.

How to count — the patterns

// O(n): one loop over the input
static int sum(int[] a) {
    int total = 0;
    for (int x : a) total += x;          // runs n times
    return total;
}

// O(n^2): a loop inside a loop, both over n
static int countPairs(int[] a) {
    int count = 0;
    for (int i = 0; i < a.length; i++)
        for (int j = i + 1; j < a.length; j++)   // n-1, n-2, ... 1 -> n(n-1)/2
            count++;
    return count;
}

// O(log n): the problem halves every step
static int halvings(int n) {
    int steps = 0;
    while (n > 1) {
        n /= 2;                           // n, n/2, n/4, ... 1
        steps++;
    }
    return steps;
}

// O(n log n): n outer iterations, each doing log n work
static int nLogN(int n) {
    int ops = 0;
    for (int i = 0; i < n; i++)
        for (int j = 1; j < n; j *= 2)    // doubles -> log n times
            ops++;
    return ops;
}

// Two SEPARATE loops over different inputs: O(n + m), not O(n * m)
static int twoLoops(int[] a, int[] b) {
    int ops = 0;
    for (int x : a) ops++;
    for (int y : b) ops++;
    return ops;
}

Running these on an 8-element array: countPairs does 28 steps (= 8·7/2), nLogN(8) does 24 (= 8·3), and halvings(1_000_000) takes only 19 steps. That last number is why O(log n) feels almost free.

  • Sequential blocks add: loop A then loop B is O(A + B).
  • Nested blocks multiply: loop A containing loop B is O(A × B).
  • Halving or doubling the loop variable gives a log.
  • Different inputs get different letters: looping over a string of length n and a list of length m is O(n + m) or O(n·m) — never just "O(n)".
  • Hidden loops count: list.contains(x), s.substring(...), String + in a loop, Arrays.sort all cost more than O(1).

Why log n is so small — binary search

n = 16 0 3 6 9 12 15 18 21 24 27 30 33 36 39 42 45 8 left 24 27 30 33 36 39 42 45 4 left 36 39 42 45 2 left 42 45 1 left 45 Searching for 45: each comparison with the middle throws away half of what is left. 16 → 8 → 4 → 2 → 1 is log2(16) = 4 halvings. A billion elements needs only about 30.
Binary search: O(log n) time. Dashed cells are eliminated; the accent cell is where the search ends.
// Binary search: O(log n) time, O(1) space
static int binarySearch(int[] a, int target) {
    int lo = 0, hi = a.length - 1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;     // avoids (lo + hi) overflow
        if (a[mid] == target) return mid;
        if (a[mid] < target) lo = mid + 1;
        else hi = mid - 1;
    }
    return -1;
}

Counting recursion

For recursive code, ask two questions: how many calls happen in total, and how much work does each call do outside its recursive calls? Multiply them.

  • One call per level, n levels, O(1) work each (factorial) → O(n).
  • One call on half the input, O(1) work each (binary search) → O(log n).
  • Two calls on n - 1 (naive Fibonacci) → the calls double each level → O(2ⁿ).
  • Two calls on half, O(n) merge work (merge sort) → log n levels, O(n) per level → O(n log n).

A useful rule: a recursion with b branches and depth d makes about bd calls.

Best, average and worst case

The same algorithm can take very different time depending on the input:

AlgorithmBestAverageWorstWorst happens when
Linear searchO(1)O(n)O(n)target is last or missing
QuicksortO(n log n)O(n log n)O(n²)pivot is always the smallest/largest
HashMap getO(1)O(1)O(n), or O(log n) in Java 8+many keys collide into one bucket
Insertion sortO(n)O(n²)O(n²)array is reverse sorted

When nobody says which, give the worst case, and mention the average if it is different (for hashing and quicksort, always mention it).

Amortised complexity — ArrayList growth

Amortised means "averaged over a long sequence of operations." Some single operations are expensive, but they are rare enough that the average cost per operation stays small.

An ArrayList is backed by a fixed-size array. When it is full, add allocates a new array about 1.5 times bigger and copies everything over — an O(n) step. But after that, the next many adds are O(1) again. Because the capacity grows by a multiplied amount, the total copying over n adds is a geometric series that adds up to a constant times n.

Simulating 1,000,000 adds with Java's 1.5x growth rule (starting at capacity 10) gives 2,430,972 element copies in total — about 2.4 copies per add. A constant, so add is amortised O(1).

Why multiply, not add? If the list grew by a fixed +10 slots each time, you would copy 10 + 20 + 30 + … + n elements = O(n²) total, so each add would be O(n) on average. Growing by a factor is what makes it cheap. The same idea applies to StringBuilder and HashMap resizing.

Space complexity (and the hidden stack)

Space complexity counts the extra memory your algorithm uses, not the input itself (say so if you are unsure — some interviewers count the input). Watch for:

  • New arrays, lists, maps, sets: O(size).
  • Copies: substring, toCharArray, new ArrayList<>(other) are all O(length).
  • The recursion stack: every active call holds a stack frame. A recursion that goes n levels deep uses O(n) space even if it creates no objects.
// Recursion that sums an array: O(n) time AND O(n) space (n stack frames)
static int sumRec(int[] a, int i) {
    if (i == a.length) return 0;
    return a[i] + sumRec(a, i + 1);
}

The iterative sum above does the same job in O(1) space. Recursive DFS on a tree uses O(h) space where h is the height: O(log n) for a balanced tree but O(n) for a skewed one.

Common complexity classes

Big-ONamen = 1,000,000 means roughlyTypical examples
O(1)Constant1 stepArray index, HashMap get/put, stack push/pop
O(log n)Logarithmic20 stepsBinary search, balanced BST / TreeMap ops, heap push/pop
O(√n)Square root1,000 stepsChecking if n is prime by trial division
O(n)Linear10⁶ stepsOne pass, two pointers, sliding window, BFS/DFS over V + E
O(n log n)Linearithmic2 × 10⁷ stepsSorting (merge sort, Arrays.sort), n heap operations
O(n²)Quadratic10¹² — too slowAll pairs, simple DP on two strings, bubble/insertion sort
O(n³)Cubicway too slowFloyd–Warshall, naive matrix multiply, interval DP
O(2ⁿ)ExponentialimpossibleAll subsets, naive recursive Fibonacci
O(n!)FactorialimpossibleAll permutations, brute-force travelling salesman

Input size → which complexity fits

A typical judge (and a typical interviewer's mental model) allows about 10⁸ simple operations per second. So the constraint on n tells you the complexity they expect — a huge hint that is often ignored:

If n is up to…Target complexityUsually means
≤ 10O(n!)Try all permutations; backtracking
≤ 20O(2ⁿ) or O(2ⁿ · n)All subsets, bitmask DP
≤ 500O(n³)Triple loop, interval DP, Floyd–Warshall
≤ 5,000O(n²)Double loop, 2D DP
≤ 10⁶O(n log n)Sort, heap, binary search, divide and conquer
larger (10⁸, 10¹⁸)O(n) or O(log n)One pass, math, binary search on the answer

The Master Theorem in easy words

Many divide-and-conquer algorithms have a running time like

T(n) = a · T(n / b) + f(n)
  a    = how many sub-problems each call makes
  n/b  = how big each sub-problem is
  f(n) = the work done in this call itself (splitting + combining)

Picture the recursion as a tree. The top level does f(n) work. The bottom level has a huge number of tiny leaves. The Master Theorem simply asks: which part of the tree does most of the work? Compare f(n) with nlog_b(a) (the number of leaves):

Level 0: 1 call doing f(n) Level 1: a calls each on n/b Level 2: a² calls each on n/b² ... (log_b n levels) Leaves: n^(log_b a) tiny calls Case 1 leaves win (f(n) smaller) T = O(n^(log_b a)) Case 2 a tie (f(n) same size) T = O(f(n) · log n) Case 3 root wins (f(n) bigger) T = O(f(n))
AlgorithmRecurrenceLeaves nlog_b aWho winsResult
Binary searchT(n) = T(n/2) + O(1)n⁰ = 1TieO(log n)
Merge sortT(n) = 2T(n/2) + O(n)n¹ = nTieO(n log n)
Tree traversalT(n) = 2T(n/2) + O(1)nLeavesO(n)
Karatsuba multiplyT(n) = 3T(n/2) + O(n)n^1.58LeavesO(n^1.58)
Quickselect (average)T(n) = T(n/2) + O(n)1RootO(n)
Interview tip: you rarely need to quote the theorem. Just say "there are log n levels, and each level does O(n) total work, so O(n log n)." Drawing the recursion tree and summing each level is the reasoning the theorem packages up.

3. Recursion & the Call Stack

In plain words

Recursion is when a method solves a problem by calling itself on a smaller version of the same problem, until the problem is so small the answer is obvious.

Every recursive method needs two parts: a base case (the tiny problem you answer directly, which stops the recursion) and a recursive case (break the problem down, call yourself on the smaller piece, and combine the result).

Russian nesting dolls. To find the smallest doll you open a doll, and if there is another inside, you do the same thing again. The base case is the solid doll that does not open. Then you put them back together in reverse order.

The two rules

// factorial: n! = n * (n-1) * ... * 1.  O(n) time, O(n) stack space
static long factorial(int n) {
    if (n <= 1) return 1;              // base case: answer directly
    return n * factorial(n - 1);       // recursive case: smaller input
}
// factorial(5)  = 120
// factorial(20) = 2432902008176640000  (factorial(21) overflows long)
  1. Every path must reach a base case. Forget it, or never shrink the input, and you recurse forever.
  2. Each call must move toward the base case (n - 1, n / 2, index + 1, a smaller subtree).

Trust the recursion (the "leap of faith"): when writing factorial(n), assume factorial(n - 1) already works and only ask "how do I use it to build my answer?" Do not try to trace every level in your head while writing — trace afterwards to check.

What the call stack actually does

The JVM keeps a call stack for each thread. Every time a method is called, a new stack frame is pushed on top. The frame holds that call's parameters, local variables, and where to return to. When the method returns, its frame is popped and the caller continues exactly where it paused.

So recursion is not magic: factorial(3) pauses in the middle of 3 * ?, waiting for factorial(2) to finish, which pauses waiting for factorial(1).

CALL STACK — phase 1: push (winding) factorial(1) n = 1 base case: return 1 factorial(2) n = 2 paused at: 2 * factorial(1) factorial(3) n = 3 paused at: 3 * factorial(2) main paused at: x = factorial(3) push phase 2: pop (unwinding) returns 1 2 * 1 = 2, returns 2 3 * 2 = 6, returns 6 x = 6 pop Max depth here is 4 frames. factorial(n) needs n frames at once, so it uses O(n) stack space.
Frames are pushed on the way down and popped on the way back up. The multiplication happens during the unwind.

Recursion trees — naive Fibonacci

When a method calls itself more than once, draw a recursion tree: each node is one call, its children are the calls it makes.

// naive fib: O(2^n) time, O(n) stack space
static long fib(int n) {
    if (n <= 1) return n;                // fib(0) = 0, fib(1) = 1
    return fib(n - 1) + fib(n - 2);
}
fib(5) / \ fib(4) fib(3) / \ / \ fib(3) fib(2) fib(2) fib(1) / \ / \ / \ fib(2) fib(1) fib(1) fib(0) fib(1) fib(0) / \ fib(1) fib(0) fib(3) is computed 2 times, fib(2) 3 times -- the same work, over and over.

Each call branches into 2, and the tree is up to n levels deep, so there are up to about 2ⁿ calls: O(2ⁿ) time. Counting for real: fib(10) makes 177 calls, and fib(30) makes 2,692,537. But the space is only O(n): at any moment only one root-to-leaf path is on the stack, and the deepest path has n frames.

Time vs space for recursion: time is the total number of nodes in the recursion tree (times work per node). Space is the height of the tree (the deepest the stack gets). Mixing these up is a very common interview slip.

Memoisation preview

The tree above repeats the same subproblems. Memoisation means caching each answer the first time you compute it and returning the cached value next time. Each fib(k) is then computed once:

// memoised fib: O(n) time, O(n) space (memo array + stack)
static long fibMemo(int n, long[] memo) {
    if (n <= 1) return n;
    if (memo[n] != 0) return memo[n];            // already solved: reuse
    return memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
}
// fibMemo(50, new long[51]) = 12586269025, instantly.
// The naive version would need billions of calls.

This is the doorway to dynamic programming: "recursion + a cache" (top-down) or "fill a table in a loop" (bottom-up). It gets its own part later.

Head vs tail recursion

Where you do the work relative to the recursive call changes the order of results:

// Head recursion: recurse FIRST, work on the way back up
static void printHead(int n) {
    if (n == 0) return;
    printHead(n - 1);
    System.out.print(n + " ");            // prints: 1 2 3
}

// Tail recursion: work FIRST, the recursive call is the very last thing
static void printTail(int n) {
    if (n == 0) return;
    System.out.print(n + " ");            // prints: 3 2 1
    printTail(n - 1);
}

A tail call means nothing is left to do after the call returns, so in theory the frame could be reused. Some languages (Scheme, Scala with @tailrec, Kotlin tailrec) do this tail-call optimisation. Java does not. A tail-recursive method in Java still uses O(n) stack.

StackOverflowError and converting to iteration

The stack has a fixed size (roughly 512 KB to 1 MB per thread by default, adjustable with -Xss). Recurse too deep and the JVM throws StackOverflowError. On a default JVM, an empty infinite recursion died after about 67,000 frames in a quick test; real methods with more locals die sooner. So deep recursion on 10⁵–10⁶ elements (a long linked list, a skewed tree, DFS on a big grid) can crash.

// Tail-recursive sum with an accumulator -- still overflows in Java for n = 1,000,000
static long sumTail(int n, long acc) {
    if (n == 0) return acc;
    return sumTail(n - 1, acc + n);
}

// Same logic as a loop: O(n) time, O(1) space, cannot overflow the stack
static long sumIter(int n) {
    long acc = 0;
    while (n > 0) { acc += n; n--; }
    return acc;
}
// sumTail(100, 0) = 5050, sumIter(100) = 5050
// sumTail(1_000_000, 0) -> StackOverflowError
// sumIter(1_000_000)    -> 500000500000

How to convert recursion to iteration:

  • Tail recursion → a plain loop. The parameters become loop variables (as above).
  • General recursion (tree DFS, backtracking) → use your own explicit stack: Deque<Node> stack = new ArrayDeque<>(). Push the starting state, then loop: pop, process, push the children. Your stack lives on the heap, which is much bigger.
  • Recursion with memo → a bottom-up DP table filled in a loop.

Classic examples

Fast power — compute xⁿ by squaring half the answer instead of multiplying n times:

// x^n in O(log n) time, O(log n) stack
static double myPow(double x, long n) {
    if (n < 0) return 1 / myPow(x, -n);   // long n: -Integer.MIN_VALUE would overflow an int
    if (n == 0) return 1;                  // base case
    double half = myPow(x, n / 2);         // solve half the problem ONCE
    return (n % 2 == 0) ? half * half : half * half * x;
}
// myPow(2, 10) = 1024.0, myPow(2, -2) = 0.25

The key is calling myPow(x, n / 2) once and storing it. Writing myPow(x, n/2) * myPow(x, n/2) makes two calls per level and falls back to O(n).

Reverse a string — swap the ends, then recurse on the middle:

// O(n) time, O(n) stack (n/2 frames)
static void reverse(char[] s, int left, int right) {
    if (left >= right) return;             // base case: 0 or 1 chars left
    char tmp = s[left]; s[left] = s[right]; s[right] = tmp;
    reverse(s, left + 1, right - 1);
}
// "hello" -> "olleh"

All subsets (preview of backtracking) — for each element, make two choices: take it or skip it:

// O(n * 2^n) time (2^n subsets, each copied in O(n)), O(n) stack
static void subsets(int[] nums, int i, List<Integer> current, List<List<Integer>> out) {
    if (i == nums.length) {                // decided for every element
        out.add(new ArrayList<>(current)); // copy! current keeps changing
        return;
    }
    current.add(nums[i]);                  // choice 1: take nums[i]
    subsets(nums, i + 1, current, out);
    current.remove(current.size() - 1);    // undo the choice (backtrack)
    subsets(nums, i + 1, current, out);    // choice 2: skip nums[i]
}
// [1,2,3] -> [[1, 2, 3], [1, 2], [1, 3], [1], [2, 3], [2], [3], []]
start [] take 1 / \ skip 1 [1] [] take 2 / \ skip take / \ skip [1,2] [1] [2] [] / \ / \ / \ / \ [1,2,3][1,2][1,3][1] [2,3][2] [3] [] <- 8 leaves = 2^3 subsets
Recursion checklist for interviews: (1) state the base case first, (2) make sure each call shrinks the input, (3) decide what the call returns and trust it, (4) give time as "number of calls × work per call" and space as "max depth", (5) if depth could reach ~10⁴ or more, mention the stack risk and the iterative alternative.

4. Java Toolkit for DSA

In plain words

In an interview you do not want to fight the language. This section is the small set of Java classes and methods that show up in almost every DSA solution — which one to pick, the exact method names, and the traps that cause wrong answers.

The big idea: pick the collection whose fast operations match what your algorithm does most. Need "have I seen this?" → HashSet. Need "smallest so far" → PriorityQueue. Need "closest key below x" → TreeMap.

A carpenter's toolbox. You can drive a screw with a hammer, but it is slow and ugly. Knowing exactly which tool is in which drawer is half the skill.

Arrays

int[] a = {5, 3, 9, 1, 7};
Arrays.sort(a);                                  // [1, 3, 5, 7, 9]  O(n log n)
int[] dp = new int[5];
Arrays.fill(dp, -1);                             // [-1, -1, -1, -1, -1]
int[] part = Arrays.copyOfRange(a, 1, 4);        // [3, 5, 7]  (end index is EXCLUSIVE)
List<String> names = Arrays.asList("bob", "amy", "cat");  // fixed-size list view
int[][] grid = new int[3][4];                    // 3 rows, 4 cols, all 0
System.out.println(Arrays.toString(a));          // printing needs toString / deepToString

// Descending order needs boxed Integer[], not int[]
Integer[] boxed = {5, 3, 9, 1};
Arrays.sort(boxed, Collections.reverseOrder());  // [9, 5, 3, 1]

// Sort 2D array (e.g. intervals) by first element
int[][] intervals = {{5, 6}, {1, 3}, {2, 4}};
Arrays.sort(intervals, (x, y) -> Integer.compare(x[0], y[0]));  // [[1, 3], [2, 4], [5, 6]]

int total = Arrays.stream(a).sum();              // 25
int max = Arrays.stream(a).max().getAsInt();     // 9
  • Arrays.sort(int[]) uses dual-pivot quicksort (O(n log n) average, not stable — fine for primitives). Arrays.sort(Object[]) uses TimSort, which is stable (equal elements keep their order) and guaranteed O(n log n).
  • Arrays.asList returns a fixed-size view: add/remove throw UnsupportedOperationException. Wrap it: new ArrayList<>(Arrays.asList(...)). And Arrays.asList(intArray) gives a list with one element (the array itself) — it does not box.
  • List.of(...) is fully immutable and rejects null.

String vs StringBuilder

A Java String is immutable: it can never change. s += "x" builds a brand-new string and copies all the old characters. In a loop that is O(n²) total. StringBuilder is a growable char buffer: append is amortised O(1).

String s = "hello";
s.charAt(1);          // 'e'   O(1)
s.length();           // 5     (method for String; field .length for arrays; .size() for collections)
s.substring(1, 3);    // "el"  O(k) -- makes a copy, end exclusive
char[] cs = s.toCharArray();
Arrays.sort(cs);
new String(cs);       // "ehllo" -- sorted letters (anagram key)
String.valueOf(42);   // "42"
s.equals("hello");    // compare CONTENT with equals, never ==

StringBuilder sb = new StringBuilder();
for (int i = 0; i < 3; i++) sb.append(i).append(',');
sb.setLength(sb.length() - 1);   // drop last comma -> "0,1,2"
sb.reverse();                    // "2,1,0"  (reverses in place)
sb.insert(0, 'x'); sb.deleteCharAt(0); sb.setCharAt(0, 'y');
String result = sb.toString();

new String("hi") == new String("hi") is false (two different objects) while .equals is true. Always use equals for strings.

char arithmetic

A char is really a small number (its Unicode code). Subtracting 'a' turns a lowercase letter into an index 0–25 — the standard way to count letters with an int[26] instead of a map.

'd' - 'a'            // 3       letter -> index
(char) ('a' + 3)     // 'd'     index -> letter (cast back!)
'7' - '0'            // 7       digit char -> int
Character.isDigit('7'); Character.isLetter('x'); Character.isLetterOrDigit(c);
Character.toUpperCase('q');     // 'Q'

int[] freq = new int[26];
for (char ch : "banana".toCharArray()) freq[ch - 'a']++;
// freq[0] ('a') = 3, freq['n' - 'a'] = 2

ArrayList

List<Integer> list = new ArrayList<>(List.of(10, 20, 30));
list.add(40);                      // append, amortised O(1)
list.add(0, 5);                    // insert at index: O(n) -- shifts everything
list.get(2); list.set(2, 99);      // O(1)
list.remove(Integer.valueOf(20));  // remove by VALUE, O(n)
list.remove(0);                    // remove by INDEX, O(n)
list.contains(30);                 // O(n) linear scan!

Stack and queue: use ArrayDeque

ArrayDeque (a resizable circular array) is the right tool for both a stack and a queue. It is faster than LinkedList (no node object per element, better cache use) and does not allow null.

Stack (LIFO) -- push/pop/peek work on the FRONT push(1) push(2) push(3) -> [3, 2, 1] peek() = 3, pop() = 3 -> [2, 1] ^ top Queue (FIFO) -- offer adds at the BACK, poll removes from the FRONT offer(1) offer(2) offer(3) -> [1, 2, 3] peek() = 1, poll() = 1 -> [2, 3] ^ front ^ back
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); stack.push(2); stack.push(3);
stack.peek();   // 3
stack.pop();    // 3  -> stack is [2, 1]

Deque<Integer> queue = new ArrayDeque<>();
queue.offer(1); queue.offer(2); queue.offer(3);
queue.peek();   // 1
queue.poll();   // 1  -> queue is [2, 3]

// Deque (both ends): offerFirst/offerLast, pollFirst/pollLast, peekFirst/peekLast
// Empty behaviour: pop()/remove() THROW; poll()/peek() return null

Why not java.util.Stack? It is an old class that extends Vector: every method is synchronized (slow, pointless for one thread), and because it is a Vector you can insert into the middle, which breaks the stack idea. Java's own docs recommend Deque instead. Mentioning this is a small but good signal.

When is LinkedList useful? Rarely in interviews. It allows null and implements both List and Deque, but get(i) is O(n). Use ArrayDeque for queues and ArrayList for lists.

HashMap and HashSet

// Counting: three equivalent styles
Map<String, Integer> count = new HashMap<>();
for (String w : "a b a c b a".split(" ")) count.merge(w, 1, Integer::sum);   // {a=3, b=2, c=1}
map.put(k, map.getOrDefault(k, 0) + 1);                   // classic

// Grouping: create the list the first time a key is seen
Map<String, List<String>> groups = new HashMap<>();
for (String w : List.of("eat", "tea", "tan", "ate")) {
    char[] k = w.toCharArray(); Arrays.sort(k);
    groups.computeIfAbsent(new String(k), key -> new ArrayList<>()).add(w);
}
// {aet=[eat, tea, ate], ant=[tan]}

// Iterate keys and values together
for (Map.Entry<String, Integer> e : count.entrySet()) {
    System.out.println(e.getKey() + ":" + e.getValue());
}
// also: map.keySet(), map.values(), map.containsKey(k), map.remove(k), map.putIfAbsent(k, v)

Set<Integer> set = new HashSet<>();
set.add(5);        // true  (was new)
set.add(5);        // false (already there) -- handy for "seen before?" in one call
set.contains(5);   // true
  • HashMap iteration order is not defined. Use LinkedHashMap for insertion order (also the basis of an LRU cache), TreeMap for sorted order.
  • Keys must have correct equals and hashCode. int[] does not — two arrays with the same contents are different keys. Use a String key, a List<Integer>, a record, or encode a pair as (long) r * cols + c.

TreeMap and TreeSet — sorted, with "nearest" queries

A red-black tree (a self-balancing binary search tree) under the hood: every operation is O(log n) and keys stay sorted. Use them when you need "the closest key to x" or ordered iteration.

TreeMap<Integer, String> tm = new TreeMap<>(Map.of(10, "a", 20, "b", 30, "c"));
tm.floorKey(25);     // 20    largest key <= 25
tm.ceilingKey(25);   // 30    smallest key >= 25
tm.lowerKey(10);     // null  largest key strictly < 10
tm.higherKey(30);    // null  smallest key strictly > 30
tm.firstKey(); tm.lastKey();   // 10, 30
tm.headMap(20);      // {10=a}          keys < 20
tm.tailMap(20);      // {20=b, 30=c}    keys >= 20
// also: floorEntry, ceilingEntry, pollFirstEntry, descendingMap

TreeSet<Integer> ts = new TreeSet<>(List.of(4, 1, 9, 6));   // [1, 4, 6, 9]
ts.floor(5);         // 4
ts.ceiling(5);       // 6
ts.pollFirst();      // 1 (removes it)

Always check for null — the "nearest" methods return null when nothing qualifies, and unboxing that into an int throws NullPointerException.

PriorityQueue — a heap

A binary heap: peek gives the smallest element in O(1); offer and poll are O(log n). By default it is a min-heap.

PriorityQueue<Integer> minHeap = new PriorityQueue<>();
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());
for (int v : new int[]{5, 1, 8, 3}) { minHeap.offer(v); maxHeap.offer(v); }
minHeap.poll();   // 1
maxHeap.poll();   // 8

// Heap of arrays: {node, distance}, ordered by distance (Dijkstra style)
PriorityQueue<int[]> pq = new PriorityQueue<>((p, q) -> Integer.compare(p[1], q[1]));
pq.offer(new int[]{0, 7}); pq.offer(new int[]{1, 2}); pq.offer(new int[]{2, 5});
pq.poll();        // [1, 2] -- smallest distance first
  • Iterating or printing a PriorityQueue does not give sorted order — only poll does.
  • remove(Object) and contains are O(n).
  • Building from a collection, new PriorityQueue<>(list), is O(n) (heapify), faster than n offers.

Collections helpers and Comparator chains

List<Integer> nums = new ArrayList<>(List.of(3, 1, 2));
Collections.sort(nums);        // [1, 2, 3]
Collections.reverse(nums);     // [3, 2, 1]
Collections.max(nums);         // 3
Collections.min(nums);         // 1
Collections.frequency(List.of(1, 1, 2), 1);   // 2
Collections.swap(nums, 0, 2);  // also: Collections.nCopies, Collections.emptyList

record Person(String name, int age) {}
List<Person> people = new ArrayList<>(List.of(
    new Person("Zoe", 30), new Person("Adam", 25), new Person("Bob", 30)));
// age descending, then name ascending
people.sort(Comparator.comparingInt(Person::age).reversed()
                      .thenComparing(Person::name));
// [Bob 30, Zoe 30, Adam 25]

List<String> words = new ArrayList<>(List.of("pear", "fig", "apple", "kiwi"));
// shortest first; ties broken by reverse alphabetical
words.sort(Comparator.comparing(String::length)
                     .thenComparing(Comparator.reverseOrder()));
// [fig, pear, kiwi, apple]
Never compare with subtraction: the comparator (a, b) -> a - b overflows for large values. For example -2_000_000_000 - 2_000_000_000 evaluates to 294967296 — a positive number, so it says the negative value is larger. Use Integer.compare(a, b) (which returned -1 here) or Comparator.comparingInt.

Integer overflow

An int is 32 bits: range about ±2.1 billion (Integer.MAX_VALUE = 2147483647). Going past it silently wraps around to negative — no error.

int big = Integer.MAX_VALUE;
big + 1;                  // -2147483648   wrapped around!
(long) big + 1;           // 2147483648    cast BEFORE the math
int m = 100_000;
m * m;                    // 1410065408    wrong (true answer is 10^10)
(long) m * m;             // 10000000000   right
Math.addExact(big, 1);    // throws ArithmeticException("integer overflow")
int mid = lo + (hi - lo) / 2;   // safe midpoint; (lo + hi) / 2 can overflow

Use long for sums of many values, products, and anything counted "modulo 10⁹ + 7" (multiply as long, then take %). (long) (m * m) is still wrong — the overflow already happened inside the brackets.

The Integer caching trap

Integer p1 = 127, p2 = 127, q1 = 128, q2 = 128;
p1 == p2;          // true   -- same cached object
q1 == q2;          // false  -- two different objects!
q1.equals(q2);     // true

Map<Character, Integer> m1 = ..., m2 = ...;   // both map 'a' -> 1000
m1.get('a') == m2.get('a');       // false  -- compares object identity
m1.get('a').equals(m2.get('a'));  // true

Java caches Integer objects for values −128 to 127, so == happens to work for small numbers in your tests and then fails on large ones. Whenever values come out of a collection, compare with .equals or unbox first (int x = map.get(k);). This bug is famous in "Minimum Window Substring"-style problems that compare two count maps.

int vs Integer in collections

  • Collections hold objects only, so List<int> is illegal; use List<Integer>. Java autoboxes for you, but each Integer is an object (≈16 bytes vs 4) — an int[] is much faster and smaller when the size is known.
  • Unboxing a null throws NullPointerException: int c = map.get(missingKey); crashes. Use getOrDefault.
  • List<Integer>.remove(1) removes index 1; remove(Integer.valueOf(1)) removes the value 1. From [1, 2, 3]: remove(1) gives [1, 3], then remove(Integer.valueOf(1)) gives [3].

Fast input (for online judges)

Scanner is convenient but slow for 10⁵+ numbers. Use BufferedReader with StringTokenizer:

BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
int n = Integer.parseInt(br.readLine().trim());
StringTokenizer st = new StringTokenizer(br.readLine());
long total = 0;
for (int i = 0; i < n; i++) total += Integer.parseInt(st.nextToken());
// For lots of output, collect in a StringBuilder and print once
// (or use PrintWriter) instead of calling System.out.println in a loop.

Operation cost cheat sheet

StructureAccess / getSearch / containsInsertDeleteNotes
int[] arrayO(1)O(n)——Fixed size; fastest
ArrayListO(1)O(n)O(1)* at end, O(n) middleO(1) at end, O(n) middle*amortised
LinkedListO(n)O(n)O(1) at endsO(1) at endsRarely the best choice
ArrayDequepeek O(1)O(n)O(1)* either endO(1) either endStack and queue
HashMap / HashSetO(1) avgO(1) avgO(1) avgO(1) avgNo order; worst O(log n) per bucket in Java 8+
LinkedHashMapO(1) avgO(1) avgO(1) avgO(1) avgKeeps insertion (or access) order
TreeMap / TreeSetO(log n)O(log n)O(log n)O(log n)Sorted; floor/ceiling O(log n); first/last O(log n)
PriorityQueuepeek O(1)O(n)O(log n)poll O(log n), remove(x) O(n)Min-heap by default
StringcharAt O(1)indexOf O(n·m)Immutable: every change makes a new copy O(n)substring is O(k)
StringBuildercharAt O(1)O(n)append O(1)*deleteCharAt O(n)insert(0, …) is O(n)

5. Bit Manipulation

In plain words

Computers store every integer as a row of bits — 0s and 1s. Bit manipulation means working on those bits directly with operators like AND, OR and XOR. It gives tiny, very fast solutions to certain problems, and lets you use a single int as a set of up to 32 yes/no flags.

A row of light switches on a wall. The number is the pattern of which switches are on. Bit operations are like saying "flip switch 3", "turn on every switch that is on in either of these two rows", or "which switches differ between these two rows?"

Binary basics

In binary each position is a power of 2, counted from the right starting at position 0. So 1101 = 1·8 + 1·4 + 0·2 + 1·1 = 13.

Integer.toBinaryString(13);      // "1101"
Integer.parseInt("1101", 2);     // 13
int n = 0b1010;                  // binary literal = 10
// bit i has value 1 << i:  1 << 0 = 1, 1 << 3 = 8, 1 << 10 = 1024

Two's complement — how negatives are stored

Java's int is 32 bits and signed. The leftmost bit is the sign bit: 0 for non-negative, 1 for negative. Negatives use two's complement: to get -x, flip every bit of x and add 1. So -x == ~x + 1.

5 = 00000000 00000000 00000000 00000101 ~5 = 11111111 11111111 11111111 11111010 (flip every bit) = -6 -5 = 11111111 11111111 11111111 11111011 (~5 + 1) -1 = 11111111 11111111 11111111 11111111 (all ones) Range: Integer.MIN_VALUE = -2^31 = 1000...0000 Integer.MAX_VALUE = 2^31 - 1 = 0111...1111 Note: -Integer.MIN_VALUE overflows back to Integer.MIN_VALUE

Why this design? Addition works the same for positive and negative numbers, and there is only one zero. It also explains ~5 == -6, which we confirmed by running it.

The operators

OperatorNameRuleExample (12 = 1100, 10 = 1010)
&AND1 only if both bits are 112 & 10 = 1000 = 8
|OR1 if either bit is 112 | 10 = 1110 = 14
^XOR1 if the bits are different12 ^ 10 = 0110 = 6
~NOTflip every bit~5 = -6
<<Left shiftmove bits left, fill with 0 (multiply by 2ᵏ)5 << 2 = 20
>>Signed right shiftmove right, copy the sign bit in (divide by 2ᵏ, rounding down)20 >> 2 = 5, -20 >> 2 = -5
>>>Unsigned right shiftmove right, always fill with 0-20 >>> 28 = 15
  • Shift amounts are taken mod 32 for int: 1 << 32 is 1, not 0. And 1 << 31 is -2147483648 (the sign bit). Use 1L << k for 64-bit masks (1L << 32 = 4294967296).
  • Precedence trap: &, ^, | bind weaker than ==. n & 1 == 0 does not compile in Java (it parses as n & (1 == 0)). Always write (n & 1) == 0.
  • Odd/even: (n & 1) is 1 for odd, 0 for even — it works for negatives too, while -7 % 2 is -1, not 1.

Single-bit tricks

// i = bit position, 0 = rightmost.  All O(1).
static boolean isSet(int n, int i) { return (n & (1 << i)) != 0; }  // check
static int setBit(int n, int i)    { return n | (1 << i); }          // turn on
static int clearBit(int n, int i)  { return n & ~(1 << i); }         // turn off
static int toggleBit(int n, int i) { return n ^ (1 << i); }          // flip

// n = 0b1010 (10)
isSet(10, 1);      // true
isSet(10, 2);      // false
setBit(10, 0);     // 11  (1011)
clearBit(10, 3);   // 2   (0010)
toggleBit(10, 1);  // 8   (1000)

Pattern: build a mask with a single 1 at position i (1 << i), then AND to test, OR to set, AND-with-NOT to clear, XOR to flip.

The two most useful tricks: n & (n - 1) and n & -n

Subtracting 1 from a number flips its lowest 1 bit to 0 and all the 0s below it to 1. So n & (n - 1) wipes out exactly the lowest set bit. And because -n = ~n + 1, n & -n keeps only the lowest set bit.

n & (n - 1) clears the lowest 1 n = 12 0 0 0 0 1 1 0 0 n-1 = 11 0 0 0 0 1 0 1 1 & = 8 0 0 0 0 1 0 0 0 n & -n keeps only the lowest 1 n = 12 0 0 0 0 1 1 0 0 -n = -12 1 1 1 1 0 1 0 0 & = 4 0 0 0 0 0 1 0 0 The highlighted column is bit 2 (value 4) — the lowest 1 in 12. Shown with 8 bits; Java uses 32, the idea is identical.
n & (n - 1) removes the lowest set bit; n & -n isolates it. Both are O(1) and appear in many classic problems.
// Power of two: exactly one bit set. O(1)
static boolean isPowerOfTwo(int n) { return n > 0 && (n & (n - 1)) == 0; }
// 64 -> true, 96 -> false, 0 -> false, Integer.MIN_VALUE -> false (n > 0 matters!)

static int lowestSetBit(int n) { return n & -n; }    // 12 -> 4

// Brian Kernighan's bit count: loops once per SET bit. O(number of 1s), at most 32
static int countBits(int n) {
    int count = 0;
    while (n != 0) {
        n &= n - 1;        // drop the lowest set bit
        count++;
    }
    return count;
}
// countBits(13) = 3, countBits(-1) = 32  (while n != 0, so negatives work)
// Built-in: Integer.bitCount(13) = 3

Other handy built-ins: Integer.numberOfTrailingZeros(8) = 3, Integer.numberOfLeadingZeros(8) = 28, Integer.highestOneBit(100) = 64, Integer.reverse(x), and Long versions of each. Say you would use the built-in in production, and write the loop if asked.

XOR tricks

XOR has three properties that power a surprising number of problems:

  • a ^ a = 0 — a number cancels itself.
  • a ^ 0 = a — zero changes nothing.
  • Order does not matter (commutative and associative) — so you can XOR a whole list in any order and every pair cancels.

Single Number — every value appears twice except one. Find it in O(1) space:

// O(n) time, O(1) space
static int singleNumber(int[] nums) {
    int x = 0;
    for (int v : nums) x ^= v;   // pairs cancel: a ^ a = 0
    return x;
}
// [4, 1, 2, 1, 2] -> 4 ^ (1 ^ 1) ^ (2 ^ 2) = 4 ^ 0 ^ 0 = 4

Missing Number — nums holds n distinct values from 0..n with one missing. XOR every index and every value; everything present appears twice and cancels, leaving the missing one:

// O(n) time, O(1) space. (Sum formula n(n+1)/2 also works but can overflow for huge n.)
static int missingNumber(int[] nums) {
    int x = nums.length;              // include n itself
    for (int i = 0; i < nums.length; i++) x ^= i ^ nums[i];
    return x;
}
// [3, 0, 1] -> 2

Swap without a temp variable — a party trick; know it, but prefer a temp in real code (it breaks if both refer to the same array slot, because x ^ x = 0):

int a = 3, b = 7;
a ^= b; b ^= a; a ^= b;     // now a = 7, b = 3

Single Number III — two values appear once, everything else twice. XOR of all gives a ^ b. Any 1 bit in that result is a position where a and b differ; split the numbers into two groups by that bit and XOR each group:

// O(n) time, O(1) space
static int[] singleNumberIII(int[] nums) {
    int xor = 0;
    for (int v : nums) xor ^= v;       // = a ^ b, non-zero because a != b
    int diff = xor & -xor;             // lowest bit where a and b differ
    int a = 0, b = 0;
    for (int v : nums) {
        if ((v & diff) == 0) a ^= v;   // group 1
        else b ^= v;                   // group 2
    }
    return new int[]{a, b};
}
// [1, 2, 1, 3, 2, 5] -> [5, 3]

Subsets via bitmask

For n items, each of the numbers 0 to 2ⁿ - 1 is a different pattern of n bits. Read bit i as "is item i in the subset?" and you get every subset exactly once — no recursion needed.

nums = [1, 2, 3] bit 0 -> 1, bit 1 -> 2, bit 2 -> 3 mask binary subset 0 000 [] 1 001 [1] 2 010 [2] 3 011 [1, 2] 4 100 [3] 5 101 [1, 3] 6 110 [2, 3] 7 111 [1, 2, 3]
// O(n * 2^n) time, O(n * 2^n) for the output
static List<List<Integer>> subsets(int[] nums) {
    int n = nums.length;
    List<List<Integer>> out = new ArrayList<>();
    for (int mask = 0; mask < (1 << n); mask++) {          // 0 .. 2^n - 1
        List<Integer> subset = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            if ((mask & (1 << i)) != 0) subset.add(nums[i]); // bit i on -> take nums[i]
        }
        out.add(subset);
    }
    return out;
}
// [1,2,3] -> [[], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3]]

This is also why problems with n ≤ 20 hint at bitmasks: 2²⁰ is about a million states. The same idea powers bitmask DP, where the mask records "which items are already used" (for example, visiting-all-cities problems).

More classic problems

Counting Bits — return the number of 1s for every number 0..n. Reuse earlier answers: i >> 1 is i with its last bit dropped, so its count is already known; add back the last bit.

// O(n) time, O(n) output
static int[] countBitsUpTo(int n) {
    int[] ans = new int[n + 1];
    for (int i = 1; i <= n; i++) ans[i] = ans[i >> 1] + (i & 1);
    return ans;
}
// n = 5 -> [0, 1, 1, 2, 1, 2]

Reverse Bits — mirror all 32 bits. Take the lowest bit of n, push it into the result from the right, repeat 32 times. Use >>> so negative inputs do not drag in sign bits:

// O(32) = O(1) time, O(1) space
static int reverseBits(int n) {
    int result = 0;
    for (int i = 0; i < 32; i++) {
        result = (result << 1) | (n & 1);  // append n's lowest bit to result
        n >>>= 1;                          // unsigned shift: no sign fill
    }
    return result;
}
// reverseBits(0b1011) = -805306368 = 11010000...0 (binary), same as Integer.reverse(0b1011)

Letter case — in ASCII, upper and lower case letters differ only in bit 5 (value 32, the space character): (char) ('A' | ' ') = 'a' and (char) ('a' & '_') = 'A'. Cute, but Character.toLowerCase is clearer in real code.

Summary table

GoalExpression
Is bit i set?(n & (1 << i)) != 0 or ((n >> i) & 1) == 1
Set / clear / toggle bit in | (1 << i) / n & ~(1 << i) / n ^ (1 << i)
Drop lowest set bitn & (n - 1)
Keep only lowest set bitn & -n
Power of two?n > 0 && (n & (n - 1)) == 0
Odd?(n & 1) == 1
Multiply / divide by 2ᵏn << k / n >> k (floor division, also for negatives)
All ones for k bits(1 << k) - 1
Count set bitsInteger.bitCount(n)
When to think "bits" in an interview: the words "appears twice except", "without extra space", "power of two", "subsets" with n ≤ 20, or "without using + or *". Explain the key property out loud (for XOR: "equal numbers cancel"), because bit solutions look like magic unless you say why they work.
Part 2 — Linear Data Structures

6. Arrays & Strings

In plain words

An array is a row of boxes, all the same size, sitting side by side in memory. Each box has a number called its index, starting at 0. Because the boxes are side by side, the computer can jump straight to box number i without looking at the others.

A string is basically an array of characters with some extra rules. In Java, a String can never be changed once it is made.

A row of numbered lockers in a hallway. If you know the locker number, you walk straight to it. But if you want to squeeze a new locker into the middle, everyone after it has to shift one place down.

How an array sits in memory

When you write new int[7], Java asks for one contiguous (unbroken) block of memory big enough for 7 ints. An int is 4 bytes, so that is 28 bytes in a row. To find element i, the machine does one small sum: start address + i × element size. That single sum is why reading a[i] is O(1) no matter how big the array is.

index value address 0 1 2 3 4 5 6 12 7 31 4 19 8 25 1000 1004 1008 1012 1016 1020 1024 a[3] = start + 3 × 4 = 1012 (one step, O(1))
Contiguous memory: every element is the same size and sits right after the previous one, so any index can be reached with one multiplication and one addition.

A side benefit: when the CPU reads one element, it pulls the neighbouring bytes into its fast cache too. Walking an array from left to right is therefore much faster in practice than hopping around a linked list, even when both are O(n) on paper.

Static vs dynamic arrays

A plain Java array (int[], String[]) is static: its length is fixed when you create it. An ArrayList is a dynamic array: a static array hidden inside an object that swaps it for a bigger one when it fills up.

ArrayList, capacity 4, size 4: [10][20][30][40] add(50) → no room! 1. allocate a bigger array [ ][ ][ ][ ][ ][ ] (Java: 1.5x, here 6) 2. copy the old elements over [10][20][30][40][ ][ ] O(n), but rare 3. write the new element [10][20][30][40][50][ ] 4. old array becomes garbage

Why is add still called O(1)? Because the expensive copy happens rarely. With doubling, filling an array to n elements copies 1 + 2 + 4 + … + n/2 elements in total, which is less than n. Spread over n adds, that is less than one extra copy per add. This is called amortised O(1): one call can be slow, but the average over many calls is constant. Java's ArrayList grows by 1.5x instead of 2x (saves memory, still amortised O(1)); the first add on a new list allocates capacity 10.

Building a dynamic array from scratch

Interviewers sometimes ask "implement an ArrayList". The whole idea fits in a few lines:

class IntList {
    private int[] data = new int[4];
    private int size = 0;

    void add(int x) {                       // amortised O(1)
        if (size == data.length) {
            data = Arrays.copyOf(data, data.length * 2);  // O(n) copy, rare
        }
        data[size++] = x;
    }
    int get(int i) {                        // O(1)
        if (i < 0 || i >= size) throw new IndexOutOfBoundsException(i);
        return data[i];
    }
    void removeAt(int i) {                  // O(n): shift left
        if (i < 0 || i >= size) throw new IndexOutOfBoundsException(i);
        System.arraycopy(data, i + 1, data, i, size - i - 1);
        size--;
    }
    int size() { return size; }
}
// Adding 10, 20, ..., 90 makes capacity go 4 4 4 4 8 8 8 8 16

Operations and their cost

Operationint[]ArrayListWhy
Read / write a[i]O(1)O(1)Address arithmetic
Add at endnot possible (fixed size)O(1) amortisedOccasional resize copy
Insert / remove at index iO(n)O(n)Everything after i must shift
Remove last—O(1)Nothing to shift
Search (unsorted)O(n)O(n)Must check each element
Search (sorted)O(log n)O(log n)Binary search
SpaceO(n)O(n)ArrayList may hold up to ~50% spare capacity

2D arrays

In Java, int[][] grid = new int[3][4] is really an array of 3 references, each pointing to its own int[4]. Rows are separate objects, so rows can even have different lengths (a jagged array). grid.length is the number of rows, grid[0].length the number of columns.

int[][] grid = new int[3][4];          // 3 rows, 4 columns, all 0
grid[1][2] = 7;                        // row 1, column 2
int[][] jagged = {{1}, {2, 3}, {4, 5, 6}};
System.out.println(Arrays.deepToString(jagged));   // [[1], [2, 3], [4, 5, 6]]

// The four neighbours of (r, c) — you will write this in every grid problem
int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (int[] d : dirs) {
    int nr = r + d[0], nc = c + d[1];
    if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) { /* visit */ }
}

Loop row by row (for r … for c …), not column by column: each row is contiguous, so row order is cache-friendly.

Strings: immutable, so use StringBuilder

A Java String is immutable: no method ever changes it. Methods like concat, replace and toUpperCase return a new string. This makes strings safe to share and cache (and to use as HashMap keys), but it has a cost: building a string with += in a loop copies the whole string every time, which is O(n²) total.

String s = "hi";
s.concat(" there");          // result thrown away
System.out.println(s);       // hi
s = s.concat(" there");      // reassign to keep it
System.out.println(s);       // hi there

// Build strings in a loop with StringBuilder: append is amortised O(1)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 5; i++) sb.append(i).append(',');
sb.setLength(sb.length() - 1);    // drop the last comma
System.out.println(sb.reverse()); // 4,3,2,1,0

// Compare contents with equals, never ==
String x = "java", y = "java", z = new String("java");
System.out.println((x == y) + " " + (x == z) + " " + x.equals(z));  // true false true

x == y is true only because identical string literals share one object in the string pool. new String(...) makes a separate object, so == (which compares references, i.e. "same object?") fails. Always use equals.

String operationCostNote
charAt(i), length()O(1)Backed by an array
substring(a, b)O(b − a)Copies the characters (since Java 7)
equals, indexOf, containsO(n) / O(n·m)Compares character by character
s + tO(n + m)New string every time
toCharArray()O(n)Gives a mutable copy you can sort or swap in
StringBuilder.appendO(1) amortisedDynamic char array inside

Counting characters with int[26]

When the input is only lowercase letters, a 26-slot array beats a HashMap: c - 'a' turns 'a' into 0, 'b' into 1, and so on. It is faster, uses fixed O(1) space, and is less code.

int[] freq = new int[26];
for (char c : "banana".toCharArray()) freq[c - 'a']++;
// freq[0] ('a') = 3, freq[1] ('b') = 1, freq['n' - 'a'] = 2

Use int[128] for any ASCII character, and a HashMap<Character, Integer> for full Unicode.

Common techniques (the toolbox)

TechniqueIdea in one lineTypical signal
Two pointersOne index from each end, or a slow and a fast index, moving toward an answerSorted array, pairs, palindromes, in-place removal
Sliding windowKeep a range [left, right] and grow/shrink it while tracking a running value"Longest / shortest subarray or substring with …"
Prefix sumsprefix[i] = sum of the first i elements, so any range sum is one subtractionMany range-sum queries, "subarray sum equals k"
Frequency countingint[26] or HashMap of countsAnagrams, "most common", duplicates
Reverse tricksReversing parts of an array rearranges it in placeRotation, reverse words
Sort firstO(n log n) sort often turns a hard problem into a two-pointer one3Sum, merge intervals, meeting rooms

Each of these gets its own section in the patterns part of these notes.

When to use what

  • int[] — size known up front, primitives, maximum speed (no boxing: int stays a raw number instead of an Integer object).
  • ArrayList — size grows, you mostly add at the end and read by index. The default list in Java.
  • Not an array — if you insert/remove at the front a lot, use ArrayDeque; if you look things up by value, use a HashSet/HashMap.

Common pitfalls

  • Off-by-one: the last index is length - 1. Loops are i < n, not i <= n.
  • length vs length() vs size(): arrays use the field a.length, strings the method s.length(), collections list.size().
  • Arrays.asList is fixed-size: calling add on it throws UnsupportedOperationException. Wrap it: new ArrayList<>(Arrays.asList(...)).
  • list.remove(1) on a List<Integer> removes index 1, not the value 1. Use list.remove(Integer.valueOf(1)) for the value.
  • Printing an array with System.out.println(a) prints something like [I@1b6d3586. Use Arrays.toString(a).
  • Integer overflow when summing or multiplying: use long when values can exceed about 2.1 billion.

Problem: Rotate Array (right by k)

Given [1,2,3,4,5,6,7] and k = 3, produce [5,6,7,1,2,3,4] in place.

void rotate(int[] nums, int k) {
    int n = nums.length;
    k %= n;                         // k can be larger than n
    reverse(nums, 0, n - 1);        // 1 2 3 4 5 6 7 -> 7 6 5 4 3 2 1
    reverse(nums, 0, k - 1);        // -> 5 6 7 | 4 3 2 1
    reverse(nums, k, n - 1);        // -> 5 6 7 | 1 2 3 4
}
void reverse(int[] a, int i, int j) {
    while (i < j) {
        int t = a[i]; a[i] = a[j]; a[j] = t;
        i++; j--;
    }
}

Time O(n) (every element is swapped at most twice). Space O(1). Why it works: reversing the whole array puts the last k elements at the front but backwards; reversing each part separately fixes the order inside each part.

Problem: Product of Array Except Self

Return ans[i] = product of every element except nums[i], without division. [1,2,3,4] gives [24,12,8,6].

int[] productExceptSelf(int[] nums) {
    int n = nums.length;
    int[] ans = new int[n];
    ans[0] = 1;
    for (int i = 1; i < n; i++) {           // ans[i] = product of everything left of i
        ans[i] = ans[i - 1] * nums[i - 1];
    }
    int right = 1;                          // running product of everything right of i
    for (int i = n - 1; i >= 0; i--) {
        ans[i] *= right;
        right *= nums[i];
    }
    return ans;
}
// [1, 2, 3, 4]      -> [24, 12, 8, 6]
// [-1, 1, 0, -3, 3] -> [0, 0, 9, 0, 0]

Time O(n). Space O(1) extra (the output array does not count). Why it works: "everything except me" is exactly "product on my left" times "product on my right", and both can be built in one pass each.

Problem: Longest Common Prefix

String longestCommonPrefix(String[] strs) {
    if (strs.length == 0) return "";
    String first = strs[0];
    for (int i = 0; i < first.length(); i++) {       // column by column
        char c = first.charAt(i);
        for (int j = 1; j < strs.length; j++) {
            if (i == strs[j].length() || strs[j].charAt(i) != c) {
                return first.substring(0, i);
            }
        }
    }
    return first;
}
// ["flower","flow","flight"] -> "fl"
// ["dog","racecar","car"]    -> ""

Time O(S), where S is the total number of characters (in the worst case every character is checked once). Space O(1) apart from the answer. Why it works: scanning column by column stops at the very first position where any string disagrees or runs out, which is exactly where the shared prefix ends.

Problem: Valid Anagram

boolean isAnagram(String s, String t) {
    if (s.length() != t.length()) return false;
    int[] count = new int[26];
    for (int i = 0; i < s.length(); i++) {
        count[s.charAt(i) - 'a']++;
        count[t.charAt(i) - 'a']--;
    }
    for (int c : count) if (c != 0) return false;
    return true;
}
// ("anagram", "nagaram") -> true,  ("rat", "car") -> false

Time O(n). Space O(1) (26 counters, whatever n is). Why it works: two strings are anagrams exactly when every letter appears the same number of times, so adding for one string and subtracting for the other must leave all zeros. (Sorting both and comparing also works, in O(n log n).)

Say this in the interview: "Arrays give O(1) access by index because memory is contiguous, but inserting in the middle is O(n) because of shifting. For strings I'll use a StringBuilder in loops since String is immutable, and an int[26] for letter counts."

7. Hashing

In plain words

Hashing turns a key (a word, a number, an object) into a number, and uses that number to pick a slot in an array. A hash table is that array of slots. Because the key tells you directly which slot to look in, finding, adding and removing are O(1) on average — no searching through everything.

In Java you use it through HashMap (key to value) and HashSet (just keys, no duplicates).

A coat check. You hand over your coat and get ticket number 42. When you come back, the attendant does not search every hook; they walk straight to hook 42. The ticket number is the hash.

The three pieces

  1. Hash function — turns a key into an integer. Same key must always give the same number. A good one spreads different keys evenly. In Java this is the key's hashCode() method.
  2. Buckets — the array of slots. The slot for a key is hash % capacity (Java uses hash & (capacity - 1), which is the same thing when capacity is a power of 2, and faster).
  3. Collision handling — two different keys can land in the same bucket. This is not a bug; it is guaranteed to happen (infinitely many possible keys, finitely many buckets). The table needs a plan for it.
"dog" hashCode() = 99644 spread, then & 7 = 5 buckets 0 1 2 3 4 6 7 null null null null bee=2 emu=8 dog=5 owl=1 cat=3 collision: owl also hashes to bucket 5 get("dog"): compute bucket 5, then walk its short chain comparing keys with equals()
Separate chaining with 8 buckets (the bucket numbers are the real ones Java computes for these strings). Each bucket holds a small linked list of the entries that landed there.

Collisions: chaining vs open addressing

Separate chainingOpen addressing
IdeaEach bucket holds a list of entries; colliding keys join the listOne entry per slot; on collision, probe (try) the next slot, then the next, until a free one
Probing styles—Linear (i+1, i+2 …), quadratic (i+1, i+4, i+9 …), double hashing (step size from a 2nd hash)
DeleteEasy: unlink from the listTricky: must leave a "deleted" marker (tombstone) or later probes break
Load factor limitCan go above 1 (just longer chains)Must stay well below 1 (clusters form fast)
Cache friendlinessWorse (pointer chasing)Better (all in one array)
Used byJava HashMapPython dict, Java IdentityHashMap, ThreadLocal
Linear probing, capacity 8. Insert "owl" (hashes to 5) when slot 5 already holds "dog": slot: 0 1 2 3 4 5 6 7 [ ] [ ] [ ] [bee] [emu] [dog] [ ] [cat] ^ taken → try 6 [ ] [ ] [ ] [bee] [emu] [dog] [owl] [cat] ^ free, store here get("owl"): start at 5, not equal → 6, equal → found. A search stops only at an EMPTY slot, which is why delete needs a tombstone.

Load factor and resizing

The load factor is entries / buckets. As it rises, chains get longer and lookups slow down. So when it passes a threshold (Java: 0.75), the table resizes: it allocates twice as many buckets and re-inserts every entry, because each entry's bucket index depends on the capacity. A resize is O(n), but it happens rarely (each time the size doubles), so put is still amortised O(1) — the same argument as ArrayList growth.

If you know you will store about a million entries, create the map with enough room to skip all the intermediate resizes: new HashMap<>(1_400_000) (expected size / 0.75), or HashMap.newHashMap(1_000_000) on Java 19+.

How Java's HashMap actually works

  • Internally it is an array Node<K,V>[] table, lazily created with 16 buckets on the first put. Capacity is always a power of 2.
  • Hash spreading: it computes h ^ (h >>> 16) so the high bits of hashCode() also influence the bucket (otherwise with 16 buckets only the lowest 4 bits would matter).
  • Bucket index = (capacity - 1) & hash.
  • get: go to the bucket, walk it, and for each node check hash == hash && key.equals(k).
  • Treeification: if one bucket's chain grows past 8 nodes, i.e. when the 9th is added, and the table has at least 64 buckets, that chain is converted into a red-black tree, so a bad bucket costs O(log n) instead of O(n). It turns back into a list if it shrinks to 6. (If the table is smaller than 64, Java resizes instead.) This protects against many keys with colliding hashes, including deliberate attacks.
  • Resize at size > capacity × 0.75, doubling the capacity. Each entry either stays at index i or moves to i + oldCapacity — Java checks one bit to decide.
  • One null key is allowed (it goes to bucket 0). Iteration order is not defined and can change after a resize.
  • Not thread-safe. Use ConcurrentHashMap across threads.

Build a hash map from scratch (separate chaining)

class MyHashMap<K, V> {
    private static class Node<K, V> {
        final K key; V value; Node<K, V> next;
        Node(K key, V value, Node<K, V> next) { this.key = key; this.value = value; this.next = next; }
    }
    private Node<K, V>[] buckets;
    private int size = 0;
    private static final double LOAD_FACTOR = 0.75;

    @SuppressWarnings("unchecked")
    MyHashMap() { buckets = (Node<K, V>[]) new Node[16]; }

    private int indexFor(Object key, int length) {
        int h = (key == null) ? 0 : key.hashCode();
        h ^= (h >>> 16);                  // mix high bits into low bits (like Java)
        return h & (length - 1);          // same as h % length when length is a power of 2
    }

    V get(K key) {                        // O(1) average
        for (Node<K, V> n = buckets[indexFor(key, buckets.length)]; n != null; n = n.next) {
            if (Objects.equals(n.key, key)) return n.value;
        }
        return null;
    }

    void put(K key, V value) {            // O(1) average, amortised
        int i = indexFor(key, buckets.length);
        for (Node<K, V> n = buckets[i]; n != null; n = n.next) {
            if (Objects.equals(n.key, key)) { n.value = value; return; }  // update
        }
        buckets[i] = new Node<>(key, value, buckets[i]);                   // insert at head
        if (++size > buckets.length * LOAD_FACTOR) resize();
    }

    @SuppressWarnings("unchecked")
    private void resize() {               // O(n), but rare -> amortised O(1)
        Node<K, V>[] old = buckets;
        buckets = (Node<K, V>[]) new Node[old.length * 2];
        for (Node<K, V> head : old) {
            for (Node<K, V> n = head; n != null; n = n.next) {
                int i = indexFor(n.key, buckets.length);
                buckets[i] = new Node<>(n.key, n.value, buckets[i]);
            }
        }
    }
}
// After 13 puts, the 13th pushes size past 16 * 0.75 = 12, so capacity becomes 32.

Operations and their cost

OperationAverageWorst (Java 8+)Note
put / get / remove / containsKeyO(1)O(log n)Worst case = everything in one treeified bucket (O(n) before Java 8 or for keys that are not Comparable)
containsValueO(n)O(n)Values are not indexed — scans everything
Iterate all entriesO(n + capacity)O(n + capacity)Visits every bucket, even empty ones
ResizeO(n), amortised O(1) per putAt load factor 0.75
SpaceO(n)Each entry is a node object: much heavier than an array slot

The equals / hashCode contract

HashMap finds a key in two steps: hashCode() picks the bucket, then equals() finds the exact key inside it. So the rule is: if a.equals(b) is true, then a.hashCode() == b.hashCode() must also be true. (The reverse is not required: different keys may share a hash — that is just a collision. For example "Aa".hashCode() and "BB".hashCode() are both 2112.)

Override equals but forget hashCode, and two "equal" objects get different identity-based hash codes, land in different buckets, and never find each other:

class BadPoint {
    final int x, y;
    BadPoint(int x, int y) { this.x = x; this.y = y; }
    @Override public boolean equals(Object o) {
        return o instanceof BadPoint p && p.x == x && p.y == y;
    }
    // hashCode NOT overridden -> uses identity hash
}

Set<BadPoint> bad = new HashSet<>();
bad.add(new BadPoint(1, 2));
System.out.println(bad.contains(new BadPoint(1, 2)));   // false  (bug!)

// Fix: a record generates correct equals AND hashCode for you
record Point(int x, int y) {}
Set<Point> good = new HashSet<>();
good.add(new Point(1, 2));
System.out.println(good.contains(new Point(1, 2)));     // true

Without records, write @Override public int hashCode() { return Objects.hash(x, y); } using the same fields that equals uses.

HashSet, LinkedHashMap, TreeMap

HashSet is literally a HashMap where the set elements are the keys and every value is the same dummy object. Same costs, same rules. set.add(x) returns false if x was already there — handy for duplicate detection.

ClassOrder when iteratingget / putUse when
HashMapNone (looks random)O(1) avgDefault choice
LinkedHashMapInsertion order (or access order)O(1) avgNeed predictable order; LRU cache (access order + removeEldestEntry)
TreeMapSorted by keyO(log n)Need sorted keys, or floor/ceiling/range queries

Handy Map methods that make interview code short: getOrDefault(k, 0), merge(k, 1, Integer::sum) (counting), computeIfAbsent(k, x -> new ArrayList<>()).add(v) (grouping), putIfAbsent.

When to use hashing

  • "Have I seen this before?" — HashSet. Turns an O(n) inner search into O(1), so many O(n²) brute forces become O(n).
  • "How many times?" — HashMap<T, Integer> of counts.
  • "Group things by some property" — HashMap<Key, List<T>>.
  • Don't use it when you need order (use TreeMap), or when keys are small integers in a known range (a plain array is faster and simpler).

Common pitfalls

  • Mutable keys: if you change a key object after putting it in, its hash changes and the map can no longer find it. A List key that you add to afterwards makes contains return false, even though the set's size is still 1. Use immutable keys (String, Integer, records).
  • Comparing Integer values with ==: map.get("x") == map.get("y") compares object references. It happens to work for -128 to 127 (Java caches those), and silently fails for 1000. Use equals or unbox to int first.
  • get returns null for a missing key; unboxing that into int throws NullPointerException. Use getOrDefault.
  • Using arrays as keys: int[] uses identity equality. Convert to a String or List<Integer> first.
  • Assuming iteration order of a HashMap. It is not insertion order.

Problem: Two Sum

Return indices of the two numbers that add up to target.

int[] twoSum(int[] nums, int target) {
    Map<Integer, Integer> seen = new HashMap<>();   // value -> index
    for (int i = 0; i < nums.length; i++) {
        int need = target - nums[i];
        if (seen.containsKey(need)) return new int[]{seen.get(need), i};
        seen.put(nums[i], i);
    }
    return new int[0];
}
// [2, 7, 11, 15], 9 -> [0, 1]
// [3, 2, 4], 6      -> [1, 2]

Time O(n). Space O(n). Why it works: for each number, its partner (target - x) is fully determined, so instead of searching for it we ask the map "have I already seen it?" in O(1). Checking before inserting stops a number pairing with itself.

Problem: Group Anagrams

List<List<String>> groupAnagrams(String[] strs) {
    Map<String, List<String>> groups = new HashMap<>();
    for (String s : strs) {
        int[] count = new int[26];
        for (char c : s.toCharArray()) count[c - 'a']++;
        String key = Arrays.toString(count);          // e.g. "[1, 0, 0, ...]"
        groups.computeIfAbsent(key, k -> new ArrayList<>()).add(s);
    }
    return new ArrayList<>(groups.values());
}
// ["eat","tea","tan","ate","nat","bat"]
//   -> [bat], [eat, tea, ate], [tan, nat]   (group order may vary)

Time O(n · k), where k is the longest word length (the key has fixed length 26). Space O(n · k). Why it works: all anagrams have identical letter counts, so the count array is a perfect "signature" to group by. (Sorting each word as the key also works: O(n · k log k).)

Problem: Longest Consecutive Sequence

In an unsorted array, find the length of the longest run of consecutive integers, in O(n).

int longestConsecutive(int[] nums) {
    Set<Integer> set = new HashSet<>();
    for (int x : nums) set.add(x);
    int best = 0;
    for (int x : set) {
        if (set.contains(x - 1)) continue;       // not the start of a run
        int len = 1;
        while (set.contains(x + len)) len++;     // walk the run
        best = Math.max(best, len);
    }
    return best;
}
// [100, 4, 200, 1, 3, 2]          -> 4   (1, 2, 3, 4)
// [0, 3, 7, 2, 5, 8, 4, 6, 0, 1]  -> 9

Time O(n): the inner while only runs from the start of each run, so every number is walked over at most once in total. Space O(n). Why it works: only counting from numbers that have no left neighbour means each run is measured exactly once, from its first element.

Problem: Subarray Sum Equals K (prefix sums preview)

Count the contiguous subarrays whose sum is exactly k. Numbers can be negative, so a sliding window does not work.

int subarraySum(int[] nums, int k) {
    Map<Integer, Integer> prefixCount = new HashMap<>();
    prefixCount.put(0, 1);                       // empty prefix
    int sum = 0, count = 0;
    for (int x : nums) {
        sum += x;
        count += prefixCount.getOrDefault(sum - k, 0);
        prefixCount.merge(sum, 1, Integer::sum);
    }
    return count;
}
// [1, 1, 1], k = 2                  -> 2
// [1, 2, 3], k = 3                  -> 2   ([1,2] and [3])
// [3, 4, 7, 2, -3, 1, 4, 2], k = 7  -> 4

Time O(n). Space O(n). Why it works: the sum of the subarray from j+1 to i is prefix[i] - prefix[j]. That equals k exactly when an earlier prefix equals prefix[i] - k, so we just count how many earlier prefixes had that value. The put(0, 1) covers subarrays that start at index 0.

Say this in the interview: "A HashMap gives O(1) average lookup, O(log n) worst case in Java 8+ because long buckets become red-black trees. I'll use it to trade O(n) extra space for dropping an O(n) inner loop. If I use custom objects as keys, I'll make sure equals and hashCode agree — a record does that for me."

8. Linked Lists

In plain words

A linked list is a chain of small objects called nodes. Each node holds a value and a pointer (in Java: a reference) to the next node. The list only remembers where the first node is — the head. The last node points to null.

Unlike an array, the nodes can be anywhere in memory. That makes adding or removing a node cheap (just redirect a pointer), but finding the 50th node means walking through the first 49.

A treasure hunt. Each clue tells you where the next clue is hidden. Adding a new clue in the middle is easy — just change one clue to point at it. But to reach clue 50 you must follow all the clues before it.

How it sits in memory

head 1 next @7F20 2 next @3A08 3 next @9C44 null val Nodes live at scattered addresses. The only way from node 1 to node 3 is to follow the arrows. Each node is a separate object: value + reference (about 16 to 24 bytes each in Java).
Singly linked list 1 → 2 → 3. "Pointer" in these notes means a Java object reference.

Singly, doubly, circular

Singly: head → [1|•] → [2|•] → [3|•] → null one link per node, can only move forward Doubly: null ← [•|1|•] ⇄ [•|2|•] ⇄ [•|3|•] → null prev + next; can walk both ways, remove a known node in O(1) Circular: head → [1|•] → [2|•] → [3|•] ─┐ ↑ │ └────────────────────────────┘ last node points back to the first (round-robin scheduling, ring buffers)

In interviews, "linked list" almost always means singly linked with this node class (the LeetCode one):

class ListNode {
    int val;
    ListNode next;
    ListNode(int val) { this.val = val; }
    ListNode(int val, ListNode next) { this.val = val; this.next = next; }
}

Insert and delete = rewiring pointers

To insert or delete, you need a reference to the node before the spot. Then it is two pointer changes, in the right order:

Insert X after A: A → B Delete B (have A = node before): 1. X.next = A.next A → B, X → B A.next = A.next.next 2. A.next = X A → X → B A → B → C becomes A → C (swap the steps and you lose B forever) B is now unreachable; Java's GC frees it

The dummy head trick: inserting or deleting at the head is a special case, because there is no "node before". Put a fake node (a dummy or sentinel) in front of the head. Now every real node has a node before it, the special case disappears, and at the end you return dummy.next. Use it any time the head might change.

Implement a linked list from scratch

class MyLinkedList {
    private final ListNode dummy = new ListNode(0);   // sentinel before the real head
    private ListNode tail = dummy;
    private int size = 0;

    void addFirst(int val) {                 // O(1)
        dummy.next = new ListNode(val, dummy.next);
        if (tail == dummy) tail = dummy.next;
        size++;
    }
    void addLast(int val) {                  // O(1) thanks to tail pointer
        tail.next = new ListNode(val);
        tail = tail.next;
        size++;
    }
    void addAt(int index, int val) {         // O(n): walk to index-1
        if (index < 0 || index > size) throw new IndexOutOfBoundsException(index);
        if (index == size) { addLast(val); return; }
        ListNode prev = dummy;
        for (int i = 0; i < index; i++) prev = prev.next;
        prev.next = new ListNode(val, prev.next);
        size++;
    }
    boolean remove(int val) {                // O(n): find the node BEFORE the target
        for (ListNode prev = dummy; prev.next != null; prev = prev.next) {
            if (prev.next.val == val) {
                if (prev.next == tail) tail = prev;
                prev.next = prev.next.next;  // unlink
                size--;
                return true;
            }
        }
        return false;
    }
    int indexOf(int val) {                   // O(n)
        int i = 0;
        for (ListNode cur = dummy.next; cur != null; cur = cur.next, i++) {
            if (cur.val == val) return i;
        }
        return -1;
    }
}
// addLast(2), addLast(3), addFirst(1), addAt(3, 4), addAt(1, 9)
//   -> 1 -> 9 -> 2 -> 3 -> 4   (size 5)

With a doubly linked list, if you already hold a node, removing it needs no search at all — this is how an LRU cache removes entries in O(1):

void unlink(DNode node) {          // assumes dummy head and tail, so prev/next are never null
    node.prev.next = node.next;
    node.next.prev = node.prev;
}

Operations and their cost

OperationSinglyDoublyArray (compare)
Access i-th elementO(n)O(n)O(1)
Search by valueO(n)O(n)O(n)
Insert / delete at headO(1)O(1)O(n)
Insert at tailO(1) with tail pointerO(1)O(1) amortised
Delete at tailO(n) (need the node before)O(1)O(1)
Insert / delete after a node you holdO(1)O(1)O(n)
Delete a node you holdO(n) (must find prev)O(1)O(n)
Extra space1 reference per node2 references per nodenone (maybe spare capacity)

Java built-in: LinkedList (and why you rarely want it)

java.util.LinkedList is a doubly linked list that implements both List and Deque: addFirst, addLast, removeFirst, getFirst are all O(1). But its get(i) is O(n), and every element is a separate object, so it is slow and memory-hungry in practice. For a stack or queue, ArrayDeque is faster; for a list, ArrayList. In interviews, linked-list questions give you ListNode and expect you to move pointers yourself.

When to use a linked list

  • You hold references to nodes and must remove/insert them in O(1): LRU cache (HashMap + doubly linked list), adjacency lists, free lists in allocators.
  • You need to splice lists together or split them without copying.
  • Otherwise, prefer an array-based structure — random access and cache locality usually win.

Common pitfalls

  • NullPointerException: check cur != null before cur.next, and fast != null && fast.next != null before fast.next.next.
  • Losing the rest of the list: save next before you overwrite cur.next.
  • Moving head itself while walking, then returning it — use a separate cur variable.
  • Forgetting the head can change — use a dummy node.
  • Creating cycles by accident (for example forgetting to set the new tail's next to null), which makes printing loop forever.
  • Draw it. Three boxes and arrows on paper catch most bugs before you write code.

Problem: Reverse Linked List (iterative and recursive)

Iterative: walk once, flipping each arrow to point backwards. start: null 1 → 2 → 3 → null prev cur step 1: null ← 1 2 → 3 → null next = 2; 1.next = null prev cur step 2: null ← 1 ← 2 3 → null next = 3; 2.next = 1 prev cur step 3: null ← 1 ← 2 ← 3 null next = null; 3.next = 2 prev cur cur is null → return prev (node 3), the new head
// Iterative: O(n) time, O(1) space
ListNode reverse(ListNode head) {
    ListNode prev = null, cur = head;
    while (cur != null) {
        ListNode next = cur.next;   // 1. save the rest
        cur.next = prev;            // 2. flip the pointer
        prev = cur;                 // 3. step prev forward
        cur = next;                 // 4. step cur forward
    }
    return prev;                    // prev is the new head
}

// Recursive: O(n) time, O(n) space (call stack)
ListNode reverseRec(ListNode head) {
    if (head == null || head.next == null) return head;
    ListNode newHead = reverseRec(head.next);  // reverse the rest
    head.next.next = head;                      // node after me points back to me
    head.next = null;                           // I become the tail
    return newHead;
}
// 1 -> 2 -> 3 -> 4 -> 5  becomes  5 -> 4 -> 3 -> 2 -> 1  (both versions)

Why it works: iteratively, everything left of cur is already reversed and prev is its head, so each step grows the reversed part by one. Recursively, trust the call to reverse the rest; head.next is now the tail of that reversed part, so hang head after it. The recursive version can overflow the stack on very long lists (tens of thousands of nodes).

Problem: Merge Two Sorted Lists

ListNode mergeTwoLists(ListNode a, ListNode b) {
    ListNode dummy = new ListNode(0), tail = dummy;
    while (a != null && b != null) {
        if (a.val <= b.val) { tail.next = a; a = a.next; }
        else                { tail.next = b; b = b.next; }
        tail = tail.next;
    }
    tail.next = (a != null) ? a : b;   // attach whatever is left
    return dummy.next;
}
// 1->2->4  +  1->3->4  ->  1 -> 1 -> 2 -> 3 -> 4 -> 4

Time O(n + m). Space O(1) — it reuses the existing nodes. Why it works: the smallest remaining element is always at the front of one of the two lists, so taking the smaller front each time builds the merged list in order (the same step as merge sort).

Problem: Remove Nth Node From End

ListNode removeNthFromEnd(ListNode head, int n) {
    ListNode dummy = new ListNode(0, head);
    ListNode fast = dummy, slow = dummy;
    for (int i = 0; i <= n; i++) fast = fast.next;   // gap of n+1 nodes
    while (fast != null) { fast = fast.next; slow = slow.next; }
    slow.next = slow.next.next;                       // slow is just before the target
    return dummy.next;
}
// 1->2->3->4->5, n = 2  ->  1 -> 2 -> 3 -> 5
// 1, n = 1              ->  null   (the dummy handles removing the head)

Time O(n), one pass. Space O(1). Why it works: if fast starts n+1 steps ahead and both move together, then when fast falls off the end, slow is exactly n+1 from the end — the node just before the one to delete.

Problem: Linked List Cycle (preview of fast and slow pointers)

boolean hasCycle(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
        if (slow == fast) return true;
    }
    return false;
}
// 3 -> 2 -> 0 -> -4 -> (back to 2)  -> true
// 1 -> 2                           -> false

Time O(n). Space O(1) (a HashSet of visited nodes also works but costs O(n)). Why it works: inside a loop, the fast pointer gains one step on the slow one each move, so the gap shrinks by 1 every time and they must meet; with no loop, fast simply reaches null. The fast and slow pointers section covers finding where the cycle starts.

Problem: Add Two Numbers

Digits are stored in reverse order (ones digit first). Return their sum as a list: 342 + 465 = 807 is 2->4->3 + 5->6->4 = 7->0->8.

ListNode addTwoNumbers(ListNode l1, ListNode l2) {
    ListNode dummy = new ListNode(0), tail = dummy;
    int carry = 0;
    while (l1 != null || l2 != null || carry != 0) {
        int sum = carry;
        if (l1 != null) { sum += l1.val; l1 = l1.next; }
        if (l2 != null) { sum += l2.val; l2 = l2.next; }
        carry = sum / 10;
        tail.next = new ListNode(sum % 10);
        tail = tail.next;
    }
    return dummy.next;
}
// 2->4->3 + 5->6->4  ->  7 -> 0 -> 8
// 9->9->9 + 1        ->  0 -> 0 -> 0 -> 1

Time O(max(n, m)). Space O(max(n, m)) for the result list. Why it works: it is school addition, right to left with a carry — and the reversed storage means the list already starts at the rightmost digit. Keeping carry != 0 in the loop condition adds the final extra digit.

Say this in the interview: "I'll use a dummy node so I don't need a special case for the head, and I'll save next before rewiring. Linked lists give O(1) insert/delete once I have the node, but O(n) access by index — so I only reach for one when I need that O(1) splice, like in an LRU cache."

9. Stacks

In plain words

A stack is a collection where you can only add or remove at one end, called the top. The last thing you put in is the first thing you take out: LIFO (Last In, First Out).

Three operations: push (put on top), pop (take off the top), peek (look at the top without removing). All O(1).

A stack of plates. You put clean plates on top and take plates from the top. The plate at the bottom was put there first and will be used last.

1 1 2 1 2 3 1 2 3 returned push(1) push(2) push(3) pop() → 3 The highlighted box is the top. Only the top can be seen or removed.
Push and pop both happen at the top, so the most recent item always leaves first.

Two ways to build one

Array-backed: keep an array and an index top. Push writes at top and moves it up; pop moves it down. Grow the array when full (amortised O(1), like ArrayList). Fast and cache-friendly.

class ArrayStack {
    private int[] data = new int[8];
    private int top = 0;                      // index of the next free slot

    void push(int x) {                        // amortised O(1)
        if (top == data.length) data = Arrays.copyOf(data, top * 2);
        data[top++] = x;
    }
    int pop() {                               // O(1)
        if (top == 0) throw new RuntimeException("stack underflow");
        return data[--top];
    }
    int peek() {
        if (top == 0) throw new RuntimeException("stack is empty");
        return data[top - 1];
    }
    boolean isEmpty() { return top == 0; }
}

Linked: the head of a singly linked list is the top. Push = add a new head, pop = move head forward. Always true O(1) (no resizing), but one object per element.

class LinkedStack {
    private static class Node { int val; Node next; Node(int v, Node n) { val = v; next = n; } }
    private Node head;                        // head IS the top

    void push(int x) { head = new Node(x, head); }        // O(1)
    int pop() {                                            // O(1)
        if (head == null) throw new RuntimeException("stack underflow");
        int v = head.val;
        head = head.next;
        return v;
    }
    int peek() {
        if (head == null) throw new RuntimeException("stack is empty");
        return head.val;
    }
    boolean isEmpty() { return head == null; }
}

Operations and their cost

OperationArray-backedLinked
pushO(1) amortisedO(1)
pop / peekO(1)O(1)
isEmpty / sizeO(1)O(1)
Search for a valueO(n)O(n)
SpaceO(n), compactO(n), plus a node object each

Java built-in: use ArrayDeque, not Stack

The old java.util.Stack class extends Vector: every method is synchronized (slow) and it lets you insert in the middle, which breaks the idea of a stack. The Java docs themselves recommend Deque:

Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); stack.push(2); stack.push(3);   // push adds at the FRONT of the deque
System.out.println(stack);                     // [3, 2, 1]  (top first)
stack.peek();      // 3  (null if empty)
stack.pop();       // 3  (throws NoSuchElementException if empty)
stack.isEmpty();   // false

Note: ArrayDeque does not allow null elements. peek() returns null on an empty deque, while pop() throws.

The call stack: where you already use a stack

Every time a method is called, the JVM pushes a stack frame (its local variables and where to return to). When the method returns, its frame is popped. That is why recursion works — and why very deep recursion throws StackOverflowError.

fact(3) calls fact(2) calls fact(1): push fact(3) | fact(3) | push fact(2) | fact(2) | ← waiting for fact(1) push fact(1) | fact(1) | ← base case, returns 1 | fact(2) | → 2 * 1 = 2, pop | fact(3) | → 3 * 2 = 6, pop empty result 6

This link matters: any recursive algorithm can be rewritten with your own explicit stack (for example iterative DFS), which avoids stack overflow on deep inputs.

When to use a stack

  • Matching pairs / nesting: brackets, HTML tags, nested k[...] encodings.
  • Undo and back-button history.
  • Expression evaluation: postfix (RPN), infix with operator precedence.
  • DFS without recursion.
  • "Next greater / smaller element" problems — the monotonic stack (a stack kept sorted), covered in its own section.

Common pitfalls

  • Popping an empty stack: always check isEmpty() first — it is often part of the answer (a ) with nothing to match means invalid).
  • Forgetting leftovers: after the loop, a non-empty stack usually means unmatched items.
  • Operand order for - and /: the first pop is the right operand.
  • Mixing deque ends: with ArrayDeque, use push/pop/peek consistently. Mixing in offer/poll (other end) turns it into something else.
  • Storing indices instead of values is often what the problem needs (for distances, like daily temperatures).

Problem: Valid Parentheses

boolean isValid(String s) {
    Deque<Character> stack = new ArrayDeque<>();
    for (char c : s.toCharArray()) {
        if (c == '(') stack.push(')');           // push the EXPECTED closer
        else if (c == '[') stack.push(']');
        else if (c == '{') stack.push('}');
        else if (stack.isEmpty() || stack.pop() != c) return false;
    }
    return stack.isEmpty();                      // leftovers = unclosed openers
}
// "()[]{}" -> true   "([)]" -> false   "{[]}" -> true   "((" -> false

Time O(n). Space O(n). Why it works: the most recently opened bracket must be the first one closed — exactly LIFO. Pushing the expected closer makes the check a single comparison.

Problem: Min Stack

Support push, pop, top and getMin, all in O(1).

class MinStack {
    private final Deque<int[]> stack = new ArrayDeque<>();   // {value, minSoFar}

    void push(int val) {
        int min = stack.isEmpty() ? val : Math.min(val, stack.peek()[1]);
        stack.push(new int[]{val, min});
    }
    void pop()   { stack.pop(); }
    int top()    { return stack.peek()[0]; }
    int getMin() { return stack.peek()[1]; }
}
// push(-2), push(0), push(-3): getMin() = -3
// pop(): top() = 0, getMin() = -2

Time O(1) for every operation. Space O(n). Why it works: a stack only ever removes from the top, so the minimum "below and including" each element never changes while that element is there — store it alongside.

Problem: Evaluate Reverse Polish Notation

In RPN (postfix), operators come after their operands: ["2","1","+","3","*"] means (2 + 1) × 3 = 9.

int evalRPN(String[] tokens) {
    Deque<Integer> stack = new ArrayDeque<>();
    for (String t : tokens) {
        switch (t) {
            case "+" -> stack.push(stack.pop() + stack.pop());
            case "*" -> stack.push(stack.pop() * stack.pop());
            case "-" -> { int b = stack.pop(), a = stack.pop(); stack.push(a - b); }
            case "/" -> { int b = stack.pop(), a = stack.pop(); stack.push(a / b); }
            default  -> stack.push(Integer.parseInt(t));
        }
    }
    return stack.pop();
}
// ["2","1","+","3","*"]   -> 9
// ["4","13","5","/","+"]  -> 6    (13 / 5 = 2, integer division toward zero)
// ["10","6","9","3","+","-11","*","/","*","17","+","5","+"] -> 22

Time O(n). Space O(n). Why it works: in postfix, an operator always applies to the two most recent unused values, which are exactly the top two of the stack. For - and / the first pop is the right-hand operand.

Problem: Daily Temperatures (monotonic stack preview)

For each day, how many days until a warmer one? 0 if never.

int[] dailyTemperatures(int[] temps) {
    int n = temps.length;
    int[] ans = new int[n];
    Deque<Integer> stack = new ArrayDeque<>();   // indices, temps strictly decreasing
    for (int i = 0; i < n; i++) {
        while (!stack.isEmpty() && temps[i] > temps[stack.peek()]) {
            int j = stack.pop();                 // day j finally found a warmer day: i
            ans[j] = i - j;
        }
        stack.push(i);
    }
    return ans;                                  // days left on stack keep 0
}
// [73, 74, 75, 71, 69, 72, 76, 73] -> [1, 1, 4, 2, 1, 1, 0, 0]

Time O(n): each index is pushed once and popped at most once. Space O(n). Why it works: the stack holds days still waiting for a warmer day, coldest on top. A new warmer day answers every waiting day colder than it, and those are exactly the ones on top.

Problem: Decode String

"3[a2[c]]" decodes to "accaccacc".

String decodeString(String s) {
    Deque<Integer> counts = new ArrayDeque<>();
    Deque<StringBuilder> outer = new ArrayDeque<>();
    StringBuilder cur = new StringBuilder();
    int k = 0;
    for (char c : s.toCharArray()) {
        if (Character.isDigit(c)) {
            k = k * 10 + (c - '0');              // numbers can have many digits
        } else if (c == '[') {
            counts.push(k);                      // save state, start fresh inside
            outer.push(cur);
            cur = new StringBuilder();
            k = 0;
        } else if (c == ']') {
            int times = counts.pop();
            StringBuilder prev = outer.pop();
            prev.append(cur.toString().repeat(times));
            cur = prev;                          // back to the outer level
        } else {
            cur.append(c);
        }
    }
    return cur.toString();
}
// "3[a]2[bc]"     -> "aaabcbc"
// "3[a2[c]]"      -> "accaccacc"
// "2[abc]3[cd]ef" -> "abcabccdcdcdef"
// "10[x]"         -> "xxxxxxxxxx"

Time O(output length), since every output character is built once per nesting level it sits in. Space O(output length). Why it works: [ means "pause what I was building and start a new inner piece", ] means "finish the inner piece and resume the outer one" — pausing and resuming in nested order is exactly what a stack does.

Say this in the interview: "This has nesting / most-recent-first behaviour, so I'll use a stack — ArrayDeque, not the legacy Stack class. Push, pop and peek are all O(1)." If the problem is "next greater element", say "monotonic stack, each element pushed and popped once, so O(n) total".

10. Queues & Deques

In plain words

A queue adds items at the back and removes them from the front. The first item in is the first item out: FIFO (First In, First Out). The two main operations are enqueue (add at the back; Java calls it offer) and dequeue (remove from the front; Java calls it poll).

A deque (say "deck", short for double-ended queue) lets you add and remove at both ends. It can act as a stack, a queue, or both at once.

A queue is the line at a coffee shop: new people join at the back, the person at the front is served first. A deque is a line where staff may also serve or let people in at the back.

offer(A) offer(B) offer(C) poll() → A offer(D) front → [A] front → [A][B][C] ← back [B][C] [B][C][D] ↑ front ↑ front ↑ back

Implementing a queue: the circular array

With a plain array, removing from the front would mean shifting everything left (O(n)). A linked list with head and tail pointers gives O(1) at both ends, but the classic array trick is the circular buffer (ring buffer): keep a head index that moves forward on every dequeue, and let both ends wrap around to index 0 when they pass the end, using % capacity. Nothing ever shifts.

E F free free C D 0 1 2 3 4 5 head = 4 (front) next write = (4 + 4) % 6 = 2 wraps around: after slot 5 comes slot 0 Queue order (front to back): C, D, E, F size = 4, capacity = 6
A ring buffer. The front moves forward on every dequeue and the write position wraps to the start, so both operations are O(1) and nothing is ever shifted.

Tracking size separately avoids the classic ambiguity: if you only keep head and tail, then head == tail could mean empty or full.

This is also the answer to Design Circular Queue (LeetCode 622) — the method names below are the ones that problem uses:

class MyCircularQueue {
    private final int[] data;
    private int head = 0;     // index of the front element
    private int size = 0;     // number of elements stored

    MyCircularQueue(int k) { data = new int[k]; }

    boolean enQueue(int value) {                         // O(1)
        if (isFull()) return false;
        int tail = (head + size) % data.length;          // first free slot, wrapping around
        data[tail] = value;
        size++;
        return true;
    }
    boolean deQueue() {                                  // O(1)
        if (isEmpty()) return false;
        head = (head + 1) % data.length;                 // front moves forward, wraps
        size--;
        return true;
    }
    int Front() { return isEmpty() ? -1 : data[head]; }
    int Rear()  { return isEmpty() ? -1 : data[(head + size - 1) % data.length]; }
    boolean isEmpty() { return size == 0; }
    boolean isFull()  { return size == data.length; }
}
// capacity 3: enQueue 1, 2, 3 -> true true true; enQueue(4) -> false (full)
// Rear() = 3; deQueue() -> true; enQueue(4) -> true (wrapped into slot 0)
// Rear() = 4, Front() = 2

Time O(1) for every operation. Space O(k). Why it works: % capacity turns the straight array into a loop, so freed slots at the start are reused instead of shifting elements.

Operations and their cost

OperationCircular array / ArrayDequeLinked listNaive array (shift on dequeue)
Enqueue at backO(1) amortisedO(1)O(1) amortised
Dequeue from frontO(1)O(1)O(n)
Peek frontO(1)O(1)O(1)
Add / remove at front (deque)O(1)O(1) if doubly linkedO(n)
SearchO(n)O(n)O(n)
SpaceO(n)O(n) + node objectsO(n)

Java built-ins: Queue, Deque, ArrayDeque

Queue and Deque are interfaces. ArrayDeque (a growable circular array) is the default implementation for both — faster than LinkedList. Each operation comes in two flavours: one that throws on failure and one that returns a special value (null or false). Interview code normally uses the second.

IntentThrows if empty/fullReturns null/false
Add at backadd(x) / addLastoffer(x) / offerLast
Remove frontremove() / removeFirstpoll() / pollFirst
Look at frontelement() / getFirstpeek() / peekFirst
Add at front (deque)addFirst / pushofferFirst
Remove / look at back (deque)removeLast / getLastpollLast / peekLast
Queue<Integer> q = new ArrayDeque<>();
q.offer(1); q.offer(2); q.offer(3);
q.peek();   // 1
q.poll();   // 1, queue is now [2, 3]
new ArrayDeque<Integer>().poll();   // null (remove() would throw)

Deque<Integer> d = new ArrayDeque<>();
d.offerFirst(2); d.offerLast(3); d.offerFirst(1);   // [1, 2, 3]
d.pollLast();    // 3
d.peekFirst();   // 1

Other queues you should know exist: PriorityQueue (next section — not FIFO, smallest first), LinkedList (allows null, slower), and BlockingQueue implementations such as ArrayBlockingQueue for producer-consumer threads.

When to use a queue or deque

  • Queue: process things in arrival order — BFS, task scheduling, buffering between a producer and a consumer, rate limiting (a queue of timestamps).
  • Deque: when you need both ends — sliding window max/min (monotonic deque), palindrome checks, 0-1 BFS, "undo with a size limit" (drop from the old end).
  • If you only need one end, it is really a stack — still use ArrayDeque.

Common pitfalls

  • Mixing ends: push adds at the front, offer at the back. Using push + poll gives a stack, not a queue.
  • ArrayDeque rejects null — offer(null) throws NullPointerException.
  • Unchecked poll() returns null; unboxing it to int throws.
  • Using q.size() as a loop bound while adding: in level-order BFS, save the size in a variable before the inner loop.
  • In your own ring buffer, forgetting the % on one of the index updates.

Problem: Implement Queue using Stacks

class MyQueue {
    private final Deque<Integer> in = new ArrayDeque<>();   // new items land here
    private final Deque<Integer> out = new ArrayDeque<>();  // items leave from here

    void push(int x) { in.push(x); }                        // O(1)
    int pop()  { shift(); return out.pop(); }               // amortised O(1)
    int peek() { shift(); return out.peek(); }
    boolean empty() { return in.isEmpty() && out.isEmpty(); }

    private void shift() {
        if (out.isEmpty()) {                                // only refill when empty
            while (!in.isEmpty()) out.push(in.pop());       // reverses the order
        }
    }
}
// push 1, push 2: peek() = 1, pop() = 1; push 3: pop() = 2, pop() = 3, empty() = true
push 1, 2, 3 in: [3 2 1] (3 on top) out: [] pop() → out empty, pour in → out: in: [] out: [1 2 3] (1 on top) → pop 1 push 4 in: [4] out: [2 3] pop() → out not empty → pop 2 (4 waits in "in" until out runs dry)

Time push O(1); pop/peek amortised O(1) — each element moves from in to out exactly once. Space O(n). Why it works: pouring one stack into another reverses the order, turning last-in-first-out into first-in-first-out; only pouring when out is empty keeps older items ahead of newer ones.

Problem: Sliding Window Maximum (monotonic deque)

For every window of size k, report the maximum. [1,3,-1,-3,5,3,6,7], k = 3 gives [3,3,5,5,6,7].

int[] maxSlidingWindow(int[] nums, int k) {
    int n = nums.length;
    int[] ans = new int[n - k + 1];
    Deque<Integer> dq = new ArrayDeque<>();     // indices; their values are decreasing
    for (int i = 0; i < n; i++) {
        if (!dq.isEmpty() && dq.peekFirst() <= i - k) dq.pollFirst();       // fell out of window
        while (!dq.isEmpty() && nums[dq.peekLast()] <= nums[i]) dq.pollLast(); // useless now
        dq.offerLast(i);
        if (i >= k - 1) ans[i - k + 1] = nums[dq.peekFirst()];              // front = max
    }
    return ans;
}
// [1, 3, -1, -3, 5, 3, 6, 7], k = 3 -> [3, 3, 5, 5, 6, 7]
// [9, 8, 7, 6], k = 2               -> [9, 8, 7]
i num deque (values, front → back) window max 0 1 [1] 1 3 [3] 1 removed: 3 is bigger and newer 2 -1 [3, -1] 3 3 -3 [3, -1, -3] 3 4 5 [5] 3 left the window, -1 and -3 beaten by 5 5 5 3 [5, 3] 5 6 6 [6] 6 7 7 [7] 7

Time O(n): each index enters and leaves the deque at most once. Space O(k). Why it works: a number that is smaller than a newer number can never be a window maximum again (the newer one outlives it), so it is thrown away. What remains is decreasing, so the front is always the max.

Problem: BFS preview — shortest path in a grid

Minimum steps from the top-left to the bottom-right of a grid (0 = open, 1 = wall), moving up/down/left/right.

int shortestPath(int[][] grid) {
    int rows = grid.length, cols = grid[0].length;
    if (grid[0][0] == 1) return -1;
    boolean[][] seen = new boolean[rows][cols];
    Queue<int[]> q = new ArrayDeque<>();
    q.offer(new int[]{0, 0});
    seen[0][0] = true;
    int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
    for (int steps = 0; !q.isEmpty(); steps++) {
        for (int size = q.size(); size > 0; size--) {       // one "ring" (level) at a time
            int[] cell = q.poll();
            if (cell[0] == rows - 1 && cell[1] == cols - 1) return steps;
            for (int[] d : dirs) {
                int r = cell[0] + d[0], c = cell[1] + d[1];
                if (r >= 0 && r < rows && c >= 0 && c < cols && grid[r][c] == 0 && !seen[r][c]) {
                    seen[r][c] = true;                       // mark when ENQUEUED
                    q.offer(new int[]{r, c});
                }
            }
        }
    }
    return -1;
}
// {{0,0,0},{1,1,0},{0,0,0}} -> 4
// {{0,1},{1,0}}             -> -1

Time O(rows × cols). Space O(rows × cols). Why it works: the FIFO queue processes all cells 1 step away, then all cells 2 steps away, and so on, like ripples in water — so the first time we reach the target is by a shortest path. The graph sections cover BFS fully.

Say this in the interview: "I'll use ArrayDeque as the queue — offer to add, poll to remove, both O(1). For a fixed-capacity queue I'd use a circular array with a head index and a size, wrapping with modulo." For sliding-window max/min: "a monotonic deque, O(n) because each index is added and removed once."

11. Heaps & Priority Queues

In plain words

A priority queue is a queue where the item that comes out next is not the oldest, but the one with the highest priority — usually the smallest (or largest) value. A heap is the data structure that makes this fast: getting the smallest item is O(1), and adding or removing one is O(log n).

A min-heap is a tree where every parent is smaller than or equal to its children, so the smallest value is always at the top (the root). A max-heap is the same with "larger". The heap is only partly sorted: it does not care how siblings compare, which is why it can be updated so cheaply.

A hospital emergency room. Patients are not seen in arrival order; the most urgent one goes next. The triage nurse doesn't fully sort the waiting room — they only need to always know who is most urgent right now.

A complete binary tree stored in an array

A heap is a complete binary tree: every level is full except possibly the last, which fills from left to right. Because there are no gaps, the tree fits perfectly in an array, level by level, with no pointers at all. Parent and children are found with index maths:

1 i=0 3 i=1 2 i=2 5 i=3 9 i=4 8 i=5 1 3 2 5 9 8 0 1 2 3 4 5 heap Index maths (0-based) parent(i) = (i - 1) / 2 left(i) = 2 * i + 1 right(i) = 2 * i + 2 last parent = n / 2 - 1 Example: node 3 is at i=1 children at 2*1+1 = 3 (value 5) and 2*1+2 = 4 (value 9) parent at (1-1)/2 = 0 (value 1)
The same min-heap as a tree (how to think about it) and as an array (how it is stored). Each level is written left to right, so no child pointers are needed.

A complete tree with n nodes has height about log₂ n. Every heap operation walks at most one path from root to leaf, which is where O(log n) comes from.

Sift up (insert) and sift down (remove the top)

  • Insert (offer): put the new value at the end of the array (the next free spot in the bottom level), then sift up: while it is smaller than its parent, swap with the parent.
  • Remove the min (poll): the answer is heap[0]. Move the last element into the root to keep the tree complete, then sift down: while it is bigger than its smaller child, swap with that child.
offer(1) into [3, 5, 8]: append [3, 5, 8, 1] 1 at i=3, parent i=1 holds 5 → swap sift up [3, 1, 8, 5] 1 at i=1, parent i=0 holds 3 → swap sift up [1, 3, 8, 5] 1 at root → stop poll() on [1, 3, 2, 5, 9, 8]: return 1, move last (8) to root [8, 3, 2, 5, 9] sift down children 3 and 2, smaller is 2 → swap [2, 3, 8, 5, 9] 8 at i=2 has no children (2*2+1 = 5 ≥ size 5) → stop

Implement a min-heap from scratch

class MinHeap {
    private int[] heap;
    private int size = 0;

    MinHeap(int capacity) { heap = new int[capacity]; }

    // Build from an existing array in O(n) (heapify)
    MinHeap(int[] values) {
        heap = Arrays.copyOf(values, Math.max(1, values.length));
        size = values.length;
        for (int i = size / 2 - 1; i >= 0; i--) siftDown(i);   // last parent down to root
    }

    void offer(int x) {                                  // O(log n)
        if (size == heap.length) heap = Arrays.copyOf(heap, size * 2);
        heap[size] = x;                                  // put at the end ...
        siftUp(size++);                                  // ... and bubble up
    }
    int peek() {                                         // O(1)
        if (size == 0) throw new NoSuchElementException();
        return heap[0];
    }
    int poll() {                                         // O(log n)
        if (size == 0) throw new NoSuchElementException();
        int min = heap[0];
        heap[0] = heap[--size];                          // move last element to root ...
        siftDown(0);                                     // ... and sink it
        return min;
    }

    private void siftUp(int i) {
        while (i > 0) {
            int parent = (i - 1) / 2;
            if (heap[parent] <= heap[i]) break;          // order is fine
            swap(i, parent);
            i = parent;
        }
    }
    private void siftDown(int i) {
        while (true) {
            int left = 2 * i + 1, right = 2 * i + 2, smallest = i;
            if (left < size && heap[left] < heap[smallest]) smallest = left;
            if (right < size && heap[right] < heap[smallest]) smallest = right;
            if (smallest == i) break;                    // both children bigger: done
            swap(i, smallest);
            i = smallest;
        }
    }
    private void swap(int a, int b) { int t = heap[a]; heap[a] = heap[b]; heap[b] = t; }
}
// offer 5, 3, 8, 1, 9, 2 -> array [1, 3, 2, 5, 9, 8]; polling gives 1 2 3 5 8 9
// new MinHeap(new int[]{9, 4, 7, 1, 8, 2, 3}) -> array [1, 4, 2, 9, 8, 7, 3]

Heapify in O(n), not O(n log n)

Building a heap by calling offer n times costs O(n log n). Heapify is faster: treat the array as a tree already, and call siftDown on every parent from the last one (n/2 - 1) back to the root. Leaves (half of all nodes) need no work.

Why O(n)? A node can only sink as far as the levels below it. Half the nodes are leaves (sink 0), a quarter sit one level up (sink at most 1), an eighth at most 2, and so on: n/4·1 + n/8·2 + n/16·3 + … which adds up to less than n. Most nodes are near the bottom, where sifting down is cheap.

Operations and their cost

OperationBinary heapWhy
peek (min/max)O(1)It is heap[0]
offer (insert)O(log n)Sift up one path
poll (remove top)O(log n)Sift down one path
Build from n items (heapify)O(n)Sum argument above
remove(x) / contains(x) (arbitrary)O(n)Must search; heap is not sorted
Heap sortO(n log n) time, O(1) spacen polls
SpaceO(n)Just the array

Java built-in: PriorityQueue

PriorityQueue is a binary min-heap by default. For a max-heap or custom ordering, pass a Comparator (a rule that says which of two items comes first).

PriorityQueue<Integer> minPq = new PriorityQueue<>(List.of(5, 1, 9));
PriorityQueue<Integer> maxPq = new PriorityQueue<>(Collections.reverseOrder());
maxPq.addAll(List.of(5, 1, 9));
System.out.println(minPq.peek() + " " + maxPq.peek());      // 1 9

// Objects: order by priority, then by name to break ties
record Task(String name, int priority) {}
PriorityQueue<Task> tasks = new PriorityQueue<>(
        Comparator.comparingInt(Task::priority).thenComparing(Task::name));
tasks.offer(new Task("write", 2));
tasks.offer(new Task("deploy", 1));
tasks.offer(new Task("alert", 1));
// polling gives: alert, deploy, write

// int[] pairs: sort by the second element
PriorityQueue<int[]> pairs = new PriorityQueue<>((a, b) -> Integer.compare(a[1], b[1]));
MethodCostNote
offer / addO(log n)
pollO(log n)Returns null if empty
peekO(1)Returns null if empty
remove(Object), containsO(n)Linear search
new PriorityQueue<>(collection)O(n)Uses heapify
sizeO(1)

Heap sort

Build a max-heap in place (O(n)), then repeatedly swap the root (the largest) to the end of the array and sift the new root down within the shrinking heap. The sorted part grows from the back.

void heapSort(int[] a) {
    int n = a.length;
    for (int i = n / 2 - 1; i >= 0; i--) sink(a, i, n);   // build max-heap: O(n)
    for (int end = n - 1; end > 0; end--) {
        int t = a[0]; a[0] = a[end]; a[end] = t;           // largest goes to the back
        sink(a, 0, end);                                   // fix the shrunk heap
    }
}
void sink(int[] a, int i, int n) {
    while (2 * i + 1 < n) {
        int child = 2 * i + 1;
        if (child + 1 < n && a[child + 1] > a[child]) child++;  // bigger child
        if (a[i] >= a[child]) break;
        int t = a[i]; a[i] = a[child]; a[child] = t;
        i = child;
    }
}
// [5, 1, 9, 3, 7, 2] -> [1, 2, 3, 5, 7, 9]

Time O(n log n) always (no bad worst case like quicksort). Space O(1). Not stable (equal items may swap order), and slower than quicksort in practice because it jumps around the array.

When to use a heap

  • You repeatedly need the current smallest/largest while items keep arriving: scheduling, Dijkstra's shortest path, event simulation.
  • Top K / Kth largest: keep a heap of size k — O(n log k) instead of sorting everything in O(n log n).
  • Merging K sorted things: a heap of the K current fronts.
  • Running median: two heaps.
  • Not for searching a specific value (O(n)), and not when you need everything in sorted order at once (just sort) or need ordered iteration (use TreeMap/TreeSet).

Common pitfalls

  • Iterating or printing a PriorityQueue is not sorted. Offering 5, 1, 4, 2, 3 and printing gives [1, 2, 4, 5, 3] — the raw heap array. Only repeated poll() gives sorted order (1 2 3 4 5).
  • Overflow in comparators: (a, b) -> a - b breaks for large values — Integer.MIN_VALUE - 1 overflows to 2147483647, a positive number, so the order is wrong. Use Integer.compare(a, b) or Comparator.comparingInt.
  • Changing an object's priority while it is inside the heap does not re-sort it. Remove it, change it, re-insert — or insert a new entry and skip stale ones when polled ("lazy deletion", common in Dijkstra).
  • Min vs max confusion for "k largest": you keep a min-heap of size k, so the smallest of the top k sits on top and is easy to evict.
  • PriorityQueue is not thread-safe; use PriorityBlockingQueue across threads.

Problem: Kth Largest Element in an Array

int findKthLargest(int[] nums, int k) {
    PriorityQueue<Integer> minHeap = new PriorityQueue<>();   // holds the k largest seen
    for (int x : nums) {
        minHeap.offer(x);
        if (minHeap.size() > k) minHeap.poll();               // drop the smallest
    }
    return minHeap.peek();                                    // smallest of the top k
}
// [3, 2, 1, 5, 6, 4], k = 2          -> 5
// [3, 2, 3, 1, 2, 4, 5, 5, 6], k = 4 -> 4

Time O(n log k). Space O(k). Why it works: the heap always holds the k largest values seen so far; the smallest of those is, by definition, the kth largest. (Quickselect gets O(n) on average — mention it as a follow-up.)

Problem: Merge K Sorted Lists

ListNode mergeKLists(ListNode[] lists) {
    PriorityQueue<ListNode> pq = new PriorityQueue<>((a, b) -> Integer.compare(a.val, b.val));
    for (ListNode head : lists) if (head != null) pq.offer(head);
    ListNode dummy = new ListNode(0), tail = dummy;
    while (!pq.isEmpty()) {
        ListNode node = pq.poll();            // smallest head among all lists
        tail.next = node;
        tail = node;
        if (node.next != null) pq.offer(node.next);
    }
    return dummy.next;
}
// [1->4->5, 1->3->4, 2->6] -> 1 1 2 3 4 4 5 6

Time O(N log k), where N is the total number of nodes and k the number of lists. Space O(k) for the heap. Why it works: the next node of the merged list must be the smallest of the k current fronts; the heap gives that in O(log k), and when a front is used its successor takes its place.

Problem: Top K Frequent Elements

int[] topKFrequent(int[] nums, int k) {
    Map<Integer, Integer> freq = new HashMap<>();
    for (int x : nums) freq.merge(x, 1, Integer::sum);
    // min-heap by frequency, size k
    PriorityQueue<Map.Entry<Integer, Integer>> pq =
            new PriorityQueue<>(Map.Entry.comparingByValue());
    for (Map.Entry<Integer, Integer> e : freq.entrySet()) {
        pq.offer(e);
        if (pq.size() > k) pq.poll();
    }
    int[] ans = new int[k];
    for (int i = k - 1; i >= 0; i--) ans[i] = pq.poll().getKey();   // most frequent first
    return ans;
}
// [1, 1, 1, 2, 2, 3], k = 2 -> [1, 2]

Time O(n log k): counting is O(n), then each of up to n distinct values costs O(log k). Space O(n) for the counts. Why it works: the same "keep the k best in a min-heap" idea as kth largest, just ranked by count instead of value. (Bucket sort by frequency gets O(n).)

Problem: Find Median from Data Stream (two heaps preview)

class MedianFinder {
    private final PriorityQueue<Integer> low = new PriorityQueue<>(Collections.reverseOrder()); // max-heap
    private final PriorityQueue<Integer> high = new PriorityQueue<>();                          // min-heap

    void addNum(int num) {                         // O(log n)
        low.offer(num);
        high.offer(low.poll());                    // largest of low moves to high
        if (high.size() > low.size()) low.offer(high.poll());  // keep low >= high in size
    }
    double findMedian() {                          // O(1)
        if (low.size() > high.size()) return low.peek();
        return (low.peek() + high.peek()) / 2.0;
    }
}
// addNum(1), addNum(2): findMedian() = 1.5
// addNum(3):            findMedian() = 2.0
low (max-heap) high (min-heap) smaller half larger half [ ... 1 2 ] top = 2 | top = 3 [ 3 ... ] ↑ the median lives at one or both of these tops

Time addNum O(log n), findMedian O(1). Space O(n). Why it works: every number in low is ≤ every number in high, and the sizes differ by at most one, so the middle value(s) are exactly the two tops. The two-heaps section goes further.

Say this in the interview: "A heap is a complete binary tree stored in an array: peek is O(1), offer and poll are O(log n), and building one from n items is O(n). In Java I'll use PriorityQueue, which is a min-heap; for a max-heap I pass Collections.reverseOrder(). For top-k I keep a min-heap of size k, which gives O(n log k)."
Part 3 — Trees & Graphs

12. Binary Trees

In plain words

A tree is a set of nodes joined by links, where every node except the top one has exactly one parent, and there are no loops. A binary tree is a tree where each node has at most two children, called left and right.

Almost every tree problem is solved the same way: do something at this node, and ask the left and right subtrees to solve the same smaller problem (recursion).

A family tree drawn upside down. The great-grandparent sits at the top, each person points to their (at most two) children, and nobody is their own ancestor.

Terminology

TermMeaning
RootThe top node; it has no parent.
LeafA node with no children.
Internal nodeAny node that is not a leaf.
SubtreeA node plus everything below it. Every node is the root of its own subtree.
Depth of a nodeNumber of edges from the root down to it. Root has depth 0.
Height of a nodeNumber of edges on the longest path from it down to a leaf. A leaf has height 0. The tree's height is the root's height. (LeetCode's "max depth" counts nodes instead, so it is height + 1.)
FullEvery node has 0 or 2 children — never exactly 1.
CompleteEvery level is full except maybe the last, and the last is filled from the left. Heaps are complete trees, which is why they fit in an array.
PerfectAll internal nodes have 2 children and all leaves are on the same level. Has exactly 2h+1 − 1 nodes.
BalancedFor every node, the heights of the left and right subtrees differ by at most 1. Height stays O(log n).
Degenerate (skewed)Every node has one child — really a linked list. Height is n − 1.
1 2 3 4 5 6 depth 0 depth 1 depth 2 root leaves: 4, 5, 6 3 has one child, so this tree is not full preorder 1 2 4 5 3 6 inorder 4 2 5 1 3 6 postorder 4 5 2 6 3 1 level [1] [2 3] [4 5 6]
The sample tree used in every snippet below, with its four traversal orders.

The node class

class TreeNode {
    int val;
    TreeNode left, right;
    TreeNode(int val) { this.val = val; }
    TreeNode(int val, TreeNode left, TreeNode right) {
        this.val = val; this.left = left; this.right = right;
    }
}

In memory each node is a separate object on the heap holding a value and two references. There is no array and no index — you can only reach a node by walking down from the root. That is why "find a value" in a plain binary tree is O(n): you may have to visit every node.

Traversals — the four ways to visit every node

The name tells you when the root is visited compared to its children:

  • Preorder (root, left, right) — visit the node before its children. Use it to copy or serialize a tree: you meet the parent before you need to build its children.
  • Inorder (left, root, right) — on a Binary Search Tree this gives values in sorted order.
  • Postorder (left, right, root) — children finish before the parent. Use it when a node needs answers from its children (height, diameter, deleting a tree).
  • Level order (BFS) — row by row, top to bottom. Use it for "per level" questions and shortest distance from the root.
void preorder(TreeNode node, List<Integer> out) {
    if (node == null) return;
    out.add(node.val);            // root
    preorder(node.left, out);     // left
    preorder(node.right, out);    // right
}
void inorder(TreeNode node, List<Integer> out) {
    if (node == null) return;
    inorder(node.left, out);      // left
    out.add(node.val);            // root
    inorder(node.right, out);     // right
}
void postorder(TreeNode node, List<Integer> out) {
    if (node == null) return;
    postorder(node.left, out);    // left
    postorder(node.right, out);   // right
    out.add(node.val);            // root
}

All three are O(n) time (each node visited once) and O(h) extra space for the call stack, where h is the height: O(log n) for a balanced tree, O(n) for a skewed one. A skewed tree with 100,000 nodes can throw StackOverflowError in Java, which is the main reason interviewers ask for iterative versions.

Iterative traversals with an explicit stack

Recursion uses the call stack for you. Iterative code just makes that stack visible, using ArrayDeque (never the old Stack class, which is synchronized and slow).

// Preorder: pop, visit, push right then left (so left is processed first)
List<Integer> preorderIter(TreeNode root) {
    List<Integer> out = new ArrayList<>();
    if (root == null) return out;
    Deque<TreeNode> stack = new ArrayDeque<>();
    stack.push(root);
    while (!stack.isEmpty()) {
        TreeNode node = stack.pop();
        out.add(node.val);
        if (node.right != null) stack.push(node.right); // push right first
        if (node.left != null) stack.push(node.left);   // so left pops first
    }
    return out;
}

// Inorder: slide left pushing nodes, pop one, visit, then go right
List<Integer> inorderIter(TreeNode root) {
    List<Integer> out = new ArrayList<>();
    Deque<TreeNode> stack = new ArrayDeque<>();
    TreeNode cur = root;
    while (cur != null || !stack.isEmpty()) {
        while (cur != null) {          // go as far left as possible
            stack.push(cur);
            cur = cur.left;
        }
        cur = stack.pop();             // leftmost unvisited node
        out.add(cur.val);
        cur = cur.right;               // then its right subtree
    }
    return out;
}

// Postorder: do "root, right, left" and reverse it -> "left, right, root"
List<Integer> postorderIter(TreeNode root) {
    LinkedList<Integer> out = new LinkedList<>();
    if (root == null) return out;
    Deque<TreeNode> stack = new ArrayDeque<>();
    stack.push(root);
    while (!stack.isEmpty()) {
        TreeNode node = stack.pop();
        out.addFirst(node.val);        // prepend = reverse for free
        if (node.left != null) stack.push(node.left);
        if (node.right != null) stack.push(node.right);
    }
    return out;
}

Level order with a queue

List<List<Integer>> levelOrder(TreeNode root) {
    List<List<Integer>> levels = new ArrayList<>();
    if (root == null) return levels;
    Queue<TreeNode> queue = new ArrayDeque<>();
    queue.offer(root);
    while (!queue.isEmpty()) {
        int size = queue.size();             // nodes on this level
        List<Integer> level = new ArrayList<>(size);
        for (int i = 0; i < size; i++) {
            TreeNode node = queue.poll();
            level.add(node.val);
            if (node.left != null) queue.offer(node.left);
            if (node.right != null) queue.offer(node.right);
        }
        levels.add(level);
    }
    return levels;   // sample tree: [[1], [2, 3], [4, 5, 6]]
}

The trick is int size = queue.size() before the inner loop: it freezes how many nodes belong to the current level, even while you add the next level's nodes to the back. Time O(n), space O(w) where w is the widest level (up to about n/2 for the last level of a perfect tree).

Morris traversal (O(1) extra space)

Morris inorder avoids both recursion and a stack. For each node with a left subtree, it finds the inorder predecessor (the rightmost node of the left subtree) and temporarily points that node's right back up to the current node — a "thread". Following the thread later is how it climbs back up without a stack. On the second visit it removes the thread, so the tree is restored.

List<Integer> morrisInorder(TreeNode root) {
    List<Integer> out = new ArrayList<>();
    TreeNode cur = root;
    while (cur != null) {
        if (cur.left == null) {
            out.add(cur.val);
            cur = cur.right;
        } else {
            TreeNode pred = cur.left;            // find inorder predecessor
            while (pred.right != null && pred.right != cur) pred = pred.right;
            if (pred.right == null) {            // first visit: build thread
                pred.right = cur;
                cur = cur.left;
            } else {                             // second visit: remove thread
                pred.right = null;
                out.add(cur.val);
                cur = cur.right;
            }
        }
    }
    return out;
}

Time O(n) (each edge is walked at most a few times), space O(1). It is rarely required, but mentioning it when asked "can you do it without extra space?" scores points. Downside: it modifies the tree while running, so it is not safe with concurrent readers.

Operations complexity (plain binary tree, n nodes, height h)

OperationTimeExtra spaceWhy
Search for a valueO(n)O(h)No ordering, so you may check every node
Insert (at a known spot)O(1) once thereO(1)Just set a left/right reference
Pre / in / post-orderO(n)O(h)Stack holds one root-to-node path
Level orderO(n)O(w)Queue holds up to one full level
Morris inorderO(n)O(1)Uses temporary threads instead of a stack
Height / size / count leavesO(n)O(h)Postorder: combine children's answers

Java built-ins: Java has no general binary tree class. TreeMap/TreeSet are balanced binary search trees (section 14) and PriorityQueue is a binary heap in an array. In interviews you write TreeNode yourself.

Problem 1 — Maximum depth

// Number of nodes on the longest root-to-leaf path
int maxDepth(TreeNode root) {
    if (root == null) return 0;
    return 1 + Math.max(maxDepth(root.left), maxDepth(root.right));
}   // sample tree: 3

Why it works: the depth of a tree is one (for this node) plus the deeper of its two subtrees. The empty tree has depth 0, which stops the recursion. O(n) time, O(h) space.

Problem 2 — Invert a binary tree

TreeNode invertTree(TreeNode root) {
    if (root == null) return null;
    TreeNode tmp = root.left;
    root.left = invertTree(root.right);
    root.right = invertTree(tmp);
    return root;
}   // sample tree level order becomes [[1], [3, 2], [6, 5, 4]]

Why it works: a mirror image is "swap my two children, and mirror each of them". Save left in tmp first, or you would overwrite it before using it. O(n) time, O(h) space.

Problem 3 — Diameter of a binary tree

The diameter is the number of edges on the longest path between any two nodes. That path may not go through the root.

class Solution {
    private int best = 0;
    public int diameterOfBinaryTree(TreeNode root) {
        height(root);
        return best;
    }
    // returns height in nodes; updates best with the longest path through node
    private int height(TreeNode node) {
        if (node == null) return 0;
        int l = height(node.left);
        int r = height(node.right);
        best = Math.max(best, l + r);   // path bends at this node
        return 1 + Math.max(l, r);
    }
}   // sample tree: 4 (path 4-2-1-3-6)

Why it works: every path has one highest node where it "bends". The longest path bending at a node is (height of left) + (height of right) edges. Postorder computes both heights once, so one pass checks every bend point. O(n) time instead of the naive O(n2) that recomputes heights.

Pattern — "return one thing, track another": the helper returns what the parent needs (height), while a field tracks the global answer (best path). The same shape solves Binary Tree Maximum Path Sum and Longest Univalue Path.

Problem 4 — Lowest common ancestor (LCA)

The LCA of p and q is the deepest node that has both p and q in its subtree (a node counts as its own descendant).

TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
    if (root == null || root == p || root == q) return root;
    TreeNode left = lowestCommonAncestor(root.left, p, q);
    TreeNode right = lowestCommonAncestor(root.right, p, q);
    if (left != null && right != null) return root; // p and q split here
    return left != null ? left : right;             // both on one side
}   // sample: lca(4,5)=2, lca(4,6)=1, lca(2,4)=2

Why it works: each call reports "I found p or q (or their LCA) in my subtree" by returning a non-null node. The first node where both sides report something is where the paths to p and q split — the LCA. If a node is p itself, returning it right away is correct even when q is below it, because then p is the LCA. O(n) time, O(h) space.

Problem 5 — Serialize and deserialize

public class Codec {
    public String serialize(TreeNode root) {
        StringBuilder sb = new StringBuilder();
        write(root, sb);
        return sb.toString();
    }
    private void write(TreeNode node, StringBuilder sb) {
        if (node == null) { sb.append("#,"); return; }   // mark nulls
        sb.append(node.val).append(',');
        write(node.left, sb);
        write(node.right, sb);
    }
    public TreeNode deserialize(String data) {
        Deque<String> tokens = new ArrayDeque<>(Arrays.asList(data.split(",")));
        return read(tokens);
    }
    private TreeNode read(Deque<String> tokens) {
        String t = tokens.poll();
        if (t.equals("#")) return null;
        TreeNode node = new TreeNode(Integer.parseInt(t));
        node.left = read(tokens);      // same order as write: root, left, right
        node.right = read(tokens);
        return node;
    }
}   // sample tree -> "1,2,4,#,#,5,#,#,3,#,6,#,#,"

Why it works: preorder alone cannot rebuild a tree (many shapes share the same order), but preorder with null markers can: the markers tell the reader exactly where each subtree ends. Reading consumes tokens in the same root-left-right order they were written. O(n) time and space for both directions.

Pitfalls

  • Forgetting the null base case — nearly every tree bug is a NullPointerException.
  • Mixing up depth (counted from the top) and height (counted from the bottom), or edges vs nodes. Say which one you mean.
  • Recomputing heights inside a recursion (O(n2)). Return the height from the same pass instead.
  • Deep recursion on skewed input — offer the iterative version.
  • Using a static field for the answer and forgetting to reset it between test cases. Prefer an instance field or an int[] holder.

13. Binary Search Trees

In plain words

A Binary Search Tree (BST) is a binary tree with one rule at every node: everything in the left subtree is smaller, everything in the right subtree is larger. Because of that rule, you can find a value by going left or right at each step and throwing away half of what remains — like binary search, but on linked nodes.

The rule is about whole subtrees, not just direct children. That detail is the source of the most common BST interview bug.

Playing "guess my number, higher or lower". Each answer rules out a whole range, so you never check the numbers on the wrong side.

8 3 10 1 6 14 4 7 13 search(7): 7 < 8 go left, 7 > 3 go right, 7 > 6 go right, found left subtree of 8: all values < 8 right subtree: all values > 8 inorder: 1 3 4 6 7 8 10 13 14 (sorted)
The BST used in this section. Search follows one root-to-leaf path, so it costs O(h), not O(n).

Search and insert

TreeNode search(TreeNode root, int key) {
    TreeNode cur = root;
    while (cur != null && cur.val != key) {
        cur = key < cur.val ? cur.left : cur.right;   // discard half
    }
    return cur;   // null if not found
}

TreeNode insert(TreeNode root, int key) {
    if (root == null) return new TreeNode(key);       // empty spot found
    if (key < root.val) root.left = insert(root.left, key);
    else if (key > root.val) root.right = insert(root.right, key);
    return root;                                      // duplicates ignored
}

Insert walks down exactly like search. When it falls off the tree (reaches null), that empty spot is where the new key belongs. The pattern root.left = insert(root.left, key) — "rebuild the link on the way back up" — is worth memorising: delete uses it too. Both are O(h) time.

Delete — the three cases

  1. Leaf (no children): just remove it — return null to the parent.
  2. One child: the child takes the node's place — return the child to the parent.
  3. Two children: find the inorder successor (the smallest value in the right subtree — go right once, then left as far as possible). Copy its value into this node, then delete the successor from the right subtree. The successor has no left child, so that second delete is always case 1 or 2.
Before: delete 3 8 3 10 1 6 14 4 7 13 successor copied up case 1: leaf 1, just remove case 2: 14 has one child, 13 moves up case 3: 3 has two children, use successor 4 After 8 4 10 1 6 14 7 13 inorder: 1 4 6 7 8 10 13 14
Two-children delete: the successor (next larger value) is the only value that can sit in that spot and keep the rule — bigger than everything on the left, smaller than everything on the right.
TreeNode delete(TreeNode root, int key) {
    if (root == null) return null;
    if (key < root.val) {
        root.left = delete(root.left, key);
    } else if (key > root.val) {
        root.right = delete(root.right, key);
    } else {
        // Case 1 and 2: zero or one child -> replace node by the other side
        if (root.left == null) return root.right;
        if (root.right == null) return root.left;
        // Case 3: two children -> copy inorder successor, delete it from right
        TreeNode succ = root.right;
        while (succ.left != null) succ = succ.left;   // smallest in right subtree
        root.val = succ.val;
        root.right = delete(root.right, succ.val);
    }
    return root;
}

Notice the leaf case needs no special code: if left is null we return right, which is also null for a leaf. Time O(h). (Using the inorder predecessor — largest value on the left — works equally well.)

Inorder = sorted

Inorder visits left subtree, node, right subtree. In a BST that is "everything smaller, me, everything larger" at every level, so the output is sorted. This one fact solves a family of problems: validate a BST (the inorder must be strictly increasing), kth smallest (stop at the kth visit), two-sum in a BST, recover a BST with two swapped nodes (find the two places where the order breaks).

Operations complexity

OperationBalanced (h = log n)Degenerate (h = n)Space
SearchO(log n)O(n)O(1) iterative
InsertO(log n)O(n)O(h) recursive
DeleteO(log n)O(n)O(h) recursive
Min / max (leftmost / rightmost)O(log n)O(n)O(1)
Successor / predecessorO(log n)O(n)O(1)
Sorted listing (inorder)O(n)O(n)O(h)

Degenerate BST — why balancing matters

A plain BST has no control over its shape: the shape depends on insert order. Insert 1, 2, 3, 4, 5, 6, 7 in order and every node goes to the right of the last one:

sorted inserts 1..7 same keys, balanced 1 4 \ / \ 2 2 6 \ / \ / \ 3 height 7 1 3 5 7 height 3 \ (a linked list) ...7

Now search is O(n), no better than a list. Real data is often sorted or nearly sorted (timestamps, auto-increment IDs), so this is not a rare edge case. Self-balancing trees (AVL, red-black — next section) fix the shape after every insert and delete, guaranteeing O(log n). That is what Java's TreeMap uses.

Problem 1 — Validate a BST (min/max bounds)

boolean isValidBST(TreeNode root) {
    return valid(root, Long.MIN_VALUE, Long.MAX_VALUE);
}
// every value in this subtree must be strictly between low and high
boolean valid(TreeNode node, long low, long high) {
    if (node == null) return true;
    if (node.val <= low || node.val >= high) return false;
    return valid(node.left, low, node.val)       // left: tighten upper bound
        && valid(node.right, node.val, high);    // right: tighten lower bound
}

Why it works: checking only left.val < node.val < right.val is the classic wrong answer. The tree 5 → right 6 → left 4 passes that local check, but 4 sits in the right subtree of 5, so it is invalid (our code returns false). Passing down the allowed range catches it: when you go left, the current value becomes the new upper limit; going right, it becomes the new lower limit. long bounds make a node holding Integer.MAX_VALUE work. O(n) time, O(h) space.

Problem 2 — Kth smallest element

int kthSmallest(TreeNode root, int k) {
    Deque<TreeNode> stack = new ArrayDeque<>();
    TreeNode cur = root;
    while (cur != null || !stack.isEmpty()) {
        while (cur != null) { stack.push(cur); cur = cur.left; }
        cur = stack.pop();
        if (--k == 0) return cur.val;     // k-th node in sorted order
        cur = cur.right;
    }
    throw new IllegalArgumentException("k is larger than the tree");
}   // sample BST, k = 3 -> 4

Why it works: iterative inorder produces values in sorted order, so the kth pop is the kth smallest. It stops early: O(h + k) time, O(h) space. Follow-up "the tree changes often and kth is asked often": store a subtree size in each node, then pick left or right by comparing k to the left size — O(h) per query.

Problem 3 — Lowest common ancestor in a BST

TreeNode lcaBST(TreeNode root, TreeNode p, TreeNode q) {
    TreeNode cur = root;
    while (cur != null) {
        if (p.val < cur.val && q.val < cur.val) cur = cur.left;        // both left
        else if (p.val > cur.val && q.val > cur.val) cur = cur.right;  // both right
        else return cur;                                               // they split here
    }
    return null;
}   // sample BST: lca(4,7)=6, lca(1,7)=3, lca(13,10)=10

Why it works: while both values are on the same side, the LCA must be on that side too. The first node where they split (or one of them equals the node) is the LCA. Uses the ordering, so it is O(h) time and O(1) space — better than the O(n) general-tree version in section 12.

Problem 4 — Convert a sorted array to a balanced BST

TreeNode sortedArrayToBST(int[] nums) {
    return build(nums, 0, nums.length - 1);
}
TreeNode build(int[] nums, int lo, int hi) {
    if (lo > hi) return null;
    int mid = lo + (hi - lo) / 2;          // middle element becomes the root
    TreeNode node = new TreeNode(nums[mid]);
    node.left = build(nums, lo, mid - 1);
    node.right = build(nums, mid + 1, hi);
    return node;
}   // [1..7] -> root 4, height 3 (in nodes)

Why it works: picking the middle puts half the values on each side, so the two subtree sizes differ by at most one at every level — the tree is height-balanced. Everything left of mid is smaller, everything right is larger, so the BST rule holds. O(n) time, O(log n) recursion space.

When to use a BST, and pitfalls

  • Use an (always balanced) BST when you need order: sorted iteration, min/max, "next larger than x", range queries. If you only need lookup by exact key, a HashMap is faster (O(1)).
  • Pitfall: validating with parent-child comparisons only (see Problem 1).
  • Pitfall: forgetting to assign the return value — insert(root.left, key) without root.left = loses the new node.
  • Pitfall: duplicates. Decide a rule up front (ignore, count field, or always go right) and say it out loud.
  • Pitfall: assuming O(log n) for a plain BST. It is O(h), and h can be n.

14. Balanced Trees & TreeMap

In plain words

A self-balancing BST is a binary search tree that repairs its own shape after every insert or delete, so its height always stays around log n. That turns the BST's "O(h), could be O(n)" promise into a guaranteed O(log n).

You will almost never code one in an interview. What you will use is Java's TreeMap and TreeSet, which are red-black trees under the hood, and their "find the nearest key" methods.

A bookshelf that re-spaces itself. Whenever one end gets crowded, it shifts a few books over so you can always find any title in a handful of glances.

Why balance matters

Every BST operation costs O(height). With n = 1,000,000 keys a balanced tree has height about 20; a degenerate one has height 1,000,000. Balanced trees keep a small invariant (a rule that is always true) that forces the height to stay O(log n), and they restore the rule after each change using rotations.

Rotations — the one move every balanced tree uses

A rotation changes which node is the parent in a small group of nodes, without breaking the BST order. In a right rotation, the left child x moves up and its old parent y moves down to the right. The middle subtree B (values between x and y) switches from x's right to y's left. Only three links change, so a rotation is O(1).

Before (left side too tall) y x C A B rotateRight(y) O(1): 3 links change After x A y B C Inorder is A, x, B, y, C in both pictures, so the BST rule still holds.
A right rotation lifts the left child. A left rotation is the exact mirror. Balanced trees fix their shape with one or two of these.

AVL trees in easy words

An AVL tree (named after its inventors Adelson-Velsky and Landis) stores a height in each node and keeps this rule: at every node, the left and right subtree heights differ by at most 1. The difference is called the balance factor. After an insert, walk back up; the first node whose balance factor becomes +2 or −2 is fixed with one of four cases:

CaseShapeFix
LLToo tall on the left, new key went into the left child's leftOne right rotation
RRMirror of LLOne left rotation
LRToo tall on the left, new key went into the left child's right (a zig-zag)Left-rotate the child (turns it into LL), then right-rotate the node
RLMirror of LRRight-rotate the child, then left-rotate the node
class Node {
    int key, height = 1;
    Node left, right;
    Node(int k) { key = k; }
}
int h(Node n) { return n == null ? 0 : n.height; }
void update(Node n) { n.height = 1 + Math.max(h(n.left), h(n.right)); }
int balance(Node n) { return h(n.left) - h(n.right); }

Node rotateRight(Node y) {
    Node x = y.left;
    Node b = x.right;
    x.right = y;          // y comes down to the right
    y.left = b;           // B changes parent (still between x and y)
    update(y);            // y is now lower: update it first
    update(x);
    return x;             // new root of this subtree
}
Node rotateLeft(Node x) {
    Node y = x.right;
    Node b = y.left;
    y.left = x;
    x.right = b;
    update(x);
    update(y);
    return y;
}

Node insert(Node node, int key) {
    if (node == null) return new Node(key);
    if (key < node.key) node.left = insert(node.left, key);
    else if (key > node.key) node.right = insert(node.right, key);
    else return node;
    update(node);
    int bal = balance(node);
    if (bal > 1 && key < node.left.key) return rotateRight(node);             // LL
    if (bal < -1 && key > node.right.key) return rotateLeft(node);            // RR
    if (bal > 1) { node.left = rotateLeft(node.left); return rotateRight(node); }   // LR
    if (bal < -1) { node.right = rotateRight(node.right); return rotateLeft(node); } // RL
    return node;
}   // inserting 1..7 in order gives root 4, height 3 (not 7)

Insert is O(log n): one walk down, one walk up, at most two rotations. You are unlikely to be asked for this code, but "explain how AVL fixes itself" is a fair question.

Red-black trees in easy words

A red-black tree paints each node red or black and keeps these rules:

  1. Every node is red or black; the root is black.
  2. Null leaves count as black.
  3. No red node has a red child (no two reds in a row).
  4. Every path from a node down to a null leaf passes the same number of black nodes (the "black height").

Why that keeps it balanced: the shortest possible path is all black; the longest alternates red and black. Rule 4 says both have the same number of blacks, and rule 3 says reds cannot pile up, so the longest path is at most twice the shortest. Height is at most 2 log2(n + 1). On insert the new node is red; if that breaks rule 3, the tree fixes it by recolouring and at most two rotations.

AVLRed-black
BalanceStrict (heights differ by at most 1)Looser (longest path at most 2x shortest)
LookupsSlightly faster (shorter tree)Slightly slower
Inserts / deletesMore rotationsFewer rotations (at most 2 per insert, 3 per delete)
Used byRead-heavy in-memory indexesJava TreeMap/TreeSet, HashMap buckets with 8+ entries, C++ std::map, Linux scheduler

B-trees — why databases use them

A B-tree is a balanced search tree where each node holds many sorted keys (often hundreds) and has one more child than keys. All leaves sit at the same depth. A B+ tree, used by MySQL InnoDB and PostgreSQL indexes, keeps all real data in the leaves and links the leaves together for fast range scans.

20 40 5 10 15 25 30 35 45 50 55 < 20 20..40 > 40 one node = one disk page; real nodes hold hundreds of keys
A B-tree node is wide, not tall. Fewer levels means fewer slow disk reads per lookup.

The reason is the disk. Reading from disk or SSD happens in pages (for example 16 KB in InnoDB), and each page read is thousands of times slower than a memory access. A binary tree with a billion keys is about 30 levels deep — 30 page reads. A B-tree with ~500 keys per node reaches a billion keys in about 4 levels, and the top levels stay cached in memory, so a lookup often needs just 1–2 real disk reads. Same O(log n) in theory; a much smaller log base in practice.

Java TreeMap and TreeSet API

TreeMap<K,V> keeps keys sorted (natural order or a Comparator). TreeSet<E> is the same thing with only keys. Both implement NavigableMap/NavigableSet, which adds the "nearest key" methods that make them useful.

TreeMap<Integer, String> map = new TreeMap<>();
map.put(10, "a"); map.put(20, "b"); map.put(30, "c"); map.put(40, "d");

map.floorKey(25);      // 20   largest key <= 25
map.ceilingKey(25);    // 30   smallest key >= 25
map.floorKey(20);      // 20   "or equal" methods include the key itself
map.lowerKey(20);      // 10   strictly less
map.higherKey(20);     // 30   strictly greater
map.floorKey(5);       // null nothing <= 5, always null-check
map.firstKey();        // 10
map.lastKey();         // 40
map.headMap(30);       // {10=a, 20=b}         keys < 30
map.tailMap(30);       // {30=c, 40=d}         keys >= 30
map.subMap(15, 35);    // {20=b, 30=c}         15 <= key < 35
map.subMap(20, true, 40, true);  // {20=b, 30=c, 40=d}  both ends inclusive
map.descendingMap();   // {40=d, 30=c, 20=b, 10=a}
map.pollFirstEntry();  // removes and returns 10=a

TreeSet<Integer> set = new TreeSet<>(List.of(5, 1, 9, 3));   // [1, 3, 5, 9]
set.floor(4);          // 3
set.higher(5);         // 9
set.pollFirst();       // 1 (removed)
set.pollLast();        // 9 (removed) -> set is now [3, 5]

Memory aid: floor/ceiling include equal, lower/higher do not. Methods ending in Key return just the key; the Entry versions (floorEntry, ceilingEntry) return key and value. headMap/subMap return live views, not copies, and cost O(log n) to create.

Operations complexity

OperationTreeMap / TreeSetHashMap / HashSet
get / put / remove / containsO(log n)O(1) average
first / last / pollFirst / pollLastO(log n)O(n) — no order
floor / ceiling / lower / higherO(log n)not supported
Iterate all in sorted orderO(n)O(n log n) — must sort
Range view (subMap) then iterate k itemsO(log n + k)O(n)
SpaceO(n), ~40 bytes per entry overheadO(n)

When to use: reach for TreeMap when a problem says "closest", "next bigger", "previous smaller", "in a range", "sorted while changing", or needs both min and max with deletes of arbitrary elements (a heap cannot remove an arbitrary element fast; a TreeMap of counts can).

Problem 1 — My Calendar I (booking overlap)

Book an event [start, end) only if it does not overlap an existing booking.

class MyCalendar {
    private final TreeMap<Integer, Integer> bookings = new TreeMap<>(); // start -> end

    public boolean book(int start, int end) {           // half-open [start, end)
        Integer prevStart = bookings.floorKey(start);    // latest booking starting at or before start
        if (prevStart != null && bookings.get(prevStart) > start) return false;
        Integer nextStart = bookings.ceilingKey(start);  // earliest booking starting at or after start
        if (nextStart != null && nextStart < end) return false;
        bookings.put(start, end);
        return true;
    }
}
// book(10,20)=true, book(15,25)=false, book(20,30)=true, book(5,10)=true, book(5,11)=false

Why it works: the bookings never overlap, so sorted by start they are also sorted by end. Only two bookings can possibly clash with the new one: the one just before it (does it end after we start?) and the one just after it (does it start before we end?). floorKey and ceilingKey find both in O(log n), so each book is O(log n) and space is O(n). A list scan would be O(n) per call.

Problem 2 — Contains Duplicate III

Are there indices i != j with |i − j| <= indexDiff and |nums[i] − nums[j]| <= valueDiff?

boolean containsNearbyAlmostDuplicate(int[] nums, int indexDiff, int valueDiff) {
    TreeSet<Long> window = new TreeSet<>();
    for (int i = 0; i < nums.length; i++) {
        long x = nums[i];
        Long ceil = window.ceiling(x - valueDiff);     // smallest value >= x - valueDiff
        if (ceil != null && ceil <= x + valueDiff) return true;
        window.add(x);
        if (i >= indexDiff) window.remove((long) nums[i - indexDiff]); // keep last indexDiff values
    }
    return false;
}
// [1,2,3,1], 3, 0 -> true     [1,5,9,1,5,9], 2, 3 -> false

Why it works: a sliding window keeps only the last indexDiff values, which handles the index condition. For the value condition we need "is any value in [x − valueDiff, x + valueDiff]?" — ceiling(x − valueDiff) returns the smallest candidate in that range, and if even that one is above x + valueDiff, none fits. Using long avoids overflow when values are near Integer.MAX_VALUE. Time O(n log k), space O(k) where k = indexDiff. (A bucket approach reaches O(n), but the TreeSet version is what most interviewers expect first.)

Problem 3 — Stock Price Fluctuation

Updates arrive as (timestamp, price) and may correct an earlier timestamp. Support current(), maximum(), minimum().

class StockPrice {
    private final Map<Integer, Integer> priceAt = new HashMap<>();      // timestamp -> price
    private final TreeMap<Integer, Integer> count = new TreeMap<>();    // price -> how many timestamps
    private int latest = 0;

    public void update(int timestamp, int price) {
        Integer old = priceAt.put(timestamp, price);
        if (old != null) {                                  // correction: remove old price once
            if (count.merge(old, -1, Integer::sum) == 0) count.remove(old);
        }
        count.merge(price, 1, Integer::sum);
        latest = Math.max(latest, timestamp);
    }
    public int current() { return priceAt.get(latest); }
    public int maximum() { return count.lastKey(); }
    public int minimum() { return count.firstKey(); }
}
// update(1,10), update(2,5): current 5, max 10
// update(1,3) corrects t=1: max 5.  update(4,2): min 2, current 2

Why it works: the hard part is corrections: the old price must disappear from the max/min structure. A TreeMap used as a multiset (value to count) removes one copy of any price in O(log n) and gives min and max as firstKey/lastKey. Update is O(log n), current is O(1), max/min are O(log n). The alternative — two heaps with lazy deletion (pop stale tops whose price no longer matches) — also works but is harder to get right.

Pitfalls

  • floorKey and friends return null, not an exception, when nothing qualifies. Store the result in an Integer, not an int, or auto-unboxing throws NullPointerException.
  • TreeSet is a set: duplicates are silently dropped. Use a TreeMap<value, count> as a multiset.
  • A Comparator that returns 0 for different objects makes the tree treat them as equal — one of them vanishes. Break ties (for example by id).
  • Comparing boxed Integer keys with == only works for small values (-128 to 127 are cached). Use equals or unbox.
  • Modifying the map while iterating over it (other than through the iterator) throws ConcurrentModificationException.

15. Tries

In plain words

A trie (say "try", from retrieval), also called a prefix tree, stores strings letter by letter along paths from the root. Words that start the same way share the same first nodes. Each node has a flag that says "a word ends here".

Its superpower is prefixes: "is there any word starting with ca?" is answered by walking two steps, no matter how many words you stored.

The index tabs of a paper dictionary: first you jump to the C section, then CA, then CAR. You never look at the D pages, and all the CAR- words sit together.

root c d a o r t g t car cart cat dog prefix "ca" shared by car, cart, cat double ring = isEnd (a word ends here) letters live on the edges / child slots; the root is empty
A trie with car, cart, cat and dog. "car" is a word and also a prefix of "cart" — that is why the end flag is needed.

Node design: array vs map

Node[] children = new Node[26]Map<Character, Node> children
SpeedFastest: direct index c - 'a'Slower: hashing and boxing
Memory26 references per node even if 1 is usedOnly stores children that exist
AlphabetFixed and small (lowercase letters, digits, bits)Any characters (Unicode, URLs)
Children in sorted orderFree: just loop 0..25Need a TreeMap

In interviews, use the array unless the problem says the characters can be anything.

Implementation — insert, search, startsWith

class Trie {
    private static class Node {
        Node[] children = new Node[26];   // one slot per letter 'a'..'z'
        boolean isEnd;                    // a word ends here
    }
    private final Node root = new Node();

    public void insert(String word) {
        Node cur = root;
        for (char c : word.toCharArray()) {
            int i = c - 'a';
            if (cur.children[i] == null) cur.children[i] = new Node();
            cur = cur.children[i];
        }
        cur.isEnd = true;
    }
    public boolean search(String word) {
        Node n = walk(word);
        return n != null && n.isEnd;       // path exists AND a word ends here
    }
    public boolean startsWith(String prefix) {
        return walk(prefix) != null;       // path exists is enough
    }
    private Node walk(String s) {
        Node cur = root;
        for (char c : s.toCharArray()) {
            cur = cur.children[c - 'a'];
            if (cur == null) return null;
        }
        return cur;
    }
}
// insert("apple"): search("apple")=true, search("app")=false, startsWith("app")=true
// insert("app"):   search("app")=true

The map version only changes two lines — cur = cur.children.computeIfAbsent(c, k -> new Node()) on insert and cur = cur.children.get(c) on lookup.

Operations complexity (L = length of the word or prefix)

OperationTrieHashSet<String>TreeSet<String>
InsertO(L)O(L) to hashO(L log n)
Exact searchO(L)O(L) averageO(L log n)
Any word with prefix p?O(|p|)O(n · L) — check every wordO(|p| log n) via ceiling(p)
List all k words with prefix pO(|p| + size of that subtree)O(n · L)O(|p| log n + k · L)
SpaceO(total characters × alphabet) worst caseO(total characters)O(total characters)

A trie does not beat a HashSet for exact lookups, and it uses more memory. Pick it when the problem is about prefixes, or when you search for many words at once while walking a structure (a grid, a stream of characters) and want to stop as soon as no word can match.

Problem 1 — Implement Trie

That is the class above. Every operation is O(L) time; insert uses O(L) new space in the worst case. Why it works: a path from the root spells a prefix, and the isEnd flag separates "a stored word" from "only a prefix of a stored word".

Problem 2 — Word Search II (the idea, plus code)

Given a letter grid and a list of words, find all words that can be traced through adjacent cells. Running a separate grid search for each word is too slow. Instead, put all words in a trie and run one DFS from each cell, walking the grid and the trie together. The moment the current path is not a prefix of any word, stop.

class Solution {
    private static class Node {
        Node[] next = new Node[26];
        String word;                      // set on the node where a word ends
    }
    public List<String> findWords(char[][] board, String[] words) {
        Node root = new Node();
        for (String w : words) {
            Node cur = root;
            for (char c : w.toCharArray()) {
                if (cur.next[c - 'a'] == null) cur.next[c - 'a'] = new Node();
                cur = cur.next[c - 'a'];
            }
            cur.word = w;
        }
        List<String> found = new ArrayList<>();
        for (int r = 0; r < board.length; r++)
            for (int c = 0; c < board[0].length; c++)
                dfs(board, r, c, root, found);
        return found;
    }
    private void dfs(char[][] b, int r, int c, Node parent, List<String> found) {
        if (r < 0 || c < 0 || r >= b.length || c >= b[0].length) return;
        char ch = b[r][c];
        if (ch == '#' || parent.next[ch - 'a'] == null) return;  // visited, or no word has this prefix
        Node node = parent.next[ch - 'a'];
        if (node.word != null) {
            found.add(node.word);
            node.word = null;                 // report each word once
        }
        b[r][c] = '#';                        // mark visited
        dfs(b, r + 1, c, node, found);
        dfs(b, r - 1, c, node, found);
        dfs(b, r, c + 1, node, found);
        dfs(b, r, c - 1, node, found);
        b[r][c] = ch;                         // restore (backtrack)
    }
}
// board oaan/etae/ihkr/iflv, words [oath, pea, eat, rain] -> [oath, eat]

Why it works: the trie shares work between words with the same prefix and prunes dead paths immediately. Time is O(R · C · 4 · 3L−1) in the worst case (L = longest word; after the first step each cell has at most 3 unvisited neighbours), but pruning makes it far faster in practice. Space O(total letters in words) for the trie plus O(L) recursion. Clearing node.word avoids duplicates without a Set.

Problem 3 — Autocomplete / search suggestions

After each typed character of searchWord, return up to three product names with that prefix, in lexicographic order.

List<List<String>> suggestedProducts(String[] products, String searchWord) {
    Arrays.sort(products);                    // insert in sorted order
    Node root = new Node();                   // Node { Node[] next = new Node[26]; List<String> top = new ArrayList<>(); }
    for (String p : products) {
        Node cur = root;
        for (char c : p.toCharArray()) {
            if (cur.next[c - 'a'] == null) cur.next[c - 'a'] = new Node();
            cur = cur.next[c - 'a'];
            if (cur.top.size() < 3) cur.top.add(p);  // first 3 seen are the 3 smallest
        }
    }
    List<List<String>> result = new ArrayList<>();
    Node cur = root;
    for (char c : searchWord.toCharArray()) {
        cur = (cur == null) ? null : cur.next[c - 'a'];
        result.add(cur == null ? List.of() : cur.top);
    }
    return result;
}
// products [mobile, mouse, moneypot, monitor, mousepad], "mouse" ->
// [[mobile, moneypot, monitor], [mobile, moneypot, monitor],
//  [mouse, mousepad], [mouse, mousepad], [mouse, mousepad]]

Why it works: because products are inserted in sorted order, the first three that pass through a node are the three smallest with that prefix, so each node can cache its answer. Build is O(n log n · L) for the sort plus O(total characters) for the trie; each keystroke is then O(1). Space O(total characters) nodes plus up to 3 references per node. Once a prefix has no match, every longer prefix returns an empty list. (A sorted array plus binary search for the prefix also works and uses less memory.)

Problem 4 — Longest word in dictionary

Find the longest word that can be built one character at a time by other words in the list (for "world", the words w, wo, wor, worl must all exist). Ties go to the lexicographically smallest.

class Solution {
    private static class Node {
        Node[] next = new Node[26];
        String word;
    }
    private String best = "";
    public String longestWord(String[] words) {
        Node root = new Node();
        for (String w : words) {
            Node cur = root;
            for (char c : w.toCharArray()) {
                if (cur.next[c - 'a'] == null) cur.next[c - 'a'] = new Node();
                cur = cur.next[c - 'a'];
            }
            cur.word = w;
        }
        dfs(root);
        return best;
    }
    // only step into children that are complete words themselves
    private void dfs(Node node) {
        for (Node child : node.next) {          // 'a'..'z' order = lexicographic
            if (child != null && child.word != null) {
                if (child.word.length() > best.length()) best = child.word;
                dfs(child);
            }
        }
    }
}
// [a, banana, app, appl, ap, apply, apple] -> "apple"  (apply is equally long but larger)

Why it works: "buildable one letter at a time" means every node on the path is a word end. So the DFS only walks through word-end nodes. Visiting children in a..z order and replacing best only when strictly longer keeps the smallest word on ties. O(total characters) time and space.

Problem 5 — Maximum XOR of two numbers (bit trie)

A trie over bits instead of letters: each number becomes a 31-step path from the highest bit to the lowest, and each node has two children, for bit 0 and bit 1.

int findMaximumXOR(int[] nums) {
    int[][] next = new int[nums.length * 31 + 1][2];  // node -> child for bit 0 / 1 (0 = none)
    int count = 1;                                     // node 0 is the root
    int best = 0;
    for (int x : nums) {
        // insert x, bit 30 down to bit 0
        int cur = 0;
        for (int b = 30; b >= 0; b--) {
            int bit = (x >> b) & 1;
            if (next[cur][bit] == 0) next[cur][bit] = count++;
            cur = next[cur][bit];
        }
        // query: greedily take the opposite bit when possible
        cur = 0;
        int xor = 0;
        for (int b = 30; b >= 0; b--) {
            int bit = (x >> b) & 1;
            int want = bit ^ 1;
            if (next[cur][want] != 0) {
                xor |= (1 << b);
                cur = next[cur][want];
            } else {
                cur = next[cur][bit];
            }
        }
        best = Math.max(best, xor);
    }
    return best;
}
// [3, 10, 5, 25, 2, 8] -> 28   (5 XOR 25)

Why it works: XOR puts a 1 wherever the bits differ, and a 1 in a higher bit is worth more than all lower bits combined (2b > 2b − 1). So for each number, walk from the top bit and take the opposite branch whenever it exists — a greedy choice that is always right. Inserting before querying is safe: a number XOR itself is 0, which never beats a real pair. O(31 · n) = O(n) time, O(31 · n) space. Storing nodes in an int[][] instead of objects is faster and a common trick.

Pitfalls

  • Forgetting isEnd: then search("app") wrongly returns true after inserting "apple".
  • c - 'a' assumes lowercase letters only. Uppercase or digits give negative or out-of-range indices.
  • Memory: 26 references × 8 bytes per node adds up fast with a million words. Mention the map or a compressed trie (radix tree, which merges single-child chains) if memory matters.
  • In Word Search II, forgetting to restore the cell after the DFS, or reporting the same word twice.

16. Graphs & Traversal

In plain words

A graph is a set of vertices (also called nodes) joined by edges. Unlike a tree, a graph can have loops, several paths between the same two nodes, and pieces that are not connected at all. A tree is just a special graph: connected, with no cycles.

Most graph problems come down to one of two walks: BFS (breadth-first search — explore in rings, nearest first) or DFS (depth-first search — go as deep as you can, then back up). The only new rule compared to trees: remember what you have already visited, or you will walk in circles forever.

A city map. Intersections are vertices, roads are edges. One-way streets make it directed, road lengths make it weighted, and an island with its own roads is a separate connected component.

Terminology

TermMeaning
UndirectedEdges go both ways (friendship). Edge u–v means you can move u to v and v to u.
Directed (digraph)Edges have a direction (following someone, a prerequisite). u → v does not give v → u.
WeightedEach edge has a cost (distance, time, price). Unweighted means every edge costs 1.
Cyclic / acyclicCyclic: you can leave a node and return to it along edges. Acyclic: you cannot.
DAGDirected Acyclic Graph — directions and no cycles. Task dependencies, course prerequisites. Can be topologically sorted.
Connected(Undirected) every node can reach every other. A connected component is a maximal group of nodes that can reach each other.
DegreeNumber of edges touching a node. Directed graphs split it into in-degree (edges coming in) and out-degree (edges going out).
Path / cycleA path is a sequence of nodes joined by edges. A cycle is a path that starts and ends at the same node.
Sparse / denseSparse: E is close to V (most real graphs). Dense: E is close to V2.

We write V for the number of vertices and E for the number of edges. Graph complexities are stated in both, like O(V + E).

0 1 2 3 4 5 6 d=0 d=1 d=1 d=2 d=3 component 2: unreachable from 0 cycle accent edges = the BFS tree (the edge 2-3 is skipped because 3 is already visited)
The graph used in the snippets below: 7 nodes, 2 connected components, one cycle. BFS distances from node 0 are shown above each node.

Representations

edges: 0-1 0-2 1-3 2-3 3-4 5-6 Adjacency list Adjacency matrix (first 5 nodes) 0: [1, 2] 0 1 2 3 4 1: [0, 3] 0 [0 1 1 0 0] 2: [0, 3] 1 [1 0 0 1 0] 3: [1, 2, 4] 2 [1 0 0 1 0] 4: [3] 3 [0 1 1 0 1] 5: [6] 4 [0 0 0 1 0] 6: [5] Edge list: [[0,1],[0,2],[1,3],[2,3],[3,4],[5,6]]
RepresentationSpaceIs u–v an edge?List neighbours of uUse when
Adjacency listO(V + E)O(degree(u))O(degree(u))Default. Sparse graphs, BFS/DFS.
Adjacency matrixO(V2)O(1)O(V)Dense graphs, small V (up to a few thousand), Floyd-Warshall.
Edge listO(E)O(E)O(E)Input format; Kruskal's MST (sort edges); Union-Find; Bellman-Ford.
Implicit (grid / rules)none storedcomputecompute (4 or 8 directions)Grid problems, word ladder: neighbours are generated on the fly.

In Java the usual adjacency list is List<List<Integer>> when nodes are 0..n−1, or Map<String, List<String>> when nodes have names. For weighted graphs store int[]{neighbour, weight} pairs.

List<List<Integer>> buildGraph(int n, int[][] edges) {
    List<List<Integer>> adj = new ArrayList<>();
    for (int i = 0; i < n; i++) adj.add(new ArrayList<>());
    for (int[] e : edges) {
        adj.get(e[0]).add(e[1]);
        adj.get(e[1]).add(e[0]);   // undirected: add both directions
    }
    return adj;
}   // edges above -> [[1, 2], [0, 3], [0, 3], [1, 2, 4], [3], [6], [5]]

BFS template — and shortest paths in unweighted graphs

// BFS: shortest number of edges from src to every node (-1 = unreachable)
int[] bfs(List<List<Integer>> adj, int src) {
    int[] dist = new int[adj.size()];
    Arrays.fill(dist, -1);                 // -1 doubles as "not visited"
    Queue<Integer> queue = new ArrayDeque<>();
    dist[src] = 0;
    queue.offer(src);
    while (!queue.isEmpty()) {
        int u = queue.poll();
        for (int v : adj.get(u)) {
            if (dist[v] == -1) {           // mark when ADDING, not when polling
                dist[v] = dist[u] + 1;
                queue.offer(v);
            }
        }
    }
    return dist;
}   // from 0: [0, 1, 1, 2, 3, -1, -1]

Why BFS gives shortest paths: the queue processes nodes in order of distance — all distance-1 nodes, then all distance-2 nodes, and so on. The first time a node is reached is therefore by a shortest route. This only holds when every edge costs the same; with weights, use Dijkstra (a later section). Time O(V + E): each node enters the queue once and each edge is looked at twice (once from each end). Space O(V).

DFS — recursive and iterative

void dfs(List<List<Integer>> adj, int u, boolean[] visited, List<Integer> order) {
    visited[u] = true;
    order.add(u);
    for (int v : adj.get(u)) {
        if (!visited[v]) dfs(adj, v, visited, order);
    }
}

List<Integer> dfsIterative(List<List<Integer>> adj, int src) {
    List<Integer> order = new ArrayList<>();
    boolean[] visited = new boolean[adj.size()];
    Deque<Integer> stack = new ArrayDeque<>();
    stack.push(src);
    while (!stack.isEmpty()) {
        int u = stack.pop();
        if (visited[u]) continue;          // may be pushed more than once
        visited[u] = true;
        order.add(u);
        List<Integer> nbrs = adj.get(u);
        for (int i = nbrs.size() - 1; i >= 0; i--) {   // reverse: same order as recursive
            if (!visited[nbrs.get(i)]) stack.push(nbrs.get(i));
        }
    }
    return order;
}   // both give [0, 1, 3, 2, 4] from node 0

Both are O(V + E) time and O(V) space. Recursive DFS is shorter; iterative DFS avoids StackOverflowError on long paths (Java's default stack handles roughly 10,000 deep calls, fewer with big frames).

Visited handling — the rules that prevent bugs

  • BFS: mark when you add to the queue, not when you remove. Otherwise the same node can be added many times by different neighbours before it is processed — correct answer, but wasted work, and in dense graphs it can blow up memory.
  • Iterative DFS: mark when you pop and skip already-visited pops (as above) if you want true DFS order.
  • Use a boolean[] for numbered nodes, a HashSet for named nodes, or change the input in place (set a grid cell to '0' or '#') if you are allowed to.
  • For a graph that may be disconnected, loop over every node and start a new search from each unvisited one.

Connected components and cycle detection (undirected)

The number of connected components is the number of times the outer loop finds an unvisited node. For cycles in an undirected graph, DFS finds one if it reaches an already-visited node that is not the node it just came from (its parent). Going back along the same edge does not count as a cycle.

boolean hasCycle(List<List<Integer>> adj) {
    boolean[] visited = new boolean[adj.size()];
    for (int i = 0; i < adj.size(); i++) {
        if (!visited[i] && cycleDfs(adj, i, -1, visited)) return true;
    }
    return false;
}
boolean cycleDfs(List<List<Integer>> adj, int u, int parent, boolean[] visited) {
    visited[u] = true;
    for (int v : adj.get(u)) {
        if (!visited[v]) {
            if (cycleDfs(adj, v, u, visited)) return true;
        } else if (v != parent) {
            return true;                   // reached an already-seen node another way
        }
    }
    return false;
}   // sample graph: true (0-1-3-2-0).  Tree 0-1, 1-2, 1-3: false

O(V + E). Directed graphs need a different test — the parent trick is wrong there, and you need three states (unvisited, in the current path, done) or Kahn's algorithm. That is covered in the topological sort section. Union-Find (section 17) is another way to detect cycles in undirected graphs.

Bipartite means the nodes can be split into two groups so every edge crosses between the groups. Colour the start node, give each neighbour the opposite colour, and fail if an edge ever joins two nodes of the same colour. A graph is bipartite exactly when it has no odd-length cycle.

Operations complexity

OperationAdjacency listAdjacency matrix
BFS / DFS / components / bipartite / cycle checkO(V + E)O(V2)
Add edgeO(1)O(1)
Remove edgeO(degree)O(1)
Check edge u–vO(degree(u))O(1)
Grid of R × C cells as a graphO(R · C) — each cell has at most 4 edges

BFS or DFS? BFS for shortest path in steps, "minimum number of moves", or anything spreading level by level. DFS for "explore everything", connectivity, cycle detection, topological order, backtracking over paths. For simple counting (components, islands), both work.

Problem 1 — Number of connected components

int countComponents(int n, int[][] edges) {
    List<List<Integer>> adj = buildGraph(n, edges);
    boolean[] visited = new boolean[n];
    int components = 0;
    for (int i = 0; i < n; i++) {
        if (!visited[i]) {
            components++;                  // new unvisited node = new component
            dfs(adj, i, visited, new ArrayList<>());
        }
    }
    return components;
}   // n=5, edges [[0,1],[1,2],[3,4]] -> 2

Why it works: one DFS marks exactly one whole component. So every time the loop meets an unvisited node, it has found a component nobody has touched yet. O(V + E) time and space. Number of Islands is the same idea on a grid.

Problem 2 — Clone graph

Node cloneGraph(Node node) {                   // Node { int val; List<Node> neighbors; }
    if (node == null) return null;
    Map<Node, Node> copies = new HashMap<>();   // original -> clone (also "visited")
    Queue<Node> queue = new ArrayDeque<>();
    copies.put(node, new Node(node.val));
    queue.offer(node);
    while (!queue.isEmpty()) {
        Node cur = queue.poll();
        for (Node nb : cur.neighbors) {
            if (!copies.containsKey(nb)) {
                copies.put(nb, new Node(nb.val));
                queue.offer(nb);
            }
            copies.get(cur).neighbors.add(copies.get(nb));  // wire clone edge
        }
    }
    return copies.get(node);
}

Why it works: the map does two jobs: it is the visited set, and it lets every edge find the clone of its other end, even if that clone was created earlier. Without it, a cycle (1–2–3–4–1) would be copied forever. O(V + E) time, O(V) space.

Problem 3 — Is graph bipartite?

boolean isBipartite(int[][] graph) {
    int n = graph.length;
    int[] color = new int[n];                 // 0 = uncoloured, 1 / -1 = the two sides
    for (int start = 0; start < n; start++) {
        if (color[start] != 0) continue;      // graph may be disconnected
        Queue<Integer> queue = new ArrayDeque<>();
        queue.offer(start);
        color[start] = 1;
        while (!queue.isEmpty()) {
            int u = queue.poll();
            for (int v : graph[u]) {
                if (color[v] == 0) {
                    color[v] = -color[u];     // neighbour gets the other side
                    queue.offer(v);
                } else if (color[v] == color[u]) {
                    return false;             // edge inside one side
                }
            }
        }
    }
    return true;
}   // square 0-1-2-3-0: true.  Square plus diagonal 0-2: false (triangle)

Why it works: within a component, once the start's colour is chosen every other colour is forced — a neighbour must be on the other side. So if a forced colouring ever conflicts, no valid split exists. O(V + E) time, O(V) space.

Problem 4 — Rotting oranges (multi-source BFS)

Grid: 0 empty, 1 fresh, 2 rotten. Every minute, rotten oranges rot their 4 neighbours. Minutes until none are fresh, or −1 if impossible.

int orangesRotting(int[][] grid) {
    int rows = grid.length, cols = grid[0].length;
    Queue<int[]> queue = new ArrayDeque<>();
    int fresh = 0;
    for (int r = 0; r < rows; r++)
        for (int c = 0; c < cols; c++) {
            if (grid[r][c] == 2) queue.offer(new int[]{r, c});   // ALL sources start together
            else if (grid[r][c] == 1) fresh++;
        }
    int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
    int minutes = 0;
    while (!queue.isEmpty() && fresh > 0) {
        int size = queue.size();                  // one level = one minute
        for (int i = 0; i < size; i++) {
            int[] cell = queue.poll();
            for (int[] d : dirs) {
                int nr = cell[0] + d[0], nc = cell[1] + d[1];
                if (nr >= 0 && nc >= 0 && nr < rows && nc < cols && grid[nr][nc] == 1) {
                    grid[nr][nc] = 2;             // rot it = mark visited
                    fresh--;
                    queue.offer(new int[]{nr, nc});
                }
            }
        }
        minutes++;
    }
    return fresh == 0 ? minutes : -1;
}   // [[2,1,1],[1,1,0],[0,1,1]] -> 4    [[2,1,1],[0,1,1],[1,0,1]] -> -1

Why it works: putting every rotten orange in the queue at the start is like adding one imaginary "super source" joined to all of them. BFS levels then equal minutes, and each fresh orange is reached at its distance to the nearest rotten one. Running a separate BFS per rotten orange would be O((R·C)2); this is O(R · C) time and space. The fresh > 0 check stops the count from adding an extra minute at the end.

Problem 5 — Word ladder

Fewest words in a transformation from beginWord to endWord, changing one letter at a time, where every word must be in the list.

int ladderLength(String beginWord, String endWord, List<String> wordList) {
    Set<String> dict = new HashSet<>(wordList);
    if (!dict.contains(endWord)) return 0;
    Queue<String> queue = new ArrayDeque<>();
    queue.offer(beginWord);
    dict.remove(beginWord);
    int steps = 1;                                  // counts words, not edges
    while (!queue.isEmpty()) {
        int size = queue.size();
        for (int i = 0; i < size; i++) {
            char[] w = queue.poll().toCharArray();
            for (int j = 0; j < w.length; j++) {
                char original = w[j];
                for (char c = 'a'; c <= 'z'; c++) {
                    if (c == original) continue;
                    w[j] = c;
                    String next = new String(w);
                    if (next.equals(endWord)) return steps + 1;
                    if (dict.remove(next)) queue.offer(next);   // remove = mark visited
                }
                w[j] = original;
            }
        }
        steps++;
    }
    return 0;
}   // hit -> cog with [hot,dot,dog,lot,log,cog]: 5 (hit hot dot dog cog)

Why it works: it is an unweighted shortest-path problem on an implicit graph: words are nodes, and two words are joined if they differ by one letter. Instead of comparing every pair of words (O(N2 · L)), generate the 25 · L possible neighbours of each word and check them in the set. Removing a word from the set when it is queued marks it visited. Time O(N · L · 26 · L) (the last L is building each string), space O(N · L). Bidirectional BFS (search from both ends and meet in the middle) is the usual follow-up speed-up.

Pitfalls

  • Forgetting the visited set: infinite loop on any cycle.
  • Marking visited on poll in BFS: duplicates in the queue.
  • Forgetting disconnected parts: always loop over all nodes when the question is about the whole graph.
  • Undirected edge added only one way when building the adjacency list.
  • Using the parent trick for cycle detection when the input has duplicate edges (u–v twice): the second copy is a real 2-cycle that the parent check hides. Check the problem statement.
  • Using BFS for shortest path on a weighted graph — it gives the fewest edges, not the lowest total weight.

17. Union-Find (Disjoint Set)

In plain words

Union-Find, also called a Disjoint Set Union (DSU), keeps track of items split into groups that never overlap. It answers two questions very fast: find — "which group is x in?" — and union — "merge the groups of x and y".

Each group is stored as a small tree. Every item points to a parent, and the item at the top (the root, which points to itself) is the group's leader. Two items are in the same group exactly when they have the same root.

Companies and acquisitions. Every employee knows their boss, and the CEO is the boss of nobody. To check if two people work for the same company, follow the bosses up to each CEO and compare. When one company buys another, the smaller company's CEO simply starts reporting to the bigger company's CEO.

How it is stored

Just two integer arrays. parent[i] is i's parent (a root has parent[i] == i). size[i] (or rank[i]) is only meaningful at a root, and records how big that tree is. No objects, no pointers — which is why it is so fast in practice.

The two tricks that make it fast

  • Union by size (or rank) — when merging, attach the smaller tree under the root of the larger one. A node only gets deeper when its tree at least doubles in size, so no tree gets taller than log2 n. Rank is the same idea using an upper bound on height instead of size.
  • Path compression — during find, after you discover the root, make every node you passed point directly at the root. The next find on any of them is one step.
Before: find(4) walks 4 steps 0 1 2 3 4 root: parent[0] = 0 find(4) starts here parent = [0, 0, 1, 2, 3] After: everyone points at the root 0 1 2 3 4 next find(4), find(3), find(2): 1 step parent = [0, 0, 0, 0, 0]
Arrows point from a node to its parent. Path compression flattens the tree as a side effect of find, so the work done once is never repeated.

Full Java class

class UnionFind {
    private final int[] parent;
    private final int[] size;      // size of the tree, valid only at roots
    private int components;

    UnionFind(int n) {
        parent = new int[n];
        size = new int[n];
        components = n;
        for (int i = 0; i < n; i++) {
            parent[i] = i;         // everyone starts as their own leader
            size[i] = 1;
        }
    }

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

    boolean union(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return false;                 // already in the same set
        if (size[ra] < size[rb]) { int t = ra; ra = rb; rb = t; }
        parent[rb] = ra;                            // attach smaller under larger
        size[ra] += size[rb];
        components--;
        return true;
    }

    boolean connected(int a, int b) { return find(a) == find(b); }
    int components() { return components; }
    int sizeOf(int x) { return size[find(x)]; }
}
// n=6; union(0,1), union(1,2), union(3,4):
// connected(0,2)=true, connected(0,3)=false, components()=3, sizeOf(2)=3, union(0,2)=false

Returning a boolean from union is a small design choice that pays off: "false" means the two were already connected, which is exactly the signal for cycle detection. Keeping a components counter answers "how many groups?" in O(1).

The recursive find can go deep before the first compression. If that worries you, use the iterative path halving version, which makes each node point to its grandparent as it walks:

int find(int x) {
    while (parent[x] != x) {
        parent[x] = parent[parent[x]];   // point to grandparent: halves the path
        x = parent[x];
    }
    return x;
}   // chain parent=[0,0,1,2,3]: find(4) = 0, parent becomes [0, 0, 0, 2, 2]

Near O(1): the inverse Ackermann function in easy words

With both tricks, a sequence of m operations on n items costs O(m · α(n)). α (alpha) is the inverse Ackermann function. The Ackermann function grows unbelievably fast, so its inverse grows unbelievably slowly: α(n) is at most 4 for any n you could ever store — even n equal to the number of atoms in the universe. So in practice each operation is constant time; you say "amortized nearly O(1)" in an interview.

Versionfind / union (per operation)
No tricksO(n) worst case — trees can become long chains
Union by size or rank onlyO(log n)
Path compression onlyO(log n) amortized
BothO(α(n)) amortized — effectively O(1)
SpaceO(n) for the two arrays

When to use Union-Find

  • Connectivity questions where edges arrive over time (online): "after each new link, how many groups?" DFS would have to restart each time; Union-Find just merges.
  • Cycle detection in an undirected graph, and Kruskal's minimum spanning tree (add cheapest edges that do not form a cycle).
  • Grouping things by a shared attribute (same email, similar strings, equal variables in equations).
  • Not a fit when you need to split groups (it only merges), or need the actual path between two nodes (use BFS/DFS).

Problem 1 — Number of provinces

isConnected[i][j] = 1 means city i and city j are directly connected. Count the groups of connected cities.

int findCircleNum(int[][] isConnected) {
    int n = isConnected.length;
    UnionFind uf = new UnionFind(n);
    for (int i = 0; i < n; i++)
        for (int j = i + 1; j < n; j++)          // matrix is symmetric: upper half only
            if (isConnected[i][j] == 1) uf.union(i, j);
    return uf.components();
}   // [[1,1,0],[1,1,0],[0,0,1]] -> 2

Why it works: start with n groups; every successful union merges two groups into one, so the counter ends at the number of connected groups. O(n2 · α(n)) time because the input is an n × n matrix, O(n) extra space. A DFS over the matrix works equally well here.

Problem 2 — Redundant connection

A tree with n nodes had one extra edge added. Return the edge that can be removed to make it a tree again (the last such edge in the input).

int[] findRedundantConnection(int[][] edges) {
    UnionFind uf = new UnionFind(edges.length + 1);   // nodes are 1..n
    for (int[] e : edges) {
        if (!uf.union(e[0], e[1])) return e;   // already connected: this edge closes a cycle
    }
    return new int[0];
}   // [[1,2],[1,3],[2,3]] -> [2, 3]     [[1,2],[2,3],[3,4],[1,4],[1,5]] -> [1, 4]

Why it works: if both ends of an edge are already in the same group, there was already a path between them, so this edge creates a cycle. Processing in input order means the first such edge is the last one of the cycle in the list — the one the problem wants. O(n · α(n)) time, O(n) space.

Problem 3 — Accounts merge

Each account is a name followed by emails. Two accounts belong to the same person if they share any email. Merge them and return each person's emails sorted.

List<List<String>> accountsMerge(List<List<String>> accounts) {
    Map<String, Integer> emailId = new HashMap<>();    // email -> first account index
    UnionFind uf = new UnionFind(accounts.size());
    for (int i = 0; i < accounts.size(); i++) {
        List<String> acc = accounts.get(i);
        for (int j = 1; j < acc.size(); j++) {
            Integer owner = emailId.putIfAbsent(acc.get(j), i);
            if (owner != null) uf.union(i, owner);     // shared email: same person
        }
    }
    Map<Integer, TreeSet<String>> groups = new HashMap<>();  // root -> sorted emails
    for (Map.Entry<String, Integer> e : emailId.entrySet()) {
        groups.computeIfAbsent(uf.find(e.getValue()), k -> new TreeSet<>()).add(e.getKey());
    }
    List<List<String>> result = new ArrayList<>();
    for (Map.Entry<Integer, TreeSet<String>> g : groups.entrySet()) {
        List<String> merged = new ArrayList<>();
        merged.add(accounts.get(g.getKey()).get(0));   // name
        merged.addAll(g.getValue());
        result.add(merged);
    }
    return result;
}
// [John: johnsmith, john_newyork] [John: johnsmith, john00] [Mary: mary] [John: johnnybravo]
// -> [John, john00@mail.com, john_newyork@mail.com, johnsmith@mail.com],
//    [John, johnnybravo@mail.com], [Mary, mary@mail.com]   (group order may vary)

Why it works: the union-find nodes are accounts. The first account to mention an email "owns" it; any later account with the same email is unioned with the owner. Afterwards, each email is filed under its owner's root, and the TreeSet sorts the emails. Two accounts with the same name but no shared email (the two Johns) correctly stay separate. Time O(E log E) where E is the total number of emails (the sort dominates), space O(E).

Problem 4 — Number of islands II (the idea)

An m × n grid starts as all water. Land is added one cell at a time; after each addition report the number of islands. Re-running a flood fill after every addition would cost O(m · n) each time. With Union-Find: the new cell becomes its own island (+1), then union it with each land neighbour; every union that actually merges two different sets is −1.

List<Integer> numIslands2(int m, int n, int[][] positions) {
    UnionFind uf = new UnionFind(m * n);
    boolean[] land = new boolean[m * n];
    int islands = 0;
    int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
    List<Integer> result = new ArrayList<>();
    for (int[] p : positions) {
        int id = p[0] * n + p[1];                 // 2D cell -> 1D index
        if (!land[id]) {
            land[id] = true;
            islands++;                            // new land starts as its own island
            for (int[] d : dirs) {
                int r = p[0] + d[0], c = p[1] + d[1];
                int nid = r * n + c;
                if (r >= 0 && c >= 0 && r < m && c < n && land[nid] && uf.union(id, nid)) {
                    islands--;                    // merged two islands into one
                }
            }
        }
        result.add(islands);
    }
    return result;
}   // 3x3, add (0,0),(0,1),(1,2),(2,1),(1,1) -> [1, 1, 2, 3, 1]

Why it works: the last cell (1,1) touches three separate islands; it adds 1 and then merges three times, so 3 + 1 − 3 = 1. Each addition is O(α(mn)), so k additions cost O(m · n + k · α(mn)) including setup; space O(m · n). The !land[id] check handles the same cell being added twice. Mapping (row, col) to row * n + col is the standard way to put a grid into a DSU.

Pitfalls

  • Comparing parent[a] == parent[b] instead of find(a) == find(b). Only roots identify a group.
  • Linking parent[a] = b instead of linking the roots: parent[find(a)] = find(b).
  • Reading size[x] for a non-root. Always use size[find(x)].
  • Off-by-one when nodes are numbered 1..n: allocate n + 1.
  • Forgetting that Union-Find cannot undo a union or remove an item (unless you use the advanced "rollback" variant without path compression).

18. Segment Trees & Fenwick Trees

In plain words

Both structures answer range queries — "what is the sum (or min, or max) of elements from index l to r?" — while the array keeps changing. Each query and each update costs O(log n).

A segment tree is a binary tree where every node stores the answer for one slice of the array; the root covers everything, the leaves cover single elements. A Fenwick tree (also called a Binary Indexed Tree, BIT) is a clever array that stores partial sums whose sizes follow the binary digits of the index. It is shorter to code but only fits "prefix-able" operations like sum.

A company's sales report. The CEO's number is the whole company; each regional manager has their region; each store manager has one store. To get "stores 3 to 7", you add a few regional totals instead of every store — and when one store's sales change, only that store's managers up the chain update their numbers.

The problem: prefix sums break with updates

A prefix sum array (section on arrays) answers any range sum in O(1): sum(l, r) = prefix[r + 1] − prefix[l]. But change one element and every prefix after it is wrong — fixing them is O(n). The plain array has the opposite problem: O(1) updates but O(n) range sums.

ApproachBuildPoint updateRange querySpace
Plain arrayO(1)O(1)O(n)O(1) extra
Prefix sum arrayO(n)O(n)O(1)O(n)
Segment treeO(n)O(log n)O(log n)O(4n)
Segment tree + lazyO(n)range update O(log n)O(log n)O(8n)
Fenwick tree (BIT)O(n log n), or O(n) with a trickO(log n)O(log n)O(n)

With q mixed updates and queries, both trees cost O(q log n) in total instead of O(q · n). When you see "update" and "range sum/min/max" in the same problem, think of these two.

Segment tree shape

[0..4] 15 [0..2] 8 [3..4] 7 [0..1] 3 [2] 5 [3] 3 [4] 4 [0] 2 [1] 1 query(1, 3) = 1 + 5 + 3 = 9 only 3 stored sums are added array index i lives at a leaf; node k has children 2k and 2k+1 (root is 1)
Segment tree for [2, 1, 5, 3, 4]. Each node's value is the sum of its two children. A range query is covered by at most about 2 log n nodes.

Like a heap, the tree is stored in a flat array: node k has children 2k and 2k + 1. Allocating 4 * n slots is always enough, even when n is not a power of two.

Segment tree — build, update, query

class SegmentTree {
    private final int n;
    private final int[] tree;          // tree[node] = sum of its range

    SegmentTree(int[] nums) {
        n = nums.length;
        tree = new int[4 * n];         // 4n is always enough
        build(nums, 1, 0, n - 1);
    }
    // node covers nums[lo..hi]; children are 2*node and 2*node+1
    private void build(int[] nums, int node, int lo, int hi) {
        if (lo == hi) { tree[node] = nums[lo]; return; }
        int mid = (lo + hi) / 2;
        build(nums, 2 * node, lo, mid);
        build(nums, 2 * node + 1, mid + 1, hi);
        tree[node] = tree[2 * node] + tree[2 * node + 1];
    }

    void update(int index, int value) { update(1, 0, n - 1, index, value); }
    private void update(int node, int lo, int hi, int index, int value) {
        if (lo == hi) { tree[node] = value; return; }
        int mid = (lo + hi) / 2;
        if (index <= mid) update(2 * node, lo, mid, index, value);
        else update(2 * node + 1, mid + 1, hi, index, value);
        tree[node] = tree[2 * node] + tree[2 * node + 1];   // fix sums on the way up
    }

    int query(int left, int right) { return query(1, 0, n - 1, left, right); }
    private int query(int node, int lo, int hi, int left, int right) {
        if (right < lo || hi < left) return 0;              // no overlap
        if (left <= lo && hi <= right) return tree[node];   // fully inside: use stored sum
        int mid = (lo + hi) / 2;                            // partial overlap: ask both kids
        return query(2 * node, lo, mid, left, right)
             + query(2 * node + 1, mid + 1, hi, left, right);
    }
}
// [2, 1, 5, 3, 4]: query(1, 3) = 9; update(2, 10); query(1, 3) = 14, query(0, 4) = 20

Why a query is O(log n): the three cases — no overlap (return the neutral value), full overlap (return the stored answer), partial overlap (split) — mean that on each level at most two nodes are partially overlapping and keep splitting. Build visits each of the ~2n nodes once: O(n). Update changes one leaf and its log n ancestors.

To answer min or max instead of sum, change the combine step (+ to Math.min) and the "no overlap" value (0 to Integer.MAX_VALUE). Anything associative works: gcd, product mod p, count, or even "max subarray sum" with a small struct per node. That flexibility is the segment tree's big advantage.

Lazy propagation (the idea)

What if the update is "add 10 to every element from l to r"? Updating each leaf is O(n). Lazy propagation stops at nodes fully inside [l, r], updates their stored sum right away (sum += 10 × length) and leaves a note — "my children still owe +10" — in a lazy[] array. The note is only pushed down to the children when a later operation needs to go below that node. Range updates and range queries both stay O(log n).

// Inside a segment tree with long[] sum, lazy (range add, range sum)
private void apply(int node, int lo, int hi, long v) {
    sum[node] += v * (hi - lo + 1);          // whole range grows by v each
    lazy[node] += v;                         // remember to tell children later
}
private void push(int node, int lo, int hi) {         // hand pending work to children
    if (lazy[node] == 0 || lo == hi) return;
    int mid = (lo + hi) / 2;
    apply(2 * node, lo, mid, lazy[node]);
    apply(2 * node + 1, mid + 1, hi, lazy[node]);
    lazy[node] = 0;
}
private void add(int node, int lo, int hi, int l, int r, long v) {
    if (r < lo || hi < l) return;
    if (l <= lo && hi <= r) { apply(node, lo, hi, v); return; }   // stop here, be lazy
    push(node, lo, hi);
    int mid = (lo + hi) / 2;
    add(2 * node, lo, mid, l, r, v);
    add(2 * node + 1, mid + 1, hi, l, r, v);
    sum[node] = sum[2 * node] + sum[2 * node + 1];
}
// query is the normal query with push(node, lo, hi) called before splitting
// [2, 1, 5, 3, 4], add 10 to [1..3]: sum(0, 4) = 45, sum(2, 2) = 15

Fenwick tree (Binary Indexed Tree)

The whole trick is lowbit: i & -i keeps only the lowest set bit of i. In two's complement, -i flips all bits and adds 1, so it matches i only at that lowest 1 bit. Examples: 12 is 1100, lowbit 4; 6 is 110, lowbit 2; 7 is 111, lowbit 1.

In a Fenwick tree (1-indexed), bit[i] stores the sum of the lowbit(i) elements ending at i — that is, positions i − lowbit(i) + 1 to i. So bit[6] holds positions 5..6, bit[8] holds 1..8, bit[7] holds just 7.

bit[8] = 1..8 bit[4] = 1..4 bit[2] bit[6] = 5..6 b1 b3 b5 b7 1 2 3 4 5 6 7 8 index prefixSum(7) = bit[7] + bit[6] + bit[4] (7 -> 6 -> 4 -> 0, strip lowest bit)
Each bit[i] covers a block whose length is the lowest set bit of i. Any prefix is the sum of at most log n blocks.
  • prefixSum(i): add bit[i], then jump to i − lowbit(i) (strip the lowest bit), until 0. 7 → 6 → 4 → 0.
  • add(i, delta): update every block that contains i: bit[i], then jump to i + lowbit(i), until past n. 5 → 6 → 8.
class FenwickTree {
    private final int[] bit;           // 1-indexed: bit[0] unused

    FenwickTree(int n) { bit = new int[n + 1]; }

    // add delta to position i (1-indexed)
    void add(int i, int delta) {
        for (; i < bit.length; i += i & -i) bit[i] += delta;   // climb to covering ranges
    }
    // sum of positions 1..i
    int prefixSum(int i) {
        int s = 0;
        for (; i > 0; i -= i & -i) s += bit[i];                // strip lowest bit each step
        return s;
    }
    int rangeSum(int l, int r) { return prefixSum(r) - prefixSum(l - 1); }
}
// values 2, 1, 5, 3, 4 at positions 1..5: prefixSum(3) = 8, rangeSum(2, 4) = 9

Both loops run at most log2 n + 1 times, since each step removes or carries one bit. Space is exactly n + 1 ints. Building by calling add n times is O(n log n).

Segment tree or Fenwick tree?

Segment treeFenwick tree
Code size~30 lines, easy to get wrong~10 lines
Memory4nn + 1
OperationsAny associative op: sum, min, max, gcd, custom structsOperations with an inverse (sum, xor, count), so ranges come from two prefixes. Not min/max over arbitrary ranges.
Range updateYes, with lazy propagationRange add + point query, or two BITs for range add + range sum
Extra tricksFind first index with value > x, merge-sort treeCounting inversions, "how many smaller seen so far"

Rule of thumb: sum or count with point updates → Fenwick. Min/max, range updates, or anything custom → segment tree. If the array never changes, neither — use a prefix sum array (or a sparse table for min/max). Java has no built-in version of either; you write them.

Problem 1 — Range Sum Query - Mutable

class NumArray {
    private final int[] nums;
    private final FenwickTree fw;

    NumArray(int[] nums) {
        this.nums = nums.clone();
        fw = new FenwickTree(nums.length);
        for (int i = 0; i < nums.length; i++) fw.add(i + 1, nums[i]);   // shift to 1-indexed
    }
    void update(int index, int val) {
        fw.add(index + 1, val - nums[index]);   // BIT stores sums, so add the difference
        nums[index] = val;
    }
    int sumRange(int left, int right) {
        return fw.rangeSum(left + 1, right + 1);
    }
}
// [1, 3, 5]: sumRange(0, 2) = 9; update(1, 2); sumRange(0, 2) = 8

Why it works: the problem gives "set to value", but a BIT only knows "add delta", so we keep a copy of the array to compute the difference. The +1 shifts LeetCode's 0-indexed positions to the BIT's 1-indexed positions. Constructor O(n log n), update and sumRange O(log n), space O(n). The SegmentTree class above solves it just as well: update(index, val) and query(left, right).

Problem 2 — Count of smaller numbers after self (BIT)

For each nums[i], count how many elements to its right are smaller.

List<Integer> countSmaller(int[] nums) {
    // 1. coordinate compression: map each value to its rank 1..m
    int[] sorted = Arrays.stream(nums).distinct().sorted().toArray();
    FenwickTree fw = new FenwickTree(sorted.length);
    Integer[] result = new Integer[nums.length];
    // 2. walk from the right; the BIT counts values already seen (to our right)
    for (int i = nums.length - 1; i >= 0; i--) {
        int rank = Arrays.binarySearch(sorted, nums[i]) + 1;  // 1-indexed rank
        result[i] = fw.prefixSum(rank - 1);                  // how many seen values are smaller
        fw.add(rank, 1);                                      // record this value
    }
    return Arrays.asList(result);
}   // [5, 2, 6, 1] -> [2, 1, 1, 0]

Why it works: scanning right to left, the BIT holds a count for every value to the right of i (a frequency table indexed by value). "How many are smaller than nums[i]" is then a prefix sum over the ranks below it. Coordinate compression — replacing values by their rank among the distinct sorted values — is needed because values can be negative or up to 109, and a BIT index must be a small positive integer. Using rank − 1 excludes equal values, so duplicates are not counted as smaller. Time O(n log n) (sort, then n binary searches and BIT operations), space O(n). The alternative is a modified merge sort that counts how many right-half elements jump ahead of each left-half element — same complexity. The same BIT pattern counts inversions in an array.

Pitfalls

  • Fenwick trees are 1-indexed. Calling add(0, x) loops forever, because 0 & -0 is 0.
  • Segment tree array too small: use 4 * n, not 2 * n, for the recursive version.
  • Returning the wrong neutral value on "no overlap" — 0 for sum, Integer.MAX_VALUE for min, Integer.MIN_VALUE for max.
  • Overflow: sums of many ints can pass 231 − 1. Use long[] when values or counts are large.
  • In lazy propagation, forgetting to push before going down in a query as well as in an update.
  • Reaching for these when the array never changes — a prefix sum array is simpler and O(1) per query.
Part 4 — Algorithms

19. Sorting

In plain words

Sorting means putting items in order (smallest to largest, A to Z, oldest to newest). Almost every interview problem gets easier once the data is sorted: duplicates sit next to each other, you can binary search, you can use two pointers, and overlapping intervals line up.

There are two big families. Comparison sorts only ever ask "is a smaller than b?" — they can never beat O(n log n) in the worst case. Non-comparison sorts (counting, radix, bucket) look at the value itself (for example, its digits) and can run in linear time, but only for small whole numbers or evenly spread data.

Sorting a hand of playing cards: you pick up one card at a time and slide it into the right spot among the cards you already hold. That is insertion sort, and it is exactly what Java does for very small arrays.

Two words you must know. A sort is stable if equal items keep their original relative order (two people both aged 30 stay in the order they came in). A sort is in-place if it only uses O(1) (or O(log n) stack) extra memory instead of a second array.

All sorts at a glance

SortBestAverageWorstExtra spaceStableIn-placeUse it when
BubbleO(n)*O(n²)O(n²)O(1)YesYesNever in practice; teaching only
SelectionO(n²)O(n²)O(n²)O(1)NoYesWrites are very expensive (at most n swaps)
InsertionO(n)O(n²)O(n²)O(1)YesYesTiny arrays (under ~20) or almost-sorted data
MergeO(n log n)O(n log n)O(n log n)O(n)YesNoNeed stability or a guaranteed bound; linked lists; external sorting
QuickO(n log n)O(n log n)O(n²)O(log n) stackNoYesFastest general in-memory sort in practice
HeapO(n log n)O(n log n)O(n log n)O(1)NoYesGuaranteed n log n with no extra memory
CountingO(n + k)O(n + k)O(n + k)O(n + k)YesNoIntegers in a small range 0..k (ages, grades, chars)
RadixO(d(n + b))O(d(n + b))O(d(n + b))O(n + b)YesNoFixed-width keys: ints, phone numbers, strings of equal length
BucketO(n + k)O(n + k)O(n²)O(n + k)Yes**NoFloats spread evenly over a known range

* Bubble sort is O(n) only with the "stop if no swaps this pass" check. ** Bucket sort is stable if the sort used inside each bucket is stable. k = range of values, d = number of digits, b = base (10 or 256).

Insertion sort — the card-hand sort

Everything left of i is already sorted. Take a[i] out, shift every bigger item one step right, and drop it into the gap. On a nearly sorted array the inner loop barely runs, which is why it is O(n) in the best case and why real libraries use it for small pieces.

// Time O(n²) worst / O(n) best, Space O(1), stable
static void insertionSort(int[] a) {
    for (int i = 1; i < a.length; i++) {
        int key = a[i];
        int j = i - 1;
        while (j >= 0 && a[j] > key) {   // shift bigger items one step right
            a[j + 1] = a[j];
            j--;
        }
        a[j + 1] = key;                   // drop key into the gap
    }
}
// {5, 2, 4, 6, 1, 3}  ->  [1, 2, 3, 4, 5, 6]

Using a[j] > key (strictly greater) is what keeps it stable: an equal item never jumps over another equal item.

Merge sort — split in half, sort each half, merge

Keep cutting the array in half until each piece has one element (a single element is sorted by definition). Then merge pairs of sorted pieces: look at the front of both, take the smaller, repeat. Merging two sorted lists of total size n takes O(n), and there are log n levels of halving, so the total is O(n log n) — always, whatever the input.

split split base case merge merge 5 2 4 1 5 2 4 1 5 2 4 1 2 5 1 4 1 2 4 5 compare fronts: 2 vs 1, take 1 ...
Going down, nothing is compared — the work happens on the way back up, when each pair of sorted halves is merged.
// Time O(n log n) always, Space O(n), stable
static void mergeSort(int[] a) {
    if (a.length < 2) return;
    int[] tmp = new int[a.length];        // one buffer, reused by every merge
    sort(a, tmp, 0, a.length - 1);
}

private static void sort(int[] a, int[] tmp, int lo, int hi) {
    if (lo >= hi) return;                 // 0 or 1 element: already sorted
    int mid = lo + (hi - lo) / 2;
    sort(a, tmp, lo, mid);
    sort(a, tmp, mid + 1, hi);
    if (a[mid] <= a[mid + 1]) return;     // halves already in order: skip merge
    merge(a, tmp, lo, mid, hi);
}

private static void merge(int[] a, int[] tmp, int lo, int mid, int hi) {
    int i = lo, j = mid + 1, k = lo;
    while (i <= mid && j <= hi) {
        // "<=" takes from the LEFT on ties: this is what makes it stable
        tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= hi)  tmp[k++] = a[j++];
    System.arraycopy(tmp, lo, a, lo, hi - lo + 1);
}
// {38, 27, 43, 3, 9, 82, 10}  ->  [3, 9, 10, 27, 38, 43, 82]

Quicksort — pick a pivot, partition, recurse

Pick one element, the pivot. Partition the array so everything smaller is on its left and everything else on its right. Now the pivot sits in its final sorted position forever. Recurse on the two sides. No merge step is needed — the work is done before the recursion, not after.

If the pivot is always the smallest or biggest element (for example, always taking the last element of an already-sorted array), one side is empty every time and you get O(n²). A random pivot makes that astronomically unlikely, giving expected O(n log n) on any input.

Lomuto partition of [7, 2, 1, 6, 8, 5, 3, 4], pivot = 4 (last element) i marks where the next "small" item goes; a[lo..i-1] are all < pivot j=0 7 < 4? no [7, 2, 1, 6, 8, 5, 3 | 4] i=0 j=1 2 < 4? yes swap a[0],a[1] [2, 7, 1, 6, 8, 5, 3 | 4] i=1 j=2 1 < 4? yes swap a[1],a[2] [2, 1, 7, 6, 8, 5, 3 | 4] i=2 j=3 6, j=4 8, j=5 5: no j=6 3 < 4? yes swap a[2],a[6] [2, 1, 3, 6, 8, 5, 7 | 4] i=3 end: swap a[3] with pivot [2, 1, 3, 4, 8, 5, 7, 6] return 3 \_ < 4 _/ \__ >= 4 __/
// Time: expected O(n log n), worst O(n²); Space O(log n) expected stack; not stable
static void quickSort(int[] a) { quickSort(a, 0, a.length - 1); }

private static void quickSort(int[] a, int lo, int hi) {
    if (lo >= hi) return;
    int p = partition(a, lo, hi);         // a[p] is now in its final place
    quickSort(a, lo, p - 1);
    quickSort(a, p + 1, hi);
}

// Lomuto: returns final index of the pivot
static int partition(int[] a, int lo, int hi) {
    int r = ThreadLocalRandom.current().nextInt(lo, hi + 1);
    swap(a, r, hi);                       // move random pivot to the end
    int pivot = a[hi];
    int i = lo;                           // a[lo..i-1] are all < pivot
    for (int j = lo; j < hi; j++) {
        if (a[j] < pivot) swap(a, i++, j);
    }
    swap(a, i, hi);                       // pivot goes right after the "< pivot" block
    return i;
}

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

Lomuto vs Hoare: Lomuto (above) is easier to write correctly. Hoare's scheme moves two pointers inward from both ends and swaps out-of-place pairs; it does about three times fewer swaps and handles many equal keys better, but the pivot does not end at the returned index, so the recursion is (lo, p) and (p + 1, hi). With lots of duplicates, Lomuto degrades toward O(n²); the fix is a 3-way partition (< pivot, == pivot, > pivot) — the same idea as the Dutch flag problem below.

Counting sort — tally, then place

When values are small whole numbers in 0..k, don't compare at all. Count how many times each value appears, turn counts into end positions with a running sum, then place each item. Walking the input backwards in the last step keeps equal items in their original order, which is what radix sort depends on.

// Time O(n + k), Space O(n + k), stable. Values must be in [0, k].
static int[] countingSort(int[] a, int k) {
    int[] count = new int[k + 1];
    for (int x : a) count[x]++;                              // 1. tally
    for (int v = 1; v <= k; v++) count[v] += count[v - 1];   // 2. prefix sums = end positions
    int[] out = new int[a.length];
    for (int i = a.length - 1; i >= 0; i--) {                // 3. walk backwards = stable
        out[--count[a[i]]] = a[i];
    }
    return out;
}
// {4, 2, 2, 8, 3, 3, 1}, k = 8  ->  [1, 2, 2, 3, 3, 4, 8]

Radix sort runs a stable counting sort once per digit, least significant digit first (base 10 or 256): O(d(n + b)). Bucket sort drops values into n buckets by range, sorts each small bucket, then concatenates — O(n) on average if data is evenly spread, O(n²) if everything lands in one bucket.

What Java's Arrays.sort actually does

CallAlgorithmGuarantees
Arrays.sort(int[]) (any primitive array)Dual-pivot quicksort (two pivots, three parts); insertion sort for tiny ranges; modern JDKs fall back to heap sort if partitioning goes badlyO(n log n), in-place, not stable
Arrays.sort(T[]), Arrays.sort(T[], cmp), Collections.sort, List.sortTimSort — finds already-sorted "runs", extends short ones with insertion sort, then merges runs like merge sortO(n log n) worst, O(n) on sorted input, O(n) extra memory, stable
Arrays.parallelSortParallel merge sort on the fork/join poolOnly faster for big arrays (roughly 10k+ elements)

Why two different algorithms? Stability. For primitives, two equal ints are indistinguishable, so stability is meaningless and Java picks the fastest in-place option: quicksort. For objects, two "equal" items can differ (two employees with the same salary but different names). If you sort by name and then by salary, you expect the names to stay in order within each salary — that needs a stable sort. TimSort is also great at real-world data, which is often partly sorted, and it uses few comparisons, which matters when each comparison is a (slow) compareTo call.

Sorting with comparators

A Comparator<T> returns a negative number if a comes first, 0 if equal, positive if b comes first. Primitive arrays cannot take a comparator — use Integer[] or sort int[][] rows.

record Person(String name, int age) {}

List<Person> people = new ArrayList<>(List.of(
    new Person("Asha", 30), new Person("Ben", 25), new Person("Cara", 30), new Person("Dev", 25)));

// age descending, then name ascending
people.sort(Comparator.comparingInt(Person::age).reversed().thenComparing(Person::name));
// [Asha 30, Cara 30, Ben 25, Dev 25]

Integer[] boxed = {5, 1, 4};
Arrays.sort(boxed, Collections.reverseOrder());          // [5, 4, 1]

int[][] pts = {{3, 1}, {1, 2}, {1, 1}};                  // by x, then by y
Arrays.sort(pts, (p, q) -> p[0] != q[0] ? Integer.compare(p[0], q[0]) : Integer.compare(p[1], q[1]));
// [[1, 1], [1, 2], [3, 1]]
Never write (a, b) -> a - b. Subtraction overflows: comparing Integer.MIN_VALUE with 1 gives 2147483647 (positive, so "MIN_VALUE is bigger" — wrong). Always use Integer.compare(a, b) or Comparator.comparingInt.

Quickselect — the k-th smallest without sorting everything

Partition once, exactly like quicksort. The pivot lands at index p. If p == k, done. Otherwise, the answer is only on one side, so recurse into that side only. Expected work is n + n/2 + n/4 + ... = 2n, which is O(n).

// k-th smallest, k is 0-based. Time: expected O(n), worst O(n²). Space O(1).
static int quickSelect(int[] a, int k) {
    int lo = 0, hi = a.length - 1;
    while (lo < hi) {
        int p = partition(a, lo, hi);     // same random-pivot Lomuto as above
        if (p == k) return a[p];
        if (p < k) lo = p + 1; else hi = p - 1;
    }
    return a[lo];
}

// LeetCode 215: k-th LARGEST
static int findKthLargest(int[] nums, int k) {
    return quickSelect(nums, nums.length - k);   // k-th largest = (n-k)-th smallest
}
// {3, 2, 1, 5, 6, 4}, k = 2  ->  5

Alternative: a min-heap of size k gives O(n log k) time with a guaranteed bound and works on a stream. Quickselect is faster on average but changes the array.

Problem: Sort Colors (Dutch national flag)

Sort an array of 0s, 1s and 2s in one pass, in place. Keep three regions: [0, low) are 0s, [low, mid) are 1s, (high, end] are 2s; [mid, high] is unknown.

// Time O(n), one pass. Space O(1).
static void sortColors(int[] nums) {
    int low = 0, mid = 0, high = nums.length - 1;
    while (mid <= high) {
        if (nums[mid] == 0)      swap(nums, low++, mid++);
        else if (nums[mid] == 1) mid++;
        else                     swap(nums, mid, high--);   // don't move mid: new value unchecked
    }
}
// {2, 0, 2, 1, 1, 0}  ->  [0, 0, 1, 1, 2, 2]

Why this works: each step shrinks the unknown region by one while keeping all three regions correct. When swapping with high, the value that arrives at mid has not been looked at yet, so mid must stay put. When swapping with low, the value arriving is already known to be a 1 (it came from the 1s region), so both pointers advance.

Problem: Merge Intervals (preview)

// Time O(n log n) for the sort, Space O(n) for the output
static int[][] mergeIntervals(int[][] intervals) {
    Arrays.sort(intervals, (x, y) -> Integer.compare(x[0], y[0]));
    List<int[]> out = new ArrayList<>();
    for (int[] cur : intervals) {
        if (out.isEmpty() || out.get(out.size() - 1)[1] < cur[0]) {
            out.add(cur);                                 // gap: start a new interval
        } else {
            int[] last = out.get(out.size() - 1);
            last[1] = Math.max(last[1], cur[1]);          // overlap: stretch the last one
        }
    }
    return out.toArray(new int[0][]);
}
// {{1,3},{8,10},{2,6},{15,18}}  ->  [[1, 6], [8, 10], [15, 18]]

Why this works: after sorting by start, any interval that overlaps the current merged block must come right after it — nothing further down the list can start earlier. So one left-to-right sweep is enough.

Problem: Largest Number

Arrange numbers to form the largest possible number: [3, 30, 34, 5, 9] becomes "9534330". Normal numeric order fails (30 > 3, but "330" > "303"). The trick is a custom comparator: put a before b if the string a + b is bigger than b + a.

// Time O(n log n * L) where L = max digits, Space O(n * L)
static String largestNumber(int[] nums) {
    String[] s = new String[nums.length];
    for (int i = 0; i < nums.length; i++) s[i] = String.valueOf(nums[i]);
    // a goes before b if a+b is bigger than b+a
    Arrays.sort(s, (a, b) -> (b + a).compareTo(a + b));
    if (s[0].equals("0")) return "0";                     // all zeros
    return String.join("", s);
}
// {3, 30, 34, 5, 9}  ->  "9534330";   {0, 0}  ->  "0"

Why this works: this "concatenation order" is transitive (a proven property), so it is a valid total order and sorting by it puts every adjacent pair in the best order — and if no adjacent swap improves the result, the whole thing is optimal. Don't forget the all-zeros edge case.

Problem: Count Inversions (via merge sort)

An inversion is a pair i < j with a[i] > a[j]. It measures "how unsorted" an array is. Brute force is O(n²). During the merge step, when we take an element from the right half, it is smaller than every element still waiting in the left half — that is mid - i + 1 inversions counted in one go.

// Time O(n log n), Space O(n). Returns long: n = 100k can give ~5 * 10^9 inversions.
static long countInversions(int[] a) {
    return countSort(a.clone(), new int[a.length], 0, a.length - 1);
}

private static long countSort(int[] a, int[] tmp, int lo, int hi) {
    if (lo >= hi) return 0;
    int mid = lo + (hi - lo) / 2;
    long count = countSort(a, tmp, lo, mid) + countSort(a, tmp, mid + 1, hi);
    int i = lo, j = mid + 1, k = lo;
    while (i <= mid && j <= hi) {
        if (a[i] <= a[j]) {
            tmp[k++] = a[i++];
        } else {
            count += mid - i + 1;     // a[i..mid] are ALL bigger than a[j]
            tmp[k++] = a[j++];
        }
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= hi)  tmp[k++] = a[j++];
    System.arraycopy(tmp, lo, a, lo, hi - lo + 1);
    return count;
}
// {8, 4, 2, 1}  ->  6;    {2, 4, 1, 3, 5}  ->  3

Why this works: every inversion is either inside the left half, inside the right half (both counted by recursion), or split across the halves. Because both halves are sorted at merge time, split inversions can be counted in bulk instead of pair by pair.

Pitfalls: mid = (lo + hi) / 2 can overflow for huge indexes — use lo + (hi - lo) / 2. Use <= in the merge to stay stable (and, for inversions, to not count equal pairs). A fixed first/last pivot turns quicksort into O(n²) on sorted input. Sorting an int[] in descending order needs boxing to Integer[] or sorting then reversing.

20. Binary Search

In plain words

Binary search finds something in sorted data by looking at the middle, deciding which half the answer must be in, and throwing the other half away. Each step halves the search space, so a million items take only about 20 steps: O(log n).

The real skill is not "find a number in a sorted array". It is spotting any problem where the answer space splits into a "no" part followed by a "yes" part (F F F T T T) and finding the boundary.

Guessing a number between 1 and 100 when someone says "higher" or "lower". You always guess the middle; you never need more than 7 guesses.

The invariant — what makes binary search correct

An invariant is a statement that is true before every loop iteration. For binary search it is: "if the answer exists, it is inside the range [lo, hi)." Every update must keep that true, and every iteration must make the range strictly smaller. If both hold, the loop ends with lo == hi pointing at the answer. Nearly every binary search bug is a violation of one of those two rules (dropping the answer, or a range that stops shrinking and loops forever).

The one template to remember: lower bound

"Find the first index where a condition becomes true." For a sorted array and the condition a[i] >= target, that index is called the lower bound. Use a half-open range [lo, hi) with hi = a.length, because the answer may be "past the end" (every element is smaller).

// First index where a[i] >= target (a.length if none). Time O(log n), Space O(1)
static int lowerBound(int[] a, int target) {
    int lo = 0, hi = a.length;           // half-open [lo, hi): answer may be a.length
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] >= target) hi = mid;  // mid could be the answer: keep it
        else lo = mid + 1;               // mid is too small: throw it away
    }
    return lo;                           // lo == hi == the boundary
}

// First index where a[i] > target. Same shape, only the condition changes.
static int upperBound(int[] a, int target) {
    int lo = 0, hi = a.length;
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] > target) hi = mid;
        else lo = mid + 1;
    }
    return lo;
}
// a = {1, 3, 3, 3, 5, 8, 9}
// lowerBound(a, 3) = 1, upperBound(a, 3) = 4, lowerBound(a, 4) = 4, lowerBound(a, 10) = 7

Why mid = lo + (hi - lo) / 2 and not (lo + hi) / 2? If lo = 2,000,000,000 and hi = 2,100,000,000, their sum is bigger than Integer.MAX_VALUE and wraps to a negative number: (lo + hi) / 2 gives -97483648. The subtraction form gives 2050000000. (lo + hi) >>> 1 (unsigned shift) also works.

Why does this loop always end? mid rounds down, so mid < hi always. Setting hi = mid shrinks the range, and lo = mid + 1 shrinks it too. No infinite loop is possible.

0 1 2 3 4 5 6 step 1 1 3 3 3 5 8 9 lo=0 hi=7 mid=3 3 >= 3: hi = mid step 2 1 3 3 discarded lo=0 hi=3 mid=1 3 >= 3: hi = mid step 3 1 lo=0 hi=1 mid=0 1 < 3: lo = mid + 1 done lo == hi == 1: first 3 is at index 1 3 steps for 7 items
Lower bound for target 3. Finding a 3 at index 3 is not enough — we keep it as a candidate (hi = mid) and keep looking left for an earlier one.

Variants built from the same template

QuestionAnswer
Does target exist?i = lowerBound(a, t); exists if i < n && a[i] == t
First occurrencelowerBound(a, t) (then check it equals t)
Last occurrenceupperBound(a, t) - 1 (then check)
Count of tupperBound(a, t) - lowerBound(a, t)
Insert position (LeetCode 35)lowerBound(a, t)
Largest element <= t (floor)upperBound(a, t) - 1 (-1 means none)
Smallest element >= t (ceiling)lowerBound(a, t) (n means none)

Java has built-ins: Arrays.binarySearch(a, key) returns some matching index (not necessarily the first!) or -(insertionPoint) - 1 if missing. TreeMap/TreeSet give floor, ceiling, lower, higher in O(log n).

Problem: First and Last Position (LeetCode 34)

// Time O(log n), Space O(1)
static int[] searchRange(int[] a, int target) {
    int first = lowerBound(a, target);
    if (first == a.length || a[first] != target) return new int[]{-1, -1};
    int last = upperBound(a, target) - 1;
    return new int[]{first, last};
}
// {5, 7, 7, 8, 8, 10}, 8  ->  [3, 4];   target 6  ->  [-1, -1]

Why this works: the first 8 is the first index with value >= 8; the last 8 is just before the first index with value > 8. Two O(log n) searches, never a linear scan over the duplicates (which would be O(n) if the whole array were 8s).

Problem: Search in Rotated Sorted Array (LeetCode 33)

[4, 5, 6, 7, 0, 1, 2] is a sorted array rotated at some point. Cut it at mid: at least one half is always normally sorted. Check if the target lies inside that sorted half's range; if yes go there, otherwise go to the other half.

// Time O(log n), Space O(1). Assumes distinct values.
static int searchRotated(int[] a, int target) {
    int lo = 0, hi = a.length - 1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] == target) return mid;
        if (a[lo] <= a[mid]) {                        // left half [lo..mid] is sorted
            if (a[lo] <= target && target < a[mid]) hi = mid - 1;
            else lo = mid + 1;
        } else {                                      // right half [mid..hi] is sorted
            if (a[mid] < target && target <= a[hi]) lo = mid + 1;
            else hi = mid - 1;
        }
    }
    return -1;
}
// {4, 5, 6, 7, 0, 1, 2}, 0  ->  4;   target 3  ->  -1

Why this works: in the sorted half, a simple range check tells us for sure whether the target can be there. If it can't, it can only be in the other half. Note a[lo] <= a[mid] uses <= because when lo == mid the "left half" is a single element, which is sorted. Related: find the minimum of a rotated array — if a[mid] > a[hi] the drop is to the right (lo = mid + 1), else hi = mid.

// Time O(log n). {4, 5, 6, 7, 0, 1, 2}  ->  0
static int findMinRotated(int[] a) {
    int lo = 0, hi = a.length - 1;
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] > a[hi]) lo = mid + 1;   // min is strictly right of mid
        else hi = mid;                      // mid might be the min
    }
    return a[lo];
}

Problem: Find Peak Element (LeetCode 162)

The array is not sorted, yet binary search works. A peak is bigger than its neighbours (edges count as minus infinity). If a[mid] < a[mid + 1], we're walking uphill to the right, so a peak must exist on the right (either it keeps rising to the edge, or it turns down somewhere).

// Time O(log n), Space O(1)
static int findPeakElement(int[] a) {
    int lo = 0, hi = a.length - 1;
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] < a[mid + 1]) lo = mid + 1;  // uphill to the right: a peak is there
        else hi = mid;                          // downhill: mid or something left is a peak
    }
    return lo;
}
// {1, 2, 3, 1}  ->  2;    {1, 2, 1, 3, 5, 6, 4}  ->  5

Why this works: the invariant is "a peak exists in [lo, hi]". Binary search needs a rule that tells you which half keeps the answer — not necessarily a sorted array. mid + 1 is safe because lo < hi means mid < hi.

Binary search on the answer

Many "minimise the maximum" or "find the smallest X such that..." problems don't search an array at all. They search the range of possible answers. You need two things: a range [lo, hi] that surely contains the answer, and a feasible(x) check that is monotonic — once it becomes true, it stays true for all bigger x. Then find the first true.

// Smallest x in [lo, hi] with feasible(x) == true. feasible must look like F F F T T T.
// Time O(log(hi - lo) * cost of feasible)
static int firstTrue(int lo, int hi, IntPredicate feasible) {
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (feasible.test(mid)) hi = mid;   // mid works: answer is mid or smaller
        else lo = mid + 1;                  // mid fails: answer is bigger
    }
    return lo;
}

Problem: Koko Eating Bananas (LeetCode 875)

Koko eats at speed k bananas per hour, one pile at a time; a pile of p takes ceil(p / k) hours. Find the slowest k that finishes within h hours. Faster is always at least as good — monotonic.

// Time O(n log M) where M = max pile, Space O(1)
static int minEatingSpeed(int[] piles, int h) {
    int lo = 1, hi = Arrays.stream(piles).max().getAsInt();
    while (lo < hi) {
        int k = lo + (hi - lo) / 2;
        if (hoursNeeded(piles, k) <= h) hi = k;  // k works: try slower
        else lo = k + 1;                         // too slow: must go faster
    }
    return lo;
}

static long hoursNeeded(int[] piles, int k) {
    long hours = 0;
    for (int p : piles) hours += (p + k - 1) / k;   // ceil(p / k) without doubles
    return hours;
}
// {3, 6, 7, 11}, h = 8  ->  4;    {30, 11, 23, 4, 20}, h = 5  ->  30

Problem: Capacity to Ship Packages Within D Days (LeetCode 1011)

// Time O(n log(sum)), Space O(1)
static int shipWithinDays(int[] weights, int days) {
    int lo = 0, hi = 0;
    for (int w : weights) { lo = Math.max(lo, w); hi += w; }   // must fit the heaviest; one day ships all
    while (lo < hi) {
        int cap = lo + (hi - lo) / 2;
        if (daysNeeded(weights, cap) <= days) hi = cap;
        else lo = cap + 1;
    }
    return lo;
}

static int daysNeeded(int[] weights, int cap) {
    int days = 1, load = 0;
    for (int w : weights) {
        if (load + w > cap) { days++; load = 0; }   // start a new day
        load += w;
    }
    return days;
}
// {1..10}, days = 5  ->  15

Problem: Split Array Largest Sum (LeetCode 410)

Split into k contiguous parts to minimise the largest part's sum. Same shape as shipping: "can I split so every part is <= X using at most k parts?" is monotonic in X, and the greedy check (fill a part until it would overflow) is optimal for a fixed X.

// Time O(n log(sum)), Space O(1). long guards against sum overflow.
static int splitArray(int[] nums, int k) {
    long lo = 0, hi = 0;
    for (int x : nums) { lo = Math.max(lo, x); hi += x; }
    while (lo < hi) {
        long mid = lo + (hi - lo) / 2;
        if (piecesNeeded(nums, mid) <= k) hi = mid;
        else lo = mid + 1;
    }
    return (int) lo;
}

static int piecesNeeded(int[] nums, long maxSum) {
    int pieces = 1; long sum = 0;
    for (int x : nums) {
        if (sum + x > maxSum) { pieces++; sum = 0; }
        sum += x;
    }
    return pieces;
}
// {7, 2, 5, 10, 8}, k = 2  ->  18   ([7,2,5] and [10,8])

Why these work: the answer lies between "the biggest single item" (no smaller value can ever be feasible) and "the total" (always feasible). The check is monotonic, so binary search finds the exact boundary in about 30–40 checks instead of trying every value.

Problem: Sqrt(x) (LeetCode 69)

// Integer square root, rounded down. Time O(log x), Space O(1)
static int mySqrt(int x) {
    long lo = 0, hi = (long) x + 1;          // search the first m with m*m > x
    while (lo < hi) {
        long mid = lo + (hi - lo) / 2;
        if (mid * mid > x) hi = mid;         // long: mid*mid never overflows
        else lo = mid + 1;
    }
    return (int) (lo - 1);                   // last m with m*m <= x
}
// 8 -> 2,  16 -> 4,  0 -> 0,  Integer.MAX_VALUE -> 46340

Why this works: "m*m > x" is F F F T T T over m, so the first true minus one is the floor of the square root. Using int for mid * mid would overflow for x near 2^31.

Common bugs

BugSymptomFix
(lo + hi) / 2Negative index on huge rangeslo + (hi - lo) / 2
lo = mid with rounding-down midInfinite loop when hi = lo + 1Use lo = mid + 1, or round mid up: lo + (hi - lo + 1) / 2
Mixing styles: while (lo <= hi) with hi = midInfinite loopClosed [lo, hi] pairs with hi = mid - 1; half-open [lo, hi) pairs with hi = mid
Wrong initial hiMisses "insert at end" or the largest answerAsk: can the answer be n? Can it be the max value?
Non-monotonic predicateRandom wrong answersWrite out F/T for a small example before coding
int overflow in feasibility sumsWrong answer on big inputsAccumulate in long
Say this in the interview: "I'll find the first value where the condition becomes true. The invariant is that the answer stays in [lo, hi); each step either keeps mid as a candidate or discards it, so the range always shrinks and the loop ends at the boundary." That sentence covers correctness and termination.

21. Recursion & Backtracking

In plain words

Backtracking is a way to try every possible option in an organised way. You build a solution one step at a time. At each step you choose an option, explore everything that follows from it (by calling yourself recursively), then un-choose it and try the next option. If a partial solution already breaks a rule, you stop exploring that branch early — that is pruning.

Use it when the problem says "return all ..." (all subsets, all permutations, all valid boards) or "does any arrangement exist?" and the input is small (n up to about 10–20).

Walking through a maze with a piece of chalk. At every fork you pick a path and mark it. At a dead end, you walk back to the last fork, rub out the mark, and try the next path.

Choose, explore, un-choose

Picture every run of a backtracking function as a decision tree. The root is the empty solution. Each edge is one choice. Each leaf is either a complete answer or a dead end. The recursion does a depth-first walk of this tree, and the call stack is the current path from the root. Because one shared list (path) is reused for every branch, you must undo each change after the recursive call returns — otherwise the next branch starts with garbage in it.

[ ] [1] [2] [3] [1,2] [1,3] [2,3] [1,2,3] +1 +2 +3 +2 +3 +3 +3 un-choose 3, back to [1,2] start index stops re-use: [2] never adds 1 again, so [2,1] is never built Every node is recorded as a subset: 8 nodes = 2^3 subsets
The highlighted path is the current call stack. After [1,2,3] is recorded, the code removes 3, returns to [1,2], finds no more options, removes 2, and moves on to [1,3].

The generic template

// Shape of every backtracking solution. Fill in the four helpers per problem.
static void backtrack(List<Integer> path, int start) {
    if (isComplete(path)) {                 // 1. goal reached: record a COPY
        result.add(new ArrayList<>(path));
        return;
    }
    for (int choice : choices(start)) {     // 2. try every option at this step
        if (!isValid(path, choice)) continue;   //    prune dead branches early
        path.add(choice);                   // 3. choose
        backtrack(path, choice + 1);        // 4. explore
        path.remove(path.size() - 1);       // 5. un-choose (undo exactly step 3)
    }
}

Three questions design any backtracking solution: What is the state? (the path plus an index or a used[] array). What are the choices at each step? When is it complete, and when is it hopeless?

The classic bug: result.add(path) instead of result.add(new ArrayList<>(path)). All entries point to the same list, which ends up empty after all the un-chooses, so you return a list of empty lists. Always add a copy.

Pruning — cutting branches early

Pruning does not change the worst-case Big-O on paper, but in practice it is the difference between "instant" and "timeout". Typical prunes: sort the input and break once a candidate is too big (combination sum); check constraints the moment you place something instead of at the end (N-Queens); skip duplicates with if (i > start && nums[i] == nums[i - 1]) continue; after sorting (Subsets II, Combination Sum II); stop if there aren't enough items left to finish.

Problem: Subsets (LeetCode 78)

// Time O(n * 2^n): 2^n subsets, each copied in O(n). Space O(n) recursion (excluding output)
static List<List<Integer>> subsets(int[] nums) {
    List<List<Integer>> res = new ArrayList<>();
    subsetsBt(nums, 0, new ArrayList<>(), res);
    return res;
}

static void subsetsBt(int[] nums, int start, List<Integer> path, List<List<Integer>> res) {
    res.add(new ArrayList<>(path));              // every node of the tree is a subset
    for (int i = start; i < nums.length; i++) {
        path.add(nums[i]);                       // choose
        subsetsBt(nums, i + 1, path, res);       // explore (only later elements)
        path.remove(path.size() - 1);            // un-choose
    }
}
// {1, 2, 3}  ->  [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]

Why this works: the start index means we only ever add elements to the right of the last one picked, so each subset is built in exactly one order and appears exactly once. Alternative view: for each element, "take it or skip it" — two choices, n times, 2^n leaves.

Problem: Permutations (LeetCode 46)

// Time O(n * n!): n! leaves, each copied in O(n). Space O(n)
static List<List<Integer>> permute(int[] nums) {
    List<List<Integer>> res = new ArrayList<>();
    permBt(nums, new boolean[nums.length], new ArrayList<>(), res);
    return res;
}

static void permBt(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> res) {
    if (path.size() == nums.length) {            // a full arrangement
        res.add(new ArrayList<>(path));
        return;
    }
    for (int i = 0; i < nums.length; i++) {
        if (used[i]) continue;
        used[i] = true;  path.add(nums[i]);      // choose
        permBt(nums, used, path, res);           // explore
        path.remove(path.size() - 1); used[i] = false;   // un-choose
    }
}
// {1, 2, 3}  ->  [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]

Why this works: order matters for permutations, so every step may pick any unused element (loop from 0, not from start). The used[] array is part of the state, so it must be un-chosen too. Subsets vs permutations in one line: subsets loop from start, permutations loop from 0 with a used check.

Problem: Combination Sum (LeetCode 39)

// Each candidate may be used many times.
// Time O(N^(T/M + 1)) worst case (N candidates, T target, M smallest candidate). Space O(T/M) depth
static List<List<Integer>> combinationSum(int[] candidates, int target) {
    Arrays.sort(candidates);                     // sorted lets us break early
    List<List<Integer>> res = new ArrayList<>();
    combBt(candidates, target, 0, new ArrayList<>(), res);
    return res;
}

static void combBt(int[] c, int remain, int start, List<Integer> path, List<List<Integer>> res) {
    if (remain == 0) { res.add(new ArrayList<>(path)); return; }
    for (int i = start; i < c.length; i++) {
        if (c[i] > remain) break;                // PRUNE: rest are even bigger
        path.add(c[i]);
        combBt(c, remain - c[i], i, path, res);  // i, not i+1: same number may repeat
        path.remove(path.size() - 1);
    }
}
// {2, 3, 6, 7}, 7  ->  [[2, 2, 3], [7]];    {2, 3, 5}, 8  ->  [[2, 2, 2, 2], [2, 3, 3], [3, 5]]

Why this works: passing i (not i + 1) lets a number be reused, while never going back to earlier numbers prevents duplicates like [3,2,2]. The sorted break kills entire subtrees at once.

Problem: N-Queens (LeetCode 51)

Place n queens on an n x n board so none attack each other. Put exactly one queen per row, so the only choice is the column. A square (r, c) is attacked if its column, its "/" diagonal (r + c is the same along it) or its "\" diagonal (r - c is the same) is taken. Three boolean arrays make the check O(1).

// Time O(n!) — row 0 has n options, row 1 at most n-1, ...  Space O(n)
static List<List<String>> solveNQueens(int n) {
    List<List<String>> res = new ArrayList<>();
    int[] queenCol = new int[n];                 // queenCol[r] = column of queen in row r
    boolean[] cols = new boolean[n];
    boolean[] diag = new boolean[2 * n - 1];     // r + c is constant on a "/" diagonal
    boolean[] anti = new boolean[2 * n - 1];     // r - c + n - 1 is constant on a "\" diagonal
    placeRow(0, n, queenCol, cols, diag, anti, res);
    return res;
}

static void placeRow(int r, int n, int[] queenCol, boolean[] cols, boolean[] diag,
                     boolean[] anti, List<List<String>> res) {
    if (r == n) { res.add(draw(queenCol)); return; }
    for (int c = 0; c < n; c++) {
        int d = r + c, a = r - c + n - 1;
        if (cols[c] || diag[d] || anti[a]) continue;      // attacked: prune
        queenCol[r] = c; cols[c] = diag[d] = anti[a] = true;
        placeRow(r + 1, n, queenCol, cols, diag, anti, res);
        cols[c] = diag[d] = anti[a] = false;              // un-choose
    }
}

static List<String> draw(int[] queenCol) {
    int n = queenCol.length;
    List<String> board = new ArrayList<>();
    for (int c : queenCol) {
        char[] row = new char[n];
        Arrays.fill(row, '.');
        row[c] = 'Q';
        board.add(new String(row));
    }
    return board;
}
// n = 4  ->  [[.Q.., ...Q, Q..., ..Q.], [..Q., Q..., ...Q, .Q..]]
// solution counts for n = 1..8: 1 0 0 2 10 4 40 92

Why this works: one-queen-per-row is built into the structure, so row conflicts are impossible. Columns and diagonals are checked on placement, so every complete board reached is valid. queenCol[r] doesn't need un-choosing because it is overwritten on the next try.

Problem: Word Search (LeetCode 79)

// Time O(R * C * 3^L): every start cell, then at most 3 new directions per letter (L = word length)
// Space O(L) recursion
static boolean exist(char[][] board, String word) {
    for (int r = 0; r < board.length; r++)
        for (int c = 0; c < board[0].length; c++)
            if (dfs(board, word, r, c, 0)) return true;
    return false;
}

static boolean dfs(char[][] b, String word, int r, int c, int i) {
    if (i == word.length()) return true;
    if (r < 0 || c < 0 || r >= b.length || c >= b[0].length || b[r][c] != word.charAt(i))
        return false;
    char saved = b[r][c];
    b[r][c] = '#';                               // choose: mark as visited
    boolean found = dfs(b, word, r + 1, c, i + 1) || dfs(b, word, r - 1, c, i + 1)
                 || dfs(b, word, r, c + 1, i + 1) || dfs(b, word, r, c - 1, i + 1);
    b[r][c] = saved;                             // un-choose: restore the cell
    return found;
}
// board ABCE / SFCS / ADEE:  "ABCCED" -> true, "SEE" -> true, "ABCB" -> false

Why this works: marking the cell in place (instead of a separate visited set) stops a path from reusing a letter, and restoring it lets other paths use that cell later. The || chain short-circuits, so the search stops the moment one path succeeds.

Problem: Palindrome Partitioning (LeetCode 131)

// Time O(n * 2^n): 2^(n-1) ways to cut, O(n) to check/copy each. Space O(n)
static List<List<String>> partition(String s) {
    List<List<String>> res = new ArrayList<>();
    partBt(s, 0, new ArrayList<>(), res);
    return res;
}

static void partBt(String s, int start, List<String> path, List<List<String>> res) {
    if (start == s.length()) { res.add(new ArrayList<>(path)); return; }
    for (int end = start; end < s.length(); end++) {
        if (!isPal(s, start, end)) continue;     // prune: only cut after a palindrome
        path.add(s.substring(start, end + 1));
        partBt(s, end + 1, path, res);
        path.remove(path.size() - 1);
    }
}

static boolean isPal(String s, int i, int j) {
    while (i < j) if (s.charAt(i++) != s.charAt(j--)) return false;
    return true;
}
// "aab"  ->  [[a, a, b], [aa, b]]

Why this works: the choice at each step is "where does the next piece end?". Only palindromic pieces are allowed, and when start reaches the end, the whole string is covered. Speed-up: precompute a boolean[][] isPal table with DP in O(n²) so each check is O(1).

Complexity cheat sheet

ProblemTree shapeTime
Subsetstake / skip each itemO(n * 2^n)
Permutationsn, then n-1, then n-2 choices...O(n * n!)
Combinations (n choose k)subsets of size kO(k * C(n, k))
Combination sumbranching N, depth T/MO(N^(T/M + 1))
N-Queensn rows, shrinking column optionsO(n!)
Word search4 then 3 directions per letterO(R * C * 3^L)
Palindrome partitioningcut or don't cut between lettersO(n * 2^n)
Backtracking vs DP: if the question asks to list all solutions, the output itself is exponential, so backtracking is the right tool. If it only asks to count them or find the best one, check for overlapping subproblems — memoization or DP usually turns it polynomial (for example, counting combination sums is the coin-change DP).

22. Greedy

In plain words

A greedy algorithm builds the answer step by step, and at every step takes the option that looks best right now — without ever going back to change an earlier decision. No trying all options, no table of sub-answers.

When it works, greedy is the simplest and fastest solution (often just "sort, then one pass", O(n log n)). The catch: it only works for problems with a special structure, and a greedy idea that "feels right" is often wrong. The hard part is being sure.

Giving change at a shop counter: hand over the biggest coin that fits, then the next biggest, and so on. With real-world coins (25, 10, 5, 1) that always gives the fewest coins. With strange coins it can fail — more on that below.

When greedy works

Two properties must hold:

  • Greedy choice property — there is always an optimal solution that starts with the greedy choice. In other words, taking the "locally best" option never rules out reaching the best overall answer.
  • Optimal substructure — after making that choice, what remains is a smaller version of the same problem, and solving it optimally gives an optimal overall answer.

The exchange argument (how you prove it, in easy words): take any optimal solution. If it doesn't use the greedy choice, swap one of its choices for the greedy one and show the result is still valid and no worse. Then an optimal solution with the greedy choice exists, so greedy is safe. Example for activity selection: an optimal schedule's first meeting ends at time E; the greedy first meeting ends at time e <= E (it's the earliest-ending one). Swap them — everything after still fits, because we finish even earlier. Same count, still optimal.

When greedy fails — the coin change counterexample

Coins {1, 3, 4}, amount 6 Greedy (biggest coin first): 4 + 1 + 1 = 3 coins Optimal: 3 + 3 = 2 coins With US coins {25, 10, 5, 1}, amount 63: greedy gives 25+25+10+1+1+1 = 6 coins = optimal. The greedy choice property holds for "canonical" coin systems, not for all of them.
// Greedy coin change: always take the biggest coin that fits. Time O(number of coins)
static int greedyCoins(int[] coinsDesc, int amount) {
    int count = 0;
    for (int c : coinsDesc) {
        count += amount / c;
        amount %= c;
    }
    return amount == 0 ? count : -1;
}
// greedyCoins({4, 3, 1}, 6) = 3, but the true minimum (coin-change DP) is 2

Lesson: before trusting greedy, try to break it with a tiny counterexample. If you find one, the problem probably needs DP (see the Dynamic Programming section). Same story for the 0/1 knapsack (greedy by value/weight fails) versus the fractional knapsack (greedy by value/weight is optimal, because you can take part of an item).

The usual recipe

  1. Decide what to sort by (end time? start time? size? ratio?). This is usually the whole insight.
  2. Sweep once, making the locally best decision and keeping a small amount of state.
  3. Sanity check with a counterexample hunt, then argue with an exchange argument.

Problem: Activity Selection / Non-overlapping Intervals (LeetCode 435)

Remove the fewest intervals so the rest don't overlap. Equivalent: keep the most non-overlapping intervals. Greedy rule: sort by end time and always keep the interval that finishes first — it leaves the most room for everything after.

Intervals [1,2] [2,3] [3,4] [1,3], sorted by end: [1,2] [2,3] [1,3] [3,4] [1,2] start 1 >= lastEnd -inf keep lastEnd = 2 [2,3] start 2 >= lastEnd 2 keep lastEnd = 3 [1,3] start 1 < lastEnd 3 drop [3,4] start 3 >= lastEnd 3 keep lastEnd = 4 kept 3 of 4 -> remove 1
// Time O(n log n), Space O(1) beyond the sort
static int eraseOverlapIntervals(int[][] intervals) {
    Arrays.sort(intervals, (a, b) -> Integer.compare(a[1], b[1]));   // by END time
    int kept = 0;
    int lastEnd = Integer.MIN_VALUE;
    for (int[] in : intervals) {
        if (in[0] >= lastEnd) {          // starts after the last kept one ends
            kept++;
            lastEnd = in[1];
        }
    }
    return intervals.length - kept;
}
// {{1,2},{2,3},{3,4},{1,3}} -> 1;   {{1,2},{1,2},{1,2}} -> 2

Why this works: the exchange argument above. Sorting by start time is the classic wrong answer: one long interval starting first would block many short ones.

Problem: Jump Game (LeetCode 55)

nums[i] is the maximum jump length from index i. Can you reach the last index? Track the farthest index reachable so far. If you ever stand on an index beyond it, you're stuck.

// Time O(n), Space O(1)
static boolean canJump(int[] nums) {
    int farthest = 0;
    for (int i = 0; i < nums.length; i++) {
        if (i > farthest) return false;           // we can't even stand here
        farthest = Math.max(farthest, i + nums[i]);
    }
    return true;
}
// {2, 3, 1, 1, 4} -> true;   {3, 2, 1, 0, 4} -> false (everything stops at index 3)

Why this works: every index up to farthest is reachable (you can always jump shorter than the max). So we don't need to know which jumps to take — only how far the reachable region extends.

Problem: Jump Game II (LeetCode 45) — minimum jumps

Think of it as BFS in levels without a queue: all indexes reachable with 1 jump form level 1, those with 2 jumps form level 2, and so on. curEnd is the end of the current level; farthest is how far the next level reaches.

nums = [2, 3, 1, 1, 4] i=0 farthest = 2 i == curEnd(0) -> jumps = 1, curEnd = 2 (level 1 = indexes 1..2) i=1 farthest = 4 i=2 farthest = 4 i == curEnd(2) -> jumps = 2, curEnd = 4 (level 2 reaches the end) i=3 farthest = 4 (loop stops before the last index) answer 2: 0 -> 1 -> 4
// Time O(n), Space O(1). Assumes the end is reachable.
static int jump(int[] nums) {
    int jumps = 0, curEnd = 0, farthest = 0;
    for (int i = 0; i < nums.length - 1; i++) {   // stop before last: no jump needed from it
        farthest = Math.max(farthest, i + nums[i]);
        if (i == curEnd) {                        // used up this jump's range
            jumps++;
            curEnd = farthest;                    // next "level" of reach
        }
    }
    return jumps;
}
// {2, 3, 1, 1, 4} -> 2;   {2, 3, 0, 1, 4} -> 2;   {0} -> 0

Pitfall: looping to nums.length instead of nums.length - 1 counts one extra jump when a level ends exactly on the last index.

Problem: Gas Station (LeetCode 134)

Circular route; gas[i] fuel at station i, cost[i] to drive to the next. Find the start index that completes the loop (unique if it exists). Two facts: (1) if total gas >= total cost, a solution exists; (2) if you start at s and run dry before reaching i + 1, then no station between s and i can be the start either — so jump the start straight to i + 1.

// Time O(n), Space O(1)
static int canCompleteCircuit(int[] gas, int[] cost) {
    int total = 0, tank = 0, start = 0;
    for (int i = 0; i < gas.length; i++) {
        int diff = gas[i] - cost[i];
        total += diff;
        tank += diff;
        if (tank < 0) {          // can't reach i+1 from start: no station in [start..i] works
            start = i + 1;
            tank = 0;
        }
    }
    return total >= 0 ? start : -1;
}
// gas {1,2,3,4,5}, cost {3,4,5,1,2} -> 3;   gas {2,3,4}, cost {3,4,3} -> -1

Why this works: any station k inside [start, i] was reached with a tank >= 0, so starting at k with an empty tank can only be worse — it would also fail at i. That's why one pass is enough.

Problem: Assign Cookies (LeetCode 455)

// Time O(n log n + m log m), Space O(1) beyond sorting
static int findContentChildren(int[] greed, int[] cookies) {
    Arrays.sort(greed);
    Arrays.sort(cookies);
    int child = 0;
    for (int c = 0; c < cookies.length && child < greed.length; c++) {
        if (cookies[c] >= greed[child]) child++;   // smallest cookie that satisfies this child
    }
    return child;
}
// greed {1,2,3}, cookies {1,1} -> 1;   greed {1,2}, cookies {1,2,3} -> 2

Why this works: give the least greedy child the smallest cookie that satisfies them. Using a bigger cookie there would never help — by exchange, it can only take a cookie some greedier child could have used.

Problem: Meeting Rooms II / Minimum Platforms

Given meetings (or train arrival/departure times), find the minimum rooms (platforms) so nothing clashes. The answer is the maximum number of meetings happening at the same moment. Sweep start times in order and free up rooms whose meetings have ended.

// Two sorted arrays + two pointers. Time O(n log n), Space O(n)
static int minMeetingRooms(int[][] meetings) {
    int n = meetings.length;
    int[] starts = new int[n], ends = new int[n];
    for (int i = 0; i < n; i++) { starts[i] = meetings[i][0]; ends[i] = meetings[i][1]; }
    Arrays.sort(starts);
    Arrays.sort(ends);
    int rooms = 0, maxRooms = 0, e = 0;
    for (int s = 0; s < n; s++) {
        while (e < n && ends[e] <= starts[s]) { e++; rooms--; }   // free rooms that ended
        rooms++;
        maxRooms = Math.max(maxRooms, rooms);
    }
    return maxRooms;
}

// Same answer with a min-heap of end times (one entry per busy room). Time O(n log n), Space O(n)
static int minMeetingRoomsHeap(int[][] meetings) {
    Arrays.sort(meetings, (a, b) -> Integer.compare(a[0], b[0]));
    PriorityQueue<Integer> endTimes = new PriorityQueue<>();
    for (int[] m : meetings) {
        if (!endTimes.isEmpty() && endTimes.peek() <= m[0]) endTimes.poll();  // reuse a room
        endTimes.offer(m[1]);
    }
    return endTimes.size();
}
// {{0,30},{5,10},{15,20}} -> 2
// trains {{900,910},{940,1200},{950,1120},{1100,1130},{1500,1900},{1800,2000}} -> 3

Why this works: a room can only be reused once some meeting has ended, and we always reuse the one that ended earliest. The count only grows when every room is busy, so the peak is exactly the minimum needed. Edge case to ask about: if one meeting ends at 10 and another starts at 10, can they share a room? The code says yes (<=). For platforms problems that count "same minute" as a clash, use <.

Huffman coding — the greedy idea

Goal: give each character a binary code so frequent characters get short codes and the total encoded length is minimal (no code is a prefix of another). Greedy rule: repeatedly merge the two rarest nodes into a new node whose frequency is their sum. Each merge pushes those characters one level deeper — one more bit each — so the cost of a merge is a + b, and the total cost is the sum of all merges.

// Total encoded bits. Time O(n log n), Space O(n)
static long huffmanCost(int[] freq) {
    PriorityQueue<Long> pq = new PriorityQueue<>();
    for (int f : freq) pq.offer((long) f);
    long cost = 0;
    while (pq.size() > 1) {
        long a = pq.poll(), b = pq.poll();   // two rarest
        cost += a + b;                       // each merge adds one bit to all symbols below
        pq.offer(a + b);
    }
    return cost;
}
// freq {5, 9, 12, 13, 16, 45} -> 224 bits

Why this works: the two rarest characters should be the deepest leaves (exchange argument: swapping a rarer character deeper never increases cost), and they can be siblings. Merge them and the rest is the same problem, one character smaller. The same "merge two smallest" pattern solves Minimum Cost to Connect Sticks and optimal file merge.

Greedy vs DP in one line: greedy commits to one choice per step and never looks back; DP tries every choice per step and remembers sub-answers. If you can build a small counterexample to your greedy rule, switch to DP. If an exchange argument works, greedy is usually the O(n log n) answer the interviewer wants.

23. Divide & Conquer

In plain words

Divide and conquer solves a big problem in three moves: split it into smaller pieces of the same kind, solve each piece (usually by calling yourself recursively), and combine the piece answers into the full answer. The pieces keep shrinking until they are so small the answer is obvious (the base case).

It differs from DP in one key way: the pieces don't overlap. The left half and right half of an array are independent, so nothing is solved twice and nothing needs caching.

Counting the votes of a whole country: every district counts its own ballots at the same time, then regions add up their districts, then the nation adds up the regions. Nobody counts the whole pile alone.

Split, solve, combine

AlgorithmSplitSolveCombineWhere the real work is
Merge sortcut at the middle, O(1)sort both halvesmerge, O(n)combine
Quicksortpartition around a pivot, O(n)sort both sidesnothingsplit
Quickselectpartition, O(n)recurse into one sidenothingsplit
Binary searchcompare with middle, O(1)recurse into one halfnothing—
Max subarray (D&C)cut at the middlebest in each halfbest crossing the middle, O(n)combine
Fast powerx^n = (x^(n/2))^2one half-size powersquare (times x if odd), O(1)—

The recursion tree and the master theorem

Write the running time as a recurrence: T(n) = a T(n / b) + f(n), meaning "a subproblems, each 1/b the size, plus f(n) work to split and combine". Draw it as a tree and add up the work level by level. For merge sort, a = 2, b = 2, f(n) = n:

work per level n n n/2 n/2 2 x n/2 = n n/4 n/4 n/4 n/4 4 x n/4 = n 1 1 1 1 1 ... 1 1 1 1 1 (n leaves) n x 1 = n log n levels log n levels x n work per level = O(n log n)
Every level does the same total work n, because the pieces double in number while halving in size. Height log n, so n log n in total.

The master theorem is a shortcut for T(n) = a T(n / b) + O(n^d). Compare d with logb a (how fast the number of pieces grows):

CaseResultIn words
d > logb aO(n^d)The top-level combine dominates
d = logb aO(n^d log n)Every level costs the same; multiply by the height
d < logb aO(n^(logb a))The leaves dominate — there are so many of them
AlgorithmRecurrenceResult
Binary searchT(n) = T(n/2) + O(1)O(log n)
Fast powerT(n) = T(n/2) + O(1)O(log n)
Quickselect (average)T(n) = T(n/2) + O(n)O(n)
Tree traversal (balanced)T(n) = 2T(n/2) + O(1)O(n)
Merge sort, count inversions, max subarray D&CT(n) = 2T(n/2) + O(n)O(n log n)
Karatsuba multiplicationT(n) = 3T(n/2) + O(n)O(n^1.585)
Strassen matrix multiplyT(n) = 7T(n/2) + O(n²)O(n^2.81)

The theorem needs equal-sized splits. Quicksort's worst case, T(n) = T(n - 1) + O(n), isn't covered — sum it directly: n + (n-1) + ... = O(n²).

Recap: merge sort and quickselect

Both were written in full in the Sorting section. Merge sort is the textbook D&C: trivial split, O(n) combine, O(n log n) always. Quickselect shows the power of recursing into only one piece: O(n) + O(n/2) + O(n/4) + ... = O(2n) = O(n) on average, versus O(n log n) to sort first. Count inversions is merge sort with a counter in the combine step — same O(n log n).

Problem: Maximum Subarray (LeetCode 53) — D&C vs Kadane

Find the contiguous subarray with the largest sum. D&C view: the best subarray is either entirely in the left half, entirely in the right half, or crosses the middle. The crossing one is the best suffix of the left half glued to the best prefix of the right half — found in O(n) by walking outward from the middle.

// Divide & conquer. Time O(n log n), Space O(log n) recursion
static int maxSubArrayDC(int[] a) { return best(a, 0, a.length - 1); }

static int best(int[] a, int lo, int hi) {
    if (lo == hi) return a[lo];                    // one element
    int mid = lo + (hi - lo) / 2;
    int left = best(a, lo, mid);                   // answer fully in left half
    int right = best(a, mid + 1, hi);              // answer fully in right half
    // answer crossing the middle: best suffix of left + best prefix of right
    int sum = 0, bestLeft = Integer.MIN_VALUE;
    for (int i = mid; i >= lo; i--) { sum += a[i]; bestLeft = Math.max(bestLeft, sum); }
    sum = 0;
    int bestRight = Integer.MIN_VALUE;
    for (int j = mid + 1; j <= hi; j++) { sum += a[j]; bestRight = Math.max(bestRight, sum); }
    return Math.max(Math.max(left, right), bestLeft + bestRight);
}

// Kadane. Time O(n), Space O(1)
static int maxSubArrayKadane(int[] a) {
    int cur = a[0], best = a[0];
    for (int i = 1; i < a.length; i++) {
        cur = Math.max(a[i], cur + a[i]);   // extend the run, or start fresh here
        best = Math.max(best, cur);
    }
    return best;
}
// {-2, 1, -3, 4, -1, 2, 1, -5, 4}  ->  6 (subarray [4, -1, 2, 1]), both methods
// {-3, -1, -2}  ->  -1 (all negative: best is the single largest element)

Why both work: D&C covers all three places the answer can live. Kadane asks at each index "is the best run ending here the previous run extended, or a fresh start?" — a one-dimensional DP. Kadane wins in an interview (O(n)), but the D&C version is a favourite follow-up ("solve it with divide and conquer") and it generalises to a segment tree that answers max-subarray queries on ranges with updates, where Kadane cannot.

Problem: Pow(x, n) — fast exponentiation (LeetCode 50)

Multiplying x by itself n times is O(n). Instead: x^n = (x^(n/2))² if n is even, and x · (x^(n/2))² if odd. One half-size subproblem per level, so O(log n). The key is computing half once — writing pow(x, n/2) * pow(x, n/2) makes two calls and falls back to O(n).

// Recursive. Time O(log n), Space O(log n)
static double myPow(double x, int n) {
    long N = n;                          // -Integer.MIN_VALUE overflows int: use long
    if (N < 0) { x = 1 / x; N = -N; }
    return pow(x, N);
}

static double pow(double x, long n) {
    if (n == 0) return 1.0;
    double half = pow(x, n / 2);         // solve ONE half-size problem
    return (n % 2 == 0) ? half * half : half * half * x;
}

// Iterative (binary exponentiation). Time O(log n), Space O(1)
static double myPowIter(double x, int n) {
    long N = n;
    if (N < 0) { x = 1 / x; N = -N; }
    double result = 1.0;
    while (N > 0) {
        if ((N & 1) == 1) result *= x;   // this bit of n is set: include x^(2^k)
        x *= x;                          // x, x^2, x^4, x^8, ...
        N >>= 1;
    }
    return result;
}

// Modular version, used everywhere in counting problems ("answer mod 1e9+7")
static long modPow(long base, long exp, long mod) {
    long result = 1;
    base %= mod;
    while (exp > 0) {
        if ((exp & 1) == 1) result = result * base % mod;
        base = base * base % mod;
        exp >>= 1;
    }
    return result;
}
// myPow(2.0, 10) = 1024.0,  myPow(2.0, -2) = 0.25,  myPow(1.0, Integer.MIN_VALUE) = 1.0
// modPow(2, 10, 1_000_000_007) = 1024

Why this works: write n in binary, for example 13 = 1101₂, so x^13 = x^8 · x^4 · x^1. The loop walks the bits and multiplies in the matching powers of x. Pitfall: n = Integer.MIN_VALUE has no positive int counterpart, so -n overflows — convert to long first. In modPow, keep mod below about 3 · 10^9 so base * base fits in a long.

Closest pair of points — the idea

Given n points in a plane, find the two closest. Brute force checks all pairs: O(n²). D&C:

  1. Split: sort by x, cut into left and right halves at the middle x.
  2. Solve: find the closest distance d in each half recursively; let d = the smaller one.
  3. Combine: the only pairs not yet checked cross the dividing line. Such a pair can only beat d if both points are within d of the line — a thin strip. Sort the strip by y; each point needs checking only against the next few points (at most 7) whose y is within d, because points on the same side are at least d apart and only a handful can fit in a d x 2d box.
// Time O(n log² n) as written (strip re-sorted at each level); O(n log n) if you merge by y
// like merge sort instead of re-sorting. Space O(n)
static double closestPair(int[][] pts) {
    int[][] byX = pts.clone();
    Arrays.sort(byX, (p, q) -> Integer.compare(p[0], q[0]));
    return closest(byX, 0, byX.length - 1);
}

static double closest(int[][] p, int lo, int hi) {
    if (hi - lo < 3) {                                   // tiny: brute force
        double best = Double.MAX_VALUE;
        for (int i = lo; i <= hi; i++)
            for (int j = i + 1; j <= hi; j++) best = Math.min(best, dist(p[i], p[j]));
        return best;
    }
    int mid = lo + (hi - lo) / 2;
    int midX = p[mid][0];
    double d = Math.min(closest(p, lo, mid), closest(p, mid + 1, hi));
    List<int[]> strip = new ArrayList<>();               // points within d of the dividing line
    for (int i = lo; i <= hi; i++) if (Math.abs(p[i][0] - midX) < d) strip.add(p[i]);
    strip.sort((a, b) -> Integer.compare(a[1], b[1]));   // sort strip by y
    for (int i = 0; i < strip.size(); i++)
        for (int j = i + 1; j < strip.size() && strip.get(j)[1] - strip.get(i)[1] < d; j++)
            d = Math.min(d, dist(strip.get(i), strip.get(j)));   // only a few j per i
    return d;
}

static double dist(int[] a, int[] b) {
    long dx = a[0] - b[0], dy = a[1] - b[1];
    return Math.sqrt(dx * dx + dy * dy);
}
// {{2,3},{12,30},{40,50},{5,1},{12,10},{3,4}}  ->  1.4142135623730951  ((2,3) and (3,4))

Rarely asked to code in full, but "how would you beat O(n²)?" is a good D&C discussion question. The inner loop looks nested, but the geometry caps it at a constant number of steps per point.

Problem: Count Inversions (recap)

The full code is in the Sorting section. As D&C: split at the middle; solve = count inversions inside each half (and sort them); combine = during the merge, each time an element is taken from the right half, add mid - i + 1 (the number of left elements still waiting, all bigger). Recurrence 2T(n/2) + O(n), so O(n log n) instead of O(n²). The same "count across the split while merging" trick solves Count of Smaller Numbers After Self (LeetCode 315) and Reverse Pairs (LeetCode 493).

When D&C beats brute force

  • The combine step is cheaper than brute-forcing across the split. Inversions: checking all left-right pairs is O(n²), but sorted halves let us count them in O(n).
  • You can throw pieces away. Binary search and quickselect recurse into only one piece — that's where O(log n) and O(n) come from.
  • Reusing one sub-result. Fast power computes x^(n/2) once and squares it.
  • Pieces are independent, so they can run in parallel (Java's ForkJoinPool, Arrays.parallelSort, MapReduce).

When it doesn't help: if the subproblems overlap (Fibonacci: fib(n-1) and fib(n-2) both recompute fib(n-3)), plain D&C is exponential — that is exactly when you switch to memoization / DP. And if the combine step is as expensive as the whole brute force, splitting buys nothing.

Pitfalls: forgetting the base case (or making it unreachable) gives a StackOverflowError. Splitting as [lo, mid] and [mid, hi] overlaps and can loop forever — use [lo, mid] and [mid + 1, hi]. Allocating new arrays at every level (e.g. Arrays.copyOfRange in merge sort) works but adds O(n log n) garbage; pass indexes and one shared buffer instead. Recursion depth is O(log n) for balanced splits but O(n) for lopsided ones (bad quicksort pivots).

24. Dynamic Programming — the Fundamentals

In plain words

Dynamic programming (DP) is recursion with a memory. You break a problem into smaller versions of itself, solve each small version once, write the answer down, and reuse it whenever it comes up again.

It works when two things are true: the same small problems come up again and again (overlapping subproblems), and the best answer to the big problem can be built from the best answers to the small ones (optimal substructure).

If someone asks you "what is 1+1+1+1+1+1+1?" you count: 7. If they then add "+1" at the end, you don't recount — you say "8", because you remembered 7. DP is simply refusing to count the same thing twice.

The two properties, explained

  • Overlapping subproblems — a plain recursive solution calls itself with the same arguments many times. If every call is different (like merge sort's halves), there is nothing to reuse: that is divide and conquer, not DP.
  • Optimal substructure — the best answer for size n is built from best answers for smaller sizes. Shortest path has it (a shortest path's middle part is also shortest). "Longest simple path" does not, which is why it has no DP.

Seeing the repeated work

Climbing stairs: you can climb 1 or 2 steps at a time; how many ways to reach step n? To land on step n, your last move was either from n-1 (a 1-step) or from n-2 (a 2-step). So ways(n) = ways(n-1) + ways(n-2) — the Fibonacci recurrence.

// Plain recursion: correct but O(2^n) time
static int climbNaive(int n) {
    if (n <= 1) return 1;                 // 1 way to stand on step 0 or reach step 1
    return climbNaive(n - 1) + climbNaive(n - 2);
}
f5 f4 f3 f3 f2 f2 f1 f2 f1 f1 f0 f1 f0 f1 f0 f3 solved 2 times, f2 solved 3 times 15 calls for n = 5 2,692,537 calls for n = 30 with a memo: only n + 1 different calls
The recursion tree for ways(5). Every highlighted box is a subproblem the plain recursion solves again from scratch. Memoisation cuts each repeated subtree down to a single lookup.

The call counts are real: counting calls in the code above gives 15 for n = 5 and 2,692,537 for n = 30. The tree roughly doubles with each extra step, so it is O(2^n). Yet there are only n + 1 different questions (f0 to fn). DP answers each one once.

The 5-step recipe

Use these steps for every DP problem. Say them out loud in an interview — it shows structure even before the code is right.

StepQuestion to askClimbing stairs
1. StateWhat does dp[i] mean, in one sentence?dp[i] = number of ways to reach step i
2. RecurrenceHow is a state built from smaller states? (Think: "what was the last choice?")dp[i] = dp[i-1] + dp[i-2]
3. Base casesWhich states can be answered without the recurrence?dp[0] = 1, dp[1] = 1
4. OrderIn what order must states be filled so dependencies are ready?i from 2 up to n
5. AnswerWhich state (or max/min of states) is the final answer?dp[n]
The state is the hard part. A good state holds exactly the information needed to decide the future, and nothing more. If your recurrence needs to know "something about the past" that the state does not hold, add it as another dimension (for example dp[i][holding] in the stock problems).

Top-down (memoisation) vs bottom-up (tabulation)

Top-down: write the natural recursion, then add a cache (the memo). Before computing, check the memo; after computing, store it. Bottom-up: create a table and fill it from the smallest states upward with a loop, so every value you need is already there.

// Top-down: recursion + memo. O(n) time, O(n) space (memo + call stack)
static int climbMemo(int n) {
    int[] memo = new int[n + 1];
    Arrays.fill(memo, -1);                       // -1 = "not solved yet"
    return go(n, memo);
}
private static int go(int i, int[] memo) {
    if (i <= 1) return 1;                        // base cases
    if (memo[i] != -1) return memo[i];           // already solved? reuse
    return memo[i] = go(i - 1, memo) + go(i - 2, memo);
}

// Bottom-up: fill a table from small to big. O(n) time, O(n) space
static int climbTab(int n) {
    if (n <= 1) return 1;
    int[] dp = new int[n + 1];
    dp[0] = 1; dp[1] = 1;                        // base cases
    for (int i = 2; i <= n; i++)
        dp[i] = dp[i - 1] + dp[i - 2];           // recurrence
    return dp[n];                                // answer
}

// Space-optimised: only the last two values are ever read. O(n) time, O(1) space
static int climbO1(int n) {
    int prev2 = 1, prev1 = 1;                    // dp[i-2], dp[i-1]
    for (int i = 2; i <= n; i++) {
        int cur = prev1 + prev2;
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
}
i0123456
dp[i]11235813

All three versions return 13 for n = 6 and 1,836,311,903 for n = 45 (the largest n that still fits in an int).

Top-down (memo)Bottom-up (table)
How you write itNatural recursion + cacheLoops in dependency order
Which states are computedOnly the ones actually reachedAll of them
Stack overflow riskYes — recursion depth can be n (Java's default stack breaks around 10,000 deep)None
Space optimisationHardEasy — keep only the rows/values you still read
When to pick itHard-to-order states, sparse state space, getting a first correct answer fastLarge n, need for speed, need to cut memory

Second example: House Robber (LC 198)

Houses in a row hold money nums[i]. You cannot rob two neighbours. Maximise the total. At each house the choice is rob it (then skip the next) or skip it.

  • State: dp[i] = most money from the first i houses.
  • Recurrence: dp[i] = max(dp[i-1], dp[i-2] + nums[i-1]) — skip house i, or rob it and add the best from two houses back.
  • Base: dp[0] = 0, dp[1] = nums[0]. Answer: dp[n].
// Top-down: best(i) = most money from houses i..n-1
static int robMemo(int[] nums) {
    return best(nums, 0, new Integer[nums.length]);  // null = not solved yet
}
private static int best(int[] nums, int i, Integer[] memo) {
    if (i >= nums.length) return 0;
    if (memo[i] != null) return memo[i];
    int rob  = nums[i] + best(nums, i + 2, memo);    // take i, skip neighbour
    int skip = best(nums, i + 1, memo);              // leave i
    return memo[i] = Math.max(rob, skip);
}

// Bottom-up table
static int robTab(int[] nums) {
    int n = nums.length;
    int[] dp = new int[n + 1];                       // dp[i] = best using first i houses
    dp[1] = nums[0];
    for (int i = 2; i <= n; i++)
        dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]);
    return dp[n];
}

// Rolling variables: O(n) time, O(1) space
static int rob(int[] nums) {
    int prev2 = 0, prev1 = 0;                        // best up to i-2, best up to i-1
    for (int x : nums) {
        int cur = Math.max(prev1, prev2 + x);
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
}

Traced on nums = [2, 7, 9, 3, 1]:

ihouse valueskip: dp[i-1]rob: dp[i-2] + valuedp[i]
12——2
2720 + 7 = 77
3972 + 9 = 1111
43117 + 3 = 1011
511111 + 1 = 1212

All three versions return 12 (rob houses 2, 9 and 1). Time O(n); space O(n) for memo/table, O(1) for the rolling version.

Space optimisation — the general rule

  • If dp[i] only reads dp[i-1] and dp[i-2], keep two variables.
  • If a 2D dp[i][j] only reads row i-1, keep one or two 1D rows. Direction matters: loop so you never read a cell you already overwrote in this row (unless you want to — see knapsack in section 25).
  • You lose the ability to reconstruct the actual choices (which houses, which items). If the question asks for the path, not just the number, keep the full table.

How to recognise a DP problem

Signal in the questionExample
"Count the number of ways…"Climbing stairs, unique paths, coin change II, decode ways
"Minimum / maximum cost, profit, length…"Coin change, house robber, LIS, edit distance
"Is it possible to…?" (yes/no)Partition equal subset sum, word break, jump game
At each step you make a choice (take/skip, left/right, buy/sell) and choices affect the futureKnapsack, stock problems
Input is a sequence, string, grid or tree, and n is up to ~10^3–10^4O(n^2) DP fits; n around 20 hints at bitmask DP
DP or greedy? Greedy makes one locally best choice and never looks back. Try a small counter-example first: coins {1, 3, 4} for amount 6 — greedy takes 4+1+1 (3 coins) but the best is 3+3 (2 coins). When greedy can be beaten like this, you need DP. If you cannot break greedy and can prove it (exchange argument), greedy is simpler and faster.
Interview flow: (1) write the brute-force recursion and say its complexity; (2) point out the repeated subproblems; (3) add a memo; (4) if asked, convert to bottom-up and shrink the space. Each step is a correct solution, so you always have something working.

25. DP Patterns

In plain words

Most interview DP problems are one of about a dozen patterns wearing a costume. Once you know the pattern, the state and recurrence are almost given to you — you only adapt the details.

For each pattern below: what dp means, how it is filled, the Java code, and its cost.

Like chess openings. A strong player does not think from scratch on move one; they recognise "this is the Sicilian" and already know the plan. Recognising "this is knapsack" gets you 80% of the way.

PatternState shapeTypical problems
0/1 knapsackdp[i][capacity]Partition equal subset, target sum, last stone weight II
Unbounded knapsackdp[amount]Coin change I/II, perfect squares, rod cutting
Single sequencedp[i] = answer ending at / using first iLIS, house robber, word break, decode ways
Two sequencesdp[i][j] = answer for prefixesLCS, edit distance, distinct subsequences
Griddp[r][c]Unique paths, minimum path sum, maximal square
Intervaldp[l][r]Burst balloons, matrix chain, palindrome partitioning
Treevalue returned from DFS per nodeHouse robber III, binary tree cameras, max path sum
Bitmaskdp[mask][i]TSP, shortest path visiting all nodes, assignment
State machinedp[i][state]Stock buy/sell family

0/1 knapsack

n items each with a weight and a value; a bag of capacity W. Each item is taken at most once. Maximise value.

  • State: dp[i][c] = best value using the first i items with capacity c.
  • Recurrence: dp[i][c] = max(dp[i-1][c], dp[i-1][c - w] + v) — skip item i, or take it (only if w <= c).
  • Base: row 0 is all zeros (no items, no value). Answer: dp[n][W].
static int knapsack2D(int[] wt, int[] val, int W) {
    int n = wt.length;
    int[][] dp = new int[n + 1][W + 1];   // dp[i][c] = best value, first i items, capacity c
    for (int i = 1; i <= n; i++) {
        for (int c = 0; c <= W; c++) {
            dp[i][c] = dp[i - 1][c];                          // skip item i
            if (wt[i - 1] <= c)                               // take item i (once)
                dp[i][c] = Math.max(dp[i][c], dp[i - 1][c - wt[i - 1]] + val[i - 1]);
        }
    }
    return dp[n][W];
}

Traced with weights {1, 3, 4}, values {15, 20, 30}, W = 4:

items \ capacity01234
none00000
+ item (w1, v15)015151515
+ item (w3, v20)015152035
+ item (w4, v30)015152035

Answer 35 (items of weight 1 and 3). Time O(n·W), space O(n·W).

1D version. Row i only reads row i-1, so one array is enough — if you loop capacity backwards.

static int knapsack1D(int[] wt, int[] val, int W) {
    int[] dp = new int[W + 1];                 // dp[c] = best value with capacity c
    for (int i = 0; i < wt.length; i++)
        for (int c = W; c >= wt[i]; c--)       // BACKWARDS: each item used at most once
            dp[c] = Math.max(dp[c], dp[c - wt[i]] + val[i]);
    return dp[W];
}
Why backwards? dp[c] reads dp[c - w], a smaller index. Going from high c to low, the smaller cell has not been touched yet in this round, so it still holds the "previous item" value — exactly dp[i-1][c-w]. Going forwards, dp[c-w] may already include the current item, so the item gets added again and again. With the example above, the forward loop returns 60 (the weight-1 item taken four times) instead of 35. Forwards = unbounded knapsack; backwards = 0/1 knapsack.

Time O(n·W), space O(W). Note W is a value, not a size — this is "pseudo-polynomial", so it is fast only when W is small (say up to 10^4–10^5).

Unbounded knapsack: Coin Change (LC 322) and Coin Change II (LC 518)

Each coin can be used any number of times. Two different questions: fewest coins (min), and number of combinations (count).

  • Coin Change — state: dp[a] = fewest coins to make amount a. Recurrence: dp[a] = min over coins (dp[a - coin] + 1). Base: dp[0] = 0.
  • Coin Change II — state: dp[a] = number of combinations making a. Recurrence: dp[a] += dp[a - coin]. Base: dp[0] = 1 (one way to make 0: take nothing).
static int coinChange(int[] coins, int amount) {
    int[] dp = new int[amount + 1];            // dp[a] = fewest coins to make a
    Arrays.fill(dp, amount + 1);               // "infinity" that cannot overflow on +1
    dp[0] = 0;
    for (int a = 1; a <= amount; a++)
        for (int coin : coins)
            if (coin <= a)
                dp[a] = Math.min(dp[a], dp[a - coin] + 1);
    return dp[amount] > amount ? -1 : dp[amount];
}

static int change(int amount, int[] coins) {
    int[] dp = new int[amount + 1];            // dp[a] = number of combinations making a
    dp[0] = 1;                                 // one way to make 0: take nothing
    for (int coin : coins)                     // coins OUTER -> combinations, not orders
        for (int a = coin; a <= amount; a++)   // forwards: a coin may repeat
            dp[a] += dp[a - coin];
    return dp[amount];
}

coinChange({1, 2, 5}, 11) = 3 (5+5+1); coinChange({2}, 3) = -1; change(5, {1, 2, 5}) = 4. Both run in O(amount · coins) time, O(amount) space.

Loop order decides combinations vs permutations. With coins in the outer loop, each coin is "introduced" once, so 1+2+2 and 2+1+2 are the same combination: 4 ways for amount 5. Swap the loops (amount outer, coins inner) and you count ordered sequences instead — the same input gives 9. That swapped version is Combination Sum IV (LC 377).

Longest Increasing Subsequence (LC 300)

A subsequence keeps order but may skip elements. Find the longest strictly increasing one.

O(n^2): dp[i] = length of the LIS that ends exactly at index i. For every earlier j with nums[j] < nums[i], you can extend it: dp[i] = max(dp[j] + 1). The answer is the max over all i (not dp[n-1]).

static int lengthOfLIS_n2(int[] nums) {
    int n = nums.length, best = 0;
    int[] dp = new int[n];                     // dp[i] = LIS ending exactly at i
    for (int i = 0; i < n; i++) {
        dp[i] = 1;                             // just nums[i] alone
        for (int j = 0; j < i; j++)
            if (nums[j] < nums[i])
                dp[i] = Math.max(dp[i], dp[j] + 1);
        best = Math.max(best, dp[i]);
    }
    return best;
}

O(n log n) — patience sorting. Keep tails[k] = the smallest possible last value of an increasing subsequence of length k+1. A smaller tail is always better, because it is easier to extend. For each number, binary-search the first tail >= x and replace it; if none, append.

static int lengthOfLIS(int[] nums) {
    int[] tails = new int[nums.length];
    int size = 0;
    for (int x : nums) {
        int lo = 0, hi = size;                 // find first tail >= x
        while (lo < hi) {
            int mid = (lo + hi) >>> 1;
            if (tails[mid] < x) lo = mid + 1; else hi = mid;
        }
        tails[lo] = x;                         // replace, or append if lo == size
        if (lo == size) size++;
    }
    return size;
}
nums = [10, 9, 2, 5, 3, 7, 101, 18] x=10 tails = [10] x=9 tails = [9] 9 replaces 10 (smaller tail, same length) x=2 tails = [2] x=5 tails = [2, 5] append x=3 tails = [2, 3] 3 replaces 5 x=7 tails = [2, 3, 7] append x=101 tails = [2, 3, 7, 101] append x=18 tails = [2, 3, 7, 18] 18 replaces 101 answer = size = 4

Both return 4. tails is not itself a valid subsequence — only its length is meaningful. For "non-decreasing" use "first tail > x" instead. Time O(n log n), space O(n). Variants: Russian doll envelopes (sort by width ascending, height descending, then LIS on heights), number of LIS, longest chain.

Longest Common Subsequence (LC 1143)

  • State: dp[i][j] = LCS length of a[0..i) and b[0..j) (the first i and j characters).
  • Recurrence: if a[i-1] == b[j-1], both last chars join the LCS: dp[i-1][j-1] + 1. Otherwise drop one of them: max(dp[i-1][j], dp[i][j-1]).
  • Base: row 0 and column 0 are 0 (empty string). Answer: dp[m][n].
static int lcs(String a, String b) {
    int m = a.length(), n = b.length();
    int[][] dp = new int[m + 1][n + 1];        // dp[i][j] = LCS of a[0..i) and b[0..j)
    for (int i = 1; i <= m; i++)
        for (int j = 1; j <= n; j++)
            dp[i][j] = a.charAt(i - 1) == b.charAt(j - 1)
                    ? dp[i - 1][j - 1] + 1                    // diagonal + 1
                    : Math.max(dp[i - 1][j], dp[i][j - 1]);   // up or left
    return dp[m][n];
}

Table for a = "abcde", b = "ace" (bold = a character match, taken from the diagonal):

""ace
""0000
a0111
b0111
c0122
d0122
e0123

Answer 3 ("ace"). To rebuild the string, start at dp[m][n]: on a match step diagonally and record the char; else move toward the larger of up/left. Time O(m·n), space O(m·n), or O(min(m, n)) with two rows.

Edit Distance (LC 72)

Fewest single-character inserts, deletes or replaces to turn a into b.

  • State: dp[i][j] = edits to turn a[0..i) into b[0..j).
  • Recurrence: chars equal: dp[i-1][j-1] (free). Otherwise 1 + min(replace dp[i-1][j-1], delete dp[i-1][j], insert dp[i][j-1]).
  • Base: dp[i][0] = i (delete all), dp[0][j] = j (insert all).
static int minDistance(String a, String b) {
    int m = a.length(), n = b.length();
    int[][] dp = new int[m + 1][n + 1];
    for (int i = 0; i <= m; i++) dp[i][0] = i; // delete everything
    for (int j = 0; j <= n; j++) dp[0][j] = j; // insert everything
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (a.charAt(i - 1) == b.charAt(j - 1)) {
                dp[i][j] = dp[i - 1][j - 1];                 // free: chars match
            } else {
                dp[i][j] = 1 + Math.min(dp[i - 1][j - 1],    // replace
                               Math.min(dp[i - 1][j],        // delete from a
                                        dp[i][j - 1]));      // insert into a
            }
        }
    }
    return dp[m][n];
}

minDistance("horse", "ros") = 3 (horse → rorse → rose → ros); ("intention", "execution") = 5. Time and space O(m·n).

Grid paths: Unique Paths (LC 62) and Minimum Path Sum (LC 64)

Moving only right or down, a cell can be reached only from above or from the left. So dp[r][c] combines dp[r-1][c] and dp[r][c-1] — sum them to count paths, min them for cheapest path.

static int uniquePaths(int m, int n) {
    int[] dp = new int[n];                     // one row, reused
    Arrays.fill(dp, 1);                        // first row: one way to each cell
    for (int r = 1; r < m; r++)
        for (int c = 1; c < n; c++)
            dp[c] += dp[c - 1];                // from above (old dp[c]) + from left
    return dp[n - 1];
}

static int minPathSum(int[][] grid) {
    int m = grid.length, n = grid[0].length;
    int[][] dp = new int[m][n];
    for (int r = 0; r < m; r++) {
        for (int c = 0; c < n; c++) {
            if (r == 0 && c == 0) dp[r][c] = grid[0][0];
            else if (r == 0)      dp[r][c] = dp[r][c - 1] + grid[r][c];   // top row: only from left
            else if (c == 0)      dp[r][c] = dp[r - 1][c] + grid[r][c];   // left column: only from above
            else dp[r][c] = Math.min(dp[r - 1][c], dp[r][c - 1]) + grid[r][c];
        }
    }
    return dp[m - 1][n - 1];
}

uniquePaths(3, 7) = 28; minPathSum([[1,3,1],[1,5,1],[4,2,1]]) = 7 (path 1→3→1→1→1). Both O(m·n) time; space O(n) and O(m·n) (can also be O(n) or done in place). With obstacles (LC 63), set dp to 0 on blocked cells.

Partition Equal Subset Sum (LC 416)

Can the array split into two parts with equal sums? That means: is there a subset summing to total / 2? This is 0/1 knapsack with booleans.

static boolean canPartition(int[] nums) {
    int total = 0;
    for (int x : nums) total += x;
    if (total % 2 != 0) return false;          // odd total can never split evenly
    int target = total / 2;
    boolean[] dp = new boolean[target + 1];    // dp[s] = can some subset sum to s?
    dp[0] = true;                              // the empty subset
    for (int x : nums)
        for (int s = target; s >= x; s--)      // backwards: each number used once
            dp[s] = dp[s] || dp[s - x];
    return dp[target];
}

[1, 5, 11, 5] → true (11 = 1+5+5); [1, 2, 3, 5] → false. Time O(n · sum), space O(sum). Same shape: Target Sum (LC 494) counts subsets with sum (total + target) / 2.

Word Break (LC 139)

  • State: dp[i] = the first i characters can be split into dictionary words.
  • Recurrence: dp[i] is true if some j < i has dp[j] true and s[j..i) is a word.
static boolean wordBreak(String s, List<String> wordDict) {
    Set<String> dict = new HashSet<>(wordDict);
    int n = s.length();
    boolean[] dp = new boolean[n + 1];         // dp[i] = s[0..i) can be segmented
    dp[0] = true;                              // empty prefix
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j < i; j++) {
            if (dp[j] && dict.contains(s.substring(j, i))) {
                dp[i] = true;
                break;                         // one valid split is enough
            }
        }
    }
    return dp[n];
}

"leetcode" with ["leet", "code"] → true; "catsandog" with ["cats", "dog", "sand", "and", "cat"] → false. Time O(n^3) in the worst case (n^2 pairs, each substring costs O(n)); limit j to the longest word length to speed it up. Space O(n).

Interval DP: Burst Balloons (LC 312)

Interval DP solves every sub-range [l, r], shortest ranges first, by trying each split point k inside. The trick in Burst Balloons is to let k be the last balloon burst in the range: then its neighbours at that moment are the fixed walls l and r, and the left and right parts become independent.

  • State: dp[l][r] = most coins from bursting every balloon strictly between l and r (padded with 1s at both ends).
  • Recurrence: dp[l][r] = max over k (dp[l][k] + v[l]·v[k]·v[r] + dp[k][r]).
  • Order: by increasing gap r - l.
static int maxCoins(int[] nums) {
    int n = nums.length;
    int[] v = new int[n + 2];                  // pad with 1 on both ends
    v[0] = v[n + 1] = 1;
    for (int i = 0; i < n; i++) v[i + 1] = nums[i];
    int[][] dp = new int[n + 2][n + 2];
    for (int len = 2; len <= n + 1; len++) {   // gap between the two walls
        for (int l = 0; l + len <= n + 1; l++) {
            int r = l + len;
            for (int k = l + 1; k < r; k++)    // k = LAST balloon burst in (l, r)
                dp[l][r] = Math.max(dp[l][r],
                        dp[l][k] + v[l] * v[k] * v[r] + dp[k][r]);
        }
    }
    return dp[0][n + 1];
}

[3, 1, 5, 8] → 167. Time O(n^3), space O(n^2). Matrix chain multiplication is the same shape: dp[i][j] = min over k (dp[i][k] + dp[k+1][j] + rows[i]·cols[k]·cols[j]) — the cost of splitting the product at k.

DP on trees: House Robber III (LC 337)

Houses form a binary tree; you cannot rob a parent and its child. Each node returns two answers to its parent — best if this node is skipped, best if it is robbed — so the parent can choose. This "return a small array from DFS" is the general tree-DP pattern.

static int robTree(TreeNode root) {
    int[] r = dfs(root);
    return Math.max(r[0], r[1]);
}
// returns {best if we SKIP this node, best if we ROB this node}
private static int[] dfs(TreeNode node) {
    if (node == null) return new int[]{0, 0};
    int[] L = dfs(node.left), R = dfs(node.right);
    int skip = Math.max(L[0], L[1]) + Math.max(R[0], R[1]); // children free to choose
    int rob  = node.val + L[0] + R[0];                      // children must be skipped
    return new int[]{skip, rob};
}

Tree [3, 2, 3, null, 3, null, 1] → 7 (3 + 3 + 1). Time O(n) (each node visited once), space O(h) for the recursion, where h is the tree height.

Bitmask DP: Travelling Salesman for small n

When n is at most about 20, a set of visited items fits in the bits of an int: bit i set means item i is in the set. 1 << n masks times n endpoints is small enough.

  • State: dp[mask][v] = cheapest path that starts at city 0, visits exactly the cities in mask, and ends at v.
  • Transition: from (mask, v) go to an unvisited u: dp[mask | 1<<u][u] = min(…, dp[mask][v] + dist[v][u]).
static int tsp(int[][] dist) {
    int n = dist.length, FULL = 1 << n, INF = Integer.MAX_VALUE / 2;
    int[][] dp = new int[FULL][n];
    for (int[] row : dp) Arrays.fill(row, INF);
    dp[1][0] = 0;                              // start at city 0; mask = {0}
    for (int mask = 1; mask < FULL; mask++) {
        for (int v = 0; v < n; v++) {
            if (dp[mask][v] == INF || (mask & (1 << v)) == 0) continue;
            for (int u = 0; u < n; u++) {
                if ((mask & (1 << u)) != 0) continue;          // already visited
                int next = mask | (1 << u);
                dp[next][u] = Math.min(dp[next][u], dp[mask][v] + dist[v][u]);
            }
        }
    }
    int best = INF;
    for (int v = 1; v < n; v++)                                // return home to 0
        best = Math.min(best, dp[FULL - 1][v] + dist[v][0]);
    return best;
}

For the classic 4-city matrix {{0,10,15,20},{10,0,35,25},{15,35,0,30},{20,25,30,0}} it returns 80. Time O(2^n · n^2), space O(2^n · n) — compared with O(n!) for trying every order. Masks increase numerically, and adding a bit always makes a larger number, so smaller masks are always ready first.

State machine: Buy and Sell Stock

Give each day a few states (holding a share, just sold, resting) and write how you move between them. Each state keeps the best profit so far in that state.

buy (-price) sell (+price) REST ----------------> HOLD ----------------> SOLD ^ \__ rest __/ \__ hold __/ | | | +------------------ cooldown (1 day) --------------+
// One transaction (LC 121): track the cheapest price seen so far. O(n), O(1)
static int maxProfitOne(int[] prices) {
    int minPrice = Integer.MAX_VALUE, best = 0;
    for (int p : prices) {
        minPrice = Math.min(minPrice, p);
        best = Math.max(best, p - minPrice);
    }
    return best;
}

// Unlimited transactions with a 1-day cooldown after selling (LC 309). O(n), O(1)
static int maxProfitCooldown(int[] prices) {
    int hold = Integer.MIN_VALUE / 2;   // own a share
    int sold = Integer.MIN_VALUE / 2;   // just sold today (tomorrow is cooldown)
    int rest = 0;                       // no share, free to buy
    for (int p : prices) {
        int prevHold = hold, prevSold = sold, prevRest = rest;
        hold = Math.max(prevHold, prevRest - p);   // keep holding, or buy from rest
        sold = prevHold + p;                       // sell what we held
        rest = Math.max(prevRest, prevSold);       // stay resting, or cooldown ends
    }
    return Math.max(sold, rest);
}

// Unlimited transactions, fee per sale (LC 714). O(n), O(1)
static int maxProfitFee(int[] prices, int fee) {
    int hold = -prices[0], cash = 0;
    for (int i = 1; i < prices.length; i++) {
        int p = prices[i];
        int newCash = Math.max(cash, hold + p - fee);  // sell today
        int newHold = Math.max(hold, cash - p);        // buy today
        cash = newCash; hold = newHold;
    }
    return cash;
}

Results: [7,1,5,3,6,4] one transaction → 5; [1,2,3,0,2] with cooldown → 3; [1,3,2,8,4,9] with fee 2 → 8. For "at most k transactions" (LC 188), add a dimension: hold[j] and cash[j] for j = 1..k, giving O(n·k).

Pitfalls across all patterns: (1) Using Integer.MAX_VALUE as infinity and then adding 1 — it overflows to a negative number; use amount + 1 or MAX_VALUE / 2. (2) Off-by-one between "first i items" (size n+1) and "index i" (size n) — pick one meaning and write it as a comment. (3) Wrong loop direction in 1D knapsack. (4) Returning dp[n-1] when the answer is the max over all dp[i] (LIS). (5) Forgetting to save updated values at the same time in state machines — copy the old values first.

26. Shortest Paths

In plain words

A shortest path is the route between two nodes with the smallest total weight (the cost written on each edge: distance, time, price). With no weights, "shortest" just means fewest edges.

The central move in every algorithm here is relaxation: "I know a way to reach u costing dist[u]. If going u → v gives v a cheaper cost than it has now, update dist[v]." The algorithms differ only in the order they relax edges.

Water poured at the source spreads through pipes of different lengths. It reaches the nearest junctions first, and each junction is "wet" at the earliest moment any route could get there. Dijkstra simulates this: always process the closest unfinished node next.

Which algorithm when

GraphAlgorithmTimeNotes
Unweighted (all edges cost 1)BFSO(V + E)First time you reach a node is the shortest
Weights only 0 or 10-1 BFS (deque)O(V + E)0-edges to the front, 1-edges to the back
Non-negative weights, one sourceDijkstra (heap)O((V + E) log V)The default choice
Negative weights, one source; or "at most K edges"Bellman-FordO(V · E)Detects negative cycles
All pairs, small V (up to ~400)Floyd-WarshallO(V^3)Handles negative edges; 3 nested loops
DAG (no cycles), any weightsTopological order + relaxO(V + E)See section 27

Dijkstra — the idea

Keep a min-heap of (node, distance). Pop the closest node. Because all weights are non-negative, nothing found later can make it cheaper — its distance is final. Relax its outgoing edges and push any improved neighbours.

4 1 2 1 5 3 A B C D E 0 4 → 3 1 6 → 4 7
Numbers next to nodes show dist[] changing over time: B first gets 4 (direct), then 3 via C; D first gets 6 via C, then 4 via B. Highlighted edges form the shortest-path tree.
heap (node,dist) pop action dist A B C D E [(A,0)] (A,0) B = 4, C = 1 0 4 1 - - [(C,1),(B,4)] (C,1) B: 1+2=3 < 4 update; D = 1+5 = 6 0 3 1 6 - [(B,3),(B,4),(D,6)] (B,3) D: 3+1=4 < 6 update 0 3 1 4 - [(B,4),(D,4),(D,6)] (B,4) stale (4 > dist[B]=3), skip 0 3 1 4 - [(D,4),(D,6)] (D,4) E = 4+3 = 7 0 3 1 4 7 [(D,6),(E,7)] (D,6) stale, skip [(E,7)] (E,7) no out-edges 0 3 1 4 7

Dijkstra — reusable Java template

// graph.get(u) = list of {v, w}. Returns dist[] from src (MAX_VALUE = unreachable).
static int[] dijkstra(List<List<int[]>> graph, int src) {
    int n = graph.size();
    int[] dist = new int[n];
    Arrays.fill(dist, Integer.MAX_VALUE);
    dist[src] = 0;
    // heap entries are {node, distance}, smallest distance first
    PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Integer.compare(a[1], b[1]));
    pq.offer(new int[]{src, 0});
    while (!pq.isEmpty()) {
        int[] top = pq.poll();
        int u = top[0], d = top[1];
        if (d > dist[u]) continue;                 // stale entry: a shorter path was already found
        for (int[] e : graph.get(u)) {
            int v = e[0], nd = d + e[1];
            if (nd < dist[v]) {                    // relax
                dist[v] = nd;
                pq.offer(new int[]{v, nd});
            }
        }
    }
    return dist;
}

On the graph above it returns [0, 3, 1, 4, 7]. Time O((V + E) log V): each edge can push once, each push/pop costs log. Space O(V + E).

  • Lazy deletion: Java's PriorityQueue has no fast "decrease key", so we push a new entry and skip the old one when it pops (the d > dist[u] line). Without that check the code is still correct but slower.
  • Use Integer.compare, not a[1] - b[1]: subtraction can overflow with large distances and sort wrongly.
  • Use long distances if a path can exceed about 2.1 billion.
  • For one target, you may stop as soon as the target is popped.

Why Dijkstra fails with negative edges

Dijkstra's promise — "the popped node is final" — assumes a longer route can never become cheaper later. A negative edge breaks that. Take edges A→B 1, A→C 5, C→B -10, B→D 1. The textbook version (mark a node "done" when popped) finalises B = 1 and D = 2, then later finds A→C→B = -5 — but B is already done. Running it gives [0, 1, 5, 2]; the true answer (Bellman-Ford) is [0, -5, 5, -4].

Subtle point: the lazy template above happens to fix this small case because it lets nodes be re-relaxed, but with negative edges it loses its guarantee and can take exponential time. The rule stays simple: negative edges mean Bellman-Ford (or SPFA), never Dijkstra.

0-1 BFS

If every weight is 0 or 1, use a deque instead of a heap: a 0-edge keeps the same distance, so push it to the front; a 1-edge goes to the back. The deque stays sorted, like a heap that never needs log. Typical problem: minimum walls to remove, minimum cost to make a grid path valid (LC 1368).

static int[] zeroOneBfs(List<List<int[]>> graph, int src) {
    int[] dist = new int[graph.size()];
    Arrays.fill(dist, Integer.MAX_VALUE);
    dist[src] = 0;
    Deque<Integer> dq = new ArrayDeque<>();
    dq.offerFirst(src);
    while (!dq.isEmpty()) {
        int u = dq.pollFirst();
        for (int[] e : graph.get(u)) {
            int v = e[0], w = e[1];                // w is 0 or 1
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                if (w == 0) dq.offerFirst(v);      // free edge: same level, go to front
                else        dq.offerLast(v);
            }
        }
    }
    return dist;
}

Time O(V + E), space O(V).

Bellman-Ford

Relax every edge, V - 1 times. After round k, every shortest path that uses at most k edges is correct. A shortest path without cycles has at most V - 1 edges, so that many rounds suffice. If a V-th round still improves something, a negative cycle exists (you could loop forever and keep getting cheaper).

// edges: {u, v, w}. Returns null if a negative cycle is reachable from src.
static int[] bellmanFord(int n, int[][] edges, int src) {
    final int INF = Integer.MAX_VALUE;
    int[] dist = new int[n];
    Arrays.fill(dist, INF);
    dist[src] = 0;
    for (int round = 1; round <= n - 1; round++) {
        boolean changed = false;
        for (int[] e : edges) {
            if (dist[e[0]] != INF && dist[e[0]] + e[2] < dist[e[1]]) {   // skip unreachable u
                dist[e[1]] = dist[e[0]] + e[2];
                changed = true;
            }
        }
        if (!changed) break;                               // already settled: stop early
    }
    for (int[] e : edges)                                  // one more round: still improving?
        if (dist[e[0]] != INF && dist[e[0]] + e[2] < dist[e[1]])
            return null;                                   // negative cycle
    return dist;
}

Time O(V · E), space O(V). The != INF check matters: INF + negative would look like a real small distance.

Floyd-Warshall (all pairs)

d[i][j] = shortest distance from i to j using only nodes 0..k as stepping stones. Each round of k asks: "is going through k better?" It is really a DP over k.

static long[][] floydWarshall(int n, int[][] edges) {
    final long INF = Long.MAX_VALUE / 4;                   // room to add two INFs safely
    long[][] d = new long[n][n];
    for (long[] row : d) Arrays.fill(row, INF);
    for (int i = 0; i < n; i++) d[i][i] = 0;
    for (int[] e : edges) d[e[0]][e[1]] = Math.min(d[e[0]][e[1]], e[2]);
    for (int k = 0; k < n; k++)                  // k MUST be the outermost loop
        for (int i = 0; i < n; i++)
            for (int j = 0; j < n; j++)
                if (d[i][k] + d[k][j] < d[i][j])
                    d[i][j] = d[i][k] + d[k][j];
    return d;                                    // d[i][i] < 0 means i is on a negative cycle
}

Time O(V^3), space O(V^2). Row A of the result for the example graph is 0 3 1 4 7, matching Dijkstra. Putting k in an inner loop is the classic bug — it gives wrong answers silently.

Problem: Network Delay Time (LC 743)

A signal starts at node k; times[i] = {u, v, w}. How long until every node has it? That is the largest shortest distance from k, or -1 if some node is unreachable.

static int networkDelayTime(int[][] times, int n, int k) {
    List<List<int[]>> g = new ArrayList<>();
    for (int i = 0; i <= n; i++) g.add(new ArrayList<>());   // nodes are 1..n
    for (int[] t : times) g.get(t[0]).add(new int[]{t[1], t[2]});
    int[] dist = new int[n + 1];
    Arrays.fill(dist, Integer.MAX_VALUE);
    dist[k] = 0;
    PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Integer.compare(a[1], b[1]));
    pq.offer(new int[]{k, 0});
    while (!pq.isEmpty()) {
        int[] top = pq.poll();
        int u = top[0], d = top[1];
        if (d > dist[u]) continue;
        for (int[] e : g.get(u)) {
            if (d + e[1] < dist[e[0]]) {
                dist[e[0]] = d + e[1];
                pq.offer(new int[]{e[0], dist[e[0]]});
            }
        }
    }
    int ans = 0;
    for (int i = 1; i <= n; i++) {
        if (dist[i] == Integer.MAX_VALUE) return -1;   // someone never gets the signal
        ans = Math.max(ans, dist[i]);
    }
    return ans;
}

times = [[2,1,1],[2,3,1],[3,4,1]], n = 4, k = 2 → 2. Time O(E log V), space O(V + E). Why it works: every node receives the signal at its shortest-path time, so the last one to receive it decides the answer.

Problem: Cheapest Flights Within K Stops (LC 787)

K stops means at most K + 1 flights (edges). Bellman-Ford's "after round r, paths with at most r edges are correct" is exactly this limit — run just K + 1 rounds.

static int findCheapestPrice(int n, int[][] flights, int src, int dst, int k) {
    final int INF = Integer.MAX_VALUE;
    int[] dist = new int[n];
    Arrays.fill(dist, INF);
    dist[src] = 0;
    for (int round = 0; round <= k; round++) {           // k stops = at most k+1 flights
        int[] next = dist.clone();                       // read ONLY last round's values
        for (int[] f : flights) {
            int u = f[0], v = f[1], price = f[2];
            if (dist[u] != INF && dist[u] + price < next[v])
                next[v] = dist[u] + price;
        }
        dist = next;
    }
    return dist[dst] == INF ? -1 : dist[dst];
}

With flights 0→1 100, 1→2 100, 0→2 500: k = 1 → 200, k = 0 → 500. Time O(K · E), space O(V).

Why the copy? Without dist.clone(), a round can chain several edges (update 1, then immediately use the new 1 to update 2) and exceed the stop limit. Plain Dijkstra on cost also fails here: the cheapest way to reach a middle city may use too many stops, while a pricier route with fewer stops is the one that works.

Problem: Path With Minimum Effort (LC 1631)

On a height grid, a path's effort is the largest height difference between consecutive cells. Minimise it. Dijkstra still works when the path cost is "max of edges" instead of "sum of edges", because extending a path never makes its cost go down.

static int minimumEffortPath(int[][] heights) {
    int R = heights.length, C = heights[0].length;
    int[][] effort = new int[R][C];
    for (int[] row : effort) Arrays.fill(row, Integer.MAX_VALUE);
    effort[0][0] = 0;
    PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Integer.compare(a[2], b[2])); // {r, c, effort}
    pq.offer(new int[]{0, 0, 0});
    int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
    while (!pq.isEmpty()) {
        int[] cur = pq.poll();
        int r = cur[0], c = cur[1], e = cur[2];
        if (r == R - 1 && c == C - 1) return e;          // first time popped = final answer
        if (e > effort[r][c]) continue;
        for (int[] d : dirs) {
            int nr = r + d[0], nc = c + d[1];
            if (nr < 0 || nc < 0 || nr >= R || nc >= C) continue;
            int ne = Math.max(e, Math.abs(heights[nr][nc] - heights[r][c])); // cost = max step
            if (ne < effort[nr][nc]) {
                effort[nr][nc] = ne;
                pq.offer(new int[]{nr, nc, ne});
            }
        }
    }
    return 0;
}

[[1,2,2],[3,8,2],[5,3,5]] → 2. Time O(R·C·log(R·C)), space O(R·C). Alternatives: binary search on the answer + BFS, or union-find adding edges in increasing weight until corner meets corner.

Pitfalls: building an undirected graph with edges in only one direction; 1-indexed node labels with a 0-indexed array of size n; comparator overflow; forgetting unreachable nodes (MAX_VALUE) when taking a max or sum.

27. Topological Sort, MST & More

In plain words

A topological sort lists the nodes of a directed graph so that every edge u → v has u before v. It only exists when the graph has no directed cycle — a DAG (directed acyclic graph).

A minimum spanning tree (MST) picks edges of a connected, undirected, weighted graph so that every node is connected, there are no cycles, and the total weight is as small as possible. It always has exactly V - 1 edges.

Topological sort is getting dressed: socks before shoes, shirt before tie before jacket. Any order that respects those rules works. An MST is laying cable between towns: connect every town with the least total cable, and never lay a redundant loop.

Topological sort — Kahn's algorithm (BFS on indegree)

The indegree of a node is how many edges point into it — how many things must happen before it. Start with every node whose indegree is 0 (no prerequisites). Take one, "finish" it, and lower the indegree of everything it points to. Anything that drops to 0 is now ready.

// Edge {u, v} means u must come before v.
// Returns a topological order, or an empty array if there is a cycle.
static int[] topoKahn(int n, int[][] edges) {
    List<List<Integer>> g = new ArrayList<>();
    for (int i = 0; i < n; i++) g.add(new ArrayList<>());
    int[] indeg = new int[n];
    for (int[] e : edges) { g.get(e[0]).add(e[1]); indeg[e[1]]++; }

    Deque<Integer> q = new ArrayDeque<>();
    for (int i = 0; i < n; i++) if (indeg[i] == 0) q.offer(i);   // no prerequisites
    int[] order = new int[n];
    int idx = 0;
    while (!q.isEmpty()) {
        int u = q.poll();
        order[idx++] = u;
        for (int v : g.get(u))
            if (--indeg[v] == 0) q.offer(v);   // its last prerequisite is done
    }
    return idx == n ? order : new int[0];      // fewer than n placed => cycle
}
nodes: 0 shirt, 1 tie, 2 jacket, 3 pants, 4 shoes edges: shirt->tie, tie->jacket, pants->shoes, pants->jacket indegree: shirt 0, tie 1, jacket 2, pants 0, shoes 1 queue [shirt, pants] pop shirt -> tie 1->0, enqueue queue [pants, tie] pop pants -> shoes 1->0, jacket 2->1 queue [tie, shoes] pop tie -> jacket 1->0, enqueue queue [shoes, jacket] pop shoes queue [jacket] pop jacket queue [] order: shirt, pants, tie, shoes, jacket (5 of 5 placed: no cycle)

Running it gives [0, 3, 1, 4, 2]. Time O(V + E), space O(V + E). Cycle detection for free: nodes on a cycle never reach indegree 0, so fewer than V nodes get placed.

Topological sort — DFS with three colours

Colour each node: white = not visited, gray = on the current DFS path (still exploring its descendants), black = completely finished. Reaching a gray node means you walked back into your own path: a cycle. When a node turns black, push it on a stack; the stack from top to bottom is a topological order (reverse post-order).

static final int WHITE = 0, GRAY = 1, BLACK = 2;
static List<List<Integer>> G;
static int[] color;
static Deque<Integer> stack;

static List<Integer> topoDfs(int n, int[][] edges) {
    G = new ArrayList<>();
    for (int i = 0; i < n; i++) G.add(new ArrayList<>());
    for (int[] e : edges) G.get(e[0]).add(e[1]);
    color = new int[n];
    stack = new ArrayDeque<>();
    for (int i = 0; i < n; i++)
        if (color[i] == WHITE && hasCycle(i)) return List.of();   // cycle: no order
    return new ArrayList<>(stack);             // top of stack first
}
// true if a cycle is reachable from u
static boolean hasCycle(int u) {
    color[u] = GRAY;                           // on the current path
    for (int v : G.get(u)) {
        if (color[v] == GRAY) return true;     // back edge => cycle
        if (color[v] == WHITE && hasCycle(v)) return true;
    }
    color[u] = BLACK;                          // fully explored
    stack.push(u);                             // push AFTER all descendants
    return false;
}

On the clothing graph it returns [3, 4, 0, 1, 2] (pants, shoes, shirt, tie, jacket) — a different but equally valid order. Time O(V + E), space O(V) plus recursion depth.

Why two colours are not enough for directed graphs: with just "visited", reaching an already-visited node might be a harmless cross edge (a diamond A→B→D, A→C→D), not a cycle. Only an edge back to a node still on the current path (gray) proves a cycle. In an undirected graph, "visited and not my parent" is enough.

Problem: Course Schedule I & II (LC 207, 210)

prerequisites[i] = {a, b} means "take b before a" — so the edge goes b → a. Getting this direction backwards is the most common bug. Course Schedule I asks "can you finish?" (is it a DAG?); II asks for an order.

static int[] findOrder(int numCourses, int[][] prerequisites) {
    List<List<Integer>> g = new ArrayList<>();
    for (int i = 0; i < numCourses; i++) g.add(new ArrayList<>());
    int[] indeg = new int[numCourses];
    for (int[] p : prerequisites) {            // p = {course, prereq}: prereq -> course
        g.get(p[1]).add(p[0]);
        indeg[p[0]]++;
    }
    Deque<Integer> q = new ArrayDeque<>();
    for (int i = 0; i < numCourses; i++) if (indeg[i] == 0) q.offer(i);
    int[] order = new int[numCourses];
    int idx = 0;
    while (!q.isEmpty()) {
        int u = q.poll();
        order[idx++] = u;
        for (int v : g.get(u)) if (--indeg[v] == 0) q.offer(v);
    }
    return idx == numCourses ? order : new int[0];
}

static boolean canFinish(int numCourses, int[][] prerequisites) {
    return findOrder(numCourses, prerequisites).length == numCourses;
}

canFinish(2, [[1,0]]) → true; canFinish(2, [[1,0],[0,1]]) → false. findOrder(4, [[1,0],[2,0],[3,1],[3,2]]) → [0, 1, 2, 3]. Time O(V + E), space O(V + E).

Problem idea: Alien Dictionary (LC 269)

Words are sorted in an unknown alphabet; find the letter order. Compare each pair of adjacent words: the first position where they differ tells you one rule, x comes before y. Those rules are edges; the answer is a topological sort of the letters.

static String alienOrder(String[] words) {
    Map<Character, Set<Character>> g = new HashMap<>();
    Map<Character, Integer> indeg = new HashMap<>();
    for (String w : words)
        for (char c : w.toCharArray()) { g.putIfAbsent(c, new HashSet<>()); indeg.putIfAbsent(c, 0); }
    for (int i = 0; i + 1 < words.length; i++) {
        String a = words[i], b = words[i + 1];
        if (a.length() > b.length() && a.startsWith(b)) return "";  // "abc" before "ab": invalid
        for (int j = 0; j < Math.min(a.length(), b.length()); j++) {
            char x = a.charAt(j), y = b.charAt(j);
            if (x != y) {                                           // first difference decides
                if (g.get(x).add(y)) indeg.merge(y, 1, Integer::sum); // count each edge once
                break;
            }
        }
    }
    Deque<Character> q = new ArrayDeque<>();
    for (var e : indeg.entrySet()) if (e.getValue() == 0) q.offer(e.getKey());
    StringBuilder sb = new StringBuilder();
    while (!q.isEmpty()) {
        char c = q.poll();
        sb.append(c);
        for (char nx : g.get(c)) if (indeg.merge(nx, -1, Integer::sum) == 0) q.offer(nx);
    }
    return sb.length() == indeg.size() ? sb.toString() : "";      // cycle => ""
}

["wrt","wrf","er","ett","rftt"] → "wertf"; ["z","x","z"] → "" (cycle). Time O(total characters), space O(alphabet^2) at most for edges.

Minimum spanning tree

Both MST algorithms rely on the cut property: split the nodes into any two groups; the cheapest edge crossing between them is safe to include in some MST. Kruskal and Prim are two ways of repeatedly finding such an edge.

4 1 2 5 8 3 9 0 1 2 3 4 Kruskal, cheapest first 0-2 (1) take 1-2 (2) take 3-4 (3) take 0-1 (4) skip 0, 1 already joined 1-3 (5) take 4 edges, total 11
Solid highlighted edges form the MST; dashed edges are rejected. Kruskal stops as soon as it has V - 1 = 4 edges.

Kruskal (sort edges + union-find)

Sort all edges by weight. Walk through them cheapest first; take an edge if it connects two different components, skip it if both ends are already connected (it would make a cycle). Union-find answers "already connected?" in almost O(1).

static class DSU {
    int[] parent, rank;
    DSU(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];
    }
    boolean union(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return false;                       // already connected
        if (rank[ra] < rank[rb]) { int t = ra; ra = rb; rb = t; }
        parent[rb] = ra;                                  // union by rank
        if (rank[ra] == rank[rb]) rank[ra]++;
        return true;
    }
}

static int kruskal(int n, int[][] edges) {                // edges {u, v, w}
    int[][] sorted = edges.clone();
    Arrays.sort(sorted, (a, b) -> Integer.compare(a[2], b[2]));   // cheapest first
    DSU dsu = new DSU(n);
    int cost = 0, used = 0;
    for (int[] e : sorted) {
        if (dsu.union(e[0], e[1])) {                      // joins two different trees?
            cost += e[2];
            if (++used == n - 1) break;                   // a tree has n-1 edges
        }
    }
    return used == n - 1 ? cost : -1;                     // -1: graph is disconnected
}

Returns 11 on the graph above. Time O(E log E) for the sort; space O(V + E). Best for sparse graphs or when edges come as a list.

Prim (grow one tree with a heap)

Start from any node. Repeatedly add the cheapest edge that leaves the current tree. It looks like Dijkstra, but the heap key is the edge weight, not the total distance from the start.

static int prim(int n, int[][] edges) {
    List<List<int[]>> g = new ArrayList<>();
    for (int i = 0; i < n; i++) g.add(new ArrayList<>());
    for (int[] e : edges) {                              // undirected: add both ways
        g.get(e[0]).add(new int[]{e[1], e[2]});
        g.get(e[1]).add(new int[]{e[0], e[2]});
    }
    boolean[] inTree = new boolean[n];
    PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Integer.compare(a[1], b[1])); // {node, edgeWeight}
    pq.offer(new int[]{0, 0});
    int cost = 0, count = 0;
    while (!pq.isEmpty() && count < n) {
        int[] top = pq.poll();
        int u = top[0];
        if (inTree[u]) continue;                         // already joined via a cheaper edge
        inTree[u] = true;
        cost += top[1];
        count++;
        for (int[] e : g.get(u))
            if (!inTree[e[0]]) pq.offer(new int[]{e[0], e[1]});
    }
    return count == n ? cost : -1;
}

Also 11. Time O(E log V), space O(V + E).

Problem: Min Cost to Connect All Points (LC 1584)

Every pair of points is an edge with cost = Manhattan distance |x1-x2| + |y1-y2|. That is a complete graph with about n^2 / 2 edges, so the array version of Prim — no heap, just scan for the closest point each step — is the best fit: O(n^2) time and only O(n) memory.

static int minCostConnectPoints(int[][] points) {
    int n = points.length, cost = 0;
    int[] best = new int[n];                   // cheapest edge from the tree to each point
    Arrays.fill(best, Integer.MAX_VALUE);
    boolean[] inTree = new boolean[n];
    best[0] = 0;
    for (int step = 0; step < n; step++) {
        int u = -1;
        for (int i = 0; i < n; i++)            // pick the closest point not yet in the tree
            if (!inTree[i] && (u == -1 || best[i] < best[u])) u = i;
        inTree[u] = true;
        cost += best[u];
        for (int v = 0; v < n; v++) {          // does u offer anyone a cheaper link?
            if (inTree[v]) continue;
            int d = Math.abs(points[u][0] - points[v][0]) + Math.abs(points[u][1] - points[v][1]);
            if (d < best[v]) best[v] = d;
        }
    }
    return cost;
}

[[0,0],[2,2],[3,10],[5,2],[7,0]] → 20. Why it works: each step adds the cheapest edge crossing the cut between "in tree" and "not in tree", which the cut property says is safe. Kruskal would need to build and sort all n^2 edges: O(n^2 log n) time and O(n^2) memory.

Strongly connected components — Kosaraju in easy words

In a directed graph, a strongly connected component (SCC) is a group where every node can reach every other node. Squash each SCC into one node and the graph becomes a DAG.

  1. DFS the whole graph and record nodes in the order they finish.
  2. Reverse every edge.
  3. Take nodes in reverse finish order; each DFS on the reversed graph from an unvisited node collects exactly one SCC.

Why: the node that finishes last belongs to a "source" component. In the reversed graph, that component cannot leak out into others, so one DFS grabs just it.

static int kosaraju(int n, int[][] edges) {
    List<List<Integer>> g = new ArrayList<>(), rg = new ArrayList<>();
    for (int i = 0; i < n; i++) { g.add(new ArrayList<>()); rg.add(new ArrayList<>()); }
    for (int[] e : edges) { g.get(e[0]).add(e[1]); rg.get(e[1]).add(e[0]); }
    boolean[] seen = new boolean[n];
    Deque<Integer> finish = new ArrayDeque<>();
    for (int i = 0; i < n; i++) if (!seen[i]) dfs1(i, g, seen, finish);  // pass 1: finish order
    Arrays.fill(seen, false);
    int count = 0;
    while (!finish.isEmpty()) {                                          // pass 2: reversed graph
        int u = finish.pop();                                            // latest finisher first
        if (!seen[u]) { dfs2(u, rg, seen); count++; }                    // one SCC per tree
    }
    return count;
}
static void dfs1(int u, List<List<Integer>> g, boolean[] seen, Deque<Integer> finish) {
    seen[u] = true;
    for (int v : g.get(u)) if (!seen[v]) dfs1(v, g, seen, finish);
    finish.push(u);                                  // pushed when u is finished
}
static void dfs2(int u, List<List<Integer>> rg, boolean[] seen) {
    seen[u] = true;
    for (int v : rg.get(u)) if (!seen[v]) dfs2(v, rg, seen);
}

Edges 0→1, 1→2, 2→0, 1→3, 3→4 give 3 SCCs: {0,1,2}, {3}, {4}. Time O(V + E), space O(V + E). Tarjan's algorithm finds SCCs in one pass using the same low values as below.

Bridges and articulation points — Tarjan's idea

In an undirected graph, a bridge is an edge whose removal disconnects the graph; an articulation point is such a node. They show up as "critical connections in a network" (LC 1192), single points of failure, and road networks.

During DFS, give each node a discovery time disc[u] and compute low[u] = the earliest discovery time reachable from u's subtree using at most one back edge. If child v has low[v] > disc[u], nothing under v can climb back above u without the edge u–v: it is a bridge. (For articulation points the test is low[v] >= disc[u], with a special rule for the root: it is one only if it has two or more DFS children.)

static int timer;
static List<List<Integer>> criticalConnections(int n, List<List<Integer>> connections) {
    List<List<Integer>> g = new ArrayList<>();
    for (int i = 0; i < n; i++) g.add(new ArrayList<>());
    for (List<Integer> c : connections) { g.get(c.get(0)).add(c.get(1)); g.get(c.get(1)).add(c.get(0)); }
    int[] disc = new int[n], low = new int[n];
    Arrays.fill(disc, -1);                               // -1 = not visited
    List<List<Integer>> bridges = new ArrayList<>();
    timer = 0;
    for (int i = 0; i < n; i++) if (disc[i] == -1) bridgeDfs(i, -1, g, disc, low, bridges);
    return bridges;
}
static void bridgeDfs(int u, int parent, List<List<Integer>> g, int[] disc, int[] low, List<List<Integer>> out) {
    disc[u] = low[u] = timer++;
    for (int v : g.get(u)) {
        if (v == parent) continue;                       // don't reuse the edge we came in on
        if (disc[v] == -1) {
            bridgeDfs(v, u, g, disc, low, out);
            low[u] = Math.min(low[u], low[v]);
            if (low[v] > disc[u]) out.add(List.of(u, v)); // v's subtree can't reach above u
        } else {
            low[u] = Math.min(low[u], disc[v]);          // back edge
        }
    }
}

A triangle 0-1-2 plus edge 1-3 gives [[1, 3]]. Time O(V + E), space O(V + E). For large inputs, recursion depth can reach V; convert to an iterative DFS or run in a thread with a bigger stack.

Pitfalls: wrong edge direction for prerequisites; using two-colour visited for directed cycle detection; running an MST algorithm on a directed graph (that is a different problem, "arborescence"); forgetting that MST needs a connected graph — check you used V - 1 edges.

28. String Algorithms

In plain words

Pattern matching means finding where a short string (the pattern, length m) appears inside a long one (the text, length n). The simple way re-checks the same characters again and again; the clever algorithms remember what they already matched so they never go backwards in the text.

Looking for "abab" in a book: after reading "aba" and hitting a mismatch, a smart reader does not jump back to the next letter and start over — they notice the "a" they just read could already be the start of a new match, and carry on from there. KMP is exactly that habit, written down as a table.

Naive matching and why it is slow

// Try every start position. O(n·m) worst case, O(1) space
static int naiveSearch(String text, String pat) {
    int n = text.length(), m = pat.length();
    for (int i = 0; i + m <= n; i++) {          // every possible start
        int j = 0;
        while (j < m && text.charAt(i + j) == pat.charAt(j)) j++;
        if (j == m) return i;
    }
    return -1;
}

Text "aaaaaaaaab", pattern "aaab": at every start the loop matches three a's, then fails on b, then slides by one and re-reads those same a's. That is up to m comparisons at each of n starts: O(n·m). On random text it is usually fine, and Java's String.indexOf works this way — but interviewers test with the bad case.

KMP (Knuth–Morris–Pratt)

KMP precomputes, for the pattern alone, the LPS array: lps[i] = length of the longest proper prefix of pat[0..i] that is also a suffix of it ("proper" = not the whole string). On a mismatch after matching j characters, we already know the last lps[j-1] characters of the text equal the start of the pattern — so we continue from there, and the text pointer never moves back.

Traced for pattern "ababaca":

icharcompare with pat[len]what happenslps[i]
0a—always 00
1bpat[0] = amismatch, len stays 00
2apat[0] = amatch, len = 1 ("a")1
3bpat[1] = bmatch, len = 2 ("ab")2
4apat[2] = amatch, len = 3 ("aba")3
5cpat[3] = bmismatch: len = lps[2] = 1; pat[1] = b, mismatch: len = lps[0] = 0; pat[0] = a, mismatch0
6apat[0] = amatch, len = 11
// lps[i] = length of the longest proper prefix of p[0..i] that is also a suffix of it
static int[] buildLps(String p) {
    int m = p.length();
    int[] lps = new int[m];
    int len = 0;                                // length of the current matched prefix
    for (int i = 1; i < m; i++) {
        while (len > 0 && p.charAt(i) != p.charAt(len))
            len = lps[len - 1];                 // fall back to the next shorter border
        if (p.charAt(i) == p.charAt(len)) len++;
        lps[i] = len;
    }
    return lps;
}

// Find the Index of the First Occurrence in a String (LC 28)
static int kmpSearch(String text, String pat) {
    if (pat.isEmpty()) return 0;
    int[] lps = buildLps(pat);
    int j = 0;                                  // chars of pat matched so far
    for (int i = 0; i < text.length(); i++) {
        while (j > 0 && text.charAt(i) != pat.charAt(j))
            j = lps[j - 1];                     // shift the pattern; i never moves back
        if (text.charAt(i) == pat.charAt(j)) j++;
        if (j == pat.length()) return i - j + 1; // for ALL matches: record, then j = lps[j - 1]
    }
    return -1;
}

buildLps("ababaca") = [0, 0, 1, 2, 3, 0, 1]; kmpSearch("sadbutsad", "sad") = 0; kmpSearch("leetcode", "leeto") = -1; kmpSearch("ababcabcabababd", "ababd") = 10. Collecting all matches of "aa" in "aaaaa" gives [0, 1, 2, 3] (overlaps included).

Time O(n + m): i only moves forward, and j can only fall back as many times as it went up. Space O(m) for the LPS array.

Rabin-Karp (rolling hash)

Turn each length-m window of the text into a number (a hash) and compare numbers instead of strings. The trick is rolling: sliding the window one step right updates the hash in O(1) — subtract the leftmost character's contribution, multiply by the base, add the new character.

hash("abc") = a·B^2 + b·B + c (mod a large prime) slide to "bcd": remove a: hash - a·B^2 shift: (…) · B add d: (…) + d = b·B^2 + c·B + d
static int rabinKarp(String text, String pat) {
    int n = text.length(), m = pat.length();
    if (m > n) return -1;
    final long MOD = 1_000_000_007L, BASE = 131;
    long power = 1;                              // BASE^(m-1) % MOD, weight of the leading char
    for (int i = 1; i < m; i++) power = power * BASE % MOD;
    long hp = 0, ht = 0;
    for (int i = 0; i < m; i++) {
        hp = (hp * BASE + pat.charAt(i)) % MOD;
        ht = (ht * BASE + text.charAt(i)) % MOD;
    }
    for (int i = 0; ; i++) {
        // equal hashes mean PROBABLY equal strings: confirm to rule out a collision
        if (hp == ht && text.regionMatches(i, pat, 0, m)) return i;
        if (i + m == n) return -1;
        ht = (ht - text.charAt(i) * power % MOD + MOD) % MOD;   // drop left char (+MOD keeps it >= 0)
        ht = (ht * BASE + text.charAt(i + m)) % MOD;              // add right char
    }
}

rabinKarp("sadbutsad", "but") = 3. Expected time O(n + m); worst case O(n·m) if many windows collide (same hash, different string) and each needs a full check. Space O(1).

  • Collisions are unavoidable (many strings, few hash values). Either verify on a hash match, as above, or use two independent hashes (two mods) so a false match is astronomically unlikely.
  • Use long and a prime modulus around 10^9; hash * BASE then stays below about 1.4·10^11, far inside long.
  • Rabin-Karp shines when you need many patterns of the same length, or substring comparisons: Longest Duplicate Substring (LC 1044) = binary search on length + rolling hash.

String hashing (prefix hashes)

Precompute the hash of every prefix once; then the hash of any substring comes out in O(1). Two substrings with equal hashes are (almost certainly) equal — so "compare two substrings" drops from O(length) to O(1).

static class StringHash {
    static final long MOD = 1_000_000_007L, BASE = 131;
    final long[] h, pw;                         // h[i] = hash of s[0..i), pw[i] = BASE^i
    StringHash(String s) {
        int n = s.length();
        h = new long[n + 1]; pw = new long[n + 1];
        pw[0] = 1;
        for (int i = 0; i < n; i++) {
            h[i + 1] = (h[i] * BASE + s.charAt(i)) % MOD;
            pw[i + 1] = pw[i] * BASE % MOD;
        }
    }
    long get(int l, int r) {                    // hash of s[l..r) in O(1)
        return ((h[r] - h[l] * pw[r - l]) % MOD + MOD) % MOD;
    }
}

For "abcabc", get(0, 3) == get(3, 6) is true ("abc" = "abc"). Build O(n), query O(1), space O(n).

Z-algorithm (briefly)

z[i] = length of the longest substring starting at i that matches a prefix of the string. It keeps a window [l, r) known to match the prefix and reuses earlier values inside it, much like KMP, for O(n) total.

static int[] zArray(String s) {
    int n = s.length();
    int[] z = new int[n];
    int l = 0, r = 0;                            // [l, r) = rightmost window matching a prefix
    for (int i = 1; i < n; i++) {
        if (i < r) z[i] = Math.min(r - i, z[i - l]);   // reuse what we know
        while (i + z[i] < n && s.charAt(z[i]) == s.charAt(i + z[i])) z[i]++;
        if (i + z[i] > r) { l = i; r = i + z[i]; }
    }
    return z;
}

zArray("aabxaab") = [0, 1, 0, 0, 3, 1, 0]. Pattern search: build pat + "$" + text (with a separator that appears in neither) and look for z[i] == m. For "aab$baabaa", z[5] = 3, so "aab" occurs in "baabaa" at index 5 - 4 = 1. Time and space O(n + m).

Problem: Repeated Substring Pattern (LC 459)

Is s some block repeated two or more times? Using LPS: the longest border (prefix = suffix) is lps[n-1]. The shortest possible repeating block has length n - lps[n-1], and s is a repetition exactly when that length divides n (and the border is non-empty).

static boolean repeatedSubstringPattern(String s) {
    int n = s.length();
    int[] lps = buildLps(s);
    int border = lps[n - 1];                    // longest prefix that is also a suffix
    int period = n - border;                    // candidate block length
    return border > 0 && n % period == 0;
}

// The one-line trick: s is a repetition iff s appears inside (s + s) with first and last char removed
static boolean repeatedTrick(String s) {
    return (s + s).substring(1, 2 * s.length() - 1).contains(s);
}

"abab" → true, "aba" → false, "abcabcabcabc" → true. LPS version: O(n) time and space. The trick uses contains (naive search), so it is O(n^2) worst case — say so if you use it.

Palindromes: expand around center

Every palindrome has a center: a single character (odd length, "aba") or the gap between two characters (even length, "abba"). There are 2n - 1 centers. From each, expand outward while both sides match.

// Longest Palindromic Substring (LC 5). O(n^2) time, O(1) space
static String longestPalindrome(String s) {
    int start = 0, best = 0;
    for (int c = 0; c < s.length(); c++) {
        int odd = expand(s, c, c);              // center on a char: "aba"
        int even = expand(s, c, c + 1);         // center between chars: "abba"
        int len = Math.max(odd, even);
        if (len > best) {
            best = len;
            start = c - (len - 1) / 2;          // works for both odd and even lengths
        }
    }
    return s.substring(start, start + best);
}
private static int expand(String s, int l, int r) {
    while (l >= 0 && r < s.length() && s.charAt(l) == s.charAt(r)) { l--; r++; }
    return r - l - 1;                           // loop overshot by one on each side
}

"babad" → "bab", "cbbd" → "bb", "forgeeksskeegfor" → "geeksskeeg". Why this works: every palindrome is found when we expand from its own center, and expansion stops exactly at its edges. It beats the O(n^2)-space DP table dp[i][j] = s[i]==s[j] && dp[i+1][j-1].

Manacher's algorithm finds all palindromes in O(n) by reusing mirror information around the rightmost palindrome found so far (the same "reuse a window" idea as the Z-algorithm). Mention it by name; interviewers rarely expect the code. Related: Palindromic Substrings (LC 647) counts instead of maximises — same expansion, add (len + 1) / 2 per center.

Problem pointer: Minimum Window Substring (LC 76)

"Smallest substring of s containing all characters of t" is not a hashing or KMP problem — it is the variable-size sliding window pattern (see the sliding window section): expand the right pointer until the window is valid (counts of needed chars all met, tracked with a need array and a missing counter), then shrink from the left while it stays valid, recording the best length. Each pointer moves at most n times: O(|s| + |t|) time, O(alphabet) space.

NeedUseTime
Find one pattern, guaranteed linearKMP (or Z)O(n + m)
Compare many substrings, many patterns of one lengthRolling / prefix hashO(n) build, O(1) compare
Period / border questionsLPS arrayO(n)
Longest palindromeExpand around center (Manacher if pressed)O(n^2) / O(n)
Many patterns at onceTrie / Aho-CorasickO(n + total pattern length + matches)
Java string pitfalls: s1 == s2 compares references, use equals. += on a String in a loop is O(n^2) — use StringBuilder. substring copies (O(length)) since Java 7. Subtracting chars (c - 'a') gives an int index; casting back needs (char).

29. Math & Number Theory

In plain words

A small set of math tools comes up again and again: greatest common divisor, prime numbers, fast powers, and doing arithmetic modulo a big prime so numbers never get too large. Each has a short, well-known algorithm you should be able to write from memory.

Modulo (a % m) is the remainder after dividing by m. Working "mod m" means you only ever keep that remainder.

A clock is arithmetic mod 12: 9 o'clock plus 5 hours is 2 o'clock, not 14. You never need to know how many days passed — only where the hand is. Problems that say "return the answer mod 10^9+7" want exactly this: the hand position, not the giant number.

GCD and LCM (Euclid)

The greatest common divisor gcd(a, b) is the largest number dividing both. Euclid's rule: gcd(a, b) = gcd(b, a % b), and gcd(a, 0) = a. It works because any number dividing a and b also divides a - k·b, which is what the remainder is.

gcd(48, 18) -> gcd(18, 48 % 18 = 12) -> gcd(12, 6) -> gcd(6, 0) = 6
static long gcd(long a, long b) {
    while (b != 0) {                 // gcd(a, b) = gcd(b, a % b)
        long t = a % b;
        a = b;
        b = t;
    }
    return Math.abs(a);
}
static long lcm(long a, long b) {
    return a / gcd(a, b) * b;        // divide FIRST to avoid overflow
}

gcd(48, 18) = 6, lcm(4, 6) = 12, lcm(10^9, 999,999,999) = 999,999,999,000,000,000 (fits in long only because we divide before multiplying). Time O(log min(a, b)) — the numbers at least halve every two steps. Uses: simplifying fractions, "water jug" problems, GCD of an array (fold gcd over it), slopes as reduced (dy, dx) pairs in Max Points on a Line.

Sieve of Eratosthenes

To find all primes up to n: start with every number marked prime. For each prime i, cross out its multiples. Start crossing at i·i — smaller multiples like 2i, 3i were already crossed out by smaller primes. Stop when i·i > n.

static boolean[] sieve(int n) {                 // isPrime[0..n]
    boolean[] isPrime = new boolean[n + 1];
    Arrays.fill(isPrime, true);
    isPrime[0] = false;
    if (n >= 1) isPrime[1] = false;
    for (int i = 2; (long) i * i <= n; i++) {   // long: i*i can overflow near MAX_VALUE
        if (!isPrime[i]) continue;
        for (int j = i * i; j <= n; j += i)     // start at i*i
            isPrime[j] = false;
    }
    return isPrime;
}

sieve(30) marks 2 3 5 7 11 13 17 19 23 29. Time O(n log log n) — nearly linear. Space O(n). To test a single number, trial division up to sqrt(n) is enough: O(sqrt n).

Problem: Count Primes (LC 204)

// Count primes strictly less than n
static int countPrimes(int n) {
    if (n < 3) return 0;
    boolean[] composite = new boolean[n];       // false = still possibly prime
    int count = 0;
    for (int i = 2; i < n; i++) {
        if (composite[i]) continue;
        count++;                                // i survived every smaller prime
        for (long j = (long) i * i; j < n; j += i) composite[(int) j] = true;
    }
    return count;
}

countPrimes(10) = 4 (2, 3, 5, 7); countPrimes(1_000_000) = 78,498. Time O(n log log n), space O(n). Using composite (default false) saves the Arrays.fill.

Prime factorisation

Divide out 2 as many times as possible, then 3, then 4 (never divides — its 2s are gone), and so on up to sqrt(n). Whatever is left above 1 is itself a prime (it cannot have two factors both bigger than sqrt).

static Map<Long, Integer> factorize(long n) {
    Map<Long, Integer> f = new TreeMap<>();
    for (long p = 2; p * p <= n; p++) {
        while (n % p == 0) {                    // divide out p completely
            f.merge(p, 1, Integer::sum);
            n /= p;
        }
    }
    if (n > 1) f.merge(n, 1, Integer::sum);     // leftover is a prime > sqrt
    return f;
}

factorize(360) = {2=3, 3=2, 5=1} (360 = 2^3 · 3^2 · 5). Time O(sqrt n), space O(log n). For factorising many numbers, sieve the smallest prime factor spf[x] once, then each number factors in O(log x) by repeatedly dividing by spf.

Fast exponentiation (binary power)

To get a^e, don't multiply e times. Write e in binary: a^13 = a^8 · a^4 · a^1 since 13 = 1101 in binary. Keep squaring the base (a, a^2, a^4, a^8…) and multiply it into the result whenever the current bit of e is 1. That is O(log e) multiplications.

static long modPow(long base, long exp, long mod) {
    long result = 1;
    base %= mod;
    if (base < 0) base += mod;
    while (exp > 0) {
        if ((exp & 1) == 1) result = result * base % mod;  // this bit is set: multiply it in
        base = base * base % mod;                          // square for the next bit
        exp >>= 1;
    }
    return result;
}

modPow(2, 10, 10^9+7) = 1024; modPow(2, 10^9, 10^9+7) = 140625001 in about 30 steps. Time O(log exp), space O(1). Safe because both factors are below mod ~ 10^9, so their product is below ~10^18 < Long.MAX_VALUE (~9.2·10^18).

Problem: Pow(x, n) (LC 50)

static double myPow(double x, int n) {
    long e = n;                       // long: -Integer.MIN_VALUE does not fit in an int
    if (e < 0) { x = 1 / x; e = -e; }
    double result = 1;
    while (e > 0) {
        if ((e & 1) == 1) result *= x;
        x *= x;
        e >>= 1;
    }
    return result;
}

myPow(2.0, 10) = 1024.0, myPow(2.0, -2) = 0.25, myPow(1.0, Integer.MIN_VALUE) = 1.0. Time O(log n), space O(1). The classic bug is writing n = -n on an int: -(-2147483648) is still -2147483648.

Modular arithmetic rules and 1_000_000_007

When answers are huge (counting paths, arrangements), problems ask for the result mod 10^9 + 7. Why this number: it is prime (so division works via inverses, below), it fits in an int, and the product of two values below it fits in a long.

OperationRuleJava
Add(a + b) mod m = ((a mod m) + (b mod m)) mod m(a + b) % MOD
SubtractSame, but the result may go negative((a - b) % MOD + MOD) % MOD
Multiply(a · b) mod m = ((a mod m) · (b mod m)) mod ma * b % MOD with long a, b
DivideNot allowed directly; multiply by the modular inversea * modInverse(b) % MOD
PowerBinary exponentiationmodPow(a, e, MOD)
Take the mod after every operation, not once at the end — the intermediate values overflow long before the end. And in Java, % keeps the sign of the left side: -7 % 3 is -1, not 2. Fix it with ((x % m) + m) % m or Math.floorMod(x, m), both of which give 2.

Modular inverse via Fermat

The inverse of a mod p is the number x with a · x ≡ 1 (mod p) — the "mod version" of 1/a. Fermat's little theorem says that for a prime p and a not divisible by p, a^(p-1) ≡ 1. Split one factor off: a · a^(p-2) ≡ 1, so a^(p-2) is the inverse.

static final int MOD = 1_000_000_007;

static long modInverse(long a) {      // valid when MOD is prime and a % MOD != 0
    return modPow(a, MOD - 2, MOD);   // a^(p-2) = a^-1
}

modInverse(2) = 500000004, and 2 · 500000004 % MOD = 1. Time O(log p). If the modulus is not prime, use the extended Euclidean algorithm instead (the inverse exists only when gcd(a, m) = 1).

nCr — Pascal's triangle and factorials mod p

C(n, r) = ways to choose r items from n. Two ways to compute it, depending on size.

Pascal's triangle (n up to a few thousand): C(n, r) = C(n-1, r-1) + C(n-1, r) — either item n is chosen or it isn't. Only additions, so it works with any modulus, even non-prime.

static long[][] pascal(int n) {       // C[i][j] for i, j <= n. O(n^2) time and space
    long[][] C = new long[n + 1][n + 1];
    for (int i = 0; i <= n; i++) {
        C[i][0] = 1;
        for (int j = 1; j <= i; j++)
            C[i][j] = (C[i - 1][j - 1] + C[i - 1][j]) % MOD;  // take item i or not
    }
    return C;
}

Factorials (n up to 10^6 or more): C(n, r) = n! / (r! · (n-r)!). Division mod p means multiplying by inverses. Precompute fact[] and invFact[] once; each query is then O(1).

static long[] fact, invFact;

static void precompute(int n) {                          // O(n + log MOD)
    fact = new long[n + 1];
    invFact = new long[n + 1];
    fact[0] = 1;
    for (int i = 1; i <= n; i++) fact[i] = fact[i - 1] * i % MOD;
    invFact[n] = modInverse(fact[n]);                    // one expensive inverse...
    for (int i = n; i > 0; i--)
        invFact[i - 1] = invFact[i] * i % MOD;           // ...then walk down: 1/(i-1)! = i/i!
}
static long nCr(int n, int r) {                          // O(1) per query
    if (r < 0 || r > n) return 0;
    return fact[n] * invFact[r] % MOD * invFact[n - r] % MOD;
}

Both give C(5, 2) = 10 and C(10, 3) = 120; nCr(100000, 50000) mod 10^9+7 = 149033233. Note the multiplication chain: take % MOD after each product, or the second multiplication overflows.

Overflow handling in Java

CodeResultWhy
Integer.MAX_VALUE + 1-2147483648Silent wrap-around; Java never throws on int/long overflow
1_000_000 * 1_000_000-727379968Both are int, so the product is computed as int before any assignment to long
1_000_000L * 1_000_0001000000000000One long operand makes the whole product long
Math.abs(Integer.MIN_VALUE)-2147483648+2147483648 does not fit in an int
Math.addExact(MAX_VALUE, 1)throws ArithmeticException ("integer overflow")Use addExact / multiplyExact when you want a loud failure
(lo + hi) / 2can overflowUse lo + (hi - lo) / 2 or (lo + hi) >>> 1

Rules of thumb: anything that can exceed ~2·10^9 goes in a long; anything that can exceed ~9·10^18 needs mod arithmetic or BigInteger. In "reverse integer" style problems, check result > (Integer.MAX_VALUE - digit) / 10 before multiplying.

Randomness: reservoir sampling

Pick one item uniformly at random from a stream whose length you don't know (and can't store). Keep the k-th item with probability 1/k, replacing the current choice. Each item ends up chosen with probability exactly 1/n: item k is picked with 1/k, then survives each later step j with (j-1)/j, and the product telescopes to 1/n.

static int reservoirPick(Iterator<Integer> stream, Random rnd) {
    int chosen = -1, seen = 0;
    while (stream.hasNext()) {
        int x = stream.next();
        seen++;
        if (rnd.nextInt(seen) == 0) chosen = x;   // keep x with probability 1/seen
    }
    return chosen;
}

Picking from [0..4] 100,000 times (seed 42) gave counts [20023, 20080, 19959, 19937, 20001] — about 20% each, as expected. Time O(n), space O(1). Used in Linked List Random Node (LC 382) and Random Pick Index (LC 398). To keep k items: fill the first k, then item i replaces a random slot with probability k/i. For shuffling an array, use Fisher–Yates: for i from the end, swap a[i] with a[rnd.nextInt(i + 1)].

Problem: Excel Sheet Column Number / Title (LC 171, 168)

Columns are base 26, but with digits 1..26 instead of 0..25 (there is no "zero" letter). Reading is ordinary base conversion; writing needs n-- first to shift into 0-based digits.

static int titleToNumber(String col) {   // "AB" -> 28
    int result = 0;
    for (char c : col.toCharArray())
        result = result * 26 + (c - 'A' + 1);
    return result;
}
static String convertToTitle(int n) {    // 28 -> "AB"
    StringBuilder sb = new StringBuilder();
    while (n > 0) {
        n--;                             // shift to 0-based: A=0 ... Z=25
        sb.append((char) ('A' + n % 26));
        n /= 26;
    }
    return sb.reverse().toString();
}

"A" → 1, "AB" → 28, "ZY" → 701; 28 → "AB", 701 → "ZY", 26 → "Z". Both O(log n) time. A version without n-- that uses (char) ('A' + n % 26 - 1) turns 26 into "A@".

Problem: Happy Number (LC 202)

Repeatedly replace n by the sum of the squares of its digits. Happy if it reaches 1; otherwise it falls into a loop. A loop in a sequence is a cycle in a linked list — use Floyd's slow/fast pointers instead of a HashSet.

static boolean isHappy(int n) {
    int slow = n, fast = next(n);
    while (fast != 1 && slow != fast) {  // Floyd cycle detection
        slow = next(slow);
        fast = next(next(fast));
    }
    return fast == 1;
}
private static int next(int n) {
    int sum = 0;
    while (n > 0) { int d = n % 10; sum += d * d; n /= 10; }
    return sum;
}

19 → true (1 + 81 = 82 → 68 → 100 → 1); 2 → false. Time O(log n) per step, and the values quickly drop below a few hundred, so the loop is short. Space O(1). Why the loop must exist: for any number with 4+ digits, the digit-square sum is smaller than the number, so values stay bounded and must eventually repeat.

Cheat sheet: gcd → Euclid O(log). All primes to n → sieve O(n log log n). One number prime? → trial division to sqrt n. a^b mod m → binary power O(log b). Division mod prime → multiply by a^(p-2). Many nCr → precompute factorials and inverse factorials. Always: long for products, mod after every step, floorMod for negatives.
Part 5 — Problem-Solving Patterns

Most interview problems are not new. They are one of about sixteen patterns wearing a different story. Once you see the pattern, you already know the shape of the code and the target complexity, and the rest is details. So the real skill is recognition: reading a problem statement and noticing the words that point to a pattern.

Every section below has the same shape: what the pattern is, how to spot it, how it moves (with a diagram), a reusable Java template, classic problems with full solutions, the usual mistakes, and a practice list. Learn the template, then solve the classics without looking.

PatternSignal in the problemTypical complexity
Two PointersSorted array, pair or triplet with a target sum, palindrome, "in place", reverseO(n) time (O(n log n) if you must sort), O(1) space
Sliding WindowContiguous subarray or substring, "longest / shortest / at most k", window of size kO(n) time, O(1) or O(alphabet) space
Prefix Sum & Difference ArraysMany range-sum queries, "subarray sum equals k", many range updatesO(n) build, O(1) per query
Fast & Slow PointersCycle in a linked list or sequence, middle of a list, O(1) extra spaceO(n) time, O(1) space
Monotonic Stack & Queue"Next greater / smaller", "previous smaller", span, histogram, window maxO(n) time (each item pushed and popped once), O(n) space
Merge IntervalsIntervals, meetings, bookings, overlapping ranges, "minimum rooms"O(n log n) for the sort, then O(n)
Cyclic SortNumbers in the range 1..n (or 0..n), find missing or duplicate, O(1) spaceO(n) time, O(1) space
In-place Linked List ReversalReverse a list or part of it, reorder, "in k-groups", no extra memoryO(n) time, O(1) space
Tree BFS & DFSLevel by level, depth, paths, "every node", tree viewsO(n) time, O(h) or O(width) space
Top K & K-way Merge"k largest / most frequent / closest", merge k sorted listsO(n log k) time, O(k) space
Two HeapsRunning median, split numbers into a smaller half and a bigger halfO(log n) per insert, O(1) to read the median
Subsets & Permutations"All combinations / subsets / permutations", generate every arrangementO(2^n) or O(n!) outputs, O(n) recursion depth
Modified Binary SearchSorted or rotated array, "minimum value that works", search on the answerO(log n) or O(n log range)
Topological SortTasks with prerequisites, build order, dependency graph, detect a cycleO(V + E) time and space
Matrix & Islands2D grid, connected regions, flood fill, shortest path in a gridO(rows x cols) time and space
Design Problems"Design a class that supports get / put / ...", LRU cache, iteratorsO(1) or O(log n) per operation
How to use this part: when you read a new problem, scan it for the signals in the middle column before writing any code. If two patterns fit, pick the one with the better complexity, and say both out loud in the interview: "this looks like sliding window; a brute force would be O(n^2)."

30. Two Pointers

In plain words

Two pointers means keeping two index variables into the same array (or string) and moving them by a rule, instead of trying every pair with two nested loops. Each step, one pointer moves and some candidates are ruled out for good, so the whole scan takes O(n) instead of O(n^2).

There are two flavours. Opposite ends: one pointer starts at the left, one at the right, and they walk toward each other. Same direction: a slow pointer and a fast pointer both go left to right; the fast one reads, the slow one marks where the next "kept" element should go.

Two people searching a sorted bookshelf for two books whose page counts add to 500. One starts at the thin end, one at the thick end. If the total is too big, the person at the thick end steps in; if too small, the person at the thin end steps in. They never need to go back.

How to recognise it

  • The input is a sorted array (or sorting it is cheap and allowed) and you need a pair or triplet with some sum or difference.
  • "Is this string a palindrome", "reverse", "compare from both ends".
  • "Remove / move elements in place", "return the new length", "O(1) extra space".
  • "Container", "trap water", "two lines" — answers that depend on a left wall and a right wall.
  • The brute force is two nested loops over pairs, and the array order lets you throw away one side each step.

How it works

Why is it safe to throw candidates away? Take Two Sum on a sorted array. If a[L] + a[R] is too big, then a[R] plus anything to the right of L is even bigger, so a[R] can never be part of the answer. Drop it: R--. If the sum is too small, the same logic drops a[L]. Every step removes one element from consideration, so there are at most n steps.

target = 10 0 1 2 3 4 5 1 1 3 4 6 8 11 L R 1 + 11 = 12 > 10 too big: move R left 2 1 3 4 6 8 11 L R 1 + 8 = 9 < 10 too small: move L right 3 1 3 4 6 8 11 L R 3 + 8 = 11 > 10 too big: move R left 4 1 3 4 6 8 11 L R 3 + 6 = 9 < 10 too small: move L right 5 1 3 4 6 8 11 L R 4 + 6 = 10 found: answer [3, 4] (1-indexed)
Opposite-ends two pointers: each comparison rules out one element (faded) for good, so the search takes at most n steps.

Same-direction pointers work like a filter. fast looks at every element once. When a[fast] should be kept, it is copied to a[slow] and slow moves on. At the end, a[0..slow-1] is the answer and slow is its length.

Move zeroes to the end, keep order: [0, 1, 0, 3, 12] fast=0 a=0 skip slow=0 fast=1 a=1 keep -> a[0]=1 slow=1 fast=2 a=0 skip slow=1 fast=3 a=3 keep -> a[1]=3 slow=2 fast=4 a=12 keep -> a[2]=12 slow=3 kept prefix = [1, 3, 12]; fill a[3..] with 0 -> [1, 3, 12, 0, 0]

Java template

// Variant 1: opposite ends — pointers start at both ends and walk toward each other.
public int[] oppositeEnds(int[] a, int target) {
    int left = 0, right = a.length - 1;
    while (left < right) {                      // stop when they meet
        int value = a[left] + a[right];         // CHANGES: what you compute from the pair
        if (value == target) {                  // CHANGES: the "found / record answer" step
            return new int[]{left, right};
        } else if (value < target) {
            left++;                             // need a bigger value -> move left inward
        } else {
            right--;                            // need a smaller value -> move right inward
        }
    }
    return new int[]{-1, -1};                   // CHANGES: what "not found" means
}

// Variant 2: same direction — a slow "write" pointer and a fast "read" pointer.
public int sameDirection(int[] a) {
    int slow = 0;                               // next position to write a kept element
    for (int fast = 0; fast < a.length; fast++) {
        if (keep(a, slow, fast)) {              // CHANGES: the rule for keeping a[fast]
            a[slow] = a[fast];
            slow++;
        }
    }
    return slow;                                // length of the kept prefix a[0..slow-1]
}

Classic problems

Two Sum II — Input Array Is Sorted, LC 167 (Medium)

Given a 1-indexed sorted array, return the positions of the two numbers that add up to target.

Key insight: sorted order tells you which way to move. Too big, move right in; too small, move left in. No HashMap needed, so space is O(1).

public int[] twoSum(int[] numbers, int target) {
    int left = 0, right = numbers.length - 1;
    while (left < right) {
        int sum = numbers[left] + numbers[right];
        if (sum == target) return new int[]{left + 1, right + 1}; // 1-indexed
        if (sum < target) left++;
        else right--;
    }
    return new int[]{-1, -1}; // problem guarantees one answer
}
// twoSum([2,7,11,15], 9)  -> [1, 2]
// twoSum([1,3,4,6,8,11], 10) -> [3, 4]

Time O(n), Space O(1).

3Sum, LC 15 (Medium)

Return all unique triplets [a, b, c] in the array with a + b + c == 0.

Key insight: sort, then fix the first number nums[i] and run Two Sum II on the rest looking for -nums[i]. Skipping equal neighbours (for i, left and right) removes duplicate triplets without a Set.

public List<List<Integer>> threeSum(int[] nums) {
    Arrays.sort(nums);
    List<List<Integer>> result = new ArrayList<>();
    for (int i = 0; i < nums.length - 2; i++) {
        if (nums[i] > 0) break;                          // smallest is positive: no zero sum
        if (i > 0 && nums[i] == nums[i - 1]) continue;   // skip duplicate first number
        int left = i + 1, right = nums.length - 1;
        while (left < right) {
            int sum = nums[i] + nums[left] + nums[right];
            if (sum < 0) {
                left++;
            } else if (sum > 0) {
                right--;
            } else {
                result.add(List.of(nums[i], nums[left], nums[right]));
                left++;
                right--;
                while (left < right && nums[left] == nums[left - 1]) left++;   // skip dup
                while (left < right && nums[right] == nums[right + 1]) right--; // skip dup
            }
        }
    }
    return result;
}
// threeSum([-1,0,1,2,-1,-4]) -> [[-1, -1, 2], [-1, 0, 1]]
// threeSum([0,0,0,0])        -> [[0, 0, 0]]

Time O(n^2) (the sort is O(n log n), then n two-pointer passes), Space O(1) extra apart from the output (the sort may use O(log n)).

Container With Most Water, LC 11 (Medium)

Each height[i] is a vertical line. Pick two lines that, with the x-axis, hold the most water.

Key insight: area = min(h[L], h[R]) * (R - L). Moving inward always shrinks the width, so the only hope of a bigger area is a taller short wall. Moving the taller wall can never help, so move the shorter one.

public int maxArea(int[] height) {
    int left = 0, right = height.length - 1, best = 0;
    while (left < right) {
        int h = Math.min(height[left], height[right]);
        best = Math.max(best, h * (right - left));
        // The shorter wall limits the area. Moving the taller one can only make it worse,
        // so always move the shorter one.
        if (height[left] < height[right]) left++;
        else right--;
    }
    return best;
}
// maxArea([1,8,6,2,5,4,8,3,7]) -> 49   (lines at index 1 and 8: min(8,7) * 7)

Time O(n), Space O(1).

Remove Duplicates from Sorted Array, LC 26 (Easy)

Remove duplicates in place so each value appears once; return the count k of unique values (they must be in nums[0..k-1]).

Key insight: same-direction pointers. Because the array is sorted, a value is new exactly when it differs from the last value written, nums[slow - 1].

public int removeDuplicates(int[] nums) {
    if (nums.length == 0) return 0;
    int slow = 1;                                   // nums[0] is always kept
    for (int fast = 1; fast < nums.length; fast++) {
        if (nums[fast] != nums[slow - 1]) {         // new value (array is sorted)
            nums[slow] = nums[fast];
            slow++;
        }
    }
    return slow;
}
// [0,0,1,1,1,2,2,3,3,4] -> 5, first five = [0, 1, 2, 3, 4]

Time O(n), Space O(1).

Trapping Rain Water, LC 42 (Hard)

Given bar heights, compute how much rain water is trapped between the bars.

Key insight: water above bar i is min(maxLeft, maxRight) - height[i]. With two pointers you don't need both maxima: if height[left] < height[right], there is a wall on the right at least as tall as anything on the left, so the level at left is decided by leftMax alone.

public int trap(int[] height) {
    int left = 0, right = height.length - 1;
    int leftMax = 0, rightMax = 0, water = 0;
    while (left < right) {
        if (height[left] < height[right]) {
            // Right side has a wall at least this tall, so leftMax decides the level here.
            leftMax = Math.max(leftMax, height[left]);
            water += leftMax - height[left];
            left++;
        } else {
            rightMax = Math.max(rightMax, height[right]);
            water += rightMax - height[right];
            right--;
        }
    }
    return water;
}
// trap([0,1,0,2,1,0,1,3,2,1,2,1]) -> 6
// trap([4,2,0,3,2,5])             -> 9

Time O(n), Space O(1). (The prefix-max version uses two extra arrays: O(n) space.)

Valid Palindrome, LC 125 (Easy)

After lower-casing and dropping everything that is not a letter or digit, does the string read the same both ways?

Key insight: compare from both ends and skip junk characters on the fly, instead of building a cleaned copy of the string.

public boolean isPalindrome(String s) {
    int left = 0, right = s.length() - 1;
    while (left < right) {
        while (left < right && !Character.isLetterOrDigit(s.charAt(left))) left++;
        while (left < right && !Character.isLetterOrDigit(s.charAt(right))) right--;
        if (Character.toLowerCase(s.charAt(left)) != Character.toLowerCase(s.charAt(right))) {
            return false;
        }
        left++;
        right--;
    }
    return true;
}
// isPalindrome("A man, a plan, a canal: Panama") -> true
// isPalindrome("race a car")                     -> false

Time O(n), Space O(1).

Common mistakes: using left <= right when a pair needs two different elements (use <); forgetting to sort first (opposite-ends logic only works on sorted data); in 3Sum, skipping duplicates before recording the triplet, or forgetting to skip them for left/right after a match, which gives repeated triplets; in Container With Most Water, moving the taller wall; returning 0-indexed positions when the problem asks for 1-indexed; in inner while loops, forgetting the left < right guard and running off the array.

Practice list

  • LC 344 Reverse String (Easy)
  • LC 283 Move Zeroes (Easy)
  • LC 977 Squares of a Sorted Array (Easy)
  • LC 88 Merge Sorted Array (Easy)
  • LC 680 Valid Palindrome II (Easy)
  • LC 75 Sort Colors (Medium) — three pointers, the Dutch national flag
  • LC 16 3Sum Closest (Medium)
  • LC 881 Boats to Save People (Medium)
  • LC 18 4Sum (Medium)

31. Sliding Window

In plain words

A sliding window is a stretch of consecutive elements, a[left..right], that moves across the array. Instead of recomputing each subarray from scratch, you update the window: add the element that enters on the right, remove the one that leaves on the left. That turns O(n·k) or O(n^2) work into O(n).

A fixed window always has size k: every step adds one and removes one. A variable window grows and shrinks: right always moves forward, and left moves forward only when the window breaks a rule ("no repeats", "at most k distinct"). Both pointers only ever move right, so each element enters once and leaves once.

Reading a long train through a window that shows a few carriages. As the train moves, one carriage appears on the right and one disappears on the left. You don't recount the whole view each time; you just note what came in and what went out.

How to recognise it

  • "Contiguous subarray" or "substring" (not subsequence: a subsequence can skip elements, a window cannot).
  • "Longest / shortest / maximum / minimum subarray such that ...".
  • "At most k distinct", "without repeating", "contains all characters of t", "an anagram / permutation of p".
  • "Every window of size k", "average of each k consecutive elements".
  • The rule is monotonic: if a window is invalid, making it bigger keeps it invalid. That is what makes shrinking from the left safe. (With negative numbers and a sum target, this breaks; use prefix sums instead.)

How it works

The variable-window loop has three beats: expand (take s[right] in and update counts), shrink (while the window is invalid, remove s[left] and move left), record (the window is now valid; compare its size to the best). For "longest" problems you record after shrinking. For "shortest" problems (like Minimum Window Substring) you record inside the shrink loop, while the window is still valid.

0 1 2 3 4 5 6 7 r=2 a b c a b c b b L R add c: "abc" is valid best = 3 r=3 a b c a b c b b L R add a: two a's, invalid drop s[0]=a, L=1: "bca", best = 3 r=4 a b c a b c b b L R add b: two b's drop s[1]=b, L=2: "cab" r=6 a b c a b c b b L R add b: two b's drop a, still invalid, drop b: "cb" r=7 a b c a b c b b L,R add b: drop c, drop b window "b"; final best = 3
Longest substring without repeating characters on "abcabcbb". R always moves right; L only moves right, and only when a repeat appears. Faded cells have left the window for good.

For character counts, an int[128] (all ASCII) or int[26] (lowercase letters) is faster and simpler than a HashMap<Character, Integer>. Use a HashMap when the "characters" are arbitrary integers, like fruit types.

Java template

// Fixed-size window of length k: add the new right element, drop the one that fell off.
public int fixedWindow(int[] a, int k) {
    int windowSum = 0, best = Integer.MIN_VALUE;        // CHANGES: the window state
    for (int right = 0; right < a.length; right++) {
        windowSum += a[right];                          // 1. add a[right]
        if (right >= k - 1) {                           // window is full (size k)
            best = Math.max(best, windowSum);           // 2. record the answer
            windowSum -= a[right - k + 1];              // 3. remove the leftmost element
        }
    }
    return best;
}

// Variable-size window: expand right every step, shrink left while the window is invalid.
public int variableWindow(String s) {
    int[] count = new int[128];                 // CHANGES: window state (counts, sum, map...)
    int left = 0, best = 0;
    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        count[c]++;                             // 1. expand: take s[right] into the window
        while (invalid(count, c)) {             // 2. shrink: CHANGES: the "invalid" rule
            count[s.charAt(left)]--;
            left++;
        }
        best = Math.max(best, right - left + 1);// 3. window [left..right] is valid: record
    }
    return best;
}

Classic problems

Maximum Sum Subarray of Size K (Easy)

Return the largest sum of any k consecutive elements. (LC 643 Maximum Average Subarray I is the same problem divided by k.)

Key insight: fixed window. Moving the window by one changes the sum by +nums[right] - nums[right - k], so each step is O(1).

public int maxSumSubarray(int[] nums, int k) {
    int windowSum = 0;
    for (int i = 0; i < k; i++) windowSum += nums[i];   // first window
    int best = windowSum;
    for (int right = k; right < nums.length; right++) {
        windowSum += nums[right] - nums[right - k];     // slide by one
        best = Math.max(best, windowSum);
    }
    return best;
}
// maxSumSubarray([2,1,5,1,3,2], 3)     -> 9   (5 + 1 + 3)
// maxSumSubarray([1,12,-5,-6,50,3], 4) -> 51  (12 - 5 - 6 + 50)

Time O(n), Space O(1).

Longest Substring Without Repeating Characters, LC 3 (Medium)

Return the length of the longest substring with all distinct characters.

Key insight: the template works (shrink while count[c] > 1). A faster trick: remember where each character was last seen and jump left straight past it. Math.max stops left from jumping backwards to an old copy that is already outside the window (try "abba").

public int lengthOfLongestSubstring(String s) {
    int[] lastSeen = new int[128];          // lastSeen[c] = (last index of c) + 1, 0 = never
    int left = 0, best = 0;
    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        left = Math.max(left, lastSeen[c]); // jump left past the previous copy of c
        lastSeen[c] = right + 1;
        best = Math.max(best, right - left + 1);
    }
    return best;
}
// "abcabcbb" -> 3 ("abc"),  "bbbbb" -> 1,  "pwwkew" -> 3 ("wke"),  "abba" -> 2

Time O(n), Space O(1) (128 slots, fixed).

Longest Repeating Character Replacement, LC 424 (Medium)

You may change at most k characters of an uppercase string. Return the longest substring of one repeated letter you can get.

Key insight: a window is fine if size - (count of its most common letter) <= k: those are the letters you'd replace. maxFreq is never decreased when shrinking. That is safe, because the answer only grows when a window beats the old maxFreq, and a stale value just keeps the window from shrinking too much.

public int characterReplacement(String s, int k) {
    int[] count = new int[26];
    int left = 0, maxFreq = 0, best = 0;
    for (int right = 0; right < s.length(); right++) {
        maxFreq = Math.max(maxFreq, ++count[s.charAt(right) - 'A']);
        // chars to replace = window size - most frequent char count
        while (right - left + 1 - maxFreq > k) {
            count[s.charAt(left) - 'A']--;
            left++;
        }
        best = Math.max(best, right - left + 1);
    }
    return best;
}
// characterReplacement("ABAB", 2)    -> 4
// characterReplacement("AABABBA", 1) -> 4

Time O(n), Space O(1).

Minimum Window Substring, LC 76 (Hard)

Return the shortest substring of s that contains every character of t (with multiplicity), or "" if none.

Key insight: keep need[c] = how many more of c the window needs (it goes negative for extras) and a single counter missing. When missing == 0 the window is valid: record it, then shrink until it breaks. This is a "shortest" problem, so you record inside the shrink loop.

public String minWindow(String s, String t) {
    int[] need = new int[128];
    for (char c : t.toCharArray()) need[c]++;
    int missing = t.length();               // chars of t still not covered by the window
    int left = 0, bestStart = 0, bestLen = Integer.MAX_VALUE;
    for (int right = 0; right < s.length(); right++) {
        if (need[s.charAt(right)]-- > 0) missing--;   // this char was actually needed
        while (missing == 0) {                         // window covers t: try to shrink
            if (right - left + 1 < bestLen) {
                bestLen = right - left + 1;
                bestStart = left;
            }
            if (++need[s.charAt(left)] > 0) missing++; // we just dropped a needed char
            left++;
        }
    }
    return bestLen == Integer.MAX_VALUE ? "" : s.substring(bestStart, bestStart + bestLen);
}
// minWindow("ADOBECODEBANC", "ABC") -> "BANC"
// minWindow("a", "aa")              -> ""

Time O(|s| + |t|), Space O(1) (128 slots).

Permutation in String, LC 567 (Medium)

Does s2 contain some permutation (anagram) of s1 as a substring?

Key insight: an anagram has the same length and the same letter counts. So slide a fixed window of size s1.length() over s2 and compare two int[26] arrays; comparing 26 numbers is O(1).

public boolean checkInclusion(String s1, String s2) {
    if (s1.length() > s2.length()) return false;
    int[] need = new int[26], window = new int[26];
    for (char c : s1.toCharArray()) need[c - 'a']++;
    int k = s1.length();
    for (int right = 0; right < s2.length(); right++) {
        window[s2.charAt(right) - 'a']++;
        if (right >= k) window[s2.charAt(right - k) - 'a']--;  // keep size exactly k
        if (Arrays.equals(need, window)) return true;           // 26 compares: O(1)
    }
    return false;
}
// checkInclusion("ab", "eidbaooo") -> true  ("ba")
// checkInclusion("ab", "eidboaoo") -> false

Time O(26·n) = O(n), Space O(1).

Fruit Into Baskets, LC 904 (Medium)

You have two baskets, each holding one fruit type. Walking right from any start, pick one fruit per tree. What is the most fruit you can collect?

Key insight: in plain words, "longest subarray with at most 2 distinct values". Keep a map of type to count; shrink while the map has more than 2 keys, removing a key when its count hits zero.

public int totalFruit(int[] fruits) {
    Map<Integer, Integer> basket = new HashMap<>();     // fruit type -> count in window
    int left = 0, best = 0;
    for (int right = 0; right < fruits.length; right++) {
        basket.merge(fruits[right], 1, Integer::sum);
        while (basket.size() > 2) {                      // more than 2 types: shrink
            int f = fruits[left++];
            if (basket.merge(f, -1, Integer::sum) == 0) basket.remove(f);
        }
        best = Math.max(best, right - left + 1);
    }
    return best;
}
// totalFruit([1,2,1]) -> 3,  [0,1,2,2] -> 3,  [1,2,3,2,2] -> 4

Time O(n), Space O(1) (the map never holds more than 3 keys).

Common mistakes: using if instead of while to shrink (one step may not be enough to make the window valid); computing the size as right - left instead of right - left + 1; recording the answer in the wrong place (after shrinking for "longest", inside the shrink loop for "shortest"); forgetting to remove a HashMap key when its count drops to zero, so size() is wrong; using a sliding window for "subarray sum equals k" when the array has negative numbers (the rule is not monotonic, so use prefix sums).

Practice list

  • LC 643 Maximum Average Subarray I (Easy)
  • LC 219 Contains Duplicate II (Easy)
  • LC 1456 Maximum Number of Vowels in a Substring of Given Length (Medium)
  • LC 209 Minimum Size Subarray Sum (Medium)
  • LC 438 Find All Anagrams in a String (Medium)
  • LC 1004 Max Consecutive Ones III (Medium)
  • LC 713 Subarray Product Less Than K (Medium)
  • LC 992 Subarrays with K Different Integers (Hard) — exactly(k) = atMost(k) - atMost(k - 1)
  • LC 30 Substring with Concatenation of All Words (Hard)

32. Prefix Sum & Difference Arrays

In plain words

A prefix sum array stores running totals: prefix[i] is the sum of the first i elements. Once you have it, the sum of any range is one subtraction, so a range query costs O(1) instead of O(n).

A difference array is the reverse trick. To "add v to every element from l to r", you only write two numbers: +v at l and -v at r + 1. After all the updates, one prefix-sum pass rebuilds the real values. Many range updates become O(1) each.

A car's odometer. To know how far you drove between Monday and Thursday, you don't add up every trip; you subtract Monday's reading from Thursday's. The odometer is the prefix sum.

How to recognise it

  • Many queries for the sum (or count, or XOR) of a range on an array that doesn't change.
  • "Number of subarrays with sum equal to k", "divisible by k", "equal number of 0s and 1s", especially when the array has negative numbers (so a sliding window won't work).
  • "Product of all elements except this one" (prefix products from both sides).
  • "Add v to every element in [l, r]", bookings, passengers getting on and off, "how many overlaps at each point".
  • 2D grid with "sum of the sub-rectangle" queries.

How it works

Picture prefix[i] sitting on the boundary before a[i]: it is the total of everything to its left. The sum between two boundaries is the difference of their totals. Using size n + 1 with prefix[0] = 0 removes the special case for ranges that start at 0.

a 0 3 1 1 2 4 3 1 4 5 5 9 a[1..3] = 1 + 4 + 1 = 6 prefix 0 0 3 1 4 2 8 3 9 4 14 5 23 6 prefix[4] - prefix[1] = 9 - 3 = 6 prefix[i] = sum of a[0..i-1] (everything left of boundary i) Any range sum is one subtraction: sum(a[l..r]) = prefix[r + 1] - prefix[l]
Build once in O(n); after that every range sum is one subtraction.

Prefix sum + HashMap. A subarray a[j+1..i] sums to k exactly when prefix[i+1] - prefix[j+1] = k, i.e. when an earlier prefix equals current - k. So walk once, keep a map of "prefix value → how many times seen", and at each step look up current - k. This is the Two Sum idea applied to prefix sums.

Difference array. Here is "add 2 to [1..3]" then "add 3 to [2..4]" on an array of 5 zeros:

index 0 1 2 3 4 5 diff after 0 +2 0 0 -2 0 add 2 to [1..3] diff after 0 +2 +3 0 -2 -3 add 3 to [2..4] running sum 0 2 5 5 3 = final array [0, 2, 5, 5, 3]

2D prefix sums. p[r][c] holds the sum of the rectangle from (0,0) to (r-1,c-1). Build with p[r+1][c+1] = cell + above + left - aboveLeft (the top-left block was added twice). Query any rectangle with the same inclusion-exclusion: big corner, minus the strip above, minus the strip to the left, plus the top-left block that was subtracted twice.

Java template

// 1. Build: prefix[i] = sum of the first i elements, so prefix[0] = 0.
static long[] buildPrefix(int[] a) {
    long[] prefix = new long[a.length + 1];          // one extra slot: no "i - 1" edge case
    for (int i = 0; i < a.length; i++) prefix[i + 1] = prefix[i] + a[i];
    return prefix;
}
// Sum of a[l..r] inclusive, O(1)
static long rangeSum(long[] prefix, int l, int r) {
    return prefix[r + 1] - prefix[l];
}

// 2. Prefix sum + HashMap: count subarrays whose "sum" hits a target.
static int countSubarrays(int[] a, int k) {
    Map<Integer, Integer> seen = new HashMap<>();   // CHANGES: store count, or first index
    seen.put(0, 1);                                 // empty prefix: sum 0 seen once
    int sum = 0, answer = 0;
    for (int x : a) {
        sum += x;                                   // CHANGES: what you accumulate
        answer += seen.getOrDefault(sum - k, 0);    // earlier prefixes that make sum k
        seen.merge(sum, 1, Integer::sum);
    }
    return answer;
}

// 3. Difference array: many "add v to a[l..r]" updates, then read the final array once.
static int[] applyUpdates(int n, int[][] updates) {  // each update = {l, r, v}
    int[] diff = new int[n + 1];                     // one extra slot for r + 1
    for (int[] u : updates) {
        diff[u[0]] += u[2];                          // start adding v at l
        diff[u[1] + 1] -= u[2];                      // stop adding v after r
    }
    int[] result = new int[n];
    int running = 0;
    for (int i = 0; i < n; i++) {                    // prefix sum of diff = final values
        running += diff[i];
        result[i] = running;
    }
    return result;
}

Classic problems

Range Sum Query — Immutable, LC 303 (Easy)

Answer many sumRange(left, right) queries on a fixed array.

Key insight: pay O(n) once in the constructor, then every query is O(1).

class NumArray {
    private final int[] prefix;

    public NumArray(int[] nums) {
        prefix = new int[nums.length + 1];
        for (int i = 0; i < nums.length; i++) prefix[i + 1] = prefix[i] + nums[i];
    }

    public int sumRange(int left, int right) {
        return prefix[right + 1] - prefix[left];
    }
}
// nums = [-2,0,3,-5,2,-1]: sumRange(0,2) -> 1, sumRange(2,5) -> -1, sumRange(0,5) -> -3

Time O(n) build, O(1) per query. Space O(n).

Subarray Sum Equals K, LC 560 (Medium)

Count the contiguous subarrays whose sum is exactly k. Numbers can be negative.

Key insight: count earlier prefixes equal to sum - k. Seed the map with {0: 1} so a subarray that starts at index 0 is counted. Look up before adding the current prefix, or k = 0 would count the empty subarray.

public int subarraySum(int[] nums, int k) {
    Map<Integer, Integer> count = new HashMap<>();  // prefix sum -> how many times seen
    count.put(0, 1);
    int sum = 0, result = 0;
    for (int x : nums) {
        sum += x;
        // a subarray ending here sums to k  <=>  some earlier prefix == sum - k
        result += count.getOrDefault(sum - k, 0);
        count.merge(sum, 1, Integer::sum);
    }
    return result;
}
// subarraySum([1,1,1], 2)              -> 2
// subarraySum([1,2,3], 3)              -> 2   ([1,2] and [3])
// subarraySum([3,4,7,2,-3,1,4,2], 7)   -> 4

Time O(n), Space O(n).

Contiguous Array, LC 525 (Medium)

In a binary array, find the longest subarray with an equal number of 0s and 1s.

Key insight: count 1 as +1 and 0 as -1. A subarray is balanced when its sum is 0, i.e. when the running balance repeats. For the longest one, store only the first index of each balance (and balance 0 at index -1).

public int findMaxLength(int[] nums) {
    Map<Integer, Integer> firstIndex = new HashMap<>(); // balance -> first index it appeared
    firstIndex.put(0, -1);                              // balance 0 "before" index 0
    int balance = 0, best = 0;
    for (int i = 0; i < nums.length; i++) {
        balance += nums[i] == 1 ? 1 : -1;               // treat 0 as -1
        if (firstIndex.containsKey(balance)) {
            best = Math.max(best, i - firstIndex.get(balance)); // same balance: equal 0s and 1s between
        } else {
            firstIndex.put(balance, i);                 // keep the EARLIEST index only
        }
    }
    return best;
}
// findMaxLength([0,1]) -> 2,  [0,1,0] -> 2,  [0,1,1,1,1,1,0,0,0] -> 6

Time O(n), Space O(n).

Product of Array Except Self, LC 238 (Medium)

Return answer[i] = product of every element except nums[i], without division, in O(n).

Key insight: answer[i] = (product of everything left of i) x (product of everything right of i). Fill the left products into the answer array, then sweep from the right with one running variable, so no extra array is needed.

public int[] productExceptSelf(int[] nums) {
    int n = nums.length;
    int[] answer = new int[n];
    answer[0] = 1;
    for (int i = 1; i < n; i++) answer[i] = answer[i - 1] * nums[i - 1]; // product of left side
    int right = 1;                                                      // product of right side
    for (int i = n - 1; i >= 0; i--) {
        answer[i] *= right;
        right *= nums[i];
    }
    return answer;
}
// [1,2,3,4]     -> [24, 12, 8, 6]
// [-1,1,0,-3,3] -> [0, 0, 9, 0, 0]

Time O(n), Space O(1) extra (the output array doesn't count).

Car Pooling, LC 1094 (Medium)

trips[i] = {passengers, from, to} on a road going one way. Can a car with capacity seats do all trips?

Key insight: each trip is a range update "+passengers on [from, to)". Mark the start and the drop-off in a difference array, then the running sum is the load at each point. Passengers leave at to, so the - goes at to, not to + 1.

public boolean carPooling(int[][] trips, int capacity) {
    int[] diff = new int[1001];                 // locations are 0..1000
    for (int[] t : trips) {                     // t = {passengers, from, to}
        diff[t[1]] += t[0];                     // they get in at "from"
        diff[t[2]] -= t[0];                     // they get out at "to" (seat free again)
    }
    int load = 0;
    for (int d : diff) {
        load += d;
        if (load > capacity) return false;
    }
    return true;
}
// carPooling([[2,1,5],[3,3,7]], 4) -> false  (5 people between 3 and 5)
// carPooling([[2,1,5],[3,3,7]], 5) -> true

Time O(n + L) where L = 1001 locations, Space O(L).

Corporate Flight Bookings, LC 1109 (Medium) is the same pattern with 1-indexed flights and inclusive ends:

public int[] corpFlightBookings(int[][] bookings, int n) {
    int[] diff = new int[n + 1];
    for (int[] b : bookings) {                  // b = {first, last, seats}, 1-indexed
        diff[b[0] - 1] += b[2];
        diff[b[1]] -= b[2];                     // (last - 1) + 1
    }
    int[] answer = new int[n];
    int running = 0;
    for (int i = 0; i < n; i++) {
        running += diff[i];
        answer[i] = running;
    }
    return answer;
}
// bookings = [[1,2,10],[2,3,20],[2,5,25]], n = 5 -> [10, 55, 45, 25, 25]

Time O(n + bookings), Space O(n).

Range Sum Query 2D — Immutable, LC 304 (Medium)

Answer many "sum of the rectangle (r1,c1)–(r2,c2)" queries on a fixed matrix.

Key insight: 2D prefix sums with one row and one column of zero padding, then inclusion-exclusion for each query.

class NumMatrix {
    private final int[][] p;   // p[r][c] = sum of the rectangle (0,0)..(r-1,c-1)

    public NumMatrix(int[][] matrix) {
        int rows = matrix.length, cols = matrix[0].length;
        p = new int[rows + 1][cols + 1];
        for (int r = 0; r < rows; r++)
            for (int c = 0; c < cols; c++)
                p[r + 1][c + 1] = matrix[r][c] + p[r][c + 1] + p[r + 1][c] - p[r][c];
    }

    public int sumRegion(int r1, int c1, int r2, int c2) {
        return p[r2 + 1][c2 + 1] - p[r1][c2 + 1] - p[r2 + 1][c1] + p[r1][c1];
    }
}
// matrix = [[3,0,1,4,2],[5,6,3,2,1],[1,2,0,1,5],[4,1,0,1,7],[1,0,3,0,5]]
// sumRegion(2,1,4,3) -> 8, sumRegion(1,1,2,2) -> 11, sumRegion(1,2,2,4) -> 12

Time O(rows x cols) build, O(1) per query. Space O(rows x cols).

Common mistakes: off-by-one between prefix[r + 1] - prefix[l] and prefix[r] - prefix[l - 1] (pick the n + 1 version and stick to it); forgetting map.put(0, 1) (or put(0, -1) for the longest-length version), which misses subarrays that start at index 0; updating the map before the lookup; in "longest" problems, overwriting the first index with later ones; integer overflow on big sums (use long); in a difference array, forgetting the extra slot for r + 1.

Practice list

  • LC 1480 Running Sum of 1d Array (Easy)
  • LC 724 Find Pivot Index (Easy)
  • LC 1413 Minimum Value to Get Positive Step by Step Sum (Easy)
  • LC 370 Range Addition (Medium)
  • LC 974 Subarray Sums Divisible by K (Medium)
  • LC 523 Continuous Subarray Sum (Medium)
  • LC 1314 Matrix Block Sum (Medium)
  • LC 1074 Number of Submatrices That Sum to Target (Hard)

33. Fast & Slow Pointers

In plain words

Two pointers walk the same path at different speeds: slow moves one step, fast moves two. If the path is a straight line, fast reaches the end first, and at that moment slow is exactly halfway. If the path loops back on itself, fast can never escape, so it eventually laps slow and they land on the same node.

This is Floyd's cycle detection, also called the tortoise and hare. It finds cycles and middles with O(1) extra memory, where a HashSet of visited nodes would need O(n).

Two runners on a track. On a straight road the faster one simply finishes first. On a circular track the faster one keeps coming round and eventually taps the slower one on the shoulder: that tap proves the track is a loop.

How to recognise it

  • "Does the linked list have a cycle?", "where does the cycle start?"
  • "Find the middle of the list", "split the list in half" (merge sort on a list, palindrome list, reorder list).
  • A sequence where each value decides the next one (x -> f(x)) and you must detect a loop: happy number, "circular array loop".
  • Array values in 1..n used as "next" indexes, with a duplicate hiding somewhere (find the duplicate number).
  • The problem says O(1) extra space and you'd otherwise use a HashSet of visited things.

How it works

Why they must meet. Once both pointers are inside the cycle, look at the gap from slow to fast measured along the loop. Every step fast gains exactly one node, so the gap shrinks by 1: 5, 4, 3, 2, 1, 0. It can't skip past 0, so they land on the same node, and this happens before slow finishes its first lap.

Why resetting one pointer finds the cycle start. Say the tail before the cycle has a nodes, the cycle has length c, and they meet b steps after the cycle start.

a steps head b steps c - b steps cycle length c cycle start meeting point At the meeting: slow walked a + b fast walked 2(a + b) difference = k full laps: a + b = k·c a = (k-1)·c + (c - b) Phase 2: one pointer from head, one from the meeting point, both 1 step: after a steps both stand on the cycle start.
Floyd's algorithm: the distance from head to the cycle start (a) equals the distance from the meeting point forward to the cycle start (c - b), plus some full laps.

Slow walked a + b. Fast walked twice that, 2(a + b), and the extra a + b it walked must be whole laps: a + b = k·c. Rearranged, a = (k-1)·c + (c - b). In words: walking a steps from the meeting point goes c - b steps to the start, then round some full laps, and ends on the start. Walking a steps from head also ends on the start. So put one pointer back on head, move both one step at a time, and where they meet is the cycle start.

List 1->2->3->4->5->6->7->(back to 3) a = 2, c = 5 step slow fast 0 1 1 1 2 3 2 3 5 3 4 7 4 5 4 5 6 6 meet at 6 (b = 3 steps past node 3) phase 2: p = head p=1 slow=6 p=2 slow=7 p=3 slow=3 meet at 3 = cycle start

Java template

ListNode fastSlow(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {   // fast needs two valid steps
        slow = slow.next;                          // 1 step
        fast = fast.next.next;                     // 2 steps
        if (slow == fast) {                        // CHANGES: cycle problems stop here
            return slow;                           // meeting point inside the cycle
        }
    }
    return slow;   // CHANGES: no cycle; slow is now the middle (second middle if even)
}

The condition fast != null && fast.next != null is the whole trick for safety: it checks that both of fast's next two steps exist.

Classic problems

Linked List Cycle, LC 141 (Easy)

Return true if the linked list has a cycle.

Key insight: if fast ever reaches null there is an end, so no cycle. If they meet, there is a cycle.

public boolean hasCycle(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
        if (slow == fast) return true;   // compare nodes (references), not values
    }
    return false;                        // fast fell off the end: no cycle
}
// [3,2,0,-4], tail links to index 1 -> true
// [1], no link                       -> false

Time O(n), Space O(1).

Linked List Cycle II, LC 142 (Medium)

Return the node where the cycle begins, or null.

Key insight: phase 1 finds any meeting point; phase 2 resets one pointer to head and moves both at speed 1. By the math above, they meet at the cycle start.

public ListNode detectCycle(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
        if (slow == fast) {                  // phase 1: they met inside the cycle
            ListNode p = head;               // phase 2: one pointer back to head
            while (p != slow) {              // both move 1 step; they meet at the start
                p = p.next;
                slow = slow.next;
            }
            return p;
        }
    }
    return null;
}
// [3,2,0,-4], tail links to index 1      -> node with value 2
// [1,2,3,4,5,6,7], tail links to index 2 -> node with value 3

Time O(n), Space O(1).

Middle of the Linked List, LC 876 (Easy)

Return the middle node; if there are two middles, return the second.

Key insight: when fast has done the whole list, slow has done half. To get the first middle instead (useful for splitting a list), loop while fast.next != null && fast.next.next != null.

public ListNode middleNode(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    return slow;    // for even length this is the SECOND middle
}
// [1,2,3,4,5]   -> node 3 (returns [3, 4, 5])
// [1,2,3,4,5,6] -> node 4 (returns [4, 5, 6])

Time O(n), Space O(1).

Happy Number, LC 202 (Easy)

Replace n by the sum of the squares of its digits, again and again. n is happy if this reaches 1. Otherwise it loops forever.

Key insight: the numbers form an implicit linked list n -> next(n). Either it reaches 1 (and stays there, since next(1) = 1) or it enters a cycle. Floyd detects which, with no HashSet.

public boolean isHappy(int n) {
    int slow = n, fast = next(n);
    while (fast != 1 && slow != fast) {
        slow = next(slow);
        fast = next(next(fast));
    }
    return fast == 1;
}

private int next(int n) {            // sum of the squares of the digits
    int sum = 0;
    while (n > 0) {
        int d = n % 10;
        sum += d * d;
        n /= 10;
    }
    return sum;
}
// isHappy(19) -> true   (19 -> 82 -> 68 -> 100 -> 1)
// isHappy(2)  -> false

Time O(log n) per step and a small number of steps, Space O(1).

Find the Duplicate Number, LC 287 (Medium)

An array of n + 1 integers, each in 1..n, has exactly one repeated value (maybe repeated many times). Find it without changing the array and with O(1) extra space.

Key insight: read the array as a linked list where index i points to index nums[i]. Index 0 is never pointed to (values start at 1), so it is the head. The duplicate value is a node with two incoming arrows, which is exactly the entrance of a cycle. So it is LC 142 on an array.

public int findDuplicate(int[] nums) {
    // Treat i -> nums[i] as a linked list. The duplicate value has two arrows
    // pointing into it, so it is exactly where the cycle starts.
    int slow = nums[0], fast = nums[0];
    do {
        slow = nums[slow];
        fast = nums[nums[fast]];
    } while (slow != fast);
    slow = nums[0];                     // phase 2: restart one pointer
    while (slow != fast) {
        slow = nums[slow];
        fast = nums[fast];
    }
    return slow;
}
// [1,3,4,2,2] -> 2,  [3,1,3,4,2] -> 3,  [3,3,3,3,3] -> 3

Time O(n), Space O(1).

Palindrome Linked List, LC 234 (Easy)

Is a singly linked list a palindrome? Aim for O(1) extra space.

Key insight: three patterns in a row: fast and slow to find the middle, in-place reversal of the second half, then walk both halves together comparing values.

public boolean isPalindrome(ListNode head) {
    // 1. find the middle
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    // 2. reverse the second half
    ListNode prev = null;
    while (slow != null) {
        ListNode next = slow.next;
        slow.next = prev;
        prev = slow;
        slow = next;
    }
    // 3. compare the first half with the reversed second half
    ListNode left = head, right = prev;
    while (right != null) {
        if (left.val != right.val) return false;
        left = left.next;
        right = right.next;
    }
    return true;
}
// [1,2,2,1] -> true,  [1,2] -> false,  [1,2,3,2,1] -> true

Time O(n), Space O(1). (This leaves the list modified; in real code, reverse the second half back before returning.)

Common mistakes: checking only fast != null and then calling fast.next.next (NullPointerException on even-length lists); comparing slow.val == fast.val instead of slow == fast (two different nodes can hold the same value); checking slow == fast before the first move, when both still sit on head; in phase 2, moving the head pointer 2 steps instead of 1; in Find the Duplicate, starting phase 2 from index 0's value inconsistently with phase 1 (both phases must start from the same head).

Practice list

  • LC 160 Intersection of Two Linked Lists (Easy) — two pointers that switch lists
  • LC 2095 Delete the Middle Node of a Linked List (Medium)
  • LC 2130 Maximum Twin Sum of a Linked List (Medium)
  • LC 19 Remove Nth Node From End of List (Medium) — a gap of n, same speed
  • LC 143 Reorder List (Medium)
  • LC 148 Sort List (Medium) — find the middle, then merge sort
  • LC 457 Circular Array Loop (Medium)

34. Monotonic Stack & Queue

In plain words

A monotonic stack is an ordinary stack with one house rule: its values are always sorted, either always decreasing from bottom to top or always increasing. Before pushing a new element, you pop everything that would break the order. The moment an element gets popped is the moment you learn its answer: "the thing that just pushed me out is my next greater (or smaller) element".

A monotonic deque (double-ended queue) is the same idea with one more exit: old elements also leave from the front when they fall out of a sliding window. The front is then always the max (or min) of the window.

People in a queue, each waiting for someone taller than them to join. When a tall person arrives, every shorter person still waiting has found their answer ("that one is taller than me") and leaves. The people left waiting are always ordered tallest to shortest, and that ordered queue is the monotonic stack.

How to recognise it

  • "Next greater / next smaller element", "previous greater / smaller", "first element to the right that is bigger".
  • "How many days until a warmer day", "stock span", "how far can this bar extend".
  • "Largest rectangle in a histogram", "maximal rectangle", "trapping rain water" (stack version).
  • "Sum of min / max over all subarrays", "for each element, in how many subarrays is it the minimum".
  • "Maximum / minimum of every window of size k" (monotonic deque).
  • "Remove k digits to make the smallest number", "smallest subsequence" (greedy with a stack).
  • The brute force is "for each i, scan right until you find something bigger": O(n^2). The stack makes it O(n).

How it works

For "next greater", keep a stack of indexes whose answer is not known yet. Their values are decreasing (if a bigger value had come after a smaller one, it would already have answered it). A new value x pops every waiting element smaller than x, because x is the first bigger thing to their right. Then x itself waits.

73 day 0 74 day 1 75 day 2 71 day 3 69 day 4 72 day 5 76 day 6 73 day 7 answer 1 1 4 2 1 1 0 0 75 waits 4 days, while 71, 69 and 72 come and go
Each arrow is "my next warmer day". The monotonic stack finds all arrows in one left-to-right pass.
temps = [73, 74, 75, 71, 69, 72, 76, 73] stack shows values, bottom -> top i=0 73 push stack [73] i=1 74 pop 73 (ans[0]=1) push stack [74] i=2 75 pop 74 (ans[1]=1) push stack [75] i=3 71 push stack [75, 71] i=4 69 push stack [75, 71, 69] i=5 72 pop 69 (ans[4]=1), pop 71 (ans[3]=2) stack [75, 72] i=6 76 pop 72 (ans[5]=1), pop 75 (ans[2]=4) stack [76] i=7 73 push stack [76, 73] (never popped: answer 0)

Why O(n)? The while loop looks scary inside a for, but each index is pushed exactly once and popped at most once, so the total work over the whole run is at most 2n.

You wantScanStack order (bottom to top)Pop while
Next greaterleft to rightdecreasinga[top] < a[i], answer popped items
Next smallerleft to rightincreasinga[top] > a[i], answer popped items
Previous greaterleft to rightdecreasinga[top] <= a[i], then top is the answer for i
Previous smallerleft to rightincreasinga[top] >= a[i], then top is the answer for i

Java template

Always use ArrayDeque as the stack (push, pop, peek), not the old synchronized Stack class. Store indexes, not values: from an index you can get both the value and the distance.

// Next greater element to the right of each index (-1 if none).
public int[] nextGreater(int[] a) {
    int n = a.length;
    int[] result = new int[n];
    Arrays.fill(result, -1);                    // CHANGES: default when nothing is found
    Deque<Integer> stack = new ArrayDeque<>();  // holds INDEXES; their values are decreasing
    for (int i = 0; i < n; i++) {
        // a[i] is the answer for every smaller element still waiting on the stack
        while (!stack.isEmpty() && a[stack.peek()] < a[i]) {   // CHANGES: the comparison
            int j = stack.pop();
            result[j] = a[i];                   // CHANGES: store value, index, or i - j
        }
        stack.push(i);                          // a[i] now waits for its own answer
    }
    return result;
}
// nextGreater([2,1,2,4,3]) -> [4, 2, 4, -1, -1]

// Previous smaller element to the left of each index (-1 if none).
public int[] previousSmaller(int[] a) {
    int[] result = new int[a.length];
    Deque<Integer> stack = new ArrayDeque<>();  // indexes; values strictly increasing
    for (int i = 0; i < a.length; i++) {
        while (!stack.isEmpty() && a[stack.peek()] >= a[i]) stack.pop(); // useless now
        result[i] = stack.isEmpty() ? -1 : a[stack.peek()];  // top = nearest smaller on left
        stack.push(i);
    }
    return result;
}
// previousSmaller([3,7,8,4,6]) -> [-1, 3, 7, 3, 4]

Classic problems

Next Greater Element I, LC 496 (Easy)

nums1 is a subset of nums2 (all values distinct). For each x in nums1, return the first greater number to the right of x in nums2, or -1.

Key insight: run the template once over nums2, saving answers in a map value -> next greater; then each query is a lookup. Values are distinct, so the stack can hold values instead of indexes.

public int[] nextGreaterElement(int[] nums1, int[] nums2) {
    Map<Integer, Integer> nextGreater = new HashMap<>();   // value -> next greater value
    Deque<Integer> stack = new ArrayDeque<>();             // values, decreasing
    for (int x : nums2) {
        while (!stack.isEmpty() && stack.peek() < x) nextGreater.put(stack.pop(), x);
        stack.push(x);
    }
    int[] result = new int[nums1.length];
    for (int i = 0; i < nums1.length; i++) result[i] = nextGreater.getOrDefault(nums1[i], -1);
    return result;
}
// nums1 = [4,1,2], nums2 = [1,3,4,2] -> [-1, 3, -1]
// nums1 = [2,4],   nums2 = [1,2,3,4] -> [3, -1]

Time O(n + m), Space O(n).

Next Greater Element II, LC 503 (Medium)

Same as above, but the array is circular: after the last element you continue from the first.

Key insight: walk indexes 0..2n-1 and read nums[i % n]. The second pass lets elements near the end find answers near the start. Only push during the first pass.

public int[] nextGreaterElements(int[] nums) {
    int n = nums.length;
    int[] result = new int[n];
    Arrays.fill(result, -1);
    Deque<Integer> stack = new ArrayDeque<>();
    for (int i = 0; i < 2 * n; i++) {          // walk the array twice to simulate the circle
        int x = nums[i % n];
        while (!stack.isEmpty() && nums[stack.peek()] < x) result[stack.pop()] = x;
        if (i < n) stack.push(i);              // only the first pass pushes
    }
    return result;
}
// [1,2,1]     -> [2, -1, 2]
// [1,2,3,4,3] -> [2, 3, 4, -1, 4]

Time O(n), Space O(n).

Daily Temperatures, LC 739 (Medium)

For each day, how many days until a warmer temperature? 0 if never.

Key insight: the next-greater template, but store the distance i - j instead of the value. That's why the stack holds indexes.

public int[] dailyTemperatures(int[] temps) {
    int[] answer = new int[temps.length];      // default 0 = no warmer day
    Deque<Integer> stack = new ArrayDeque<>(); // indexes of days still waiting
    for (int i = 0; i < temps.length; i++) {
        while (!stack.isEmpty() && temps[stack.peek()] < temps[i]) {
            int j = stack.pop();
            answer[j] = i - j;                 // distance, not the temperature
        }
        stack.push(i);
    }
    return answer;
}
// [73,74,75,71,69,72,76,73] -> [1, 1, 4, 2, 1, 1, 0, 0]

Time O(n), Space O(n).

Largest Rectangle in Histogram, LC 84 (Hard)

Bars of width 1 with the given heights. Find the area of the largest rectangle inside the histogram.

Key insight: the best rectangle uses some bar as its shortest bar, and stretches left and right until it hits a shorter bar. Keep an increasing stack. When bar i is shorter than the top, the top has found its right limit (i), and the element under it in the stack is its left limit. A final height of 0 flushes everything.

public int largestRectangleArea(int[] heights) {
    int n = heights.length, best = 0;
    Deque<Integer> stack = new ArrayDeque<>();     // indexes, heights increasing
    for (int i = 0; i <= n; i++) {
        int h = (i == n) ? 0 : heights[i];         // height 0 at the end flushes the stack
        while (!stack.isEmpty() && heights[stack.peek()] > h) {
            int height = heights[stack.pop()];     // this bar is the shortest in its rectangle
            // it extends right until i (exclusive) and left until the new top (exclusive)
            int left = stack.isEmpty() ? -1 : stack.peek();
            best = Math.max(best, height * (i - left - 1));
        }
        stack.push(i);
    }
    return best;
}
// [2,1,5,6,2,3] -> 10   (bars 5 and 6, height 5, width 2)
// [2,4]         -> 4

Time O(n), Space O(n).

Sliding Window Maximum, LC 239 (Hard)

Return the maximum of every window of size k.

Key insight: a monotonic deque of indexes with decreasing values. If a newer element is bigger than an older one, the older one can never be a window max again (the newer one is bigger and stays longer), so pop it from the back. Pop from the front when the index has left the window. The front is always the current max.

public int[] maxSlidingWindow(int[] nums, int k) {
    int[] result = new int[nums.length - k + 1];
    Deque<Integer> dq = new ArrayDeque<>();         // indexes, values decreasing
    for (int i = 0; i < nums.length; i++) {
        if (!dq.isEmpty() && dq.peekFirst() <= i - k) dq.pollFirst();
        while (!dq.isEmpty() && nums[dq.peekLast()] <= nums[i]) dq.pollLast();
        dq.offerLast(i);
        if (i >= k - 1) result[i - k + 1] = nums[dq.peekFirst()];
    }
    return result;
}
// nums = [1,3,-1,-3,5,3,6,7], k = 3 -> [3, 3, 5, 5, 6, 7]
// nums = [1], k = 1                 -> [1]

Time O(n), Space O(k). (A max-heap works too, but is O(n log n).)

Online Stock Span, LC 901 (Medium)

Prices arrive one per day. For each, return the span: how many consecutive days, ending today, had a price <= today's price.

Key insight: this is "previous greater" in streaming form. Keep {price, span} pairs in a decreasing stack; today's price swallows every smaller-or-equal day, adding up their spans, so they're never looked at again.

class StockSpanner {
    private final Deque<int[]> stack = new ArrayDeque<>();  // {price, span}, prices decreasing

    public int next(int price) {
        int span = 1;
        while (!stack.isEmpty() && stack.peek()[0] <= price) {
            span += stack.pop()[1];     // absorb the span of every smaller-or-equal day
        }
        stack.push(new int[]{price, span});
        return span;
    }
}
// next(100), next(80), next(60), next(70), next(60), next(75), next(85)
// -> 1 1 1 2 1 4 6

Time O(1) amortised per call, Space O(n).

Sum of Subarray Minimums, LC 907 (Medium)

Return the sum of min(sub) over every contiguous subarray, modulo 10^9 + 7.

Key insight: flip the question: for each arr[i], in how many subarrays is it the minimum? That's (choices of start) x (choices of end) = left[i] * right[i], found with a previous-smaller and a next-smaller pass. Use strict > on one side and >= on the other so equal values are counted exactly once.

public int sumSubarrayMins(int[] arr) {
    final long MOD = 1_000_000_007L;
    int n = arr.length;
    int[] left = new int[n], right = new int[n];
    Deque<Integer> stack = new ArrayDeque<>();
    // left[i]: how many starts can arr[i] be the minimum for (previous strictly smaller)
    for (int i = 0; i < n; i++) {
        while (!stack.isEmpty() && arr[stack.peek()] > arr[i]) stack.pop();
        left[i] = stack.isEmpty() ? i + 1 : i - stack.peek();
        stack.push(i);
    }
    stack.clear();
    // right[i]: how many ends (next smaller OR EQUAL, so ties are counted once)
    for (int i = n - 1; i >= 0; i--) {
        while (!stack.isEmpty() && arr[stack.peek()] >= arr[i]) stack.pop();
        right[i] = stack.isEmpty() ? n - i : stack.peek() - i;
        stack.push(i);
    }
    long sum = 0;
    for (int i = 0; i < n; i++) {
        sum = (sum + (long) arr[i] * left[i] * right[i]) % MOD;
    }
    return (int) sum;
}
// [3,1,2,4]         -> 17
// [11,81,94,43,3]   -> 444

Time O(n), Space O(n).

Common mistakes: pushing values when you need distances (push indexes); getting the comparison direction wrong (write the table above on the whiteboard first); using < vs <= carelessly, which breaks on duplicates (Sum of Subarray Minimums double-counts, Stock Span under-counts); forgetting to flush the stack at the end (the extra height-0 bar in LC 84); in the deque, forgetting to evict the front when it leaves the window; using java.util.Stack, which is slow and legacy.

Practice list

  • LC 1475 Final Prices With a Special Discount in a Shop (Easy)
  • LC 402 Remove K Digits (Medium)
  • LC 316 Remove Duplicate Letters (Medium)
  • LC 853 Car Fleet (Medium)
  • LC 1856 Maximum Subarray Min-Product (Medium)
  • LC 1944 Number of Visible People in a Queue (Hard)
  • LC 862 Shortest Subarray with Sum at Least K (Hard) — prefix sums + monotonic deque
  • LC 85 Maximal Rectangle (Hard) — LC 84 once per row

35. Merge Intervals

In plain words

An interval is a pair [start, end]: a meeting, a booking, a range of numbers. Interval problems ask which ones overlap, how to combine them, or how many overlap at once. The trick that unlocks almost all of them: sort by start time, then sweep left to right. After sorting, an interval can only overlap the ones just before it, so one pass is enough.

A cousin of this is the sweep line: split every interval into a "start" event and an "end" event, sort all events by time, and walk through them keeping a running count of how many intervals are open.

Tidying a pile of receipts for trips. Sort them by departure date, then go through the stack: if a trip starts before the previous one ended, staple them together; otherwise start a new bundle.

How to recognise it

  • The input is a list of pairs [start, end]: meetings, bookings, time slots, ranges, balloons.
  • "Merge overlapping", "insert into a sorted list of intervals", "find the free time".
  • "Minimum number of rooms / platforms / machines", "max number of overlapping events at any moment".
  • "Remove the minimum number so the rest don't overlap", "minimum arrows / points to cover all".
  • "Intersection of two lists of intervals".

How it works

Everything rests on one test: when do two intervals overlap? There are six ways B can sit relative to A. Four of them overlap; only "B entirely before A" and "B entirely after A" don't. So the overlap test is simply "neither is entirely before the other":

A interval A (fixed) A.start A.end B 1. B before A no overlap B 2. B overlaps A's start overlap B 3. B inside A overlap B 4. B contains A overlap B 5. B overlaps A's end overlap B 6. B after A no overlap Overlap <=> A.start <= B.end and B.start <= A.end
Two intervals overlap unless one ends before the other starts. Whether touching ends ([1,4] and [4,5]) count as overlapping depends on the problem: read it carefully.

After sorting by start, cur.start >= last.start is already true, so the test shrinks to one comparison: cur.start <= last.end means overlap. When merging, the new end is max(last.end, cur.end): case 3 (B inside A) shows why you can't just take cur.end.

merge [[1,3],[2,6],[8,10],[15,18]] (already sorted by start) [1,3] result [[1,3]] [2,6] 2 <= 3 merge result [[1,6]] [8,10] 8 > 6 new result [[1,6],[8,10]] [15,18] 15 > 10 new result [[1,6],[8,10],[15,18]]
Sort byWhenExamples
StartYou merge or group intervals togetherMerge Intervals, Meeting Rooms, Insert Interval
EndGreedy: keep as many non-overlapping as possible, or cover with fewest pointsNon-overlapping Intervals, Minimum Arrows, activity selection

Java template

public int[][] sweep(int[][] intervals) {
    Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));  // 1. sort by start
    List<int[]> result = new ArrayList<>();
    for (int[] cur : intervals) {
        if (result.isEmpty() || result.get(result.size() - 1)[1] < cur[0]) {
            result.add(cur);                         // 2. no overlap: start a new group
        } else {
            int[] last = result.get(result.size() - 1);
            last[1] = Math.max(last[1], cur[1]);     // 3. overlap: CHANGES: merge / count / skip
        }
    }
    return result.toArray(new int[0][]);
}

Sort with Integer.compare(a[0], b[0]), not a[0] - b[0]: subtraction overflows when values are near Integer.MIN_VALUE or MAX_VALUE and gives the wrong order.

Classic problems

Merge Intervals, LC 56 (Medium)

Merge all overlapping intervals and return the non-overlapping result.

Key insight: sort by start; each interval either extends the last merged one or starts a new one. Touching intervals like [1,4] and [4,5] count as overlapping here, hence < for "no overlap".

public int[][] merge(int[][] intervals) {
    Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));
    List<int[]> merged = new ArrayList<>();
    for (int[] cur : intervals) {
        int[] last = merged.isEmpty() ? null : merged.get(merged.size() - 1);
        if (last == null || last[1] < cur[0]) {
            merged.add(new int[]{cur[0], cur[1]});
        } else {
            last[1] = Math.max(last[1], cur[1]);   // max: cur may sit fully inside last
        }
    }
    return merged.toArray(new int[0][]);
}
// [[1,3],[2,6],[8,10],[15,18]] -> [[1, 6], [8, 10], [15, 18]]
// [[1,4],[4,5]]                -> [[1, 5]]
// [[1,10],[2,3],[4,5]]         -> [[1, 10]]

Time O(n log n), Space O(n) for the output (plus the sort's O(log n)).

Insert Interval, LC 57 (Medium)

Intervals are already sorted and non-overlapping. Insert a new one and merge if needed.

Key insight: no sort needed. Three phases: copy everything that ends before the new one starts, swallow everything that overlaps it (growing start and end), copy the rest.

public int[][] insert(int[][] intervals, int[] newInterval) {
    List<int[]> result = new ArrayList<>();
    int i = 0, n = intervals.length;
    int start = newInterval[0], end = newInterval[1];
    while (i < n && intervals[i][1] < start) result.add(intervals[i++]);   // 1. fully left
    while (i < n && intervals[i][0] <= end) {                              // 2. overlapping
        start = Math.min(start, intervals[i][0]);
        end = Math.max(end, intervals[i][1]);
        i++;
    }
    result.add(new int[]{start, end});
    while (i < n) result.add(intervals[i++]);                              // 3. fully right
    return result.toArray(new int[0][]);
}
// [[1,3],[6,9]], [2,5]                      -> [[1, 5], [6, 9]]
// [[1,2],[3,5],[6,7],[8,10],[12,16]], [4,8] -> [[1, 2], [3, 10], [12, 16]]

Time O(n), Space O(n).

Non-overlapping Intervals, LC 435 (Medium)

Return the minimum number of intervals to remove so the rest don't overlap (touching is allowed).

Key insight: flip it: keep the maximum number of non-overlapping intervals. Sorting by end and always keeping the one that finishes first is the classic greedy: it leaves the most room for everything after.

public int eraseOverlapIntervals(int[][] intervals) {
    // Greedy: keep the interval that ENDS first; it leaves the most room for the rest.
    Arrays.sort(intervals, (a, b) -> Integer.compare(a[1], b[1]));
    int removed = 0, lastEnd = Integer.MIN_VALUE;
    for (int[] cur : intervals) {
        if (cur[0] >= lastEnd) {
            lastEnd = cur[1];      // keep it (touching at the edge is fine here)
        } else {
            removed++;             // overlaps the last kept one: remove it
        }
    }
    return removed;
}
// [[1,2],[2,3],[3,4],[1,3]] -> 1
// [[1,2],[1,2],[1,2]]       -> 2
// [[1,2],[2,3]]             -> 0

Time O(n log n), Space O(1) extra.

Meeting Rooms, LC 252 (Easy) and Meeting Rooms II, LC 253 (Medium)

I: can one person attend every meeting? II: what is the minimum number of rooms needed?

Key insight (I): after sorting by start, any meeting that starts before the previous one ends is a clash.

public boolean canAttendMeetings(int[][] intervals) {
    Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));
    for (int i = 1; i < intervals.length; i++) {
        if (intervals[i][0] < intervals[i - 1][1]) return false;  // starts before previous ends
    }
    return true;
}
// [[0,30],[5,10],[15,20]] -> false,  [[7,10],[2,4]] -> true

Time O(n log n), Space O(1) extra.

Key insight (II), heap: process meetings by start time and keep a min-heap of the end times of rooms in use. If the room that frees up first is free by now, reuse it (poll); either way the new meeting takes a room (offer). The heap's size is the number of rooms.

public int minMeetingRooms(int[][] intervals) {
    Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));
    PriorityQueue<Integer> endTimes = new PriorityQueue<>();   // end time of each busy room
    for (int[] m : intervals) {
        if (!endTimes.isEmpty() && endTimes.peek() <= m[0]) {
            endTimes.poll();                  // earliest-ending room is free: reuse it
        }
        endTimes.offer(m[1]);
    }
    return endTimes.size();                   // rooms ever opened and still counted
}
// [[0,30],[5,10],[15,20]]    -> 2
// [[7,10],[2,4]]             -> 1
// [[1,5],[2,6],[3,7],[6,8]]  -> 3

Sweep-line version: sort all starts and all ends separately. Walk the starts; before each one, release every meeting that has already ended. The running count's peak is the answer.

public int minMeetingRoomsSweep(int[][] intervals) {
    int n = intervals.length;
    int[] starts = new int[n], ends = new int[n];
    for (int i = 0; i < n; i++) {
        starts[i] = intervals[i][0];
        ends[i] = intervals[i][1];
    }
    Arrays.sort(starts);
    Arrays.sort(ends);
    int rooms = 0, best = 0, e = 0;
    for (int s = 0; s < n; s++) {
        while (ends[e] <= starts[s]) {        // meetings that ended free their rooms
            rooms--;
            e++;
        }
        rooms++;
        best = Math.max(best, rooms);
    }
    return best;
}
// same answers: 2, 1, 3

Both: Time O(n log n), Space O(n).

Interval List Intersections, LC 986 (Medium)

Two lists of sorted, disjoint intervals. Return all their intersections.

Key insight: two pointers. The intersection of two intervals is [max(starts), min(ends)], valid if lo <= hi. Then advance whichever interval ends first: it can't meet anything else in the other list.

public int[][] intervalIntersection(int[][] first, int[][] second) {
    List<int[]> result = new ArrayList<>();
    int i = 0, j = 0;
    while (i < first.length && j < second.length) {
        int lo = Math.max(first[i][0], second[j][0]);
        int hi = Math.min(first[i][1], second[j][1]);
        if (lo <= hi) result.add(new int[]{lo, hi});   // they overlap on [lo, hi]
        if (first[i][1] < second[j][1]) i++;           // drop the one that ends first
        else j++;
    }
    return result.toArray(new int[0][]);
}
// first  = [[0,2],[5,10],[13,23],[24,25]]
// second = [[1,5],[8,12],[15,24],[25,26]]
// -> [[1, 2], [5, 5], [8, 10], [15, 23], [24, 24], [25, 25]]

Time O(n + m), Space O(n + m) for the output.

Minimum Number of Arrows to Burst Balloons, LC 452 (Medium)

Balloons are horizontal ranges [xstart, xend]. A vertical arrow at x bursts every balloon with xstart <= x <= xend. Find the fewest arrows.

Key insight: sort by end and shoot each arrow at the end of the first unburst balloon: that's as far right as the arrow can go while still hitting it, so it catches the most later balloons. A balloon that starts after the arrow needs a new one. Touching counts as a hit, hence >.

public int findMinArrowShots(int[][] points) {
    // Compare with Integer.compare: a[1] - b[1] can overflow for values near 2^31.
    Arrays.sort(points, (a, b) -> Integer.compare(a[1], b[1]));
    int arrows = 1;
    int arrowX = points[0][1];                 // shoot at the end of the first balloon
    for (int[] p : points) {
        if (p[0] > arrowX) {                   // this balloon starts after the arrow: new arrow
            arrows++;
            arrowX = p[1];
        }
    }
    return arrows;
}
// [[10,16],[2,8],[1,6],[7,12]] -> 2
// [[1,2],[3,4],[5,6],[7,8]]    -> 4
// [[1,2],[2,3],[3,4],[4,5]]    -> 2

Time O(n log n), Space O(1) extra.

Common mistakes: forgetting to sort; sorting with a[0] - b[0] (overflow, LC 452 has values near 2^31); setting last.end = cur.end instead of max(last.end, cur.end); getting touching intervals wrong (< vs <=) for the specific problem; sorting by start in the greedy "keep the most" problems, where sorting by end is what makes the greedy correct; in Meeting Rooms II, comparing against the most recent meeting instead of the room that frees up earliest (that's why it's a min-heap).

Practice list

  • LC 228 Summary Ranges (Easy)
  • LC 1854 Maximum Population Year (Easy)
  • LC 1288 Remove Covered Intervals (Medium)
  • LC 646 Maximum Length of Pair Chain (Medium)
  • LC 729 My Calendar I (Medium)
  • LC 1094 Car Pooling (Medium) — sweep line / difference array
  • LC 759 Employee Free Time (Hard)
  • LC 2402 Meeting Rooms III (Hard)

36. Cyclic Sort

In plain words

When an array holds numbers from a known small range, like 1 to n, every number has a natural home: value v belongs at index v - 1. Cyclic sort walks the array and keeps swapping the current number into its home until the current slot holds the right number (or a number that has no home). Then it moves on.

After that, one more scan finds whatever is wrong: a slot whose number doesn't match its index points straight to a missing number (the index) or a duplicate (the value in it). All in O(n) time and O(1) extra space, with no HashSet and no real sorting.

Handing back numbered exam papers in a classroom where seat 1 is for student 1, seat 2 for student 2, and so on. Pick up the paper on the first desk, walk it to its owner's seat, pick up the paper that was lying there, walk that one to its seat, and so on. Empty seats at the end tell you who was absent.

How to recognise it

  • The array contains numbers in the range 1..n or 0..n, where n is (about) the array length.
  • "Find the missing number(s)", "find the duplicate(s)", "find the smallest missing positive", "set mismatch".
  • "O(1) extra space" and "O(n) time", ruling out both a HashSet and sorting.
  • You are allowed to modify the input array (if not, look at fast & slow pointers or bit tricks instead).

How it works

Stay on index i and keep swapping nums[i] to its home until the value at i is at home, or its home already holds the same value (a duplicate), or it has no home (out of range). Only then do i++.

0 1 2 3 4 1 3 1 5 4 2 i 3 belongs at index 2 swap a[0] and a[2] 2 5 1 3 4 2 i 5 belongs at index 4 swap a[0] and a[4] 3 2 1 3 4 5 i 2 belongs at index 1 swap a[0] and a[1] 4 1 2 3 4 5 every value is home i walks to the end, no more swaps
Cyclic sort on [3, 1, 5, 4, 2]: i stays at 0 while each swap sends one number home. Highlighted cells are the pair being swapped.

Why is it O(n) with a while that doesn't always move i? Every swap puts at least one number into its final home, and a number at home is never moved again. So there are at most n swaps in total, plus n increments of i: under 2n steps.

The check nums[i] != nums[correct] (compare the values, not i != correct) is what stops an infinite loop on duplicates: if the home already holds the same value, swapping would change nothing, forever.

Java template

public void placeEach(int[] nums) {
    int i = 0;
    while (i < nums.length) {
        int correct = nums[i] - 1;                 // CHANGES: home index (value - 1, or value)
        if (inRange(nums[i], nums.length)          // CHANGES: skip values with no home
                && nums[i] != nums[correct]) {     // home not already holding this value
            swap(nums, i, correct);                // send it home; DON'T move i yet
        } else {
            i++;                                   // placed, duplicate, or out of range
        }
    }
    // Then scan: the first index i with nums[i] != i + 1 tells you what's missing / extra.
}

private boolean inRange(int v, int n) { return v >= 1 && v <= n; }

private void swap(int[] a, int i, int j) {
    int t = a[i]; a[i] = a[j]; a[j] = t;
}
After sorting, if nums[i] != i + 1It means
i + 1is a missing number
nums[i]is a duplicate (its real home is taken by another copy)

Classic problems

Cyclic Sort (Easy)

An array holds each number from 1 to n exactly once, in any order. Sort it in place in O(n). (The core loop behind every problem below; it uses swap from the template.)

Key insight: you already know where each value goes, so you don't need comparisons between elements: just send each one home.

public void cyclicSort(int[] nums) {        // nums holds each of 1..n exactly once
    int i = 0;
    while (i < nums.length) {
        int correct = nums[i] - 1;
        if (nums[i] != nums[correct]) swap(nums, i, correct);
        else i++;
    }
}
// [3,1,5,4,2]   -> [1, 2, 3, 4, 5]
// [2,6,4,3,1,5] -> [1, 2, 3, 4, 5, 6]

Time O(n), Space O(1).

Missing Number, LC 268 (Easy)

nums holds n distinct numbers from 0..n. Return the one that is missing.

Key insight: here the range starts at 0, so value v lives at index v. The value n has no slot, so leave it wherever it lands. The first index that doesn't hold its own number is the answer; if all match, the answer is n.

public int missingNumber(int[] nums) {      // n numbers from 0..n, one missing
    int i = 0, n = nums.length;
    while (i < n) {
        int correct = nums[i];                          // value v belongs at index v
        if (correct < n && nums[i] != nums[correct]) swap(nums, i, correct);
        else i++;                                       // value n has no slot: leave it
    }
    for (i = 0; i < n; i++) {
        if (nums[i] != i) return i;
    }
    return n;                                           // 0..n-1 all present
}
// [3,0,1] -> 2,  [0,1] -> 2,  [9,6,4,2,3,5,7,0,1] -> 8

Time O(n), Space O(1).

Worth knowing: the XOR trick solves this one without touching the array, since x ^ x = 0:

public int missingNumberXor(int[] nums) {
    int x = nums.length;                    // start with n
    for (int i = 0; i < nums.length; i++) x ^= i ^ nums[i];
    return x;                               // every present number cancels itself
}
// [9,6,4,2,3,5,7,0,1] -> 8

Find All Numbers Disappeared in an Array, LC 448 (Easy)

n numbers, each in 1..n, some appearing twice. Return all numbers in 1..n that don't appear.

Key insight: run cyclic sort. Duplicates get stuck in slots whose owner is missing, so every mismatched index i means i + 1 is missing.

public List<Integer> findDisappearedNumbers(int[] nums) {
    cyclicSort(nums);   // same loop; duplicates just stop when their home is taken
    List<Integer> missing = new ArrayList<>();
    for (int i = 0; i < nums.length; i++) {
        if (nums[i] != i + 1) missing.add(i + 1);
    }
    return missing;
}
// [4,3,2,7,8,2,3,1] -> [5, 6]
// [1,1]             -> [2]

Time O(n), Space O(1) extra (the output list doesn't count).

Find the Duplicate Number, LC 287 (Medium)

n + 1 numbers, each in 1..n, one value repeated. Return it.

Key insight: while sending a number home, if its home already holds the same value, you've found the duplicate. Note: LC 287 forbids modifying the array, so in that exact problem use Floyd's cycle detection; the cyclic-sort version is the right answer when changes are allowed.

public int findDuplicate(int[] nums) {      // n + 1 numbers from 1..n
    int i = 0;
    while (i < nums.length) {
        if (nums[i] != i + 1) {             // not at home
            int correct = nums[i] - 1;
            if (nums[i] == nums[correct]) return nums[i];   // home already taken by a copy
            swap(nums, i, correct);
        } else {
            i++;
        }
    }
    return -1;
}
// [1,3,4,2,2] -> 2,  [3,1,3,4,2] -> 3,  [3,3,3,3,3] -> 3

Time O(n), Space O(1).

First Missing Positive, LC 41 (Hard)

Unsorted array of any integers. Return the smallest positive integer that is not present, in O(n) time and O(1) space.

Key insight: with n slots, the answer is always in 1..n+1. So only values 1..n matter: send them home and ignore negatives, zeros and big values. The first slot not holding i + 1 is the answer.

public int firstMissingPositive(int[] nums) {
    int n = nums.length, i = 0;
    while (i < n) {
        int v = nums[i];
        // only 1..n can matter: the answer is always in 1..n+1
        if (v >= 1 && v <= n && nums[v - 1] != v) swap(nums, i, v - 1);
        else i++;
    }
    for (i = 0; i < n; i++) {
        if (nums[i] != i + 1) return i + 1;
    }
    return n + 1;
}
// [1,2,0] -> 3,  [3,4,-1,1] -> 2,  [7,8,9,11,12] -> 1,  [1,1] -> 2

Time O(n), Space O(1).

Set Mismatch, LC 645 (Easy)

The set 1..n got corrupted: one number was copied over another. Return [duplicate, missing].

Key insight: after cyclic sort there is exactly one mismatched slot: it holds the duplicate, and its index + 1 is the missing number.

public int[] findErrorNums(int[] nums) {    // one number duplicated, one missing
    cyclicSort(nums);
    for (int i = 0; i < nums.length; i++) {
        if (nums[i] != i + 1) return new int[]{nums[i], i + 1};  // {duplicate, missing}
    }
    return new int[]{-1, -1};
}
// [1,2,2,4]     -> [2, 3]
// [1,1]         -> [1, 2]
// [3,2,3,4,6,5] -> [3, 1]

Time O(n), Space O(1).

Common mistakes: writing if (i != correct) instead of if (nums[i] != nums[correct]), which loops forever on duplicates; doing i++ after a swap (the new value at i hasn't been placed yet); an off-by-one between "value v at index v" (range 0..n) and "value v at index v - 1" (range 1..n); forgetting to skip values that have no home (0, negatives, values > n), causing ArrayIndexOutOfBoundsException; using it when the problem says the array must not be modified.

Practice list

  • LC 1920 Build Array from Permutation (Easy)
  • LC 1539 Kth Missing Positive Number (Easy)
  • LC 442 Find All Duplicates in an Array (Medium)
  • LC 565 Array Nesting (Medium) — follows the same "index to value" cycles
  • LC 2471 Minimum Number of Operations to Sort a Binary Tree by Level (Medium) — count swaps with cycles
  • LC 765 Couples Holding Hands (Hard) — swap each partner into place

37. In-place Linked List Reversal

In plain words

To reverse a linked list you don't copy anything: you walk along it once and flip each arrow so it points backwards. The only difficulty is that the moment you flip curr.next, you lose your way to the rest of the list. So you keep three pointers: prev (the part already reversed), curr (the node being flipped) and next (a bookmark to the rest). That "three-pointer dance" is the whole pattern.

Harder problems reverse only part of the list (a sublist, every group of k, every pair), and the extra work is all about reconnecting the reversed piece to the nodes before and after it. A dummy node in front of the head makes that reconnection the same everywhere, even at the very start.

Turning around a line of people holding the shoulder of the person in front. You go down the line and ask each person to let go and hold the person behind instead. Before they let go, you note who was in front of them, so you know who to ask next.

How to recognise it

  • "Reverse a linked list", "reverse from position left to right", "reverse every k nodes", "swap nodes in pairs".
  • "Reorder", "rotate", "fold" a list, or "compare the list with its reverse" (palindrome list).
  • "Add two numbers" where digits are stored most-significant first (reverse, add, reverse back).
  • "Do it in place", "O(1) extra memory": no ArrayList of nodes, no stack.

How it works

Every loop step does the same four moves in the same order: save next = curr.next, flip curr.next = prev, advance prev = curr, advance curr = next. When curr falls off the end, prev is sitting on the old tail, which is the new head.

each step: next = curr.next; curr.next = prev; prev = curr; curr = next null null 1 2 3 prev curr start prev = null, curr = head null null 1 2 3 prev curr after step 1 1.next = null null null 1 2 3 prev curr after step 2 2.next = 1 null null 1 2 3 prev curr after step 3 curr is null: return prev (3)
Reversing 1 -> 2 -> 3. Accent arrows are already flipped; grey arrows are still original. Each step flips exactly one arrow.

Useful facts after a reversal loop: prev is the new head of the reversed piece, curr is the first node that was not reversed, and the node you started from is now the tail of the reversed piece. Keeping a reference to that starting node is how you reconnect in the sublist and k-group problems.

Sublist reversal (reverse positions 2..4 of 1->2->3->4->5), head-insertion style: before = node 1, first = node 2 (it will end up as the sublist's tail) move 3 to the front: 1 -> 3 -> 2 -> 4 -> 5 move 4 to the front: 1 -> 4 -> 3 -> 2 -> 5 (right - left = 2 moves, done)

Java template

The node class used in this section (the LeetCode one):

class ListNode {
    int val;
    ListNode next;
    ListNode(int val) { this.val = val; }
    ListNode(int val, ListNode next) { this.val = val; this.next = next; }
}
// Reverse the list starting at head; returns the new head.
ListNode reverse(ListNode head) {
    ListNode prev = null;          // already-reversed part (starts empty)
    ListNode curr = head;          // node we are about to flip
    while (curr != null) {         // CHANGES: stop after k nodes / at a given node
        ListNode next = curr.next; // 1. save the rest of the list
        curr.next = prev;          // 2. flip the arrow
        prev = curr;               // 3. grow the reversed part
        curr = next;               // 4. step forward
    }
    return prev;                   // prev = new head; curr = first node NOT reversed
}

For partial reversals add a dummy node: ListNode dummy = new ListNode(0, head);, work from dummy, and return dummy.next. Then "the node before the reversed piece" always exists, even when the piece starts at the head.

Classic problems

Reverse Linked List, LC 206 (Easy)

Reverse a singly linked list and return the new head.

Key insight: the template, exactly. Know the recursive version too: interviewers often ask for both.

public ListNode reverseList(ListNode head) {
    ListNode prev = null, curr = head;
    while (curr != null) {
        ListNode next = curr.next;
        curr.next = prev;
        prev = curr;
        curr = next;
    }
    return prev;
}
// [1,2,3,4,5] -> [5, 4, 3, 2, 1],  [1,2] -> [2, 1],  [] -> []

Time O(n), Space O(1).

public ListNode reverseListRecursive(ListNode head) {
    if (head == null || head.next == null) return head;  // 0 or 1 node: already reversed
    ListNode newHead = reverseListRecursive(head.next);   // reverse everything after head
    head.next.next = head;     // the node after head now points back to head
    head.next = null;          // head becomes the tail
    return newHead;
}
// [1,2,3,4,5] -> [5, 4, 3, 2, 1]

Time O(n), Space O(n) for the call stack, so the iterative version is preferred (and a list of 100,000 nodes can overflow the stack recursively).

Reverse Linked List II, LC 92 (Medium)

Reverse only the nodes from position left to right (1-indexed), in one pass.

Key insight: walk to the node before position left (a dummy makes this work when left = 1). Then do right - left "head insertions": take the node after first and move it to the front of the sublist. first slides toward the end on its own and stays connected to the rest.

public ListNode reverseBetween(ListNode head, int left, int right) {
    ListNode dummy = new ListNode(0, head);
    ListNode before = dummy;
    for (int i = 1; i < left; i++) before = before.next;   // node just before position left

    // Head insertion: repeatedly move the node after 'first' to the front of the sublist.
    ListNode first = before.next;                          // becomes the sublist's tail
    for (int i = 0; i < right - left; i++) {
        ListNode moved = first.next;
        first.next = moved.next;
        moved.next = before.next;
        before.next = moved;
    }
    return dummy.next;
}
// [1,2,3,4,5], left=2, right=4 -> [1, 4, 3, 2, 5]
// [5], 1, 1 -> [5],  [3,5], 1, 2 -> [5, 3]

Time O(n), Space O(1).

Reverse Nodes in k-Group, LC 25 (Hard)

Reverse the list k nodes at a time. A final group with fewer than k nodes stays as it is.

Key insight: for each group, first check that k nodes exist (find the k-th). Reverse the group with prev starting at groupNext instead of null: that way the group's new tail already points at the next part. Then hook the previous part to the group's new head (kth) and move groupPrev to the group's new tail.

public ListNode reverseKGroup(ListNode head, int k) {
    ListNode dummy = new ListNode(0, head);
    ListNode groupPrev = dummy;                 // node before the current group
    while (true) {
        ListNode kth = groupPrev;               // find the k-th node of this group
        for (int i = 0; i < k && kth != null; i++) kth = kth.next;
        if (kth == null) break;                 // fewer than k nodes left: leave them

        ListNode groupNext = kth.next;          // first node after the group
        ListNode prev = groupNext, curr = groupPrev.next;
        while (curr != groupNext) {             // standard reversal, but prev starts at
            ListNode next = curr.next;          // groupNext so the tail links onward
            curr.next = prev;
            prev = curr;
            curr = next;
        }
        ListNode oldFirst = groupPrev.next;     // it is now the group's last node
        groupPrev.next = kth;                   // kth is the group's new first node
        groupPrev = oldFirst;
    }
    return dummy.next;
}
// [1,2,3,4,5], k=2 -> [2, 1, 4, 3, 5]
// [1,2,3,4,5], k=3 -> [3, 2, 1, 4, 5]
// [1,2,3],     k=1 -> [1, 2, 3]

Time O(n) (each node is visited twice: once to count, once to reverse), Space O(1).

Swap Nodes in Pairs, LC 24 (Medium)

Swap every two adjacent nodes (change links, not values).

Key insight: it's k-group with k = 2, small enough to write the three pointer changes by hand. Draw prev -> a -> b -> rest and rewire to prev -> b -> a -> rest.

public ListNode swapPairs(ListNode head) {
    ListNode dummy = new ListNode(0, head);
    ListNode prev = dummy;
    while (prev.next != null && prev.next.next != null) {
        ListNode a = prev.next, b = a.next;
        a.next = b.next;        // a points past the pair
        b.next = a;             // b points back to a
        prev.next = b;          // the node before the pair now points to b
        prev = a;               // a is now second: it's the "before" of the next pair
    }
    return dummy.next;
}
// [1,2,3,4] -> [2, 1, 4, 3],  [1,2,3] -> [2, 1, 3],  [] -> []

Time O(n), Space O(1).

Rotate List, LC 61 (Medium)

Rotate the list to the right by k places.

Key insight: no reversal needed, just relinking. Find the length n and the tail, reduce k %= n (k can be huge), join tail to head to make a ring, then cut the ring after the (n - k)-th node.

public ListNode rotateRight(ListNode head, int k) {
    if (head == null || head.next == null) return head;
    int n = 1;
    ListNode tail = head;
    while (tail.next != null) { tail = tail.next; n++; }   // length and tail
    k %= n;
    if (k == 0) return head;
    tail.next = head;                        // close into a ring
    ListNode newTail = head;
    for (int i = 1; i < n - k; i++) newTail = newTail.next;  // (n - k)-th node
    ListNode newHead = newTail.next;
    newTail.next = null;                     // cut the ring
    return newHead;
}
// [1,2,3,4,5], k=2 -> [4, 5, 1, 2, 3]
// [0,1,2],     k=4 -> [2, 0, 1]

Time O(n), Space O(1).

Reorder List, LC 143 (Medium)

Reorder L0 -> L1 -> ... -> Ln into L0 -> Ln -> L1 -> Ln-1 -> ... in place.

Key insight: three patterns glued together: fast & slow to find the middle, reverse the second half (the template's reverse), then weave the two halves, one node from each.

public void reorderList(ListNode head) {
    if (head == null || head.next == null) return;
    // 1. middle (first middle, so the first half is not shorter)
    ListNode slow = head, fast = head;
    while (fast.next != null && fast.next.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    // 2. reverse the second half and cut it off
    ListNode second = reverse(slow.next);
    slow.next = null;
    // 3. weave: one from the front, one from the back
    ListNode first = head;
    while (second != null) {
        ListNode n1 = first.next, n2 = second.next;
        first.next = second;
        second.next = n1;
        first = n1;
        second = n2;
    }
}
// [1,2,3,4]   -> [1, 4, 2, 3]
// [1,2,3,4,5] -> [1, 5, 2, 4, 3]

Time O(n), Space O(1).

Common mistakes: flipping curr.next before saving it (the rest of the list is lost); returning head or curr instead of prev; forgetting that the old head is now the tail and must point to null (or to the next part), which creates a cycle; not using a dummy node, so the case "reverse starting at the head" needs special code; in k-group, reversing a final group that has fewer than k nodes; in Rotate List, forgetting k %= n. Tip: draw the boxes and arrows before coding, and trace a 2-node and a 3-node list by hand.

Practice list

  • LC 234 Palindrome Linked List (Easy)
  • LC 2130 Maximum Twin Sum of a Linked List (Medium)
  • LC 445 Add Two Numbers II (Medium)
  • LC 328 Odd Even Linked List (Medium)
  • LC 1721 Swapping Nodes in a Linked List (Medium)
  • LC 2074 Reverse Nodes in Even Length Groups (Medium)
  • LC 86 Partition List (Medium)
  • LC 2816 Double a Number Represented as a Linked List (Medium)

38. Tree BFS & DFS

In plain words

Almost every binary tree question is solved by walking the tree in one of two ways. BFS (breadth-first search) visits the tree level by level, top to bottom, using a queue. DFS (depth-first search) goes as deep as possible down one branch before coming back, using recursion (the call stack).

The pattern is not "know BFS and DFS" — you already do from section 12. The pattern is knowing which one fits and having a template ready: BFS when the question talks about levels or "closest to the root"; DFS when it talks about paths, depths, or answers built from subtrees.

BFS is a building inspector who checks every room on floor 1, then every room on floor 2, and so on. DFS is a person exploring a cave: follow one tunnel to its end, come back to the last fork, try the next tunnel.

How to recognise it

  • "Level order", "each level", "row by row", "average / max of each level", "zigzag" — BFS.
  • "Right side view", "left view", "nodes you can see", "minimum depth" (first leaf you reach) — BFS (stop at the first answer).
  • "Root-to-leaf path", "path sum", "all paths", "sum of numbers along the path" — DFS top-down (carry the path/sum down).
  • "Height", "diameter", "balanced?", "maximum path sum", "subtree ..." — DFS bottom-up (children report up, parent combines).
  • "Good nodes", "max so far on the path from root", "ancestor" — DFS carrying state down.

How it works

BFS with a size snapshot. Put the root in a queue. At the start of each round, write down size = queue.size(). Exactly those size nodes belong to the current level — the children you add while processing them wait for the next round. This one line is what turns "BFS" into "BFS that knows where each level ends".

level 0 level 1 level 2 3 9 20 15 7 Round Queue at start Size Level 1 [3] 1 [3] 2 [9, 20] 2 [9,20] 3 [15, 7] 2 [15,7] Children added in a round wait for the next round: the size snapshot keeps levels apart.
Level-order BFS. Each row on the right is one pass of the outer loop.

DFS: two styles. Decide which direction information flows.

StyleInformation flowsYou writeUse for
Top-down (preorder thinking)From parent to child, through parametersvoid dfs(node, stateSoFar)Path sums, max-so-far on the path, depth of each node, building a path list
Bottom-up (postorder thinking)From children to parent, through return valuesint dfs(node) returns an answer for that subtreeHeight, diameter, balanced check, max path sum, subtree sums

Trace of bottom-up height on the same tree: h(15)=1, h(7)=1, so h(20)=1+max(1,1)=2; h(9)=1; h(3)=1+max(1,2)=3. Each node is visited once, and the answer "bubbles up" to the root.

Java template

List<List<Integer>> levelOrder(TreeNode root) {
    List<List<Integer>> result = new ArrayList<>();
    if (root == null) return result;
    Deque<TreeNode> queue = new ArrayDeque<>();
    queue.offer(root);
    while (!queue.isEmpty()) {
        int size = queue.size();              // snapshot: nodes on THIS level
        List<Integer> level = new ArrayList<>();
        for (int i = 0; i < size; i++) {
            TreeNode node = queue.poll();
            level.add(node.val);              // CHANGES: what you do with each node
            if (node.left != null) queue.offer(node.left);
            if (node.right != null) queue.offer(node.right);
        }
        result.add(level);                    // CHANGES: what you keep per level
    }
    return result;
}
// Top-down: pass state DOWN as parameters (depth, path, running sum, max so far)
int best = 0;
void topDown(TreeNode node, int depth) {
    if (node == null) return;
    if (node.left == null && node.right == null)
        best = Math.max(best, depth);         // CHANGES: act at a leaf / at each node
    topDown(node.left, depth + 1);            // CHANGES: how state is updated
    topDown(node.right, depth + 1);
}

// Bottom-up: children RETURN answers, parent combines them
int bottomUp(TreeNode node) {
    if (node == null) return 0;               // CHANGES: value for an empty tree
    int left = bottomUp(node.left);
    int right = bottomUp(node.right);
    return 1 + Math.max(left, right);         // CHANGES: how to combine
}
Which DFS style? Ask: "can a node decide its answer from what its ancestors tell it?" Then top-down. "Does a node need to know something about its whole subtree first?" Then bottom-up. Many hard problems use both: state comes down in parameters, results go up in the return value (see Count Good Nodes).

Classic problems

Binary Tree Level Order Traversal — LC 102 (Medium)

Return the node values level by level: [3,9,20,null,null,15,7] gives [[3],[9,20],[15,7]].

Key insight: this is the BFS template above, unchanged. Everything else in this section is a small edit of it.

Time O(n), Space O(w) where w is the widest level (up to n/2 for a full tree).

Binary Tree Zigzag Level Order Traversal — LC 103 (Medium)

Like level order, but alternate direction: left-to-right, then right-to-left. Same tree gives [[3],[20,9],[15,7]].

Key insight: do not change the order you visit nodes — that gets messy. Visit normally and just fill the level list from the front on odd levels.

List<List<Integer>> zigzagLevelOrder(TreeNode root) {
    List<List<Integer>> result = new ArrayList<>();
    if (root == null) return result;
    Deque<TreeNode> queue = new ArrayDeque<>();
    queue.offer(root);
    boolean leftToRight = true;
    while (!queue.isEmpty()) {
        int size = queue.size();
        LinkedList<Integer> level = new LinkedList<>();
        for (int i = 0; i < size; i++) {
            TreeNode node = queue.poll();
            if (leftToRight) level.addLast(node.val);
            else level.addFirst(node.val);    // fill backwards on odd levels
            if (node.left != null) queue.offer(node.left);
            if (node.right != null) queue.offer(node.right);
        }
        result.add(level);
        leftToRight = !leftToRight;
    }
    return result;
}

Time O(n), Space O(w).

Binary Tree Right Side View — LC 199 (Medium)

Return the values you would see standing on the right of the tree. [1,2,3,null,5,null,4] gives [1,3,4].

Key insight: "what you see from the right" = the last node of each level. A common wrong answer is "keep going right" — that misses a left child that sticks out lower than every right node (the 5 above would be missed if 4 were absent).

List<Integer> rightSideView(TreeNode root) {
    List<Integer> view = new ArrayList<>();
    if (root == null) return view;
    Deque<TreeNode> queue = new ArrayDeque<>();
    queue.offer(root);
    while (!queue.isEmpty()) {
        int size = queue.size();
        for (int i = 0; i < size; i++) {
            TreeNode node = queue.poll();
            if (i == size - 1) view.add(node.val);   // last node of the level
            if (node.left != null) queue.offer(node.left);
            if (node.right != null) queue.offer(node.right);
        }
    }
    return view;
}

Time O(n), Space O(w). (A DFS alternative: visit right child first and record the first node you reach at each new depth.)

Path Sum II — LC 113 (Medium)

Return every root-to-leaf path whose values add up to targetSum. For [5,4,8,11,null,13,4,7,2,null,null,5,1] and target 22 the answer is [[5,4,11,2],[5,8,4,5]].

Key insight: top-down DFS carrying the remaining sum and the current path. Add the node on the way in, remove it on the way out (backtracking), and copy the path when you save it.

List<List<Integer>> pathSum(TreeNode root, int targetSum) {
    List<List<Integer>> result = new ArrayList<>();
    dfs(root, targetSum, new ArrayList<>(), result);
    return result;
}

private void dfs(TreeNode node, int remaining, List<Integer> path,
                 List<List<Integer>> result) {
    if (node == null) return;
    path.add(node.val);                                  // choose
    remaining -= node.val;
    if (node.left == null && node.right == null && remaining == 0)
        result.add(new ArrayList<>(path));               // copy, not the live list
    dfs(node.left, remaining, path, result);
    dfs(node.right, remaining, path, result);
    path.remove(path.size() - 1);                        // un-choose (backtrack)
}

Time O(n²) worst case (n nodes, and copying a path of up to n values at each leaf), Space O(h) for recursion plus the path.

Binary Tree Maximum Path Sum — LC 124 (Hard)

A path is any chain of connected nodes (it need not pass through the root and can bend once at some node). Find the largest sum. [-10,9,20,null,null,15,7] gives 42 (15 → 20 → 7).

Key insight: bottom-up DFS where each node answers two different questions:

  • Returned to the parent: the best "one-armed" path starting here and going down one side — because a parent can only extend a path that doesn't already bend.
  • Recorded in a global: the best path that bends here: node + left + right.

Negative branches are clamped to 0 ("don't take that branch").

int maxSum = Integer.MIN_VALUE;

int maxPathSum(TreeNode root) {
    gain(root);
    return maxSum;
}

// returns the best sum of a path that STARTS at node and goes down one side
private int gain(TreeNode node) {
    if (node == null) return 0;
    int left = Math.max(0, gain(node.left));    // drop negative branches
    int right = Math.max(0, gain(node.right));
    maxSum = Math.max(maxSum, node.val + left + right);  // path bends here
    return node.val + Math.max(left, right);    // parent can use only one side
}

Time O(n), Space O(h). Start maxSum at Integer.MIN_VALUE, not 0 — for the single-node tree [-3] the answer is -3.

Count Good Nodes in Binary Tree — LC 1448 (Medium)

A node is "good" if no node on the path from the root to it has a bigger value. [3,1,4,3,null,1,5] gives 4.

Key insight: pass the max seen so far down (top-down), and add up the counts on the way back (bottom-up) — both styles in one function.

int goodNodes(TreeNode root) {
    return count(root, Integer.MIN_VALUE);
}

private int count(TreeNode node, int maxSoFar) {
    if (node == null) return 0;
    int good = node.val >= maxSoFar ? 1 : 0;       // top-down: state flows in
    int newMax = Math.max(maxSoFar, node.val);
    return good + count(node.left, newMax) + count(node.right, newMax);  // bottom-up sum
}

Time O(n), Space O(h).

Common mistakes: (1) Reading queue.size() inside the for condition — it changes as you add children; snapshot it first. (2) Adding the live path list to the result instead of new ArrayList<>(path) — every saved answer ends up empty. (3) Forgetting to remove the last element after recursing (the "un-choose" step). (4) Treating "leaf" as "node == null" — a leaf is a node with both children null; checking null fires twice per leaf and wrongly counts nodes with one child. (5) Initialising a max to 0 when all values can be negative. (6) Recursion depth: a skewed tree of 105 nodes can overflow the Java stack; mention BFS or an explicit stack as the fix.

Practice list

  • LC 104 Maximum Depth of Binary Tree (Easy)
  • LC 111 Minimum Depth of Binary Tree (Easy) — BFS, stop at the first leaf
  • LC 112 Path Sum (Easy)
  • LC 637 Average of Levels in Binary Tree (Easy)
  • LC 116 Populating Next Right Pointers in Each Node (Medium)
  • LC 129 Sum Root to Leaf Numbers (Medium)
  • LC 236 Lowest Common Ancestor of a Binary Tree (Medium)
  • LC 662 Maximum Width of Binary Tree (Medium)
  • LC 437 Path Sum III (Medium) — prefix sums on a tree path
  • LC 297 Serialize and Deserialize Binary Tree (Hard)

39. Top K Elements & K-way Merge

In plain words

Top K: you want the k biggest (or smallest, or most frequent, or closest) items, not a full sort. Keep a small heap of size k that holds the best k seen so far. Every new item fights the weakest member; the loser is thrown out.

K-way merge: you have k sorted lists and want one sorted output. Put the front item of every list in a min-heap. Repeatedly take the smallest, and replace it with the next item from the same list.

Top K is a talent show with only k chairs on stage. A new act walks on; if they beat the weakest person sitting down, that person leaves and the new act sits. K-way merge is k checkout queues merging into one door: the guard looks only at the person at the front of each queue, lets through the one with the lowest ticket number, and that queue moves up one.

How to recognise it

  • "k-th largest / smallest", "top k", "k most frequent", "k closest", "k best" — heap of size k.
  • The input is huge or a stream (you can't sort it all at once) — heap of size k uses O(k) memory.
  • "k sorted lists / arrays / rows", "merge sorted", "smallest range across lists", "k-th smallest in a sorted matrix" — k-way merge.
  • Frequencies bounded by n ("appears how many times") — consider bucket sort for O(n).

How it works

Why a MIN-heap for the k LARGEST? It sounds backwards. The heap is a waiting room for the current top k. To decide if a new number belongs, you compare it with the weakest of the current top k — the smallest one. A min-heap keeps exactly that one on top, ready in O(1), removable in O(log k). Mirror rule: k smallest uses a max-heap.

nums = [3, 1, 5, 12, 2, 11], k = 3 min-heap contents (sorted for reading) offer 3 [3] offer 1 [1, 3] offer 5 [1, 3, 5] offer 12 → size 4 > 3 [1, 3, 5, 12] → poll 1 → [3, 5, 12] offer 2 → size 4 > 3 [2, 3, 5, 12] → poll 2 → [3, 5, 12] offer 11 → size 4 > 3 [3, 5, 11, 12] → poll 3 → [5, 11, 12] top 3 = [5, 11, 12] heap.peek() = 5 = the 3rd largest

Cost: n offers and polls on a heap that never grows past k+1, so O(n log k) time and O(k) space — better than sorting (O(n log n)) when k is small.

k sorted lists L0 1 4 5 L1 1 3 4 L2 2 6 min-heap (size k) (1, L0, 0) (1, L1, 0) (2, L2, 0) (value, list, index) poll min output 1 1 2 3 4 4 5 6 after polling (1, L0, 0), push (4, L0, 1): the NEXT item from the same list
K-way merge. The heap never holds more than k items — one "front runner" per list.

Each of the N total items enters and leaves the heap once, and the heap has at most k items: O(N log k) time, O(k) extra space.

Java template

// Top k LARGEST: keep a MIN-heap of size k. Its top is the weakest of the "winners".
List<Integer> topKLargest(int[] nums, int k) {
    PriorityQueue<Integer> heap = new PriorityQueue<>();      // min-heap
    for (int x : nums) {
        heap.offer(x);
        if (heap.size() > k) heap.poll();     // kick out the smallest of k+1
    }
    return new ArrayList<>(heap);             // any order; heap.peek() = k-th largest
}
// CHANGES per problem:
//   top k SMALLEST       -> max-heap: new PriorityQueue<>(Comparator.reverseOrder())
//   rank by something else (distance, frequency) -> custom comparator
// K-way merge: lists[i] is sorted; output everything in sorted order
List<Integer> mergeSortedArrays(int[][] lists) {
    // entry = {value, listIndex, elementIndex}
    PriorityQueue<int[]> heap = new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));
    for (int i = 0; i < lists.length; i++)
        if (lists[i].length > 0) heap.offer(new int[]{lists[i][0], i, 0});

    List<Integer> out = new ArrayList<>();
    while (!heap.isEmpty()) {
        int[] cur = heap.poll();
        out.add(cur[0]);                      // CHANGES: what you do with the smallest
        int li = cur[1], next = cur[2] + 1;
        if (next < lists[li].length)          // same list sends its next item
            heap.offer(new int[]{lists[li][next], li, next});
    }
    return out;
}

Classic problems

Kth Largest Element in an Array — LC 215 (Medium)

Return the k-th largest value. [3,2,1,5,6,4], k = 2 gives 5.

Key insight: the top of a size-k min-heap is the k-th largest.

int findKthLargest(int[] nums, int k) {
    PriorityQueue<Integer> heap = new PriorityQueue<>();
    for (int x : nums) {
        heap.offer(x);
        if (heap.size() > k) heap.poll();
    }
    return heap.peek();
}

Time O(n log k), Space O(k).

Quickselect alternative. Quicksort's partition puts one pivot at its final sorted index and smaller values to its left. If that index is the one you want, stop; otherwise continue on one side only. Average O(n) because you throw away about half each time (n + n/2 + n/4 + … ≈ 2n). Worst case O(n²) with bad pivots — a random pivot makes that very unlikely.

int findKthLargestQuick(int[] nums, int k) {
    int target = nums.length - k;             // index it would have if sorted ascending
    int lo = 0, hi = nums.length - 1;
    Random rnd = new Random();
    while (lo < hi) {
        int p = partition(nums, lo, hi, lo + rnd.nextInt(hi - lo + 1));
        if (p == target) break;
        if (p < target) lo = p + 1;           // answer is on the right
        else hi = p - 1;                      // answer is on the left
    }
    return nums[target];
}

// Lomuto partition: smaller values go left of the pivot; returns pivot's final index
private int partition(int[] a, int lo, int hi, int pivotIdx) {
    int pivot = a[pivotIdx];
    swap(a, pivotIdx, hi);
    int store = lo;
    for (int i = lo; i < hi; i++)
        if (a[i] < pivot) swap(a, i, store++);
    swap(a, store, hi);
    return store;
}

private void swap(int[] a, int i, int j) { int t = a[i]; a[i] = a[j]; a[j] = t; }

[3,2,3,1,2,4,5,5,6], k = 4 gives 4. Time O(n) average / O(n²) worst, Space O(1). It rearranges the input — say so in the interview. Many duplicates slow Lomuto partition down; a three-way partition fixes that.

Top K Frequent Elements — LC 347 (Medium)

Return the k most frequent values. [1,1,1,2,2,3], k = 2 gives [1,2].

Key insight: a heap of size k keyed on frequency works (O(n log k)). But a frequency can never exceed n, so use bucket sort: bucket[f] lists the values that appear f times. Walk from the highest bucket down. O(n).

int[] topKFrequent(int[] nums, int k) {
    Map<Integer, Integer> freq = new HashMap<>();
    for (int x : nums) freq.merge(x, 1, Integer::sum);

    // bucket[f] = numbers that appear exactly f times (f is at most n)
    List<Integer>[] bucket = new List[nums.length + 1];
    for (var e : freq.entrySet()) {
        int f = e.getValue();
        if (bucket[f] == null) bucket[f] = new ArrayList<>();
        bucket[f].add(e.getKey());
    }

    int[] result = new int[k];
    int idx = 0;
    for (int f = nums.length; f >= 1 && idx < k; f--)     // highest frequency first
        if (bucket[f] != null)
            for (int x : bucket[f])
                if (idx < k) result[idx++] = x;
    return result;
}

Time O(n), Space O(n). (Java warns about the generic array new List[n + 1]; that's fine here, or use List<List<Integer>>.)

K Closest Points to Origin — LC 973 (Medium)

Return the k points nearest to (0, 0). [[3,3],[5,-1],[-2,4]], k = 2 gives [[3,3],[-2,4]] (any order).

Key insight: "k closest" = "k smallest distances", so keep a max-heap of size k and drop the farthest. Compare squared distances — no Math.sqrt, no floating point.

int[][] kClosest(int[][] points, int k) {
    // max-heap by distance: the top is the FARTHEST of the k we keep
    PriorityQueue<int[]> heap = new PriorityQueue<>(
        (a, b) -> Integer.compare(dist(b), dist(a)));
    for (int[] p : points) {
        heap.offer(p);
        if (heap.size() > k) heap.poll();     // drop the farthest
    }
    return heap.toArray(new int[0][]);
}

private int dist(int[] p) { return p[0] * p[0] + p[1] * p[1]; }   // no sqrt needed

Time O(n log k), Space O(k). Quickselect on distance also works in O(n) average.

Merge k Sorted Lists — LC 23 (Hard)

Merge k sorted linked lists into one. [[1,4,5],[1,3,4],[2,6]] gives 1->1->2->3->4->4->5->6.

Key insight: the list nodes carry their own "next", so the heap can hold nodes directly — no (list, index) bookkeeping.

ListNode mergeKLists(ListNode[] lists) {
    PriorityQueue<ListNode> heap = new PriorityQueue<>((a, b) -> Integer.compare(a.val, b.val));
    for (ListNode head : lists)
        if (head != null) heap.offer(head);   // one "front runner" per list

    ListNode dummy = new ListNode(0), tail = dummy;
    while (!heap.isEmpty()) {
        ListNode smallest = heap.poll();
        tail.next = smallest;
        tail = smallest;
        if (smallest.next != null) heap.offer(smallest.next);  // its list sends the next one
    }
    return dummy.next;
}

Time O(N log k) for N total nodes, Space O(k). The alternative is divide and conquer: merge lists in pairs, rounds of log k, same O(N log k).

Kth Smallest Element in a Sorted Matrix — LC 378 (Medium)

Every row and every column is sorted. Find the k-th smallest. [[1,5,9],[10,11,13],[12,13,15]], k = 8 gives 13.

Key insight: each row is a sorted list — k-way merge the rows and stop at the k-th pop. Only the first min(n, k) rows can matter.

int kthSmallest(int[][] matrix, int k) {
    int n = matrix.length;
    // entry = {value, row, col}; each row is a sorted list
    PriorityQueue<int[]> heap = new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));
    for (int r = 0; r < Math.min(n, k); r++)
        heap.offer(new int[]{matrix[r][0], r, 0});

    for (int i = 1; i < k; i++) {             // pop k-1 times
        int[] cur = heap.poll();
        int r = cur[1], c = cur[2];
        if (c + 1 < n) heap.offer(new int[]{matrix[r][c + 1], r, c + 1});
    }
    return heap.peek()[0];                    // k-th pop
}

Time O(k log n), Space O(n). A binary search on the value (count how many are <= mid) gives O(n log(max - min)) — see section 42.

Smallest Range Covering Elements from K Lists — LC 632 (Hard)

Find the smallest range [a, b] that includes at least one number from each of k sorted lists. [[4,10,15,24,26],[0,9,12,20],[5,18,22,30]] gives [20,24].

Key insight: keep exactly one number from each list in the heap — that set always covers all lists. Its range is [heap min, current max]. To try to shrink it, the only move that can help is advancing the minimum. Stop when some list runs out.

int[] smallestRange(List<List<Integer>> nums) {
    // entry = {value, listIndex, elementIndex}
    PriorityQueue<int[]> heap = new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));
    int max = Integer.MIN_VALUE;
    for (int i = 0; i < nums.size(); i++) {
        int v = nums.get(i).get(0);
        heap.offer(new int[]{v, i, 0});
        max = Math.max(max, v);               // track the max of the current window
    }

    int bestLo = 0, bestHi = Integer.MAX_VALUE;
    while (true) {
        int[] cur = heap.poll();              // current minimum
        int min = cur[0], li = cur[1], ei = cur[2];
        if (max - min < bestHi - bestLo) { bestLo = min; bestHi = max; }
        if (ei + 1 == nums.get(li).size()) break;   // a list ran out: can't cover all k
        int next = nums.get(li).get(ei + 1);
        heap.offer(new int[]{next, li, ei + 1});
        max = Math.max(max, next);
    }
    return new int[]{bestLo, bestHi};
}

Time O(N log k), Space O(k).

Common mistakes: (1) Using a max-heap of all n items for "k largest" — works but costs O(n) space and O(n log n) time; the point is the size-k min-heap. (2) Comparator written as (a, b) -> a - b — overflows for large or negative values; use Integer.compare. (3) Returning heap contents as if sorted — a PriorityQueue iterates in heap order, not sorted order; poll repeatedly if order matters. (4) In k-way merge, forgetting to push the next item from the same list, or pushing from the wrong list because the index wasn't stored. (5) Offering null list heads into the heap (NullPointerException).

Practice list

  • LC 1046 Last Stone Weight (Easy)
  • LC 703 Kth Largest Element in a Stream (Easy)
  • LC 451 Sort Characters By Frequency (Medium)
  • LC 692 Top K Frequent Words (Medium) — tie-break by word
  • LC 658 Find K Closest Elements (Medium)
  • LC 373 Find K Pairs with Smallest Sums (Medium) — k-way merge in disguise
  • LC 767 Reorganize String (Medium)
  • LC 621 Task Scheduler (Medium)
  • LC 1439 Find the Kth Smallest Sum of a Matrix With Sorted Rows (Hard)

40. Two Heaps

In plain words

Split the numbers into a smaller half and a larger half. Keep the smaller half in a max-heap (so its biggest value is on top) and the larger half in a min-heap (so its smallest value is on top). The two tops sit right next to each other in the middle of the data — so the median is always one peek() away.

The same "two heaps facing each other" idea also shows up when you have two groups of items — "things I can't use yet" and "things I can use now" — and keep moving items from one heap to the other (the IPO problem below).

Line people up by height and cut the line in half. The short group faces right so its tallest person stands at the cut; the tall group faces left so its shortest person stands at the cut. To find the middle height you only look at the two people at the cut — never at anyone else.

How to recognise it

  • "Median" of a stream / of a changing set / of every window.
  • "Numbers arrive one by one, answer after each one" and the answer depends on the middle of the data.
  • "Split into two groups so that every item in one is <= every item in the other".
  • Scheduling with a threshold that grows: "you can only pick projects/tasks whose requirement <= what you have now" — one heap holds the locked items sorted by requirement, the other holds the unlocked ones sorted by value.

How it works

Two rules keep the structure correct:

  1. Order rule: every value in low <= every value in high, i.e. low.peek() <= high.peek().
  2. Size rule: low.size() == high.size() or low.size() == high.size() + 1. (Low is allowed the one extra item when the count is odd.)

Then: odd count, the median is low.peek(); even count, it is the average of the two tops.

sorted view of the data: 1 3 5 | 8 15 low: max-heap smaller half, biggest on top 5 3 1 size 3 = high + 1 high: min-heap larger half, smallest on top 8 15 size 2 tops meet odd count: median = low.peek() = 5 even count: median = (low.peek() + high.peek()) / 2
Two heaps facing each other. Only the two tops are ever needed to answer "what is the median?".

The three-step add (simplest version that can't get the order rule wrong): (1) push into low; (2) move low's top into high — this guarantees the biggest of the small half crosses over, so the order rule holds; (3) if high is now bigger, move high's top back to low.

stream: 5, 15, 1, 3, 8 (heap contents shown sorted for reading) add 5 : low=[5] high=[] median 5.0 add 15 : low=[5] high=[15] median (5+15)/2 = 10.0 add 1 : low=[1,5] high=[15] median 5.0 (5 went up, then came back) add 3 : low=[1,3] high=[5,15] median (3+5)/2 = 4.0 add 8 : low=[1,3,5] high=[8,15] median 5.0

Java template

PriorityQueue<Integer> low  = new PriorityQueue<>(Comparator.reverseOrder()); // max-heap
PriorityQueue<Integer> high = new PriorityQueue<>();                          // min-heap

void add(int x) {
    low.offer(x);
    high.offer(low.poll());                   // keeps order rule
    if (high.size() > low.size()) low.offer(high.poll());   // keeps size rule
}
// CHANGES per problem: what you read from the tops (median, balance point),
// and whether items must also be REMOVED (then use TreeSet / lazy deletion)

Classic problems

Find Median from Data Stream — LC 295 (Hard)

Design a class with addNum(int) and findMedian(). addNum(1), addNum(2) then findMedian() is 1.5; addNum(3) then findMedian() is 2.0.

Key insight: sorting after every add costs O(n log n) each time; two heaps make addNum O(log n) and findMedian O(1).

class MedianFinder {
    // lower half: max-heap (top = biggest of the small numbers)
    private final PriorityQueue<Integer> low = new PriorityQueue<>(Comparator.reverseOrder());
    // upper half: min-heap (top = smallest of the big numbers)
    private final PriorityQueue<Integer> high = new PriorityQueue<>();

    public void addNum(int num) {
        low.offer(num);                       // 1. always enter through the lower half
        high.offer(low.poll());               // 2. move low's biggest up (keeps order right)
        if (high.size() > low.size())         // 3. rebalance: low may have 1 extra, never high
            low.offer(high.poll());
    }

    public double findMedian() {
        if (low.size() > high.size()) return low.peek();       // odd count
        return (low.peek() + (long) high.peek()) / 2.0;         // even count; long avoids overflow
    }
}

Time O(log n) per add, O(1) per median. Space O(n).

Follow-ups interviewers like: "All numbers are in 0..100" — use a count array of 101 buckets and walk it: O(100) per median. "99% of numbers are in 0..100" — count array for those, plus two small counters (or heaps) for the outliers below and above.

Sliding Window Median — LC 480 (Hard)

Return the median of every window of size k. [1,3,-1,-3,5,3,6,7], k = 3 gives [1.0,-1.0,-1.0,3.0,5.0,6.0].

Key insight: now items must also leave. PriorityQueue.remove(Object) is O(k) (a linear scan). Use two TreeSets instead — they support "biggest", "smallest" and removing any element in O(log k). A set drops duplicates, so store indices and compare by value, then by index, which makes every entry unique.

double[] medianSlidingWindow(int[] nums, int k) {
    // Compare by value, then by index, so equal values are still distinct entries.
    Comparator<Integer> byValue = (a, b) ->
        nums[a] != nums[b] ? Integer.compare(nums[a], nums[b]) : Integer.compare(a, b);
    TreeSet<Integer> low = new TreeSet<>(byValue);     // indices of the lower half
    TreeSet<Integer> high = new TreeSet<>(byValue);    // indices of the upper half

    double[] result = new double[nums.length - k + 1];
    for (int i = 0; i < nums.length; i++) {
        // add: same three steps as MedianFinder
        low.add(i);
        high.add(low.pollLast());
        if (high.size() > low.size()) low.add(high.pollFirst());

        if (i >= k) {                                  // remove the index that left the window
            int out = i - k;
            if (!low.remove(out)) high.remove(out);
            // fix sizes again: low must have size == high or high + 1
            if (low.size() < high.size()) low.add(high.pollFirst());
            else if (low.size() > high.size() + 1) high.add(low.pollLast());
        }

        if (i >= k - 1) {
            result[i - k + 1] = (k % 2 == 1)
                ? nums[low.last()]
                : ((double) nums[low.last()] + nums[high.first()]) / 2.0;
        }
    }
    return result;
}

Time O(n log k), Space O(k). The sum is cast to double first so two values near Integer.MAX_VALUE don't overflow. (Another accepted approach: two PriorityQueues with lazy deletion — keep a map of "to be deleted" values and throw them away only when they reach the top.)

IPO — LC 502 (Hard)

You start with capital w and may finish at most k projects. Project i needs capital[i] to start and adds profits[i] to your capital. Maximise final capital. k=2, w=0, profits=[1,2,3], capital=[0,1,1] gives 4.

Key insight: greedy — each round, among the projects you can afford right now, take the most profitable (taking it can only unlock more). Two heaps: a min-heap by capital of locked projects, and a max-heap by profit of affordable ones. As w grows, move newly affordable projects across.

int findMaximizedCapital(int k, int w, int[] profits, int[] capital) {
    int n = profits.length;
    // min-heap of projects by required capital: "locked" projects
    PriorityQueue<int[]> locked = new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));
    // max-heap of profits among projects we can afford: "unlocked"
    PriorityQueue<Integer> available = new PriorityQueue<>(Comparator.reverseOrder());
    for (int i = 0; i < n; i++) locked.offer(new int[]{capital[i], profits[i]});

    for (int round = 0; round < k; round++) {
        while (!locked.isEmpty() && locked.peek()[0] <= w)
            available.offer(locked.poll()[1]);  // now affordable
        if (available.isEmpty()) break;         // nothing we can start
        w += available.poll();                  // greedy: take the most profitable
    }
    return w;
}

Time O(n log n + k log n), Space O(n). Trace: w=0, unlock project 0 (profit 1), take it: w=1. Unlock projects 1 and 2 (profits 2, 3), take 3: w=4.

Common mistakes: (1) Pushing into one heap by comparing with its top, without rebalancing — the sizes drift and the median is wrong. (2) Letting high hold the extra element but still reading the median from low — pick one convention and stick to it. (3) (a + b) / 2 with ints: integer division and overflow; use (a + (long) b) / 2.0. (4) Using PriorityQueue.remove(x) in a sliding window and calling it O(log k) — it is O(k). (5) Putting values (not indices) in a TreeSet — duplicate values vanish.

Practice list

  • LC 703 Kth Largest Element in a Stream (Easy) — warm-up with one heap
  • LC 436 Find Right Interval (Medium)
  • LC 1834 Single-Threaded CPU (Medium) — "available" vs "not yet arrived" heaps
  • LC 2542 Maximum Subsequence Score (Medium)
  • LC 857 Minimum Cost to Hire K Workers (Hard)
  • LC 630 Course Schedule III (Hard)
  • LC 871 Minimum Number of Refueling Stops (Hard) — same unlock-then-take-best idea as IPO
  • LC 2402 Meeting Rooms III (Hard) — free rooms heap + busy rooms heap

41. Subsets, Permutations & Combinations

In plain words

These problems ask you to list every possible choice: every subset, every ordering, every group that adds up to a target. You build answers one element at a time with backtracking: choose an element, explore everything that follows from that choice, then un-choose it and try the next option. (Section 21 covers backtracking in general; this section is the three templates you will use again and again.)

The three families differ in only one thing — which elements you are allowed to pick next:

  • Subsets / combinations — order doesn't matter ([1,2] is the same as [2,1]), so only pick elements to the right of the last pick: a start index.
  • Permutations — order matters, so any element not yet used: a used[] array.
  • With duplicates in the input — sort first, then skip an element equal to its left neighbour at the same level.

Ordering at a sandwich counter. For toppings (a subset), "cheese then tomato" is the same sandwich as "tomato then cheese", so you go down the list once and say yes or no. For a race podium (a permutation), who is first matters, so every runner can take every free spot.

How to recognise it

  • "Return all subsets / the power set", "all combinations", "all permutations", "all possible ...".
  • "All combinations that sum to target", "choose k numbers from 1..n".
  • "All valid strings" (parentheses, IP addresses, letter combinations of a phone number).
  • Small input: n <= 10–20. Output size is exponential (2n subsets, n! permutations), so nothing faster exists — the input limit is the hint.
  • "Without duplicate subsets/permutations" when the input has repeated values — sort + skip rule.

How it works

Picture a decision tree. Each node is a partial answer (the path). Each edge is "add one more element". Backtracking is a DFS over this tree: going down an edge is choose, returning up is un-choose.

[ ] +1 +2 +3 [1] [2] [3] +2 +3 +3 [1,2] [1,3] [2,3] +3 [1,2,3] start index rule after picking nums[i], the child loop starts at i + 1, so [2,1] is never built (it equals [1,2]) 8 nodes = 2^3 subsets
Subsets of [1, 2, 3]. Every node in the tree is an answer; the start index stops the tree from building the same set in a different order.

Trace (path shown at each step): [] save → add 1 → [1] save → add 2 → [1,2] save → add 3 → [1,2,3] save → remove 3 → remove 2 → add 3 → [1,3] save → remove 3 → remove 1 → add 2 → [2] save → add 3 → [2,3] save → … → [3] save. Output order: [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]].

Problem typeLoop starts atRecurse withSave whenExtra
Subsetsstarti + 1every node—
Combinations of size k / sum to targetstarti + 1 (each once) or i (reuse allowed)size == k / remaining == 0sort + break when too big
Permutations0same arraypath.size() == nused[]
Any of the above with duplicate inputssame as abovesort, then skip rule

The duplicate skip rule. With input [1,2,2] the tree would build [1,2] twice — once with the first 2, once with the second. After sorting, equal values are neighbours, and the rule is:

if (i > start && nums[i] == nums[i - 1]) continue;

Read it as: "at this level of the tree (this loop), I already tried a branch starting with this value — don't start another one". i > start (not i > 0) matters: going deeper with the second 2 (building [2,2]) is still allowed, because that is a different level.

Java template

List<List<Integer>> result = new ArrayList<>();

void backtrack(int[] nums, int start, List<Integer> path) {
    result.add(new ArrayList<>(path));        // CHANGES: when a path counts as an answer
    for (int i = start; i < nums.length; i++) {
        // CHANGES: skip rules (duplicates, over target, used[])
        path.add(nums[i]);                    // 1. choose
        backtrack(nums, i + 1, path);         // 2. explore  (i + 1: no reuse; i: reuse allowed)
        path.remove(path.size() - 1);         // 3. un-choose
    }
}

Classic problems

Subsets — LC 78 (Medium)

Return all subsets of distinct integers. [1,2,3] gives the 8 subsets above.

Key insight: every node of the start-index tree is a subset — save on entry, no base case needed (the loop simply doesn't run when start == n).

List<List<Integer>> subsets(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    build(nums, 0, new ArrayList<>(), result);
    return result;
}

private void build(int[] nums, int start, List<Integer> path, List<List<Integer>> result) {
    result.add(new ArrayList<>(path));        // every node of the tree is a subset
    for (int i = start; i < nums.length; i++) {
        path.add(nums[i]);
        build(nums, i + 1, path, result);
        path.remove(path.size() - 1);
    }
}

Time O(n · 2n) (2n subsets, each copied in up to O(n)), Space O(n) recursion (plus the output). Bitmask alternative: every number mask from 0 to 2n-1 is a subset — bit i set means "take nums[i]".

Subsets II — LC 90 (Medium)

Same, but the input may contain duplicates and the answer must not. [1,2,2] gives [[],[1],[1,2],[1,2,2],[2],[2,2]].

Key insight: sort + the skip rule.

List<List<Integer>> subsetsWithDup(int[] nums) {
    Arrays.sort(nums);                        // duplicates become neighbours
    List<List<Integer>> result = new ArrayList<>();
    buildDup(nums, 0, new ArrayList<>(), result);
    return result;
}

private void buildDup(int[] nums, int start, List<Integer> path, List<List<Integer>> result) {
    result.add(new ArrayList<>(path));
    for (int i = start; i < nums.length; i++) {
        if (i > start && nums[i] == nums[i - 1]) continue;  // same value, same level: skip
        path.add(nums[i]);
        buildDup(nums, i + 1, path, result);
        path.remove(path.size() - 1);
    }
}

Time O(n · 2n), Space O(n).

Permutations — LC 46 (Medium)

Return all orderings of distinct integers. [1,2,3] gives [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]].

Key insight: order matters, so every level loops from 0, skipping only elements already in the path (used[i]).

List<List<Integer>> permute(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    perm(nums, new boolean[nums.length], new ArrayList<>(), result);
    return result;
}

private void perm(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> result) {
    if (path.size() == nums.length) {         // every slot filled
        result.add(new ArrayList<>(path));
        return;
    }
    for (int i = 0; i < nums.length; i++) {   // start from 0: order matters
        if (used[i]) continue;
        used[i] = true;
        path.add(nums[i]);
        perm(nums, used, path, result);
        path.remove(path.size() - 1);
        used[i] = false;
    }
}

Time O(n · n!), Space O(n).

Permutations II — LC 47 (Medium)

Input may contain duplicates; return unique orderings. [1,1,2] gives [[1,1,2],[1,2,1],[2,1,1]].

Key insight: sort, then treat equal values as if they must be used left to right. Skip nums[i] if its equal left twin is not currently used — that means the twin was already tried in this same slot and removed.

List<List<Integer>> permuteUnique(int[] nums) {
    Arrays.sort(nums);
    List<List<Integer>> result = new ArrayList<>();
    permDup(nums, new boolean[nums.length], new ArrayList<>(), result);
    return result;
}

private void permDup(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> result) {
    if (path.size() == nums.length) {
        result.add(new ArrayList<>(path));
        return;
    }
    for (int i = 0; i < nums.length; i++) {
        if (used[i]) continue;
        // equal twin to my left is NOT in use: it was tried at this slot already
        if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) continue;
        used[i] = true;
        path.add(nums[i]);
        permDup(nums, used, path, result);
        path.remove(path.size() - 1);
        used[i] = false;
    }
}

Time O(n · n!), Space O(n).

Combination Sum — LC 39 (Medium)

Distinct candidates, each usable any number of times. Return all combinations that sum to target. [2,3,6,7], target 7 gives [[2,2,3],[7]].

Key insight: recurse with i (not i + 1) so the same number can be picked again, but never an earlier one. Sorting lets you break as soon as a candidate is bigger than what's left.

List<List<Integer>> combinationSum(int[] candidates, int target) {
    Arrays.sort(candidates);                  // lets us stop early
    List<List<Integer>> result = new ArrayList<>();
    comb(candidates, target, 0, new ArrayList<>(), result);
    return result;
}

private void comb(int[] c, int remaining, int start, List<Integer> path,
                  List<List<Integer>> result) {
    if (remaining == 0) {
        result.add(new ArrayList<>(path));
        return;
    }
    for (int i = start; i < c.length; i++) {
        if (c[i] > remaining) break;          // sorted: everything after is bigger too
        path.add(c[i]);
        comb(c, remaining - c[i], i, path, result);   // i, not i + 1: reuse allowed
        path.remove(path.size() - 1);
    }
}

Time O(nT/m) roughly (T = target, m = smallest candidate: the tree's depth is at most T/m), Space O(T/m).

Combination Sum II — LC 40 (Medium)

Candidates may repeat; each used at most once; no duplicate combinations. [10,1,2,7,6,1,5], target 8 gives [[1,1,6],[1,2,5],[1,7],[2,6]].

Key insight: it is Subsets II with a target: sort, skip rule, recurse with i + 1.

List<List<Integer>> combinationSum2(int[] candidates, int target) {
    Arrays.sort(candidates);
    List<List<Integer>> result = new ArrayList<>();
    comb2(candidates, target, 0, new ArrayList<>(), result);
    return result;
}

private void comb2(int[] c, int remaining, int start, List<Integer> path,
                   List<List<Integer>> result) {
    if (remaining == 0) {
        result.add(new ArrayList<>(path));
        return;
    }
    for (int i = start; i < c.length; i++) {
        if (i > start && c[i] == c[i - 1]) continue;  // skip duplicate at this level
        if (c[i] > remaining) break;
        path.add(c[i]);
        comb2(c, remaining - c[i], i + 1, path, result);  // i + 1: each used once
        path.remove(path.size() - 1);
    }
}

Time O(n · 2n), Space O(n).

Letter Combinations of a Phone Number — LC 17 (Medium)

Map each digit 2–9 to its phone letters and return all strings. "23" gives [ad, ae, af, bd, be, bf, cd, ce, cf].

Key insight: level idx of the tree = digit idx; the choices at that level are its letters. Use a StringBuilder and delete the last char to un-choose.

private static final String[] KEYS =
    {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};

List<String> letterCombinations(String digits) {
    List<String> result = new ArrayList<>();
    if (digits.isEmpty()) return result;
    letters(digits, 0, new StringBuilder(), result);
    return result;
}

private void letters(String digits, int idx, StringBuilder sb, List<String> result) {
    if (idx == digits.length()) {
        result.add(sb.toString());
        return;
    }
    for (char ch : KEYS[digits.charAt(idx) - '0'].toCharArray()) {
        sb.append(ch);
        letters(digits, idx + 1, sb, result);
        sb.deleteCharAt(sb.length() - 1);
    }
}

Time O(n · 4n), Space O(n).

Generate Parentheses — LC 22 (Medium)

Return all valid strings of n pairs. n = 3 gives [((())), (()()), (())(), ()(()), ()()()].

Key insight: don't generate all 22n strings and filter. Only make moves that keep the string valid: add ( if open < n; add ) only if close < open.

List<String> generateParenthesis(int n) {
    List<String> result = new ArrayList<>();
    gen(n, 0, 0, new StringBuilder(), result);
    return result;
}

private void gen(int n, int open, int close, StringBuilder sb, List<String> result) {
    if (sb.length() == 2 * n) {
        result.add(sb.toString());
        return;
    }
    if (open < n) {                           // can still open one
        sb.append('(');
        gen(n, open + 1, close, sb, result);
        sb.deleteCharAt(sb.length() - 1);
    }
    if (close < open) {                       // can close only what is open
        sb.append(')');
        gen(n, open, close + 1, sb, result);
        sb.deleteCharAt(sb.length() - 1);
    }
}

Time O(4n/√n) (the n-th Catalan number of results), Space O(n).

Common mistakes: (1) result.add(path) instead of result.add(new ArrayList<>(path)) — all answers point at the same list, which ends empty. (2) Forgetting to un-choose (remove the last element / reset used[i]). (3) Skip rule written as i > 0 instead of i > start — this wrongly removes answers like [2,2]. (4) Forgetting to sort before applying the skip rule. (5) Using i + 1 when reuse is allowed (Combination Sum) or i when it isn't. (6) Using start for permutations — you'd only get one ordering.

Practice list

  • LC 784 Letter Case Permutation (Medium)
  • LC 77 Combinations (Medium)
  • LC 216 Combination Sum III (Medium)
  • LC 1079 Letter Tile Possibilities (Medium)
  • LC 131 Palindrome Partitioning (Medium)
  • LC 93 Restore IP Addresses (Medium)
  • LC 79 Word Search (Medium)
  • LC 698 Partition to K Equal Sum Subsets (Medium)
  • LC 51 N-Queens (Hard)
  • LC 37 Sudoku Solver (Hard)

42. Modified Binary Search

In plain words

Plain binary search (section 20) finds a value in a sorted array by halving the range each step. Modified binary search uses the same halving on things that are not obviously sorted: a sorted array that was rotated, a 2D matrix, two arrays at once, or even a range of possible answers.

The one question that makes it work: "Looking at mid, can I tell which half cannot contain the answer?" If yes, throw that half away — that is binary search, whatever the data looks like.

Finding a page in a book whose first chapters were torn off and stapled to the back. You can't just compare page numbers blindly — but whenever you open it, you can see whether the pages from here to the end run in order. That tells you which side to search.

How to recognise it

  • "Sorted" + "rotated", "sorted matrix", "two sorted arrays".
  • "Find first / last position", "first bad version", "smallest index such that ..." — a boundary search.
  • "Must run in O(log n)" — almost always binary search.
  • "Minimum capacity / speed / largest minimum / smallest maximum such that it's possible" — binary search on the answer: you can't compute the answer directly, but you can quickly check a guess, and bigger guesses are always at least as feasible.
  • "Peak element", "mountain array" — the slope at mid tells you the side.

How it works

Rotated sorted array. [4,5,6,7,0,1,2] is a sorted array rotated at index 4. Cut it anywhere, and at least one half is fully sorted. Compare nums[lo] with nums[mid] to find which one, then check whether the target lies inside that sorted half's range. If it does, go there; if not, go to the other half.

search target 0 in [4,5,6,7,0,1,2] 4 5 6 7 0 1 2 lo mid hi sorted half: 4 .. 7 the drop (7 → 0) is in here nums[lo] = 4 <= nums[mid] = 7, so the LEFT half is sorted. Is 4 <= target(0) < 7? No, so the target must be on the right: lo = mid + 1. Next round: [0, 1, 2] is a normal sorted range, mid = 1, then 0 is found at index 4.
In a rotated array one side of mid is always sorted; use that side's first and last values to decide where the target can be.

The general boundary template. Most modified searches reduce to "find the first index where a yes/no condition becomes true" over a range that looks like no no no YES YES YES. With while (lo < hi), hi = mid on yes and lo = mid + 1 on no, the loop always ends with lo == hi sitting on the first yes — no off-by-one guessing.

Java template

// Find the FIRST index where condition(i) is true.
// Works whenever condition looks like: false false false TRUE TRUE TRUE
int firstTrue(int lo, int hi) {               // search space [lo, hi]
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;         // no overflow; leans left
        if (condition(mid)) hi = mid;         // mid might be the answer: keep it
        else lo = mid + 1;                    // mid is definitely not: drop it
    }
    return lo;                                // lo == hi: the boundary
}
// CHANGES per problem: the search space [lo, hi] and condition(mid)
ProblemSearch spacecondition(mid)
First position of targetindices [0, n]nums[mid] >= target
Minimum of rotated arrayindices [0, n-1]nums[mid] <= nums[n-1] (mid is in the right, lower part)
Ship packages / Koko bananasanswers [max item, sum]feasible(mid)
Peak elementindices [0, n-1]nums[mid] > nums[mid + 1] (going downhill)

Classic problems

Search in Rotated Sorted Array — LC 33 (Medium)

Distinct values, rotated at an unknown pivot. Return the index of target or -1. [4,5,6,7,0,1,2], target 0 gives 4; target 3 gives -1.

Key insight: find the sorted half, check if the target is inside it.

int search(int[] nums, int target) {
    int lo = 0, hi = nums.length - 1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if (nums[mid] == target) return mid;
        if (nums[lo] <= nums[mid]) {                        // left half is sorted
            if (nums[lo] <= target && target < nums[mid]) hi = mid - 1;
            else lo = mid + 1;
        } else {                                            // right half is sorted
            if (nums[mid] < target && target <= nums[hi]) lo = mid + 1;
            else hi = mid - 1;
        }
    }
    return -1;
}

Time O(log n), Space O(1). The <= in nums[lo] <= nums[mid] matters when lo == mid (a one-element left half is sorted).

Find Minimum in Rotated Sorted Array — LC 153 (Medium)

[3,4,5,1,2] gives 1; [11,13,15,17] (not rotated) gives 11.

Key insight: compare mid with hi, not lo. If nums[mid] > nums[hi], the drop (and the minimum) is strictly right of mid. Otherwise mid..hi is sorted and the minimum is mid or to its left.

int findMin(int[] nums) {
    int lo = 0, hi = nums.length - 1;
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (nums[mid] > nums[hi]) lo = mid + 1;   // drop is to the right of mid
        else hi = mid;                            // mid..hi sorted: min is mid or left
    }
    return nums[lo];
}

Time O(log n), Space O(1). With duplicates (LC 154) add: if nums[mid] == nums[hi], do hi-- — worst case becomes O(n).

Search a 2D Matrix — LC 74 (Medium)

Each row sorted, and each row's first value is bigger than the previous row's last. [[1,3,5,7],[10,11,16,20],[23,30,34,60]]: target 3 gives true, 13 gives false.

Key insight: read row by row, it is one sorted array of rows * cols values. Binary search a flat index and convert: row = idx / cols, col = idx % cols.

boolean searchMatrix(int[][] matrix, int target) {
    int rows = matrix.length, cols = matrix[0].length;
    int lo = 0, hi = rows * cols - 1;             // pretend it is one flat array
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        int val = matrix[mid / cols][mid % cols]; // flat index -> (row, col)
        if (val == target) return true;
        if (val < target) lo = mid + 1;
        else hi = mid - 1;
    }
    return false;
}

Time O(log(m·n)), Space O(1).

Search a 2D Matrix II — LC 240 (Medium)

Rows sorted left to right, columns sorted top to bottom, but rows do not chain. Target 5 in the 5x5 example gives true, 20 gives false.

Key insight: start at the top-right corner. Everything left of it is smaller and everything below is bigger, so each comparison deletes a whole row or column — a "staircase" search. (Top-left doesn't work: both moves make it bigger.)

boolean searchMatrix2(int[][] matrix, int target) {
    int r = 0, c = matrix[0].length - 1;          // start top-right
    while (r < matrix.length && c >= 0) {
        int val = matrix[r][c];
        if (val == target) return true;
        if (val > target) c--;                    // whole column below is bigger: drop it
        else r++;                                 // whole row to the left is smaller: drop it
    }
    return false;
}

Time O(m + n), Space O(1).

Find First and Last Position of Element in Sorted Array — LC 34 (Medium)

[5,7,7,8,8,10], target 8 gives [3,4]; target 6 gives [-1,-1].

Key insight: two boundary searches. First = first index with value >= target. Last = (first index with value >= target + 1) - 1. One helper, called twice.

int[] searchRange(int[] nums, int target) {
    int first = lowerBound(nums, target);
    if (first == nums.length || nums[first] != target) return new int[]{-1, -1};
    int last = lowerBound(nums, target + 1) - 1;  // first index of anything bigger, minus 1
    return new int[]{first, last};
}

// first index with nums[i] >= target (nums.length if none)
private int lowerBound(int[] nums, int target) {
    int lo = 0, hi = nums.length;                 // hi = n: "not found" is a valid answer
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (nums[mid] >= target) hi = mid;
        else lo = mid + 1;
    }
    return lo;
}

Time O(log n), Space O(1). Note hi = nums.length: "target is bigger than everything" must be a possible answer.

Median of Two Sorted Arrays — LC 4 (Hard)

Find the median of two sorted arrays in O(log(min(m, n))). [1,3], [2] gives 2.0; [1,2], [3,4] gives 2.5.

Key insight: binary search the cut, not a value. Take i items from a and j = half - i from b for the left half. The cut is right when everything on the left <= everything on the right, i.e. aLeft <= bRight and bLeft <= aRight. If aLeft > bRight, you took too many from a (move left); otherwise too few (move right).

a = [1, 2] b = [3, 4] half = (2 + 2 + 1) / 2 = 2 try i = 1, j = 1: left = {1 | 3} right = {2 | 4} aLeft=1 bLeft=3 aRight=2 bRight=4 bLeft 3 > aRight 2 → too few from a → lo = 2 try i = 2, j = 0: left = {1, 2} right = {3, 4} aLeft=2 bLeft=-inf aRight=+inf bRight=3 valid cut → even total → (max(2, -inf) + min(+inf, 3)) / 2 = 2.5
double findMedianSortedArrays(int[] a, int[] b) {
    if (a.length > b.length) return findMedianSortedArrays(b, a);  // search the shorter one
    int m = a.length, n = b.length;
    int half = (m + n + 1) / 2;                   // size of the left part
    int lo = 0, hi = m;
    while (lo <= hi) {
        int i = (lo + hi) / 2;                    // take i from a ...
        int j = half - i;                         // ... and j from b
        int aLeft  = i == 0 ? Integer.MIN_VALUE : a[i - 1];
        int aRight = i == m ? Integer.MAX_VALUE : a[i];
        int bLeft  = j == 0 ? Integer.MIN_VALUE : b[j - 1];
        int bRight = j == n ? Integer.MAX_VALUE : b[j];

        if (aLeft <= bRight && bLeft <= aRight) {             // valid cut
            if ((m + n) % 2 == 1) return Math.max(aLeft, bLeft);
            return (Math.max(aLeft, bLeft) + (double) Math.min(aRight, bRight)) / 2.0;
        } else if (aLeft > bRight) {
            hi = i - 1;                           // took too many from a
        } else {
            lo = i + 1;                           // took too few from a
        }
    }
    throw new IllegalArgumentException("inputs not sorted");
}

Time O(log(min(m, n))), Space O(1). Searching the shorter array keeps j in range.

Capacity To Ship Packages Within D Days — LC 1011 (Medium)

Ship packages in order, one day at a time, never over capacity. Find the smallest capacity that finishes within days. [1..10], days = 5 gives 15.

Key insight (binary search on the answer): the answer lies between max(weights) (must carry the heaviest) and sum(weights) (one day). Checking one capacity is a simple greedy O(n) pass, and if capacity c works, every bigger one works — a no no YES YES range. Find the first yes.

int shipWithinDays(int[] weights, int days) {
    int lo = 0, hi = 0;
    for (int w : weights) {
        lo = Math.max(lo, w);                     // must fit the heaviest package
        hi += w;                                  // one day for everything
    }
    while (lo < hi) {                             // first capacity that works
        int mid = lo + (hi - lo) / 2;
        if (daysNeeded(weights, mid) <= days) hi = mid;
        else lo = mid + 1;
    }
    return lo;
}

private int daysNeeded(int[] weights, int cap) {
    int days = 1, load = 0;
    for (int w : weights) {
        if (load + w > cap) { days++; load = 0; } // start a new day
        load += w;
    }
    return days;
}

Time O(n log(sum)), Space O(1). Same shape: LC 875 Koko Eating Bananas, LC 410 Split Array Largest Sum, LC 1482 Minimum Days to Make m Bouquets.

Common mistakes: (1) mid = (lo + hi) / 2 overflows when both are near Integer.MAX_VALUE; use lo + (hi - lo) / 2. (2) Mixing templates: while (lo <= hi) goes with hi = mid - 1; while (lo < hi) goes with hi = mid. Mixing them causes infinite loops. (3) With lo = mid (searching for the last true), use an upper mid lo + (hi - lo + 1) / 2, or the loop never ends. (4) In rotated arrays, comparing with the wrong end or using < where <= is needed. (5) In "search on answer", setting lo = 0 or 1 instead of max(weights) — the feasibility check then has to handle impossible capacities.

Practice list

  • LC 704 Binary Search (Easy)
  • LC 35 Search Insert Position (Easy)
  • LC 278 First Bad Version (Easy)
  • LC 162 Find Peak Element (Medium)
  • LC 540 Single Element in a Sorted Array (Medium)
  • LC 81 Search in Rotated Sorted Array II (Medium)
  • LC 875 Koko Eating Bananas (Medium)
  • LC 1482 Minimum Number of Days to Make m Bouquets (Medium)
  • LC 154 Find Minimum in Rotated Sorted Array II (Hard)
  • LC 410 Split Array Largest Sum (Hard)

43. Topological Sort

In plain words

A topological order is a line-up of tasks where every task comes after all the tasks it depends on. The dependencies form a directed graph: an edge A → B means "A must happen before B". An order exists only if there is no cycle (a graph with no cycles is a DAG, directed acyclic graph).

Kahn's algorithm builds the order with a queue. A node's indegree is the number of arrows pointing into it — the number of things still blocking it. Start with every node whose indegree is 0 (nothing blocks it). Each time you "do" a node, remove its outgoing arrows; any node whose indegree drops to 0 is now free and joins the queue.

Getting dressed. Socks before shoes, underwear before trousers, trousers before shoes. At any moment you can put on anything whose "before" items are all already on. If "shoes before socks" and "socks before shoes" were both rules, you could never finish — that's a cycle.

How to recognise it

  • "Prerequisites", "dependencies", "must be done before", "build order", "task scheduling", "compile order".
  • "Is it possible to finish all ...?" — cycle detection in a directed graph.
  • "Return an order / any valid order".
  • "Derive the order of letters / ranks from sorted examples" (alien dictionary, sequence reconstruction).
  • "Minimum number of semesters / rounds" when things in the same round can run in parallel — BFS by levels.
  • Peeling a tree or graph from the outside in (leaves first) — same indegree/degree idea.

How it works

0 in = 0 1 in = 0 2 in = 2 3 in = 1 4 in = 2 5 in = 1 start queue order: 0, 1, 2, 3, 4, 5
Nodes with indegree 0 (highlighted) have no prerequisites and start in the queue.
indegree: 0:0 1:0 2:2 3:1 4:2 5:1 queue = [0, 1] poll 0 → order [0] 2: 2→1 poll 1 → order [0,1] 2: 1→0 push 2 3: 1→0 push 3 queue = [2, 3] poll 2 → order [0,1,2] 4: 2→1 poll 3 → order [0,1,2,3] 4: 1→0 push 4 poll 4 → order [0,1,2,3,4] 5: 1→0 push 5 poll 5 → order [0,1,2,3,4,5] processed 6 == n → valid order, no cycle

Cycle detection for free: nodes on a cycle wait for each other forever — their indegree never reaches 0, so they never enter the queue. If the output has fewer than n nodes, there is a cycle. With edges 0→1, 1→2, 2→0 no node starts at indegree 0 and the result is empty.

The DFS alternative: run DFS and add each node to a list after all its descendants finish (post-order), then reverse the list. Detect cycles with three colours (unvisited / in progress / done): meeting an "in progress" node means a back edge, i.e. a cycle. Kahn's is usually easier to get right under pressure. More theory in section 27.

Java template

// n nodes 0..n-1, edges[i] = {from, to} meaning "from must come before to"
List<Integer> topoSort(int n, int[][] edges) {
    List<List<Integer>> graph = new ArrayList<>();
    for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
    int[] indegree = new int[n];
    for (int[] e : edges) {
        graph.get(e[0]).add(e[1]);
        indegree[e[1]]++;                     // one more thing must happen before e[1]
    }

    Deque<Integer> queue = new ArrayDeque<>();
    for (int i = 0; i < n; i++)
        if (indegree[i] == 0) queue.offer(i); // nothing blocks these: start here

    List<Integer> order = new ArrayList<>();
    while (!queue.isEmpty()) {
        int node = queue.poll();
        order.add(node);                      // CHANGES: what you record per node
        for (int next : graph.get(node))
            if (--indegree[next] == 0)        // last blocker removed
                queue.offer(next);
    }
    return order.size() == n ? order : List.of();   // fewer than n: there is a cycle
}

Classic problems

Course Schedule — LC 207 (Medium)

numCourses courses, prerequisites[i] = [a, b] means "take b before a". Can you finish all? 2, [[1,0]] gives true; 2, [[1,0],[0,1]] gives false.

Key insight: "can finish" = "no cycle" = Kahn's processes all n nodes. Watch the edge direction: the pair is [course, prereq], so the edge is prereq → course.

boolean canFinish(int numCourses, int[][] prerequisites) {
    List<List<Integer>> graph = new ArrayList<>();
    for (int i = 0; i < numCourses; i++) graph.add(new ArrayList<>());
    int[] indegree = new int[numCourses];
    for (int[] p : prerequisites) {           // p = {course, prereq}: prereq -> course
        graph.get(p[1]).add(p[0]);
        indegree[p[0]]++;
    }
    Deque<Integer> queue = new ArrayDeque<>();
    for (int i = 0; i < numCourses; i++) if (indegree[i] == 0) queue.offer(i);

    int taken = 0;
    while (!queue.isEmpty()) {
        int c = queue.poll();
        taken++;
        for (int next : graph.get(c))
            if (--indegree[next] == 0) queue.offer(next);
    }
    return taken == numCourses;               // stuck courses sit on a cycle
}

Time O(V + E), Space O(V + E).

Course Schedule II — LC 210 (Medium)

Return a valid order, or an empty array if impossible. 4, [[1,0],[2,0],[3,1],[3,2]] gives [0,1,2,3] ([0,2,1,3] is also valid).

Key insight: the same code; record the poll order.

int[] findOrder(int numCourses, int[][] prerequisites) {
    List<List<Integer>> graph = new ArrayList<>();
    for (int i = 0; i < numCourses; i++) graph.add(new ArrayList<>());
    int[] indegree = new int[numCourses];
    for (int[] p : prerequisites) {
        graph.get(p[1]).add(p[0]);
        indegree[p[0]]++;
    }
    Deque<Integer> queue = new ArrayDeque<>();
    for (int i = 0; i < numCourses; i++) if (indegree[i] == 0) queue.offer(i);

    int[] order = new int[numCourses];
    int idx = 0;
    while (!queue.isEmpty()) {
        int c = queue.poll();
        order[idx++] = c;
        for (int next : graph.get(c))
            if (--indegree[next] == 0) queue.offer(next);
    }
    return idx == numCourses ? order : new int[0];
}

Time O(V + E), Space O(V + E).

Alien Dictionary — LC 269 (Hard)

Words are sorted in an unknown alphabet. Return any valid letter order, or "" if none. ["wrt","wrf","er","ett","rftt"] gives "wertf".

Key insight: compare each pair of neighbouring words. The first differing letter gives one edge (t → f from "wrt" / "wrf"); letters after it tell you nothing. Then topological sort the letters. Two traps: a word followed by its own prefix ("abc" before "ab") is invalid, and every letter must be a node even if it has no edges.

String alienOrder(String[] words) {
    Map<Character, Set<Character>> graph = new HashMap<>();
    Map<Character, Integer> indegree = new HashMap<>();
    for (String w : words)
        for (char c : w.toCharArray()) {      // every letter is a node, even with no edges
            graph.putIfAbsent(c, new HashSet<>());
            indegree.putIfAbsent(c, 0);
        }

    for (int i = 0; i + 1 < words.length; i++) {
        String a = words[i], b = words[i + 1];
        if (a.length() > b.length() && a.startsWith(b)) return "";  // "abc" before "ab": invalid
        for (int j = 0; j < Math.min(a.length(), b.length()); j++) {
            char x = a.charAt(j), y = b.charAt(j);
            if (x != y) {                     // first difference is the ONLY clue
                if (graph.get(x).add(y))      // avoid counting the same edge twice
                    indegree.merge(y, 1, Integer::sum);
                break;
            }
        }
    }

    Deque<Character> queue = new ArrayDeque<>();
    for (var e : indegree.entrySet()) if (e.getValue() == 0) queue.offer(e.getKey());
    StringBuilder sb = new StringBuilder();
    while (!queue.isEmpty()) {
        char c = queue.poll();
        sb.append(c);
        for (char next : graph.get(c))
            if (indegree.merge(next, -1, Integer::sum) == 0) queue.offer(next);
    }
    return sb.length() == indegree.size() ? sb.toString() : "";   // cycle: no valid order
}

Time O(C) where C is the total number of characters, Space O(U + edges) with U unique letters (at most 26). ["z","x","z"] gives "" (cycle).

Parallel Courses — LC 1136 (Medium)

Courses 1..n, relations[i] = [prev, next]. Any number of courses per semester. Minimum semesters, or -1 if impossible. 3, [[1,3],[2,3]] gives 2.

Key insight: Kahn's by levels — use the same size snapshot as tree BFS. Each level is one semester: everything currently free can be taken together.

int minimumSemesters(int n, int[][] relations) {  // courses are 1..n
    List<List<Integer>> graph = new ArrayList<>();
    for (int i = 0; i <= n; i++) graph.add(new ArrayList<>());
    int[] indegree = new int[n + 1];
    for (int[] r : relations) {               // r = {prev, next}
        graph.get(r[0]).add(r[1]);
        indegree[r[1]]++;
    }
    Deque<Integer> queue = new ArrayDeque<>();
    for (int i = 1; i <= n; i++) if (indegree[i] == 0) queue.offer(i);

    int semesters = 0, taken = 0;
    while (!queue.isEmpty()) {
        int size = queue.size();              // all courses available THIS semester
        semesters++;
        for (int k = 0; k < size; k++) {
            int c = queue.poll();
            taken++;
            for (int next : graph.get(c))
                if (--indegree[next] == 0) queue.offer(next);
        }
    }
    return taken == n ? semesters : -1;
}

Time O(V + E), Space O(V + E). The answer is the length of the longest path in the DAG.

Minimum Height Trees — LC 310 (Medium)

An undirected tree of n nodes. Return every root that gives the minimum height. n = 6, [[3,0],[3,1],[3,2],[3,4],[5,4]] gives [3,4].

Key insight: the best roots are the centre of the longest path. Find it by peeling leaves (degree 1) layer by layer, like Kahn's from the outside in. When 1 or 2 nodes remain, those are the centres.

List<Integer> findMinHeightTrees(int n, int[][] edges) {
    if (n == 1) return List.of(0);
    List<Set<Integer>> graph = new ArrayList<>();
    for (int i = 0; i < n; i++) graph.add(new HashSet<>());
    int[] degree = new int[n];
    for (int[] e : edges) {
        graph.get(e[0]).add(e[1]);
        graph.get(e[1]).add(e[0]);
        degree[e[0]]++;
        degree[e[1]]++;
    }

    Deque<Integer> leaves = new ArrayDeque<>();
    for (int i = 0; i < n; i++) if (degree[i] == 1) leaves.offer(i);

    int remaining = n;
    while (remaining > 2) {                   // peel leaves layer by layer
        int size = leaves.size();
        remaining -= size;
        for (int k = 0; k < size; k++) {
            int leaf = leaves.poll();
            for (int nb : graph.get(leaf))
                if (--degree[nb] == 1) leaves.offer(nb);
        }
    }
    return new ArrayList<>(leaves);           // 1 or 2 centres left
}

Time O(n), Space O(n). Brute force (BFS from every node) is O(n²).

Common mistakes: (1) Reversing the edge direction — for [a, b] = "b before a", the edge is b → a and indegree[a]++. (2) Forgetting nodes with no edges (they must still appear in the order). (3) Returning the partial order when there is a cycle instead of checking count == n. (4) In alien dictionary, adding an edge for every differing position rather than only the first, or counting a repeated edge twice in the indegree. (5) Using plain visited-set DFS for cycle detection in a directed graph — you need three states, because reaching a "done" node again is not a cycle.

Practice list

  • LC 1557 Minimum Number of Vertices to Reach All Nodes (Medium)
  • LC 802 Find Eventual Safe States (Medium)
  • LC 1462 Course Schedule IV (Medium)
  • LC 2115 Find All Possible Recipes from Given Supplies (Medium)
  • LC 444 Sequence Reconstruction (Medium) — unique order means the queue never holds 2 items
  • LC 2050 Parallel Courses III (Hard)
  • LC 329 Longest Increasing Path in a Matrix (Hard)
  • LC 1203 Sort Items by Groups Respecting Dependencies (Hard)

44. Matrix & Islands

In plain words

A 2D grid is a graph in disguise. Each cell is a node, and it is connected to its 4 neighbours (up, down, left, right — sometimes 8 with diagonals). You never build an adjacency list: the neighbours are computed on the fly with a small direction array. Then every graph tool works: DFS to explore a region, BFS for shortest steps, multi-source BFS for "spreading" problems.

A second family is pure matrix manipulation — rotate, spiral, set zeroes — where the trick is careful index arithmetic, not graph search.

Spilling paint on a tiled floor: it spreads to every touching tile of the same kind and stops at walls. Counting islands is "how many times did I have to pour paint to cover all the land?".

How to recognise it

  • Input is a char[][] / int[][] of land/water, walls/open, colours, heights.
  • "Number of islands / regions / provinces", "size of the largest region", "fill a region" — DFS/BFS flood fill.
  • "Minimum steps / minutes to reach / to spread" — BFS (each layer = one step).
  • "Spreads from all sources at once" (rotting oranges, distance to nearest 0, walls and gates) — multi-source BFS: put every source in the queue at the start.
  • "Cells connected to the border", "can reach the ocean", "enclosed / surrounded" — boundary trick: start from the edges and mark what is reachable, then everything else is the answer.
  • "Rotate", "spiral", "in place", "transpose" — matrix index manipulation.

How it works

grid (1 = land) 1 1 0 0 0 1 1 0 0 0 0 0 1 0 0 0 0 0 1 1 3 islands (shaded / outlined) DIRS from (r, c) (r, c) r-1, c r+1, c r, c-1 r, c+1 {{1,0},{-1,0},{0,1},{0,-1}}: bounds-check every one
Scan every cell. Each time you find unvisited land, count one island and flood-fill it so it is never counted again.

DFS vs BFS on a grid. Both visit each cell once: O(rows × cols). Use DFS (shorter code) for "explore / count / measure a region". Use BFS when you need the fewest steps — BFS reaches cells in order of distance. On a 1000×1000 grid of all land, recursive DFS can go 106 calls deep and overflow the stack; BFS (or an explicit stack) is safe.

Multi-source BFS. Put all starting cells in the queue before the loop. The BFS layers then grow outward from every source at once, and each cell's layer number is its distance to the nearest source. Rotting oranges trace:

minute 0 minute 1 minute 2 minute 3 minute 4 2 1 1 2 2 1 2 2 2 2 2 2 2 2 2 1 1 0 → 2 1 0 → 2 2 0 → 2 2 0 → 2 2 0 0 1 1 0 1 1 0 1 1 0 2 1 0 2 2 fresh = 0 → answer 4

Boundary trick. "Which cells are not connected to the border?" is hard to ask directly. Flip it: DFS from every border cell and mark what it reaches; whatever is left unmarked is enclosed.

Marking visited. Either overwrite the grid ('1' → '0', 1 → 2) — O(1) extra space, but it destroys the input (ask first) — or keep a boolean[][] seen. In BFS, mark a cell when you add it to the queue, not when you poll it, or the same cell is queued many times.

Java template

private static final int[][] DIRS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};  // down, up, right, left

// DFS flood fill: visit every cell connected to (r, c)
void dfs(char[][] grid, int r, int c) {
    if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length) return;  // off the grid
    if (grid[r][c] != '1') return;            // CHANGES: "not part of the region"
    grid[r][c] = '#';                         // mark visited (or use boolean[][] seen)
    for (int[] d : DIRS) dfs(grid, r + d[0], c + d[1]);
}

// BFS: same idea, but layer by layer -> fewest steps from start to target
int bfs(int[][] grid, int sr, int sc, int tr, int tc) {
    int rows = grid.length, cols = grid[0].length;
    boolean[][] seen = new boolean[rows][cols];
    Deque<int[]> queue = new ArrayDeque<>();
    queue.offer(new int[]{sr, sc});           // CHANGES: multi-source = offer ALL starts here
    seen[sr][sc] = true;
    int steps = 0;
    while (!queue.isEmpty()) {
        for (int size = queue.size(); size > 0; size--) {   // one layer = one step
            int[] cur = queue.poll();
            if (cur[0] == tr && cur[1] == tc) return steps;  // CHANGES: goal test
            for (int[] d : DIRS) {
                int nr = cur[0] + d[0], nc = cur[1] + d[1];
                if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
                if (seen[nr][nc] || grid[nr][nc] == 1) continue;   // CHANGES: walls
                seen[nr][nc] = true;          // mark when ADDING, not when polling
                queue.offer(new int[]{nr, nc});
            }
        }
        steps++;
    }
    return -1;                                // target unreachable
}

Classic problems

Number of Islands — LC 200 (Medium)

Count groups of '1' connected up/down/left/right. The grid in the diagram gives 3.

Key insight: scan; every '1' you meet is a new island — count it and sink it.

int numIslands(char[][] grid) {
    int count = 0;
    for (int r = 0; r < grid.length; r++)
        for (int c = 0; c < grid[0].length; c++)
            if (grid[r][c] == '1') {
                count++;                      // new island found
                sink(grid, r, c);             // erase it so we never count it again
            }
    return count;
}

private void sink(char[][] grid, int r, int c) {
    if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] != '1') return;
    grid[r][c] = '0';
    for (int[] d : DIRS) sink(grid, r + d[0], c + d[1]);
}

Time O(m·n), Space O(m·n) worst-case recursion. Union-find (section 17) also works and handles "land added over time" (LC 305).

Max Area of Island — LC 695 (Medium)

Return the size of the largest island (0 if none). The LeetCode example grid gives 6.

Key insight: bottom-up DFS: a cell's area = 1 + the areas its neighbours return.

int maxAreaOfIsland(int[][] grid) {
    int best = 0;
    for (int r = 0; r < grid.length; r++)
        for (int c = 0; c < grid[0].length; c++)
            best = Math.max(best, area(grid, r, c));
    return best;
}

private int area(int[][] grid, int r, int c) {
    if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] != 1) return 0;
    grid[r][c] = 0;
    int size = 1;                             // bottom-up: this cell + what neighbours return
    for (int[] d : DIRS) size += area(grid, r + d[0], c + d[1]);
    return size;
}

Time O(m·n), Space O(m·n).

Flood Fill — LC 733 (Easy)

Recolour the region containing (sr, sc). [[1,1,1],[1,1,0],[1,0,1]], sr=1, sc=1, color=2 gives [[2,2,2],[2,2,0],[2,0,1]].

Key insight: the "paint bucket" DFS. If the new colour equals the old one, return immediately — otherwise painted cells still match "old" and the DFS never stops.

int[][] floodFill(int[][] image, int sr, int sc, int color) {
    int old = image[sr][sc];
    if (old != color) paint(image, sr, sc, old, color);   // same colour: would loop forever
    return image;
}

private void paint(int[][] img, int r, int c, int old, int color) {
    if (r < 0 || r >= img.length || c < 0 || c >= img[0].length || img[r][c] != old) return;
    img[r][c] = color;
    for (int[] d : DIRS) paint(img, r + d[0], c + d[1], old, color);
}

Time O(m·n), Space O(m·n).

Surrounded Regions — LC 130 (Medium)

Flip every 'O' region that is completely surrounded by 'X'. ["XXXX","XOOX","XXOX","XOXX"] gives ["XXXX","XXXX","XXXX","XOXX"] — the bottom O touches the border and survives.

Key insight: boundary trick. Mark every O reachable from the border as safe; flip the rest.

void solve(char[][] board) {
    int rows = board.length, cols = board[0].length;
    // 1. Every 'O' reachable from the border is SAFE: mark it 'S'
    for (int r = 0; r < rows; r++) { keep(board, r, 0); keep(board, r, cols - 1); }
    for (int c = 0; c < cols; c++) { keep(board, 0, c); keep(board, rows - 1, c); }
    // 2. Remaining 'O' are surrounded -> 'X'; restore safe ones -> 'O'
    for (int r = 0; r < rows; r++)
        for (int c = 0; c < cols; c++)
            board[r][c] = board[r][c] == 'S' ? 'O' : 'X';
}

private void keep(char[][] b, int r, int c) {
    if (r < 0 || r >= b.length || c < 0 || c >= b[0].length || b[r][c] != 'O') return;
    b[r][c] = 'S';
    for (int[] d : DIRS) keep(b, r + d[0], c + d[1]);
}

Time O(m·n), Space O(m·n).

Pacific Atlantic Water Flow — LC 417 (Medium)

Water flows to a neighbour of equal or lower height. The Pacific touches the top and left edges, the Atlantic the bottom and right. Return cells that can reach both. The 5x5 example gives [[0,4],[1,3],[1,4],[2,2],[3,0],[3,1],[4,0]].

Key insight: running DFS from every cell is O((m·n)²). Reverse the flow: start at each ocean's edge and walk uphill (to neighbours of equal or greater height). Two passes, two seen grids; answer = cells marked in both.

List<List<Integer>> pacificAtlantic(int[][] heights) {
    int rows = heights.length, cols = heights[0].length;
    boolean[][] pac = new boolean[rows][cols], atl = new boolean[rows][cols];
    // Walk UPHILL from each ocean's edge (reverse of how water flows)
    for (int r = 0; r < rows; r++) {
        climb(heights, pac, r, 0, Integer.MIN_VALUE);
        climb(heights, atl, r, cols - 1, Integer.MIN_VALUE);
    }
    for (int c = 0; c < cols; c++) {
        climb(heights, pac, 0, c, Integer.MIN_VALUE);
        climb(heights, atl, rows - 1, c, Integer.MIN_VALUE);
    }
    List<List<Integer>> result = new ArrayList<>();
    for (int r = 0; r < rows; r++)
        for (int c = 0; c < cols; c++)
            if (pac[r][c] && atl[r][c]) result.add(List.of(r, c));
    return result;
}

private void climb(int[][] h, boolean[][] seen, int r, int c, int prevHeight) {
    if (r < 0 || r >= h.length || c < 0 || c >= h[0].length) return;
    if (seen[r][c] || h[r][c] < prevHeight) return;     // water cannot flow up to prev
    seen[r][c] = true;
    for (int[] d : DIRS) climb(h, seen, r + d[0], c + d[1], h[r][c]);
}

Time O(m·n), Space O(m·n).

Rotting Oranges — LC 994 (Medium)

0 empty, 1 fresh, 2 rotten. Each minute, rotten oranges rot their 4 neighbours. Minutes until none are fresh, or -1. [[2,1,1],[1,1,0],[0,1,1]] gives 4; [[2,1,1],[0,1,1],[1,0,1]] gives -1; [[0,2]] gives 0.

Key insight: multi-source BFS — all rotten oranges start together, one layer per minute. Count fresh oranges up front to know when to stop and whether any are unreachable.

int orangesRotting(int[][] grid) {
    int rows = grid.length, cols = grid[0].length, fresh = 0;
    Deque<int[]> queue = new ArrayDeque<>();
    for (int r = 0; r < rows; r++)
        for (int c = 0; c < cols; c++) {
            if (grid[r][c] == 2) queue.offer(new int[]{r, c});   // ALL rotten start together
            else if (grid[r][c] == 1) fresh++;
        }

    int minutes = 0;
    while (!queue.isEmpty() && fresh > 0) {
        for (int size = queue.size(); size > 0; size--) {
            int[] cur = queue.poll();
            for (int[] d : DIRS) {
                int nr = cur[0] + d[0], nc = cur[1] + d[1];
                if (nr < 0 || nr >= rows || nc < 0 || nc >= cols || grid[nr][nc] != 1) continue;
                grid[nr][nc] = 2;             // rots now: this is also our "visited" mark
                fresh--;
                queue.offer(new int[]{nr, nc});
            }
        }
        minutes++;                            // one BFS layer = one minute
    }
    return fresh == 0 ? minutes : -1;
}

Time O(m·n), Space O(m·n). The fresh > 0 check in the loop stops an extra, empty minute from being counted.

Shortest Path in Binary Matrix — LC 1091 (Medium)

Shortest path of 0-cells from top-left to bottom-right, moving in 8 directions; length counts cells. [[0,0,0],[1,1,0],[1,1,0]] gives 4; [[0,1],[1,0]] gives 2.

Key insight: unweighted shortest path = BFS. The first time you poll the target, its layer is the answer.

int shortestPathBinaryMatrix(int[][] grid) {
    int n = grid.length;
    if (grid[0][0] == 1 || grid[n - 1][n - 1] == 1) return -1;
    Deque<int[]> queue = new ArrayDeque<>();
    queue.offer(new int[]{0, 0});
    grid[0][0] = 1;                           // mark visited by blocking it
    int length = 1;                           // path length counts CELLS
    while (!queue.isEmpty()) {
        for (int size = queue.size(); size > 0; size--) {
            int[] cur = queue.poll();
            if (cur[0] == n - 1 && cur[1] == n - 1) return length;
            for (int dr = -1; dr <= 1; dr++)              // 8 directions
                for (int dc = -1; dc <= 1; dc++) {
                    int nr = cur[0] + dr, nc = cur[1] + dc;
                    if (nr < 0 || nr >= n || nc < 0 || nc >= n || grid[nr][nc] != 0) continue;
                    grid[nr][nc] = 1;
                    queue.offer(new int[]{nr, nc});
                }
        }
        length++;
    }
    return -1;
}

Time O(n²), Space O(n²).

Matrix manipulation

Rotate Image — LC 48 (Medium)

Rotate an n×n matrix 90° clockwise, in place. [[1,2,3],[4,5,6],[7,8,9]] gives [[7,4,1],[8,5,2],[9,6,3]].

Key insight: clockwise rotation = transpose (swap m[i][j] and m[j][i]) then reverse each row. (Counter-clockwise: transpose, then reverse each column.)

void rotate(int[][] m) {
    int n = m.length;
    for (int i = 0; i < n; i++)               // 1. transpose: swap across the main diagonal
        for (int j = i + 1; j < n; j++) {
            int t = m[i][j]; m[i][j] = m[j][i]; m[j][i] = t;
        }
    for (int[] row : m)                       // 2. reverse each row
        for (int l = 0, r = n - 1; l < r; l++, r--) {
            int t = row[l]; row[l] = row[r]; row[r] = t;
        }
}

Time O(n²), Space O(1).

Spiral Matrix — LC 54 (Medium)

Return all elements in spiral order. [[1,2,3,4],[5,6,7,8],[9,10,11,12]] gives [1,2,3,4,8,12,11,10,9,5,6,7].

Key insight: four walls — top, bottom, left, right. Walk one side, move that wall inward. Before walking the bottom row and left column, re-check that a row/column is still left, or a single row or column gets read twice.

List<Integer> spiralOrder(int[][] m) {
    List<Integer> out = new ArrayList<>();
    int top = 0, bottom = m.length - 1, left = 0, right = m[0].length - 1;
    while (top <= bottom && left <= right) {
        for (int c = left; c <= right; c++) out.add(m[top][c]);       // right along top
        top++;
        for (int r = top; r <= bottom; r++) out.add(m[r][right]);     // down right side
        right--;
        if (top <= bottom) {                                          // row still left?
            for (int c = right; c >= left; c--) out.add(m[bottom][c]); // left along bottom
            bottom--;
        }
        if (left <= right) {                                          // column still left?
            for (int r = bottom; r >= top; r--) out.add(m[r][left]);  // up left side
            left++;
        }
    }
    return out;
}

Time O(m·n), Space O(1) besides the output. [[1],[2],[3]] gives [1,2,3] — the re-checks are what make this single column work.

Set Matrix Zeroes — LC 73 (Medium)

If a cell is 0, set its whole row and column to 0, in place. [[0,1,2,0],[3,4,5,2],[1,3,1,5]] gives [[0,0,0,0],[0,4,5,0],[0,3,1,0]].

Key insight: zeroing immediately spreads fake zeros. Record first, apply later. For O(1) space, use row 0 and column 0 as the notebook, with two booleans remembering whether row 0 and column 0 themselves need zeroing; apply those last.

void setZeroes(int[][] m) {
    int rows = m.length, cols = m[0].length;
    boolean firstRowZero = false, firstColZero = false;
    for (int c = 0; c < cols; c++) if (m[0][c] == 0) firstRowZero = true;
    for (int r = 0; r < rows; r++) if (m[r][0] == 0) firstColZero = true;

    // use row 0 and column 0 as the "marker" arrays: O(1) extra space
    for (int r = 1; r < rows; r++)
        for (int c = 1; c < cols; c++)
            if (m[r][c] == 0) { m[r][0] = 0; m[0][c] = 0; }

    for (int r = 1; r < rows; r++)
        for (int c = 1; c < cols; c++)
            if (m[r][0] == 0 || m[0][c] == 0) m[r][c] = 0;

    if (firstRowZero) for (int c = 0; c < cols; c++) m[0][c] = 0;   // markers last
    if (firstColZero) for (int r = 0; r < rows; r++) m[r][0] = 0;
}

Time O(m·n), Space O(1).

Common mistakes: (1) Bounds check after indexing — check r < 0 || r >= rows ... before reading grid[r][c]. (2) Mixing up rows and columns: grid.length is rows, grid[0].length is columns; non-square grids expose this. (3) Marking visited on poll instead of on push in BFS — cells get queued many times (can time out). (4) Forgetting to count BFS layers with a size snapshot, so "minutes" or "steps" come out wrong. (5) Comparing char grids with 1 instead of '1'. (6) Modifying the input without saying so — offer a seen array if the interviewer wants the grid intact.

Practice list

  • LC 463 Island Perimeter (Easy)
  • LC 1020 Number of Enclaves (Medium) — boundary trick
  • LC 1254 Number of Closed Islands (Medium)
  • LC 542 01 Matrix (Medium) — multi-source BFS from all zeros
  • LC 286 Walls and Gates (Medium)
  • LC 934 Shortest Bridge (Medium) — DFS one island, BFS to the other
  • LC 59 Spiral Matrix II (Medium)
  • LC 36 Valid Sudoku (Medium)
  • LC 827 Making A Large Island (Hard)
  • LC 1293 Shortest Path in a Grid with Obstacles Elimination (Hard) — BFS over (row, col, removals left)

45. Design Problems

In plain words

"Design a data structure" questions give you a class with a few methods and a required speed for each — usually O(1) or O(log n). No single built-in structure meets every requirement, so you combine two: one that is fast at finding things (a HashMap) plus one that keeps the order or ranking you need (a linked list, a heap, a TreeMap, an array list).

The recipe: list each operation and its target cost, pick a structure that gives each one, then make sure every structure stays in sync on every write.

A library with a card catalogue and shelves. The catalogue (hash map) tells you exactly where a book is in one step; the shelves (the list) keep books in an order. Neither alone is enough — the catalogue has no order, and scanning shelves is slow.

How to recognise it

  • "Implement the XYZ class" with methods like get, put, push, getMin, getRandom.
  • "Each operation must run in O(1) average time" — hash map plus something ordered.
  • "Least recently used", "least frequently used", "evict" — cache design.
  • "Most recent 10", "news feed", "timeline" — heap merge of per-user lists.
  • "Value at a given timestamp", "latest version <= t" — TreeMap.floorEntry or binary search.

How it works

You needReach for
Find by key in O(1)HashMap
Move / remove any known item in O(1), keep an orderDoubly linked list (with the node stored in the map)
Pick a random item in O(1)ArrayList (index access) + map from value to index
Min / max so far, with undoStack of pairs (value, min at that point)
Top k / most recent across many listsPriorityQueue (k-way merge, section 39)
Nearest key <= x or >= xTreeMap: floorKey, ceilingKey (section 14)
Prefix lookupsTrie (section 15)

Java template

class Design {
    // 1. One field per requirement, e.g.
    //    Map<Key, Node> index;     -> O(1) find
    //    ordering structure        -> list / heap / TreeMap
    // 2. Every public method:
    //    a. look up via the map
    //    b. update the ordering structure
    //    c. update the map (add / remove) so both agree
    // 3. Private helpers for the fiddly pointer work: remove(node), addFirst(node)
}

Classic problems

LRU Cache — LC 146 (Medium)

Fixed capacity. get(key) returns the value or -1; put(key, value) inserts or updates, and when full evicts the least recently used key. Both in O(1).

Key insight: a doubly linked list keeps keys in "recently used" order: the front is most recent, the back is the next to evict. Moving a node to the front, or removing the back, is O(1) — if you can find the node instantly. The HashMap from key to node gives that. Two sentinel nodes (dummy head and tail) remove every null check.

HashMap 3 → 1 → 2 → head 3 : 30 1 : 10 2 : 20 tail most recent least recent: evict tail.prev sentinel
The map finds a node in O(1); the prev/next links let you unlink it and re-insert it at the front in O(1).
class LRUCache {
    private class Node {
        int key, val;
        Node prev, next;
        Node(int k, int v) { key = k; val = v; }
    }

    private final int capacity;
    private final Map<Integer, Node> map = new HashMap<>();   // key -> node: O(1) find
    private final Node head = new Node(0, 0);                 // sentinel: most recent side
    private final Node tail = new Node(0, 0);                 // sentinel: least recent side

    public LRUCache(int capacity) {
        this.capacity = capacity;
        head.next = tail;
        tail.prev = head;
    }

    public int get(int key) {
        Node node = map.get(key);
        if (node == null) return -1;
        remove(node);                         // touched: move to the front
        addFirst(node);
        return node.val;
    }

    public void put(int key, int value) {
        Node node = map.get(key);
        if (node != null) {                   // update existing
            node.val = value;
            remove(node);
            addFirst(node);
            return;
        }
        if (map.size() == capacity) {         // full: evict least recently used
            Node lru = tail.prev;
            remove(lru);
            map.remove(lru.key);              // this is why Node stores its key
        }
        Node fresh = new Node(key, value);
        addFirst(fresh);
        map.put(key, fresh);
    }

    private void remove(Node n) {             // unlink: O(1) because we know prev
        n.prev.next = n.next;
        n.next.prev = n.prev;
    }

    private void addFirst(Node n) {           // insert right after head
        n.next = head.next;
        n.prev = head;
        head.next.prev = n;
        head.next = n;
    }
}

Capacity 2: put(1,1), put(2,2), get(1) returns 1; put(3,3) evicts key 2; get(2) returns -1; put(4,4) evicts key 1; then get(1), get(3), get(4) return -1, 3, 4. Time O(1) per operation, Space O(capacity).

Why each node stores its key: when you evict tail.prev, you must also delete it from the map — and the map is keyed by key.

The LinkedHashMap shortcut. Java's LinkedHashMap is a hash map plus a doubly linked list. Constructed with accessOrder = true, every get moves the entry to the end; override removeEldestEntry to evict. Mention it — then offer to write the manual version, which is what most interviewers want to see.

class LRUCacheShort extends LinkedHashMap<Integer, Integer> {
    private final int capacity;

    LRUCacheShort(int capacity) {
        super(16, 0.75f, true);               // true = ordered by ACCESS, not insertion
        this.capacity = capacity;
    }

    public int get(int key) { return getOrDefault(key, -1); }

    public void put(int key, int value) { super.put(key, value); }

    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
        return size() > capacity;             // called after every put: evict if too big
    }
}

LFU Cache — LC 460 (Hard)

Evict the key used the fewest times; break ties by least recently used. All in O(1).

Key idea: group keys by use count. buckets[f] is an insertion-ordered set of keys used exactly f times (oldest first), and minFreq tracks the smallest non-empty count. Using a key moves it from bucket f to f+1; if bucket f was the minimum and is now empty, minFreq++. A new key always has count 1, so it resets minFreq = 1. Evict the first key in buckets[minFreq].

class LFUCache {
    private final int capacity;
    private int minFreq = 0;
    private final Map<Integer, Integer> vals = new HashMap<>();     // key -> value
    private final Map<Integer, Integer> freq = new HashMap<>();     // key -> use count
    // use count -> keys with that count, in LRU order (LinkedHashSet keeps insertion order)
    private final Map<Integer, LinkedHashSet<Integer>> buckets = new HashMap<>();

    LFUCache(int capacity) { this.capacity = capacity; }

    public int get(int key) {
        if (!vals.containsKey(key)) return -1;
        touch(key);
        return vals.get(key);
    }

    public void put(int key, int value) {
        if (capacity == 0) return;
        if (vals.containsKey(key)) {
            vals.put(key, value);
            touch(key);
            return;
        }
        if (vals.size() == capacity) {        // evict: lowest count, then oldest
            Iterator<Integer> it = buckets.get(minFreq).iterator();
            int victim = it.next();
            it.remove();
            vals.remove(victim);
            freq.remove(victim);
        }
        vals.put(key, value);
        freq.put(key, 1);
        buckets.computeIfAbsent(1, f -> new LinkedHashSet<>()).add(key);
        minFreq = 1;                          // a brand-new key always has count 1
    }

    private void touch(int key) {             // move key from bucket f to bucket f+1
        int f = freq.get(key);
        freq.put(key, f + 1);
        buckets.get(f).remove(key);
        if (f == minFreq && buckets.get(f).isEmpty()) minFreq++;
        buckets.computeIfAbsent(f + 1, x -> new LinkedHashSet<>()).add(key);
    }
}

Capacity 2: put(1,1), put(2,2), get(1) → 1; put(3,3) evicts 2 (count 1); get(2) → -1, get(3) → 3; put(4,4): keys 1 and 3 both have count 2, 1 is older, so evict 1; get(1), get(3), get(4) → -1, 3, 4. Time O(1) average per operation.

Min Stack — LC 155 (Medium)

A stack with push, pop, top and getMin, all O(1).

Key insight: the minimum only changes on push and pop, in stack order. So store, with every entry, the minimum at the moment it was pushed. Popping automatically restores the previous minimum.

class MinStack {
    private final Deque<int[]> stack = new ArrayDeque<>();   // {value, min so far}

    public void push(int val) {
        int min = stack.isEmpty() ? val : Math.min(val, stack.peek()[1]);
        stack.push(new int[]{val, min});
    }

    public void pop() { stack.pop(); }

    public int top() { return stack.peek()[0]; }

    public int getMin() { return stack.peek()[1]; }       // every entry remembers the min below it
}

push(-2), push(0), push(-3), getMin() → -3; pop(), top() → 0; getMin() → -2. Time O(1), Space O(n).

Implement Trie — LC 208 (Medium)

insert, search and startsWith in O(length of word). Each node holds an array of 26 children and an "end of word" flag. The full implementation and variations (wildcard search, word search II) are in section 15 — in a design round, reach for it whenever the question says "prefix" or "autocomplete".

Design Twitter — LC 355 (Medium)

postTweet, follow, unfollow, and getNewsFeed(userId) returning the 10 most recent tweet ids from the user and everyone they follow, newest first.

Key insight: each user's tweets are already sorted by time (you append them). The feed is a k-way merge of those lists — start a max-heap with each followed user's newest tweet, pop 10 times, and after each pop push that user's next-older tweet. A global counter gives the timestamps.

class Twitter {
    private int time = 0;                                  // global clock: bigger = newer
    private final Map<Integer, Set<Integer>> follows = new HashMap<>();
    private final Map<Integer, List<int[]>> tweets = new HashMap<>();   // user -> {time, tweetId}

    public void postTweet(int userId, int tweetId) {
        tweets.computeIfAbsent(userId, u -> new ArrayList<>()).add(new int[]{time++, tweetId});
    }

    public List<Integer> getNewsFeed(int userId) {
        Set<Integer> users = new HashSet<>(follows.getOrDefault(userId, Set.of()));
        users.add(userId);                                 // your own tweets count too
        // K-way merge: heap entry = {time, tweetId, userId, index in that user's list}
        PriorityQueue<int[]> heap = new PriorityQueue<>((a, b) -> Integer.compare(b[0], a[0]));
        for (int u : users) {
            List<int[]> list = tweets.get(u);
            if (list != null && !list.isEmpty()) {
                int i = list.size() - 1;                   // newest tweet of this user
                heap.offer(new int[]{list.get(i)[0], list.get(i)[1], u, i});
            }
        }
        List<Integer> feed = new ArrayList<>();
        while (!heap.isEmpty() && feed.size() < 10) {
            int[] top = heap.poll();
            feed.add(top[1]);
            int i = top[3] - 1;                            // that user's next-older tweet
            if (i >= 0) {
                int[] t = tweets.get(top[2]).get(i);
                heap.offer(new int[]{t[0], t[1], top[2], i});
            }
        }
        return feed;
    }

    public void follow(int followerId, int followeeId) {
        if (followerId != followeeId)
            follows.computeIfAbsent(followerId, u -> new HashSet<>()).add(followeeId);
    }

    public void unfollow(int followerId, int followeeId) {
        Set<Integer> s = follows.get(followerId);
        if (s != null) s.remove(followeeId);
    }
}

postTweet(1,5), getNewsFeed(1) → [5]; follow(1,2), postTweet(2,6), getNewsFeed(1) → [6, 5]; unfollow(1,2), getNewsFeed(1) → [5]. Feed cost O(F + 10 log F) for F followees; post/follow O(1).

Time Based Key-Value Store — LC 981 (Medium)

set(key, value, timestamp) and get(key, timestamp) returning the value with the largest timestamp <= the given one (or "").

Key insight: "largest key <= t" is exactly TreeMap.floorEntry(t). (LeetCode guarantees timestamps arrive in increasing order, so an ArrayList per key with your own binary search also works and is a common follow-up.)

class TimeMap {
    private final Map<String, TreeMap<Integer, String>> store = new HashMap<>();

    public void set(String key, String value, int timestamp) {
        store.computeIfAbsent(key, k -> new TreeMap<>()).put(timestamp, value);
    }

    public String get(String key, int timestamp) {
        TreeMap<Integer, String> history = store.get(key);
        if (history == null) return "";
        Map.Entry<Integer, String> e = history.floorEntry(timestamp);  // latest time <= timestamp
        return e == null ? "" : e.getValue();
    }
}

set("foo","bar",1); get("foo",1) → "bar"; get("foo",3) → "bar"; set("foo","bar2",4); get("foo",4) → "bar2"; get("foo",5) → "bar2"; get("foo",0) → "". Time O(log n) per call, Space O(n).

Insert Delete GetRandom O(1) — LC 380 (Medium)

A set with insert, remove and getRandom (each element equally likely), all average O(1).

Key insight: getRandom needs index access, so values live in an ArrayList. But removing from the middle of a list is O(n) — so swap the victim with the last element and remove from the end. A map from value to index makes finding the victim O(1), and you must update the moved element's index.

class RandomizedSet {
    private final Map<Integer, Integer> index = new HashMap<>();  // value -> position in list
    private final List<Integer> values = new ArrayList<>();
    private final Random rnd = new Random();

    public boolean insert(int val) {
        if (index.containsKey(val)) return false;
        index.put(val, values.size());
        values.add(val);                      // append: O(1)
        return true;
    }

    public boolean remove(int val) {
        Integer i = index.get(val);
        if (i == null) return false;
        int last = values.get(values.size() - 1);
        values.set(i, last);                  // move the last value into the hole
        index.put(last, i);
        values.remove(values.size() - 1);     // remove from the END: O(1)
        index.remove(val);
        return true;
    }

    public int getRandom() {
        return values.get(rnd.nextInt(values.size()));   // needs an index: that's why a list
    }
}

Time O(1) average for all three, Space O(n).

Common mistakes: (1) Updating one structure but not the other — e.g. evicting from the list but leaving the key in the map. Walk through every method and check both. (2) In LRU, forgetting that put on an existing key must also move it to the front. (3) In the linked list, updating pointers in the wrong order and losing the rest of the list — set the new node's pointers first, then its neighbours'. (4) In RandomizedSet, removing by index with values.remove(i) in the middle (O(n)), or forgetting to update the moved element's index. (5) Min Stack with a single min variable — after popping the minimum you can't recover the previous one. (6) Not stating the complexity of each method — that is the whole point of the question.

Practice list

  • LC 232 Implement Queue using Stacks (Easy)
  • LC 705 Design HashSet (Easy)
  • LC 706 Design HashMap (Easy)
  • LC 622 Design Circular Queue (Medium)
  • LC 362 Design Hit Counter (Medium)
  • LC 1396 Design Underground System (Medium)
  • LC 211 Design Add and Search Words Data Structure (Medium)
  • LC 1352 Product of the Last K Numbers (Medium)
  • LC 381 Insert Delete GetRandom O(1) - Duplicates allowed (Hard)
  • LC 432 All O`one Data Structure (Hard)
Revision

Pattern Cheat Sheet — if you see X, try Y

In plain words

Problem statements leave clues: the shape of the input, the words in the question, and the size limits. Each clue below points to a pattern that usually works. Treat it as a first guess to test, not a rule — say the guess out loud, then check it against the examples.

If you see…Try…Section
Sorted array + find a pair / triplet with a sumTwo pointers from both ends30
Remove duplicates / partition / move zeros in placeTwo pointers (slow writer, fast reader)30
Longest / shortest contiguous subarray or substring with a conditionSliding window (grow right, shrink left)31
Subarray sum equals k, negatives allowed; many range-sum queries, no updatesPrefix sum + hash map32
Add a value to many ranges, then read onceDifference array32
Linked list cycle, middle of list, "happy number"Fast & slow pointers33
Reverse a list / a part of it / in groups of kIn-place reversal (prev, cur, next)37
Next greater / next smaller element, span, histogram area, temperaturesMonotonic stack34
Max / min of every window of size kMonotonic deque34
Overlapping intervals, meeting rooms, merge rangesSort by start, then sweep (or a min-heap of end times)35
Array holds numbers in 1..n; find missing / duplicateCyclic sort (put each value at index value - 1)36
Valid parentheses, undo, evaluate expression, nested structureStack9
Seen before? count occurrences? pair with complement?HashMap / HashSet7
Tree: anything per level, "closest to root", right side viewTree BFS with size snapshot38
Tree: paths, heights, subtree answersTree DFS (top-down or bottom-up)38
BST: k-th smallest, validate, rangeIn-order traversal (gives sorted order)13
k-th largest / top k / k most frequent / k closestHeap of size k (min-heap for largest); quickselect; bucket sort39
Merge k sorted lists / arrays; k-th smallest in sorted matrixK-way merge with a heap39
Median of a stream / sliding window medianTwo heaps40
All subsets / combinations / permutations / arrangementsBacktracking (start index or used[])41
Place items under constraints (N-Queens, Sudoku, word search)Backtracking with pruning21
Sorted / rotated input, or "O(log n) required"Binary search20, 42
"Minimum capacity / speed / largest minimum such that possible"Binary search on the answer + greedy check42
Dependencies, prerequisites, build orderTopological sort (Kahn's)43
Grid of land / water, regions, flood fillDFS / BFS on the grid44
"Spreads from many sources", distance to nearest XMulti-source BFS44
Shortest path, unweighted (or all edges equal)BFS16
Shortest path, weights 0 or 10-1 BFS with a deque26
Shortest path, non-negative weightsDijkstra26
Shortest path, negative weights / at most k edgesBellman-Ford26
All-pairs shortest paths, n <= ~400Floyd-Warshall26
Connect all points at minimum costMinimum spanning tree (Kruskal / Prim)27
Connected groups, merging sets, "are these connected?", redundant edgeUnion-Find17
Prefix lookups, autocomplete, many words searched in a gridTrie15
Range sum / min / max with updatesFenwick tree / segment tree18
Nearest key <= x or >= x, sorted keys with insertsTreeMap / TreeSet14
Count ways / min cost / max value with choices, overlapping subproblemsDynamic programming24, 25
Two strings: edit distance, LCS, matching2D DP over (i, j)25
Pick items under a capacity / target sumKnapsack DP25
Local best choice is provably safe (intervals by end time, jumps)Greedy22
Split in halves, combine (count inversions, merge sort)Divide & conquer23
Find a pattern in text fast, repeated substringsKMP / Z-function / rolling hash28
Single number, XOR tricks, subsets as bitmasks, power of twoBit manipulation5
GCD, primes, modular arithmetic, "answer modulo 10^9+7"Math / number theory29
"Design a class with O(1) operations"HashMap + list / heap / TreeMap45

Constraints tell you the complexity

A judge runs roughly 108 simple operations per second. Put n into your complexity and you know if the idea will pass before you write a line.

n up toComplexity that fitsUsually means
10–12O(n!), O(n · n!)Permutations, brute force over orderings
20–25O(2n), O(n · 2n)Subsets, backtracking, bitmask DP
100O(n4)Several nested loops, small interval DP
500O(n3)Floyd-Warshall, interval DP
5,000O(n2)2D DP, all pairs, nested loops
105–106O(n log n) or O(n)Sorting, heap, binary search, two pointers, sliding window, hashing
108 and moreO(log n) or O(1)Binary search on the answer, math formula

Complexity Cheat Sheet

In plain words

The costs to know without thinking. "Average" for hash structures assumes a decent hash function; "amortized" means an occasional expensive step (like resizing) averaged over many cheap ones.

Data structure operations

Structure (Java)Access by indexSearchInsertDeleteNotes
Array int[]O(1)O(n); O(log n) if sortedO(n)O(n)Fixed size
ArrayListO(1)O(n)O(1) amortized at end; O(n) in middleO(1) at end; O(n) in middleResizes by 1.5x
LinkedListO(n)O(n)O(1) at ends / at a known nodeO(1) at ends / at a known nodeRarely the best choice in Java
ArrayDeque (stack / queue)—O(n)O(1) amortized at both endsO(1) at both endsUse instead of Stack and LinkedList
HashMap / HashSet—O(1) averageO(1) averageO(1) averageWorst O(log n) per bucket (tree bins) in Java 8+
TreeMap / TreeSet—O(log n)O(log n)O(log n)Sorted; floor / ceiling / first / last in O(log n)
PriorityQueue (binary heap)peek O(1)O(n)O(log n)poll O(log n); remove(x) O(n)Building from a collection: O(n)
Trie—O(L)O(L)O(L)L = word length; memory O(total chars × alphabet)
Union-Find (path compression + union by rank)—find ~O(1) (α(n))union ~O(1)—α is the inverse Ackermann function, <= 4 in practice
Fenwick / segment tree—range query O(log n)point update O(log n)—Build O(n)

Sorting algorithms

AlgorithmBestAverageWorstExtra spaceStable?
Bubble / insertion sortO(n)O(n²)O(n²)O(1)Yes
Selection sortO(n²)O(n²)O(n²)O(1)No
Merge sortO(n log n)O(n log n)O(n log n)O(n)Yes
QuicksortO(n log n)O(n log n)O(n²)O(log n) stackNo
HeapsortO(n log n)O(n log n)O(n log n)O(1)No
Counting sortO(n + k)O(n + k)O(n + k)O(k)Yes
Radix sortO(d(n + b))O(d(n + b))O(d(n + b))O(n + b)Yes
Bucket sortO(n + k)O(n + k)O(n²)O(n + k)Yes (if buckets are)
Arrays.sort(int[])Dual-pivot quicksort, O(n log n) typicalO(log n)No
Arrays.sort(Object[]), Collections.sortO(n)O(n log n)O(n log n)O(n)Yes (TimSort)

Graph algorithms

V = vertices, E = edges.

AlgorithmTimeSpaceUse for
BFSO(V + E)O(V)Shortest path in unweighted graphs, levels
DFSO(V + E)O(V)Reachability, components, cycle detection
Topological sort (Kahn's or DFS)O(V + E)O(V)Order with dependencies; detect cycle in a directed graph
Dijkstra (binary heap)O((V + E) log V)O(V)Single-source shortest paths, non-negative weights
Bellman-FordO(V · E)O(V)Negative weights; detects negative cycles; "at most k edges"
Floyd-WarshallO(V³)O(V²)All-pairs shortest paths, small V
KruskalO(E log E)O(V)MST: sort edges + union-find
Prim (binary heap)O(E log V)O(V + E)MST on dense-ish graphs, grows from one vertex
Union-Find over all edgesO(E · α(V))O(V)Connected components, dynamic connectivity
Grid BFS / DFSO(rows × cols)O(rows × cols)Islands, flood fill, shortest steps

Learning Path & Must-Do Problems

In plain words

Learn a topic, then immediately practise its pattern. Each week below pairs data structures and algorithms with the patterns that use them. About 10–15 hours a week is enough; the order matters more than the speed. If you have less time, do weeks 1–8 and the "must-do" list — that covers most interview questions.

12-week plan

WeekLearnPatterns to practise
1Approach, Big-O, recursion, Java toolkit, bits (1–5)Write every Java collection from memory; 5 easy problems
2Arrays & strings, hashing (6, 7)Two pointers (30), prefix sum (32)
3Sliding window, cyclic sortSliding window (31), cyclic sort (36)
4Linked lists, stacks, queues (8–10)Fast & slow (33), list reversal (37), monotonic stack (34)
5Sorting, binary search (19, 20)Merge intervals (35), modified binary search (42)
6Binary trees, BSTs, TreeMap (12–14)Tree BFS & DFS (38)
7Heaps (11)Top K & k-way merge (39), two heaps (40)
8Backtracking, tries (21, 15)Subsets & permutations (41)
9Graphs, union-find (16, 17)Matrix & islands (44), topological sort (43)
10Shortest paths, MST, greedy (26, 27, 22)Dijkstra problems, interval greedy
11Dynamic programming (24, 25)1D DP, knapsack, 2D grid / string DP
12Leftovers: divide & conquer, strings, math, segment trees (23, 28, 29, 18); design (45)Mock interviews, mixed random problems, redo old mistakes

Must-do problems (grouped by pattern)

PatternProblems
Arrays & hashingLC 1 Two Sum (E) · LC 242 Valid Anagram (E) · LC 49 Group Anagrams (M) · LC 238 Product of Array Except Self (M) · LC 128 Longest Consecutive Sequence (M)
Two pointersLC 125 Valid Palindrome (E) · LC 15 3Sum (M) · LC 11 Container With Most Water (M) · LC 42 Trapping Rain Water (H)
Sliding windowLC 121 Best Time to Buy and Sell Stock (E) · LC 3 Longest Substring Without Repeating Characters (M) · LC 424 Longest Repeating Character Replacement (M) · LC 76 Minimum Window Substring (H) · LC 239 Sliding Window Maximum (H)
Prefix sumLC 560 Subarray Sum Equals K (M) · LC 523 Continuous Subarray Sum (M)
Stack / monotonic stackLC 20 Valid Parentheses (E) · LC 155 Min Stack (M) · LC 739 Daily Temperatures (M) · LC 84 Largest Rectangle in Histogram (H)
Binary searchLC 704 Binary Search (E) · LC 33 Search in Rotated Sorted Array (M) · LC 153 Find Minimum in Rotated Sorted Array (M) · LC 875 Koko Eating Bananas (M) · LC 4 Median of Two Sorted Arrays (H)
Linked listLC 206 Reverse Linked List (E) · LC 21 Merge Two Sorted Lists (E) · LC 141 Linked List Cycle (E) · LC 143 Reorder List (M) · LC 23 Merge k Sorted Lists (H)
IntervalsLC 56 Merge Intervals (M) · LC 435 Non-overlapping Intervals (M) · LC 253 Meeting Rooms II (M)
Cyclic sortLC 268 Missing Number (E) · LC 448 Find All Numbers Disappeared in an Array (E) · LC 41 First Missing Positive (H)
TreesLC 226 Invert Binary Tree (E) · LC 104 Maximum Depth of Binary Tree (E) · LC 102 Binary Tree Level Order Traversal (M) · LC 98 Validate Binary Search Tree (M) · LC 230 Kth Smallest Element in a BST (M) · LC 105 Construct Binary Tree from Preorder and Inorder Traversal (M) · LC 124 Binary Tree Maximum Path Sum (H) · LC 297 Serialize and Deserialize Binary Tree (H)
Heap / top KLC 215 Kth Largest Element in an Array (M) · LC 347 Top K Frequent Elements (M) · LC 973 K Closest Points to Origin (M) · LC 295 Find Median from Data Stream (H)
BacktrackingLC 78 Subsets (M) · LC 46 Permutations (M) · LC 39 Combination Sum (M) · LC 79 Word Search (M) · LC 51 N-Queens (H)
TrieLC 208 Implement Trie (Prefix Tree) (M) · LC 212 Word Search II (H)
GraphsLC 200 Number of Islands (M) · LC 994 Rotting Oranges (M) · LC 417 Pacific Atlantic Water Flow (M) · LC 207 Course Schedule (M) · LC 210 Course Schedule II (M) · LC 684 Redundant Connection (M) · LC 743 Network Delay Time (M) · LC 127 Word Ladder (H) · LC 269 Alien Dictionary (H)
1D dynamic programmingLC 70 Climbing Stairs (E) · LC 198 House Robber (M) · LC 322 Coin Change (M) · LC 300 Longest Increasing Subsequence (M) · LC 139 Word Break (M) · LC 416 Partition Equal Subset Sum (M)
2D dynamic programmingLC 62 Unique Paths (M) · LC 1143 Longest Common Subsequence (M) · LC 72 Edit Distance (M)
GreedyLC 53 Maximum Subarray (M) · LC 55 Jump Game (M) · LC 134 Gas Station (M)
Bit manipulationLC 136 Single Number (E)
DesignLC 146 LRU Cache (M) · LC 380 Insert Delete GetRandom O(1) (M)

That is 79 problems. If time is short, do the first two of every row first (about 35), then come back for the rest. The pattern sections above each end with a longer practice list.

How to practise

  • Timebox. Give a new problem 25–30 minutes (45 for a Hard). If you have no working idea by then, read the solution — but only the idea, then close it and write the code yourself.
  • Name the pattern first. Before coding, say which pattern from the cheat sheet you are using and why. If you can't, that is the thing to study.
  • Redo after 3 days, then after 2 weeks. Keep a list of problems you needed help with. Solve them again from a blank editor. A problem is "done" only when you can solve it cold.
  • Explain aloud. Talk through the approach, the complexity, and one edge case as if an interviewer were listening. Record yourself or practise with a friend — silent solving does not train the interview.
  • Test by hand. Before running code, trace a small example and the edge cases: empty input, one element, all equal, negative numbers, overflow.
  • Mix it up in the last weeks. Doing ten sliding-window problems in a row tells you the pattern in advance. Random mixed sets train the real skill: recognising which pattern to use.
The goal is not to memorise 300 answers. It is to know about 20 patterns so well that a new problem feels like a variation of one you have already solved. When you finish a problem, write one line in your notes: "signal → pattern → key trick". That line is what you will remember in the interview.