DSA in Java — Concept Notes
Data structures, algorithms and the problem-solving patterns behind interview questions, in easy words and full depth. Every solution is in Java and has been compiled and run. Print this page for a PDF.
1. How to Approach a DSA Problem
A coding interview is not a test of whether you have memorised the answer. It is a test of how you think: can you understand a fuzzy problem, find a correct solution, make it fast, and write clean code while explaining yourself?
The trick is to follow the same steps every time. A fixed routine stops you from panicking, stops you from coding the wrong thing, and shows the interviewer a calm, organised engineer.
Like a pilot's pre-flight checklist. Experienced pilots still walk through it every single time — not because they forget how to fly, but because the checklist catches the mistakes that confidence hides.
The eight-step framework
Budget roughly: 5 minutes for steps 1–2, 5–10 minutes for 3–5, 15–20 minutes to code, 5 minutes to dry-run and test. If you jump straight to step 6, you will often solve the wrong problem.
Step 1 — Clarify
Problems are left vague on purpose. The interviewer wants to see you ask. Good questions:
- Input: What type and size? Can it be empty? Can values be negative, zero, duplicate? Is the array sorted?
- Output: Return indices or values? Any valid answer or all of them? What if there is no answer — return -1, empty list, or throw?
- Constraints: How big is
n? (This tells you the target complexity — see section 2.) Does it fit in memory? Can I modify the input?
Step 2 — Examples and edge cases
Write a normal example and then attack the corners. An edge case is an input at the boundary of what is allowed, where code usually breaks:
| Input type | Edge cases to try |
|---|---|
| Array / list | empty, one element, two elements, all equal, already sorted, reverse sorted |
| Numbers | 0, negative, Integer.MAX_VALUE (overflow), Integer.MIN_VALUE |
| String | empty "", one char, all same char, spaces, upper vs lower case |
| Tree / linked list | null root/head, single node, fully skewed (a "tree" that is really a line) |
| Graph | disconnected parts, cycles, self-loops, a single node |
Step 3 — Brute force first
Say the most obvious correct solution out loud, with its complexity: "The simplest thing is to try every pair, which is O(n²)." This proves you understand the problem, gives you a fallback answer, and gives you something to improve. You usually do not code it — ask "should I code this or look for something faster?"
Steps 4–5 — Find the bottleneck, then optimise
Look at the brute force and ask: what work am I repeating? Most optimisations are one of these moves:
| If you see… | Try… | Typical gain |
|---|---|---|
| "Have I seen X before?" inside a loop | HashMap / HashSet | O(n²) → O(n) |
| Sorted array, or can sort | Two pointers or binary search | O(n²) → O(n log n) or O(n) |
| Contiguous subarray / substring | Sliding window or prefix sums | O(n²) → O(n) |
| "Top k", "smallest", "next best" | Heap (PriorityQueue) | O(n log n) → O(n log k) |
| Same subproblem solved again and again | Memoisation / dynamic programming | O(2ⁿ) → O(n²) or better |
| "All combinations / permutations" | Backtracking (it is exponential; prune early) | Cuts branches, not the class |
A very common trade: spend memory to save time. A HashMap costs O(n) extra space but turns an O(n) search into an O(1) lookup.
Step 6 — Code
- Use clear names:
left,right,seen,maxLen— nota,b,x. - Pull repeated logic into a helper (
isValid(r, c),swap(arr, i, j)). - Handle the edge cases you listed at the top of the method.
- If you are unsure of a library method, say so and pick a sensible name: "I'll assume a helper that returns the neighbours."
Steps 7–8 — Dry-run and test
Do not say "I think it works." Pick a small example and trace the variables line by line, out loud. Then run through your edge cases. Finding your own bug is a positive signal — it shows you test your work. Finish by restating the time and space complexity.
How to talk while coding
- Narrate intent, not syntax. Say "now I shrink the window from the left until it's valid again," not "i plus plus."
- Announce trade-offs. "I'll use extra O(n) space for the map to get linear time."
- Check in at decision points. "Does that approach sound good before I code it?"
- It is fine to go quiet for 20–30 seconds to think — just say "let me think about this for a moment" first.
What to do when you are stuck
- Go back to a tiny example and solve it by hand. Watch what your brain does — that is often the algorithm.
- Ask "what if the input were sorted?" or "what if I had a HashMap of everything?"
- Walk the pattern table above: two pointers? sliding window? BFS? heap? DP?
- Solve a simpler version first (1D before 2D, no duplicates before duplicates, k = 1 before general k).
- Say what you are thinking. Interviewers give hints to candidates who are visibly reasoning, not to silent ones.
- If time runs low, code the brute force cleanly. A working O(n²) beats a broken O(n).
Worked example — Two Sum, end to end
Problem: given an array nums and a target, return the indices of the two numbers that add up to target.
1. Clarify. "Is there always exactly one answer? Can I use the same element twice? Can values be negative? Is it sorted?" Assume: exactly one answer, not the same index twice, negatives allowed, not sorted.
2. Examples. [2,7,11,15], 9 → [0,1]. [3,2,4], 6 → [1,2] (note: not [0,0] — cannot reuse index 0). [3,3], 6 → [0,1] (duplicates).
3. Brute force. Try every pair (i, j) with j > i. That is about n²/2 pairs, so O(n²) time and O(1) space.
// Brute force: try every pair. O(n^2) time, O(1) space.
static int[] twoSumBrute(int[] nums, int target) {
for (int i = 0; i < nums.length; i++) {
for (int j = i + 1; j < nums.length; j++) {
if (nums[i] + nums[j] == target) return new int[]{i, j};
}
}
return new int[]{-1, -1}; // no pair found
}
4. Bottleneck. For each nums[i], the inner loop is really asking one question: "does the number target - nums[i] exist somewhere I've already been?" That is a lookup, and we are doing it with a slow linear scan.
5. Optimise. Remember every number we have passed in a HashMap from value to index. Then the lookup is O(1) on average, and one pass is enough.
import java.util.*;
class Solution {
// One pass with a HashMap. O(n) time, O(n) space.
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> seen = new HashMap<>(); // value -> index
for (int i = 0; i < nums.length; i++) {
int need = target - nums[i]; // the partner we want
if (seen.containsKey(need)) {
return new int[]{seen.get(need), i};
}
seen.put(nums[i], i); // store AFTER checking
}
return new int[]{-1, -1};
}
}
Why "store after checking"? If we stored first, then for [3,2,4], target 6 the element 3 would find itself (6 - 3 = 3) and return [0,0]. Checking first means we only ever pair with an earlier index.
7. Dry-run on nums = [2,7,11,15], target = 9:
| i | nums[i] | need = 9 - nums[i] | seen before step | Action |
|---|---|---|---|---|
| 0 | 2 | 7 | {} | 7 not in map; put 2 → 0 |
| 1 | 7 | 2 | {2=0} | 2 is in map → return [0, 1] |
8. Test. Running the code gives: [2,7,11,15], 9 → [0, 1]; [3,2,4], 6 → [1, 2]; [3,3], 6 → [0, 1] (the second 3 finds the first); [1,2], 7 → [-1, -1] (no answer).
Complexity: O(n) time — one pass, each map operation O(1) on average. O(n) space — the map may hold every element.
left = 0, right = n - 1; if the sum is too small move left right, if too big move right left. That is O(n) time and O(1) space — better on memory than the map. Recognising when a different constraint unlocks a different pattern is exactly what they want to see.2. Big-O & Complexity
Big-O describes how the running time (or memory) of an algorithm grows as the input gets bigger. It ignores the exact seconds, the machine, and small details, and keeps only the shape of the growth: does doubling the input double the work, quadruple it, or barely change it?
Time complexity counts steps. Space complexity counts extra memory. Both are written as a function of n, the input size.
Handing out invitations. Walking to each of n guests is O(n). Having every guest shake hands with every other guest is O(n²). Shouting once in the hall is O(1) — the same effort whether 10 or 10,000 people are there.
What Big-O really means
Formally, f(n) = O(g(n)) means that for large enough n, f(n) is at most some constant times g(n). In practice this gives two rules:
- Drop constants. 3n + 5 steps is O(n). Two separate passes over the array is still O(n).
- Keep only the biggest term. n² + 100n + 7 is O(n²), because when n is a million, the n² part dwarfs everything else.
Related words you may hear: Big-Omega (Ω) is a lower bound ("at least this much"), Big-Theta (Θ) is a tight bound (both). In interviews, people say "Big-O" and mean the tight worst-case bound.
How to count — the patterns
// O(n): one loop over the input
static int sum(int[] a) {
int total = 0;
for (int x : a) total += x; // runs n times
return total;
}
// O(n^2): a loop inside a loop, both over n
static int countPairs(int[] a) {
int count = 0;
for (int i = 0; i < a.length; i++)
for (int j = i + 1; j < a.length; j++) // n-1, n-2, ... 1 -> n(n-1)/2
count++;
return count;
}
// O(log n): the problem halves every step
static int halvings(int n) {
int steps = 0;
while (n > 1) {
n /= 2; // n, n/2, n/4, ... 1
steps++;
}
return steps;
}
// O(n log n): n outer iterations, each doing log n work
static int nLogN(int n) {
int ops = 0;
for (int i = 0; i < n; i++)
for (int j = 1; j < n; j *= 2) // doubles -> log n times
ops++;
return ops;
}
// Two SEPARATE loops over different inputs: O(n + m), not O(n * m)
static int twoLoops(int[] a, int[] b) {
int ops = 0;
for (int x : a) ops++;
for (int y : b) ops++;
return ops;
}
Running these on an 8-element array: countPairs does 28 steps (= 8·7/2), nLogN(8) does 24 (= 8·3), and halvings(1_000_000) takes only 19 steps. That last number is why O(log n) feels almost free.
- Sequential blocks add: loop A then loop B is O(A + B).
- Nested blocks multiply: loop A containing loop B is O(A × B).
- Halving or doubling the loop variable gives a log.
- Different inputs get different letters: looping over a string of length n and a list of length m is O(n + m) or O(n·m) — never just "O(n)".
- Hidden loops count:
list.contains(x),s.substring(...),String +in a loop,Arrays.sortall cost more than O(1).
Why log n is so small — binary search
// Binary search: O(log n) time, O(1) space
static int binarySearch(int[] a, int target) {
int lo = 0, hi = a.length - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2; // avoids (lo + hi) overflow
if (a[mid] == target) return mid;
if (a[mid] < target) lo = mid + 1;
else hi = mid - 1;
}
return -1;
}
Counting recursion
For recursive code, ask two questions: how many calls happen in total, and how much work does each call do outside its recursive calls? Multiply them.
- One call per level, n levels, O(1) work each (factorial) → O(n).
- One call on half the input, O(1) work each (binary search) → O(log n).
- Two calls on n - 1 (naive Fibonacci) → the calls double each level → O(2ⁿ).
- Two calls on half, O(n) merge work (merge sort) → log n levels, O(n) per level → O(n log n).
A useful rule: a recursion with b branches and depth d makes about bd calls.
Best, average and worst case
The same algorithm can take very different time depending on the input:
| Algorithm | Best | Average | Worst | Worst happens when |
|---|---|---|---|---|
| Linear search | O(1) | O(n) | O(n) | target is last or missing |
| Quicksort | O(n log n) | O(n log n) | O(n²) | pivot is always the smallest/largest |
| HashMap get | O(1) | O(1) | O(n), or O(log n) in Java 8+ | many keys collide into one bucket |
| Insertion sort | O(n) | O(n²) | O(n²) | array is reverse sorted |
When nobody says which, give the worst case, and mention the average if it is different (for hashing and quicksort, always mention it).
Amortised complexity — ArrayList growth
Amortised means "averaged over a long sequence of operations." Some single operations are expensive, but they are rare enough that the average cost per operation stays small.
An ArrayList is backed by a fixed-size array. When it is full, add allocates a new array about 1.5 times bigger and copies everything over — an O(n) step. But after that, the next many adds are O(1) again. Because the capacity grows by a multiplied amount, the total copying over n adds is a geometric series that adds up to a constant times n.
Simulating 1,000,000 adds with Java's 1.5x growth rule (starting at capacity 10) gives 2,430,972 element copies in total — about 2.4 copies per add. A constant, so add is amortised O(1).
StringBuilder and HashMap resizing.Space complexity (and the hidden stack)
Space complexity counts the extra memory your algorithm uses, not the input itself (say so if you are unsure — some interviewers count the input). Watch for:
- New arrays, lists, maps, sets: O(size).
- Copies:
substring,toCharArray,new ArrayList<>(other)are all O(length). - The recursion stack: every active call holds a stack frame. A recursion that goes n levels deep uses O(n) space even if it creates no objects.
// Recursion that sums an array: O(n) time AND O(n) space (n stack frames)
static int sumRec(int[] a, int i) {
if (i == a.length) return 0;
return a[i] + sumRec(a, i + 1);
}
The iterative sum above does the same job in O(1) space. Recursive DFS on a tree uses O(h) space where h is the height: O(log n) for a balanced tree but O(n) for a skewed one.
Common complexity classes
| Big-O | Name | n = 1,000,000 means roughly | Typical examples |
|---|---|---|---|
| O(1) | Constant | 1 step | Array index, HashMap get/put, stack push/pop |
| O(log n) | Logarithmic | 20 steps | Binary search, balanced BST / TreeMap ops, heap push/pop |
| O(√n) | Square root | 1,000 steps | Checking if n is prime by trial division |
| O(n) | Linear | 10⁶ steps | One pass, two pointers, sliding window, BFS/DFS over V + E |
| O(n log n) | Linearithmic | 2 × 10⁷ steps | Sorting (merge sort, Arrays.sort), n heap operations |
| O(n²) | Quadratic | 10¹² — too slow | All pairs, simple DP on two strings, bubble/insertion sort |
| O(n³) | Cubic | way too slow | Floyd–Warshall, naive matrix multiply, interval DP |
| O(2ⁿ) | Exponential | impossible | All subsets, naive recursive Fibonacci |
| O(n!) | Factorial | impossible | All permutations, brute-force travelling salesman |
Input size → which complexity fits
A typical judge (and a typical interviewer's mental model) allows about 10⁸ simple operations per second. So the constraint on n tells you the complexity they expect — a huge hint that is often ignored:
| If n is up to… | Target complexity | Usually means |
|---|---|---|
| ≤ 10 | O(n!) | Try all permutations; backtracking |
| ≤ 20 | O(2ⁿ) or O(2ⁿ · n) | All subsets, bitmask DP |
| ≤ 500 | O(n³) | Triple loop, interval DP, Floyd–Warshall |
| ≤ 5,000 | O(n²) | Double loop, 2D DP |
| ≤ 10⁶ | O(n log n) | Sort, heap, binary search, divide and conquer |
| larger (10⁸, 10¹⁸) | O(n) or O(log n) | One pass, math, binary search on the answer |
The Master Theorem in easy words
Many divide-and-conquer algorithms have a running time like
T(n) = a · T(n / b) + f(n)
a = how many sub-problems each call makes
n/b = how big each sub-problem is
f(n) = the work done in this call itself (splitting + combining)
Picture the recursion as a tree. The top level does f(n) work. The bottom level has a huge number of tiny leaves. The Master Theorem simply asks: which part of the tree does most of the work? Compare f(n) with nlog_b(a) (the number of leaves):
| Algorithm | Recurrence | Leaves nlog_b a | Who wins | Result |
|---|---|---|---|---|
| Binary search | T(n) = T(n/2) + O(1) | n⁰ = 1 | Tie | O(log n) |
| Merge sort | T(n) = 2T(n/2) + O(n) | n¹ = n | Tie | O(n log n) |
| Tree traversal | T(n) = 2T(n/2) + O(1) | n | Leaves | O(n) |
| Karatsuba multiply | T(n) = 3T(n/2) + O(n) | n^1.58 | Leaves | O(n^1.58) |
| Quickselect (average) | T(n) = T(n/2) + O(n) | 1 | Root | O(n) |
3. Recursion & the Call Stack
Recursion is when a method solves a problem by calling itself on a smaller version of the same problem, until the problem is so small the answer is obvious.
Every recursive method needs two parts: a base case (the tiny problem you answer directly, which stops the recursion) and a recursive case (break the problem down, call yourself on the smaller piece, and combine the result).
Russian nesting dolls. To find the smallest doll you open a doll, and if there is another inside, you do the same thing again. The base case is the solid doll that does not open. Then you put them back together in reverse order.
The two rules
// factorial: n! = n * (n-1) * ... * 1. O(n) time, O(n) stack space
static long factorial(int n) {
if (n <= 1) return 1; // base case: answer directly
return n * factorial(n - 1); // recursive case: smaller input
}
// factorial(5) = 120
// factorial(20) = 2432902008176640000 (factorial(21) overflows long)
- Every path must reach a base case. Forget it, or never shrink the input, and you recurse forever.
- Each call must move toward the base case (n - 1, n / 2, index + 1, a smaller subtree).
Trust the recursion (the "leap of faith"): when writing factorial(n), assume factorial(n - 1) already works and only ask "how do I use it to build my answer?" Do not try to trace every level in your head while writing — trace afterwards to check.
What the call stack actually does
The JVM keeps a call stack for each thread. Every time a method is called, a new stack frame is pushed on top. The frame holds that call's parameters, local variables, and where to return to. When the method returns, its frame is popped and the caller continues exactly where it paused.
So recursion is not magic: factorial(3) pauses in the middle of 3 * ?, waiting for factorial(2) to finish, which pauses waiting for factorial(1).
Recursion trees — naive Fibonacci
When a method calls itself more than once, draw a recursion tree: each node is one call, its children are the calls it makes.
// naive fib: O(2^n) time, O(n) stack space
static long fib(int n) {
if (n <= 1) return n; // fib(0) = 0, fib(1) = 1
return fib(n - 1) + fib(n - 2);
}
Each call branches into 2, and the tree is up to n levels deep, so there are up to about 2ⁿ calls: O(2ⁿ) time. Counting for real: fib(10) makes 177 calls, and fib(30) makes 2,692,537. But the space is only O(n): at any moment only one root-to-leaf path is on the stack, and the deepest path has n frames.
Memoisation preview
The tree above repeats the same subproblems. Memoisation means caching each answer the first time you compute it and returning the cached value next time. Each fib(k) is then computed once:
// memoised fib: O(n) time, O(n) space (memo array + stack)
static long fibMemo(int n, long[] memo) {
if (n <= 1) return n;
if (memo[n] != 0) return memo[n]; // already solved: reuse
return memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
}
// fibMemo(50, new long[51]) = 12586269025, instantly.
// The naive version would need billions of calls.
This is the doorway to dynamic programming: "recursion + a cache" (top-down) or "fill a table in a loop" (bottom-up). It gets its own part later.
Head vs tail recursion
Where you do the work relative to the recursive call changes the order of results:
// Head recursion: recurse FIRST, work on the way back up
static void printHead(int n) {
if (n == 0) return;
printHead(n - 1);
System.out.print(n + " "); // prints: 1 2 3
}
// Tail recursion: work FIRST, the recursive call is the very last thing
static void printTail(int n) {
if (n == 0) return;
System.out.print(n + " "); // prints: 3 2 1
printTail(n - 1);
}
A tail call means nothing is left to do after the call returns, so in theory the frame could be reused. Some languages (Scheme, Scala with @tailrec, Kotlin tailrec) do this tail-call optimisation. Java does not. A tail-recursive method in Java still uses O(n) stack.
StackOverflowError and converting to iteration
The stack has a fixed size (roughly 512 KB to 1 MB per thread by default, adjustable with -Xss). Recurse too deep and the JVM throws StackOverflowError. On a default JVM, an empty infinite recursion died after about 67,000 frames in a quick test; real methods with more locals die sooner. So deep recursion on 10⁵–10⁶ elements (a long linked list, a skewed tree, DFS on a big grid) can crash.
// Tail-recursive sum with an accumulator -- still overflows in Java for n = 1,000,000
static long sumTail(int n, long acc) {
if (n == 0) return acc;
return sumTail(n - 1, acc + n);
}
// Same logic as a loop: O(n) time, O(1) space, cannot overflow the stack
static long sumIter(int n) {
long acc = 0;
while (n > 0) { acc += n; n--; }
return acc;
}
// sumTail(100, 0) = 5050, sumIter(100) = 5050
// sumTail(1_000_000, 0) -> StackOverflowError
// sumIter(1_000_000) -> 500000500000
How to convert recursion to iteration:
- Tail recursion → a plain loop. The parameters become loop variables (as above).
- General recursion (tree DFS, backtracking) → use your own explicit stack:
Deque<Node> stack = new ArrayDeque<>(). Push the starting state, then loop: pop, process, push the children. Your stack lives on the heap, which is much bigger. - Recursion with memo → a bottom-up DP table filled in a loop.
Classic examples
Fast power — compute xⁿ by squaring half the answer instead of multiplying n times:
// x^n in O(log n) time, O(log n) stack
static double myPow(double x, long n) {
if (n < 0) return 1 / myPow(x, -n); // long n: -Integer.MIN_VALUE would overflow an int
if (n == 0) return 1; // base case
double half = myPow(x, n / 2); // solve half the problem ONCE
return (n % 2 == 0) ? half * half : half * half * x;
}
// myPow(2, 10) = 1024.0, myPow(2, -2) = 0.25
The key is calling myPow(x, n / 2) once and storing it. Writing myPow(x, n/2) * myPow(x, n/2) makes two calls per level and falls back to O(n).
Reverse a string — swap the ends, then recurse on the middle:
// O(n) time, O(n) stack (n/2 frames)
static void reverse(char[] s, int left, int right) {
if (left >= right) return; // base case: 0 or 1 chars left
char tmp = s[left]; s[left] = s[right]; s[right] = tmp;
reverse(s, left + 1, right - 1);
}
// "hello" -> "olleh"
All subsets (preview of backtracking) — for each element, make two choices: take it or skip it:
// O(n * 2^n) time (2^n subsets, each copied in O(n)), O(n) stack
static void subsets(int[] nums, int i, List<Integer> current, List<List<Integer>> out) {
if (i == nums.length) { // decided for every element
out.add(new ArrayList<>(current)); // copy! current keeps changing
return;
}
current.add(nums[i]); // choice 1: take nums[i]
subsets(nums, i + 1, current, out);
current.remove(current.size() - 1); // undo the choice (backtrack)
subsets(nums, i + 1, current, out); // choice 2: skip nums[i]
}
// [1,2,3] -> [[1, 2, 3], [1, 2], [1, 3], [1], [2, 3], [2], [3], []]
4. Java Toolkit for DSA
In an interview you do not want to fight the language. This section is the small set of Java classes and methods that show up in almost every DSA solution — which one to pick, the exact method names, and the traps that cause wrong answers.
The big idea: pick the collection whose fast operations match what your algorithm does most. Need "have I seen this?" → HashSet. Need "smallest so far" → PriorityQueue. Need "closest key below x" → TreeMap.
A carpenter's toolbox. You can drive a screw with a hammer, but it is slow and ugly. Knowing exactly which tool is in which drawer is half the skill.
Arrays
int[] a = {5, 3, 9, 1, 7};
Arrays.sort(a); // [1, 3, 5, 7, 9] O(n log n)
int[] dp = new int[5];
Arrays.fill(dp, -1); // [-1, -1, -1, -1, -1]
int[] part = Arrays.copyOfRange(a, 1, 4); // [3, 5, 7] (end index is EXCLUSIVE)
List<String> names = Arrays.asList("bob", "amy", "cat"); // fixed-size list view
int[][] grid = new int[3][4]; // 3 rows, 4 cols, all 0
System.out.println(Arrays.toString(a)); // printing needs toString / deepToString
// Descending order needs boxed Integer[], not int[]
Integer[] boxed = {5, 3, 9, 1};
Arrays.sort(boxed, Collections.reverseOrder()); // [9, 5, 3, 1]
// Sort 2D array (e.g. intervals) by first element
int[][] intervals = {{5, 6}, {1, 3}, {2, 4}};
Arrays.sort(intervals, (x, y) -> Integer.compare(x[0], y[0])); // [[1, 3], [2, 4], [5, 6]]
int total = Arrays.stream(a).sum(); // 25
int max = Arrays.stream(a).max().getAsInt(); // 9
Arrays.sort(int[])uses dual-pivot quicksort (O(n log n) average, not stable — fine for primitives).Arrays.sort(Object[])uses TimSort, which is stable (equal elements keep their order) and guaranteed O(n log n).Arrays.asListreturns a fixed-size view:add/removethrowUnsupportedOperationException. Wrap it:new ArrayList<>(Arrays.asList(...)). AndArrays.asList(intArray)gives a list with one element (the array itself) — it does not box.List.of(...)is fully immutable and rejectsnull.
String vs StringBuilder
A Java String is immutable: it can never change. s += "x" builds a brand-new string and copies all the old characters. In a loop that is O(n²) total. StringBuilder is a growable char buffer: append is amortised O(1).
String s = "hello";
s.charAt(1); // 'e' O(1)
s.length(); // 5 (method for String; field .length for arrays; .size() for collections)
s.substring(1, 3); // "el" O(k) -- makes a copy, end exclusive
char[] cs = s.toCharArray();
Arrays.sort(cs);
new String(cs); // "ehllo" -- sorted letters (anagram key)
String.valueOf(42); // "42"
s.equals("hello"); // compare CONTENT with equals, never ==
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 3; i++) sb.append(i).append(',');
sb.setLength(sb.length() - 1); // drop last comma -> "0,1,2"
sb.reverse(); // "2,1,0" (reverses in place)
sb.insert(0, 'x'); sb.deleteCharAt(0); sb.setCharAt(0, 'y');
String result = sb.toString();
new String("hi") == new String("hi") is false (two different objects) while .equals is true. Always use equals for strings.
char arithmetic
A char is really a small number (its Unicode code). Subtracting 'a' turns a lowercase letter into an index 0–25 — the standard way to count letters with an int[26] instead of a map.
'd' - 'a' // 3 letter -> index
(char) ('a' + 3) // 'd' index -> letter (cast back!)
'7' - '0' // 7 digit char -> int
Character.isDigit('7'); Character.isLetter('x'); Character.isLetterOrDigit(c);
Character.toUpperCase('q'); // 'Q'
int[] freq = new int[26];
for (char ch : "banana".toCharArray()) freq[ch - 'a']++;
// freq[0] ('a') = 3, freq['n' - 'a'] = 2
ArrayList
List<Integer> list = new ArrayList<>(List.of(10, 20, 30));
list.add(40); // append, amortised O(1)
list.add(0, 5); // insert at index: O(n) -- shifts everything
list.get(2); list.set(2, 99); // O(1)
list.remove(Integer.valueOf(20)); // remove by VALUE, O(n)
list.remove(0); // remove by INDEX, O(n)
list.contains(30); // O(n) linear scan!
Stack and queue: use ArrayDeque
ArrayDeque (a resizable circular array) is the right tool for both a stack and a queue. It is faster than LinkedList (no node object per element, better cache use) and does not allow null.
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); stack.push(2); stack.push(3);
stack.peek(); // 3
stack.pop(); // 3 -> stack is [2, 1]
Deque<Integer> queue = new ArrayDeque<>();
queue.offer(1); queue.offer(2); queue.offer(3);
queue.peek(); // 1
queue.poll(); // 1 -> queue is [2, 3]
// Deque (both ends): offerFirst/offerLast, pollFirst/pollLast, peekFirst/peekLast
// Empty behaviour: pop()/remove() THROW; poll()/peek() return null
Why not java.util.Stack? It is an old class that extends Vector: every method is synchronized (slow, pointless for one thread), and because it is a Vector you can insert into the middle, which breaks the stack idea. Java's own docs recommend Deque instead. Mentioning this is a small but good signal.
When is LinkedList useful? Rarely in interviews. It allows null and implements both List and Deque, but get(i) is O(n). Use ArrayDeque for queues and ArrayList for lists.
HashMap and HashSet
// Counting: three equivalent styles
Map<String, Integer> count = new HashMap<>();
for (String w : "a b a c b a".split(" ")) count.merge(w, 1, Integer::sum); // {a=3, b=2, c=1}
map.put(k, map.getOrDefault(k, 0) + 1); // classic
// Grouping: create the list the first time a key is seen
Map<String, List<String>> groups = new HashMap<>();
for (String w : List.of("eat", "tea", "tan", "ate")) {
char[] k = w.toCharArray(); Arrays.sort(k);
groups.computeIfAbsent(new String(k), key -> new ArrayList<>()).add(w);
}
// {aet=[eat, tea, ate], ant=[tan]}
// Iterate keys and values together
for (Map.Entry<String, Integer> e : count.entrySet()) {
System.out.println(e.getKey() + ":" + e.getValue());
}
// also: map.keySet(), map.values(), map.containsKey(k), map.remove(k), map.putIfAbsent(k, v)
Set<Integer> set = new HashSet<>();
set.add(5); // true (was new)
set.add(5); // false (already there) -- handy for "seen before?" in one call
set.contains(5); // true
- HashMap iteration order is not defined. Use
LinkedHashMapfor insertion order (also the basis of an LRU cache),TreeMapfor sorted order. - Keys must have correct
equalsandhashCode.int[]does not — two arrays with the same contents are different keys. Use aStringkey, aList<Integer>, arecord, or encode a pair as(long) r * cols + c.
TreeMap and TreeSet — sorted, with "nearest" queries
A red-black tree (a self-balancing binary search tree) under the hood: every operation is O(log n) and keys stay sorted. Use them when you need "the closest key to x" or ordered iteration.
TreeMap<Integer, String> tm = new TreeMap<>(Map.of(10, "a", 20, "b", 30, "c"));
tm.floorKey(25); // 20 largest key <= 25
tm.ceilingKey(25); // 30 smallest key >= 25
tm.lowerKey(10); // null largest key strictly < 10
tm.higherKey(30); // null smallest key strictly > 30
tm.firstKey(); tm.lastKey(); // 10, 30
tm.headMap(20); // {10=a} keys < 20
tm.tailMap(20); // {20=b, 30=c} keys >= 20
// also: floorEntry, ceilingEntry, pollFirstEntry, descendingMap
TreeSet<Integer> ts = new TreeSet<>(List.of(4, 1, 9, 6)); // [1, 4, 6, 9]
ts.floor(5); // 4
ts.ceiling(5); // 6
ts.pollFirst(); // 1 (removes it)
Always check for null — the "nearest" methods return null when nothing qualifies, and unboxing that into an int throws NullPointerException.
PriorityQueue — a heap
A binary heap: peek gives the smallest element in O(1); offer and poll are O(log n). By default it is a min-heap.
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());
for (int v : new int[]{5, 1, 8, 3}) { minHeap.offer(v); maxHeap.offer(v); }
minHeap.poll(); // 1
maxHeap.poll(); // 8
// Heap of arrays: {node, distance}, ordered by distance (Dijkstra style)
PriorityQueue<int[]> pq = new PriorityQueue<>((p, q) -> Integer.compare(p[1], q[1]));
pq.offer(new int[]{0, 7}); pq.offer(new int[]{1, 2}); pq.offer(new int[]{2, 5});
pq.poll(); // [1, 2] -- smallest distance first
- Iterating or printing a PriorityQueue does not give sorted order — only
polldoes. remove(Object)andcontainsare O(n).- Building from a collection,
new PriorityQueue<>(list), is O(n) (heapify), faster than n offers.
Collections helpers and Comparator chains
List<Integer> nums = new ArrayList<>(List.of(3, 1, 2));
Collections.sort(nums); // [1, 2, 3]
Collections.reverse(nums); // [3, 2, 1]
Collections.max(nums); // 3
Collections.min(nums); // 1
Collections.frequency(List.of(1, 1, 2), 1); // 2
Collections.swap(nums, 0, 2); // also: Collections.nCopies, Collections.emptyList
record Person(String name, int age) {}
List<Person> people = new ArrayList<>(List.of(
new Person("Zoe", 30), new Person("Adam", 25), new Person("Bob", 30)));
// age descending, then name ascending
people.sort(Comparator.comparingInt(Person::age).reversed()
.thenComparing(Person::name));
// [Bob 30, Zoe 30, Adam 25]
List<String> words = new ArrayList<>(List.of("pear", "fig", "apple", "kiwi"));
// shortest first; ties broken by reverse alphabetical
words.sort(Comparator.comparing(String::length)
.thenComparing(Comparator.reverseOrder()));
// [fig, pear, kiwi, apple]
(a, b) -> a - b overflows for large values. For example -2_000_000_000 - 2_000_000_000 evaluates to 294967296 — a positive number, so it says the negative value is larger. Use Integer.compare(a, b) (which returned -1 here) or Comparator.comparingInt.Integer overflow
An int is 32 bits: range about ±2.1 billion (Integer.MAX_VALUE = 2147483647). Going past it silently wraps around to negative — no error.
int big = Integer.MAX_VALUE;
big + 1; // -2147483648 wrapped around!
(long) big + 1; // 2147483648 cast BEFORE the math
int m = 100_000;
m * m; // 1410065408 wrong (true answer is 10^10)
(long) m * m; // 10000000000 right
Math.addExact(big, 1); // throws ArithmeticException("integer overflow")
int mid = lo + (hi - lo) / 2; // safe midpoint; (lo + hi) / 2 can overflow
Use long for sums of many values, products, and anything counted "modulo 10⁹ + 7" (multiply as long, then take %). (long) (m * m) is still wrong — the overflow already happened inside the brackets.
The Integer caching trap
Integer p1 = 127, p2 = 127, q1 = 128, q2 = 128;
p1 == p2; // true -- same cached object
q1 == q2; // false -- two different objects!
q1.equals(q2); // true
Map<Character, Integer> m1 = ..., m2 = ...; // both map 'a' -> 1000
m1.get('a') == m2.get('a'); // false -- compares object identity
m1.get('a').equals(m2.get('a')); // true
Java caches Integer objects for values −128 to 127, so == happens to work for small numbers in your tests and then fails on large ones. Whenever values come out of a collection, compare with .equals or unbox first (int x = map.get(k);). This bug is famous in "Minimum Window Substring"-style problems that compare two count maps.
int vs Integer in collections
- Collections hold objects only, so
List<int>is illegal; useList<Integer>. Java autoboxes for you, but eachIntegeris an object (≈16 bytes vs 4) — anint[]is much faster and smaller when the size is known. - Unboxing a
nullthrowsNullPointerException:int c = map.get(missingKey);crashes. UsegetOrDefault. List<Integer>.remove(1)removes index 1;remove(Integer.valueOf(1))removes the value 1. From[1, 2, 3]:remove(1)gives[1, 3], thenremove(Integer.valueOf(1))gives[3].
Fast input (for online judges)
Scanner is convenient but slow for 10⁵+ numbers. Use BufferedReader with StringTokenizer:
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
int n = Integer.parseInt(br.readLine().trim());
StringTokenizer st = new StringTokenizer(br.readLine());
long total = 0;
for (int i = 0; i < n; i++) total += Integer.parseInt(st.nextToken());
// For lots of output, collect in a StringBuilder and print once
// (or use PrintWriter) instead of calling System.out.println in a loop.
Operation cost cheat sheet
| Structure | Access / get | Search / contains | Insert | Delete | Notes |
|---|---|---|---|---|---|
int[] array | O(1) | O(n) | — | — | Fixed size; fastest |
ArrayList | O(1) | O(n) | O(1)* at end, O(n) middle | O(1) at end, O(n) middle | *amortised |
LinkedList | O(n) | O(n) | O(1) at ends | O(1) at ends | Rarely the best choice |
ArrayDeque | peek O(1) | O(n) | O(1)* either end | O(1) either end | Stack and queue |
HashMap / HashSet | O(1) avg | O(1) avg | O(1) avg | O(1) avg | No order; worst O(log n) per bucket in Java 8+ |
LinkedHashMap | O(1) avg | O(1) avg | O(1) avg | O(1) avg | Keeps insertion (or access) order |
TreeMap / TreeSet | O(log n) | O(log n) | O(log n) | O(log n) | Sorted; floor/ceiling O(log n); first/last O(log n) |
PriorityQueue | peek O(1) | O(n) | O(log n) | poll O(log n), remove(x) O(n) | Min-heap by default |
String | charAt O(1) | indexOf O(n·m) | Immutable: every change makes a new copy O(n) | substring is O(k) | |
StringBuilder | charAt O(1) | O(n) | append O(1)* | deleteCharAt O(n) | insert(0, …) is O(n) |
5. Bit Manipulation
Computers store every integer as a row of bits — 0s and 1s. Bit manipulation means working on those bits directly with operators like AND, OR and XOR. It gives tiny, very fast solutions to certain problems, and lets you use a single int as a set of up to 32 yes/no flags.
A row of light switches on a wall. The number is the pattern of which switches are on. Bit operations are like saying "flip switch 3", "turn on every switch that is on in either of these two rows", or "which switches differ between these two rows?"
Binary basics
In binary each position is a power of 2, counted from the right starting at position 0. So 1101 = 1·8 + 1·4 + 0·2 + 1·1 = 13.
Integer.toBinaryString(13); // "1101"
Integer.parseInt("1101", 2); // 13
int n = 0b1010; // binary literal = 10
// bit i has value 1 << i: 1 << 0 = 1, 1 << 3 = 8, 1 << 10 = 1024
Two's complement — how negatives are stored
Java's int is 32 bits and signed. The leftmost bit is the sign bit: 0 for non-negative, 1 for negative. Negatives use two's complement: to get -x, flip every bit of x and add 1. So -x == ~x + 1.
Why this design? Addition works the same for positive and negative numbers, and there is only one zero. It also explains ~5 == -6, which we confirmed by running it.
The operators
| Operator | Name | Rule | Example (12 = 1100, 10 = 1010) |
|---|---|---|---|
& | AND | 1 only if both bits are 1 | 12 & 10 = 1000 = 8 |
| | OR | 1 if either bit is 1 | 12 | 10 = 1110 = 14 |
^ | XOR | 1 if the bits are different | 12 ^ 10 = 0110 = 6 |
~ | NOT | flip every bit | ~5 = -6 |
<< | Left shift | move bits left, fill with 0 (multiply by 2ᵏ) | 5 << 2 = 20 |
>> | Signed right shift | move right, copy the sign bit in (divide by 2ᵏ, rounding down) | 20 >> 2 = 5, -20 >> 2 = -5 |
>>> | Unsigned right shift | move right, always fill with 0 | -20 >>> 28 = 15 |
- Shift amounts are taken mod 32 for
int:1 << 32is1, not 0. And1 << 31is-2147483648(the sign bit). Use1L << kfor 64-bit masks (1L << 32 = 4294967296). - Precedence trap:
&,^,|bind weaker than==.n & 1 == 0does not compile in Java (it parses asn & (1 == 0)). Always write(n & 1) == 0. - Odd/even:
(n & 1)is 1 for odd, 0 for even — it works for negatives too, while-7 % 2is-1, not 1.
Single-bit tricks
// i = bit position, 0 = rightmost. All O(1).
static boolean isSet(int n, int i) { return (n & (1 << i)) != 0; } // check
static int setBit(int n, int i) { return n | (1 << i); } // turn on
static int clearBit(int n, int i) { return n & ~(1 << i); } // turn off
static int toggleBit(int n, int i) { return n ^ (1 << i); } // flip
// n = 0b1010 (10)
isSet(10, 1); // true
isSet(10, 2); // false
setBit(10, 0); // 11 (1011)
clearBit(10, 3); // 2 (0010)
toggleBit(10, 1); // 8 (1000)
Pattern: build a mask with a single 1 at position i (1 << i), then AND to test, OR to set, AND-with-NOT to clear, XOR to flip.
The two most useful tricks: n & (n - 1) and n & -n
Subtracting 1 from a number flips its lowest 1 bit to 0 and all the 0s below it to 1. So n & (n - 1) wipes out exactly the lowest set bit. And because -n = ~n + 1, n & -n keeps only the lowest set bit.
// Power of two: exactly one bit set. O(1)
static boolean isPowerOfTwo(int n) { return n > 0 && (n & (n - 1)) == 0; }
// 64 -> true, 96 -> false, 0 -> false, Integer.MIN_VALUE -> false (n > 0 matters!)
static int lowestSetBit(int n) { return n & -n; } // 12 -> 4
// Brian Kernighan's bit count: loops once per SET bit. O(number of 1s), at most 32
static int countBits(int n) {
int count = 0;
while (n != 0) {
n &= n - 1; // drop the lowest set bit
count++;
}
return count;
}
// countBits(13) = 3, countBits(-1) = 32 (while n != 0, so negatives work)
// Built-in: Integer.bitCount(13) = 3
Other handy built-ins: Integer.numberOfTrailingZeros(8) = 3, Integer.numberOfLeadingZeros(8) = 28, Integer.highestOneBit(100) = 64, Integer.reverse(x), and Long versions of each. Say you would use the built-in in production, and write the loop if asked.
XOR tricks
XOR has three properties that power a surprising number of problems:
a ^ a = 0— a number cancels itself.a ^ 0 = a— zero changes nothing.- Order does not matter (commutative and associative) — so you can XOR a whole list in any order and every pair cancels.
Single Number — every value appears twice except one. Find it in O(1) space:
// O(n) time, O(1) space
static int singleNumber(int[] nums) {
int x = 0;
for (int v : nums) x ^= v; // pairs cancel: a ^ a = 0
return x;
}
// [4, 1, 2, 1, 2] -> 4 ^ (1 ^ 1) ^ (2 ^ 2) = 4 ^ 0 ^ 0 = 4
Missing Number — nums holds n distinct values from 0..n with one missing. XOR every index and every value; everything present appears twice and cancels, leaving the missing one:
// O(n) time, O(1) space. (Sum formula n(n+1)/2 also works but can overflow for huge n.)
static int missingNumber(int[] nums) {
int x = nums.length; // include n itself
for (int i = 0; i < nums.length; i++) x ^= i ^ nums[i];
return x;
}
// [3, 0, 1] -> 2
Swap without a temp variable — a party trick; know it, but prefer a temp in real code (it breaks if both refer to the same array slot, because x ^ x = 0):
int a = 3, b = 7;
a ^= b; b ^= a; a ^= b; // now a = 7, b = 3
Single Number III — two values appear once, everything else twice. XOR of all gives a ^ b. Any 1 bit in that result is a position where a and b differ; split the numbers into two groups by that bit and XOR each group:
// O(n) time, O(1) space
static int[] singleNumberIII(int[] nums) {
int xor = 0;
for (int v : nums) xor ^= v; // = a ^ b, non-zero because a != b
int diff = xor & -xor; // lowest bit where a and b differ
int a = 0, b = 0;
for (int v : nums) {
if ((v & diff) == 0) a ^= v; // group 1
else b ^= v; // group 2
}
return new int[]{a, b};
}
// [1, 2, 1, 3, 2, 5] -> [5, 3]
Subsets via bitmask
For n items, each of the numbers 0 to 2ⁿ - 1 is a different pattern of n bits. Read bit i as "is item i in the subset?" and you get every subset exactly once — no recursion needed.
// O(n * 2^n) time, O(n * 2^n) for the output
static List<List<Integer>> subsets(int[] nums) {
int n = nums.length;
List<List<Integer>> out = new ArrayList<>();
for (int mask = 0; mask < (1 << n); mask++) { // 0 .. 2^n - 1
List<Integer> subset = new ArrayList<>();
for (int i = 0; i < n; i++) {
if ((mask & (1 << i)) != 0) subset.add(nums[i]); // bit i on -> take nums[i]
}
out.add(subset);
}
return out;
}
// [1,2,3] -> [[], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3]]
This is also why problems with n ≤ 20 hint at bitmasks: 2²⁰ is about a million states. The same idea powers bitmask DP, where the mask records "which items are already used" (for example, visiting-all-cities problems).
More classic problems
Counting Bits — return the number of 1s for every number 0..n. Reuse earlier answers: i >> 1 is i with its last bit dropped, so its count is already known; add back the last bit.
// O(n) time, O(n) output
static int[] countBitsUpTo(int n) {
int[] ans = new int[n + 1];
for (int i = 1; i <= n; i++) ans[i] = ans[i >> 1] + (i & 1);
return ans;
}
// n = 5 -> [0, 1, 1, 2, 1, 2]
Reverse Bits — mirror all 32 bits. Take the lowest bit of n, push it into the result from the right, repeat 32 times. Use >>> so negative inputs do not drag in sign bits:
// O(32) = O(1) time, O(1) space
static int reverseBits(int n) {
int result = 0;
for (int i = 0; i < 32; i++) {
result = (result << 1) | (n & 1); // append n's lowest bit to result
n >>>= 1; // unsigned shift: no sign fill
}
return result;
}
// reverseBits(0b1011) = -805306368 = 11010000...0 (binary), same as Integer.reverse(0b1011)
Letter case — in ASCII, upper and lower case letters differ only in bit 5 (value 32, the space character): (char) ('A' | ' ') = 'a' and (char) ('a' & '_') = 'A'. Cute, but Character.toLowerCase is clearer in real code.
Summary table
| Goal | Expression |
|---|---|
| Is bit i set? | (n & (1 << i)) != 0 or ((n >> i) & 1) == 1 |
| Set / clear / toggle bit i | n | (1 << i) / n & ~(1 << i) / n ^ (1 << i) |
| Drop lowest set bit | n & (n - 1) |
| Keep only lowest set bit | n & -n |
| Power of two? | n > 0 && (n & (n - 1)) == 0 |
| Odd? | (n & 1) == 1 |
| Multiply / divide by 2ᵏ | n << k / n >> k (floor division, also for negatives) |
| All ones for k bits | (1 << k) - 1 |
| Count set bits | Integer.bitCount(n) |
6. Arrays & Strings
An array is a row of boxes, all the same size, sitting side by side in memory. Each box has a number called its index, starting at 0. Because the boxes are side by side, the computer can jump straight to box number i without looking at the others.
A string is basically an array of characters with some extra rules. In Java, a String can never be changed once it is made.
A row of numbered lockers in a hallway. If you know the locker number, you walk straight to it. But if you want to squeeze a new locker into the middle, everyone after it has to shift one place down.
How an array sits in memory
When you write new int[7], Java asks for one contiguous (unbroken) block of memory big enough for 7 ints. An int is 4 bytes, so that is 28 bytes in a row. To find element i, the machine does one small sum: start address + i × element size. That single sum is why reading a[i] is O(1) no matter how big the array is.
A side benefit: when the CPU reads one element, it pulls the neighbouring bytes into its fast cache too. Walking an array from left to right is therefore much faster in practice than hopping around a linked list, even when both are O(n) on paper.
Static vs dynamic arrays
A plain Java array (int[], String[]) is static: its length is fixed when you create it. An ArrayList is a dynamic array: a static array hidden inside an object that swaps it for a bigger one when it fills up.
Why is add still called O(1)? Because the expensive copy happens rarely. With doubling, filling an array to n elements copies 1 + 2 + 4 + … + n/2 elements in total, which is less than n. Spread over n adds, that is less than one extra copy per add. This is called amortised O(1): one call can be slow, but the average over many calls is constant. Java's ArrayList grows by 1.5x instead of 2x (saves memory, still amortised O(1)); the first add on a new list allocates capacity 10.
Building a dynamic array from scratch
Interviewers sometimes ask "implement an ArrayList". The whole idea fits in a few lines:
class IntList {
private int[] data = new int[4];
private int size = 0;
void add(int x) { // amortised O(1)
if (size == data.length) {
data = Arrays.copyOf(data, data.length * 2); // O(n) copy, rare
}
data[size++] = x;
}
int get(int i) { // O(1)
if (i < 0 || i >= size) throw new IndexOutOfBoundsException(i);
return data[i];
}
void removeAt(int i) { // O(n): shift left
if (i < 0 || i >= size) throw new IndexOutOfBoundsException(i);
System.arraycopy(data, i + 1, data, i, size - i - 1);
size--;
}
int size() { return size; }
}
// Adding 10, 20, ..., 90 makes capacity go 4 4 4 4 8 8 8 8 16
Operations and their cost
| Operation | int[] | ArrayList | Why |
|---|---|---|---|
Read / write a[i] | O(1) | O(1) | Address arithmetic |
| Add at end | not possible (fixed size) | O(1) amortised | Occasional resize copy |
| Insert / remove at index i | O(n) | O(n) | Everything after i must shift |
| Remove last | — | O(1) | Nothing to shift |
| Search (unsorted) | O(n) | O(n) | Must check each element |
| Search (sorted) | O(log n) | O(log n) | Binary search |
| Space | O(n) | O(n) | ArrayList may hold up to ~50% spare capacity |
2D arrays
In Java, int[][] grid = new int[3][4] is really an array of 3 references, each pointing to its own int[4]. Rows are separate objects, so rows can even have different lengths (a jagged array). grid.length is the number of rows, grid[0].length the number of columns.
int[][] grid = new int[3][4]; // 3 rows, 4 columns, all 0
grid[1][2] = 7; // row 1, column 2
int[][] jagged = {{1}, {2, 3}, {4, 5, 6}};
System.out.println(Arrays.deepToString(jagged)); // [[1], [2, 3], [4, 5, 6]]
// The four neighbours of (r, c) — you will write this in every grid problem
int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (int[] d : dirs) {
int nr = r + d[0], nc = c + d[1];
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) { /* visit */ }
}
Loop row by row (for r … for c …), not column by column: each row is contiguous, so row order is cache-friendly.
Strings: immutable, so use StringBuilder
A Java String is immutable: no method ever changes it. Methods like concat, replace and toUpperCase return a new string. This makes strings safe to share and cache (and to use as HashMap keys), but it has a cost: building a string with += in a loop copies the whole string every time, which is O(n²) total.
String s = "hi";
s.concat(" there"); // result thrown away
System.out.println(s); // hi
s = s.concat(" there"); // reassign to keep it
System.out.println(s); // hi there
// Build strings in a loop with StringBuilder: append is amortised O(1)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 5; i++) sb.append(i).append(',');
sb.setLength(sb.length() - 1); // drop the last comma
System.out.println(sb.reverse()); // 4,3,2,1,0
// Compare contents with equals, never ==
String x = "java", y = "java", z = new String("java");
System.out.println((x == y) + " " + (x == z) + " " + x.equals(z)); // true false true
x == y is true only because identical string literals share one object in the string pool. new String(...) makes a separate object, so == (which compares references, i.e. "same object?") fails. Always use equals.
| String operation | Cost | Note |
|---|---|---|
charAt(i), length() | O(1) | Backed by an array |
substring(a, b) | O(b − a) | Copies the characters (since Java 7) |
equals, indexOf, contains | O(n) / O(n·m) | Compares character by character |
s + t | O(n + m) | New string every time |
toCharArray() | O(n) | Gives a mutable copy you can sort or swap in |
StringBuilder.append | O(1) amortised | Dynamic char array inside |
Counting characters with int[26]
When the input is only lowercase letters, a 26-slot array beats a HashMap: c - 'a' turns 'a' into 0, 'b' into 1, and so on. It is faster, uses fixed O(1) space, and is less code.
int[] freq = new int[26];
for (char c : "banana".toCharArray()) freq[c - 'a']++;
// freq[0] ('a') = 3, freq[1] ('b') = 1, freq['n' - 'a'] = 2
Use int[128] for any ASCII character, and a HashMap<Character, Integer> for full Unicode.
Common techniques (the toolbox)
| Technique | Idea in one line | Typical signal |
|---|---|---|
| Two pointers | One index from each end, or a slow and a fast index, moving toward an answer | Sorted array, pairs, palindromes, in-place removal |
| Sliding window | Keep a range [left, right] and grow/shrink it while tracking a running value | "Longest / shortest subarray or substring with …" |
| Prefix sums | prefix[i] = sum of the first i elements, so any range sum is one subtraction | Many range-sum queries, "subarray sum equals k" |
| Frequency counting | int[26] or HashMap of counts | Anagrams, "most common", duplicates |
| Reverse tricks | Reversing parts of an array rearranges it in place | Rotation, reverse words |
| Sort first | O(n log n) sort often turns a hard problem into a two-pointer one | 3Sum, merge intervals, meeting rooms |
Each of these gets its own section in the patterns part of these notes.
When to use what
int[]— size known up front, primitives, maximum speed (no boxing:intstays a raw number instead of anIntegerobject).ArrayList— size grows, you mostly add at the end and read by index. The default list in Java.- Not an array — if you insert/remove at the front a lot, use
ArrayDeque; if you look things up by value, use aHashSet/HashMap.
Common pitfalls
- Off-by-one: the last index is
length - 1. Loops arei < n, noti <= n. lengthvslength()vssize(): arrays use the fielda.length, strings the methods.length(), collectionslist.size().Arrays.asListis fixed-size: callingaddon it throwsUnsupportedOperationException. Wrap it:new ArrayList<>(Arrays.asList(...)).list.remove(1)on aList<Integer>removes index 1, not the value 1. Uselist.remove(Integer.valueOf(1))for the value.- Printing an array with
System.out.println(a)prints something like[I@1b6d3586. UseArrays.toString(a). - Integer overflow when summing or multiplying: use
longwhen values can exceed about 2.1 billion.
Problem: Rotate Array (right by k)
Given [1,2,3,4,5,6,7] and k = 3, produce [5,6,7,1,2,3,4] in place.
void rotate(int[] nums, int k) {
int n = nums.length;
k %= n; // k can be larger than n
reverse(nums, 0, n - 1); // 1 2 3 4 5 6 7 -> 7 6 5 4 3 2 1
reverse(nums, 0, k - 1); // -> 5 6 7 | 4 3 2 1
reverse(nums, k, n - 1); // -> 5 6 7 | 1 2 3 4
}
void reverse(int[] a, int i, int j) {
while (i < j) {
int t = a[i]; a[i] = a[j]; a[j] = t;
i++; j--;
}
}
Time O(n) (every element is swapped at most twice). Space O(1). Why it works: reversing the whole array puts the last k elements at the front but backwards; reversing each part separately fixes the order inside each part.
Problem: Product of Array Except Self
Return ans[i] = product of every element except nums[i], without division. [1,2,3,4] gives [24,12,8,6].
int[] productExceptSelf(int[] nums) {
int n = nums.length;
int[] ans = new int[n];
ans[0] = 1;
for (int i = 1; i < n; i++) { // ans[i] = product of everything left of i
ans[i] = ans[i - 1] * nums[i - 1];
}
int right = 1; // running product of everything right of i
for (int i = n - 1; i >= 0; i--) {
ans[i] *= right;
right *= nums[i];
}
return ans;
}
// [1, 2, 3, 4] -> [24, 12, 8, 6]
// [-1, 1, 0, -3, 3] -> [0, 0, 9, 0, 0]
Time O(n). Space O(1) extra (the output array does not count). Why it works: "everything except me" is exactly "product on my left" times "product on my right", and both can be built in one pass each.
Problem: Longest Common Prefix
String longestCommonPrefix(String[] strs) {
if (strs.length == 0) return "";
String first = strs[0];
for (int i = 0; i < first.length(); i++) { // column by column
char c = first.charAt(i);
for (int j = 1; j < strs.length; j++) {
if (i == strs[j].length() || strs[j].charAt(i) != c) {
return first.substring(0, i);
}
}
}
return first;
}
// ["flower","flow","flight"] -> "fl"
// ["dog","racecar","car"] -> ""
Time O(S), where S is the total number of characters (in the worst case every character is checked once). Space O(1) apart from the answer. Why it works: scanning column by column stops at the very first position where any string disagrees or runs out, which is exactly where the shared prefix ends.
Problem: Valid Anagram
boolean isAnagram(String s, String t) {
if (s.length() != t.length()) return false;
int[] count = new int[26];
for (int i = 0; i < s.length(); i++) {
count[s.charAt(i) - 'a']++;
count[t.charAt(i) - 'a']--;
}
for (int c : count) if (c != 0) return false;
return true;
}
// ("anagram", "nagaram") -> true, ("rat", "car") -> false
Time O(n). Space O(1) (26 counters, whatever n is). Why it works: two strings are anagrams exactly when every letter appears the same number of times, so adding for one string and subtracting for the other must leave all zeros. (Sorting both and comparing also works, in O(n log n).)
StringBuilder in loops since String is immutable, and an int[26] for letter counts."7. Hashing
Hashing turns a key (a word, a number, an object) into a number, and uses that number to pick a slot in an array. A hash table is that array of slots. Because the key tells you directly which slot to look in, finding, adding and removing are O(1) on average — no searching through everything.
In Java you use it through HashMap (key to value) and HashSet (just keys, no duplicates).
A coat check. You hand over your coat and get ticket number 42. When you come back, the attendant does not search every hook; they walk straight to hook 42. The ticket number is the hash.
The three pieces
- Hash function — turns a key into an integer. Same key must always give the same number. A good one spreads different keys evenly. In Java this is the key's
hashCode()method. - Buckets — the array of slots. The slot for a key is
hash % capacity(Java useshash & (capacity - 1), which is the same thing when capacity is a power of 2, and faster). - Collision handling — two different keys can land in the same bucket. This is not a bug; it is guaranteed to happen (infinitely many possible keys, finitely many buckets). The table needs a plan for it.
Collisions: chaining vs open addressing
| Separate chaining | Open addressing | |
|---|---|---|
| Idea | Each bucket holds a list of entries; colliding keys join the list | One entry per slot; on collision, probe (try) the next slot, then the next, until a free one |
| Probing styles | — | Linear (i+1, i+2 …), quadratic (i+1, i+4, i+9 …), double hashing (step size from a 2nd hash) |
| Delete | Easy: unlink from the list | Tricky: must leave a "deleted" marker (tombstone) or later probes break |
| Load factor limit | Can go above 1 (just longer chains) | Must stay well below 1 (clusters form fast) |
| Cache friendliness | Worse (pointer chasing) | Better (all in one array) |
| Used by | Java HashMap | Python dict, Java IdentityHashMap, ThreadLocal |
Load factor and resizing
The load factor is entries / buckets. As it rises, chains get longer and lookups slow down. So when it passes a threshold (Java: 0.75), the table resizes: it allocates twice as many buckets and re-inserts every entry, because each entry's bucket index depends on the capacity. A resize is O(n), but it happens rarely (each time the size doubles), so put is still amortised O(1) — the same argument as ArrayList growth.
If you know you will store about a million entries, create the map with enough room to skip all the intermediate resizes: new HashMap<>(1_400_000) (expected size / 0.75), or HashMap.newHashMap(1_000_000) on Java 19+.
How Java's HashMap actually works
- Internally it is an array
Node<K,V>[] table, lazily created with 16 buckets on the firstput. Capacity is always a power of 2. - Hash spreading: it computes
h ^ (h >>> 16)so the high bits ofhashCode()also influence the bucket (otherwise with 16 buckets only the lowest 4 bits would matter). - Bucket index =
(capacity - 1) & hash. get: go to the bucket, walk it, and for each node checkhash == hash && key.equals(k).- Treeification: if one bucket's chain grows past 8 nodes, i.e. when the 9th is added, and the table has at least 64 buckets, that chain is converted into a red-black tree, so a bad bucket costs O(log n) instead of O(n). It turns back into a list if it shrinks to 6. (If the table is smaller than 64, Java resizes instead.) This protects against many keys with colliding hashes, including deliberate attacks.
- Resize at
size > capacity × 0.75, doubling the capacity. Each entry either stays at index i or moves to i + oldCapacity — Java checks one bit to decide. - One
nullkey is allowed (it goes to bucket 0). Iteration order is not defined and can change after a resize. - Not thread-safe. Use
ConcurrentHashMapacross threads.
Build a hash map from scratch (separate chaining)
class MyHashMap<K, V> {
private static class Node<K, V> {
final K key; V value; Node<K, V> next;
Node(K key, V value, Node<K, V> next) { this.key = key; this.value = value; this.next = next; }
}
private Node<K, V>[] buckets;
private int size = 0;
private static final double LOAD_FACTOR = 0.75;
@SuppressWarnings("unchecked")
MyHashMap() { buckets = (Node<K, V>[]) new Node[16]; }
private int indexFor(Object key, int length) {
int h = (key == null) ? 0 : key.hashCode();
h ^= (h >>> 16); // mix high bits into low bits (like Java)
return h & (length - 1); // same as h % length when length is a power of 2
}
V get(K key) { // O(1) average
for (Node<K, V> n = buckets[indexFor(key, buckets.length)]; n != null; n = n.next) {
if (Objects.equals(n.key, key)) return n.value;
}
return null;
}
void put(K key, V value) { // O(1) average, amortised
int i = indexFor(key, buckets.length);
for (Node<K, V> n = buckets[i]; n != null; n = n.next) {
if (Objects.equals(n.key, key)) { n.value = value; return; } // update
}
buckets[i] = new Node<>(key, value, buckets[i]); // insert at head
if (++size > buckets.length * LOAD_FACTOR) resize();
}
@SuppressWarnings("unchecked")
private void resize() { // O(n), but rare -> amortised O(1)
Node<K, V>[] old = buckets;
buckets = (Node<K, V>[]) new Node[old.length * 2];
for (Node<K, V> head : old) {
for (Node<K, V> n = head; n != null; n = n.next) {
int i = indexFor(n.key, buckets.length);
buckets[i] = new Node<>(n.key, n.value, buckets[i]);
}
}
}
}
// After 13 puts, the 13th pushes size past 16 * 0.75 = 12, so capacity becomes 32.
Operations and their cost
| Operation | Average | Worst (Java 8+) | Note |
|---|---|---|---|
put / get / remove / containsKey | O(1) | O(log n) | Worst case = everything in one treeified bucket (O(n) before Java 8 or for keys that are not Comparable) |
containsValue | O(n) | O(n) | Values are not indexed — scans everything |
| Iterate all entries | O(n + capacity) | O(n + capacity) | Visits every bucket, even empty ones |
| Resize | O(n), amortised O(1) per put | At load factor 0.75 | |
| Space | O(n) | Each entry is a node object: much heavier than an array slot | |
The equals / hashCode contract
HashMap finds a key in two steps: hashCode() picks the bucket, then equals() finds the exact key inside it. So the rule is: if a.equals(b) is true, then a.hashCode() == b.hashCode() must also be true. (The reverse is not required: different keys may share a hash — that is just a collision. For example "Aa".hashCode() and "BB".hashCode() are both 2112.)
Override equals but forget hashCode, and two "equal" objects get different identity-based hash codes, land in different buckets, and never find each other:
class BadPoint {
final int x, y;
BadPoint(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof BadPoint p && p.x == x && p.y == y;
}
// hashCode NOT overridden -> uses identity hash
}
Set<BadPoint> bad = new HashSet<>();
bad.add(new BadPoint(1, 2));
System.out.println(bad.contains(new BadPoint(1, 2))); // false (bug!)
// Fix: a record generates correct equals AND hashCode for you
record Point(int x, int y) {}
Set<Point> good = new HashSet<>();
good.add(new Point(1, 2));
System.out.println(good.contains(new Point(1, 2))); // true
Without records, write @Override public int hashCode() { return Objects.hash(x, y); } using the same fields that equals uses.
HashSet, LinkedHashMap, TreeMap
HashSet is literally a HashMap where the set elements are the keys and every value is the same dummy object. Same costs, same rules. set.add(x) returns false if x was already there — handy for duplicate detection.
| Class | Order when iterating | get / put | Use when |
|---|---|---|---|
HashMap | None (looks random) | O(1) avg | Default choice |
LinkedHashMap | Insertion order (or access order) | O(1) avg | Need predictable order; LRU cache (access order + removeEldestEntry) |
TreeMap | Sorted by key | O(log n) | Need sorted keys, or floor/ceiling/range queries |
Handy Map methods that make interview code short: getOrDefault(k, 0), merge(k, 1, Integer::sum) (counting), computeIfAbsent(k, x -> new ArrayList<>()).add(v) (grouping), putIfAbsent.
When to use hashing
- "Have I seen this before?" —
HashSet. Turns an O(n) inner search into O(1), so many O(n²) brute forces become O(n). - "How many times?" —
HashMap<T, Integer>of counts. - "Group things by some property" —
HashMap<Key, List<T>>. - Don't use it when you need order (use
TreeMap), or when keys are small integers in a known range (a plain array is faster and simpler).
Common pitfalls
- Mutable keys: if you change a key object after putting it in, its hash changes and the map can no longer find it. A
Listkey that youaddto afterwards makescontainsreturnfalse, even though the set's size is still 1. Use immutable keys (String,Integer, records). - Comparing
Integervalues with==:map.get("x") == map.get("y")compares object references. It happens to work for -128 to 127 (Java caches those), and silently fails for 1000. Useequalsor unbox tointfirst. getreturnsnullfor a missing key; unboxing that intointthrowsNullPointerException. UsegetOrDefault.- Using arrays as keys:
int[]uses identity equality. Convert to aStringorList<Integer>first. - Assuming iteration order of a
HashMap. It is not insertion order.
Problem: Two Sum
Return indices of the two numbers that add up to target.
int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> seen = new HashMap<>(); // value -> index
for (int i = 0; i < nums.length; i++) {
int need = target - nums[i];
if (seen.containsKey(need)) return new int[]{seen.get(need), i};
seen.put(nums[i], i);
}
return new int[0];
}
// [2, 7, 11, 15], 9 -> [0, 1]
// [3, 2, 4], 6 -> [1, 2]
Time O(n). Space O(n). Why it works: for each number, its partner (target - x) is fully determined, so instead of searching for it we ask the map "have I already seen it?" in O(1). Checking before inserting stops a number pairing with itself.
Problem: Group Anagrams
List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> groups = new HashMap<>();
for (String s : strs) {
int[] count = new int[26];
for (char c : s.toCharArray()) count[c - 'a']++;
String key = Arrays.toString(count); // e.g. "[1, 0, 0, ...]"
groups.computeIfAbsent(key, k -> new ArrayList<>()).add(s);
}
return new ArrayList<>(groups.values());
}
// ["eat","tea","tan","ate","nat","bat"]
// -> [bat], [eat, tea, ate], [tan, nat] (group order may vary)
Time O(n · k), where k is the longest word length (the key has fixed length 26). Space O(n · k). Why it works: all anagrams have identical letter counts, so the count array is a perfect "signature" to group by. (Sorting each word as the key also works: O(n · k log k).)
Problem: Longest Consecutive Sequence
In an unsorted array, find the length of the longest run of consecutive integers, in O(n).
int longestConsecutive(int[] nums) {
Set<Integer> set = new HashSet<>();
for (int x : nums) set.add(x);
int best = 0;
for (int x : set) {
if (set.contains(x - 1)) continue; // not the start of a run
int len = 1;
while (set.contains(x + len)) len++; // walk the run
best = Math.max(best, len);
}
return best;
}
// [100, 4, 200, 1, 3, 2] -> 4 (1, 2, 3, 4)
// [0, 3, 7, 2, 5, 8, 4, 6, 0, 1] -> 9
Time O(n): the inner while only runs from the start of each run, so every number is walked over at most once in total. Space O(n). Why it works: only counting from numbers that have no left neighbour means each run is measured exactly once, from its first element.
Problem: Subarray Sum Equals K (prefix sums preview)
Count the contiguous subarrays whose sum is exactly k. Numbers can be negative, so a sliding window does not work.
int subarraySum(int[] nums, int k) {
Map<Integer, Integer> prefixCount = new HashMap<>();
prefixCount.put(0, 1); // empty prefix
int sum = 0, count = 0;
for (int x : nums) {
sum += x;
count += prefixCount.getOrDefault(sum - k, 0);
prefixCount.merge(sum, 1, Integer::sum);
}
return count;
}
// [1, 1, 1], k = 2 -> 2
// [1, 2, 3], k = 3 -> 2 ([1,2] and [3])
// [3, 4, 7, 2, -3, 1, 4, 2], k = 7 -> 4
Time O(n). Space O(n). Why it works: the sum of the subarray from j+1 to i is prefix[i] - prefix[j]. That equals k exactly when an earlier prefix equals prefix[i] - k, so we just count how many earlier prefixes had that value. The put(0, 1) covers subarrays that start at index 0.
equals and hashCode agree — a record does that for me."8. Linked Lists
A linked list is a chain of small objects called nodes. Each node holds a value and a pointer (in Java: a reference) to the next node. The list only remembers where the first node is — the head. The last node points to null.
Unlike an array, the nodes can be anywhere in memory. That makes adding or removing a node cheap (just redirect a pointer), but finding the 50th node means walking through the first 49.
A treasure hunt. Each clue tells you where the next clue is hidden. Adding a new clue in the middle is easy — just change one clue to point at it. But to reach clue 50 you must follow all the clues before it.
How it sits in memory
Singly, doubly, circular
In interviews, "linked list" almost always means singly linked with this node class (the LeetCode one):
class ListNode {
int val;
ListNode next;
ListNode(int val) { this.val = val; }
ListNode(int val, ListNode next) { this.val = val; this.next = next; }
}
Insert and delete = rewiring pointers
To insert or delete, you need a reference to the node before the spot. Then it is two pointer changes, in the right order:
The dummy head trick: inserting or deleting at the head is a special case, because there is no "node before". Put a fake node (a dummy or sentinel) in front of the head. Now every real node has a node before it, the special case disappears, and at the end you return dummy.next. Use it any time the head might change.
Implement a linked list from scratch
class MyLinkedList {
private final ListNode dummy = new ListNode(0); // sentinel before the real head
private ListNode tail = dummy;
private int size = 0;
void addFirst(int val) { // O(1)
dummy.next = new ListNode(val, dummy.next);
if (tail == dummy) tail = dummy.next;
size++;
}
void addLast(int val) { // O(1) thanks to tail pointer
tail.next = new ListNode(val);
tail = tail.next;
size++;
}
void addAt(int index, int val) { // O(n): walk to index-1
if (index < 0 || index > size) throw new IndexOutOfBoundsException(index);
if (index == size) { addLast(val); return; }
ListNode prev = dummy;
for (int i = 0; i < index; i++) prev = prev.next;
prev.next = new ListNode(val, prev.next);
size++;
}
boolean remove(int val) { // O(n): find the node BEFORE the target
for (ListNode prev = dummy; prev.next != null; prev = prev.next) {
if (prev.next.val == val) {
if (prev.next == tail) tail = prev;
prev.next = prev.next.next; // unlink
size--;
return true;
}
}
return false;
}
int indexOf(int val) { // O(n)
int i = 0;
for (ListNode cur = dummy.next; cur != null; cur = cur.next, i++) {
if (cur.val == val) return i;
}
return -1;
}
}
// addLast(2), addLast(3), addFirst(1), addAt(3, 4), addAt(1, 9)
// -> 1 -> 9 -> 2 -> 3 -> 4 (size 5)
With a doubly linked list, if you already hold a node, removing it needs no search at all — this is how an LRU cache removes entries in O(1):
void unlink(DNode node) { // assumes dummy head and tail, so prev/next are never null
node.prev.next = node.next;
node.next.prev = node.prev;
}
Operations and their cost
| Operation | Singly | Doubly | Array (compare) |
|---|---|---|---|
| Access i-th element | O(n) | O(n) | O(1) |
| Search by value | O(n) | O(n) | O(n) |
| Insert / delete at head | O(1) | O(1) | O(n) |
| Insert at tail | O(1) with tail pointer | O(1) | O(1) amortised |
| Delete at tail | O(n) (need the node before) | O(1) | O(1) |
| Insert / delete after a node you hold | O(1) | O(1) | O(n) |
| Delete a node you hold | O(n) (must find prev) | O(1) | O(n) |
| Extra space | 1 reference per node | 2 references per node | none (maybe spare capacity) |
Java built-in: LinkedList (and why you rarely want it)
java.util.LinkedList is a doubly linked list that implements both List and Deque: addFirst, addLast, removeFirst, getFirst are all O(1). But its get(i) is O(n), and every element is a separate object, so it is slow and memory-hungry in practice. For a stack or queue, ArrayDeque is faster; for a list, ArrayList. In interviews, linked-list questions give you ListNode and expect you to move pointers yourself.
When to use a linked list
- You hold references to nodes and must remove/insert them in O(1): LRU cache (HashMap + doubly linked list), adjacency lists, free lists in allocators.
- You need to splice lists together or split them without copying.
- Otherwise, prefer an array-based structure — random access and cache locality usually win.
Common pitfalls
NullPointerException: checkcur != nullbeforecur.next, andfast != null && fast.next != nullbeforefast.next.next.- Losing the rest of the list: save
nextbefore you overwritecur.next. - Moving
headitself while walking, then returning it — use a separatecurvariable. - Forgetting the head can change — use a dummy node.
- Creating cycles by accident (for example forgetting to set the new tail's
nexttonull), which makes printing loop forever. - Draw it. Three boxes and arrows on paper catch most bugs before you write code.
Problem: Reverse Linked List (iterative and recursive)
// Iterative: O(n) time, O(1) space
ListNode reverse(ListNode head) {
ListNode prev = null, cur = head;
while (cur != null) {
ListNode next = cur.next; // 1. save the rest
cur.next = prev; // 2. flip the pointer
prev = cur; // 3. step prev forward
cur = next; // 4. step cur forward
}
return prev; // prev is the new head
}
// Recursive: O(n) time, O(n) space (call stack)
ListNode reverseRec(ListNode head) {
if (head == null || head.next == null) return head;
ListNode newHead = reverseRec(head.next); // reverse the rest
head.next.next = head; // node after me points back to me
head.next = null; // I become the tail
return newHead;
}
// 1 -> 2 -> 3 -> 4 -> 5 becomes 5 -> 4 -> 3 -> 2 -> 1 (both versions)
Why it works: iteratively, everything left of cur is already reversed and prev is its head, so each step grows the reversed part by one. Recursively, trust the call to reverse the rest; head.next is now the tail of that reversed part, so hang head after it. The recursive version can overflow the stack on very long lists (tens of thousands of nodes).
Problem: Merge Two Sorted Lists
ListNode mergeTwoLists(ListNode a, ListNode b) {
ListNode dummy = new ListNode(0), tail = dummy;
while (a != null && b != null) {
if (a.val <= b.val) { tail.next = a; a = a.next; }
else { tail.next = b; b = b.next; }
tail = tail.next;
}
tail.next = (a != null) ? a : b; // attach whatever is left
return dummy.next;
}
// 1->2->4 + 1->3->4 -> 1 -> 1 -> 2 -> 3 -> 4 -> 4
Time O(n + m). Space O(1) — it reuses the existing nodes. Why it works: the smallest remaining element is always at the front of one of the two lists, so taking the smaller front each time builds the merged list in order (the same step as merge sort).
Problem: Remove Nth Node From End
ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(0, head);
ListNode fast = dummy, slow = dummy;
for (int i = 0; i <= n; i++) fast = fast.next; // gap of n+1 nodes
while (fast != null) { fast = fast.next; slow = slow.next; }
slow.next = slow.next.next; // slow is just before the target
return dummy.next;
}
// 1->2->3->4->5, n = 2 -> 1 -> 2 -> 3 -> 5
// 1, n = 1 -> null (the dummy handles removing the head)
Time O(n), one pass. Space O(1). Why it works: if fast starts n+1 steps ahead and both move together, then when fast falls off the end, slow is exactly n+1 from the end — the node just before the one to delete.
Problem: Linked List Cycle (preview of fast and slow pointers)
boolean hasCycle(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) return true;
}
return false;
}
// 3 -> 2 -> 0 -> -4 -> (back to 2) -> true
// 1 -> 2 -> false
Time O(n). Space O(1) (a HashSet of visited nodes also works but costs O(n)). Why it works: inside a loop, the fast pointer gains one step on the slow one each move, so the gap shrinks by 1 every time and they must meet; with no loop, fast simply reaches null. The fast and slow pointers section covers finding where the cycle starts.
Problem: Add Two Numbers
Digits are stored in reverse order (ones digit first). Return their sum as a list: 342 + 465 = 807 is 2->4->3 + 5->6->4 = 7->0->8.
ListNode addTwoNumbers(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0), tail = dummy;
int carry = 0;
while (l1 != null || l2 != null || carry != 0) {
int sum = carry;
if (l1 != null) { sum += l1.val; l1 = l1.next; }
if (l2 != null) { sum += l2.val; l2 = l2.next; }
carry = sum / 10;
tail.next = new ListNode(sum % 10);
tail = tail.next;
}
return dummy.next;
}
// 2->4->3 + 5->6->4 -> 7 -> 0 -> 8
// 9->9->9 + 1 -> 0 -> 0 -> 0 -> 1
Time O(max(n, m)). Space O(max(n, m)) for the result list. Why it works: it is school addition, right to left with a carry — and the reversed storage means the list already starts at the rightmost digit. Keeping carry != 0 in the loop condition adds the final extra digit.
next before rewiring. Linked lists give O(1) insert/delete once I have the node, but O(n) access by index — so I only reach for one when I need that O(1) splice, like in an LRU cache."9. Stacks
A stack is a collection where you can only add or remove at one end, called the top. The last thing you put in is the first thing you take out: LIFO (Last In, First Out).
Three operations: push (put on top), pop (take off the top), peek (look at the top without removing). All O(1).
A stack of plates. You put clean plates on top and take plates from the top. The plate at the bottom was put there first and will be used last.
Two ways to build one
Array-backed: keep an array and an index top. Push writes at top and moves it up; pop moves it down. Grow the array when full (amortised O(1), like ArrayList). Fast and cache-friendly.
class ArrayStack {
private int[] data = new int[8];
private int top = 0; // index of the next free slot
void push(int x) { // amortised O(1)
if (top == data.length) data = Arrays.copyOf(data, top * 2);
data[top++] = x;
}
int pop() { // O(1)
if (top == 0) throw new RuntimeException("stack underflow");
return data[--top];
}
int peek() {
if (top == 0) throw new RuntimeException("stack is empty");
return data[top - 1];
}
boolean isEmpty() { return top == 0; }
}
Linked: the head of a singly linked list is the top. Push = add a new head, pop = move head forward. Always true O(1) (no resizing), but one object per element.
class LinkedStack {
private static class Node { int val; Node next; Node(int v, Node n) { val = v; next = n; } }
private Node head; // head IS the top
void push(int x) { head = new Node(x, head); } // O(1)
int pop() { // O(1)
if (head == null) throw new RuntimeException("stack underflow");
int v = head.val;
head = head.next;
return v;
}
int peek() {
if (head == null) throw new RuntimeException("stack is empty");
return head.val;
}
boolean isEmpty() { return head == null; }
}
Operations and their cost
| Operation | Array-backed | Linked |
|---|---|---|
push | O(1) amortised | O(1) |
pop / peek | O(1) | O(1) |
isEmpty / size | O(1) | O(1) |
| Search for a value | O(n) | O(n) |
| Space | O(n), compact | O(n), plus a node object each |
Java built-in: use ArrayDeque, not Stack
The old java.util.Stack class extends Vector: every method is synchronized (slow) and it lets you insert in the middle, which breaks the idea of a stack. The Java docs themselves recommend Deque:
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); stack.push(2); stack.push(3); // push adds at the FRONT of the deque
System.out.println(stack); // [3, 2, 1] (top first)
stack.peek(); // 3 (null if empty)
stack.pop(); // 3 (throws NoSuchElementException if empty)
stack.isEmpty(); // false
Note: ArrayDeque does not allow null elements. peek() returns null on an empty deque, while pop() throws.
The call stack: where you already use a stack
Every time a method is called, the JVM pushes a stack frame (its local variables and where to return to). When the method returns, its frame is popped. That is why recursion works — and why very deep recursion throws StackOverflowError.
This link matters: any recursive algorithm can be rewritten with your own explicit stack (for example iterative DFS), which avoids stack overflow on deep inputs.
When to use a stack
- Matching pairs / nesting: brackets, HTML tags, nested
k[...]encodings. - Undo and back-button history.
- Expression evaluation: postfix (RPN), infix with operator precedence.
- DFS without recursion.
- "Next greater / smaller element" problems — the monotonic stack (a stack kept sorted), covered in its own section.
Common pitfalls
- Popping an empty stack: always check
isEmpty()first — it is often part of the answer (a)with nothing to match means invalid). - Forgetting leftovers: after the loop, a non-empty stack usually means unmatched items.
- Operand order for
-and/: the first pop is the right operand. - Mixing deque ends: with
ArrayDeque, usepush/pop/peekconsistently. Mixing inoffer/poll(other end) turns it into something else. - Storing indices instead of values is often what the problem needs (for distances, like daily temperatures).
Problem: Valid Parentheses
boolean isValid(String s) {
Deque<Character> stack = new ArrayDeque<>();
for (char c : s.toCharArray()) {
if (c == '(') stack.push(')'); // push the EXPECTED closer
else if (c == '[') stack.push(']');
else if (c == '{') stack.push('}');
else if (stack.isEmpty() || stack.pop() != c) return false;
}
return stack.isEmpty(); // leftovers = unclosed openers
}
// "()[]{}" -> true "([)]" -> false "{[]}" -> true "((" -> false
Time O(n). Space O(n). Why it works: the most recently opened bracket must be the first one closed — exactly LIFO. Pushing the expected closer makes the check a single comparison.
Problem: Min Stack
Support push, pop, top and getMin, all in O(1).
class MinStack {
private final Deque<int[]> stack = new ArrayDeque<>(); // {value, minSoFar}
void push(int val) {
int min = stack.isEmpty() ? val : Math.min(val, stack.peek()[1]);
stack.push(new int[]{val, min});
}
void pop() { stack.pop(); }
int top() { return stack.peek()[0]; }
int getMin() { return stack.peek()[1]; }
}
// push(-2), push(0), push(-3): getMin() = -3
// pop(): top() = 0, getMin() = -2
Time O(1) for every operation. Space O(n). Why it works: a stack only ever removes from the top, so the minimum "below and including" each element never changes while that element is there — store it alongside.
Problem: Evaluate Reverse Polish Notation
In RPN (postfix), operators come after their operands: ["2","1","+","3","*"] means (2 + 1) × 3 = 9.
int evalRPN(String[] tokens) {
Deque<Integer> stack = new ArrayDeque<>();
for (String t : tokens) {
switch (t) {
case "+" -> stack.push(stack.pop() + stack.pop());
case "*" -> stack.push(stack.pop() * stack.pop());
case "-" -> { int b = stack.pop(), a = stack.pop(); stack.push(a - b); }
case "/" -> { int b = stack.pop(), a = stack.pop(); stack.push(a / b); }
default -> stack.push(Integer.parseInt(t));
}
}
return stack.pop();
}
// ["2","1","+","3","*"] -> 9
// ["4","13","5","/","+"] -> 6 (13 / 5 = 2, integer division toward zero)
// ["10","6","9","3","+","-11","*","/","*","17","+","5","+"] -> 22
Time O(n). Space O(n). Why it works: in postfix, an operator always applies to the two most recent unused values, which are exactly the top two of the stack. For - and / the first pop is the right-hand operand.
Problem: Daily Temperatures (monotonic stack preview)
For each day, how many days until a warmer one? 0 if never.
int[] dailyTemperatures(int[] temps) {
int n = temps.length;
int[] ans = new int[n];
Deque<Integer> stack = new ArrayDeque<>(); // indices, temps strictly decreasing
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && temps[i] > temps[stack.peek()]) {
int j = stack.pop(); // day j finally found a warmer day: i
ans[j] = i - j;
}
stack.push(i);
}
return ans; // days left on stack keep 0
}
// [73, 74, 75, 71, 69, 72, 76, 73] -> [1, 1, 4, 2, 1, 1, 0, 0]
Time O(n): each index is pushed once and popped at most once. Space O(n). Why it works: the stack holds days still waiting for a warmer day, coldest on top. A new warmer day answers every waiting day colder than it, and those are exactly the ones on top.
Problem: Decode String
"3[a2[c]]" decodes to "accaccacc".
String decodeString(String s) {
Deque<Integer> counts = new ArrayDeque<>();
Deque<StringBuilder> outer = new ArrayDeque<>();
StringBuilder cur = new StringBuilder();
int k = 0;
for (char c : s.toCharArray()) {
if (Character.isDigit(c)) {
k = k * 10 + (c - '0'); // numbers can have many digits
} else if (c == '[') {
counts.push(k); // save state, start fresh inside
outer.push(cur);
cur = new StringBuilder();
k = 0;
} else if (c == ']') {
int times = counts.pop();
StringBuilder prev = outer.pop();
prev.append(cur.toString().repeat(times));
cur = prev; // back to the outer level
} else {
cur.append(c);
}
}
return cur.toString();
}
// "3[a]2[bc]" -> "aaabcbc"
// "3[a2[c]]" -> "accaccacc"
// "2[abc]3[cd]ef" -> "abcabccdcdcdef"
// "10[x]" -> "xxxxxxxxxx"
Time O(output length), since every output character is built once per nesting level it sits in. Space O(output length). Why it works: [ means "pause what I was building and start a new inner piece", ] means "finish the inner piece and resume the outer one" — pausing and resuming in nested order is exactly what a stack does.
ArrayDeque, not the legacy Stack class. Push, pop and peek are all O(1)." If the problem is "next greater element", say "monotonic stack, each element pushed and popped once, so O(n) total".10. Queues & Deques
A queue adds items at the back and removes them from the front. The first item in is the first item out: FIFO (First In, First Out). The two main operations are enqueue (add at the back; Java calls it offer) and dequeue (remove from the front; Java calls it poll).
A deque (say "deck", short for double-ended queue) lets you add and remove at both ends. It can act as a stack, a queue, or both at once.
A queue is the line at a coffee shop: new people join at the back, the person at the front is served first. A deque is a line where staff may also serve or let people in at the back.
Implementing a queue: the circular array
With a plain array, removing from the front would mean shifting everything left (O(n)). A linked list with head and tail pointers gives O(1) at both ends, but the classic array trick is the circular buffer (ring buffer): keep a head index that moves forward on every dequeue, and let both ends wrap around to index 0 when they pass the end, using % capacity. Nothing ever shifts.
Tracking size separately avoids the classic ambiguity: if you only keep head and tail, then head == tail could mean empty or full.
This is also the answer to Design Circular Queue (LeetCode 622) — the method names below are the ones that problem uses:
class MyCircularQueue {
private final int[] data;
private int head = 0; // index of the front element
private int size = 0; // number of elements stored
MyCircularQueue(int k) { data = new int[k]; }
boolean enQueue(int value) { // O(1)
if (isFull()) return false;
int tail = (head + size) % data.length; // first free slot, wrapping around
data[tail] = value;
size++;
return true;
}
boolean deQueue() { // O(1)
if (isEmpty()) return false;
head = (head + 1) % data.length; // front moves forward, wraps
size--;
return true;
}
int Front() { return isEmpty() ? -1 : data[head]; }
int Rear() { return isEmpty() ? -1 : data[(head + size - 1) % data.length]; }
boolean isEmpty() { return size == 0; }
boolean isFull() { return size == data.length; }
}
// capacity 3: enQueue 1, 2, 3 -> true true true; enQueue(4) -> false (full)
// Rear() = 3; deQueue() -> true; enQueue(4) -> true (wrapped into slot 0)
// Rear() = 4, Front() = 2
Time O(1) for every operation. Space O(k). Why it works: % capacity turns the straight array into a loop, so freed slots at the start are reused instead of shifting elements.
Operations and their cost
| Operation | Circular array / ArrayDeque | Linked list | Naive array (shift on dequeue) |
|---|---|---|---|
| Enqueue at back | O(1) amortised | O(1) | O(1) amortised |
| Dequeue from front | O(1) | O(1) | O(n) |
| Peek front | O(1) | O(1) | O(1) |
| Add / remove at front (deque) | O(1) | O(1) if doubly linked | O(n) |
| Search | O(n) | O(n) | O(n) |
| Space | O(n) | O(n) + node objects | O(n) |
Java built-ins: Queue, Deque, ArrayDeque
Queue and Deque are interfaces. ArrayDeque (a growable circular array) is the default implementation for both — faster than LinkedList. Each operation comes in two flavours: one that throws on failure and one that returns a special value (null or false). Interview code normally uses the second.
| Intent | Throws if empty/full | Returns null/false |
|---|---|---|
| Add at back | add(x) / addLast | offer(x) / offerLast |
| Remove front | remove() / removeFirst | poll() / pollFirst |
| Look at front | element() / getFirst | peek() / peekFirst |
| Add at front (deque) | addFirst / push | offerFirst |
| Remove / look at back (deque) | removeLast / getLast | pollLast / peekLast |
Queue<Integer> q = new ArrayDeque<>();
q.offer(1); q.offer(2); q.offer(3);
q.peek(); // 1
q.poll(); // 1, queue is now [2, 3]
new ArrayDeque<Integer>().poll(); // null (remove() would throw)
Deque<Integer> d = new ArrayDeque<>();
d.offerFirst(2); d.offerLast(3); d.offerFirst(1); // [1, 2, 3]
d.pollLast(); // 3
d.peekFirst(); // 1
Other queues you should know exist: PriorityQueue (next section — not FIFO, smallest first), LinkedList (allows null, slower), and BlockingQueue implementations such as ArrayBlockingQueue for producer-consumer threads.
When to use a queue or deque
- Queue: process things in arrival order — BFS, task scheduling, buffering between a producer and a consumer, rate limiting (a queue of timestamps).
- Deque: when you need both ends — sliding window max/min (monotonic deque), palindrome checks, 0-1 BFS, "undo with a size limit" (drop from the old end).
- If you only need one end, it is really a stack — still use
ArrayDeque.
Common pitfalls
- Mixing ends:
pushadds at the front,offerat the back. Usingpush+pollgives a stack, not a queue. ArrayDequerejectsnull—offer(null)throwsNullPointerException.- Unchecked
poll()returnsnull; unboxing it tointthrows. - Using
q.size()as a loop bound while adding: in level-order BFS, save the size in a variable before the inner loop. - In your own ring buffer, forgetting the
%on one of the index updates.
Problem: Implement Queue using Stacks
class MyQueue {
private final Deque<Integer> in = new ArrayDeque<>(); // new items land here
private final Deque<Integer> out = new ArrayDeque<>(); // items leave from here
void push(int x) { in.push(x); } // O(1)
int pop() { shift(); return out.pop(); } // amortised O(1)
int peek() { shift(); return out.peek(); }
boolean empty() { return in.isEmpty() && out.isEmpty(); }
private void shift() {
if (out.isEmpty()) { // only refill when empty
while (!in.isEmpty()) out.push(in.pop()); // reverses the order
}
}
}
// push 1, push 2: peek() = 1, pop() = 1; push 3: pop() = 2, pop() = 3, empty() = true
Time push O(1); pop/peek amortised O(1) — each element moves from in to out exactly once. Space O(n). Why it works: pouring one stack into another reverses the order, turning last-in-first-out into first-in-first-out; only pouring when out is empty keeps older items ahead of newer ones.
Problem: Sliding Window Maximum (monotonic deque)
For every window of size k, report the maximum. [1,3,-1,-3,5,3,6,7], k = 3 gives [3,3,5,5,6,7].
int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
int[] ans = new int[n - k + 1];
Deque<Integer> dq = new ArrayDeque<>(); // indices; their values are decreasing
for (int i = 0; i < n; i++) {
if (!dq.isEmpty() && dq.peekFirst() <= i - k) dq.pollFirst(); // fell out of window
while (!dq.isEmpty() && nums[dq.peekLast()] <= nums[i]) dq.pollLast(); // useless now
dq.offerLast(i);
if (i >= k - 1) ans[i - k + 1] = nums[dq.peekFirst()]; // front = max
}
return ans;
}
// [1, 3, -1, -3, 5, 3, 6, 7], k = 3 -> [3, 3, 5, 5, 6, 7]
// [9, 8, 7, 6], k = 2 -> [9, 8, 7]
Time O(n): each index enters and leaves the deque at most once. Space O(k). Why it works: a number that is smaller than a newer number can never be a window maximum again (the newer one outlives it), so it is thrown away. What remains is decreasing, so the front is always the max.
Problem: BFS preview — shortest path in a grid
Minimum steps from the top-left to the bottom-right of a grid (0 = open, 1 = wall), moving up/down/left/right.
int shortestPath(int[][] grid) {
int rows = grid.length, cols = grid[0].length;
if (grid[0][0] == 1) return -1;
boolean[][] seen = new boolean[rows][cols];
Queue<int[]> q = new ArrayDeque<>();
q.offer(new int[]{0, 0});
seen[0][0] = true;
int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (int steps = 0; !q.isEmpty(); steps++) {
for (int size = q.size(); size > 0; size--) { // one "ring" (level) at a time
int[] cell = q.poll();
if (cell[0] == rows - 1 && cell[1] == cols - 1) return steps;
for (int[] d : dirs) {
int r = cell[0] + d[0], c = cell[1] + d[1];
if (r >= 0 && r < rows && c >= 0 && c < cols && grid[r][c] == 0 && !seen[r][c]) {
seen[r][c] = true; // mark when ENQUEUED
q.offer(new int[]{r, c});
}
}
}
}
return -1;
}
// {{0,0,0},{1,1,0},{0,0,0}} -> 4
// {{0,1},{1,0}} -> -1
Time O(rows × cols). Space O(rows × cols). Why it works: the FIFO queue processes all cells 1 step away, then all cells 2 steps away, and so on, like ripples in water — so the first time we reach the target is by a shortest path. The graph sections cover BFS fully.
ArrayDeque as the queue — offer to add, poll to remove, both O(1). For a fixed-capacity queue I'd use a circular array with a head index and a size, wrapping with modulo." For sliding-window max/min: "a monotonic deque, O(n) because each index is added and removed once."11. Heaps & Priority Queues
A priority queue is a queue where the item that comes out next is not the oldest, but the one with the highest priority — usually the smallest (or largest) value. A heap is the data structure that makes this fast: getting the smallest item is O(1), and adding or removing one is O(log n).
A min-heap is a tree where every parent is smaller than or equal to its children, so the smallest value is always at the top (the root). A max-heap is the same with "larger". The heap is only partly sorted: it does not care how siblings compare, which is why it can be updated so cheaply.
A hospital emergency room. Patients are not seen in arrival order; the most urgent one goes next. The triage nurse doesn't fully sort the waiting room — they only need to always know who is most urgent right now.
A complete binary tree stored in an array
A heap is a complete binary tree: every level is full except possibly the last, which fills from left to right. Because there are no gaps, the tree fits perfectly in an array, level by level, with no pointers at all. Parent and children are found with index maths:
A complete tree with n nodes has height about log₂ n. Every heap operation walks at most one path from root to leaf, which is where O(log n) comes from.
Sift up (insert) and sift down (remove the top)
- Insert (offer): put the new value at the end of the array (the next free spot in the bottom level), then sift up: while it is smaller than its parent, swap with the parent.
- Remove the min (poll): the answer is
heap[0]. Move the last element into the root to keep the tree complete, then sift down: while it is bigger than its smaller child, swap with that child.
Implement a min-heap from scratch
class MinHeap {
private int[] heap;
private int size = 0;
MinHeap(int capacity) { heap = new int[capacity]; }
// Build from an existing array in O(n) (heapify)
MinHeap(int[] values) {
heap = Arrays.copyOf(values, Math.max(1, values.length));
size = values.length;
for (int i = size / 2 - 1; i >= 0; i--) siftDown(i); // last parent down to root
}
void offer(int x) { // O(log n)
if (size == heap.length) heap = Arrays.copyOf(heap, size * 2);
heap[size] = x; // put at the end ...
siftUp(size++); // ... and bubble up
}
int peek() { // O(1)
if (size == 0) throw new NoSuchElementException();
return heap[0];
}
int poll() { // O(log n)
if (size == 0) throw new NoSuchElementException();
int min = heap[0];
heap[0] = heap[--size]; // move last element to root ...
siftDown(0); // ... and sink it
return min;
}
private void siftUp(int i) {
while (i > 0) {
int parent = (i - 1) / 2;
if (heap[parent] <= heap[i]) break; // order is fine
swap(i, parent);
i = parent;
}
}
private void siftDown(int i) {
while (true) {
int left = 2 * i + 1, right = 2 * i + 2, smallest = i;
if (left < size && heap[left] < heap[smallest]) smallest = left;
if (right < size && heap[right] < heap[smallest]) smallest = right;
if (smallest == i) break; // both children bigger: done
swap(i, smallest);
i = smallest;
}
}
private void swap(int a, int b) { int t = heap[a]; heap[a] = heap[b]; heap[b] = t; }
}
// offer 5, 3, 8, 1, 9, 2 -> array [1, 3, 2, 5, 9, 8]; polling gives 1 2 3 5 8 9
// new MinHeap(new int[]{9, 4, 7, 1, 8, 2, 3}) -> array [1, 4, 2, 9, 8, 7, 3]
Heapify in O(n), not O(n log n)
Building a heap by calling offer n times costs O(n log n). Heapify is faster: treat the array as a tree already, and call siftDown on every parent from the last one (n/2 - 1) back to the root. Leaves (half of all nodes) need no work.
Why O(n)? A node can only sink as far as the levels below it. Half the nodes are leaves (sink 0), a quarter sit one level up (sink at most 1), an eighth at most 2, and so on: n/4·1 + n/8·2 + n/16·3 + … which adds up to less than n. Most nodes are near the bottom, where sifting down is cheap.
Operations and their cost
| Operation | Binary heap | Why |
|---|---|---|
peek (min/max) | O(1) | It is heap[0] |
offer (insert) | O(log n) | Sift up one path |
poll (remove top) | O(log n) | Sift down one path |
| Build from n items (heapify) | O(n) | Sum argument above |
remove(x) / contains(x) (arbitrary) | O(n) | Must search; heap is not sorted |
| Heap sort | O(n log n) time, O(1) space | n polls |
| Space | O(n) | Just the array |
Java built-in: PriorityQueue
PriorityQueue is a binary min-heap by default. For a max-heap or custom ordering, pass a Comparator (a rule that says which of two items comes first).
PriorityQueue<Integer> minPq = new PriorityQueue<>(List.of(5, 1, 9));
PriorityQueue<Integer> maxPq = new PriorityQueue<>(Collections.reverseOrder());
maxPq.addAll(List.of(5, 1, 9));
System.out.println(minPq.peek() + " " + maxPq.peek()); // 1 9
// Objects: order by priority, then by name to break ties
record Task(String name, int priority) {}
PriorityQueue<Task> tasks = new PriorityQueue<>(
Comparator.comparingInt(Task::priority).thenComparing(Task::name));
tasks.offer(new Task("write", 2));
tasks.offer(new Task("deploy", 1));
tasks.offer(new Task("alert", 1));
// polling gives: alert, deploy, write
// int[] pairs: sort by the second element
PriorityQueue<int[]> pairs = new PriorityQueue<>((a, b) -> Integer.compare(a[1], b[1]));
| Method | Cost | Note |
|---|---|---|
offer / add | O(log n) | |
poll | O(log n) | Returns null if empty |
peek | O(1) | Returns null if empty |
remove(Object), contains | O(n) | Linear search |
new PriorityQueue<>(collection) | O(n) | Uses heapify |
size | O(1) |
Heap sort
Build a max-heap in place (O(n)), then repeatedly swap the root (the largest) to the end of the array and sift the new root down within the shrinking heap. The sorted part grows from the back.
void heapSort(int[] a) {
int n = a.length;
for (int i = n / 2 - 1; i >= 0; i--) sink(a, i, n); // build max-heap: O(n)
for (int end = n - 1; end > 0; end--) {
int t = a[0]; a[0] = a[end]; a[end] = t; // largest goes to the back
sink(a, 0, end); // fix the shrunk heap
}
}
void sink(int[] a, int i, int n) {
while (2 * i + 1 < n) {
int child = 2 * i + 1;
if (child + 1 < n && a[child + 1] > a[child]) child++; // bigger child
if (a[i] >= a[child]) break;
int t = a[i]; a[i] = a[child]; a[child] = t;
i = child;
}
}
// [5, 1, 9, 3, 7, 2] -> [1, 2, 3, 5, 7, 9]
Time O(n log n) always (no bad worst case like quicksort). Space O(1). Not stable (equal items may swap order), and slower than quicksort in practice because it jumps around the array.
When to use a heap
- You repeatedly need the current smallest/largest while items keep arriving: scheduling, Dijkstra's shortest path, event simulation.
- Top K / Kth largest: keep a heap of size k — O(n log k) instead of sorting everything in O(n log n).
- Merging K sorted things: a heap of the K current fronts.
- Running median: two heaps.
- Not for searching a specific value (O(n)), and not when you need everything in sorted order at once (just sort) or need ordered iteration (use
TreeMap/TreeSet).
Common pitfalls
- Iterating or printing a PriorityQueue is not sorted. Offering 5, 1, 4, 2, 3 and printing gives
[1, 2, 4, 5, 3]— the raw heap array. Only repeatedpoll()gives sorted order (1 2 3 4 5). - Overflow in comparators:
(a, b) -> a - bbreaks for large values —Integer.MIN_VALUE - 1overflows to 2147483647, a positive number, so the order is wrong. UseInteger.compare(a, b)orComparator.comparingInt. - Changing an object's priority while it is inside the heap does not re-sort it. Remove it, change it, re-insert — or insert a new entry and skip stale ones when polled ("lazy deletion", common in Dijkstra).
- Min vs max confusion for "k largest": you keep a min-heap of size k, so the smallest of the top k sits on top and is easy to evict.
PriorityQueueis not thread-safe; usePriorityBlockingQueueacross threads.
Problem: Kth Largest Element in an Array
int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> minHeap = new PriorityQueue<>(); // holds the k largest seen
for (int x : nums) {
minHeap.offer(x);
if (minHeap.size() > k) minHeap.poll(); // drop the smallest
}
return minHeap.peek(); // smallest of the top k
}
// [3, 2, 1, 5, 6, 4], k = 2 -> 5
// [3, 2, 3, 1, 2, 4, 5, 5, 6], k = 4 -> 4
Time O(n log k). Space O(k). Why it works: the heap always holds the k largest values seen so far; the smallest of those is, by definition, the kth largest. (Quickselect gets O(n) on average — mention it as a follow-up.)
Problem: Merge K Sorted Lists
ListNode mergeKLists(ListNode[] lists) {
PriorityQueue<ListNode> pq = new PriorityQueue<>((a, b) -> Integer.compare(a.val, b.val));
for (ListNode head : lists) if (head != null) pq.offer(head);
ListNode dummy = new ListNode(0), tail = dummy;
while (!pq.isEmpty()) {
ListNode node = pq.poll(); // smallest head among all lists
tail.next = node;
tail = node;
if (node.next != null) pq.offer(node.next);
}
return dummy.next;
}
// [1->4->5, 1->3->4, 2->6] -> 1 1 2 3 4 4 5 6
Time O(N log k), where N is the total number of nodes and k the number of lists. Space O(k) for the heap. Why it works: the next node of the merged list must be the smallest of the k current fronts; the heap gives that in O(log k), and when a front is used its successor takes its place.
Problem: Top K Frequent Elements
int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> freq = new HashMap<>();
for (int x : nums) freq.merge(x, 1, Integer::sum);
// min-heap by frequency, size k
PriorityQueue<Map.Entry<Integer, Integer>> pq =
new PriorityQueue<>(Map.Entry.comparingByValue());
for (Map.Entry<Integer, Integer> e : freq.entrySet()) {
pq.offer(e);
if (pq.size() > k) pq.poll();
}
int[] ans = new int[k];
for (int i = k - 1; i >= 0; i--) ans[i] = pq.poll().getKey(); // most frequent first
return ans;
}
// [1, 1, 1, 2, 2, 3], k = 2 -> [1, 2]
Time O(n log k): counting is O(n), then each of up to n distinct values costs O(log k). Space O(n) for the counts. Why it works: the same "keep the k best in a min-heap" idea as kth largest, just ranked by count instead of value. (Bucket sort by frequency gets O(n).)
Problem: Find Median from Data Stream (two heaps preview)
class MedianFinder {
private final PriorityQueue<Integer> low = new PriorityQueue<>(Collections.reverseOrder()); // max-heap
private final PriorityQueue<Integer> high = new PriorityQueue<>(); // min-heap
void addNum(int num) { // O(log n)
low.offer(num);
high.offer(low.poll()); // largest of low moves to high
if (high.size() > low.size()) low.offer(high.poll()); // keep low >= high in size
}
double findMedian() { // O(1)
if (low.size() > high.size()) return low.peek();
return (low.peek() + high.peek()) / 2.0;
}
}
// addNum(1), addNum(2): findMedian() = 1.5
// addNum(3): findMedian() = 2.0
Time addNum O(log n), findMedian O(1). Space O(n). Why it works: every number in low is ≤ every number in high, and the sizes differ by at most one, so the middle value(s) are exactly the two tops. The two-heaps section goes further.
PriorityQueue, which is a min-heap; for a max-heap I pass Collections.reverseOrder(). For top-k I keep a min-heap of size k, which gives O(n log k)."12. Binary Trees
A tree is a set of nodes joined by links, where every node except the top one has exactly one parent, and there are no loops. A binary tree is a tree where each node has at most two children, called left and right.
Almost every tree problem is solved the same way: do something at this node, and ask the left and right subtrees to solve the same smaller problem (recursion).
A family tree drawn upside down. The great-grandparent sits at the top, each person points to their (at most two) children, and nobody is their own ancestor.
Terminology
| Term | Meaning |
|---|---|
| Root | The top node; it has no parent. |
| Leaf | A node with no children. |
| Internal node | Any node that is not a leaf. |
| Subtree | A node plus everything below it. Every node is the root of its own subtree. |
| Depth of a node | Number of edges from the root down to it. Root has depth 0. |
| Height of a node | Number of edges on the longest path from it down to a leaf. A leaf has height 0. The tree's height is the root's height. (LeetCode's "max depth" counts nodes instead, so it is height + 1.) |
| Full | Every node has 0 or 2 children — never exactly 1. |
| Complete | Every level is full except maybe the last, and the last is filled from the left. Heaps are complete trees, which is why they fit in an array. |
| Perfect | All internal nodes have 2 children and all leaves are on the same level. Has exactly 2h+1 − 1 nodes. |
| Balanced | For every node, the heights of the left and right subtrees differ by at most 1. Height stays O(log n). |
| Degenerate (skewed) | Every node has one child — really a linked list. Height is n − 1. |
The node class
class TreeNode {
int val;
TreeNode left, right;
TreeNode(int val) { this.val = val; }
TreeNode(int val, TreeNode left, TreeNode right) {
this.val = val; this.left = left; this.right = right;
}
}
In memory each node is a separate object on the heap holding a value and two references. There is no array and no index — you can only reach a node by walking down from the root. That is why "find a value" in a plain binary tree is O(n): you may have to visit every node.
Traversals — the four ways to visit every node
The name tells you when the root is visited compared to its children:
- Preorder (root, left, right) — visit the node before its children. Use it to copy or serialize a tree: you meet the parent before you need to build its children.
- Inorder (left, root, right) — on a Binary Search Tree this gives values in sorted order.
- Postorder (left, right, root) — children finish before the parent. Use it when a node needs answers from its children (height, diameter, deleting a tree).
- Level order (BFS) — row by row, top to bottom. Use it for "per level" questions and shortest distance from the root.
void preorder(TreeNode node, List<Integer> out) {
if (node == null) return;
out.add(node.val); // root
preorder(node.left, out); // left
preorder(node.right, out); // right
}
void inorder(TreeNode node, List<Integer> out) {
if (node == null) return;
inorder(node.left, out); // left
out.add(node.val); // root
inorder(node.right, out); // right
}
void postorder(TreeNode node, List<Integer> out) {
if (node == null) return;
postorder(node.left, out); // left
postorder(node.right, out); // right
out.add(node.val); // root
}
All three are O(n) time (each node visited once) and O(h) extra space for the call stack, where h is the height: O(log n) for a balanced tree, O(n) for a skewed one. A skewed tree with 100,000 nodes can throw StackOverflowError in Java, which is the main reason interviewers ask for iterative versions.
Iterative traversals with an explicit stack
Recursion uses the call stack for you. Iterative code just makes that stack visible, using ArrayDeque (never the old Stack class, which is synchronized and slow).
// Preorder: pop, visit, push right then left (so left is processed first)
List<Integer> preorderIter(TreeNode root) {
List<Integer> out = new ArrayList<>();
if (root == null) return out;
Deque<TreeNode> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
out.add(node.val);
if (node.right != null) stack.push(node.right); // push right first
if (node.left != null) stack.push(node.left); // so left pops first
}
return out;
}
// Inorder: slide left pushing nodes, pop one, visit, then go right
List<Integer> inorderIter(TreeNode root) {
List<Integer> out = new ArrayList<>();
Deque<TreeNode> stack = new ArrayDeque<>();
TreeNode cur = root;
while (cur != null || !stack.isEmpty()) {
while (cur != null) { // go as far left as possible
stack.push(cur);
cur = cur.left;
}
cur = stack.pop(); // leftmost unvisited node
out.add(cur.val);
cur = cur.right; // then its right subtree
}
return out;
}
// Postorder: do "root, right, left" and reverse it -> "left, right, root"
List<Integer> postorderIter(TreeNode root) {
LinkedList<Integer> out = new LinkedList<>();
if (root == null) return out;
Deque<TreeNode> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
out.addFirst(node.val); // prepend = reverse for free
if (node.left != null) stack.push(node.left);
if (node.right != null) stack.push(node.right);
}
return out;
}
Level order with a queue
List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> levels = new ArrayList<>();
if (root == null) return levels;
Queue<TreeNode> queue = new ArrayDeque<>();
queue.offer(root);
while (!queue.isEmpty()) {
int size = queue.size(); // nodes on this level
List<Integer> level = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
level.add(node.val);
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
levels.add(level);
}
return levels; // sample tree: [[1], [2, 3], [4, 5, 6]]
}
The trick is int size = queue.size() before the inner loop: it freezes how many nodes belong to the current level, even while you add the next level's nodes to the back. Time O(n), space O(w) where w is the widest level (up to about n/2 for the last level of a perfect tree).
Morris traversal (O(1) extra space)
Morris inorder avoids both recursion and a stack. For each node with a left subtree, it finds the inorder predecessor (the rightmost node of the left subtree) and temporarily points that node's right back up to the current node — a "thread". Following the thread later is how it climbs back up without a stack. On the second visit it removes the thread, so the tree is restored.
List<Integer> morrisInorder(TreeNode root) {
List<Integer> out = new ArrayList<>();
TreeNode cur = root;
while (cur != null) {
if (cur.left == null) {
out.add(cur.val);
cur = cur.right;
} else {
TreeNode pred = cur.left; // find inorder predecessor
while (pred.right != null && pred.right != cur) pred = pred.right;
if (pred.right == null) { // first visit: build thread
pred.right = cur;
cur = cur.left;
} else { // second visit: remove thread
pred.right = null;
out.add(cur.val);
cur = cur.right;
}
}
}
return out;
}
Time O(n) (each edge is walked at most a few times), space O(1). It is rarely required, but mentioning it when asked "can you do it without extra space?" scores points. Downside: it modifies the tree while running, so it is not safe with concurrent readers.
Operations complexity (plain binary tree, n nodes, height h)
| Operation | Time | Extra space | Why |
|---|---|---|---|
| Search for a value | O(n) | O(h) | No ordering, so you may check every node |
| Insert (at a known spot) | O(1) once there | O(1) | Just set a left/right reference |
| Pre / in / post-order | O(n) | O(h) | Stack holds one root-to-node path |
| Level order | O(n) | O(w) | Queue holds up to one full level |
| Morris inorder | O(n) | O(1) | Uses temporary threads instead of a stack |
| Height / size / count leaves | O(n) | O(h) | Postorder: combine children's answers |
Java built-ins: Java has no general binary tree class. TreeMap/TreeSet are balanced binary search trees (section 14) and PriorityQueue is a binary heap in an array. In interviews you write TreeNode yourself.
Problem 1 — Maximum depth
// Number of nodes on the longest root-to-leaf path
int maxDepth(TreeNode root) {
if (root == null) return 0;
return 1 + Math.max(maxDepth(root.left), maxDepth(root.right));
} // sample tree: 3
Why it works: the depth of a tree is one (for this node) plus the deeper of its two subtrees. The empty tree has depth 0, which stops the recursion. O(n) time, O(h) space.
Problem 2 — Invert a binary tree
TreeNode invertTree(TreeNode root) {
if (root == null) return null;
TreeNode tmp = root.left;
root.left = invertTree(root.right);
root.right = invertTree(tmp);
return root;
} // sample tree level order becomes [[1], [3, 2], [6, 5, 4]]
Why it works: a mirror image is "swap my two children, and mirror each of them". Save left in tmp first, or you would overwrite it before using it. O(n) time, O(h) space.
Problem 3 — Diameter of a binary tree
The diameter is the number of edges on the longest path between any two nodes. That path may not go through the root.
class Solution {
private int best = 0;
public int diameterOfBinaryTree(TreeNode root) {
height(root);
return best;
}
// returns height in nodes; updates best with the longest path through node
private int height(TreeNode node) {
if (node == null) return 0;
int l = height(node.left);
int r = height(node.right);
best = Math.max(best, l + r); // path bends at this node
return 1 + Math.max(l, r);
}
} // sample tree: 4 (path 4-2-1-3-6)
Why it works: every path has one highest node where it "bends". The longest path bending at a node is (height of left) + (height of right) edges. Postorder computes both heights once, so one pass checks every bend point. O(n) time instead of the naive O(n2) that recomputes heights.
Problem 4 — Lowest common ancestor (LCA)
The LCA of p and q is the deepest node that has both p and q in its subtree (a node counts as its own descendant).
TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
if (root == null || root == p || root == q) return root;
TreeNode left = lowestCommonAncestor(root.left, p, q);
TreeNode right = lowestCommonAncestor(root.right, p, q);
if (left != null && right != null) return root; // p and q split here
return left != null ? left : right; // both on one side
} // sample: lca(4,5)=2, lca(4,6)=1, lca(2,4)=2
Why it works: each call reports "I found p or q (or their LCA) in my subtree" by returning a non-null node. The first node where both sides report something is where the paths to p and q split — the LCA. If a node is p itself, returning it right away is correct even when q is below it, because then p is the LCA. O(n) time, O(h) space.
Problem 5 — Serialize and deserialize
public class Codec {
public String serialize(TreeNode root) {
StringBuilder sb = new StringBuilder();
write(root, sb);
return sb.toString();
}
private void write(TreeNode node, StringBuilder sb) {
if (node == null) { sb.append("#,"); return; } // mark nulls
sb.append(node.val).append(',');
write(node.left, sb);
write(node.right, sb);
}
public TreeNode deserialize(String data) {
Deque<String> tokens = new ArrayDeque<>(Arrays.asList(data.split(",")));
return read(tokens);
}
private TreeNode read(Deque<String> tokens) {
String t = tokens.poll();
if (t.equals("#")) return null;
TreeNode node = new TreeNode(Integer.parseInt(t));
node.left = read(tokens); // same order as write: root, left, right
node.right = read(tokens);
return node;
}
} // sample tree -> "1,2,4,#,#,5,#,#,3,#,6,#,#,"
Why it works: preorder alone cannot rebuild a tree (many shapes share the same order), but preorder with null markers can: the markers tell the reader exactly where each subtree ends. Reading consumes tokens in the same root-left-right order they were written. O(n) time and space for both directions.
Pitfalls
- Forgetting the
nullbase case — nearly every tree bug is aNullPointerException. - Mixing up depth (counted from the top) and height (counted from the bottom), or edges vs nodes. Say which one you mean.
- Recomputing heights inside a recursion (O(n2)). Return the height from the same pass instead.
- Deep recursion on skewed input — offer the iterative version.
- Using a static field for the answer and forgetting to reset it between test cases. Prefer an instance field or an
int[]holder.
13. Binary Search Trees
A Binary Search Tree (BST) is a binary tree with one rule at every node: everything in the left subtree is smaller, everything in the right subtree is larger. Because of that rule, you can find a value by going left or right at each step and throwing away half of what remains — like binary search, but on linked nodes.
The rule is about whole subtrees, not just direct children. That detail is the source of the most common BST interview bug.
Playing "guess my number, higher or lower". Each answer rules out a whole range, so you never check the numbers on the wrong side.
Search and insert
TreeNode search(TreeNode root, int key) {
TreeNode cur = root;
while (cur != null && cur.val != key) {
cur = key < cur.val ? cur.left : cur.right; // discard half
}
return cur; // null if not found
}
TreeNode insert(TreeNode root, int key) {
if (root == null) return new TreeNode(key); // empty spot found
if (key < root.val) root.left = insert(root.left, key);
else if (key > root.val) root.right = insert(root.right, key);
return root; // duplicates ignored
}
Insert walks down exactly like search. When it falls off the tree (reaches null), that empty spot is where the new key belongs. The pattern root.left = insert(root.left, key) — "rebuild the link on the way back up" — is worth memorising: delete uses it too. Both are O(h) time.
Delete — the three cases
- Leaf (no children): just remove it — return
nullto the parent. - One child: the child takes the node's place — return the child to the parent.
- Two children: find the inorder successor (the smallest value in the right subtree — go right once, then left as far as possible). Copy its value into this node, then delete the successor from the right subtree. The successor has no left child, so that second delete is always case 1 or 2.
TreeNode delete(TreeNode root, int key) {
if (root == null) return null;
if (key < root.val) {
root.left = delete(root.left, key);
} else if (key > root.val) {
root.right = delete(root.right, key);
} else {
// Case 1 and 2: zero or one child -> replace node by the other side
if (root.left == null) return root.right;
if (root.right == null) return root.left;
// Case 3: two children -> copy inorder successor, delete it from right
TreeNode succ = root.right;
while (succ.left != null) succ = succ.left; // smallest in right subtree
root.val = succ.val;
root.right = delete(root.right, succ.val);
}
return root;
}
Notice the leaf case needs no special code: if left is null we return right, which is also null for a leaf. Time O(h). (Using the inorder predecessor — largest value on the left — works equally well.)
Inorder = sorted
Inorder visits left subtree, node, right subtree. In a BST that is "everything smaller, me, everything larger" at every level, so the output is sorted. This one fact solves a family of problems: validate a BST (the inorder must be strictly increasing), kth smallest (stop at the kth visit), two-sum in a BST, recover a BST with two swapped nodes (find the two places where the order breaks).
Operations complexity
| Operation | Balanced (h = log n) | Degenerate (h = n) | Space |
|---|---|---|---|
| Search | O(log n) | O(n) | O(1) iterative |
| Insert | O(log n) | O(n) | O(h) recursive |
| Delete | O(log n) | O(n) | O(h) recursive |
| Min / max (leftmost / rightmost) | O(log n) | O(n) | O(1) |
| Successor / predecessor | O(log n) | O(n) | O(1) |
| Sorted listing (inorder) | O(n) | O(n) | O(h) |
Degenerate BST — why balancing matters
A plain BST has no control over its shape: the shape depends on insert order. Insert 1, 2, 3, 4, 5, 6, 7 in order and every node goes to the right of the last one:
Now search is O(n), no better than a list. Real data is often sorted or nearly sorted (timestamps, auto-increment IDs), so this is not a rare edge case. Self-balancing trees (AVL, red-black — next section) fix the shape after every insert and delete, guaranteeing O(log n). That is what Java's TreeMap uses.
Problem 1 — Validate a BST (min/max bounds)
boolean isValidBST(TreeNode root) {
return valid(root, Long.MIN_VALUE, Long.MAX_VALUE);
}
// every value in this subtree must be strictly between low and high
boolean valid(TreeNode node, long low, long high) {
if (node == null) return true;
if (node.val <= low || node.val >= high) return false;
return valid(node.left, low, node.val) // left: tighten upper bound
&& valid(node.right, node.val, high); // right: tighten lower bound
}
Why it works: checking only left.val < node.val < right.val is the classic wrong answer. The tree 5 → right 6 → left 4 passes that local check, but 4 sits in the right subtree of 5, so it is invalid (our code returns false). Passing down the allowed range catches it: when you go left, the current value becomes the new upper limit; going right, it becomes the new lower limit. long bounds make a node holding Integer.MAX_VALUE work. O(n) time, O(h) space.
Problem 2 — Kth smallest element
int kthSmallest(TreeNode root, int k) {
Deque<TreeNode> stack = new ArrayDeque<>();
TreeNode cur = root;
while (cur != null || !stack.isEmpty()) {
while (cur != null) { stack.push(cur); cur = cur.left; }
cur = stack.pop();
if (--k == 0) return cur.val; // k-th node in sorted order
cur = cur.right;
}
throw new IllegalArgumentException("k is larger than the tree");
} // sample BST, k = 3 -> 4
Why it works: iterative inorder produces values in sorted order, so the kth pop is the kth smallest. It stops early: O(h + k) time, O(h) space. Follow-up "the tree changes often and kth is asked often": store a subtree size in each node, then pick left or right by comparing k to the left size — O(h) per query.
Problem 3 — Lowest common ancestor in a BST
TreeNode lcaBST(TreeNode root, TreeNode p, TreeNode q) {
TreeNode cur = root;
while (cur != null) {
if (p.val < cur.val && q.val < cur.val) cur = cur.left; // both left
else if (p.val > cur.val && q.val > cur.val) cur = cur.right; // both right
else return cur; // they split here
}
return null;
} // sample BST: lca(4,7)=6, lca(1,7)=3, lca(13,10)=10
Why it works: while both values are on the same side, the LCA must be on that side too. The first node where they split (or one of them equals the node) is the LCA. Uses the ordering, so it is O(h) time and O(1) space — better than the O(n) general-tree version in section 12.
Problem 4 — Convert a sorted array to a balanced BST
TreeNode sortedArrayToBST(int[] nums) {
return build(nums, 0, nums.length - 1);
}
TreeNode build(int[] nums, int lo, int hi) {
if (lo > hi) return null;
int mid = lo + (hi - lo) / 2; // middle element becomes the root
TreeNode node = new TreeNode(nums[mid]);
node.left = build(nums, lo, mid - 1);
node.right = build(nums, mid + 1, hi);
return node;
} // [1..7] -> root 4, height 3 (in nodes)
Why it works: picking the middle puts half the values on each side, so the two subtree sizes differ by at most one at every level — the tree is height-balanced. Everything left of mid is smaller, everything right is larger, so the BST rule holds. O(n) time, O(log n) recursion space.
When to use a BST, and pitfalls
- Use an (always balanced) BST when you need order: sorted iteration, min/max, "next larger than x", range queries. If you only need lookup by exact key, a
HashMapis faster (O(1)). - Pitfall: validating with parent-child comparisons only (see Problem 1).
- Pitfall: forgetting to assign the return value —
insert(root.left, key)withoutroot.left =loses the new node. - Pitfall: duplicates. Decide a rule up front (ignore, count field, or always go right) and say it out loud.
- Pitfall: assuming O(log n) for a plain BST. It is O(h), and h can be n.
14. Balanced Trees & TreeMap
A self-balancing BST is a binary search tree that repairs its own shape after every insert or delete, so its height always stays around log n. That turns the BST's "O(h), could be O(n)" promise into a guaranteed O(log n).
You will almost never code one in an interview. What you will use is Java's TreeMap and TreeSet, which are red-black trees under the hood, and their "find the nearest key" methods.
A bookshelf that re-spaces itself. Whenever one end gets crowded, it shifts a few books over so you can always find any title in a handful of glances.
Why balance matters
Every BST operation costs O(height). With n = 1,000,000 keys a balanced tree has height about 20; a degenerate one has height 1,000,000. Balanced trees keep a small invariant (a rule that is always true) that forces the height to stay O(log n), and they restore the rule after each change using rotations.
Rotations — the one move every balanced tree uses
A rotation changes which node is the parent in a small group of nodes, without breaking the BST order. In a right rotation, the left child x moves up and its old parent y moves down to the right. The middle subtree B (values between x and y) switches from x's right to y's left. Only three links change, so a rotation is O(1).
AVL trees in easy words
An AVL tree (named after its inventors Adelson-Velsky and Landis) stores a height in each node and keeps this rule: at every node, the left and right subtree heights differ by at most 1. The difference is called the balance factor. After an insert, walk back up; the first node whose balance factor becomes +2 or −2 is fixed with one of four cases:
| Case | Shape | Fix |
|---|---|---|
| LL | Too tall on the left, new key went into the left child's left | One right rotation |
| RR | Mirror of LL | One left rotation |
| LR | Too tall on the left, new key went into the left child's right (a zig-zag) | Left-rotate the child (turns it into LL), then right-rotate the node |
| RL | Mirror of LR | Right-rotate the child, then left-rotate the node |
class Node {
int key, height = 1;
Node left, right;
Node(int k) { key = k; }
}
int h(Node n) { return n == null ? 0 : n.height; }
void update(Node n) { n.height = 1 + Math.max(h(n.left), h(n.right)); }
int balance(Node n) { return h(n.left) - h(n.right); }
Node rotateRight(Node y) {
Node x = y.left;
Node b = x.right;
x.right = y; // y comes down to the right
y.left = b; // B changes parent (still between x and y)
update(y); // y is now lower: update it first
update(x);
return x; // new root of this subtree
}
Node rotateLeft(Node x) {
Node y = x.right;
Node b = y.left;
y.left = x;
x.right = b;
update(x);
update(y);
return y;
}
Node insert(Node node, int key) {
if (node == null) return new Node(key);
if (key < node.key) node.left = insert(node.left, key);
else if (key > node.key) node.right = insert(node.right, key);
else return node;
update(node);
int bal = balance(node);
if (bal > 1 && key < node.left.key) return rotateRight(node); // LL
if (bal < -1 && key > node.right.key) return rotateLeft(node); // RR
if (bal > 1) { node.left = rotateLeft(node.left); return rotateRight(node); } // LR
if (bal < -1) { node.right = rotateRight(node.right); return rotateLeft(node); } // RL
return node;
} // inserting 1..7 in order gives root 4, height 3 (not 7)
Insert is O(log n): one walk down, one walk up, at most two rotations. You are unlikely to be asked for this code, but "explain how AVL fixes itself" is a fair question.
Red-black trees in easy words
A red-black tree paints each node red or black and keeps these rules:
- Every node is red or black; the root is black.
- Null leaves count as black.
- No red node has a red child (no two reds in a row).
- Every path from a node down to a null leaf passes the same number of black nodes (the "black height").
Why that keeps it balanced: the shortest possible path is all black; the longest alternates red and black. Rule 4 says both have the same number of blacks, and rule 3 says reds cannot pile up, so the longest path is at most twice the shortest. Height is at most 2 log2(n + 1). On insert the new node is red; if that breaks rule 3, the tree fixes it by recolouring and at most two rotations.
| AVL | Red-black | |
|---|---|---|
| Balance | Strict (heights differ by at most 1) | Looser (longest path at most 2x shortest) |
| Lookups | Slightly faster (shorter tree) | Slightly slower |
| Inserts / deletes | More rotations | Fewer rotations (at most 2 per insert, 3 per delete) |
| Used by | Read-heavy in-memory indexes | Java TreeMap/TreeSet, HashMap buckets with 8+ entries, C++ std::map, Linux scheduler |
B-trees — why databases use them
A B-tree is a balanced search tree where each node holds many sorted keys (often hundreds) and has one more child than keys. All leaves sit at the same depth. A B+ tree, used by MySQL InnoDB and PostgreSQL indexes, keeps all real data in the leaves and links the leaves together for fast range scans.
The reason is the disk. Reading from disk or SSD happens in pages (for example 16 KB in InnoDB), and each page read is thousands of times slower than a memory access. A binary tree with a billion keys is about 30 levels deep — 30 page reads. A B-tree with ~500 keys per node reaches a billion keys in about 4 levels, and the top levels stay cached in memory, so a lookup often needs just 1–2 real disk reads. Same O(log n) in theory; a much smaller log base in practice.
Java TreeMap and TreeSet API
TreeMap<K,V> keeps keys sorted (natural order or a Comparator). TreeSet<E> is the same thing with only keys. Both implement NavigableMap/NavigableSet, which adds the "nearest key" methods that make them useful.
TreeMap<Integer, String> map = new TreeMap<>();
map.put(10, "a"); map.put(20, "b"); map.put(30, "c"); map.put(40, "d");
map.floorKey(25); // 20 largest key <= 25
map.ceilingKey(25); // 30 smallest key >= 25
map.floorKey(20); // 20 "or equal" methods include the key itself
map.lowerKey(20); // 10 strictly less
map.higherKey(20); // 30 strictly greater
map.floorKey(5); // null nothing <= 5, always null-check
map.firstKey(); // 10
map.lastKey(); // 40
map.headMap(30); // {10=a, 20=b} keys < 30
map.tailMap(30); // {30=c, 40=d} keys >= 30
map.subMap(15, 35); // {20=b, 30=c} 15 <= key < 35
map.subMap(20, true, 40, true); // {20=b, 30=c, 40=d} both ends inclusive
map.descendingMap(); // {40=d, 30=c, 20=b, 10=a}
map.pollFirstEntry(); // removes and returns 10=a
TreeSet<Integer> set = new TreeSet<>(List.of(5, 1, 9, 3)); // [1, 3, 5, 9]
set.floor(4); // 3
set.higher(5); // 9
set.pollFirst(); // 1 (removed)
set.pollLast(); // 9 (removed) -> set is now [3, 5]
Memory aid: floor/ceiling include equal, lower/higher do not. Methods ending in Key return just the key; the Entry versions (floorEntry, ceilingEntry) return key and value. headMap/subMap return live views, not copies, and cost O(log n) to create.
Operations complexity
| Operation | TreeMap / TreeSet | HashMap / HashSet |
|---|---|---|
| get / put / remove / contains | O(log n) | O(1) average |
| first / last / pollFirst / pollLast | O(log n) | O(n) — no order |
| floor / ceiling / lower / higher | O(log n) | not supported |
| Iterate all in sorted order | O(n) | O(n log n) — must sort |
| Range view (subMap) then iterate k items | O(log n + k) | O(n) |
| Space | O(n), ~40 bytes per entry overhead | O(n) |
When to use: reach for TreeMap when a problem says "closest", "next bigger", "previous smaller", "in a range", "sorted while changing", or needs both min and max with deletes of arbitrary elements (a heap cannot remove an arbitrary element fast; a TreeMap of counts can).
Problem 1 — My Calendar I (booking overlap)
Book an event [start, end) only if it does not overlap an existing booking.
class MyCalendar {
private final TreeMap<Integer, Integer> bookings = new TreeMap<>(); // start -> end
public boolean book(int start, int end) { // half-open [start, end)
Integer prevStart = bookings.floorKey(start); // latest booking starting at or before start
if (prevStart != null && bookings.get(prevStart) > start) return false;
Integer nextStart = bookings.ceilingKey(start); // earliest booking starting at or after start
if (nextStart != null && nextStart < end) return false;
bookings.put(start, end);
return true;
}
}
// book(10,20)=true, book(15,25)=false, book(20,30)=true, book(5,10)=true, book(5,11)=false
Why it works: the bookings never overlap, so sorted by start they are also sorted by end. Only two bookings can possibly clash with the new one: the one just before it (does it end after we start?) and the one just after it (does it start before we end?). floorKey and ceilingKey find both in O(log n), so each book is O(log n) and space is O(n). A list scan would be O(n) per call.
Problem 2 — Contains Duplicate III
Are there indices i != j with |i − j| <= indexDiff and |nums[i] − nums[j]| <= valueDiff?
boolean containsNearbyAlmostDuplicate(int[] nums, int indexDiff, int valueDiff) {
TreeSet<Long> window = new TreeSet<>();
for (int i = 0; i < nums.length; i++) {
long x = nums[i];
Long ceil = window.ceiling(x - valueDiff); // smallest value >= x - valueDiff
if (ceil != null && ceil <= x + valueDiff) return true;
window.add(x);
if (i >= indexDiff) window.remove((long) nums[i - indexDiff]); // keep last indexDiff values
}
return false;
}
// [1,2,3,1], 3, 0 -> true [1,5,9,1,5,9], 2, 3 -> false
Why it works: a sliding window keeps only the last indexDiff values, which handles the index condition. For the value condition we need "is any value in [x − valueDiff, x + valueDiff]?" — ceiling(x − valueDiff) returns the smallest candidate in that range, and if even that one is above x + valueDiff, none fits. Using long avoids overflow when values are near Integer.MAX_VALUE. Time O(n log k), space O(k) where k = indexDiff. (A bucket approach reaches O(n), but the TreeSet version is what most interviewers expect first.)
Problem 3 — Stock Price Fluctuation
Updates arrive as (timestamp, price) and may correct an earlier timestamp. Support current(), maximum(), minimum().
class StockPrice {
private final Map<Integer, Integer> priceAt = new HashMap<>(); // timestamp -> price
private final TreeMap<Integer, Integer> count = new TreeMap<>(); // price -> how many timestamps
private int latest = 0;
public void update(int timestamp, int price) {
Integer old = priceAt.put(timestamp, price);
if (old != null) { // correction: remove old price once
if (count.merge(old, -1, Integer::sum) == 0) count.remove(old);
}
count.merge(price, 1, Integer::sum);
latest = Math.max(latest, timestamp);
}
public int current() { return priceAt.get(latest); }
public int maximum() { return count.lastKey(); }
public int minimum() { return count.firstKey(); }
}
// update(1,10), update(2,5): current 5, max 10
// update(1,3) corrects t=1: max 5. update(4,2): min 2, current 2
Why it works: the hard part is corrections: the old price must disappear from the max/min structure. A TreeMap used as a multiset (value to count) removes one copy of any price in O(log n) and gives min and max as firstKey/lastKey. Update is O(log n), current is O(1), max/min are O(log n). The alternative — two heaps with lazy deletion (pop stale tops whose price no longer matches) — also works but is harder to get right.
Pitfalls
floorKeyand friends returnnull, not an exception, when nothing qualifies. Store the result in anInteger, not anint, or auto-unboxing throwsNullPointerException.TreeSetis a set: duplicates are silently dropped. Use aTreeMap<value, count>as a multiset.- A
Comparatorthat returns 0 for different objects makes the tree treat them as equal — one of them vanishes. Break ties (for example by id). - Comparing boxed
Integerkeys with==only works for small values (-128 to 127 are cached). Useequalsor unbox. - Modifying the map while iterating over it (other than through the iterator) throws
ConcurrentModificationException.
15. Tries
A trie (say "try", from retrieval), also called a prefix tree, stores strings letter by letter along paths from the root. Words that start the same way share the same first nodes. Each node has a flag that says "a word ends here".
Its superpower is prefixes: "is there any word starting with ca?" is answered by walking two steps, no matter how many words you stored.
The index tabs of a paper dictionary: first you jump to the C section, then CA, then CAR. You never look at the D pages, and all the CAR- words sit together.
Node design: array vs map
Node[] children = new Node[26] | Map<Character, Node> children | |
|---|---|---|
| Speed | Fastest: direct index c - 'a' | Slower: hashing and boxing |
| Memory | 26 references per node even if 1 is used | Only stores children that exist |
| Alphabet | Fixed and small (lowercase letters, digits, bits) | Any characters (Unicode, URLs) |
| Children in sorted order | Free: just loop 0..25 | Need a TreeMap |
In interviews, use the array unless the problem says the characters can be anything.
Implementation — insert, search, startsWith
class Trie {
private static class Node {
Node[] children = new Node[26]; // one slot per letter 'a'..'z'
boolean isEnd; // a word ends here
}
private final Node root = new Node();
public void insert(String word) {
Node cur = root;
for (char c : word.toCharArray()) {
int i = c - 'a';
if (cur.children[i] == null) cur.children[i] = new Node();
cur = cur.children[i];
}
cur.isEnd = true;
}
public boolean search(String word) {
Node n = walk(word);
return n != null && n.isEnd; // path exists AND a word ends here
}
public boolean startsWith(String prefix) {
return walk(prefix) != null; // path exists is enough
}
private Node walk(String s) {
Node cur = root;
for (char c : s.toCharArray()) {
cur = cur.children[c - 'a'];
if (cur == null) return null;
}
return cur;
}
}
// insert("apple"): search("apple")=true, search("app")=false, startsWith("app")=true
// insert("app"): search("app")=true
The map version only changes two lines — cur = cur.children.computeIfAbsent(c, k -> new Node()) on insert and cur = cur.children.get(c) on lookup.
Operations complexity (L = length of the word or prefix)
| Operation | Trie | HashSet<String> | TreeSet<String> |
|---|---|---|---|
| Insert | O(L) | O(L) to hash | O(L log n) |
| Exact search | O(L) | O(L) average | O(L log n) |
| Any word with prefix p? | O(|p|) | O(n · L) — check every word | O(|p| log n) via ceiling(p) |
| List all k words with prefix p | O(|p| + size of that subtree) | O(n · L) | O(|p| log n + k · L) |
| Space | O(total characters × alphabet) worst case | O(total characters) | O(total characters) |
A trie does not beat a HashSet for exact lookups, and it uses more memory. Pick it when the problem is about prefixes, or when you search for many words at once while walking a structure (a grid, a stream of characters) and want to stop as soon as no word can match.
Problem 1 — Implement Trie
That is the class above. Every operation is O(L) time; insert uses O(L) new space in the worst case. Why it works: a path from the root spells a prefix, and the isEnd flag separates "a stored word" from "only a prefix of a stored word".
Problem 2 — Word Search II (the idea, plus code)
Given a letter grid and a list of words, find all words that can be traced through adjacent cells. Running a separate grid search for each word is too slow. Instead, put all words in a trie and run one DFS from each cell, walking the grid and the trie together. The moment the current path is not a prefix of any word, stop.
class Solution {
private static class Node {
Node[] next = new Node[26];
String word; // set on the node where a word ends
}
public List<String> findWords(char[][] board, String[] words) {
Node root = new Node();
for (String w : words) {
Node cur = root;
for (char c : w.toCharArray()) {
if (cur.next[c - 'a'] == null) cur.next[c - 'a'] = new Node();
cur = cur.next[c - 'a'];
}
cur.word = w;
}
List<String> found = new ArrayList<>();
for (int r = 0; r < board.length; r++)
for (int c = 0; c < board[0].length; c++)
dfs(board, r, c, root, found);
return found;
}
private void dfs(char[][] b, int r, int c, Node parent, List<String> found) {
if (r < 0 || c < 0 || r >= b.length || c >= b[0].length) return;
char ch = b[r][c];
if (ch == '#' || parent.next[ch - 'a'] == null) return; // visited, or no word has this prefix
Node node = parent.next[ch - 'a'];
if (node.word != null) {
found.add(node.word);
node.word = null; // report each word once
}
b[r][c] = '#'; // mark visited
dfs(b, r + 1, c, node, found);
dfs(b, r - 1, c, node, found);
dfs(b, r, c + 1, node, found);
dfs(b, r, c - 1, node, found);
b[r][c] = ch; // restore (backtrack)
}
}
// board oaan/etae/ihkr/iflv, words [oath, pea, eat, rain] -> [oath, eat]
Why it works: the trie shares work between words with the same prefix and prunes dead paths immediately. Time is O(R · C · 4 · 3L−1) in the worst case (L = longest word; after the first step each cell has at most 3 unvisited neighbours), but pruning makes it far faster in practice. Space O(total letters in words) for the trie plus O(L) recursion. Clearing node.word avoids duplicates without a Set.
Problem 3 — Autocomplete / search suggestions
After each typed character of searchWord, return up to three product names with that prefix, in lexicographic order.
List<List<String>> suggestedProducts(String[] products, String searchWord) {
Arrays.sort(products); // insert in sorted order
Node root = new Node(); // Node { Node[] next = new Node[26]; List<String> top = new ArrayList<>(); }
for (String p : products) {
Node cur = root;
for (char c : p.toCharArray()) {
if (cur.next[c - 'a'] == null) cur.next[c - 'a'] = new Node();
cur = cur.next[c - 'a'];
if (cur.top.size() < 3) cur.top.add(p); // first 3 seen are the 3 smallest
}
}
List<List<String>> result = new ArrayList<>();
Node cur = root;
for (char c : searchWord.toCharArray()) {
cur = (cur == null) ? null : cur.next[c - 'a'];
result.add(cur == null ? List.of() : cur.top);
}
return result;
}
// products [mobile, mouse, moneypot, monitor, mousepad], "mouse" ->
// [[mobile, moneypot, monitor], [mobile, moneypot, monitor],
// [mouse, mousepad], [mouse, mousepad], [mouse, mousepad]]
Why it works: because products are inserted in sorted order, the first three that pass through a node are the three smallest with that prefix, so each node can cache its answer. Build is O(n log n · L) for the sort plus O(total characters) for the trie; each keystroke is then O(1). Space O(total characters) nodes plus up to 3 references per node. Once a prefix has no match, every longer prefix returns an empty list. (A sorted array plus binary search for the prefix also works and uses less memory.)
Problem 4 — Longest word in dictionary
Find the longest word that can be built one character at a time by other words in the list (for "world", the words w, wo, wor, worl must all exist). Ties go to the lexicographically smallest.
class Solution {
private static class Node {
Node[] next = new Node[26];
String word;
}
private String best = "";
public String longestWord(String[] words) {
Node root = new Node();
for (String w : words) {
Node cur = root;
for (char c : w.toCharArray()) {
if (cur.next[c - 'a'] == null) cur.next[c - 'a'] = new Node();
cur = cur.next[c - 'a'];
}
cur.word = w;
}
dfs(root);
return best;
}
// only step into children that are complete words themselves
private void dfs(Node node) {
for (Node child : node.next) { // 'a'..'z' order = lexicographic
if (child != null && child.word != null) {
if (child.word.length() > best.length()) best = child.word;
dfs(child);
}
}
}
}
// [a, banana, app, appl, ap, apply, apple] -> "apple" (apply is equally long but larger)
Why it works: "buildable one letter at a time" means every node on the path is a word end. So the DFS only walks through word-end nodes. Visiting children in a..z order and replacing best only when strictly longer keeps the smallest word on ties. O(total characters) time and space.
Problem 5 — Maximum XOR of two numbers (bit trie)
A trie over bits instead of letters: each number becomes a 31-step path from the highest bit to the lowest, and each node has two children, for bit 0 and bit 1.
int findMaximumXOR(int[] nums) {
int[][] next = new int[nums.length * 31 + 1][2]; // node -> child for bit 0 / 1 (0 = none)
int count = 1; // node 0 is the root
int best = 0;
for (int x : nums) {
// insert x, bit 30 down to bit 0
int cur = 0;
for (int b = 30; b >= 0; b--) {
int bit = (x >> b) & 1;
if (next[cur][bit] == 0) next[cur][bit] = count++;
cur = next[cur][bit];
}
// query: greedily take the opposite bit when possible
cur = 0;
int xor = 0;
for (int b = 30; b >= 0; b--) {
int bit = (x >> b) & 1;
int want = bit ^ 1;
if (next[cur][want] != 0) {
xor |= (1 << b);
cur = next[cur][want];
} else {
cur = next[cur][bit];
}
}
best = Math.max(best, xor);
}
return best;
}
// [3, 10, 5, 25, 2, 8] -> 28 (5 XOR 25)
Why it works: XOR puts a 1 wherever the bits differ, and a 1 in a higher bit is worth more than all lower bits combined (2b > 2b − 1). So for each number, walk from the top bit and take the opposite branch whenever it exists — a greedy choice that is always right. Inserting before querying is safe: a number XOR itself is 0, which never beats a real pair. O(31 · n) = O(n) time, O(31 · n) space. Storing nodes in an int[][] instead of objects is faster and a common trick.
Pitfalls
- Forgetting
isEnd: thensearch("app")wrongly returns true after inserting "apple". c - 'a'assumes lowercase letters only. Uppercase or digits give negative or out-of-range indices.- Memory: 26 references × 8 bytes per node adds up fast with a million words. Mention the map or a compressed trie (radix tree, which merges single-child chains) if memory matters.
- In Word Search II, forgetting to restore the cell after the DFS, or reporting the same word twice.
16. Graphs & Traversal
A graph is a set of vertices (also called nodes) joined by edges. Unlike a tree, a graph can have loops, several paths between the same two nodes, and pieces that are not connected at all. A tree is just a special graph: connected, with no cycles.
Most graph problems come down to one of two walks: BFS (breadth-first search — explore in rings, nearest first) or DFS (depth-first search — go as deep as you can, then back up). The only new rule compared to trees: remember what you have already visited, or you will walk in circles forever.
A city map. Intersections are vertices, roads are edges. One-way streets make it directed, road lengths make it weighted, and an island with its own roads is a separate connected component.
Terminology
| Term | Meaning |
|---|---|
| Undirected | Edges go both ways (friendship). Edge u–v means you can move u to v and v to u. |
| Directed (digraph) | Edges have a direction (following someone, a prerequisite). u → v does not give v → u. |
| Weighted | Each edge has a cost (distance, time, price). Unweighted means every edge costs 1. |
| Cyclic / acyclic | Cyclic: you can leave a node and return to it along edges. Acyclic: you cannot. |
| DAG | Directed Acyclic Graph — directions and no cycles. Task dependencies, course prerequisites. Can be topologically sorted. |
| Connected | (Undirected) every node can reach every other. A connected component is a maximal group of nodes that can reach each other. |
| Degree | Number of edges touching a node. Directed graphs split it into in-degree (edges coming in) and out-degree (edges going out). |
| Path / cycle | A path is a sequence of nodes joined by edges. A cycle is a path that starts and ends at the same node. |
| Sparse / dense | Sparse: E is close to V (most real graphs). Dense: E is close to V2. |
We write V for the number of vertices and E for the number of edges. Graph complexities are stated in both, like O(V + E).
Representations
| Representation | Space | Is u–v an edge? | List neighbours of u | Use when |
|---|---|---|---|---|
| Adjacency list | O(V + E) | O(degree(u)) | O(degree(u)) | Default. Sparse graphs, BFS/DFS. |
| Adjacency matrix | O(V2) | O(1) | O(V) | Dense graphs, small V (up to a few thousand), Floyd-Warshall. |
| Edge list | O(E) | O(E) | O(E) | Input format; Kruskal's MST (sort edges); Union-Find; Bellman-Ford. |
| Implicit (grid / rules) | none stored | compute | compute (4 or 8 directions) | Grid problems, word ladder: neighbours are generated on the fly. |
In Java the usual adjacency list is List<List<Integer>> when nodes are 0..n−1, or Map<String, List<String>> when nodes have names. For weighted graphs store int[]{neighbour, weight} pairs.
List<List<Integer>> buildGraph(int n, int[][] edges) {
List<List<Integer>> adj = new ArrayList<>();
for (int i = 0; i < n; i++) adj.add(new ArrayList<>());
for (int[] e : edges) {
adj.get(e[0]).add(e[1]);
adj.get(e[1]).add(e[0]); // undirected: add both directions
}
return adj;
} // edges above -> [[1, 2], [0, 3], [0, 3], [1, 2, 4], [3], [6], [5]]
BFS template — and shortest paths in unweighted graphs
// BFS: shortest number of edges from src to every node (-1 = unreachable)
int[] bfs(List<List<Integer>> adj, int src) {
int[] dist = new int[adj.size()];
Arrays.fill(dist, -1); // -1 doubles as "not visited"
Queue<Integer> queue = new ArrayDeque<>();
dist[src] = 0;
queue.offer(src);
while (!queue.isEmpty()) {
int u = queue.poll();
for (int v : adj.get(u)) {
if (dist[v] == -1) { // mark when ADDING, not when polling
dist[v] = dist[u] + 1;
queue.offer(v);
}
}
}
return dist;
} // from 0: [0, 1, 1, 2, 3, -1, -1]
Why BFS gives shortest paths: the queue processes nodes in order of distance — all distance-1 nodes, then all distance-2 nodes, and so on. The first time a node is reached is therefore by a shortest route. This only holds when every edge costs the same; with weights, use Dijkstra (a later section). Time O(V + E): each node enters the queue once and each edge is looked at twice (once from each end). Space O(V).
DFS — recursive and iterative
void dfs(List<List<Integer>> adj, int u, boolean[] visited, List<Integer> order) {
visited[u] = true;
order.add(u);
for (int v : adj.get(u)) {
if (!visited[v]) dfs(adj, v, visited, order);
}
}
List<Integer> dfsIterative(List<List<Integer>> adj, int src) {
List<Integer> order = new ArrayList<>();
boolean[] visited = new boolean[adj.size()];
Deque<Integer> stack = new ArrayDeque<>();
stack.push(src);
while (!stack.isEmpty()) {
int u = stack.pop();
if (visited[u]) continue; // may be pushed more than once
visited[u] = true;
order.add(u);
List<Integer> nbrs = adj.get(u);
for (int i = nbrs.size() - 1; i >= 0; i--) { // reverse: same order as recursive
if (!visited[nbrs.get(i)]) stack.push(nbrs.get(i));
}
}
return order;
} // both give [0, 1, 3, 2, 4] from node 0
Both are O(V + E) time and O(V) space. Recursive DFS is shorter; iterative DFS avoids StackOverflowError on long paths (Java's default stack handles roughly 10,000 deep calls, fewer with big frames).
Visited handling — the rules that prevent bugs
- BFS: mark when you add to the queue, not when you remove. Otherwise the same node can be added many times by different neighbours before it is processed — correct answer, but wasted work, and in dense graphs it can blow up memory.
- Iterative DFS: mark when you pop and skip already-visited pops (as above) if you want true DFS order.
- Use a
boolean[]for numbered nodes, aHashSetfor named nodes, or change the input in place (set a grid cell to'0'or'#') if you are allowed to. - For a graph that may be disconnected, loop over every node and start a new search from each unvisited one.
Connected components and cycle detection (undirected)
The number of connected components is the number of times the outer loop finds an unvisited node. For cycles in an undirected graph, DFS finds one if it reaches an already-visited node that is not the node it just came from (its parent). Going back along the same edge does not count as a cycle.
boolean hasCycle(List<List<Integer>> adj) {
boolean[] visited = new boolean[adj.size()];
for (int i = 0; i < adj.size(); i++) {
if (!visited[i] && cycleDfs(adj, i, -1, visited)) return true;
}
return false;
}
boolean cycleDfs(List<List<Integer>> adj, int u, int parent, boolean[] visited) {
visited[u] = true;
for (int v : adj.get(u)) {
if (!visited[v]) {
if (cycleDfs(adj, v, u, visited)) return true;
} else if (v != parent) {
return true; // reached an already-seen node another way
}
}
return false;
} // sample graph: true (0-1-3-2-0). Tree 0-1, 1-2, 1-3: false
O(V + E). Directed graphs need a different test — the parent trick is wrong there, and you need three states (unvisited, in the current path, done) or Kahn's algorithm. That is covered in the topological sort section. Union-Find (section 17) is another way to detect cycles in undirected graphs.
Bipartite means the nodes can be split into two groups so every edge crosses between the groups. Colour the start node, give each neighbour the opposite colour, and fail if an edge ever joins two nodes of the same colour. A graph is bipartite exactly when it has no odd-length cycle.
Operations complexity
| Operation | Adjacency list | Adjacency matrix |
|---|---|---|
| BFS / DFS / components / bipartite / cycle check | O(V + E) | O(V2) |
| Add edge | O(1) | O(1) |
| Remove edge | O(degree) | O(1) |
| Check edge u–v | O(degree(u)) | O(1) |
| Grid of R × C cells as a graph | O(R · C) — each cell has at most 4 edges | |
BFS or DFS? BFS for shortest path in steps, "minimum number of moves", or anything spreading level by level. DFS for "explore everything", connectivity, cycle detection, topological order, backtracking over paths. For simple counting (components, islands), both work.
Problem 1 — Number of connected components
int countComponents(int n, int[][] edges) {
List<List<Integer>> adj = buildGraph(n, edges);
boolean[] visited = new boolean[n];
int components = 0;
for (int i = 0; i < n; i++) {
if (!visited[i]) {
components++; // new unvisited node = new component
dfs(adj, i, visited, new ArrayList<>());
}
}
return components;
} // n=5, edges [[0,1],[1,2],[3,4]] -> 2
Why it works: one DFS marks exactly one whole component. So every time the loop meets an unvisited node, it has found a component nobody has touched yet. O(V + E) time and space. Number of Islands is the same idea on a grid.
Problem 2 — Clone graph
Node cloneGraph(Node node) { // Node { int val; List<Node> neighbors; }
if (node == null) return null;
Map<Node, Node> copies = new HashMap<>(); // original -> clone (also "visited")
Queue<Node> queue = new ArrayDeque<>();
copies.put(node, new Node(node.val));
queue.offer(node);
while (!queue.isEmpty()) {
Node cur = queue.poll();
for (Node nb : cur.neighbors) {
if (!copies.containsKey(nb)) {
copies.put(nb, new Node(nb.val));
queue.offer(nb);
}
copies.get(cur).neighbors.add(copies.get(nb)); // wire clone edge
}
}
return copies.get(node);
}
Why it works: the map does two jobs: it is the visited set, and it lets every edge find the clone of its other end, even if that clone was created earlier. Without it, a cycle (1–2–3–4–1) would be copied forever. O(V + E) time, O(V) space.
Problem 3 — Is graph bipartite?
boolean isBipartite(int[][] graph) {
int n = graph.length;
int[] color = new int[n]; // 0 = uncoloured, 1 / -1 = the two sides
for (int start = 0; start < n; start++) {
if (color[start] != 0) continue; // graph may be disconnected
Queue<Integer> queue = new ArrayDeque<>();
queue.offer(start);
color[start] = 1;
while (!queue.isEmpty()) {
int u = queue.poll();
for (int v : graph[u]) {
if (color[v] == 0) {
color[v] = -color[u]; // neighbour gets the other side
queue.offer(v);
} else if (color[v] == color[u]) {
return false; // edge inside one side
}
}
}
}
return true;
} // square 0-1-2-3-0: true. Square plus diagonal 0-2: false (triangle)
Why it works: within a component, once the start's colour is chosen every other colour is forced — a neighbour must be on the other side. So if a forced colouring ever conflicts, no valid split exists. O(V + E) time, O(V) space.
Problem 4 — Rotting oranges (multi-source BFS)
Grid: 0 empty, 1 fresh, 2 rotten. Every minute, rotten oranges rot their 4 neighbours. Minutes until none are fresh, or −1 if impossible.
int orangesRotting(int[][] grid) {
int rows = grid.length, cols = grid[0].length;
Queue<int[]> queue = new ArrayDeque<>();
int fresh = 0;
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++) {
if (grid[r][c] == 2) queue.offer(new int[]{r, c}); // ALL sources start together
else if (grid[r][c] == 1) fresh++;
}
int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
int minutes = 0;
while (!queue.isEmpty() && fresh > 0) {
int size = queue.size(); // one level = one minute
for (int i = 0; i < size; i++) {
int[] cell = queue.poll();
for (int[] d : dirs) {
int nr = cell[0] + d[0], nc = cell[1] + d[1];
if (nr >= 0 && nc >= 0 && nr < rows && nc < cols && grid[nr][nc] == 1) {
grid[nr][nc] = 2; // rot it = mark visited
fresh--;
queue.offer(new int[]{nr, nc});
}
}
}
minutes++;
}
return fresh == 0 ? minutes : -1;
} // [[2,1,1],[1,1,0],[0,1,1]] -> 4 [[2,1,1],[0,1,1],[1,0,1]] -> -1
Why it works: putting every rotten orange in the queue at the start is like adding one imaginary "super source" joined to all of them. BFS levels then equal minutes, and each fresh orange is reached at its distance to the nearest rotten one. Running a separate BFS per rotten orange would be O((R·C)2); this is O(R · C) time and space. The fresh > 0 check stops the count from adding an extra minute at the end.
Problem 5 — Word ladder
Fewest words in a transformation from beginWord to endWord, changing one letter at a time, where every word must be in the list.
int ladderLength(String beginWord, String endWord, List<String> wordList) {
Set<String> dict = new HashSet<>(wordList);
if (!dict.contains(endWord)) return 0;
Queue<String> queue = new ArrayDeque<>();
queue.offer(beginWord);
dict.remove(beginWord);
int steps = 1; // counts words, not edges
while (!queue.isEmpty()) {
int size = queue.size();
for (int i = 0; i < size; i++) {
char[] w = queue.poll().toCharArray();
for (int j = 0; j < w.length; j++) {
char original = w[j];
for (char c = 'a'; c <= 'z'; c++) {
if (c == original) continue;
w[j] = c;
String next = new String(w);
if (next.equals(endWord)) return steps + 1;
if (dict.remove(next)) queue.offer(next); // remove = mark visited
}
w[j] = original;
}
}
steps++;
}
return 0;
} // hit -> cog with [hot,dot,dog,lot,log,cog]: 5 (hit hot dot dog cog)
Why it works: it is an unweighted shortest-path problem on an implicit graph: words are nodes, and two words are joined if they differ by one letter. Instead of comparing every pair of words (O(N2 · L)), generate the 25 · L possible neighbours of each word and check them in the set. Removing a word from the set when it is queued marks it visited. Time O(N · L · 26 · L) (the last L is building each string), space O(N · L). Bidirectional BFS (search from both ends and meet in the middle) is the usual follow-up speed-up.
Pitfalls
- Forgetting the visited set: infinite loop on any cycle.
- Marking visited on poll in BFS: duplicates in the queue.
- Forgetting disconnected parts: always loop over all nodes when the question is about the whole graph.
- Undirected edge added only one way when building the adjacency list.
- Using the parent trick for cycle detection when the input has duplicate edges (u–v twice): the second copy is a real 2-cycle that the parent check hides. Check the problem statement.
- Using BFS for shortest path on a weighted graph — it gives the fewest edges, not the lowest total weight.
17. Union-Find (Disjoint Set)
Union-Find, also called a Disjoint Set Union (DSU), keeps track of items split into groups that never overlap. It answers two questions very fast: find — "which group is x in?" — and union — "merge the groups of x and y".
Each group is stored as a small tree. Every item points to a parent, and the item at the top (the root, which points to itself) is the group's leader. Two items are in the same group exactly when they have the same root.
Companies and acquisitions. Every employee knows their boss, and the CEO is the boss of nobody. To check if two people work for the same company, follow the bosses up to each CEO and compare. When one company buys another, the smaller company's CEO simply starts reporting to the bigger company's CEO.
How it is stored
Just two integer arrays. parent[i] is i's parent (a root has parent[i] == i). size[i] (or rank[i]) is only meaningful at a root, and records how big that tree is. No objects, no pointers — which is why it is so fast in practice.
The two tricks that make it fast
- Union by size (or rank) — when merging, attach the smaller tree under the root of the larger one. A node only gets deeper when its tree at least doubles in size, so no tree gets taller than log2 n. Rank is the same idea using an upper bound on height instead of size.
- Path compression — during
find, after you discover the root, make every node you passed point directly at the root. The nextfindon any of them is one step.
find, so the work done once is never repeated.Full Java class
class UnionFind {
private final int[] parent;
private final int[] size; // size of the tree, valid only at roots
private int components;
UnionFind(int n) {
parent = new int[n];
size = new int[n];
components = n;
for (int i = 0; i < n; i++) {
parent[i] = i; // everyone starts as their own leader
size[i] = 1;
}
}
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]); // path compression
return parent[x];
}
boolean union(int a, int b) {
int ra = find(a), rb = find(b);
if (ra == rb) return false; // already in the same set
if (size[ra] < size[rb]) { int t = ra; ra = rb; rb = t; }
parent[rb] = ra; // attach smaller under larger
size[ra] += size[rb];
components--;
return true;
}
boolean connected(int a, int b) { return find(a) == find(b); }
int components() { return components; }
int sizeOf(int x) { return size[find(x)]; }
}
// n=6; union(0,1), union(1,2), union(3,4):
// connected(0,2)=true, connected(0,3)=false, components()=3, sizeOf(2)=3, union(0,2)=false
Returning a boolean from union is a small design choice that pays off: "false" means the two were already connected, which is exactly the signal for cycle detection. Keeping a components counter answers "how many groups?" in O(1).
The recursive find can go deep before the first compression. If that worries you, use the iterative path halving version, which makes each node point to its grandparent as it walks:
int find(int x) {
while (parent[x] != x) {
parent[x] = parent[parent[x]]; // point to grandparent: halves the path
x = parent[x];
}
return x;
} // chain parent=[0,0,1,2,3]: find(4) = 0, parent becomes [0, 0, 0, 2, 2]
Near O(1): the inverse Ackermann function in easy words
With both tricks, a sequence of m operations on n items costs O(m · α(n)). α (alpha) is the inverse Ackermann function. The Ackermann function grows unbelievably fast, so its inverse grows unbelievably slowly: α(n) is at most 4 for any n you could ever store — even n equal to the number of atoms in the universe. So in practice each operation is constant time; you say "amortized nearly O(1)" in an interview.
| Version | find / union (per operation) |
|---|---|
| No tricks | O(n) worst case — trees can become long chains |
| Union by size or rank only | O(log n) |
| Path compression only | O(log n) amortized |
| Both | O(α(n)) amortized — effectively O(1) |
| Space | O(n) for the two arrays |
When to use Union-Find
- Connectivity questions where edges arrive over time (online): "after each new link, how many groups?" DFS would have to restart each time; Union-Find just merges.
- Cycle detection in an undirected graph, and Kruskal's minimum spanning tree (add cheapest edges that do not form a cycle).
- Grouping things by a shared attribute (same email, similar strings, equal variables in equations).
- Not a fit when you need to split groups (it only merges), or need the actual path between two nodes (use BFS/DFS).
Problem 1 — Number of provinces
isConnected[i][j] = 1 means city i and city j are directly connected. Count the groups of connected cities.
int findCircleNum(int[][] isConnected) {
int n = isConnected.length;
UnionFind uf = new UnionFind(n);
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++) // matrix is symmetric: upper half only
if (isConnected[i][j] == 1) uf.union(i, j);
return uf.components();
} // [[1,1,0],[1,1,0],[0,0,1]] -> 2
Why it works: start with n groups; every successful union merges two groups into one, so the counter ends at the number of connected groups. O(n2 · α(n)) time because the input is an n × n matrix, O(n) extra space. A DFS over the matrix works equally well here.
Problem 2 — Redundant connection
A tree with n nodes had one extra edge added. Return the edge that can be removed to make it a tree again (the last such edge in the input).
int[] findRedundantConnection(int[][] edges) {
UnionFind uf = new UnionFind(edges.length + 1); // nodes are 1..n
for (int[] e : edges) {
if (!uf.union(e[0], e[1])) return e; // already connected: this edge closes a cycle
}
return new int[0];
} // [[1,2],[1,3],[2,3]] -> [2, 3] [[1,2],[2,3],[3,4],[1,4],[1,5]] -> [1, 4]
Why it works: if both ends of an edge are already in the same group, there was already a path between them, so this edge creates a cycle. Processing in input order means the first such edge is the last one of the cycle in the list — the one the problem wants. O(n · α(n)) time, O(n) space.
Problem 3 — Accounts merge
Each account is a name followed by emails. Two accounts belong to the same person if they share any email. Merge them and return each person's emails sorted.
List<List<String>> accountsMerge(List<List<String>> accounts) {
Map<String, Integer> emailId = new HashMap<>(); // email -> first account index
UnionFind uf = new UnionFind(accounts.size());
for (int i = 0; i < accounts.size(); i++) {
List<String> acc = accounts.get(i);
for (int j = 1; j < acc.size(); j++) {
Integer owner = emailId.putIfAbsent(acc.get(j), i);
if (owner != null) uf.union(i, owner); // shared email: same person
}
}
Map<Integer, TreeSet<String>> groups = new HashMap<>(); // root -> sorted emails
for (Map.Entry<String, Integer> e : emailId.entrySet()) {
groups.computeIfAbsent(uf.find(e.getValue()), k -> new TreeSet<>()).add(e.getKey());
}
List<List<String>> result = new ArrayList<>();
for (Map.Entry<Integer, TreeSet<String>> g : groups.entrySet()) {
List<String> merged = new ArrayList<>();
merged.add(accounts.get(g.getKey()).get(0)); // name
merged.addAll(g.getValue());
result.add(merged);
}
return result;
}
// [John: johnsmith, john_newyork] [John: johnsmith, john00] [Mary: mary] [John: johnnybravo]
// -> [John, john00@mail.com, john_newyork@mail.com, johnsmith@mail.com],
// [John, johnnybravo@mail.com], [Mary, mary@mail.com] (group order may vary)
Why it works: the union-find nodes are accounts. The first account to mention an email "owns" it; any later account with the same email is unioned with the owner. Afterwards, each email is filed under its owner's root, and the TreeSet sorts the emails. Two accounts with the same name but no shared email (the two Johns) correctly stay separate. Time O(E log E) where E is the total number of emails (the sort dominates), space O(E).
Problem 4 — Number of islands II (the idea)
An m × n grid starts as all water. Land is added one cell at a time; after each addition report the number of islands. Re-running a flood fill after every addition would cost O(m · n) each time. With Union-Find: the new cell becomes its own island (+1), then union it with each land neighbour; every union that actually merges two different sets is −1.
List<Integer> numIslands2(int m, int n, int[][] positions) {
UnionFind uf = new UnionFind(m * n);
boolean[] land = new boolean[m * n];
int islands = 0;
int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
List<Integer> result = new ArrayList<>();
for (int[] p : positions) {
int id = p[0] * n + p[1]; // 2D cell -> 1D index
if (!land[id]) {
land[id] = true;
islands++; // new land starts as its own island
for (int[] d : dirs) {
int r = p[0] + d[0], c = p[1] + d[1];
int nid = r * n + c;
if (r >= 0 && c >= 0 && r < m && c < n && land[nid] && uf.union(id, nid)) {
islands--; // merged two islands into one
}
}
}
result.add(islands);
}
return result;
} // 3x3, add (0,0),(0,1),(1,2),(2,1),(1,1) -> [1, 1, 2, 3, 1]
Why it works: the last cell (1,1) touches three separate islands; it adds 1 and then merges three times, so 3 + 1 − 3 = 1. Each addition is O(α(mn)), so k additions cost O(m · n + k · α(mn)) including setup; space O(m · n). The !land[id] check handles the same cell being added twice. Mapping (row, col) to row * n + col is the standard way to put a grid into a DSU.
Pitfalls
- Comparing
parent[a] == parent[b]instead offind(a) == find(b). Only roots identify a group. - Linking
parent[a] = binstead of linking the roots:parent[find(a)] = find(b). - Reading
size[x]for a non-root. Always usesize[find(x)]. - Off-by-one when nodes are numbered 1..n: allocate n + 1.
- Forgetting that Union-Find cannot undo a union or remove an item (unless you use the advanced "rollback" variant without path compression).
18. Segment Trees & Fenwick Trees
Both structures answer range queries — "what is the sum (or min, or max) of elements from index l to r?" — while the array keeps changing. Each query and each update costs O(log n).
A segment tree is a binary tree where every node stores the answer for one slice of the array; the root covers everything, the leaves cover single elements. A Fenwick tree (also called a Binary Indexed Tree, BIT) is a clever array that stores partial sums whose sizes follow the binary digits of the index. It is shorter to code but only fits "prefix-able" operations like sum.
A company's sales report. The CEO's number is the whole company; each regional manager has their region; each store manager has one store. To get "stores 3 to 7", you add a few regional totals instead of every store — and when one store's sales change, only that store's managers up the chain update their numbers.
The problem: prefix sums break with updates
A prefix sum array (section on arrays) answers any range sum in O(1): sum(l, r) = prefix[r + 1] − prefix[l]. But change one element and every prefix after it is wrong — fixing them is O(n). The plain array has the opposite problem: O(1) updates but O(n) range sums.
| Approach | Build | Point update | Range query | Space |
|---|---|---|---|---|
| Plain array | O(1) | O(1) | O(n) | O(1) extra |
| Prefix sum array | O(n) | O(n) | O(1) | O(n) |
| Segment tree | O(n) | O(log n) | O(log n) | O(4n) |
| Segment tree + lazy | O(n) | range update O(log n) | O(log n) | O(8n) |
| Fenwick tree (BIT) | O(n log n), or O(n) with a trick | O(log n) | O(log n) | O(n) |
With q mixed updates and queries, both trees cost O(q log n) in total instead of O(q · n). When you see "update" and "range sum/min/max" in the same problem, think of these two.
Segment tree shape
Like a heap, the tree is stored in a flat array: node k has children 2k and 2k + 1. Allocating 4 * n slots is always enough, even when n is not a power of two.
Segment tree — build, update, query
class SegmentTree {
private final int n;
private final int[] tree; // tree[node] = sum of its range
SegmentTree(int[] nums) {
n = nums.length;
tree = new int[4 * n]; // 4n is always enough
build(nums, 1, 0, n - 1);
}
// node covers nums[lo..hi]; children are 2*node and 2*node+1
private void build(int[] nums, int node, int lo, int hi) {
if (lo == hi) { tree[node] = nums[lo]; return; }
int mid = (lo + hi) / 2;
build(nums, 2 * node, lo, mid);
build(nums, 2 * node + 1, mid + 1, hi);
tree[node] = tree[2 * node] + tree[2 * node + 1];
}
void update(int index, int value) { update(1, 0, n - 1, index, value); }
private void update(int node, int lo, int hi, int index, int value) {
if (lo == hi) { tree[node] = value; return; }
int mid = (lo + hi) / 2;
if (index <= mid) update(2 * node, lo, mid, index, value);
else update(2 * node + 1, mid + 1, hi, index, value);
tree[node] = tree[2 * node] + tree[2 * node + 1]; // fix sums on the way up
}
int query(int left, int right) { return query(1, 0, n - 1, left, right); }
private int query(int node, int lo, int hi, int left, int right) {
if (right < lo || hi < left) return 0; // no overlap
if (left <= lo && hi <= right) return tree[node]; // fully inside: use stored sum
int mid = (lo + hi) / 2; // partial overlap: ask both kids
return query(2 * node, lo, mid, left, right)
+ query(2 * node + 1, mid + 1, hi, left, right);
}
}
// [2, 1, 5, 3, 4]: query(1, 3) = 9; update(2, 10); query(1, 3) = 14, query(0, 4) = 20
Why a query is O(log n): the three cases — no overlap (return the neutral value), full overlap (return the stored answer), partial overlap (split) — mean that on each level at most two nodes are partially overlapping and keep splitting. Build visits each of the ~2n nodes once: O(n). Update changes one leaf and its log n ancestors.
To answer min or max instead of sum, change the combine step (+ to Math.min) and the "no overlap" value (0 to Integer.MAX_VALUE). Anything associative works: gcd, product mod p, count, or even "max subarray sum" with a small struct per node. That flexibility is the segment tree's big advantage.
Lazy propagation (the idea)
What if the update is "add 10 to every element from l to r"? Updating each leaf is O(n). Lazy propagation stops at nodes fully inside [l, r], updates their stored sum right away (sum += 10 × length) and leaves a note — "my children still owe +10" — in a lazy[] array. The note is only pushed down to the children when a later operation needs to go below that node. Range updates and range queries both stay O(log n).
// Inside a segment tree with long[] sum, lazy (range add, range sum)
private void apply(int node, int lo, int hi, long v) {
sum[node] += v * (hi - lo + 1); // whole range grows by v each
lazy[node] += v; // remember to tell children later
}
private void push(int node, int lo, int hi) { // hand pending work to children
if (lazy[node] == 0 || lo == hi) return;
int mid = (lo + hi) / 2;
apply(2 * node, lo, mid, lazy[node]);
apply(2 * node + 1, mid + 1, hi, lazy[node]);
lazy[node] = 0;
}
private void add(int node, int lo, int hi, int l, int r, long v) {
if (r < lo || hi < l) return;
if (l <= lo && hi <= r) { apply(node, lo, hi, v); return; } // stop here, be lazy
push(node, lo, hi);
int mid = (lo + hi) / 2;
add(2 * node, lo, mid, l, r, v);
add(2 * node + 1, mid + 1, hi, l, r, v);
sum[node] = sum[2 * node] + sum[2 * node + 1];
}
// query is the normal query with push(node, lo, hi) called before splitting
// [2, 1, 5, 3, 4], add 10 to [1..3]: sum(0, 4) = 45, sum(2, 2) = 15
Fenwick tree (Binary Indexed Tree)
The whole trick is lowbit: i & -i keeps only the lowest set bit of i. In two's complement, -i flips all bits and adds 1, so it matches i only at that lowest 1 bit. Examples: 12 is 1100, lowbit 4; 6 is 110, lowbit 2; 7 is 111, lowbit 1.
In a Fenwick tree (1-indexed), bit[i] stores the sum of the lowbit(i) elements ending at i — that is, positions i − lowbit(i) + 1 to i. So bit[6] holds positions 5..6, bit[8] holds 1..8, bit[7] holds just 7.
bit[i] covers a block whose length is the lowest set bit of i. Any prefix is the sum of at most log n blocks.- prefixSum(i): add
bit[i], then jump toi − lowbit(i)(strip the lowest bit), until 0. 7 → 6 → 4 → 0. - add(i, delta): update every block that contains i:
bit[i], then jump toi + lowbit(i), until past n. 5 → 6 → 8.
class FenwickTree {
private final int[] bit; // 1-indexed: bit[0] unused
FenwickTree(int n) { bit = new int[n + 1]; }
// add delta to position i (1-indexed)
void add(int i, int delta) {
for (; i < bit.length; i += i & -i) bit[i] += delta; // climb to covering ranges
}
// sum of positions 1..i
int prefixSum(int i) {
int s = 0;
for (; i > 0; i -= i & -i) s += bit[i]; // strip lowest bit each step
return s;
}
int rangeSum(int l, int r) { return prefixSum(r) - prefixSum(l - 1); }
}
// values 2, 1, 5, 3, 4 at positions 1..5: prefixSum(3) = 8, rangeSum(2, 4) = 9
Both loops run at most log2 n + 1 times, since each step removes or carries one bit. Space is exactly n + 1 ints. Building by calling add n times is O(n log n).
Segment tree or Fenwick tree?
| Segment tree | Fenwick tree | |
|---|---|---|
| Code size | ~30 lines, easy to get wrong | ~10 lines |
| Memory | 4n | n + 1 |
| Operations | Any associative op: sum, min, max, gcd, custom structs | Operations with an inverse (sum, xor, count), so ranges come from two prefixes. Not min/max over arbitrary ranges. |
| Range update | Yes, with lazy propagation | Range add + point query, or two BITs for range add + range sum |
| Extra tricks | Find first index with value > x, merge-sort tree | Counting inversions, "how many smaller seen so far" |
Rule of thumb: sum or count with point updates → Fenwick. Min/max, range updates, or anything custom → segment tree. If the array never changes, neither — use a prefix sum array (or a sparse table for min/max). Java has no built-in version of either; you write them.
Problem 1 — Range Sum Query - Mutable
class NumArray {
private final int[] nums;
private final FenwickTree fw;
NumArray(int[] nums) {
this.nums = nums.clone();
fw = new FenwickTree(nums.length);
for (int i = 0; i < nums.length; i++) fw.add(i + 1, nums[i]); // shift to 1-indexed
}
void update(int index, int val) {
fw.add(index + 1, val - nums[index]); // BIT stores sums, so add the difference
nums[index] = val;
}
int sumRange(int left, int right) {
return fw.rangeSum(left + 1, right + 1);
}
}
// [1, 3, 5]: sumRange(0, 2) = 9; update(1, 2); sumRange(0, 2) = 8
Why it works: the problem gives "set to value", but a BIT only knows "add delta", so we keep a copy of the array to compute the difference. The +1 shifts LeetCode's 0-indexed positions to the BIT's 1-indexed positions. Constructor O(n log n), update and sumRange O(log n), space O(n). The SegmentTree class above solves it just as well: update(index, val) and query(left, right).
Problem 2 — Count of smaller numbers after self (BIT)
For each nums[i], count how many elements to its right are smaller.
List<Integer> countSmaller(int[] nums) {
// 1. coordinate compression: map each value to its rank 1..m
int[] sorted = Arrays.stream(nums).distinct().sorted().toArray();
FenwickTree fw = new FenwickTree(sorted.length);
Integer[] result = new Integer[nums.length];
// 2. walk from the right; the BIT counts values already seen (to our right)
for (int i = nums.length - 1; i >= 0; i--) {
int rank = Arrays.binarySearch(sorted, nums[i]) + 1; // 1-indexed rank
result[i] = fw.prefixSum(rank - 1); // how many seen values are smaller
fw.add(rank, 1); // record this value
}
return Arrays.asList(result);
} // [5, 2, 6, 1] -> [2, 1, 1, 0]
Why it works: scanning right to left, the BIT holds a count for every value to the right of i (a frequency table indexed by value). "How many are smaller than nums[i]" is then a prefix sum over the ranks below it. Coordinate compression — replacing values by their rank among the distinct sorted values — is needed because values can be negative or up to 109, and a BIT index must be a small positive integer. Using rank − 1 excludes equal values, so duplicates are not counted as smaller. Time O(n log n) (sort, then n binary searches and BIT operations), space O(n). The alternative is a modified merge sort that counts how many right-half elements jump ahead of each left-half element — same complexity. The same BIT pattern counts inversions in an array.
Pitfalls
- Fenwick trees are 1-indexed. Calling
add(0, x)loops forever, because0 & -0is 0. - Segment tree array too small: use
4 * n, not2 * n, for the recursive version. - Returning the wrong neutral value on "no overlap" — 0 for sum,
Integer.MAX_VALUEfor min,Integer.MIN_VALUEfor max. - Overflow: sums of many ints can pass 231 − 1. Use
long[]when values or counts are large. - In lazy propagation, forgetting to
pushbefore going down in a query as well as in an update. - Reaching for these when the array never changes — a prefix sum array is simpler and O(1) per query.
19. Sorting
Sorting means putting items in order (smallest to largest, A to Z, oldest to newest). Almost every interview problem gets easier once the data is sorted: duplicates sit next to each other, you can binary search, you can use two pointers, and overlapping intervals line up.
There are two big families. Comparison sorts only ever ask "is a smaller than b?" — they can never beat O(n log n) in the worst case. Non-comparison sorts (counting, radix, bucket) look at the value itself (for example, its digits) and can run in linear time, but only for small whole numbers or evenly spread data.
Sorting a hand of playing cards: you pick up one card at a time and slide it into the right spot among the cards you already hold. That is insertion sort, and it is exactly what Java does for very small arrays.
Two words you must know. A sort is stable if equal items keep their original relative order (two people both aged 30 stay in the order they came in). A sort is in-place if it only uses O(1) (or O(log n) stack) extra memory instead of a second array.
All sorts at a glance
| Sort | Best | Average | Worst | Extra space | Stable | In-place | Use it when |
|---|---|---|---|---|---|---|---|
| Bubble | O(n)* | O(n²) | O(n²) | O(1) | Yes | Yes | Never in practice; teaching only |
| Selection | O(n²) | O(n²) | O(n²) | O(1) | No | Yes | Writes are very expensive (at most n swaps) |
| Insertion | O(n) | O(n²) | O(n²) | O(1) | Yes | Yes | Tiny arrays (under ~20) or almost-sorted data |
| Merge | O(n log n) | O(n log n) | O(n log n) | O(n) | Yes | No | Need stability or a guaranteed bound; linked lists; external sorting |
| Quick | O(n log n) | O(n log n) | O(n²) | O(log n) stack | No | Yes | Fastest general in-memory sort in practice |
| Heap | O(n log n) | O(n log n) | O(n log n) | O(1) | No | Yes | Guaranteed n log n with no extra memory |
| Counting | O(n + k) | O(n + k) | O(n + k) | O(n + k) | Yes | No | Integers in a small range 0..k (ages, grades, chars) |
| Radix | O(d(n + b)) | O(d(n + b)) | O(d(n + b)) | O(n + b) | Yes | No | Fixed-width keys: ints, phone numbers, strings of equal length |
| Bucket | O(n + k) | O(n + k) | O(n²) | O(n + k) | Yes** | No | Floats spread evenly over a known range |
* Bubble sort is O(n) only with the "stop if no swaps this pass" check. ** Bucket sort is stable if the sort used inside each bucket is stable. k = range of values, d = number of digits, b = base (10 or 256).
Insertion sort — the card-hand sort
Everything left of i is already sorted. Take a[i] out, shift every bigger item one step right, and drop it into the gap. On a nearly sorted array the inner loop barely runs, which is why it is O(n) in the best case and why real libraries use it for small pieces.
// Time O(n²) worst / O(n) best, Space O(1), stable
static void insertionSort(int[] a) {
for (int i = 1; i < a.length; i++) {
int key = a[i];
int j = i - 1;
while (j >= 0 && a[j] > key) { // shift bigger items one step right
a[j + 1] = a[j];
j--;
}
a[j + 1] = key; // drop key into the gap
}
}
// {5, 2, 4, 6, 1, 3} -> [1, 2, 3, 4, 5, 6]
Using a[j] > key (strictly greater) is what keeps it stable: an equal item never jumps over another equal item.
Merge sort — split in half, sort each half, merge
Keep cutting the array in half until each piece has one element (a single element is sorted by definition). Then merge pairs of sorted pieces: look at the front of both, take the smaller, repeat. Merging two sorted lists of total size n takes O(n), and there are log n levels of halving, so the total is O(n log n) — always, whatever the input.
// Time O(n log n) always, Space O(n), stable
static void mergeSort(int[] a) {
if (a.length < 2) return;
int[] tmp = new int[a.length]; // one buffer, reused by every merge
sort(a, tmp, 0, a.length - 1);
}
private static void sort(int[] a, int[] tmp, int lo, int hi) {
if (lo >= hi) return; // 0 or 1 element: already sorted
int mid = lo + (hi - lo) / 2;
sort(a, tmp, lo, mid);
sort(a, tmp, mid + 1, hi);
if (a[mid] <= a[mid + 1]) return; // halves already in order: skip merge
merge(a, tmp, lo, mid, hi);
}
private static void merge(int[] a, int[] tmp, int lo, int mid, int hi) {
int i = lo, j = mid + 1, k = lo;
while (i <= mid && j <= hi) {
// "<=" takes from the LEFT on ties: this is what makes it stable
tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
}
while (i <= mid) tmp[k++] = a[i++];
while (j <= hi) tmp[k++] = a[j++];
System.arraycopy(tmp, lo, a, lo, hi - lo + 1);
}
// {38, 27, 43, 3, 9, 82, 10} -> [3, 9, 10, 27, 38, 43, 82]
Quicksort — pick a pivot, partition, recurse
Pick one element, the pivot. Partition the array so everything smaller is on its left and everything else on its right. Now the pivot sits in its final sorted position forever. Recurse on the two sides. No merge step is needed — the work is done before the recursion, not after.
If the pivot is always the smallest or biggest element (for example, always taking the last element of an already-sorted array), one side is empty every time and you get O(n²). A random pivot makes that astronomically unlikely, giving expected O(n log n) on any input.
// Time: expected O(n log n), worst O(n²); Space O(log n) expected stack; not stable
static void quickSort(int[] a) { quickSort(a, 0, a.length - 1); }
private static void quickSort(int[] a, int lo, int hi) {
if (lo >= hi) return;
int p = partition(a, lo, hi); // a[p] is now in its final place
quickSort(a, lo, p - 1);
quickSort(a, p + 1, hi);
}
// Lomuto: returns final index of the pivot
static int partition(int[] a, int lo, int hi) {
int r = ThreadLocalRandom.current().nextInt(lo, hi + 1);
swap(a, r, hi); // move random pivot to the end
int pivot = a[hi];
int i = lo; // a[lo..i-1] are all < pivot
for (int j = lo; j < hi; j++) {
if (a[j] < pivot) swap(a, i++, j);
}
swap(a, i, hi); // pivot goes right after the "< pivot" block
return i;
}
static void swap(int[] a, int i, int j) { int t = a[i]; a[i] = a[j]; a[j] = t; }
Lomuto vs Hoare: Lomuto (above) is easier to write correctly. Hoare's scheme moves two pointers inward from both ends and swaps out-of-place pairs; it does about three times fewer swaps and handles many equal keys better, but the pivot does not end at the returned index, so the recursion is (lo, p) and (p + 1, hi). With lots of duplicates, Lomuto degrades toward O(n²); the fix is a 3-way partition (< pivot, == pivot, > pivot) — the same idea as the Dutch flag problem below.
Counting sort — tally, then place
When values are small whole numbers in 0..k, don't compare at all. Count how many times each value appears, turn counts into end positions with a running sum, then place each item. Walking the input backwards in the last step keeps equal items in their original order, which is what radix sort depends on.
// Time O(n + k), Space O(n + k), stable. Values must be in [0, k].
static int[] countingSort(int[] a, int k) {
int[] count = new int[k + 1];
for (int x : a) count[x]++; // 1. tally
for (int v = 1; v <= k; v++) count[v] += count[v - 1]; // 2. prefix sums = end positions
int[] out = new int[a.length];
for (int i = a.length - 1; i >= 0; i--) { // 3. walk backwards = stable
out[--count[a[i]]] = a[i];
}
return out;
}
// {4, 2, 2, 8, 3, 3, 1}, k = 8 -> [1, 2, 2, 3, 3, 4, 8]
Radix sort runs a stable counting sort once per digit, least significant digit first (base 10 or 256): O(d(n + b)). Bucket sort drops values into n buckets by range, sorts each small bucket, then concatenates — O(n) on average if data is evenly spread, O(n²) if everything lands in one bucket.
What Java's Arrays.sort actually does
| Call | Algorithm | Guarantees |
|---|---|---|
Arrays.sort(int[]) (any primitive array) | Dual-pivot quicksort (two pivots, three parts); insertion sort for tiny ranges; modern JDKs fall back to heap sort if partitioning goes badly | O(n log n), in-place, not stable |
Arrays.sort(T[]), Arrays.sort(T[], cmp), Collections.sort, List.sort | TimSort — finds already-sorted "runs", extends short ones with insertion sort, then merges runs like merge sort | O(n log n) worst, O(n) on sorted input, O(n) extra memory, stable |
Arrays.parallelSort | Parallel merge sort on the fork/join pool | Only faster for big arrays (roughly 10k+ elements) |
Why two different algorithms? Stability. For primitives, two equal ints are indistinguishable, so stability is meaningless and Java picks the fastest in-place option: quicksort. For objects, two "equal" items can differ (two employees with the same salary but different names). If you sort by name and then by salary, you expect the names to stay in order within each salary — that needs a stable sort. TimSort is also great at real-world data, which is often partly sorted, and it uses few comparisons, which matters when each comparison is a (slow) compareTo call.
Sorting with comparators
A Comparator<T> returns a negative number if a comes first, 0 if equal, positive if b comes first. Primitive arrays cannot take a comparator — use Integer[] or sort int[][] rows.
record Person(String name, int age) {}
List<Person> people = new ArrayList<>(List.of(
new Person("Asha", 30), new Person("Ben", 25), new Person("Cara", 30), new Person("Dev", 25)));
// age descending, then name ascending
people.sort(Comparator.comparingInt(Person::age).reversed().thenComparing(Person::name));
// [Asha 30, Cara 30, Ben 25, Dev 25]
Integer[] boxed = {5, 1, 4};
Arrays.sort(boxed, Collections.reverseOrder()); // [5, 4, 1]
int[][] pts = {{3, 1}, {1, 2}, {1, 1}}; // by x, then by y
Arrays.sort(pts, (p, q) -> p[0] != q[0] ? Integer.compare(p[0], q[0]) : Integer.compare(p[1], q[1]));
// [[1, 1], [1, 2], [3, 1]]
(a, b) -> a - b. Subtraction overflows: comparing Integer.MIN_VALUE with 1 gives 2147483647 (positive, so "MIN_VALUE is bigger" — wrong). Always use Integer.compare(a, b) or Comparator.comparingInt.Quickselect — the k-th smallest without sorting everything
Partition once, exactly like quicksort. The pivot lands at index p. If p == k, done. Otherwise, the answer is only on one side, so recurse into that side only. Expected work is n + n/2 + n/4 + ... = 2n, which is O(n).
// k-th smallest, k is 0-based. Time: expected O(n), worst O(n²). Space O(1).
static int quickSelect(int[] a, int k) {
int lo = 0, hi = a.length - 1;
while (lo < hi) {
int p = partition(a, lo, hi); // same random-pivot Lomuto as above
if (p == k) return a[p];
if (p < k) lo = p + 1; else hi = p - 1;
}
return a[lo];
}
// LeetCode 215: k-th LARGEST
static int findKthLargest(int[] nums, int k) {
return quickSelect(nums, nums.length - k); // k-th largest = (n-k)-th smallest
}
// {3, 2, 1, 5, 6, 4}, k = 2 -> 5
Alternative: a min-heap of size k gives O(n log k) time with a guaranteed bound and works on a stream. Quickselect is faster on average but changes the array.
Problem: Sort Colors (Dutch national flag)
Sort an array of 0s, 1s and 2s in one pass, in place. Keep three regions: [0, low) are 0s, [low, mid) are 1s, (high, end] are 2s; [mid, high] is unknown.
// Time O(n), one pass. Space O(1).
static void sortColors(int[] nums) {
int low = 0, mid = 0, high = nums.length - 1;
while (mid <= high) {
if (nums[mid] == 0) swap(nums, low++, mid++);
else if (nums[mid] == 1) mid++;
else swap(nums, mid, high--); // don't move mid: new value unchecked
}
}
// {2, 0, 2, 1, 1, 0} -> [0, 0, 1, 1, 2, 2]
Why this works: each step shrinks the unknown region by one while keeping all three regions correct. When swapping with high, the value that arrives at mid has not been looked at yet, so mid must stay put. When swapping with low, the value arriving is already known to be a 1 (it came from the 1s region), so both pointers advance.
Problem: Merge Intervals (preview)
// Time O(n log n) for the sort, Space O(n) for the output
static int[][] mergeIntervals(int[][] intervals) {
Arrays.sort(intervals, (x, y) -> Integer.compare(x[0], y[0]));
List<int[]> out = new ArrayList<>();
for (int[] cur : intervals) {
if (out.isEmpty() || out.get(out.size() - 1)[1] < cur[0]) {
out.add(cur); // gap: start a new interval
} else {
int[] last = out.get(out.size() - 1);
last[1] = Math.max(last[1], cur[1]); // overlap: stretch the last one
}
}
return out.toArray(new int[0][]);
}
// {{1,3},{8,10},{2,6},{15,18}} -> [[1, 6], [8, 10], [15, 18]]
Why this works: after sorting by start, any interval that overlaps the current merged block must come right after it — nothing further down the list can start earlier. So one left-to-right sweep is enough.
Problem: Largest Number
Arrange numbers to form the largest possible number: [3, 30, 34, 5, 9] becomes "9534330". Normal numeric order fails (30 > 3, but "330" > "303"). The trick is a custom comparator: put a before b if the string a + b is bigger than b + a.
// Time O(n log n * L) where L = max digits, Space O(n * L)
static String largestNumber(int[] nums) {
String[] s = new String[nums.length];
for (int i = 0; i < nums.length; i++) s[i] = String.valueOf(nums[i]);
// a goes before b if a+b is bigger than b+a
Arrays.sort(s, (a, b) -> (b + a).compareTo(a + b));
if (s[0].equals("0")) return "0"; // all zeros
return String.join("", s);
}
// {3, 30, 34, 5, 9} -> "9534330"; {0, 0} -> "0"
Why this works: this "concatenation order" is transitive (a proven property), so it is a valid total order and sorting by it puts every adjacent pair in the best order — and if no adjacent swap improves the result, the whole thing is optimal. Don't forget the all-zeros edge case.
Problem: Count Inversions (via merge sort)
An inversion is a pair i < j with a[i] > a[j]. It measures "how unsorted" an array is. Brute force is O(n²). During the merge step, when we take an element from the right half, it is smaller than every element still waiting in the left half — that is mid - i + 1 inversions counted in one go.
// Time O(n log n), Space O(n). Returns long: n = 100k can give ~5 * 10^9 inversions.
static long countInversions(int[] a) {
return countSort(a.clone(), new int[a.length], 0, a.length - 1);
}
private static long countSort(int[] a, int[] tmp, int lo, int hi) {
if (lo >= hi) return 0;
int mid = lo + (hi - lo) / 2;
long count = countSort(a, tmp, lo, mid) + countSort(a, tmp, mid + 1, hi);
int i = lo, j = mid + 1, k = lo;
while (i <= mid && j <= hi) {
if (a[i] <= a[j]) {
tmp[k++] = a[i++];
} else {
count += mid - i + 1; // a[i..mid] are ALL bigger than a[j]
tmp[k++] = a[j++];
}
}
while (i <= mid) tmp[k++] = a[i++];
while (j <= hi) tmp[k++] = a[j++];
System.arraycopy(tmp, lo, a, lo, hi - lo + 1);
return count;
}
// {8, 4, 2, 1} -> 6; {2, 4, 1, 3, 5} -> 3
Why this works: every inversion is either inside the left half, inside the right half (both counted by recursion), or split across the halves. Because both halves are sorted at merge time, split inversions can be counted in bulk instead of pair by pair.
mid = (lo + hi) / 2 can overflow for huge indexes — use lo + (hi - lo) / 2. Use <= in the merge to stay stable (and, for inversions, to not count equal pairs). A fixed first/last pivot turns quicksort into O(n²) on sorted input. Sorting an int[] in descending order needs boxing to Integer[] or sorting then reversing.20. Binary Search
Binary search finds something in sorted data by looking at the middle, deciding which half the answer must be in, and throwing the other half away. Each step halves the search space, so a million items take only about 20 steps: O(log n).
The real skill is not "find a number in a sorted array". It is spotting any problem where the answer space splits into a "no" part followed by a "yes" part (F F F T T T) and finding the boundary.
Guessing a number between 1 and 100 when someone says "higher" or "lower". You always guess the middle; you never need more than 7 guesses.
The invariant — what makes binary search correct
An invariant is a statement that is true before every loop iteration. For binary search it is: "if the answer exists, it is inside the range [lo, hi)." Every update must keep that true, and every iteration must make the range strictly smaller. If both hold, the loop ends with lo == hi pointing at the answer. Nearly every binary search bug is a violation of one of those two rules (dropping the answer, or a range that stops shrinking and loops forever).
The one template to remember: lower bound
"Find the first index where a condition becomes true." For a sorted array and the condition a[i] >= target, that index is called the lower bound. Use a half-open range [lo, hi) with hi = a.length, because the answer may be "past the end" (every element is smaller).
// First index where a[i] >= target (a.length if none). Time O(log n), Space O(1)
static int lowerBound(int[] a, int target) {
int lo = 0, hi = a.length; // half-open [lo, hi): answer may be a.length
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (a[mid] >= target) hi = mid; // mid could be the answer: keep it
else lo = mid + 1; // mid is too small: throw it away
}
return lo; // lo == hi == the boundary
}
// First index where a[i] > target. Same shape, only the condition changes.
static int upperBound(int[] a, int target) {
int lo = 0, hi = a.length;
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (a[mid] > target) hi = mid;
else lo = mid + 1;
}
return lo;
}
// a = {1, 3, 3, 3, 5, 8, 9}
// lowerBound(a, 3) = 1, upperBound(a, 3) = 4, lowerBound(a, 4) = 4, lowerBound(a, 10) = 7
Why mid = lo + (hi - lo) / 2 and not (lo + hi) / 2? If lo = 2,000,000,000 and hi = 2,100,000,000, their sum is bigger than Integer.MAX_VALUE and wraps to a negative number: (lo + hi) / 2 gives -97483648. The subtraction form gives 2050000000. (lo + hi) >>> 1 (unsigned shift) also works.
Why does this loop always end? mid rounds down, so mid < hi always. Setting hi = mid shrinks the range, and lo = mid + 1 shrinks it too. No infinite loop is possible.
Variants built from the same template
| Question | Answer |
|---|---|
| Does target exist? | i = lowerBound(a, t); exists if i < n && a[i] == t |
| First occurrence | lowerBound(a, t) (then check it equals t) |
| Last occurrence | upperBound(a, t) - 1 (then check) |
| Count of t | upperBound(a, t) - lowerBound(a, t) |
| Insert position (LeetCode 35) | lowerBound(a, t) |
| Largest element <= t (floor) | upperBound(a, t) - 1 (-1 means none) |
| Smallest element >= t (ceiling) | lowerBound(a, t) (n means none) |
Java has built-ins: Arrays.binarySearch(a, key) returns some matching index (not necessarily the first!) or -(insertionPoint) - 1 if missing. TreeMap/TreeSet give floor, ceiling, lower, higher in O(log n).
Problem: First and Last Position (LeetCode 34)
// Time O(log n), Space O(1)
static int[] searchRange(int[] a, int target) {
int first = lowerBound(a, target);
if (first == a.length || a[first] != target) return new int[]{-1, -1};
int last = upperBound(a, target) - 1;
return new int[]{first, last};
}
// {5, 7, 7, 8, 8, 10}, 8 -> [3, 4]; target 6 -> [-1, -1]
Why this works: the first 8 is the first index with value >= 8; the last 8 is just before the first index with value > 8. Two O(log n) searches, never a linear scan over the duplicates (which would be O(n) if the whole array were 8s).
Problem: Search in Rotated Sorted Array (LeetCode 33)
[4, 5, 6, 7, 0, 1, 2] is a sorted array rotated at some point. Cut it at mid: at least one half is always normally sorted. Check if the target lies inside that sorted half's range; if yes go there, otherwise go to the other half.
// Time O(log n), Space O(1). Assumes distinct values.
static int searchRotated(int[] a, int target) {
int lo = 0, hi = a.length - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
if (a[mid] == target) return mid;
if (a[lo] <= a[mid]) { // left half [lo..mid] is sorted
if (a[lo] <= target && target < a[mid]) hi = mid - 1;
else lo = mid + 1;
} else { // right half [mid..hi] is sorted
if (a[mid] < target && target <= a[hi]) lo = mid + 1;
else hi = mid - 1;
}
}
return -1;
}
// {4, 5, 6, 7, 0, 1, 2}, 0 -> 4; target 3 -> -1
Why this works: in the sorted half, a simple range check tells us for sure whether the target can be there. If it can't, it can only be in the other half. Note a[lo] <= a[mid] uses <= because when lo == mid the "left half" is a single element, which is sorted. Related: find the minimum of a rotated array — if a[mid] > a[hi] the drop is to the right (lo = mid + 1), else hi = mid.
// Time O(log n). {4, 5, 6, 7, 0, 1, 2} -> 0
static int findMinRotated(int[] a) {
int lo = 0, hi = a.length - 1;
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (a[mid] > a[hi]) lo = mid + 1; // min is strictly right of mid
else hi = mid; // mid might be the min
}
return a[lo];
}
Problem: Find Peak Element (LeetCode 162)
The array is not sorted, yet binary search works. A peak is bigger than its neighbours (edges count as minus infinity). If a[mid] < a[mid + 1], we're walking uphill to the right, so a peak must exist on the right (either it keeps rising to the edge, or it turns down somewhere).
// Time O(log n), Space O(1)
static int findPeakElement(int[] a) {
int lo = 0, hi = a.length - 1;
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (a[mid] < a[mid + 1]) lo = mid + 1; // uphill to the right: a peak is there
else hi = mid; // downhill: mid or something left is a peak
}
return lo;
}
// {1, 2, 3, 1} -> 2; {1, 2, 1, 3, 5, 6, 4} -> 5
Why this works: the invariant is "a peak exists in [lo, hi]". Binary search needs a rule that tells you which half keeps the answer — not necessarily a sorted array. mid + 1 is safe because lo < hi means mid < hi.
Binary search on the answer
Many "minimise the maximum" or "find the smallest X such that..." problems don't search an array at all. They search the range of possible answers. You need two things: a range [lo, hi] that surely contains the answer, and a feasible(x) check that is monotonic — once it becomes true, it stays true for all bigger x. Then find the first true.
// Smallest x in [lo, hi] with feasible(x) == true. feasible must look like F F F T T T.
// Time O(log(hi - lo) * cost of feasible)
static int firstTrue(int lo, int hi, IntPredicate feasible) {
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (feasible.test(mid)) hi = mid; // mid works: answer is mid or smaller
else lo = mid + 1; // mid fails: answer is bigger
}
return lo;
}
Problem: Koko Eating Bananas (LeetCode 875)
Koko eats at speed k bananas per hour, one pile at a time; a pile of p takes ceil(p / k) hours. Find the slowest k that finishes within h hours. Faster is always at least as good — monotonic.
// Time O(n log M) where M = max pile, Space O(1)
static int minEatingSpeed(int[] piles, int h) {
int lo = 1, hi = Arrays.stream(piles).max().getAsInt();
while (lo < hi) {
int k = lo + (hi - lo) / 2;
if (hoursNeeded(piles, k) <= h) hi = k; // k works: try slower
else lo = k + 1; // too slow: must go faster
}
return lo;
}
static long hoursNeeded(int[] piles, int k) {
long hours = 0;
for (int p : piles) hours += (p + k - 1) / k; // ceil(p / k) without doubles
return hours;
}
// {3, 6, 7, 11}, h = 8 -> 4; {30, 11, 23, 4, 20}, h = 5 -> 30
Problem: Capacity to Ship Packages Within D Days (LeetCode 1011)
// Time O(n log(sum)), Space O(1)
static int shipWithinDays(int[] weights, int days) {
int lo = 0, hi = 0;
for (int w : weights) { lo = Math.max(lo, w); hi += w; } // must fit the heaviest; one day ships all
while (lo < hi) {
int cap = lo + (hi - lo) / 2;
if (daysNeeded(weights, cap) <= days) hi = cap;
else lo = cap + 1;
}
return lo;
}
static int daysNeeded(int[] weights, int cap) {
int days = 1, load = 0;
for (int w : weights) {
if (load + w > cap) { days++; load = 0; } // start a new day
load += w;
}
return days;
}
// {1..10}, days = 5 -> 15
Problem: Split Array Largest Sum (LeetCode 410)
Split into k contiguous parts to minimise the largest part's sum. Same shape as shipping: "can I split so every part is <= X using at most k parts?" is monotonic in X, and the greedy check (fill a part until it would overflow) is optimal for a fixed X.
// Time O(n log(sum)), Space O(1). long guards against sum overflow.
static int splitArray(int[] nums, int k) {
long lo = 0, hi = 0;
for (int x : nums) { lo = Math.max(lo, x); hi += x; }
while (lo < hi) {
long mid = lo + (hi - lo) / 2;
if (piecesNeeded(nums, mid) <= k) hi = mid;
else lo = mid + 1;
}
return (int) lo;
}
static int piecesNeeded(int[] nums, long maxSum) {
int pieces = 1; long sum = 0;
for (int x : nums) {
if (sum + x > maxSum) { pieces++; sum = 0; }
sum += x;
}
return pieces;
}
// {7, 2, 5, 10, 8}, k = 2 -> 18 ([7,2,5] and [10,8])
Why these work: the answer lies between "the biggest single item" (no smaller value can ever be feasible) and "the total" (always feasible). The check is monotonic, so binary search finds the exact boundary in about 30–40 checks instead of trying every value.
Problem: Sqrt(x) (LeetCode 69)
// Integer square root, rounded down. Time O(log x), Space O(1)
static int mySqrt(int x) {
long lo = 0, hi = (long) x + 1; // search the first m with m*m > x
while (lo < hi) {
long mid = lo + (hi - lo) / 2;
if (mid * mid > x) hi = mid; // long: mid*mid never overflows
else lo = mid + 1;
}
return (int) (lo - 1); // last m with m*m <= x
}
// 8 -> 2, 16 -> 4, 0 -> 0, Integer.MAX_VALUE -> 46340
Why this works: "m*m > x" is F F F T T T over m, so the first true minus one is the floor of the square root. Using int for mid * mid would overflow for x near 2^31.
Common bugs
| Bug | Symptom | Fix |
|---|---|---|
(lo + hi) / 2 | Negative index on huge ranges | lo + (hi - lo) / 2 |
lo = mid with rounding-down mid | Infinite loop when hi = lo + 1 | Use lo = mid + 1, or round mid up: lo + (hi - lo + 1) / 2 |
Mixing styles: while (lo <= hi) with hi = mid | Infinite loop | Closed [lo, hi] pairs with hi = mid - 1; half-open [lo, hi) pairs with hi = mid |
Wrong initial hi | Misses "insert at end" or the largest answer | Ask: can the answer be n? Can it be the max value? |
| Non-monotonic predicate | Random wrong answers | Write out F/T for a small example before coding |
int overflow in feasibility sums | Wrong answer on big inputs | Accumulate in long |
21. Recursion & Backtracking
Backtracking is a way to try every possible option in an organised way. You build a solution one step at a time. At each step you choose an option, explore everything that follows from it (by calling yourself recursively), then un-choose it and try the next option. If a partial solution already breaks a rule, you stop exploring that branch early — that is pruning.
Use it when the problem says "return all ..." (all subsets, all permutations, all valid boards) or "does any arrangement exist?" and the input is small (n up to about 10–20).
Walking through a maze with a piece of chalk. At every fork you pick a path and mark it. At a dead end, you walk back to the last fork, rub out the mark, and try the next path.
Choose, explore, un-choose
Picture every run of a backtracking function as a decision tree. The root is the empty solution. Each edge is one choice. Each leaf is either a complete answer or a dead end. The recursion does a depth-first walk of this tree, and the call stack is the current path from the root. Because one shared list (path) is reused for every branch, you must undo each change after the recursive call returns — otherwise the next branch starts with garbage in it.
The generic template
// Shape of every backtracking solution. Fill in the four helpers per problem.
static void backtrack(List<Integer> path, int start) {
if (isComplete(path)) { // 1. goal reached: record a COPY
result.add(new ArrayList<>(path));
return;
}
for (int choice : choices(start)) { // 2. try every option at this step
if (!isValid(path, choice)) continue; // prune dead branches early
path.add(choice); // 3. choose
backtrack(path, choice + 1); // 4. explore
path.remove(path.size() - 1); // 5. un-choose (undo exactly step 3)
}
}
Three questions design any backtracking solution: What is the state? (the path plus an index or a used[] array). What are the choices at each step? When is it complete, and when is it hopeless?
result.add(path) instead of result.add(new ArrayList<>(path)). All entries point to the same list, which ends up empty after all the un-chooses, so you return a list of empty lists. Always add a copy.Pruning — cutting branches early
Pruning does not change the worst-case Big-O on paper, but in practice it is the difference between "instant" and "timeout". Typical prunes: sort the input and break once a candidate is too big (combination sum); check constraints the moment you place something instead of at the end (N-Queens); skip duplicates with if (i > start && nums[i] == nums[i - 1]) continue; after sorting (Subsets II, Combination Sum II); stop if there aren't enough items left to finish.
Problem: Subsets (LeetCode 78)
// Time O(n * 2^n): 2^n subsets, each copied in O(n). Space O(n) recursion (excluding output)
static List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
subsetsBt(nums, 0, new ArrayList<>(), res);
return res;
}
static void subsetsBt(int[] nums, int start, List<Integer> path, List<List<Integer>> res) {
res.add(new ArrayList<>(path)); // every node of the tree is a subset
for (int i = start; i < nums.length; i++) {
path.add(nums[i]); // choose
subsetsBt(nums, i + 1, path, res); // explore (only later elements)
path.remove(path.size() - 1); // un-choose
}
}
// {1, 2, 3} -> [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
Why this works: the start index means we only ever add elements to the right of the last one picked, so each subset is built in exactly one order and appears exactly once. Alternative view: for each element, "take it or skip it" — two choices, n times, 2^n leaves.
Problem: Permutations (LeetCode 46)
// Time O(n * n!): n! leaves, each copied in O(n). Space O(n)
static List<List<Integer>> permute(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
permBt(nums, new boolean[nums.length], new ArrayList<>(), res);
return res;
}
static void permBt(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> res) {
if (path.size() == nums.length) { // a full arrangement
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
if (used[i]) continue;
used[i] = true; path.add(nums[i]); // choose
permBt(nums, used, path, res); // explore
path.remove(path.size() - 1); used[i] = false; // un-choose
}
}
// {1, 2, 3} -> [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
Why this works: order matters for permutations, so every step may pick any unused element (loop from 0, not from start). The used[] array is part of the state, so it must be un-chosen too. Subsets vs permutations in one line: subsets loop from start, permutations loop from 0 with a used check.
Problem: Combination Sum (LeetCode 39)
// Each candidate may be used many times.
// Time O(N^(T/M + 1)) worst case (N candidates, T target, M smallest candidate). Space O(T/M) depth
static List<List<Integer>> combinationSum(int[] candidates, int target) {
Arrays.sort(candidates); // sorted lets us break early
List<List<Integer>> res = new ArrayList<>();
combBt(candidates, target, 0, new ArrayList<>(), res);
return res;
}
static void combBt(int[] c, int remain, int start, List<Integer> path, List<List<Integer>> res) {
if (remain == 0) { res.add(new ArrayList<>(path)); return; }
for (int i = start; i < c.length; i++) {
if (c[i] > remain) break; // PRUNE: rest are even bigger
path.add(c[i]);
combBt(c, remain - c[i], i, path, res); // i, not i+1: same number may repeat
path.remove(path.size() - 1);
}
}
// {2, 3, 6, 7}, 7 -> [[2, 2, 3], [7]]; {2, 3, 5}, 8 -> [[2, 2, 2, 2], [2, 3, 3], [3, 5]]
Why this works: passing i (not i + 1) lets a number be reused, while never going back to earlier numbers prevents duplicates like [3,2,2]. The sorted break kills entire subtrees at once.
Problem: N-Queens (LeetCode 51)
Place n queens on an n x n board so none attack each other. Put exactly one queen per row, so the only choice is the column. A square (r, c) is attacked if its column, its "/" diagonal (r + c is the same along it) or its "\" diagonal (r - c is the same) is taken. Three boolean arrays make the check O(1).
// Time O(n!) — row 0 has n options, row 1 at most n-1, ... Space O(n)
static List<List<String>> solveNQueens(int n) {
List<List<String>> res = new ArrayList<>();
int[] queenCol = new int[n]; // queenCol[r] = column of queen in row r
boolean[] cols = new boolean[n];
boolean[] diag = new boolean[2 * n - 1]; // r + c is constant on a "/" diagonal
boolean[] anti = new boolean[2 * n - 1]; // r - c + n - 1 is constant on a "\" diagonal
placeRow(0, n, queenCol, cols, diag, anti, res);
return res;
}
static void placeRow(int r, int n, int[] queenCol, boolean[] cols, boolean[] diag,
boolean[] anti, List<List<String>> res) {
if (r == n) { res.add(draw(queenCol)); return; }
for (int c = 0; c < n; c++) {
int d = r + c, a = r - c + n - 1;
if (cols[c] || diag[d] || anti[a]) continue; // attacked: prune
queenCol[r] = c; cols[c] = diag[d] = anti[a] = true;
placeRow(r + 1, n, queenCol, cols, diag, anti, res);
cols[c] = diag[d] = anti[a] = false; // un-choose
}
}
static List<String> draw(int[] queenCol) {
int n = queenCol.length;
List<String> board = new ArrayList<>();
for (int c : queenCol) {
char[] row = new char[n];
Arrays.fill(row, '.');
row[c] = 'Q';
board.add(new String(row));
}
return board;
}
// n = 4 -> [[.Q.., ...Q, Q..., ..Q.], [..Q., Q..., ...Q, .Q..]]
// solution counts for n = 1..8: 1 0 0 2 10 4 40 92
Why this works: one-queen-per-row is built into the structure, so row conflicts are impossible. Columns and diagonals are checked on placement, so every complete board reached is valid. queenCol[r] doesn't need un-choosing because it is overwritten on the next try.
Problem: Word Search (LeetCode 79)
// Time O(R * C * 3^L): every start cell, then at most 3 new directions per letter (L = word length)
// Space O(L) recursion
static boolean exist(char[][] board, String word) {
for (int r = 0; r < board.length; r++)
for (int c = 0; c < board[0].length; c++)
if (dfs(board, word, r, c, 0)) return true;
return false;
}
static boolean dfs(char[][] b, String word, int r, int c, int i) {
if (i == word.length()) return true;
if (r < 0 || c < 0 || r >= b.length || c >= b[0].length || b[r][c] != word.charAt(i))
return false;
char saved = b[r][c];
b[r][c] = '#'; // choose: mark as visited
boolean found = dfs(b, word, r + 1, c, i + 1) || dfs(b, word, r - 1, c, i + 1)
|| dfs(b, word, r, c + 1, i + 1) || dfs(b, word, r, c - 1, i + 1);
b[r][c] = saved; // un-choose: restore the cell
return found;
}
// board ABCE / SFCS / ADEE: "ABCCED" -> true, "SEE" -> true, "ABCB" -> false
Why this works: marking the cell in place (instead of a separate visited set) stops a path from reusing a letter, and restoring it lets other paths use that cell later. The || chain short-circuits, so the search stops the moment one path succeeds.
Problem: Palindrome Partitioning (LeetCode 131)
// Time O(n * 2^n): 2^(n-1) ways to cut, O(n) to check/copy each. Space O(n)
static List<List<String>> partition(String s) {
List<List<String>> res = new ArrayList<>();
partBt(s, 0, new ArrayList<>(), res);
return res;
}
static void partBt(String s, int start, List<String> path, List<List<String>> res) {
if (start == s.length()) { res.add(new ArrayList<>(path)); return; }
for (int end = start; end < s.length(); end++) {
if (!isPal(s, start, end)) continue; // prune: only cut after a palindrome
path.add(s.substring(start, end + 1));
partBt(s, end + 1, path, res);
path.remove(path.size() - 1);
}
}
static boolean isPal(String s, int i, int j) {
while (i < j) if (s.charAt(i++) != s.charAt(j--)) return false;
return true;
}
// "aab" -> [[a, a, b], [aa, b]]
Why this works: the choice at each step is "where does the next piece end?". Only palindromic pieces are allowed, and when start reaches the end, the whole string is covered. Speed-up: precompute a boolean[][] isPal table with DP in O(n²) so each check is O(1).
Complexity cheat sheet
| Problem | Tree shape | Time |
|---|---|---|
| Subsets | take / skip each item | O(n * 2^n) |
| Permutations | n, then n-1, then n-2 choices... | O(n * n!) |
| Combinations (n choose k) | subsets of size k | O(k * C(n, k)) |
| Combination sum | branching N, depth T/M | O(N^(T/M + 1)) |
| N-Queens | n rows, shrinking column options | O(n!) |
| Word search | 4 then 3 directions per letter | O(R * C * 3^L) |
| Palindrome partitioning | cut or don't cut between letters | O(n * 2^n) |
22. Greedy
A greedy algorithm builds the answer step by step, and at every step takes the option that looks best right now — without ever going back to change an earlier decision. No trying all options, no table of sub-answers.
When it works, greedy is the simplest and fastest solution (often just "sort, then one pass", O(n log n)). The catch: it only works for problems with a special structure, and a greedy idea that "feels right" is often wrong. The hard part is being sure.
Giving change at a shop counter: hand over the biggest coin that fits, then the next biggest, and so on. With real-world coins (25, 10, 5, 1) that always gives the fewest coins. With strange coins it can fail — more on that below.
When greedy works
Two properties must hold:
- Greedy choice property — there is always an optimal solution that starts with the greedy choice. In other words, taking the "locally best" option never rules out reaching the best overall answer.
- Optimal substructure — after making that choice, what remains is a smaller version of the same problem, and solving it optimally gives an optimal overall answer.
The exchange argument (how you prove it, in easy words): take any optimal solution. If it doesn't use the greedy choice, swap one of its choices for the greedy one and show the result is still valid and no worse. Then an optimal solution with the greedy choice exists, so greedy is safe. Example for activity selection: an optimal schedule's first meeting ends at time E; the greedy first meeting ends at time e <= E (it's the earliest-ending one). Swap them — everything after still fits, because we finish even earlier. Same count, still optimal.
When greedy fails — the coin change counterexample
// Greedy coin change: always take the biggest coin that fits. Time O(number of coins)
static int greedyCoins(int[] coinsDesc, int amount) {
int count = 0;
for (int c : coinsDesc) {
count += amount / c;
amount %= c;
}
return amount == 0 ? count : -1;
}
// greedyCoins({4, 3, 1}, 6) = 3, but the true minimum (coin-change DP) is 2
Lesson: before trusting greedy, try to break it with a tiny counterexample. If you find one, the problem probably needs DP (see the Dynamic Programming section). Same story for the 0/1 knapsack (greedy by value/weight fails) versus the fractional knapsack (greedy by value/weight is optimal, because you can take part of an item).
The usual recipe
- Decide what to sort by (end time? start time? size? ratio?). This is usually the whole insight.
- Sweep once, making the locally best decision and keeping a small amount of state.
- Sanity check with a counterexample hunt, then argue with an exchange argument.
Problem: Activity Selection / Non-overlapping Intervals (LeetCode 435)
Remove the fewest intervals so the rest don't overlap. Equivalent: keep the most non-overlapping intervals. Greedy rule: sort by end time and always keep the interval that finishes first — it leaves the most room for everything after.
// Time O(n log n), Space O(1) beyond the sort
static int eraseOverlapIntervals(int[][] intervals) {
Arrays.sort(intervals, (a, b) -> Integer.compare(a[1], b[1])); // by END time
int kept = 0;
int lastEnd = Integer.MIN_VALUE;
for (int[] in : intervals) {
if (in[0] >= lastEnd) { // starts after the last kept one ends
kept++;
lastEnd = in[1];
}
}
return intervals.length - kept;
}
// {{1,2},{2,3},{3,4},{1,3}} -> 1; {{1,2},{1,2},{1,2}} -> 2
Why this works: the exchange argument above. Sorting by start time is the classic wrong answer: one long interval starting first would block many short ones.
Problem: Jump Game (LeetCode 55)
nums[i] is the maximum jump length from index i. Can you reach the last index? Track the farthest index reachable so far. If you ever stand on an index beyond it, you're stuck.
// Time O(n), Space O(1)
static boolean canJump(int[] nums) {
int farthest = 0;
for (int i = 0; i < nums.length; i++) {
if (i > farthest) return false; // we can't even stand here
farthest = Math.max(farthest, i + nums[i]);
}
return true;
}
// {2, 3, 1, 1, 4} -> true; {3, 2, 1, 0, 4} -> false (everything stops at index 3)
Why this works: every index up to farthest is reachable (you can always jump shorter than the max). So we don't need to know which jumps to take — only how far the reachable region extends.
Problem: Jump Game II (LeetCode 45) — minimum jumps
Think of it as BFS in levels without a queue: all indexes reachable with 1 jump form level 1, those with 2 jumps form level 2, and so on. curEnd is the end of the current level; farthest is how far the next level reaches.
// Time O(n), Space O(1). Assumes the end is reachable.
static int jump(int[] nums) {
int jumps = 0, curEnd = 0, farthest = 0;
for (int i = 0; i < nums.length - 1; i++) { // stop before last: no jump needed from it
farthest = Math.max(farthest, i + nums[i]);
if (i == curEnd) { // used up this jump's range
jumps++;
curEnd = farthest; // next "level" of reach
}
}
return jumps;
}
// {2, 3, 1, 1, 4} -> 2; {2, 3, 0, 1, 4} -> 2; {0} -> 0
Pitfall: looping to nums.length instead of nums.length - 1 counts one extra jump when a level ends exactly on the last index.
Problem: Gas Station (LeetCode 134)
Circular route; gas[i] fuel at station i, cost[i] to drive to the next. Find the start index that completes the loop (unique if it exists). Two facts: (1) if total gas >= total cost, a solution exists; (2) if you start at s and run dry before reaching i + 1, then no station between s and i can be the start either — so jump the start straight to i + 1.
// Time O(n), Space O(1)
static int canCompleteCircuit(int[] gas, int[] cost) {
int total = 0, tank = 0, start = 0;
for (int i = 0; i < gas.length; i++) {
int diff = gas[i] - cost[i];
total += diff;
tank += diff;
if (tank < 0) { // can't reach i+1 from start: no station in [start..i] works
start = i + 1;
tank = 0;
}
}
return total >= 0 ? start : -1;
}
// gas {1,2,3,4,5}, cost {3,4,5,1,2} -> 3; gas {2,3,4}, cost {3,4,3} -> -1
Why this works: any station k inside [start, i] was reached with a tank >= 0, so starting at k with an empty tank can only be worse — it would also fail at i. That's why one pass is enough.
Problem: Assign Cookies (LeetCode 455)
// Time O(n log n + m log m), Space O(1) beyond sorting
static int findContentChildren(int[] greed, int[] cookies) {
Arrays.sort(greed);
Arrays.sort(cookies);
int child = 0;
for (int c = 0; c < cookies.length && child < greed.length; c++) {
if (cookies[c] >= greed[child]) child++; // smallest cookie that satisfies this child
}
return child;
}
// greed {1,2,3}, cookies {1,1} -> 1; greed {1,2}, cookies {1,2,3} -> 2
Why this works: give the least greedy child the smallest cookie that satisfies them. Using a bigger cookie there would never help — by exchange, it can only take a cookie some greedier child could have used.
Problem: Meeting Rooms II / Minimum Platforms
Given meetings (or train arrival/departure times), find the minimum rooms (platforms) so nothing clashes. The answer is the maximum number of meetings happening at the same moment. Sweep start times in order and free up rooms whose meetings have ended.
// Two sorted arrays + two pointers. Time O(n log n), Space O(n)
static int minMeetingRooms(int[][] meetings) {
int n = meetings.length;
int[] starts = new int[n], ends = new int[n];
for (int i = 0; i < n; i++) { starts[i] = meetings[i][0]; ends[i] = meetings[i][1]; }
Arrays.sort(starts);
Arrays.sort(ends);
int rooms = 0, maxRooms = 0, e = 0;
for (int s = 0; s < n; s++) {
while (e < n && ends[e] <= starts[s]) { e++; rooms--; } // free rooms that ended
rooms++;
maxRooms = Math.max(maxRooms, rooms);
}
return maxRooms;
}
// Same answer with a min-heap of end times (one entry per busy room). Time O(n log n), Space O(n)
static int minMeetingRoomsHeap(int[][] meetings) {
Arrays.sort(meetings, (a, b) -> Integer.compare(a[0], b[0]));
PriorityQueue<Integer> endTimes = new PriorityQueue<>();
for (int[] m : meetings) {
if (!endTimes.isEmpty() && endTimes.peek() <= m[0]) endTimes.poll(); // reuse a room
endTimes.offer(m[1]);
}
return endTimes.size();
}
// {{0,30},{5,10},{15,20}} -> 2
// trains {{900,910},{940,1200},{950,1120},{1100,1130},{1500,1900},{1800,2000}} -> 3
Why this works: a room can only be reused once some meeting has ended, and we always reuse the one that ended earliest. The count only grows when every room is busy, so the peak is exactly the minimum needed. Edge case to ask about: if one meeting ends at 10 and another starts at 10, can they share a room? The code says yes (<=). For platforms problems that count "same minute" as a clash, use <.
Huffman coding — the greedy idea
Goal: give each character a binary code so frequent characters get short codes and the total encoded length is minimal (no code is a prefix of another). Greedy rule: repeatedly merge the two rarest nodes into a new node whose frequency is their sum. Each merge pushes those characters one level deeper — one more bit each — so the cost of a merge is a + b, and the total cost is the sum of all merges.
// Total encoded bits. Time O(n log n), Space O(n)
static long huffmanCost(int[] freq) {
PriorityQueue<Long> pq = new PriorityQueue<>();
for (int f : freq) pq.offer((long) f);
long cost = 0;
while (pq.size() > 1) {
long a = pq.poll(), b = pq.poll(); // two rarest
cost += a + b; // each merge adds one bit to all symbols below
pq.offer(a + b);
}
return cost;
}
// freq {5, 9, 12, 13, 16, 45} -> 224 bits
Why this works: the two rarest characters should be the deepest leaves (exchange argument: swapping a rarer character deeper never increases cost), and they can be siblings. Merge them and the rest is the same problem, one character smaller. The same "merge two smallest" pattern solves Minimum Cost to Connect Sticks and optimal file merge.
23. Divide & Conquer
Divide and conquer solves a big problem in three moves: split it into smaller pieces of the same kind, solve each piece (usually by calling yourself recursively), and combine the piece answers into the full answer. The pieces keep shrinking until they are so small the answer is obvious (the base case).
It differs from DP in one key way: the pieces don't overlap. The left half and right half of an array are independent, so nothing is solved twice and nothing needs caching.
Counting the votes of a whole country: every district counts its own ballots at the same time, then regions add up their districts, then the nation adds up the regions. Nobody counts the whole pile alone.
Split, solve, combine
| Algorithm | Split | Solve | Combine | Where the real work is |
|---|---|---|---|---|
| Merge sort | cut at the middle, O(1) | sort both halves | merge, O(n) | combine |
| Quicksort | partition around a pivot, O(n) | sort both sides | nothing | split |
| Quickselect | partition, O(n) | recurse into one side | nothing | split |
| Binary search | compare with middle, O(1) | recurse into one half | nothing | — |
| Max subarray (D&C) | cut at the middle | best in each half | best crossing the middle, O(n) | combine |
| Fast power | x^n = (x^(n/2))^2 | one half-size power | square (times x if odd), O(1) | — |
The recursion tree and the master theorem
Write the running time as a recurrence: T(n) = a T(n / b) + f(n), meaning "a subproblems, each 1/b the size, plus f(n) work to split and combine". Draw it as a tree and add up the work level by level. For merge sort, a = 2, b = 2, f(n) = n:
The master theorem is a shortcut for T(n) = a T(n / b) + O(n^d). Compare d with logb a (how fast the number of pieces grows):
| Case | Result | In words |
|---|---|---|
| d > logb a | O(n^d) | The top-level combine dominates |
| d = logb a | O(n^d log n) | Every level costs the same; multiply by the height |
| d < logb a | O(n^(logb a)) | The leaves dominate — there are so many of them |
| Algorithm | Recurrence | Result |
|---|---|---|
| Binary search | T(n) = T(n/2) + O(1) | O(log n) |
| Fast power | T(n) = T(n/2) + O(1) | O(log n) |
| Quickselect (average) | T(n) = T(n/2) + O(n) | O(n) |
| Tree traversal (balanced) | T(n) = 2T(n/2) + O(1) | O(n) |
| Merge sort, count inversions, max subarray D&C | T(n) = 2T(n/2) + O(n) | O(n log n) |
| Karatsuba multiplication | T(n) = 3T(n/2) + O(n) | O(n^1.585) |
| Strassen matrix multiply | T(n) = 7T(n/2) + O(n²) | O(n^2.81) |
The theorem needs equal-sized splits. Quicksort's worst case, T(n) = T(n - 1) + O(n), isn't covered — sum it directly: n + (n-1) + ... = O(n²).
Recap: merge sort and quickselect
Both were written in full in the Sorting section. Merge sort is the textbook D&C: trivial split, O(n) combine, O(n log n) always. Quickselect shows the power of recursing into only one piece: O(n) + O(n/2) + O(n/4) + ... = O(2n) = O(n) on average, versus O(n log n) to sort first. Count inversions is merge sort with a counter in the combine step — same O(n log n).
Problem: Maximum Subarray (LeetCode 53) — D&C vs Kadane
Find the contiguous subarray with the largest sum. D&C view: the best subarray is either entirely in the left half, entirely in the right half, or crosses the middle. The crossing one is the best suffix of the left half glued to the best prefix of the right half — found in O(n) by walking outward from the middle.
// Divide & conquer. Time O(n log n), Space O(log n) recursion
static int maxSubArrayDC(int[] a) { return best(a, 0, a.length - 1); }
static int best(int[] a, int lo, int hi) {
if (lo == hi) return a[lo]; // one element
int mid = lo + (hi - lo) / 2;
int left = best(a, lo, mid); // answer fully in left half
int right = best(a, mid + 1, hi); // answer fully in right half
// answer crossing the middle: best suffix of left + best prefix of right
int sum = 0, bestLeft = Integer.MIN_VALUE;
for (int i = mid; i >= lo; i--) { sum += a[i]; bestLeft = Math.max(bestLeft, sum); }
sum = 0;
int bestRight = Integer.MIN_VALUE;
for (int j = mid + 1; j <= hi; j++) { sum += a[j]; bestRight = Math.max(bestRight, sum); }
return Math.max(Math.max(left, right), bestLeft + bestRight);
}
// Kadane. Time O(n), Space O(1)
static int maxSubArrayKadane(int[] a) {
int cur = a[0], best = a[0];
for (int i = 1; i < a.length; i++) {
cur = Math.max(a[i], cur + a[i]); // extend the run, or start fresh here
best = Math.max(best, cur);
}
return best;
}
// {-2, 1, -3, 4, -1, 2, 1, -5, 4} -> 6 (subarray [4, -1, 2, 1]), both methods
// {-3, -1, -2} -> -1 (all negative: best is the single largest element)
Why both work: D&C covers all three places the answer can live. Kadane asks at each index "is the best run ending here the previous run extended, or a fresh start?" — a one-dimensional DP. Kadane wins in an interview (O(n)), but the D&C version is a favourite follow-up ("solve it with divide and conquer") and it generalises to a segment tree that answers max-subarray queries on ranges with updates, where Kadane cannot.
Problem: Pow(x, n) — fast exponentiation (LeetCode 50)
Multiplying x by itself n times is O(n). Instead: x^n = (x^(n/2))² if n is even, and x · (x^(n/2))² if odd. One half-size subproblem per level, so O(log n). The key is computing half once — writing pow(x, n/2) * pow(x, n/2) makes two calls and falls back to O(n).
// Recursive. Time O(log n), Space O(log n)
static double myPow(double x, int n) {
long N = n; // -Integer.MIN_VALUE overflows int: use long
if (N < 0) { x = 1 / x; N = -N; }
return pow(x, N);
}
static double pow(double x, long n) {
if (n == 0) return 1.0;
double half = pow(x, n / 2); // solve ONE half-size problem
return (n % 2 == 0) ? half * half : half * half * x;
}
// Iterative (binary exponentiation). Time O(log n), Space O(1)
static double myPowIter(double x, int n) {
long N = n;
if (N < 0) { x = 1 / x; N = -N; }
double result = 1.0;
while (N > 0) {
if ((N & 1) == 1) result *= x; // this bit of n is set: include x^(2^k)
x *= x; // x, x^2, x^4, x^8, ...
N >>= 1;
}
return result;
}
// Modular version, used everywhere in counting problems ("answer mod 1e9+7")
static long modPow(long base, long exp, long mod) {
long result = 1;
base %= mod;
while (exp > 0) {
if ((exp & 1) == 1) result = result * base % mod;
base = base * base % mod;
exp >>= 1;
}
return result;
}
// myPow(2.0, 10) = 1024.0, myPow(2.0, -2) = 0.25, myPow(1.0, Integer.MIN_VALUE) = 1.0
// modPow(2, 10, 1_000_000_007) = 1024
Why this works: write n in binary, for example 13 = 1101₂, so x^13 = x^8 · x^4 · x^1. The loop walks the bits and multiplies in the matching powers of x. Pitfall: n = Integer.MIN_VALUE has no positive int counterpart, so -n overflows — convert to long first. In modPow, keep mod below about 3 · 10^9 so base * base fits in a long.
Closest pair of points — the idea
Given n points in a plane, find the two closest. Brute force checks all pairs: O(n²). D&C:
- Split: sort by x, cut into left and right halves at the middle x.
- Solve: find the closest distance d in each half recursively; let d = the smaller one.
- Combine: the only pairs not yet checked cross the dividing line. Such a pair can only beat d if both points are within d of the line — a thin strip. Sort the strip by y; each point needs checking only against the next few points (at most 7) whose y is within d, because points on the same side are at least d apart and only a handful can fit in a d x 2d box.
// Time O(n log² n) as written (strip re-sorted at each level); O(n log n) if you merge by y
// like merge sort instead of re-sorting. Space O(n)
static double closestPair(int[][] pts) {
int[][] byX = pts.clone();
Arrays.sort(byX, (p, q) -> Integer.compare(p[0], q[0]));
return closest(byX, 0, byX.length - 1);
}
static double closest(int[][] p, int lo, int hi) {
if (hi - lo < 3) { // tiny: brute force
double best = Double.MAX_VALUE;
for (int i = lo; i <= hi; i++)
for (int j = i + 1; j <= hi; j++) best = Math.min(best, dist(p[i], p[j]));
return best;
}
int mid = lo + (hi - lo) / 2;
int midX = p[mid][0];
double d = Math.min(closest(p, lo, mid), closest(p, mid + 1, hi));
List<int[]> strip = new ArrayList<>(); // points within d of the dividing line
for (int i = lo; i <= hi; i++) if (Math.abs(p[i][0] - midX) < d) strip.add(p[i]);
strip.sort((a, b) -> Integer.compare(a[1], b[1])); // sort strip by y
for (int i = 0; i < strip.size(); i++)
for (int j = i + 1; j < strip.size() && strip.get(j)[1] - strip.get(i)[1] < d; j++)
d = Math.min(d, dist(strip.get(i), strip.get(j))); // only a few j per i
return d;
}
static double dist(int[] a, int[] b) {
long dx = a[0] - b[0], dy = a[1] - b[1];
return Math.sqrt(dx * dx + dy * dy);
}
// {{2,3},{12,30},{40,50},{5,1},{12,10},{3,4}} -> 1.4142135623730951 ((2,3) and (3,4))
Rarely asked to code in full, but "how would you beat O(n²)?" is a good D&C discussion question. The inner loop looks nested, but the geometry caps it at a constant number of steps per point.
Problem: Count Inversions (recap)
The full code is in the Sorting section. As D&C: split at the middle; solve = count inversions inside each half (and sort them); combine = during the merge, each time an element is taken from the right half, add mid - i + 1 (the number of left elements still waiting, all bigger). Recurrence 2T(n/2) + O(n), so O(n log n) instead of O(n²). The same "count across the split while merging" trick solves Count of Smaller Numbers After Self (LeetCode 315) and Reverse Pairs (LeetCode 493).
When D&C beats brute force
- The combine step is cheaper than brute-forcing across the split. Inversions: checking all left-right pairs is O(n²), but sorted halves let us count them in O(n).
- You can throw pieces away. Binary search and quickselect recurse into only one piece — that's where O(log n) and O(n) come from.
- Reusing one sub-result. Fast power computes x^(n/2) once and squares it.
- Pieces are independent, so they can run in parallel (Java's
ForkJoinPool,Arrays.parallelSort, MapReduce).
When it doesn't help: if the subproblems overlap (Fibonacci: fib(n-1) and fib(n-2) both recompute fib(n-3)), plain D&C is exponential — that is exactly when you switch to memoization / DP. And if the combine step is as expensive as the whole brute force, splitting buys nothing.
StackOverflowError. Splitting as [lo, mid] and [mid, hi] overlaps and can loop forever — use [lo, mid] and [mid + 1, hi]. Allocating new arrays at every level (e.g. Arrays.copyOfRange in merge sort) works but adds O(n log n) garbage; pass indexes and one shared buffer instead. Recursion depth is O(log n) for balanced splits but O(n) for lopsided ones (bad quicksort pivots).24. Dynamic Programming — the Fundamentals
Dynamic programming (DP) is recursion with a memory. You break a problem into smaller versions of itself, solve each small version once, write the answer down, and reuse it whenever it comes up again.
It works when two things are true: the same small problems come up again and again (overlapping subproblems), and the best answer to the big problem can be built from the best answers to the small ones (optimal substructure).
If someone asks you "what is 1+1+1+1+1+1+1?" you count: 7. If they then add "+1" at the end, you don't recount — you say "8", because you remembered 7. DP is simply refusing to count the same thing twice.
The two properties, explained
- Overlapping subproblems — a plain recursive solution calls itself with the same arguments many times. If every call is different (like merge sort's halves), there is nothing to reuse: that is divide and conquer, not DP.
- Optimal substructure — the best answer for size
nis built from best answers for smaller sizes. Shortest path has it (a shortest path's middle part is also shortest). "Longest simple path" does not, which is why it has no DP.
Seeing the repeated work
Climbing stairs: you can climb 1 or 2 steps at a time; how many ways to reach step n? To land on step n, your last move was either from n-1 (a 1-step) or from n-2 (a 2-step). So ways(n) = ways(n-1) + ways(n-2) — the Fibonacci recurrence.
// Plain recursion: correct but O(2^n) time
static int climbNaive(int n) {
if (n <= 1) return 1; // 1 way to stand on step 0 or reach step 1
return climbNaive(n - 1) + climbNaive(n - 2);
}
The call counts are real: counting calls in the code above gives 15 for n = 5 and 2,692,537 for n = 30. The tree roughly doubles with each extra step, so it is O(2^n). Yet there are only n + 1 different questions (f0 to fn). DP answers each one once.
The 5-step recipe
Use these steps for every DP problem. Say them out loud in an interview — it shows structure even before the code is right.
| Step | Question to ask | Climbing stairs |
|---|---|---|
| 1. State | What does dp[i] mean, in one sentence? | dp[i] = number of ways to reach step i |
| 2. Recurrence | How is a state built from smaller states? (Think: "what was the last choice?") | dp[i] = dp[i-1] + dp[i-2] |
| 3. Base cases | Which states can be answered without the recurrence? | dp[0] = 1, dp[1] = 1 |
| 4. Order | In what order must states be filled so dependencies are ready? | i from 2 up to n |
| 5. Answer | Which state (or max/min of states) is the final answer? | dp[n] |
dp[i][holding] in the stock problems).Top-down (memoisation) vs bottom-up (tabulation)
Top-down: write the natural recursion, then add a cache (the memo). Before computing, check the memo; after computing, store it. Bottom-up: create a table and fill it from the smallest states upward with a loop, so every value you need is already there.
// Top-down: recursion + memo. O(n) time, O(n) space (memo + call stack)
static int climbMemo(int n) {
int[] memo = new int[n + 1];
Arrays.fill(memo, -1); // -1 = "not solved yet"
return go(n, memo);
}
private static int go(int i, int[] memo) {
if (i <= 1) return 1; // base cases
if (memo[i] != -1) return memo[i]; // already solved? reuse
return memo[i] = go(i - 1, memo) + go(i - 2, memo);
}
// Bottom-up: fill a table from small to big. O(n) time, O(n) space
static int climbTab(int n) {
if (n <= 1) return 1;
int[] dp = new int[n + 1];
dp[0] = 1; dp[1] = 1; // base cases
for (int i = 2; i <= n; i++)
dp[i] = dp[i - 1] + dp[i - 2]; // recurrence
return dp[n]; // answer
}
// Space-optimised: only the last two values are ever read. O(n) time, O(1) space
static int climbO1(int n) {
int prev2 = 1, prev1 = 1; // dp[i-2], dp[i-1]
for (int i = 2; i <= n; i++) {
int cur = prev1 + prev2;
prev2 = prev1;
prev1 = cur;
}
return prev1;
}
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
dp[i] | 1 | 1 | 2 | 3 | 5 | 8 | 13 |
All three versions return 13 for n = 6 and 1,836,311,903 for n = 45 (the largest n that still fits in an int).
| Top-down (memo) | Bottom-up (table) | |
|---|---|---|
| How you write it | Natural recursion + cache | Loops in dependency order |
| Which states are computed | Only the ones actually reached | All of them |
| Stack overflow risk | Yes — recursion depth can be n (Java's default stack breaks around 10,000 deep) | None |
| Space optimisation | Hard | Easy — keep only the rows/values you still read |
| When to pick it | Hard-to-order states, sparse state space, getting a first correct answer fast | Large n, need for speed, need to cut memory |
Second example: House Robber (LC 198)
Houses in a row hold money nums[i]. You cannot rob two neighbours. Maximise the total. At each house the choice is rob it (then skip the next) or skip it.
- State:
dp[i]= most money from the firstihouses. - Recurrence:
dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])— skip housei, or rob it and add the best from two houses back. - Base:
dp[0] = 0,dp[1] = nums[0]. Answer:dp[n].
// Top-down: best(i) = most money from houses i..n-1
static int robMemo(int[] nums) {
return best(nums, 0, new Integer[nums.length]); // null = not solved yet
}
private static int best(int[] nums, int i, Integer[] memo) {
if (i >= nums.length) return 0;
if (memo[i] != null) return memo[i];
int rob = nums[i] + best(nums, i + 2, memo); // take i, skip neighbour
int skip = best(nums, i + 1, memo); // leave i
return memo[i] = Math.max(rob, skip);
}
// Bottom-up table
static int robTab(int[] nums) {
int n = nums.length;
int[] dp = new int[n + 1]; // dp[i] = best using first i houses
dp[1] = nums[0];
for (int i = 2; i <= n; i++)
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]);
return dp[n];
}
// Rolling variables: O(n) time, O(1) space
static int rob(int[] nums) {
int prev2 = 0, prev1 = 0; // best up to i-2, best up to i-1
for (int x : nums) {
int cur = Math.max(prev1, prev2 + x);
prev2 = prev1;
prev1 = cur;
}
return prev1;
}
Traced on nums = [2, 7, 9, 3, 1]:
| i | house value | skip: dp[i-1] | rob: dp[i-2] + value | dp[i] |
|---|---|---|---|---|
| 1 | 2 | — | — | 2 |
| 2 | 7 | 2 | 0 + 7 = 7 | 7 |
| 3 | 9 | 7 | 2 + 9 = 11 | 11 |
| 4 | 3 | 11 | 7 + 3 = 10 | 11 |
| 5 | 1 | 11 | 11 + 1 = 12 | 12 |
All three versions return 12 (rob houses 2, 9 and 1). Time O(n); space O(n) for memo/table, O(1) for the rolling version.
Space optimisation — the general rule
- If
dp[i]only readsdp[i-1]anddp[i-2], keep two variables. - If a 2D
dp[i][j]only reads rowi-1, keep one or two 1D rows. Direction matters: loop so you never read a cell you already overwrote in this row (unless you want to — see knapsack in section 25). - You lose the ability to reconstruct the actual choices (which houses, which items). If the question asks for the path, not just the number, keep the full table.
How to recognise a DP problem
| Signal in the question | Example |
|---|---|
| "Count the number of ways…" | Climbing stairs, unique paths, coin change II, decode ways |
| "Minimum / maximum cost, profit, length…" | Coin change, house robber, LIS, edit distance |
| "Is it possible to…?" (yes/no) | Partition equal subset sum, word break, jump game |
| At each step you make a choice (take/skip, left/right, buy/sell) and choices affect the future | Knapsack, stock problems |
Input is a sequence, string, grid or tree, and n is up to ~10^3–10^4 | O(n^2) DP fits; n around 20 hints at bitmask DP |
{1, 3, 4} for amount 6 — greedy takes 4+1+1 (3 coins) but the best is 3+3 (2 coins). When greedy can be beaten like this, you need DP. If you cannot break greedy and can prove it (exchange argument), greedy is simpler and faster.25. DP Patterns
Most interview DP problems are one of about a dozen patterns wearing a costume. Once you know the pattern, the state and recurrence are almost given to you — you only adapt the details.
For each pattern below: what dp means, how it is filled, the Java code, and its cost.
Like chess openings. A strong player does not think from scratch on move one; they recognise "this is the Sicilian" and already know the plan. Recognising "this is knapsack" gets you 80% of the way.
| Pattern | State shape | Typical problems |
|---|---|---|
| 0/1 knapsack | dp[i][capacity] | Partition equal subset, target sum, last stone weight II |
| Unbounded knapsack | dp[amount] | Coin change I/II, perfect squares, rod cutting |
| Single sequence | dp[i] = answer ending at / using first i | LIS, house robber, word break, decode ways |
| Two sequences | dp[i][j] = answer for prefixes | LCS, edit distance, distinct subsequences |
| Grid | dp[r][c] | Unique paths, minimum path sum, maximal square |
| Interval | dp[l][r] | Burst balloons, matrix chain, palindrome partitioning |
| Tree | value returned from DFS per node | House robber III, binary tree cameras, max path sum |
| Bitmask | dp[mask][i] | TSP, shortest path visiting all nodes, assignment |
| State machine | dp[i][state] | Stock buy/sell family |
0/1 knapsack
n items each with a weight and a value; a bag of capacity W. Each item is taken at most once. Maximise value.
- State:
dp[i][c]= best value using the firstiitems with capacityc. - Recurrence:
dp[i][c] = max(dp[i-1][c], dp[i-1][c - w] + v)— skip itemi, or take it (only ifw <= c). - Base: row 0 is all zeros (no items, no value). Answer:
dp[n][W].
static int knapsack2D(int[] wt, int[] val, int W) {
int n = wt.length;
int[][] dp = new int[n + 1][W + 1]; // dp[i][c] = best value, first i items, capacity c
for (int i = 1; i <= n; i++) {
for (int c = 0; c <= W; c++) {
dp[i][c] = dp[i - 1][c]; // skip item i
if (wt[i - 1] <= c) // take item i (once)
dp[i][c] = Math.max(dp[i][c], dp[i - 1][c - wt[i - 1]] + val[i - 1]);
}
}
return dp[n][W];
}
Traced with weights {1, 3, 4}, values {15, 20, 30}, W = 4:
| items \ capacity | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| none | 0 | 0 | 0 | 0 | 0 |
| + item (w1, v15) | 0 | 15 | 15 | 15 | 15 |
| + item (w3, v20) | 0 | 15 | 15 | 20 | 35 |
| + item (w4, v30) | 0 | 15 | 15 | 20 | 35 |
Answer 35 (items of weight 1 and 3). Time O(n·W), space O(n·W).
1D version. Row i only reads row i-1, so one array is enough — if you loop capacity backwards.
static int knapsack1D(int[] wt, int[] val, int W) {
int[] dp = new int[W + 1]; // dp[c] = best value with capacity c
for (int i = 0; i < wt.length; i++)
for (int c = W; c >= wt[i]; c--) // BACKWARDS: each item used at most once
dp[c] = Math.max(dp[c], dp[c - wt[i]] + val[i]);
return dp[W];
}
dp[c] reads dp[c - w], a smaller index. Going from high c to low, the smaller cell has not been touched yet in this round, so it still holds the "previous item" value — exactly dp[i-1][c-w]. Going forwards, dp[c-w] may already include the current item, so the item gets added again and again. With the example above, the forward loop returns 60 (the weight-1 item taken four times) instead of 35. Forwards = unbounded knapsack; backwards = 0/1 knapsack.Time O(n·W), space O(W). Note W is a value, not a size — this is "pseudo-polynomial", so it is fast only when W is small (say up to 10^4–10^5).
Unbounded knapsack: Coin Change (LC 322) and Coin Change II (LC 518)
Each coin can be used any number of times. Two different questions: fewest coins (min), and number of combinations (count).
- Coin Change — state:
dp[a]= fewest coins to make amounta. Recurrence:dp[a] = min over coins (dp[a - coin] + 1). Base:dp[0] = 0. - Coin Change II — state:
dp[a]= number of combinations makinga. Recurrence:dp[a] += dp[a - coin]. Base:dp[0] = 1(one way to make 0: take nothing).
static int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1]; // dp[a] = fewest coins to make a
Arrays.fill(dp, amount + 1); // "infinity" that cannot overflow on +1
dp[0] = 0;
for (int a = 1; a <= amount; a++)
for (int coin : coins)
if (coin <= a)
dp[a] = Math.min(dp[a], dp[a - coin] + 1);
return dp[amount] > amount ? -1 : dp[amount];
}
static int change(int amount, int[] coins) {
int[] dp = new int[amount + 1]; // dp[a] = number of combinations making a
dp[0] = 1; // one way to make 0: take nothing
for (int coin : coins) // coins OUTER -> combinations, not orders
for (int a = coin; a <= amount; a++) // forwards: a coin may repeat
dp[a] += dp[a - coin];
return dp[amount];
}
coinChange({1, 2, 5}, 11) = 3 (5+5+1); coinChange({2}, 3) = -1; change(5, {1, 2, 5}) = 4. Both run in O(amount · coins) time, O(amount) space.
Longest Increasing Subsequence (LC 300)
A subsequence keeps order but may skip elements. Find the longest strictly increasing one.
O(n^2): dp[i] = length of the LIS that ends exactly at index i. For every earlier j with nums[j] < nums[i], you can extend it: dp[i] = max(dp[j] + 1). The answer is the max over all i (not dp[n-1]).
static int lengthOfLIS_n2(int[] nums) {
int n = nums.length, best = 0;
int[] dp = new int[n]; // dp[i] = LIS ending exactly at i
for (int i = 0; i < n; i++) {
dp[i] = 1; // just nums[i] alone
for (int j = 0; j < i; j++)
if (nums[j] < nums[i])
dp[i] = Math.max(dp[i], dp[j] + 1);
best = Math.max(best, dp[i]);
}
return best;
}
O(n log n) — patience sorting. Keep tails[k] = the smallest possible last value of an increasing subsequence of length k+1. A smaller tail is always better, because it is easier to extend. For each number, binary-search the first tail >= x and replace it; if none, append.
static int lengthOfLIS(int[] nums) {
int[] tails = new int[nums.length];
int size = 0;
for (int x : nums) {
int lo = 0, hi = size; // find first tail >= x
while (lo < hi) {
int mid = (lo + hi) >>> 1;
if (tails[mid] < x) lo = mid + 1; else hi = mid;
}
tails[lo] = x; // replace, or append if lo == size
if (lo == size) size++;
}
return size;
}
Both return 4. tails is not itself a valid subsequence — only its length is meaningful. For "non-decreasing" use "first tail > x" instead. Time O(n log n), space O(n). Variants: Russian doll envelopes (sort by width ascending, height descending, then LIS on heights), number of LIS, longest chain.
Longest Common Subsequence (LC 1143)
- State:
dp[i][j]= LCS length ofa[0..i)andb[0..j)(the firstiandjcharacters). - Recurrence: if
a[i-1] == b[j-1], both last chars join the LCS:dp[i-1][j-1] + 1. Otherwise drop one of them:max(dp[i-1][j], dp[i][j-1]). - Base: row 0 and column 0 are 0 (empty string). Answer:
dp[m][n].
static int lcs(String a, String b) {
int m = a.length(), n = b.length();
int[][] dp = new int[m + 1][n + 1]; // dp[i][j] = LCS of a[0..i) and b[0..j)
for (int i = 1; i <= m; i++)
for (int j = 1; j <= n; j++)
dp[i][j] = a.charAt(i - 1) == b.charAt(j - 1)
? dp[i - 1][j - 1] + 1 // diagonal + 1
: Math.max(dp[i - 1][j], dp[i][j - 1]); // up or left
return dp[m][n];
}
Table for a = "abcde", b = "ace" (bold = a character match, taken from the diagonal):
| "" | a | c | e | |
|---|---|---|---|---|
| "" | 0 | 0 | 0 | 0 |
| a | 0 | 1 | 1 | 1 |
| b | 0 | 1 | 1 | 1 |
| c | 0 | 1 | 2 | 2 |
| d | 0 | 1 | 2 | 2 |
| e | 0 | 1 | 2 | 3 |
Answer 3 ("ace"). To rebuild the string, start at dp[m][n]: on a match step diagonally and record the char; else move toward the larger of up/left. Time O(m·n), space O(m·n), or O(min(m, n)) with two rows.
Edit Distance (LC 72)
Fewest single-character inserts, deletes or replaces to turn a into b.
- State:
dp[i][j]= edits to turna[0..i)intob[0..j). - Recurrence: chars equal:
dp[i-1][j-1](free). Otherwise1 + min(replace dp[i-1][j-1], delete dp[i-1][j], insert dp[i][j-1]). - Base:
dp[i][0] = i(delete all),dp[0][j] = j(insert all).
static int minDistance(String a, String b) {
int m = a.length(), n = b.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 0; i <= m; i++) dp[i][0] = i; // delete everything
for (int j = 0; j <= n; j++) dp[0][j] = j; // insert everything
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (a.charAt(i - 1) == b.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1]; // free: chars match
} else {
dp[i][j] = 1 + Math.min(dp[i - 1][j - 1], // replace
Math.min(dp[i - 1][j], // delete from a
dp[i][j - 1])); // insert into a
}
}
}
return dp[m][n];
}
minDistance("horse", "ros") = 3 (horse → rorse → rose → ros); ("intention", "execution") = 5. Time and space O(m·n).
Grid paths: Unique Paths (LC 62) and Minimum Path Sum (LC 64)
Moving only right or down, a cell can be reached only from above or from the left. So dp[r][c] combines dp[r-1][c] and dp[r][c-1] — sum them to count paths, min them for cheapest path.
static int uniquePaths(int m, int n) {
int[] dp = new int[n]; // one row, reused
Arrays.fill(dp, 1); // first row: one way to each cell
for (int r = 1; r < m; r++)
for (int c = 1; c < n; c++)
dp[c] += dp[c - 1]; // from above (old dp[c]) + from left
return dp[n - 1];
}
static int minPathSum(int[][] grid) {
int m = grid.length, n = grid[0].length;
int[][] dp = new int[m][n];
for (int r = 0; r < m; r++) {
for (int c = 0; c < n; c++) {
if (r == 0 && c == 0) dp[r][c] = grid[0][0];
else if (r == 0) dp[r][c] = dp[r][c - 1] + grid[r][c]; // top row: only from left
else if (c == 0) dp[r][c] = dp[r - 1][c] + grid[r][c]; // left column: only from above
else dp[r][c] = Math.min(dp[r - 1][c], dp[r][c - 1]) + grid[r][c];
}
}
return dp[m - 1][n - 1];
}
uniquePaths(3, 7) = 28; minPathSum([[1,3,1],[1,5,1],[4,2,1]]) = 7 (path 1→3→1→1→1). Both O(m·n) time; space O(n) and O(m·n) (can also be O(n) or done in place). With obstacles (LC 63), set dp to 0 on blocked cells.
Partition Equal Subset Sum (LC 416)
Can the array split into two parts with equal sums? That means: is there a subset summing to total / 2? This is 0/1 knapsack with booleans.
static boolean canPartition(int[] nums) {
int total = 0;
for (int x : nums) total += x;
if (total % 2 != 0) return false; // odd total can never split evenly
int target = total / 2;
boolean[] dp = new boolean[target + 1]; // dp[s] = can some subset sum to s?
dp[0] = true; // the empty subset
for (int x : nums)
for (int s = target; s >= x; s--) // backwards: each number used once
dp[s] = dp[s] || dp[s - x];
return dp[target];
}
[1, 5, 11, 5] → true (11 = 1+5+5); [1, 2, 3, 5] → false. Time O(n · sum), space O(sum). Same shape: Target Sum (LC 494) counts subsets with sum (total + target) / 2.
Word Break (LC 139)
- State:
dp[i]= the firsticharacters can be split into dictionary words. - Recurrence:
dp[i]is true if somej < ihasdp[j]true ands[j..i)is a word.
static boolean wordBreak(String s, List<String> wordDict) {
Set<String> dict = new HashSet<>(wordDict);
int n = s.length();
boolean[] dp = new boolean[n + 1]; // dp[i] = s[0..i) can be segmented
dp[0] = true; // empty prefix
for (int i = 1; i <= n; i++) {
for (int j = 0; j < i; j++) {
if (dp[j] && dict.contains(s.substring(j, i))) {
dp[i] = true;
break; // one valid split is enough
}
}
}
return dp[n];
}
"leetcode" with ["leet", "code"] → true; "catsandog" with ["cats", "dog", "sand", "and", "cat"] → false. Time O(n^3) in the worst case (n^2 pairs, each substring costs O(n)); limit j to the longest word length to speed it up. Space O(n).
Interval DP: Burst Balloons (LC 312)
Interval DP solves every sub-range [l, r], shortest ranges first, by trying each split point k inside. The trick in Burst Balloons is to let k be the last balloon burst in the range: then its neighbours at that moment are the fixed walls l and r, and the left and right parts become independent.
- State:
dp[l][r]= most coins from bursting every balloon strictly betweenlandr(padded with 1s at both ends). - Recurrence:
dp[l][r] = max over k (dp[l][k] + v[l]·v[k]·v[r] + dp[k][r]). - Order: by increasing gap
r - l.
static int maxCoins(int[] nums) {
int n = nums.length;
int[] v = new int[n + 2]; // pad with 1 on both ends
v[0] = v[n + 1] = 1;
for (int i = 0; i < n; i++) v[i + 1] = nums[i];
int[][] dp = new int[n + 2][n + 2];
for (int len = 2; len <= n + 1; len++) { // gap between the two walls
for (int l = 0; l + len <= n + 1; l++) {
int r = l + len;
for (int k = l + 1; k < r; k++) // k = LAST balloon burst in (l, r)
dp[l][r] = Math.max(dp[l][r],
dp[l][k] + v[l] * v[k] * v[r] + dp[k][r]);
}
}
return dp[0][n + 1];
}
[3, 1, 5, 8] → 167. Time O(n^3), space O(n^2). Matrix chain multiplication is the same shape: dp[i][j] = min over k (dp[i][k] + dp[k+1][j] + rows[i]·cols[k]·cols[j]) — the cost of splitting the product at k.
DP on trees: House Robber III (LC 337)
Houses form a binary tree; you cannot rob a parent and its child. Each node returns two answers to its parent — best if this node is skipped, best if it is robbed — so the parent can choose. This "return a small array from DFS" is the general tree-DP pattern.
static int robTree(TreeNode root) {
int[] r = dfs(root);
return Math.max(r[0], r[1]);
}
// returns {best if we SKIP this node, best if we ROB this node}
private static int[] dfs(TreeNode node) {
if (node == null) return new int[]{0, 0};
int[] L = dfs(node.left), R = dfs(node.right);
int skip = Math.max(L[0], L[1]) + Math.max(R[0], R[1]); // children free to choose
int rob = node.val + L[0] + R[0]; // children must be skipped
return new int[]{skip, rob};
}
Tree [3, 2, 3, null, 3, null, 1] → 7 (3 + 3 + 1). Time O(n) (each node visited once), space O(h) for the recursion, where h is the tree height.
Bitmask DP: Travelling Salesman for small n
When n is at most about 20, a set of visited items fits in the bits of an int: bit i set means item i is in the set. 1 << n masks times n endpoints is small enough.
- State:
dp[mask][v]= cheapest path that starts at city 0, visits exactly the cities inmask, and ends atv. - Transition: from
(mask, v)go to an unvisitedu:dp[mask | 1<<u][u] = min(…, dp[mask][v] + dist[v][u]).
static int tsp(int[][] dist) {
int n = dist.length, FULL = 1 << n, INF = Integer.MAX_VALUE / 2;
int[][] dp = new int[FULL][n];
for (int[] row : dp) Arrays.fill(row, INF);
dp[1][0] = 0; // start at city 0; mask = {0}
for (int mask = 1; mask < FULL; mask++) {
for (int v = 0; v < n; v++) {
if (dp[mask][v] == INF || (mask & (1 << v)) == 0) continue;
for (int u = 0; u < n; u++) {
if ((mask & (1 << u)) != 0) continue; // already visited
int next = mask | (1 << u);
dp[next][u] = Math.min(dp[next][u], dp[mask][v] + dist[v][u]);
}
}
}
int best = INF;
for (int v = 1; v < n; v++) // return home to 0
best = Math.min(best, dp[FULL - 1][v] + dist[v][0]);
return best;
}
For the classic 4-city matrix {{0,10,15,20},{10,0,35,25},{15,35,0,30},{20,25,30,0}} it returns 80. Time O(2^n · n^2), space O(2^n · n) — compared with O(n!) for trying every order. Masks increase numerically, and adding a bit always makes a larger number, so smaller masks are always ready first.
State machine: Buy and Sell Stock
Give each day a few states (holding a share, just sold, resting) and write how you move between them. Each state keeps the best profit so far in that state.
// One transaction (LC 121): track the cheapest price seen so far. O(n), O(1)
static int maxProfitOne(int[] prices) {
int minPrice = Integer.MAX_VALUE, best = 0;
for (int p : prices) {
minPrice = Math.min(minPrice, p);
best = Math.max(best, p - minPrice);
}
return best;
}
// Unlimited transactions with a 1-day cooldown after selling (LC 309). O(n), O(1)
static int maxProfitCooldown(int[] prices) {
int hold = Integer.MIN_VALUE / 2; // own a share
int sold = Integer.MIN_VALUE / 2; // just sold today (tomorrow is cooldown)
int rest = 0; // no share, free to buy
for (int p : prices) {
int prevHold = hold, prevSold = sold, prevRest = rest;
hold = Math.max(prevHold, prevRest - p); // keep holding, or buy from rest
sold = prevHold + p; // sell what we held
rest = Math.max(prevRest, prevSold); // stay resting, or cooldown ends
}
return Math.max(sold, rest);
}
// Unlimited transactions, fee per sale (LC 714). O(n), O(1)
static int maxProfitFee(int[] prices, int fee) {
int hold = -prices[0], cash = 0;
for (int i = 1; i < prices.length; i++) {
int p = prices[i];
int newCash = Math.max(cash, hold + p - fee); // sell today
int newHold = Math.max(hold, cash - p); // buy today
cash = newCash; hold = newHold;
}
return cash;
}
Results: [7,1,5,3,6,4] one transaction → 5; [1,2,3,0,2] with cooldown → 3; [1,3,2,8,4,9] with fee 2 → 8. For "at most k transactions" (LC 188), add a dimension: hold[j] and cash[j] for j = 1..k, giving O(n·k).
Integer.MAX_VALUE as infinity and then adding 1 — it overflows to a negative number; use amount + 1 or MAX_VALUE / 2. (2) Off-by-one between "first i items" (size n+1) and "index i" (size n) — pick one meaning and write it as a comment. (3) Wrong loop direction in 1D knapsack. (4) Returning dp[n-1] when the answer is the max over all dp[i] (LIS). (5) Forgetting to save updated values at the same time in state machines — copy the old values first.26. Shortest Paths
A shortest path is the route between two nodes with the smallest total weight (the cost written on each edge: distance, time, price). With no weights, "shortest" just means fewest edges.
The central move in every algorithm here is relaxation: "I know a way to reach u costing dist[u]. If going u → v gives v a cheaper cost than it has now, update dist[v]." The algorithms differ only in the order they relax edges.
Water poured at the source spreads through pipes of different lengths. It reaches the nearest junctions first, and each junction is "wet" at the earliest moment any route could get there. Dijkstra simulates this: always process the closest unfinished node next.
Which algorithm when
| Graph | Algorithm | Time | Notes |
|---|---|---|---|
| Unweighted (all edges cost 1) | BFS | O(V + E) | First time you reach a node is the shortest |
| Weights only 0 or 1 | 0-1 BFS (deque) | O(V + E) | 0-edges to the front, 1-edges to the back |
| Non-negative weights, one source | Dijkstra (heap) | O((V + E) log V) | The default choice |
| Negative weights, one source; or "at most K edges" | Bellman-Ford | O(V · E) | Detects negative cycles |
| All pairs, small V (up to ~400) | Floyd-Warshall | O(V^3) | Handles negative edges; 3 nested loops |
| DAG (no cycles), any weights | Topological order + relax | O(V + E) | See section 27 |
Dijkstra — the idea
Keep a min-heap of (node, distance). Pop the closest node. Because all weights are non-negative, nothing found later can make it cheaper — its distance is final. Relax its outgoing edges and push any improved neighbours.
Dijkstra — reusable Java template
// graph.get(u) = list of {v, w}. Returns dist[] from src (MAX_VALUE = unreachable).
static int[] dijkstra(List<List<int[]>> graph, int src) {
int n = graph.size();
int[] dist = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[src] = 0;
// heap entries are {node, distance}, smallest distance first
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Integer.compare(a[1], b[1]));
pq.offer(new int[]{src, 0});
while (!pq.isEmpty()) {
int[] top = pq.poll();
int u = top[0], d = top[1];
if (d > dist[u]) continue; // stale entry: a shorter path was already found
for (int[] e : graph.get(u)) {
int v = e[0], nd = d + e[1];
if (nd < dist[v]) { // relax
dist[v] = nd;
pq.offer(new int[]{v, nd});
}
}
}
return dist;
}
On the graph above it returns [0, 3, 1, 4, 7]. Time O((V + E) log V): each edge can push once, each push/pop costs log. Space O(V + E).
- Lazy deletion: Java's
PriorityQueuehas no fast "decrease key", so we push a new entry and skip the old one when it pops (thed > dist[u]line). Without that check the code is still correct but slower. - Use
Integer.compare, nota[1] - b[1]: subtraction can overflow with large distances and sort wrongly. - Use
longdistances if a path can exceed about 2.1 billion. - For one target, you may stop as soon as the target is popped.
Why Dijkstra fails with negative edges
Dijkstra's promise — "the popped node is final" — assumes a longer route can never become cheaper later. A negative edge breaks that. Take edges A→B 1, A→C 5, C→B -10, B→D 1. The textbook version (mark a node "done" when popped) finalises B = 1 and D = 2, then later finds A→C→B = -5 — but B is already done. Running it gives [0, 1, 5, 2]; the true answer (Bellman-Ford) is [0, -5, 5, -4].
0-1 BFS
If every weight is 0 or 1, use a deque instead of a heap: a 0-edge keeps the same distance, so push it to the front; a 1-edge goes to the back. The deque stays sorted, like a heap that never needs log. Typical problem: minimum walls to remove, minimum cost to make a grid path valid (LC 1368).
static int[] zeroOneBfs(List<List<int[]>> graph, int src) {
int[] dist = new int[graph.size()];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[src] = 0;
Deque<Integer> dq = new ArrayDeque<>();
dq.offerFirst(src);
while (!dq.isEmpty()) {
int u = dq.pollFirst();
for (int[] e : graph.get(u)) {
int v = e[0], w = e[1]; // w is 0 or 1
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
if (w == 0) dq.offerFirst(v); // free edge: same level, go to front
else dq.offerLast(v);
}
}
}
return dist;
}
Time O(V + E), space O(V).
Bellman-Ford
Relax every edge, V - 1 times. After round k, every shortest path that uses at most k edges is correct. A shortest path without cycles has at most V - 1 edges, so that many rounds suffice. If a V-th round still improves something, a negative cycle exists (you could loop forever and keep getting cheaper).
// edges: {u, v, w}. Returns null if a negative cycle is reachable from src.
static int[] bellmanFord(int n, int[][] edges, int src) {
final int INF = Integer.MAX_VALUE;
int[] dist = new int[n];
Arrays.fill(dist, INF);
dist[src] = 0;
for (int round = 1; round <= n - 1; round++) {
boolean changed = false;
for (int[] e : edges) {
if (dist[e[0]] != INF && dist[e[0]] + e[2] < dist[e[1]]) { // skip unreachable u
dist[e[1]] = dist[e[0]] + e[2];
changed = true;
}
}
if (!changed) break; // already settled: stop early
}
for (int[] e : edges) // one more round: still improving?
if (dist[e[0]] != INF && dist[e[0]] + e[2] < dist[e[1]])
return null; // negative cycle
return dist;
}
Time O(V · E), space O(V). The != INF check matters: INF + negative would look like a real small distance.
Floyd-Warshall (all pairs)
d[i][j] = shortest distance from i to j using only nodes 0..k as stepping stones. Each round of k asks: "is going through k better?" It is really a DP over k.
static long[][] floydWarshall(int n, int[][] edges) {
final long INF = Long.MAX_VALUE / 4; // room to add two INFs safely
long[][] d = new long[n][n];
for (long[] row : d) Arrays.fill(row, INF);
for (int i = 0; i < n; i++) d[i][i] = 0;
for (int[] e : edges) d[e[0]][e[1]] = Math.min(d[e[0]][e[1]], e[2]);
for (int k = 0; k < n; k++) // k MUST be the outermost loop
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
if (d[i][k] + d[k][j] < d[i][j])
d[i][j] = d[i][k] + d[k][j];
return d; // d[i][i] < 0 means i is on a negative cycle
}
Time O(V^3), space O(V^2). Row A of the result for the example graph is 0 3 1 4 7, matching Dijkstra. Putting k in an inner loop is the classic bug — it gives wrong answers silently.
Problem: Network Delay Time (LC 743)
A signal starts at node k; times[i] = {u, v, w}. How long until every node has it? That is the largest shortest distance from k, or -1 if some node is unreachable.
static int networkDelayTime(int[][] times, int n, int k) {
List<List<int[]>> g = new ArrayList<>();
for (int i = 0; i <= n; i++) g.add(new ArrayList<>()); // nodes are 1..n
for (int[] t : times) g.get(t[0]).add(new int[]{t[1], t[2]});
int[] dist = new int[n + 1];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[k] = 0;
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Integer.compare(a[1], b[1]));
pq.offer(new int[]{k, 0});
while (!pq.isEmpty()) {
int[] top = pq.poll();
int u = top[0], d = top[1];
if (d > dist[u]) continue;
for (int[] e : g.get(u)) {
if (d + e[1] < dist[e[0]]) {
dist[e[0]] = d + e[1];
pq.offer(new int[]{e[0], dist[e[0]]});
}
}
}
int ans = 0;
for (int i = 1; i <= n; i++) {
if (dist[i] == Integer.MAX_VALUE) return -1; // someone never gets the signal
ans = Math.max(ans, dist[i]);
}
return ans;
}
times = [[2,1,1],[2,3,1],[3,4,1]], n = 4, k = 2 → 2. Time O(E log V), space O(V + E). Why it works: every node receives the signal at its shortest-path time, so the last one to receive it decides the answer.
Problem: Cheapest Flights Within K Stops (LC 787)
K stops means at most K + 1 flights (edges). Bellman-Ford's "after round r, paths with at most r edges are correct" is exactly this limit — run just K + 1 rounds.
static int findCheapestPrice(int n, int[][] flights, int src, int dst, int k) {
final int INF = Integer.MAX_VALUE;
int[] dist = new int[n];
Arrays.fill(dist, INF);
dist[src] = 0;
for (int round = 0; round <= k; round++) { // k stops = at most k+1 flights
int[] next = dist.clone(); // read ONLY last round's values
for (int[] f : flights) {
int u = f[0], v = f[1], price = f[2];
if (dist[u] != INF && dist[u] + price < next[v])
next[v] = dist[u] + price;
}
dist = next;
}
return dist[dst] == INF ? -1 : dist[dst];
}
With flights 0→1 100, 1→2 100, 0→2 500: k = 1 → 200, k = 0 → 500. Time O(K · E), space O(V).
dist.clone(), a round can chain several edges (update 1, then immediately use the new 1 to update 2) and exceed the stop limit. Plain Dijkstra on cost also fails here: the cheapest way to reach a middle city may use too many stops, while a pricier route with fewer stops is the one that works.Problem: Path With Minimum Effort (LC 1631)
On a height grid, a path's effort is the largest height difference between consecutive cells. Minimise it. Dijkstra still works when the path cost is "max of edges" instead of "sum of edges", because extending a path never makes its cost go down.
static int minimumEffortPath(int[][] heights) {
int R = heights.length, C = heights[0].length;
int[][] effort = new int[R][C];
for (int[] row : effort) Arrays.fill(row, Integer.MAX_VALUE);
effort[0][0] = 0;
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Integer.compare(a[2], b[2])); // {r, c, effort}
pq.offer(new int[]{0, 0, 0});
int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
while (!pq.isEmpty()) {
int[] cur = pq.poll();
int r = cur[0], c = cur[1], e = cur[2];
if (r == R - 1 && c == C - 1) return e; // first time popped = final answer
if (e > effort[r][c]) continue;
for (int[] d : dirs) {
int nr = r + d[0], nc = c + d[1];
if (nr < 0 || nc < 0 || nr >= R || nc >= C) continue;
int ne = Math.max(e, Math.abs(heights[nr][nc] - heights[r][c])); // cost = max step
if (ne < effort[nr][nc]) {
effort[nr][nc] = ne;
pq.offer(new int[]{nr, nc, ne});
}
}
}
return 0;
}
[[1,2,2],[3,8,2],[5,3,5]] → 2. Time O(R·C·log(R·C)), space O(R·C). Alternatives: binary search on the answer + BFS, or union-find adding edges in increasing weight until corner meets corner.
n; comparator overflow; forgetting unreachable nodes (MAX_VALUE) when taking a max or sum.27. Topological Sort, MST & More
A topological sort lists the nodes of a directed graph so that every edge u → v has u before v. It only exists when the graph has no directed cycle — a DAG (directed acyclic graph).
A minimum spanning tree (MST) picks edges of a connected, undirected, weighted graph so that every node is connected, there are no cycles, and the total weight is as small as possible. It always has exactly V - 1 edges.
Topological sort is getting dressed: socks before shoes, shirt before tie before jacket. Any order that respects those rules works. An MST is laying cable between towns: connect every town with the least total cable, and never lay a redundant loop.
Topological sort — Kahn's algorithm (BFS on indegree)
The indegree of a node is how many edges point into it — how many things must happen before it. Start with every node whose indegree is 0 (no prerequisites). Take one, "finish" it, and lower the indegree of everything it points to. Anything that drops to 0 is now ready.
// Edge {u, v} means u must come before v.
// Returns a topological order, or an empty array if there is a cycle.
static int[] topoKahn(int n, int[][] edges) {
List<List<Integer>> g = new ArrayList<>();
for (int i = 0; i < n; i++) g.add(new ArrayList<>());
int[] indeg = new int[n];
for (int[] e : edges) { g.get(e[0]).add(e[1]); indeg[e[1]]++; }
Deque<Integer> q = new ArrayDeque<>();
for (int i = 0; i < n; i++) if (indeg[i] == 0) q.offer(i); // no prerequisites
int[] order = new int[n];
int idx = 0;
while (!q.isEmpty()) {
int u = q.poll();
order[idx++] = u;
for (int v : g.get(u))
if (--indeg[v] == 0) q.offer(v); // its last prerequisite is done
}
return idx == n ? order : new int[0]; // fewer than n placed => cycle
}
Running it gives [0, 3, 1, 4, 2]. Time O(V + E), space O(V + E). Cycle detection for free: nodes on a cycle never reach indegree 0, so fewer than V nodes get placed.
Topological sort — DFS with three colours
Colour each node: white = not visited, gray = on the current DFS path (still exploring its descendants), black = completely finished. Reaching a gray node means you walked back into your own path: a cycle. When a node turns black, push it on a stack; the stack from top to bottom is a topological order (reverse post-order).
static final int WHITE = 0, GRAY = 1, BLACK = 2;
static List<List<Integer>> G;
static int[] color;
static Deque<Integer> stack;
static List<Integer> topoDfs(int n, int[][] edges) {
G = new ArrayList<>();
for (int i = 0; i < n; i++) G.add(new ArrayList<>());
for (int[] e : edges) G.get(e[0]).add(e[1]);
color = new int[n];
stack = new ArrayDeque<>();
for (int i = 0; i < n; i++)
if (color[i] == WHITE && hasCycle(i)) return List.of(); // cycle: no order
return new ArrayList<>(stack); // top of stack first
}
// true if a cycle is reachable from u
static boolean hasCycle(int u) {
color[u] = GRAY; // on the current path
for (int v : G.get(u)) {
if (color[v] == GRAY) return true; // back edge => cycle
if (color[v] == WHITE && hasCycle(v)) return true;
}
color[u] = BLACK; // fully explored
stack.push(u); // push AFTER all descendants
return false;
}
On the clothing graph it returns [3, 4, 0, 1, 2] (pants, shoes, shirt, tie, jacket) — a different but equally valid order. Time O(V + E), space O(V) plus recursion depth.
A→B→D, A→C→D), not a cycle. Only an edge back to a node still on the current path (gray) proves a cycle. In an undirected graph, "visited and not my parent" is enough.Problem: Course Schedule I & II (LC 207, 210)
prerequisites[i] = {a, b} means "take b before a" — so the edge goes b → a. Getting this direction backwards is the most common bug. Course Schedule I asks "can you finish?" (is it a DAG?); II asks for an order.
static int[] findOrder(int numCourses, int[][] prerequisites) {
List<List<Integer>> g = new ArrayList<>();
for (int i = 0; i < numCourses; i++) g.add(new ArrayList<>());
int[] indeg = new int[numCourses];
for (int[] p : prerequisites) { // p = {course, prereq}: prereq -> course
g.get(p[1]).add(p[0]);
indeg[p[0]]++;
}
Deque<Integer> q = new ArrayDeque<>();
for (int i = 0; i < numCourses; i++) if (indeg[i] == 0) q.offer(i);
int[] order = new int[numCourses];
int idx = 0;
while (!q.isEmpty()) {
int u = q.poll();
order[idx++] = u;
for (int v : g.get(u)) if (--indeg[v] == 0) q.offer(v);
}
return idx == numCourses ? order : new int[0];
}
static boolean canFinish(int numCourses, int[][] prerequisites) {
return findOrder(numCourses, prerequisites).length == numCourses;
}
canFinish(2, [[1,0]]) → true; canFinish(2, [[1,0],[0,1]]) → false. findOrder(4, [[1,0],[2,0],[3,1],[3,2]]) → [0, 1, 2, 3]. Time O(V + E), space O(V + E).
Problem idea: Alien Dictionary (LC 269)
Words are sorted in an unknown alphabet; find the letter order. Compare each pair of adjacent words: the first position where they differ tells you one rule, x comes before y. Those rules are edges; the answer is a topological sort of the letters.
static String alienOrder(String[] words) {
Map<Character, Set<Character>> g = new HashMap<>();
Map<Character, Integer> indeg = new HashMap<>();
for (String w : words)
for (char c : w.toCharArray()) { g.putIfAbsent(c, new HashSet<>()); indeg.putIfAbsent(c, 0); }
for (int i = 0; i + 1 < words.length; i++) {
String a = words[i], b = words[i + 1];
if (a.length() > b.length() && a.startsWith(b)) return ""; // "abc" before "ab": invalid
for (int j = 0; j < Math.min(a.length(), b.length()); j++) {
char x = a.charAt(j), y = b.charAt(j);
if (x != y) { // first difference decides
if (g.get(x).add(y)) indeg.merge(y, 1, Integer::sum); // count each edge once
break;
}
}
}
Deque<Character> q = new ArrayDeque<>();
for (var e : indeg.entrySet()) if (e.getValue() == 0) q.offer(e.getKey());
StringBuilder sb = new StringBuilder();
while (!q.isEmpty()) {
char c = q.poll();
sb.append(c);
for (char nx : g.get(c)) if (indeg.merge(nx, -1, Integer::sum) == 0) q.offer(nx);
}
return sb.length() == indeg.size() ? sb.toString() : ""; // cycle => ""
}
["wrt","wrf","er","ett","rftt"] → "wertf"; ["z","x","z"] → "" (cycle). Time O(total characters), space O(alphabet^2) at most for edges.
Minimum spanning tree
Both MST algorithms rely on the cut property: split the nodes into any two groups; the cheapest edge crossing between them is safe to include in some MST. Kruskal and Prim are two ways of repeatedly finding such an edge.
Kruskal (sort edges + union-find)
Sort all edges by weight. Walk through them cheapest first; take an edge if it connects two different components, skip it if both ends are already connected (it would make a cycle). Union-find answers "already connected?" in almost O(1).
static class DSU {
int[] parent, rank;
DSU(int n) {
parent = new int[n]; rank = new int[n];
for (int i = 0; i < n; i++) parent[i] = i;
}
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]); // path compression
return parent[x];
}
boolean union(int a, int b) {
int ra = find(a), rb = find(b);
if (ra == rb) return false; // already connected
if (rank[ra] < rank[rb]) { int t = ra; ra = rb; rb = t; }
parent[rb] = ra; // union by rank
if (rank[ra] == rank[rb]) rank[ra]++;
return true;
}
}
static int kruskal(int n, int[][] edges) { // edges {u, v, w}
int[][] sorted = edges.clone();
Arrays.sort(sorted, (a, b) -> Integer.compare(a[2], b[2])); // cheapest first
DSU dsu = new DSU(n);
int cost = 0, used = 0;
for (int[] e : sorted) {
if (dsu.union(e[0], e[1])) { // joins two different trees?
cost += e[2];
if (++used == n - 1) break; // a tree has n-1 edges
}
}
return used == n - 1 ? cost : -1; // -1: graph is disconnected
}
Returns 11 on the graph above. Time O(E log E) for the sort; space O(V + E). Best for sparse graphs or when edges come as a list.
Prim (grow one tree with a heap)
Start from any node. Repeatedly add the cheapest edge that leaves the current tree. It looks like Dijkstra, but the heap key is the edge weight, not the total distance from the start.
static int prim(int n, int[][] edges) {
List<List<int[]>> g = new ArrayList<>();
for (int i = 0; i < n; i++) g.add(new ArrayList<>());
for (int[] e : edges) { // undirected: add both ways
g.get(e[0]).add(new int[]{e[1], e[2]});
g.get(e[1]).add(new int[]{e[0], e[2]});
}
boolean[] inTree = new boolean[n];
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Integer.compare(a[1], b[1])); // {node, edgeWeight}
pq.offer(new int[]{0, 0});
int cost = 0, count = 0;
while (!pq.isEmpty() && count < n) {
int[] top = pq.poll();
int u = top[0];
if (inTree[u]) continue; // already joined via a cheaper edge
inTree[u] = true;
cost += top[1];
count++;
for (int[] e : g.get(u))
if (!inTree[e[0]]) pq.offer(new int[]{e[0], e[1]});
}
return count == n ? cost : -1;
}
Also 11. Time O(E log V), space O(V + E).
Problem: Min Cost to Connect All Points (LC 1584)
Every pair of points is an edge with cost = Manhattan distance |x1-x2| + |y1-y2|. That is a complete graph with about n^2 / 2 edges, so the array version of Prim — no heap, just scan for the closest point each step — is the best fit: O(n^2) time and only O(n) memory.
static int minCostConnectPoints(int[][] points) {
int n = points.length, cost = 0;
int[] best = new int[n]; // cheapest edge from the tree to each point
Arrays.fill(best, Integer.MAX_VALUE);
boolean[] inTree = new boolean[n];
best[0] = 0;
for (int step = 0; step < n; step++) {
int u = -1;
for (int i = 0; i < n; i++) // pick the closest point not yet in the tree
if (!inTree[i] && (u == -1 || best[i] < best[u])) u = i;
inTree[u] = true;
cost += best[u];
for (int v = 0; v < n; v++) { // does u offer anyone a cheaper link?
if (inTree[v]) continue;
int d = Math.abs(points[u][0] - points[v][0]) + Math.abs(points[u][1] - points[v][1]);
if (d < best[v]) best[v] = d;
}
}
return cost;
}
[[0,0],[2,2],[3,10],[5,2],[7,0]] → 20. Why it works: each step adds the cheapest edge crossing the cut between "in tree" and "not in tree", which the cut property says is safe. Kruskal would need to build and sort all n^2 edges: O(n^2 log n) time and O(n^2) memory.
Strongly connected components — Kosaraju in easy words
In a directed graph, a strongly connected component (SCC) is a group where every node can reach every other node. Squash each SCC into one node and the graph becomes a DAG.
- DFS the whole graph and record nodes in the order they finish.
- Reverse every edge.
- Take nodes in reverse finish order; each DFS on the reversed graph from an unvisited node collects exactly one SCC.
Why: the node that finishes last belongs to a "source" component. In the reversed graph, that component cannot leak out into others, so one DFS grabs just it.
static int kosaraju(int n, int[][] edges) {
List<List<Integer>> g = new ArrayList<>(), rg = new ArrayList<>();
for (int i = 0; i < n; i++) { g.add(new ArrayList<>()); rg.add(new ArrayList<>()); }
for (int[] e : edges) { g.get(e[0]).add(e[1]); rg.get(e[1]).add(e[0]); }
boolean[] seen = new boolean[n];
Deque<Integer> finish = new ArrayDeque<>();
for (int i = 0; i < n; i++) if (!seen[i]) dfs1(i, g, seen, finish); // pass 1: finish order
Arrays.fill(seen, false);
int count = 0;
while (!finish.isEmpty()) { // pass 2: reversed graph
int u = finish.pop(); // latest finisher first
if (!seen[u]) { dfs2(u, rg, seen); count++; } // one SCC per tree
}
return count;
}
static void dfs1(int u, List<List<Integer>> g, boolean[] seen, Deque<Integer> finish) {
seen[u] = true;
for (int v : g.get(u)) if (!seen[v]) dfs1(v, g, seen, finish);
finish.push(u); // pushed when u is finished
}
static void dfs2(int u, List<List<Integer>> rg, boolean[] seen) {
seen[u] = true;
for (int v : rg.get(u)) if (!seen[v]) dfs2(v, rg, seen);
}
Edges 0→1, 1→2, 2→0, 1→3, 3→4 give 3 SCCs: {0,1,2}, {3}, {4}. Time O(V + E), space O(V + E). Tarjan's algorithm finds SCCs in one pass using the same low values as below.
Bridges and articulation points — Tarjan's idea
In an undirected graph, a bridge is an edge whose removal disconnects the graph; an articulation point is such a node. They show up as "critical connections in a network" (LC 1192), single points of failure, and road networks.
During DFS, give each node a discovery time disc[u] and compute low[u] = the earliest discovery time reachable from u's subtree using at most one back edge. If child v has low[v] > disc[u], nothing under v can climb back above u without the edge u–v: it is a bridge. (For articulation points the test is low[v] >= disc[u], with a special rule for the root: it is one only if it has two or more DFS children.)
static int timer;
static List<List<Integer>> criticalConnections(int n, List<List<Integer>> connections) {
List<List<Integer>> g = new ArrayList<>();
for (int i = 0; i < n; i++) g.add(new ArrayList<>());
for (List<Integer> c : connections) { g.get(c.get(0)).add(c.get(1)); g.get(c.get(1)).add(c.get(0)); }
int[] disc = new int[n], low = new int[n];
Arrays.fill(disc, -1); // -1 = not visited
List<List<Integer>> bridges = new ArrayList<>();
timer = 0;
for (int i = 0; i < n; i++) if (disc[i] == -1) bridgeDfs(i, -1, g, disc, low, bridges);
return bridges;
}
static void bridgeDfs(int u, int parent, List<List<Integer>> g, int[] disc, int[] low, List<List<Integer>> out) {
disc[u] = low[u] = timer++;
for (int v : g.get(u)) {
if (v == parent) continue; // don't reuse the edge we came in on
if (disc[v] == -1) {
bridgeDfs(v, u, g, disc, low, out);
low[u] = Math.min(low[u], low[v]);
if (low[v] > disc[u]) out.add(List.of(u, v)); // v's subtree can't reach above u
} else {
low[u] = Math.min(low[u], disc[v]); // back edge
}
}
}
A triangle 0-1-2 plus edge 1-3 gives [[1, 3]]. Time O(V + E), space O(V + E). For large inputs, recursion depth can reach V; convert to an iterative DFS or run in a thread with a bigger stack.
V - 1 edges.28. String Algorithms
Pattern matching means finding where a short string (the pattern, length m) appears inside a long one (the text, length n). The simple way re-checks the same characters again and again; the clever algorithms remember what they already matched so they never go backwards in the text.
Looking for "abab" in a book: after reading "aba" and hitting a mismatch, a smart reader does not jump back to the next letter and start over — they notice the "a" they just read could already be the start of a new match, and carry on from there. KMP is exactly that habit, written down as a table.
Naive matching and why it is slow
// Try every start position. O(n·m) worst case, O(1) space
static int naiveSearch(String text, String pat) {
int n = text.length(), m = pat.length();
for (int i = 0; i + m <= n; i++) { // every possible start
int j = 0;
while (j < m && text.charAt(i + j) == pat.charAt(j)) j++;
if (j == m) return i;
}
return -1;
}
Text "aaaaaaaaab", pattern "aaab": at every start the loop matches three a's, then fails on b, then slides by one and re-reads those same a's. That is up to m comparisons at each of n starts: O(n·m). On random text it is usually fine, and Java's String.indexOf works this way — but interviewers test with the bad case.
KMP (Knuth–Morris–Pratt)
KMP precomputes, for the pattern alone, the LPS array: lps[i] = length of the longest proper prefix of pat[0..i] that is also a suffix of it ("proper" = not the whole string). On a mismatch after matching j characters, we already know the last lps[j-1] characters of the text equal the start of the pattern — so we continue from there, and the text pointer never moves back.
Traced for pattern "ababaca":
| i | char | compare with pat[len] | what happens | lps[i] |
|---|---|---|---|---|
| 0 | a | — | always 0 | 0 |
| 1 | b | pat[0] = a | mismatch, len stays 0 | 0 |
| 2 | a | pat[0] = a | match, len = 1 ("a") | 1 |
| 3 | b | pat[1] = b | match, len = 2 ("ab") | 2 |
| 4 | a | pat[2] = a | match, len = 3 ("aba") | 3 |
| 5 | c | pat[3] = b | mismatch: len = lps[2] = 1; pat[1] = b, mismatch: len = lps[0] = 0; pat[0] = a, mismatch | 0 |
| 6 | a | pat[0] = a | match, len = 1 | 1 |
// lps[i] = length of the longest proper prefix of p[0..i] that is also a suffix of it
static int[] buildLps(String p) {
int m = p.length();
int[] lps = new int[m];
int len = 0; // length of the current matched prefix
for (int i = 1; i < m; i++) {
while (len > 0 && p.charAt(i) != p.charAt(len))
len = lps[len - 1]; // fall back to the next shorter border
if (p.charAt(i) == p.charAt(len)) len++;
lps[i] = len;
}
return lps;
}
// Find the Index of the First Occurrence in a String (LC 28)
static int kmpSearch(String text, String pat) {
if (pat.isEmpty()) return 0;
int[] lps = buildLps(pat);
int j = 0; // chars of pat matched so far
for (int i = 0; i < text.length(); i++) {
while (j > 0 && text.charAt(i) != pat.charAt(j))
j = lps[j - 1]; // shift the pattern; i never moves back
if (text.charAt(i) == pat.charAt(j)) j++;
if (j == pat.length()) return i - j + 1; // for ALL matches: record, then j = lps[j - 1]
}
return -1;
}
buildLps("ababaca") = [0, 0, 1, 2, 3, 0, 1]; kmpSearch("sadbutsad", "sad") = 0; kmpSearch("leetcode", "leeto") = -1; kmpSearch("ababcabcabababd", "ababd") = 10. Collecting all matches of "aa" in "aaaaa" gives [0, 1, 2, 3] (overlaps included).
Time O(n + m): i only moves forward, and j can only fall back as many times as it went up. Space O(m) for the LPS array.
Rabin-Karp (rolling hash)
Turn each length-m window of the text into a number (a hash) and compare numbers instead of strings. The trick is rolling: sliding the window one step right updates the hash in O(1) — subtract the leftmost character's contribution, multiply by the base, add the new character.
static int rabinKarp(String text, String pat) {
int n = text.length(), m = pat.length();
if (m > n) return -1;
final long MOD = 1_000_000_007L, BASE = 131;
long power = 1; // BASE^(m-1) % MOD, weight of the leading char
for (int i = 1; i < m; i++) power = power * BASE % MOD;
long hp = 0, ht = 0;
for (int i = 0; i < m; i++) {
hp = (hp * BASE + pat.charAt(i)) % MOD;
ht = (ht * BASE + text.charAt(i)) % MOD;
}
for (int i = 0; ; i++) {
// equal hashes mean PROBABLY equal strings: confirm to rule out a collision
if (hp == ht && text.regionMatches(i, pat, 0, m)) return i;
if (i + m == n) return -1;
ht = (ht - text.charAt(i) * power % MOD + MOD) % MOD; // drop left char (+MOD keeps it >= 0)
ht = (ht * BASE + text.charAt(i + m)) % MOD; // add right char
}
}
rabinKarp("sadbutsad", "but") = 3. Expected time O(n + m); worst case O(n·m) if many windows collide (same hash, different string) and each needs a full check. Space O(1).
- Collisions are unavoidable (many strings, few hash values). Either verify on a hash match, as above, or use two independent hashes (two mods) so a false match is astronomically unlikely.
- Use
longand a prime modulus around 10^9;hash * BASEthen stays below about 1.4·10^11, far insidelong. - Rabin-Karp shines when you need many patterns of the same length, or substring comparisons: Longest Duplicate Substring (LC 1044) = binary search on length + rolling hash.
String hashing (prefix hashes)
Precompute the hash of every prefix once; then the hash of any substring comes out in O(1). Two substrings with equal hashes are (almost certainly) equal — so "compare two substrings" drops from O(length) to O(1).
static class StringHash {
static final long MOD = 1_000_000_007L, BASE = 131;
final long[] h, pw; // h[i] = hash of s[0..i), pw[i] = BASE^i
StringHash(String s) {
int n = s.length();
h = new long[n + 1]; pw = new long[n + 1];
pw[0] = 1;
for (int i = 0; i < n; i++) {
h[i + 1] = (h[i] * BASE + s.charAt(i)) % MOD;
pw[i + 1] = pw[i] * BASE % MOD;
}
}
long get(int l, int r) { // hash of s[l..r) in O(1)
return ((h[r] - h[l] * pw[r - l]) % MOD + MOD) % MOD;
}
}
For "abcabc", get(0, 3) == get(3, 6) is true ("abc" = "abc"). Build O(n), query O(1), space O(n).
Z-algorithm (briefly)
z[i] = length of the longest substring starting at i that matches a prefix of the string. It keeps a window [l, r) known to match the prefix and reuses earlier values inside it, much like KMP, for O(n) total.
static int[] zArray(String s) {
int n = s.length();
int[] z = new int[n];
int l = 0, r = 0; // [l, r) = rightmost window matching a prefix
for (int i = 1; i < n; i++) {
if (i < r) z[i] = Math.min(r - i, z[i - l]); // reuse what we know
while (i + z[i] < n && s.charAt(z[i]) == s.charAt(i + z[i])) z[i]++;
if (i + z[i] > r) { l = i; r = i + z[i]; }
}
return z;
}
zArray("aabxaab") = [0, 1, 0, 0, 3, 1, 0]. Pattern search: build pat + "$" + text (with a separator that appears in neither) and look for z[i] == m. For "aab$baabaa", z[5] = 3, so "aab" occurs in "baabaa" at index 5 - 4 = 1. Time and space O(n + m).
Problem: Repeated Substring Pattern (LC 459)
Is s some block repeated two or more times? Using LPS: the longest border (prefix = suffix) is lps[n-1]. The shortest possible repeating block has length n - lps[n-1], and s is a repetition exactly when that length divides n (and the border is non-empty).
static boolean repeatedSubstringPattern(String s) {
int n = s.length();
int[] lps = buildLps(s);
int border = lps[n - 1]; // longest prefix that is also a suffix
int period = n - border; // candidate block length
return border > 0 && n % period == 0;
}
// The one-line trick: s is a repetition iff s appears inside (s + s) with first and last char removed
static boolean repeatedTrick(String s) {
return (s + s).substring(1, 2 * s.length() - 1).contains(s);
}
"abab" → true, "aba" → false, "abcabcabcabc" → true. LPS version: O(n) time and space. The trick uses contains (naive search), so it is O(n^2) worst case — say so if you use it.
Palindromes: expand around center
Every palindrome has a center: a single character (odd length, "aba") or the gap between two characters (even length, "abba"). There are 2n - 1 centers. From each, expand outward while both sides match.
// Longest Palindromic Substring (LC 5). O(n^2) time, O(1) space
static String longestPalindrome(String s) {
int start = 0, best = 0;
for (int c = 0; c < s.length(); c++) {
int odd = expand(s, c, c); // center on a char: "aba"
int even = expand(s, c, c + 1); // center between chars: "abba"
int len = Math.max(odd, even);
if (len > best) {
best = len;
start = c - (len - 1) / 2; // works for both odd and even lengths
}
}
return s.substring(start, start + best);
}
private static int expand(String s, int l, int r) {
while (l >= 0 && r < s.length() && s.charAt(l) == s.charAt(r)) { l--; r++; }
return r - l - 1; // loop overshot by one on each side
}
"babad" → "bab", "cbbd" → "bb", "forgeeksskeegfor" → "geeksskeeg". Why this works: every palindrome is found when we expand from its own center, and expansion stops exactly at its edges. It beats the O(n^2)-space DP table dp[i][j] = s[i]==s[j] && dp[i+1][j-1].
Manacher's algorithm finds all palindromes in O(n) by reusing mirror information around the rightmost palindrome found so far (the same "reuse a window" idea as the Z-algorithm). Mention it by name; interviewers rarely expect the code. Related: Palindromic Substrings (LC 647) counts instead of maximises — same expansion, add (len + 1) / 2 per center.
Problem pointer: Minimum Window Substring (LC 76)
"Smallest substring of s containing all characters of t" is not a hashing or KMP problem — it is the variable-size sliding window pattern (see the sliding window section): expand the right pointer until the window is valid (counts of needed chars all met, tracked with a need array and a missing counter), then shrink from the left while it stays valid, recording the best length. Each pointer moves at most n times: O(|s| + |t|) time, O(alphabet) space.
| Need | Use | Time |
|---|---|---|
| Find one pattern, guaranteed linear | KMP (or Z) | O(n + m) |
| Compare many substrings, many patterns of one length | Rolling / prefix hash | O(n) build, O(1) compare |
| Period / border questions | LPS array | O(n) |
| Longest palindrome | Expand around center (Manacher if pressed) | O(n^2) / O(n) |
| Many patterns at once | Trie / Aho-Corasick | O(n + total pattern length + matches) |
s1 == s2 compares references, use equals. += on a String in a loop is O(n^2) — use StringBuilder. substring copies (O(length)) since Java 7. Subtracting chars (c - 'a') gives an int index; casting back needs (char).29. Math & Number Theory
A small set of math tools comes up again and again: greatest common divisor, prime numbers, fast powers, and doing arithmetic modulo a big prime so numbers never get too large. Each has a short, well-known algorithm you should be able to write from memory.
Modulo (a % m) is the remainder after dividing by m. Working "mod m" means you only ever keep that remainder.
A clock is arithmetic mod 12: 9 o'clock plus 5 hours is 2 o'clock, not 14. You never need to know how many days passed — only where the hand is. Problems that say "return the answer mod 10^9+7" want exactly this: the hand position, not the giant number.
GCD and LCM (Euclid)
The greatest common divisor gcd(a, b) is the largest number dividing both. Euclid's rule: gcd(a, b) = gcd(b, a % b), and gcd(a, 0) = a. It works because any number dividing a and b also divides a - k·b, which is what the remainder is.
static long gcd(long a, long b) {
while (b != 0) { // gcd(a, b) = gcd(b, a % b)
long t = a % b;
a = b;
b = t;
}
return Math.abs(a);
}
static long lcm(long a, long b) {
return a / gcd(a, b) * b; // divide FIRST to avoid overflow
}
gcd(48, 18) = 6, lcm(4, 6) = 12, lcm(10^9, 999,999,999) = 999,999,999,000,000,000 (fits in long only because we divide before multiplying). Time O(log min(a, b)) — the numbers at least halve every two steps. Uses: simplifying fractions, "water jug" problems, GCD of an array (fold gcd over it), slopes as reduced (dy, dx) pairs in Max Points on a Line.
Sieve of Eratosthenes
To find all primes up to n: start with every number marked prime. For each prime i, cross out its multiples. Start crossing at i·i — smaller multiples like 2i, 3i were already crossed out by smaller primes. Stop when i·i > n.
static boolean[] sieve(int n) { // isPrime[0..n]
boolean[] isPrime = new boolean[n + 1];
Arrays.fill(isPrime, true);
isPrime[0] = false;
if (n >= 1) isPrime[1] = false;
for (int i = 2; (long) i * i <= n; i++) { // long: i*i can overflow near MAX_VALUE
if (!isPrime[i]) continue;
for (int j = i * i; j <= n; j += i) // start at i*i
isPrime[j] = false;
}
return isPrime;
}
sieve(30) marks 2 3 5 7 11 13 17 19 23 29. Time O(n log log n) — nearly linear. Space O(n). To test a single number, trial division up to sqrt(n) is enough: O(sqrt n).
Problem: Count Primes (LC 204)
// Count primes strictly less than n
static int countPrimes(int n) {
if (n < 3) return 0;
boolean[] composite = new boolean[n]; // false = still possibly prime
int count = 0;
for (int i = 2; i < n; i++) {
if (composite[i]) continue;
count++; // i survived every smaller prime
for (long j = (long) i * i; j < n; j += i) composite[(int) j] = true;
}
return count;
}
countPrimes(10) = 4 (2, 3, 5, 7); countPrimes(1_000_000) = 78,498. Time O(n log log n), space O(n). Using composite (default false) saves the Arrays.fill.
Prime factorisation
Divide out 2 as many times as possible, then 3, then 4 (never divides — its 2s are gone), and so on up to sqrt(n). Whatever is left above 1 is itself a prime (it cannot have two factors both bigger than sqrt).
static Map<Long, Integer> factorize(long n) {
Map<Long, Integer> f = new TreeMap<>();
for (long p = 2; p * p <= n; p++) {
while (n % p == 0) { // divide out p completely
f.merge(p, 1, Integer::sum);
n /= p;
}
}
if (n > 1) f.merge(n, 1, Integer::sum); // leftover is a prime > sqrt
return f;
}
factorize(360) = {2=3, 3=2, 5=1} (360 = 2^3 · 3^2 · 5). Time O(sqrt n), space O(log n). For factorising many numbers, sieve the smallest prime factor spf[x] once, then each number factors in O(log x) by repeatedly dividing by spf.
Fast exponentiation (binary power)
To get a^e, don't multiply e times. Write e in binary: a^13 = a^8 · a^4 · a^1 since 13 = 1101 in binary. Keep squaring the base (a, a^2, a^4, a^8…) and multiply it into the result whenever the current bit of e is 1. That is O(log e) multiplications.
static long modPow(long base, long exp, long mod) {
long result = 1;
base %= mod;
if (base < 0) base += mod;
while (exp > 0) {
if ((exp & 1) == 1) result = result * base % mod; // this bit is set: multiply it in
base = base * base % mod; // square for the next bit
exp >>= 1;
}
return result;
}
modPow(2, 10, 10^9+7) = 1024; modPow(2, 10^9, 10^9+7) = 140625001 in about 30 steps. Time O(log exp), space O(1). Safe because both factors are below mod ~ 10^9, so their product is below ~10^18 < Long.MAX_VALUE (~9.2·10^18).
Problem: Pow(x, n) (LC 50)
static double myPow(double x, int n) {
long e = n; // long: -Integer.MIN_VALUE does not fit in an int
if (e < 0) { x = 1 / x; e = -e; }
double result = 1;
while (e > 0) {
if ((e & 1) == 1) result *= x;
x *= x;
e >>= 1;
}
return result;
}
myPow(2.0, 10) = 1024.0, myPow(2.0, -2) = 0.25, myPow(1.0, Integer.MIN_VALUE) = 1.0. Time O(log n), space O(1). The classic bug is writing n = -n on an int: -(-2147483648) is still -2147483648.
Modular arithmetic rules and 1_000_000_007
When answers are huge (counting paths, arrangements), problems ask for the result mod 10^9 + 7. Why this number: it is prime (so division works via inverses, below), it fits in an int, and the product of two values below it fits in a long.
| Operation | Rule | Java |
|---|---|---|
| Add | (a + b) mod m = ((a mod m) + (b mod m)) mod m | (a + b) % MOD |
| Subtract | Same, but the result may go negative | ((a - b) % MOD + MOD) % MOD |
| Multiply | (a · b) mod m = ((a mod m) · (b mod m)) mod m | a * b % MOD with long a, b |
| Divide | Not allowed directly; multiply by the modular inverse | a * modInverse(b) % MOD |
| Power | Binary exponentiation | modPow(a, e, MOD) |
% keeps the sign of the left side: -7 % 3 is -1, not 2. Fix it with ((x % m) + m) % m or Math.floorMod(x, m), both of which give 2.Modular inverse via Fermat
The inverse of a mod p is the number x with a · x ≡ 1 (mod p) — the "mod version" of 1/a. Fermat's little theorem says that for a prime p and a not divisible by p, a^(p-1) ≡ 1. Split one factor off: a · a^(p-2) ≡ 1, so a^(p-2) is the inverse.
static final int MOD = 1_000_000_007;
static long modInverse(long a) { // valid when MOD is prime and a % MOD != 0
return modPow(a, MOD - 2, MOD); // a^(p-2) = a^-1
}
modInverse(2) = 500000004, and 2 · 500000004 % MOD = 1. Time O(log p). If the modulus is not prime, use the extended Euclidean algorithm instead (the inverse exists only when gcd(a, m) = 1).
nCr — Pascal's triangle and factorials mod p
C(n, r) = ways to choose r items from n. Two ways to compute it, depending on size.
Pascal's triangle (n up to a few thousand): C(n, r) = C(n-1, r-1) + C(n-1, r) — either item n is chosen or it isn't. Only additions, so it works with any modulus, even non-prime.
static long[][] pascal(int n) { // C[i][j] for i, j <= n. O(n^2) time and space
long[][] C = new long[n + 1][n + 1];
for (int i = 0; i <= n; i++) {
C[i][0] = 1;
for (int j = 1; j <= i; j++)
C[i][j] = (C[i - 1][j - 1] + C[i - 1][j]) % MOD; // take item i or not
}
return C;
}
Factorials (n up to 10^6 or more): C(n, r) = n! / (r! · (n-r)!). Division mod p means multiplying by inverses. Precompute fact[] and invFact[] once; each query is then O(1).
static long[] fact, invFact;
static void precompute(int n) { // O(n + log MOD)
fact = new long[n + 1];
invFact = new long[n + 1];
fact[0] = 1;
for (int i = 1; i <= n; i++) fact[i] = fact[i - 1] * i % MOD;
invFact[n] = modInverse(fact[n]); // one expensive inverse...
for (int i = n; i > 0; i--)
invFact[i - 1] = invFact[i] * i % MOD; // ...then walk down: 1/(i-1)! = i/i!
}
static long nCr(int n, int r) { // O(1) per query
if (r < 0 || r > n) return 0;
return fact[n] * invFact[r] % MOD * invFact[n - r] % MOD;
}
Both give C(5, 2) = 10 and C(10, 3) = 120; nCr(100000, 50000) mod 10^9+7 = 149033233. Note the multiplication chain: take % MOD after each product, or the second multiplication overflows.
Overflow handling in Java
| Code | Result | Why |
|---|---|---|
Integer.MAX_VALUE + 1 | -2147483648 | Silent wrap-around; Java never throws on int/long overflow |
1_000_000 * 1_000_000 | -727379968 | Both are int, so the product is computed as int before any assignment to long |
1_000_000L * 1_000_000 | 1000000000000 | One long operand makes the whole product long |
Math.abs(Integer.MIN_VALUE) | -2147483648 | +2147483648 does not fit in an int |
Math.addExact(MAX_VALUE, 1) | throws ArithmeticException ("integer overflow") | Use addExact / multiplyExact when you want a loud failure |
(lo + hi) / 2 | can overflow | Use lo + (hi - lo) / 2 or (lo + hi) >>> 1 |
Rules of thumb: anything that can exceed ~2·10^9 goes in a long; anything that can exceed ~9·10^18 needs mod arithmetic or BigInteger. In "reverse integer" style problems, check result > (Integer.MAX_VALUE - digit) / 10 before multiplying.
Randomness: reservoir sampling
Pick one item uniformly at random from a stream whose length you don't know (and can't store). Keep the k-th item with probability 1/k, replacing the current choice. Each item ends up chosen with probability exactly 1/n: item k is picked with 1/k, then survives each later step j with (j-1)/j, and the product telescopes to 1/n.
static int reservoirPick(Iterator<Integer> stream, Random rnd) {
int chosen = -1, seen = 0;
while (stream.hasNext()) {
int x = stream.next();
seen++;
if (rnd.nextInt(seen) == 0) chosen = x; // keep x with probability 1/seen
}
return chosen;
}
Picking from [0..4] 100,000 times (seed 42) gave counts [20023, 20080, 19959, 19937, 20001] — about 20% each, as expected. Time O(n), space O(1). Used in Linked List Random Node (LC 382) and Random Pick Index (LC 398). To keep k items: fill the first k, then item i replaces a random slot with probability k/i. For shuffling an array, use Fisher–Yates: for i from the end, swap a[i] with a[rnd.nextInt(i + 1)].
Problem: Excel Sheet Column Number / Title (LC 171, 168)
Columns are base 26, but with digits 1..26 instead of 0..25 (there is no "zero" letter). Reading is ordinary base conversion; writing needs n-- first to shift into 0-based digits.
static int titleToNumber(String col) { // "AB" -> 28
int result = 0;
for (char c : col.toCharArray())
result = result * 26 + (c - 'A' + 1);
return result;
}
static String convertToTitle(int n) { // 28 -> "AB"
StringBuilder sb = new StringBuilder();
while (n > 0) {
n--; // shift to 0-based: A=0 ... Z=25
sb.append((char) ('A' + n % 26));
n /= 26;
}
return sb.reverse().toString();
}
"A" → 1, "AB" → 28, "ZY" → 701; 28 → "AB", 701 → "ZY", 26 → "Z". Both O(log n) time. A version without n-- that uses (char) ('A' + n % 26 - 1) turns 26 into "A@".
Problem: Happy Number (LC 202)
Repeatedly replace n by the sum of the squares of its digits. Happy if it reaches 1; otherwise it falls into a loop. A loop in a sequence is a cycle in a linked list — use Floyd's slow/fast pointers instead of a HashSet.
static boolean isHappy(int n) {
int slow = n, fast = next(n);
while (fast != 1 && slow != fast) { // Floyd cycle detection
slow = next(slow);
fast = next(next(fast));
}
return fast == 1;
}
private static int next(int n) {
int sum = 0;
while (n > 0) { int d = n % 10; sum += d * d; n /= 10; }
return sum;
}
19 → true (1 + 81 = 82 → 68 → 100 → 1); 2 → false. Time O(log n) per step, and the values quickly drop below a few hundred, so the loop is short. Space O(1). Why the loop must exist: for any number with 4+ digits, the digit-square sum is smaller than the number, so values stay bounded and must eventually repeat.
O(log). All primes to n → sieve O(n log log n). One number prime? → trial division to sqrt n. a^b mod m → binary power O(log b). Division mod prime → multiply by a^(p-2). Many nCr → precompute factorials and inverse factorials. Always: long for products, mod after every step, floorMod for negatives.Most interview problems are not new. They are one of about sixteen patterns wearing a different story. Once you see the pattern, you already know the shape of the code and the target complexity, and the rest is details. So the real skill is recognition: reading a problem statement and noticing the words that point to a pattern.
Every section below has the same shape: what the pattern is, how to spot it, how it moves (with a diagram), a reusable Java template, classic problems with full solutions, the usual mistakes, and a practice list. Learn the template, then solve the classics without looking.
| Pattern | Signal in the problem | Typical complexity |
|---|---|---|
| Two Pointers | Sorted array, pair or triplet with a target sum, palindrome, "in place", reverse | O(n) time (O(n log n) if you must sort), O(1) space |
| Sliding Window | Contiguous subarray or substring, "longest / shortest / at most k", window of size k | O(n) time, O(1) or O(alphabet) space |
| Prefix Sum & Difference Arrays | Many range-sum queries, "subarray sum equals k", many range updates | O(n) build, O(1) per query |
| Fast & Slow Pointers | Cycle in a linked list or sequence, middle of a list, O(1) extra space | O(n) time, O(1) space |
| Monotonic Stack & Queue | "Next greater / smaller", "previous smaller", span, histogram, window max | O(n) time (each item pushed and popped once), O(n) space |
| Merge Intervals | Intervals, meetings, bookings, overlapping ranges, "minimum rooms" | O(n log n) for the sort, then O(n) |
| Cyclic Sort | Numbers in the range 1..n (or 0..n), find missing or duplicate, O(1) space | O(n) time, O(1) space |
| In-place Linked List Reversal | Reverse a list or part of it, reorder, "in k-groups", no extra memory | O(n) time, O(1) space |
| Tree BFS & DFS | Level by level, depth, paths, "every node", tree views | O(n) time, O(h) or O(width) space |
| Top K & K-way Merge | "k largest / most frequent / closest", merge k sorted lists | O(n log k) time, O(k) space |
| Two Heaps | Running median, split numbers into a smaller half and a bigger half | O(log n) per insert, O(1) to read the median |
| Subsets & Permutations | "All combinations / subsets / permutations", generate every arrangement | O(2^n) or O(n!) outputs, O(n) recursion depth |
| Modified Binary Search | Sorted or rotated array, "minimum value that works", search on the answer | O(log n) or O(n log range) |
| Topological Sort | Tasks with prerequisites, build order, dependency graph, detect a cycle | O(V + E) time and space |
| Matrix & Islands | 2D grid, connected regions, flood fill, shortest path in a grid | O(rows x cols) time and space |
| Design Problems | "Design a class that supports get / put / ...", LRU cache, iterators | O(1) or O(log n) per operation |
30. Two Pointers
Two pointers means keeping two index variables into the same array (or string) and moving them by a rule, instead of trying every pair with two nested loops. Each step, one pointer moves and some candidates are ruled out for good, so the whole scan takes O(n) instead of O(n^2).
There are two flavours. Opposite ends: one pointer starts at the left, one at the right, and they walk toward each other. Same direction: a slow pointer and a fast pointer both go left to right; the fast one reads, the slow one marks where the next "kept" element should go.
Two people searching a sorted bookshelf for two books whose page counts add to 500. One starts at the thin end, one at the thick end. If the total is too big, the person at the thick end steps in; if too small, the person at the thin end steps in. They never need to go back.
How to recognise it
- The input is a sorted array (or sorting it is cheap and allowed) and you need a pair or triplet with some sum or difference.
- "Is this string a palindrome", "reverse", "compare from both ends".
- "Remove / move elements in place", "return the new length", "O(1) extra space".
- "Container", "trap water", "two lines" — answers that depend on a left wall and a right wall.
- The brute force is two nested loops over pairs, and the array order lets you throw away one side each step.
How it works
Why is it safe to throw candidates away? Take Two Sum on a sorted array. If a[L] + a[R] is too big, then a[R] plus anything to the right of L is even bigger, so a[R] can never be part of the answer. Drop it: R--. If the sum is too small, the same logic drops a[L]. Every step removes one element from consideration, so there are at most n steps.
Same-direction pointers work like a filter. fast looks at every element once. When a[fast] should be kept, it is copied to a[slow] and slow moves on. At the end, a[0..slow-1] is the answer and slow is its length.
Java template
// Variant 1: opposite ends — pointers start at both ends and walk toward each other.
public int[] oppositeEnds(int[] a, int target) {
int left = 0, right = a.length - 1;
while (left < right) { // stop when they meet
int value = a[left] + a[right]; // CHANGES: what you compute from the pair
if (value == target) { // CHANGES: the "found / record answer" step
return new int[]{left, right};
} else if (value < target) {
left++; // need a bigger value -> move left inward
} else {
right--; // need a smaller value -> move right inward
}
}
return new int[]{-1, -1}; // CHANGES: what "not found" means
}
// Variant 2: same direction — a slow "write" pointer and a fast "read" pointer.
public int sameDirection(int[] a) {
int slow = 0; // next position to write a kept element
for (int fast = 0; fast < a.length; fast++) {
if (keep(a, slow, fast)) { // CHANGES: the rule for keeping a[fast]
a[slow] = a[fast];
slow++;
}
}
return slow; // length of the kept prefix a[0..slow-1]
}
Classic problems
Two Sum II — Input Array Is Sorted, LC 167 (Medium)
Given a 1-indexed sorted array, return the positions of the two numbers that add up to target.
Key insight: sorted order tells you which way to move. Too big, move right in; too small, move left in. No HashMap needed, so space is O(1).
public int[] twoSum(int[] numbers, int target) {
int left = 0, right = numbers.length - 1;
while (left < right) {
int sum = numbers[left] + numbers[right];
if (sum == target) return new int[]{left + 1, right + 1}; // 1-indexed
if (sum < target) left++;
else right--;
}
return new int[]{-1, -1}; // problem guarantees one answer
}
// twoSum([2,7,11,15], 9) -> [1, 2]
// twoSum([1,3,4,6,8,11], 10) -> [3, 4]
Time O(n), Space O(1).
3Sum, LC 15 (Medium)
Return all unique triplets [a, b, c] in the array with a + b + c == 0.
Key insight: sort, then fix the first number nums[i] and run Two Sum II on the rest looking for -nums[i]. Skipping equal neighbours (for i, left and right) removes duplicate triplets without a Set.
public List<List<Integer>> threeSum(int[] nums) {
Arrays.sort(nums);
List<List<Integer>> result = new ArrayList<>();
for (int i = 0; i < nums.length - 2; i++) {
if (nums[i] > 0) break; // smallest is positive: no zero sum
if (i > 0 && nums[i] == nums[i - 1]) continue; // skip duplicate first number
int left = i + 1, right = nums.length - 1;
while (left < right) {
int sum = nums[i] + nums[left] + nums[right];
if (sum < 0) {
left++;
} else if (sum > 0) {
right--;
} else {
result.add(List.of(nums[i], nums[left], nums[right]));
left++;
right--;
while (left < right && nums[left] == nums[left - 1]) left++; // skip dup
while (left < right && nums[right] == nums[right + 1]) right--; // skip dup
}
}
}
return result;
}
// threeSum([-1,0,1,2,-1,-4]) -> [[-1, -1, 2], [-1, 0, 1]]
// threeSum([0,0,0,0]) -> [[0, 0, 0]]
Time O(n^2) (the sort is O(n log n), then n two-pointer passes), Space O(1) extra apart from the output (the sort may use O(log n)).
Container With Most Water, LC 11 (Medium)
Each height[i] is a vertical line. Pick two lines that, with the x-axis, hold the most water.
Key insight: area = min(h[L], h[R]) * (R - L). Moving inward always shrinks the width, so the only hope of a bigger area is a taller short wall. Moving the taller wall can never help, so move the shorter one.
public int maxArea(int[] height) {
int left = 0, right = height.length - 1, best = 0;
while (left < right) {
int h = Math.min(height[left], height[right]);
best = Math.max(best, h * (right - left));
// The shorter wall limits the area. Moving the taller one can only make it worse,
// so always move the shorter one.
if (height[left] < height[right]) left++;
else right--;
}
return best;
}
// maxArea([1,8,6,2,5,4,8,3,7]) -> 49 (lines at index 1 and 8: min(8,7) * 7)
Time O(n), Space O(1).
Remove Duplicates from Sorted Array, LC 26 (Easy)
Remove duplicates in place so each value appears once; return the count k of unique values (they must be in nums[0..k-1]).
Key insight: same-direction pointers. Because the array is sorted, a value is new exactly when it differs from the last value written, nums[slow - 1].
public int removeDuplicates(int[] nums) {
if (nums.length == 0) return 0;
int slow = 1; // nums[0] is always kept
for (int fast = 1; fast < nums.length; fast++) {
if (nums[fast] != nums[slow - 1]) { // new value (array is sorted)
nums[slow] = nums[fast];
slow++;
}
}
return slow;
}
// [0,0,1,1,1,2,2,3,3,4] -> 5, first five = [0, 1, 2, 3, 4]
Time O(n), Space O(1).
Trapping Rain Water, LC 42 (Hard)
Given bar heights, compute how much rain water is trapped between the bars.
Key insight: water above bar i is min(maxLeft, maxRight) - height[i]. With two pointers you don't need both maxima: if height[left] < height[right], there is a wall on the right at least as tall as anything on the left, so the level at left is decided by leftMax alone.
public int trap(int[] height) {
int left = 0, right = height.length - 1;
int leftMax = 0, rightMax = 0, water = 0;
while (left < right) {
if (height[left] < height[right]) {
// Right side has a wall at least this tall, so leftMax decides the level here.
leftMax = Math.max(leftMax, height[left]);
water += leftMax - height[left];
left++;
} else {
rightMax = Math.max(rightMax, height[right]);
water += rightMax - height[right];
right--;
}
}
return water;
}
// trap([0,1,0,2,1,0,1,3,2,1,2,1]) -> 6
// trap([4,2,0,3,2,5]) -> 9
Time O(n), Space O(1). (The prefix-max version uses two extra arrays: O(n) space.)
Valid Palindrome, LC 125 (Easy)
After lower-casing and dropping everything that is not a letter or digit, does the string read the same both ways?
Key insight: compare from both ends and skip junk characters on the fly, instead of building a cleaned copy of the string.
public boolean isPalindrome(String s) {
int left = 0, right = s.length() - 1;
while (left < right) {
while (left < right && !Character.isLetterOrDigit(s.charAt(left))) left++;
while (left < right && !Character.isLetterOrDigit(s.charAt(right))) right--;
if (Character.toLowerCase(s.charAt(left)) != Character.toLowerCase(s.charAt(right))) {
return false;
}
left++;
right--;
}
return true;
}
// isPalindrome("A man, a plan, a canal: Panama") -> true
// isPalindrome("race a car") -> false
Time O(n), Space O(1).
left <= right when a pair needs two different elements (use <); forgetting to sort first (opposite-ends logic only works on sorted data); in 3Sum, skipping duplicates before recording the triplet, or forgetting to skip them for left/right after a match, which gives repeated triplets; in Container With Most Water, moving the taller wall; returning 0-indexed positions when the problem asks for 1-indexed; in inner while loops, forgetting the left < right guard and running off the array.Practice list
- LC 344 Reverse String (Easy)
- LC 283 Move Zeroes (Easy)
- LC 977 Squares of a Sorted Array (Easy)
- LC 88 Merge Sorted Array (Easy)
- LC 680 Valid Palindrome II (Easy)
- LC 75 Sort Colors (Medium) — three pointers, the Dutch national flag
- LC 16 3Sum Closest (Medium)
- LC 881 Boats to Save People (Medium)
- LC 18 4Sum (Medium)
31. Sliding Window
A sliding window is a stretch of consecutive elements, a[left..right], that moves across the array. Instead of recomputing each subarray from scratch, you update the window: add the element that enters on the right, remove the one that leaves on the left. That turns O(n·k) or O(n^2) work into O(n).
A fixed window always has size k: every step adds one and removes one. A variable window grows and shrinks: right always moves forward, and left moves forward only when the window breaks a rule ("no repeats", "at most k distinct"). Both pointers only ever move right, so each element enters once and leaves once.
Reading a long train through a window that shows a few carriages. As the train moves, one carriage appears on the right and one disappears on the left. You don't recount the whole view each time; you just note what came in and what went out.
How to recognise it
- "Contiguous subarray" or "substring" (not subsequence: a subsequence can skip elements, a window cannot).
- "Longest / shortest / maximum / minimum subarray such that ...".
- "At most k distinct", "without repeating", "contains all characters of t", "an anagram / permutation of p".
- "Every window of size k", "average of each k consecutive elements".
- The rule is monotonic: if a window is invalid, making it bigger keeps it invalid. That is what makes shrinking from the left safe. (With negative numbers and a sum target, this breaks; use prefix sums instead.)
How it works
The variable-window loop has three beats: expand (take s[right] in and update counts), shrink (while the window is invalid, remove s[left] and move left), record (the window is now valid; compare its size to the best). For "longest" problems you record after shrinking. For "shortest" problems (like Minimum Window Substring) you record inside the shrink loop, while the window is still valid.
For character counts, an int[128] (all ASCII) or int[26] (lowercase letters) is faster and simpler than a HashMap<Character, Integer>. Use a HashMap when the "characters" are arbitrary integers, like fruit types.
Java template
// Fixed-size window of length k: add the new right element, drop the one that fell off.
public int fixedWindow(int[] a, int k) {
int windowSum = 0, best = Integer.MIN_VALUE; // CHANGES: the window state
for (int right = 0; right < a.length; right++) {
windowSum += a[right]; // 1. add a[right]
if (right >= k - 1) { // window is full (size k)
best = Math.max(best, windowSum); // 2. record the answer
windowSum -= a[right - k + 1]; // 3. remove the leftmost element
}
}
return best;
}
// Variable-size window: expand right every step, shrink left while the window is invalid.
public int variableWindow(String s) {
int[] count = new int[128]; // CHANGES: window state (counts, sum, map...)
int left = 0, best = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
count[c]++; // 1. expand: take s[right] into the window
while (invalid(count, c)) { // 2. shrink: CHANGES: the "invalid" rule
count[s.charAt(left)]--;
left++;
}
best = Math.max(best, right - left + 1);// 3. window [left..right] is valid: record
}
return best;
}
Classic problems
Maximum Sum Subarray of Size K (Easy)
Return the largest sum of any k consecutive elements. (LC 643 Maximum Average Subarray I is the same problem divided by k.)
Key insight: fixed window. Moving the window by one changes the sum by +nums[right] - nums[right - k], so each step is O(1).
public int maxSumSubarray(int[] nums, int k) {
int windowSum = 0;
for (int i = 0; i < k; i++) windowSum += nums[i]; // first window
int best = windowSum;
for (int right = k; right < nums.length; right++) {
windowSum += nums[right] - nums[right - k]; // slide by one
best = Math.max(best, windowSum);
}
return best;
}
// maxSumSubarray([2,1,5,1,3,2], 3) -> 9 (5 + 1 + 3)
// maxSumSubarray([1,12,-5,-6,50,3], 4) -> 51 (12 - 5 - 6 + 50)
Time O(n), Space O(1).
Longest Substring Without Repeating Characters, LC 3 (Medium)
Return the length of the longest substring with all distinct characters.
Key insight: the template works (shrink while count[c] > 1). A faster trick: remember where each character was last seen and jump left straight past it. Math.max stops left from jumping backwards to an old copy that is already outside the window (try "abba").
public int lengthOfLongestSubstring(String s) {
int[] lastSeen = new int[128]; // lastSeen[c] = (last index of c) + 1, 0 = never
int left = 0, best = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
left = Math.max(left, lastSeen[c]); // jump left past the previous copy of c
lastSeen[c] = right + 1;
best = Math.max(best, right - left + 1);
}
return best;
}
// "abcabcbb" -> 3 ("abc"), "bbbbb" -> 1, "pwwkew" -> 3 ("wke"), "abba" -> 2
Time O(n), Space O(1) (128 slots, fixed).
Longest Repeating Character Replacement, LC 424 (Medium)
You may change at most k characters of an uppercase string. Return the longest substring of one repeated letter you can get.
Key insight: a window is fine if size - (count of its most common letter) <= k: those are the letters you'd replace. maxFreq is never decreased when shrinking. That is safe, because the answer only grows when a window beats the old maxFreq, and a stale value just keeps the window from shrinking too much.
public int characterReplacement(String s, int k) {
int[] count = new int[26];
int left = 0, maxFreq = 0, best = 0;
for (int right = 0; right < s.length(); right++) {
maxFreq = Math.max(maxFreq, ++count[s.charAt(right) - 'A']);
// chars to replace = window size - most frequent char count
while (right - left + 1 - maxFreq > k) {
count[s.charAt(left) - 'A']--;
left++;
}
best = Math.max(best, right - left + 1);
}
return best;
}
// characterReplacement("ABAB", 2) -> 4
// characterReplacement("AABABBA", 1) -> 4
Time O(n), Space O(1).
Minimum Window Substring, LC 76 (Hard)
Return the shortest substring of s that contains every character of t (with multiplicity), or "" if none.
Key insight: keep need[c] = how many more of c the window needs (it goes negative for extras) and a single counter missing. When missing == 0 the window is valid: record it, then shrink until it breaks. This is a "shortest" problem, so you record inside the shrink loop.
public String minWindow(String s, String t) {
int[] need = new int[128];
for (char c : t.toCharArray()) need[c]++;
int missing = t.length(); // chars of t still not covered by the window
int left = 0, bestStart = 0, bestLen = Integer.MAX_VALUE;
for (int right = 0; right < s.length(); right++) {
if (need[s.charAt(right)]-- > 0) missing--; // this char was actually needed
while (missing == 0) { // window covers t: try to shrink
if (right - left + 1 < bestLen) {
bestLen = right - left + 1;
bestStart = left;
}
if (++need[s.charAt(left)] > 0) missing++; // we just dropped a needed char
left++;
}
}
return bestLen == Integer.MAX_VALUE ? "" : s.substring(bestStart, bestStart + bestLen);
}
// minWindow("ADOBECODEBANC", "ABC") -> "BANC"
// minWindow("a", "aa") -> ""
Time O(|s| + |t|), Space O(1) (128 slots).
Permutation in String, LC 567 (Medium)
Does s2 contain some permutation (anagram) of s1 as a substring?
Key insight: an anagram has the same length and the same letter counts. So slide a fixed window of size s1.length() over s2 and compare two int[26] arrays; comparing 26 numbers is O(1).
public boolean checkInclusion(String s1, String s2) {
if (s1.length() > s2.length()) return false;
int[] need = new int[26], window = new int[26];
for (char c : s1.toCharArray()) need[c - 'a']++;
int k = s1.length();
for (int right = 0; right < s2.length(); right++) {
window[s2.charAt(right) - 'a']++;
if (right >= k) window[s2.charAt(right - k) - 'a']--; // keep size exactly k
if (Arrays.equals(need, window)) return true; // 26 compares: O(1)
}
return false;
}
// checkInclusion("ab", "eidbaooo") -> true ("ba")
// checkInclusion("ab", "eidboaoo") -> false
Time O(26·n) = O(n), Space O(1).
Fruit Into Baskets, LC 904 (Medium)
You have two baskets, each holding one fruit type. Walking right from any start, pick one fruit per tree. What is the most fruit you can collect?
Key insight: in plain words, "longest subarray with at most 2 distinct values". Keep a map of type to count; shrink while the map has more than 2 keys, removing a key when its count hits zero.
public int totalFruit(int[] fruits) {
Map<Integer, Integer> basket = new HashMap<>(); // fruit type -> count in window
int left = 0, best = 0;
for (int right = 0; right < fruits.length; right++) {
basket.merge(fruits[right], 1, Integer::sum);
while (basket.size() > 2) { // more than 2 types: shrink
int f = fruits[left++];
if (basket.merge(f, -1, Integer::sum) == 0) basket.remove(f);
}
best = Math.max(best, right - left + 1);
}
return best;
}
// totalFruit([1,2,1]) -> 3, [0,1,2,2] -> 3, [1,2,3,2,2] -> 4
Time O(n), Space O(1) (the map never holds more than 3 keys).
if instead of while to shrink (one step may not be enough to make the window valid); computing the size as right - left instead of right - left + 1; recording the answer in the wrong place (after shrinking for "longest", inside the shrink loop for "shortest"); forgetting to remove a HashMap key when its count drops to zero, so size() is wrong; using a sliding window for "subarray sum equals k" when the array has negative numbers (the rule is not monotonic, so use prefix sums).Practice list
- LC 643 Maximum Average Subarray I (Easy)
- LC 219 Contains Duplicate II (Easy)
- LC 1456 Maximum Number of Vowels in a Substring of Given Length (Medium)
- LC 209 Minimum Size Subarray Sum (Medium)
- LC 438 Find All Anagrams in a String (Medium)
- LC 1004 Max Consecutive Ones III (Medium)
- LC 713 Subarray Product Less Than K (Medium)
- LC 992 Subarrays with K Different Integers (Hard) — exactly(k) = atMost(k) - atMost(k - 1)
- LC 30 Substring with Concatenation of All Words (Hard)
32. Prefix Sum & Difference Arrays
A prefix sum array stores running totals: prefix[i] is the sum of the first i elements. Once you have it, the sum of any range is one subtraction, so a range query costs O(1) instead of O(n).
A difference array is the reverse trick. To "add v to every element from l to r", you only write two numbers: +v at l and -v at r + 1. After all the updates, one prefix-sum pass rebuilds the real values. Many range updates become O(1) each.
A car's odometer. To know how far you drove between Monday and Thursday, you don't add up every trip; you subtract Monday's reading from Thursday's. The odometer is the prefix sum.
How to recognise it
- Many queries for the sum (or count, or XOR) of a range on an array that doesn't change.
- "Number of subarrays with sum equal to k", "divisible by k", "equal number of 0s and 1s", especially when the array has negative numbers (so a sliding window won't work).
- "Product of all elements except this one" (prefix products from both sides).
- "Add v to every element in [l, r]", bookings, passengers getting on and off, "how many overlaps at each point".
- 2D grid with "sum of the sub-rectangle" queries.
How it works
Picture prefix[i] sitting on the boundary before a[i]: it is the total of everything to its left. The sum between two boundaries is the difference of their totals. Using size n + 1 with prefix[0] = 0 removes the special case for ranges that start at 0.
Prefix sum + HashMap. A subarray a[j+1..i] sums to k exactly when prefix[i+1] - prefix[j+1] = k, i.e. when an earlier prefix equals current - k. So walk once, keep a map of "prefix value → how many times seen", and at each step look up current - k. This is the Two Sum idea applied to prefix sums.
Difference array. Here is "add 2 to [1..3]" then "add 3 to [2..4]" on an array of 5 zeros:
2D prefix sums. p[r][c] holds the sum of the rectangle from (0,0) to (r-1,c-1). Build with p[r+1][c+1] = cell + above + left - aboveLeft (the top-left block was added twice). Query any rectangle with the same inclusion-exclusion: big corner, minus the strip above, minus the strip to the left, plus the top-left block that was subtracted twice.
Java template
// 1. Build: prefix[i] = sum of the first i elements, so prefix[0] = 0.
static long[] buildPrefix(int[] a) {
long[] prefix = new long[a.length + 1]; // one extra slot: no "i - 1" edge case
for (int i = 0; i < a.length; i++) prefix[i + 1] = prefix[i] + a[i];
return prefix;
}
// Sum of a[l..r] inclusive, O(1)
static long rangeSum(long[] prefix, int l, int r) {
return prefix[r + 1] - prefix[l];
}
// 2. Prefix sum + HashMap: count subarrays whose "sum" hits a target.
static int countSubarrays(int[] a, int k) {
Map<Integer, Integer> seen = new HashMap<>(); // CHANGES: store count, or first index
seen.put(0, 1); // empty prefix: sum 0 seen once
int sum = 0, answer = 0;
for (int x : a) {
sum += x; // CHANGES: what you accumulate
answer += seen.getOrDefault(sum - k, 0); // earlier prefixes that make sum k
seen.merge(sum, 1, Integer::sum);
}
return answer;
}
// 3. Difference array: many "add v to a[l..r]" updates, then read the final array once.
static int[] applyUpdates(int n, int[][] updates) { // each update = {l, r, v}
int[] diff = new int[n + 1]; // one extra slot for r + 1
for (int[] u : updates) {
diff[u[0]] += u[2]; // start adding v at l
diff[u[1] + 1] -= u[2]; // stop adding v after r
}
int[] result = new int[n];
int running = 0;
for (int i = 0; i < n; i++) { // prefix sum of diff = final values
running += diff[i];
result[i] = running;
}
return result;
}
Classic problems
Range Sum Query — Immutable, LC 303 (Easy)
Answer many sumRange(left, right) queries on a fixed array.
Key insight: pay O(n) once in the constructor, then every query is O(1).
class NumArray {
private final int[] prefix;
public NumArray(int[] nums) {
prefix = new int[nums.length + 1];
for (int i = 0; i < nums.length; i++) prefix[i + 1] = prefix[i] + nums[i];
}
public int sumRange(int left, int right) {
return prefix[right + 1] - prefix[left];
}
}
// nums = [-2,0,3,-5,2,-1]: sumRange(0,2) -> 1, sumRange(2,5) -> -1, sumRange(0,5) -> -3
Time O(n) build, O(1) per query. Space O(n).
Subarray Sum Equals K, LC 560 (Medium)
Count the contiguous subarrays whose sum is exactly k. Numbers can be negative.
Key insight: count earlier prefixes equal to sum - k. Seed the map with {0: 1} so a subarray that starts at index 0 is counted. Look up before adding the current prefix, or k = 0 would count the empty subarray.
public int subarraySum(int[] nums, int k) {
Map<Integer, Integer> count = new HashMap<>(); // prefix sum -> how many times seen
count.put(0, 1);
int sum = 0, result = 0;
for (int x : nums) {
sum += x;
// a subarray ending here sums to k <=> some earlier prefix == sum - k
result += count.getOrDefault(sum - k, 0);
count.merge(sum, 1, Integer::sum);
}
return result;
}
// subarraySum([1,1,1], 2) -> 2
// subarraySum([1,2,3], 3) -> 2 ([1,2] and [3])
// subarraySum([3,4,7,2,-3,1,4,2], 7) -> 4
Time O(n), Space O(n).
Contiguous Array, LC 525 (Medium)
In a binary array, find the longest subarray with an equal number of 0s and 1s.
Key insight: count 1 as +1 and 0 as -1. A subarray is balanced when its sum is 0, i.e. when the running balance repeats. For the longest one, store only the first index of each balance (and balance 0 at index -1).
public int findMaxLength(int[] nums) {
Map<Integer, Integer> firstIndex = new HashMap<>(); // balance -> first index it appeared
firstIndex.put(0, -1); // balance 0 "before" index 0
int balance = 0, best = 0;
for (int i = 0; i < nums.length; i++) {
balance += nums[i] == 1 ? 1 : -1; // treat 0 as -1
if (firstIndex.containsKey(balance)) {
best = Math.max(best, i - firstIndex.get(balance)); // same balance: equal 0s and 1s between
} else {
firstIndex.put(balance, i); // keep the EARLIEST index only
}
}
return best;
}
// findMaxLength([0,1]) -> 2, [0,1,0] -> 2, [0,1,1,1,1,1,0,0,0] -> 6
Time O(n), Space O(n).
Product of Array Except Self, LC 238 (Medium)
Return answer[i] = product of every element except nums[i], without division, in O(n).
Key insight: answer[i] = (product of everything left of i) x (product of everything right of i). Fill the left products into the answer array, then sweep from the right with one running variable, so no extra array is needed.
public int[] productExceptSelf(int[] nums) {
int n = nums.length;
int[] answer = new int[n];
answer[0] = 1;
for (int i = 1; i < n; i++) answer[i] = answer[i - 1] * nums[i - 1]; // product of left side
int right = 1; // product of right side
for (int i = n - 1; i >= 0; i--) {
answer[i] *= right;
right *= nums[i];
}
return answer;
}
// [1,2,3,4] -> [24, 12, 8, 6]
// [-1,1,0,-3,3] -> [0, 0, 9, 0, 0]
Time O(n), Space O(1) extra (the output array doesn't count).
Car Pooling, LC 1094 (Medium)
trips[i] = {passengers, from, to} on a road going one way. Can a car with capacity seats do all trips?
Key insight: each trip is a range update "+passengers on [from, to)". Mark the start and the drop-off in a difference array, then the running sum is the load at each point. Passengers leave at to, so the - goes at to, not to + 1.
public boolean carPooling(int[][] trips, int capacity) {
int[] diff = new int[1001]; // locations are 0..1000
for (int[] t : trips) { // t = {passengers, from, to}
diff[t[1]] += t[0]; // they get in at "from"
diff[t[2]] -= t[0]; // they get out at "to" (seat free again)
}
int load = 0;
for (int d : diff) {
load += d;
if (load > capacity) return false;
}
return true;
}
// carPooling([[2,1,5],[3,3,7]], 4) -> false (5 people between 3 and 5)
// carPooling([[2,1,5],[3,3,7]], 5) -> true
Time O(n + L) where L = 1001 locations, Space O(L).
Corporate Flight Bookings, LC 1109 (Medium) is the same pattern with 1-indexed flights and inclusive ends:
public int[] corpFlightBookings(int[][] bookings, int n) {
int[] diff = new int[n + 1];
for (int[] b : bookings) { // b = {first, last, seats}, 1-indexed
diff[b[0] - 1] += b[2];
diff[b[1]] -= b[2]; // (last - 1) + 1
}
int[] answer = new int[n];
int running = 0;
for (int i = 0; i < n; i++) {
running += diff[i];
answer[i] = running;
}
return answer;
}
// bookings = [[1,2,10],[2,3,20],[2,5,25]], n = 5 -> [10, 55, 45, 25, 25]
Time O(n + bookings), Space O(n).
Range Sum Query 2D — Immutable, LC 304 (Medium)
Answer many "sum of the rectangle (r1,c1)–(r2,c2)" queries on a fixed matrix.
Key insight: 2D prefix sums with one row and one column of zero padding, then inclusion-exclusion for each query.
class NumMatrix {
private final int[][] p; // p[r][c] = sum of the rectangle (0,0)..(r-1,c-1)
public NumMatrix(int[][] matrix) {
int rows = matrix.length, cols = matrix[0].length;
p = new int[rows + 1][cols + 1];
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
p[r + 1][c + 1] = matrix[r][c] + p[r][c + 1] + p[r + 1][c] - p[r][c];
}
public int sumRegion(int r1, int c1, int r2, int c2) {
return p[r2 + 1][c2 + 1] - p[r1][c2 + 1] - p[r2 + 1][c1] + p[r1][c1];
}
}
// matrix = [[3,0,1,4,2],[5,6,3,2,1],[1,2,0,1,5],[4,1,0,1,7],[1,0,3,0,5]]
// sumRegion(2,1,4,3) -> 8, sumRegion(1,1,2,2) -> 11, sumRegion(1,2,2,4) -> 12
Time O(rows x cols) build, O(1) per query. Space O(rows x cols).
prefix[r + 1] - prefix[l] and prefix[r] - prefix[l - 1] (pick the n + 1 version and stick to it); forgetting map.put(0, 1) (or put(0, -1) for the longest-length version), which misses subarrays that start at index 0; updating the map before the lookup; in "longest" problems, overwriting the first index with later ones; integer overflow on big sums (use long); in a difference array, forgetting the extra slot for r + 1.Practice list
- LC 1480 Running Sum of 1d Array (Easy)
- LC 724 Find Pivot Index (Easy)
- LC 1413 Minimum Value to Get Positive Step by Step Sum (Easy)
- LC 370 Range Addition (Medium)
- LC 974 Subarray Sums Divisible by K (Medium)
- LC 523 Continuous Subarray Sum (Medium)
- LC 1314 Matrix Block Sum (Medium)
- LC 1074 Number of Submatrices That Sum to Target (Hard)
33. Fast & Slow Pointers
Two pointers walk the same path at different speeds: slow moves one step, fast moves two. If the path is a straight line, fast reaches the end first, and at that moment slow is exactly halfway. If the path loops back on itself, fast can never escape, so it eventually laps slow and they land on the same node.
This is Floyd's cycle detection, also called the tortoise and hare. It finds cycles and middles with O(1) extra memory, where a HashSet of visited nodes would need O(n).
Two runners on a track. On a straight road the faster one simply finishes first. On a circular track the faster one keeps coming round and eventually taps the slower one on the shoulder: that tap proves the track is a loop.
How to recognise it
- "Does the linked list have a cycle?", "where does the cycle start?"
- "Find the middle of the list", "split the list in half" (merge sort on a list, palindrome list, reorder list).
- A sequence where each value decides the next one (
x -> f(x)) and you must detect a loop: happy number, "circular array loop". - Array values in
1..nused as "next" indexes, with a duplicate hiding somewhere (find the duplicate number). - The problem says O(1) extra space and you'd otherwise use a HashSet of visited things.
How it works
Why they must meet. Once both pointers are inside the cycle, look at the gap from slow to fast measured along the loop. Every step fast gains exactly one node, so the gap shrinks by 1: 5, 4, 3, 2, 1, 0. It can't skip past 0, so they land on the same node, and this happens before slow finishes its first lap.
Why resetting one pointer finds the cycle start. Say the tail before the cycle has a nodes, the cycle has length c, and they meet b steps after the cycle start.
Slow walked a + b. Fast walked twice that, 2(a + b), and the extra a + b it walked must be whole laps: a + b = k·c. Rearranged, a = (k-1)·c + (c - b). In words: walking a steps from the meeting point goes c - b steps to the start, then round some full laps, and ends on the start. Walking a steps from head also ends on the start. So put one pointer back on head, move both one step at a time, and where they meet is the cycle start.
Java template
ListNode fastSlow(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) { // fast needs two valid steps
slow = slow.next; // 1 step
fast = fast.next.next; // 2 steps
if (slow == fast) { // CHANGES: cycle problems stop here
return slow; // meeting point inside the cycle
}
}
return slow; // CHANGES: no cycle; slow is now the middle (second middle if even)
}
The condition fast != null && fast.next != null is the whole trick for safety: it checks that both of fast's next two steps exist.
Classic problems
Linked List Cycle, LC 141 (Easy)
Return true if the linked list has a cycle.
Key insight: if fast ever reaches null there is an end, so no cycle. If they meet, there is a cycle.
public boolean hasCycle(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) return true; // compare nodes (references), not values
}
return false; // fast fell off the end: no cycle
}
// [3,2,0,-4], tail links to index 1 -> true
// [1], no link -> false
Time O(n), Space O(1).
Linked List Cycle II, LC 142 (Medium)
Return the node where the cycle begins, or null.
Key insight: phase 1 finds any meeting point; phase 2 resets one pointer to head and moves both at speed 1. By the math above, they meet at the cycle start.
public ListNode detectCycle(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) { // phase 1: they met inside the cycle
ListNode p = head; // phase 2: one pointer back to head
while (p != slow) { // both move 1 step; they meet at the start
p = p.next;
slow = slow.next;
}
return p;
}
}
return null;
}
// [3,2,0,-4], tail links to index 1 -> node with value 2
// [1,2,3,4,5,6,7], tail links to index 2 -> node with value 3
Time O(n), Space O(1).
Middle of the Linked List, LC 876 (Easy)
Return the middle node; if there are two middles, return the second.
Key insight: when fast has done the whole list, slow has done half. To get the first middle instead (useful for splitting a list), loop while fast.next != null && fast.next.next != null.
public ListNode middleNode(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow; // for even length this is the SECOND middle
}
// [1,2,3,4,5] -> node 3 (returns [3, 4, 5])
// [1,2,3,4,5,6] -> node 4 (returns [4, 5, 6])
Time O(n), Space O(1).
Happy Number, LC 202 (Easy)
Replace n by the sum of the squares of its digits, again and again. n is happy if this reaches 1. Otherwise it loops forever.
Key insight: the numbers form an implicit linked list n -> next(n). Either it reaches 1 (and stays there, since next(1) = 1) or it enters a cycle. Floyd detects which, with no HashSet.
public boolean isHappy(int n) {
int slow = n, fast = next(n);
while (fast != 1 && slow != fast) {
slow = next(slow);
fast = next(next(fast));
}
return fast == 1;
}
private int next(int n) { // sum of the squares of the digits
int sum = 0;
while (n > 0) {
int d = n % 10;
sum += d * d;
n /= 10;
}
return sum;
}
// isHappy(19) -> true (19 -> 82 -> 68 -> 100 -> 1)
// isHappy(2) -> false
Time O(log n) per step and a small number of steps, Space O(1).
Find the Duplicate Number, LC 287 (Medium)
An array of n + 1 integers, each in 1..n, has exactly one repeated value (maybe repeated many times). Find it without changing the array and with O(1) extra space.
Key insight: read the array as a linked list where index i points to index nums[i]. Index 0 is never pointed to (values start at 1), so it is the head. The duplicate value is a node with two incoming arrows, which is exactly the entrance of a cycle. So it is LC 142 on an array.
public int findDuplicate(int[] nums) {
// Treat i -> nums[i] as a linked list. The duplicate value has two arrows
// pointing into it, so it is exactly where the cycle starts.
int slow = nums[0], fast = nums[0];
do {
slow = nums[slow];
fast = nums[nums[fast]];
} while (slow != fast);
slow = nums[0]; // phase 2: restart one pointer
while (slow != fast) {
slow = nums[slow];
fast = nums[fast];
}
return slow;
}
// [1,3,4,2,2] -> 2, [3,1,3,4,2] -> 3, [3,3,3,3,3] -> 3
Time O(n), Space O(1).
Palindrome Linked List, LC 234 (Easy)
Is a singly linked list a palindrome? Aim for O(1) extra space.
Key insight: three patterns in a row: fast and slow to find the middle, in-place reversal of the second half, then walk both halves together comparing values.
public boolean isPalindrome(ListNode head) {
// 1. find the middle
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// 2. reverse the second half
ListNode prev = null;
while (slow != null) {
ListNode next = slow.next;
slow.next = prev;
prev = slow;
slow = next;
}
// 3. compare the first half with the reversed second half
ListNode left = head, right = prev;
while (right != null) {
if (left.val != right.val) return false;
left = left.next;
right = right.next;
}
return true;
}
// [1,2,2,1] -> true, [1,2] -> false, [1,2,3,2,1] -> true
Time O(n), Space O(1). (This leaves the list modified; in real code, reverse the second half back before returning.)
fast != null and then calling fast.next.next (NullPointerException on even-length lists); comparing slow.val == fast.val instead of slow == fast (two different nodes can hold the same value); checking slow == fast before the first move, when both still sit on head; in phase 2, moving the head pointer 2 steps instead of 1; in Find the Duplicate, starting phase 2 from index 0's value inconsistently with phase 1 (both phases must start from the same head).Practice list
- LC 160 Intersection of Two Linked Lists (Easy) — two pointers that switch lists
- LC 2095 Delete the Middle Node of a Linked List (Medium)
- LC 2130 Maximum Twin Sum of a Linked List (Medium)
- LC 19 Remove Nth Node From End of List (Medium) — a gap of n, same speed
- LC 143 Reorder List (Medium)
- LC 148 Sort List (Medium) — find the middle, then merge sort
- LC 457 Circular Array Loop (Medium)
34. Monotonic Stack & Queue
A monotonic stack is an ordinary stack with one house rule: its values are always sorted, either always decreasing from bottom to top or always increasing. Before pushing a new element, you pop everything that would break the order. The moment an element gets popped is the moment you learn its answer: "the thing that just pushed me out is my next greater (or smaller) element".
A monotonic deque (double-ended queue) is the same idea with one more exit: old elements also leave from the front when they fall out of a sliding window. The front is then always the max (or min) of the window.
People in a queue, each waiting for someone taller than them to join. When a tall person arrives, every shorter person still waiting has found their answer ("that one is taller than me") and leaves. The people left waiting are always ordered tallest to shortest, and that ordered queue is the monotonic stack.
How to recognise it
- "Next greater / next smaller element", "previous greater / smaller", "first element to the right that is bigger".
- "How many days until a warmer day", "stock span", "how far can this bar extend".
- "Largest rectangle in a histogram", "maximal rectangle", "trapping rain water" (stack version).
- "Sum of min / max over all subarrays", "for each element, in how many subarrays is it the minimum".
- "Maximum / minimum of every window of size k" (monotonic deque).
- "Remove k digits to make the smallest number", "smallest subsequence" (greedy with a stack).
- The brute force is "for each i, scan right until you find something bigger": O(n^2). The stack makes it O(n).
How it works
For "next greater", keep a stack of indexes whose answer is not known yet. Their values are decreasing (if a bigger value had come after a smaller one, it would already have answered it). A new value x pops every waiting element smaller than x, because x is the first bigger thing to their right. Then x itself waits.
Why O(n)? The while loop looks scary inside a for, but each index is pushed exactly once and popped at most once, so the total work over the whole run is at most 2n.
| You want | Scan | Stack order (bottom to top) | Pop while |
|---|---|---|---|
| Next greater | left to right | decreasing | a[top] < a[i], answer popped items |
| Next smaller | left to right | increasing | a[top] > a[i], answer popped items |
| Previous greater | left to right | decreasing | a[top] <= a[i], then top is the answer for i |
| Previous smaller | left to right | increasing | a[top] >= a[i], then top is the answer for i |
Java template
Always use ArrayDeque as the stack (push, pop, peek), not the old synchronized Stack class. Store indexes, not values: from an index you can get both the value and the distance.
// Next greater element to the right of each index (-1 if none).
public int[] nextGreater(int[] a) {
int n = a.length;
int[] result = new int[n];
Arrays.fill(result, -1); // CHANGES: default when nothing is found
Deque<Integer> stack = new ArrayDeque<>(); // holds INDEXES; their values are decreasing
for (int i = 0; i < n; i++) {
// a[i] is the answer for every smaller element still waiting on the stack
while (!stack.isEmpty() && a[stack.peek()] < a[i]) { // CHANGES: the comparison
int j = stack.pop();
result[j] = a[i]; // CHANGES: store value, index, or i - j
}
stack.push(i); // a[i] now waits for its own answer
}
return result;
}
// nextGreater([2,1,2,4,3]) -> [4, 2, 4, -1, -1]
// Previous smaller element to the left of each index (-1 if none).
public int[] previousSmaller(int[] a) {
int[] result = new int[a.length];
Deque<Integer> stack = new ArrayDeque<>(); // indexes; values strictly increasing
for (int i = 0; i < a.length; i++) {
while (!stack.isEmpty() && a[stack.peek()] >= a[i]) stack.pop(); // useless now
result[i] = stack.isEmpty() ? -1 : a[stack.peek()]; // top = nearest smaller on left
stack.push(i);
}
return result;
}
// previousSmaller([3,7,8,4,6]) -> [-1, 3, 7, 3, 4]
Classic problems
Next Greater Element I, LC 496 (Easy)
nums1 is a subset of nums2 (all values distinct). For each x in nums1, return the first greater number to the right of x in nums2, or -1.
Key insight: run the template once over nums2, saving answers in a map value -> next greater; then each query is a lookup. Values are distinct, so the stack can hold values instead of indexes.
public int[] nextGreaterElement(int[] nums1, int[] nums2) {
Map<Integer, Integer> nextGreater = new HashMap<>(); // value -> next greater value
Deque<Integer> stack = new ArrayDeque<>(); // values, decreasing
for (int x : nums2) {
while (!stack.isEmpty() && stack.peek() < x) nextGreater.put(stack.pop(), x);
stack.push(x);
}
int[] result = new int[nums1.length];
for (int i = 0; i < nums1.length; i++) result[i] = nextGreater.getOrDefault(nums1[i], -1);
return result;
}
// nums1 = [4,1,2], nums2 = [1,3,4,2] -> [-1, 3, -1]
// nums1 = [2,4], nums2 = [1,2,3,4] -> [3, -1]
Time O(n + m), Space O(n).
Next Greater Element II, LC 503 (Medium)
Same as above, but the array is circular: after the last element you continue from the first.
Key insight: walk indexes 0..2n-1 and read nums[i % n]. The second pass lets elements near the end find answers near the start. Only push during the first pass.
public int[] nextGreaterElements(int[] nums) {
int n = nums.length;
int[] result = new int[n];
Arrays.fill(result, -1);
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < 2 * n; i++) { // walk the array twice to simulate the circle
int x = nums[i % n];
while (!stack.isEmpty() && nums[stack.peek()] < x) result[stack.pop()] = x;
if (i < n) stack.push(i); // only the first pass pushes
}
return result;
}
// [1,2,1] -> [2, -1, 2]
// [1,2,3,4,3] -> [2, 3, 4, -1, 4]
Time O(n), Space O(n).
Daily Temperatures, LC 739 (Medium)
For each day, how many days until a warmer temperature? 0 if never.
Key insight: the next-greater template, but store the distance i - j instead of the value. That's why the stack holds indexes.
public int[] dailyTemperatures(int[] temps) {
int[] answer = new int[temps.length]; // default 0 = no warmer day
Deque<Integer> stack = new ArrayDeque<>(); // indexes of days still waiting
for (int i = 0; i < temps.length; i++) {
while (!stack.isEmpty() && temps[stack.peek()] < temps[i]) {
int j = stack.pop();
answer[j] = i - j; // distance, not the temperature
}
stack.push(i);
}
return answer;
}
// [73,74,75,71,69,72,76,73] -> [1, 1, 4, 2, 1, 1, 0, 0]
Time O(n), Space O(n).
Largest Rectangle in Histogram, LC 84 (Hard)
Bars of width 1 with the given heights. Find the area of the largest rectangle inside the histogram.
Key insight: the best rectangle uses some bar as its shortest bar, and stretches left and right until it hits a shorter bar. Keep an increasing stack. When bar i is shorter than the top, the top has found its right limit (i), and the element under it in the stack is its left limit. A final height of 0 flushes everything.
public int largestRectangleArea(int[] heights) {
int n = heights.length, best = 0;
Deque<Integer> stack = new ArrayDeque<>(); // indexes, heights increasing
for (int i = 0; i <= n; i++) {
int h = (i == n) ? 0 : heights[i]; // height 0 at the end flushes the stack
while (!stack.isEmpty() && heights[stack.peek()] > h) {
int height = heights[stack.pop()]; // this bar is the shortest in its rectangle
// it extends right until i (exclusive) and left until the new top (exclusive)
int left = stack.isEmpty() ? -1 : stack.peek();
best = Math.max(best, height * (i - left - 1));
}
stack.push(i);
}
return best;
}
// [2,1,5,6,2,3] -> 10 (bars 5 and 6, height 5, width 2)
// [2,4] -> 4
Time O(n), Space O(n).
Sliding Window Maximum, LC 239 (Hard)
Return the maximum of every window of size k.
Key insight: a monotonic deque of indexes with decreasing values. If a newer element is bigger than an older one, the older one can never be a window max again (the newer one is bigger and stays longer), so pop it from the back. Pop from the front when the index has left the window. The front is always the current max.
public int[] maxSlidingWindow(int[] nums, int k) {
int[] result = new int[nums.length - k + 1];
Deque<Integer> dq = new ArrayDeque<>(); // indexes, values decreasing
for (int i = 0; i < nums.length; i++) {
if (!dq.isEmpty() && dq.peekFirst() <= i - k) dq.pollFirst();
while (!dq.isEmpty() && nums[dq.peekLast()] <= nums[i]) dq.pollLast();
dq.offerLast(i);
if (i >= k - 1) result[i - k + 1] = nums[dq.peekFirst()];
}
return result;
}
// nums = [1,3,-1,-3,5,3,6,7], k = 3 -> [3, 3, 5, 5, 6, 7]
// nums = [1], k = 1 -> [1]
Time O(n), Space O(k). (A max-heap works too, but is O(n log n).)
Online Stock Span, LC 901 (Medium)
Prices arrive one per day. For each, return the span: how many consecutive days, ending today, had a price <= today's price.
Key insight: this is "previous greater" in streaming form. Keep {price, span} pairs in a decreasing stack; today's price swallows every smaller-or-equal day, adding up their spans, so they're never looked at again.
class StockSpanner {
private final Deque<int[]> stack = new ArrayDeque<>(); // {price, span}, prices decreasing
public int next(int price) {
int span = 1;
while (!stack.isEmpty() && stack.peek()[0] <= price) {
span += stack.pop()[1]; // absorb the span of every smaller-or-equal day
}
stack.push(new int[]{price, span});
return span;
}
}
// next(100), next(80), next(60), next(70), next(60), next(75), next(85)
// -> 1 1 1 2 1 4 6
Time O(1) amortised per call, Space O(n).
Sum of Subarray Minimums, LC 907 (Medium)
Return the sum of min(sub) over every contiguous subarray, modulo 10^9 + 7.
Key insight: flip the question: for each arr[i], in how many subarrays is it the minimum? That's (choices of start) x (choices of end) = left[i] * right[i], found with a previous-smaller and a next-smaller pass. Use strict > on one side and >= on the other so equal values are counted exactly once.
public int sumSubarrayMins(int[] arr) {
final long MOD = 1_000_000_007L;
int n = arr.length;
int[] left = new int[n], right = new int[n];
Deque<Integer> stack = new ArrayDeque<>();
// left[i]: how many starts can arr[i] be the minimum for (previous strictly smaller)
for (int i = 0; i < n; i++) {
while (!stack.isEmpty() && arr[stack.peek()] > arr[i]) stack.pop();
left[i] = stack.isEmpty() ? i + 1 : i - stack.peek();
stack.push(i);
}
stack.clear();
// right[i]: how many ends (next smaller OR EQUAL, so ties are counted once)
for (int i = n - 1; i >= 0; i--) {
while (!stack.isEmpty() && arr[stack.peek()] >= arr[i]) stack.pop();
right[i] = stack.isEmpty() ? n - i : stack.peek() - i;
stack.push(i);
}
long sum = 0;
for (int i = 0; i < n; i++) {
sum = (sum + (long) arr[i] * left[i] * right[i]) % MOD;
}
return (int) sum;
}
// [3,1,2,4] -> 17
// [11,81,94,43,3] -> 444
Time O(n), Space O(n).
< vs <= carelessly, which breaks on duplicates (Sum of Subarray Minimums double-counts, Stock Span under-counts); forgetting to flush the stack at the end (the extra height-0 bar in LC 84); in the deque, forgetting to evict the front when it leaves the window; using java.util.Stack, which is slow and legacy.Practice list
- LC 1475 Final Prices With a Special Discount in a Shop (Easy)
- LC 402 Remove K Digits (Medium)
- LC 316 Remove Duplicate Letters (Medium)
- LC 853 Car Fleet (Medium)
- LC 1856 Maximum Subarray Min-Product (Medium)
- LC 1944 Number of Visible People in a Queue (Hard)
- LC 862 Shortest Subarray with Sum at Least K (Hard) — prefix sums + monotonic deque
- LC 85 Maximal Rectangle (Hard) — LC 84 once per row
35. Merge Intervals
An interval is a pair [start, end]: a meeting, a booking, a range of numbers. Interval problems ask which ones overlap, how to combine them, or how many overlap at once. The trick that unlocks almost all of them: sort by start time, then sweep left to right. After sorting, an interval can only overlap the ones just before it, so one pass is enough.
A cousin of this is the sweep line: split every interval into a "start" event and an "end" event, sort all events by time, and walk through them keeping a running count of how many intervals are open.
Tidying a pile of receipts for trips. Sort them by departure date, then go through the stack: if a trip starts before the previous one ended, staple them together; otherwise start a new bundle.
How to recognise it
- The input is a list of pairs
[start, end]: meetings, bookings, time slots, ranges, balloons. - "Merge overlapping", "insert into a sorted list of intervals", "find the free time".
- "Minimum number of rooms / platforms / machines", "max number of overlapping events at any moment".
- "Remove the minimum number so the rest don't overlap", "minimum arrows / points to cover all".
- "Intersection of two lists of intervals".
How it works
Everything rests on one test: when do two intervals overlap? There are six ways B can sit relative to A. Four of them overlap; only "B entirely before A" and "B entirely after A" don't. So the overlap test is simply "neither is entirely before the other":
[1,4] and [4,5]) count as overlapping depends on the problem: read it carefully.After sorting by start, cur.start >= last.start is already true, so the test shrinks to one comparison: cur.start <= last.end means overlap. When merging, the new end is max(last.end, cur.end): case 3 (B inside A) shows why you can't just take cur.end.
| Sort by | When | Examples |
|---|---|---|
| Start | You merge or group intervals together | Merge Intervals, Meeting Rooms, Insert Interval |
| End | Greedy: keep as many non-overlapping as possible, or cover with fewest points | Non-overlapping Intervals, Minimum Arrows, activity selection |
Java template
public int[][] sweep(int[][] intervals) {
Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0])); // 1. sort by start
List<int[]> result = new ArrayList<>();
for (int[] cur : intervals) {
if (result.isEmpty() || result.get(result.size() - 1)[1] < cur[0]) {
result.add(cur); // 2. no overlap: start a new group
} else {
int[] last = result.get(result.size() - 1);
last[1] = Math.max(last[1], cur[1]); // 3. overlap: CHANGES: merge / count / skip
}
}
return result.toArray(new int[0][]);
}
Sort with Integer.compare(a[0], b[0]), not a[0] - b[0]: subtraction overflows when values are near Integer.MIN_VALUE or MAX_VALUE and gives the wrong order.
Classic problems
Merge Intervals, LC 56 (Medium)
Merge all overlapping intervals and return the non-overlapping result.
Key insight: sort by start; each interval either extends the last merged one or starts a new one. Touching intervals like [1,4] and [4,5] count as overlapping here, hence < for "no overlap".
public int[][] merge(int[][] intervals) {
Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));
List<int[]> merged = new ArrayList<>();
for (int[] cur : intervals) {
int[] last = merged.isEmpty() ? null : merged.get(merged.size() - 1);
if (last == null || last[1] < cur[0]) {
merged.add(new int[]{cur[0], cur[1]});
} else {
last[1] = Math.max(last[1], cur[1]); // max: cur may sit fully inside last
}
}
return merged.toArray(new int[0][]);
}
// [[1,3],[2,6],[8,10],[15,18]] -> [[1, 6], [8, 10], [15, 18]]
// [[1,4],[4,5]] -> [[1, 5]]
// [[1,10],[2,3],[4,5]] -> [[1, 10]]
Time O(n log n), Space O(n) for the output (plus the sort's O(log n)).
Insert Interval, LC 57 (Medium)
Intervals are already sorted and non-overlapping. Insert a new one and merge if needed.
Key insight: no sort needed. Three phases: copy everything that ends before the new one starts, swallow everything that overlaps it (growing start and end), copy the rest.
public int[][] insert(int[][] intervals, int[] newInterval) {
List<int[]> result = new ArrayList<>();
int i = 0, n = intervals.length;
int start = newInterval[0], end = newInterval[1];
while (i < n && intervals[i][1] < start) result.add(intervals[i++]); // 1. fully left
while (i < n && intervals[i][0] <= end) { // 2. overlapping
start = Math.min(start, intervals[i][0]);
end = Math.max(end, intervals[i][1]);
i++;
}
result.add(new int[]{start, end});
while (i < n) result.add(intervals[i++]); // 3. fully right
return result.toArray(new int[0][]);
}
// [[1,3],[6,9]], [2,5] -> [[1, 5], [6, 9]]
// [[1,2],[3,5],[6,7],[8,10],[12,16]], [4,8] -> [[1, 2], [3, 10], [12, 16]]
Time O(n), Space O(n).
Non-overlapping Intervals, LC 435 (Medium)
Return the minimum number of intervals to remove so the rest don't overlap (touching is allowed).
Key insight: flip it: keep the maximum number of non-overlapping intervals. Sorting by end and always keeping the one that finishes first is the classic greedy: it leaves the most room for everything after.
public int eraseOverlapIntervals(int[][] intervals) {
// Greedy: keep the interval that ENDS first; it leaves the most room for the rest.
Arrays.sort(intervals, (a, b) -> Integer.compare(a[1], b[1]));
int removed = 0, lastEnd = Integer.MIN_VALUE;
for (int[] cur : intervals) {
if (cur[0] >= lastEnd) {
lastEnd = cur[1]; // keep it (touching at the edge is fine here)
} else {
removed++; // overlaps the last kept one: remove it
}
}
return removed;
}
// [[1,2],[2,3],[3,4],[1,3]] -> 1
// [[1,2],[1,2],[1,2]] -> 2
// [[1,2],[2,3]] -> 0
Time O(n log n), Space O(1) extra.
Meeting Rooms, LC 252 (Easy) and Meeting Rooms II, LC 253 (Medium)
I: can one person attend every meeting? II: what is the minimum number of rooms needed?
Key insight (I): after sorting by start, any meeting that starts before the previous one ends is a clash.
public boolean canAttendMeetings(int[][] intervals) {
Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));
for (int i = 1; i < intervals.length; i++) {
if (intervals[i][0] < intervals[i - 1][1]) return false; // starts before previous ends
}
return true;
}
// [[0,30],[5,10],[15,20]] -> false, [[7,10],[2,4]] -> true
Time O(n log n), Space O(1) extra.
Key insight (II), heap: process meetings by start time and keep a min-heap of the end times of rooms in use. If the room that frees up first is free by now, reuse it (poll); either way the new meeting takes a room (offer). The heap's size is the number of rooms.
public int minMeetingRooms(int[][] intervals) {
Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));
PriorityQueue<Integer> endTimes = new PriorityQueue<>(); // end time of each busy room
for (int[] m : intervals) {
if (!endTimes.isEmpty() && endTimes.peek() <= m[0]) {
endTimes.poll(); // earliest-ending room is free: reuse it
}
endTimes.offer(m[1]);
}
return endTimes.size(); // rooms ever opened and still counted
}
// [[0,30],[5,10],[15,20]] -> 2
// [[7,10],[2,4]] -> 1
// [[1,5],[2,6],[3,7],[6,8]] -> 3
Sweep-line version: sort all starts and all ends separately. Walk the starts; before each one, release every meeting that has already ended. The running count's peak is the answer.
public int minMeetingRoomsSweep(int[][] intervals) {
int n = intervals.length;
int[] starts = new int[n], ends = new int[n];
for (int i = 0; i < n; i++) {
starts[i] = intervals[i][0];
ends[i] = intervals[i][1];
}
Arrays.sort(starts);
Arrays.sort(ends);
int rooms = 0, best = 0, e = 0;
for (int s = 0; s < n; s++) {
while (ends[e] <= starts[s]) { // meetings that ended free their rooms
rooms--;
e++;
}
rooms++;
best = Math.max(best, rooms);
}
return best;
}
// same answers: 2, 1, 3
Both: Time O(n log n), Space O(n).
Interval List Intersections, LC 986 (Medium)
Two lists of sorted, disjoint intervals. Return all their intersections.
Key insight: two pointers. The intersection of two intervals is [max(starts), min(ends)], valid if lo <= hi. Then advance whichever interval ends first: it can't meet anything else in the other list.
public int[][] intervalIntersection(int[][] first, int[][] second) {
List<int[]> result = new ArrayList<>();
int i = 0, j = 0;
while (i < first.length && j < second.length) {
int lo = Math.max(first[i][0], second[j][0]);
int hi = Math.min(first[i][1], second[j][1]);
if (lo <= hi) result.add(new int[]{lo, hi}); // they overlap on [lo, hi]
if (first[i][1] < second[j][1]) i++; // drop the one that ends first
else j++;
}
return result.toArray(new int[0][]);
}
// first = [[0,2],[5,10],[13,23],[24,25]]
// second = [[1,5],[8,12],[15,24],[25,26]]
// -> [[1, 2], [5, 5], [8, 10], [15, 23], [24, 24], [25, 25]]
Time O(n + m), Space O(n + m) for the output.
Minimum Number of Arrows to Burst Balloons, LC 452 (Medium)
Balloons are horizontal ranges [xstart, xend]. A vertical arrow at x bursts every balloon with xstart <= x <= xend. Find the fewest arrows.
Key insight: sort by end and shoot each arrow at the end of the first unburst balloon: that's as far right as the arrow can go while still hitting it, so it catches the most later balloons. A balloon that starts after the arrow needs a new one. Touching counts as a hit, hence >.
public int findMinArrowShots(int[][] points) {
// Compare with Integer.compare: a[1] - b[1] can overflow for values near 2^31.
Arrays.sort(points, (a, b) -> Integer.compare(a[1], b[1]));
int arrows = 1;
int arrowX = points[0][1]; // shoot at the end of the first balloon
for (int[] p : points) {
if (p[0] > arrowX) { // this balloon starts after the arrow: new arrow
arrows++;
arrowX = p[1];
}
}
return arrows;
}
// [[10,16],[2,8],[1,6],[7,12]] -> 2
// [[1,2],[3,4],[5,6],[7,8]] -> 4
// [[1,2],[2,3],[3,4],[4,5]] -> 2
Time O(n log n), Space O(1) extra.
a[0] - b[0] (overflow, LC 452 has values near 2^31); setting last.end = cur.end instead of max(last.end, cur.end); getting touching intervals wrong (< vs <=) for the specific problem; sorting by start in the greedy "keep the most" problems, where sorting by end is what makes the greedy correct; in Meeting Rooms II, comparing against the most recent meeting instead of the room that frees up earliest (that's why it's a min-heap).Practice list
- LC 228 Summary Ranges (Easy)
- LC 1854 Maximum Population Year (Easy)
- LC 1288 Remove Covered Intervals (Medium)
- LC 646 Maximum Length of Pair Chain (Medium)
- LC 729 My Calendar I (Medium)
- LC 1094 Car Pooling (Medium) — sweep line / difference array
- LC 759 Employee Free Time (Hard)
- LC 2402 Meeting Rooms III (Hard)
36. Cyclic Sort
When an array holds numbers from a known small range, like 1 to n, every number has a natural home: value v belongs at index v - 1. Cyclic sort walks the array and keeps swapping the current number into its home until the current slot holds the right number (or a number that has no home). Then it moves on.
After that, one more scan finds whatever is wrong: a slot whose number doesn't match its index points straight to a missing number (the index) or a duplicate (the value in it). All in O(n) time and O(1) extra space, with no HashSet and no real sorting.
Handing back numbered exam papers in a classroom where seat 1 is for student 1, seat 2 for student 2, and so on. Pick up the paper on the first desk, walk it to its owner's seat, pick up the paper that was lying there, walk that one to its seat, and so on. Empty seats at the end tell you who was absent.
How to recognise it
- The array contains numbers in the range 1..n or 0..n, where n is (about) the array length.
- "Find the missing number(s)", "find the duplicate(s)", "find the smallest missing positive", "set mismatch".
- "O(1) extra space" and "O(n) time", ruling out both a HashSet and sorting.
- You are allowed to modify the input array (if not, look at fast & slow pointers or bit tricks instead).
How it works
Stay on index i and keep swapping nums[i] to its home until the value at i is at home, or its home already holds the same value (a duplicate), or it has no home (out of range). Only then do i++.
Why is it O(n) with a while that doesn't always move i? Every swap puts at least one number into its final home, and a number at home is never moved again. So there are at most n swaps in total, plus n increments of i: under 2n steps.
The check nums[i] != nums[correct] (compare the values, not i != correct) is what stops an infinite loop on duplicates: if the home already holds the same value, swapping would change nothing, forever.
Java template
public void placeEach(int[] nums) {
int i = 0;
while (i < nums.length) {
int correct = nums[i] - 1; // CHANGES: home index (value - 1, or value)
if (inRange(nums[i], nums.length) // CHANGES: skip values with no home
&& nums[i] != nums[correct]) { // home not already holding this value
swap(nums, i, correct); // send it home; DON'T move i yet
} else {
i++; // placed, duplicate, or out of range
}
}
// Then scan: the first index i with nums[i] != i + 1 tells you what's missing / extra.
}
private boolean inRange(int v, int n) { return v >= 1 && v <= n; }
private void swap(int[] a, int i, int j) {
int t = a[i]; a[i] = a[j]; a[j] = t;
}
After sorting, if nums[i] != i + 1 | It means |
|---|---|
i + 1 | is a missing number |
nums[i] | is a duplicate (its real home is taken by another copy) |
Classic problems
Cyclic Sort (Easy)
An array holds each number from 1 to n exactly once, in any order. Sort it in place in O(n). (The core loop behind every problem below; it uses swap from the template.)
Key insight: you already know where each value goes, so you don't need comparisons between elements: just send each one home.
public void cyclicSort(int[] nums) { // nums holds each of 1..n exactly once
int i = 0;
while (i < nums.length) {
int correct = nums[i] - 1;
if (nums[i] != nums[correct]) swap(nums, i, correct);
else i++;
}
}
// [3,1,5,4,2] -> [1, 2, 3, 4, 5]
// [2,6,4,3,1,5] -> [1, 2, 3, 4, 5, 6]
Time O(n), Space O(1).
Missing Number, LC 268 (Easy)
nums holds n distinct numbers from 0..n. Return the one that is missing.
Key insight: here the range starts at 0, so value v lives at index v. The value n has no slot, so leave it wherever it lands. The first index that doesn't hold its own number is the answer; if all match, the answer is n.
public int missingNumber(int[] nums) { // n numbers from 0..n, one missing
int i = 0, n = nums.length;
while (i < n) {
int correct = nums[i]; // value v belongs at index v
if (correct < n && nums[i] != nums[correct]) swap(nums, i, correct);
else i++; // value n has no slot: leave it
}
for (i = 0; i < n; i++) {
if (nums[i] != i) return i;
}
return n; // 0..n-1 all present
}
// [3,0,1] -> 2, [0,1] -> 2, [9,6,4,2,3,5,7,0,1] -> 8
Time O(n), Space O(1).
Worth knowing: the XOR trick solves this one without touching the array, since x ^ x = 0:
public int missingNumberXor(int[] nums) {
int x = nums.length; // start with n
for (int i = 0; i < nums.length; i++) x ^= i ^ nums[i];
return x; // every present number cancels itself
}
// [9,6,4,2,3,5,7,0,1] -> 8
Find All Numbers Disappeared in an Array, LC 448 (Easy)
n numbers, each in 1..n, some appearing twice. Return all numbers in 1..n that don't appear.
Key insight: run cyclic sort. Duplicates get stuck in slots whose owner is missing, so every mismatched index i means i + 1 is missing.
public List<Integer> findDisappearedNumbers(int[] nums) {
cyclicSort(nums); // same loop; duplicates just stop when their home is taken
List<Integer> missing = new ArrayList<>();
for (int i = 0; i < nums.length; i++) {
if (nums[i] != i + 1) missing.add(i + 1);
}
return missing;
}
// [4,3,2,7,8,2,3,1] -> [5, 6]
// [1,1] -> [2]
Time O(n), Space O(1) extra (the output list doesn't count).
Find the Duplicate Number, LC 287 (Medium)
n + 1 numbers, each in 1..n, one value repeated. Return it.
Key insight: while sending a number home, if its home already holds the same value, you've found the duplicate. Note: LC 287 forbids modifying the array, so in that exact problem use Floyd's cycle detection; the cyclic-sort version is the right answer when changes are allowed.
public int findDuplicate(int[] nums) { // n + 1 numbers from 1..n
int i = 0;
while (i < nums.length) {
if (nums[i] != i + 1) { // not at home
int correct = nums[i] - 1;
if (nums[i] == nums[correct]) return nums[i]; // home already taken by a copy
swap(nums, i, correct);
} else {
i++;
}
}
return -1;
}
// [1,3,4,2,2] -> 2, [3,1,3,4,2] -> 3, [3,3,3,3,3] -> 3
Time O(n), Space O(1).
First Missing Positive, LC 41 (Hard)
Unsorted array of any integers. Return the smallest positive integer that is not present, in O(n) time and O(1) space.
Key insight: with n slots, the answer is always in 1..n+1. So only values 1..n matter: send them home and ignore negatives, zeros and big values. The first slot not holding i + 1 is the answer.
public int firstMissingPositive(int[] nums) {
int n = nums.length, i = 0;
while (i < n) {
int v = nums[i];
// only 1..n can matter: the answer is always in 1..n+1
if (v >= 1 && v <= n && nums[v - 1] != v) swap(nums, i, v - 1);
else i++;
}
for (i = 0; i < n; i++) {
if (nums[i] != i + 1) return i + 1;
}
return n + 1;
}
// [1,2,0] -> 3, [3,4,-1,1] -> 2, [7,8,9,11,12] -> 1, [1,1] -> 2
Time O(n), Space O(1).
Set Mismatch, LC 645 (Easy)
The set 1..n got corrupted: one number was copied over another. Return [duplicate, missing].
Key insight: after cyclic sort there is exactly one mismatched slot: it holds the duplicate, and its index + 1 is the missing number.
public int[] findErrorNums(int[] nums) { // one number duplicated, one missing
cyclicSort(nums);
for (int i = 0; i < nums.length; i++) {
if (nums[i] != i + 1) return new int[]{nums[i], i + 1}; // {duplicate, missing}
}
return new int[]{-1, -1};
}
// [1,2,2,4] -> [2, 3]
// [1,1] -> [1, 2]
// [3,2,3,4,6,5] -> [3, 1]
Time O(n), Space O(1).
if (i != correct) instead of if (nums[i] != nums[correct]), which loops forever on duplicates; doing i++ after a swap (the new value at i hasn't been placed yet); an off-by-one between "value v at index v" (range 0..n) and "value v at index v - 1" (range 1..n); forgetting to skip values that have no home (0, negatives, values > n), causing ArrayIndexOutOfBoundsException; using it when the problem says the array must not be modified.Practice list
- LC 1920 Build Array from Permutation (Easy)
- LC 1539 Kth Missing Positive Number (Easy)
- LC 442 Find All Duplicates in an Array (Medium)
- LC 565 Array Nesting (Medium) — follows the same "index to value" cycles
- LC 2471 Minimum Number of Operations to Sort a Binary Tree by Level (Medium) — count swaps with cycles
- LC 765 Couples Holding Hands (Hard) — swap each partner into place
37. In-place Linked List Reversal
To reverse a linked list you don't copy anything: you walk along it once and flip each arrow so it points backwards. The only difficulty is that the moment you flip curr.next, you lose your way to the rest of the list. So you keep three pointers: prev (the part already reversed), curr (the node being flipped) and next (a bookmark to the rest). That "three-pointer dance" is the whole pattern.
Harder problems reverse only part of the list (a sublist, every group of k, every pair), and the extra work is all about reconnecting the reversed piece to the nodes before and after it. A dummy node in front of the head makes that reconnection the same everywhere, even at the very start.
Turning around a line of people holding the shoulder of the person in front. You go down the line and ask each person to let go and hold the person behind instead. Before they let go, you note who was in front of them, so you know who to ask next.
How to recognise it
- "Reverse a linked list", "reverse from position left to right", "reverse every k nodes", "swap nodes in pairs".
- "Reorder", "rotate", "fold" a list, or "compare the list with its reverse" (palindrome list).
- "Add two numbers" where digits are stored most-significant first (reverse, add, reverse back).
- "Do it in place", "O(1) extra memory": no ArrayList of nodes, no stack.
How it works
Every loop step does the same four moves in the same order: save next = curr.next, flip curr.next = prev, advance prev = curr, advance curr = next. When curr falls off the end, prev is sitting on the old tail, which is the new head.
Useful facts after a reversal loop: prev is the new head of the reversed piece, curr is the first node that was not reversed, and the node you started from is now the tail of the reversed piece. Keeping a reference to that starting node is how you reconnect in the sublist and k-group problems.
Java template
The node class used in this section (the LeetCode one):
class ListNode {
int val;
ListNode next;
ListNode(int val) { this.val = val; }
ListNode(int val, ListNode next) { this.val = val; this.next = next; }
}
// Reverse the list starting at head; returns the new head.
ListNode reverse(ListNode head) {
ListNode prev = null; // already-reversed part (starts empty)
ListNode curr = head; // node we are about to flip
while (curr != null) { // CHANGES: stop after k nodes / at a given node
ListNode next = curr.next; // 1. save the rest of the list
curr.next = prev; // 2. flip the arrow
prev = curr; // 3. grow the reversed part
curr = next; // 4. step forward
}
return prev; // prev = new head; curr = first node NOT reversed
}
For partial reversals add a dummy node: ListNode dummy = new ListNode(0, head);, work from dummy, and return dummy.next. Then "the node before the reversed piece" always exists, even when the piece starts at the head.
Classic problems
Reverse Linked List, LC 206 (Easy)
Reverse a singly linked list and return the new head.
Key insight: the template, exactly. Know the recursive version too: interviewers often ask for both.
public ListNode reverseList(ListNode head) {
ListNode prev = null, curr = head;
while (curr != null) {
ListNode next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
return prev;
}
// [1,2,3,4,5] -> [5, 4, 3, 2, 1], [1,2] -> [2, 1], [] -> []
Time O(n), Space O(1).
public ListNode reverseListRecursive(ListNode head) {
if (head == null || head.next == null) return head; // 0 or 1 node: already reversed
ListNode newHead = reverseListRecursive(head.next); // reverse everything after head
head.next.next = head; // the node after head now points back to head
head.next = null; // head becomes the tail
return newHead;
}
// [1,2,3,4,5] -> [5, 4, 3, 2, 1]
Time O(n), Space O(n) for the call stack, so the iterative version is preferred (and a list of 100,000 nodes can overflow the stack recursively).
Reverse Linked List II, LC 92 (Medium)
Reverse only the nodes from position left to right (1-indexed), in one pass.
Key insight: walk to the node before position left (a dummy makes this work when left = 1). Then do right - left "head insertions": take the node after first and move it to the front of the sublist. first slides toward the end on its own and stays connected to the rest.
public ListNode reverseBetween(ListNode head, int left, int right) {
ListNode dummy = new ListNode(0, head);
ListNode before = dummy;
for (int i = 1; i < left; i++) before = before.next; // node just before position left
// Head insertion: repeatedly move the node after 'first' to the front of the sublist.
ListNode first = before.next; // becomes the sublist's tail
for (int i = 0; i < right - left; i++) {
ListNode moved = first.next;
first.next = moved.next;
moved.next = before.next;
before.next = moved;
}
return dummy.next;
}
// [1,2,3,4,5], left=2, right=4 -> [1, 4, 3, 2, 5]
// [5], 1, 1 -> [5], [3,5], 1, 2 -> [5, 3]
Time O(n), Space O(1).
Reverse Nodes in k-Group, LC 25 (Hard)
Reverse the list k nodes at a time. A final group with fewer than k nodes stays as it is.
Key insight: for each group, first check that k nodes exist (find the k-th). Reverse the group with prev starting at groupNext instead of null: that way the group's new tail already points at the next part. Then hook the previous part to the group's new head (kth) and move groupPrev to the group's new tail.
public ListNode reverseKGroup(ListNode head, int k) {
ListNode dummy = new ListNode(0, head);
ListNode groupPrev = dummy; // node before the current group
while (true) {
ListNode kth = groupPrev; // find the k-th node of this group
for (int i = 0; i < k && kth != null; i++) kth = kth.next;
if (kth == null) break; // fewer than k nodes left: leave them
ListNode groupNext = kth.next; // first node after the group
ListNode prev = groupNext, curr = groupPrev.next;
while (curr != groupNext) { // standard reversal, but prev starts at
ListNode next = curr.next; // groupNext so the tail links onward
curr.next = prev;
prev = curr;
curr = next;
}
ListNode oldFirst = groupPrev.next; // it is now the group's last node
groupPrev.next = kth; // kth is the group's new first node
groupPrev = oldFirst;
}
return dummy.next;
}
// [1,2,3,4,5], k=2 -> [2, 1, 4, 3, 5]
// [1,2,3,4,5], k=3 -> [3, 2, 1, 4, 5]
// [1,2,3], k=1 -> [1, 2, 3]
Time O(n) (each node is visited twice: once to count, once to reverse), Space O(1).
Swap Nodes in Pairs, LC 24 (Medium)
Swap every two adjacent nodes (change links, not values).
Key insight: it's k-group with k = 2, small enough to write the three pointer changes by hand. Draw prev -> a -> b -> rest and rewire to prev -> b -> a -> rest.
public ListNode swapPairs(ListNode head) {
ListNode dummy = new ListNode(0, head);
ListNode prev = dummy;
while (prev.next != null && prev.next.next != null) {
ListNode a = prev.next, b = a.next;
a.next = b.next; // a points past the pair
b.next = a; // b points back to a
prev.next = b; // the node before the pair now points to b
prev = a; // a is now second: it's the "before" of the next pair
}
return dummy.next;
}
// [1,2,3,4] -> [2, 1, 4, 3], [1,2,3] -> [2, 1, 3], [] -> []
Time O(n), Space O(1).
Rotate List, LC 61 (Medium)
Rotate the list to the right by k places.
Key insight: no reversal needed, just relinking. Find the length n and the tail, reduce k %= n (k can be huge), join tail to head to make a ring, then cut the ring after the (n - k)-th node.
public ListNode rotateRight(ListNode head, int k) {
if (head == null || head.next == null) return head;
int n = 1;
ListNode tail = head;
while (tail.next != null) { tail = tail.next; n++; } // length and tail
k %= n;
if (k == 0) return head;
tail.next = head; // close into a ring
ListNode newTail = head;
for (int i = 1; i < n - k; i++) newTail = newTail.next; // (n - k)-th node
ListNode newHead = newTail.next;
newTail.next = null; // cut the ring
return newHead;
}
// [1,2,3,4,5], k=2 -> [4, 5, 1, 2, 3]
// [0,1,2], k=4 -> [2, 0, 1]
Time O(n), Space O(1).
Reorder List, LC 143 (Medium)
Reorder L0 -> L1 -> ... -> Ln into L0 -> Ln -> L1 -> Ln-1 -> ... in place.
Key insight: three patterns glued together: fast & slow to find the middle, reverse the second half (the template's reverse), then weave the two halves, one node from each.
public void reorderList(ListNode head) {
if (head == null || head.next == null) return;
// 1. middle (first middle, so the first half is not shorter)
ListNode slow = head, fast = head;
while (fast.next != null && fast.next.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// 2. reverse the second half and cut it off
ListNode second = reverse(slow.next);
slow.next = null;
// 3. weave: one from the front, one from the back
ListNode first = head;
while (second != null) {
ListNode n1 = first.next, n2 = second.next;
first.next = second;
second.next = n1;
first = n1;
second = n2;
}
}
// [1,2,3,4] -> [1, 4, 2, 3]
// [1,2,3,4,5] -> [1, 5, 2, 4, 3]
Time O(n), Space O(1).
curr.next before saving it (the rest of the list is lost); returning head or curr instead of prev; forgetting that the old head is now the tail and must point to null (or to the next part), which creates a cycle; not using a dummy node, so the case "reverse starting at the head" needs special code; in k-group, reversing a final group that has fewer than k nodes; in Rotate List, forgetting k %= n. Tip: draw the boxes and arrows before coding, and trace a 2-node and a 3-node list by hand.Practice list
- LC 234 Palindrome Linked List (Easy)
- LC 2130 Maximum Twin Sum of a Linked List (Medium)
- LC 445 Add Two Numbers II (Medium)
- LC 328 Odd Even Linked List (Medium)
- LC 1721 Swapping Nodes in a Linked List (Medium)
- LC 2074 Reverse Nodes in Even Length Groups (Medium)
- LC 86 Partition List (Medium)
- LC 2816 Double a Number Represented as a Linked List (Medium)
38. Tree BFS & DFS
Almost every binary tree question is solved by walking the tree in one of two ways. BFS (breadth-first search) visits the tree level by level, top to bottom, using a queue. DFS (depth-first search) goes as deep as possible down one branch before coming back, using recursion (the call stack).
The pattern is not "know BFS and DFS" — you already do from section 12. The pattern is knowing which one fits and having a template ready: BFS when the question talks about levels or "closest to the root"; DFS when it talks about paths, depths, or answers built from subtrees.
BFS is a building inspector who checks every room on floor 1, then every room on floor 2, and so on. DFS is a person exploring a cave: follow one tunnel to its end, come back to the last fork, try the next tunnel.
How to recognise it
- "Level order", "each level", "row by row", "average / max of each level", "zigzag" — BFS.
- "Right side view", "left view", "nodes you can see", "minimum depth" (first leaf you reach) — BFS (stop at the first answer).
- "Root-to-leaf path", "path sum", "all paths", "sum of numbers along the path" — DFS top-down (carry the path/sum down).
- "Height", "diameter", "balanced?", "maximum path sum", "subtree ..." — DFS bottom-up (children report up, parent combines).
- "Good nodes", "max so far on the path from root", "ancestor" — DFS carrying state down.
How it works
BFS with a size snapshot. Put the root in a queue. At the start of each round, write down size = queue.size(). Exactly those size nodes belong to the current level — the children you add while processing them wait for the next round. This one line is what turns "BFS" into "BFS that knows where each level ends".
DFS: two styles. Decide which direction information flows.
| Style | Information flows | You write | Use for |
|---|---|---|---|
| Top-down (preorder thinking) | From parent to child, through parameters | void dfs(node, stateSoFar) | Path sums, max-so-far on the path, depth of each node, building a path list |
| Bottom-up (postorder thinking) | From children to parent, through return values | int dfs(node) returns an answer for that subtree | Height, diameter, balanced check, max path sum, subtree sums |
Trace of bottom-up height on the same tree: h(15)=1, h(7)=1, so h(20)=1+max(1,1)=2; h(9)=1; h(3)=1+max(1,2)=3. Each node is visited once, and the answer "bubbles up" to the root.
Java template
List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) return result;
Deque<TreeNode> queue = new ArrayDeque<>();
queue.offer(root);
while (!queue.isEmpty()) {
int size = queue.size(); // snapshot: nodes on THIS level
List<Integer> level = new ArrayList<>();
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
level.add(node.val); // CHANGES: what you do with each node
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
result.add(level); // CHANGES: what you keep per level
}
return result;
}
// Top-down: pass state DOWN as parameters (depth, path, running sum, max so far)
int best = 0;
void topDown(TreeNode node, int depth) {
if (node == null) return;
if (node.left == null && node.right == null)
best = Math.max(best, depth); // CHANGES: act at a leaf / at each node
topDown(node.left, depth + 1); // CHANGES: how state is updated
topDown(node.right, depth + 1);
}
// Bottom-up: children RETURN answers, parent combines them
int bottomUp(TreeNode node) {
if (node == null) return 0; // CHANGES: value for an empty tree
int left = bottomUp(node.left);
int right = bottomUp(node.right);
return 1 + Math.max(left, right); // CHANGES: how to combine
}
Classic problems
Binary Tree Level Order Traversal — LC 102 (Medium)
Return the node values level by level: [3,9,20,null,null,15,7] gives [[3],[9,20],[15,7]].
Key insight: this is the BFS template above, unchanged. Everything else in this section is a small edit of it.
Time O(n), Space O(w) where w is the widest level (up to n/2 for a full tree).
Binary Tree Zigzag Level Order Traversal — LC 103 (Medium)
Like level order, but alternate direction: left-to-right, then right-to-left. Same tree gives [[3],[20,9],[15,7]].
Key insight: do not change the order you visit nodes — that gets messy. Visit normally and just fill the level list from the front on odd levels.
List<List<Integer>> zigzagLevelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) return result;
Deque<TreeNode> queue = new ArrayDeque<>();
queue.offer(root);
boolean leftToRight = true;
while (!queue.isEmpty()) {
int size = queue.size();
LinkedList<Integer> level = new LinkedList<>();
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
if (leftToRight) level.addLast(node.val);
else level.addFirst(node.val); // fill backwards on odd levels
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
result.add(level);
leftToRight = !leftToRight;
}
return result;
}
Time O(n), Space O(w).
Binary Tree Right Side View — LC 199 (Medium)
Return the values you would see standing on the right of the tree. [1,2,3,null,5,null,4] gives [1,3,4].
Key insight: "what you see from the right" = the last node of each level. A common wrong answer is "keep going right" — that misses a left child that sticks out lower than every right node (the 5 above would be missed if 4 were absent).
List<Integer> rightSideView(TreeNode root) {
List<Integer> view = new ArrayList<>();
if (root == null) return view;
Deque<TreeNode> queue = new ArrayDeque<>();
queue.offer(root);
while (!queue.isEmpty()) {
int size = queue.size();
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
if (i == size - 1) view.add(node.val); // last node of the level
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
}
return view;
}
Time O(n), Space O(w). (A DFS alternative: visit right child first and record the first node you reach at each new depth.)
Path Sum II — LC 113 (Medium)
Return every root-to-leaf path whose values add up to targetSum. For [5,4,8,11,null,13,4,7,2,null,null,5,1] and target 22 the answer is [[5,4,11,2],[5,8,4,5]].
Key insight: top-down DFS carrying the remaining sum and the current path. Add the node on the way in, remove it on the way out (backtracking), and copy the path when you save it.
List<List<Integer>> pathSum(TreeNode root, int targetSum) {
List<List<Integer>> result = new ArrayList<>();
dfs(root, targetSum, new ArrayList<>(), result);
return result;
}
private void dfs(TreeNode node, int remaining, List<Integer> path,
List<List<Integer>> result) {
if (node == null) return;
path.add(node.val); // choose
remaining -= node.val;
if (node.left == null && node.right == null && remaining == 0)
result.add(new ArrayList<>(path)); // copy, not the live list
dfs(node.left, remaining, path, result);
dfs(node.right, remaining, path, result);
path.remove(path.size() - 1); // un-choose (backtrack)
}
Time O(n²) worst case (n nodes, and copying a path of up to n values at each leaf), Space O(h) for recursion plus the path.
Binary Tree Maximum Path Sum — LC 124 (Hard)
A path is any chain of connected nodes (it need not pass through the root and can bend once at some node). Find the largest sum. [-10,9,20,null,null,15,7] gives 42 (15 → 20 → 7).
Key insight: bottom-up DFS where each node answers two different questions:
- Returned to the parent: the best "one-armed" path starting here and going down one side — because a parent can only extend a path that doesn't already bend.
- Recorded in a global: the best path that bends here:
node + left + right.
Negative branches are clamped to 0 ("don't take that branch").
int maxSum = Integer.MIN_VALUE;
int maxPathSum(TreeNode root) {
gain(root);
return maxSum;
}
// returns the best sum of a path that STARTS at node and goes down one side
private int gain(TreeNode node) {
if (node == null) return 0;
int left = Math.max(0, gain(node.left)); // drop negative branches
int right = Math.max(0, gain(node.right));
maxSum = Math.max(maxSum, node.val + left + right); // path bends here
return node.val + Math.max(left, right); // parent can use only one side
}
Time O(n), Space O(h). Start maxSum at Integer.MIN_VALUE, not 0 — for the single-node tree [-3] the answer is -3.
Count Good Nodes in Binary Tree — LC 1448 (Medium)
A node is "good" if no node on the path from the root to it has a bigger value. [3,1,4,3,null,1,5] gives 4.
Key insight: pass the max seen so far down (top-down), and add up the counts on the way back (bottom-up) — both styles in one function.
int goodNodes(TreeNode root) {
return count(root, Integer.MIN_VALUE);
}
private int count(TreeNode node, int maxSoFar) {
if (node == null) return 0;
int good = node.val >= maxSoFar ? 1 : 0; // top-down: state flows in
int newMax = Math.max(maxSoFar, node.val);
return good + count(node.left, newMax) + count(node.right, newMax); // bottom-up sum
}
Time O(n), Space O(h).
queue.size() inside the for condition — it changes as you add children; snapshot it first. (2) Adding the live path list to the result instead of new ArrayList<>(path) — every saved answer ends up empty. (3) Forgetting to remove the last element after recursing (the "un-choose" step). (4) Treating "leaf" as "node == null" — a leaf is a node with both children null; checking null fires twice per leaf and wrongly counts nodes with one child. (5) Initialising a max to 0 when all values can be negative. (6) Recursion depth: a skewed tree of 105 nodes can overflow the Java stack; mention BFS or an explicit stack as the fix.Practice list
- LC 104 Maximum Depth of Binary Tree (Easy)
- LC 111 Minimum Depth of Binary Tree (Easy) — BFS, stop at the first leaf
- LC 112 Path Sum (Easy)
- LC 637 Average of Levels in Binary Tree (Easy)
- LC 116 Populating Next Right Pointers in Each Node (Medium)
- LC 129 Sum Root to Leaf Numbers (Medium)
- LC 236 Lowest Common Ancestor of a Binary Tree (Medium)
- LC 662 Maximum Width of Binary Tree (Medium)
- LC 437 Path Sum III (Medium) — prefix sums on a tree path
- LC 297 Serialize and Deserialize Binary Tree (Hard)
39. Top K Elements & K-way Merge
Top K: you want the k biggest (or smallest, or most frequent, or closest) items, not a full sort. Keep a small heap of size k that holds the best k seen so far. Every new item fights the weakest member; the loser is thrown out.
K-way merge: you have k sorted lists and want one sorted output. Put the front item of every list in a min-heap. Repeatedly take the smallest, and replace it with the next item from the same list.
Top K is a talent show with only k chairs on stage. A new act walks on; if they beat the weakest person sitting down, that person leaves and the new act sits. K-way merge is k checkout queues merging into one door: the guard looks only at the person at the front of each queue, lets through the one with the lowest ticket number, and that queue moves up one.
How to recognise it
- "k-th largest / smallest", "top k", "k most frequent", "k closest", "k best" — heap of size k.
- The input is huge or a stream (you can't sort it all at once) — heap of size k uses O(k) memory.
- "k sorted lists / arrays / rows", "merge sorted", "smallest range across lists", "k-th smallest in a sorted matrix" — k-way merge.
- Frequencies bounded by n ("appears how many times") — consider bucket sort for O(n).
How it works
Why a MIN-heap for the k LARGEST? It sounds backwards. The heap is a waiting room for the current top k. To decide if a new number belongs, you compare it with the weakest of the current top k — the smallest one. A min-heap keeps exactly that one on top, ready in O(1), removable in O(log k). Mirror rule: k smallest uses a max-heap.
Cost: n offers and polls on a heap that never grows past k+1, so O(n log k) time and O(k) space — better than sorting (O(n log n)) when k is small.
Each of the N total items enters and leaves the heap once, and the heap has at most k items: O(N log k) time, O(k) extra space.
Java template
// Top k LARGEST: keep a MIN-heap of size k. Its top is the weakest of the "winners".
List<Integer> topKLargest(int[] nums, int k) {
PriorityQueue<Integer> heap = new PriorityQueue<>(); // min-heap
for (int x : nums) {
heap.offer(x);
if (heap.size() > k) heap.poll(); // kick out the smallest of k+1
}
return new ArrayList<>(heap); // any order; heap.peek() = k-th largest
}
// CHANGES per problem:
// top k SMALLEST -> max-heap: new PriorityQueue<>(Comparator.reverseOrder())
// rank by something else (distance, frequency) -> custom comparator
// K-way merge: lists[i] is sorted; output everything in sorted order
List<Integer> mergeSortedArrays(int[][] lists) {
// entry = {value, listIndex, elementIndex}
PriorityQueue<int[]> heap = new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));
for (int i = 0; i < lists.length; i++)
if (lists[i].length > 0) heap.offer(new int[]{lists[i][0], i, 0});
List<Integer> out = new ArrayList<>();
while (!heap.isEmpty()) {
int[] cur = heap.poll();
out.add(cur[0]); // CHANGES: what you do with the smallest
int li = cur[1], next = cur[2] + 1;
if (next < lists[li].length) // same list sends its next item
heap.offer(new int[]{lists[li][next], li, next});
}
return out;
}
Classic problems
Kth Largest Element in an Array — LC 215 (Medium)
Return the k-th largest value. [3,2,1,5,6,4], k = 2 gives 5.
Key insight: the top of a size-k min-heap is the k-th largest.
int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> heap = new PriorityQueue<>();
for (int x : nums) {
heap.offer(x);
if (heap.size() > k) heap.poll();
}
return heap.peek();
}
Time O(n log k), Space O(k).
Quickselect alternative. Quicksort's partition puts one pivot at its final sorted index and smaller values to its left. If that index is the one you want, stop; otherwise continue on one side only. Average O(n) because you throw away about half each time (n + n/2 + n/4 + … ≈ 2n). Worst case O(n²) with bad pivots — a random pivot makes that very unlikely.
int findKthLargestQuick(int[] nums, int k) {
int target = nums.length - k; // index it would have if sorted ascending
int lo = 0, hi = nums.length - 1;
Random rnd = new Random();
while (lo < hi) {
int p = partition(nums, lo, hi, lo + rnd.nextInt(hi - lo + 1));
if (p == target) break;
if (p < target) lo = p + 1; // answer is on the right
else hi = p - 1; // answer is on the left
}
return nums[target];
}
// Lomuto partition: smaller values go left of the pivot; returns pivot's final index
private int partition(int[] a, int lo, int hi, int pivotIdx) {
int pivot = a[pivotIdx];
swap(a, pivotIdx, hi);
int store = lo;
for (int i = lo; i < hi; i++)
if (a[i] < pivot) swap(a, i, store++);
swap(a, store, hi);
return store;
}
private void swap(int[] a, int i, int j) { int t = a[i]; a[i] = a[j]; a[j] = t; }
[3,2,3,1,2,4,5,5,6], k = 4 gives 4. Time O(n) average / O(n²) worst, Space O(1). It rearranges the input — say so in the interview. Many duplicates slow Lomuto partition down; a three-way partition fixes that.
Top K Frequent Elements — LC 347 (Medium)
Return the k most frequent values. [1,1,1,2,2,3], k = 2 gives [1,2].
Key insight: a heap of size k keyed on frequency works (O(n log k)). But a frequency can never exceed n, so use bucket sort: bucket[f] lists the values that appear f times. Walk from the highest bucket down. O(n).
int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> freq = new HashMap<>();
for (int x : nums) freq.merge(x, 1, Integer::sum);
// bucket[f] = numbers that appear exactly f times (f is at most n)
List<Integer>[] bucket = new List[nums.length + 1];
for (var e : freq.entrySet()) {
int f = e.getValue();
if (bucket[f] == null) bucket[f] = new ArrayList<>();
bucket[f].add(e.getKey());
}
int[] result = new int[k];
int idx = 0;
for (int f = nums.length; f >= 1 && idx < k; f--) // highest frequency first
if (bucket[f] != null)
for (int x : bucket[f])
if (idx < k) result[idx++] = x;
return result;
}
Time O(n), Space O(n). (Java warns about the generic array new List[n + 1]; that's fine here, or use List<List<Integer>>.)
K Closest Points to Origin — LC 973 (Medium)
Return the k points nearest to (0, 0). [[3,3],[5,-1],[-2,4]], k = 2 gives [[3,3],[-2,4]] (any order).
Key insight: "k closest" = "k smallest distances", so keep a max-heap of size k and drop the farthest. Compare squared distances — no Math.sqrt, no floating point.
int[][] kClosest(int[][] points, int k) {
// max-heap by distance: the top is the FARTHEST of the k we keep
PriorityQueue<int[]> heap = new PriorityQueue<>(
(a, b) -> Integer.compare(dist(b), dist(a)));
for (int[] p : points) {
heap.offer(p);
if (heap.size() > k) heap.poll(); // drop the farthest
}
return heap.toArray(new int[0][]);
}
private int dist(int[] p) { return p[0] * p[0] + p[1] * p[1]; } // no sqrt needed
Time O(n log k), Space O(k). Quickselect on distance also works in O(n) average.
Merge k Sorted Lists — LC 23 (Hard)
Merge k sorted linked lists into one. [[1,4,5],[1,3,4],[2,6]] gives 1->1->2->3->4->4->5->6.
Key insight: the list nodes carry their own "next", so the heap can hold nodes directly — no (list, index) bookkeeping.
ListNode mergeKLists(ListNode[] lists) {
PriorityQueue<ListNode> heap = new PriorityQueue<>((a, b) -> Integer.compare(a.val, b.val));
for (ListNode head : lists)
if (head != null) heap.offer(head); // one "front runner" per list
ListNode dummy = new ListNode(0), tail = dummy;
while (!heap.isEmpty()) {
ListNode smallest = heap.poll();
tail.next = smallest;
tail = smallest;
if (smallest.next != null) heap.offer(smallest.next); // its list sends the next one
}
return dummy.next;
}
Time O(N log k) for N total nodes, Space O(k). The alternative is divide and conquer: merge lists in pairs, rounds of log k, same O(N log k).
Kth Smallest Element in a Sorted Matrix — LC 378 (Medium)
Every row and every column is sorted. Find the k-th smallest. [[1,5,9],[10,11,13],[12,13,15]], k = 8 gives 13.
Key insight: each row is a sorted list — k-way merge the rows and stop at the k-th pop. Only the first min(n, k) rows can matter.
int kthSmallest(int[][] matrix, int k) {
int n = matrix.length;
// entry = {value, row, col}; each row is a sorted list
PriorityQueue<int[]> heap = new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));
for (int r = 0; r < Math.min(n, k); r++)
heap.offer(new int[]{matrix[r][0], r, 0});
for (int i = 1; i < k; i++) { // pop k-1 times
int[] cur = heap.poll();
int r = cur[1], c = cur[2];
if (c + 1 < n) heap.offer(new int[]{matrix[r][c + 1], r, c + 1});
}
return heap.peek()[0]; // k-th pop
}
Time O(k log n), Space O(n). A binary search on the value (count how many are <= mid) gives O(n log(max - min)) — see section 42.
Smallest Range Covering Elements from K Lists — LC 632 (Hard)
Find the smallest range [a, b] that includes at least one number from each of k sorted lists. [[4,10,15,24,26],[0,9,12,20],[5,18,22,30]] gives [20,24].
Key insight: keep exactly one number from each list in the heap — that set always covers all lists. Its range is [heap min, current max]. To try to shrink it, the only move that can help is advancing the minimum. Stop when some list runs out.
int[] smallestRange(List<List<Integer>> nums) {
// entry = {value, listIndex, elementIndex}
PriorityQueue<int[]> heap = new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));
int max = Integer.MIN_VALUE;
for (int i = 0; i < nums.size(); i++) {
int v = nums.get(i).get(0);
heap.offer(new int[]{v, i, 0});
max = Math.max(max, v); // track the max of the current window
}
int bestLo = 0, bestHi = Integer.MAX_VALUE;
while (true) {
int[] cur = heap.poll(); // current minimum
int min = cur[0], li = cur[1], ei = cur[2];
if (max - min < bestHi - bestLo) { bestLo = min; bestHi = max; }
if (ei + 1 == nums.get(li).size()) break; // a list ran out: can't cover all k
int next = nums.get(li).get(ei + 1);
heap.offer(new int[]{next, li, ei + 1});
max = Math.max(max, next);
}
return new int[]{bestLo, bestHi};
}
Time O(N log k), Space O(k).
(a, b) -> a - b — overflows for large or negative values; use Integer.compare. (3) Returning heap contents as if sorted — a PriorityQueue iterates in heap order, not sorted order; poll repeatedly if order matters. (4) In k-way merge, forgetting to push the next item from the same list, or pushing from the wrong list because the index wasn't stored. (5) Offering null list heads into the heap (NullPointerException).Practice list
- LC 1046 Last Stone Weight (Easy)
- LC 703 Kth Largest Element in a Stream (Easy)
- LC 451 Sort Characters By Frequency (Medium)
- LC 692 Top K Frequent Words (Medium) — tie-break by word
- LC 658 Find K Closest Elements (Medium)
- LC 373 Find K Pairs with Smallest Sums (Medium) — k-way merge in disguise
- LC 767 Reorganize String (Medium)
- LC 621 Task Scheduler (Medium)
- LC 1439 Find the Kth Smallest Sum of a Matrix With Sorted Rows (Hard)
40. Two Heaps
Split the numbers into a smaller half and a larger half. Keep the smaller half in a max-heap (so its biggest value is on top) and the larger half in a min-heap (so its smallest value is on top). The two tops sit right next to each other in the middle of the data — so the median is always one peek() away.
The same "two heaps facing each other" idea also shows up when you have two groups of items — "things I can't use yet" and "things I can use now" — and keep moving items from one heap to the other (the IPO problem below).
Line people up by height and cut the line in half. The short group faces right so its tallest person stands at the cut; the tall group faces left so its shortest person stands at the cut. To find the middle height you only look at the two people at the cut — never at anyone else.
How to recognise it
- "Median" of a stream / of a changing set / of every window.
- "Numbers arrive one by one, answer after each one" and the answer depends on the middle of the data.
- "Split into two groups so that every item in one is <= every item in the other".
- Scheduling with a threshold that grows: "you can only pick projects/tasks whose requirement <= what you have now" — one heap holds the locked items sorted by requirement, the other holds the unlocked ones sorted by value.
How it works
Two rules keep the structure correct:
- Order rule: every value in
low<= every value inhigh, i.e.low.peek() <= high.peek(). - Size rule:
low.size() == high.size()orlow.size() == high.size() + 1. (Low is allowed the one extra item when the count is odd.)
Then: odd count, the median is low.peek(); even count, it is the average of the two tops.
The three-step add (simplest version that can't get the order rule wrong): (1) push into low; (2) move low's top into high — this guarantees the biggest of the small half crosses over, so the order rule holds; (3) if high is now bigger, move high's top back to low.
Java template
PriorityQueue<Integer> low = new PriorityQueue<>(Comparator.reverseOrder()); // max-heap
PriorityQueue<Integer> high = new PriorityQueue<>(); // min-heap
void add(int x) {
low.offer(x);
high.offer(low.poll()); // keeps order rule
if (high.size() > low.size()) low.offer(high.poll()); // keeps size rule
}
// CHANGES per problem: what you read from the tops (median, balance point),
// and whether items must also be REMOVED (then use TreeSet / lazy deletion)
Classic problems
Find Median from Data Stream — LC 295 (Hard)
Design a class with addNum(int) and findMedian(). addNum(1), addNum(2) then findMedian() is 1.5; addNum(3) then findMedian() is 2.0.
Key insight: sorting after every add costs O(n log n) each time; two heaps make addNum O(log n) and findMedian O(1).
class MedianFinder {
// lower half: max-heap (top = biggest of the small numbers)
private final PriorityQueue<Integer> low = new PriorityQueue<>(Comparator.reverseOrder());
// upper half: min-heap (top = smallest of the big numbers)
private final PriorityQueue<Integer> high = new PriorityQueue<>();
public void addNum(int num) {
low.offer(num); // 1. always enter through the lower half
high.offer(low.poll()); // 2. move low's biggest up (keeps order right)
if (high.size() > low.size()) // 3. rebalance: low may have 1 extra, never high
low.offer(high.poll());
}
public double findMedian() {
if (low.size() > high.size()) return low.peek(); // odd count
return (low.peek() + (long) high.peek()) / 2.0; // even count; long avoids overflow
}
}
Time O(log n) per add, O(1) per median. Space O(n).
Sliding Window Median — LC 480 (Hard)
Return the median of every window of size k. [1,3,-1,-3,5,3,6,7], k = 3 gives [1.0,-1.0,-1.0,3.0,5.0,6.0].
Key insight: now items must also leave. PriorityQueue.remove(Object) is O(k) (a linear scan). Use two TreeSets instead — they support "biggest", "smallest" and removing any element in O(log k). A set drops duplicates, so store indices and compare by value, then by index, which makes every entry unique.
double[] medianSlidingWindow(int[] nums, int k) {
// Compare by value, then by index, so equal values are still distinct entries.
Comparator<Integer> byValue = (a, b) ->
nums[a] != nums[b] ? Integer.compare(nums[a], nums[b]) : Integer.compare(a, b);
TreeSet<Integer> low = new TreeSet<>(byValue); // indices of the lower half
TreeSet<Integer> high = new TreeSet<>(byValue); // indices of the upper half
double[] result = new double[nums.length - k + 1];
for (int i = 0; i < nums.length; i++) {
// add: same three steps as MedianFinder
low.add(i);
high.add(low.pollLast());
if (high.size() > low.size()) low.add(high.pollFirst());
if (i >= k) { // remove the index that left the window
int out = i - k;
if (!low.remove(out)) high.remove(out);
// fix sizes again: low must have size == high or high + 1
if (low.size() < high.size()) low.add(high.pollFirst());
else if (low.size() > high.size() + 1) high.add(low.pollLast());
}
if (i >= k - 1) {
result[i - k + 1] = (k % 2 == 1)
? nums[low.last()]
: ((double) nums[low.last()] + nums[high.first()]) / 2.0;
}
}
return result;
}
Time O(n log k), Space O(k). The sum is cast to double first so two values near Integer.MAX_VALUE don't overflow. (Another accepted approach: two PriorityQueues with lazy deletion — keep a map of "to be deleted" values and throw them away only when they reach the top.)
IPO — LC 502 (Hard)
You start with capital w and may finish at most k projects. Project i needs capital[i] to start and adds profits[i] to your capital. Maximise final capital. k=2, w=0, profits=[1,2,3], capital=[0,1,1] gives 4.
Key insight: greedy — each round, among the projects you can afford right now, take the most profitable (taking it can only unlock more). Two heaps: a min-heap by capital of locked projects, and a max-heap by profit of affordable ones. As w grows, move newly affordable projects across.
int findMaximizedCapital(int k, int w, int[] profits, int[] capital) {
int n = profits.length;
// min-heap of projects by required capital: "locked" projects
PriorityQueue<int[]> locked = new PriorityQueue<>((a, b) -> Integer.compare(a[0], b[0]));
// max-heap of profits among projects we can afford: "unlocked"
PriorityQueue<Integer> available = new PriorityQueue<>(Comparator.reverseOrder());
for (int i = 0; i < n; i++) locked.offer(new int[]{capital[i], profits[i]});
for (int round = 0; round < k; round++) {
while (!locked.isEmpty() && locked.peek()[0] <= w)
available.offer(locked.poll()[1]); // now affordable
if (available.isEmpty()) break; // nothing we can start
w += available.poll(); // greedy: take the most profitable
}
return w;
}
Time O(n log n + k log n), Space O(n). Trace: w=0, unlock project 0 (profit 1), take it: w=1. Unlock projects 1 and 2 (profits 2, 3), take 3: w=4.
high hold the extra element but still reading the median from low — pick one convention and stick to it. (3) (a + b) / 2 with ints: integer division and overflow; use (a + (long) b) / 2.0. (4) Using PriorityQueue.remove(x) in a sliding window and calling it O(log k) — it is O(k). (5) Putting values (not indices) in a TreeSet — duplicate values vanish.Practice list
- LC 703 Kth Largest Element in a Stream (Easy) — warm-up with one heap
- LC 436 Find Right Interval (Medium)
- LC 1834 Single-Threaded CPU (Medium) — "available" vs "not yet arrived" heaps
- LC 2542 Maximum Subsequence Score (Medium)
- LC 857 Minimum Cost to Hire K Workers (Hard)
- LC 630 Course Schedule III (Hard)
- LC 871 Minimum Number of Refueling Stops (Hard) — same unlock-then-take-best idea as IPO
- LC 2402 Meeting Rooms III (Hard) — free rooms heap + busy rooms heap
41. Subsets, Permutations & Combinations
These problems ask you to list every possible choice: every subset, every ordering, every group that adds up to a target. You build answers one element at a time with backtracking: choose an element, explore everything that follows from that choice, then un-choose it and try the next option. (Section 21 covers backtracking in general; this section is the three templates you will use again and again.)
The three families differ in only one thing — which elements you are allowed to pick next:
- Subsets / combinations — order doesn't matter (
[1,2]is the same as[2,1]), so only pick elements to the right of the last pick: astartindex. - Permutations — order matters, so any element not yet used: a
used[]array. - With duplicates in the input — sort first, then skip an element equal to its left neighbour at the same level.
Ordering at a sandwich counter. For toppings (a subset), "cheese then tomato" is the same sandwich as "tomato then cheese", so you go down the list once and say yes or no. For a race podium (a permutation), who is first matters, so every runner can take every free spot.
How to recognise it
- "Return all subsets / the power set", "all combinations", "all permutations", "all possible ...".
- "All combinations that sum to target", "choose k numbers from 1..n".
- "All valid strings" (parentheses, IP addresses, letter combinations of a phone number).
- Small input: n <= 10–20. Output size is exponential (2n subsets, n! permutations), so nothing faster exists — the input limit is the hint.
- "Without duplicate subsets/permutations" when the input has repeated values — sort + skip rule.
How it works
Picture a decision tree. Each node is a partial answer (the path). Each edge is "add one more element". Backtracking is a DFS over this tree: going down an edge is choose, returning up is un-choose.
Trace (path shown at each step): [] save → add 1 → [1] save → add 2 → [1,2] save → add 3 → [1,2,3] save → remove 3 → remove 2 → add 3 → [1,3] save → remove 3 → remove 1 → add 2 → [2] save → add 3 → [2,3] save → … → [3] save. Output order: [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]].
| Problem type | Loop starts at | Recurse with | Save when | Extra |
|---|---|---|---|---|
| Subsets | start | i + 1 | every node | — |
| Combinations of size k / sum to target | start | i + 1 (each once) or i (reuse allowed) | size == k / remaining == 0 | sort + break when too big |
| Permutations | 0 | same array | path.size() == n | used[] |
| Any of the above with duplicate inputs | same as above | sort, then skip rule | ||
The duplicate skip rule. With input [1,2,2] the tree would build [1,2] twice — once with the first 2, once with the second. After sorting, equal values are neighbours, and the rule is:
if (i > start && nums[i] == nums[i - 1]) continue;
Read it as: "at this level of the tree (this loop), I already tried a branch starting with this value — don't start another one". i > start (not i > 0) matters: going deeper with the second 2 (building [2,2]) is still allowed, because that is a different level.
Java template
List<List<Integer>> result = new ArrayList<>();
void backtrack(int[] nums, int start, List<Integer> path) {
result.add(new ArrayList<>(path)); // CHANGES: when a path counts as an answer
for (int i = start; i < nums.length; i++) {
// CHANGES: skip rules (duplicates, over target, used[])
path.add(nums[i]); // 1. choose
backtrack(nums, i + 1, path); // 2. explore (i + 1: no reuse; i: reuse allowed)
path.remove(path.size() - 1); // 3. un-choose
}
}
Classic problems
Subsets — LC 78 (Medium)
Return all subsets of distinct integers. [1,2,3] gives the 8 subsets above.
Key insight: every node of the start-index tree is a subset — save on entry, no base case needed (the loop simply doesn't run when start == n).
List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
build(nums, 0, new ArrayList<>(), result);
return result;
}
private void build(int[] nums, int start, List<Integer> path, List<List<Integer>> result) {
result.add(new ArrayList<>(path)); // every node of the tree is a subset
for (int i = start; i < nums.length; i++) {
path.add(nums[i]);
build(nums, i + 1, path, result);
path.remove(path.size() - 1);
}
}
Time O(n · 2n) (2n subsets, each copied in up to O(n)), Space O(n) recursion (plus the output). Bitmask alternative: every number mask from 0 to 2n-1 is a subset — bit i set means "take nums[i]".
Subsets II — LC 90 (Medium)
Same, but the input may contain duplicates and the answer must not. [1,2,2] gives [[],[1],[1,2],[1,2,2],[2],[2,2]].
Key insight: sort + the skip rule.
List<List<Integer>> subsetsWithDup(int[] nums) {
Arrays.sort(nums); // duplicates become neighbours
List<List<Integer>> result = new ArrayList<>();
buildDup(nums, 0, new ArrayList<>(), result);
return result;
}
private void buildDup(int[] nums, int start, List<Integer> path, List<List<Integer>> result) {
result.add(new ArrayList<>(path));
for (int i = start; i < nums.length; i++) {
if (i > start && nums[i] == nums[i - 1]) continue; // same value, same level: skip
path.add(nums[i]);
buildDup(nums, i + 1, path, result);
path.remove(path.size() - 1);
}
}
Time O(n · 2n), Space O(n).
Permutations — LC 46 (Medium)
Return all orderings of distinct integers. [1,2,3] gives [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]].
Key insight: order matters, so every level loops from 0, skipping only elements already in the path (used[i]).
List<List<Integer>> permute(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
perm(nums, new boolean[nums.length], new ArrayList<>(), result);
return result;
}
private void perm(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> result) {
if (path.size() == nums.length) { // every slot filled
result.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) { // start from 0: order matters
if (used[i]) continue;
used[i] = true;
path.add(nums[i]);
perm(nums, used, path, result);
path.remove(path.size() - 1);
used[i] = false;
}
}
Time O(n · n!), Space O(n).
Permutations II — LC 47 (Medium)
Input may contain duplicates; return unique orderings. [1,1,2] gives [[1,1,2],[1,2,1],[2,1,1]].
Key insight: sort, then treat equal values as if they must be used left to right. Skip nums[i] if its equal left twin is not currently used — that means the twin was already tried in this same slot and removed.
List<List<Integer>> permuteUnique(int[] nums) {
Arrays.sort(nums);
List<List<Integer>> result = new ArrayList<>();
permDup(nums, new boolean[nums.length], new ArrayList<>(), result);
return result;
}
private void permDup(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> result) {
if (path.size() == nums.length) {
result.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
if (used[i]) continue;
// equal twin to my left is NOT in use: it was tried at this slot already
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) continue;
used[i] = true;
path.add(nums[i]);
permDup(nums, used, path, result);
path.remove(path.size() - 1);
used[i] = false;
}
}
Time O(n · n!), Space O(n).
Combination Sum — LC 39 (Medium)
Distinct candidates, each usable any number of times. Return all combinations that sum to target. [2,3,6,7], target 7 gives [[2,2,3],[7]].
Key insight: recurse with i (not i + 1) so the same number can be picked again, but never an earlier one. Sorting lets you break as soon as a candidate is bigger than what's left.
List<List<Integer>> combinationSum(int[] candidates, int target) {
Arrays.sort(candidates); // lets us stop early
List<List<Integer>> result = new ArrayList<>();
comb(candidates, target, 0, new ArrayList<>(), result);
return result;
}
private void comb(int[] c, int remaining, int start, List<Integer> path,
List<List<Integer>> result) {
if (remaining == 0) {
result.add(new ArrayList<>(path));
return;
}
for (int i = start; i < c.length; i++) {
if (c[i] > remaining) break; // sorted: everything after is bigger too
path.add(c[i]);
comb(c, remaining - c[i], i, path, result); // i, not i + 1: reuse allowed
path.remove(path.size() - 1);
}
}
Time O(nT/m) roughly (T = target, m = smallest candidate: the tree's depth is at most T/m), Space O(T/m).
Combination Sum II — LC 40 (Medium)
Candidates may repeat; each used at most once; no duplicate combinations. [10,1,2,7,6,1,5], target 8 gives [[1,1,6],[1,2,5],[1,7],[2,6]].
Key insight: it is Subsets II with a target: sort, skip rule, recurse with i + 1.
List<List<Integer>> combinationSum2(int[] candidates, int target) {
Arrays.sort(candidates);
List<List<Integer>> result = new ArrayList<>();
comb2(candidates, target, 0, new ArrayList<>(), result);
return result;
}
private void comb2(int[] c, int remaining, int start, List<Integer> path,
List<List<Integer>> result) {
if (remaining == 0) {
result.add(new ArrayList<>(path));
return;
}
for (int i = start; i < c.length; i++) {
if (i > start && c[i] == c[i - 1]) continue; // skip duplicate at this level
if (c[i] > remaining) break;
path.add(c[i]);
comb2(c, remaining - c[i], i + 1, path, result); // i + 1: each used once
path.remove(path.size() - 1);
}
}
Time O(n · 2n), Space O(n).
Letter Combinations of a Phone Number — LC 17 (Medium)
Map each digit 2–9 to its phone letters and return all strings. "23" gives [ad, ae, af, bd, be, bf, cd, ce, cf].
Key insight: level idx of the tree = digit idx; the choices at that level are its letters. Use a StringBuilder and delete the last char to un-choose.
private static final String[] KEYS =
{"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
List<String> letterCombinations(String digits) {
List<String> result = new ArrayList<>();
if (digits.isEmpty()) return result;
letters(digits, 0, new StringBuilder(), result);
return result;
}
private void letters(String digits, int idx, StringBuilder sb, List<String> result) {
if (idx == digits.length()) {
result.add(sb.toString());
return;
}
for (char ch : KEYS[digits.charAt(idx) - '0'].toCharArray()) {
sb.append(ch);
letters(digits, idx + 1, sb, result);
sb.deleteCharAt(sb.length() - 1);
}
}
Time O(n · 4n), Space O(n).
Generate Parentheses — LC 22 (Medium)
Return all valid strings of n pairs. n = 3 gives [((())), (()()), (())(), ()(()), ()()()].
Key insight: don't generate all 22n strings and filter. Only make moves that keep the string valid: add ( if open < n; add ) only if close < open.
List<String> generateParenthesis(int n) {
List<String> result = new ArrayList<>();
gen(n, 0, 0, new StringBuilder(), result);
return result;
}
private void gen(int n, int open, int close, StringBuilder sb, List<String> result) {
if (sb.length() == 2 * n) {
result.add(sb.toString());
return;
}
if (open < n) { // can still open one
sb.append('(');
gen(n, open + 1, close, sb, result);
sb.deleteCharAt(sb.length() - 1);
}
if (close < open) { // can close only what is open
sb.append(')');
gen(n, open, close + 1, sb, result);
sb.deleteCharAt(sb.length() - 1);
}
}
Time O(4n/√n) (the n-th Catalan number of results), Space O(n).
result.add(path) instead of result.add(new ArrayList<>(path)) — all answers point at the same list, which ends empty. (2) Forgetting to un-choose (remove the last element / reset used[i]). (3) Skip rule written as i > 0 instead of i > start — this wrongly removes answers like [2,2]. (4) Forgetting to sort before applying the skip rule. (5) Using i + 1 when reuse is allowed (Combination Sum) or i when it isn't. (6) Using start for permutations — you'd only get one ordering.Practice list
- LC 784 Letter Case Permutation (Medium)
- LC 77 Combinations (Medium)
- LC 216 Combination Sum III (Medium)
- LC 1079 Letter Tile Possibilities (Medium)
- LC 131 Palindrome Partitioning (Medium)
- LC 93 Restore IP Addresses (Medium)
- LC 79 Word Search (Medium)
- LC 698 Partition to K Equal Sum Subsets (Medium)
- LC 51 N-Queens (Hard)
- LC 37 Sudoku Solver (Hard)
42. Modified Binary Search
Plain binary search (section 20) finds a value in a sorted array by halving the range each step. Modified binary search uses the same halving on things that are not obviously sorted: a sorted array that was rotated, a 2D matrix, two arrays at once, or even a range of possible answers.
The one question that makes it work: "Looking at mid, can I tell which half cannot contain the answer?" If yes, throw that half away — that is binary search, whatever the data looks like.
Finding a page in a book whose first chapters were torn off and stapled to the back. You can't just compare page numbers blindly — but whenever you open it, you can see whether the pages from here to the end run in order. That tells you which side to search.
How to recognise it
- "Sorted" + "rotated", "sorted matrix", "two sorted arrays".
- "Find first / last position", "first bad version", "smallest index such that ..." — a boundary search.
- "Must run in O(log n)" — almost always binary search.
- "Minimum capacity / speed / largest minimum / smallest maximum such that it's possible" — binary search on the answer: you can't compute the answer directly, but you can quickly check a guess, and bigger guesses are always at least as feasible.
- "Peak element", "mountain array" — the slope at
midtells you the side.
How it works
Rotated sorted array. [4,5,6,7,0,1,2] is a sorted array rotated at index 4. Cut it anywhere, and at least one half is fully sorted. Compare nums[lo] with nums[mid] to find which one, then check whether the target lies inside that sorted half's range. If it does, go there; if not, go to the other half.
The general boundary template. Most modified searches reduce to "find the first index where a yes/no condition becomes true" over a range that looks like no no no YES YES YES. With while (lo < hi), hi = mid on yes and lo = mid + 1 on no, the loop always ends with lo == hi sitting on the first yes — no off-by-one guessing.
Java template
// Find the FIRST index where condition(i) is true.
// Works whenever condition looks like: false false false TRUE TRUE TRUE
int firstTrue(int lo, int hi) { // search space [lo, hi]
while (lo < hi) {
int mid = lo + (hi - lo) / 2; // no overflow; leans left
if (condition(mid)) hi = mid; // mid might be the answer: keep it
else lo = mid + 1; // mid is definitely not: drop it
}
return lo; // lo == hi: the boundary
}
// CHANGES per problem: the search space [lo, hi] and condition(mid)
| Problem | Search space | condition(mid) |
|---|---|---|
| First position of target | indices [0, n] | nums[mid] >= target |
| Minimum of rotated array | indices [0, n-1] | nums[mid] <= nums[n-1] (mid is in the right, lower part) |
| Ship packages / Koko bananas | answers [max item, sum] | feasible(mid) |
| Peak element | indices [0, n-1] | nums[mid] > nums[mid + 1] (going downhill) |
Classic problems
Search in Rotated Sorted Array — LC 33 (Medium)
Distinct values, rotated at an unknown pivot. Return the index of target or -1. [4,5,6,7,0,1,2], target 0 gives 4; target 3 gives -1.
Key insight: find the sorted half, check if the target is inside it.
int search(int[] nums, int target) {
int lo = 0, hi = nums.length - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
if (nums[mid] == target) return mid;
if (nums[lo] <= nums[mid]) { // left half is sorted
if (nums[lo] <= target && target < nums[mid]) hi = mid - 1;
else lo = mid + 1;
} else { // right half is sorted
if (nums[mid] < target && target <= nums[hi]) lo = mid + 1;
else hi = mid - 1;
}
}
return -1;
}
Time O(log n), Space O(1). The <= in nums[lo] <= nums[mid] matters when lo == mid (a one-element left half is sorted).
Find Minimum in Rotated Sorted Array — LC 153 (Medium)
[3,4,5,1,2] gives 1; [11,13,15,17] (not rotated) gives 11.
Key insight: compare mid with hi, not lo. If nums[mid] > nums[hi], the drop (and the minimum) is strictly right of mid. Otherwise mid..hi is sorted and the minimum is mid or to its left.
int findMin(int[] nums) {
int lo = 0, hi = nums.length - 1;
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (nums[mid] > nums[hi]) lo = mid + 1; // drop is to the right of mid
else hi = mid; // mid..hi sorted: min is mid or left
}
return nums[lo];
}
Time O(log n), Space O(1). With duplicates (LC 154) add: if nums[mid] == nums[hi], do hi-- — worst case becomes O(n).
Search a 2D Matrix — LC 74 (Medium)
Each row sorted, and each row's first value is bigger than the previous row's last. [[1,3,5,7],[10,11,16,20],[23,30,34,60]]: target 3 gives true, 13 gives false.
Key insight: read row by row, it is one sorted array of rows * cols values. Binary search a flat index and convert: row = idx / cols, col = idx % cols.
boolean searchMatrix(int[][] matrix, int target) {
int rows = matrix.length, cols = matrix[0].length;
int lo = 0, hi = rows * cols - 1; // pretend it is one flat array
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
int val = matrix[mid / cols][mid % cols]; // flat index -> (row, col)
if (val == target) return true;
if (val < target) lo = mid + 1;
else hi = mid - 1;
}
return false;
}
Time O(log(m·n)), Space O(1).
Search a 2D Matrix II — LC 240 (Medium)
Rows sorted left to right, columns sorted top to bottom, but rows do not chain. Target 5 in the 5x5 example gives true, 20 gives false.
Key insight: start at the top-right corner. Everything left of it is smaller and everything below is bigger, so each comparison deletes a whole row or column — a "staircase" search. (Top-left doesn't work: both moves make it bigger.)
boolean searchMatrix2(int[][] matrix, int target) {
int r = 0, c = matrix[0].length - 1; // start top-right
while (r < matrix.length && c >= 0) {
int val = matrix[r][c];
if (val == target) return true;
if (val > target) c--; // whole column below is bigger: drop it
else r++; // whole row to the left is smaller: drop it
}
return false;
}
Time O(m + n), Space O(1).
Find First and Last Position of Element in Sorted Array — LC 34 (Medium)
[5,7,7,8,8,10], target 8 gives [3,4]; target 6 gives [-1,-1].
Key insight: two boundary searches. First = first index with value >= target. Last = (first index with value >= target + 1) - 1. One helper, called twice.
int[] searchRange(int[] nums, int target) {
int first = lowerBound(nums, target);
if (first == nums.length || nums[first] != target) return new int[]{-1, -1};
int last = lowerBound(nums, target + 1) - 1; // first index of anything bigger, minus 1
return new int[]{first, last};
}
// first index with nums[i] >= target (nums.length if none)
private int lowerBound(int[] nums, int target) {
int lo = 0, hi = nums.length; // hi = n: "not found" is a valid answer
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (nums[mid] >= target) hi = mid;
else lo = mid + 1;
}
return lo;
}
Time O(log n), Space O(1). Note hi = nums.length: "target is bigger than everything" must be a possible answer.
Median of Two Sorted Arrays — LC 4 (Hard)
Find the median of two sorted arrays in O(log(min(m, n))). [1,3], [2] gives 2.0; [1,2], [3,4] gives 2.5.
Key insight: binary search the cut, not a value. Take i items from a and j = half - i from b for the left half. The cut is right when everything on the left <= everything on the right, i.e. aLeft <= bRight and bLeft <= aRight. If aLeft > bRight, you took too many from a (move left); otherwise too few (move right).
double findMedianSortedArrays(int[] a, int[] b) {
if (a.length > b.length) return findMedianSortedArrays(b, a); // search the shorter one
int m = a.length, n = b.length;
int half = (m + n + 1) / 2; // size of the left part
int lo = 0, hi = m;
while (lo <= hi) {
int i = (lo + hi) / 2; // take i from a ...
int j = half - i; // ... and j from b
int aLeft = i == 0 ? Integer.MIN_VALUE : a[i - 1];
int aRight = i == m ? Integer.MAX_VALUE : a[i];
int bLeft = j == 0 ? Integer.MIN_VALUE : b[j - 1];
int bRight = j == n ? Integer.MAX_VALUE : b[j];
if (aLeft <= bRight && bLeft <= aRight) { // valid cut
if ((m + n) % 2 == 1) return Math.max(aLeft, bLeft);
return (Math.max(aLeft, bLeft) + (double) Math.min(aRight, bRight)) / 2.0;
} else if (aLeft > bRight) {
hi = i - 1; // took too many from a
} else {
lo = i + 1; // took too few from a
}
}
throw new IllegalArgumentException("inputs not sorted");
}
Time O(log(min(m, n))), Space O(1). Searching the shorter array keeps j in range.
Capacity To Ship Packages Within D Days — LC 1011 (Medium)
Ship packages in order, one day at a time, never over capacity. Find the smallest capacity that finishes within days. [1..10], days = 5 gives 15.
Key insight (binary search on the answer): the answer lies between max(weights) (must carry the heaviest) and sum(weights) (one day). Checking one capacity is a simple greedy O(n) pass, and if capacity c works, every bigger one works — a no no YES YES range. Find the first yes.
int shipWithinDays(int[] weights, int days) {
int lo = 0, hi = 0;
for (int w : weights) {
lo = Math.max(lo, w); // must fit the heaviest package
hi += w; // one day for everything
}
while (lo < hi) { // first capacity that works
int mid = lo + (hi - lo) / 2;
if (daysNeeded(weights, mid) <= days) hi = mid;
else lo = mid + 1;
}
return lo;
}
private int daysNeeded(int[] weights, int cap) {
int days = 1, load = 0;
for (int w : weights) {
if (load + w > cap) { days++; load = 0; } // start a new day
load += w;
}
return days;
}
Time O(n log(sum)), Space O(1). Same shape: LC 875 Koko Eating Bananas, LC 410 Split Array Largest Sum, LC 1482 Minimum Days to Make m Bouquets.
mid = (lo + hi) / 2 overflows when both are near Integer.MAX_VALUE; use lo + (hi - lo) / 2. (2) Mixing templates: while (lo <= hi) goes with hi = mid - 1; while (lo < hi) goes with hi = mid. Mixing them causes infinite loops. (3) With lo = mid (searching for the last true), use an upper mid lo + (hi - lo + 1) / 2, or the loop never ends. (4) In rotated arrays, comparing with the wrong end or using < where <= is needed. (5) In "search on answer", setting lo = 0 or 1 instead of max(weights) — the feasibility check then has to handle impossible capacities.Practice list
- LC 704 Binary Search (Easy)
- LC 35 Search Insert Position (Easy)
- LC 278 First Bad Version (Easy)
- LC 162 Find Peak Element (Medium)
- LC 540 Single Element in a Sorted Array (Medium)
- LC 81 Search in Rotated Sorted Array II (Medium)
- LC 875 Koko Eating Bananas (Medium)
- LC 1482 Minimum Number of Days to Make m Bouquets (Medium)
- LC 154 Find Minimum in Rotated Sorted Array II (Hard)
- LC 410 Split Array Largest Sum (Hard)
43. Topological Sort
A topological order is a line-up of tasks where every task comes after all the tasks it depends on. The dependencies form a directed graph: an edge A → B means "A must happen before B". An order exists only if there is no cycle (a graph with no cycles is a DAG, directed acyclic graph).
Kahn's algorithm builds the order with a queue. A node's indegree is the number of arrows pointing into it — the number of things still blocking it. Start with every node whose indegree is 0 (nothing blocks it). Each time you "do" a node, remove its outgoing arrows; any node whose indegree drops to 0 is now free and joins the queue.
Getting dressed. Socks before shoes, underwear before trousers, trousers before shoes. At any moment you can put on anything whose "before" items are all already on. If "shoes before socks" and "socks before shoes" were both rules, you could never finish — that's a cycle.
How to recognise it
- "Prerequisites", "dependencies", "must be done before", "build order", "task scheduling", "compile order".
- "Is it possible to finish all ...?" — cycle detection in a directed graph.
- "Return an order / any valid order".
- "Derive the order of letters / ranks from sorted examples" (alien dictionary, sequence reconstruction).
- "Minimum number of semesters / rounds" when things in the same round can run in parallel — BFS by levels.
- Peeling a tree or graph from the outside in (leaves first) — same indegree/degree idea.
How it works
Cycle detection for free: nodes on a cycle wait for each other forever — their indegree never reaches 0, so they never enter the queue. If the output has fewer than n nodes, there is a cycle. With edges 0→1, 1→2, 2→0 no node starts at indegree 0 and the result is empty.
The DFS alternative: run DFS and add each node to a list after all its descendants finish (post-order), then reverse the list. Detect cycles with three colours (unvisited / in progress / done): meeting an "in progress" node means a back edge, i.e. a cycle. Kahn's is usually easier to get right under pressure. More theory in section 27.
Java template
// n nodes 0..n-1, edges[i] = {from, to} meaning "from must come before to"
List<Integer> topoSort(int n, int[][] edges) {
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
int[] indegree = new int[n];
for (int[] e : edges) {
graph.get(e[0]).add(e[1]);
indegree[e[1]]++; // one more thing must happen before e[1]
}
Deque<Integer> queue = new ArrayDeque<>();
for (int i = 0; i < n; i++)
if (indegree[i] == 0) queue.offer(i); // nothing blocks these: start here
List<Integer> order = new ArrayList<>();
while (!queue.isEmpty()) {
int node = queue.poll();
order.add(node); // CHANGES: what you record per node
for (int next : graph.get(node))
if (--indegree[next] == 0) // last blocker removed
queue.offer(next);
}
return order.size() == n ? order : List.of(); // fewer than n: there is a cycle
}
Classic problems
Course Schedule — LC 207 (Medium)
numCourses courses, prerequisites[i] = [a, b] means "take b before a". Can you finish all? 2, [[1,0]] gives true; 2, [[1,0],[0,1]] gives false.
Key insight: "can finish" = "no cycle" = Kahn's processes all n nodes. Watch the edge direction: the pair is [course, prereq], so the edge is prereq → course.
boolean canFinish(int numCourses, int[][] prerequisites) {
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < numCourses; i++) graph.add(new ArrayList<>());
int[] indegree = new int[numCourses];
for (int[] p : prerequisites) { // p = {course, prereq}: prereq -> course
graph.get(p[1]).add(p[0]);
indegree[p[0]]++;
}
Deque<Integer> queue = new ArrayDeque<>();
for (int i = 0; i < numCourses; i++) if (indegree[i] == 0) queue.offer(i);
int taken = 0;
while (!queue.isEmpty()) {
int c = queue.poll();
taken++;
for (int next : graph.get(c))
if (--indegree[next] == 0) queue.offer(next);
}
return taken == numCourses; // stuck courses sit on a cycle
}
Time O(V + E), Space O(V + E).
Course Schedule II — LC 210 (Medium)
Return a valid order, or an empty array if impossible. 4, [[1,0],[2,0],[3,1],[3,2]] gives [0,1,2,3] ([0,2,1,3] is also valid).
Key insight: the same code; record the poll order.
int[] findOrder(int numCourses, int[][] prerequisites) {
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < numCourses; i++) graph.add(new ArrayList<>());
int[] indegree = new int[numCourses];
for (int[] p : prerequisites) {
graph.get(p[1]).add(p[0]);
indegree[p[0]]++;
}
Deque<Integer> queue = new ArrayDeque<>();
for (int i = 0; i < numCourses; i++) if (indegree[i] == 0) queue.offer(i);
int[] order = new int[numCourses];
int idx = 0;
while (!queue.isEmpty()) {
int c = queue.poll();
order[idx++] = c;
for (int next : graph.get(c))
if (--indegree[next] == 0) queue.offer(next);
}
return idx == numCourses ? order : new int[0];
}
Time O(V + E), Space O(V + E).
Alien Dictionary — LC 269 (Hard)
Words are sorted in an unknown alphabet. Return any valid letter order, or "" if none. ["wrt","wrf","er","ett","rftt"] gives "wertf".
Key insight: compare each pair of neighbouring words. The first differing letter gives one edge (t → f from "wrt" / "wrf"); letters after it tell you nothing. Then topological sort the letters. Two traps: a word followed by its own prefix ("abc" before "ab") is invalid, and every letter must be a node even if it has no edges.
String alienOrder(String[] words) {
Map<Character, Set<Character>> graph = new HashMap<>();
Map<Character, Integer> indegree = new HashMap<>();
for (String w : words)
for (char c : w.toCharArray()) { // every letter is a node, even with no edges
graph.putIfAbsent(c, new HashSet<>());
indegree.putIfAbsent(c, 0);
}
for (int i = 0; i + 1 < words.length; i++) {
String a = words[i], b = words[i + 1];
if (a.length() > b.length() && a.startsWith(b)) return ""; // "abc" before "ab": invalid
for (int j = 0; j < Math.min(a.length(), b.length()); j++) {
char x = a.charAt(j), y = b.charAt(j);
if (x != y) { // first difference is the ONLY clue
if (graph.get(x).add(y)) // avoid counting the same edge twice
indegree.merge(y, 1, Integer::sum);
break;
}
}
}
Deque<Character> queue = new ArrayDeque<>();
for (var e : indegree.entrySet()) if (e.getValue() == 0) queue.offer(e.getKey());
StringBuilder sb = new StringBuilder();
while (!queue.isEmpty()) {
char c = queue.poll();
sb.append(c);
for (char next : graph.get(c))
if (indegree.merge(next, -1, Integer::sum) == 0) queue.offer(next);
}
return sb.length() == indegree.size() ? sb.toString() : ""; // cycle: no valid order
}
Time O(C) where C is the total number of characters, Space O(U + edges) with U unique letters (at most 26). ["z","x","z"] gives "" (cycle).
Parallel Courses — LC 1136 (Medium)
Courses 1..n, relations[i] = [prev, next]. Any number of courses per semester. Minimum semesters, or -1 if impossible. 3, [[1,3],[2,3]] gives 2.
Key insight: Kahn's by levels — use the same size snapshot as tree BFS. Each level is one semester: everything currently free can be taken together.
int minimumSemesters(int n, int[][] relations) { // courses are 1..n
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i <= n; i++) graph.add(new ArrayList<>());
int[] indegree = new int[n + 1];
for (int[] r : relations) { // r = {prev, next}
graph.get(r[0]).add(r[1]);
indegree[r[1]]++;
}
Deque<Integer> queue = new ArrayDeque<>();
for (int i = 1; i <= n; i++) if (indegree[i] == 0) queue.offer(i);
int semesters = 0, taken = 0;
while (!queue.isEmpty()) {
int size = queue.size(); // all courses available THIS semester
semesters++;
for (int k = 0; k < size; k++) {
int c = queue.poll();
taken++;
for (int next : graph.get(c))
if (--indegree[next] == 0) queue.offer(next);
}
}
return taken == n ? semesters : -1;
}
Time O(V + E), Space O(V + E). The answer is the length of the longest path in the DAG.
Minimum Height Trees — LC 310 (Medium)
An undirected tree of n nodes. Return every root that gives the minimum height. n = 6, [[3,0],[3,1],[3,2],[3,4],[5,4]] gives [3,4].
Key insight: the best roots are the centre of the longest path. Find it by peeling leaves (degree 1) layer by layer, like Kahn's from the outside in. When 1 or 2 nodes remain, those are the centres.
List<Integer> findMinHeightTrees(int n, int[][] edges) {
if (n == 1) return List.of(0);
List<Set<Integer>> graph = new ArrayList<>();
for (int i = 0; i < n; i++) graph.add(new HashSet<>());
int[] degree = new int[n];
for (int[] e : edges) {
graph.get(e[0]).add(e[1]);
graph.get(e[1]).add(e[0]);
degree[e[0]]++;
degree[e[1]]++;
}
Deque<Integer> leaves = new ArrayDeque<>();
for (int i = 0; i < n; i++) if (degree[i] == 1) leaves.offer(i);
int remaining = n;
while (remaining > 2) { // peel leaves layer by layer
int size = leaves.size();
remaining -= size;
for (int k = 0; k < size; k++) {
int leaf = leaves.poll();
for (int nb : graph.get(leaf))
if (--degree[nb] == 1) leaves.offer(nb);
}
}
return new ArrayList<>(leaves); // 1 or 2 centres left
}
Time O(n), Space O(n). Brute force (BFS from every node) is O(n²).
[a, b] = "b before a", the edge is b → a and indegree[a]++. (2) Forgetting nodes with no edges (they must still appear in the order). (3) Returning the partial order when there is a cycle instead of checking count == n. (4) In alien dictionary, adding an edge for every differing position rather than only the first, or counting a repeated edge twice in the indegree. (5) Using plain visited-set DFS for cycle detection in a directed graph — you need three states, because reaching a "done" node again is not a cycle.Practice list
- LC 1557 Minimum Number of Vertices to Reach All Nodes (Medium)
- LC 802 Find Eventual Safe States (Medium)
- LC 1462 Course Schedule IV (Medium)
- LC 2115 Find All Possible Recipes from Given Supplies (Medium)
- LC 444 Sequence Reconstruction (Medium) — unique order means the queue never holds 2 items
- LC 2050 Parallel Courses III (Hard)
- LC 329 Longest Increasing Path in a Matrix (Hard)
- LC 1203 Sort Items by Groups Respecting Dependencies (Hard)
44. Matrix & Islands
A 2D grid is a graph in disguise. Each cell is a node, and it is connected to its 4 neighbours (up, down, left, right — sometimes 8 with diagonals). You never build an adjacency list: the neighbours are computed on the fly with a small direction array. Then every graph tool works: DFS to explore a region, BFS for shortest steps, multi-source BFS for "spreading" problems.
A second family is pure matrix manipulation — rotate, spiral, set zeroes — where the trick is careful index arithmetic, not graph search.
Spilling paint on a tiled floor: it spreads to every touching tile of the same kind and stops at walls. Counting islands is "how many times did I have to pour paint to cover all the land?".
How to recognise it
- Input is a
char[][]/int[][]of land/water, walls/open, colours, heights. - "Number of islands / regions / provinces", "size of the largest region", "fill a region" — DFS/BFS flood fill.
- "Minimum steps / minutes to reach / to spread" — BFS (each layer = one step).
- "Spreads from all sources at once" (rotting oranges, distance to nearest 0, walls and gates) — multi-source BFS: put every source in the queue at the start.
- "Cells connected to the border", "can reach the ocean", "enclosed / surrounded" — boundary trick: start from the edges and mark what is reachable, then everything else is the answer.
- "Rotate", "spiral", "in place", "transpose" — matrix index manipulation.
How it works
DFS vs BFS on a grid. Both visit each cell once: O(rows × cols). Use DFS (shorter code) for "explore / count / measure a region". Use BFS when you need the fewest steps — BFS reaches cells in order of distance. On a 1000×1000 grid of all land, recursive DFS can go 106 calls deep and overflow the stack; BFS (or an explicit stack) is safe.
Multi-source BFS. Put all starting cells in the queue before the loop. The BFS layers then grow outward from every source at once, and each cell's layer number is its distance to the nearest source. Rotting oranges trace:
Boundary trick. "Which cells are not connected to the border?" is hard to ask directly. Flip it: DFS from every border cell and mark what it reaches; whatever is left unmarked is enclosed.
Marking visited. Either overwrite the grid ('1' → '0', 1 → 2) — O(1) extra space, but it destroys the input (ask first) — or keep a boolean[][] seen. In BFS, mark a cell when you add it to the queue, not when you poll it, or the same cell is queued many times.
Java template
private static final int[][] DIRS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; // down, up, right, left
// DFS flood fill: visit every cell connected to (r, c)
void dfs(char[][] grid, int r, int c) {
if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length) return; // off the grid
if (grid[r][c] != '1') return; // CHANGES: "not part of the region"
grid[r][c] = '#'; // mark visited (or use boolean[][] seen)
for (int[] d : DIRS) dfs(grid, r + d[0], c + d[1]);
}
// BFS: same idea, but layer by layer -> fewest steps from start to target
int bfs(int[][] grid, int sr, int sc, int tr, int tc) {
int rows = grid.length, cols = grid[0].length;
boolean[][] seen = new boolean[rows][cols];
Deque<int[]> queue = new ArrayDeque<>();
queue.offer(new int[]{sr, sc}); // CHANGES: multi-source = offer ALL starts here
seen[sr][sc] = true;
int steps = 0;
while (!queue.isEmpty()) {
for (int size = queue.size(); size > 0; size--) { // one layer = one step
int[] cur = queue.poll();
if (cur[0] == tr && cur[1] == tc) return steps; // CHANGES: goal test
for (int[] d : DIRS) {
int nr = cur[0] + d[0], nc = cur[1] + d[1];
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
if (seen[nr][nc] || grid[nr][nc] == 1) continue; // CHANGES: walls
seen[nr][nc] = true; // mark when ADDING, not when polling
queue.offer(new int[]{nr, nc});
}
}
steps++;
}
return -1; // target unreachable
}
Classic problems
Number of Islands — LC 200 (Medium)
Count groups of '1' connected up/down/left/right. The grid in the diagram gives 3.
Key insight: scan; every '1' you meet is a new island — count it and sink it.
int numIslands(char[][] grid) {
int count = 0;
for (int r = 0; r < grid.length; r++)
for (int c = 0; c < grid[0].length; c++)
if (grid[r][c] == '1') {
count++; // new island found
sink(grid, r, c); // erase it so we never count it again
}
return count;
}
private void sink(char[][] grid, int r, int c) {
if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] != '1') return;
grid[r][c] = '0';
for (int[] d : DIRS) sink(grid, r + d[0], c + d[1]);
}
Time O(m·n), Space O(m·n) worst-case recursion. Union-find (section 17) also works and handles "land added over time" (LC 305).
Max Area of Island — LC 695 (Medium)
Return the size of the largest island (0 if none). The LeetCode example grid gives 6.
Key insight: bottom-up DFS: a cell's area = 1 + the areas its neighbours return.
int maxAreaOfIsland(int[][] grid) {
int best = 0;
for (int r = 0; r < grid.length; r++)
for (int c = 0; c < grid[0].length; c++)
best = Math.max(best, area(grid, r, c));
return best;
}
private int area(int[][] grid, int r, int c) {
if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] != 1) return 0;
grid[r][c] = 0;
int size = 1; // bottom-up: this cell + what neighbours return
for (int[] d : DIRS) size += area(grid, r + d[0], c + d[1]);
return size;
}
Time O(m·n), Space O(m·n).
Flood Fill — LC 733 (Easy)
Recolour the region containing (sr, sc). [[1,1,1],[1,1,0],[1,0,1]], sr=1, sc=1, color=2 gives [[2,2,2],[2,2,0],[2,0,1]].
Key insight: the "paint bucket" DFS. If the new colour equals the old one, return immediately — otherwise painted cells still match "old" and the DFS never stops.
int[][] floodFill(int[][] image, int sr, int sc, int color) {
int old = image[sr][sc];
if (old != color) paint(image, sr, sc, old, color); // same colour: would loop forever
return image;
}
private void paint(int[][] img, int r, int c, int old, int color) {
if (r < 0 || r >= img.length || c < 0 || c >= img[0].length || img[r][c] != old) return;
img[r][c] = color;
for (int[] d : DIRS) paint(img, r + d[0], c + d[1], old, color);
}
Time O(m·n), Space O(m·n).
Surrounded Regions — LC 130 (Medium)
Flip every 'O' region that is completely surrounded by 'X'. ["XXXX","XOOX","XXOX","XOXX"] gives ["XXXX","XXXX","XXXX","XOXX"] — the bottom O touches the border and survives.
Key insight: boundary trick. Mark every O reachable from the border as safe; flip the rest.
void solve(char[][] board) {
int rows = board.length, cols = board[0].length;
// 1. Every 'O' reachable from the border is SAFE: mark it 'S'
for (int r = 0; r < rows; r++) { keep(board, r, 0); keep(board, r, cols - 1); }
for (int c = 0; c < cols; c++) { keep(board, 0, c); keep(board, rows - 1, c); }
// 2. Remaining 'O' are surrounded -> 'X'; restore safe ones -> 'O'
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
board[r][c] = board[r][c] == 'S' ? 'O' : 'X';
}
private void keep(char[][] b, int r, int c) {
if (r < 0 || r >= b.length || c < 0 || c >= b[0].length || b[r][c] != 'O') return;
b[r][c] = 'S';
for (int[] d : DIRS) keep(b, r + d[0], c + d[1]);
}
Time O(m·n), Space O(m·n).
Pacific Atlantic Water Flow — LC 417 (Medium)
Water flows to a neighbour of equal or lower height. The Pacific touches the top and left edges, the Atlantic the bottom and right. Return cells that can reach both. The 5x5 example gives [[0,4],[1,3],[1,4],[2,2],[3,0],[3,1],[4,0]].
Key insight: running DFS from every cell is O((m·n)²). Reverse the flow: start at each ocean's edge and walk uphill (to neighbours of equal or greater height). Two passes, two seen grids; answer = cells marked in both.
List<List<Integer>> pacificAtlantic(int[][] heights) {
int rows = heights.length, cols = heights[0].length;
boolean[][] pac = new boolean[rows][cols], atl = new boolean[rows][cols];
// Walk UPHILL from each ocean's edge (reverse of how water flows)
for (int r = 0; r < rows; r++) {
climb(heights, pac, r, 0, Integer.MIN_VALUE);
climb(heights, atl, r, cols - 1, Integer.MIN_VALUE);
}
for (int c = 0; c < cols; c++) {
climb(heights, pac, 0, c, Integer.MIN_VALUE);
climb(heights, atl, rows - 1, c, Integer.MIN_VALUE);
}
List<List<Integer>> result = new ArrayList<>();
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
if (pac[r][c] && atl[r][c]) result.add(List.of(r, c));
return result;
}
private void climb(int[][] h, boolean[][] seen, int r, int c, int prevHeight) {
if (r < 0 || r >= h.length || c < 0 || c >= h[0].length) return;
if (seen[r][c] || h[r][c] < prevHeight) return; // water cannot flow up to prev
seen[r][c] = true;
for (int[] d : DIRS) climb(h, seen, r + d[0], c + d[1], h[r][c]);
}
Time O(m·n), Space O(m·n).
Rotting Oranges — LC 994 (Medium)
0 empty, 1 fresh, 2 rotten. Each minute, rotten oranges rot their 4 neighbours. Minutes until none are fresh, or -1. [[2,1,1],[1,1,0],[0,1,1]] gives 4; [[2,1,1],[0,1,1],[1,0,1]] gives -1; [[0,2]] gives 0.
Key insight: multi-source BFS — all rotten oranges start together, one layer per minute. Count fresh oranges up front to know when to stop and whether any are unreachable.
int orangesRotting(int[][] grid) {
int rows = grid.length, cols = grid[0].length, fresh = 0;
Deque<int[]> queue = new ArrayDeque<>();
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++) {
if (grid[r][c] == 2) queue.offer(new int[]{r, c}); // ALL rotten start together
else if (grid[r][c] == 1) fresh++;
}
int minutes = 0;
while (!queue.isEmpty() && fresh > 0) {
for (int size = queue.size(); size > 0; size--) {
int[] cur = queue.poll();
for (int[] d : DIRS) {
int nr = cur[0] + d[0], nc = cur[1] + d[1];
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols || grid[nr][nc] != 1) continue;
grid[nr][nc] = 2; // rots now: this is also our "visited" mark
fresh--;
queue.offer(new int[]{nr, nc});
}
}
minutes++; // one BFS layer = one minute
}
return fresh == 0 ? minutes : -1;
}
Time O(m·n), Space O(m·n). The fresh > 0 check in the loop stops an extra, empty minute from being counted.
Shortest Path in Binary Matrix — LC 1091 (Medium)
Shortest path of 0-cells from top-left to bottom-right, moving in 8 directions; length counts cells. [[0,0,0],[1,1,0],[1,1,0]] gives 4; [[0,1],[1,0]] gives 2.
Key insight: unweighted shortest path = BFS. The first time you poll the target, its layer is the answer.
int shortestPathBinaryMatrix(int[][] grid) {
int n = grid.length;
if (grid[0][0] == 1 || grid[n - 1][n - 1] == 1) return -1;
Deque<int[]> queue = new ArrayDeque<>();
queue.offer(new int[]{0, 0});
grid[0][0] = 1; // mark visited by blocking it
int length = 1; // path length counts CELLS
while (!queue.isEmpty()) {
for (int size = queue.size(); size > 0; size--) {
int[] cur = queue.poll();
if (cur[0] == n - 1 && cur[1] == n - 1) return length;
for (int dr = -1; dr <= 1; dr++) // 8 directions
for (int dc = -1; dc <= 1; dc++) {
int nr = cur[0] + dr, nc = cur[1] + dc;
if (nr < 0 || nr >= n || nc < 0 || nc >= n || grid[nr][nc] != 0) continue;
grid[nr][nc] = 1;
queue.offer(new int[]{nr, nc});
}
}
length++;
}
return -1;
}
Time O(n²), Space O(n²).
Matrix manipulation
Rotate Image — LC 48 (Medium)
Rotate an n×n matrix 90° clockwise, in place. [[1,2,3],[4,5,6],[7,8,9]] gives [[7,4,1],[8,5,2],[9,6,3]].
Key insight: clockwise rotation = transpose (swap m[i][j] and m[j][i]) then reverse each row. (Counter-clockwise: transpose, then reverse each column.)
void rotate(int[][] m) {
int n = m.length;
for (int i = 0; i < n; i++) // 1. transpose: swap across the main diagonal
for (int j = i + 1; j < n; j++) {
int t = m[i][j]; m[i][j] = m[j][i]; m[j][i] = t;
}
for (int[] row : m) // 2. reverse each row
for (int l = 0, r = n - 1; l < r; l++, r--) {
int t = row[l]; row[l] = row[r]; row[r] = t;
}
}
Time O(n²), Space O(1).
Spiral Matrix — LC 54 (Medium)
Return all elements in spiral order. [[1,2,3,4],[5,6,7,8],[9,10,11,12]] gives [1,2,3,4,8,12,11,10,9,5,6,7].
Key insight: four walls — top, bottom, left, right. Walk one side, move that wall inward. Before walking the bottom row and left column, re-check that a row/column is still left, or a single row or column gets read twice.
List<Integer> spiralOrder(int[][] m) {
List<Integer> out = new ArrayList<>();
int top = 0, bottom = m.length - 1, left = 0, right = m[0].length - 1;
while (top <= bottom && left <= right) {
for (int c = left; c <= right; c++) out.add(m[top][c]); // right along top
top++;
for (int r = top; r <= bottom; r++) out.add(m[r][right]); // down right side
right--;
if (top <= bottom) { // row still left?
for (int c = right; c >= left; c--) out.add(m[bottom][c]); // left along bottom
bottom--;
}
if (left <= right) { // column still left?
for (int r = bottom; r >= top; r--) out.add(m[r][left]); // up left side
left++;
}
}
return out;
}
Time O(m·n), Space O(1) besides the output. [[1],[2],[3]] gives [1,2,3] — the re-checks are what make this single column work.
Set Matrix Zeroes — LC 73 (Medium)
If a cell is 0, set its whole row and column to 0, in place. [[0,1,2,0],[3,4,5,2],[1,3,1,5]] gives [[0,0,0,0],[0,4,5,0],[0,3,1,0]].
Key insight: zeroing immediately spreads fake zeros. Record first, apply later. For O(1) space, use row 0 and column 0 as the notebook, with two booleans remembering whether row 0 and column 0 themselves need zeroing; apply those last.
void setZeroes(int[][] m) {
int rows = m.length, cols = m[0].length;
boolean firstRowZero = false, firstColZero = false;
for (int c = 0; c < cols; c++) if (m[0][c] == 0) firstRowZero = true;
for (int r = 0; r < rows; r++) if (m[r][0] == 0) firstColZero = true;
// use row 0 and column 0 as the "marker" arrays: O(1) extra space
for (int r = 1; r < rows; r++)
for (int c = 1; c < cols; c++)
if (m[r][c] == 0) { m[r][0] = 0; m[0][c] = 0; }
for (int r = 1; r < rows; r++)
for (int c = 1; c < cols; c++)
if (m[r][0] == 0 || m[0][c] == 0) m[r][c] = 0;
if (firstRowZero) for (int c = 0; c < cols; c++) m[0][c] = 0; // markers last
if (firstColZero) for (int r = 0; r < rows; r++) m[r][0] = 0;
}
Time O(m·n), Space O(1).
r < 0 || r >= rows ... before reading grid[r][c]. (2) Mixing up rows and columns: grid.length is rows, grid[0].length is columns; non-square grids expose this. (3) Marking visited on poll instead of on push in BFS — cells get queued many times (can time out). (4) Forgetting to count BFS layers with a size snapshot, so "minutes" or "steps" come out wrong. (5) Comparing char grids with 1 instead of '1'. (6) Modifying the input without saying so — offer a seen array if the interviewer wants the grid intact.Practice list
- LC 463 Island Perimeter (Easy)
- LC 1020 Number of Enclaves (Medium) — boundary trick
- LC 1254 Number of Closed Islands (Medium)
- LC 542 01 Matrix (Medium) — multi-source BFS from all zeros
- LC 286 Walls and Gates (Medium)
- LC 934 Shortest Bridge (Medium) — DFS one island, BFS to the other
- LC 59 Spiral Matrix II (Medium)
- LC 36 Valid Sudoku (Medium)
- LC 827 Making A Large Island (Hard)
- LC 1293 Shortest Path in a Grid with Obstacles Elimination (Hard) — BFS over (row, col, removals left)
45. Design Problems
"Design a data structure" questions give you a class with a few methods and a required speed for each — usually O(1) or O(log n). No single built-in structure meets every requirement, so you combine two: one that is fast at finding things (a HashMap) plus one that keeps the order or ranking you need (a linked list, a heap, a TreeMap, an array list).
The recipe: list each operation and its target cost, pick a structure that gives each one, then make sure every structure stays in sync on every write.
A library with a card catalogue and shelves. The catalogue (hash map) tells you exactly where a book is in one step; the shelves (the list) keep books in an order. Neither alone is enough — the catalogue has no order, and scanning shelves is slow.
How to recognise it
- "Implement the
XYZclass" with methods likeget,put,push,getMin,getRandom. - "Each operation must run in O(1) average time" — hash map plus something ordered.
- "Least recently used", "least frequently used", "evict" — cache design.
- "Most recent 10", "news feed", "timeline" — heap merge of per-user lists.
- "Value at a given timestamp", "latest version <= t" —
TreeMap.floorEntryor binary search.
How it works
| You need | Reach for |
|---|---|
| Find by key in O(1) | HashMap |
| Move / remove any known item in O(1), keep an order | Doubly linked list (with the node stored in the map) |
| Pick a random item in O(1) | ArrayList (index access) + map from value to index |
| Min / max so far, with undo | Stack of pairs (value, min at that point) |
| Top k / most recent across many lists | PriorityQueue (k-way merge, section 39) |
| Nearest key <= x or >= x | TreeMap: floorKey, ceilingKey (section 14) |
| Prefix lookups | Trie (section 15) |
Java template
class Design {
// 1. One field per requirement, e.g.
// Map<Key, Node> index; -> O(1) find
// ordering structure -> list / heap / TreeMap
// 2. Every public method:
// a. look up via the map
// b. update the ordering structure
// c. update the map (add / remove) so both agree
// 3. Private helpers for the fiddly pointer work: remove(node), addFirst(node)
}
Classic problems
LRU Cache — LC 146 (Medium)
Fixed capacity. get(key) returns the value or -1; put(key, value) inserts or updates, and when full evicts the least recently used key. Both in O(1).
Key insight: a doubly linked list keeps keys in "recently used" order: the front is most recent, the back is the next to evict. Moving a node to the front, or removing the back, is O(1) — if you can find the node instantly. The HashMap from key to node gives that. Two sentinel nodes (dummy head and tail) remove every null check.
class LRUCache {
private class Node {
int key, val;
Node prev, next;
Node(int k, int v) { key = k; val = v; }
}
private final int capacity;
private final Map<Integer, Node> map = new HashMap<>(); // key -> node: O(1) find
private final Node head = new Node(0, 0); // sentinel: most recent side
private final Node tail = new Node(0, 0); // sentinel: least recent side
public LRUCache(int capacity) {
this.capacity = capacity;
head.next = tail;
tail.prev = head;
}
public int get(int key) {
Node node = map.get(key);
if (node == null) return -1;
remove(node); // touched: move to the front
addFirst(node);
return node.val;
}
public void put(int key, int value) {
Node node = map.get(key);
if (node != null) { // update existing
node.val = value;
remove(node);
addFirst(node);
return;
}
if (map.size() == capacity) { // full: evict least recently used
Node lru = tail.prev;
remove(lru);
map.remove(lru.key); // this is why Node stores its key
}
Node fresh = new Node(key, value);
addFirst(fresh);
map.put(key, fresh);
}
private void remove(Node n) { // unlink: O(1) because we know prev
n.prev.next = n.next;
n.next.prev = n.prev;
}
private void addFirst(Node n) { // insert right after head
n.next = head.next;
n.prev = head;
head.next.prev = n;
head.next = n;
}
}
Capacity 2: put(1,1), put(2,2), get(1) returns 1; put(3,3) evicts key 2; get(2) returns -1; put(4,4) evicts key 1; then get(1), get(3), get(4) return -1, 3, 4. Time O(1) per operation, Space O(capacity).
Why each node stores its key: when you evict tail.prev, you must also delete it from the map — and the map is keyed by key.
The LinkedHashMap shortcut. Java's LinkedHashMap is a hash map plus a doubly linked list. Constructed with accessOrder = true, every get moves the entry to the end; override removeEldestEntry to evict. Mention it — then offer to write the manual version, which is what most interviewers want to see.
class LRUCacheShort extends LinkedHashMap<Integer, Integer> {
private final int capacity;
LRUCacheShort(int capacity) {
super(16, 0.75f, true); // true = ordered by ACCESS, not insertion
this.capacity = capacity;
}
public int get(int key) { return getOrDefault(key, -1); }
public void put(int key, int value) { super.put(key, value); }
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > capacity; // called after every put: evict if too big
}
}
LFU Cache — LC 460 (Hard)
Evict the key used the fewest times; break ties by least recently used. All in O(1).
Key idea: group keys by use count. buckets[f] is an insertion-ordered set of keys used exactly f times (oldest first), and minFreq tracks the smallest non-empty count. Using a key moves it from bucket f to f+1; if bucket f was the minimum and is now empty, minFreq++. A new key always has count 1, so it resets minFreq = 1. Evict the first key in buckets[minFreq].
class LFUCache {
private final int capacity;
private int minFreq = 0;
private final Map<Integer, Integer> vals = new HashMap<>(); // key -> value
private final Map<Integer, Integer> freq = new HashMap<>(); // key -> use count
// use count -> keys with that count, in LRU order (LinkedHashSet keeps insertion order)
private final Map<Integer, LinkedHashSet<Integer>> buckets = new HashMap<>();
LFUCache(int capacity) { this.capacity = capacity; }
public int get(int key) {
if (!vals.containsKey(key)) return -1;
touch(key);
return vals.get(key);
}
public void put(int key, int value) {
if (capacity == 0) return;
if (vals.containsKey(key)) {
vals.put(key, value);
touch(key);
return;
}
if (vals.size() == capacity) { // evict: lowest count, then oldest
Iterator<Integer> it = buckets.get(minFreq).iterator();
int victim = it.next();
it.remove();
vals.remove(victim);
freq.remove(victim);
}
vals.put(key, value);
freq.put(key, 1);
buckets.computeIfAbsent(1, f -> new LinkedHashSet<>()).add(key);
minFreq = 1; // a brand-new key always has count 1
}
private void touch(int key) { // move key from bucket f to bucket f+1
int f = freq.get(key);
freq.put(key, f + 1);
buckets.get(f).remove(key);
if (f == minFreq && buckets.get(f).isEmpty()) minFreq++;
buckets.computeIfAbsent(f + 1, x -> new LinkedHashSet<>()).add(key);
}
}
Capacity 2: put(1,1), put(2,2), get(1) → 1; put(3,3) evicts 2 (count 1); get(2) → -1, get(3) → 3; put(4,4): keys 1 and 3 both have count 2, 1 is older, so evict 1; get(1), get(3), get(4) → -1, 3, 4. Time O(1) average per operation.
Min Stack — LC 155 (Medium)
A stack with push, pop, top and getMin, all O(1).
Key insight: the minimum only changes on push and pop, in stack order. So store, with every entry, the minimum at the moment it was pushed. Popping automatically restores the previous minimum.
class MinStack {
private final Deque<int[]> stack = new ArrayDeque<>(); // {value, min so far}
public void push(int val) {
int min = stack.isEmpty() ? val : Math.min(val, stack.peek()[1]);
stack.push(new int[]{val, min});
}
public void pop() { stack.pop(); }
public int top() { return stack.peek()[0]; }
public int getMin() { return stack.peek()[1]; } // every entry remembers the min below it
}
push(-2), push(0), push(-3), getMin() → -3; pop(), top() → 0; getMin() → -2. Time O(1), Space O(n).
Implement Trie — LC 208 (Medium)
insert, search and startsWith in O(length of word). Each node holds an array of 26 children and an "end of word" flag. The full implementation and variations (wildcard search, word search II) are in section 15 — in a design round, reach for it whenever the question says "prefix" or "autocomplete".
Design Twitter — LC 355 (Medium)
postTweet, follow, unfollow, and getNewsFeed(userId) returning the 10 most recent tweet ids from the user and everyone they follow, newest first.
Key insight: each user's tweets are already sorted by time (you append them). The feed is a k-way merge of those lists — start a max-heap with each followed user's newest tweet, pop 10 times, and after each pop push that user's next-older tweet. A global counter gives the timestamps.
class Twitter {
private int time = 0; // global clock: bigger = newer
private final Map<Integer, Set<Integer>> follows = new HashMap<>();
private final Map<Integer, List<int[]>> tweets = new HashMap<>(); // user -> {time, tweetId}
public void postTweet(int userId, int tweetId) {
tweets.computeIfAbsent(userId, u -> new ArrayList<>()).add(new int[]{time++, tweetId});
}
public List<Integer> getNewsFeed(int userId) {
Set<Integer> users = new HashSet<>(follows.getOrDefault(userId, Set.of()));
users.add(userId); // your own tweets count too
// K-way merge: heap entry = {time, tweetId, userId, index in that user's list}
PriorityQueue<int[]> heap = new PriorityQueue<>((a, b) -> Integer.compare(b[0], a[0]));
for (int u : users) {
List<int[]> list = tweets.get(u);
if (list != null && !list.isEmpty()) {
int i = list.size() - 1; // newest tweet of this user
heap.offer(new int[]{list.get(i)[0], list.get(i)[1], u, i});
}
}
List<Integer> feed = new ArrayList<>();
while (!heap.isEmpty() && feed.size() < 10) {
int[] top = heap.poll();
feed.add(top[1]);
int i = top[3] - 1; // that user's next-older tweet
if (i >= 0) {
int[] t = tweets.get(top[2]).get(i);
heap.offer(new int[]{t[0], t[1], top[2], i});
}
}
return feed;
}
public void follow(int followerId, int followeeId) {
if (followerId != followeeId)
follows.computeIfAbsent(followerId, u -> new HashSet<>()).add(followeeId);
}
public void unfollow(int followerId, int followeeId) {
Set<Integer> s = follows.get(followerId);
if (s != null) s.remove(followeeId);
}
}
postTweet(1,5), getNewsFeed(1) → [5]; follow(1,2), postTweet(2,6), getNewsFeed(1) → [6, 5]; unfollow(1,2), getNewsFeed(1) → [5]. Feed cost O(F + 10 log F) for F followees; post/follow O(1).
Time Based Key-Value Store — LC 981 (Medium)
set(key, value, timestamp) and get(key, timestamp) returning the value with the largest timestamp <= the given one (or "").
Key insight: "largest key <= t" is exactly TreeMap.floorEntry(t). (LeetCode guarantees timestamps arrive in increasing order, so an ArrayList per key with your own binary search also works and is a common follow-up.)
class TimeMap {
private final Map<String, TreeMap<Integer, String>> store = new HashMap<>();
public void set(String key, String value, int timestamp) {
store.computeIfAbsent(key, k -> new TreeMap<>()).put(timestamp, value);
}
public String get(String key, int timestamp) {
TreeMap<Integer, String> history = store.get(key);
if (history == null) return "";
Map.Entry<Integer, String> e = history.floorEntry(timestamp); // latest time <= timestamp
return e == null ? "" : e.getValue();
}
}
set("foo","bar",1); get("foo",1) → "bar"; get("foo",3) → "bar"; set("foo","bar2",4); get("foo",4) → "bar2"; get("foo",5) → "bar2"; get("foo",0) → "". Time O(log n) per call, Space O(n).
Insert Delete GetRandom O(1) — LC 380 (Medium)
A set with insert, remove and getRandom (each element equally likely), all average O(1).
Key insight: getRandom needs index access, so values live in an ArrayList. But removing from the middle of a list is O(n) — so swap the victim with the last element and remove from the end. A map from value to index makes finding the victim O(1), and you must update the moved element's index.
class RandomizedSet {
private final Map<Integer, Integer> index = new HashMap<>(); // value -> position in list
private final List<Integer> values = new ArrayList<>();
private final Random rnd = new Random();
public boolean insert(int val) {
if (index.containsKey(val)) return false;
index.put(val, values.size());
values.add(val); // append: O(1)
return true;
}
public boolean remove(int val) {
Integer i = index.get(val);
if (i == null) return false;
int last = values.get(values.size() - 1);
values.set(i, last); // move the last value into the hole
index.put(last, i);
values.remove(values.size() - 1); // remove from the END: O(1)
index.remove(val);
return true;
}
public int getRandom() {
return values.get(rnd.nextInt(values.size())); // needs an index: that's why a list
}
}
Time O(1) average for all three, Space O(n).
put on an existing key must also move it to the front. (3) In the linked list, updating pointers in the wrong order and losing the rest of the list — set the new node's pointers first, then its neighbours'. (4) In RandomizedSet, removing by index with values.remove(i) in the middle (O(n)), or forgetting to update the moved element's index. (5) Min Stack with a single min variable — after popping the minimum you can't recover the previous one. (6) Not stating the complexity of each method — that is the whole point of the question.Practice list
- LC 232 Implement Queue using Stacks (Easy)
- LC 705 Design HashSet (Easy)
- LC 706 Design HashMap (Easy)
- LC 622 Design Circular Queue (Medium)
- LC 362 Design Hit Counter (Medium)
- LC 1396 Design Underground System (Medium)
- LC 211 Design Add and Search Words Data Structure (Medium)
- LC 1352 Product of the Last K Numbers (Medium)
- LC 381 Insert Delete GetRandom O(1) - Duplicates allowed (Hard)
- LC 432 All O`one Data Structure (Hard)
Pattern Cheat Sheet — if you see X, try Y
Problem statements leave clues: the shape of the input, the words in the question, and the size limits. Each clue below points to a pattern that usually works. Treat it as a first guess to test, not a rule — say the guess out loud, then check it against the examples.
| If you see… | Try… | Section |
|---|---|---|
| Sorted array + find a pair / triplet with a sum | Two pointers from both ends | 30 |
| Remove duplicates / partition / move zeros in place | Two pointers (slow writer, fast reader) | 30 |
| Longest / shortest contiguous subarray or substring with a condition | Sliding window (grow right, shrink left) | 31 |
| Subarray sum equals k, negatives allowed; many range-sum queries, no updates | Prefix sum + hash map | 32 |
| Add a value to many ranges, then read once | Difference array | 32 |
| Linked list cycle, middle of list, "happy number" | Fast & slow pointers | 33 |
| Reverse a list / a part of it / in groups of k | In-place reversal (prev, cur, next) | 37 |
| Next greater / next smaller element, span, histogram area, temperatures | Monotonic stack | 34 |
| Max / min of every window of size k | Monotonic deque | 34 |
| Overlapping intervals, meeting rooms, merge ranges | Sort by start, then sweep (or a min-heap of end times) | 35 |
| Array holds numbers in 1..n; find missing / duplicate | Cyclic sort (put each value at index value - 1) | 36 |
| Valid parentheses, undo, evaluate expression, nested structure | Stack | 9 |
| Seen before? count occurrences? pair with complement? | HashMap / HashSet | 7 |
| Tree: anything per level, "closest to root", right side view | Tree BFS with size snapshot | 38 |
| Tree: paths, heights, subtree answers | Tree DFS (top-down or bottom-up) | 38 |
| BST: k-th smallest, validate, range | In-order traversal (gives sorted order) | 13 |
| k-th largest / top k / k most frequent / k closest | Heap of size k (min-heap for largest); quickselect; bucket sort | 39 |
| Merge k sorted lists / arrays; k-th smallest in sorted matrix | K-way merge with a heap | 39 |
| Median of a stream / sliding window median | Two heaps | 40 |
| All subsets / combinations / permutations / arrangements | Backtracking (start index or used[]) | 41 |
| Place items under constraints (N-Queens, Sudoku, word search) | Backtracking with pruning | 21 |
| Sorted / rotated input, or "O(log n) required" | Binary search | 20, 42 |
| "Minimum capacity / speed / largest minimum such that possible" | Binary search on the answer + greedy check | 42 |
| Dependencies, prerequisites, build order | Topological sort (Kahn's) | 43 |
| Grid of land / water, regions, flood fill | DFS / BFS on the grid | 44 |
| "Spreads from many sources", distance to nearest X | Multi-source BFS | 44 |
| Shortest path, unweighted (or all edges equal) | BFS | 16 |
| Shortest path, weights 0 or 1 | 0-1 BFS with a deque | 26 |
| Shortest path, non-negative weights | Dijkstra | 26 |
| Shortest path, negative weights / at most k edges | Bellman-Ford | 26 |
| All-pairs shortest paths, n <= ~400 | Floyd-Warshall | 26 |
| Connect all points at minimum cost | Minimum spanning tree (Kruskal / Prim) | 27 |
| Connected groups, merging sets, "are these connected?", redundant edge | Union-Find | 17 |
| Prefix lookups, autocomplete, many words searched in a grid | Trie | 15 |
| Range sum / min / max with updates | Fenwick tree / segment tree | 18 |
| Nearest key <= x or >= x, sorted keys with inserts | TreeMap / TreeSet | 14 |
| Count ways / min cost / max value with choices, overlapping subproblems | Dynamic programming | 24, 25 |
| Two strings: edit distance, LCS, matching | 2D DP over (i, j) | 25 |
| Pick items under a capacity / target sum | Knapsack DP | 25 |
| Local best choice is provably safe (intervals by end time, jumps) | Greedy | 22 |
| Split in halves, combine (count inversions, merge sort) | Divide & conquer | 23 |
| Find a pattern in text fast, repeated substrings | KMP / Z-function / rolling hash | 28 |
| Single number, XOR tricks, subsets as bitmasks, power of two | Bit manipulation | 5 |
| GCD, primes, modular arithmetic, "answer modulo 10^9+7" | Math / number theory | 29 |
| "Design a class with O(1) operations" | HashMap + list / heap / TreeMap | 45 |
Constraints tell you the complexity
A judge runs roughly 108 simple operations per second. Put n into your complexity and you know if the idea will pass before you write a line.
| n up to | Complexity that fits | Usually means |
|---|---|---|
| 10–12 | O(n!), O(n · n!) | Permutations, brute force over orderings |
| 20–25 | O(2n), O(n · 2n) | Subsets, backtracking, bitmask DP |
| 100 | O(n4) | Several nested loops, small interval DP |
| 500 | O(n3) | Floyd-Warshall, interval DP |
| 5,000 | O(n2) | 2D DP, all pairs, nested loops |
| 105–106 | O(n log n) or O(n) | Sorting, heap, binary search, two pointers, sliding window, hashing |
| 108 and more | O(log n) or O(1) | Binary search on the answer, math formula |
Complexity Cheat Sheet
The costs to know without thinking. "Average" for hash structures assumes a decent hash function; "amortized" means an occasional expensive step (like resizing) averaged over many cheap ones.
Data structure operations
| Structure (Java) | Access by index | Search | Insert | Delete | Notes |
|---|---|---|---|---|---|
Array int[] | O(1) | O(n); O(log n) if sorted | O(n) | O(n) | Fixed size |
ArrayList | O(1) | O(n) | O(1) amortized at end; O(n) in middle | O(1) at end; O(n) in middle | Resizes by 1.5x |
LinkedList | O(n) | O(n) | O(1) at ends / at a known node | O(1) at ends / at a known node | Rarely the best choice in Java |
ArrayDeque (stack / queue) | — | O(n) | O(1) amortized at both ends | O(1) at both ends | Use instead of Stack and LinkedList |
HashMap / HashSet | — | O(1) average | O(1) average | O(1) average | Worst O(log n) per bucket (tree bins) in Java 8+ |
TreeMap / TreeSet | — | O(log n) | O(log n) | O(log n) | Sorted; floor / ceiling / first / last in O(log n) |
PriorityQueue (binary heap) | peek O(1) | O(n) | O(log n) | poll O(log n); remove(x) O(n) | Building from a collection: O(n) |
| Trie | — | O(L) | O(L) | O(L) | L = word length; memory O(total chars × alphabet) |
| Union-Find (path compression + union by rank) | — | find ~O(1) (α(n)) | union ~O(1) | — | α is the inverse Ackermann function, <= 4 in practice |
| Fenwick / segment tree | — | range query O(log n) | point update O(log n) | — | Build O(n) |
Sorting algorithms
| Algorithm | Best | Average | Worst | Extra space | Stable? |
|---|---|---|---|---|---|
| Bubble / insertion sort | O(n) | O(n²) | O(n²) | O(1) | Yes |
| Selection sort | O(n²) | O(n²) | O(n²) | O(1) | No |
| Merge sort | O(n log n) | O(n log n) | O(n log n) | O(n) | Yes |
| Quicksort | O(n log n) | O(n log n) | O(n²) | O(log n) stack | No |
| Heapsort | O(n log n) | O(n log n) | O(n log n) | O(1) | No |
| Counting sort | O(n + k) | O(n + k) | O(n + k) | O(k) | Yes |
| Radix sort | O(d(n + b)) | O(d(n + b)) | O(d(n + b)) | O(n + b) | Yes |
| Bucket sort | O(n + k) | O(n + k) | O(n²) | O(n + k) | Yes (if buckets are) |
Arrays.sort(int[]) | Dual-pivot quicksort, O(n log n) typical | O(log n) | No | ||
Arrays.sort(Object[]), Collections.sort | O(n) | O(n log n) | O(n log n) | O(n) | Yes (TimSort) |
Graph algorithms
V = vertices, E = edges.
| Algorithm | Time | Space | Use for |
|---|---|---|---|
| BFS | O(V + E) | O(V) | Shortest path in unweighted graphs, levels |
| DFS | O(V + E) | O(V) | Reachability, components, cycle detection |
| Topological sort (Kahn's or DFS) | O(V + E) | O(V) | Order with dependencies; detect cycle in a directed graph |
| Dijkstra (binary heap) | O((V + E) log V) | O(V) | Single-source shortest paths, non-negative weights |
| Bellman-Ford | O(V · E) | O(V) | Negative weights; detects negative cycles; "at most k edges" |
| Floyd-Warshall | O(V³) | O(V²) | All-pairs shortest paths, small V |
| Kruskal | O(E log E) | O(V) | MST: sort edges + union-find |
| Prim (binary heap) | O(E log V) | O(V + E) | MST on dense-ish graphs, grows from one vertex |
| Union-Find over all edges | O(E · α(V)) | O(V) | Connected components, dynamic connectivity |
| Grid BFS / DFS | O(rows × cols) | O(rows × cols) | Islands, flood fill, shortest steps |
Learning Path & Must-Do Problems
Learn a topic, then immediately practise its pattern. Each week below pairs data structures and algorithms with the patterns that use them. About 10–15 hours a week is enough; the order matters more than the speed. If you have less time, do weeks 1–8 and the "must-do" list — that covers most interview questions.
12-week plan
| Week | Learn | Patterns to practise |
|---|---|---|
| 1 | Approach, Big-O, recursion, Java toolkit, bits (1–5) | Write every Java collection from memory; 5 easy problems |
| 2 | Arrays & strings, hashing (6, 7) | Two pointers (30), prefix sum (32) |
| 3 | Sliding window, cyclic sort | Sliding window (31), cyclic sort (36) |
| 4 | Linked lists, stacks, queues (8–10) | Fast & slow (33), list reversal (37), monotonic stack (34) |
| 5 | Sorting, binary search (19, 20) | Merge intervals (35), modified binary search (42) |
| 6 | Binary trees, BSTs, TreeMap (12–14) | Tree BFS & DFS (38) |
| 7 | Heaps (11) | Top K & k-way merge (39), two heaps (40) |
| 8 | Backtracking, tries (21, 15) | Subsets & permutations (41) |
| 9 | Graphs, union-find (16, 17) | Matrix & islands (44), topological sort (43) |
| 10 | Shortest paths, MST, greedy (26, 27, 22) | Dijkstra problems, interval greedy |
| 11 | Dynamic programming (24, 25) | 1D DP, knapsack, 2D grid / string DP |
| 12 | Leftovers: divide & conquer, strings, math, segment trees (23, 28, 29, 18); design (45) | Mock interviews, mixed random problems, redo old mistakes |
Must-do problems (grouped by pattern)
| Pattern | Problems |
|---|---|
| Arrays & hashing | LC 1 Two Sum (E) · LC 242 Valid Anagram (E) · LC 49 Group Anagrams (M) · LC 238 Product of Array Except Self (M) · LC 128 Longest Consecutive Sequence (M) |
| Two pointers | LC 125 Valid Palindrome (E) · LC 15 3Sum (M) · LC 11 Container With Most Water (M) · LC 42 Trapping Rain Water (H) |
| Sliding window | LC 121 Best Time to Buy and Sell Stock (E) · LC 3 Longest Substring Without Repeating Characters (M) · LC 424 Longest Repeating Character Replacement (M) · LC 76 Minimum Window Substring (H) · LC 239 Sliding Window Maximum (H) |
| Prefix sum | LC 560 Subarray Sum Equals K (M) · LC 523 Continuous Subarray Sum (M) |
| Stack / monotonic stack | LC 20 Valid Parentheses (E) · LC 155 Min Stack (M) · LC 739 Daily Temperatures (M) · LC 84 Largest Rectangle in Histogram (H) |
| Binary search | LC 704 Binary Search (E) · LC 33 Search in Rotated Sorted Array (M) · LC 153 Find Minimum in Rotated Sorted Array (M) · LC 875 Koko Eating Bananas (M) · LC 4 Median of Two Sorted Arrays (H) |
| Linked list | LC 206 Reverse Linked List (E) · LC 21 Merge Two Sorted Lists (E) · LC 141 Linked List Cycle (E) · LC 143 Reorder List (M) · LC 23 Merge k Sorted Lists (H) |
| Intervals | LC 56 Merge Intervals (M) · LC 435 Non-overlapping Intervals (M) · LC 253 Meeting Rooms II (M) |
| Cyclic sort | LC 268 Missing Number (E) · LC 448 Find All Numbers Disappeared in an Array (E) · LC 41 First Missing Positive (H) |
| Trees | LC 226 Invert Binary Tree (E) · LC 104 Maximum Depth of Binary Tree (E) · LC 102 Binary Tree Level Order Traversal (M) · LC 98 Validate Binary Search Tree (M) · LC 230 Kth Smallest Element in a BST (M) · LC 105 Construct Binary Tree from Preorder and Inorder Traversal (M) · LC 124 Binary Tree Maximum Path Sum (H) · LC 297 Serialize and Deserialize Binary Tree (H) |
| Heap / top K | LC 215 Kth Largest Element in an Array (M) · LC 347 Top K Frequent Elements (M) · LC 973 K Closest Points to Origin (M) · LC 295 Find Median from Data Stream (H) |
| Backtracking | LC 78 Subsets (M) · LC 46 Permutations (M) · LC 39 Combination Sum (M) · LC 79 Word Search (M) · LC 51 N-Queens (H) |
| Trie | LC 208 Implement Trie (Prefix Tree) (M) · LC 212 Word Search II (H) |
| Graphs | LC 200 Number of Islands (M) · LC 994 Rotting Oranges (M) · LC 417 Pacific Atlantic Water Flow (M) · LC 207 Course Schedule (M) · LC 210 Course Schedule II (M) · LC 684 Redundant Connection (M) · LC 743 Network Delay Time (M) · LC 127 Word Ladder (H) · LC 269 Alien Dictionary (H) |
| 1D dynamic programming | LC 70 Climbing Stairs (E) · LC 198 House Robber (M) · LC 322 Coin Change (M) · LC 300 Longest Increasing Subsequence (M) · LC 139 Word Break (M) · LC 416 Partition Equal Subset Sum (M) |
| 2D dynamic programming | LC 62 Unique Paths (M) · LC 1143 Longest Common Subsequence (M) · LC 72 Edit Distance (M) |
| Greedy | LC 53 Maximum Subarray (M) · LC 55 Jump Game (M) · LC 134 Gas Station (M) |
| Bit manipulation | LC 136 Single Number (E) |
| Design | LC 146 LRU Cache (M) · LC 380 Insert Delete GetRandom O(1) (M) |
That is 79 problems. If time is short, do the first two of every row first (about 35), then come back for the rest. The pattern sections above each end with a longer practice list.
How to practise
- Timebox. Give a new problem 25–30 minutes (45 for a Hard). If you have no working idea by then, read the solution — but only the idea, then close it and write the code yourself.
- Name the pattern first. Before coding, say which pattern from the cheat sheet you are using and why. If you can't, that is the thing to study.
- Redo after 3 days, then after 2 weeks. Keep a list of problems you needed help with. Solve them again from a blank editor. A problem is "done" only when you can solve it cold.
- Explain aloud. Talk through the approach, the complexity, and one edge case as if an interviewer were listening. Record yourself or practise with a friend — silent solving does not train the interview.
- Test by hand. Before running code, trace a small example and the edge cases: empty input, one element, all equal, negative numbers, overflow.
- Mix it up in the last weeks. Doing ten sliding-window problems in a row tells you the pattern in advance. Random mixed sets train the real skill: recognising which pattern to use.