DSA in Java — Interview Questions

Read notes

Answered the way you would say it in the room — first person, with an example every time. Tap a question to reveal the answer. Levels: Basic, Intermediate, Advanced.

Part 1 — Foundations

1. How to Approach a DSA Problem

BasicI've just read you the problem. What do you do in the first few minutes?

So I don't start coding. The first thing I do is ask questions until the problem is unambiguous, and then I write two or three concrete examples, including at least one edge case. Problems are often left vague on purpose, and the interviewer wants to see me notice that.

Let me show you with a quick example. Say you ask me "find two numbers that add up to a target." My questions would be:

  • Input: Is the array sorted? Can it be empty? Negatives? Duplicates? How big is n?
  • Output: Indices or values? Exactly one answer, or could there be none or many? What do I return if there's none?
  • Constraints: Can I use the same element twice? Can I modify the input?

Then I'd write examples like [2,7,11,15], 9 → [0,1], [3,3], 6 → [0,1] for duplicates, and [3,2,4], 6 → [1,2], which catches the "used the same index twice" bug before I've written a line.

The reason I do this is that the most expensive mistake in an interview is solving the wrong problem for twenty minutes. The size of n in particular tells me what complexity they're expecting. So in practice I spend around five minutes here, and I say my assumptions out loud so the interviewer can correct me early.

Interviewers look for: questions about empty input, duplicates, negatives and the size of n. Candidates who jump straight into code get marked down even when the code works.

BasicWhy would you describe a brute-force solution if you already know it's too slow?

So the brute force proves I understand the problem, it gives me a correct fallback if I run out of time, and it's the thing I optimise from. Most good solutions come from looking at the brute force and asking "what work am I repeating?"

Let me show you with a quick example: "does this array contain a duplicate?"

// Brute force: compare every pair. O(n^2) time, O(1) space.
static boolean hasDuplicateBrute(int[] a) {
    for (int i = 0; i < a.length; i++)
        for (int j = i + 1; j < a.length; j++)
            if (a[i] == a[j]) return true;
    return false;
}

// The inner loop is really asking "have I seen a[i] before?"
// That's a lookup, so a HashSet makes it O(1). O(n) time, O(n) space.
static boolean hasDuplicate(int[] a) {
    Set<Integer> seen = new HashSet<>();
    for (int x : a) if (!seen.add(x)) return true;   // add returns false if present
    return false;
}
// [4,1,7,1] -> true, [1,2,3] -> false

What happens here is that saying "the inner loop is just a lookup" is literally the bottleneck step, and naming it points straight at the fix. I say the brute force and its Big-O in one or two sentences. I don't code it unless the interviewer asks.

So in practice: brute force out loud, name the bottleneck, then trade memory for time or sort first. And if time runs low, a clean working O(n²) beats a broken O(n).

IntermediateWalk me through Two Sum, from brute force to the optimal solution.

So the brute force tries every pair, which is O(n²). The optimisation comes from noticing the inner loop only asks one question: "does target - nums[i] exist somewhere I've already been?" A HashMap answers that in O(1) on average, so one pass is enough.

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];
        if (seen.containsKey(need)) return new int[]{seen.get(need), i};
        seen.put(nums[i], i);                        // store AFTER checking
    }
    return new int[]{-1, -1};                        // no pair
}

Let me dry-run it on [2,7,11,15], target 9. At i = 0 I need 7, the map is empty, so I store 2 → 0. At i = 1 I need 2, it's in the map, so I return [0, 1].

One detail I always call out is storing after checking. If I stored first, then for [3,2,4] with target 6, the 3 would find itself and return [0,0]. Checking first means I only ever pair with an earlier index. It also handles [3,3] correctly, because the second 3 finds the first.

That's O(n) time and O(n) space. So I'm trading memory for time, and I'd say that out loud.

Interviewers look for: the follow-up "what if the array is sorted?" Then I'd use two pointers from both ends, which is O(n) time and O(1) space.

IntermediateHow do the constraints tell you what complexity to aim for?

So I work backwards from roughly 10⁸ simple operations per second. If n is up to 10⁵, an O(n²) solution is 10¹⁰ operations, which is far too slow, so they're expecting O(n log n) or O(n). The constraint on n is one of the biggest hints in the problem, and a lot of people ignore it.

Let me show you the mental table I use:

n up toTargetUsually means
10O(n!)All permutations, backtracking
20O(2ⁿ · n)All subsets, bitmask DP
500O(n³)Triple loop, interval DP
5,000O(n²)Double loop, 2D DP
10⁶O(n log n)Sort, heap, binary search
10⁹ and upO(log n) or O(1)Math, binary search on the answer

What happens in practice is that if I see "n ≤ 20", I immediately think about subsets or bitmasks, because 2²⁰ is only about a million. If I see "n ≤ 10⁵", I know my double loop won't pass, so I start thinking about sorting, a hash map or a sliding window.

If the interviewer hasn't given constraints, I ask for them. And if they say "assume it's large", I aim for O(n log n) or better and say so.

IntermediateYou're stuck and don't see the optimal approach. What do you do?

So I keep talking and go back to something concrete. I solve a tiny example by hand and watch what my brain does, because that's often the algorithm. Then I walk through a short list of patterns and ask whether each one fits.

The questions I literally ask myself:

  • What if the input were sorted? That suggests two pointers or binary search.
  • What if I had a HashMap of everything I've seen? That turns repeated lookups into O(1).
  • Is it a contiguous range? Then a sliding window or prefix sums.
  • Is it "top k" or "smallest so far"? Then a heap.
  • Am I solving the same subproblem again? Then memoisation or DP.

I also simplify the problem: 1D before 2D, no duplicates before duplicates, k = 1 before general k. Solving the simple version often shows me the general one.

One thing I've learned is that silence hurts more than being stuck. If I say "I'm thinking a heap might work here because we only care about the smallest k, but I'm not sure how to handle updates", the interviewer can nudge me. They give hints to people who are visibly reasoning.

So in practice, if time is running out, I code the brute force cleanly and explain how I'd improve it.

Interviewers look for: whether you can use a hint. Taking a hint well and running with it is scored positively, not negatively.

AdvancedYour solution uses a HashMap. What if the input is 500 GB and doesn't fit in memory?

So the in-memory HashMap stops working, and I'd switch to an approach that processes the data in chunks that do fit. The two standard tools are hash partitioning and external sorting. Both turn one huge problem into many small ones I can solve with the normal algorithm.

Let me show you with "find duplicate IDs in a 500 GB file" on a machine with 8 GB of RAM:

Pass 1 (partition): stream the file once.
  for each id: write it to bucket file  (hash(id) % 100)
  -> 100 files of about 5 GB each.
  Equal ids always land in the SAME file.

Pass 2 (solve each piece): for each bucket file,
  load it into a HashSet and report duplicates.
  Each piece fits in memory, so this is the normal O(n) algorithm.

The reason this is correct is that duplicates can only be in the same bucket, so I never need to compare across files. The total cost is two sequential passes over the disk, and sequential I/O is what disks are good at.

The alternative is an external merge sort: sort chunks in memory, write them out, then k-way merge them with a heap. After sorting, duplicates are adjacent. That's O(n log n) but also works for things like "top k" and "merge sorted logs".

So in practice I'd also ask whether an approximate answer is fine. If it is, a Bloom filter or a count-min sketch gives me a result in fixed memory, with a small, known error rate.

Interviewers look for: "equal keys go to the same partition", and awareness that one skewed bucket might still be too big, so you'd re-partition it with a different hash.

2. Big-O & Complexity

BasicWhat is Big-O, and why do we drop constants?

So Big-O describes how the running time or memory of an algorithm grows as the input grows. It ignores the machine and the exact seconds and keeps only the shape of the growth. That's why 3n + 5 is O(n) and n² + 100n is O(n²).

Let me show you with a quick example:

// Two separate passes: n + n = 2n steps -> still O(n)
static int maxMinusMin(int[] a) {
    int max = Integer.MIN_VALUE, min = Integer.MAX_VALUE;
    for (int x : a) max = Math.max(max, x);
    for (int x : a) min = Math.min(min, x);
    return max - min;
}

What happens here is that doubling the input doubles the work, whether I do one pass or two. The constant 2 doesn't change the shape, it just depends on hardware and implementation details that Big-O isn't trying to measure. And the smaller terms vanish because when n is a million, n² is a trillion while 100n is only a hundred million.

Formally, O is an upper bound, Ω is a lower bound, and Θ is a tight bound. In interviews people say "Big-O" but mean the tight worst-case bound, and that's what I give unless asked otherwise.

So in practice I use Big-O to compare approaches, but I don't pretend constants never matter. An O(n) solution with a HashMap can be slower than an O(n log n) sort on small inputs because of boxing and hashing overhead.

BasicWhat is the time complexity of this code?
int ops = 0;
for (int i = 0; i < n; i++)
    for (int j = 1; j < n; j *= 2)
        ops++;

So this is O(n log n). The outer loop runs n times, and the inner loop doubles j each time, so it only runs about log₂ n times. Nested loops multiply, so I get n × log n.

Let me verify with numbers. For n = 8 the inner loop runs for j = 1, 2, 4, which is 3 times, so the total is 8 × 3 = 24. For n = 1024 it's 1024 × 10 = 10,240. I ran both and got exactly those counts.

The rules I apply to any snippet like this:

  • Sequential blocks add. Nested blocks multiply.
  • If the loop variable is multiplied or divided by a constant, that loop is logarithmic.
  • Different inputs get different letters. Looping over a string of length n inside a list of length m is O(n·m), not "O(n)".
  • Hidden loops count: list.contains, substring, and String += all cost more than O(1).

So I always look at what the loop variable does, not just how many loops there are.

Interviewers look for: the trap version where the inner loop depends on the outer one, like for (i = 1; i < n; i *= 2) for (j = 0; j < i; j++). That's 1 + 2 + 4 + … which sums to under 2n, so it's O(n), not O(n log n). For n = 1024 it's exactly 1023 steps.

IntermediateWhat does "amortised O(1)" mean? Use ArrayList.add as the example.

So amortised means averaged over a long sequence of operations. An individual add can be O(n) when the backing array is full and has to be copied, but that happens so rarely that the average cost per add stays constant.

Let me show you with a quick simulation of ArrayList's growth rule: start at capacity 10 and grow by 1.5x when full.

long copies = 0; int cap = 10, size = 0;
for (int k = 0; k < 1_000_000; k++) {
    if (size == cap) {             // full: copy everything into a bigger array
        copies += size;
        cap = cap + (cap >> 1);    // grow by 1.5x
    }
    size++;
}
// copies = 2,430,972 for 1,000,000 adds -> about 2.4 copies per add

What happens here is that each resize copies a lot, but the resizes get further and further apart. The total copy work is a geometric series, bounded by a constant times n, so each add costs O(1) on average.

The key detail is multiplying the capacity. If it grew by a fixed +10 slots each time, I'd copy 10 + 20 + 30 + … + n, which is O(n²) total, so each add would be O(n) on average.

So in practice amortised O(1) is fine for throughput, but a single call can still be slow. If I know the size up front, I pass it to the constructor, new ArrayList<>(n), and skip the resizes entirely.

Interviewers look for: the difference between amortised and average-case. Amortised is a guarantee over any sequence. Average-case, like HashMap's O(1), depends on the input being well-distributed.

IntermediateWhat's the space complexity of a recursive function that creates no objects?

So it's not O(1). Every active call holds a stack frame with its parameters and locals, so the space is the maximum recursion depth. A recursion that goes n levels deep uses O(n) space even if it never allocates anything.

// O(n) time AND O(n) space: n frames are alive at the deepest point
static long sumRec(int[] a, int i) {
    if (i == a.length) return 0;
    return a[i] + sumRec(a, i + 1);
}

// Same job, O(1) space
static long sumIter(int[] a) {
    long total = 0;
    for (int x : a) total += x;
    return total;
}

The rule I use is: time is the total number of calls times the work per call, and space is the height of the recursion tree. Those are different numbers. Naive Fibonacci is the classic example. It makes about 2ⁿ calls, but at any moment only one root-to-leaf path is on the stack, so the space is only O(n).

For trees the same rule gives O(h), where h is the height. That's O(log n) for a balanced tree, but O(n) for a skewed one that's really a linked list.

So in practice I always mention the stack when I give space for recursive code. And on a very deep input, like a 10⁶-node linked list, I'd switch to iteration, because Java will throw StackOverflowError long before it runs out of heap.

IntermediateQuicksort is O(n²) in the worst case. Why do people still call it O(n log n)?

So people are quoting its average case. With a good pivot, each partition splits the array roughly in half, giving log n levels of O(n) work. The O(n²) case only happens when the pivot is always the smallest or largest element, and randomised pivots make that astronomically unlikely.

Let me show you the cases side by side:

AlgorithmBestAverageWorstWorst happens when
QuicksortO(n log n)O(n log n)O(n²)Pivot is always the min or max
HashMap getO(1)O(1)O(log n) in Java 8+Many keys collide in one bucket
Insertion sortO(n)O(n²)O(n²)Input is reverse sorted
Merge sortO(n log n)O(n log n)O(n log n)Never worse, but O(n) extra space

What happens with a naive "always pick the first element" pivot on an already sorted array is that every partition peels off just one element. That gives n levels instead of log n, so n + (n−1) + … = O(n²).

This is also why Java uses different sorts. Arrays.sort(int[]) uses dual-pivot quicksort, which is fast in practice. Arrays.sort(Object[]) uses TimSort, which is stable and guaranteed O(n log n).

So in practice, when nobody specifies, I give the worst case and mention the average if it's different. For hashing and quicksort I always mention both.

AdvancedDerive the complexity of merge sort from its recurrence.

So merge sort's recurrence is T(n) = 2T(n/2) + O(n): two halves, plus a linear merge. There are log₂ n levels in the recursion tree, and each level does O(n) total work, so it's O(n log n).

Let me show you the recursion tree, because that's how I'd explain it on a whiteboard:

Level 0:  1 call  on n          -> merge work n
Level 1:  2 calls on n/2        -> 2 * n/2 = n
Level 2:  4 calls on n/4        -> 4 * n/4 = n
  ...
Level k:  2^k calls on n/2^k    -> n
Stops when n/2^k = 1, so k = log2 n levels.

Total = n per level * log2 n levels = O(n log n)

What happens is that the number of calls doubles at each level while the size of each call halves, so every level costs the same. That's the "tie" case of the Master Theorem. For T(n) = aT(n/b) + f(n) I compare f(n) with n^(log_b a). Here that's n^(log₂ 2) = n, which matches the merge work, so the answer is n × log n.

The same tool covers the others quickly. Binary search is T(n/2) + O(1), a tie at 1, so O(log n). A tree traversal is 2T(n/2) + O(1), where the leaves win, so O(n). Quickselect on average is T(n/2) + O(n), where the root wins, so O(n).

So in practice I rarely quote the theorem by name. I just say "log n levels, O(n) per level" and draw the tree, which is the reasoning the theorem packages up.

Interviewers look for: the space answer too. Merge sort on an array needs O(n) extra for the merge buffer, plus O(log n) stack.

3. Recursion & the Call Stack

BasicWhat is recursion? Write factorial recursively.

So recursion is a method solving a problem by calling itself on a smaller version of the same problem, until the problem is small enough to answer directly. Every recursive method needs a base case that stops it, and a recursive case that shrinks the input toward that base case.

static long factorial(int n) {
    if (n <= 1) return 1;              // base case
    return n * factorial(n - 1);       // smaller input, then combine
}
// factorial(5)  = 120
// factorial(20) = 2432902008176640000
// factorial(21) = -4249290049419214848   (overflowed long!)

The way I write it is the "leap of faith". I assume factorial(n - 1) already works and only ask how to build my answer from it. I don't try to trace every level in my head while writing it.

My checklist is: state the base case first, make sure every call shrinks the input, and decide exactly what the method returns. If I forget the base case or don't shrink the input, it recurses until the stack overflows.

One gotcha I call out is that factorial(21) silently overflows long and comes out negative. If they wanted bigger values I'd use BigInteger, or they probably want the answer modulo 10⁹ + 7.

That's O(n) time and O(n) stack space. So in practice I'd write factorial as a loop, but it's the cleanest way to show the two-part structure.

BasicWhat actually happens on the call stack when a recursive method runs?

So each thread in the JVM has a call stack. Every method call pushes a new frame holding that call's parameters, locals and return address. When the method returns, its frame is popped and the caller continues from where it paused. Recursion just means many frames for the same method.

Let me trace factorial(3):

Winding (push):
  main          paused at  x = factorial(3)
  factorial(3)  paused at  3 * factorial(2)
  factorial(2)  paused at  2 * factorial(1)
  factorial(1)  base case, returns 1

Unwinding (pop):
  factorial(2)  computes 2 * 1 = 2, returns 2
  factorial(3)  computes 3 * 2 = 6, returns 6
  main          x = 6

What happens here is that no multiplication happens on the way down. Each frame is paused mid-expression, waiting. All the work happens during the unwind. That's why the maximum depth, four frames here, is the space cost.

Each frame has its own copy of n, which is why the three calls don't interfere with each other. Objects passed in are different, though. The reference is copied, but the object is shared, so if I mutate a list in a deeper call, the caller sees it. That's exactly what backtracking relies on.

So in practice this model explains both the O(depth) space cost and StackOverflowError. The stack is a fixed size, and if depth goes too far, it runs out.

IntermediateWhat's the complexity of naive recursive Fibonacci, and how would you fix it?

So naive Fibonacci is O(2ⁿ) time, because each call branches into two, and the same subproblems get recomputed over and over. The space is only O(n), because the stack only holds one path of the tree at a time.

static long fib(int n) {                       // O(2^n) time, O(n) stack
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
}

static long fibMemo(int n, long[] memo) {      // O(n) time, O(n) space
    if (n <= 1) return n;
    if (memo[n] != 0) return memo[n];          // already solved
    return memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
}

static long fibIter(int n) {                   // O(n) time, O(1) space
    if (n == 0) return 0;
    long prev = 0, cur = 1;
    for (int i = 2; i <= n; i++) { long next = prev + cur; prev = cur; cur = next; }
    return cur;
}
// fib(10) makes 177 calls; fib(30) makes 2,692,537.
// fibMemo(50) = fibIter(50) = 12586269025, instantly.

What happens in the naive version is that fib(5) computes fib(3) twice and fib(2) three times, and it gets exponentially worse. Memoisation caches each answer the first time, so each fib(k) is computed once.

The iterative version is the bottom-up DP. I only ever need the last two values, so I keep two variables instead of an array.

So in practice I'd go naive, then memo, then iterative. That's the standard path from recursion to dynamic programming.

Interviewers look for: the time-vs-space distinction. Saying "O(2ⁿ) space" for naive Fibonacci is a very common slip. Space is the tree's height, not its size.

IntermediateWhat causes a StackOverflowError, and does tail recursion fix it in Java?

So a StackOverflowError happens when recursion goes deeper than the thread's stack can hold. That's usually a missing base case, or a correct recursion on a very deep input. And no, tail recursion doesn't help in Java, because the JVM doesn't do tail-call optimisation.

// Tail-recursive: the call is the very last thing
static long sumTail(int n, long acc) {
    if (n == 0) return acc;
    return sumTail(n - 1, acc + n);
}

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

A tail call means there's nothing left to do after it returns, so in theory the frame could be reused. Scala with @tailrec and Kotlin's tailrec do that, but Java still pushes a new frame for every call, so a million-deep tail recursion still overflows.

The default stack is roughly 512 KB to 1 MB per thread. I can raise it with -Xss, but that's treating the symptom.

So in practice the fix is converting to iteration. Tail recursion becomes a plain loop, where the parameters turn into loop variables. General recursion, like DFS, uses an explicit ArrayDeque as a stack, which lives on the heap and can grow much larger.

IntermediateImplement pow(x, n) faster than O(n).

So I'd use fast exponentiation. xⁿ is (x^(n/2))², times one extra x if n is odd. That halves the problem each call, so it's O(log n) time and O(log n) stack.

static double myPow(double x, long n) {
    if (n < 0) return 1 / myPow(x, -n);   // long: -Integer.MIN_VALUE overflows an int
    if (n == 0) return 1;
    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
// myPow(-1, Integer.MIN_VALUE) = 1.0

What happens for 2¹⁰ is 10 → 5 → 2 → 1 → 0, so five calls instead of ten multiplications. For n around a billion it's about 30 calls.

There are two gotchas I call out. First, I must store half and reuse it. If I wrote myPow(x, n/2) * myPow(x, n/2), that's two calls per level, and the recursion tree has n leaves again, so it falls back to O(n).

Second, negative exponents. If n were an int and equal to Integer.MIN_VALUE, then -n overflows back to itself and the recursion never ends. Taking the parameter as long avoids that.

So in practice the same squaring idea shows up in modular exponentiation and matrix power for Fibonacci in O(log n).

Interviewers look for: the Integer.MIN_VALUE edge case and the "call it once" detail. Missing either is the usual way people fail this one.

AdvancedHow do you convert a general recursive DFS into an iterative one?

So I replace the call stack with my own explicit stack. I push the starting state, then loop: pop, process, and push the next states. The only subtle part is the push order, because a stack reverses things.

Let me show you with a preorder traversal of a binary tree:

record Node(int val, Node left, Node right) {}

static void preorderRec(Node n, List<Integer> out) {
    if (n == null) return;
    out.add(n.val);
    preorderRec(n.left, out);
    preorderRec(n.right, out);
}

static List<Integer> preorderIter(Node root) {
    List<Integer> out = new ArrayList<>();
    if (root == null) return out;
    Deque<Node> stack = new ArrayDeque<>();
    stack.push(root);
    while (!stack.isEmpty()) {
        Node cur = stack.pop();
        out.add(cur.val);
        if (cur.right != null) stack.push(cur.right);   // right first...
        if (cur.left != null) stack.push(cur.left);     // ...so left pops first
    }
    return out;
}
// Tree 1(2(4,5),3): both give [1, 2, 4, 5, 3]

What happens here is that each stack entry is a "call I still need to make." I push right before left because the stack is last-in, first-out, so left gets processed first, just like the recursive version.

It gets harder when the recursive version does work after a child returns, like postorder or backtracking. Then I push a small state object, such as a node plus a "visited" flag or the index of the next child, so I know where to resume.

So in practice I write the recursive version first because it's clearer. I convert when depth could hit around 10⁴ or more, like a skewed tree or a flood fill on a 1000×1000 grid.

Interviewers look for: the push-order reasoning, and knowing that the explicit stack lives on the heap, so it isn't limited by -Xss.

4. Java Toolkit for DSA

BasicWhy is String immutable in Java? And why must you compare strings with equals?

So a String can never change after it's created. Every "modification" returns a new object. Java made it immutable for safety and sharing: strings can be pooled, cached, used as HashMap keys and shared across threads without anyone being able to change them underneath you.

Let me show you with a quick example.

String s = "hi";
String t = s;
s = s + "!";                 // builds a NEW string; t still points at the old one
// t = "hi", s = "hi!"

String u = "hello";
u.toUpperCase();             // result thrown away, u is unchanged
// u = "hello"

String a = "java", b = "java", c = new String("java");
a == b;          // true   both literals share one object in the string pool
a == c;          // false  new String makes a separate object
a.equals(c);     // true   same characters
a == c.intern(); // true   intern() returns the pooled copy

The reasons I'd give are:

  • String pool: identical literals can share one object only because nobody can change it.
  • Hashing: String caches its hashCode. If a key could change after being put in a HashMap, it would sit in the wrong bucket forever.
  • Security: file paths, URLs and class names can't be changed after they've been validated.
  • Thread safety: immutable objects need no locking.

== compares references, meaning "is this the same object?" It happens to work for literals because of the pool, which is exactly why it's a trap. So in practice I always use equals for content.

Interviewers look for: the cost side of immutability. Every change is a copy, which leads straight into the StringBuilder question.

BasicString vs StringBuilder vs StringBuffer. When would you use each?

So String is immutable, StringBuilder is a mutable, growable char buffer, and StringBuffer is the old synchronized version of StringBuilder. For building a string in a loop I use StringBuilder, because += in a loop copies the whole string every time, which is O(n²) total.

// O(n^2): each += copies everything built so far
String s = "";
for (int i = 0; i < 100_000; i++) s += 'x';

// O(n): append is amortised O(1)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100_000; i++) sb.append('x');
String result = sb.toString();

// Useful methods
StringBuilder b = new StringBuilder();
for (int i = 0; i < 5; i++) b.append(i).append(',');
b.setLength(b.length() - 1);   // drop last comma -> "0,1,2,3,4"
b.reverse();                   // in place        -> "4,3,2,1,0"

When I timed these on my machine, the += loop took about 480 ms and the StringBuilder loop about 2 ms, for the same 100,000-character result. That's the O(n²) vs O(n) difference showing up.

A single line like "a" + x + "b" is fine. The compiler handles that efficiently. The problem is only repeated concatenation in a loop.

MutableThread-safeUse when
StringNoYes (immutable)Keys, constants, values you pass around
StringBuilderYesNoBuilding strings in one thread (almost always)
StringBufferYesYes (synchronized)Legacy code; rarely needed today

So in practice I reach for StringBuilder any time I'm building output, reversing, or doing backtracking on characters. One cost to remember: insert(0, …) and deleteCharAt(0) are O(n) because they shift everything.

IntermediateWhy use ArrayDeque instead of Stack for a stack?

So java.util.Stack is a legacy class that extends Vector. Every method is synchronized, which is pointless overhead in single-threaded code, and because it is a Vector, you can insert into the middle, which breaks the whole idea of a stack. The Javadoc itself recommends Deque.

Stack<Integer> st = new Stack<>();
st.push(1); st.push(2); st.push(3);
System.out.println(st);        // [1, 2, 3]   top is at the END
st.add(0, 99);                 // legal! -> [99, 1, 2, 3]

Deque<Integer> dq = new ArrayDeque<>();
dq.push(1); dq.push(2); dq.push(3);
System.out.println(dq);        // [3, 2, 1]   top is at the FRONT
dq.peek();                     // 3

// Empty behaviour
new ArrayDeque<Integer>().poll();   // null
new ArrayDeque<Integer>().pop();    // NoSuchElementException
new Stack<Integer>().pop();         // EmptyStackException

What I like about ArrayDeque is that it's one class for both stack and queue. It's a resizable circular array, so push, pop, offer and poll are all O(1), and it's faster than LinkedList because there's no node object per element.

One gotcha I've hit is that the iteration order differs. Printing a Stack shows the top last, while printing an ArrayDeque used as a stack shows the top first. So if I convert a stack to a list for output, I check which order I'm getting.

The other gotcha is that ArrayDeque rejects null with a NullPointerException, because poll uses null to mean "empty". If I need a null marker, like in level-order traversal, I use a sentinel object instead.

So in practice I declare it as Deque<Integer> stack = new ArrayDeque<>() and stick to push, pop and peek.

Interviewers look for: "Stack extends Vector" and "synchronized". The follow-up is usually "then when is LinkedList better?" and the honest answer is almost never, unless you need nulls or to remove elements during iteration.

IntermediateHashMap vs LinkedHashMap vs TreeMap. How do you choose?

So I choose by what order I need. HashMap has no defined order and is O(1) on average. LinkedHashMap is a HashMap plus a doubly linked list, so it keeps insertion order (or access order) at the same O(1). TreeMap is a red-black tree, so keys stay sorted and every operation is O(log n), but I get "nearest key" queries.

for (String w : List.of("pear", "apple", "fig", "banana")) { ...put(w, w.length()); }

HashMap        {banana=6, apple=5, pear=4, fig=3}   // some order, may change on resize
LinkedHashMap  {pear=4, apple=5, fig=3, banana=6}   // insertion order
TreeMap        {apple=5, banana=6, fig=3, pear=4}   // sorted by key

TreeMap<Integer, String> plans = new TreeMap<>(Map.of(100, "basic", 500, "pro", 1000, "enterprise"));
plans.floorEntry(750);    // 500=pro     largest key <= 750
plans.ceilingKey(750);    // 1000        smallest key >= 750
plans.floorKey(50);       // null        nothing qualifies
Orderget / putNull keyPick it for
HashMapNoneO(1) averageOne allowedCounting, lookups, the default
LinkedHashMapInsertion or accessO(1) averageOne allowedPredictable output, LRU cache
TreeMapSorted by keyO(log n)Not allowed (natural order)Floor/ceiling, ranges, sorted iteration

The reason I'd pick TreeMap is a question like "which price tier does 750 fall into?" or "what's the next free meeting slot after 10:30?" Those are floorKey and ceilingKey in O(log n), which a HashMap simply can't do.

One gotcha: the nearest methods return null when nothing qualifies, so unboxing straight into an int throws a NullPointerException.

So in practice HashMap is my default, LinkedHashMap when order matters or for an LRU cache with removeEldestEntry, and TreeMap when I need order-based queries.

IntermediateWhy does map1.get(k) == map2.get(k) sometimes return false when both values are 1000?

So it's the Integer caching trap. Collections store Integer objects, and == on two Integers compares references, not values. Java caches Integer objects from −128 to 127, so small values happen to be the same object and == works. Larger values are separate objects, so == is false.

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
q1.intValue() == q2; // true   one side is int, so the other unboxes

Map<Character, Integer> m1 = new HashMap<>(), m2 = new HashMap<>();
m1.put('a', 1000); m2.put('a', 1000);
m1.get('a') == m2.get('a');               // false
m1.get('a').equals(m2.get('a'));          // true
Objects.equals(m1.get('a'), m2.get('a')); // true, and null-safe

int x = m1.get('z');   // NullPointerException: unboxing null

What makes this nasty is that tests with small numbers pass. I've seen this exact bug in "are these two strings anagrams using two frequency maps?" It works on "abc" and fails once a count goes above 127.

There are two related traps I watch for. Unboxing a missing key crashes, so I use getOrDefault. And on a List<Integer>, remove(1) removes index 1, while remove(Integer.valueOf(1)) removes the value 1. From [1, 2, 3], the first gives [1, 3].

So in practice, any time a value comes out of a collection, I compare with equals or unbox to int first.

Interviewers look for: the range −128 to 127 and the word "reference". Bonus if you mention that the upper bound of the cache can be raised with a JVM flag, which is exactly why you should never rely on it.

AdvancedWrite a Comparator to sort people by age descending, then name ascending. Why not (a, b) -> a - b?

So I'd chain the built-in comparator factories, which are readable and can't overflow. The subtraction trick breaks because a - b can overflow an int and flip the sign, which silently gives a wrong order.

record Person(String name, int age) {}

people.sort(Comparator.comparingInt(Person::age).reversed()
                      .thenComparing(Person::name));
// [Bob 30, Zoe 30, Adam 25]

// The overflow bug
int[][] pts = {{-2_000_000_000, 0}, {2_000_000_000, 0}, {5, 0}};
Arrays.sort(pts, (x, y) -> x[0] - y[0]);
// [[5, 0], [2000000000, 0], [-2000000000, 0]]   WRONG
Arrays.sort(pts, (x, y) -> Integer.compare(x[0], y[0]));
// [[-2000000000, 0], [5, 0], [2000000000, 0]]   right

// -2_000_000_000 - 2_000_000_000 evaluates to 294967296 (positive!)

What happens with subtraction is that the true difference is −4 billion, which doesn't fit in an int, so it wraps to +294,967,296. The comparator then claims the negative number is larger. With typical test values it works fine, which is what makes it dangerous.

A Comparator returns negative if the first argument comes first, zero if they're equal, and positive otherwise. It also has to be consistent. If compare says two different items are equal, a TreeSet or TreeMap will treat them as duplicates and drop one, which is a real bug I've seen with "sort by score" sets.

For heaps I write the same thing inline, for example a PriorityQueue of {id, distance} ordered by distance, then id:

PriorityQueue<int[]> pq = new PriorityQueue<>((x, y) ->
    x[1] != y[1] ? Integer.compare(x[1], y[1]) : Integer.compare(x[0], y[0]));
// offer {3,2}, {1,5}, {2,2} -> polls {2,2}, {3,2}, {1,5}

So in practice I use Integer.compare or comparingInt every time, and thenComparing for tie-breaks.

Interviewers look for: the difference between Comparable (the class's natural order, compareTo) and Comparator (an external order you pass in), and that .reversed() applies to the whole chain before it.

5. Bit Manipulation

BasicHow do you check if a number is odd, or a power of two, using bits?

So for odd, I check the lowest bit: (n & 1) == 1. For a power of two, I use n > 0 && (n & (n - 1)) == 0, because a power of two has exactly one 1 bit, and n & (n - 1) clears the lowest 1 bit.

static boolean isOdd(int n)        { return (n & 1) == 1; }
static boolean isPowerOfTwo(int n) { return n > 0 && (n & (n - 1)) == 0; }

isOdd(-7);                   // true
-7 % 2;                      // -1, so "n % 2 == 1" says -7 is NOT odd
isPowerOfTwo(64);            // true   1000000 & 0111111 = 0
isPowerOfTwo(96);            // false  1100000 & 1011111 = 1000000
isPowerOfTwo(0);             // false  (guarded by n > 0)
isPowerOfTwo(Integer.MIN_VALUE); // false (guarded by n > 0)

What happens with n - 1 is that subtracting 1 flips the lowest 1 bit to 0 and every 0 below it to 1. ANDing with the original wipes out exactly that lowest bit. If nothing is left, there was only one bit.

The n > 0 guard matters. Zero has no bits, so the AND gives 0 and it would wrongly pass. Integer.MIN_VALUE is a single sign bit, so it would pass too without the guard.

One Java gotcha: I always put brackets around the AND. & binds weaker than ==, so n & 1 == 0 doesn't even compile.

So in practice (n & 1) is also the safe odd check for negatives, where n % 2 == 1 fails.

BasicWhat's the difference between >> and >>>, and how are negative numbers stored?

So Java stores ints in 32-bit two's complement. To get −x, you flip every bit of x and add 1, so ~x == -x - 1. >> is the signed shift: it copies the sign bit in from the left, so negatives stay negative. >>> is the unsigned shift: it always fills with 0.

-20       = 11111111111111111111111111101100
-20 >> 2  = 11111111111111111111111111111011  = -5   (sign bit copied)
-20 >>> 28 = 1111                              = 15   (zeros shifted in)
-1 >> 1   = -1
-1 >>> 1  = 2147483647

~5 = -6,   ~5 + 1 = -5
Math.abs(Integer.MIN_VALUE) = -2147483648   (no positive twin!)
1 << 31 = -2147483648,  1 << 32 = 1,  1L << 32 = 4294967296

The reason two's complement is used is that addition works the same for positive and negative numbers and there's only one zero. The range is lopsided, −2³¹ to 2³¹ − 1, so Integer.MIN_VALUE has no positive version and Math.abs returns it unchanged.

A place >>> actually matters is the binary search midpoint. (lo + hi) / 2 overflows for large indices, and (lo + hi) >>> 1 fixes it because it treats the overflowed sum as unsigned. With lo = 2,000,000,000 and hi = 2,100,000,000, the naive version gives −97,483,648, and both >>> 1 and lo + (hi - lo) / 2 give 2,050,000,000.

So in practice I also remember that shift amounts are taken mod 32 for int, which is why 1 << 32 is 1. For 64-bit masks I write 1L << k.

Interviewers look for: ">> keeps the sign". Also that >> on a negative odd number rounds toward negative infinity, while / 2 rounds toward zero: -7 >> 1 is −4 but -7 / 2 is −3.

IntermediateEvery element appears twice except one. Find it in O(n) time and O(1) space.

So I XOR everything together. XOR has two properties that make this work: x ^ x = 0 and x ^ 0 = x, and it's commutative, so order doesn't matter. Every pair cancels out and only the single number survives.

static int singleNumber(int[] nums) {
    int x = 0;
    for (int v : nums) x ^= v;
    return x;
}
// [4, 1, 2, 1, 2] -> 4 ^ (1 ^ 1) ^ (2 ^ 2) = 4 ^ 0 ^ 0 = 4

Let me trace it: 0 ^ 4 = 4, 4 ^ 1 = 5, 5 ^ 2 = 7, 7 ^ 1 = 6, 6 ^ 2 = 4. The intermediate values look random, but the pairs cancel by the end.

The obvious alternatives are a HashSet, where I add and remove as I go (O(n) space), or sorting and checking neighbours (O(n log n)). XOR beats both on the constraints given.

The same trick solves "missing number": the array has 0..n with one missing, so I XOR every index and every value together, plus n. Everything present appears twice and cancels. For [3, 0, 1] that gives 2.

static int missingNumber(int[] nums) {
    int x = nums.length;
    for (int i = 0; i < nums.length; i++) x ^= i ^ nums[i];
    return x;
}

So in practice, whenever I hear "appears twice" or "pairs cancel", XOR is the first thing I try.

Interviewers look for: the follow-up "what if every other element appears three times?" XOR doesn't cancel triples, so you count each bit position mod 3 instead.

IntermediateCount the number of 1 bits in an integer.

So the neat approach is Brian Kernighan's trick: n &= n - 1 clears the lowest set bit each time, so the loop runs once per 1 bit, not once per bit position. In real code I'd just call Integer.bitCount.

static int countBits(int n) {
    int c = 0;
    while (n != 0) {          // != 0, not > 0, so negatives work
        n &= n - 1;           // drop the lowest 1 bit
        c++;
    }
    return c;
}
// countBits(13) = 3     (1101)
// countBits(-1) = 32    (all ones)
// Integer.bitCount(13) = 3, Integer.bitCount(-1) = 32

Let me trace 13: 1101 & 1100 = 1100, then 1100 & 1011 = 1000, then 1000 & 0111 = 0. Three iterations, three bits.

The naive version checks n & 1 and shifts right 32 times. That's fine too, but there's a trap: if I shift with >> on a negative number, the sign bit keeps being copied in, so the loop never reaches 0. I'd have to use >>>.

Both versions are O(1) because an int has a fixed 32 bits. But Kernighan's is O(number of set bits), which is nicer for sparse values.

A common follow-up is "count bits for every number from 0 to n." There I'd use DP: bits[i] = bits[i >> 1] + (i & 1), which is O(n) total instead of O(n log n).

So in practice I mention Integer.bitCount, which the JIT turns into a single CPU instruction on most machines, and then write Kernighan's to show I understand it.

IntermediateGenerate all subsets of an array using a bitmask.

So with n elements there are 2ⁿ subsets, and each one maps to an n-bit number where bit i says whether element i is in or out. I loop mask from 0 to 2ⁿ − 1 and build each subset from its bits.

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++) {
        List<Integer> cur = new ArrayList<>();
        for (int i = 0; i < n; i++)
            if ((mask & (1 << i)) != 0) cur.add(nums[i]);
        out.add(cur);
    }
    return out;
}
// [1,2,3] -> [[], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3]]

Let me show you the mapping for a few masks: mask 5 is 101, so bits 0 and 2 are set, giving [1, 3]. Mask 6 is 110, giving [2, 3]. Mask 7 is everything.

That's O(n · 2ⁿ) time, because there are 2ⁿ masks and I check n bits for each. The output itself is that size, so I can't do better.

Compared to the recursive take-or-skip version, the bitmask version is iterative with no stack and very easy to get right. The recursive one is easier to prune, though. If I only want subsets whose sum is at most k, backtracking can stop a branch early, while the bitmask loop visits every mask.

So in practice bitmasks are my go-to when n ≤ 20, which is also the size where bitmask DP shows up, like "visit all cities" with dp[mask][city].

Interviewers look for: 1 << n as the count, and awareness that for n ≥ 31 an int mask overflows, so you'd need long, although 2³¹ subsets is already too many to generate.

AdvancedEvery element appears twice except two. Find both in O(n) time and O(1) space.

So XOR-ing everything gives me a ^ b, not the two numbers. The trick is that a ^ b is non-zero, so they differ in at least one bit. I pick the lowest such bit with xor & -xor and use it to split the array into two groups. Each group then contains exactly one of the singles, and every pair lands in the same group, so the plain XOR trick works on each group.

static int[] singleNumberIII(int[] nums) {
    int xor = 0;
    for (int v : nums) xor ^= v;          // xor = 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 without that bit
        else b ^= v;                      // group with that bit
    }
    return new int[]{a, b};
}
// [1, 2, 1, 3, 2, 5] -> [5, 3]

Let me trace it. The XOR of everything is 3 ^ 5 = 011 ^ 101 = 110, which is 6. The lowest set bit of 6 is 2 (010). Numbers without bit 1 set are 1, 1 and 5, which XOR to 5. Numbers with it are 2, 3 and 2, which XOR to 3.

Why does x & -x isolate the lowest bit? Because -x = ~x + 1. Flipping every bit and adding 1 makes the carry ripple up to exactly the lowest 1 of x, and every bit above it is the opposite of x, so the AND keeps only that one bit. This is the same operation Fenwick trees use.

So in practice this is two passes, O(n) time, and three variables of space.

Interviewers look for: the explanation of why pairs can't be split across groups (both copies have the same bit), and the x & -x identity. The edge case where xor is Integer.MIN_VALUE still works, because MIN_VALUE & -MIN_VALUE is MIN_VALUE, a single bit.

Part 2 — Linear Data Structures

6. Arrays & Strings

BasicWhy is array access O(1), and when would you use an int[] over an ArrayList?

So an array is one contiguous block of memory where every slot is the same size. The address of element i is just base + i × elementSize, one multiply and one add, no matter how big the array is. That's why indexing is O(1).

Let me show you the trade-offs I think about:

int[]ArrayList<Integer>
SizeFixed at creationGrows (amortised O(1) add)
ElementsRaw ints, 4 bytes eachReferences to Integer objects (about 16 bytes each, plus the reference)
Cache behaviourValues sit next to each otherValues scattered on the heap
Lengtha.length (field)list.size() (method)
Insert in middleManual shift, O(n)add(i, x), still O(n)

What that means in practice is that an int[] is noticeably faster and smaller. It avoids boxing, where each int is wrapped in an object, and the CPU can prefetch contiguous memory.

So I use int[] when I know the size up front, like a DP table, a frequency array or a prefix-sum array. I use ArrayList when the size grows as I go, like collecting results. And if I'm inserting or removing at the front a lot, neither is right. I'd use an ArrayDeque.

Interviewers look for: "contiguous" and "same-size elements", and knowing that inserting at index 0 is O(n) for both because everything shifts.

BasicCheck if a string is a palindrome, ignoring case and non-alphanumeric characters.

So I'd use two pointers, one at each end, moving inward. I skip anything that isn't a letter or digit and compare the rest case-insensitively. That's O(n) time and O(1) space, with no cleaned-up copy of the string.

static boolean isPalindrome(String s) {
    int left = 0, right = s.length() - 1;
    while (left < right) {
        char l = s.charAt(left), r = s.charAt(right);
        if (!Character.isLetterOrDigit(l)) { left++; continue; }
        if (!Character.isLetterOrDigit(r)) { right--; continue; }
        if (Character.toLowerCase(l) != Character.toLowerCase(r)) return false;
        left++; right--;
    }
    return true;
}
// "A man, a plan, a canal: Panama" -> true
// "race a car" -> false
// "" -> true

What happens here is that each pointer only ever moves inward, so together they take at most n steps. The continue after skipping lets me re-check the loop condition, so I can't run past the other pointer.

The simpler version is to build a cleaned lowercase string with a StringBuilder, then compare it with its reverse. That's easier to read, but it uses O(n) extra space. I'd mention it and then write the two-pointer version.

So in practice I also clarify up front what counts as "alphanumeric". Some interviewers mean ASCII only, and Character.isLetterOrDigit also accepts Unicode letters.

IntermediateCheck if two strings are anagrams. What are the options and their costs?

So two strings are anagrams if they have the same characters with the same counts. For lowercase English letters, the best answer is a 26-slot count array: count up for one string, down for the other, and check everything is zero. That's O(n) time and O(1) space.

static boolean isAnagram(String s, String t) {
    if (s.length() != t.length()) return false;
    int[] freq = new int[26];
    for (int i = 0; i < s.length(); i++) {
        freq[s.charAt(i) - 'a']++;
        freq[t.charAt(i) - 'a']--;
    }
    for (int f : freq) if (f != 0) return false;
    return true;
}
// ("listen", "silent") -> true, ("rat", "car") -> false
ApproachTimeSpaceWhen
Sort both, compareO(n log n)O(n) for the char arraysQuick to write, any charset
int[26] countsO(n)O(1)Lowercase letters only
int[128]O(n)O(1)Any ASCII
HashMap<Character,Integer>O(n)O(k) distinct charsFull Unicode

The reason c - 'a' works is that a char is really a number, its character code. So 'a' becomes 0 and 'z' becomes 25. The length check at the top is a free early exit.

So in practice I ask about the character set first, because that decides between the array and the map. And if I did use two maps, I'd compare them with equals, never by comparing values with ==, because of the Integer cache.

IntermediateRotate an array to the right by k steps, in place.

So the clean O(1)-space trick is three reversals: reverse the whole array, then reverse the first k elements, then reverse the rest. And I take k %= n first, because rotating by n is the same as not rotating.

static void rotate(int[] nums, int k) {
    int n = nums.length;
    k %= n;
    reverse(nums, 0, n - 1);
    reverse(nums, 0, k - 1);
    reverse(nums, k, n - 1);
}
static void reverse(int[] a, int i, int j) {
    while (i < j) { int t = a[i]; a[i++] = a[j]; a[j--] = t; }
}

Let me trace it with [1,2,3,4,5,6,7] and k = 3:

reverse all        [7,6,5,4,3,2,1]
reverse first 3    [5,6,7,4,3,2,1]
reverse last 4     [5,6,7,1,2,3,4]   done

Why it works: after reversing everything, the last k elements are at the front, just backwards, and the first n − k are at the back, also backwards. Reversing each block puts them the right way round.

The alternatives are copying into a new array with out[(i + k) % n] = nums[i], which is O(n) extra space, or rotating by one step k times, which is O(n·k) and too slow.

So in practice this is O(n) time and O(1) space. The edge case I always test is k larger than n, like [1,2] with k = 5, which gives [2,1] only because of the k %= n.

Interviewers look for: the k %= n line. Forgetting it causes an index-out-of-bounds crash on the second reverse.

IntermediateProduct of array except self, without using division.

So the answer at index i is the product of everything to its left times everything to its right. I compute the left products in one forward pass into the output array, then sweep backwards with a running right product. That's O(n) time and O(1) extra space, not counting the output.

static int[] productExceptSelf(int[] nums) {
    int n = nums.length;
    int[] out = new int[n];
    out[0] = 1;
    for (int i = 1; i < n; i++) out[i] = out[i - 1] * nums[i - 1];  // left products
    int right = 1;
    for (int i = n - 1; i >= 0; i--) {
        out[i] *= right;          // times everything to the right
        right *= nums[i];
    }
    return out;
}
// [1,2,3,4]      -> [24, 12, 8, 6]
// [-1,1,0,-3,3]  -> [0, 0, 9, 0, 0]

Let me trace [1,2,3,4]. After the forward pass, out is [1, 1, 2, 6], which is the product of everything left of each index. Going backwards, right takes the values 1, 4, 12, 24, so out becomes [24, 12, 8, 6].

The reason they ban division is zeros. "Total product divided by nums[i]" breaks on a zero, and you'd need special cases for one zero versus two. The prefix/suffix version handles zeros naturally, which you can see in the second example.

So in practice this "left pass, right pass" shape shows up a lot, for example in trapping rain water and in candy distribution.

AdvancedFind the length of the longest substring without repeating characters.

So I'd use a sliding window. I keep a window [left, right] with no repeats and remember the last index where I saw each character. When the new character was already seen inside the window, I jump left just past that earlier occurrence. Each character is visited once, so it's O(n).

static int lengthOfLongestSubstring(String s) {
    int[] last = new int[128];            // last index of each ASCII char
    Arrays.fill(last, -1);
    int best = 0, left = 0;
    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        if (last[c] >= left) left = last[c] + 1;   // repeat inside window
        last[c] = right;
        best = Math.max(best, right - left + 1);
    }
    return best;
}
// "abcabcbb" -> 3, "bbbbb" -> 1, "pwwkew" -> 3, "abba" -> 2

The subtle part is the last[c] >= left check. On "abba", when I reach the final 'a', its last index is 0, but left has already moved to 2 because of the 'b's. Without the check, left would jump backwards to 1 and I'd return 3, which is wrong. That's the test case I always run.

The brute force checks every substring for repeats, which is O(n³), or O(n²) with a set. The window works because once [left, right] is valid, every smaller window inside it is too, so I never need to move left backwards.

So in practice this is O(n) time and O(1) space for ASCII. For full Unicode I'd swap the array for a HashMap<Character, Integer>. If the input were a huge stream, this still works in one pass, because I only keep the window's metadata, not the string.

Interviewers look for: the "abba" case, and the general sliding-window template: expand right, shrink left until valid, record the answer.

7. Hashing

BasicWhy is HashMap get and put O(1) on average? When is it not?

So a HashMap turns the key into an array index with a hash function, then jumps straight to that bucket, just like array indexing. As long as keys are spread evenly and the table isn't too full, each bucket holds only a few entries, so a lookup does a constant amount of work regardless of the map's size.

Let me show you with the three pieces:

key "cat"
  -> hashCode()           98262
  -> spread (h ^ h>>>16)  98263
  -> index = hash & 15   7        (16 buckets)
  -> walk bucket 7, compare with equals()

What can break it is collisions: two keys landing in the same bucket. A few collisions are normal and cheap. The bad case is many keys in one bucket, from a poor hashCode or a deliberate attack. Then that bucket becomes a long chain and a lookup is O(n). Since Java 8, a bucket that grows past 8 entries turns into a red-black tree, so the worst case drops to O(log n).

Real collisions are easy to produce: "Aa".hashCode() and "BB".hashCode() are both 2112.

The other cost is resizing. When the map is 75% full it doubles and re-inserts everything, which is O(n) for that one put. But that's amortised O(1), like ArrayList.

So in practice I say "O(1) average, O(log n) worst case per bucket in Java 8+". And I remember that hashing a String key is O(length) the first time, so for long keys that's not free.

Interviewers look for: "average, not guaranteed", and the words collision, load factor and resize. That leads straight into the internals question.

IntermediateHow does Java's HashMap work internally?

So internally it's an array of buckets, Node<K,V>[] table, where each bucket is a linked list of nodes, or a red-black tree if it gets long. The capacity is always a power of two, starting at 16, and the table is created lazily on the first put.

Let me walk through a put(key, value):

  • Hash: call key.hashCode(), then spread it with h ^ (h >>> 16). That mixes the high bits into the low bits, because with 16 buckets only the lowest 4 bits pick the bucket.
  • Index: (capacity - 1) & hash. With a power-of-two capacity that's the same as hash % capacity, but faster and never negative.
  • Search the bucket: for each node, check node.hash == hash && (node.key == key || key.equals(node.key)). If found, replace the value. If not, append a new node.
  • Treeify: if that bucket's chain grows past the threshold of 8 nodes and the table has at least 64 buckets, convert it to a red-black tree. If the table is smaller than 64, it resizes instead. A tree shrinks back to a list at 6.
  • Resize: if size exceeds capacity × 0.75 (the load factor), double the capacity and redistribute.
// Capacity 16 -> 32: each entry stays at i or moves to i + 16,
// decided by a single bit of the hash (hash & 16)
"cat"  idx16 = 7    idx32 = 23   (bit set: moves by 16)
"dog"  idx16 = 13   idx32 = 29   (bit set: moves by 16)
"owl"  idx16 = 5    idx32 = 5    (bit clear: stays)
"emu"  idx16 = 12   idx32 = 12   (bit clear: stays)

get follows the same path: hash, index, then walk the bucket using hash and equals. The stored hash is compared first because comparing ints is much cheaper than calling equals.

A few details I'd add: one null key is allowed and goes to bucket 0, iteration order isn't defined and can change after a resize, and it's not thread-safe. Concurrent puts can lose updates, so across threads I use ConcurrentHashMap.

So in practice, if I know I'll store about a million entries, I pre-size it, HashMap.newHashMap(1_000_000) on Java 19+, to skip all the intermediate resizes.

Interviewers look for: the numbers 16, 0.75, 8 and 64, "power of two so the index is a bit mask", and the reason for treeification: protecting against collision attacks. The follow-up is usually the equals/hashCode contract.

IntermediateWhat happens if you override equals but not hashCode?

So hash-based collections break. The contract says if two objects are equal, they must have the same hashCode. If I only override equals, two equal objects still get different identity-based hash codes, land in different buckets, and the map never even calls my equals.

final class Point {
    final int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }
    @Override public boolean equals(Object o) {
        return o instanceof Point p && p.x == x && p.y == y;
    }
    // no hashCode!
}
Set<Point> set = new HashSet<>();
set.add(new Point(1, 2));
set.contains(new Point(1, 2));   // false (almost always)

// Fix: add  @Override public int hashCode() { return Objects.hash(x, y); }
// Or just use a record, which generates both:
record P(int x, int y) {}
// set.contains(new P(1, 2)) -> true

The rules I keep in mind are: equal objects must have equal hash codes, but unequal objects may share a hash code. That's just a collision, and it's allowed. Both methods should use the same fields.

A related bug is mutating a key after putting it in the map. I've seen this with a List as a key: I put [1, 2], then added 3 to the same list. Now get with the list returns null, and so does get(List.of(1, 2)), because the entry is sitting in the bucket for the old hash. The map still says size 1, but the entry is unreachable.

So in practice my keys are immutable: Strings, boxed numbers, records, or an encoded long like (long) row * cols + col. And I never use int[] as a key, because arrays use identity equality.

Interviewers look for: the direction of the contract (equal implies same hash, not the reverse), and the mutable-key bug.

IntermediateGroup a list of words into anagram groups.

So the idea is to give every word a canonical key that's the same for all its anagrams, then group by that key in a HashMap. The simplest key is the word's letters sorted.

static Map<String, List<String>> groupAnagrams(String[] strs) {
    Map<String, List<String>> groups = new HashMap<>();
    for (String s : strs) {
        char[] k = s.toCharArray();
        Arrays.sort(k);
        groups.computeIfAbsent(new String(k), key -> new ArrayList<>()).add(s);
    }
    return groups;
}
// ["eat","tea","tan","ate","nat","bat"]
// -> {aet=[eat, tea, ate], ant=[tan, nat], abt=[bat]}

computeIfAbsent creates the list the first time a key appears and returns the existing one after that, so there's no "if not present, put" dance.

The cost with sorted keys is O(n · k log k), where n is the number of words and k is the maximum length. If words are long, I'd use a count-based key instead: count the 26 letters and use Arrays.toString(count) as the key. That's O(n · k), because building the key is linear.

One gotcha: I can't use the char[] or an int[] directly as the key. Arrays don't override equals and hashCode, so two identical arrays are different keys. Converting to a String fixes that.

So in practice this "canonical key + grouping map" pattern also solves things like grouping shifted strings or grouping points by slope.

IntermediateCount the subarrays whose sum equals k.

So I use prefix sums with a HashMap. The sum of a subarray (j, i] is prefix[i] - prefix[j]. I want that to equal k, so at each position I ask: how many earlier prefixes equal currentSum - k? A map from prefix sum to count answers that in O(1).

static int subarraySum(int[] nums, int k) {
    Map<Integer, Integer> prefixCount = new HashMap<>();
    prefixCount.put(0, 1);                 // the 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

Let me trace [1,2,3], k = 3. Sums are 1, 3, 6. At sum 3, I look up 0, which is the seeded empty prefix, so I count 1 (the subarray [1,2]). At sum 6, I look up 3, which I've seen once, so I count another (the subarray [3]). Total 2.

The put(0, 1) seed is the line people forget. It represents "a subarray starting at index 0". Without it, [1,2] wouldn't be counted.

The reason a sliding window doesn't work here is negative numbers. With negatives, growing the window can make the sum smaller, so I can't decide which side to move. Prefix sums don't care about signs.

So in practice this is O(n) time and O(n) space, versus O(n²) for checking every subarray.

Interviewers look for: the seed and the "why not sliding window?" answer. Also, if values are large, the running sum should be a long.

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

So the obvious answer is to sort, which is O(n log n). To get O(n) I put everything in a HashSet, and then I only start counting from numbers that begin a run, meaning x - 1 isn't in the set. From each start I count upward.

static 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++;
        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   (0..8)

It looks like a nested loop, so it looks like O(n²). But the continue is what makes it linear. The inner while only runs from the start of each run, and every number belongs to exactly one run, so across the whole loop each number is visited by the while at most once. That's at most n outer iterations plus n inner steps, so O(n) total.

Without that check, an input like 1..n would count from every number: n + (n−1) + … which is O(n²).

I iterate over the set, not the original array, so duplicates like the two zeros don't cause repeated work.

So in practice this is O(n) average time and O(n) space. On small inputs sorting might actually be faster because of hashing and boxing overhead, and I'd say that. An alternative is Union-Find, joining x with x + 1, but the set version is simpler.

Interviewers look for: a clear amortised argument for why the nested loop is O(n). Just asserting it isn't enough.

8. Linked Lists

BasicArrayList vs LinkedList. When would you actually use a LinkedList?

So an ArrayList is a dynamic array: O(1) access by index, amortised O(1) add at the end, O(n) insert in the middle. A LinkedList is a doubly linked list: O(1) insert and remove if you already hold the node, but O(n) to reach index i. In practice ArrayList wins almost everywhere.

ArrayListLinkedList
get(i)O(1)O(n), walks from the nearer end
add at endO(1) amortisedO(1)
add/remove at frontO(n), shifts everythingO(1)
Insert in middleO(n) shiftO(n) to find the spot, then O(1)
MemoryOne array of referencesA node object per element, plus two pointers

Let me show you why the table undersells it. I filled both with 100,000 Integers and read them with a for (int i…) list.get(i) loop. The ArrayList took about 13 ms. The LinkedList took about 8.7 seconds, because each get(i) walks the list, which makes the loop O(n²). That's an easy bug to write by accident.

Even when Big-O says LinkedList should win, like inserting in the middle, it often doesn't. Finding the spot is O(n) pointer-chasing, and scattered nodes are bad for the CPU cache, while ArrayList's shift is one fast memory copy.

So in practice I use ArrayList for lists and ArrayDeque for queues and stacks. The linked list as a concept matters a lot, though. It's the core of LRU caches, adjacency lists and hash-map buckets, where I hold node references directly.

Interviewers look for: "O(1) insert only if you already have the node", and the cache-locality point.

BasicReverse a singly linked list.

So I walk the list once with three pointers, prev, cur and next, and flip each node's arrow to point backwards. When cur falls off the end, prev is the new head. That's O(n) time and O(1) space.

static 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 arrow
        prev = cur;                 // 3. step prev forward
        cur = next;                 // 4. step cur forward
    }
    return prev;
}
// [1,2,3,4,5] -> [5,4,3,2,1],  null -> null

The order of those four lines is everything. If I flip cur.next before saving it, I've lost the rest of the list. So I always say "save, flip, step, step" out loud.

The recursive version reverses the rest first, then hooks the current node onto the end:

static ListNode reverseRec(ListNode head) {
    if (head == null || head.next == null) return head;
    ListNode newHead = reverseRec(head.next);
    head.next.next = head;      // the node after me now points back to me
    head.next = null;           // I become the tail
    return newHead;
}
// [1,2,3] -> [3,2,1]

The recursive one is elegant, but it's O(n) stack space, and on a 100,000-node list it can throw StackOverflowError.

So in practice I write the iterative version by default. I test it on an empty list, a single node and two nodes, because those are where pointer bugs show up.

IntermediateHow do you detect a cycle in a linked list, and find where it starts?

So I use Floyd's fast and slow pointers. Slow moves one step, fast moves two. If there's a cycle, fast eventually laps slow and they meet. If fast hits null, there's no cycle. That's O(n) time and O(1) space, versus O(n) space for a HashSet of visited nodes.

static 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) {                  // they met inside the cycle
            ListNode p = head;
            while (p != slow) { p = p.next; slow = slow.next; }
            return p;                        // cycle start
        }
    }
    return null;                             // no cycle
}
// 3 -> 2 -> 0 -> -4 -> (back to 2)  returns the node 2
// 1 -> 2                             returns null

Why do they always meet? Once both are inside the cycle, the gap between them shrinks by exactly one each step, so fast can't jump over slow.

Why does the second phase find the start? Say the start is a steps from the head, and they meet b steps into the cycle. Slow walked a + b. Fast walked twice that, and the extra a + b is a whole number of laps. So walking a more steps from the meeting point also lands on the start. That's why a new pointer from the head and slow, moving one step each, meet exactly at the cycle start.

I compare nodes with == here, not values, because two different nodes can hold the same value.

So in practice the same slow/fast idea finds the middle of a list (slow is at the middle when fast reaches the end) and powers "find the duplicate number" on an array.

Interviewers look for: the loop condition fast != null && fast.next != null, which prevents a NullPointerException, and at least a sketch of the a + b proof.

IntermediateMerge two sorted linked lists.

So I use a dummy head node and a tail pointer. At each step I attach whichever list has the smaller front node, then advance. When one list runs out, I attach the rest of the other in one step. That's O(n + m) time and O(1) extra space, because I'm re-linking existing nodes, not copying.

static ListNode merge(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]
// null + [0] -> [0]

The reason I use a dummy node is that without it, the first node is a special case. I'd need an if to decide which list provides the head. With the dummy, every node is attached the same way, and I just return dummy.next.

I use <= rather than < so that equal values come from the first list first. That keeps the merge stable, which matters when this is the merge step of merge sort on a linked list.

So in practice this is the building block for "merge k sorted lists". There I'd either merge in pairs (O(N log k)) or use a min-heap of the k front nodes, also O(N log k).

IntermediateRemove the nth node from the end of a list in one pass.

So I use two pointers with a fixed gap. I move fast n + 1 steps ahead of slow, then move both until fast falls off the end. At that point slow is right before the node to delete, so I can unlink it. A dummy node in front handles deleting the head.

static ListNode removeNthFromEnd(ListNode head, int n) {
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    ListNode fast = dummy, slow = dummy;
    for (int i = 0; i <= n; i++) fast = fast.next;   // gap of n + 1
    while (fast != null) { fast = fast.next; slow = slow.next; }
    slow.next = slow.next.next;                       // unlink the target
    return dummy.next;
}
// [1,2,3,4,5], n=2 -> [1,2,3,5]
// [1], n=1         -> []
// [1,2], n=2       -> [2]   (removes the head)

Let me trace the first example. fast starts at dummy and moves 3 steps to node 3. Then both move together until fast is null. That's 3 more steps, leaving slow at node 3. Its next is 4, the second from the end, and I skip it.

The reason for the gap of n + 1, not n, is that to delete a node in a singly linked list I need its predecessor. And the dummy node is why [1,2] with n = 2 works: the predecessor of the head is the dummy.

So in practice the two-pass version, counting the length and then walking to length - n, is also O(n) and perfectly fine. I mention it, but "one pass" is what they're asking to see.

Interviewers look for: the dummy node, and handling "remove the head" without a special case.

AdvancedDesign an LRU cache with O(1) get and put.

So I combine a HashMap with a doubly linked list. The map gives O(1) lookup from key to node. The list keeps nodes in recency order: most recent at the front, least recent at the back. On every access I move the node to the front, and when the cache is full I evict from the back. All of those are O(1) because the list is doubly linked and I already hold the node.

class LRUCache {
    private static 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<>();
    private final Node head = new Node(0, 0), tail = new Node(0, 0);   // sentinels

    LRUCache(int capacity) { this.capacity = capacity; head.next = tail; tail.prev = head; }

    int get(int key) {
        Node n = map.get(key);
        if (n == null) return -1;
        unlink(n); addFront(n);                  // now most recent
        return n.val;
    }
    void put(int key, int value) {
        Node n = map.get(key);
        if (n != null) { n.val = value; unlink(n); addFront(n); return; }
        if (map.size() == capacity) {
            Node lru = tail.prev;                // least recent is at the back
            unlink(lru); map.remove(lru.key);
        }
        n = new Node(key, value);
        map.put(key, n); addFront(n);
    }
    private void unlink(Node n) { n.prev.next = n.next; n.next.prev = n.prev; }
    private void addFront(Node n) { n.next = head.next; n.prev = head; head.next.prev = n; head.next = n; }
}
// capacity 2: put(1) put(2) get(1)=1 put(3) [evicts 2] get(2)=-1
//             put(4) [evicts 1] get(1)=-1 get(3)=3 get(4)=4

A few design choices I'd explain. The sentinel head and tail mean unlink and addFront never check for null. The node stores its key so that when I evict from the list, I know which map entry to remove. And it has to be doubly linked, because unlinking needs the previous node in O(1).

In production Java I'd just extend LinkedHashMap with access order, which is the same structure built in:

class LRU<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;
    LRU(int capacity) { super(16, 0.75f, true); this.capacity = capacity; }  // true = access order
    @Override protected boolean removeEldestEntry(Map.Entry<K, V> e) { return size() > capacity; }
}
// put 1, put 2, get 1, put 3 -> keys [1, 3]

So in practice I'd mention that neither version is thread-safe. For a shared cache I'd wrap it with a lock or use a library like Caffeine.

Interviewers look for: why each piece is needed (map for lookup, doubly linked list for O(1) reordering), and the key stored in the node. The follow-up is often "now make it LFU", which needs a map of frequency to list.

9. Stacks

BasicWhat is a stack, and where have you actually used one?

So a stack is a collection where I only add and remove at one end, the top. The last thing I push is the first thing I pop, which is LIFO. Push, pop and peek are all O(1).

Let me show you with a quick example.

Deque<Integer> stack = new ArrayDeque<>();
stack.push(1);
stack.push(2);
stack.push(3);
System.out.println(stack);        // [3, 2, 1]  (top first)
System.out.println(stack.peek()); // 3, still there
System.out.println(stack.pop());  // 3, removed
System.out.println(stack);        // [2, 1]

The place I use a stack most is one I never write myself: the call stack. Every method call pushes a frame with its locals and return address, and returning pops it. That's why recursion works, and why very deep recursion throws StackOverflowError.

In code I write, I reach for a stack whenever the problem has nesting or "most recent first" behaviour. Matching brackets or HTML tags, undo history, the browser back button, evaluating postfix expressions, iterative DFS, and "next greater element" problems with a monotonic stack.

So my signal in an interview is: if the thing I opened last is the thing I need to close first, it's a stack.

BasicWhy do people say not to use java.util.Stack? What do you use instead?

So Stack is a legacy class that extends Vector. Every method is synchronized, which costs a lock even in single-threaded code, and because it's a Vector it exposes list methods that break the stack idea. The Java docs themselves recommend Deque, and I use ArrayDeque.

Let me show you with a quick example.

Stack<Integer> legacy = new Stack<>();
legacy.push(1); legacy.push(2); legacy.push(3);
System.out.println(legacy);   // [1, 2, 3]  (bottom first)
legacy.add(0, 99);            // allowed: insert under the bottom
System.out.println(legacy);   // [99, 1, 2, 3]

Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); stack.push(2); stack.push(3);
System.out.println(stack);    // [3, 2, 1]  (top first)

What happens here is that Stack happily lets me insert at index 0, which no real stack should allow. It also iterates bottom to top, while ArrayDeque iterates from the top, and that difference has confused me when printing debug output.

One gotcha I've hit with ArrayDeque: push, pop and peek all work on the front. If I mix in offer or poll, which work on the other end, I've quietly built a queue. Also, peek() returns null on empty, while pop() throws NoSuchElementException. And it rejects null elements.

So in practice I always write Deque<T> stack = new ArrayDeque<>(), and stick to push, pop and peek.

IntermediateWrite a function to check if a string of brackets is valid.

So this is the textbook stack problem. The most recently opened bracket must be the first one closed, which is exactly LIFO. When I see an opener I push the closer I expect. When I see a closer, it has to match the top.

Let me show you with a quick example.

static boolean isValid(String s) {
    Deque<Character> stack = new ArrayDeque<>();
    for (char c : s.toCharArray()) {
        if (c == '(') stack.push(')');
        else if (c == '[') stack.push(']');
        else if (c == '{') stack.push('}');
        else if (stack.isEmpty() || stack.pop() != c) return false;
    }
    return stack.isEmpty();
}

isValid("({[]})");   // true
isValid("([)]");     // false, ] arrives while ) is expected
isValid("((");       // false, leftovers
isValid(")");        // false, nothing to match

Pushing the expected closer instead of the opener is a small trick I like, because the check becomes one comparison and I don't need a map.

The two edge cases people miss are both in there. A closer on an empty stack means invalid, so I check isEmpty() before popping. And after the loop, anything left on the stack is an unmatched opener, so I return stack.isEmpty(), not true.

It's O(n) time and O(n) space in the worst case, like a string of all openers. If there were only one bracket type, I'd drop the stack and just keep a counter.

Interviewers look for: both edge cases without prompting. A common follow-up is "only round brackets, O(1) space", which is the counter version.

IntermediateDesign a stack that returns the minimum element in O(1).

So the key insight is that a stack only ever changes at the top. The minimum of everything at or below a given element can't change while that element is still there. So I store the running minimum alongside each value.

Let me show you with a quick example.

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

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

// push 5, 2, 7, 1  -> getMin() = 1
// pop              -> getMin() = 2
// pop, pop         -> getMin() = 5

What happens here is that when I pop the 1, the pair below it is {7, 2}, which already knows the min was 2 at that point. No searching and no recomputation, so every operation is O(1).

The cost is O(n) extra space, since every element carries its min. A common optimisation is a second stack that only pushes when the new value is less than or equal to the current min. The "or equal" matters. Without it, duplicates of the minimum break the pop logic.

So in an interview I write the pair version first because it's hard to get wrong, then mention the two-stack version as the space optimisation.

IntermediateYour recursive tree walk throws StackOverflowError on large input. How would you fix it?

So recursion uses the JVM call stack, which is small, typically enough for somewhere in the tens of thousands of frames depending on frame size and -Xss. A deep, skewed input blows past that. The fix is to manage my own stack on the heap, which is limited only by memory.

Let me show you with a quick example.

// recursive: one call frame per level of depth
static long sumRecursive(Node node) {
    long sum = node.val;
    for (Node child : node.children) sum += sumRecursive(child);
    return sum;
}

// iterative: the same walk with an explicit stack
static long sumIterative(Node root) {
    long sum = 0;
    Deque<Node> stack = new ArrayDeque<>();
    stack.push(root);
    while (!stack.isEmpty()) {
        Node node = stack.pop();
        sum += node.val;
        for (Node child : node.children) stack.push(child);
    }
    return sum;
}

// a chain 200,000 nodes deep:
// sumIterative -> 200000
// sumRecursive -> StackOverflowError

What happens here is that each "call" becomes a push, and each "return" becomes the loop picking the next item. Any recursive algorithm can be rewritten this way. When the recursion does work after the children return, like postorder, I push extra state, such as a "visited children" flag.

Raising -Xss also works, but it's a band-aid. It's a JVM-wide setting and it just moves the limit.

So in practice I keep recursion for balanced trees, where depth is about log n, and switch to an explicit stack when the depth depends on untrusted input, like parsing nested JSON or walking a file system.

Interviewers look for: that you know the heap-vs-stack memory difference. Also note that Java has no tail-call optimisation, so rewriting as tail recursion doesn't help.

AdvancedFor each element, find the next greater element to its right. Can you do better than O(n²)?

So the brute force scans right from every index, which is O(n²). The O(n) answer is a monotonic stack: I keep indices that are still waiting for a bigger value, and whenever a new value is bigger than the top, it's the answer for that top.

Let me show you with a quick example.

static int[] nextGreater(int[] nums) {
    int n = nums.length;
    int[] ans = new int[n];
    Arrays.fill(ans, -1);
    Deque<Integer> stack = new ArrayDeque<>(); // indices waiting for an answer
    for (int i = 0; i < n; i++) {
        while (!stack.isEmpty() && nums[i] > nums[stack.peek()]) {
            ans[stack.pop()] = nums[i];
        }
        stack.push(i);
    }
    return ans;
}

nextGreater(new int[]{2, 1, 5, 3, 4, 1});  // [5, 5, -1, 4, -1, -1]

Walking through it: 2 waits, 1 waits on top of it. Then 5 arrives and pops both, so each gets 5. Next 3 waits, and 4 pops it. Then 1 waits on 4, and the loop ends. Whatever is still on the stack has no greater element, so it keeps -1.

The stack is always decreasing from bottom to top. That's the invariant. It looks like O(n²) because of the nested loop, but each index is pushed once and popped at most once, so the total work is O(n). That's amortised analysis, and I always say it out loud.

I store indices rather than values because many variants need distances. Daily Temperatures asks "how many days until warmer", which is i - index. The same pattern solves stock span and largest rectangle in a histogram.

Interviewers look for: the "each element pushed and popped once" argument. Follow-ups: circular array (loop twice with i % n), or next smaller (flip the comparison).

10. Queues & Deques

BasicWhat's the difference between a queue, a stack and a deque? How do you use them in Java?

So a queue is FIFO: add at the back, remove from the front, like a line at a counter. A stack is LIFO. A deque, the double-ended queue, lets me add and remove at both ends, so it can act as either one. In Java, ArrayDeque is my default implementation for all three.

Let me show you with a quick example.

Queue<String> q = new ArrayDeque<>();
q.offer("a"); q.offer("b"); q.offer("c");
q.poll();      // "a"  (first in, first out)
q.peek();      // "b"
q.clear();
q.poll();      // null on empty
q.remove();    // throws NoSuchElementException on empty

Each operation comes in two flavours. offer, poll and peek return a special value on failure. add, remove and element throw. I use the first set because an empty queue is usually a normal condition in my loop, not an error.

A few gotchas I've hit. ArrayDeque rejects null, so offer(null) throws NullPointerException. If I write int x = q.poll() on an empty Queue<Integer>, the unboxing of null throws too. And mixing ends: push adds at the front while offer adds at the back, so push plus poll is a stack, not a queue.

So in practice: queue for arrival order and BFS, stack for nesting, and a deque when I need both ends, like a sliding window.

BasicHow would you implement a fixed-size queue with an array?

So with a plain array, removing from the front would mean shifting every element left, which is O(n). The trick is a circular buffer: I keep a head index and a size, and wrap around with modulo so freed slots at the start get reused.

Let me show you with a quick example.

class CircularQueue {
    private final int[] data;
    private int head = 0, size = 0;

    CircularQueue(int capacity) { data = new int[capacity]; }

    boolean offer(int x) {
        if (size == data.length) return false;          // full
        data[(head + size) % data.length] = x;          // tail = head + size
        size++;
        return true;
    }
    Integer poll() {
        if (size == 0) return null;                     // empty
        int x = data[head];
        head = (head + 1) % data.length;
        size--;
        return x;
    }
}

// capacity 3: offer 1,2,3 -> true; offer 4 -> false (full)
// poll -> 1; offer 4 -> true (wraps into slot 0)
// poll x4 -> 2, 3, 4, null

What happens here is that after polling 1, slot 0 is free. The tail position is (1 + 2) % 3 = 0, so 4 goes there. No shifting, and every operation is O(1).

The reason I track size instead of a separate tail pointer is the classic ambiguity. With only head and tail, head == tail could mean empty or full. Using size removes that, without wasting one slot.

So this is the design behind ring buffers in logging, audio, and network I/O, and it's also what ArrayDeque does internally, except it grows instead of rejecting.

Interviewers look for: how you tell full from empty. This is LeetCode 622, Design Circular Queue.

IntermediateImplement a queue using two stacks. What's the complexity?

So I use an in stack for pushes and an out stack for pops. Pouring one stack into another reverses the order, which turns LIFO into FIFO. The key is that I only pour when out is empty.

Let me show you with a quick example.

class MyQueue {
    private final Deque<Integer> in = new ArrayDeque<>();
    private final Deque<Integer> out = new ArrayDeque<>();

    void push(int x) { in.push(x); }
    int pop()  { refill(); return out.pop(); }
    int peek() { refill(); return out.peek(); }
    boolean empty() { return in.isEmpty() && out.isEmpty(); }

    private void refill() {
        if (out.isEmpty()) {
            while (!in.isEmpty()) out.push(in.pop());   // reverses the order
        }
    }
}

// push 1, 2, 3; pop -> 1
// push 4; pop, pop, pop -> 2, 3, 4

Walking through it: after pushing 1, 2, 3, in has 3 on top. The first pop moves all three, so out has 1 on top, and it pops 1. Then I push 4 onto in. The next pops drain 2 and 3 from out first, and only then pour 4 across. Order is preserved.

Push is O(1). A single pop can be O(n) when it triggers a pour, but every element is moved from in to out exactly once in its life. So over any sequence of operations, pop is amortised O(1).

The bug I see most is pouring on every pop, even when out isn't empty. That interleaves new elements ahead of older ones and breaks FIFO.

Interviewers look for: the word "amortised" and why. The mirror question, "stack using queues", costs O(n) per push because you rotate the queue.

IntermediateArrayDeque or LinkedList as a queue: which would you pick and why?

So I pick ArrayDeque. Both give O(1) at both ends, but ArrayDeque is a growable circular array, so elements sit next to each other in memory. LinkedList allocates a node object per element with two extra pointers.

Let me show you with a quick comparison.

ArrayDequeLinkedList
Add/remove at endsO(1) amortisedO(1)
Memory per elementOne array slotA node: value, next, prev, header
Cache behaviourContiguous, fastPointer chasing
GC pressureLowOne object per add
Allows nullNoYes
Also a ListNoYes (index access is O(n))

The reason ArrayDeque wins in practice is the constant factor. The CPU prefetches contiguous memory, and there's no allocation per operation. The "amortised" part is the occasional resize, which doubles the array, same idea as ArrayList.

I'd only choose LinkedList if I genuinely needed null elements, or if I was removing from the middle through an iterator I already held. Both are rare.

So in practice, new ArrayDeque<>() is my queue, stack and deque, and I use PriorityQueue when order is by priority rather than arrival.

AdvancedFind the maximum of every window of size k in an array, in O(n).

So a heap would give O(n log k). For O(n) I use a monotonic deque of indices, with values decreasing from front to back. The front is always the current window's max.

Let me show you with a quick example.

static int[] maxSlidingWindow(int[] nums, int k) {
    int[] result = new int[nums.length - k + 1];
    Deque<Integer> dq = new ArrayDeque<>();   // indices, values decreasing
    for (int i = 0; i < nums.length; i++) {
        if (!dq.isEmpty() && dq.peekFirst() <= i - k) dq.pollFirst();        // left the window
        while (!dq.isEmpty() && nums[dq.peekLast()] <= nums[i]) dq.pollLast(); // can never win again
        dq.offerLast(i);
        if (i >= k - 1) result[i - k + 1] = nums[dq.peekFirst()];
    }
    return result;
}

maxSlidingWindow(new int[]{1, 3, -1, -3, 5, 3, 6, 7}, 3);  // [3, 3, 5, 5, 6, 7]

The insight is this: if a new value is bigger than an older one, the older one can never be a window max again, because the new value is bigger and stays in the window longer. So I throw it away from the back. The front leaves only when its index slides out.

That's why I need a deque: expired indices leave from the front, dominated ones leave from the back. Each index enters once and leaves once, so it's O(n) total, and O(k) space.

I store indices, not values, because I need to know when the front has expired. With duplicate values, storing values makes that check ambiguous.

AdvancedWhat if several threads produce and consume from the same queue?

So ArrayDeque isn't thread-safe, and wrapping it in synchronized blocks means writing my own wait and notify logic. I'd use java.util.concurrent. A BlockingQueue handles both the locking and the waiting: put blocks when full and take blocks when empty.

Let me show you with a quick example.

BlockingQueue<Integer> jobs = new ArrayBlockingQueue<>(100);

Thread producer = new Thread(() -> {
    try {
        for (int i = 1; i <= 5; i++) jobs.put(i);   // blocks if full
        jobs.put(-1);                                // poison pill: no more work
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
Thread consumer = new Thread(() -> {
    try {
        while (true) {
            int job = jobs.take();                   // blocks if empty
            if (job == -1) break;
            System.out.println("processed " + job);
        }
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
producer.start(); consumer.start();
// processed 1 ... processed 5

What I like here is the backpressure. A bounded queue means a fast producer can't fill the heap. It simply waits for the consumer. The "poison pill" is a common way to tell consumers to stop cleanly.

The options I choose between: ArrayBlockingQueue is bounded, with one lock. LinkedBlockingQueue has separate put and take locks, so it has more throughput, but it's unbounded by default, which is a memory risk. ConcurrentLinkedQueue is lock-free and never blocks, so it's good when threads shouldn't wait. PriorityBlockingQueue is for priority order.

So in practice, for a job pipeline I pick a bounded BlockingQueue, or just an ExecutorService, which uses one internally.

11. Heaps & Priority Queues

BasicWhat is a heap, and how is it stored?

So a heap is a complete binary tree with one ordering rule. In a min-heap every parent is less than or equal to its children, so the smallest value is always at the root. It is only partially ordered: siblings and cousins have no order between them.

Because it's complete (every level is full except the last, which fills from the left), there are no gaps. So I store it in a plain array, level by level, and find relatives with arithmetic instead of pointers.

index i:     parent = (i - 1) / 2
             left   = 2 * i + 1
             right  = 2 * i + 2

array  [1, 2, 3, 4, 9, 6, 7]

tree          1
            /   \
           2     3
          / \   / \
         4   9 6   7

Insert puts the value at the end of the array and "sifts up", swapping with its parent while it's smaller. Removing the min takes heap[0], moves the last element to the root, and "sifts down", swapping with the smaller child. Each moves along one root-to-leaf path, and a complete tree has height log n, so both are O(log n). Peek is O(1).

The array layout is a big practical win. No node objects, no pointers, and good cache locality.

So I think of a heap as "the cheapest structure that always knows the current minimum", not as a sorted collection.

Interviewers look for: the index formulas and the word "complete". A common trap is "is a heap a BST?" No: a heap only orders parent against child, not left against right.

BasicI added 5, 1, 4, 2, 3 to a PriorityQueue and printed it. Why isn't it sorted?

So toString and the iterator just walk the underlying heap array in index order. A heap only guarantees the root is the smallest. Only repeated poll() gives sorted order.

Let me show you with a quick example.

PriorityQueue<Integer> pq = new PriorityQueue<>();
for (int x : new int[]{5, 1, 4, 2, 3}) pq.offer(x);

System.out.println(pq);          // [1, 2, 4, 5, 3]   raw heap array

while (!pq.isEmpty()) {
    System.out.print(pq.poll() + " ");   // 1 2 3 4 5
}

What happens here is each offer sifts the value up only as far as it needs. The array ends up as a valid heap, with 1 at the root and every parent smaller than its children, but 5 and 3 are nowhere near sorted positions.

Here are the costs I keep in mind for PriorityQueue:

OperationCost
peek()O(1)
offer(), poll()O(log n)
remove(Object), contains()O(n), a linear scan
Build from a collectionO(n) heapify
IterationAny order

That remove(Object) line has bitten me. If I need to delete arbitrary elements often, or iterate in order, I'd switch to a TreeSet or TreeMap.

So if I need all items sorted once, I just sort. A heap is for when items keep arriving and I keep needing the current min.

IntermediateFind the kth largest element in an array. Why a min-heap and not a max-heap?

So I keep a min-heap of size k holding the k largest values seen so far. The top of that heap is the smallest of the top k, which is by definition the kth largest. It costs O(n log k) time and O(k) space.

Let me show you with a quick example.

static int findKthLargest(int[] nums, int k) {
    PriorityQueue<Integer> heap = new PriorityQueue<>();   // min-heap
    for (int x : nums) {
        heap.offer(x);
        if (heap.size() > k) heap.poll();                  // evict the smallest
    }
    return heap.peek();
}

findKthLargest(new int[]{3, 2, 1, 5, 6, 4}, 2);   // 5

The reason it's a min-heap is eviction. When a new value arrives and the heap is over size, I want to throw out the weakest candidate, which is the smallest. A min-heap has it right on top. With a max-heap of everything I'd need O(n) space and k polls.

Compared to sorting, which is O(n log n), this wins when k is small. It also works on a stream, since I never need the whole array in memory. That's the answer when the interviewer says "what if the input doesn't fit in memory?"

If I have the whole array and want the best average, Quickselect is O(n) on average. It partitions like quicksort but only recurses into the side that contains index n - k. Its worst case is O(n²), so I'd shuffle or pick a random pivot.

Interviewers look for: O(n log k) versus O(n log n), and the streaming argument. Expect "can you do O(n)?" as the follow-up, which is Quickselect.

IntermediateHow do you make a max-heap in Java? What's wrong with (a, b) -> b - a?

So PriorityQueue is a min-heap by default, and I get a max-heap by passing a comparator. The safe way is Comparator.reverseOrder(). The subtraction lambda looks fine, but it overflows on large values and silently gives the wrong order.

Let me show you with a quick example.

PriorityQueue<Integer> bad = new PriorityQueue<>((a, b) -> b - a);
bad.offer(1);
bad.offer(Integer.MIN_VALUE);
bad.peek();    // -2147483648   wrong, should be 1

PriorityQueue<Integer> good = new PriorityQueue<>(Comparator.reverseOrder());
// or (a, b) -> Integer.compare(b, a)

What happens here is that comparing MIN_VALUE with 1 computes Integer.MIN_VALUE - 1, which wraps around to 2147483647, a positive number. So the comparator says the opposite of the truth. It passes every small test and then fails on real data.

For objects I build comparators with the helper methods, which also read much better:

record Task(String name, int priority) {}

PriorityQueue<Task> tasks = new PriorityQueue<>(
    Comparator.comparingInt(Task::priority).reversed()
              .thenComparing(Task::name));
// offer email(1), deploy(5), alert(5)
// poll order: alert(5), deploy(5), email(1)

One more gotcha: if I change a field that the comparator reads while the object is inside the heap, the heap doesn't notice. I remove it, update it, and re-insert it.

So in practice I never subtract in a comparator. Integer.compare or the Comparator factories every time.

IntermediateWhy is building a heap O(n) and not O(n log n)?

So calling offer n times is O(n log n). Heapify is faster because it works bottom-up. I treat the array as a tree already and sift down every parent, from the last parent at n/2 - 1 back to the root. Most nodes are near the bottom, and they barely move.

Let me show you with a quick example.

static void heapify(int[] a) {
    for (int i = a.length / 2 - 1; i >= 0; i--) siftDown(a, i, a.length);
}

static void siftDown(int[] a, int i, int n) {
    while (true) {
        int smallest = i, l = 2 * i + 1, r = 2 * i + 2;
        if (l < n && a[l] < a[smallest]) smallest = l;
        if (r < n && a[r] < a[smallest]) smallest = r;
        if (smallest == i) return;
        int t = a[i]; a[i] = a[smallest]; a[smallest] = t;
        i = smallest;
    }
}

int[] a = {9, 4, 7, 1, 2, 6, 3};
heapify(a);   // [1, 2, 3, 4, 9, 6, 7]

The intuition for the math: half the nodes are leaves and do zero work. A quarter sit one level up and move at most 1 step. An eighth move at most 2 steps, and so on. The sum n/4 · 1 + n/8 · 2 + n/16 · 3 + … converges to about n. Only the single root pays the full log n.

Inserting one by one is the opposite. Each new item is a leaf that might sift all the way up, and half the items are leaves, so it's O(n log n).

In Java, new PriorityQueue<>(collection) uses heapify, so it's O(n). The same step is phase one of heap sort.

AdvancedDesign a structure that returns the median of a stream of numbers.

So I use two heaps. A max-heap low holds the smaller half and a min-heap high holds the larger half. I keep their sizes equal or let low have one extra. Then the median is either the top of low or the average of the two tops.

Let me show you with a quick example.

class MedianFinder {
    private final PriorityQueue<Integer> low = new PriorityQueue<>(Comparator.reverseOrder());
    private final PriorityQueue<Integer> high = new PriorityQueue<>();

    void addNum(int x) {
        low.offer(x);
        high.offer(low.poll());                                  // largest of low moves across
        if (high.size() > low.size()) low.offer(high.poll());    // rebalance sizes
    }
    double findMedian() {
        return low.size() > high.size() ? low.peek() : (low.peek() + high.peek()) / 2.0;
    }
}

// add 5 -> 5.0
// add 2 -> 3.5
// add 8 -> 5.0
// add 1 -> 3.5

The reason I always route through low first and then pass its max to high is that it keeps the ordering rule for free. Every number in low stays less than or equal to every number in high, without me comparing against both tops by hand.

addNum is O(log n), just a few heap operations. findMedian is O(1), and space is O(n).

The obvious alternatives are worse. Sorting on every query is O(n log n). Insertion into a sorted list is O(n) per add.

If the interviewer adds "and remove old numbers" (a sliding window median), heaps struggle because remove(Object) is O(n). There I'd use two TreeMap multisets, or lazy deletion with a map of pending removals.

Interviewers look for: the invariant stated clearly. Follow-up: "what if all numbers are in 0 to 100?", where a count array gives O(100) median queries.

Part 3 — Trees & Graphs

12. Binary Trees

BasicWhat's the difference between a full, complete, perfect and balanced binary tree?

So these words describe the tree's shape, and they matter because they decide the height, and most tree operations cost O(height). Let me give you each in one line, then say why I care.

TermMeaningWhy it matters
FullEvery node has 0 or 2 children, never 1Shows up in expression trees and Huffman trees
CompleteEvery level full except maybe the last, which fills from the leftFits in an array with no gaps, which is how heaps work
PerfectAll internal nodes have 2 children and all leaves are on one levelExactly 2h+1 − 1 nodes
BalancedAt every node, left and right heights differ by at most 1Height stays O(log n)
DegenerateEvery node has one childReally a linked list, height n − 1

I also keep depth and height straight. Depth counts edges from the root down to a node, so the root has depth 0. Height counts edges from a node down to its deepest leaf, so a leaf has height 0. One gotcha: LeetCode's "maximum depth" counts nodes, not edges, so it's height + 1.

The one that matters most in practice is balanced versus degenerate. The same 1,000,000 keys give a height of about 20 in a balanced tree and 999,999 in a degenerate one. That's the whole reason self-balancing trees exist.

So when I state a complexity for a tree algorithm, I say O(h), and then add "which is O(log n) if balanced, O(n) worst case".

Interviewers look for: complete versus full, which people mix up. Every perfect tree is both full and complete, but not the other way round.

BasicWalk me through the tree traversals. When would you use each one?

So there are three depth-first orders, named by when I visit the root relative to its children, plus level order, which is breadth-first. They all visit every node once, so each is O(n) time and O(h) space for the recursion.

Let me show you with a quick example.

//        1
//       / \
//      2   3
//     / \   \
//    4   5   6

static void preorder(TreeNode n, List<Integer> out) {
    if (n == null) return;
    out.add(n.val);             // root first
    preorder(n.left, out);
    preorder(n.right, out);
}
// preorder   [1, 2, 4, 5, 3, 6]   root, left, right
// inorder    [4, 2, 5, 1, 3, 6]   left, root, right
// postorder  [4, 5, 2, 6, 3, 1]   left, right, root
// level      [[1], [2, 3], [4, 5, 6]]

Here's how I pick. Preorder meets the parent before its children, so I use it to copy or serialize a tree. Inorder on a binary search tree gives sorted order, so it's for anything "kth smallest" or validation. Postorder finishes the children first, so it's for anything where a node needs its children's answers: height, diameter, or deleting a tree. Level order is for "per level" questions, like right side view, and for shortest distance from the root.

In practice, most tree problems I solve are really postorder. I write a helper that returns something about the subtree, and the parent combines the left and right answers.

So the question I ask myself is: does the node need information from above (preorder, pass it down as a parameter) or from below (postorder, return it up)?

IntermediateWrite level order traversal that returns each level as its own list.

So this is BFS with a queue. The one trick is to freeze the queue's size at the start of each level. That tells me exactly how many nodes belong to the current level before I start adding their children.

Let me show you with a quick example.

static 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();              // freeze this level's size
        List<Integer> level = new ArrayList<>();
        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;
}
// tree 1 / (2: 4, 5) / (3: -, 6)  ->  [[1], [2, 3], [4, 5, 6]]

What happens here is that at the start of each outer loop, the queue holds exactly one full level. The inner loop polls those nodes and offers their children, which become the next level.

The bug I see most often is writing for (int i = 0; i < queue.size(); i++). The size keeps growing as I add children, so levels blur together. Save it in a variable first.

It's O(n) time. Space is O(w), where w is the maximum width, which can be about n/2 for the bottom level of a perfect tree.

The same skeleton answers many variants: right side view (last node per level), zigzag (reverse every other level), average per level, and minimum depth (return at the first leaf).

Interviewers look for: the frozen size. Follow-up: "can you do right side view with DFS?", which works by visiting right first and recording the first node at each depth.

IntermediateFind the diameter of a binary tree.

So the diameter is the number of edges on the longest path between any two nodes, and that path doesn't have to pass through the root. Every path has one highest node where it bends, and the longest path bending at a node is its left height plus its right height. I compute that at every node in one postorder pass.

Let me show you with a quick example.

static int best;

static int diameterOfBinaryTree(TreeNode root) {
    best = 0;
    height(root);
    return best;
}

// returns the number of nodes on the longest downward path
static int height(TreeNode node) {
    if (node == null) return 0;
    int left = height(node.left);
    int right = height(node.right);
    best = Math.max(best, left + right);      // longest path bending here, in edges
    return 1 + Math.max(left, right);
}

// 1 / (2: 4, 5) / (3: -, 6)   ->  4   (4-2-1-3-6)

This is the pattern I call "return one thing, track another". The helper returns what the parent needs, the height, but it tracks the global answer in a field. The parent can't use a path that bends below it, because a path can't fork, so the two values really are different things.

The naive approach calls a separate height() at every node, which is O(n²) on a skewed tree. This version visits each node once, so it's O(n) time and O(h) space.

So once I see this shape I reuse it. Binary Tree Maximum Path Sum is the same code with sums and Math.max(0, …) to drop negative branches.

AdvancedFind the lowest common ancestor of two nodes in a binary tree.

So the LCA is the deepest node that has both p and q in its subtree, where a node counts as its own descendant. My recursion returns "what I found in this subtree": p, q, their LCA, or null. The first node where both the left and right calls return something is where the paths split, so that's the answer.

Let me show you with a quick example.

static 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;
}

//        1
//       / \
//      2   3
//     / \   \
//    4   5   6
// LCA(4, 5) = 2    LCA(4, 6) = 1    LCA(2, 5) = 2

The subtle case is LCA(2, 5). When the recursion reaches 2, it returns immediately without looking below. That's correct, because if 5 is under 2, then 2 is the LCA, and if it weren't, the other branch would report 5 and the split would happen higher.

That shortcut relies on an assumption: both nodes exist in the tree. If one might be missing, the function would wrongly return the one it found. In that case I do a full traversal that counts how many targets were found.

It's O(n) time and O(h) space. If the tree is a BST I'd use the ordering instead and walk down from the root in O(h), no recursion into both sides.

Interviewers look for: the "both must exist" assumption. Other follow-ups: nodes with parent pointers (it becomes intersection of two linked lists), or many LCA queries (binary lifting).

AdvancedHow would you serialize a binary tree to a string and rebuild it?

So I use preorder with a marker for null children. Preorder alone can't rebuild a tree, because many shapes share the same order. The null markers tell the reader exactly where each subtree ends, so reading the tokens in the same order rebuilds the same shape.

Let me show you with a quick example.

static String serialize(TreeNode root) {
    StringBuilder sb = new StringBuilder();
    write(root, sb);
    return sb.toString();
}
static void write(TreeNode node, StringBuilder sb) {
    if (node == null) { sb.append("#,"); return; }
    sb.append(node.val).append(',');
    write(node.left, sb);
    write(node.right, sb);
}

static TreeNode deserialize(String data) {
    Iterator<String> tokens = Arrays.asList(data.split(",")).iterator();
    return read(tokens);
}
static TreeNode read(Iterator<String> tokens) {
    String t = tokens.next();
    if (t.equals("#")) return null;
    TreeNode node = new TreeNode(Integer.parseInt(t));
    node.left = read(tokens);
    node.right = read(tokens);
    return node;
}

// 1 / (2: 4, 5) / (3: -, 6)
// "1,2,4,#,#,5,#,#,3,#,6,#,#,"

The iterator is the important design choice. Both the left and right recursive calls consume from the same shared stream, so the left subtree eats exactly its tokens and the right call starts where it left off. No index math.

It's O(n) both ways. The string holds n values plus n + 1 null markers.

A level-order version also works and is what LeetCode displays, but it's more code. If the interviewer says "it's a BST", I can drop the null markers entirely: preorder alone is enough, because the value bounds tell me where each subtree ends.

So in real systems I'd probably just use JSON. But this question checks whether I understand why a traversal alone loses the shape.

13. Binary Search Trees

BasicWhat is a BST, and what's the complexity of search and insert?

So a binary search tree is a binary tree with one rule at every node: everything in the left subtree is smaller, and everything in the right subtree is larger. That lets me throw away half the remaining tree at each step, like binary search. Search and insert are O(h), where h is the height.

Let me show you with a quick example.

static TreeNode search(TreeNode node, int target) {
    while (node != null && node.val != target) {
        node = target < node.val ? node.left : node.right;
    }
    return node;   // null if not found
}

static TreeNode insert(TreeNode node, int val) {
    if (node == null) return new TreeNode(val);
    if (val < node.val) node.left = insert(node.left, val);
    else if (val > node.val) node.right = insert(node.right, val);
    return node;   // duplicates ignored
}

// insert 50, 30, 70, 20, 40, 60, 80
// inorder -> [20, 30, 40, 50, 60, 70, 80], height 2
// search(60) found, search(65) -> null

Insert is just a search that fails, then attaches a new leaf where the search fell off. Returning the node from the recursion is the neat part: the parent reassigns its child pointer, so I don't need a separate "parent" variable.

The important part is the "O(h)" wording. In a balanced tree h is about log n, so it's O(log n). But nothing in a plain BST keeps it balanced, so the worst case is O(n).

I also decide up front how to handle duplicates. Ignoring them makes it a set. For a multiset I'd store a count in the node rather than sending duplicates left or right, which messes up the ordering rule.

Interviewers look for: "O(h)" instead of a flat "O(log n)". Saying O(log n) without "if balanced" is the trap they're listening for.

BasicWhat happens if you insert already-sorted data into a BST?

So every new value is larger than everything before it, so it always goes right. The tree becomes a straight line, a degenerate tree that is really a linked list. Height becomes n − 1, and every operation drops to O(n).

Let me show you with a quick example.

TreeNode skew = null;
for (int v = 1; v <= 5; v++) skew = insert(skew, v);

// 1
//  \
//   2
//    \
//     3
//      \
//       4
//        \
//         5        height 4, not 2

// insert 1..10,000 in order -> height 9,999

What makes this nasty in real life is that sorted input is common, not rare. Auto-increment IDs, timestamps, and data loaded from a sorted file all hit the worst case. And with the recursive insert above, a big sorted input also gives a StackOverflowError, because the recursion depth equals the height.

There are three fixes I'd mention. Use a self-balancing tree, like red-black or AVL, which repairs its shape after every change. That's what TreeMap does. If I have all the data up front, I can build a balanced tree from the sorted array by always picking the middle. Or I can shuffle the input, since a random insertion order gives an expected height of O(log n).

So in practice I never use a hand-rolled plain BST in production. I use TreeMap or TreeSet, which guarantee O(log n).

IntermediateHow do you check whether a binary tree is a valid BST?

So the classic wrong answer is checking only that left.val < node.val < right.val at each node. The BST rule is about whole subtrees, not just children. The correct approach passes down a valid range: every node must sit strictly between a lower and an upper bound inherited from its ancestors.

Let me show you with a quick example.

static boolean isValidBST(TreeNode root) {
    return valid(root, Long.MIN_VALUE, Long.MAX_VALUE);
}
static 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 side: must stay below me
        && valid(node.right, node.val, high);    // right side: must stay above me
}

//   5
//    \
//     6
//    /
//   4
// local check: true  (6 > 5, 4 < 6)
// isValidBST:  false (4 is in 5's right subtree)

What happens here is that when I go right from 5, the lower bound becomes 5. When I then go left from 6, the upper bound becomes 6. So 4 must be in (5, 6), and it isn't.

I use long bounds on purpose. If I started with Integer.MIN_VALUE and MAX_VALUE, a tree containing Integer.MAX_VALUE would fail the >= check. Another clean option is Integer bounds with null meaning "no limit".

The alternative I also like is an inorder traversal that checks each value is strictly greater than the previous one. Same O(n) time and O(h) space.

Interviewers look for: the counterexample with the 4, and the Integer.MAX_VALUE edge case. Also whether duplicates are allowed, which changes <= to < on one side.

IntermediateHow do you delete a node from a BST?

So I first search for the node, then there are three cases. A leaf just disappears. A node with one child is replaced by that child. A node with two children is the interesting one: I copy in its inorder successor, the smallest value in its right subtree, and then delete that successor from the right subtree.

Let me show you with a quick example.

static TreeNode delete(TreeNode node, int key) {
    if (node == null) return null;
    if (key < node.val) node.left = delete(node.left, key);
    else if (key > node.val) node.right = delete(node.right, key);
    else {
        if (node.left == null) return node.right;    // leaf or only right child
        if (node.right == null) return node.left;    // only left child
        TreeNode succ = node.right;                  // two children:
        while (succ.left != null) succ = succ.left;  // smallest in right subtree
        node.val = succ.val;
        node.right = delete(node.right, succ.val);
    }
    return node;
}

// tree from 50, 30, 70, 20, 40, 60, 80
// delete 20 (leaf)          -> [30, 40, 50, 60, 70, 80]
// delete 30 (one child)     -> 40 takes its place
// delete 50 (two children)  -> 60 becomes the root

The reason the successor works is that it's the next value in sorted order. It's bigger than everything on the left and smaller than everything else on the right, so putting it at the node keeps the BST rule. And it has no left child by definition, so deleting it is always the easy case.

Notice the first two cases collapse into two lines. If the left is null I return the right, which is null for a leaf anyway.

It's O(h). The predecessor, the largest value on the left, works just as well.

So the thing I'm careful about is returning the node and reassigning child pointers all the way up. Forgetting node.right = … is the classic bug where the delete silently does nothing.

IntermediateFind the kth smallest element in a BST. What if the tree changes often?

So inorder traversal of a BST visits values in sorted order, so the kth node I visit is the answer. I do it iteratively with a stack so I can stop as soon as I hit k, without walking the rest of the tree.

Let me show you with a quick example.

static 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; }   // go left as far as possible
        cur = stack.pop();
        if (--k == 0) return cur.val;                              // kth in sorted order
        cur = cur.right;
    }
    throw new IllegalArgumentException("k is larger than the tree");
}

// tree from 50, 30, 70, 20, 40, 60, 80
// kthSmallest(root, 3) -> 40

What happens here is that I dive left, pushing the path, until there's nothing left. Popping gives the smallest remaining node. Then I move to its right subtree and repeat. The pops come out as 20, 30, 40, and I stop at the third one.

The cost is O(h + k): I pay the height to reach the first node, then roughly one step per pop. That's much better than collecting all n values into a list first.

For the follow-up, if the tree is updated often and I'm asked for kth many times, I'd store a size field in every node, the count of nodes in its subtree. Then at each node I compare k with left.size + 1 and go left, stop, or go right with k reduced. That's O(h) per query. It's called an order-statistic tree, and inserts and deletes just update sizes along the path.

Interviewers look for: the early stop, and the subtree-size idea for the follow-up. Java's TreeMap doesn't track subtree sizes, so this is custom.

AdvancedBuild a height-balanced BST from a sorted array.

So I pick the middle element as the root, then recursively build the left half into the left subtree and the right half into the right subtree. Everything before the middle is smaller and everything after is bigger, so the BST rule holds. Each split halves the range, so the tree stays balanced.

Let me show you with a quick example.

static TreeNode sortedArrayToBST(int[] nums) {
    return build(nums, 0, nums.length - 1);
}
static TreeNode build(int[] nums, int lo, int hi) {
    if (lo > hi) return null;
    int mid = lo + (hi - lo) / 2;
    TreeNode node = new TreeNode(nums[mid]);
    node.left = build(nums, lo, mid - 1);
    node.right = build(nums, mid + 1, hi);
    return node;
}

// [1, 2, 3, 4, 5, 6, 7]  -> root 4, height 2
//        4
//      /   \
//     2     6
//    / \   / \
//   1   3 5   7

At every level, the two halves differ in size by at most one, so left and right heights differ by at most one. That's exactly the height-balanced definition.

I write lo + (hi - lo) / 2 out of habit. It avoids overflow on huge indices, the same bug that once existed in the JDK's binary search.

It's O(n) time since each element becomes one node. Space is O(log n) for the recursion, plus the O(n) tree itself.

This is also the practical fix for the degenerate-BST problem. If I already have sorted data, I build balanced in one pass instead of inserting one by one. The harder variant is a sorted linked list. Finding the middle each time costs O(n log n), but I can get O(n) by building in inorder order while advancing a single list pointer.

14. Balanced Trees & TreeMap

BasicWhy do we need self-balancing trees? What does Java's TreeMap use?

So every BST operation costs O(height), and a plain BST can degrade to height n with sorted input. A self-balancing tree repairs its shape after every insert and delete, so the height always stays O(log n). That turns "O(h), maybe O(n)" into a guaranteed O(log n). TreeMap and TreeSet are red-black trees.

Let me show you the scale of it.

KeysBalanced height (about log2 n)Degenerate height
1,000about 10999
1,000,000about 20999,999
1,000,000,000about 30999,999,999

The way they repair themselves is with rotations. A rotation moves one node up and its parent down, in O(1), and it keeps the inorder order intact. Different trees just have different rules for when to rotate.

The reason this matters beyond interviews is worst-case guarantees. If a service stores keys from users, an attacker, or just a sorted import, can feed it sorted data. A self-balancing tree doesn't care about input order.

HashMap uses red-black trees too, since Java 8. When one bucket gets more than 8 entries (and the table has at least 64 buckets), that bucket is converted to a small red-black tree. That caps a bad bucket at O(log n) instead of O(n).

So in Java I never write a balanced tree myself. I use TreeMap or TreeSet, and in an interview I explain the idea and the rotations.

Interviewers look for: "red-black tree" by name, and the HashMap bucket treeification detail if it's a Java-heavy interview.

BasicHashMap vs TreeMap: when would you use each?

So HashMap is for fast lookup by exact key, O(1) on average, with no ordering at all. TreeMap keeps keys sorted, costs O(log n) for everything, and gives me ordered operations that a hash map simply can't do.

Let me show you with a quick comparison.

HashMapTreeMap
get / put / removeO(1) averageO(log n) guaranteed
Iteration orderNoneSorted by key
Min / max keyO(n) scanO(log n): firstKey, lastKey
"Closest key to x"Not possiblefloorKey, ceilingKey
Range queryNot possiblesubMap, headMap, tailMap
Key requirementhashCode and equalsComparable or a Comparator
null keyOne allowedNot with natural ordering

The trigger words for TreeMap are "closest", "next bigger", "previous smaller", "in a range", "sorted while changing", or needing both min and max while deleting arbitrary items, where a heap is weak.

One gotcha I've hit: TreeMap decides equality using the comparator, not equals. If my comparator says two different objects compare as 0, the map treats them as the same key and one overwrites the other.

A middle option is LinkedHashMap, which keeps insertion order at O(1). That's what I use for an LRU cache, not TreeMap.

So my default is HashMap, and I switch to TreeMap only when the problem needs order.

IntermediateExplain floorKey, ceilingKey, lowerKey and higherKey with a real use case.

So these are "nearest key" lookups, all O(log n). floor and ceiling include an exact match: the largest key <= x and the smallest key >= x. lower and higher are strict: the largest key < x and the smallest key > x. All return null when nothing qualifies.

Let me show you with a quick example, a pricing table where the price picks the plan tier.

TreeMap<Integer, String> prices = new TreeMap<>();
prices.put(100, "basic");
prices.put(250, "plus");
prices.put(500, "pro");
prices.put(1000, "enterprise");

prices.floorKey(300);      // 250   largest <= 300
prices.ceilingKey(300);    // 500   smallest >= 300
prices.floorKey(250);      // 250   equal counts
prices.lowerKey(250);      // 100   strictly less
prices.higherKey(250);     // 500   strictly greater
prices.floorKey(50);       // null  nothing below

prices.headMap(500);          // {100=basic, 250=plus}   keys < 500
prices.subMap(200, 600);      // {250=plus, 500=pro}     [200, 600)
prices.tailMap(500, false);   // {1000=enterprise}       keys > 500

Real places I've used this: mapping a timestamp to the config version active at that time (floorEntry), finding which IP range an address falls in, tier pricing like above, and consistent hashing, where I find the next server clockwise on the ring with ceilingEntry, wrapping to firstEntry.

My memory aid: floor and ceiling include equal, lower and higher don't. Methods ending in Key return just the key, and the Entry versions return key and value together, which saves a second lookup.

So one gotcha: floorKey returns an Integer, and assigning it to an int throws NullPointerException when nothing is found. I always null-check first.

Interviewers look for: the inclusive versus strict distinction, and that subMap is half-open by default.

IntermediateAVL tree vs red-black tree: what's the difference?

So both are self-balancing BSTs with O(log n) operations. AVL is strictly balanced: at every node the left and right heights differ by at most 1. Red-black is looser: it colours nodes and guarantees the longest path is at most twice the shortest. That trade makes AVL faster to search and red-black cheaper to update.

Let me show you with a quick comparison.

AVLRed-black
RuleHeight difference at most 1No red-red, same black count on every path
Max heightAbout 1.44 log nAbout 2 log n
LookupsSlightly faster (shorter)Slightly slower
Rotations per insertAt most 2At most 2
Rotations per deleteUp to O(log n)At most 3
Used byRead-heavy indexesJava TreeMap, C++ std::map, Linux scheduler

The red-black rules are: every node is red or black, the root is black, no red node has a red child, and every path from a node down to a null leaf has the same number of black nodes. The shortest possible path is all black and the longest alternates red and black, which gives the 2x bound.

Both use rotations to fix things. For AVL there are four cases. Left-left and right-right need one rotation. Left-right and right-left, the zig-zags, need two: first rotate the child to make it a straight line, then rotate the node.

So general-purpose libraries pick red-black, because inserts and deletes are cheaper and more predictable. I'd consider AVL only when the workload is mostly lookups.

IntermediateDesign a calendar that rejects overlapping bookings.

So I store bookings in a TreeMap from start to end. Because accepted bookings never overlap, sorting by start also sorts them by end. That means only two existing bookings can possibly clash with a new one: the one starting just before it and the one starting just after it.

Let me show you with a quick example.

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

    boolean book(int start, int end) {                                   // [start, end)
        Map.Entry<Integer, Integer> prev = bookings.floorEntry(start);    // starts at or before me
        Map.Entry<Integer, Integer> next = bookings.ceilingEntry(start);  // starts at or after me
        if (prev != null && prev.getValue() > start) return false;       // prev runs into me
        if (next != null && next.getKey() < end) return false;           // I run into next
        bookings.put(start, end);
        return true;
    }
}

// book(10, 20) true
// book(15, 25) false   overlaps [10, 20)
// book(20, 30) true    touching is fine, end is exclusive
// book(5, 10)  true
// book(8, 12)  false

What happens with book(8, 12): the floor of 8 is the booking at 5, which ends at 10. Since 10 is greater than 8, they overlap, so I reject it.

The naive version checks every existing booking, which is O(n) per call and O(n²) overall. This is O(log n) per booking.

The half-open interval [start, end) is a deliberate choice. It makes back-to-back meetings like 10–20 and 20–30 not count as a conflict, and the comparisons stay strict.

So when the interviewer extends it to "allow double booking but not triple", I switch to a sweep line: a TreeMap of time to +1/−1, where I add the event and walk the running sum.

AdvancedWhy do databases use B-trees instead of binary search trees?

So it comes down to the disk. Storage is read in pages, around 16 KB in InnoDB, and each page read costs far more than a memory access. A binary tree does one comparison per node, so looking up a key costs one page read per level. A B-tree packs hundreds of sorted keys into each node, so each page read cuts the search space by hundreds instead of by 2.

Let me show you the numbers for a billion keys.

TreeFan-outHeight for 109 keys
Balanced binary tree2About 30 levels, so about 30 page reads
B-treeAbout 500About 3 to 4 levels

And the top levels are small enough to stay cached in memory, so in practice a lookup is often just one or two actual disk reads.

Most databases use the B+ tree variant. The internal nodes hold only keys for routing, and all the actual rows or row pointers live in the leaves. The leaves are linked in sorted order. That link is what makes range queries like WHERE created_at BETWEEN … fast: find the first leaf, then scan sideways.

They stay balanced differently from red-black trees. Nodes split when they overflow and merge when they underflow, and the tree grows at the root, so all leaves are always at the same depth.

So this is also why index design matters. A composite index on (a, b) is sorted by a, then b, so it helps queries on a, or on a and b, but not on b alone.

Interviewers look for: "fewer, bigger nodes because of page I/O", and the linked leaves for range scans. The LSM tree, used by Cassandra and RocksDB, is the write-optimised alternative.

15. Tries

BasicWhat is a trie, and when would you use one over a HashSet?

So a trie, or prefix tree, stores strings letter by letter along paths from the root. Words that start the same way share their first nodes. Each node has children for the next possible letters and a flag that says "a word ends here".

Let me show you with a quick example.

insert "car", "card", "care"

root
 └ c
   └ a
     └ r  (end)
       ├ d  (end)
       └ e  (end)

search("car")      -> true   path exists and ends at a word
search("ca")       -> false  path exists but "ca" isn't a word
startsWith("ca")   -> true   path exists

The reason I'd pick a trie is prefix queries. "Does any word start with ca?" or "give me all words starting with ca" costs O(length of prefix) to find the spot. A HashSet would have to scan every word.

For exact lookups, a trie doesn't beat a HashSet. Both are O(L), since hashing a string also reads every character. And the trie uses much more memory, with one node object per character.

So I use a trie for autocomplete, spell-check suggestions, IP routing by longest prefix, and word games. It also shines when I search for many words while walking a structure, like a grid, because I can stop the moment the current path isn't a prefix of anything.

Interviewers look for: why you need the end flag, and an honest "a HashSet is fine for exact lookups."

BasicWhat's the complexity of insert, search and startsWith in a trie?

So all three are O(L), where L is the length of the word or prefix. The important point is that it doesn't depend on how many words are stored. Whether the trie holds 10 words or 10 million, looking up "card" takes four steps.

Let me show you with a quick breakdown.

OperationTimeExtra space
insert(word)O(L)Up to O(L) new nodes
search(word)O(L)O(1)
startsWith(prefix)O(L)O(1)
List all words with a prefixO(L + size of that subtree)Output size
Whole trieO(total characters) nodes in the worst case

With the array-of-26 design, each step is a direct index, children[c - 'a'], so it's truly constant per character. With a HashMap for children it's O(1) on average per character.

The space line is where tries hurt. If words share few prefixes, I get close to one node per character, and each node with a 26-slot array costs well over 100 bytes on a 64-bit JVM. So a million words can easily use hundreds of megabytes.

So in an interview I say "O(L) per operation, independent of n", then mention the memory cost before they ask.

IntermediateImplement a trie with insert, search and startsWith.

So each node holds an array of 26 children and an isEnd flag. Insert walks the word, creating nodes as needed, and marks the last one. Search and startsWith share the same walk. The only difference is whether I also require isEnd at the end.

Let me show you with a quick example.

class Trie {
    private static class Node {
        Node[] children = new Node[26];
        boolean isEnd;
    }
    private final Node root = new Node();

    void insert(String word) {
        Node node = root;
        for (char c : word.toCharArray()) {
            int i = c - 'a';
            if (node.children[i] == null) node.children[i] = new Node();
            node = node.children[i];
        }
        node.isEnd = true;
    }
    boolean search(String word) {
        Node node = walk(word);
        return node != null && node.isEnd;
    }
    boolean startsWith(String prefix) {
        return walk(prefix) != null;
    }
    private Node walk(String s) {
        Node node = root;
        for (char c : s.toCharArray()) {
            node = node.children[c - 'a'];
            if (node == null) return null;
        }
        return node;
    }
}

// insert car, card, care
// search("car") true, search("ca") false, startsWith("ca") true
// search("cards") false, startsWith("cat") false

Pulling out the shared walk helper is the design choice I'd point out. It removes duplicated loops and makes the difference between the two queries a single condition.

The bug I see most often is forgetting isEnd and returning true for "ca" just because the path exists. Another is assuming lowercase a to z without saying so. c - 'a' goes out of bounds on anything else.

So to support delete, I'd add a count of words passing through each node, decrement along the path, and prune children whose count hits zero.

Interviewers look for: stating the alphabet assumption. Common follow-ups: count words with a prefix (store a pass-through count) and wildcard search with . (DFS into all children at that position).

IntermediateShould trie children be an array or a HashMap?

So an array is faster and simpler when the alphabet is small and fixed, like lowercase English. A HashMap is better when the alphabet is large or unknown, like Unicode, URLs, or mixed case, because it only stores children that actually exist.

Let me show you with a quick example.

// array: fixed 26 slots per node
Node[] children = new Node[26];
node = node.children[c - 'a'];

// map: only real children, any character
class Node {
    Map<Character, Node> children = new HashMap<>();
    boolean isEnd;
}
node = node.children.computeIfAbsent(c, k -> new Node());

// map version inserting "héllo" works fine
// array version would throw ArrayIndexOutOfBoundsException on 'é'

Here's how I weigh it:

Array[26]HashMap
Step costDirect index, very fastHash plus boxing a Character
Memory per node26 references, even if mostly emptyOnly existing children, but map overhead
AlphabetMust be known and smallAnything
Children in sorted orderFree: iterate 0 to 25Needs a TreeMap

The sorted-order point matters more than people expect. For "autocomplete in alphabetical order" or "lexicographically smallest word", the array gives it for free.

For a huge dictionary where memory is the problem, I'd go further. A radix tree, also called a compressed trie, merges chains of single-child nodes into one edge labelled with a whole substring. That cuts the node count a lot.

So my default in interviews is the array, and I say "I'd switch to a map if the character set isn't fixed."

AdvancedGiven a letter grid and a list of words, find every word that appears in the grid.

So running a separate grid search for each word repeats the same work for words that share prefixes. Instead, I put all the words into a trie and do one DFS from each cell, walking the trie alongside the grid. The moment the current path isn't a prefix of any word, I stop.

Let me show you with a quick example.

static class TNode { TNode[] next = new TNode[26]; String word; }

static List<String> findWords(char[][] board, String[] words) {
    TNode root = new TNode();
    for (String w : words) {
        TNode node = root;
        for (char c : w.toCharArray()) {
            if (node.next[c - 'a'] == null) node.next[c - 'a'] = new TNode();
            node = node.next[c - 'a'];
        }
        node.word = w;                       // store the word at its end node
    }
    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;
}

static void dfs(char[][] board, int r, int c, TNode parent, List<String> found) {
    if (r < 0 || c < 0 || r >= board.length || c >= board[0].length) return;
    char ch = board[r][c];
    if (ch == '#' || parent.next[ch - 'a'] == null) return;   // used, or no word goes this way
    TNode node = parent.next[ch - 'a'];
    if (node.word != null) { found.add(node.word); node.word = null; }  // report once
    board[r][c] = '#';                                          // mark as used on this path
    dfs(board, r + 1, c, node, found);
    dfs(board, r - 1, c, node, found);
    dfs(board, r, c + 1, node, found);
    dfs(board, r, c - 1, node, found);
    board[r][c] = ch;                                           // backtrack
}

// board oaan / etae / ihkr / iflv, words oath, pea, eat, rain
// -> [oath, eat]

A few choices here I'd explain. Storing the full word at its end node means I don't need to build a string during the DFS. Setting it to null after finding it avoids duplicates when the same word appears twice in the grid. And overwriting the cell with # is my visited marker, restored when I backtrack.

The worst case is O(R · C · 4 · 3L−1), where L is the longest word: four directions on the first step, then at most three, since I can't go back. In practice the trie pruning makes it far faster.

So the extra optimisation for big inputs is pruning found leaves from the trie, so later searches don't walk dead branches.

AdvancedHow would you build autocomplete for millions of search terms?

So the core is a trie, but a naive "walk to the prefix node, then DFS its whole subtree and sort" is too slow for short prefixes like "a", which could have millions of matches. The fix is to precompute: each node caches its top k suggestions, so a query is just O(prefix length) to reach the node and O(k) to read the list.

Let me show you with a quick sketch.

class Node {
    Node[] children = new Node[26];
    List<String> top = new ArrayList<>();   // best k completions through this node
}

// build: insert terms sorted by popularity (or alphabetically),
// and while walking each term's path, add it to node.top
// if that list has fewer than k entries
void insert(String term, int k) {
    Node node = root;
    for (char c : term.toCharArray()) {
        int i = c - 'a';
        if (node.children[i] == null) node.children[i] = new Node();
        node = node.children[i];
        if (node.top.size() < k) node.top.add(term);
    }
}

// query: walk the prefix, return node.top. O(prefix + k).

Because I insert in ranked order, the first k terms that pass through a node are the best k for that prefix. That's the same trick as LeetCode's "search suggestions system", which inserts sorted products and caches three per node.

Now, at real scale I'd talk about the system around it. Memory: I'd use a compressed radix tree and store term IDs instead of strings. Updates: popularity changes, so I'd rebuild the trie offline periodically and swap it in, rather than updating live. Distribution: shard by the first character or two, and put a cache in front, since a small set of prefixes gets most of the traffic.

Plenty of production systems skip the hand-built trie and use a search engine's completion feature, like Elasticsearch's completion suggester, which uses a compact automaton (an FST).

Interviewers look for: caching top k per node instead of searching the subtree per query, and awareness of memory and update cost.

16. Graphs & Traversal

BasicHow would you represent a graph? Adjacency list or matrix?

So my default is an adjacency list: for each vertex, a list of its neighbours. It uses O(V + E) space, and BFS or DFS over it costs O(V + E). A matrix uses O(V²) space no matter how many edges exist, but it answers "is there an edge u to v?" in O(1).

Let me show you with a quick example.

static 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]);   // drop this line for a directed graph
    }
    return adj;
}

buildGraph(6, new int[][]{{0,1},{0,2},{1,3},{2,3},{3,4}});
// [[1, 2], [0, 3], [0, 3], [1, 2, 4], [3], []]
// node 5 has no edges, but it still exists
Adjacency listAdjacency matrix
SpaceO(V + E)O(V²)
Is there an edge u–v?O(degree of u)O(1)
List neighbours of uO(degree of u)O(V)
Best forSparse graphs, BFS/DFSDense graphs, small V, Floyd-Warshall

Most real graphs are sparse. A social network with a billion users, where each has a few hundred friends, would need 1018 cells as a matrix. So the list wins.

Two other forms come up a lot. An edge list, just an array of pairs, is often the input format, and it's what Kruskal's algorithm sorts. And implicit graphs, like grids or word ladders, where I never store edges at all and just generate neighbours on the fly.

So my first step in any graph problem is converting the input into an adjacency list, and remembering to create empty lists for isolated nodes.

BasicBFS vs DFS: how do they differ and when would you use each?

So BFS explores in rings, nearest nodes first, using a queue. DFS goes as deep as it can down one path, then backs up, using recursion or a stack. Both visit every reachable node once, so both are O(V + E). The difference is the order, and that decides which problems each one solves.

Let me show you with a quick example on the same graph.

// edges 0-1, 0-2, 1-3, 2-3, 3-4

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

// DFS from 0:            [0, 1, 3, 2, 4]  dives 0 -> 1 -> 3 -> 2, backs up, then 4
// BFS distances from 0:  [0, 1, 1, 2, 3]  rings: {0}, {1, 2}, {3}, {4}

Here's how I choose. BFS for shortest path in an unweighted graph, "minimum number of moves", or anything spreading level by level, like rotting oranges or friend-of-friend distance. DFS for exploring everything, counting connected components, cycle detection, topological sort, and backtracking, where I need the full current path.

Memory differs too. BFS holds a whole frontier, which can be huge in a wide graph. DFS holds one path, which is small unless the graph is a long chain, and then recursive DFS risks a stack overflow. In that case I use an explicit stack.

For plain "can I reach X" or "count the islands", either works, and I pick whichever I can write fastest.

So the one rule I never break: if the question says "shortest" or "minimum steps" and edges are unweighted, it's BFS. DFS does not give shortest paths.

Interviewers look for: the shortest-path rule, and the memory trade-off for wide versus deep graphs.

IntermediateWhy does BFS find the shortest path in an unweighted graph? Where do you mark nodes visited?

So the queue processes nodes in order of distance: every node at distance 1, then every node at distance 2, and so on. The first time I reach a node, I've reached it by the fewest edges possible, because any shorter route would have been processed earlier. And I mark a node visited when I add it to the queue, not when I remove it.

Let me show you with a quick example.

static int[] bfsDistances(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;
}

// edges 0-1, 0-2, 1-3, 2-3, 3-4, node 5 isolated
// bfsDistances(adj, 0) -> [0, 1, 1, 2, 3, -1]

Node 3 is the case I'd point at. Both 1 and 2 reach it. Because I mark it when 1 adds it, 2 skips it. If I marked on poll instead, 3 would go into the queue twice. The answer would still be right, but on a dense graph the queue can blow up to O(E) duplicate entries.

Using a dist array as the visited marker is a small habit I like. One array does two jobs, and unreachable nodes naturally stay at -1.

This breaks as soon as edges have weights. Then a path with more edges can be cheaper, so I need Dijkstra, which is BFS with a priority queue. For weights of only 0 and 1 there's 0-1 BFS with a deque.

Interviewers look for: "mark on enqueue". To rebuild the actual path, keep a parent array and walk back from the target.

IntermediateCount the number of islands in a grid of 1s and 0s.

So this is connected components on an implicit graph. Each land cell is a node, and edges go to its four neighbours. I scan every cell. When I find unvisited land, that's a new island, so I increment the count and flood-fill the whole island so I never count it again.

Let me show you with a quick example.

static 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++;                   // found an untouched island
                sink(grid, r, c);          // mark the whole island visited
            }
    return count;
}
static void sink(char[][] grid, int r, int c) {
    if (r < 0 || c < 0 || r >= grid.length || c >= grid[0].length || grid[r][c] != '1') return;
    grid[r][c] = '0';
    sink(grid, r + 1, c); sink(grid, r - 1, c);
    sink(grid, r, c + 1); sink(grid, r, c - 1);
}

// 11000
// 11000
// 00100
// 00011    -> 3

What happens here is that "sinking" land to '0' is my visited marker, so I don't need a separate boolean[][]. I'd mention that this mutates the input. If the caller needs the grid back, I'd use a visited array instead.

It's O(R × C) time, since each cell is sunk at most once and checked a constant number of times. Space is O(R × C) in the worst case for the recursion, when the whole grid is one snake-shaped island.

That worst case is real. A 1000 by 1000 grid of all land can recurse up to a million deep and overflow the stack. For big grids I'd switch the flood fill to BFS with a queue.

So the same skeleton solves max area of island, surrounded regions, and number of provinces. Union-Find is the alternative, and it's better when land gets added over time (Number of Islands II).

Interviewers look for: the stack-depth risk on large grids, and whether you ask before mutating the input.

IntermediateHow do you detect a cycle in a graph? Is it different for directed graphs?

So yes, the two cases need different checks. In an undirected graph, DFS finds a cycle if it reaches an already-visited node that isn't the parent I just came from. In a directed graph, "already visited" isn't enough. I need three colours, and a cycle exists only if I reach a node that is still on the current DFS path.

Let me show you with a quick example.

// undirected: skip the edge back to my parent
static boolean dfsUndirected(List<List<Integer>> adj, int u, int parent, boolean[] seen) {
    seen[u] = true;
    for (int v : adj.get(u)) {
        if (v == parent) continue;                  // the edge I came in on
        if (seen[v] || dfsUndirected(adj, v, u, seen)) return true;
    }
    return false;
}

// directed: 0 = unvisited, 1 = on current path, 2 = finished
static boolean dfsCycle(List<List<Integer>> adj, int u, int[] color) {
    color[u] = 1;
    for (int v : adj.get(u)) {
        if (color[v] == 1) return true;             // back edge to the current path
        if (color[v] == 0 && dfsCycle(adj, v, color)) return true;
    }
    color[u] = 2;
    return false;
}

// directed 0->1, 1->2, 0->2, 2->3   -> no cycle
// add 3->1                           -> cycle (1 -> 2 -> 3 -> 1)

The directed example shows why two states aren't enough. From 0, I visit 1, then 2, then 3. Back at 0, I try the edge to 2, which is already visited. With a plain visited set that looks like a cycle, but 2 is finished (black), so it's just a second route into the same place. Only a grey node, still on the stack, closes a loop.

Both run in O(V + E), and I wrap them in a loop over all vertices in case the graph is disconnected.

So for directed graphs I often use Kahn's algorithm instead: a BFS topological sort by in-degree. If it can't output every node, there's a cycle. That's the Course Schedule approach. For undirected graphs, Union-Find also works: an edge joining two nodes already in the same set closes a cycle.

Interviewers look for: the parent check for undirected, the grey state for directed, and an example where the naive check gives a false positive.

AdvancedEvery minute, rotten oranges rot their fresh neighbours. How long until all are rotten?

So this is multi-source BFS. Instead of starting from one node, I put every rotten orange in the queue at the start. BFS levels then equal minutes, and each fresh orange is reached at the minute it would actually rot. I count the fresh ones up front so I can tell if any are unreachable.

Let me show you with a quick example.

static int orangesRotting(int[][] grid) {
    int rows = grid.length, cols = grid[0].length, fresh = 0;
    Queue<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});   // every source at once
            else if (grid[r][c] == 1) fresh++;
        }
    int minutes = 0;
    int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}};
    while (!queue.isEmpty() && fresh > 0) {
        for (int i = queue.size(); i > 0; i--) {                // one level = one minute
            int[] cell = queue.poll();
            for (int[] d : dirs) {
                int r = cell[0] + d[0], c = cell[1] + d[1];
                if (r < 0 || c < 0 || r >= rows || c >= cols || grid[r][c] != 1) continue;
                grid[r][c] = 2;                                  // mark on enqueue
                fresh--;
                queue.offer(new int[]{r, c});
            }
        }
        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   bottom-left orange is cut off
// [[0,2]]                    -> 0    nothing to rot

Starting from all sources at once is like adding one imaginary "super source" joined to every rotten orange. That's why it's correct: each fresh orange is reached from its nearest rotten one.

The fresh > 0 condition in the loop matters. Without it, I'd count one extra minute for the last level, which spreads to nothing.

The naive approach runs a BFS from each rotten orange separately, which is O((R × C)²). This is O(R × C) time and space.

So the same pattern solves "walls and gates", "distance to nearest 0" (01 Matrix), and in real life, things like distance from every house to its nearest store.

Interviewers look for: seeding all sources at once, and the -1 case. Word Ladder is the other classic BFS here: words are nodes, and I generate neighbours by changing one letter at a time.

17. Union-Find (Disjoint Set)

BasicWhat is Union-Find, and what operations does it support?

So Union-Find, or Disjoint Set Union, tracks items split into groups that never overlap. It supports two operations: find(x), which returns the leader of x's group, and union(a, b), which merges two groups. Two items are connected exactly when they have the same leader.

Let me show you with a quick example. The whole thing is two int arrays.

class UnionFind {
    private final int[] parent, size;
    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; size[i] = 1; }
    }
    int find(int x) {
        while (parent[x] != x) {
            parent[x] = parent[parent[x]];   // path halving
            x = parent[x];
        }
        return x;
    }
    boolean union(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return false;          // already connected
        if (size[ra] < size[rb]) { int t = ra; ra = rb; rb = t; }
        parent[rb] = ra;                     // smaller tree under larger
        size[ra] += size[rb];
        components--;
        return true;
    }
    int components() { return components; }
}
// n = 5: union(0,1), union(3,4) -> find(1)==find(0) true, find(0)==find(3) false, components() = 3

What happens here is each group is a small tree, and the root points to itself. find walks up to the root and flattens the path as it goes. union links the two roots, never the original nodes.

I like returning a boolean from union. "False" means the two were already connected, and that is exactly the signal for cycle detection. The counter gives me "how many groups" for free.

Interviewers look for: linking roots, not nodes. parent[a] = b is the classic bug; it has to be parent[find(a)] = find(b).

BasicWhen would you use Union-Find instead of BFS or DFS for connectivity?

So if the graph is fixed and I'm asked one question about it, BFS or DFS is fine and simpler. I reach for Union-Find when edges arrive over time and I need an answer after each one, or when I only care about "same group or not", never the actual path.

Let me show you with a quick comparison.

SituationMy pickWhy
Static graph, count components onceEitherBoth O(V + E)
Edges added one by one, answer after eachUnion-FindDFS would restart each time: O(k(V + E))
Need the path, or the shortest pathBFS / DFSUnion-Find only knows the leader
Kruskal's MST, cycle check in undirected graphUnion-Find"Would this edge join two things already joined?"
Edges get removedBFS / DFSUnion-Find cannot split

The reason I'd pick Union-Find for the online case is that each new edge is one near-O(1) union, instead of a whole re-traversal. Number of Islands II is the classic example: land cells appear one at a time, and each new cell unions with its land neighbours.

So in practice I ask two questions: does the data change over time, and do I need paths? Changing plus no paths means Union-Find.

IntermediateExplain path compression and union by rank. What's the resulting complexity?

So without any tricks, Union-Find can degrade into a long chain and find becomes O(n). The two optimisations fix that from both sides. Union by size or rank keeps trees shallow when merging. Path compression flattens trees when reading.

Let me show you with the numbers.

VersionCost per operation
No tricksO(n) worst case
Union by size / rank onlyO(log n)
Path compression onlyO(log n) amortized
BothO(α(n)) amortized, effectively O(1)

Union by size means I always attach the smaller tree under the bigger root. A node only gets one level deeper when its tree at least doubles, so no tree is taller than log n. Path compression means that after find(x) discovers the root, every node on the way points straight at it, so that work is never repeated.

α is the inverse Ackermann function. It grows so slowly that it is at most 4 for any input that fits in the universe, which is why people call it "effectively constant".

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

One gotcha I've hit: the recursive version can go very deep on the first call before anything is compressed. On big inputs I use the iterative path-halving loop instead.

Interviewers look for: that you say "amortized". A single find can still be slow; it's the sequence of m operations that costs O(m α(n)).

IntermediateHow would you detect a cycle in an undirected graph using Union-Find?

So I process the edges one by one and union their endpoints. If an edge connects two nodes that already have the same root, there was already a path between them, so this edge closes a cycle.

Let me show you with a quick example.

static boolean hasCycle(int n, int[][] edges) {
    UnionFind uf = new UnionFind(n);
    for (int[] e : edges) {
        if (!uf.union(e[0], e[1])) return true;   // ends already connected
    }
    return false;
}
// {{0,1},{1,2},{2,0}} -> true      {{0,1},{1,2},{2,3}} -> false

What happens here is: edge 0-1 merges two singletons, edge 1-2 merges again, and now 0, 1 and 2 share a root. When 2-0 arrives, union sees the same root and returns false. That's the cycle.

This is exactly LeetCode's Redundant Connection: return the first edge where union fails. Time is O(E α(V)), space O(V).

The one thing I'd point out is that this only works for undirected graphs. In a directed graph, A to B and C to B is not a cycle, but Union-Find would say those nodes are "connected". For directed graphs I use DFS with three colours, or Kahn's topological sort.

Interviewers look for: the directed-graph caveat. It's the most common follow-up to this question.

IntermediateWalk me through Kruskal's algorithm. Where does Union-Find come in?

So Kruskal builds a minimum spanning tree greedily: sort all edges by weight, then take each edge in order unless it would create a cycle. Union-Find is the cycle check. An edge is safe exactly when its two endpoints are in different components.

Let me show you with a quick example.

static int kruskal(int n, int[][] edges) {        // edge = {u, v, weight}
    Arrays.sort(edges, Comparator.comparingInt(e -> e[2]));
    UnionFind uf = new UnionFind(n);
    int total = 0, used = 0;
    for (int[] e : edges) {
        if (uf.union(e[0], e[1])) {           // skip edges that would form a cycle
            total += e[2];
            if (++used == n - 1) break;        // tree is complete
        }
    }
    return used == n - 1 ? total : -1;         // -1: graph not connected
}
// edges {0-1:4, 0-2:1, 1-2:2, 1-3:5, 2-3:8}, n = 4 -> 8

Tracing it: the sorted order is 0-2 (1), 1-2 (2), 0-1 (4), 1-3 (5), 2-3 (8). I take 0-2 and 1-2. Then 0-1 fails because 0 and 1 already share a root, so I skip it. I take 1-3, and that's three edges for four nodes, so I stop. Total is 1 + 2 + 5 = 8.

Complexity is O(E log E) for the sort. The Union-Find part is nearly linear, so the sort dominates.

So in practice I pick Kruskal for sparse graphs given as an edge list, and Prim's with a heap for dense graphs given as an adjacency list.

AdvancedWhat if the items aren't 0..n-1 integers, or you need to undo a union?

So for arbitrary keys like emails or strings, I either map each key to an integer id first, or I back the parent array with a HashMap and create entries lazily. Undo is a different story. Path compression rewrites the tree, so I can't cleanly reverse it. For undo I drop compression and keep only union by size, then record each union on a stack.

Let me show you the map-based version.

class DSU<T> {
    private final Map<T, T> parent = new HashMap<>();
    T find(T x) {
        parent.putIfAbsent(x, x);                  // lazily create new items
        T p = parent.get(x);
        if (!p.equals(x)) { p = find(p); parent.put(x, p); }  // path compression
        return p;
    }
    void union(T a, T b) { parent.put(find(a), find(b)); }
}
// union("a@x.com","b@x.com"), union("c@x.com","b@x.com")
// find(a) == find(c) -> true,  find(a) == find("z@x.com") -> false

This is handy for Accounts Merge. The trade-off is that a HashMap is several times slower than an int array, so if I know all the keys up front I compress them to ids first.

For rollback, the rule is: union by size only, and each union pushes "rb's parent was rb, ra's size was s" onto a stack. Undo pops and restores. Without compression, find is O(log n) instead of near O(1). That variant shows up in offline dynamic connectivity, where edges are added and removed.

Interviewers look for: that you know plain Union-Find can't delete, and why path compression is what blocks undo.

18. Segment Trees & Fenwick Trees

BasicWhat problem does a segment tree solve? Why not just use a prefix sum array?

So a segment tree answers range queries, like sum, min or max over [l, r], while the array is also being updated. Both operations cost O(log n). A prefix sum array gives O(1) queries, but a single update invalidates every prefix after it, so updates cost O(n).

Let me show you with a quick comparison.

ApproachPoint updateRange queryq mixed operations
Plain arrayO(1)O(n)O(q n)
Prefix sumsO(n)O(1)O(q n)
Segment tree / FenwickO(log n)O(log n)O(q log n)

What happens with the tree is that every node stores the answer for a slice of the array. The root covers everything, and the leaves are single elements. A query stitches together a few stored slices. An update only fixes the log n ancestors of one leaf.

With n and q both around 105, the first two rows are about 1010 operations, which times out. The tree is about 1.7 × 106.

So my rule is: if the array never changes, I use prefix sums. It's simpler and faster. When I see "update" and "range query" in the same problem, I think segment tree or Fenwick tree.

IntermediateWalk me through a segment tree range query. Why is it O(log n), and why allocate 4n?

So a query has three cases at each node. No overlap: return the neutral value. Full overlap: return the stored answer without going deeper. Partial overlap: ask both children and combine.

Let me show you with a range-minimum tree.

class MinSegTree {
    private final int n;
    private final int[] tree;
    MinSegTree(int[] a) { n = a.length; tree = new int[4 * n]; build(a, 1, 0, n - 1); }
    private void build(int[] a, int node, int lo, int hi) {
        if (lo == hi) { tree[node] = a[lo]; return; }
        int mid = (lo + hi) / 2;
        build(a, 2 * node, lo, mid);
        build(a, 2 * node + 1, mid + 1, hi);
        tree[node] = Math.min(tree[2 * node], tree[2 * node + 1]);
    }
    int query(int l, int r) { return query(1, 0, n - 1, l, r); }
    private int query(int node, int lo, int hi, int l, int r) {
        if (r < lo || hi < l) return Integer.MAX_VALUE;      // no overlap: neutral
        if (l <= lo && hi <= r) return tree[node];            // fully inside
        int mid = (lo + hi) / 2;                              // partial: split
        return Math.min(query(2 * node, lo, mid, l, r),
                        query(2 * node + 1, mid + 1, hi, l, r));
    }
}
// {5, 2, 8, 6, 3, 7}: query(0,5) = 2, query(2,4) = 3, query(2,3) = 6

Why it's O(log n): on any level of the tree, at most two nodes are partially covered, the ones holding the left and right edges of the range. Everything strictly between them is a full overlap and returns immediately. So I visit a constant number of nodes per level, times log n levels.

Why 4n: the tree is stored like a heap, with children at 2k and 2k + 1. When n isn't a power of two, the tree isn't perfectly packed, and the highest index can go past 2n. 4n is a safe bound for every n.

Interviewers look for: the neutral value. For min it's Integer.MAX_VALUE, not 0. Returning 0 is a silent wrong answer.

IntermediateHow does a Fenwick tree work? What does i & -i do?

So a Fenwick tree, or Binary Indexed Tree, is a single 1-indexed array where bit[i] stores the sum of a block of elements ending at i. The block length is the lowest set bit of i. i & -i isolates that lowest set bit. In two's complement, -i flips every bit and adds one, so it matches i only at that bit. For example, 12 & -12 is 4, 6 & -6 is 2, and 7 & -7 is 1.

Let me show you the whole thing. It's about ten lines.

class FenwickTree {
    private final long[] bit;                  // 1-indexed, bit[0] unused
    FenwickTree(int n) { bit = new long[n + 1]; }
    void add(int i, long delta) {              // position i += delta
        for (; i < bit.length; i += i & -i) bit[i] += delta;
    }
    long prefixSum(int i) {                    // sum of 1..i
        long s = 0;
        for (; i > 0; i -= i & -i) s += bit[i];
        return s;
    }
    long rangeSum(int l, int r) { return prefixSum(r) - prefixSum(l - 1); }
}
// values 2,1,5,3,4 at 1..5: prefixSum(3) = 8, rangeSum(2,4) = 9; add(3, 5): rangeSum(2,4) = 14

What happens in prefixSum(7) is: add bit[7], strip the low bit to get 6, add bit[6], strip to 4, add bit[4], stop at 0. Three blocks cover 1..7. add goes the other way and climbs to every block that contains i. Both loops run at most about log n times.

One gotcha I've hit: calling add(0, x) loops forever, because 0 & -0 is 0. So I always shift 0-indexed input by one.

IntermediateSegment tree or Fenwick tree: which would you pick?

So for sums or counts with point updates, I pick Fenwick. It's ten lines, uses n + 1 memory, and is hard to get wrong. For min or max over arbitrary ranges, range updates, or anything custom, I pick a segment tree.

Let me show you the trade-offs.

Segment treeFenwick tree
Code size~30 lines, easy to slip~10 lines
Memory4nn + 1
OperationsAny associative op: sum, min, max, gcd, custom structsOps with an inverse (sum, xor, count), since ranges come from two prefixes
Range updatesYes, with lazy propagationRange add + point query, or two BITs for range add + range sum

The key limitation of Fenwick is that it only knows prefixes. A range sum works because sum(l..r) = prefix(r) - prefix(l-1). You can't "subtract" a min, so range min over any [l, r] needs a segment tree.

Fenwick does have one trick a segment tree makes awkward: it doubles as a frequency table indexed by value. That's why "count inversions" and "how many smaller elements have I seen so far" are almost always written with a BIT.

And if the array never changes, neither. Prefix sums for sums, a sparse table for range min. So in an interview I say the rule out loud, then write whichever one the operation needs.

Interviewers look for: the "needs an inverse" reason. Saying "Fenwick can't do min" without the why sounds memorised.

AdvancedWhat is lazy propagation, and when do you need it?

So lazy propagation lets a segment tree do range updates, like "add 10 to every element in [l, r]", in O(log n) instead of O(n). When an update fully covers a node, I update that node's sum right away and leave a note saying "my children still owe +10". I only push that note down when a later operation actually needs to go below that node.

Let me show you the core of it, for range add and range sum.

void apply(int node, int lo, int hi, long v) {
    sum[node] += v * (hi - lo + 1);       // whole segment grows by v each
    lazy[node] += v;                      // owe this to the children
}
void push(int node, int lo, int hi) {
    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;
}
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
    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];
}
// {2, 1, 5, 3, 4}, add 10 to [1..3]: sum(0,4) = 45, sum(2,2) = 15

The reason it stays O(log n) is that an update touches the same nodes a query would. It stops at fully covered nodes and never walks their subtrees.

The bug I see most is forgetting to call push in the query path too. Then a query reads children that never received the pending add. I also use long, because v * length overflows int quickly.

Interviewers look for: "push before you go down, pull after you come back up". That's the whole invariant.

AdvancedHow would you count inversions, or "smaller numbers after self", with a Fenwick tree?

So I use the Fenwick tree as a frequency table indexed by value, not by position. I scan the array once. At each element I ask "how many values already seen are bigger than me?" That's a prefix-sum query. Then I add myself to the table. If values are large or negative, I coordinate-compress them to ranks 1..m first.

Let me show you with a quick example.

static long countInversions(int[] nums) {
    int[] sorted = Arrays.stream(nums).distinct().sorted().toArray();
    FenwickTree fw = new FenwickTree(sorted.length);
    long inversions = 0;
    for (int i = 0; i < nums.length; i++) {
        int rank = Arrays.binarySearch(sorted, nums[i]) + 1;   // 1..m
        inversions += i - fw.prefixSum(rank);   // seen so far that are bigger
        fw.add(rank, 1);
    }
    return inversions;
}
// {8, 4, 2, 1} -> 6    {2, 4, 1, 3, 5} -> 3    {3, 3, 1} -> 2

What happens here is that at index i I've seen i elements. prefixSum(rank) counts how many of them are <= me, so i minus that is how many are strictly bigger. Each of those forms an inversion with me. Equal values are not counted, which is correct.

Time is O(n log n) for the sort plus n log n for the tree. Space is O(n), and the answer goes in a long, because n = 105 can give around 5 × 109 inversions.

"Count of smaller numbers after self" is the mirror image: scan right to left and query prefixSum(rank - 1). Merge sort solves the same thing, but the BIT version is easier to adapt to online queries.

Part 4 — Algorithms

19. Sorting

BasicWhat does it mean for a sort to be stable, and when does it matter?

So a stable sort keeps equal elements in the order they had before sorting. It matters whenever the elements carry more than the sort key. The classic case is sorting by one field after already sorting by another, where I want the first ordering kept as the tie-breaker.

Let me show you with a quick example.

record Order(String id, String status, int amount) {}

List<Order> orders = new ArrayList<>(List.of(
    new Order("A", "PAID", 50), new Order("B", "NEW", 20),
    new Order("C", "PAID", 20), new Order("D", "NEW", 50)));

orders.sort(Comparator.comparingInt(Order::amount));   // first by amount
orders.sort(Comparator.comparing(Order::status));      // then by status
// B D C A: NEW before PAID, and inside each status still by amount

What happens here is that List.sort is stable, so after the second sort B (20) still comes before D (50) among the NEW orders. With an unstable sort, the amount order inside each status could come out scrambled. This is exactly what happens when a user clicks one table column and then another.

StableNot stable
Merge sort, insertion sort, TimSort, counting sort, radix sortQuicksort, heap sort, selection sort

So in practice, if I control the comparator, I'd rather write one chained comparator, comparing(status).thenComparingInt(amount). That doesn't depend on stability at all. Stability matters most when sorting happens in stages or in someone else's code.

Interviewers look for: a real use case (multi-column sort, keeping arrival order for ties), not only the definition.

BasicCompare merge sort and quicksort.

So both are O(n log n) on average. Merge sort guarantees it and is stable, but needs O(n) extra memory. Quicksort is in-place and usually faster in practice, but it isn't stable and has an O(n²) worst case.

Let me show you side by side.

Merge sortQuicksort
Average / worstO(n log n) / O(n log n)O(n log n) / O(n²)
Extra spaceO(n) bufferO(log n) stack expected
StableYesNo
Where the work isCombine step (merge)Split step (partition)
Best fitLinked lists, external sorting, stable object sortsIn-memory primitive arrays

The reason quicksort wins on arrays in practice is cache behaviour. Partitioning scans memory sequentially and swaps in place, and there's no copying to a buffer and back. Merge sort's buffer doubles the memory traffic.

But merge sort is the natural choice for linked lists. Splitting and merging are just pointer changes, so no buffer is needed, and it doesn't need random access. It's also the basis of external sorting, where I sort chunks that fit in memory and then k-way merge files from disk.

So I'd say: quicksort for raw speed on arrays, merge sort when I need a guarantee, stability, or I'm sorting something that isn't an array.

IntermediateWhat is quicksort's worst case, and how do you avoid it?

So the worst case is O(n²). It happens when the pivot is always the smallest or largest element, so every partition peels off just one element. The textbook trigger is picking the last element as pivot on an array that's already sorted.

Let me show you with a quick example. I counted comparisons with a "last element" pivot on sorted input.

static void naiveQuick(int[] a, int lo, int hi) {
    if (lo >= hi) return;
    int pivot = a[hi], i = lo;                 // always the LAST element
    for (int j = lo; j < hi; j++) { comparisons++; if (a[j] < pivot) swap(a, i++, j); }
    swap(a, i, hi);
    naiveQuick(a, lo, i - 1);
    naiveQuick(a, i + 1, hi);
}
// sorted input, n = 1,000:  499,500 comparisons   (n(n-1)/2)
// sorted input, n = 10,000: 49,995,000 comparisons

What happens here is that 10× the input gives 100× the work. That's the quadratic signature. Recursion depth also becomes n, so a big enough input throws StackOverflowError as well.

How I avoid it:

  • Random pivot (or median-of-three). Then no fixed input is bad, and the expected time is O(n log n).
  • Three-way partition (<, =, >) for many duplicates. Otherwise an all-equal array is also quadratic.
  • Recurse on the smaller side, loop on the larger. That caps the stack at O(log n).
  • Introsort fallback: if the depth passes about 2 log n, switch to heap sort. That's a hard O(n log n) guarantee, and it's what C++ std::sort does and what recent JDKs do for primitives.

Interviewers look for: the duplicates case. Many candidates fix sorted input with a random pivot and forget that all-equal keys break two-way partitioning too.

IntermediateWhat sort does Java's Arrays.sort use, and why is it different for primitives and objects?

So for primitive arrays like int[], Java uses dual-pivot quicksort, with insertion sort for tiny ranges and a heap sort fallback in modern JDKs. For objects, meaning Arrays.sort(T[]), Collections.sort and List.sort, it uses TimSort, which is a stable merge-sort hybrid. The reason is stability.

Let me show you the breakdown.

CallAlgorithmGuarantees
Arrays.sort(int[]) (any primitive)Dual-pivot quicksortO(n log n), in-place, not stable
Arrays.sort(T[]), List.sort, Collections.sortTimSortStable, O(n log n) worst, O(n) on already-sorted runs, O(n) extra memory
Arrays.parallelSortParallel merge sort (fork/join)Worth it only for large arrays

Two equal ints are indistinguishable. You can't tell which 5 came first, so stability means nothing, and Java picks the fastest in-place option. Two "equal" objects can differ in every other field, like two employees with the same salary. Users expect the earlier order to survive, so objects get a stable sort. The extra memory is acceptable there, because the array only holds references.

TimSort is also tuned for real data, which is often partly sorted. It finds existing runs and merges them, so sorted input costs O(n).

One gotcha I've hit: a comparator written as (a, b) -> a - b. Subtraction overflows.

Integer[] boxed = {Integer.MIN_VALUE, 1, 0};
Arrays.sort(boxed, (x, y) -> x - y);       // [0, 1, -2147483648]  wrong
Arrays.sort(boxed, Integer::compare);      // [-2147483648, 0, 1]  right

Interviewers look for: the stability reason, stated plainly. Bonus points for knowing TimSort throws "Comparison method violates its general contract" when your comparator isn't transitive.

IntermediateCan you sort faster than O(n log n)?

So not with a comparison sort. Any algorithm that only asks "is a < b?" needs Ω(n log n) comparisons in the worst case. But if I can look at the values themselves, and they're integers in a small range or fixed-width keys, counting sort and radix sort run in linear time.

Let me show you with a quick example.

static int[] countingSort(int[] a, int k) {    // values in [0, k]
    int[] count = new int[k + 1];
    for (int x : a) count[x]++;
    int[] out = new int[a.length];
    int idx = 0;
    for (int v = 0; v <= k; v++)
        while (count[v]-- > 0) out[idx++] = v;
    return out;
}
// {4, 2, 2, 8, 3, 3, 1}, k = 8 -> [1, 2, 2, 3, 3, 4, 8]    O(n + k)

Why the lower bound holds: there are n! possible orderings of the input. Each comparison has two outcomes, so after c comparisons I can tell apart at most 2c cases. I need 2c ≥ n!, so c ≥ log2(n!), which is about n log n.

When linear sorts fit:

  • Counting sort: small range k, like ages, grades or chars. O(n + k). Useless if k is 109.
  • Radix sort: a stable counting sort per digit. O(d(n + b)). Good for 32-bit ints or phone numbers.
  • Bucket sort: floats spread evenly over a known range. O(n) on average, O(n²) if everything lands in one bucket.

So in practice, if the problem says "values are between 0 and 100" or "lowercase letters only", I think counting array first.

AdvancedFind the k-th largest element. Would you sort, use a heap, or use quickselect?

So all three work. Sorting is O(n log n) and the simplest. A min-heap of size k is O(n log k), with a guaranteed bound and it works on a stream. Quickselect is O(n) expected, but it's O(n²) in the worst case and it reorders the array. In an interview I usually offer the heap first and then quickselect as the optimisation.

Let me show you both.

static int kthLargestHeap(int[] nums, int k) {
    PriorityQueue<Integer> heap = new PriorityQueue<>();   // min-heap of the k largest
    for (int x : nums) {
        heap.offer(x);
        if (heap.size() > k) heap.poll();                  // drop the smallest
    }
    return heap.peek();
}

static int findKthLargest(int[] nums, int k) {
    int target = nums.length - k;              // index in sorted order
    int lo = 0, hi = nums.length - 1;
    while (lo < hi) {
        int p = partition(nums, lo, hi);       // random-pivot Lomuto
        if (p == target) return nums[p];
        if (p < target) lo = p + 1; else hi = p - 1;
    }
    return nums[lo];
}
// {3,2,1,5,6,4}, k=2 -> 5      {3,2,3,1,2,4,5,5,6}, k=4 -> 4   (both methods)

What happens in quickselect is that the pivot lands at its final sorted index p. If that's the target I'm done. Otherwise the answer is only on one side, so I recurse into that side and throw the other away. The expected work is n + n/2 + n/4 + ..., which is about 2n.

The heap keeps the k largest seen so far, and its top is the smallest of them, which is the k-th largest.

MethodTimeSpaceNotes
SortO(n log n)O(1) to O(n)Simplest
Min-heap size kO(n log k)O(k)Streaming, guaranteed
QuickselectO(n) expectedO(1)Mutates input

Interviewers look for: "what if the data doesn't fit in memory?" The answer is the heap. It only ever holds k elements.

20. Binary Search

BasicWrite binary search. What are the common bugs?

So binary search looks at the middle of a sorted range, decides which half the target must be in, and throws the other half away. That makes it O(log n): a million elements take about 20 steps. The code is short, but almost every bug is at the boundaries.

Let me show you with a quick example.

static int binarySearch(int[] a, int target) {
    int lo = 0, hi = a.length - 1;             // closed range [lo, hi]
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;          // no overflow
        if (a[mid] == target) return mid;
        if (a[mid] < target) lo = mid + 1;
        else hi = mid - 1;
    }
    return -1;
}
// {1, 3, 5, 7, 9, 11}: search 7 -> 3, search 4 -> -1

The bugs I watch for:

  • (lo + hi) / 2 overflow. With lo = 2,000,000,000 and hi = 2,100,000,000, the sum wraps and gives -97483648. lo + (hi - lo) / 2 gives 2050000000.
  • Mixing styles. A closed range [lo, hi] goes with lo <= hi and hi = mid - 1. A half-open range [lo, hi) goes with lo < hi and hi = mid. Mixing them gives infinite loops or missed elements.
  • lo = mid with a rounded-down mid loops forever when hi = lo + 1.

So I pick one style per problem and say the invariant out loud: "if the target exists, it's inside [lo, hi]".

Interviewers look for: the overflow fix without prompting. It's a famous bug that sat in the JDK's own binary search for years.

IntermediateHow do you find the first and last position of a target in a sorted array with duplicates?

So I use a lower-bound search: find the first index where a[i] >= target. That's the first occurrence. The last occurrence is one before the first index where a[i] > target. Two O(log n) searches, and no linear scan over the duplicates.

Let me show you with a quick example.

static int lowerBound(int[] a, int target) {   // first i with a[i] >= target
    int lo = 0, hi = a.length;                 // half-open [lo, hi)
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] >= target) hi = mid;        // mid might be the answer
        else lo = mid + 1;
    }
    return lo;
}
static int[] searchRange(int[] a, int t) {
    int first = lowerBound(a, t);
    if (first == a.length || a[first] != t) return new int[]{-1, -1};
    int last = lowerBound(a, t + 1) - 1;       // first index > t, minus one
    return new int[]{first, last};
}
// {5, 7, 7, 8, 8, 10}, 8 -> [3, 4]     target 6 -> [-1, -1]

What happens here is that when I find an 8, I don't stop. I keep it as a candidate with hi = mid and keep looking left. The loop ends when lo == hi, and that's the boundary.

Why not find any 8 and walk outward? If the whole array is 8s, that walk is O(n).

The t + 1 trick works for ints unless t is Integer.MAX_VALUE. In that case I write a separate upper bound with >. Also note that Java's Arrays.binarySearch returns some matching index, not necessarily the first.

Interviewers look for: that this one template also gives insert position, count of t, floor and ceiling. It's the "first true" pattern.

IntermediateSearch in a rotated sorted array. Walk me through it.

So in an array like [4, 5, 6, 7, 0, 1, 2], if I cut at any mid, at least one half is normally sorted. I check which half that is, and whether the target falls inside its range. If it does, I go there. If not, it can only be in the other half. Still O(log n).

Let me show you with a quick example.

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 sorted
            if (a[lo] <= target && target < a[mid]) hi = mid - 1;
            else lo = mid + 1;
        } else {                                           // right half 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

Tracing target 0: lo=0, hi=6, mid=3 with value 7. Since a[0]=4 <= 7, the left half is sorted. Is 0 in [4, 7)? No, so lo=4. Now mid=5 with value 1. a[4]=0 <= 1, so the left half [0, 1] is sorted. Is 0 in [0, 1)? Yes, so hi=4. Then mid=4 finds 0.

The <= in a[lo] <= a[mid] matters. When lo == mid, the "left half" is a single element and counts as sorted.

So the key idea I'd say out loud is that binary search needs a rule that tells me which half keeps the answer. It doesn't need a fully sorted array.

Interviewers look for: the follow-up "what if there are duplicates?" With [1, 1, 1, 3, 1] you can't tell which side is sorted, so you shrink with lo++, and the worst case becomes O(n).

IntermediateWhat is "binary search on the answer"? Give an example.

So instead of searching an array, I search the range of possible answers. It works when there's a yes/no check that's monotonic: once some value works, every bigger value also works. Then the answers look like F F F T T T, and I binary search for the first T. The signal words are "minimum speed", "smallest capacity" or "minimise the maximum".

Let me show you with Koko Eating Bananas: find the slowest speed k that finishes all piles within h hours.

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 (hours(piles, k) <= h) hi = k;       // fast enough: try slower
        else lo = k + 1;                        // too slow
    }
    return lo;
}
static long hours(int[] piles, int k) {
    long total = 0;
    for (int p : piles) total += (p + k - 1) / k;   // ceil(p / k)
    return total;
}
// {3,6,7,11}, h=8 -> 4     {30,11,23,4,20}, h=5 -> 30     h=6 -> 23

What happens here is the range is [1, max pile]. Speed 1 might be too slow, and max pile always finishes in n hours. Each check is O(n), and I do log(max) of them, so the total is O(n log M).

The same shape solves Capacity to Ship Packages, Split Array Largest Sum, and Minimum Days to Make Bouquets. Only the check function changes.

One gotcha I've hit is summing hours in an int. With big piles and speed 1, it overflows, so I use long.

AdvancedCan binary search work on an unsorted array? When exactly is it valid?

So yes. Binary search needs a rule that, looking only at mid, guarantees which half still contains an answer. Sortedness is one such rule, but not the only one. Find Peak Element is the classic example on unsorted data.

Let me show you with a quick example.

static int findPeak(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: peak on the right
        else hi = mid;                           // downhill: peak at mid or left
    }
    return lo;
}
// {1, 2, 3, 1} -> 2      {1, 2, 1, 3, 5, 6, 4} -> 5

Why it works: if a[mid] < a[mid + 1], I'm walking uphill to the right. Either it keeps rising to the end, and the last element is a peak because the edges count as minus infinity, or it drops somewhere, and that turn is a peak. Either way the right side has one. The invariant is "a peak exists in [lo, hi]".

So the general condition is a monotonic predicate over the index or value space: F F F T T T. Before coding, I write the F/T pattern out for a small example. If it looks like F T F T, binary search will give random wrong answers.

Note it finds a peak, not the highest one. Finding the global maximum of an unsorted array still needs O(n).

Interviewers look for: the invariant stated in words, and the distinction between "a peak" and "the maximum".

AdvancedHow would you binary search when you don't know the size of the data, like a huge or unbounded sorted stream?

So I use exponential search, also called galloping. I probe indexes 1, 2, 4, 8, and so on until I hit a value >= target or run past the end. That gives me a bracket [hi/2, hi] that must contain the target. Then I do a normal binary search inside it. Both phases are O(log p), where p is the target's position, not the total size.

Let me show you with a quick example. Here the reader returns Integer.MAX_VALUE past the end, like LeetCode's ArrayReader.

static int searchUnknownSize(IntUnaryOperator get, int target) {
    int hi = 1;
    while (get.applyAsInt(hi) < target) hi *= 2;    // gallop: 1, 2, 4, 8, ...
    int lo = hi / 2;
    while (lo <= hi) {                              // normal search in [hi/2, hi]
        int mid = lo + (hi - lo) / 2;
        int v = get.applyAsInt(mid);
        if (v == target) return mid;
        if (v < target) lo = mid + 1; else hi = mid - 1;
    }
    return -1;
}
// data = 0, 3, 6, ..., 2997 (1000 items): 2997 -> 999, 300 -> 100, 301 -> -1, 0 -> 0

The same idea shows up in real systems. Searching a sorted log file by timestamp, I don't read the whole file. I seek to byte offsets, and I can binary search offsets directly because I know the file size. Databases do the same with B-tree pages, where each node is a sorted array searched with binary search.

TimSort uses galloping too. When one run keeps winning during a merge, it gallops ahead instead of comparing one element at a time.

So when someone says "the input is too huge", my first question is whether it's sorted and seekable. If yes, logarithmic probes beat loading everything.

21. Recursion & Backtracking

BasicWhat is backtracking, and how is it different from plain recursion or brute force?

So backtracking is organised brute force. I build a solution one choice at a time. At each step I choose an option, recurse to explore everything that follows, then undo the choice and try the next one. What makes it better than blind brute force is pruning: the moment a partial solution can't possibly work, I abandon that whole branch.

Let me show you with the template I use.

void backtrack(List<Integer> path, int start) {
    if (isComplete(path)) {
        result.add(new ArrayList<>(path));    // record a COPY
        return;
    }
    for (int choice : choices(start)) {
        if (!isValid(path, choice)) continue;  // prune
        path.add(choice);                      // choose
        backtrack(path, choice + 1);           // explore
        path.remove(path.size() - 1);          // un-choose
    }
}

What happens here is a depth-first walk over a decision tree. The root is the empty solution, each edge is a choice, and each leaf is either an answer or a dead end. The "un-choose" line is what makes it backtracking. It restores the shared state, so the next sibling branch starts clean.

Every backtracking solution is recursion, but not every recursion is backtracking. Merge sort recurses without choices or undoing.

The signal I look for is "return all ..." or "does any arrangement exist?" with small n, around 10 to 20. The output itself is exponential, so nothing faster exists.

IntermediateGenerate all subsets of an array. What's the complexity, and what's the classic bug?

So every node of the decision tree is a subset. I record the current path on entry, then for each later element I add it, recurse, and remove it. There are 2n subsets and each is copied in O(n), so it's O(n · 2n) time.

Let me show you with a quick example.

static void backtrack(int[] nums, int start, List<Integer> path, List<List<Integer>> res) {
    res.add(new ArrayList<>(path));          // record a COPY of this node
    for (int i = start; i < nums.length; i++) {
        path.add(nums[i]);                   // choose
        backtrack(nums, i + 1, path, res);   // explore
        path.remove(path.size() - 1);        // un-choose
    }
}
// {1, 2, 3} -> [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]

The start index is what prevents duplicates. I only ever add elements to the right of the last one picked, so [2, 1] is never built. Each subset appears in exactly one order.

The classic bug is res.add(path) instead of res.add(new ArrayList<>(path)). I ran it to be sure:

// res.add(path)  ->  [[], [], [], [], [], [], [], []]

All eight entries are references to the same list. After every un-choose it ends up empty. So I always store a snapshot.

Space is O(n) for recursion and path, not counting the output. The alternative is bitmasks: loop mask from 0 to 2n - 1 and take the elements whose bits are set. Same complexity, no recursion.

Interviewers look for: the copy bug, and "what if the input has duplicates?" (Subsets II: sort, then skip nums[i] == nums[i-1] when i > start).

IntermediateGenerate permutations when the input has duplicates. How do you avoid repeated results?

So permutations differ from subsets because order matters. Every position may take any unused element, so I loop from 0 and track used[]. With duplicates, I sort first so equal values are adjacent. Then I skip a value if it equals the previous one and that previous copy isn't currently in the path. That forces equal values to be used left to right, so each distinct arrangement is built once.

Let me show you with a quick example.

static List<List<Integer>> permuteUnique(int[] nums) {
    Arrays.sort(nums);                               // duplicates side by side
    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) { res.add(new ArrayList<>(path)); return; }
    for (int i = 0; i < nums.length; i++) {
        if (used[i]) continue;
        if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) continue;   // skip repeat
        used[i] = true; path.add(nums[i]);
        permBt(nums, used, path, res);
        path.remove(path.size() - 1); used[i] = false;
    }
}
// {1, 1, 2} -> [[1, 1, 2], [1, 2, 1], [2, 1, 1]]   (3, not 6)

What happens here is that at the top level, choosing the second 1 first would build exactly the same trees as choosing the first 1. The skip condition cuts that branch. Without it I'd get 6 results with each one twice.

I could also dump everything into a HashSet, but that still does all n! work plus hashing. Pruning avoids the work entirely.

Time is O(n · n!) in the worst case. Note that used[] is part of the state, so it has to be un-chosen too.

IntermediateHow would you solve N-Queens? How do you check attacks in O(1)?

So I place exactly one queen per row, so the only choice is the column. For the attack check I keep three boolean arrays: columns, "/" diagonals, and "\" diagonals. On a "/" diagonal, r + c is constant. On a "\" diagonal, r - c is constant, and I shift it by n - 1 to make it a valid index. So each check is three array lookups.

Let me show you with a quick example that counts solutions.

static int place(int r, int n, boolean[] cols, boolean[] diag, boolean[] anti) {
    if (r == n) return 1;                              // all rows filled
    int count = 0;
    for (int c = 0; c < n; c++) {
        int d = r + c, x = r - c + n - 1;              // diagonal ids
        if (cols[c] || diag[d] || anti[x]) continue;   // attacked: prune
        cols[c] = diag[d] = anti[x] = true;
        count += place(r + 1, n, cols, diag, anti);
        cols[c] = diag[d] = anti[x] = false;           // un-choose
    }
    return count;
}
// place(0, n, new boolean[n], new boolean[2n-1], new boolean[2n-1])
// n = 1..8: 1 0 0 2 10 4 40 92

What happens here is that row conflicts are impossible by construction. Column and diagonal conflicts are rejected before placing, so every board that reaches r == n is valid. I never have to check a full board.

A naive approach scans the board for each placement, which is O(n) per check. The arrays make it O(1). The time is bounded by O(n!), because row 0 has n options, row 1 at most n - 1, and so on. Pruning makes the real number far smaller.

Interviewers look for: the r + c / r - c diagonal trick. For a senior follow-up, the three arrays can become three int bitmasks.

AdvancedYour backtracking solution times out. How do you speed it up, and when do you switch to DP?

So first I prune harder, and then I ask whether the question really needs every solution. If it only asks to count them, or for the best one, and the same sub-state keeps coming back, that's DP, and memoising the recursion can drop the cost from exponential to polynomial.

Let me show you a prune with Combination Sum.

static void comb(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;               // sorted: everything after is too big
        path.add(c[i]);
        comb(c, remain - c[i], i, path, res);   // i, not i + 1: reuse allowed
        path.remove(path.size() - 1);
    }
}
// sorted {2,3,6,7}, 7 -> [[2, 2, 3], [7]]     {2,3,5}, 8 -> [[2,2,2,2], [2,3,3], [3,5]]

The break kills a whole subtree at once. That only works because I sorted first. My checklist of speed-ups:

  • Sort and break early once candidates exceed the remaining budget.
  • Order choices well: most constrained first. In Sudoku, fill the cell with the fewest options.
  • O(1) validity checks with boolean arrays or bitmasks instead of rescanning.
  • Precompute: in Palindrome Partitioning, a table of which substrings are palindromes.
  • Memoise when the state repeats.

The DP switch: Combination Sum IV asks only for the count. The state is just "remaining amount", so there are only target + 1 states, and dp[t] = sum of dp[t - c] is O(target · n). Listing all combinations would be exponential.

Interviewers look for: "list all" means backtracking, "count or optimise" means check for overlapping subproblems.

22. Greedy

BasicWhat is a greedy algorithm, and when does it work?

So a greedy algorithm builds the answer step by step and at each step takes the option that looks best right now, without ever revisiting that choice. It works only when two properties hold. First, the greedy choice property: some optimal solution starts with the greedy choice. Second, optimal substructure: after that choice, what's left is a smaller version of the same problem.

Let me show you with a quick example: fractional knapsack, where I can take part of an item.

// items (weight, value): (10, 60), (20, 100), (30, 120); capacity 50
// sort by value/weight: 6.0, 5.0, 4.0
// take all of item 1 (60), all of item 2 (100), 20/30 of item 3 (80)
// total = 240.0

Greedy by value-per-kilo is optimal here. Any kilo spent on a worse ratio could be swapped for a better one without losing value.

But 0/1 knapsack, where I can't split items, breaks the same greedy. With capacity 50 it takes items 1 and 2 for 160, while items 2 and 3 give 220. That one needs DP.

What I like about greedy is that when it works, it's usually "sort, then one pass", O(n log n), with tiny code. The risk is that a greedy idea that feels right can be wrong. So I always try to break it with a small counterexample before coding.

Interviewers look for: fractional vs 0/1 knapsack as the example of the same problem flipping from greedy to DP.

BasicWhy does greedy fail for coin change?

So "always take the biggest coin that fits" is only optimal for certain coin systems, called canonical ones, like US or Indian coins. For arbitrary denominations it can lose, because taking the big coin now can force several small coins later.

Let me show you with a quick example.

static int greedyCoins(int[] coinsDesc, int amount) {
    int count = 0;
    for (int c : coinsDesc) { count += amount / c; amount %= c; }
    return amount == 0 ? count : -1;
}
// coins {4, 3, 1}, amount 6: greedy 4 + 1 + 1 = 3 coins, optimal 3 + 3 = 2 coins
// coins {25, 10, 5, 1}, amount 63: greedy 6 coins, which is optimal

What happens here is that greedy commits to the 4 and never reconsiders. Once 4 is used, the remaining 2 can only be made with two 1s. The greedy choice property fails: no optimal solution for 6 starts with a 4.

Greedy can even fail to find any answer at all. With coins {5, 3} and amount 9, it takes 5, then 3, and is left with 1 it can't pay. Meanwhile 3 + 3 + 3 works.

The fix is DP: dp[a] = min over coins c of dp[a - c] + 1, in O(amount × coins). It tries every "last coin" instead of committing to one.

So in practice this is my go-to sanity check. If I'm tempted by a greedy rule, I spend a minute hunting for a tiny input like {1, 3, 4} that breaks it.

IntermediateFor interval scheduling, why sort by end time and not start time?

So to keep the most non-overlapping intervals, I always keep the one that finishes first. It leaves the most room for everything after it. Sorting by start time fails because one long interval that starts early blocks many short ones.

Let me show you with a quick example.

static int maxNonOverlapping(int[][] intervals) {
    Arrays.sort(intervals, (a, b) -> Integer.compare(a[1], b[1]));  // by END
    int kept = 0, lastEnd = Integer.MIN_VALUE;
    for (int[] in : intervals) {
        if (in[0] >= lastEnd) { kept++; lastEnd = in[1]; }
    }
    return kept;
}
// {1,10}, {2,3}, {4,5}, {6,7}
// by end   -> keeps [2,3], [4,5], [6,7] = 3
// by start -> keeps [1,10] only         = 1

Why it's correct, with the exchange argument: take any optimal schedule. Its first interval ends at or after the earliest-ending interval overall. If I swap that first interval for the earliest-ending one, nothing after it conflicts, because it ends even sooner. So an optimal schedule exists that starts with the greedy choice. Then I repeat on what's left.

LeetCode 435, Non-overlapping Intervals, asks for the minimum number to remove. That's just n - kept. The whole thing is O(n log n) for the sort plus one pass.

Interviewers look for: the counterexample for start-time sorting, and the exchange argument in your own words. Also watch >= vs >: whether [1,2] and [2,3] overlap depends on the problem.

IntermediateJump Game: can you reach the last index, and what's the minimum number of jumps?

So for "can I reach the end", I track the farthest index reachable so far. If I ever stand on an index beyond it, I'm stuck. For "minimum jumps", I treat it like BFS in levels without a queue. All indexes reachable in one jump are level 1, and so on, and I count how many levels it takes.

Let me show you both.

static boolean canJump(int[] nums) {
    int farthest = 0;
    for (int i = 0; i < nums.length; i++) {
        if (i > farthest) return false;               // can't even stand here
        farthest = Math.max(farthest, i + nums[i]);
    }
    return true;
}
static int jump(int[] nums) {
    int jumps = 0, curEnd = 0, farthest = 0;
    for (int i = 0; i < nums.length - 1; i++) {       // no jump needed from the last
        farthest = Math.max(farthest, i + nums[i]);
        if (i == curEnd) { jumps++; curEnd = farthest; }   // used up this level
    }
    return jumps;
}
// canJump {2,3,1,1,4} -> true, {3,2,1,0,4} -> false;   jump {2,3,1,1,4} -> 2

The insight for canJump is that every index up to farthest is reachable, because I can always jump shorter than the max. So I don't need to know which jumps to take, only how far the reachable region extends. That's O(n) time and O(1) space. A DP over "can I reach i" would be O(n²).

For jump, curEnd is the edge of the current level. When I reach it, I must spend another jump, and the new edge is the farthest point seen in this level.

One gotcha I've hit: looping to nums.length instead of nums.length - 1. If a level ends exactly on the last index, that counts one extra jump.

IntermediateGas Station: explain the one-pass solution.

So two facts make it one pass. First, if total gas >= total cost, a valid start exists. Second, if I start at s and run dry before reaching station i + 1, then no station between s and i can be the start either. So I jump the start straight to i + 1 and reset the tank.

Let me show you with a quick example.

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) { start = i + 1; tank = 0; }   // nothing in [start..i] works
    }
    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 the second fact holds: any station k between start and i was reached with a tank >= 0. Starting at k with an empty tank can only be worse, so it would also fail at i. That's why I never need to go back and retry them.

Tracing the first example: the diffs are -2, -2, -2, 3, 3. The tank goes negative at 0, 1 and 2, moving start to 3. Then 3 + 3 = 6 stays positive. The total is 0, which is >= 0, so the answer is 3.

So the brute force of trying every start is O(n²), and this is O(n) time, O(1) space.

Interviewers look for: the proof of why skipping to i + 1 is safe. The code is easy to memorise, the reasoning is what they're testing.

AdvancedMinimum meeting rooms. How would you solve it, and how do you prove a greedy correct in general?

So the answer is the maximum number of meetings happening at the same moment. I sort by start time and keep a min-heap of end times, one per busy room. For each meeting, if the earliest-ending room is free by then, I reuse it. Otherwise I open a new room. The heap size at the end is the answer.

Let me show you with a quick example.

static int minMeetingRooms(int[][] meetings) {
    Arrays.sort(meetings, (a, b) -> Integer.compare(a[0], b[0]));   // by start
    PriorityQueue<Integer> ends = new PriorityQueue<>();            // end time per busy room
    for (int[] m : meetings) {
        if (!ends.isEmpty() && ends.peek() <= m[0]) ends.poll();     // reuse earliest-free room
        ends.offer(m[1]);
    }
    return ends.size();
}
// {{0,30},{5,10},{15,20}} -> 2      {{1,5},{2,6},{3,7},{5,8}} -> 3

In the second example, [5, 8] reuses the room freed by [1, 5], so three rooms are enough. Time is O(n log n), space O(n).

How I prove a greedy in general, with the exchange argument: take any optimal solution. Find the first place it differs from the greedy one. Show that swapping its choice for the greedy choice keeps the solution valid and no worse. Repeat, and the optimal solution becomes the greedy one without getting worse, so greedy is optimal. Here the proof is even simpler. A new room only opens when every existing room is busy at that start time, so at that moment there really are that many overlapping meetings. No schedule can use fewer.

Interviewers look for: the lower-bound argument ("at time t, k meetings overlap, so k rooms is necessary"). It shows you understand why the answer is optimal, not just how to compute it.

23. Divide & Conquer

BasicWhat is divide and conquer, and how is it different from dynamic programming?

So divide and conquer solves a problem in three moves: split it into smaller pieces of the same kind, solve each piece recursively, and combine the answers. The difference from DP is whether the pieces overlap. In D&C they're independent, like the two halves in merge sort, so nothing is solved twice and there's nothing to cache. In DP the same subproblem comes up again and again, so I store it.

Let me show you with a quick comparison.

AlgorithmSplitCombineSubproblems overlap?
Merge sortMiddle, O(1)Merge, O(n)No
QuicksortPartition, O(n)NothingNo
Binary searchCompare with middleNothing (one side only)No
Naive Fibonaccifib(n-1), fib(n-2)AddYes, so use DP

What happens with naive Fibonacci is that it looks like D&C, but fib(n-1) and fib(n-2) both recompute fib(n-3), and so on down. The call tree blows up to O(2n). A memo fixes it. That's the moment it becomes DP.

Notice the work sits in different places. Merge sort's split is trivial and the merge is the real work. Quicksort's partition is the real work and there's no combine.

So my quick test is: draw the recursion tree for a small input. If the same arguments appear in two branches, I add a memo. If not, plain recursion is already optimal.

Interviewers look for: "overlapping subproblems" as the one-line difference.

IntermediateHow do you find the time complexity of a recursive algorithm? Explain the master theorem.

So I write the running time as a recurrence, T(n) = a T(n/b) + f(n): a subproblems, each 1/b the size, plus f(n) work to split and combine. Then I either sum the recursion tree level by level, or use the master theorem as a shortcut. For f(n) = O(nd), I compare d with logb a.

Let me show you with the three cases and common examples.

CaseResultMeaning
d > logb aO(nd)The top-level work dominates
d = logb aO(nd log n)Every level costs the same
d < logb aO(nlogb a)The leaves dominate
AlgorithmRecurrenceResult
Binary searchT(n/2) + O(1)O(log n)
Merge sort2T(n/2) + O(n)O(n log n)
Quickselect (average)T(n/2) + O(n)O(n)
Balanced tree traversal2T(n/2) + O(1)O(n)
Karatsuba3T(n/2) + O(n)O(n1.585)

For merge sort, a = 2, b = 2, d = 1, and log2 2 = 1 = d. So it's the middle case, O(n log n). Intuitively, each level does n total work, because the pieces double in number while halving in size, and there are log n levels.

One gotcha: the theorem needs equal-sized splits. Quicksort's worst case is T(n) = T(n-1) + O(n), which isn't covered. I sum it directly: n + (n-1) + ... + 1 = O(n²).

IntermediateImplement pow(x, n) in O(log n).

So I use xn = (xn/2)² when n is even, and x · (xn/2)² when it's odd. That's one half-size subproblem per level, so O(log n). The key detail is computing the half once and squaring it.

Let me show you with a quick example.

static double myPow(double x, int n) {
    long N = n;                          // -Integer.MIN_VALUE overflows int
    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);         // ONE recursive call
    return (n % 2 == 0) ? half * half : half * half * x;
}
// myPow(2.0, 10) = 1024.0    myPow(2.0, -2) = 0.25    myPow(1.0, Integer.MIN_VALUE) = 1.0

Two gotchas I watch for. First, writing pow(x, n/2) * pow(x, n/2) makes two calls per level. That's T(n) = 2T(n/2) + O(1), which is O(n) again. I counted it: for n = 1024 that version makes 4,095 calls, while the version above makes 12. Second, n = Integer.MIN_VALUE has no positive int counterpart, so -n overflows. That's why I widen to long before negating.

The iterative version walks the bits of n. 13 is 1101 in binary, so x13 = x8 · x4 · x1. That's O(1) space. With % mod after each multiply, it becomes the modPow used in almost every "answer mod 109+7" problem.

Interviewers look for: the Integer.MIN_VALUE edge case and the "two calls makes it linear" trap.

IntermediateSolve Maximum Subarray with divide and conquer. How does it compare to Kadane's?

So the best subarray is in one of three places: entirely in the left half, entirely in the right half, or crossing the middle. The first two come from recursion. The crossing one is the best suffix of the left half plus the best prefix of the right half, which I find with two linear scans. That's T(n) = 2T(n/2) + O(n), so O(n log n).

Let me show you both.

static int best(int[] a, int lo, int hi) {
    if (lo == hi) return a[lo];
    int mid = lo + (hi - lo) / 2;
    int left = best(a, lo, mid), right = best(a, mid + 1, hi);
    int sum = 0, bestLeft = Integer.MIN_VALUE;          // best suffix of left half
    for (int i = mid; i >= lo; i--) { sum += a[i]; bestLeft = Math.max(bestLeft, sum); }
    sum = 0;
    int bestRight = Integer.MIN_VALUE;                  // best prefix of right half
    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);
}
static int kadane(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 restart here
        best = Math.max(best, cur);
    }
    return best;
}
// {-2,1,-3,4,-1,2,1,-5,4} -> 6 (both)      {-3,-1,-2} -> -1 (both)

Kadane is O(n) time and O(1) space, so in an interview it's the answer I'd give. It's really a one-dimensional DP: the best run ending at i is either the previous run extended, or a fresh start at a[i].

The D&C version is still worth knowing. It parallelises naturally, and if each node also stores its total, best prefix and best suffix, it becomes a segment tree that answers "max subarray in [l, r]" with updates in O(log n). Kadane can't do that.

Interviewers look for: the all-negative case. Initialising best = 0 returns 0 for {-3, -1, -2}, but the answer is -1.

AdvancedCount inversions in O(n log n). Why does merge sort make it possible?

So an inversion is a pair i < j with a[i] > a[j]. Brute force checks every pair, which is O(n²). With merge sort, inversions inside each half are counted recursively. The ones that cross the halves are counted during the merge: when I take an element from the right half, it's smaller than every element still waiting in the left half, so all of those are inversions with it at once.

Let me show you with a quick example.

static long countInv(int[] a, int[] tmp, int lo, int hi) {
    if (lo >= hi) return 0;
    int mid = lo + (hi - lo) / 2;
    long count = countInv(a, tmp, lo, mid) + countInv(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; tmp[k++] = a[j++]; }   // a[i..mid] all > 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;
}
// {2, 4, 1, 3, 5} -> 3   (pairs 2>1, 4>1, 4>3)

This shows when D&C actually wins: the combine step must be cheaper than brute-forcing across the split. Checking every left-right pair is O(n²). But because both halves are already sorted, mid - i + 1 counts a whole block of pairs in O(1). So the combine is O(n), and the total is O(n log n).

Details I care about: <= in the comparison, so equal elements aren't counted as inversions. A long for the count, because n = 105 can give about 5 × 109. And sorting a copy if the caller still needs the original.

In practice, inversion count measures how far apart two rankings are, like comparing two users' movie preferences.

AdvancedHow does divide and conquer relate to parallelism? How would you use it in Java?

So because D&C subproblems are independent, they can run on different cores at the same time with no locking. Java's fork/join framework is built exactly for this. A RecursiveTask splits until the piece is small enough, forks one half, computes the other itself, and joins. The pool uses work stealing, so idle threads take pending halves from busy ones.

Let me show you with a quick example: a parallel sum.

class SumTask extends RecursiveTask<Long> {
    private static final int THRESHOLD = 10_000;
    private final int[] a; private final int lo, hi;     // sums a[lo..hi)
    SumTask(int[] a, int lo, int hi) { this.a = a; this.lo = lo; this.hi = hi; }
    @Override protected Long compute() {
        if (hi - lo <= THRESHOLD) {                      // small: just loop
            long s = 0;
            for (int i = lo; i < hi; i++) s += a[i];
            return s;
        }
        int mid = lo + (hi - lo) / 2;
        SumTask left = new SumTask(a, lo, mid);
        left.fork();                                     // run left half in parallel
        long right = new SumTask(a, mid, hi).compute();  // do right half ourselves
        return left.join() + right;                      // combine
    }
}
// 5,000,000 ints (i % 100): ForkJoinPool.commonPool().invoke(new SumTask(a, 0, a.length)) = 247500000

What happens here is the threshold matters a lot. Splitting down to single elements would spend more time creating tasks than adding numbers. The pattern of fork one half, compute the other in the current thread, then join, avoids wasting a thread that would otherwise just wait.

This is what Arrays.parallelSort and parallel streams use underneath. At a bigger scale, MapReduce is the same idea across machines: split the data, process pieces independently, combine.

So in practice I only reach for it with large inputs and CPU-bound work. For small arrays the overhead makes it slower than a plain loop.

Interviewers look for: the threshold and the "why not fork both" detail. Those show you've actually used fork/join.

24. Dynamic Programming — the Fundamentals

BasicWhat is dynamic programming?

So dynamic programming is recursion with a memory. I break a problem into smaller versions of itself, solve each distinct one once, store the answer, and reuse it. It applies when two things hold: overlapping subproblems, meaning the same subproblem comes up many times, and optimal substructure, meaning the best answer is built from the best answers to smaller pieces.

Let me show you with a quick example: Fibonacci.

static int fibNaive(int n) {
    if (n <= 1) return n;
    return fibNaive(n - 1) + fibNaive(n - 2);
}
// fibNaive(30) = 832040, but it makes 2,692,537 calls

static long fibMemo(int n, long[] memo) {
    if (n <= 1) return n;
    if (memo[n] != 0) return memo[n];                 // already solved
    return memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
}
// fibMemo(50) = 12586269025 instantly: only 51 distinct states

What happens in the naive version is that fib(28) is computed twice, fib(27) three times, and so on. The call tree grows roughly like 1.6n, but there are only n + 1 distinct questions. The memo turns each repeat into an array lookup, so it's O(n).

On optimal substructure: shortest path has it, since every piece of a shortest path is itself a shortest path. Longest simple path doesn't, because the best sub-paths can reuse the same vertex, so you can't just glue them together.

So the way I describe DP in one line is: exponential recursion, made polynomial by never solving the same state twice. The cost becomes number of states × work per state.

Interviewers look for: the two property names, and "states × transitions" as the way you compute DP complexity.

BasicHow do you know a problem is a DP problem?

So I look for three things. First, the question asks for a count, a min or max, or a yes/no, not a list of every solution. Second, at each step I make a choice that affects what comes after. Third, when I sketch the brute-force recursion, the same arguments show up in different branches. If all three are there, it's DP.

Let me show you the signals I use.

Signal in the questionExamples
"Count the number of ways..."Climbing stairs, unique paths, decode ways, coin change II
"Minimum / maximum cost, profit, length..."Coin change, house robber, LIS, edit distance
"Is it possible to...?"Partition equal subset sum, word break
A choice at each step: take/skip, buy/sell, left/rightKnapsack, stock problems
Sequence, string or grid with n up to ~103–104O(n²) DP fits; n around 20 hints at bitmask DP

Then I rule out the neighbours. If it says "return all combinations", the output is exponential, so it's backtracking. If a simple local rule works, it's greedy. I try to break the greedy with a tiny counterexample, like coins {1, 3, 4} for amount 6, where greedy gives 3 coins and the answer is 2. If the subproblems don't overlap, like merge sort's halves, it's divide and conquer.

The most reliable test is concrete. I write the recursive function, say its parameters out loud, and ask "can two different paths reach the same parameters?" If yes, those parameters are my DP state.

Interviewers look for: that you can name the state before coding. "dp[i] means the fewest coins to make amount i" shows you've recognised it, not just pattern-matched.

IntermediateMemoization vs tabulation: what are the trade-offs?

So memoization is top-down: I write the natural recursion and add a cache. Tabulation is bottom-up: I fill a table from the smallest states upward with loops. Both compute each state once, so the Big-O is usually the same. The differences are practical: stack depth, which states get computed, and how easily I can shrink memory.

Let me show you both on climbing stairs.

// Top-down
static int climbMemo(int n, Integer[] memo) {
    if (n <= 1) return 1;
    if (memo[n] != null) return memo[n];
    return memo[n] = (climbMemo(n - 1, memo) + climbMemo(n - 2, memo)) % 1_000_000_007;
}
// Bottom-up, space-optimised to two variables
static int climbTab(int n) {
    int prev2 = 1, prev1 = 1;                        // ways(i-2), ways(i-1)
    for (int i = 2; i <= n; i++) {
        int cur = (prev1 + prev2) % 1_000_000_007;
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
}
// n = 100,000: climbTab -> 967618232,   climbMemo -> StackOverflowError
Memoization (top-down)Tabulation (bottom-up)
Writing itNatural: recursion + cacheMust work out the fill order
States computedOnly the reachable onesAll of them
Stack overflowYes, when depth ~ n is largeNever
SpeedCall overhead, boxed cachesTight loops, cache-friendly
Space optimisationHardEasy: keep only the rows you read

So in practice I start with memoization in an interview, because it falls straight out of the brute force and is easiest to get right. I switch to tabulation when n is large, when I need to cut memory to O(1) or one row, or when an interviewer asks for it. Memoization wins when the state space is sparse, for example a HashMap memo over states that are hard to enumerate in order.

Interviewers look for: the stack depth point. Java's default stack gives out somewhere in the thousands-to-tens-of-thousands of frames, depending on frame size.

IntermediateWalk me through House Robber using a DP recipe.

So I use five steps: state, recurrence, base cases, order, answer. Houses are in a row, I can't rob two neighbours, and I want the maximum total. At each house I either skip it or rob it and skip its neighbour.

Let me show you.

  • State: dp[i] is the most money from the first i houses.
  • Recurrence: dp[i] = max(dp[i-1], dp[i-2] + nums[i-1]), meaning skip house i or rob it.
  • Base: dp[0] = 0, dp[1] = nums[0].
  • Order: i from 2 up to n.
  • Answer: dp[n].
static int rob(int[] nums) {
    int prev2 = 0, prev1 = 0;                        // best up to i-2, i-1
    for (int x : nums) {
        int cur = Math.max(prev1, prev2 + x);        // skip x, or take it
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
}
// {2, 7, 9, 3, 1} -> 12   (houses 0, 2, 4: 2 + 9 + 1)

Tracing it: after 2, best is 2. After 7, max(2, 0 + 7) = 7. After 9, max(7, 2 + 9) = 11. After 3, max(11, 7 + 3) = 11. After 1, max(11, 11 + 1) = 12.

Since dp[i] only reads the previous two values, I keep two variables. That's O(n) time and O(1) space.

The greedy idea of taking every other house fails. For [2, 1, 1, 2], both alternating picks give 3, but the answer is 2 + 2 = 4.

Interviewers look for: the follow-up "houses in a circle" (House Robber II). Run the same function twice, once without the first house and once without the last, and take the max.

IntermediateSolve Coin Change (fewest coins). What's the complexity?

So the state is dp[a], the fewest coins to make amount a. To make a, the last coin I used was some c, so dp[a] = min over coins c of dp[a - c] + 1. The base is dp[0] = 0, and I fill from 1 up to amount.

Let me show you with a quick example.

static int coinChange(int[] coins, int amount) {
    int[] dp = new int[amount + 1];                  // dp[a] = fewest coins for a
    Arrays.fill(dp, amount + 1);                     // "infinity"
    dp[0] = 0;
    for (int a = 1; a <= amount; a++)
        for (int c : coins)
            if (c <= a) dp[a] = Math.min(dp[a], dp[a - c] + 1);   // last coin is c
    return dp[amount] > amount ? -1 : dp[amount];
}
// {1, 3, 4}, 6 -> 2 (3 + 3)     {2}, 3 -> -1     {1, 2, 5}, 11 -> 3

Complexity is O(amount × coins) time and O(amount) space. That's states × transitions: amount + 1 states, each trying every coin.

One gotcha I've hit is using Integer.MAX_VALUE as infinity. Then dp[a - c] + 1 overflows to a negative number and wins the min. amount + 1 is a safe infinity, because no answer can use more than amount coins, since the smallest coin is at least 1.

This is the problem where greedy fails. Greedy on {1, 3, 4} for 6 gives 4 + 1 + 1. The DP tries every last coin instead of committing to the biggest.

Also note that it's pseudo-polynomial. It's linear in the value of amount, not the input size. With amount = 109 this table doesn't fit, and I'd need a different approach, like BFS with pruning or number theory.

AdvancedWhen you space-optimise a DP, what do you lose? How do you reconstruct the actual solution?

So rolling the table down to a few variables or one row keeps the value of the answer, but throws away the history of choices. If the question asks "which houses" or "which items", not just "how much", I keep the full table and walk it backwards. At each state I check which choice produced that value.

Let me show you with House Robber.

static List<Integer> robWhich(int[] nums) {
    int n = nums.length;
    int[] dp = new int[n + 1];
    dp[1] = nums[0];
    for (int i = 2; i <= n; i++) dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]);
    List<Integer> picked = new ArrayList<>();
    for (int i = n; i >= 1; ) {
        if (i == 1 || dp[i] != dp[i - 1]) { picked.add(i - 1); i -= 2; }   // house i-1 was robbed
        else i--;                                                          // house i-1 was skipped
    }
    Collections.reverse(picked);
    return picked;
}
// {2, 7, 9, 3, 1} -> [0, 2, 4]   (2 + 9 + 1 = 12)

What happens here is: if dp[i] equals dp[i-1], skipping house i was optimal, so I step back one. Otherwise I must have robbed it, so I record it and jump back two. The walk is O(n), so reconstruction doesn't change the complexity. It only costs the O(n) table.

The general rules for rolling arrays:

  • If dp[i] reads only the last k values, keep k variables.
  • If dp[i][j] reads only row i - 1, keep one or two rows. Loop direction matters so I never read a cell I already overwrote in this pass. In 0/1 knapsack I go right to left for exactly that reason.
  • If I need the path, keep the table, or store a separate "choice" array, which is smaller than the full values.

Interviewers look for: the loop-direction point in 1D knapsack. Going left to right silently turns 0/1 knapsack into unbounded knapsack.

25. DP Patterns

BasicYou've been handed a problem and you suspect DP. How do you figure out which DP pattern it is?

So I look at two things: what the input is shaped like, and what choice I make at each step. Those two usually map straight onto one of about nine patterns, and the pattern hands me the state.

What I noticePatternState
Items with a weight, a budget, "use each once"0/1 knapsackdp[i][cap], or 1D going backwards
Same, but "unlimited supply" (coins, rods)Unbounded knapsackdp[amount]
One array or string, answer builds left to rightSingle sequencedp[i] = best ending at / using first i
Two strings comparedTwo sequencesdp[i][j] over prefixes
Moves only right/down in a gridGriddp[r][c]
"Merge / burst / split a range", order mattersIntervaldp[l][r]
A binary tree with a take/skip choiceTree DPPair returned from DFS
n <= 20, "visit all", "assign each"Bitmaskdp[mask][i]
Days with modes: holding, sold, cooldownState machinedp[i][state]

The tell that it's DP at all is "count the ways", "min/max", or "is it possible", plus a brute force that re-solves the same subproblem. Then I say the state in words before I write code: "dp[i][j] is the edit distance between the first i chars of a and the first j of b." If I can't say it in one sentence, I haven't found the pattern yet.

So in practice, recognising the pattern is most of the work. The recurrence usually falls out once the state sentence is right.

BasicWalk me through Coin Change: the fewest coins to make an amount.

So this is unbounded knapsack, because I can reuse each coin as many times as I like. My state is dp[a] = the fewest coins that make exactly a. For each amount I try every coin as the "last coin" and take the best.

static int coinChange(int[] coins, int amount) {
  int[] dp = new int[amount + 1];
  Arrays.fill(dp, amount + 1);          // "infinity" that can't overflow
  dp[0] = 0;
  for (int a = 1; a <= amount; a++) {
    for (int c : coins) {
      if (c <= a) dp[a] = Math.min(dp[a], dp[a - c] + 1);
    }
  }
  return dp[amount] > amount ? -1 : dp[amount];
}
// coinChange([1, 2, 5], 11) -> 3   (5 + 5 + 1)
// coinChange([2], 3)        -> -1

The recurrence is dp[a] = 1 + min(dp[a - c]) over coins c. The base case is dp[0] = 0. It's O(amount x coins) time and O(amount) space.

One gotcha I've hit is using Integer.MAX_VALUE as "unreachable". The moment I do dp[a - c] + 1 it wraps to a negative number and wins every min. Using amount + 1 works because no real answer can need more than amount coins.

And I'd say why greedy doesn't work here: with coins [1, 3, 4] and amount 6, greedy takes 4+1+1 (three coins) but 3+3 is two. DP tries every last coin, so it can't be fooled that way.

Interviewers look for: the follow-up is Coin Change II, "count the ways". They want to hear that the loop order decides what you count: coins in the outer loop counts combinations (4 ways for [1, 2, 5], amount 5), amount in the outer loop counts ordered sequences (9 ways).

IntermediateFor 0/1 knapsack in one dimension, why does the capacity loop go backwards? Show me with Partition Equal Subset Sum.

So going backwards is what makes each item usable only once. When I update dp[s] from dp[s - x], I need dp[s - x] to still be the value from before this item. Going from high to low, the smaller index hasn't been touched yet in this round.

static boolean canPartition(int[] nums) {
  int total = 0;
  for (int x : nums) total += x;
  if (total % 2 != 0) return false;
  int target = total / 2;
  boolean[] dp = new boolean[target + 1];
  dp[0] = true;
  for (int x : nums) {
    for (int s = target; s >= x; s--) {  // backwards: each item 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 (total 11 is odd)

The problem becomes knapsack once I notice that splitting into two equal halves means "can some subset sum to total / 2?". dp[s] is "some subset of the items so far sums to s."

If I went forwards with x = 5, I'd set dp[5] true, then later in the same pass read dp[5] to set dp[10]. That uses the 5 twice. That forward order is exactly unbounded knapsack. So the direction of one loop is the whole difference between the two patterns.

It's O(n x target) time and O(target) space. That's pseudo-polynomial, so if the sum could be in the billions I'd say so and look for a different approach.

Interviewers look for: the reduction to "subset sums to half" and the one-line reason for the backwards loop. Target Sum (LC 494) is the usual follow-up, same trick.

IntermediateLongest Increasing Subsequence: give me the O(n²) idea, then get it to O(n log n).

So the O(n²) version is dp[i] = the length of the longest increasing subsequence ending at i, and I fill it by looking at every earlier j with a smaller value. The O(n log n) version keeps a different array: tails[k] = the smallest possible tail of an increasing subsequence of length k + 1.

static int lengthOfLIS(int[] nums) {
  int[] tails = new int[nums.length];
  int size = 0;
  for (int x : nums) {
    int lo = 0, hi = size;
    while (lo < hi) {                     // first tail >= x
      int mid = (lo + hi) >>> 1;
      if (tails[mid] < x) lo = mid + 1; else hi = mid;
    }
    tails[lo] = x;
    if (lo == size) size++;
  }
  return size;
}
// [10, 9, 2, 5, 3, 7, 101, 18] -> 4   (2, 3, 7, 18)

Why keep the smallest tail? A smaller tail is always at least as good, because it leaves more room for the next number to extend it. tails stays sorted, so for each x I binary search the first tail that is >= x and replace it. If x is bigger than every tail, it extends the longest one.

One gotcha: tails is not the actual subsequence. For the example it ends as [2, 3, 7, 18] by luck, but in general it's a mix. If they want the sequence itself, I store a parent index for each element.

For strictly increasing I search >= x. For non-decreasing I'd search > x instead.

IntermediateWalk me through Edit Distance. Where do the three operations show up in the recurrence?

So dp[i][j] is the minimum edits to turn the first i characters of a into the first j characters of b. If the last characters match, they cost nothing. If they don't, I pay 1 and take the best of replace, delete, or insert, each of which is a neighbouring cell.

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 all
  for (int j = 0; j <= n; j++) dp[0][j] = j;   // insert all
  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];
      } else {
        dp[i][j] = 1 + Math.min(dp[i - 1][j - 1],     // replace
                       Math.min(dp[i - 1][j],         // delete
                                dp[i][j - 1]));       // insert
      }
    }
  }
  return dp[m][n];
}
// minDistance("horse", "ros") -> 3

The mapping is: replace fixes both last chars, so diagonal dp[i-1][j-1]. Delete drops a's last char, so up, dp[i-1][j]. Insert adds b's last char, so left, dp[i][j-1]. The first row and column are the "one string is empty" cases.

I use size m + 1 by n + 1 so index 0 means "empty prefix". That removes all the edge checks, and the price is remembering charAt(i - 1).

It's O(mn) time. Each row only reads the row above, so I can drop to two rows, O(n) space, if memory matters. LCS is the same grid, with +1 on the diagonal for a match and max instead of min.

AdvancedHow would you do Best Time to Buy and Sell Stock with Cooldown? Why call it a state machine?

So each day I'm in one of three states: holding a share, just sold (so tomorrow is a cooldown), or resting with no share and free to buy. I write down how each state can be reached from yesterday's states, and that is the recurrence. Drawing the three boxes and arrows first is why people call it a state machine.

static int maxProfitCooldown(int[] prices) {
  int hold = Integer.MIN_VALUE / 2;  // own a share
  int sold = 0;                      // just sold today (cooldown next)
  int rest = 0;                      // free to buy
  for (int p : prices) {
    int prevHold = hold, prevSold = sold, prevRest = rest;
    hold = Math.max(prevHold, prevRest - p);   // keep, or buy from rest
    sold = prevHold + p;                       // sell today
    rest = Math.max(prevRest, prevSold);       // wait, or cooldown ends
  }
  return Math.max(sold, rest);
}
// [1, 2, 3, 0, 2] -> 3   (buy 1, sell 2, cooldown, buy 0, sell 2)

The cooldown lives in one arrow: you can only buy from rest, never straight from sold. That's the whole rule, encoded by which arrows exist.

The gotcha I watch for is updating in place. If I wrote hold first and then used the new hold to compute sold, I'd be buying and selling on the same day. So I copy yesterday's values first.

The nice thing is the whole stock family is the same machine with different arrows. A transaction fee subtracts on the sell arrow. "At most k transactions" adds the count to the state, dp[i][k][holding]. It's O(n) time and O(1) space here.

Interviewers look for: the states named and drawn before code, and the "use yesterday's values" point. They often follow with "now at most two transactions" (LC 123).

26. Shortest Paths

BasicHow do you choose between BFS, Dijkstra, Bellman-Ford and Floyd-Warshall?

So I decide from the edge weights and from whether I need one source or all pairs. The fastest algorithm that is still correct for those weights wins.

GraphAlgorithmTime
UnweightedBFSO(V + E)
Weights only 0 or 10-1 BFS with a dequeO(V + E)
Non-negative weights, one sourceDijkstra with a heapO((V + E) log V)
Negative weights, or "at most K edges"Bellman-FordO(V x E)
All pairs, small V (a few hundred)Floyd-WarshallO(V³)
DAG, any weightsTopological order, then relaxO(V + E)

They all share one move called relaxation: "if going through u gives v a cheaper cost than it has, update dist[v]." What differs is the order in which they relax edges. BFS goes by layers. Dijkstra always finishes the closest unfinished node next. Bellman-Ford relaxes every edge, round after round. Floyd-Warshall tries every node as a middle stop.

A mistake I see is people using Dijkstra on an unweighted grid. It works, but BFS gives the same answer without the log factor, so I'd pick BFS there.

So in practice Dijkstra is my default for weighted graphs, and I move away from it only when I see negative weights, a limit on the number of edges, or a request for all pairs.

BasicWrite Dijkstra for me and tell me its complexity.

So I keep a dist array and a min-heap of (node, distance). I always pop the closest node, and relax its edges. Java's PriorityQueue has no decrease-key, so I push a new entry instead and skip stale ones when they come out.

// graph.get(u) holds int[]{to, weight}
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;
  PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Integer.compare(a[1], b[1]));
  pq.offer(new int[]{src, 0});
  while (!pq.isEmpty()) {
    int[] cur = pq.poll();
    int u = cur[0], d = cur[1];
    if (d > dist[u]) continue;               // stale entry, skip
    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;
}

For Network Delay Time (LC 743) I run this from k and return the max of dist, or -1 if any node stays at MAX_VALUE. For edges 2->1, 2->3, 3->4 all weight 1, starting at 2, that's 2.

Complexity is O((V + E) log V). Each edge can push at most one entry, so the heap holds up to E items, and log E is at most 2 log V. Space is O(V + E).

Two gotchas. I use Integer.compare in the comparator, not a[1] - b[1], which can overflow. And the d > dist[u] check matters: without it, stale entries re-expand nodes and the run slows down badly on dense graphs.

Interviewers look for: the lazy-deletion check, and an honest complexity. They may ask why you don't need a visited set: the stale check does the same job.

IntermediateWhy does Dijkstra fail with negative edge weights? Give me a concrete example.

So Dijkstra assumes that once a node is the closest thing in the heap, its distance is final. That's only true if no path can get cheaper by going further. A negative edge breaks that: a longer detour can end up cheaper.

// A = 0, B = 1, C = 2
// A -> B cost 2
// A -> C cost 3
// C -> B cost -2
//
// Dijkstra (marking nodes final when popped):
//   pop A (0): dist B = 2, dist C = 3
//   pop B (2): B is final at 2
//   pop C (3): C -> B gives 1, but B is already final
// result:  [0, 2, 3]
// correct: [0, 1, 3]   (A -> C -> B = 3 - 2)

I ran exactly this. Dijkstra with a "done" array returns B = 2, and Bellman-Ford returns B = 1.

The lazy version without a done array sometimes "gets lucky" on small cases because it keeps relaxing. But then it can re-process nodes many times, and the running time can blow up to exponential in bad cases. So it isn't a real fix.

A trick people suggest is "add a constant to every edge to make them positive". That's wrong too. It punishes paths with more edges, so it changes which path is shortest.

So when I see negative weights, I go to Bellman-Ford. If the graph is a DAG, I use topological order and relax once, which handles negatives in linear time.

Interviewers look for: a three-node counterexample drawn on the spot, and that you reject the "add a constant" trick.

IntermediateExplain Bellman-Ford. Why V - 1 rounds, and how does it detect a negative cycle?

So Bellman-Ford just relaxes every edge, again and again. After round k, every shortest path that uses at most k edges is correct. A shortest path with no cycle visits each node at most once, so it has at most V - 1 edges. That's why V - 1 rounds are enough.

static long[] bellmanFord(int n, int[][] edges, int src) {
  long INF = Long.MAX_VALUE / 4;
  long[] dist = new long[n];
  Arrays.fill(dist, INF);
  dist[src] = 0;
  for (int round = 0; round < n - 1; round++) {
    boolean changed = false;
    for (int[] e : edges) {
      int u = e[0], v = e[1], w = e[2];
      if (dist[u] != INF && dist[u] + w < dist[v]) {
        dist[v] = dist[u] + w;
        changed = true;
      }
    }
    if (!changed) break;                     // early exit
  }
  for (int[] e : edges) {                    // one more round
    if (dist[e[0]] != INF && dist[e[0]] + e[2] < dist[e[1]]) {
      return null;                           // negative cycle reachable
    }
  }
  return dist;
}

For the negative cycle: after V - 1 rounds, everything should be settled. If one more pass can still make something cheaper, the only explanation is a cycle whose total is negative, because you can go round it forever and keep getting cheaper. So I return a signal instead of distances.

The dist[u] != INF check matters. Without it, "infinity plus a negative weight" looks smaller than infinity, and unreachable nodes get fake distances.

It's O(V x E), much slower than Dijkstra, so I only use it when I need what it offers: negative weights, cycle detection, or a limit on the number of edges.

IntermediateCheapest Flights Within K Stops: why not just use Dijkstra, and what's the bug people hit with Bellman-Ford?

So plain Dijkstra finalises each city at its cheapest cost, but the cheapest route might use too many stops. A pricier route with fewer stops might be the only legal one, and Dijkstra has already thrown it away. Bellman-Ford fits naturally, because round i means "paths with at most i edges". K stops is K + 1 flights, so I run K + 1 rounds.

static int findCheapestPrice(int n, int[][] flights, int src, int dst, int k) {
  int INF = Integer.MAX_VALUE / 2;
  int[] dist = new int[n];
  Arrays.fill(dist, INF);
  dist[src] = 0;
  for (int i = 0; i <= k; i++) {             // k stops = k + 1 edges
    int[] next = dist.clone();               // read old, write new
    for (int[] f : flights) {
      int u = f[0], v = f[1], w = f[2];
      if (dist[u] + w < next[v]) next[v] = dist[u] + w;
    }
    dist = next;
  }
  return dist[dst] >= INF ? -1 : dist[dst];
}
// flights 0->1 (100), 1->2 (100), 0->2 (500), src 0, dst 2
// k = 1 -> 200,  k = 0 -> 500

The bug is skipping the clone(). If I update dist in place, one round can chain several edges: it sets city 1 to 100, and then in the same round uses that new 100 to reach city 2. I tested it: with k = 0 the in-place version returns 200, which uses a stop it isn't allowed. The copy makes sure each round adds exactly one edge.

It's O(K x E). Dijkstra can also work if the state is (city, stops used), but the Bellman-Ford version is shorter and harder to get wrong.

Interviewers look for: the "read old, write new" copy. It's the single line that makes or breaks this problem.

AdvancedIn Floyd-Warshall, why must the k loop be outermost? And when would you use 0-1 BFS instead?

So Floyd-Warshall is a DP. After the k-th outer step, d[i][j] is the shortest path from i to j that uses only nodes 0..k as middle stops. Each step allows one more middle node. That only works if every pair is fully updated for node k before we allow node k + 1, so k has to be the outer loop.

static void floydWarshall(long[][] d) {      // d[i][j] = edge weight, or a big INF
  int n = d.length;
  for (int k = 0; k < n; k++)                // k MUST be outermost
    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];
}

If I put k inside, d[i][j] gets its final value while d[i][k] and d[k][j] are still unfinished, and it never gets revisited. The answer comes out wrong with no error. After it runs, a negative d[i][i] means node i is on a negative cycle. I use Long.MAX_VALUE / 4 as INF so that adding two of them doesn't overflow.

0-1 BFS is for a different case: single source, and every weight is 0 or 1. Think "free to move along an arrow, cost 1 to break a wall". I use a deque. A 0-edge pushes to the front and a 1-edge to the back. The deque then stays sorted by distance, like Dijkstra's heap, but each operation is O(1), so the total is O(V + E).

So I'd pick Floyd-Warshall only when V is a few hundred and I really need all pairs. V³ at 400 is 64 million steps, which is fine. At 5,000 it's far too slow.

27. Topological Sort, MST & More

BasicWhat is a topological sort, and when does one exist?

So a topological sort is an ordering of the nodes of a directed graph where every edge u -> v has u before v. It's "an order that respects every dependency." One exists exactly when the graph has no directed cycle, so a DAG.

Let me show you with a quick example. Courses: 0 is required for 1 and 2, and both 1 and 2 are required for 3.

0 -> 1 -> 3
0 -> 2 -> 3

valid orders: [0, 1, 2, 3] and [0, 2, 1, 3]
add 3 -> 0 and there is no valid order (0 needs 3 needs 1 needs 0)

Two things to notice. First, the order is usually not unique. Any node whose dependencies are done can go next. If the problem wants a specific one, like the smallest-first order, I swap the queue for a min-heap. Second, a cycle makes it impossible, and the algorithm gives me that check for free.

The signals that tell me to reach for it: "prerequisites", "build order", "tasks that depend on other tasks", "is it possible to finish", or "derive an ordering from pairwise comparisons" like Alien Dictionary.

There are two standard ways to compute it: Kahn's algorithm, which is BFS on in-degrees, and DFS, where I add each node after all its descendants and reverse. Both are O(V + E).

BasicWalk me through Course Schedule II using Kahn's algorithm.

So Kahn's algorithm repeatedly takes a node with no remaining prerequisites, outputs it, and removes its outgoing edges. That may free up new nodes. If I output all n nodes, that's my order. If I get stuck early, the leftover nodes are in a cycle.

static int[] findOrder(int n, int[][] prereq) {
  List<List<Integer>> g = new ArrayList<>();
  for (int i = 0; i < n; i++) g.add(new ArrayList<>());
  int[] indeg = new int[n];
  for (int[] p : prereq) {                 // [course, pre] means pre -> course
    g.get(p[1]).add(p[0]);
    indeg[p[0]]++;
  }
  Deque<Integer> q = new ArrayDeque<>();
  for (int i = 0; i < n; i++) if (indeg[i] == 0) q.offer(i);
  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);
    }
  }
  return idx == n ? order : new int[0];     // fewer than n means a cycle
}
// n = 4, [[1,0],[2,0],[3,1],[3,2]] -> [0, 1, 2, 3]
// n = 2, [[1,0],[0,1]]             -> []

The step people get wrong is the edge direction. [1, 0] means "to take 1 you need 0", so the edge is 0 -> 1. If I reverse it, I get a valid-looking order that's backwards.

The cycle check is the idx == n line. Nodes on a cycle all keep in-degree at least 1, so they never enter the queue. Course Schedule I is the same code returning idx == n as a boolean.

It's O(V + E) time and space. Each node is enqueued once, and each edge decrements once.

IntermediateFor cycle detection in a directed graph with DFS, why isn't a single visited array enough?

So in a directed graph, reaching an already-visited node doesn't mean there's a cycle. It might just be a node I finished earlier through another branch. I need to know if that node is on my current path. So I use three colours: white (not seen), gray (on the current recursion path), black (fully done).

static boolean hasCycle(List<List<Integer>> g) {
  int[] color = new int[g.size()];          // 0 white, 1 gray, 2 black
  for (int i = 0; i < g.size(); i++)
    if (color[i] == 0 && dfs(g, i, color)) return true;
  return false;
}

static boolean dfs(List<List<Integer>> g, int u, int[] color) {
  color[u] = 1;                             // on the current path
  for (int v : g.get(u)) {
    if (color[v] == 1) return true;         // back edge: cycle
    if (color[v] == 0 && dfs(g, v, color)) return true;
  }
  color[u] = 2;                             // fully explored, safe
  return false;
}
// diamond 0->1, 0->2, 1->3, 2->3 : false
// triangle 0->1->2->0           : true

The diamond is the case that trips a plain visited array. I go 0 -> 1 -> 3, finish, then 0 -> 2 -> 3. Node 3 is visited, but it's black, not gray, so it isn't a cycle. A single boolean would wrongly say "cycle" there.

In an undirected graph, one visited array plus "skip the parent" is enough, because every edge goes both ways anyway.

Bonus: if I append each node when it turns black and then reverse the list, I get a topological order. One gotcha is recursion depth. With 100,000 nodes in a chain, Java's default stack can overflow, so for big inputs I prefer Kahn's.

Interviewers look for: the diamond counterexample, and the directed vs undirected difference.

IntermediateKruskal vs Prim for a minimum spanning tree: how do they work, and which would you pick?

So both build the cheapest set of edges that connects every node with no cycle. Kruskal looks at all edges globally, cheapest first, and keeps an edge if it joins two different components. Prim grows one tree from a start node, always adding the cheapest edge that leaves the tree.

static long kruskal(int n, int[][] edges) {  // edges: {u, v, w}
  parent = new int[n];
  rank = new int[n];
  for (int i = 0; i < n; i++) parent[i] = i;
  Arrays.sort(edges, (a, b) -> Integer.compare(a[2], b[2]));
  long total = 0;
  int used = 0;
  for (int[] e : edges) {
    if (union(e[0], e[1])) {                // false if already connected
      total += e[2];
      if (++used == n - 1) break;           // tree is complete
    }
  }
  return used == n - 1 ? total : -1;        // -1: graph not connected
}
// 4 nodes, edges (0-1,1) (1-2,2) (0-2,3) (2-3,4) (1-3,5) -> 7

Here union is the standard union-find with path compression and union by rank. It returns false when both ends already have the same root, which is exactly "this edge would make a cycle". In the example, 0-2 with cost 3 is skipped for that reason.

Kruskal is O(E log E), and sorting is most of that. Prim with a binary heap is O(E log V). On paper they're close. How I pick: if I already have an edge list, or the graph is sparse, Kruskal. If the graph is dense or implicit, like "every pair of points is an edge" in Min Cost to Connect All Points, I use Prim. The simple array version of Prim is O(V²) and never builds the V² edges at all.

Both are greedy, and both are correct because of the cut property: the cheapest edge crossing any cut is safe to include.

Interviewers look for: union-find for cycle checks in Kruskal, and a reason to prefer one over the other that depends on density.

IntermediateHow would you approach Alien Dictionary?

So I turn the sorted word list into a graph of letter rules, then topologically sort the letters. Each pair of adjacent words gives at most one rule: the first position where they differ says which letter comes first.

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"
    for (int j = 0; j < Math.min(a.length(), b.length()); j++) {
      char x = a.charAt(j), y = b.charAt(j);
      if (x != y) {
        if (g.get(x).add(y)) indeg.merge(y, 1, Integer::sum);
        break;                              // only the FIRST difference counts
      }
    }
  }
  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() : "";
}
// ["wrt","wrf","er","ett","rftt"] -> "wertf"

Three traps I watch for. Only the first difference gives a rule. After that, the rest of the letters tell you nothing. A longer word before its own prefix, like "abc" then "ab", is invalid input, so I return empty. And I add each edge once, which is why I check add(y) before bumping the in-degree. Otherwise duplicate rules inflate in-degrees and the sort gets stuck.

Every letter that appears must be in the output, even if no rule mentions it. That's why I register all letters first. It's O(total characters) to build and O(V + E) to sort, where V is at most 26.

AdvancedHow do you find bridges in a graph, like in Critical Connections in a Network?

So a bridge is an edge whose removal disconnects the graph. Tarjan's idea is one DFS that records, for every node, its discovery time disc and a low value: the earliest discovery time it can reach using its subtree plus one back edge. An edge u -> v in the DFS tree is a bridge when low[v] > disc[u]. That means nothing below v has another way back up to u or above.

static void bridgeDfs(List<List<Integer>> g, int u, int parent) {
  disc[u] = low[u] = timer++;
  for (int v : g.get(u)) {
    if (v == parent) continue;              // don't use the edge we came in on
    if (disc[v] == -1) {
      bridgeDfs(g, v, u);
      low[u] = Math.min(low[u], low[v]);
      if (low[v] > disc[u]) bridges.add(List.of(u, v));
    } else {
      low[u] = Math.min(low[u], disc[v]);   // back edge
    }
  }
}
// edges 0-1, 1-2, 2-0, 1-3  -> bridges [[1, 3]]

In the example, the triangle 0-1-2 has back edges, so every node in it gets low = 0, and none of its edges are bridges. Node 3 only hangs off 1, so low[3] = disc[3], which is greater than disc[1]. So 1-3 is the bridge.

Articulation points (cut vertices) use the same arrays with >= instead of >, and a special rule that the DFS root is a cut vertex only if it has two or more children.

It's O(V + E). The gotcha in Java is recursion depth. LC 1192 allows 100,000 nodes, and a long chain can overflow the default stack, so in production I'd write it iteratively or run it on a thread with a bigger stack. Skipping the parent by node id also breaks if there are parallel edges. In that case I skip by edge id instead.

Interviewers look for: a clear meaning for low, and the > vs >= difference between bridges and articulation points.

28. String Algorithms

BasicWhy is building a string with s += c in a loop slow in Java, and what do you use instead?

So Java strings are immutable. Every s += c creates a brand new string and copies all the old characters into it. Doing that n times copies 1 + 2 + ... + n characters, which is O(n²). A StringBuilder appends into a growable buffer, so each append is amortised O(1) and the whole loop is O(n).

// O(n^2): a new String on every pass
String s = "";
for (char c : chars) s += c;

// O(n): one buffer, doubled when full
StringBuilder sb = new StringBuilder();
for (char c : chars) sb.append(c);
String result = sb.toString();

What happens under the hood is that the compiler turns each += into its own concatenation. It can't reuse the buffer across loop iterations, so you pay the full copy every time. With 100,000 characters that's around 5 billion character copies, which is why it times out.

A few related things I keep in mind. substring copies too, so it's O(length), not free. In a hot loop I compare characters with charAt rather than building substrings. For counting letters I use an int[26] or int[128] instead of a HashMap<Character, Integer>, which avoids boxing. And toCharArray() is handy when I want to sort or swap characters in place.

So in practice, any time I build a string in a loop, I reach for StringBuilder without thinking about it.

BasicWhat's the complexity of naive string matching, and what input makes it slow?

So the naive approach tries every start position in the text and compares the pattern character by character. That's O(n x m) in the worst case, for text length n and pattern length m.

static int naive(String text, String p) {
  for (int i = 0; i + p.length() <= text.length(); i++) {
    int j = 0;
    while (j < p.length() && text.charAt(i + j) == p.charAt(j)) j++;
    if (j == p.length()) return i;
  }
  return -1;
}
// worst case: text = "aaaa...a" (n chars), p = "aaa...ab" (m chars)
// every start matches m - 1 chars, then fails on the 'b'

The slow case is lots of near-misses. With a text of all as and a pattern of as ending in b, every start position gets almost all the way through before failing. The waste is that after a failure, we go back and re-read text characters we have already matched.

On random or normal text it's fast, because most attempts fail on the first or second character. That's why Java's String.indexOf is basically this naive loop and still works fine in everyday code.

So in an interview I'd say: indexOf is fine unless the input is adversarial or huge. If they want a guaranteed O(n + m), I'd use KMP, which never moves backwards in the text. Or I'd use Rabin-Karp, which compares hashes instead of characters.

IntermediateWalk me through KMP. What is the LPS array?

So KMP avoids re-reading the text. When a mismatch happens after matching j characters, it already knows what those characters were: the first j of the pattern. The LPS array tells it how much of that is still usable. lps[i] is the length of the longest proper prefix of p[0..i] that is also a suffix of it.

static int[] buildLps(String p) {
  int[] lps = new int[p.length()];
  int len = 0;                               // length of current border
  for (int i = 1; i < p.length(); ) {
    if (p.charAt(i) == p.charAt(len)) {
      lps[i++] = ++len;
    } else if (len > 0) {
      len = lps[len - 1];                    // fall back, don't move i
    } else {
      lps[i++] = 0;
    }
  }
  return lps;
}

static int kmpSearch(String text, String p) {
  if (p.isEmpty()) return 0;
  int[] lps = buildLps(p);
  int j = 0;                                 // chars of p matched so far
  for (int i = 0; i < text.length(); i++) {
    while (j > 0 && text.charAt(i) != p.charAt(j)) j = lps[j - 1];
    if (text.charAt(i) == p.charAt(j)) j++;
    if (j == p.length()) return i - j + 1;   // first match
  }
  return -1;
}
// buildLps("ababaca") -> [0, 0, 1, 2, 3, 0, 1]
// kmpSearch("ababcabcabababd", "ababd") -> 10

Take "ababaca". At index 4 the prefix is "ababa", and "aba" is both its prefix and its suffix, so lps[4] = 3. If a mismatch happens right after matching "ababa", I don't restart. I keep 3 characters matched and try again from there.

The index i in the text only moves forward. j can fall back, but it can only fall back as much as it has gone up, so the total work is O(n + m). That's amortised analysis, and it's the part interviewers want explained.

Interviewers look for: a clear one-line definition of LPS and the "text pointer never goes back" argument. Writing it from memory is less important than explaining it.

IntermediateHow does Rabin-Karp work, and how do you deal with hash collisions?

So Rabin-Karp turns each window of the text into a number (a polynomial hash) and compares numbers instead of strings. The trick is the rolling hash: moving the window one step right is O(1). I subtract the leaving character, shift everything by the base, and add the new one.

static List<Integer> rabinKarp(String text, String p) {
  List<Integer> res = new ArrayList<>();
  int n = text.length(), m = p.length();
  if (m == 0 || m > n) return res;
  final long MOD = 1_000_000_007L, BASE = 131;
  long pow = 1;                              // BASE^(m-1)
  for (int i = 0; i < m - 1; i++) pow = pow * BASE % MOD;
  long hp = 0, hw = 0;
  for (int i = 0; i < m; i++) {
    hp = (hp * BASE + p.charAt(i)) % MOD;
    hw = (hw * BASE + text.charAt(i)) % MOD;
  }
  for (int i = 0; ; i++) {
    if (hp == hw && text.regionMatches(i, p, 0, m)) res.add(i);  // confirm
    if (i + m == n) break;
    hw = (hw - text.charAt(i) * pow % MOD + MOD) % MOD;          // drop left
    hw = (hw * BASE + text.charAt(i + m)) % MOD;                 // add right
  }
  return res;
}
// rabinKarp("abracadabra", "abra") -> [0, 7]

Collisions are real: two different strings can hash to the same value. So when the hashes match, I confirm with a real comparison, regionMatches. That keeps the answer always correct. The expected time is O(n + m). The worst case is O(nm) if everything collides, which a good modulus makes very unlikely.

Two details that bite in Java. The + MOD after subtracting, because % can return a negative. And every product is at most about 109 times a char or the base, far below the long limit, so nothing overflows.

Where Rabin-Karp beats KMP is when I'm matching many patterns of the same length, or comparing lots of substrings to each other. The hashes go into a set. If I can't confirm every match and need extra safety, I use two different mods (double hashing).

IntermediateFind the longest palindromic substring. What's your approach and complexity?

So I expand around every center. A palindrome mirrors around its middle, and there are 2n - 1 possible middles: each character (odd length) and each gap between two characters (even length). From each one, I grow outwards while the ends match.

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
    int even = expand(s, c, c + 1);          // center between chars
    int len = Math.max(odd, even);
    if (len > best) {
      best = len;
      start = c - (len - 1) / 2;
    }
  }
  return s.substring(start, start + best);
}

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;
}
// "babad" -> "bab"   "cbbd" -> "bb"

It's O(n²) time in the worst case, like a string of all the same letter, and O(1) extra space. That beats the DP table version, which is also O(n²) time but uses O(n²) memory for no gain.

The fiddly part is the start index. When the loop stops, l and r are one step past the palindrome, so the length is r - l - 1. c - (len - 1) / 2 works for both odd and even lengths. I usually check it on "cbbd": center 1, even length 2, start = 1.

If they push for linear time, that's Manacher's algorithm, O(n). I'd name it and explain that it reuses mirrored radii, but I wouldn't write it unless asked.

AdvancedHow would you find the longest substring that appears at least twice?

So I binary search on the length, and check each length with a rolling hash. If some substring of length L repeats, then a shorter one repeats too (just take its prefix). That monotonic yes/no means binary search is valid. Each check slides a window of length L, stores hashes, and looks for a repeat.

static String longestDupSubstring(String s) {
  int lo = 1, hi = s.length() - 1;
  int bestStart = -1, bestLen = 0;
  while (lo <= hi) {
    int mid = (lo + hi) >>> 1;
    int start = findDup(s, mid);
    if (start != -1) { bestStart = start; bestLen = mid; lo = mid + 1; }
    else hi = mid - 1;
  }
  return bestStart == -1 ? "" : s.substring(bestStart, bestStart + bestLen);
}

static int findDup(String s, int len) {       // start of a repeated length-len substring, or -1
  final long M = 1_000_000_007L, B = 131;
  long pow = 1, h = 0;
  for (int i = 0; i < len; i++) { h = (h * B + s.charAt(i)) % M; if (i > 0) pow = pow * B % M; }
  Map<Long, List<Integer>> seen = new HashMap<>();
  seen.computeIfAbsent(h, k -> new ArrayList<>()).add(0);
  for (int i = 1; i + len <= s.length(); i++) {
    h = (h - s.charAt(i - 1) * pow % M + M) % M;
    h = (h * B + s.charAt(i + len - 1)) % M;
    List<Integer> starts = seen.computeIfAbsent(h, k -> new ArrayList<>());
    for (int j : starts)
      if (s.regionMatches(i, s, j, len)) return i;   // real match, not a collision
    starts.add(i);
  }
  return -1;
}
// "banana" -> "ana"   "abcd" -> ""

Expected cost is O(n log n): log n binary search steps, each an O(n) scan. I keep a list of start positions per hash and confirm with regionMatches, so a collision can't give a wrong answer. It only costs extra time.

The pattern here is useful beyond this problem: "binary search on the answer, plus a linear check". I also mention the textbook alternative, a suffix array with an LCP array, which gives the answer directly as the max LCP value. But it's much more code for an interview.

Interviewers look for: the argument for why the check is monotonic, and how you handle collisions. On LeetCode 1044, a single weak hash without verification fails on purpose-built tests.

29. Math & Number Theory

BasicHow do you compute GCD and LCM, and why does Euclid's algorithm work?

So Euclid's algorithm uses one fact: gcd(a, b) = gcd(b, a % b). Any number that divides both a and b also divides a - k*b, which is the remainder. So I keep replacing the pair until the second number is zero, and the first is the answer.

static long gcd(long a, long b) {
  while (b != 0) {
    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): (48,18) -> (18,12) -> (12,6) -> (6,0) -> 6
// lcm(4, 6) = 12

It's O(log min(a, b)). The quick reason is that every two steps the bigger number at least halves. The worst case is two consecutive Fibonacci numbers.

For LCM, the formula is a * b / gcd, but I write it as a / gcd * b. Multiplying first can overflow even when the final LCM fits. Dividing first keeps the middle value small.

Where I've used GCD in problems: simplifying fractions, "GCD of strings" (LC 1071), checking whether a jug amount is reachable (it has to be a multiple of the GCD), and reducing a slope dy/dx to a canonical key when counting points on a line.

BasicCount the primes below n. Walk me through the Sieve of Eratosthenes.

So instead of testing each number, I cross out multiples. I walk from 2 upward. Any number that is still not crossed out is prime, and I cross out all its multiples. By the end, everything unmarked is prime.

static int countPrimes(int n) {              // primes strictly less than n
  if (n < 3) return 0;
  boolean[] composite = new boolean[n];
  int count = 0;
  for (int i = 2; i < n; i++) {
    if (composite[i]) continue;
    count++;
    for (long j = (long) i * i; j < n; j += i) {   // long: i*i can overflow
      composite[(int) j] = true;
    }
  }
  return count;
}
// countPrimes(10) -> 4   (2, 3, 5, 7)
// countPrimes(1_000_000) -> 78498

Why start crossing at i * i? Any smaller multiple, like 3 * i, has a smaller prime factor, so it was already crossed out by that factor. That's also why I only need to sieve with primes up to sqrt(n).

The complexity is O(n log log n), which is basically linear in practice, and O(n) memory. Checking each number by trial division would be O(n sqrt n).

The gotcha is the i * i. For i around 50,000, i * i is bigger than Integer.MAX_VALUE and wraps to a negative number. As an int, the loop might then write to a negative index. Using a long for j avoids it.

For a single number I'd just do trial division up to sqrt(n). The sieve pays off when I need many primes, or many answers, up to some limit.

BasicWhat integer overflow traps do you watch for in Java?

So Java integers wrap around silently. There's no exception, just a wrong number. I watch for a handful of specific places where that happens in DSA code.

int a = 100_000, b = 100_000;
long wrong = (long) (a * b);   // 1410065408: multiplied as int, then widened
long right = (long) a * b;     // 10000000000: widened first

Integer.MAX_VALUE + 1          // -2147483648
Math.abs(Integer.MIN_VALUE)    // -2147483648 (still negative!)
-7 % 3                         // -1
Math.floorMod(-7, 3)           // 2

int lo = 2_000_000_000, hi = 2_100_000_000;
(lo + hi) / 2                  // -97483648 (overflowed)
lo + (hi - lo) / 2             // 2050000000
(lo + hi) >>> 1                // 2050000000

Math.multiplyExact(a, b)       // throws ArithmeticException

The one that catches most people is the cast. (long) (a * b) multiplies as int first, so it has already overflowed before the cast. The cast has to be on an operand.

The others I check for: the binary search midpoint; Integer.MAX_VALUE used as infinity and then having something added to it; Math.abs and negation of MIN_VALUE (which is why reversing an integer or pow(x, n) need a long); comparators written as a - b; and a % of a negative number used as an array index.

So in practice, I use long for any sum or product that could pass about 2 x 109. When I want a loud failure instead of a silent one, I use Math.addExact or multiplyExact.

Interviewers look for: the (long) (a * b) trap and abs(MIN_VALUE). Both come up in "reverse integer" and "pow" follow-ups.

IntermediateImplement Pow(x, n) in O(log n). What edge case breaks naive solutions?

So I use binary exponentiation. I read the exponent in binary. x^13 is x^8 * x^4 * x^1, because 13 is 1101. I keep squaring x to get x, x², x⁴, x⁸, and multiply into the result whenever the current bit is 1. That's O(log n) multiplications instead of n.

static double myPow(double x, int n) {
  long e = n;                                // -Integer.MIN_VALUE overflows an int
  if (e < 0) { x = 1 / x; e = -e; }
  double result = 1;
  while (e > 0) {
    if ((e & 1) == 1) result *= x;           // this bit is set: use current power
    x *= x;                                  // x, x^2, x^4, x^8 ...
    e >>= 1;
  }
  return result;
}
// myPow(2, 10) -> 1024.0    myPow(2, -2) -> 0.25

The edge case is n = Integer.MIN_VALUE. To handle a negative exponent I flip it to positive, but -(-2147483648) is still -2147483648 as an int. The loop then runs on a negative exponent and gives the wrong answer. Copying to a long first fixes it. That's the test case LC 50 includes on purpose.

The same loop with % mod after every multiply is modular exponentiation, modPow(base, exp, mod). That's what I use for things like a^b mod 1_000_000_007 and for modular inverses. With long and a mod near 109, each product stays under 1018, which fits.

A linear loop would also time out here, because n can be about two billion.

IntermediateWhy do problems ask for the answer mod 1_000_000_007, and how do you compute nCr under a mod?

So the true answer is too big for a long, and the mod keeps it small while still letting them check correctness. 1_000_000_007 is chosen because it's prime and fits in an int. The product of two values under it still fits in a long. And being prime means every non-zero number has a modular inverse, which is how I "divide".

The rules: add, subtract and multiply all commute with mod, so I mod after every step. Subtraction can go negative, so I add MOD back. Division is not allowed directly. Instead, I multiply by the inverse, which by Fermat's little theorem is a^(MOD - 2).

static final long MOD = 1_000_000_007L;

static long inverse(long a) {
  return modPow(a, MOD - 2, MOD);            // Fermat: a^(p-2) = a^-1 mod p
}
// modPow is the binary-power loop from Pow(x, n), with % MOD after each multiply

static long[] fact, invFact;
static void precompute(int n) {
  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] = inverse(fact[n]);             // one modPow
  for (int i = n; i > 0; i--) invFact[i - 1] = invFact[i] * i % MOD;
}

static long nCr(int n, int r) {
  if (r < 0 || r > n) return 0;
  return fact[n] * invFact[r] % MOD * invFact[n - r] % MOD;
}
// inverse(2) -> 500000004    nCr(5, 2) -> 10    (3 - 5) % MOD -> -2 (add MOD!)

After an O(n) precompute, each nCr is O(1). The backwards loop for invFact is a small trick: I compute one inverse with modPow, then get the rest by multiplying, since 1/(i-1)! = i/i!.

If n is small, like 1,000, I'd just use Pascal's triangle, C[n][r] = C[n-1][r-1] + C[n-1][r]. It needs no inverses at all.

Interviewers look for: "you can't divide under a mod, you multiply by the inverse", and the negative-after-subtraction fix.

AdvancedPick a uniformly random element from a stream of unknown length, using O(1) memory.

So that's reservoir sampling. I keep one "chosen" element. When the i-th element arrives (counting from 1), I replace my choice with it with probability 1/i. When the stream ends, every element has been chosen with probability exactly 1/n, even though I never knew n.

static int reservoir(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;
}
// 100,000 runs over [0..4] gave about 20,000 each:
// [20028, 20047, 20033, 19874, 20018]

The proof is a short product. For element i to be the final answer, it has to be picked when it arrives, with probability 1/i. Then it must survive every later step. At step i + 1 it survives with i/(i+1), at i + 2 with (i+1)/(i+2), and so on up to (n-1)/n. That product cancels down to 1/n.

For k samples, I fill the reservoir with the first k items. Then for item i > k, I draw r = nextInt(i), and if r < k I replace slot r. The same argument gives each item a k/n chance.

Where it shows up: Linked List Random Node (LC 382), Random Pick Index (LC 398), and in real systems, sampling log lines or events from a stream too big to store.

Interviewers look for: the survival-product proof, not just the code. The usual follow-up is "now pick k items".

Part 5 — Problem-Solving Patterns

30. Two Pointers

BasicHow do you recognise that a problem is a two-pointer problem?

So the strongest signal is a sorted array (or one I'm allowed to sort) where I need a pair or triplet with some sum. The other signals are "compare from both ends", like palindromes and reversing, and "modify in place with O(1) extra space". Underneath all of them, the brute force is two nested loops, and the ordering lets me throw away one side at every step.

There are two flavours, and I decide which one early:

FlavourPointersTypical problems
Opposite endsl = 0, r = n - 1, walk inwardsTwo Sum II, 3Sum, Container With Most Water, Valid Palindrome, Trapping Rain Water
Same directionfast reads, slow marks the next write spotRemove Duplicates, Move Zeroes, Remove Element

Let me show you the same-direction flavour quickly, since it looks different:

static int removeDuplicates(int[] nums) {    // sorted input
  if (nums.length == 0) return 0;
  int slow = 1;                              // next write position
  for (int fast = 1; fast < nums.length; fast++) {
    if (nums[fast] != nums[slow - 1]) nums[slow++] = nums[fast];
  }
  return slow;
}
// [0,0,1,1,1,2,2,3,3,4] -> 5, array starts with [0,1,2,3,4]

What happens is that fast scans everything, and slow only moves when I keep an element. Everything before slow is the finished answer.

So in practice, when I read "sorted", "pair", "in place", or "palindrome", I try two pointers first. It usually turns an O(n²) brute force into O(n) with O(1) space.

Interviewers look for: that you name the flavour and say what each pointer means. They'll ask "what if the array isn't sorted?" next.

BasicWalk me through Two Sum II on a sorted array, and convince me it can't miss the answer.

So I start with one pointer at each end. If the sum is too small, I move the left pointer right to get a bigger number. If it's too big, I move the right pointer left. I stop when they meet or I find the target.

static int[] twoSum(int[] nums, int target) {  // sorted input, 1-indexed answer
  int l = 0, r = nums.length - 1;
  while (l < r) {
    int sum = nums[l] + nums[r];
    if (sum == target) return new int[]{l + 1, r + 1};
    if (sum < target) l++;                   // need bigger: move the small end up
    else r--;                                // need smaller: move the big end down
  }
  return new int[]{-1, -1};
}
// [2, 7, 11, 15], target 9 -> [1, 2]

Why it can't miss: when nums[l] + nums[r] is too small, nums[l] paired with anything to the left of r is even smaller, because the array is sorted. So nums[l] can't be in any answer, and I can safely drop it. The same argument, mirrored, works for dropping nums[r] when the sum is too big. Each step removes one element that can't be part of the answer, so the real pair is never skipped.

It's O(n) time and O(1) space. The hash map version is also O(n), but it uses O(n) memory. With sorted input, two pointers is strictly better.

One small thing: if values can be near Integer.MAX_VALUE, I compute the sum as a long.

IntermediateWalk me through 3Sum. How do you avoid duplicate triplets?

So I sort the array, fix the first number with a loop, and run Two Sum II on the rest for target -nums[i]. That's O(n²) overall. Sorting also solves the duplicates problem, because equal values end up next to each other and I can just skip over them.

static List<List<Integer>> threeSum(int[] nums) {
  Arrays.sort(nums);
  List<List<Integer>> res = new ArrayList<>();
  for (int i = 0; i < nums.length - 2; i++) {
    if (i > 0 && nums[i] == nums[i - 1]) continue;   // skip duplicate anchors
    if (nums[i] > 0) break;                          // smallest is positive: done
    int l = i + 1, r = nums.length - 1;
    while (l < r) {
      int sum = nums[i] + nums[l] + nums[r];
      if (sum < 0) l++;
      else if (sum > 0) r--;
      else {
        res.add(List.of(nums[i], nums[l], nums[r]));
        l++;
        r--;
        while (l < r && nums[l] == nums[l - 1]) l++;  // skip duplicate lefts
      }
    }
  }
  return res;
}
// [-1, 0, 1, 2, -1, -4] -> [[-1, -1, 2], [-1, 0, 1]]
// [0, 0, 0, 0]          -> [[0, 0, 0]]

There are two places to dedupe. For the anchor, I skip i if it equals the previous anchor. I compare with i - 1, not i + 1, so that a triplet like [-1, -1, 2] can still use two equal values. For the pair, after a hit I move both pointers and skip left values equal to the one I just used. Skipping on the left is enough: once the left value is new, the matching right value must be new too.

The nums[i] > 0 break is a small optimisation. If the smallest of the three is positive, the sum can't be zero.

A HashSet of triplets also removes duplicates, but it's slower and it hides the reasoning, so I prefer the skip loops.

IntermediateContainer With Most Water: why is it safe to always move the shorter wall?

So the area is min(h[l], h[r]) x (r - l). The shorter wall caps the height. If I moved the taller wall inwards, the width drops and the height is still capped by the same short wall, so the area can only get worse. Every container that uses the short wall with a closer partner is no better than the one I just measured. So I can drop the short wall for good.

static int maxArea(int[] h) {
  int l = 0, r = h.length - 1, best = 0;
  while (l < r) {
    best = Math.max(best, Math.min(h[l], h[r]) * (r - l));
    if (h[l] < h[r]) l++;                    // the shorter wall is the limit
    else r--;
  }
  return best;
}
// [1, 8, 6, 2, 5, 4, 8, 3, 7] -> 49   (walls at index 1 and 8: min(8, 7) x 7)

Let me trace the start. l = 0 (height 1), r = 8 (height 7): area 8. The left wall is shorter, and any container using height 1 is at most 1 tall, so index 0 is done. Move l. Now h[1] = 8 and h[8] = 7: area 49, which turns out to be the answer.

When the walls are equal, I can move either one. Neither can be part of a bigger container with a closer partner.

It's O(n) instead of checking all O(n²) pairs. I've checked this against the brute force on random arrays and they always match.

Interviewers look for: the argument, not just the code. "Move the shorter one" without the reason sounds memorised.

IntermediateHow do you solve Trapping Rain Water with two pointers?

So the water above each bar is min(maxLeft, maxRight) - height. The two-pointer trick is that I don't need both maxes exactly. If h[l] < h[r], I know there's a wall on the right at least as tall as h[l]. So the water at l is decided by leftMax alone, and I can settle that bar and move on.

static int trap(int[] h) {
  int l = 0, r = h.length - 1;
  int leftMax = 0, rightMax = 0, water = 0;
  while (l < r) {
    if (h[l] < h[r]) {                       // left side is the bottleneck
      leftMax = Math.max(leftMax, h[l]);
      water += leftMax - h[l];
      l++;
    } else {
      rightMax = Math.max(rightMax, h[r]);
      water += rightMax - h[r];
      r--;
    }
  }
  return water;
}
// [0,1,0,2,1,0,1,3,2,1,2,1] -> 6

What happens is that I always process the lower side. The lower side's max is the limiting wall, because the other side already has something at least as tall. Updating leftMax before adding means a new tallest bar just adds zero, which handles that case without an extra branch.

The simpler way is two prefix arrays, maxLeft[i] and maxRight[i], then one pass. That's also O(n) time but O(n) space. I usually explain that version first, because it makes the formula obvious, then compress it to two pointers for O(1) space.

A monotonic stack also works and computes water layer by layer, which some people find easier to extend. I checked the two-pointer version against a brute force on random arrays and they matched every time.

AdvancedWhen does two pointers NOT apply? Give me examples where it's the wrong tool.

So two pointers only works when moving a pointer lets me safely throw something away. That needs some kind of order: a sorted array, or a rule that changes in one direction. Without that, moving a pointer can skip the answer.

The cases I watch for:

Unsorted input where I need original indices. Classic Two Sum returns indices. Sorting destroys them, and sorting costs O(n log n) anyway. A hash map does it in one pass.

static int[] twoSumUnsorted(int[] nums, int target) {
  Map<Integer, Integer> seen = new HashMap<>();   // value -> index
  for (int i = 0; i < nums.length; i++) {
    Integer j = seen.get(target - nums[i]);
    if (j != null) return new int[]{j, i};
    seen.put(nums[i], i);
  }
  return new int[]{-1, -1};
}
// [3, 2, 4], target 6 -> [1, 2]

Subarray sums with negative numbers. "Count subarrays with sum k" looks like a window, but with negatives, growing the window can make the sum smaller. So there's no safe direction to move. That's a prefix sum plus hash map problem.

Counting all pairs, not finding one. "How many pairs sum to less than k" still works with sorting (add r - l pairs at a time). But "how many pairs have a sum divisible by k" has no order to exploit, so I'd count remainders in a map instead.

Ordering is not the relevant property. For "closest pair of points in 2D", sorting by x doesn't make the pointer moves safe. It needs divide and conquer or a sweep.

So my check is: can I say, in one sentence, why the element I'm skipping can never be part of the answer? If I can't, I don't use two pointers.

Interviewers look for: the hash map fallback for unsorted Two Sum, and the negative-numbers case that pushes you to prefix sums.

31. Sliding Window

BasicHow do you recognise a sliding window problem? What's the difference between a fixed and a variable window?

So the words I listen for are "contiguous subarray" or "substring", plus "longest", "shortest", "at most k", or "every window of size k". The key requirement is that the rule is monotonic: if a window breaks the rule, making it bigger keeps it broken. That's what makes it safe to shrink from the left.

A fixed window has size k the whole time. Every step, one element comes in and one goes out.

static int maxSumK(int[] a, int k) {
  int sum = 0;
  for (int i = 0; i < k; i++) sum += a[i];
  int best = sum;
  for (int r = k; r < a.length; r++) {
    sum += a[r] - a[r - k];                  // one in, one out
    best = Math.max(best, sum);
  }
  return best;
}
// [2, 1, 5, 1, 3, 2], k = 3 -> 9   (5 + 1 + 3)

A variable window grows by moving right every step. When the window breaks the rule, left moves forward until it's valid again. Both pointers only go forward, so each element enters once and leaves once. That's O(n), even though there's a loop inside a loop.

I also watch for "subsequence". A subsequence can skip elements, and a window can't, so that's usually DP instead.

So my first question on any array or string problem is "is the answer contiguous?" If yes, I try a window. Then I ask whether the size is fixed or depends on a rule.

BasicWalk me through Longest Substring Without Repeating Characters.

So I keep a window with no repeated characters and a count of each character in it. I extend right one character at a time. If that character now appears twice, I shrink from left until it appears once. After each step, the window is valid, so I record its length.

static int lengthOfLongestSubstring(String s) {
  int[] count = new int[128];
  int left = 0, best = 0;
  for (int right = 0; right < s.length(); right++) {
    char c = s.charAt(right);
    count[c]++;
    while (count[c] > 1) {                   // window broke the rule: shrink
      count[s.charAt(left)]--;
      left++;
    }
    best = Math.max(best, right - left + 1);
  }
  return best;
}
// "abcabcbb" -> 3 ("abc")    "pwwkew" -> 3 ("wke")

Let me trace "pwwkew". The window grows to "pw". The second w makes w appear twice, so I drop p, then the first w, and I'm left with "w". Then it grows to "wke", length 3. The last w forces the window down to "kew", still 3.

I use int[128] instead of a HashMap because the input is ASCII. It's faster and avoids boxing. For full Unicode I'd switch to a map.

There's also a jump version: store each character's last index and set left = max(left, last[c] + 1) directly. The max is the part people forget. Without it, left can jump backwards to an old position. Both versions are O(n).

IntermediateWalk me through Minimum Window Substring.

So this is the "shortest" version of the window. I grow right until the window covers every character of t, with the right counts. Then, while it's still valid, I shrink from left and record the smallest valid window. When shrinking breaks it, I go back to growing.

static 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
  int left = 0, bestStart = 0, bestLen = Integer.MAX_VALUE;
  for (int right = 0; right < s.length(); right++) {
    if (need[s.charAt(right)]-- > 0) missing--;
    while (missing == 0) {                   // valid: try to shrink
      if (right - left + 1 < bestLen) {
        bestLen = right - left + 1;
        bestStart = left;
      }
      if (++need[s.charAt(left)] > 0) missing++;   // lost a needed char
      left++;
    }
  }
  return bestLen == Integer.MAX_VALUE ? "" : s.substring(bestStart, bestStart + bestLen);
}
// minWindow("ADOBECODEBANC", "ABC") -> "BANC"

The trick that keeps it O(n) is the single missing counter. I don't compare two maps each step. need[c] can go negative, which means the window has extra copies of c. missing only drops when I add a character that was actually needed, and only rises when I remove one that becomes needed again.

Characters not in t just go negative and never affect missing, so I don't need to special-case them.

Compare with the previous problem: there I record the answer after shrinking, because I want the longest valid window. Here I record it inside the shrink loop, because I want the shortest. That one placement is the difference between the "longest" and "shortest" templates.

Interviewers look for: the missing counter instead of comparing maps, and duplicates in t handled correctly ("AA" needs two A's).

IntermediateIn Longest Repeating Character Replacement, why don't you need to decrease maxFreq when the window shrinks?

So a window is valid if length - maxFreq <= k: I replace everything except the most common letter. When left moves, the true max frequency might drop, and I don't update it. That's fine, because I only care about finding a longer window. A longer window can only beat my best if some letter reaches a higher frequency than before. So a stale, too-high maxFreq never makes me report a wrong answer.

static 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']);
    if (right - left + 1 - maxFreq > k) {    // too many to replace
      count[s.charAt(left) - 'A']--;
      left++;                                // slide, don't shrink
    }
    best = Math.max(best, right - left + 1);
  }
  return best;
}
// ("AABABBA", 1) -> 4    ("ABAB", 2) -> 4

Notice it's an if, not a while. Once I've found a window of size w, I never let it get smaller. When it's invalid I move both ends by one, so the window slides at its best size and only grows when maxFreq goes up. The window at the end may not be valid, but best was recorded when it was.

This is subtle, so in an interview I'd say the safe version first: recompute the max over 26 counts when needed, which is O(26n). Then I'd offer this as the optimisation. I checked it against a brute force on random strings.

Interviewers look for: a real explanation of the stale maxFreq. Many candidates write this code but can't say why it's correct.

IntermediateHow do you count subarrays with exactly K distinct values? A plain window doesn't seem to work.

So "exactly K" isn't monotonic. Adding an element can take the window from too few to exactly right to too many, so there's no single rule to shrink on. But "at most K" is monotonic. So I compute exactly(K) = atMost(K) - atMost(K - 1).

static int subarraysWithKDistinct(int[] nums, int k) {
  return atMost(nums, k) - atMost(nums, k - 1);
}

static int atMost(int[] nums, int k) {
  Map<Integer, Integer> freq = new HashMap<>();
  int left = 0, count = 0;
  for (int right = 0; right < nums.length; right++) {
    if (freq.merge(nums[right], 1, Integer::sum) == 1) k--;   // new distinct value
    while (k < 0) {
      if (freq.merge(nums[left], -1, Integer::sum) == 0) k++;
      left++;
    }
    count += right - left + 1;               // every subarray ending at right
  }
  return count;
}
// [1, 2, 1, 2, 3], K = 2 -> 7

The counting line is the key idea. Once the window [left, right] is valid, every subarray that ends at right and starts anywhere from left to right is also valid, since it's a smaller piece. That's right - left + 1 subarrays, added in one go.

I use k itself as the "distinct slots left" budget. It goes down when a new value enters and up when a value's count hits zero.

The same trick handles "Count Number of Nice Subarrays" (exactly K odd numbers) and "Binary Subarrays With Sum". Whenever I see "exactly", I check if "at most" is monotonic and subtract. Total time is two O(n) passes.

AdvancedWhen does a sliding window NOT work, and what do you use instead?

So a window needs one thing: when the window breaks the rule, moving left forward must be the only fix, and making it bigger must never fix it. When that's false, shrinking can throw away the answer.

The classic failure is a sum target with negative numbers. Take "count subarrays summing to 0" on [1, -1, 1, -1]. There's no rule for when to shrink. A sum that's too big can come back down by adding a negative. So I switch to prefix sums plus a hash map, which is O(n) and doesn't care about signs.

// "shortest subarray with sum at least K", with negatives (LC 862):
// a plain window is wrong. Use prefix sums + a monotonic deque of indices:
//   pre[j] - pre[i] >= K  ->  pop i from the front, record j - i
//   keep deque prefix values increasing (pop the back while pre[back] >= pre[j])

Other cases where I change tools:

Problem shapeWhy a window failsWhat I use
Subarray sum = k with negativesNot monotonicPrefix sum + hash map
Max or min in every window of size kA window works, but finding the max inside it isn't O(1)Window + monotonic deque
Longest subsequence with some propertyNot contiguousDP
"Exactly k" of somethingNot monotonicatMost(k) - atMost(k - 1)
Median of every windowNeed order statisticsTwo heaps or a TreeMap

So my test before writing any window code is: "if the window is invalid, can extending it ever make it valid again?" If the answer is yes, I don't use a plain window.

Interviewers look for: the negative-numbers counterexample and the prefix-sum switch. Mentioning the monotonic deque for LC 862 is a strong senior signal.

32. Prefix Sum & Difference Arrays

BasicWhat is a prefix sum, and how do you recognise when to use one?

So a prefix sum array stores running totals, so the sum of any range is one subtraction: sum(l..r) = pre[r + 1] - pre[l]. It's O(n) to build and O(1) per query. I reach for it when I see many range-sum queries on an array that doesn't change, or "count subarrays with sum k / divisible by k / equal 0s and 1s", especially with negative numbers, where a window fails.

static int[] prefix(int[] a) {
  int[] pre = new int[a.length + 1];         // pre[i] = sum of a[0..i-1]
  for (int i = 0; i < a.length; i++) pre[i + 1] = pre[i] + a[i];
  return pre;
}

static int rangeSum(int[] pre, int l, int r) {   // inclusive l..r
  return pre[r + 1] - pre[l];
}
// a   = [-2, 0, 3, -5, 2, -1]
// pre = [0, -2, -2, 1, -4, -2, -3]
// rangeSum(0, 2) = 1    rangeSum(2, 5) = -1

The n + 1 size with pre[0] = 0 is the detail I care about most. It means a range starting at index 0 needs no special case. That's the classic off-by-one bug with size-n prefix arrays.

The same idea works for anything I can "undo": counts, XOR (pre[r+1] ^ pre[l]), and products if there are no zeros. For Product of Array Except Self, I use a prefix product from the left and a suffix product from the right, and no division at all.

So in practice, when the brute force is "for every range, add it up", I build a prefix array once and the inner loop disappears.

Interviewers look for: the n + 1 convention stated clearly, and Range Sum Query (LC 303) done with O(1) queries.

BasicWalk me through Subarray Sum Equals K. Why seed the map with 0?

So a subarray (i, j] sums to k exactly when pre[j] - pre[i] = k, which means pre[i] = pre[j] - k. So as I walk, I keep a map of how many times each prefix sum has appeared. At each position, I add the count of sum - k seen so far.

static int subarraySum(int[] nums, int k) {
  Map<Integer, Integer> seen = new HashMap<>();   // prefix sum -> how many times
  seen.put(0, 1);                            // the empty prefix
  int sum = 0, count = 0;
  for (int x : nums) {
    sum += x;
    count += seen.getOrDefault(sum - k, 0);  // earlier prefixes that leave exactly k
    seen.merge(sum, 1, Integer::sum);
  }
  return count;
}
// [1, 1, 1], k = 2 -> 2

The seed {0: 1} stands for the empty prefix before index 0. Without it, I'd miss every subarray that starts at index 0. In [1, 1, 1] with k = 2, the subarray [1, 1] at the start needs sum - k = 0 to be in the map.

The order inside the loop matters too. I look up before adding the current sum. Otherwise, when k = 0, I'd match the current prefix with itself and count an empty subarray.

It's O(n) time and O(n) space. And it works with negative numbers, which is exactly why this isn't a sliding window problem. I've checked it against an O(n²) brute force on random arrays with negatives.

Interviewers look for: the seed and the lookup-before-insert order. The follow-up is often "divisible by k" (LC 974): store floorMod(sum, k) instead.

IntermediateHow would you find the longest subarray with an equal number of 0s and 1s?

So I turn it into a prefix-sum problem by treating every 0 as -1. Then "equal 0s and 1s" means "sums to 0", and a subarray sums to 0 when the same running balance shows up twice. For the longest, I store the first index where each balance appeared.

static int findMaxLength(int[] nums) {
  Map<Integer, Integer> first = new HashMap<>();  // balance -> first index seen
  first.put(0, -1);
  int balance = 0, best = 0;
  for (int i = 0; i < nums.length; i++) {
    balance += nums[i] == 1 ? 1 : -1;        // treat 0 as -1
    Integer j = first.get(balance);
    if (j != null) best = Math.max(best, i - j);
    else first.put(balance, i);              // keep only the earliest
  }
  return best;
}
// [0, 1, 0]                -> 2
// [0, 0, 1, 0, 0, 0, 1, 1] -> 6   (indices 2..7)

The differences from the last problem are worth pointing out. There I stored counts because I wanted how many subarrays. Here I store the first index because I want the longest one. If I overwrote it with later indices, I'd shorten the answer. The seed is 0 -> -1, so a balanced prefix starting at index 0 gets length i + 1.

Since the balance is always between -n and n, I could use an array of size 2n + 1 with an offset instead of a map. That's faster in practice, with the same O(n).

So the general move is: "turn the condition into a sum being zero or k, then match prefix sums." Counting vowels versus consonants, or "more 1s than 0s", reduce the same way.

IntermediateWhat's a difference array? Walk me through Car Pooling with it.

So a difference array is the reverse of a prefix sum. To add v to every element in [l, r], I write just two numbers: +v at l and -v at r + 1. After all the updates, one prefix-sum pass rebuilds the real values. So q range updates cost O(q + n) instead of O(q x n).

static boolean carPooling(int[][] trips, int capacity) {
  int[] diff = new int[1001];                // stops are 0..1000
  for (int[] t : trips) {
    diff[t[1]] += t[0];                      // get on at from
    diff[t[2]] -= t[0];                      // get off at to (not to + 1)
  }
  int onBoard = 0;
  for (int d : diff) {
    onBoard += d;
    if (onBoard > capacity) return false;
  }
  return true;
}
// trips [[2,1,5],[3,3,7]]: capacity 4 -> false, capacity 5 -> true

Each trip is "passengers on board from stop from until just before to". Passengers get off at to, so the range is [from, to - 1], and the minus goes at to. That's the off-by-one to get right in this problem. In the example, between stops 3 and 4 there are 2 + 3 = 5 people. That fails with capacity 4 and passes with 5.

The running sum is literally "how many people are on the bus right now", which is a nice way to explain it. The same trick solves Corporate Flight Bookings (LC 1109), and "how many intervals cover each point".

If the coordinates were huge, like up to 109, I couldn't make the array. Then I'd put the +v and -v events in a TreeMap and sweep over them in order. That's O(q log q).

Interviewers look for: the inclusive vs exclusive endpoint reasoning, and the sweep-line fallback for huge coordinates.

IntermediateHow do you answer sub-rectangle sum queries on a 2D grid in O(1)?

So I build a 2D prefix array where pre[r][c] is the sum of the rectangle from the top-left corner to just before (r, c). Any sub-rectangle is then four lookups, using inclusion-exclusion: take the big rectangle, subtract the strip above and the strip on the left, and add back the corner I subtracted twice.

static class NumMatrix {
  private final int[][] pre;

  NumMatrix(int[][] m) {
    int R = m.length, C = m[0].length;
    pre = new int[R + 1][C + 1];
    for (int r = 0; r < R; r++)
      for (int c = 0; c < C; c++)
        pre[r + 1][c + 1] = m[r][c] + pre[r][c + 1] + pre[r + 1][c] - pre[r][c];
  }

  int sumRegion(int r1, int c1, int r2, int c2) {
    return pre[r2 + 1][c2 + 1] - pre[r1][c2 + 1] - pre[r2 + 1][c1] + pre[r1][c1];
  }
}
// LC 304 example grid: sumRegion(2,1,4,3) = 8, (1,1,2,2) = 11, (1,2,2,4) = 12

The build uses the same inclusion-exclusion. The cell itself, plus the block above, plus the block to the left, minus the overlap that got counted twice.

Again I pad with an extra row and column of zeros, so the first row and column need no special cases. That's the same n + 1 idea as in 1D. It's O(R x C) to build and O(1) per query.

This comes up inside bigger problems too. Matrix Block Sum (LC 1314), "count submatrices that sum to target" (fix two rows, then run the 1D subarray-sum-equals-k trick on the column sums), and image processing box blurs all use it.

AdvancedWhen does a prefix sum stop being the right tool?

So a prefix sum assumes two things: the array doesn't change, and the operation can be undone by subtraction. When either one breaks, I switch tools.

The array gets updated. One point update means every prefix after it changes, so it's O(n) per update. With a mix of updates and queries, I use a Fenwick tree (Binary Indexed Tree). Both operations are O(log n).

static class Fenwick {
  private final long[] tree;
  Fenwick(int n) { tree = new long[n + 1]; }

  void add(int i, long delta) {              // 0-indexed position
    for (i++; i < tree.length; i += i & -i) tree[i] += delta;
  }

  long prefixSum(int i) {                    // sum of [0..i]
    long s = 0;
    for (i++; i > 0; i -= i & -i) s += tree[i];
    return s;
  }
}
// build from [1,2,3,4,5], then add(2, 7):
// prefixSum(3) = 17, range 2..4 = prefixSum(4) - prefixSum(1) = 19

The operation can't be undone. Min and max don't have an inverse. Knowing the max of [0..r] and of [0..l-1] tells you nothing about [l..r]. For static range-min I'd use a sparse table (O(1) query after O(n log n) build). With updates, I'd use a segment tree.

Range updates and range queries, both mixed. A difference array only gives answers after all updates are done. If queries come in between, I need a segment tree with lazy propagation, or two Fenwick trees.

Overflow. Prefix sums of large values grow fast. I use long[] as soon as the total could pass about 2 x 109.

So the order I'd reach for: prefix sum if it's static, Fenwick for point updates with sum queries, segment tree for anything more general.

Interviewers look for: "what if the array changes?" is the standard follow-up to LC 303. Naming Fenwick or a segment tree, with O(log n) for both operations, is the expected answer.

33. Fast & Slow Pointers

BasicHow do you recognise a fast and slow pointer problem?

So I reach for fast and slow pointers when I'm walking something that behaves like a linked list, where each step decides the next one, and I need to know either "does this loop?" or "where is the middle?". The giveaway is usually that the obvious answer is a HashSet of visited nodes, and the problem says O(1) extra space.

These are the signals I listen for:

What the problem saysWhy it fits
"Does the list have a cycle?", "where does the cycle start?"Floyd's tortoise and hare, the textbook case
"Find the middle", "split the list in half"When fast reaches the end, slow is halfway
A sequence x -> f(x) that might repeat (happy number)It's an implicit linked list, so it either ends or cycles
Values in 1..n used as indexes, one duplicateRead i -> nums[i] as next pointers; the duplicate is a cycle entry

The mental model I use is two runners on a track. On a straight road the fast one just finishes first. On a circular track the fast one laps the slow one and taps them on the shoulder, and that tap proves there's a loop.

So in practice, the moment I see "linked list" plus "cycle" or "middle", or I see a hidden "next" function with a space limit, I write the slow = slow.next; fast = fast.next.next loop.

Interviewers look for: spotting the implicit linked list. Happy Number and Find the Duplicate don't mention lists at all, and seeing the x -> f(x) chain is the real test.

BasicWalk me through detecting a cycle in a linked list.

So I start two pointers at the head. Slow moves one node per step, fast moves two. If fast ever hits null, the list has an end, so there's no cycle. If the two pointers ever point at the same node, there is one.

boolean hasCycle(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {   // fast needs two valid steps
        slow = slow.next;
        fast = fast.next.next;
        if (slow == fast) return true;             // same NODE, not same value
    }
    return false;                                  // fast fell off the end
}
// [3,2,0,-4] with the tail linked back to index 1 -> true
// [1,2] with no link                               -> false

The reason they must meet is simple once both are inside the loop. Think of the gap from slow to fast measured along the cycle. Every step fast gains exactly one node, so the gap goes 5, 4, 3, 2, 1, 0. It shrinks by one at a time, so it can't jump over zero. That also bounds the time: slow is caught before it finishes its first lap, so the whole thing is O(n) time and O(1) space.

Two bugs I watch for. First, checking only fast != null and then calling fast.next.next, which throws a NullPointerException on an even-length list. Second, comparing slow.val == fast.val. Two different nodes can hold the same value, so I compare references.

So in practice I'd mention the HashSet<ListNode> version first as the O(n)-space baseline, then give this as the O(1) improvement.

Interviewers look for: the follow-up "why speed 2 and not 3?". Speed 2 closes the gap by exactly 1 per step, so meeting is guaranteed. With speed 3 the gap shrinks by 2, so it can jump past 0 and take extra laps. Starting both at the head they still meet, but it's slower to argue, and the reset-to-head trick for finding the cycle start depends on fast having walked exactly twice as far.

IntermediateHow do you find the middle of a linked list, and where does that get used?

So it's the same loop with no meeting check. Fast covers the whole list while slow covers half, so when fast runs out, slow is sitting on the middle. The only real decision is which middle I want on an even-length list.

// second middle: [1,2,3,4,5,6] -> 4
ListNode secondMiddle(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    return slow;
}

// first middle: [1,2,3,4,5,6] -> 3  (odd lengths give the same node)
ListNode firstMiddle(ListNode head) {       // assumes head != null
    ListNode slow = head, fast = head;
    while (fast.next != null && fast.next.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    return slow;
}

The first-middle version is the one I use when I want to split the list, because I need the last node of the first half so I can set slow.next = null. That's exactly what merge sort on a linked list needs (LC 148). If I split at the second middle on a two-node list, one half is empty and the recursion never shrinks.

It's also the first step of a few combo problems. Palindrome Linked List is find the middle, reverse the second half, compare. Reorder List is find the middle, reverse the second half, weave.

So the answer I give is: same loop, and I pick the loop condition based on whether I'm returning a middle or cutting the list.

IntermediateHow do you find where the cycle starts, and why does it work?

So once slow and fast meet, I put one pointer back at the head and leave the other at the meeting point. Then I move both one step at a time. The node where they meet again is the start of the cycle.

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: met inside the cycle
            ListNode p = head;               // phase 2
            while (p != slow) {
                p = p.next;
                slow = slow.next;
            }
            return p;
        }
    }
    return null;
}
// 1->2->3->4->5->6->7->(back to 3) -> node 3

Here's the proof I'd say out loud. Let the tail before the cycle be a nodes, the cycle length c, and say they meet b steps past the cycle start. Slow walked a + b. Fast walked twice that, 2(a + b). The extra a + b that fast walked must be whole laps, so a + b = k·c. Rearranged, a = (k-1)·c + (c - b).

In words: walking a steps from the meeting point takes you c - b steps to the cycle start, plus some full laps that land you back there. And walking a steps from the head also lands on the cycle start. So both pointers arrive together.

In the example, a = 2 and c = 5. They meet at node 6, and from there p goes 1, 2, 3 while slow goes 7, 3. Both on node 3.

Interviewers look for: the algebra, not just the code. Plenty of candidates memorise "reset to head" but can't say why. The follow-up is "how would you get the cycle length?": from the meeting point, walk once around counting until you're back.

IntermediateWhen does fast and slow NOT apply, and what would you use instead?

So it only works when there's a single "next" from each position, so the path is a chain or a rho shape. The moment a node can have several next steps, or I need more than "is there a loop" and "where's the middle", I use something else.

SituationWhy fast/slow failsWhat I use
Cycle in a general graph (course schedule)Many out-edges per nodeDFS with three colours, or Kahn's topological sort
I need every repeated node, or the order they were first seenFloyd only finds one meeting pointHashSet of visited, O(n) space
Middle of an arrayRandom access, so pointers are pointlessarr[n / 2]
"Nth node from the end"It's about a fixed gap, not speedTwo pointers at the same speed, n apart
Duplicate in 1..n and I'm allowed to modify the arrayFloyd works, but there's a simpler optionCyclic sort or negation marking

The gap variant is worth showing because people confuse the two:

ListNode removeNthFromEnd(ListNode head, int n) {
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    ListNode lead = dummy, trail = dummy;
    for (int i = 0; i <= n; i++) lead = lead.next;   // open a gap of n + 1
    while (lead != null) {                            // same speed
        lead = lead.next;
        trail = trail.next;
    }
    trail.next = trail.next.next;                     // trail is just before the target
    return dummy.next;
}
// [1,2,3,4,5], n = 2 -> [1,2,3,5]

So in practice my rule is: speed difference for cycles and middles, fixed gap for "k from the end", and a set or a graph algorithm when there's branching.

AdvancedFind the duplicate in an array of n + 1 numbers from 1..n without modifying it and in O(1) space.

So the trick is to read the array as a linked list where index i points to index nums[i]. Index 0 is never pointed to, since values start at 1, so it's the head. The duplicate value has two arrows coming into it, and a node with two incoming arrows in a functional graph is exactly the entry point of a cycle. So it's Linked List Cycle II on an array.

int findDuplicate(int[] nums) {
    int slow = nums[0], fast = nums[0];
    do {                                // phase 1: find a meeting point
        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

Let me trace [1,3,4,2,2]. The chain is 0 -> 1 -> 3 -> 2 -> 4 -> 2 -> 4... Phase 1: slow goes 3 then 2, fast goes 2 then 2, so they meet at 2. Phase 2: slow restarts, goes 1, 3, 2 while fast goes 4, 2. Both land on 2, which is the duplicate.

I use do-while because both pointers start equal, so a plain while would exit immediately. It's O(n) time and O(1) space, and it never writes to the array.

The alternatives I'd mention: a HashSet is O(n) space; sorting modifies the array; cyclic sort also modifies it; and binary search on the value range (count how many are <= mid) keeps the array read-only in O(n log n). Floyd is the only one that hits all three constraints.

Interviewers look for: why the duplicate is the cycle start. If you can say "two arrows point into it, and the cycle entry is the only node with two incoming arrows", you've shown you understand it rather than memorised it.

34. Monotonic Stack & Queue

BasicWhat is a monotonic stack, and how do you recognise a problem that needs one?

So a monotonic stack is a normal stack with one house rule: its values stay sorted, always decreasing or always increasing from bottom to top. Before I push a new element, I pop everything that would break the order, and the pops are where the answers come from.

The recognition signal is a question about the nearest bigger or smaller element in some direction:

  • "Next greater element", "previous smaller element", "first element to the right that's bigger".
  • "How many days until a warmer day", "stock span", "how far can this bar stretch".
  • "Largest rectangle in a histogram", "trapping rain water".
  • "Max of every window of size k", which is the deque version.

The tell in the brute force is "for each i, scan right until I find something bigger". That's O(n²), and the stack makes it O(n).

The way I picture it is people in a queue each waiting for someone taller. When a tall person arrives, every shorter person still waiting has found their answer and leaves. The people left waiting are always in decreasing height, and that's the stack.

So in practice, if I catch myself writing a nested loop that looks for the first element that beats the current one, I replace it with a stack of indexes.

Interviewers look for: the word "nearest". A monotonic stack answers nearest-greater and nearest-smaller questions. It doesn't answer "second greater" or "greatest in an arbitrary range" by itself.

BasicWalk me through Daily Temperatures with a monotonic stack.

So for each day I want how many days until a warmer one. I keep a stack of the indexes of days still waiting for their answer. Their temperatures are decreasing, because a warmer day would already have answered the colder ones below it.

int[] dailyTemperatures(int[] temps) {
    int[] answer = new int[temps.length];        // default 0 = never warmer
    Deque<Integer> stack = new ArrayDeque<>();   // indexes of waiting days
    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, which is why I store indexes
        }
        stack.push(i);
    }
    return answer;
}

Let me trace [73,74,75,71,69,72,76,73], showing the values on the stack:

i=2  75  pops 74           stack [75]
i=3  71  push              stack [75, 71]
i=4  69  push              stack [75, 71, 69]
i=5  72  pops 69 (ans 1), 71 (ans 2)     stack [75, 72]
i=6  76  pops 72 (ans 1), 75 (ans 4)     stack [76]
i=7  73  push, never popped -> 0
result [1, 1, 4, 2, 1, 1, 0, 0]

The question I always get is "a while inside a for, how is that O(n)?". Each index is pushed exactly once and popped at most once, so across the whole run there are at most 2n stack operations. It's amortised O(n) time and O(n) space.

I use ArrayDeque rather than the old Stack class, because Stack extends Vector and every call is synchronized for no reason.

IntermediateHow do you decide the stack direction and whether to use < or <=?

So I decide by what I'm looking for. For "next greater", the waiting elements must be decreasing, so a bigger newcomer pops them. For "next smaller" it's the mirror. For "previous" questions I pop the useless ones first and then the top is the answer for the current index.

I wantStack order (bottom to top)Pop whileAnswer comes from
Next greaterdecreasinga[top] < a[i]the popped items get a[i]
Next smallerincreasinga[top] > a[i]the popped items get a[i]
Previous greaterdecreasinga[top] <= a[i]the top, after popping
Previous smallerincreasinga[top] >= a[i]the top, after popping

Strict versus non-strict only matters with duplicates, and it matters most in counting problems. In Sum of Subarray Minimums, I count for each element how many subarrays it's the minimum of. If I use strict on both sides, a subarray like [2,2] gets counted for both 2s:

// arr = [2, 2]; true answer: [2] + [2] + [2,2] = 6
// strict on both sides:            8  (the [2,2] subarray counted twice)
// strict left, non-strict right:   6  (each tie owned by exactly one index)
while (!stack.isEmpty() && arr[stack.peek()] > arr[i]) stack.pop();   // left pass
while (!stack.isEmpty() && arr[stack.peek()] >= arr[i]) stack.pop();  // right pass

So I write this table on the whiteboard before coding. One side strict and the other non-strict is the standard way to make ties count once.

Interviewers look for: a test with duplicates. Candidates who get Daily Temperatures right often break on [2,2] in counting problems.

IntermediateWalk me through Sliding Window Maximum with a monotonic deque.

So I keep a deque of indexes whose values are decreasing from front to back. The front is always the max of the current window. Elements leave from the back when a bigger one arrives, and from the front when they slide out of the window.

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();   // expired
        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;
}
// [1,3,-1,-3,5,3,6,7], k = 3 -> [3, 3, 5, 5, 6, 7]

The key argument for popping from the back: if a newer element is at least as big as an older one, the older one can never be a window max again. The newer one is bigger and stays in the window longer. So throwing it away loses nothing.

The deque has to hold indexes, not values, because I need to know when the front has expired. Each index goes in once and out once, so it's O(n) time and O(k) space.

The alternative is a max-heap of (value, index) pairs with lazy removal of expired tops. That's O(n log n) and easier to get right under pressure, so I'd mention it, but the deque is the answer they want.

Interviewers look for: the order of the three steps: expire from the front, pop smaller from the back, push, then read. Reading before expiring gives a stale max.

IntermediateWhen doesn't a monotonic stack apply, and what's the alternative?

So it only answers "nearest bigger or smaller in one direction", processed in one sweep. If the question is about arbitrary ranges, the k-th something, or data that changes, I need a different structure.

QuestionWhy the stack doesn't fitWhat I use
Max or min of arbitrary ranges [l, r], many queriesWindows aren't sliding in one directionSparse table (static) or segment tree
Values get updated between queriesThe stack's pops are permanentSegment tree
"Second greater element" to the rightOne stack only gives the firstTwo stacks (LC 2454), or a sorted set
"Any greater element anywhere", not nearestPosition doesn't matterJust track the global max, or sort
Median of a sliding windowMedian isn't a nearest relationTwo heaps or two TreeSets
Window where the element leaving isn't the oldestDeque assumes FIFO expiryA heap with lazy deletion or a TreeMap of counts

A quick check I use: can I discard an element for good the moment something better arrives? If yes, the monotonic structure works, because that's exactly what the pop does. If an element I'd pop might matter again later, it doesn't.

So in practice: nearest greater or smaller, or a sliding max/min, means monotonic stack or deque. Arbitrary ranges or updates mean a tree structure.

AdvancedWalk me through Largest Rectangle in Histogram.

So the key observation is that the best rectangle uses some bar as its shortest bar, and stretches left and right until it hits a shorter bar. So for every bar I need its previous smaller and next smaller, and an increasing stack gives me both in one pass.

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];           // sentinel 0 flushes the stack
        while (!stack.isEmpty() && heights[stack.peek()] > h) {
            int height = heights[stack.pop()];       // this bar is the shortest
            int left = stack.isEmpty() ? -1 : stack.peek();   // previous smaller
            best = Math.max(best, height * (i - left - 1));   // i is next smaller
        }
        stack.push(i);
    }
    return best;
}
// [2,1,5,6,2,3] -> 10,  [2,4] -> 4

What happens is: when bar i is shorter than the top, the top has just found its right boundary. After popping it, the new top is the nearest shorter bar on its left, because the stack is increasing. So the width is everything strictly between them, i - left - 1.

On [2,1,5,6,2,3], when the 2 at index 4 arrives it pops 6 (area 6 × 1 = 6) and then 5 (left boundary is index 1, width 4 - 1 - 1 = 2, area 10). That's the answer.

The sentinel height 0 at i == n is the trick that saves me a second cleanup loop: it's shorter than everything, so it pops every remaining bar. O(n) time, O(n) space.

Interviewers look for: the extension to Maximal Rectangle (LC 85). Treat each row of a binary matrix as the base of a histogram of column heights and run this once per row.

35. Merge Intervals

BasicHow do you recognise an interval problem, and when do two intervals overlap?

So the input gives it away: a list of pairs like [start, end], meaning meetings, bookings, time slots or ranges. And the question asks which ones overlap, how to combine them, or how many are open at the same time. The move that unlocks nearly all of them is sorting first.

For the overlap test, I think about when they don't overlap. Of all the ways B can sit relative to A, only two are disjoint: B entirely before A, or B entirely after A. So:

// overlap unless one ends before the other starts
boolean overlaps(int[] a, int[] b) {
    return a[0] <= b[1] && b[0] <= a[1];
}
// [1,4] and [4,5] -> true with <=  (touching counts)
// with < instead, touching intervals would not overlap

Whether touching ends count depends on the problem, and I always ask. For Merge Intervals, [1,4] and [4,5] merge. For meeting rooms, a meeting ending at 10 and another starting at 10 can share a room.

Once the list is sorted by start, I already know cur.start >= last.start, so the test shrinks to one comparison: cur.start <= last.end.

So the signals are: pairs in the input, and words like "merge", "insert", "free time", "minimum rooms", "remove the fewest so none overlap", or "intersection of two lists".

Interviewers look for: the clarifying question about touching endpoints before you code. It changes < to <= and flips the answer on edge cases.

BasicWalk me through Merge Intervals.

So I sort by start time, then walk through once. Each interval either overlaps the last merged one, in which case I extend that one's end, or it doesn't, in which case it starts a new group.

int[][] merge(int[][] intervals) {
    Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));
    List<int[]> merged = new ArrayList<>();
    for (int[] cur : intervals) {
        if (merged.isEmpty() || merged.get(merged.size() - 1)[1] < cur[0]) {
            merged.add(new int[]{cur[0], cur[1]});        // no overlap: new group
        } else {
            int[] last = merged.get(merged.size() - 1);
            last[1] = Math.max(last[1], cur[1]);          // max, not cur[1]
        }
    }
    return merged.toArray(new int[0][]);
}
// [[1,3],[2,6],[8,10],[15,18]] -> [[1,6],[8,10],[15,18]]
// [[1,10],[2,3],[4,5]]         -> [[1,10]]

Tracing the first one: [1,3] starts a group. [2,6] starts at 2, which is within 3, so the group becomes [1,6]. [8,10] starts after 6, so it's new. Same for [15,18].

Two gotchas I've hit. First, it has to be Math.max(last[1], cur[1]), because [2,3] sits entirely inside [1,10] and would otherwise shrink it to [1,3]. Second, I sort with Integer.compare, not a[0] - b[0], because subtraction overflows for values near Integer.MIN_VALUE and silently gives the wrong order.

It's O(n log n) for the sort and O(n) for the output.

Interviewers look for: the follow-up "the list is already sorted, insert one new interval". Then I skip the sort and do three phases in O(n): copy everything that ends before it, swallow everything that overlaps, copy the rest.

IntermediateWhen do you sort by start and when by end? Walk me through Non-overlapping Intervals.

So I sort by start when I'm grouping or merging intervals, and by end when I'm greedily keeping as many non-overlapping intervals as possible. Keeping the interval that finishes first leaves the most room for everything after it.

Non-overlapping Intervals asks for the fewest removals so the rest don't overlap. I flip it into "keep the most", which is classic activity selection:

int eraseOverlapIntervals(int[][] intervals) {
    Arrays.sort(intervals, (a, b) -> Integer.compare(a[1], b[1]));   // by END
    int removed = 0, lastEnd = Integer.MIN_VALUE;
    for (int[] cur : intervals) {
        if (cur[0] >= lastEnd) lastEnd = cur[1];   // keep it (touching is fine)
        else removed++;                            // clashes with the last kept one
    }
    return removed;
}
// [[1,2],[2,3],[3,4],[1,3]]      -> 1
// [[1,100],[2,3],[4,5],[6,7]]    -> 1

That second example is the one that shows why sorting by start is wrong. By start, [1,100] comes first, I keep it, and then all three short ones clash, so I'd remove 3. By end, I keep the three short ones and only drop [1,100].

The proof is an exchange argument. Take any optimal answer. Its first interval ends no earlier than the interval with the smallest end, so I can swap in the one that ends earliest without causing any new clash. Repeat, and the greedy answer is at least as good.

Same idea powers Minimum Arrows to Burst Balloons: sort by end and shoot each arrow at the end of the first unburst balloon.

IntermediateHow many meeting rooms do you need? Walk me through it.

So the answer is the maximum number of meetings running at the same moment. I sort by start time and keep a min-heap of end times of rooms in use. For each meeting, if the room that frees up earliest is free by now, I reuse it. Either way the new meeting takes a room.

int minMeetingRooms(int[][] intervals) {
    Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));
    PriorityQueue<Integer> endTimes = new PriorityQueue<>();
    for (int[] m : intervals) {
        if (!endTimes.isEmpty() && endTimes.peek() <= m[0]) {
            endTimes.poll();               // earliest room is free: reuse it
        }
        endTimes.offer(m[1]);
    }
    return endTimes.size();
}
// [[0,30],[5,10],[15,20]]   -> 2
// [[1,5],[2,6],[3,7],[6,8]] -> 3

On the second one: meetings at 1, 2 and 3 all need their own room, so the heap holds {5, 6, 7}. The meeting at 6 finds the room ending at 5 is free, reuses it, and the heap stays at size 3.

The other version I'd mention is a sweep line: sort all starts and all ends separately, walk the starts, and before each start release every meeting whose end is <= it. The peak of the running count is the answer. Both are O(n log n).

The <= is deliberate. A meeting ending at 10 frees the room for one starting at 10.

Interviewers look for: the "which room?" follow-up (Meeting Rooms III). Then you need two heaps: free rooms by number, and busy rooms by end time.

AdvancedWhen doesn't sort-and-sweep apply? What if intervals arrive one at a time?

So sort-and-sweep assumes I have the whole list up front. If bookings arrive one by one and I must answer each immediately, re-sorting every time is O(n log n) per call. I switch to a structure that stays sorted, and in Java that's a TreeMap keyed by start.

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

    public boolean book(int start, int end) {        // half-open [start, end)
        var before = booked.floorEntry(start);       // latest booking starting <= start
        var after = booked.ceilingEntry(start);      // earliest booking starting >= start
        if (before != null && before.getValue() > start) return false;
        if (after != null && after.getKey() < end) return false;
        booked.put(start, end);
        return true;
    }
}
// book(10,20) true, book(15,25) false, book(20,30) true,
// book(5,10) true, book(8,12) false

Because existing bookings never overlap, only the two neighbours can clash, so each call is O(log n).

Other cases where I'd move away from the basic pattern:

  • Small coordinate range, like hours 0..1000 or car pooling stops: a difference array, +1 at start and -1 at end, then a prefix sum. O(n + range), no sort.
  • "Which intervals contain point x?" with many queries: an interval tree or a segment tree.
  • Data too big for memory: external sort by start, then the same single merge pass streams through, since it only needs the last merged interval.

So in practice: batch input means sort and sweep; online input means TreeMap; bounded integer coordinates mean a difference array.

36. Cyclic Sort

BasicWhat is cyclic sort, and how do you recognise when to use it?

So cyclic sort works when an array holds numbers from a known range like 1..n, where n is about the array length. Every value has a natural home, value v at index v - 1, so I just keep swapping each number into its home. After that, one scan finds whatever is missing or duplicated.

The signals I look for all together:

  • The values are in 1..n or 0..n.
  • The question is "find the missing number(s)", "find the duplicate(s)", "smallest missing positive" or "set mismatch".
  • It asks for O(n) time and O(1) extra space, which rules out both sorting and a HashSet.
  • I'm allowed to modify the array.

The picture I use is handing back numbered exam papers where seat 1 is for student 1. Pick up the paper on desk 1, walk it to its owner, pick up the paper that was there, walk that one home, and keep going. Nobody compares papers with each other; everyone already knows their seat.

That's also why it beats comparison sorting's O(n log n) lower bound. It never compares elements. It uses the value as an address, much like counting sort, but in place.

So in practice: range 1..n plus "missing or duplicate" plus O(1) space tells me cyclic sort, as long as I can write to the input.

Interviewers look for: "can you modify the input?" as a clarifying question. If the answer is no, the pattern is off the table.

BasicWalk me through finding all the numbers missing from an array of 1..n.

So I run cyclic sort first. The duplicates can't go home, because their home already holds a copy, so they end up parked in the slots whose owners are missing. Then every index i where nums[i] != i + 1 means i + 1 is missing.

List<Integer> findDisappearedNumbers(int[] nums) {
    int i = 0;
    while (i < nums.length) {
        int correct = nums[i] - 1;               // home index of this value
        if (nums[i] != nums[correct]) swap(nums, i, correct);   // send it home
        else i++;                                // at home, or home holds a copy
    }
    List<Integer> missing = new ArrayList<>();
    for (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]

The condition is nums[i] != nums[correct], comparing values, not i != correct. With the index version, a duplicate whose home already holds the same value swaps forever, because the swap changes nothing. That's the classic infinite loop.

The other thing I point out is that I don't do i++ after a swap. The value that just landed at i hasn't been placed yet.

It looks like it could be O(n²) because i doesn't always move, but every swap sends at least one number to its final home, and a number at home never moves again. So at most n swaps plus n increments: O(n) time, O(1) extra space.

IntermediateFind the first missing positive in O(n) time and O(1) space.

So the insight is that with n slots, the answer must be somewhere in 1..n+1. If 1..n are all present, it's n + 1; otherwise it's the smallest gap in 1..n. So only values 1..n matter. I send those home and ignore negatives, zeros and anything bigger than n.

int firstMissingPositive(int[] nums) {
    int n = nums.length, i = 0;
    while (i < n) {
        int v = nums[i];
        if (v >= 1 && v <= n && nums[v - 1] != v) swap(nums, i, v - 1);
        else i++;                                  // out of range, home, or duplicate
    }
    for (i = 0; i < n; i++) {
        if (nums[i] != i + 1) return i + 1;
    }
    return n + 1;
}
// [3,4,-1,1]      -> 2
// [7,8,9,11,12]   -> 1
// [1,2,0]         -> 3
// [1,1]           -> 2

On [3,4,-1,1]: 3 goes to index 2, which brings -1 to index 0. -1 has no home, so move on. 4 goes to index 3, bringing 1 back, and 1 goes to index 0. The array ends as [1,-1,3,4], and index 1 doesn't hold 2, so the answer is 2.

The range check v >= 1 && v <= n has to come before nums[v - 1], otherwise a negative or large value throws ArrayIndexOutOfBoundsException.

Interviewers look for: the "answer is in 1..n+1" argument. It's what makes an arbitrary-integers problem fit a 1..n pattern. A HashSet answer is fine as a starting point, but this is the one they're waiting for.

IntermediateWhen doesn't cyclic sort apply, and what would you use instead?

So it needs two things: values that map to indexes, and permission to rearrange the array. If either is missing, I use something else.

SituationAlternative
Array is read-only, one duplicate in 1..nFloyd's cycle detection on i -> nums[i]
Read-only, exactly one missing in 0..nSum formula or XOR
Values are arbitrary (not tied to n)HashSet, or sort then scan
Range much bigger than n (values up to 109)HashSet; there's no slot to swap into
Can modify, but must keep the orderNegation marking, then restore the signs

The single-missing case is where I'd skip cyclic sort entirely even if I'm allowed to swap:

int missingNumberXor(int[] nums) {          // n numbers from 0..n, one missing
    int x = nums.length;
    for (int i = 0; i < nums.length; i++) x ^= i ^ nums[i];
    return x;                                // every present number cancels itself
}

int missingNumberSum(int[] nums) {
    int n = nums.length;
    long expected = (long) n * (n + 1) / 2;  // long: n(n+1) can overflow int
    long actual = 0;
    for (int x : nums) actual += x;
    return (int) (expected - actual);
}
// [9,6,4,2,3,5,7,0,1] -> 8 for both

Both are O(n) time, O(1) space, and they don't touch the array. The XOR one can't overflow at all, which is why I usually prefer it.

So in practice: many missing or many duplicates with a writable array is cyclic sort. One missing is a formula. Read-only with one duplicate is Floyd.

AdvancedFind all duplicates in an array of 1..n in O(n) time and O(1) space. Is there another way?

So there are two in-place approaches and I'd show both. Cyclic sort parks each duplicate in a slot whose owner is missing, so after sorting any mismatched slot holds a duplicate value. Negation marking uses the sign bit of nums[v - 1] as a "seen v" flag, which keeps the element order.

// 1. cyclic sort
List<Integer> findDuplicatesCyclic(int[] nums) {
    int i = 0;
    while (i < nums.length) {
        int correct = nums[i] - 1;
        if (nums[i] != nums[correct]) swap(nums, i, correct);
        else i++;
    }
    List<Integer> dups = new ArrayList<>();
    for (i = 0; i < nums.length; i++) {
        if (nums[i] != i + 1) dups.add(nums[i]);
    }
    return dups;
}

// 2. negation marking
List<Integer> findDuplicatesNegation(int[] nums) {
    List<Integer> dups = new ArrayList<>();
    for (int x : nums) {
        int idx = Math.abs(x) - 1;               // x may already be negated
        if (nums[idx] < 0) dups.add(Math.abs(x)); // seen before
        else nums[idx] = -nums[idx];              // mark as seen
    }
    for (int i = 0; i < nums.length; i++) nums[i] = Math.abs(nums[i]);   // restore
    return dups;
}
// [4,3,2,7,8,2,3,1] -> cyclic: [3, 2]   negation: [2, 3]

The cyclic version reports duplicates in slot order, the negation version in the order the second copy appears. Both are correct since the problem allows any order.

The one gotcha in negation marking is Math.abs(x): by the time I read x, an earlier step may have flipped it negative. And both tricks rely on values being at least 1, since there's no sign to flip on 0.

So I pick negation when order matters or the interviewer wants the array restored, and cyclic sort when the follow-up might be "now also find the missing ones", since the same sorted array answers both.

Interviewers look for: the honest caveat that "O(1) extra space" here still means mutating the input. In production code I'd usually just use a HashSet unless memory really is tight.

37. In-place Linked List Reversal

BasicHow do you recognise an in-place reversal problem?

So the obvious cases say "reverse" outright: reverse the list, reverse from position left to right, reverse every k nodes, swap pairs. The less obvious ones need the list read backwards at some point, like checking a palindrome, reordering L0, Ln, L1, Ln-1, or adding two numbers stored most-significant digit first.

The phrase that seals it is "in place" or "O(1) extra memory", which rules out copying the nodes into an ArrayList or pushing them onto a stack.

ProblemWhere the reversal hides
Palindrome Linked ListReverse the second half, then compare
Reorder ListReverse the second half, then weave
Add Two Numbers IIReverse both, add from the ones digit, reverse the result
Swap Nodes in PairsIt's reverse-in-groups with k = 2

The mental model is a line of people each holding the shoulder of the person in front. I go down the line and ask each person to let go and hold the person behind instead. Before they let go, I note who was in front, so I don't lose the rest of the line.

So in practice I treat reversal as a building block. Most medium list problems are fast/slow to find the middle, reversal of one half, then some kind of merge.

BasicReverse a linked list for me, iteratively and recursively.

So iteratively it's four moves per node, always in this order: save next, flip the arrow, advance prev, advance curr. When curr falls off the end, prev is on the old tail, which is the new head.

ListNode reverseList(ListNode head) {
    ListNode prev = null, curr = head;
    while (curr != null) {
        ListNode next = curr.next;   // 1. save the rest
        curr.next = prev;            // 2. flip
        prev = curr;                 // 3. grow the reversed part
        curr = next;                 // 4. step forward
    }
    return prev;                     // not head, not curr
}

ListNode reverseRecursive(ListNode head) {
    if (head == null || head.next == null) return head;
    ListNode newHead = reverseRecursive(head.next);   // reverse the rest
    head.next.next = head;           // the node after me points back at me
    head.next = null;                // I become the tail
    return newHead;
}
// [1,2,3,4,5] -> [5,4,3,2,1] for both;  [] -> []

In the recursive version, the trick line is head.next.next = head. After the recursive call, head.next is still the node right after me, which is now the tail of the reversed rest. So I hang myself off its end and cut my own forward pointer.

Both are O(n) time, but the recursion is O(n) stack space. I actually tested it: the recursive version throws StackOverflowError on a 200,000-node list with the default stack, while the iterative one is fine. So in real code I use the iterative one.

Interviewers look for: returning prev, and forgetting to null out the old head's next in the recursive version, which leaves a two-node cycle at the end.

IntermediateReverse only the nodes between positions left and right, in one pass.

So I walk to the node just before position left, then do right - left "head insertions": I take the node right after the sublist's first node and move it to the front of the sublist. The first node slides toward the back and ends up as the sublist's tail.

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 before position left
    ListNode first = before.next;                          // will end up as the tail
    for (int i = 0; i < right - left; i++) {
        ListNode moved = first.next;     // take the node after 'first'
        first.next = moved.next;         // unhook it
        moved.next = before.next;        // put it at the front of the sublist
        before.next = moved;
    }
    return dummy.next;
}
// [1,2,3,4,5], left=2, right=4 -> [1,4,3,2,5]
// [3,5], left=1, right=2       -> [5,3]

Tracing [1,2,3,4,5] from 2 to 4: before is 1, first is 2. Move 3 to the front: 1,3,2,4,5. Move 4 to the front: 1,4,3,2,5. Two moves, done.

The dummy node is what makes left = 1 work. Without it, there's no "node before" when the sublist starts at the head, and I'd need a special case to update head. With it, I always return dummy.next.

It's O(n) time and O(1) space, and it's genuinely one pass, which is what the question usually asks for.

Interviewers look for: the dummy node habit. Any time the head might change, a dummy removes the special case, and interviewers notice when you reach for it without being prompted.

IntermediateWhen wouldn't you reverse a list in place? What are the alternatives?

So in-place reversal destroys the original order, and that's a problem whenever someone else holds a reference to the list or I need it intact afterwards. Mutating input is a side effect, and interviewers like it when I call that out.

SituationWhat I'd do instead
Caller still needs the original listReverse, use it, reverse back before returning; or build a reversed copy in O(n) space
Just need to read values backwardsPush onto an ArrayDeque and pop, O(n) space but no mutation
Doubly linked list, or Java's LinkedListdescendingIterator(); no need to reverse at all
ArrayTwo pointers swapping from both ends
Shared or concurrent listNever mutate; other threads could see a half-reversed chain
Immutable or persistent listBuild a new list by prepending each element

Add Two Numbers II is a nice example of the trade-off. The reversal version is O(1) extra space but mutates both inputs. The stack version pushes both lists' digits onto two stacks and pops them in parallel, which leaves the inputs untouched. For an interview I'd say which one I'm choosing and why.

So in practice: in-place is right when I own the list and memory matters. If the list is shared, I either restore it or I pay O(n) for a stack.

AdvancedWalk me through Reverse Nodes in k-Group.

So I process the list one group at a time. For each group I first check that k nodes actually exist, since a short final group stays as it is. Then I reverse the group with a small twist: prev starts at the node after the group instead of null, so the reversed group's tail already points onward. Finally I hook the node before the group to the new first node.

ListNode reverseKGroup(ListNode head, int k) {
    ListNode dummy = new ListNode(0, head);
    ListNode groupPrev = dummy;
    while (true) {
        ListNode kth = groupPrev;
        for (int i = 0; i < k && kth != null; i++) kth = kth.next;
        if (kth == null) break;                  // fewer than k left
        ListNode groupNext = kth.next;
        ListNode prev = groupNext, curr = groupPrev.next;
        while (curr != groupNext) {              // standard reversal
            ListNode next = curr.next;
            curr.next = prev;
            prev = curr;
            curr = next;
        }
        ListNode oldFirst = groupPrev.next;      // now the group's last node
        groupPrev.next = kth;                    // kth is the new first node
        groupPrev = oldFirst;                    // becomes "before" for the next group
    }
    return dummy.next;
}
// [1,2,3,4,5], k=2         -> [2,1,4,3,5]
// [1,2,3,4,5,6,7,8], k=3   -> [3,2,1,6,5,4,7,8]

What I find helps is naming the four boundary nodes before touching anything: groupPrev, first, kth and groupNext. After reversal, groupPrev points at kth, and the old first now points at groupNext. The old first becomes the next groupPrev.

Each node is visited twice, once while counting and once while reversing, so it's O(n) time and O(1) space. The recursive solution is shorter but uses O(n/k) stack.

Interviewers look for: the "check k nodes first" step. Candidates who reverse greedily then have to un-reverse the short tail, which is where the bugs come from.

38. Tree BFS & DFS

BasicHow do you decide between BFS and DFS for a tree problem?

So I look at what the question is organised around. If it talks about levels, rows, or "closest to the root", I use BFS with a queue. If it talks about paths from the root, or something computed over a whole subtree like height or diameter, I use DFS with recursion.

WordingPick
"Level order", "each level", "average per level", "zigzag"BFS
"Right side view", "minimum depth", "nearest leaf"BFS, and stop at the first answer
"Root-to-leaf path", "path sum", "all paths"DFS top-down, carry state down
"Height", "diameter", "balanced?", "max path sum"DFS bottom-up, children report up

Memory is the other lens. BFS holds a whole level, which is up to about n/2 nodes on the last level of a full tree. DFS holds one root-to-node path, which is O(h), so O(log n) on a balanced tree but O(n) on a skewed one.

The picture I use: BFS is a building inspector checking every room on floor 1, then floor 2. DFS is someone exploring a cave, following one tunnel to the end before backtracking.

So in practice: levels or shortest means BFS; paths or subtree facts means DFS. For plain "visit every node" questions either works, and I pick DFS because it's shorter to write.

Interviewers look for: the minimum-depth case. DFS has to explore the whole tree to be sure, while BFS can return at the first leaf it sees.

BasicWalk me through level order traversal.

So I put the root in a queue, and at the start of each round I snapshot size = queue.size(). Exactly that many nodes belong to the current level. The children I add while processing them wait for the next round.

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 BEFORE the loop
        List<Integer> level = new ArrayList<>();
        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);
        }
        result.add(level);
    }
    return result;
}
// [3,9,20,null,null,15,7] -> [[3], [9, 20], [15, 7]]

The bug I see most is writing for (int i = 0; i < queue.size(); i++). The size grows as I add children, so levels bleed into each other. I always store it in a variable first.

Once this template is in my hands, lots of problems are one-line edits. Right side view: record the node when i == size - 1. Average of levels: sum instead of list. Zigzag: on odd levels, addFirst into a LinkedList instead of changing the visiting order.

It's O(n) time and O(w) space where w is the widest level. I use ArrayDeque rather than LinkedList as the queue; it's faster and rejects nulls, which catches bugs.

IntermediateWhat's the difference between top-down and bottom-up DFS? Show me with diameter.

So the difference is which way information flows. Top-down passes state from parent to child through parameters, like the running sum or the max seen so far on the path. Bottom-up gets answers from the children through return values and combines them, like height.

The question I ask myself: can a node decide its answer from what its ancestors tell it? Then top-down. Does it need to know something about its whole subtree first? Then bottom-up.

Diameter is a good bottom-up example because each node answers two different questions: what height do I return to my parent, and what's the longest path that bends through me?

int diameter = 0;

int diameterOfBinaryTree(TreeNode root) {
    height(root);
    return diameter;                           // counted in edges
}

int height(TreeNode node) {
    if (node == null) return 0;
    int left = height(node.left);
    int right = height(node.right);
    diameter = Math.max(diameter, left + right);   // path bending here
    return 1 + Math.max(left, right);              // what my parent can extend
}
// [1,2,3,4,5] -> 3   (4 -> 2 -> 1 -> 3)

The naive version calls a separate height() at every node, which is O(n²) on a skewed tree. Folding the diameter update into the height recursion makes it one O(n) pass with O(h) stack.

Some problems use both directions at once. In Count Good Nodes, I pass the max-so-far down and add the counts on the way back up.

Interviewers look for: the "return one thing, record another" split. It's the same shape as Binary Tree Maximum Path Sum, which is usually the next question.

IntermediateWhen is recursive DFS a bad idea, and what do you do instead?

So recursive DFS uses the call stack, and its depth equals the tree height. On a balanced tree that's about 20 frames for a million nodes, which is nothing. On a skewed tree, basically a linked list, it's n frames, and with Java's default thread stack that overflows somewhere in the thousands to tens of thousands, depending on frame size. I tested it: a 100,000-node right-leaning chain throws StackOverflowError recursively.

The fix is an explicit stack on the heap:

int maxDepthIterative(TreeNode root) {
    if (root == null) return 0;
    Deque<Object[]> stack = new ArrayDeque<>();   // {node, depth}
    stack.push(new Object[]{root, 1});
    int best = 0;
    while (!stack.isEmpty()) {
        Object[] top = stack.pop();
        TreeNode node = (TreeNode) top[0];
        int depth = (int) top[1];
        best = Math.max(best, depth);
        if (node.right != null) stack.push(new Object[]{node.right, depth + 1});
        if (node.left != null) stack.push(new Object[]{node.left, depth + 1});
    }
    return best;
}
// same chain of 100,000 nodes -> 100000, no overflow

In real code I'd use a small record Frame(TreeNode node, int depth) instead of Object[]. I push right before left so left is processed first, matching preorder.

Other alternatives: BFS also avoids recursion, and for traversal-only tasks Morris traversal gets O(1) extra space by temporarily threading right pointers, at the cost of mutating the tree while it runs.

So in practice: recursion for interviews and for trees I know are balanced, like a TreeMap. Iterative when the input comes from users or files and could be arbitrarily deep.

IntermediateFind the minimum depth of a binary tree. Why BFS here?

So minimum depth is the number of nodes on the shortest root-to-leaf path. BFS visits nodes in order of depth, so the first leaf it meets is guaranteed to be the shallowest one, and I can return right there.

int minDepth(TreeNode root) {
    if (root == null) return 0;
    Deque<TreeNode> queue = new ArrayDeque<>();
    queue.offer(root);
    int depth = 1;
    while (!queue.isEmpty()) {
        int size = queue.size();
        for (int i = 0; i < size; i++) {
            TreeNode node = queue.poll();
            if (node.left == null && node.right == null) return depth;  // first leaf
            if (node.left != null) queue.offer(node.left);
            if (node.right != null) queue.offer(node.right);
        }
        depth++;
    }
    return depth;
}
// [3,9,20,null,null,15,7]       -> 2  (3 -> 9)
// [2,null,3,null,4,null,5,null,6] -> 5 (a chain; the only leaf is 6)

The second example is the classic trap for the DFS version. If I write 1 + Math.min(minDepth(left), minDepth(right)), a node with only a right child gets min(0, ...) = 0 from the missing side and reports depth 1, as if the null were a leaf. The correct DFS has to special-case one-child nodes.

BFS avoids that bug naturally, since it only stops at real leaves. And on a wide tree with a shallow leaf, it stops early instead of exploring everything.

Interviewers look for: the one-child case in the DFS version. If you use DFS, say out loud that a missing child is not a leaf.

AdvancedWalk me through Binary Tree Maximum Path Sum.

So a path can start and end anywhere and bends at most once, at its highest node. I do a bottom-up DFS where every node answers two questions. What do I return to my parent? The best path that starts at me and goes down one side, because my parent can only extend a path that hasn't bent yet. What do I record globally? The best path that bends at me: my value plus the best from both sides.

int maxSum = Integer.MIN_VALUE;

int maxPathSum(TreeNode root) {
    gain(root);
    return maxSum;
}

int gain(TreeNode node) {
    if (node == null) return 0;
    int left = Math.max(0, gain(node.left));      // a negative branch: don't take it
    int right = Math.max(0, gain(node.right));
    maxSum = Math.max(maxSum, node.val + left + right);  // bends here
    return node.val + Math.max(left, right);             // one arm for the parent
}
// [-10,9,20,null,null,15,7] -> 42   (15 -> 20 -> 7)
// [-3]                      -> -3
// [1,2,3]                   -> 6

Walking the first example: 15 and 7 return themselves. At 20, the bend is 20 + 15 + 7 = 42, recorded; it returns 20 + 15 = 35. At 9 it returns 9. At -10, the bend is -10 + 9 + 35 = 34, which doesn't beat 42.

Two details matter. Clamping with Math.max(0, ...) means "I'd rather not extend into a negative branch". And maxSum starts at Integer.MIN_VALUE, not 0, because a tree of all negatives, like [-3], must still return a real path.

It's O(n) time and O(h) space.

Interviewers look for: a clear statement of why the return value and the recorded value differ. Returning node.val + left + right to the parent is the most common wrong answer.

39. Top K Elements & K-way Merge

BasicHow do you recognise a top K problem, and why use a min-heap for the k largest?

So the signal is the letter k: "k-th largest", "top k", "k most frequent", "k closest". Especially if the input is huge or a stream, because then I can't sort it all. I keep a heap of size k holding the best k seen so far.

The min-heap for the k largest sounds backwards, and I explain it like a talent show with k chairs. When a new act arrives, I compare it with the weakest person on stage, because that's who they'd replace. A min-heap keeps exactly that weakest one on top.

nums = [3, 1, 5, 12, 2, 11], k = 3      min-heap (shown sorted)
offer 3, 1, 5                           [1, 3, 5]
offer 12 -> size 4, poll 1              [3, 5, 12]
offer 2  -> size 4, poll 2              [3, 5, 12]
offer 11 -> size 4, poll 3              [5, 11, 12]
top 3 = [5, 11, 12], peek() = 5 = 3rd largest

The heap never grows past k + 1, so it's O(n log k) time and O(k) space. Sorting would be O(n log n) and needs everything in memory.

The rule of thumb: k largest uses a min-heap, k smallest uses a max-heap with Comparator.reverseOrder(). The heap's top is always the element that's "about to be kicked out".

The other half of this section, k-way merge, has its own signal: "k sorted lists", "merge sorted", "smallest range across lists".

Interviewers look for: the reasoning, not just the choice. "Min-heap because the top is the threshold a newcomer has to beat" is the sentence they want.

BasicWalk me through finding the k-th largest element.

So I stream through the array into a min-heap and poll whenever it goes above size k. At the end the heap holds the k largest values, and its top is the smallest of them, which is the k-th largest.

int findKthLargest(int[] nums, int k) {
    PriorityQueue<Integer> heap = new PriorityQueue<>();   // min-heap
    for (int x : nums) {
        heap.offer(x);
        if (heap.size() > k) heap.poll();    // drop the smallest of k + 1
    }
    return heap.peek();
}
// [3,2,1,5,6,4], k = 2        -> 5
// [3,2,3,1,2,4,5,5,6], k = 4  -> 4   (duplicates count separately)

Duplicates are the thing to check with the interviewer. "k-th largest element" means position in sorted order, so in the second example the sorted array ends ..., 4, 5, 5, 6 and the 4th from the end is 4. If they meant k-th largest distinct value, I'd put the numbers into a set first.

The complexity is O(n log k) time and O(k) space. When k is tiny that's close to linear. When k is close to n, I'd flip it and find the (n - k + 1)-th smallest with a max-heap, so the heap stays small.

A small optimisation: once the heap has k items, I can skip the offer if x <= heap.peek(), which avoids a push and pop for elements that can't make the cut.

Interviewers look for: the follow-up "can you do better than O(n log k)?". The answer is quickselect, average O(n).

IntermediateTop K Frequent Elements: heap or bucket sort?

So both work, and I'd mention both. The heap version counts frequencies and keeps a size-k min-heap keyed on frequency, O(n log k). But a frequency can never exceed n, so I can bucket by frequency and walk from the top, which is O(n).

int[] topKFrequent(int[] nums, int k) {
    Map<Integer, Integer> freq = new HashMap<>();
    for (int x : nums) freq.merge(x, 1, Integer::sum);

    List<List<Integer>> bucket = new ArrayList<>();    // bucket[f] = values seen f times
    for (int i = 0; i <= nums.length; i++) bucket.add(new ArrayList<>());
    for (var e : freq.entrySet()) bucket.get(e.getValue()).add(e.getKey());

    int[] result = new int[k];
    int idx = 0;
    for (int f = nums.length; f >= 1 && idx < k; f--) {   // highest frequency first
        for (int x : bucket.get(f)) {
            if (idx == k) break;
            result[idx++] = x;
        }
    }
    return result;
}
// [1,1,1,2,2,3], k = 2 -> [1, 2]

I use a List<List<Integer>> rather than new List[n + 1], because generic array creation gives an unchecked warning.

The heap version is what I'd pick if the data is a stream, since I can't know the maximum frequency up front, or if the number of distinct values is huge and k is small. The bucket version needs O(n) space for the buckets but is linear.

If ties need a defined order, like LC 692 where equal frequencies sort alphabetically, the heap with a two-level comparator is cleaner than buckets.

Interviewers look for: noticing the bound on frequency. "Frequencies are at most n, so I can bucket" is the step from good to strong.

IntermediateWalk me through merging k sorted linked lists.

So I put the head of every list into a min-heap. Then I repeatedly poll the smallest, append it to the output, and push that node's next, so the list it came from sends its next candidate. The heap never holds more than k nodes, one front-runner per list.

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);      // skip empty lists
    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);
    }
    return dummy.next;
}
// [[1,4,5],[1,3,4],[2,6]] -> 1 1 2 3 4 4 5 6

Every one of the N total nodes goes in and out of a heap of size at most k, so it's O(N log k) time and O(k) extra space.

The comparison I get asked about:

ApproachTime
Merge list 1 with 2, then the result with 3, and so onO(N·k), because early nodes get re-scanned every round
Divide and conquer: merge in pairs, log k roundsO(N log k), O(1) extra besides recursion
Min-heapO(N log k), O(k) extra
Dump everything and sortO(N log N)

For arrays instead of linked lists, the heap entry has to carry {value, listIndex, elementIndex}, because an array element doesn't know its successor.

Interviewers look for: the connection to external sorting. This is exactly how you merge sorted chunks of a file too big for memory: one buffered reader per chunk, one heap.

IntermediateWhen doesn't a size-k heap make sense? What if the data is huge?

So the size-k heap shines when k is small relative to n. It stops being the best tool in a few cases, and I'd name them before the interviewer does.

SituationBetter choice
k is close to nJust sort, O(n log n), or flip to the n - k smallest
One k-th element, array fits in memory, can modify itQuickselect, O(n) average
Many different k queries on static dataSort once, then each query is O(1)
Values in a small rangeCounting array, O(n + range)
Need items removed or updated in the candidate setTreeMap or TreeSet

For "huge", I split it by what's huge. If it's a stream that doesn't fit in memory, the size-k heap is actually ideal, since it only needs O(k) memory and one pass. If the data is spread across machines, each machine computes its local top k, and a coordinator merges those k-sized lists. That's correct because the global top k must be in the union of the local top k's.

Top k frequent over a huge stream is harder, because I'd need a count for every distinct key. There I'd say: partition by hash of the key so each machine owns its keys fully, count locally, local top k, merge. If an approximate answer is okay, a Count-Min Sketch plus a small heap uses fixed memory.

So in practice: small k over big data is a heap. One-off selection in memory is quickselect. Repeated queries is sort once.

AdvancedCan you find the k-th largest faster than O(n log k)?

So yes, quickselect gets average O(n). It uses quicksort's partition, which puts one pivot at its final sorted position with smaller values to its left. If that position is the one I want, I stop. Otherwise I continue on only one side, and that's the whole difference from quicksort.

final Random rnd = new Random();

int findKthLargestQuick(int[] nums, int k) {
    int target = nums.length - k;           // index in ascending order
    int lo = 0, hi = nums.length - 1;
    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;
        else hi = p - 1;
    }
    return nums[target];
}

int partition(int[] a, int lo, int hi, int pivotIdx) {   // Lomuto
    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;                           // pivot's final index
}
// [3,2,3,1,2,4,5,5,6], k = 4 -> 4

Why average O(n): a random pivot cuts the range roughly in half on average, so the work is n + n/2 + n/4 + ..., which sums to about 2n. The worst case is O(n²) if the pivot is always the min or max, which is why I pick it randomly. A fixed pivot on sorted input hits the worst case every time.

I checked this against a sort-based answer on a thousand random arrays with lots of duplicates, and it matched every time.

The trade-offs I'd say out loud: it rearranges the input, it needs the whole array in memory, and with heavy duplicates Lomuto degrades, which a three-way partition fixes. For a stream I'd still use the heap.

Interviewers look for: "random pivot" and "it mutates the input". Bonus if you mention median-of-medians gives a guaranteed O(n) but is rarely used in practice because of its large constant.

40. Two Heaps

BasicWhat is the two heaps pattern, and when do you reach for it?

So I split the numbers into a smaller half and a larger half. The smaller half goes in a max-heap, so its biggest value is on top. The larger half goes in a min-heap, so its smallest value is on top. Those two tops sit right next to each other in sorted order, so the median is always one or two peeks away.

The signals are:

  • "Median" of a stream, of a changing set, or of every window.
  • "Numbers arrive one at a time, answer after each one", where the answer depends on the middle.
  • Two groups of items where I keep moving items from one to the other: "can't use yet" and "can use now", like IPO or a CPU scheduler.

Two invariants keep it correct:

RuleMeaning
OrderEvery value in low is <= every value in high, so low.peek() <= high.peek()
Sizelow.size() == high.size() or low.size() == high.size() + 1

Then with an odd count the median is low.peek(), and with an even count it's the average of both tops.

The picture I use: line people up by height and cut the line in half. The short group faces the cut with its tallest person there; the tall group faces the cut with its shortest person there. To find the middle height I only look at the two people at the cut.

Interviewers look for: which heap is which. Mixing them up (min-heap for the lower half) is a common slip and makes the tops useless.

BasicWalk me through Find Median from Data Stream.

So addNum does three steps: push into low, move low's top into high, and if high is now bigger, move its top back. findMedian just reads the tops.

class MedianFinder {
    private final PriorityQueue<Integer> low =
        new PriorityQueue<>(Comparator.reverseOrder());   // max-heap, smaller half
    private final PriorityQueue<Integer> high =
        new PriorityQueue<>();                            // min-heap, larger half

    public void addNum(int num) {
        low.offer(num);
        high.offer(low.poll());                  // the biggest of the small half crosses
        if (high.size() > low.size()) low.offer(high.poll());   // low keeps the extra
    }

    public double findMedian() {
        if (low.size() > high.size()) return low.peek();
        return (low.peek() + (long) high.peek()) / 2.0;   // long: no int overflow
    }
}

Tracing the stream 5, 15, 1, 3, 8:

add  5:  low [5]        high []        median 5.0
add 15:  low [5]        high [15]      median 10.0
add  1:  low [1, 5]     high [15]      median 5.0
add  3:  low [1, 3]     high [5, 15]   median 4.0
add  8:  low [1, 3, 5]  high [8, 15]   median 5.0

Adding 1 is interesting: 1 goes into low, low's top 5 moves to high, then high is bigger so 5 comes back. It looks wasteful, but it's what makes the order rule impossible to break.

addNum is O(log n), findMedian is O(1), space O(n). Re-sorting on every add would be O(n log n) per call.

Interviewers look for: the (long) cast. Two values near Integer.MAX_VALUE overflow when added as ints and give a negative median.

IntermediateWhy the three-step add? What goes wrong with a simpler version?

So the three steps each protect one invariant. Pushing into low and immediately moving its max to high guarantees the order rule, because whatever crosses over is the biggest of the small half. The final rebalance guarantees the size rule. I never have to reason about where the new number "should" go.

The version people write first compares with a top and then forgets to rebalance:

// fragile version
if (low.isEmpty() || num <= low.peek()) low.offer(num);
else high.offer(num);
// missing: fix the sizes afterwards

Feed it 1, 2, 3, 4, 5 and every number after the first goes into high, because each is bigger than low's top of 1. Now low = [1] and high = [2,3,4,5], and the median read from the tops is wrong. The comparison version can work, but only with two extra rebalancing checks, and that's where the off-by-ones creep in.

Other mistakes I watch for:

  • Letting high hold the extra element but still reading the odd-count median from low. Pick one heap to own the extra and read from that one.
  • Using (a, b) -> b - a for the max-heap. It overflows for large values; Comparator.reverseOrder() doesn't.
  • Integer division in the even case, which drops the .5.

So I use the three-step add because it costs a couple of extra O(log n) operations and removes a whole class of bugs.

IntermediateWhen don't two heaps fit? What are the alternatives?

So two heaps are built for "insert, then read the middle". They're weak at removing arbitrary elements and overkill when the data is static or small-range. The classic follow-ups to Find Median probe exactly that.

SituationBetter choice
All values in 0..100Count array of 101 buckets; median by walking it, O(100)
Elements must also be removed (sliding window)Two TreeSets, or heaps with lazy deletion
Static array, median onceQuickselect, O(n) average
Any percentile, not just the medianOrder-statistic tree, or sort if static
Huge stream, approximate is fineA sketch like t-digest

The bounded-range follow-up is worth showing, since it's what they ask when they say "all numbers are between 0 and 100":

class BoundedMedian {
    private final int[] count = new int[101];
    private int n = 0;

    void addNum(int x) { count[x]++; n++; }          // O(1)

    double findMedian() {                           // O(101)
        int a = kth((n + 1) / 2), b = kth(n / 2 + 1);
        return n % 2 == 1 ? a : (a + (double) b) / 2.0;
    }

    private int kth(int k) {                        // 1-indexed k-th smallest
        int seen = 0;
        for (int v = 0; v <= 100; v++) {
            seen += count[v];
            if (seen >= k) return v;
        }
        return -1;
    }
}
// add 5, 15, 1, 3 -> 4.0;  add 8 -> 5.0

For "99% of values are in 0..100", I'd keep the count array plus two counters for values below and above that range. The median almost certainly falls inside the array, and I only need exact outliers in the rare case it doesn't.

So in practice: two heaps for an unbounded stream with inserts only; a count array for a small range; TreeSets when things also leave.

AdvancedHow do you find the median of every sliding window?

So now elements leave as well as arrive, and that's the weak spot of PriorityQueue: remove(Object) is a linear scan, O(k). I switch to two TreeSets, which give me biggest, smallest and arbitrary removal in O(log k). The add logic stays exactly the same.

double[] medianSlidingWindow(int[] nums, int k) {
    // store INDEXES; compare by value, then index, so duplicates stay distinct
    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);
    TreeSet<Integer> high = new TreeSet<>(byValue);
    double[] result = new double[nums.length - k + 1];
    for (int i = 0; i < nums.length; i++) {
        low.add(i);
        high.add(low.pollLast());
        if (high.size() > low.size()) low.add(high.pollFirst());
        if (i >= k) {                               // remove the index that left
            int out = i - k;
            if (!low.remove(out)) high.remove(out);
            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;
}
// [1,3,-1,-3,5,3,6,7], k = 3 -> [1.0, -1.0, -1.0, 3.0, 5.0, 6.0]
// [2,2,2,2], k = 2           -> [2.0, 2.0, 2.0]

The subtle part is storing indexes, not values. A TreeSet drops duplicates, so a window like [2,2] would silently lose an element. Tie-breaking on the index makes every entry unique, and it also means I remove exactly the element that left, not some other copy.

It's O(n log k) time and O(k) space.

The other accepted approach is two PriorityQueues with lazy deletion: keep a map of values waiting to be deleted, track the "real" sizes separately, and discard stale tops when they reach the top. It's faster in practice but has more bookkeeping, so I'd go with TreeSets in an interview.

Interviewers look for: the duplicate problem with TreeSet. Candidates who store raw values pass the first example and fail on repeated numbers.

AdvancedWalk me through IPO, the "two groups" version of two heaps.

So here the two heaps don't hold halves of a sorted list. They hold two groups: projects I can't afford yet, in a min-heap by required capital, and projects I can afford now, in a max-heap by profit. Each round I unlock everything my capital covers, then greedily take the most profitable.

int findMaximizedCapital(int k, int w, int[] profits, int[] capital) {
    PriorityQueue<int[]> locked =
        new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));   // {capital, profit}
    PriorityQueue<Integer> available =
        new PriorityQueue<>(Comparator.reverseOrder());               // profits
    for (int i = 0; i < profits.length; 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 I can start
        w += available.poll();                   // take the best
    }
    return w;
}
// k=2, w=0, profits=[1,2,3], capital=[0,1,1] -> 4
// k=3, w=0, profits=[1,2,3], capital=[0,1,2] -> 6

Tracing the first one: with w = 0 only project 0 unlocks, so I take profit 1 and w = 1. Now projects 1 and 2 unlock, I take profit 3, and w = 4.

The greedy is safe because capital only goes up. Taking the most profitable affordable project can never lock me out of anything; it only unlocks more. And once a project moves to available, it stays there, so each project crosses at most once. That's O(n log n) to build plus O(k log n) for the rounds.

The same "locked versus ready" shape shows up in Single-Threaded CPU (tasks not yet arrived versus waiting), Meeting Rooms III (busy rooms versus free rooms) and Minimum Refueling Stops.

Interviewers look for: recognising that this is the same pattern as the median problem even though nothing is being split in half: two heaps, each keeping the item you care about next on top, with items moving one way between them.

41. Subsets, Permutations & Combinations

BasicHow many subsets and permutations does an array of n elements have, and what does that mean for the running time?

So an array of n distinct elements has 2n subsets and n! permutations. Those are output sizes, so no algorithm that lists them all can beat them. The best I can do is not waste work on top.

Let me show you with a quick table.

n     2^n subsets     n! permutations
3     8               6
10    1,024           3,628,800
15    32,768          ~1.3 trillion
20    ~1 million      ~2.4 * 10^18

Each subset has up to n elements, and I copy it into the result, so generating all subsets is O(n · 2n). Permutations are O(n · n!) for the same reason. Every leaf costs a copy of length n.

What this tells me in an interview is the input limit. If the constraint says n <= 20, 2n is about a million and fine. If it says n <= 10, n! is about 3.6 million and fine. If it says n <= 105, the problem is not asking me to enumerate. It wants a count via DP or a formula, and listing would never finish.

So in practice I read the constraints first. A tiny n is the hint that backtracking is expected.

Interviewers look for: the extra factor of n for copying each answer. Saying "O(2n)" alone is usually accepted but gets the follow-up "what does each leaf cost?"

BasicWhy do you write result.add(new ArrayList<>(path)) instead of result.add(path)?

So path is one list that I keep mutating as I choose and un-choose. If I add the reference itself, every entry in the result points to that same list, and by the end it's empty.

Let me show you with a quick example.

static List<List<Integer>> wrong = new ArrayList<>();

static void badBacktrack(int[] nums, int start, List<Integer> path) {
    wrong.add(path);                          // BUG: stores the same list object
    for (int i = start; i < nums.length; i++) {
        path.add(nums[i]);
        badBacktrack(nums, i + 1, path);
        path.remove(path.size() - 1);
    }
}

// badBacktrack(new int[]{1, 2, 3}, 0, new ArrayList<>());
// wrong = [[], [], [], [], [], [], [], []]

What happens here is that I do record eight answers, but they're eight references to one object. Backtracking undoes every choice on the way out, so when I print the result that one list is back to [].

The copy takes a snapshot of the path at that moment. That snapshot is also where the O(n) per answer in the complexity comes from.

The same trap shows up with a StringBuilder. I have to call sb.toString() when saving, never store the builder. And with an int[] path I'd use path.clone() or Arrays.copyOf.

So the rule I follow: the path is shared scratch space, and anything that goes into the result is a copy.

IntermediateWalk me through generating all subsets. Is there a way without recursion?

So I'd use the start-index backtracking template. Every node of the decision tree is a valid subset, so I save at every call. Then I only extend with elements to the right of the last pick, so [2,1] is never built as a duplicate of [1,2].

static List<List<Integer>> subsets(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    backtrack(nums, 0, new ArrayList<>(), result);
    return result;
}

static void backtrack(int[] nums, int start, List<Integer> path, List<List<Integer>> result) {
    result.add(new ArrayList<>(path));       // every node is an answer
    for (int i = start; i < nums.length; i++) {
        path.add(nums[i]);                    // choose
        backtrack(nums, i + 1, path, result); // explore
        path.remove(path.size() - 1);         // un-choose
    }
}
// [1,2,3] -> [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]

Without recursion, I use a bitmask. Each number from 0 to 2n - 1 is one subset: bit i set means "include nums[i]".

static List<List<Integer>> subsetsBits(int[] nums) {
    int n = nums.length;
    List<List<Integer>> result = new ArrayList<>();
    for (int mask = 0; mask < (1 << n); mask++) {
        List<Integer> subset = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            if ((mask & (1 << i)) != 0) subset.add(nums[i]);
        }
        result.add(subset);
    }
    return result;
}
// [1,2,3] -> [[], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3]]

Both are O(n · 2n). The bitmask one is short, but it's limited to n <= 30 with an int, and it doesn't extend to duplicates or pruning. So in practice I write the backtracking version, because the same template becomes Subsets II and Combination Sum with a one-line change.

Interviewers look for: that you know both. The follow-up is usually "now the input has duplicates", which is only easy with the backtracking version.

IntermediateThe input has duplicates, like [1,2,2]. How do you avoid duplicate subsets? Why i > start and not i > 0?

So I sort first, which puts equal values next to each other. Then inside the loop I skip a value if it equals its left neighbour at the same level of the tree. That means I only start one branch per distinct value at each level.

static void dup(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]);
        dup(nums, i + 1, path, result);
        path.remove(path.size() - 1);
    }
}
// sorted [1,2,2] -> [[], [1], [1, 2], [1, 2, 2], [2], [2, 2]]

The i > start part is the subtle bit. The loop at one call is one level of the tree. The first element of the loop, at i == start, is always allowed, even if it equals nums[i - 1], because nums[i - 1] was picked at the level above. That's how [2,2] gets built: go down with the first 2, and at the next level the second 2 is the first option.

If I wrote i > 0, I'd lose those. I actually ran it: with i > 0 the output is [[], [1], [1, 2], [2]]. [1,2,2] and [2,2] disappear.

For permutations with duplicates, the rule is i > 0 && nums[i] == nums[i-1] && !used[i-1], because there's no start index. So in practice: sort, then skip same value at the same level.

Interviewers look for: the level explanation, not just the line of code. Many candidates memorise the skip and can't say why [2,2] survives.

IntermediateCombination Sum: numbers can be reused. How does that change the template, and how do you prune?

So two changes. I recurse with i instead of i + 1, which lets the same candidate be picked again. And I track remaining, saving the path only when it hits exactly zero.

static List<List<Integer>> combinationSum(int[] candidates, int target) {
    Arrays.sort(candidates);
    List<List<Integer>> result = new ArrayList<>();
    comb(candidates, 0, target, new ArrayList<>(), result);
    return result;
}

static void comb(int[] c, int start, int remaining, 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, i, remaining - c[i], path, result);   // i, not i + 1: reuse allowed
        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]]

The start index still matters even with reuse. Without it I'd get [2,2,3], [2,3,2] and [3,2,2] as three answers. Keeping picks in non-decreasing index order means each combination is built exactly once.

The pruning comes from the sort. Once c[i] is bigger than what's left, every later candidate is too, so I break instead of continue. That cuts whole subtrees, not just one branch.

So the three variants are one template with different knobs: i + 1 for use-once, i for reuse, and the duplicate skip for Combination Sum II.

AdvancedGenerate all valid parentheses for n pairs. How do you avoid generating invalid strings, and how many are there?

So instead of building all 22n strings and filtering, I only ever add a character that keeps the prefix valid. I can add ( while I've used fewer than n. I can add ) only while there are more opens than closes. Every leaf is then valid by construction.

static List<String> generateParenthesis(int n) {
    List<String> result = new ArrayList<>();
    gen(n, 0, 0, new StringBuilder(), result);
    return result;
}

static 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) {
        sb.append('(');
        gen(n, open + 1, close, sb, result);
        sb.deleteCharAt(sb.length() - 1);
    }
    if (close < open) {
        sb.append(')');
        gen(n, open, close + 1, sb, result);
        sb.deleteCharAt(sb.length() - 1);
    }
}
// n = 3 -> [((())), (()()), (())(), ()(()), ()()()]

The count is the Catalan number Cn = (2n)! / ((n+1)! n!): 1, 2, 5, 14, 42... For n = 3 I get 5, and for n = 4 I get 14. It grows roughly like 4n / n1.5.

For complexity, the tree has no dead ends, since every branch I take leads to at least one valid string. So the work is proportional to the output: O(Cn · n) including the toString copies. Recursion depth is 2n.

So the general lesson I'd point out: move the validity check from the leaf into the branching condition. That's the difference between filtering and pruning.

Interviewers look for: "close < open", not "close < n". The follow-up is often "just count them", which is the Catalan recurrence or a DP on (open, close).

42. Modified Binary Search

BasicWhy do you write mid = lo + (hi - lo) / 2 instead of (lo + hi) / 2?

So lo + hi can overflow an int when both are large, and the mid goes negative. lo + (hi - lo) / 2 never adds the two big numbers, so it stays in range.

Let me show you with a quick example.

int lo = 2_000_000_000, hi = 2_100_000_000;
(lo + hi) / 2          // -97483648   overflowed past Integer.MAX_VALUE
lo + (hi - lo) / 2     // 2050000000
(lo + hi) >>> 1        // 2050000000  unsigned shift also works

You won't hit this with array indices under a billion. But it matters a lot for binary search on the answer, where the range is values, not indices. A capacity or a time can easily go up to 109 or more. This exact bug lived in the JDK's Arrays.binarySearch for years.

The other detail is that both forms round down, so mid leans left. That's why the while (lo < hi) template pairs with hi = mid and lo = mid + 1. If I ever need lo = mid, I have to round up with lo + (hi - lo + 1) / 2, or the loop can get stuck when hi = lo + 1.

So I use lo + (hi - lo) / 2 by habit, and I check the rounding matches how I move the bounds.

IntermediateSearch for a target in a rotated sorted array in O(log n).

So the key fact is that if I cut a rotated sorted array at mid, at least one half is fully sorted. I find which half by comparing nums[lo] with nums[mid]. Then I check whether the target is inside that sorted half's range. If yes I go there, otherwise I go to the other half.

static 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;
}
// [4,5,6,7,0,1,2], 0 -> 4    target 3 -> -1    [1], 0 -> -1

Tracing target 0: lo=0, hi=6, mid=3 (7). 4 <= 7, so the left half is sorted. Is 0 in [4, 7)? No, so lo = 4. Now mid=5 (1). nums[4]=0 <= 1, left sorted, and 0 is in [0, 1), so hi = 4. mid=4 is 0. Found at index 4.

One gotcha I've hit is <= in nums[lo] <= nums[mid]. When lo == mid, which happens with two elements left, the "left half" is just one element and it counts as sorted. With < it breaks on inputs like [3,1] searching for 1.

It's O(log n) time, O(1) space. With duplicates allowed (LC 81), nums[lo] == nums[mid] == nums[hi] makes it impossible to tell which side is sorted. Then I shrink by one, and the worst case becomes O(n).

Interviewers look for: the "one half is always sorted" sentence before any code. And the duplicates follow-up, where the honest answer is O(n) worst case.

IntermediateFind the first and last position of a target in a sorted array.

So I run two boundary searches instead of finding any match and scanning outward. Scanning outward is O(n) when the whole array is the target. I write one lowerBound, "first index with value >= x", and call it twice.

static 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;
}

static 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;  // one before the first bigger value
    return new int[]{first, last};
}
// [5,7,7,8,8,10], 8 -> [3, 4]    6 -> [-1, -1]    [] , 0 -> [-1, -1]

The reason I like this shape is that the condition is monotonic: false false false TRUE TRUE. The loop always ends with lo == hi on the first true, so there's no "did I return mid or mid + 1?" guessing.

The last position is "first index with value >= target + 1, minus one". That trick needs a type that won't overflow at target + 1. If the target could be Integer.MAX_VALUE, I'd write a separate upper-bound with nums[mid] > target instead.

So it's two O(log n) searches, O(1) space, and the same helper solves "count of x" (last - first + 1) and "insert position" too.

IntermediateFind the minimum of a rotated sorted array. What changes if there are duplicates?

So I compare mid with the last element. If nums[mid] > nums[hi], the drop is to the right of mid, so the minimum is in (mid, hi]. Otherwise mid is in the lower run, and the minimum is at mid or to its left.

static 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 could be the minimum
    }
    return nums[lo];
}
// [3,4,5,1,2] -> 1    [4,5,6,7,0,1,2] -> 0    [11,13,15,17] -> 11

I compare with hi rather than lo because it handles the not-rotated case for free. In [11,13,15,17], mid is always <= hi, so hi keeps moving left to index 0.

With duplicates (LC 154), nums[mid] == nums[hi] tells me nothing. In [3,1,3,3,3] the minimum is left of mid, and in [3,3,3,1,3] it's right. So I drop just hi, which is safe because mid is an equal copy:

if (nums[mid] > nums[hi]) lo = mid + 1;
else if (nums[mid] < nums[hi]) hi = mid;
else hi--;                                        // can't tell: shrink by one
// [2,2,2,0,1] -> 0    [3,1,3,3,3] -> 1

That makes the worst case O(n), for example an array of all 1s with one 0 somewhere. So without duplicates it's O(log n), and with them I say "O(log n) average, O(n) worst" and explain why no algorithm can do better.

Interviewers look for: why you compare with hi, not lo. The follow-up is "return the rotation index", which is just lo.

AdvancedWhat is "binary search on the answer"? Show me with Capacity to Ship Packages Within D Days.

So sometimes I can't compute the answer directly, but I can cheaply check a guess, and feasibility is monotonic: if capacity C works, any bigger capacity works too. Then the answers look like no no no YES YES, and I binary search for the first yes over the range of possible answers.

static 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 ships everything
    }
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (canShip(weights, days, mid)) hi = mid;
        else lo = mid + 1;
    }
    return lo;
}

static boolean canShip(int[] weights, int days, int capacity) {
    int needed = 1, load = 0;
    for (int w : weights) {                       // greedy: fill each day in order
        if (load + w > capacity) {
            needed++;
            load = 0;
        }
        load += w;
    }
    return needed <= days;
}
// [1..10], 5 -> 15     [3,2,2,4,1,4], 3 -> 6     [1,2,3,1,1], 4 -> 3

The bounds matter. The low end is the heaviest package, since anything smaller can't carry it. The high end is the total, which ships in one day. The check is greedy: packages must go in order, so I pack each day as full as possible.

Complexity is O(n · log(sum)). The binary search does about log(sum) rounds, and each check is one O(n) pass. With sum up to 5 * 107, that's about 26 passes.

The same shape solves Koko Eating Bananas, Split Array Largest Sum, and Minimum Days to Make Bouquets. The phrases that give it away are "minimum capacity / speed" and "minimise the maximum". So in practice, when I see those, I first ask myself "can I check a guess in O(n)?"

Interviewers look for: a stated monotonicity argument and correct bounds. A common bug is starting lo at 1, which makes canShip silently wrong for a package heavier than the capacity.

AdvancedHow do you find the median of two sorted arrays in O(log(min(m, n)))?

So I don't merge. I binary search for a partition: take i elements from the shorter array A and j = half - i from B, so the left side has half of all elements. The partition is correct when everything on the left is <= everything on the right. Because each array is sorted, I only need to check the two cross pairs.

static double findMedianSortedArrays(int[] a, int[] b) {
    if (a.length > b.length) return findMedianSortedArrays(b, a);   // search the shorter
    int m = a.length, n = b.length, half = (m + n + 1) / 2;
    int lo = 0, hi = m;
    while (lo <= hi) {
        int i = (lo + hi) / 2;                    // take i from a
        int j = half - i;                         // and the rest 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) {
            if ((m + n) % 2 == 1) return Math.max(aLeft, bLeft);
            return (Math.max(aLeft, bLeft) + 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");
}
// [1,3],[2] -> 2.0     [1,2],[3,4] -> 2.5     [],[1] -> 1.0

If aLeft > bRight, I took too many from A, so I move left. Otherwise I took too few. The sentinels MIN_VALUE and MAX_VALUE handle the edge where a partition takes nothing or everything from one array.

I search the shorter array for two reasons: it's O(log(min(m, n))), and it guarantees j stays within [0, n]. The + 1 in half puts the extra element on the left for odd totals, so the median is just the max of the left side.

In an interview I'd mention the O(m + n) merge first as the baseline. I'd do this version only if they ask for log time.

Interviewers look for: the partition idea explained in words, and the sentinel edges. Most failed attempts break on an empty array or on j going out of range.

43. Topological Sort

BasicWhat is a topological sort, and when does one exist?

So a topological sort is an ordering of the nodes of a directed graph where, for every edge A → B, A comes before B. Think of the edges as "must happen before". One exists if and only if the graph has no directed cycle, meaning it's a DAG.

Let me show you with a quick example.

edges: 0 -> 1, 0 -> 2, 1 -> 3, 2 -> 3

valid orders:   [0, 1, 2, 3]   [0, 2, 1, 3]
invalid:        [1, 0, 2, 3]   (1 before 0, but 0 -> 1)

add 3 -> 0:     0 -> 1 -> 3 -> 0 is a cycle, so no order exists

The cycle part is intuitive. If A must come before B and B before A, nobody can go first. And the other direction also holds: every DAG has at least one node with no incoming edges, so I can always pick something to go first, remove it, and repeat.

The order is usually not unique. In the example, 1 and 2 don't depend on each other, so either can come first.

Where I've actually seen this: build systems ordering compile steps, package managers resolving install order, spreadsheet cells recomputing, and database migrations. The interview versions are Course Schedule, build order and Alien Dictionary.

So when a problem says "prerequisites", "depends on" or "must be done before", I think topological sort. And "is it possible?" is really "is there a cycle?"

IntermediateWalk me through Kahn's algorithm. How does it detect a cycle?

So Kahn's algorithm is BFS on indegrees. A node's indegree is how many prerequisites are still unfinished. I start a queue with every node at indegree 0. Each time I poll a node, I add it to the order and decrement its neighbours. Any neighbour that drops to 0 joins the queue.

static 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) {           // 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[] order = new int[numCourses];
    int idx = 0;
    while (!queue.isEmpty()) {
        int node = queue.poll();
        order[idx++] = node;
        for (int next : graph.get(node)) {
            if (--indegree[next] == 0) queue.offer(next);
        }
    }
    return idx == numCourses ? order : new int[0];   // fewer than n: cycle
}
// 4, [[1,0],[2,0],[3,1],[3,2]] -> [0, 1, 2, 3]     2, [[1,0],[0,1]] -> []

The cycle detection comes for free. Nodes on a cycle wait on each other, so their indegree never reaches 0 and they never enter the queue. If I processed fewer than n nodes, there's a cycle.

One gotcha I've hit is edge direction. LeetCode gives [a, b] meaning "b before a", so the edge is b → a. I always write that comment before building the graph.

It's O(V + E) time and space: each node is enqueued once, and each edge is decremented once.

Interviewers look for: the "count processed nodes" check and the edge direction. The follow-up is often "which nodes are in the cycle?", which are exactly the ones with indegree still above 0.

IntermediateHow do you do topological sort with DFS? Why do you need three colours instead of a visited boolean?

So in DFS I add a node to the order only after all its descendants finish, which is post-order. Reversing that gives a valid topological order. For cycle detection I track three states: unvisited, in progress (on the current path) and done.

static final int WHITE = 0, GRAY = 1, BLACK = 2;

static boolean dfs(int u, List<List<Integer>> graph, int[] color, Deque<Integer> stack) {
    color[u] = GRAY;                          // on the current path
    for (int v : graph.get(u)) {
        if (color[v] == GRAY) return false;   // back edge: cycle
        if (color[v] == WHITE && !dfs(v, graph, color, stack)) return false;
    }
    color[u] = BLACK;                         // fully finished
    stack.push(u);                            // post-order, pushed to the front
    return true;
}
// edges 0->1, 0->2, 1->3, 2->3 : order [0, 2, 1, 3]
// edges 0->1, 1->2, 2->0       : cycle, []

The reason one boolean isn't enough: in a directed graph, reaching an already visited node doesn't mean a cycle. In the diamond 0→1→3 and 0→2→3, I reach 3 a second time through 2, but 3 is finished, and that's fine. It's only a cycle if the node is still on my current path, which is GRAY.

For undirected graphs, one visited flag plus "ignore the parent" works. That's where people's habit comes from, and it's exactly what breaks here.

Both approaches are O(V + E). I usually pick Kahn's because it has no recursion depth problem on long chains, and it extends to "minimum number of rounds". I'd pick DFS if I already have a DFS written, or for a strongly connected components follow-up.

IntermediateWhat is the minimum number of semesters to finish all courses, if you can take any number of courses at once?

So that's Kahn's algorithm processed level by level. Everything in the queue at the same time has no unmet prerequisites, so it can all be taken in the same semester. The number of levels is the answer. It equals the number of nodes on the longest path in the DAG.

static int minimumSemesters(int n, int[][] relations) {   // 1-indexed {prev, next}
    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) {
        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()) {
        semesters++;
        for (int size = queue.size(); size > 0; size--) {   // one level = one semester
            int course = queue.poll();
            taken++;
            for (int next : graph.get(course)) if (--indegree[next] == 0) queue.offer(next);
        }
    }
    return taken == n ? semesters : -1;
}
// 3, [[1,3],[2,3]] -> 2      3, [[1,2],[2,3],[3,1]] -> -1

The trick is snapshotting queue.size() before the inner loop. New courses that unlock during this semester go to the back of the queue and get counted next round. It's the same pattern as level-order traversal of a tree.

A related question is "is the order unique?" That's true only if the queue never holds more than one node at a time. Two free nodes at once means I could take them in either order.

Still O(V + E). If there's a cap of k courses per semester (LC 1494), the greedy breaks down and it becomes bitmask DP, which I'd call out rather than try to force.

Interviewers look for: the level snapshot. And whether you notice that a per-semester limit changes the problem completely.

AdvancedAlien Dictionary: given words sorted in an unknown alphabet, find the letter order.

So it's a graph-building problem followed by a plain topological sort. Adjacent words tell me one fact each: at their first differing position, the letter in the first word comes before the letter in the second. Everything after that position tells me nothing.

static 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()) {
            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 k = 0; k < Math.min(a.length(), b.length()); k++) {
            char x = a.charAt(k), y = b.charAt(k);
            if (x != y) {
                if (graph.get(x).add(y)) indegree.merge(y, 1, Integer::sum);  // only new edges
                break;                        // only the FIRST difference counts
            }
        }
    }
    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
}
// ["wrt","wrf","er","ett","rftt"] -> "wertf"     ["z","x","z"] -> ""

Three edge cases are where people lose this one. First, every letter that appears must be in the output, even with no edges, so I register all characters up front. Second, a word followed by its own prefix, like "abc" then "ab", is impossible in any alphabet, so I return "". Third, the same pair can appear twice, so I use a Set and only bump the indegree on a new edge. Otherwise the count never reaches zero.

Complexity is O(C) to scan all characters, plus O(U + E) for the sort, where U is at most 26. So it's effectively linear in the total input length.

Interviewers look for: the prefix case and the duplicate-edge case. Candidates who compare every pair of words, not just adjacent ones, also get pushed on why that's unnecessary.

44. Matrix & Islands

BasicHow do you treat a grid as a graph? What is the complexity of visiting every cell?

So each cell is a node, and its neighbours are the up to four cells next to it. I don't build an adjacency list. I compute neighbours on the fly with a direction array and a bounds check. Visiting every cell with DFS or BFS is O(R × C), because each cell is marked once and checks at most four neighbours.

Let me show you with a quick example.

static final int[][] DIRS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};   // down, up, right, left

for (int[] d : DIRS) {
    int nr = r + d[0], nc = c + d[1];
    if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;    // off the grid
    if (grid[nr][nc] != '1') continue;                             // not part of the region
    // visit (nr, nc)
}

The direction array replaces four copy-pasted if-blocks, and that's where most grid bugs come from. If the problem allows diagonals, I add the four diagonal pairs and nothing else changes.

In graph terms, V = R × C and E is at most 4V, so O(V + E) becomes O(R × C). Space is O(R × C) for the visited array. I can make it O(1) extra by overwriting the grid, as long as I ask first whether mutating the input is fine.

A trick I use when a set or union-find needs one key per cell: encode (r, c) as r * cols + c, and decode with / cols and % cols.

So I set up the grid scaffolding once, and every island problem becomes a question of what to do at each cell.

IntermediateYour recursive DFS for islands crashes on a 1000 × 1000 grid. Why, and how do you fix it?

So the recursion depth can reach the number of cells in one island. A grid of all land is a million nested calls, and Java's default thread stack only fits tens of thousands of frames at most. I tested it, and it throws StackOverflowError. The fix is BFS or DFS with an explicit queue or stack, which lives on the heap.

static int numIslandsBfs(char[][] grid) {
    int rows = grid.length, cols = grid[0].length, count = 0;
    Deque<int[]> queue = new ArrayDeque<>();
    for (int r = 0; r < rows; r++) {
        for (int c = 0; c < cols; c++) {
            if (grid[r][c] != '1') continue;
            count++;
            grid[r][c] = '0';                 // mark when ENQUEUED
            queue.offer(new int[]{r, c});
            while (!queue.isEmpty()) {
                int[] cell = queue.poll();
                for (int[] d : DIRS) {
                    int nr = cell[0] + d[0], nc = cell[1] + d[1];
                    if (nr < 0 || nr >= rows || nc < 0 || nc >= cols || grid[nr][nc] != '1') continue;
                    grid[nr][nc] = '0';
                    queue.offer(new int[]{nr, nc});
                }
            }
        }
    }
    return count;
}
// 1000 x 1000 all land -> 1, no crash

One detail matters a lot here: I mark a cell visited when I enqueue it, not when I poll it. If I mark on poll, the same cell can be added by up to four neighbours before it's processed. The queue swells, and on big grids it's noticeably slower.

Other options are running the DFS in a thread with a bigger stack size, or union-find. But in an interview I'd say "the recursive version is fine for LeetCode sizes, and for production-sized grids I'd switch to BFS." That shows I know where the limit is.

Interviewers look for: naming the stack depth as O(R × C) in the worst case, and marking on enqueue. The follow-up is often "and BFS queue size?", which is much smaller in practice.

IntermediateRotting Oranges: how many minutes until every orange is rotten?

So it's multi-source BFS. Every rotten orange starts spreading at the same time, so I put all of them in the queue before the loop. Each BFS layer is one minute. I count fresh oranges up front so I can stop early and detect the unreachable ones.

static 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});   // every source up front
            else if (grid[r][c] == 1) fresh++;
        }
    }
    int minutes = 0;
    while (!queue.isEmpty() && fresh > 0) {
        minutes++;
        for (int size = queue.size(); size > 0; size--) {       // one layer = one minute
            int[] cell = queue.poll();
            for (int[] d : DIRS) {
                int nr = cell[0] + d[0], nc = cell[1] + d[1];
                if (nr < 0 || nr >= rows || nc < 0 || nc >= cols || grid[nr][nc] != 1) continue;
                grid[nr][nc] = 2;
                fresh--;
                queue.offer(new int[]{nr, nc});
            }
        }
    }
    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    [[0,2]] -> 0

The fresh > 0 in the loop condition is deliberate. Without it I'd count one extra minute for the final layer that rots nothing. And [[0,2]] must return 0, not -1, because there was nothing to rot.

Why not run a BFS from each rotten orange separately? That's O(k × R × C), and taking the minimum per cell is messy. Seeding all sources at once is like adding a virtual super-source connected to each of them. One BFS gives every cell its distance to the nearest source.

O(R × C) time and space. The same pattern solves 01 Matrix and Walls and Gates.

IntermediateSurrounded Regions: flip every 'O' region that isn't connected to the border. How do you approach it?

So I flip the question around. Checking "is this region enclosed?" means exploring a region and only finding out at the end. Instead, I start from every border 'O' and mark everything it reaches as safe. Whatever 'O' is left unmarked is enclosed.

static void solve(char[][] board) {
    int rows = board.length, cols = board[0].length;
    for (int r = 0; r < rows; r++) {
        mark(board, r, 0);
        mark(board, r, cols - 1);
    }
    for (int c = 0; c < cols; c++) {
        mark(board, 0, c);
        mark(board, rows - 1, c);
    }
    for (int r = 0; r < rows; r++) {
        for (int c = 0; c < cols; c++) {
            if (board[r][c] == 'O') board[r][c] = 'X';        // not reached: enclosed
            else if (board[r][c] == '#') board[r][c] = 'O';   // reached from border: restore
        }
    }
}

static void mark(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] = '#';
    for (int[] d : DIRS) mark(b, r + d[0], c + d[1]);
}
// XXXX      XXXX
// XOOX  ->  XXXX
// XXOX      XXXX
// XOXX      XOXX   (bottom O touches the border, so it stays)

The temporary '#' is a third state, so I can tell "safe O" from "enclosed O" in one final sweep without an extra visited array.

This boundary trick shows up a lot. In Pacific Atlantic Water Flow I run it twice, once from each ocean's edges, flowing uphill, and the answer is the intersection. In Number of Enclaves I count what's left instead of flipping it.

It's O(R × C) time. Space is O(R × C) worst case for the recursion, so for big boards I'd use the BFS version from the previous question.

Interviewers look for: the "start from the border" flip. Candidates who DFS from each interior 'O' and try to undo changes when they hit the edge usually get tangled.

IntermediateRotate an n × n matrix 90 degrees clockwise in place.

So a clockwise rotation is a transpose followed by reversing each row. Both steps are simple in-place swaps, so I don't need a second matrix.

static void rotate(int[][] m) {
    int n = m.length;
    for (int i = 0; i < n; i++) {                 // 1. transpose: swap across the 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;
        }
    }
}
// 1 2 3      1 4 7      7 4 1
// 4 5 6  ->  2 5 8  ->  8 5 2
// 7 8 9      3 6 9      9 6 3
//   transpose    reverse rows

The way I justify it: clockwise rotation sends (i, j) to (j, n-1-i). Transpose sends (i, j) to (j, i), and reversing the row sends column i to n-1-i. Put together, that's the rotation.

The bug I watch for is the inner loop starting at j = i + 1. If it starts at 0, every pair gets swapped twice and the matrix comes back unchanged.

For counter-clockwise, I transpose and then reverse each column, or equivalently reverse each row first and then transpose. For 180 degrees, I reverse the order of the rows and then reverse each row.

It's O(n2) time, which is optimal since every cell moves, and O(1) extra space. The alternative is rotating four cells at a time layer by layer. It's also O(1), but the index arithmetic is easier to get wrong under pressure.

AdvancedLand cells are added one at a time. How do you report the number of islands after each addition?

So rerunning DFS after each addition is O(k × R × C). Instead I use union-find. Each new land cell starts as its own island, so count + 1. Then for each land neighbour in a different set, I union them and count - 1. Each addition costs nearly constant time.

static int[] parent;

static int find(int x) {
    while (parent[x] != x) {
        parent[x] = parent[parent[x]];            // path halving
        x = parent[x];
    }
    return x;
}

static List<Integer> numIslands2(int m, int n, int[][] positions) {
    parent = new int[m * n];
    Arrays.fill(parent, -1);                      // -1 = water
    List<Integer> result = new ArrayList<>();
    int count = 0;
    for (int[] p : positions) {
        int id = p[0] * n + p[1];
        if (parent[id] != -1) {                   // already land: nothing changes
            result.add(count);
            continue;
        }
        parent[id] = id;
        count++;                                  // new island on its own...
        for (int[] d : DIRS) {
            int r = p[0] + d[0], c = p[1] + d[1], nid = r * n + c;
            if (r < 0 || r >= m || c < 0 || c >= n || parent[nid] == -1) continue;
            int a = find(id), b = find(nid);
            if (a != b) {                         // ...unless it touches others: merge
                parent[a] = b;
                count--;
            }
        }
        result.add(count);
    }
    return result;
}
// 3x3, [[0,0],[0,1],[1,2],[2,1]] -> [1, 1, 2, 3]
// 3x3, [[0,0],[0,2],[0,1]]       -> [1, 2, 1]   (the middle cell joins two islands)

The a != b check is what makes it correct. When a new cell touches two neighbours that already belong to the same island, only the first union decrements the count.

The duplicate-position check matters too. Adding the same cell twice must not bump the count.

With path compression and union by rank, each operation is O(α(n)), effectively constant. I only did path halving here, which is O(log n) amortised and fine in practice. Total is O(R × C + k · α). The limit is that union-find only handles additions. If land can also be removed, it doesn't support splitting, and I'd have to process offline in reverse.

Interviewers look for: recognising "dynamic connectivity, additions only" as union-find, and the r * n + c encoding. The next question is usually "what if cells can turn back into water?"

45. Design Problems

BasicHow do you approach a "design a data structure" question?

So I start by listing each operation with its required cost, because that list picks the structures for me. Usually no single structure meets every target, so I combine two and make sure every write keeps both in sync.

Let me show you with a quick table of what I reach for.

Requirement                                   Structure
find by key in O(1)                           HashMap
move/remove a known item in O(1), keep order  doubly linked list (node stored in the map)
random element in O(1)                        ArrayList + map value -> index
min so far, with pops                         stack of (value, min at that point)
nearest key <= x                              TreeMap.floorKey
top k across many lists                       PriorityQueue

Then I talk through it in a fixed order. First, the fields, one per requirement. Second, for each public method: look up via the map, update the ordering structure, update the map. Third, private helpers for fiddly pointer work, like unlink(node) and addFirst(node), so the public methods read like English.

Before coding, I ask the clarifying questions that change the design. What happens on a duplicate insert? Can capacity be zero? Does get count as a "use"? Is it single-threaded?

So in practice the interviewer is testing whether I can justify each field by the operation it speeds up. I say that out loud, "the map is for O(1) lookup, the list is for O(1) reordering", before I write anything.

IntermediateDesign an LRU cache with O(1) get and put. Write the full implementation.

So I combine a HashMap with a doubly linked list. The list keeps keys in recency order: the front is most recently used, the back is next to evict. The map goes from key to list node, so I can find any node in O(1) and unlink it in O(1), since it knows its own neighbours.

class LRUCache {
    private static class Node {
        int key, val;
        Node prev, next;
        Node(int key, int val) { this.key = key; this.val = val; }
    }

    private final int capacity;
    private final Map<Integer, Node> map = new HashMap<>();
    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;
        moveToFront(node);                        // reading counts as a use
        return node.val;
    }

    public void put(int key, int value) {
        Node node = map.get(key);
        if (node != null) {                       // existing key: update + touch
            node.val = value;
            moveToFront(node);
            return;
        }
        if (map.size() == capacity) {             // full: evict the LRU
            Node lru = tail.prev;
            unlink(lru);
            map.remove(lru.key);                  // why the node stores its key
        }
        Node fresh = new Node(key, value);
        addFirst(fresh);
        map.put(key, fresh);
    }

    private void moveToFront(Node node) {
        unlink(node);
        addFirst(node);
    }

    private void unlink(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    private void addFirst(Node node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }
}
// capacity 2: put(1,1) put(2,2) get(1)=1 put(3,3) [evicts 2] get(2)=-1
//             put(4,4) [evicts 1] get(1)=-1 get(3)=3 get(4)=4

The sentinels are the thing that keeps this bug-free. Because head and tail always exist, unlink and addFirst never check for null, and there's no special case for an empty list or a single node.

Why doubly linked? To remove a node in O(1), I need its predecessor. A singly linked list would need an O(n) walk to find it. And why store the key in the node? When I evict tail.prev, I have to delete it from the map too, and the map is keyed by key.

O(1) per operation, O(capacity) space.

Interviewers look for: sentinels, the key stored in the node, and updating an existing key without evicting. The classic bug is checking size == capacity before checking whether the key already exists.

IntermediateCan you build the LRU cache with LinkedHashMap? When would you use it in real code?

So yes. LinkedHashMap is literally a hash map plus a doubly linked list. If I construct it with accessOrder = true, every get and put moves the entry to the end. Overriding removeEldestEntry gives me eviction. The whole cache is about ten lines.

class LRUCacheShort extends LinkedHashMap<Integer, Integer> {
    private final int capacity;

    LRUCacheShort(int capacity) {
        super(16, 0.75f, true);                   // true = access order, not insertion order
        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;                 // checked after every insert
    }
}
// same sequence as before: 1, -1, -1, 3, 4

The detail people get wrong is the third constructor argument. Without true, the map is in insertion order, so reading a key doesn't refresh it, and I've built a FIFO cache instead of an LRU one. Also, removeEldestEntry runs after the insert, so the check is size() > capacity, not >=.

In an interview I mention this first and then ask, "do you want me to implement the linked list myself?" Usually they do, since that's the point of the question.

In real code I'd use this for a small single-threaded cache. It isn't thread-safe, and wrapping it in Collections.synchronizedMap puts one lock around every read, because reads mutate the order. For a real shared cache I'd reach for Caffeine, which has a smarter eviction policy (W-TinyLFU) and good concurrency, instead of writing my own.

Interviewers look for: the accessOrder flag and knowing that a get is a write in an access-ordered map. The follow-up is "make it thread-safe".

IntermediateDesign a stack that returns the minimum element in O(1).

So alongside each value, I store the minimum of the stack at the moment it was pushed. The current minimum is then always on top. When I pop, the previous minimum comes back automatically, because it was saved in the entry below.

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

    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]; }
}
// push -2, push 0, push -3: getMin = -3
// pop:                      top = 0, getMin = -2

A single min field doesn't work. When I pop the current minimum, I'd have no way to know the previous one without scanning, and that's O(n). Storing the minimum per level is basically an undo log.

A space-saving variant keeps a second stack that only gets a push when the new value is <= the current minimum. On pop, I pop from it too if the values match. The <= matters because of duplicates: if I push 1 twice and use <, the first pop loses the minimum.

All operations are O(1). Space is O(n) for the pairs version, and it's smaller in practice for the two-stack version.

The same idea extends to a queue with O(1) max, built from two such stacks, and to monotonic deques for sliding window maximum.

IntermediateDesign a set with insert, remove and getRandom, all in O(1) average.

So getRandom in O(1) needs index access, which means an ArrayList. remove in O(1) needs to find the value, which means a map from value to index. The problem is that removing from the middle of an ArrayList is O(n). So I swap the last element into the hole and remove from the end instead.

class RandomizedSet {
    private final List<Integer> values = new ArrayList<>();
    private final Map<Integer, Integer> indexOf = new HashMap<>();
    private final Random random = new Random();

    public boolean insert(int val) {
        if (indexOf.containsKey(val)) return false;
        indexOf.put(val, values.size());
        values.add(val);
        return true;
    }

    public boolean remove(int val) {
        Integer i = indexOf.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
        indexOf.put(last, i);
        values.remove(values.size() - 1);         // removing the LAST slot is O(1)
        indexOf.remove(val);
        return true;
    }

    public int getRandom() {
        return values.get(random.nextInt(values.size()));
    }
}

The order of the last two map operations matters. If val is itself the last element, indexOf.put(last, i) writes it back, and then indexOf.remove(val) deletes it. If I did them the other way around, I'd leave a stale entry for a removed value.

Also, I use values.remove(values.size() - 1) with an int index. With a List<Integer>, calling remove with an Integer would remove by value in O(n). That overload trap is a real Java gotcha.

Swapping breaks ordering, which is fine because a set has none. Each value is equally likely, since the list has no gaps.

Interviewers look for: the swap-with-last trick and the self-removal edge case. The follow-up is LC 381, which allows duplicates, where the map value becomes a set of indices.

AdvancedHow would you design an LFU cache with O(1) operations?

So I group keys by use count. buckets[f] holds the keys used exactly f times, in LRU order for tie-breaking, and a minFreq variable points at the lowest non-empty bucket. Using a key moves it from bucket f to f + 1. Eviction takes the oldest key in the minFreq bucket.

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
    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) {
            Iterator<Integer> it = buckets.get(minFreq).iterator();
            int victim = it.next();               // oldest key in the lowest bucket
            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 new key always has count 1
    }

    private void touch(int key) {
        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);
    }
}
// cap 2: put(1) put(2) get(1)=1 put(3) [evicts 2] get(2)=-1 get(3)=3
//        put(4) [evicts 1: count 2 vs 3's count 2, 1 is older] get(1)=-1 get(3)=3 get(4)=4

The reason minFreq is O(1) to maintain: it only changes in two ways. A new key resets it to 1. Or the key I just touched was the last one in the minimum bucket, and then the minimum is exactly f + 1, because that's where the key went. I never have to search.

LinkedHashSet gives me O(1) add, remove, and "oldest element" through its iterator. That's the per-bucket LRU for free.

So it's O(1) per operation and O(capacity) space. In an interview I'd first explain why a heap keyed on (count, time) is O(log n), and then present this as the O(1) upgrade.

Interviewers look for: why minFreq never needs a scan, and the capacity-zero edge. Getting tie-breaking wrong, by evicting any key with the minimum count instead of the oldest, is the most common miss.

Practice

Complexity Questions

BasicWhat's the complexity? (nested loops with a dependent bound)
int count = 0;
for (int i = 0; i < n; i++) {
    for (int j = 0; j < i; j++) {
        count++;
    }
}

Answer: Time O(n2), Space O(1)

So I count how many times the inner body runs. When i is 0 it runs 0 times, when i is 1 it runs once, and so on up to n - 1. That's 0 + 1 + ... + (n - 1) = n(n - 1) / 2. I actually checked it with n = 1000 and got 499,500, which matches.

n(n - 1) / 2 is about n2 / 2, and I drop the constant, so it's O(n2). The fact that the inner loop is "only half" doesn't change the class. Half of quadratic is still quadratic.

Space is O(1): just the counters.

This is the shape of every "compare each pair once" loop, like the brute force for Two Sum or checking all pairs for duplicates. So when I see j < i or j = i + 1, I think "n choose 2", which is O(n2).

Interviewers look for: the arithmetic series, not "two loops so n squared". Two loops are not always n2, as the halving and harmonic questions show.

BasicWhat's the complexity? (a loop that halves)
int steps = 0;
while (n > 0) {
    n /= 2;
    steps++;
}

Answer: Time O(log n), Space O(1)

So I ask how many times I can halve n before it hits 0. After k steps, n has become n / 2k, and the loop stops when that drops below 1, which is when 2k > n. So k is about log2 n, plus one for the final step. I checked it: for n = 1,000,000 it runs 20 times, and log2(106) is about 19.9.

The base of the log doesn't matter in big-O. log2 n and log10 n only differ by a constant factor, so I just write O(log n).

The same count shows up whenever the problem size is multiplied or divided by a constant each step: binary search, i *= 2 loops, counting the bits of a number, and climbing a balanced tree.

What would change it: if the loop body did O(n) work each time, like scanning the current range, the total would be n + n/2 + n/4 + ..., which is O(n), not O(n log n). That's a nice trap to remember.

BasicWhat's the complexity? (loop while i * i <= n)
static boolean isPrime(int n) {
    if (n < 2) return false;
    for (int i = 2; i * i <= n; i++) {
        if (n % i == 0) return false;
    }
    return true;
}

Answer: Time O(√n), Space O(1)

So the loop variable goes up by one each step, and it stops when i2 passes n, which means i passes √n. That's about √n iterations in the worst case, which is when n is prime. For n = 109, that's around 31,623 checks instead of a billion.

The reason stopping at √n is correct: if n = a × b with a <= b, then a <= √n. So any composite number has a factor at or below its square root, and if I haven't found one by then, there isn't one.

One gotcha I've hit: i * i can overflow for n near Integer.MAX_VALUE. When i reaches 46,341, i2 goes negative, which is still <= n, so the loop keeps going. For large n I'd write i <= n / i or use long.

Best case is O(1) for an even number. But I state the worst case unless asked otherwise.

Interviewers look for: the "a factor pair has one side at most √n" reason. The follow-up is "primes up to n", where the Sieve of Eratosthenes is O(n log log n).

IntermediateWhat's the complexity? (recursive Fibonacci without memoisation)
static int fib(int n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
}

Answer: Time O(2n), tighter O(φn) ≈ O(1.618n), Space O(n)

So I draw the call tree. Each call makes two calls, and the tree is at most n levels deep, so there are at most 2n nodes. That's where O(2n) comes from, and it's a valid upper bound.

It's not tight, though, because the right branch, n - 2, is shallower than the left. The number of calls satisfies the same recurrence as Fibonacci itself, so it grows like φn, where φ is the golden ratio, about 1.618. Either answer is usually accepted. I mention φ to show I know 2n is loose.

For space, the tree is huge, but it's never all in memory at once. The call stack only holds one root-to-leaf path at a time, and the longest path is fib(n) → fib(n-1) → ... → fib(1). So that's O(n) stack.

The real problem is repeated work. fib(n - 2) is computed once directly and again inside fib(n - 1), and it gets worse deeper down. fib(40) makes over 300 million calls.

Interviewers look for: time from the size of the tree, space from its depth. Mixing those up, by saying space is O(2n), is the common mistake.

IntermediateWhat's the complexity? (the same Fibonacci with memoisation)
static long fibMemo(int n, long[] memo) {
    if (n <= 1) return n;
    if (memo[n] != 0) return memo[n];
    return memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
}
// fibMemo(50, new long[51]) = 12586269025

Answer: Time O(n), Space O(n)

So the way I count memoised recursion is: number of distinct states times work per state. The only state is n, and there are n + 1 of them, from 0 to n. Each one does O(1) work besides its recursive calls, and it's computed only once. After that, any call for that state returns straight from the memo. So the total is O(n).

Another way to see it: fib(n) goes all the way down the left spine first, filling in memo[2], memo[3], and so on as it returns. By the time any right-branch call happens, its answer is already cached, so each right branch is O(1).

Space is O(n) for the memo array plus O(n) for the recursion depth, which is still O(n).

If they push further, I'd go bottom-up with two variables, prev and curr. That's O(n) time and O(1) space. And matrix exponentiation gets it to O(log n).

One small thing I changed: the return type is long. fib(47) already overflows an int, so that's worth mentioning.

IntermediateWhat's the complexity? (building a String with + in a loop)
String s = "";
for (int i = 0; i < n; i++) {
    s += "a";
}

Answer: Time O(n2), Space O(n)

So Java strings are immutable. s += "a" doesn't append in place. It creates a new string and copies all the current characters plus one. On iteration i, that copy is about i characters, so the total is 1 + 2 + ... + n = n(n + 1) / 2 character copies. That's O(n2).

The loop looks O(n), which is the trap. The hidden cost is inside the +=.

Space: at any moment only the current string, about n chars, is live, so it's O(n). The total allocated over the run is O(n2), which is garbage the GC has to clean up. So it's also slow in practice.

The fix is StringBuilder. It keeps a growable char array and doubles its capacity when full, so appends are amortised O(1) and the whole loop is O(n).

StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) sb.append('a');
String s = sb.toString();

The compiler optimises a + b + c in a single expression, but it can't merge across loop iterations. So in any loop, I use a builder.

Interviewers look for: immutability as the reason. The same trap exists with list = concat(list, x) patterns and with repeated substring calls in recursion.

IntermediateWhat's the complexity? (ArrayList.remove(0) in a loop)
List<Integer> list = new ArrayList<>();
for (int i = 0; i < n; i++) list.add(i);
while (!list.isEmpty()) {
    list.remove(0);
}

Answer: Time O(n2), Space O(n)

So ArrayList is backed by an array, and removing index 0 shifts every remaining element one slot left with System.arraycopy. With k elements left, that's k - 1 moves. Summed over the loop, it's (n - 1) + (n - 2) + ... + 0, which is O(n2).

The filling loop is O(n) amortised, so the removal dominates.

Space is O(n) for the list itself. The removal allocates nothing new.

This comes up in real code when someone uses an ArrayList as a queue in a BFS. The fix is ArrayDeque: poll() from the front is O(1) because it's a circular buffer, so it just moves a head index. LinkedList also gives O(1) at the ends, but ArrayDeque is faster in practice because of memory locality.

The opposite end is fine. list.remove(list.size() - 1) is O(1), since nothing shifts. That's why a stack built on ArrayList works well, and why the RandomizedSet trick swaps with the last element.

IntermediateWhat's the complexity? (merge sort)
static void mergeSort(int[] a, int lo, int hi, int[] tmp) {   // sorts a[lo, hi)
    if (hi - lo < 2) return;
    int mid = (lo + hi) >>> 1;
    mergeSort(a, lo, mid, tmp);
    mergeSort(a, mid, hi, tmp);
    int i = lo, j = mid, k = lo;
    while (i < mid && j < hi) 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);
}

Answer: Time O(n log n), Space O(n)

So I count it level by level. The recursion splits the array in half each time, so there are about log2 n levels. At every level, the pieces together cover all n elements, and merging is linear in the piece size. So each level costs O(n), and O(n) per level times log n levels gives O(n log n).

Formally it's T(n) = 2T(n/2) + O(n), which is the standard Master theorem case giving n log n.

That's the same in the best, average and worst case. Merge sort doesn't care about the input order, unlike quicksort.

Space is O(n) for the temp array, which I allocate once and pass down instead of creating one per call. The recursion stack adds O(log n), which is smaller.

That's also the reason Java's Arrays.sort on objects uses TimSort, a merge sort variant: it's stable and has a guaranteed n log n. For primitives it uses dual-pivot quicksort, which is in place and fast on average.

Interviewers look for: the level-by-level picture, not just the recurrence. The follow-up is "can you do it with O(1) extra space?", and the practical answer is heap sort, which isn't stable.

IntermediateWhat's the complexity? (BFS shortest path on a grid)
static int bfs(int[][] grid) {                    // 0 = open, 1 = wall
    int rows = grid.length, cols = grid[0].length;
    int[][] dist = new int[rows][cols];
    for (int[] row : dist) Arrays.fill(row, -1);
    Deque<int[]> queue = new ArrayDeque<>();
    queue.offer(new int[]{0, 0});
    dist[0][0] = 0;
    int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
    while (!queue.isEmpty()) {
        int[] cur = queue.poll();
        for (int[] d : dirs) {
            int r = cur[0] + d[0], c = cur[1] + d[1];
            if (r < 0 || r >= rows || c < 0 || c >= cols) continue;
            if (grid[r][c] == 1 || dist[r][c] != -1) continue;
            dist[r][c] = dist[cur[0]][cur[1]] + 1;
            queue.offer(new int[]{r, c});
        }
    }
    return dist[rows - 1][cols - 1];
}

Answer: Time O(R × C), Space O(R × C)

So the key is that each cell gets its dist set once, and only then is it enqueued. So each cell is enqueued and polled at most once. When it's polled, it looks at 4 neighbours, which is constant work. That's O(4 × R × C), which is O(R × C).

In graph terms, V = R × C and E <= 4V, so the usual O(V + E) for BFS becomes O(R × C).

Space is O(R × C) for the dist array. The queue holds the current frontier, which is usually much smaller than the grid, but I still bound it by R × C.

What would break the bound is checking visited when I poll instead of when I enqueue. Then a cell can be enqueued by several neighbours before it's processed. It's still correct, but the queue does repeated work.

So on any grid question, I say O(R × C) and explain "each cell once, four neighbours each."

AdvancedWhat's the complexity? (HashMap operations in a loop, including the worst case)
Map<Integer, Integer> freq = new HashMap<>();
for (int x : nums) {
    freq.merge(x, 1, Integer::sum);
}

Answer: Time O(n) average, O(n log n) worst case on Java 8+, Space O(n)

So each merge is a hash lookup plus an insert or update. On average that's O(1), assuming the hash spreads keys evenly and the table resizes to keep the load factor under 0.75. Resizing is amortised O(1) because the table doubles. So n operations are O(n) on average.

The worst case is when many keys land in the same bucket. Before Java 8, a bucket was a linked list, so each operation could be O(n), and the loop would be O(n2). Since Java 8, a bucket with more than 8 entries (in a table of at least 64) turns into a red-black tree. For Comparable keys like Integer, that makes each operation O(log n), so the loop is O(n log n).

The catch is that the tree needs to order the keys. I tested it: with a key class where every hash collides but that's not Comparable, 20,000 puts took about 2 seconds and 40,000 took about 8. That's quadratic. The same keys made Comparable took milliseconds.

Space is O(n) for the entries plus a table of about n / 0.75 buckets.

Interviewers look for: "O(1) average", with the word average, and knowing about treeification. The deeper follow-up is hash-flooding attacks on web servers, which is why the Java 8 change happened.

AdvancedWhat's the complexity? (the harmonic loop: j += i)
for (int i = 1; i <= n; i++) {
    for (int j = i; j <= n; j += i) {
        work(i, j);                               // O(1)
    }
}

Answer: Time O(n log n), Space O(1)

So it looks like two nested loops up to n, but the inner loop steps by i, not by 1. For a given i, it runs about n / i times. Summing over all i, that's n/1 + n/2 + n/3 + ... + n/n = n × (1 + 1/2 + ... + 1/n).

That bracket is the harmonic series Hn, which grows like ln n. So the total is about n ln n, which is O(n log n). I checked it for n = 1,000,000: the inner body ran 13,970,034 times, and n ln n is 13,815,510. That's very close.

Space is O(1) beyond whatever work uses.

Where this shows up: the Sieve of Eratosthenes, where marking multiples of each i is this exact pattern. The sieve is O(n log log n) because it only loops over primes, which is even smaller. It also shows up when precomputing divisors for all numbers up to n, and in "for each number, visit its multiples" DP.

So the lesson I take from it: don't count loops, count how many times the innermost line runs. Two nested loops can be O(n2), O(n log n) or even O(n), depending on how the inner bound moves.

Interviewers look for: naming the harmonic series. Candidates who say O(n2) by pattern-matching on "two loops" are exactly who this question is built to catch.

AdvancedWhat's the complexity? (backtracking to generate all subsets)
static void subsets(int[] nums, int start, List<Integer> path, List<List<Integer>> out) {
    out.add(new ArrayList<>(path));
    for (int i = start; i < nums.length; i++) {
        path.add(nums[i]);
        subsets(nums, i + 1, path, out);
        path.remove(path.size() - 1);
    }
}

Answer: Time O(n · 2n), Space O(n) auxiliary, O(n · 2n) including the output

So I count the calls first. Every call adds exactly one subset to out, and there are 2n subsets, so there are 2n calls. For n = 4 I get 16.

Then I count the work per call. The new ArrayList<>(path) copy costs up to n. The loop's add and remove are O(1) per child, and each child is its own call, so they're already counted. That gives 2n calls times O(n) copy, which is O(n · 2n). A tighter count is that the average subset has n/2 elements, but that's still the same class.

Space depends on what I count. The recursion depth is at most n, and path holds at most n elements, so the auxiliary space is O(n). The output itself holds 2n lists of average length n/2, which is O(n · 2n). I say both and make clear which is which.

For permutations the same reasoning gives O(n · n!): n! leaves, each copied at length n, plus the internal nodes, which are fewer than the leaves times a constant.

So for any backtracking question my recipe is: number of nodes in the tree, times work per node, then state output space separately.

Coding Rounds

IntermediateLC 3 · Longest Substring Without Repeating Characters

So before I start, a few clarifying questions. What characters can appear, only lowercase or any ASCII? That decides between an int[128] and a map. Is it a substring, meaning contiguous, and not a subsequence? And for an empty string, should I return 0? I'll assume any characters, contiguous, and 0 for empty.

Examples: "abcabcbb" gives 3 ("abc"), "bbbbb" gives 1, "pwwkew" gives 3 ("wke", not "pwke", because that's not contiguous). Edge cases I'd keep in mind are "" giving 0, " " giving 1, and "abba", which I'll come back to.

The brute force is to check every substring for uniqueness with a set. That's O(n2) substrings, and O(n) to check each, so O(n3). If I extend from each start and stop at the first repeat, it's O(n2).

The insight is that when I extend and hit a repeat, I don't need to restart. The valid window just needs its left edge moved past the earlier copy of that character. So it's a sliding window where both pointers only move forward. If I store each character's last index, I can jump left in one step instead of shrinking one at a time.

static int lengthOfLongestSubstring(String s) {
    Map<Character, Integer> lastSeen = new HashMap<>();
    int best = 0, left = 0;
    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        Integer prev = lastSeen.get(c);
        if (prev != null && prev >= left) {
            left = prev + 1;                  // jump past the earlier copy
        }
        lastSeen.put(c, right);
        best = Math.max(best, right - left + 1);
    }
    return best;
}
// "abcabcbb" -> 3   "bbbbb" -> 1   "pwwkew" -> 3   "" -> 0   "abba" -> 2   " " -> 1

Dry run on "abcabcbb": right 0 to 2 builds "abc" with left = 0, so best = 3. At right = 3 ('a'), the previous 'a' is at 0, so left becomes 1 and the window is "bca". At right = 4 ('b'), left becomes 2. At right = 5 ('c'), left becomes 3. At right = 6 ('b'), the previous 'b' is at 4, so left = 5. At right = 7, left = 7. The window never beats 3.

The prev >= left check is why I mentioned "abba". At the last 'a', its previous index is 0, but left is already 2 because of the second 'b'. Without the check, left would jump back to 1 and I'd return 3. With it, I correctly get 2.

Final complexity: O(n) time, since each character is visited once by right and left only jumps forward. Space is O(min(n, alphabet)). If they confirm ASCII, I'd swap the map for an int[128] filled with -1, which is faster.

IntermediateLC 56 · Merge Intervals

So first the clarifying questions. Is the input sorted? I'll assume not. Do touching intervals like [1,4] and [4,5] count as overlapping? LeetCode says yes, so they merge into [1,5]. Can I modify the input array, for example sort it in place? And is it guaranteed that start <= end?

Examples: [[1,3],[2,6],[8,10],[15,18]] gives [[1,6],[8,10],[15,18]]. [[1,4],[4,5]] gives [[1,5]]. Edge cases I'd test are unsorted input like [[4,7],[1,4]], giving [[1,7]], and containment like [[1,10],[2,3]], which must give [[1,10]] and not shrink the end to 3.

The brute force is to repeatedly scan all pairs, merge any two that overlap, and restart until nothing changes. That's O(n2) per pass and up to n passes, so O(n3), and it's fiddly.

The insight is to sort by start. After sorting, any interval that overlaps the current merged block must come right after it. There can't be one hiding further down that I'd miss. So one pass is enough: either the next interval starts after the current block ends, which is a gap, or it overlaps, and I extend the end.

static int[][] merge(int[][] intervals) {
    Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));
    List<int[]> merged = new ArrayList<>();
    for (int[] cur : intervals) {
        if (merged.isEmpty() || merged.get(merged.size() - 1)[1] < cur[0]) {
            merged.add(new int[]{cur[0], cur[1]});        // gap: start a new block
        } else {
            int[] last = merged.get(merged.size() - 1);
            last[1] = Math.max(last[1], cur[1]);          // overlap: stretch the end
        }
    }
    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]]   [[4,7],[1,4]] -> [[1,7]]   [[1,10],[2,3]] -> [[1,10]]

Dry run on the first example: [1,3] starts a block. [2,6]: 3 is not < 2, so it overlaps, and the end becomes max(3, 6) = 6. [8,10]: 6 < 8, so it's a gap and starts a new block. [15,18]: 10 < 15, another gap. Result [[1,6],[8,10],[15,18]].

Two details I'm deliberate about. The Math.max handles containment. And I use Integer.compare rather than a[0] - b[0], because subtraction overflows for large negative and positive starts.

Final complexity: O(n log n) time for the sort, and the merge pass is O(n). Space is O(n) for the output, plus O(log n) for the sort. If they asked for no sorting, like a stream of intervals, I'd switch to a TreeMap keyed by start.

IntermediateLC 200 · Number of Islands

So my clarifying questions. Is connectivity 4-directional only, or are diagonals included? I'll assume 4. Is the grid char '1' and '0'? Can I modify the grid, or should I leave it unchanged? And how large can it be? That tells me whether recursion depth is a risk.

Examples: a grid with one big blob of land gives 1. The grid 11000 / 11000 / 00100 / 00011 gives 3. Edge cases are all water, giving 0, all land, giving 1, and a single cell.

A brute-force-ish approach would be, for every land cell, to check whether it connects to any previously counted cell by searching. That's O((R × C)2) in the worst case.

The insight is that the grid is a graph, and an island is a connected component. I scan every cell. When I find unvisited land, that's a new island: I count it and flood-fill the whole island so none of its cells is counted again. Every cell is visited a constant number of times.

static 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++;
                sink(grid, r, c);
            }
        }
    }
    return count;
}

static 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';                             // mark visited by sinking it
    sink(grid, r + 1, c);
    sink(grid, r - 1, c);
    sink(grid, r, c + 1);
    sink(grid, r, c - 1);
}
// ["11110","11010","11000","00000"] -> 1
// ["11000","11000","00100","00011"] -> 3

Dry run on the second grid: (0,0) is land, count = 1, and sink clears (0,0), (0,1), (1,0), (1,1). The scan passes over the zeros. At (2,2), count = 2, and it sinks alone. At (3,3), count = 3, and sink clears (3,3) and (3,4). At (3,4) it's already '0', so it's skipped. The answer is 3.

I mark the cell before recursing. If I marked after, two neighbours could recurse into each other forever.

Final complexity: O(R × C) time. Space is O(R × C) worst case for the recursion stack, when the grid is all land in a snake shape. If they said the grid can be 1000 × 1000, I'd switch to BFS with an ArrayDeque, because the recursive version overflows the stack at that size. If mutating the input isn't allowed, I'd use a boolean[][] visited instead.

IntermediateLC 238 · Product of Array Except Self

So my clarifying questions. Can I use division? The problem says no, but I'd ask anyway, because it shapes the approach. Can there be zeros, and negative numbers? Is the product guaranteed to fit in an int? And does the output array count toward extra space?

Examples: [1,2,3,4] gives [24,12,8,6]. [-1,1,0,-3,3] gives [0,0,9,0,0]: only the position of the zero gets a non-zero product. Edge cases are two zeros, where everything is 0, and a length-2 array, which just swaps the elements.

The brute force is, for each i, to multiply all other elements. That's O(n2). The division trick, total product divided by nums[i], is O(n), but it's banned and breaks on zeros anyway.

The insight is that "everything except i" is "everything left of i" times "everything right of i". I can compute all prefix products in one pass and all suffix products in another. To get O(1) extra space, I store the prefix products directly in the answer array, then sweep from the right with a single running variable for the suffix.

static 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 everything to the left
    }
    int right = 1;
    for (int i = n - 1; i >= 0; i--) {
        answer[i] *= right;                            // times everything to the right
        right *= nums[i];
    }
    return answer;
}
// [1,2,3,4] -> [24,12,8,6]     [-1,1,0,-3,3] -> [0,0,9,0,0]

Dry run on [1,2,3,4]. The left pass gives answer = [1, 1, 2, 6]: at index 3, that's 1 × 2 × 3. The right pass: i = 3, answer = 6 × 1 = 6, right = 4. i = 2, answer = 2 × 4 = 8, right = 12. i = 1, answer = 1 × 12 = 12, right = 24. i = 0, answer = 1 × 24 = 24. Final [24, 12, 8, 6].

The order inside the second loop matters. I multiply answer[i] by right before including nums[i] in right, since index i must be excluded.

Zeros need no special handling. A zero just flows into the prefix or suffix of everyone except itself, which is exactly right.

Final complexity: O(n) time with two passes, and O(1) extra space, since the output array doesn't count by the problem's convention. With separate prefix and suffix arrays it would be O(n) space. I'd mention that as the stepping stone.

IntermediateLC 347 · Top K Frequent Elements

So my clarifying questions. Is the answer guaranteed unique, meaning no tie at the k-th place? LeetCode says yes. Does the output order matter? It doesn't. Is k always valid, between 1 and the number of distinct values? And roughly how big is n compared with the number of distinct values?

Examples: [1,1,1,2,2,3], k = 2 gives [1,2]. [1], k = 1 gives [1]. Edge cases are k equal to the number of distinct values, where I return all of them, and all elements the same.

The brute force is to count with a HashMap, sort the distinct values by count, and take the first k. That's O(n + d log d), where d is the number of distinct values, so O(n log n) worst case. That's actually a fine first answer, but the problem asks for better than n log n.

There are two improvements. A min-heap of size k keeps only the top k seen so far, which is O(n log k). Even better, I can use bucket sort by frequency. A frequency can only be between 1 and n, so I make an array of n + 1 buckets indexed by count and walk it from the top. That's O(n).

static int[] topKFrequent(int[] nums, int k) {
    Map<Integer, Integer> count = new HashMap<>();
    for (int x : nums) count.merge(x, 1, Integer::sum);

    @SuppressWarnings("unchecked")
    List<Integer>[] buckets = new List[nums.length + 1];   // index = frequency
    for (var e : count.entrySet()) {
        int f = e.getValue();
        if (buckets[f] == null) buckets[f] = new ArrayList<>();
        buckets[f].add(e.getKey());
    }

    int[] result = new int[k];
    int idx = 0;
    for (int f = nums.length; f >= 1 && idx < k; f--) {
        if (buckets[f] == null) continue;
        for (int x : buckets[f]) {
            if (idx == k) break;
            result[idx++] = x;
        }
    }
    return result;
}
// [1,1,1,2,2,3], 2 -> [1, 2]     [1], 1 -> [1]

Dry run on the first example: counts are {1:3, 2:2, 3:1}. Buckets: [3] = [1], [2] = [2], [1] = [3]. I walk f from 6 down. Buckets 6, 5 and 4 are empty. At f = 3 I take 1, and at f = 2 I take 2. idx reaches k, so I stop. Result [1, 2].

Final complexity: O(n) time, since counting is O(n) and the bucket walk touches at most n + 1 buckets plus d values. Space is O(n) for the map and buckets. In practice I'd also mention the heap version:

PriorityQueue<Integer> heap = new PriorityQueue<>((a, b) -> count.get(a) - count.get(b));
for (int x : count.keySet()) {
    heap.offer(x);
    if (heap.size() > k) heap.poll();             // drop the least frequent
}

That's O(n log k) and uses only O(k) extra beyond the counts. It's the one I'd pick for a stream where n isn't known up front.

IntermediateLC 207 · Course Schedule

So my clarifying questions. What does a pair [a, b] mean exactly? Here it's "to take a you must first take b", so the edge is b → a. Can there be duplicate pairs, or a course that requires itself? Are courses numbered 0 to n - 1? And I only need a yes or no, not the order, right?

Examples: n = 2, [[1,0]] gives true, since I take 0 and then 1. n = 2, [[1,0],[0,1]] gives false, because they wait on each other. Edge cases are no prerequisites at all, which is true, a self-loop [0,0], which is false, and disconnected groups of courses.

The brute force is to try orderings, or, for every course, DFS to see if it can reach itself. The per-node cycle check is O(V × (V + E)).

The insight is that "can I finish everything?" is the same question as "does the dependency graph have no cycle?", which is "does a topological order exist?" Kahn's algorithm answers it in one pass. I repeatedly take a course with no remaining prerequisites. If I can take all n that way, there's no cycle. Courses on a cycle never reach indegree 0.

static 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) {               // {a, b}: take b before a
        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 course = queue.poll();
        taken++;
        for (int next : graph.get(course)) {
            if (--indegree[next] == 0) queue.offer(next);
        }
    }
    return taken == numCourses;
}
// 2, [[1,0]] -> true     2, [[1,0],[0,1]] -> false

Dry run on the first: the graph is 0 → 1, with indegree [0, 1]. The queue starts as [0]. I poll 0, so taken = 1, and 1's indegree drops to 0, so I push it. I poll 1, so taken = 2. 2 == 2, so true.

On the cycle case, the indegrees are [1, 1] and nothing starts in the queue. taken stays 0, so false.

A self-loop gives the course an indegree of 1 from itself, so it never enters the queue. That's handled without special code.

Final complexity: O(V + E) time, since each course is enqueued once and each edge is relaxed once. Space is O(V + E) for the adjacency list. The DFS version with three colours has the same complexity. I pick Kahn's because there's no recursion depth issue, and the follow-up, LC 210 "return the order", is a two-line change: record each polled course.

AdvancedLC 322 · Coin Change

So my clarifying questions. Is the supply of each coin unlimited? Yes, here. What if the amount can't be made? Return -1. Amount 0? That needs 0 coins. How large can the amount be? That decides whether an O(amount) array is OK. It's up to 104 here, so it's fine.

Examples: coins = [1,2,5], amount = 11 gives 3 (5 + 5 + 1). [2], 3 gives -1. [1], 0 gives 0. The edge case I'd bring up myself is [1,3,4], 6. Greedy takes 4 + 1 + 1, which is 3 coins, but the right answer is 3 + 3, which is 2 coins.

That example is why the first idea, greedy by largest coin, is wrong for arbitrary denominations. The real brute force is recursion: minCoins(a) = 1 + min over coins of minCoins(a - coin). Without memo, that's exponential, roughly O(kamount / min coin).

The insight is overlapping subproblems. minCoins(a) only depends on a, and a only takes amount + 1 values. So I fill a table bottom-up, where dp[a] is the fewest coins to make a. Each entry tries every coin as the last coin used.

static int coinChange(int[] coins, int amount) {
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, amount + 1);                  // "infinity": more coins than possible
    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];
}
// [1,2,5], 11 -> 3     [2], 3 -> -1     [1], 0 -> 0     [1,3,4], 6 -> 2

Dry run for [1,2,5], 11. The table comes out as dp = [0, 1, 1, 2, 2, 1, 2, 2, 3, 3, 2, 3]. For example, dp[5] = 1, just the 5 coin. dp[10] = dp[5] + 1 = 2. dp[11] = min(dp[10] + 1, dp[9] + 1, dp[6] + 1) = min(3, 4, 3) = 3.

I use amount + 1 as infinity instead of Integer.MAX_VALUE on purpose. MAX_VALUE + 1 overflows to a negative number and would win every min. amount + 1 is safe, because no answer can use more than amount coins, since the smallest coin is 1.

Final complexity: O(amount × k) time for k coins, and O(amount) space. If they asked for the number of ways instead (LC 518), I'd swap the loops, with coins outer, so each combination is counted once and not once per ordering.

AdvancedLC 42 · Trapping Rain Water

So my clarifying questions. Are the heights non-negative integers? Is the width of each bar 1? What's the answer for fewer than 3 bars? It's 0, since there's nothing to hold water. And can the array be empty?

Examples: [0,1,0,2,1,0,1,3,2,1,2,1] gives 6. [4,2,0,3,2,5] gives 9. Edge cases are a strictly increasing or decreasing array, which traps nothing, and an empty array, which gives 0.

The key observation for any approach is that water above bar i is min(tallest to the left, tallest to the right) - height[i]. The lower wall decides the level. The brute force computes both maxima by scanning for every i, which is O(n2).

The first improvement is to precompute leftMax[] and rightMax[] in two passes. That's O(n) time and O(n) space. Then the insight for O(1) space is two pointers. If height[left] < height[right], then there's a wall at least as tall as height[left] on the right. So the water at left is decided only by leftMax, and I can settle it now and move left. The same logic applies from the other side.

static 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]) {
            leftMax = Math.max(leftMax, height[left]);
            water += leftMax - height[left];      // right side is taller: leftMax decides
            left++;
        } else {
            rightMax = Math.max(rightMax, height[right]);
            water += rightMax - height[right];
            right--;
        }
    }
    return water;
}
// [0,1,0,2,1,0,1,3,2,1,2,1] -> 6     [4,2,0,3,2,5] -> 9     [] -> 0

Dry run on [4,2,0,3,2,5]. left = 0 (4), right = 5 (5). 4 < 5, so leftMax = 4, water += 0, and left moves on. Height 2 < 5: water += 4 - 2 = 2. Height 0: water += 4, total 6. Height 3: water += 1, total 7. Height 2: water += 2, total 9. Then left = right = 5, and I stop. The answer is 9.

I update the max before adding water. That way leftMax - height[left] is never negative: when the current bar is the new max, it just adds 0.

The empty array works because right = -1 and the loop never runs.

Final complexity: O(n) time with one pass, and O(1) space. I'd present the prefix-max version first, since it's easier to prove correct, and then the two-pointer version as the space optimisation. A monotonic stack also solves it in O(n), filling water layer by layer horizontally. I'd mention that as the alternative if they ask.