Data Structures & Algorithms

All 40 notes on one page

Complexity & Fundamentals

1

Big O Notation

beginner complexity big-o time-complexity space-complexity

Big O notation is a way to describe how fast an algorithm runs (or how much memory it uses) as the input size grows. It gives us a common language to compare algorithms without actually running them.

In simple language, Big O answers: “If we double the input, how much slower does this get?”

We don’t care about exact milliseconds. We care about the growth rate. An O(n) algorithm might be slower than O(n²) for 5 items, but for a million items? O(n) wins every time.

Common Complexity Classes

Here’s the lineup from fastest to slowest:

Big ONameExample
O(1)ConstantArray index access
O(log n)LogarithmicBinary search
O(n)LinearSingle loop through array
O(n log n)LinearithmicMerge sort, quick sort
O(n²)QuadraticNested loops
O(2^n)ExponentialRecursive fibonacci (naive)

Visual Comparison

Growth Rate Comparison (n = 16)
O(1)
1
O(log n)
4
O(n)
16
O(n log n)
64
O(n²)
256
O(2^n)
65,536

Look at that jump from O(n²) to O(2^n). That’s why complexity matters — a bad algorithm can turn a one-second task into a “heat death of the universe” situation.

How to Analyze Code

The key rules are simple:

  1. Drop constants — O(2n) is just O(n)
  2. Drop smaller terms — O(n² + n) is just O(n²)
  3. Loops — a single loop over n items is O(n)
  4. Nested loops — a loop inside a loop is O(n × m), or O(n²) if both go over the same array
  5. Halving — if we cut the problem in half each step, that’s O(log n)

O(1) — Constant Time

No matter how big the input is, we do the same amount of work.

// Accessing an array element by index -- always instant
function getFirst(arr) {
  return arr[0]; // one operation, regardless of array size
}
# Accessing a list element by index -- always instant
def get_first(arr):
    return arr[0]  # one operation, regardless of list size
// Accessing an array element by index -- always instant
static int getFirst(int[] arr) {
    return arr[0]; // one operation, regardless of array size
}

O(log n) — Logarithmic

We cut the input in half each step. Binary search is the classic example.

// Binary search -- halves the search space each iteration
function binarySearch(arr, target) {
  let left = 0, right = arr.length - 1;
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    if (arr[mid] === target) return mid;
    if (arr[mid] < target) left = mid + 1;
    else right = mid - 1;
  }
  return -1;
}
# Binary search -- halves the search space each iteration
def binary_search(arr, target):
    left, right = 0, len(arr) - 1
    while left <= right:
        mid = (left + right) // 2
        if arr[mid] == target:
            return mid
        if arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
    return -1
// Binary search -- halves the search space each iteration
static int binarySearch(int[] arr, int target) {
    int left = 0, right = arr.length - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] == target) return mid;
        if (arr[mid] < target) left = mid + 1;
        else right = mid - 1;
    }
    return -1;
}

For an array of 1 billion elements, binary search needs at most ~30 steps. That’s the power of O(log n).

O(n) — Linear

We touch each element once. Double the input, double the work.

// Find the maximum -- must check every element
function findMax(arr) {
  let max = arr[0];
  for (let i = 1; i < arr.length; i++) {
    if (arr[i] > max) max = arr[i];
  }
  return max;
}
# Find the maximum -- must check every element
def find_max(arr):
    max_val = arr[0]
    for num in arr[1:]:
        if num > max_val:
            max_val = num
    return max_val
// Find the maximum -- must check every element
static int findMax(int[] arr) {
    int max = arr[0];
    for (int i = 1; i < arr.length; i++) {
        if (arr[i] > max) max = arr[i];
    }
    return max;
}

O(n²) — Quadratic

Nested loops where both iterate over the input. This is where things start getting slow.

// Check for duplicates with brute force -- every pair gets compared
function hasDuplicate(arr) {
  for (let i = 0; i < arr.length; i++) {
    for (let j = i + 1; j < arr.length; j++) {
      if (arr[i] === arr[j]) return true;
    }
  }
  return false;
}
# Check for duplicates with brute force -- every pair gets compared
def has_duplicate(arr):
    for i in range(len(arr)):
        for j in range(i + 1, len(arr)):
            if arr[i] == arr[j]:
                return True
    return False
// Check for duplicates with brute force -- every pair gets compared
static boolean hasDuplicate(int[] arr) {
    for (int i = 0; i < arr.length; i++) {
        for (int j = i + 1; j < arr.length; j++) {
            if (arr[i] == arr[j]) return true;
        }
    }
    return false;
}

For 10,000 elements, that’s ~50 million comparisons. For 100,000 elements, it’s ~5 billion. Yikes.

O(2^n) — Exponential

Each step doubles the work. Recursive fibonacci without memoization is the classic example.

// Naive fibonacci -- each call spawns TWO more calls
function fib(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2); // two recursive calls = exponential
}
# Naive fibonacci -- each call spawns TWO more calls
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)  # two recursive calls = exponential
// Naive fibonacci -- each call spawns TWO more calls
static int fib(int n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2); // two recursive calls = exponential
}

fib(40) makes over a billion function calls. fib(50) could take minutes. We’ll fix this later with dynamic programming.

Space Complexity

Time complexity measures how fast we are. Space complexity measures how much extra memory we use.

The rules are the same — we use Big O notation, but we count memory instead of operations.

// O(1) space -- we only use a few variables, no matter the input size
function sum(arr) {
  let total = 0;          // just one variable
  for (const num of arr) {
    total += num;
  }
  return total;
}

// O(n) space -- we create a new array the same size as the input
function doubled(arr) {
  const result = [];       // grows with input size
  for (const num of arr) {
    result.push(num * 2);
  }
  return result;
}
# O(1) space -- we only use a few variables, no matter the input size
def sum_arr(arr):
    total = 0              # just one variable
    for num in arr:
        total += num
    return total

# O(n) space -- we create a new list the same size as the input
def doubled(arr):
    result = []            # grows with input size
    for num in arr:
        result.append(num * 2)
    return result
// O(1) space -- we only use a few variables, no matter the input size
static int sum(int[] arr) {
    int total = 0;          // just one variable
    for (int num : arr) {
        total += num;
    }
    return total;
}

// O(n) space -- we create a new array the same size as the input
static int[] doubled(int[] arr) {
    int[] result = new int[arr.length]; // grows with input size
    for (int i = 0; i < arr.length; i++) {
        result[i] = arr[i] * 2;
    }
    return result;
}

In interviews, always mention both time and space complexity. A common trade-off is using extra memory (like a hash map) to make the algorithm faster. That’s the classic time-space trade-off.


2

Recursion

beginner recursion call-stack base-case

Recursion is when a function calls itself to solve a smaller version of the same problem. It keeps calling itself until it hits a stopping condition, then works its way back up with the answers.

In simple language, think of it like Russian nesting dolls. We keep opening dolls until we find the smallest one (the base case), then we put them back together one by one.

The Two Ingredients

Every recursive function needs exactly two things:

  1. Base case — the condition where we STOP recursing. Without it, we get infinite recursion and a stack overflow.
  2. Recursive case — the part where the function calls itself with a smaller/simpler input.
function countdown(n) {
  if (n <= 0) {           // base case -- stop here
    console.log("Done!");
    return;
  }
  console.log(n);
  countdown(n - 1);       // recursive case -- smaller input
}
// countdown(3) prints: 3, 2, 1, Done!
def countdown(n):
    if n <= 0:             # base case -- stop here
        print("Done!")
        return
    print(n)
    countdown(n - 1)      # recursive case -- smaller input

# countdown(3) prints: 3, 2, 1, Done!
static void countdown(int n) {
    if (n <= 0) {           // base case -- stop here
        System.out.println("Done!");
        return;
    }
    System.out.println(n);
    countdown(n - 1);       // recursive case -- smaller input
}
// countdown(3) prints: 3, 2, 1, Done!

The Call Stack

When a function calls itself, the current call gets paused and pushed onto the call stack. Each call waits for the one below it to return before it can finish.

Here’s what happens when we call factorial(4):

Call Stack for factorial(4)
Winding up
factorial(4) = 4 * ?
factorial(3) = 3 * ?
factorial(2) = 2 * ?
factorial(1) = 1
^ base case hit!
Unwinding
factorial(4) = 4 * 6 = 24
factorial(3) = 3 * 2 = 6
factorial(2) = 2 * 1 = 2
factorial(1) = 1
^ returns propagate up

Each call adds a frame to the stack. Once the base case returns, the frames start popping off one by one, combining their results.

Classic Examples

Factorial

n! = n * (n-1) * (n-2) * … * 1. The base case is 1! = 1.

function factorial(n) {
  if (n <= 1) return 1;       // base case
  return n * factorial(n - 1); // n * (n-1)!
}
// factorial(5) => 5 * 4 * 3 * 2 * 1 => 120
def factorial(n):
    if n <= 1:
        return 1               # base case
    return n * factorial(n - 1) # n * (n-1)!

# factorial(5) => 5 * 4 * 3 * 2 * 1 => 120
static int factorial(int n) {
    if (n <= 1) return 1;       // base case
    return n * factorial(n - 1); // n * (n-1)!
}
// factorial(5) => 5 * 4 * 3 * 2 * 1 => 120

Time: O(n) — we make n calls. Space: O(n) — n frames on the call stack.

Fibonacci

Each number is the sum of the two before it: 0, 1, 1, 2, 3, 5, 8, 13…

function fib(n) {
  if (n <= 0) return 0;    // base case 1
  if (n === 1) return 1;   // base case 2
  return fib(n - 1) + fib(n - 2);
}
// fib(6) => 8
def fib(n):
    if n <= 0:
        return 0            # base case 1
    if n == 1:
        return 1            # base case 2
    return fib(n - 1) + fib(n - 2)

# fib(6) => 8
static int fib(int n) {
    if (n <= 0) return 0;    // base case 1
    if (n == 1) return 1;    // base case 2
    return fib(n - 1) + fib(n - 2);
}
// fib(6) => 8

Time: O(2^n) — each call branches into two more. Extremely slow for large n. We’ll fix this with memoization (dynamic programming) later.

Sum of an Array

Break the problem down: the sum of an array is the first element plus the sum of the rest.

function sumArray(arr) {
  if (arr.length === 0) return 0;         // base case: empty array
  return arr[0] + sumArray(arr.slice(1)); // first + sum of rest
}
// sumArray([1, 2, 3, 4]) => 10
def sum_array(arr):
    if len(arr) == 0:
        return 0                       # base case: empty list
    return arr[0] + sum_array(arr[1:]) # first + sum of rest

# sum_array([1, 2, 3, 4]) => 10
static int sumArray(int[] arr, int i) {
    if (i >= arr.length) return 0;        // base case: past the end
    return arr[i] + sumArray(arr, i + 1); // current + sum of rest
}
// sumArray(new int[]{1, 2, 3, 4}, 0) => 10

Recursion vs Iteration

Every recursive solution can be rewritten as a loop (and vice versa). So when do we pick which?

RecursionIteration
Cleaner for tree/graph traversalBetter for simple linear problems
Uses call stack memory (O(n) space)Usually O(1) space
Risk of stack overflow for deep recursionNo stack overflow risk
Easier to express divide-and-conquerMore performant in most languages

Think of it like this: if the problem has a tree-like structure (sub-problems branching out), recursion is natural. If it’s a straight-line walk through data, a loop is simpler.

Common Pitfalls

1. Missing or Wrong Base Case

Without a base case, the function calls itself forever until the stack overflows.

// BAD -- no base case, infinite recursion
function oops(n) {
  return n + oops(n - 1); // never stops!
}

// GOOD -- base case stops the recursion
function sum(n) {
  if (n <= 0) return 0;   // this is what saves us
  return n + sum(n - 1);
}
# BAD -- no base case, infinite recursion
def oops(n):
    return n + oops(n - 1)  # never stops!

# GOOD -- base case stops the recursion
def sum_n(n):
    if n <= 0:
        return 0            # this is what saves us
    return n + sum_n(n - 1)
// BAD -- no base case, infinite recursion
static int oops(int n) {
    return n + oops(n - 1); // never stops!
}

// GOOD -- base case stops the recursion
static int sum(int n) {
    if (n <= 0) return 0;   // this is what saves us
    return n + sum(n - 1);
}

2. Not Shrinking the Problem

Each recursive call MUST make the problem smaller. If we pass the same input, we loop forever.

3. Stack Overflow

Deep recursion (say, factorial(100000)) can crash the program. Most languages have a call stack limit (around 10,000-15,000 frames). For deep recursion, either convert to iteration or use tail-call optimization (where supported).

In simple language, recursion is powerful but we need to respect its limits. Start with the base case, make sure we’re always moving toward it, and watch the call stack depth.


3

Bit Manipulation

intermediate bits bitwise xor

Bit manipulation means working directly with the binary representation of numbers using bitwise operators. Computers already think in bits — so these operations are blazing fast (single CPU instruction).

In simple language, instead of doing math the “normal” way, we flip individual 0s and 1s inside a number. It feels like a magic trick, but it’s just math at the lowest level.

The Bitwise Operators

Here’s the toolkit. Everything operates on individual bits:

OperatorSymbolWhat it does
AND&Both bits must be 1 to get 1
OR|Either bit being 1 gives 1
XOR^Bits must be different to get 1
NOT~Flips all bits
Left Shift<<Shifts bits left (multiply by 2)
Right Shift>>Shifts bits right (divide by 2)
Bitwise Operations on 5 (0101) and 3 (0011)
AND (&)
0101 & 0011
= 0001 (1)
OR (|)
0101 | 0011
= 0111 (7)
XOR (^)
0101 ^ 0011
= 0110 (6)
Left Shift (<<)
0101 << 1
= 1010 (10)

Why It Matters in Interviews

Bit manipulation shows up in interviews because:

  • It leads to O(1) space solutions where other approaches need extra memory
  • Some problems are literally impossible to solve optimally without XOR tricks
  • It tests whether we understand how numbers work under the hood

Common Tricks

Check Even or Odd

The last bit of any number tells us if it’s even (0) or odd (1). Way faster than using modulo.

function isOdd(n) {
  return (n & 1) === 1; // last bit is 1 = odd
}

// 5 in binary: 101 -> last bit is 1 -> odd
// 4 in binary: 100 -> last bit is 0 -> even
def is_odd(n):
    return (n & 1) == 1  # last bit is 1 = odd

# 5 in binary: 101 -> last bit is 1 -> odd
# 4 in binary: 100 -> last bit is 0 -> even
static boolean isOdd(int n) {
    return (n & 1) == 1; // last bit is 1 = odd
}

// 5 in binary: 101 -> last bit is 1 -> odd
// 4 in binary: 100 -> last bit is 0 -> even

Swap Two Numbers Without a Temp Variable

XOR has a cool property: a ^ a = 0 and a ^ 0 = a. We can use this to swap.

function swap(a, b) {
  a = a ^ b; // a now holds combined info
  b = a ^ b; // b gets original a
  a = a ^ b; // a gets original b
  return [a, b];
}
// swap(3, 7) => [7, 3]
def swap(a, b):
    a = a ^ b  # a now holds combined info
    b = a ^ b  # b gets original a
    a = a ^ b  # a gets original b
    return a, b

# swap(3, 7) => (7, 3)
static void swap(int a, int b) {
    a = a ^ b; // a now holds combined info
    b = a ^ b; // b gets original a
    a = a ^ b; // a gets original b
    System.out.println(a + ", " + b);
}
// swap(3, 7) => prints "7, 3"

Check if a Number is a Power of 2

Powers of 2 in binary have exactly one 1-bit: 1, 10, 100, 1000… The trick: n & (n - 1) removes the lowest set bit. If the result is 0, there was only one bit set.

function isPowerOfTwo(n) {
  return n > 0 && (n & (n - 1)) === 0;
}
// 8 = 1000, 7 = 0111 -> 1000 & 0111 = 0000 -> true
// 6 = 0110, 5 = 0101 -> 0110 & 0101 = 0100 -> false
def is_power_of_two(n):
    return n > 0 and (n & (n - 1)) == 0

# 8 = 1000, 7 = 0111 -> 1000 & 0111 = 0000 -> True
# 6 = 0110, 5 = 0101 -> 0110 & 0101 = 0100 -> False
static boolean isPowerOfTwo(int n) {
    return n > 0 && (n & (n - 1)) == 0;
}
// 8 = 1000, 7 = 0111 -> 1000 & 0111 = 0000 -> true
// 6 = 0110, 5 = 0101 -> 0110 & 0101 = 0100 -> false

Count Set Bits (Hamming Weight)

We keep clearing the lowest set bit until the number becomes 0. Each iteration removes exactly one 1-bit.

function countBits(n) {
  let count = 0;
  while (n > 0) {
    n = n & (n - 1); // remove lowest set bit
    count++;
  }
  return count;
}
// countBits(13) => 3  (13 = 1101, three 1-bits)
def count_bits(n):
    count = 0
    while n > 0:
        n = n & (n - 1)  # remove lowest set bit
        count += 1
    return count

# count_bits(13) => 3  (13 = 1101, three 1-bits)
static int countBits(int n) {
    int count = 0;
    while (n > 0) {
        n = n & (n - 1); // remove lowest set bit
        count++;
    }
    return count;
}
// countBits(13) => 3  (13 = 1101, three 1-bits)

Time: O(k) where k is the number of set bits. That’s even better than O(log n).

XOR Patterns

XOR is the star of bit manipulation interviews. Key properties to remember:

  • a ^ a = 0 (anything XOR itself is 0)
  • a ^ 0 = a (anything XOR zero is itself)
  • XOR is commutative and associative (order doesn’t matter)

Find the Single Number

Given an array where every element appears twice except one, find the unique element. XOR everything together — the pairs cancel out and we’re left with the answer.

function singleNumber(nums) {
  let result = 0;
  for (const num of nums) {
    result ^= num; // pairs cancel: a ^ a = 0
  }
  return result;
}
// singleNumber([4, 1, 2, 1, 2]) => 4
// 4^1^2^1^2 = 4^(1^1)^(2^2) = 4^0^0 = 4
def single_number(nums):
    result = 0
    for num in nums:
        result ^= num  # pairs cancel: a ^ a = 0
    return result

# single_number([4, 1, 2, 1, 2]) => 4
# 4^1^2^1^2 = 4^(1^1)^(2^2) = 4^0^0 = 4
static int singleNumber(int[] nums) {
    int result = 0;
    for (int num : nums) {
        result ^= num; // pairs cancel: a ^ a = 0
    }
    return result;
}
// singleNumber([4, 1, 2, 1, 2]) => 4

Time: O(n), Space: O(1). No hash maps needed. That’s the beauty of XOR.

Find Two Non-Repeating Numbers

Given an array where every element appears twice except two, find both unique elements. This is a trickier extension.

The idea: XOR everything to get a ^ b. Since a and b are different, at least one bit differs between them. We use that differing bit to split the array into two groups — each group contains exactly one of the unique numbers.

function twoSingleNumbers(nums) {
  let xor = 0;
  for (const num of nums) xor ^= num; // xor = a ^ b

  // find rightmost set bit (where a and b differ)
  const diffBit = xor & (-xor);

  let a = 0, b = 0;
  for (const num of nums) {
    if (num & diffBit) a ^= num;  // group with bit set
    else b ^= num;                 // group without bit set
  }
  return [a, b];
}
// twoSingleNumbers([1, 2, 1, 3, 2, 5]) => [3, 5]
def two_single_numbers(nums):
    xor = 0
    for num in nums:
        xor ^= num  # xor = a ^ b

    # find rightmost set bit (where a and b differ)
    diff_bit = xor & (-xor)

    a, b = 0, 0
    for num in nums:
        if num & diff_bit:
            a ^= num   # group with bit set
        else:
            b ^= num   # group without bit set
    return [a, b]

# two_single_numbers([1, 2, 1, 3, 2, 5]) => [3, 5]
static int[] twoSingleNumbers(int[] nums) {
    int xor = 0;
    for (int num : nums) xor ^= num; // xor = a ^ b

    // find rightmost set bit (where a and b differ)
    int diffBit = xor & (-xor);

    int a = 0, b = 0;
    for (int num : nums) {
        if ((num & diffBit) != 0) a ^= num; // group with bit set
        else b ^= num;                       // group without bit set
    }
    return new int[]{a, b};
}
// twoSingleNumbers([1, 2, 1, 3, 2, 5]) => [3, 5]

Time: O(n), Space: O(1). Two passes through the array, no extra data structures.

Quick Reference

Here’s a cheat sheet of handy bit tricks:

TrickCodeWhy it works
Check if oddn & 1Last bit is 1 for odd numbers
Multiply by 2n << 1Shifting left adds a zero at the end
Divide by 2n >> 1Shifting right removes the last bit
Toggle bit at pos kn ^ (1 << k)XOR flips the target bit
Set bit at pos kn | (1 << k)OR forces the target bit to 1
Clear bit at pos kn & ~(1 << k)AND with inverted mask clears it
Check bit at pos k(n >> k) & 1Shift it down and check last bit
Remove lowest set bitn & (n - 1)Subtracting 1 flips all bits up to lowest set

In simple language, bit manipulation is one of those topics that feels intimidating at first but has a small set of patterns that repeat everywhere. Master XOR properties and n & (n - 1), and we can handle most interview problems.


4

Two Pointers

beginner two-pointers arrays pattern

Two pointers is a technique where we use two references (usually indices) to traverse an array or linked list. Instead of checking every possible pair with nested loops (O(n²)), we move the pointers strategically to get O(n) solutions.

In simple language, think of it like two fingers on a ruler. We move them based on some condition, and they narrow down the answer without checking every combination.

The Three Patterns

Almost every two pointer problem falls into one of these patterns:

1. Opposite Direction
L
1 3 5 7 9
R
Start at edges, move inward. Used for: two sum (sorted), reverse, palindrome
2. Same Direction (Read/Write)
1S 1F 2 2 3
→ →
Both move forward. Slow writes, fast reads. Used for: remove duplicates, partition
3. Fast & Slow (Floyd's)
Aslow B Cfast D E
Slow moves 1 step, fast moves 2. Used for: cycle detection, find middle of linked list

Pattern 1: Opposite Direction

Two Sum (Sorted Array)

Given a sorted array and a target sum, find two numbers that add up to the target. We start with pointers at both ends. If the sum is too small, move the left pointer right (bigger number). If it’s too big, move the right pointer left (smaller number).

function twoSum(arr, target) {
  let left = 0, right = arr.length - 1;
  while (left < right) {
    const sum = arr[left] + arr[right];
    if (sum === target) return [left, right];
    if (sum < target) left++;   // need bigger sum
    else right--;                // need smaller sum
  }
  return [-1, -1]; // no pair found
}
// twoSum([1, 3, 5, 7, 11], 12) => [1, 4] (3 + 11 = 14 -> too big,
//   3 + 7 = 10 -> too small, 5 + 7 = 12 -> found!)
def two_sum(arr, target):
    left, right = 0, len(arr) - 1
    while left < right:
        total = arr[left] + arr[right]
        if total == target:
            return [left, right]
        if total < target:
            left += 1       # need bigger sum
        else:
            right -= 1      # need smaller sum
    return [-1, -1]  # no pair found

# two_sum([1, 3, 5, 7, 11], 12) => [2, 3]  (5 + 7)
static int[] twoSum(int[] arr, int target) {
    int left = 0, right = arr.length - 1;
    while (left < right) {
        int sum = arr[left] + arr[right];
        if (sum == target) return new int[]{left, right};
        if (sum < target) left++;   // need bigger sum
        else right--;                // need smaller sum
    }
    return new int[]{-1, -1}; // no pair found
}
// twoSum([1, 3, 5, 7, 11], 12) => [2, 3]  (5 + 7)

Time: O(n), Space: O(1). Compare that to the brute force O(n²) nested loop approach.

Reverse an Array

Classic opposite direction — swap elements at left and right, then move inward.

function reverseArray(arr) {
  let left = 0, right = arr.length - 1;
  while (left < right) {
    [arr[left], arr[right]] = [arr[right], arr[left]]; // swap
    left++;
    right--;
  }
  return arr;
}
// reverseArray([1, 2, 3, 4, 5]) => [5, 4, 3, 2, 1]
def reverse_array(arr):
    left, right = 0, len(arr) - 1
    while left < right:
        arr[left], arr[right] = arr[right], arr[left]  # swap
        left += 1
        right -= 1
    return arr

# reverse_array([1, 2, 3, 4, 5]) => [5, 4, 3, 2, 1]
static void reverseArray(int[] arr) {
    int left = 0, right = arr.length - 1;
    while (left < right) {
        int temp = arr[left];
        arr[left] = arr[right]; // swap
        arr[right] = temp;
        left++;
        right--;
    }
}
// reverseArray([1, 2, 3, 4, 5]) => [5, 4, 3, 2, 1]

Time: O(n), Space: O(1). In-place reversal, no extra array needed.

Pattern 2: Same Direction

Remove Duplicates from Sorted Array

We need to modify the array in-place and return the count of unique elements. The slow pointer marks where the next unique element should go. The fast pointer scans ahead.

function removeDuplicates(nums) {
  if (nums.length === 0) return 0;
  let slow = 0; // position of last unique element
  for (let fast = 1; fast < nums.length; fast++) {
    if (nums[fast] !== nums[slow]) {
      slow++;
      nums[slow] = nums[fast]; // write unique element
    }
  }
  return slow + 1; // count of unique elements
}
// removeDuplicates([1,1,2,2,3]) => 3, array becomes [1,2,3,...]
def remove_duplicates(nums):
    if not nums:
        return 0
    slow = 0  # position of last unique element
    for fast in range(1, len(nums)):
        if nums[fast] != nums[slow]:
            slow += 1
            nums[slow] = nums[fast]  # write unique element
    return slow + 1  # count of unique elements

# remove_duplicates([1,1,2,2,3]) => 3, list becomes [1,2,3,...]
static int removeDuplicates(int[] nums) {
    if (nums.length == 0) return 0;
    int slow = 0; // position of last unique element
    for (int fast = 1; fast < nums.length; fast++) {
        if (nums[fast] != nums[slow]) {
            slow++;
            nums[slow] = nums[fast]; // write unique element
        }
    }
    return slow + 1; // count of unique elements
}
// removeDuplicates([1,1,2,2,3]) => 3, array becomes [1,2,3,...]

Time: O(n), Space: O(1). The fast pointer reads, the slow pointer writes. Clean and efficient.

Pattern 3: Fast & Slow (Floyd’s Cycle Detection)

Detect a Cycle in a Linked List

The slow pointer moves 1 step at a time, the fast pointer moves 2 steps. If there’s a cycle, they’ll eventually meet. If there’s no cycle, the fast pointer hits the end.

Think of it like two runners on a track. If the track is circular, the faster runner will eventually lap the slower one.

function hasCycle(head) {
  let slow = head, fast = head;
  while (fast !== null && fast.next !== null) {
    slow = slow.next;        // 1 step
    fast = fast.next.next;   // 2 steps
    if (slow === fast) return true; // they met -- cycle!
  }
  return false; // fast hit the end -- no cycle
}
def has_cycle(head):
    slow = fast = head
    while fast and fast.next:
        slow = slow.next         # 1 step
        fast = fast.next.next    # 2 steps
        if slow == fast:
            return True          # they met -- cycle!
    return False  # fast hit the end -- no cycle
static boolean hasCycle(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;        // 1 step
        fast = fast.next.next;   // 2 steps
        if (slow == fast) return true; // they met -- cycle!
    }
    return false; // fast hit the end -- no cycle
}

Time: O(n), Space: O(1). Without this trick, we’d need a hash set to track visited nodes (O(n) space).

Find the Middle of a Linked List

Same idea but simpler. When the fast pointer reaches the end, the slow pointer is at the middle.

function findMiddle(head) {
  let slow = head, fast = head;
  while (fast !== null && fast.next !== null) {
    slow = slow.next;
    fast = fast.next.next;
  }
  return slow; // slow is at the middle
}
def find_middle(head):
    slow = fast = head
    while fast and fast.next:
        slow = slow.next
        fast = fast.next.next
    return slow  # slow is at the middle
static ListNode findMiddle(ListNode head) {
    ListNode slow = head, fast = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    return slow; // slow is at the middle
}

How to Recognize a Two Pointer Problem

Look for these clues:

  • The array is sorted (or can be sorted) — opposite direction likely works
  • We need to find a pair that satisfies some condition
  • We need to do something in-place with O(1) space
  • The problem involves a linked list (cycle, middle, intersection)
  • The brute force involves nested loops comparing pairs — two pointers can often reduce it to a single pass

In simple language, whenever we see “sorted array” and “find a pair” in the same problem, our first thought should be two pointers from opposite ends. It’s one of those patterns that, once we see it, we can’t unsee it.


5

Sliding Window

intermediate sliding-window arrays strings pattern

Sliding window is a technique where we maintain a “window” (a contiguous subarray or substring) and slide it across the data. Instead of recalculating everything from scratch for each position, we update the window incrementally — add the new element, remove the old one.

In simple language, imagine looking through a picture frame that we slide across a wall of photos. We don’t re-examine every photo each time — we just note what entered the frame and what left it.

Why It’s Efficient

The brute force approach to “find the best subarray of size k” checks every possible subarray — that’s O(n * k). With sliding window, we process each element at most twice (once when it enters the window, once when it leaves). That gives us O(n).

Two Types of Windows

Fixed-Size Window (k = 3)
Step 1: 1 3 5 2 8 sum = 9
Step 2: 1 3 5 2 8 sum = 9 - 1 + 2 = 10
Step 3: 1 3 5 2 8 sum = 10 - 3 + 8 = 15
Window always has exactly k elements. Slide by removing left, adding right.
Variable-Size Window
Expand: a b c a b valid: "abc"
Shrink: a b c a b dup 'a'! shrink left
Window grows and shrinks based on a condition (validity check).

Fixed-Size Window: Max Sum Subarray of Size K

Given an array and a number k, find the subarray of size k with the maximum sum. We compute the sum of the first window, then slide it — subtract the element that leaves, add the element that enters.

function maxSumSubarray(arr, k) {
  // build the first window
  let windowSum = 0;
  for (let i = 0; i < k; i++) {
    windowSum += arr[i];
  }

  let maxSum = windowSum;
  // slide the window: remove left element, add right element
  for (let i = k; i < arr.length; i++) {
    windowSum += arr[i] - arr[i - k]; // add new, remove old
    maxSum = Math.max(maxSum, windowSum);
  }
  return maxSum;
}
// maxSumSubarray([2, 1, 5, 1, 3, 2], 3) => 9  (5 + 1 + 3)
def max_sum_subarray(arr, k):
    # build the first window
    window_sum = sum(arr[:k])
    max_sum = window_sum

    # slide the window: remove left element, add right element
    for i in range(k, len(arr)):
        window_sum += arr[i] - arr[i - k]  # add new, remove old
        max_sum = max(max_sum, window_sum)
    return max_sum

# max_sum_subarray([2, 1, 5, 1, 3, 2], 3) => 9  (5 + 1 + 3)
static int maxSumSubarray(int[] arr, int k) {
    // build the first window
    int windowSum = 0;
    for (int i = 0; i < k; i++) {
        windowSum += arr[i];
    }

    int maxSum = windowSum;
    // slide the window: remove left element, add right element
    for (int i = k; i < arr.length; i++) {
        windowSum += arr[i] - arr[i - k]; // add new, remove old
        maxSum = Math.max(maxSum, windowSum);
    }
    return maxSum;
}
// maxSumSubarray([2, 1, 5, 1, 3, 2], 3) => 9  (5 + 1 + 3)

Time: O(n), Space: O(1). Each element is visited exactly once. The brute force approach (recomputing the sum for each window) would be O(n * k).

Variable-Size Window: Longest Substring Without Repeating Characters

This is one of the most popular interview questions (LeetCode #3). We expand the window by moving the right pointer. When we hit a duplicate character, we shrink the window from the left until the duplicate is removed.

function lengthOfLongestSubstring(s) {
  const seen = new Set();
  let left = 0, maxLen = 0;

  for (let right = 0; right < s.length; right++) {
    // shrink window until no duplicate
    while (seen.has(s[right])) {
      seen.delete(s[left]);
      left++;
    }
    seen.add(s[right]);
    maxLen = Math.max(maxLen, right - left + 1);
  }
  return maxLen;
}
// lengthOfLongestSubstring("abcabcbb") => 3  ("abc")
// lengthOfLongestSubstring("bbbbb") => 1  ("b")
def length_of_longest_substring(s):
    seen = set()
    left = 0
    max_len = 0

    for right in range(len(s)):
        # shrink window until no duplicate
        while s[right] in seen:
            seen.discard(s[left])
            left += 1
        seen.add(s[right])
        max_len = max(max_len, right - left + 1)
    return max_len

# length_of_longest_substring("abcabcbb") => 3  ("abc")
# length_of_longest_substring("bbbbb") => 1  ("b")
static int lengthOfLongestSubstring(String s) {
    Set<Character> seen = new HashSet<>();
    int left = 0, maxLen = 0;

    for (int right = 0; right < s.length(); right++) {
        // shrink window until no duplicate
        while (seen.contains(s.charAt(right))) {
            seen.remove(s.charAt(left));
            left++;
        }
        seen.add(s.charAt(right));
        maxLen = Math.max(maxLen, right - left + 1);
    }
    return maxLen;
}
// lengthOfLongestSubstring("abcabcbb") => 3  ("abc")

Time: O(n), Space: O(min(n, alphabet_size)). Each character enters and leaves the set at most once, so the inner while loop doesn’t make this O(n²) — it’s amortized O(n).

Sliding Window Template

Most variable-size window problems follow the same structure. Here’s the template:

function slidingWindow(arr) {
  let left = 0;
  let result = 0; // or Infinity, depending on min/max

  for (let right = 0; right < arr.length; right++) {
    // 1. Expand: add arr[right] to our window state

    // 2. Shrink: while window is invalid, remove arr[left]
    while (/* window is invalid */) {
      // remove arr[left] from window state
      left++;
    }

    // 3. Update: record the best answer so far
    result = Math.max(result, right - left + 1);
  }
  return result;
}
def sliding_window(arr):
    left = 0
    result = 0  # or float('inf'), depending on min/max

    for right in range(len(arr)):
        # 1. Expand: add arr[right] to our window state

        # 2. Shrink: while window is invalid, remove arr[left]
        while True:  # window is invalid
            # remove arr[left] from window state
            left += 1
            break  # replace with actual condition

        # 3. Update: record the best answer so far
        result = max(result, right - left + 1)
    return result
static int slidingWindow(int[] arr) {
    int left = 0;
    int result = 0; // or Integer.MAX_VALUE for min problems

    for (int right = 0; right < arr.length; right++) {
        // 1. Expand: add arr[right] to our window state

        // 2. Shrink: while window is invalid, remove arr[left]
        while (/* window is invalid */) {
            // remove arr[left] from window state
            left++;
        }

        // 3. Update: record the best answer so far
        result = Math.max(result, right - left + 1);
    }
    return result;
}

The three steps are always the same: expand the right side, shrink the left side when the window becomes invalid, and update the result. The only difference between problems is what “invalid” means and what state we track.

Fixed vs Variable: When to Use Which

Fixed-Size WindowVariable-Size Window
Window size k is given in the problemWe’re looking for the longest/shortest subarray
”Subarray of size k""Smallest subarray with sum >= target”
Slide by exactly 1 each stepLeft pointer moves only when window is invalid
Usually simpler to implementNeeds a condition to shrink/expand

How to Recognize a Sliding Window Problem

Look for these clues:

  • The problem asks about contiguous subarrays or substrings
  • Words like “maximum”, “minimum”, “longest”, “shortest” paired with a subarray constraint
  • We need to find something optimal within a range of elements
  • The brute force would involve checking all possible subarrays (O(n²) or worse)

The only difference between sliding window and two pointers is that sliding window specifically deals with contiguous elements (subarrays/substrings), while two pointers is more general.

In simple language, if the problem says “contiguous subarray” and “find the best one”, sliding window is almost always the answer. We just need to figure out whether it’s fixed-size or variable-size, and what condition controls the window.


Arrays, Strings & Hashing

6

Arrays & Operations

beginner arrays data-structure operations

An array is the simplest data structure there is. It’s a contiguous block of memory that stores elements of the same type, one after another. Think of it like a row of lockers in a hallway — each locker has a number (index) and holds one item.

The reason arrays are so fundamental is that almost every other data structure is built on top of them. Stacks, queues, heaps, hash tables — they all use arrays under the hood.

How Arrays Work in Memory

When we create an array of size 5, the computer reserves 5 consecutive memory slots. Because the slots are next to each other, the computer can jump to any element instantly using math: address = base_address + (index × element_size).

Array in Memory (contiguous)
10 20 30 40 50
[0] [1] [2] [3] [4]
Address: 0x100 → 0x104 → 0x108 → 0x10C → 0x110

This is why accessing an element by index is O(1) — the computer doesn’t need to search. It just calculates the address and goes straight there.

Static vs Dynamic Arrays

Static arrays have a fixed size decided at creation time. Once we say “give me an array of size 10,” that’s it. We can’t make it bigger. C and Java (with int[]) use static arrays.

Dynamic arrays can grow and shrink. When the internal array fills up, it creates a new array (usually 2x the size), copies everything over, and ditches the old one. This resize is O(n), but it happens so rarely that the amortized cost of appending is still O(1).

JavaScript’s Array, Python’s list, and Java’s ArrayList are all dynamic arrays.

Common Operations & Time Complexities

OperationTime ComplexityWhy
Access by indexO(1)Direct address calculation
Search (unsorted)O(n)Might need to check every element
Search (sorted)O(log n)Binary search
Insert at endO(1) amortizedJust put it at the next slot
Insert at beginning/middleO(n)Shift all elements to the right
Delete at endO(1)Just remove the last element
Delete at beginning/middleO(n)Shift all elements to the left

The key insight: arrays are fast for reading, slow for inserting/deleting in the middle. Every insert or delete (except at the end) forces us to shift elements.

Basic Array Operations

// Creating and accessing arrays
const nums = [10, 20, 30, 40, 50];
console.log(nums[0]);  // 10 — O(1) access
console.log(nums[4]);  // 50

// Insert at end — O(1) amortized
nums.push(60);

// Insert at beginning — O(n), shifts everything
nums.unshift(5);

// Delete from end — O(1)
nums.pop();

// Delete from beginning — O(n), shifts everything
nums.shift();

// Search — O(n)
const index = nums.indexOf(30);  // 2
# Creating and accessing arrays (lists in Python)
nums = [10, 20, 30, 40, 50]
print(nums[0])  # 10 — O(1) access
print(nums[4])  # 50

# Insert at end — O(1) amortized
nums.append(60)

# Insert at beginning — O(n), shifts everything
nums.insert(0, 5)

# Delete from end — O(1)
nums.pop()

# Delete from beginning — O(n), shifts everything
nums.pop(0)

# Search — O(n)
index = nums.index(30)  # 2
// Using ArrayList (dynamic array)
import java.util.ArrayList;

ArrayList<Integer> nums = new ArrayList<>(List.of(10, 20, 30, 40, 50));
System.out.println(nums.get(0));  // 10 — O(1) access

// Insert at end — O(1) amortized
nums.add(60);

// Insert at beginning — O(n), shifts everything
nums.add(0, 5);

// Delete from end — O(1)
nums.remove(nums.size() - 1);

// Delete from beginning — O(n), shifts everything
nums.remove(0);

// Search — O(n)
int index = nums.indexOf(30);  // 2

Array Reversal

Reversing an array is one of the most common interview warm-up questions. The trick is to use two pointers — one at the start, one at the end — and swap them, moving inward.

function reverseArray(arr) {
  let left = 0;
  let right = arr.length - 1;
  while (left < right) {
    [arr[left], arr[right]] = [arr[right], arr[left]]; // swap
    left++;
    right--;
  }
  return arr;
}

console.log(reverseArray([1, 2, 3, 4, 5])); // [5, 4, 3, 2, 1]
def reverse_array(arr):
    left, right = 0, len(arr) - 1
    while left < right:
        arr[left], arr[right] = arr[right], arr[left]  # swap
        left += 1
        right -= 1
    return arr

print(reverse_array([1, 2, 3, 4, 5]))  # [5, 4, 3, 2, 1]
public static void reverseArray(int[] arr) {
    int left = 0, right = arr.length - 1;
    while (left < right) {
        int temp = arr[left];
        arr[left] = arr[right]; // swap
        arr[right] = temp;
        left++;
        right--;
    }
}
// [1, 2, 3, 4, 5] → [5, 4, 3, 2, 1]

Time: O(n), Space: O(1) — we do it in-place without extra memory.

Array Rotation

Rotating an array means shifting elements left or right by k positions. The elements that fall off one end wrap around to the other.

For example, rotating [1, 2, 3, 4, 5] left by 2 gives us [3, 4, 5, 1, 2].

The clever trick: reverse three times. Reverse the first k elements, reverse the rest, then reverse the whole array.

function rotateLeft(arr, k) {
  k = k % arr.length; // handle k > length
  reverse(arr, 0, k - 1);       // reverse first k
  reverse(arr, k, arr.length - 1); // reverse the rest
  reverse(arr, 0, arr.length - 1); // reverse entire array
  return arr;
}

function reverse(arr, l, r) {
  while (l < r) {
    [arr[l], arr[r]] = [arr[r], arr[l]];
    l++; r--;
  }
}

console.log(rotateLeft([1, 2, 3, 4, 5], 2)); // [3, 4, 5, 1, 2]
def rotate_left(arr, k):
    k = k % len(arr)  # handle k > length
    def reverse(l, r):
        while l < r:
            arr[l], arr[r] = arr[r], arr[l]
            l += 1
            r -= 1

    reverse(0, k - 1)          # reverse first k
    reverse(k, len(arr) - 1)   # reverse the rest
    reverse(0, len(arr) - 1)   # reverse entire array
    return arr

print(rotate_left([1, 2, 3, 4, 5], 2))  # [3, 4, 5, 1, 2]
public static void rotateLeft(int[] arr, int k) {
    k = k % arr.length; // handle k > length
    reverse(arr, 0, k - 1);           // reverse first k
    reverse(arr, k, arr.length - 1);  // reverse the rest
    reverse(arr, 0, arr.length - 1);  // reverse entire array
}

private static void reverse(int[] arr, int l, int r) {
    while (l < r) {
        int temp = arr[l];
        arr[l++] = arr[r];
        arr[r--] = temp;
    }
}
// [1, 2, 3, 4, 5] with k=2 → [3, 4, 5, 1, 2]

Time: O(n), Space: O(1) — three passes through the array, no extra space.

When to Use Arrays

Arrays are the go-to when we need:

  • Fast access by index — O(1) lookups
  • Sequential storage — elements stored in order
  • Cache-friendly iteration — contiguous memory means CPU caches love arrays

They’re NOT ideal when we need:

  • Frequent inserts/deletes in the middle (use a linked list)
  • Dynamic key-based lookups (use a hash map)
  • Fast search in unsorted data (use a hash set)

In simple language, arrays are the bread and butter of programming. They’re simple, they’re fast for reading, and they’re the building block for almost everything else. Master the two-pointer technique for array problems — it comes up everywhere.


7

Strings & Pattern Matching

intermediate strings palindrome anagram pattern-matching

A string is really just an array of characters. Under the hood, "hello" is stored as ['h', 'e', 'l', 'l', 'o']. This means most array techniques (two pointers, sliding window, hashing) work on strings too.

The big catch? In some languages, strings are immutable — we can’t change them in place. Every time we “modify” a string, we’re actually creating a brand new one.

String Immutability

This is one of those things that trips people up in interviews. Let’s be clear about which languages do what:

LanguageMutable?What happens on “modification”
JavaScriptImmutableCreates a new string
PythonImmutableCreates a new string
JavaImmutable (String), Mutable (StringBuilder)String creates new, StringBuilder modifies in place

Why does this matter? Because concatenating strings in a loop is O(n²) in immutable languages. Each concatenation creates a new string and copies everything over.

// BAD — O(n²) because strings are immutable
let result = "";
for (let i = 0; i < 1000; i++) {
  result += "a"; // creates a new string every time!
}

// GOOD — use array and join at the end
const parts = [];
for (let i = 0; i < 1000; i++) {
  parts.push("a");
}
const result2 = parts.join(""); // one allocation
# BAD — O(n²) because strings are immutable
result = ""
for i in range(1000):
    result += "a"  # creates a new string every time!

# GOOD — use list and join at the end
parts = []
for i in range(1000):
    parts.append("a")
result2 = "".join(parts)  # one allocation
// BAD — O(n²) with String
String result = "";
for (int i = 0; i < 1000; i++) {
    result += "a"; // creates a new String every time!
}

// GOOD — use StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append("a"); // modifies in place
}
String result2 = sb.toString(); // one allocation

Palindrome Check

A palindrome reads the same forwards and backwards. “racecar” is a palindrome. “hello” is not.

The classic approach: two pointers — one at the start, one at the end. If they always match as they walk inward, it’s a palindrome.

function isPalindrome(s) {
  let left = 0;
  let right = s.length - 1;
  while (left < right) {
    if (s[left] !== s[right]) return false;
    left++;
    right--;
  }
  return true;
}

console.log(isPalindrome("racecar")); // true
console.log(isPalindrome("hello"));   // false
def is_palindrome(s):
    left, right = 0, len(s) - 1
    while left < right:
        if s[left] != s[right]:
            return False
        left += 1
        right -= 1
    return True

print(is_palindrome("racecar"))  # True
print(is_palindrome("hello"))    # False
public static boolean isPalindrome(String s) {
    int left = 0, right = s.length() - 1;
    while (left < right) {
        if (s.charAt(left) != s.charAt(right)) return false;
        left++;
        right--;
    }
    return true;
}
// "racecar" → true, "hello" → false

Time: O(n), Space: O(1) — we just walk from both ends.

Interview tip: If the problem says “consider only alphanumeric characters and ignore case,” we add a check to skip non-alphanumeric characters and compare lowercase versions. This is LeetCode 125 — Valid Palindrome.

Anagram Detection

Two strings are anagrams if they contain the exact same characters, just rearranged. “listen” and “silent” are anagrams. “hello” and “world” are not.

The idea: count the frequency of each character in both strings. If the counts match, they’re anagrams.

function isAnagram(s, t) {
  if (s.length !== t.length) return false;

  const freq = {};
  for (const ch of s) freq[ch] = (freq[ch] || 0) + 1;
  for (const ch of t) {
    if (!freq[ch]) return false; // char not found or count is 0
    freq[ch]--;
  }
  return true;
}

console.log(isAnagram("listen", "silent")); // true
console.log(isAnagram("hello", "world"));   // false
def is_anagram(s, t):
    if len(s) != len(t):
        return False

    freq = {}
    for ch in s:
        freq[ch] = freq.get(ch, 0) + 1
    for ch in t:
        if freq.get(ch, 0) == 0:
            return False  # char not found or count is 0
        freq[ch] -= 1
    return True

print(is_anagram("listen", "silent"))  # True
print(is_anagram("hello", "world"))    # False
public static boolean isAnagram(String s, String t) {
    if (s.length() != t.length()) return false;

    int[] freq = new int[26]; // assuming lowercase a-z
    for (char ch : s.toCharArray()) freq[ch - 'a']++;
    for (char ch : t.toCharArray()) {
        freq[ch - 'a']--;
        if (freq[ch - 'a'] < 0) return false;
    }
    return true;
}
// "listen", "silent" → true

Time: O(n), Space: O(1) — the frequency map has at most 26 keys (for lowercase English letters), so space is constant.

Character Frequency Counting

Counting how often each character appears is the backbone of tons of string problems. Let’s use it to find the first non-repeating character — a classic interview question.

function firstNonRepeating(s) {
  const freq = {};
  for (const ch of s) freq[ch] = (freq[ch] || 0) + 1;

  // second pass: find first char with count 1
  for (let i = 0; i < s.length; i++) {
    if (freq[s[i]] === 1) return i;
  }
  return -1; // all characters repeat
}

console.log(firstNonRepeating("aabbcdd")); // 4 (index of 'c')
console.log(firstNonRepeating("aabb"));    // -1
def first_non_repeating(s):
    freq = {}
    for ch in s:
        freq[ch] = freq.get(ch, 0) + 1

    # second pass: find first char with count 1
    for i, ch in enumerate(s):
        if freq[ch] == 1:
            return i
    return -1  # all characters repeat

print(first_non_repeating("aabbcdd"))  # 4 (index of 'c')
print(first_non_repeating("aabb"))     # -1
public static int firstNonRepeating(String s) {
    int[] freq = new int[26];
    for (char ch : s.toCharArray()) freq[ch - 'a']++;

    // second pass: find first char with count 1
    for (int i = 0; i < s.length(); i++) {
        if (freq[s.charAt(i) - 'a'] == 1) return i;
    }
    return -1; // all characters repeat
}
// "aabbcdd" → 4 (index of 'c')

Time: O(n), Space: O(1) — two passes through the string, fixed-size frequency map.

Common String Patterns in Interviews

Here’s a cheat sheet of what technique to reach for based on the problem type:

Problem TypeGo-To Technique
Palindrome checkTwo pointers (start + end)
Anagram check / groupingCharacter frequency map
Substring searchSliding window
First unique / most frequent charFrequency counting
String reversalTwo pointers
Longest substring without repeatsSliding window + hash set
Pattern matching (exact)KMP or Rabin-Karp (advanced)

String Comparison Gotcha

One thing that bites people in Java: never compare strings with ==. The == operator compares memory addresses, not content. Always use .equals().

// JavaScript — === works fine for strings
const a = "hello";
const b = "hello";
console.log(a === b); // true (compares values)
# Python — == works fine for strings
a = "hello"
b = "hello"
print(a == b)  # True (compares values)
// Java — NEVER use == for strings!
String a = new String("hello");
String b = new String("hello");
System.out.println(a == b);      // false (compares references!)
System.out.println(a.equals(b)); // true (compares content)

In simple language, strings are arrays with a personality. The immutability thing is the big gotcha — always think about whether we’re creating new strings when we modify them. For interview problems, the frequency map is our best friend. If we need to count characters, check for anagrams, or find unique characters, a hash map with character counts is almost always the way to go.


8

Hash Maps & Hash Sets

beginner hash-map hash-set hash-table data-structure

A hash table is a data structure that maps keys to values using a hash function. Think of it like a magical dictionary — we give it a word (key), and it instantly tells us the definition (value). No searching, no scanning. Just boom, here’s the answer.

This “instant lookup” is what makes hash tables so powerful. Insert, lookup, and delete are all O(1) on average. That’s why they show up in almost every coding interview.

How Hash Tables Work

Here’s the basic idea:

  1. We have an array of buckets (slots).
  2. When we insert a key-value pair, a hash function converts the key into a number.
  3. That number (modulo the array size) tells us which bucket to put it in.
  4. To look up a key, we hash it again and go straight to that bucket.
Hash Table — Insert "apple" → 5
"apple" hash("apple") = 2
[0] empty
[1] empty
[2] "apple" → 5
[3] empty
[4] empty

What Happens When Two Keys Hash to the Same Bucket? (Collisions)

This is called a collision. Two different keys produce the same hash value. It’s inevitable — we’re mapping infinite possible keys to a finite number of buckets.

Collision at Bucket [2] — Chaining
[0] empty
[1] "grape" → 8
[2] "apple" → 5 "mango" → 3
[3] empty
Both "apple" and "mango" hash to bucket [2] — stored as a linked list

There are two main ways to handle collisions:

Chaining — Each bucket holds a linked list. If two keys hash to the same bucket, we just add to the list. To find a key, we hash it to the bucket and then walk the list. This is the most common approach.

Open addressing — If a bucket is taken, we probe (check) the next bucket, and the next, until we find an empty one. Linear probing checks the very next slot. Quadratic probing jumps by increasing amounts.

In practice, we rarely implement this ourselves. The language’s built-in hash map handles collisions for us.

Hash Map vs Hash Set

They’re siblings, not twins.

Hash MapHash Set
StoresKey-value pairsKeys only
Use forLooking up values by keyChecking if something exists
Example{name: "Alice", age: 30}{"Alice", "Bob", "Charlie"}
JSMap / {}Set
Pythondictset
JavaHashMapHashSet

In simple language: a hash map is a dictionary (word → definition). A hash set is a guest list (just names, no extra info).

Time Complexity

OperationAverageWorst Case
InsertO(1)O(n)
LookupO(1)O(n)
DeleteO(1)O(n)

The worst case happens when everything hashes to the same bucket (all collisions). In practice, with a good hash function and proper resizing, we always get O(1).

Two Sum with Hash Map

This is THE classic hash map problem. Given an array and a target sum, find two numbers that add up to it. Return their indices.

The brute force approach checks every pair — O(n²). With a hash map, we do it in one pass.

The idea: for each number, we check if target - number already exists in our map. If yes, we found the pair. If no, we store the current number and move on.

function twoSum(nums, target) {
  const map = new Map(); // value → index

  for (let i = 0; i < nums.length; i++) {
    const complement = target - nums[i];
    if (map.has(complement)) {
      return [map.get(complement), i]; // found it!
    }
    map.set(nums[i], i); // store for later
  }
  return []; // no solution
}

console.log(twoSum([2, 7, 11, 15], 9)); // [0, 1]
def two_sum(nums, target):
    seen = {}  # value → index

    for i, num in enumerate(nums):
        complement = target - num
        if complement in seen:
            return [seen[complement], i]  # found it!
        seen[num] = i  # store for later
    return []  # no solution

print(two_sum([2, 7, 11, 15], 9))  # [0, 1]
public static int[] twoSum(int[] nums, int target) {
    Map<Integer, Integer> map = new HashMap<>(); // value → index

    for (int i = 0; i < nums.length; i++) {
        int complement = target - nums[i];
        if (map.containsKey(complement)) {
            return new int[]{map.get(complement), i}; // found it!
        }
        map.put(nums[i], i); // store for later
    }
    return new int[]{}; // no solution
}
// [2, 7, 11, 15] with target 9 → [0, 1]

Time: O(n), Space: O(n) — one pass through the array, hash map stores up to n elements.

Frequency Counter

Counting how often things appear is one of the most common hash map patterns. We can use it to find the most frequent element, check for duplicates, group items, and more.

function frequencyCount(arr) {
  const freq = new Map();
  for (const item of arr) {
    freq.set(item, (freq.get(item) || 0) + 1);
  }
  return freq;
}

const counts = frequencyCount([1, 2, 2, 3, 3, 3]);
console.log(counts); // Map { 1 → 1, 2 → 2, 3 → 3 }

// Find the most frequent element
let maxCount = 0, mostFrequent = null;
for (const [key, count] of counts) {
  if (count > maxCount) { maxCount = count; mostFrequent = key; }
}
console.log(mostFrequent); // 3
def frequency_count(arr):
    freq = {}
    for item in arr:
        freq[item] = freq.get(item, 0) + 1
    return freq

counts = frequency_count([1, 2, 2, 3, 3, 3])
print(counts)  # {1: 1, 2: 2, 3: 3}

# Find the most frequent element
most_frequent = max(counts, key=counts.get)
print(most_frequent)  # 3
public static Map<Integer, Integer> frequencyCount(int[] arr) {
    Map<Integer, Integer> freq = new HashMap<>();
    for (int item : arr) {
        freq.merge(item, 1, Integer::sum);
    }
    return freq;
}
// [1, 2, 2, 3, 3, 3] → {1=1, 2=2, 3=3}

// Find the most frequent element
int mostFrequent = Collections.max(freq.entrySet(),
    Map.Entry.comparingByValue()).getKey(); // 3

Find Duplicates with Hash Set

Need to check if something exists? That’s a hash set’s entire job. O(1) lookups, no values to worry about.

function containsDuplicate(nums) {
  const seen = new Set();
  for (const num of nums) {
    if (seen.has(num)) return true; // already seen this one
    seen.add(num);
  }
  return false;
}

console.log(containsDuplicate([1, 2, 3, 1]));    // true
console.log(containsDuplicate([1, 2, 3, 4]));    // false
def contains_duplicate(nums):
    seen = set()
    for num in nums:
        if num in seen:
            return True  # already seen this one
        seen.add(num)
    return False

print(contains_duplicate([1, 2, 3, 1]))  # True
print(contains_duplicate([1, 2, 3, 4]))  # False
public static boolean containsDuplicate(int[] nums) {
    Set<Integer> seen = new HashSet<>();
    for (int num : nums) {
        if (!seen.add(num)) return true; // add returns false if already exists
    }
    return false;
}
// [1, 2, 3, 1] → true, [1, 2, 3, 4] → false

Time: O(n), Space: O(n) — much better than the O(n²) brute force of checking every pair.

When to Use Hash Map vs Hash Set

Reach for a hash map when we need to:

  • Associate values with keys (two sum: value → index)
  • Count frequencies (character counting, word counting)
  • Cache computed results (memoization)

Reach for a hash set when we need to:

  • Check existence quickly (have we seen this before?)
  • Remove duplicates from a collection
  • Find intersection or union of two collections

The Big Picture

Hash tables are the Swiss Army knife of data structures. When we’re stuck on a brute-force O(n²) solution that checks every pair, the first question we should ask ourselves is: “Can a hash map bring this down to O(n)?” The answer is surprisingly often yes.

In simple language, a hash map gives us instant lookups by trading space for speed. We store extra data (the map itself) so we can find things in O(1) instead of scanning through the whole collection. It’s the most useful data structure in coding interviews, period.


9

Prefix Sum & Difference Arrays

intermediate prefix-sum difference-array range-query pattern

Prefix sum is a technique where we precompute cumulative sums so we can answer “what’s the sum from index i to index j?” in O(1) time. Without it, every range sum query takes O(n). With it, we do O(n) work once, and then every query is instant.

Think of it like keeping a running total. Instead of adding up numbers every time someone asks, we maintain a list of “sum so far” values and just subtract to get any range.

Building a Prefix Sum Array

Given an array nums, the prefix sum array prefix[i] = sum of all elements from index 0 to i.

Prefix Sum Construction
Original:
2 4 1 3 5
↓ cumulative sums ↓
Prefix:
2 6 7 10 15
[0] [1] [2] [3] [4]
prefix[3] = 2 + 4 + 1 + 3 = 10
function buildPrefixSum(nums) {
  const prefix = new Array(nums.length);
  prefix[0] = nums[0];
  for (let i = 1; i < nums.length; i++) {
    prefix[i] = prefix[i - 1] + nums[i]; // running total
  }
  return prefix;
}

console.log(buildPrefixSum([2, 4, 1, 3, 5])); // [2, 6, 7, 10, 15]
def build_prefix_sum(nums):
    prefix = [0] * len(nums)
    prefix[0] = nums[0]
    for i in range(1, len(nums)):
        prefix[i] = prefix[i - 1] + nums[i]  # running total
    return prefix

print(build_prefix_sum([2, 4, 1, 3, 5]))  # [2, 6, 7, 10, 15]
public static int[] buildPrefixSum(int[] nums) {
    int[] prefix = new int[nums.length];
    prefix[0] = nums[0];
    for (int i = 1; i < nums.length; i++) {
        prefix[i] = prefix[i - 1] + nums[i]; // running total
    }
    return prefix;
}
// [2, 4, 1, 3, 5] → [2, 6, 7, 10, 15]

Time: O(n) to build, O(1) per query after that.

Range Sum Query

Once we have the prefix sum array, getting the sum of any range [left, right] is just one subtraction:

sum(left, right) = prefix[right] - prefix[left - 1]

If left is 0, then sum(0, right) = prefix[right].

function rangeSum(prefix, left, right) {
  if (left === 0) return prefix[right];
  return prefix[right] - prefix[left - 1];
}

const prefix = buildPrefixSum([2, 4, 1, 3, 5]);
console.log(rangeSum(prefix, 1, 3)); // 4 + 1 + 3 = 8
console.log(rangeSum(prefix, 0, 4)); // 2 + 4 + 1 + 3 + 5 = 15
console.log(rangeSum(prefix, 2, 2)); // 1 (single element)
def range_sum(prefix, left, right):
    if left == 0:
        return prefix[right]
    return prefix[right] - prefix[left - 1]

prefix = build_prefix_sum([2, 4, 1, 3, 5])
print(range_sum(prefix, 1, 3))  # 4 + 1 + 3 = 8
print(range_sum(prefix, 0, 4))  # 2 + 4 + 1 + 3 + 5 = 15
print(range_sum(prefix, 2, 2))  # 1 (single element)
public static int rangeSum(int[] prefix, int left, int right) {
    if (left == 0) return prefix[right];
    return prefix[right] - prefix[left - 1];
}

// prefix = [2, 6, 7, 10, 15]
// rangeSum(prefix, 1, 3) → 10 - 2 = 8 ✓
// rangeSum(prefix, 0, 4) → 15 ✓

Pro tip: Some people prefer building the prefix array with an extra 0 at the beginning: prefix[0] = 0, prefix[1] = nums[0], .... This eliminates the left === 0 edge case because the formula becomes prefix[right + 1] - prefix[left] always.

Subarray Sum Equals K

This is one of the most popular prefix sum interview problems (LeetCode 560). Given an array and a target k, find the number of subarrays that sum to k.

The key insight: if prefixSum[j] - prefixSum[i] = k, then the subarray from i+1 to j sums to k. So we need to count how many times prefixSum[j] - k has appeared before.

We use a hash map to track how many times each prefix sum has occurred.

function subarraySum(nums, k) {
  const prefixCount = new Map(); // prefix sum → count
  prefixCount.set(0, 1);  // empty prefix has sum 0

  let sum = 0;
  let count = 0;

  for (const num of nums) {
    sum += num;
    // if (sum - k) exists, there's a subarray ending here with sum k
    if (prefixCount.has(sum - k)) {
      count += prefixCount.get(sum - k);
    }
    prefixCount.set(sum, (prefixCount.get(sum) || 0) + 1);
  }
  return count;
}

console.log(subarraySum([1, 1, 1], 2));     // 2 ([1,1] at two positions)
console.log(subarraySum([1, 2, 3], 3));     // 2 ([1,2] and [3])
def subarray_sum(nums, k):
    prefix_count = {0: 1}  # prefix sum → count
    current_sum = 0
    count = 0

    for num in nums:
        current_sum += num
        # if (sum - k) exists, there's a subarray ending here with sum k
        if current_sum - k in prefix_count:
            count += prefix_count[current_sum - k]
        prefix_count[current_sum] = prefix_count.get(current_sum, 0) + 1

    return count

print(subarray_sum([1, 1, 1], 2))   # 2
print(subarray_sum([1, 2, 3], 3))   # 2
public static int subarraySum(int[] nums, int k) {
    Map<Integer, Integer> prefixCount = new HashMap<>();
    prefixCount.put(0, 1); // empty prefix has sum 0

    int sum = 0, count = 0;

    for (int num : nums) {
        sum += num;
        if (prefixCount.containsKey(sum - k)) {
            count += prefixCount.get(sum - k);
        }
        prefixCount.merge(sum, 1, Integer::sum);
    }
    return count;
}
// [1, 1, 1] with k=2 → 2

Time: O(n), Space: O(n) — the hash map + prefix sum combo turns an O(n²) problem into O(n).

Difference Array Technique

Prefix sum is for range queries. The difference array is for range updates.

Imagine we have an array of zeros and we need to add 5 to every element from index 2 to 5. Then add 3 to every element from index 1 to 4. Doing this naively is O(n) per update. With a difference array, each update is O(1).

The idea: instead of updating every element in the range, we mark the start and end+1 of the range. Then we compute the prefix sum at the end to get the final array.

function applyRangeUpdates(n, updates) {
  const diff = new Array(n + 1).fill(0); // difference array

  for (const [left, right, value] of updates) {
    diff[left] += value;        // start adding here
    diff[right + 1] -= value;   // stop adding after here
  }

  // build result from difference array (prefix sum)
  const result = new Array(n);
  result[0] = diff[0];
  for (let i = 1; i < n; i++) {
    result[i] = result[i - 1] + diff[i];
  }
  return result;
}

// Array of size 5, two updates: add 5 to [1,3], add 3 to [2,4]
console.log(applyRangeUpdates(5, [[1, 3, 5], [2, 4, 3]]));
// [0, 5, 8, 8, 3]
def apply_range_updates(n, updates):
    diff = [0] * (n + 1)  # difference array

    for left, right, value in updates:
        diff[left] += value       # start adding here
        diff[right + 1] -= value  # stop adding after here

    # build result from difference array (prefix sum)
    result = [0] * n
    result[0] = diff[0]
    for i in range(1, n):
        result[i] = result[i - 1] + diff[i]
    return result

# Array of size 5, two updates: add 5 to [1,3], add 3 to [2,4]
print(apply_range_updates(5, [[1, 3, 5], [2, 4, 3]]))
# [0, 5, 8, 8, 3]
public static int[] applyRangeUpdates(int n, int[][] updates) {
    int[] diff = new int[n + 1]; // difference array

    for (int[] update : updates) {
        diff[update[0]] += update[2];       // start adding here
        diff[update[1] + 1] -= update[2];   // stop adding after here
    }

    // build result from difference array (prefix sum)
    int[] result = new int[n];
    result[0] = diff[0];
    for (int i = 1; i < n; i++) {
        result[i] = result[i - 1] + diff[i];
    }
    return result;
}
// n=5, updates=[[1,3,5],[2,4,3]] → [0, 5, 8, 8, 3]

Each update is O(1). Final reconstruction is O(n). So for q updates on an array of size n, it’s O(n + q) instead of O(n * q).

When to Use These Techniques

TechniqueUse WhenComplexity
Prefix SumMany range sum queries on a static arrayO(n) build + O(1) per query
Prefix Sum + Hash MapCount subarrays with a target sumO(n) time + O(n) space
Difference ArrayMany range update operations, query at the endO(1) per update + O(n) to reconstruct

In simple language, prefix sum is about doing homework upfront so we don’t repeat work later. We spend O(n) once to build the prefix array, and then every range query is free (O(1)). The difference array flips this idea — instead of querying ranges efficiently, it lets us update ranges efficiently. Both are must-know patterns for coding interviews.


10

Matrix Problems

intermediate matrix 2d-array traversal

A matrix is just a 2D array — an array of arrays. We access elements with two indices: matrix[row][col]. Think of it like a spreadsheet — rows go across, columns go down.

Matrices show up a lot in interviews: image rotation, game boards, grids, graphs represented as adjacency matrices. The key is getting comfortable with the indexing and the common traversal patterns.

Row-Major Indexing

In most languages, matrices are stored in row-major order — all elements of row 0 come first in memory, then row 1, then row 2, etc.

3×4 Matrix — matrix[row][col]
c0 c1 c2 c3
r0 1 2 3 4
r1 5 6 7 8
r2 9 10 11 12
matrix[1][2] = 7  |  rows = 3, cols = 4

Quick cheat sheet for matrix dimensions:

  • rows = matrix.length
  • cols = matrix[0].length
  • To visit every element: nested loop over rows and cols

Spiral Order Traversal

Spiral traversal visits elements in a clockwise spiral: right across the top, down the right side, left across the bottom, up the left side, then repeat on the inner ring.

Spiral Traversal Order
1→ 2→ 3→ 4↓
↑12 →→ ↓↓ 5↓
↑11 ←← ↑↑ 6↓
10← 9← 8← 7←
Outer ring first, then inner ring

The approach: maintain four boundaries (top, bottom, left, right) and shrink them inward after processing each side.

function spiralOrder(matrix) {
  const result = [];
  let top = 0, bottom = matrix.length - 1;
  let left = 0, right = matrix[0].length - 1;

  while (top <= bottom && left <= right) {
    // go right across top row
    for (let c = left; c <= right; c++) result.push(matrix[top][c]);
    top++;

    // go down right column
    for (let r = top; r <= bottom; r++) result.push(matrix[r][right]);
    right--;

    // go left across bottom row (if rows remain)
    if (top <= bottom) {
      for (let c = right; c >= left; c--) result.push(matrix[bottom][c]);
      bottom--;
    }

    // go up left column (if cols remain)
    if (left <= right) {
      for (let r = bottom; r >= top; r--) result.push(matrix[r][left]);
      left++;
    }
  }
  return result;
}

console.log(spiralOrder([[1,2,3],[4,5,6],[7,8,9]]));
// [1, 2, 3, 6, 9, 8, 7, 4, 5]
def spiral_order(matrix):
    result = []
    top, bottom = 0, len(matrix) - 1
    left, right = 0, len(matrix[0]) - 1

    while top <= bottom and left <= right:
        # go right across top row
        for c in range(left, right + 1):
            result.append(matrix[top][c])
        top += 1

        # go down right column
        for r in range(top, bottom + 1):
            result.append(matrix[r][right])
        right -= 1

        # go left across bottom row (if rows remain)
        if top <= bottom:
            for c in range(right, left - 1, -1):
                result.append(matrix[bottom][c])
            bottom -= 1

        # go up left column (if cols remain)
        if left <= right:
            for r in range(bottom, top - 1, -1):
                result.append(matrix[r][left])
            left += 1

    return result

print(spiral_order([[1,2,3],[4,5,6],[7,8,9]]))
# [1, 2, 3, 6, 9, 8, 7, 4, 5]
public static List<Integer> spiralOrder(int[][] matrix) {
    List<Integer> result = new ArrayList<>();
    int top = 0, bottom = matrix.length - 1;
    int left = 0, right = matrix[0].length - 1;

    while (top <= bottom && left <= right) {
        for (int c = left; c <= right; c++) result.add(matrix[top][c]);
        top++;
        for (int r = top; r <= bottom; r++) result.add(matrix[r][right]);
        right--;
        if (top <= bottom) {
            for (int c = right; c >= left; c--) result.add(matrix[bottom][c]);
            bottom--;
        }
        if (left <= right) {
            for (int r = bottom; r >= top; r--) result.add(matrix[r][left]);
            left++;
        }
    }
    return result;
}
// [[1,2,3],[4,5,6],[7,8,9]] → [1, 2, 3, 6, 9, 8, 7, 4, 5]

Time: O(m × n), Space: O(1) — we visit every element exactly once (output array doesn’t count as extra space).

Rotate Matrix 90 Degrees

Rotating a matrix 90 degrees clockwise is a classic interview question (LeetCode 48). The trick is a two-step process:

  1. Transpose — swap matrix[i][j] with matrix[j][i] (rows become columns)
  2. Reverse each row — flip every row left-to-right

That’s it. Transpose + reverse = 90-degree clockwise rotation.

function rotate(matrix) {
  const n = matrix.length;

  // Step 1: transpose (swap across diagonal)
  for (let i = 0; i < n; i++) {
    for (let j = i + 1; j < n; j++) {
      [matrix[i][j], matrix[j][i]] = [matrix[j][i], matrix[i][j]];
    }
  }

  // Step 2: reverse each row
  for (let i = 0; i < n; i++) {
    matrix[i].reverse();
  }
  return matrix;
}

console.log(rotate([[1,2,3],[4,5,6],[7,8,9]]));
// [[7,4,1],[8,5,2],[9,6,3]]
def rotate(matrix):
    n = len(matrix)

    # Step 1: transpose (swap across diagonal)
    for i in range(n):
        for j in range(i + 1, n):
            matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j]

    # Step 2: reverse each row
    for row in matrix:
        row.reverse()

    return matrix

print(rotate([[1,2,3],[4,5,6],[7,8,9]]))
# [[7,4,1],[8,5,2],[9,6,3]]
public static void rotate(int[][] matrix) {
    int n = matrix.length;

    // Step 1: transpose (swap across diagonal)
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            int temp = matrix[i][j];
            matrix[i][j] = matrix[j][i];
            matrix[j][i] = temp;
        }
    }

    // Step 2: reverse each row
    for (int[] row : matrix) {
        int l = 0, r = n - 1;
        while (l < r) {
            int temp = row[l];
            row[l++] = row[r];
            row[r--] = temp;
        }
    }
}
// [[1,2,3],[4,5,6],[7,8,9]] → [[7,4,1],[8,5,2],[9,6,3]]

Time: O(n²), Space: O(1) — done in-place.

Rotation cheat sheet:

  • 90° clockwise = transpose + reverse rows
  • 90° counter-clockwise = transpose + reverse columns
  • 180° = reverse rows + reverse columns

Search in a Sorted Matrix

When the matrix is sorted (each row is sorted left-to-right, and the first element of each row is greater than the last element of the previous row), we can search in O(log(m × n)) using binary search. We treat the 2D matrix as a flat sorted array.

But there’s a simpler variant: rows are sorted left-to-right AND columns are sorted top-to-bottom (but no guarantee between rows). For this, we use the staircase search — start from the top-right corner.

function searchMatrix(matrix, target) {
  // Start from top-right corner
  let row = 0;
  let col = matrix[0].length - 1;

  while (row < matrix.length && col >= 0) {
    if (matrix[row][col] === target) return true;
    if (matrix[row][col] > target) {
      col--;  // too big, go left
    } else {
      row++;  // too small, go down
    }
  }
  return false;
}

const matrix = [
  [1,  4,  7, 11],
  [2,  5,  8, 12],
  [3,  6,  9, 16],
  [10, 13, 14, 17]
];
console.log(searchMatrix(matrix, 5));  // true
console.log(searchMatrix(matrix, 20)); // false
def search_matrix(matrix, target):
    # Start from top-right corner
    row, col = 0, len(matrix[0]) - 1

    while row < len(matrix) and col >= 0:
        if matrix[row][col] == target:
            return True
        if matrix[row][col] > target:
            col -= 1  # too big, go left
        else:
            row += 1  # too small, go down
    return False

matrix = [
    [1,  4,  7, 11],
    [2,  5,  8, 12],
    [3,  6,  9, 16],
    [10, 13, 14, 17]
]
print(search_matrix(matrix, 5))   # True
print(search_matrix(matrix, 20))  # False
public static boolean searchMatrix(int[][] matrix, int target) {
    // Start from top-right corner
    int row = 0, col = matrix[0].length - 1;

    while (row < matrix.length && col >= 0) {
        if (matrix[row][col] == target) return true;
        if (matrix[row][col] > target) col--;  // go left
        else row++;  // go down
    }
    return false;
}
// Search for 5 → true, search for 20 → false

Time: O(m + n), Space: O(1) — we eliminate one row or column with each step.

Why does starting from the top-right work? Because going left decreases the value and going down increases it. This gives us a clear decision at every step — no backtracking needed.

Common Matrix Traversal Patterns

Here’s a quick reference for the traversal patterns that come up most often:

PatternWhen to UseKey Idea
Row-by-rowBasic processingTwo nested loops
Column-by-columnTranspose-like operationsOuter loop = col, inner = row
SpiralPrint/flatten in spiral orderFour boundaries, shrink inward
DiagonalZigzag or diagonal groupingi + j = constant for each diagonal
BFS/DFS on gridConnected components, shortest pathTreat each cell as a graph node

In simple language, matrix problems are really just array problems in 2D. The hard part isn’t the algorithm — it’s keeping track of the boundaries and not going out of bounds. The spiral traversal and rotation by transpose+reverse are the two patterns that show up most in interviews. Get those down and the rest follows.


11

Sorting Algorithms

intermediate sorting merge-sort quick-sort algorithms

Sorting is one of the most fundamental operations in computer science. Almost every real-world application involves sorting at some point — displaying search results, ranking items, organizing data for binary search, or removing duplicates efficiently.

In simple language, sorting means arranging elements in a specific order (usually ascending or descending). The interesting part isn’t what sorting does — it’s how different algorithms do it, and why some are drastically faster than others.

Why Sorting Matters for Interviews

Many problems become trivially easy once the data is sorted. Two sum? Sort first, then use two pointers. Finding duplicates? Sort first, then check neighbors. Merge intervals? Sort by start time first.

Knowing when to sort (and what it costs) is a superpower in interviews.

The Basic Sorts (Quick Overview)

These are O(n²) algorithms. We won’t implement them all in detail, but we need to know they exist and why they’re slow.

Bubble Sort

Repeatedly swap adjacent elements if they’re in the wrong order. Like bubbles rising to the surface. Simple but painfully slow.

Selection Sort

Find the minimum element, put it first. Find the next minimum, put it second. Repeat. Also O(n²) — we scan the remaining array each time.

Insertion Sort

Build the sorted array one element at a time. Pick the next element, insert it into the correct position in the already-sorted portion. Actually decent for small or nearly-sorted arrays.

// Insertion sort -- good for small/nearly-sorted arrays
function insertionSort(arr) {
  for (let i = 1; i < arr.length; i++) {
    let key = arr[i];
    let j = i - 1;
    while (j >= 0 && arr[j] > key) {
      arr[j + 1] = arr[j]; // shift larger elements right
      j--;
    }
    arr[j + 1] = key; // insert in correct position
  }
  return arr;
}
# Insertion sort -- good for small/nearly-sorted arrays
def insertion_sort(arr):
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        while j >= 0 and arr[j] > key:
            arr[j + 1] = arr[j]  # shift larger elements right
            j -= 1
        arr[j + 1] = key  # insert in correct position
    return arr
// Insertion sort -- good for small/nearly-sorted arrays
static void insertionSort(int[] arr) {
    for (int i = 1; i < arr.length; i++) {
        int key = arr[i];
        int j = i - 1;
        while (j >= 0 && arr[j] > key) {
            arr[j + 1] = arr[j]; // shift larger elements right
            j--;
        }
        arr[j + 1] = key; // insert in correct position
    }
}

Merge Sort — Divide and Conquer

Merge sort is the go-to O(n log n) algorithm that’s always O(n log n), no matter what input we throw at it. The idea is classic divide and conquer:

  1. Split the array in half
  2. Recursively sort each half
  3. Merge the two sorted halves together

The magic is in the merge step — combining two sorted arrays into one sorted array takes just O(n) time.

Merge Sort: Split and Merge
[38, 27, 43, 3, 9, 82, 10]
--- split ---
[38, 27, 43, 3]
[9, 82, 10]
--- split ---
[38, 27]
[43, 3]
[9, 82]
[10]
--- merge back (sorted) ---
[27, 38]
[3, 43]
[9, 82]
[10]
--- merge ---
[3, 27, 38, 43]
[9, 10, 82]
--- final merge ---
[3, 9, 10, 27, 38, 43, 82]
// Merge sort -- always O(n log n), stable sort
function mergeSort(arr) {
  if (arr.length <= 1) return arr;

  const mid = Math.floor(arr.length / 2);
  const left = mergeSort(arr.slice(0, mid));
  const right = mergeSort(arr.slice(mid));

  return merge(left, right);
}

function merge(left, right) {
  const result = [];
  let i = 0, j = 0;

  while (i < left.length && j < right.length) {
    if (left[i] <= right[j]) result.push(left[i++]);
    else result.push(right[j++]);
  }
  // add remaining elements
  return result.concat(left.slice(i)).concat(right.slice(j));
}
# Merge sort -- always O(n log n), stable sort
def merge_sort(arr):
    if len(arr) <= 1:
        return arr

    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])

    return merge(left, right)

def merge(left, right):
    result = []
    i = j = 0

    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i]); i += 1
        else:
            result.append(right[j]); j += 1
    # add remaining elements
    result.extend(left[i:])
    result.extend(right[j:])
    return result
// Merge sort -- always O(n log n), stable sort
static void mergeSort(int[] arr, int left, int right) {
    if (left >= right) return;

    int mid = left + (right - left) / 2;
    mergeSort(arr, left, mid);
    mergeSort(arr, mid + 1, right);
    merge(arr, left, mid, right);
}

static void merge(int[] arr, int left, int mid, int right) {
    int[] temp = new int[right - left + 1];
    int i = left, j = mid + 1, k = 0;

    while (i <= mid && j <= right) {
        if (arr[i] <= arr[j]) temp[k++] = arr[i++];
        else temp[k++] = arr[j++];
    }
    while (i <= mid) temp[k++] = arr[i++];
    while (j <= right) temp[k++] = arr[j++];
    System.arraycopy(temp, 0, arr, left, temp.length);
}

Quick Sort — The Fast Gambler

Quick sort is usually the fastest in practice, but it has a weakness: its worst case is O(n²). The algorithm:

  1. Pick a pivot element
  2. Partition the array so everything smaller goes left, everything larger goes right
  3. Recursively sort the left and right parts

The pivot choice matters a lot. Bad pivot (like always picking the first element on sorted data) gives O(n²). Random pivot gives O(n log n) on average.

// Quick sort -- O(n log n) average, in-place
function quickSort(arr, low = 0, high = arr.length - 1) {
  if (low >= high) return;

  const pivotIdx = partition(arr, low, high);
  quickSort(arr, low, pivotIdx - 1);
  quickSort(arr, pivotIdx + 1, high);
}

function partition(arr, low, high) {
  const pivot = arr[high]; // pick last element as pivot
  let i = low - 1;

  for (let j = low; j < high; j++) {
    if (arr[j] < pivot) {
      i++;
      [arr[i], arr[j]] = [arr[j], arr[i]]; // swap
    }
  }
  [arr[i + 1], arr[high]] = [arr[high], arr[i + 1]];
  return i + 1; // pivot's final position
}
# Quick sort -- O(n log n) average, in-place
def quick_sort(arr, low=0, high=None):
    if high is None:
        high = len(arr) - 1
    if low >= high:
        return

    pivot_idx = partition(arr, low, high)
    quick_sort(arr, low, pivot_idx - 1)
    quick_sort(arr, pivot_idx + 1, high)

def partition(arr, low, high):
    pivot = arr[high]  # pick last element as pivot
    i = low - 1

    for j in range(low, high):
        if arr[j] < pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]  # swap
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1  # pivot's final position
// Quick sort -- O(n log n) average, in-place
static void quickSort(int[] arr, int low, int high) {
    if (low >= high) return;

    int pivotIdx = partition(arr, low, high);
    quickSort(arr, low, pivotIdx - 1);
    quickSort(arr, pivotIdx + 1, high);
}

static int partition(int[] arr, int low, int high) {
    int pivot = arr[high]; // pick last element as pivot
    int i = low - 1;

    for (int j = low; j < high; j++) {
        if (arr[j] < pivot) {
            i++;
            int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp;
        }
    }
    int temp = arr[i + 1]; arr[i + 1] = arr[high]; arr[high] = temp;
    return i + 1; // pivot's final position
}

Built-in Sorts with Custom Comparators

In interviews, we almost never write merge sort from scratch. We use the language’s built-in sort with a custom comparator. This is essential to know.

// Sort numbers (default .sort() is lexicographic!)
const nums = [10, 9, 2, 30];
nums.sort((a, b) => a - b);       // ascending: [2, 9, 10, 30]
nums.sort((a, b) => b - a);       // descending: [30, 10, 9, 2]

// Sort objects by a property
const people = [{name: "Bob", age: 25}, {name: "Alice", age: 30}];
people.sort((a, b) => a.age - b.age); // sort by age ascending

// Sort strings by length
const words = ["banana", "kiwi", "apple"];
words.sort((a, b) => a.length - b.length);
# Sort numbers
nums = [10, 9, 2, 30]
nums.sort()                          # in-place ascending: [2, 9, 10, 30]
sorted_desc = sorted(nums, reverse=True)  # new list descending

# Sort objects by a key
people = [{"name": "Bob", "age": 25}, {"name": "Alice", "age": 30}]
people.sort(key=lambda p: p["age"])  # sort by age ascending

# Sort strings by length
words = ["banana", "kiwi", "apple"]
words.sort(key=lambda w: len(w))
// Sort numbers
int[] nums = {10, 9, 2, 30};
Arrays.sort(nums);  // ascending: [2, 9, 10, 30]

// Sort objects with comparator
String[] words = {"banana", "kiwi", "apple"};
Arrays.sort(words, (a, b) -> a.length() - b.length()); // by length

// Sort a list of objects
List<int[]> intervals = new ArrayList<>();
intervals.sort((a, b) -> a[0] - b[0]); // sort by first element

Gotcha: JavaScript’s default .sort() converts elements to strings. [10, 9, 2].sort() gives [10, 2, 9]. Always pass a comparator for numbers.

Stability — Does Order of Equal Elements Matter?

A stable sort preserves the relative order of elements with equal keys. If we sort students by grade and two students both have an A, a stable sort keeps them in their original order.

  • Stable: Merge sort, insertion sort, Python’s Timsort, Java’s Arrays.sort for objects
  • Unstable: Quick sort, heap sort

When does stability matter? When we sort by multiple criteria. Sort by name first, then by grade — a stable sort preserves the name ordering within each grade.

The Big Comparison Table

AlgorithmBestAverageWorstSpaceStable?
Bubble SortO(n)O(n²)O(n²)O(1)Yes
Selection SortO(n²)O(n²)O(n²)O(1)No
Insertion SortO(n)O(n²)O(n²)O(1)Yes
Merge SortO(n log n)O(n log n)O(n log n)O(n)Yes
Quick SortO(n log n)O(n log n)O(n²)O(log n)No
Heap SortO(n log n)O(n log n)O(n log n)O(1)No

When to Use Which

  • Merge sort: When we need guaranteed O(n log n) and stability. Great for linked lists (no extra space needed).
  • Quick sort: Default choice for arrays. Fastest in practice due to cache efficiency. Most language built-in sorts use a variant of quick sort.
  • Insertion sort: Small arrays (n < 20) or nearly sorted data. Many built-in sorts switch to insertion sort for small subarrays.
  • Heap sort: When we need O(n log n) guarantee with O(1) space. Rarely used directly — we’ll cover heaps in a later topic.

In interviews, the key insight is: sorting costs O(n log n), so if our solution is already O(n log n) or worse, sorting is “free” — it doesn’t change the overall complexity.


Linked Lists, Stacks & Queues

12

Linked Lists

beginner linked-list pointers data-structure

A linked list is a linear data structure where elements aren’t stored in contiguous memory. Instead, each element (called a node) holds two things: the data and a pointer (or reference) to the next node.

In simple language, think of a linked list like a scavenger hunt. Each clue tells us where to find the next clue. We can only move forward by following the chain — we can’t jump to the 5th clue directly.

Why Use Linked Lists?

Arrays are great, but they have a weakness: inserting or deleting in the middle requires shifting everything over. That’s O(n). Linked lists can insert or delete in O(1) if we already have a pointer to the right spot.

The trade-off? We lose random access. Getting the 5th element means following 5 pointers. That’s O(n) instead of O(1) with arrays.

Singly Linked List

Each node points to the next. The last node points to null.

Singly Linked List
head -->
10 | next
-->
20 | next
-->
30 | next
-->
null

Doubly Linked List

Each node has pointers to both the next AND previous nodes. We can traverse in both directions.

Doubly Linked List
null
<-->
prev | 10 | next
<-->
prev | 20 | next
<-->
null

Node Definition

// Singly linked list node
class ListNode {
  constructor(val, next = null) {
    this.val = val;
    this.next = next;
  }
}

// Doubly linked list node
class DListNode {
  constructor(val, prev = null, next = null) {
    this.val = val;
    this.prev = prev;
    this.next = next;
  }
}
# Singly linked list node
class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# Doubly linked list node
class DListNode:
    def __init__(self, val=0, prev=None, next=None):
        self.val = val
        self.prev = prev
        self.next = next
// Singly linked list node
class ListNode {
    int val;
    ListNode next;
    ListNode(int val) { this.val = val; }
    ListNode(int val, ListNode next) {
        this.val = val;
        this.next = next;
    }
}

// Doubly linked list node
class DListNode {
    int val;
    DListNode prev, next;
    DListNode(int val) { this.val = val; }
}

Core Operations

Insert at Head — O(1)

// Insert a new node at the beginning
function insertAtHead(head, val) {
  const newNode = new ListNode(val);
  newNode.next = head; // point new node to old head
  return newNode;       // new node is now the head
}
# Insert a new node at the beginning
def insert_at_head(head, val):
    new_node = ListNode(val)
    new_node.next = head  # point new node to old head
    return new_node       # new node is now the head
// Insert a new node at the beginning
static ListNode insertAtHead(ListNode head, int val) {
    ListNode newNode = new ListNode(val);
    newNode.next = head; // point new node to old head
    return newNode;       // new node is now the head
}

Delete a Node — O(n) search + O(1) delete

// Delete first node with given value
function deleteNode(head, val) {
  const dummy = new ListNode(0, head); // dummy to handle head deletion
  let prev = dummy;

  while (prev.next) {
    if (prev.next.val === val) {
      prev.next = prev.next.next; // skip over the node
      break;
    }
    prev = prev.next;
  }
  return dummy.next;
}
# Delete first node with given value
def delete_node(head, val):
    dummy = ListNode(0, head)  # dummy to handle head deletion
    prev = dummy

    while prev.next:
        if prev.next.val == val:
            prev.next = prev.next.next  # skip over the node
            break
        prev = prev.next
    return dummy.next
// Delete first node with given value
static ListNode deleteNode(ListNode head, int val) {
    ListNode dummy = new ListNode(0, head); // dummy to handle head deletion
    ListNode prev = dummy;

    while (prev.next != null) {
        if (prev.next.val == val) {
            prev.next = prev.next.next; // skip over the node
            break;
        }
        prev = prev.next;
    }
    return dummy.next;
}

Pro tip: The dummy node trick is hugely useful. It eliminates the edge case of deleting the head node. We’ll use it constantly in linked list problems.

Reverse a Linked List

This is the #1 most common linked list interview question. The idea: we walk through the list and flip each pointer to point backward instead of forward.

// Reverse a singly linked list -- O(n) time, O(1) space
function reverseList(head) {
  let prev = null;
  let curr = head;

  while (curr) {
    const next = curr.next; // save next before we break the link
    curr.next = prev;       // reverse the pointer
    prev = curr;            // move prev forward
    curr = next;            // move curr forward
  }
  return prev; // prev is the new head
}
# Reverse a singly linked list -- O(n) time, O(1) space
def reverse_list(head):
    prev = None
    curr = head

    while curr:
        next_node = curr.next  # save next before we break the link
        curr.next = prev       # reverse the pointer
        prev = curr            # move prev forward
        curr = next_node       # move curr forward
    return prev  # prev is the new head
// Reverse a singly linked list -- O(n) time, O(1) space
static ListNode reverseList(ListNode head) {
    ListNode prev = null;
    ListNode curr = head;

    while (curr != null) {
        ListNode next = curr.next; // save next before we break the link
        curr.next = prev;          // reverse the pointer
        prev = curr;               // move prev forward
        curr = next;               // move curr forward
    }
    return prev; // prev is the new head
}

Three pointers is all we need: prev, curr, and next. Memorize this pattern — it shows up everywhere.

Detect a Cycle — Floyd’s Algorithm

If a linked list has a cycle (a node’s next points back to an earlier node), we’ll loop forever trying to reach null. Floyd’s tortoise and hare algorithm detects this using two pointers moving at different speeds.

The slow pointer moves 1 step at a time. The fast pointer moves 2 steps. If there’s a cycle, they’ll eventually meet. If there’s no cycle, fast reaches null.

// Detect cycle in a linked list
function hasCycle(head) {
  let slow = head;
  let fast = head;

  while (fast && fast.next) {
    slow = slow.next;       // move 1 step
    fast = fast.next.next;  // move 2 steps
    if (slow === fast) return true; // they met -- cycle exists!
  }
  return false; // fast reached null -- no cycle
}
# Detect cycle in a linked list
def has_cycle(head):
    slow = head
    fast = head

    while fast and fast.next:
        slow = slow.next       # move 1 step
        fast = fast.next.next  # move 2 steps
        if slow == fast:
            return True  # they met -- cycle exists!
    return False  # fast reached null -- no cycle
// Detect cycle in a linked list
static boolean hasCycle(ListNode head) {
    ListNode slow = head;
    ListNode fast = head;

    while (fast != null && fast.next != null) {
        slow = slow.next;       // move 1 step
        fast = fast.next.next;  // move 2 steps
        if (slow == fast) return true; // they met -- cycle exists!
    }
    return false; // fast reached null -- no cycle
}

Why does this work? Think of two runners on a circular track. The faster one will always lap the slower one. Same idea here.

Merge Two Sorted Lists

Another interview classic. We have two sorted linked lists and want to merge them into one sorted list.

// Merge two sorted linked lists
function mergeTwoLists(l1, l2) {
  const dummy = new ListNode(0); // dummy head
  let tail = dummy;

  while (l1 && l2) {
    if (l1.val <= l2.val) {
      tail.next = l1;
      l1 = l1.next;
    } else {
      tail.next = l2;
      l2 = l2.next;
    }
    tail = tail.next;
  }
  tail.next = l1 || l2; // attach remaining nodes
  return dummy.next;
}
# Merge two sorted linked lists
def merge_two_lists(l1, l2):
    dummy = ListNode(0)  # dummy head
    tail = dummy

    while l1 and l2:
        if l1.val <= l2.val:
            tail.next = l1
            l1 = l1.next
        else:
            tail.next = l2
            l2 = l2.next
        tail = tail.next
    tail.next = l1 or l2  # attach remaining nodes
    return dummy.next
// Merge two sorted linked lists
static ListNode mergeTwoLists(ListNode l1, ListNode l2) {
    ListNode dummy = new ListNode(0); // dummy head
    ListNode tail = dummy;

    while (l1 != null && l2 != null) {
        if (l1.val <= l2.val) {
            tail.next = l1;
            l1 = l1.next;
        } else {
            tail.next = l2;
            l2 = l2.next;
        }
        tail = tail.next;
    }
    tail.next = (l1 != null) ? l1 : l2; // attach remaining
    return dummy.next;
}

Key Patterns to Remember

PatternWhen to Use
Dummy nodeWhen the head might change (deletion, merging)
Two pointers (slow/fast)Cycle detection, finding middle node
Prev/Curr/Next pointersReversing a list
RecursionWhen iterative feels awkward (merge, reverse)

Complexity Cheat Sheet

OperationSingly Linked ListDoubly Linked List
Access by indexO(n)O(n)
Insert at headO(1)O(1)
Insert at tailO(n) / O(1) with tail pointerO(1) with tail pointer
Delete (given node)O(n) to find prevO(1) — we have prev pointer
SearchO(n)O(n)

The main takeaway: linked lists trade random access for efficient insertion/deletion. In interviews, watch for problems where we need to manipulate pointers — that’s where linked lists shine.


13

Stacks

beginner stack LIFO data-structure

A stack is a Last In, First Out (LIFO) data structure. The last thing we put in is the first thing we take out. Think of a stack of plates — we always add and remove from the top.

In simple language, a stack is a collection where we can only interact with the top element. We push things on, we pop things off. That’s it.

Stack Operations

Every stack supports these core operations, all in O(1) time:

  • push(item) — add to the top
  • pop() — remove from the top
  • peek() / top() — look at the top without removing
  • isEmpty() — check if the stack is empty
Stack: Push and Pop
push(40)
-- top --
40
30
20
10
pop() -> 40
-- top --
30
20
10

Using Stacks in Each Language

We don’t usually build stacks from scratch. Every language has a built-in way to use one.

// JavaScript -- just use an array
const stack = [];
stack.push(10);       // push
stack.push(20);
stack.push(30);
stack.pop();          // 30 -- removes from top
stack[stack.length - 1]; // 20 -- peek at top
stack.length === 0;   // false -- isEmpty check
# Python -- just use a list
stack = []
stack.append(10)     # push
stack.append(20)
stack.append(30)
stack.pop()          # 30 -- removes from top
stack[-1]            # 20 -- peek at top
len(stack) == 0      # False -- isEmpty check
// Java -- use ArrayDeque (faster than Stack class)
Deque<Integer> stack = new ArrayDeque<>();
stack.push(10);      // push
stack.push(20);
stack.push(30);
stack.pop();         // 30 -- removes from top
stack.peek();        // 20 -- peek at top
stack.isEmpty();     // false -- isEmpty check

Java note: Don’t use java.util.Stack — it’s a legacy class from Java 1.0 with thread synchronization overhead. Use ArrayDeque instead.

The Call Stack Connection

Every time our program calls a function, it goes on the call stack. When the function returns, it gets popped off. This is why stack overflow happens — too many function calls (usually from infinite recursion) and the call stack runs out of space.

// The call stack in action
function a() { return b(); }  // a() waits on call stack
function b() { return c(); }  // b() waits on call stack
function c() { return 42; }   // c() runs, pops off, then b, then a

a(); // Call stack: [a] -> [a, b] -> [a, b, c] -> [a, b] -> [a] -> []
# The call stack in action
def a(): return b()  # a() waits on call stack
def b(): return c()  # b() waits on call stack
def c(): return 42   # c() runs, pops off, then b, then a

a()  # Call stack: [a] -> [a, b] -> [a, b, c] -> [a, b] -> [a] -> []
// The call stack in action
static int a() { return b(); }  // a() waits on call stack
static int b() { return c(); }  // b() waits on call stack
static int c() { return 42; }   // c() runs, pops off, then b, then a

// Call stack: [a] -> [a, b] -> [a, b, c] -> [a, b] -> [a] -> []

Understanding the call stack helps us reason about recursion and convert recursive solutions to iterative ones using an explicit stack.

Classic Problem: Valid Parentheses

Given a string with (), {}, [], determine if every opening bracket has a matching closing bracket in the right order. This is THE classic stack problem.

The idea: push opening brackets, pop when we see a closing bracket. If the popped bracket doesn’t match, it’s invalid.

// Valid parentheses -- O(n) time, O(n) space
function isValid(s) {
  const stack = [];
  const pairs = { ')': '(', '}': '{', ']': '[' };

  for (const ch of s) {
    if ('({['.includes(ch)) {
      stack.push(ch);              // opening bracket: push
    } else {
      if (stack.pop() !== pairs[ch]) return false; // must match
    }
  }
  return stack.length === 0; // stack must be empty at the end
}
# Valid parentheses -- O(n) time, O(n) space
def is_valid(s):
    stack = []
    pairs = {')': '(', '}': '{', ']': '['}

    for ch in s:
        if ch in '({[':
            stack.append(ch)          # opening bracket: push
        else:
            if not stack or stack.pop() != pairs[ch]:
                return False          # must match
    return len(stack) == 0  # stack must be empty at the end
// Valid parentheses -- O(n) time, O(n) space
static boolean isValid(String s) {
    Deque<Character> stack = new ArrayDeque<>();
    Map<Character, Character> pairs = Map.of(')', '(', '}', '{', ']', '[');

    for (char ch : s.toCharArray()) {
        if (ch == '(' || ch == '{' || ch == '[') {
            stack.push(ch);           // opening bracket: push
        } else {
            if (stack.isEmpty() || stack.pop() != pairs.get(ch))
                return false;         // must match
        }
    }
    return stack.isEmpty(); // stack must be empty at the end
}

Classic Problem: Next Greater Element

Given an array, for each element, find the next element to the right that’s larger. If there’s none, the answer is -1.

Brute force is O(n²). With a stack, we can do it in O(n).

The trick: we iterate from right to left, maintaining a stack of elements we’ve seen. We pop off anything smaller than the current element (they can never be the “next greater” for anything to the left).

// Next greater element -- O(n) with a stack
function nextGreaterElement(nums) {
  const result = new Array(nums.length).fill(-1);
  const stack = []; // stores indices

  for (let i = nums.length - 1; i >= 0; i--) {
    while (stack.length && stack[stack.length - 1] <= nums[i]) {
      stack.pop(); // pop smaller elements -- they're useless now
    }
    if (stack.length) {
      result[i] = stack[stack.length - 1]; // top is next greater
    }
    stack.push(nums[i]);
  }
  return result;
}
// [4, 5, 2, 10] -> [5, 10, 10, -1]
# Next greater element -- O(n) with a stack
def next_greater_element(nums):
    result = [-1] * len(nums)
    stack = []  # stores values

    for i in range(len(nums) - 1, -1, -1):
        while stack and stack[-1] <= nums[i]:
            stack.pop()  # pop smaller elements -- they're useless now
        if stack:
            result[i] = stack[-1]  # top is next greater
        stack.append(nums[i])
    return result
# [4, 5, 2, 10] -> [5, 10, 10, -1]
// Next greater element -- O(n) with a stack
static int[] nextGreaterElement(int[] nums) {
    int[] result = new int[nums.length];
    Arrays.fill(result, -1);
    Deque<Integer> stack = new ArrayDeque<>();

    for (int i = nums.length - 1; i >= 0; i--) {
        while (!stack.isEmpty() && stack.peek() <= nums[i]) {
            stack.pop(); // pop smaller elements -- they're useless now
        }
        if (!stack.isEmpty()) {
            result[i] = stack.peek(); // top is next greater
        }
        stack.push(nums[i]);
    }
    return result;
}
// [4, 5, 2, 10] -> [5, 10, 10, -1]

Min Stack Concept

A Min Stack is a stack that also supports getMin() in O(1) time. The trick is maintaining a second stack that tracks the minimum at each level.

We push to the min stack whenever the new value is less than or equal to the current minimum. We pop from the min stack whenever we pop the current minimum from the main stack.

// Min Stack -- all operations in O(1)
class MinStack {
  constructor() {
    this.stack = [];
    this.minStack = []; // tracks minimums
  }
  push(val) {
    this.stack.push(val);
    // push to minStack if it's empty or val <= current min
    if (!this.minStack.length || val <= this.getMin()) {
      this.minStack.push(val);
    }
  }
  pop() {
    if (this.stack.pop() === this.getMin()) {
      this.minStack.pop(); // min was removed, pop from minStack too
    }
  }
  top()    { return this.stack[this.stack.length - 1]; }
  getMin() { return this.minStack[this.minStack.length - 1]; }
}
# Min Stack -- all operations in O(1)
class MinStack:
    def __init__(self):
        self.stack = []
        self.min_stack = []  # tracks minimums

    def push(self, val):
        self.stack.append(val)
        # push to min_stack if it's empty or val <= current min
        if not self.min_stack or val <= self.get_min():
            self.min_stack.append(val)

    def pop(self):
        if self.stack.pop() == self.get_min():
            self.min_stack.pop()  # min was removed

    def top(self):
        return self.stack[-1]

    def get_min(self):
        return self.min_stack[-1]
// Min Stack -- all operations in O(1)
class MinStack {
    Deque<Integer> stack = new ArrayDeque<>();
    Deque<Integer> minStack = new ArrayDeque<>();

    void push(int val) {
        stack.push(val);
        if (minStack.isEmpty() || val <= minStack.peek()) {
            minStack.push(val);
        }
    }
    void pop() {
        if (stack.pop().equals(minStack.peek())) {
            minStack.pop();
        }
    }
    int top()    { return stack.peek(); }
    int getMin() { return minStack.peek(); }
}

When to Think “Stack”

Here’s a mental checklist. If the problem involves any of these, consider using a stack:

  • Matching pairs (parentheses, HTML tags)
  • Most recent first processing (undo, browser back button)
  • Next greater/smaller element problems
  • Expression evaluation (postfix, infix)
  • DFS traversal (iterative version uses an explicit stack)
  • Recursion simulation (any recursion can be converted to a stack)

Stacks are simple but incredibly versatile. Master the patterns above and we’ll be able to spot stack problems in interviews immediately.


14

Monotonic Stack

intermediate monotonic-stack stack pattern

A monotonic stack is just a regular stack with one rule: the elements inside are always in sorted order (either increasing or decreasing from bottom to top). Whenever we push a new element, we pop off anything that violates this order.

In simple language, it’s a stack that stays sorted at all times. We enforce the ordering ourselves during push operations.

Why It Matters

Monotonic stacks turn O(n²) “find the next greater/smaller element” problems into O(n). That’s a massive improvement. They show up in a surprising number of interview problems: stock prices, temperatures, histograms, and more.

Two Flavors

Monotonically increasing (bottom to top): each element is larger than the one below it. We pop off elements that are greater than or equal to the new one.

Monotonically decreasing (bottom to top): each element is smaller than the one below it. We pop off elements that are less than or equal to the new one.

Monotonic Decreasing Stack -- Processing [3, 1, 4, 1, 5]
push 3:
3
push 1:
3 1
(1 < 3, ok)
push 4:
4
popped 1, 3 (both smaller than 4)
push 1:
4 1
(1 < 4, ok)
push 5:
5
popped 1, 4 (both smaller than 5)

The key insight: each element gets pushed once and popped at most once. So even though we have a while loop inside a for loop, the total work is O(n).

Pattern: Next Greater Element

We covered this briefly in the stacks topic. Let’s formalize the monotonic stack approach. We use a decreasing stack (from bottom to top) and iterate left to right.

When we’re about to push an element, everything we pop off has found its “next greater element” — it’s the element we’re about to push.

// Next greater element using monotonic stack -- O(n)
function nextGreaterElement(nums) {
  const result = new Array(nums.length).fill(-1);
  const stack = []; // stores indices, values decrease bottom to top

  for (let i = 0; i < nums.length; i++) {
    // pop all smaller elements -- current num is their "next greater"
    while (stack.length && nums[stack[stack.length - 1]] < nums[i]) {
      result[stack.pop()] = nums[i];
    }
    stack.push(i);
  }
  return result;
}
// [2, 1, 3, 2, 4] -> [3, 3, 4, 4, -1]
# Next greater element using monotonic stack -- O(n)
def next_greater_element(nums):
    result = [-1] * len(nums)
    stack = []  # stores indices, values decrease bottom to top

    for i in range(len(nums)):
        # pop all smaller elements -- current num is their "next greater"
        while stack and nums[stack[-1]] < nums[i]:
            result[stack.pop()] = nums[i]
        stack.append(i)
    return result
# [2, 1, 3, 2, 4] -> [3, 3, 4, 4, -1]
// Next greater element using monotonic stack -- O(n)
static int[] nextGreaterElement(int[] nums) {
    int[] result = new int[nums.length];
    Arrays.fill(result, -1);
    Deque<Integer> stack = new ArrayDeque<>(); // stores indices

    for (int i = 0; i < nums.length; i++) {
        while (!stack.isEmpty() && nums[stack.peek()] < nums[i]) {
            result[stack.pop()] = nums[i];
        }
        stack.push(i);
    }
    return result;
}
// [2, 1, 3, 2, 4] -> [3, 3, 4, 4, -1]

Daily Temperatures

Given daily temperatures, find how many days we need to wait for a warmer day. This is just “next greater element” but we return the distance instead of the value.

Example: [73, 74, 75, 71, 69, 72, 76, 73] returns [1, 1, 4, 2, 1, 1, 0, 0].

// Daily temperatures -- how many days until warmer?
function dailyTemperatures(temps) {
  const result = new Array(temps.length).fill(0);
  const stack = []; // stores indices of days waiting for a warmer day

  for (let i = 0; i < temps.length; i++) {
    while (stack.length && temps[stack[stack.length - 1]] < temps[i]) {
      const prevDay = stack.pop();
      result[prevDay] = i - prevDay; // distance to warmer day
    }
    stack.push(i);
  }
  return result;
}
# Daily temperatures -- how many days until warmer?
def daily_temperatures(temps):
    result = [0] * len(temps)
    stack = []  # stores indices of days waiting for a warmer day

    for i in range(len(temps)):
        while stack and temps[stack[-1]] < temps[i]:
            prev_day = stack.pop()
            result[prev_day] = i - prev_day  # distance to warmer day
        stack.append(i)
    return result
// Daily temperatures -- how many days until warmer?
static int[] dailyTemperatures(int[] temps) {
    int[] result = new int[temps.length];
    Deque<Integer> stack = new ArrayDeque<>();

    for (int i = 0; i < temps.length; i++) {
        while (!stack.isEmpty() && temps[stack.peek()] < temps[i]) {
            int prevDay = stack.pop();
            result[prevDay] = i - prevDay; // distance to warmer day
        }
        stack.push(i);
    }
    return result;
}

Notice how similar this is to the next greater element problem? Same pattern, different return value. That’s the beauty of monotonic stacks — once we know the pattern, many problems look the same.

Largest Rectangle in Histogram

This is a harder problem, but it’s a classic monotonic stack application. Given an array of bar heights, find the area of the largest rectangle we can form.

The key insight: for each bar, we need to know how far it can extend to the left and right without hitting a shorter bar. A monotonic increasing stack gives us exactly that.

// Largest rectangle in histogram -- O(n)
function largestRectangleArea(heights) {
  const stack = []; // stores indices, heights increase bottom to top
  let maxArea = 0;
  const n = heights.length;

  for (let i = 0; i <= n; i++) {
    const h = i === n ? 0 : heights[i]; // sentinel value at end

    while (stack.length && heights[stack[stack.length - 1]] > h) {
      const height = heights[stack.pop()];
      // width = distance between current i and new stack top
      const width = stack.length ? i - stack[stack.length - 1] - 1 : i;
      maxArea = Math.max(maxArea, height * width);
    }
    stack.push(i);
  }
  return maxArea;
}
# Largest rectangle in histogram -- O(n)
def largest_rectangle_area(heights):
    stack = []  # stores indices, heights increase bottom to top
    max_area = 0
    n = len(heights)

    for i in range(n + 1):
        h = heights[i] if i < n else 0  # sentinel value at end

        while stack and heights[stack[-1]] > h:
            height = heights[stack.pop()]
            width = i - stack[-1] - 1 if stack else i
            max_area = max(max_area, height * width)
        stack.append(i)
    return max_area
// Largest rectangle in histogram -- O(n)
static int largestRectangleArea(int[] heights) {
    Deque<Integer> stack = new ArrayDeque<>();
    int maxArea = 0;
    int n = heights.length;

    for (int i = 0; i <= n; i++) {
        int h = (i == n) ? 0 : heights[i]; // sentinel value at end

        while (!stack.isEmpty() && heights[stack.peek()] > h) {
            int height = heights[stack.pop()];
            int width = stack.isEmpty() ? i : i - stack.peek() - 1;
            maxArea = Math.max(maxArea, height * width);
        }
        stack.push(i);
    }
    return maxArea;
}

The sentinel value (0 at the end) forces us to process all remaining bars in the stack. Without it, we’d need a separate loop after the main iteration.

Choosing the Right Monotonic Stack

Problem TypeStack OrderDirection
Next greater elementDecreasing (bottom to top)Left to right
Next smaller elementIncreasing (bottom to top)Left to right
Previous greater elementDecreasingRight to left
Previous smaller elementIncreasingRight to left

When to Think Monotonic Stack

The signal is usually one of these:

  • “Find the next greater/smaller element”
  • “How many days until…”
  • “Largest rectangle / container”
  • Any problem where we need to efficiently look at nearby elements that satisfy a comparison

If a brute force approach uses nested loops where the inner loop searches for the next bigger/smaller value, that’s almost certainly a monotonic stack problem. We go from O(n²) to O(n) — each element is pushed and popped at most once.


15

Queues and Deques

beginner queue deque FIFO data-structure

A queue is a First In, First Out (FIFO) data structure. The first thing we put in is the first thing we take out. Think of a line at a coffee shop — first person in line gets served first.

In simple language, a queue is the opposite of a stack. Instead of taking from the top (last in), we take from the front (first in).

Queue Operations

All O(1) time:

  • enqueue(item) — add to the back
  • dequeue() — remove from the front
  • front() / peek() — look at the front without removing
  • isEmpty() — check if the queue is empty
Queue: Enqueue and Dequeue
dequeue <--
10 20 30 40
<-- enqueue
front back

Using Queues in Each Language

// JavaScript -- no built-in queue, use array (or linked list for O(1))
const queue = [];
queue.push(10);       // enqueue to back
queue.push(20);
queue.push(30);
queue.shift();        // 10 -- dequeue from front (O(n) with arrays!)
queue[0];             // 20 -- peek at front

// For O(1) dequeue, use a Map or linked list implementation
// In interviews, array is usually fine
# Python -- use collections.deque (O(1) both ends)
from collections import deque
queue = deque()
queue.append(10)      # enqueue to back
queue.append(20)
queue.append(30)
queue.popleft()       # 10 -- dequeue from front, O(1)!
queue[0]              # 20 -- peek at front
// Java -- use LinkedList or ArrayDeque as Queue
Queue<Integer> queue = new LinkedList<>();
queue.offer(10);      // enqueue to back
queue.offer(20);
queue.offer(30);
queue.poll();         // 10 -- dequeue from front
queue.peek();         // 20 -- peek at front

JavaScript gotcha: Array.shift() is O(n) because it has to re-index everything. For performance-critical code, we’d use a linked list. But in interviews, the array approach is usually acceptable.

Queues and BFS

Queues are the backbone of Breadth-First Search (BFS). BFS explores all neighbors at the current depth before moving deeper. The queue ensures we process nodes level by level.

// BFS using a queue -- level order traversal
function bfs(graph, start) {
  const visited = new Set([start]);
  const queue = [start];

  while (queue.length) {
    const node = queue.shift();     // process front of queue
    console.log(node);

    for (const neighbor of graph[node]) {
      if (!visited.has(neighbor)) {
        visited.add(neighbor);
        queue.push(neighbor);       // add neighbors to back
      }
    }
  }
}
# BFS using a queue -- level order traversal
from collections import deque

def bfs(graph, start):
    visited = {start}
    queue = deque([start])

    while queue:
        node = queue.popleft()      # process front of queue
        print(node)

        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)  # add neighbors to back
// BFS using a queue -- level order traversal
static void bfs(Map<Integer, List<Integer>> graph, int start) {
    Set<Integer> visited = new HashSet<>();
    Queue<Integer> queue = new LinkedList<>();
    visited.add(start);
    queue.offer(start);

    while (!queue.isEmpty()) {
        int node = queue.poll();    // process front of queue
        System.out.println(node);

        for (int neighbor : graph.get(node)) {
            if (!visited.contains(neighbor)) {
                visited.add(neighbor);
                queue.offer(neighbor);  // add neighbors to back
            }
        }
    }
}

We’ll dive deep into BFS in the graphs topic. For now, just remember: BFS = queue, DFS = stack (or recursion).

Deque — Double-Ended Queue

A deque (pronounced “deck”) allows adding and removing from both ends in O(1). It’s like a queue and a stack combined.

Deque: Operations on Both Ends
addFirst /
removeFirst
<-->
front ... back
<--> addLast /
removeLast
// JavaScript doesn't have a native deque
// Use an array (shift is O(n)) or implement with doubly linked list
const deque = [];
deque.push(10);       // addLast
deque.unshift(5);     // addFirst (O(n) with array)
deque.pop();          // removeLast
deque.shift();        // removeFirst (O(n) with array)
# Python deque -- O(1) on both ends
from collections import deque
dq = deque()
dq.append(10)         # addLast
dq.appendleft(5)      # addFirst
dq.pop()              # removeLast -> 10
dq.popleft()          # removeFirst -> 5
// Java ArrayDeque -- O(1) on both ends
Deque<Integer> deque = new ArrayDeque<>();
deque.addLast(10);    // addLast
deque.addFirst(5);    // addFirst
deque.removeLast();   // removeLast -> 10
deque.removeFirst();  // removeFirst -> 5

Classic Problem: Sliding Window Maximum

Given an array and a window size k, find the maximum value in each window as it slides from left to right. This is THE classic deque problem.

Brute force: for each window, scan all k elements. That’s O(n * k).

With a deque: maintain a monotonically decreasing deque of indices. The front always holds the index of the current window’s maximum. We get O(n).

// Sliding window maximum -- O(n) using deque
function maxSlidingWindow(nums, k) {
  const result = [];
  const deque = []; // stores indices, values decrease front to back

  for (let i = 0; i < nums.length; i++) {
    // remove indices outside the window
    if (deque.length && deque[0] <= i - k) {
      deque.shift();
    }
    // remove smaller elements from back (they'll never be max)
    while (deque.length && nums[deque[deque.length - 1]] <= nums[i]) {
      deque.pop();
    }
    deque.push(i);

    // window is fully formed starting at index k-1
    if (i >= k - 1) {
      result.push(nums[deque[0]]); // front is the max
    }
  }
  return result;
}
// [1, 3, -1, -3, 5, 3, 6, 7], k=3 -> [3, 3, 5, 5, 6, 7]
# Sliding window maximum -- O(n) using deque
from collections import deque

def max_sliding_window(nums, k):
    result = []
    dq = deque()  # stores indices, values decrease front to back

    for i in range(len(nums)):
        # remove indices outside the window
        if dq and dq[0] <= i - k:
            dq.popleft()
        # remove smaller elements from back
        while dq and nums[dq[-1]] <= nums[i]:
            dq.pop()
        dq.append(i)

        # window is fully formed starting at index k-1
        if i >= k - 1:
            result.append(nums[dq[0]])  # front is the max
    return result
# [1, 3, -1, -3, 5, 3, 6, 7], k=3 -> [3, 3, 5, 5, 6, 7]
// Sliding window maximum -- O(n) using deque
static int[] maxSlidingWindow(int[] nums, int k) {
    int[] result = new int[nums.length - k + 1];
    Deque<Integer> deque = new ArrayDeque<>();
    int idx = 0;

    for (int i = 0; i < nums.length; i++) {
        // remove indices outside the window
        if (!deque.isEmpty() && deque.peekFirst() <= i - k) {
            deque.pollFirst();
        }
        // remove smaller elements from back
        while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
            deque.pollLast();
        }
        deque.addLast(i);

        if (i >= k - 1) {
            result[idx++] = nums[deque.peekFirst()]; // front is max
        }
    }
    return result;
}
// [1, 3, -1, -3, 5, 3, 6, 7], k=3 -> [3, 3, 5, 5, 6, 7]

This combines a sliding window with a monotonic deque. Each element is added and removed from the deque at most once, so the total time is O(n).

Queue vs Stack vs Deque

StructureInsertRemoveUse Case
StackTop - O(1)Top - O(1)DFS, undo, matching pairs
QueueBack - O(1)Front - O(1)BFS, scheduling, buffering
DequeBoth ends - O(1)Both ends - O(1)Sliding window, palindrome check

When to Think Queue or Deque

  • Processing in order: Tasks, print jobs, message queues — anything “first come, first served”
  • Level-by-level traversal: BFS on trees and graphs
  • Sliding window maximum/minimum: Deque with monotonic ordering
  • Recent history with expiration: Remove old items from front, add new to back

The big takeaway: if the problem says “process in order” or “level by level”, we want a queue. If we need efficient access to both ends, we want a deque.


16

Priority Queues and Heaps

intermediate heap priority-queue min-heap max-heap

A heap is a special binary tree where the parent is always smaller (min-heap) or larger (max-heap) than its children. A priority queue is the abstract concept — “give me the smallest/largest element quickly.” A heap is the data structure that implements it.

In simple language, think of a priority queue like an emergency room. It’s not first-come-first-served. The most urgent patient (highest priority) gets treated first, regardless of when they arrived.

Why Heaps Matter

Without a heap, finding the minimum element in a collection is O(n). With a heap, it’s O(1). Inserting and removing are O(log n). That’s a huge deal when we’re repeatedly asking “what’s the smallest/largest element right now?”

Min-Heap vs Max-Heap

Min-heap: The root is the smallest element. Every parent is smaller than its children.

Max-heap: The root is the largest element. Every parent is larger than its children.

Min-Heap
1
/
3
/
5
\
7
\
4
/
8
\
9
root is always the minimum
Max-Heap
9
/
7
/
3
\
5
\
8
/
1
\
4
root is always the maximum

How Heaps Work Under the Hood

A heap is a complete binary tree stored as an array. We don’t need actual node objects or pointers. The tree structure is implicit from the array indices:

  • Parent of index i: Math.floor((i - 1) / 2)
  • Left child of index i: 2 * i + 1
  • Right child of index i: 2 * i + 2

Push (insert): Add to the end of the array, then “bubble up” — swap with parent until the heap property is satisfied.

Pop (extract min/max): Remove the root (index 0), move the last element to the root, then “bubble down” — swap with the smaller (or larger) child until settled.

Both operations are O(log n) because the tree height is log n.

Using Built-in Priority Queues

In interviews, we almost never implement heaps from scratch. We use built-in priority queues.

// JavaScript has NO built-in heap!
// We need to implement one or use a library
// Here's a minimal MinHeap class for interviews
class MinHeap {
  constructor() { this.data = []; }

  push(val) {
    this.data.push(val);
    this._bubbleUp(this.data.length - 1);
  }
  pop() {
    const min = this.data[0];
    const last = this.data.pop();
    if (this.data.length) {
      this.data[0] = last;
      this._bubbleDown(0);
    }
    return min;
  }
  peek()  { return this.data[0]; }
  size()  { return this.data.length; }

  _bubbleUp(i) {
    while (i > 0) {
      const parent = Math.floor((i - 1) / 2);
      if (this.data[parent] <= this.data[i]) break;
      [this.data[parent], this.data[i]] = [this.data[i], this.data[parent]];
      i = parent;
    }
  }
  _bubbleDown(i) {
    while (true) {
      let smallest = i;
      const left = 2 * i + 1, right = 2 * i + 2;
      if (left < this.data.length && this.data[left] < this.data[smallest]) smallest = left;
      if (right < this.data.length && this.data[right] < this.data[smallest]) smallest = right;
      if (smallest === i) break;
      [this.data[smallest], this.data[i]] = [this.data[i], this.data[smallest]];
      i = smallest;
    }
  }
}
# Python -- heapq module (min-heap by default)
import heapq

heap = []
heapq.heappush(heap, 3)   # push
heapq.heappush(heap, 1)
heapq.heappush(heap, 4)
heapq.heappop(heap)        # 1 -- pops smallest
heap[0]                     # 3 -- peek at smallest

# For max-heap, negate values
max_heap = []
heapq.heappush(max_heap, -3)
heapq.heappush(max_heap, -1)
heapq.heappush(max_heap, -4)
-heapq.heappop(max_heap)    # 4 -- largest value
// Java -- PriorityQueue (min-heap by default)
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
minHeap.offer(3);          // push
minHeap.offer(1);
minHeap.offer(4);
minHeap.poll();            // 1 -- pops smallest
minHeap.peek();            // 3 -- peek at smallest

// For max-heap, use reverse comparator
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());
maxHeap.offer(3);
maxHeap.offer(1);
maxHeap.offer(4);
maxHeap.poll();            // 4 -- pops largest

Python note: heapq only supports min-heaps. The standard trick for a max-heap is to negate values: push -val, pop and negate back. It’s ugly but it works.

JavaScript note: There’s no built-in heap in JavaScript. In interviews, either write a quick MinHeap class (like above) or mention that we’d use one.

Top K Frequent Elements

Given an array, find the K most frequent elements. This is a classic heap problem.

Strategy: Count frequencies with a hash map, then use a min-heap of size K. We push each element and pop when the heap exceeds size K. At the end, everything left in the heap is a top-K element.

Why a min-heap and not a max-heap? Because we want to evict the least frequent element when the heap is full. The min-heap keeps the smallest frequency at the top, ready to be kicked out.

// Top K frequent elements -- O(n log k)
function topKFrequent(nums, k) {
  // step 1: count frequencies
  const freq = new Map();
  for (const n of nums) freq.set(n, (freq.get(n) || 0) + 1);

  // step 2: use a min-heap of size k
  // (using our MinHeap class storing [frequency, number])
  const heap = new MinHeap(); // compare by first element (freq)
  for (const [num, count] of freq) {
    heap.push([count, num]);
    if (heap.size() > k) heap.pop(); // evict least frequent
  }

  // step 3: extract results
  const result = [];
  while (heap.size()) result.push(heap.pop()[1]);
  return result;
}
# Top K frequent elements -- O(n log k)
import heapq
from collections import Counter

def top_k_frequent(nums, k):
    # step 1: count frequencies
    freq = Counter(nums)

    # step 2: use a min-heap of size k
    # heapq compares tuples by first element (count)
    heap = []
    for num, count in freq.items():
        heapq.heappush(heap, (count, num))
        if len(heap) > k:
            heapq.heappop(heap)  # evict least frequent

    # step 3: extract results
    return [num for count, num in heap]
// Top K frequent elements -- O(n log k)
static int[] topKFrequent(int[] nums, int k) {
    // step 1: count frequencies
    Map<Integer, Integer> freq = new HashMap<>();
    for (int n : nums) freq.merge(n, 1, Integer::sum);

    // step 2: use a min-heap of size k
    PriorityQueue<int[]> heap = new PriorityQueue<>((a, b) -> a[0] - b[0]);
    for (var entry : freq.entrySet()) {
        heap.offer(new int[]{entry.getValue(), entry.getKey()});
        if (heap.size() > k) heap.poll(); // evict least frequent
    }

    // step 3: extract results
    int[] result = new int[k];
    for (int i = 0; i < k; i++) result[i] = heap.poll()[1];
    return result;
}

Merge K Sorted Lists (Concept)

Given K sorted linked lists, merge them into one sorted list. Brute force: merge two at a time, K-1 times. That works but is O(N * K) where N is the total number of nodes.

With a min-heap: push the first node of each list into the heap. Pop the smallest, add it to the result, and push that node’s next into the heap. We always have at most K elements in the heap, so each push/pop is O(log K).

Total time: O(N log K) — much better when K is large.

// Merge K sorted lists -- O(N log K)
function mergeKLists(lists) {
  const heap = new MinHeap(); // stores [val, listIndex, node]

  // push first node from each list
  for (let i = 0; i < lists.length; i++) {
    if (lists[i]) heap.push([lists[i].val, i, lists[i]]);
  }

  const dummy = new ListNode(0);
  let tail = dummy;

  while (heap.size()) {
    const [val, idx, node] = heap.pop(); // get smallest
    tail.next = node;
    tail = tail.next;
    if (node.next) heap.push([node.next.val, idx, node.next]);
  }
  return dummy.next;
}
# Merge K sorted lists -- O(N log K)
import heapq

def merge_k_lists(lists):
    heap = []

    # push first node from each list
    for i, lst in enumerate(lists):
        if lst:
            heapq.heappush(heap, (lst.val, i, lst))

    dummy = ListNode(0)
    tail = dummy

    while heap:
        val, idx, node = heapq.heappop(heap)  # get smallest
        tail.next = node
        tail = tail.next
        if node.next:
            heapq.heappush(heap, (node.next.val, idx, node.next))
    return dummy.next
// Merge K sorted lists -- O(N log K)
static ListNode mergeKLists(ListNode[] lists) {
    PriorityQueue<ListNode> heap = new PriorityQueue<>(
        (a, b) -> a.val - b.val
    );

    for (ListNode list : lists) {
        if (list != null) heap.offer(list);
    }

    ListNode dummy = new ListNode(0);
    ListNode tail = dummy;

    while (!heap.isEmpty()) {
        ListNode node = heap.poll(); // get smallest
        tail.next = node;
        tail = tail.next;
        if (node.next != null) heap.offer(node.next);
    }
    return dummy.next;
}

Time Complexities

OperationTime
peek (get min/max)O(1)
push (insert)O(log n)
pop (extract min/max)O(log n)
heapify (build heap from array)O(n)
searchO(n) — heaps aren’t designed for search

The O(n) heapify is surprising — building a heap from an unsorted array is O(n), not O(n log n). We won’t prove it here, but it’s a useful fact for interviews.

When to Think Heap

  • “Find the K largest/smallest” — classic heap signal
  • “Merge K sorted things” — min-heap to track the smallest across K sources
  • “Continuously find min/max” in a stream of data
  • “Median of a stream” — use two heaps (max-heap for lower half, min-heap for upper half)
  • “Schedule tasks by priority” — that’s literally a priority queue

If sorting the entire collection seems wasteful because we only need the top K elements, a heap is probably the answer. Sorting is O(n log n); a heap approach for top K is O(n log k) — and when k is small, that’s much faster.


17

Data Structure Design Problems

advanced design LRU-cache min-stack advanced

Design problems are a special breed of interview questions. Instead of solving a puzzle with existing data structures, we’re asked to build a data structure that supports specific operations with specific time complexities. They test whether we truly understand how data structures work under the hood.

In simple language, the interviewer says “build me a thing that does X in O(1)” and we figure out which building blocks to combine.

Why Interviewers Love These

Design problems test multiple skills at once: understanding of data structures, ability to combine them creatively, edge case handling, and clean API design. They’re also closer to real engineering work — we often need to design custom data structures to meet performance requirements.

Min Stack

We covered this briefly in the stacks topic. Let’s go deeper. The challenge: design a stack that supports push, pop, top, and getMin — all in O(1) time.

The trick: maintain a second stack that tracks the minimum at each level. Every time we push to the main stack, we also track what the minimum is at that point.

A cleaner approach: store pairs of (value, current_minimum) in a single stack.

// Min Stack -- all operations O(1)
class MinStack {
  constructor() {
    this.stack = []; // each entry: [value, minAtThisLevel]
  }

  push(val) {
    const currentMin = this.stack.length
      ? Math.min(val, this.getMin())
      : val;
    this.stack.push([val, currentMin]);
  }

  pop() {
    this.stack.pop();
  }

  top() {
    return this.stack[this.stack.length - 1][0];
  }

  getMin() {
    return this.stack[this.stack.length - 1][1];
  }
}
# Min Stack -- all operations O(1)
class MinStack:
    def __init__(self):
        self.stack = []  # each entry: (value, min_at_this_level)

    def push(self, val):
        current_min = min(val, self.get_min()) if self.stack else val
        self.stack.append((val, current_min))

    def pop(self):
        self.stack.pop()

    def top(self):
        return self.stack[-1][0]

    def get_min(self):
        return self.stack[-1][1]
// Min Stack -- all operations O(1)
class MinStack {
    // each entry: [value, minAtThisLevel]
    Deque<int[]> stack = new ArrayDeque<>();

    void push(int val) {
        int currentMin = stack.isEmpty() ? val : Math.min(val, getMin());
        stack.push(new int[]{val, currentMin});
    }

    void pop() { stack.pop(); }

    int top() { return stack.peek()[0]; }

    int getMin() { return stack.peek()[1]; }
}

The space trade-off is O(n) extra for the minimums. That’s acceptable — we traded space for O(1) getMin.

Queue Using Two Stacks

Design a queue (FIFO) using only two stacks. This sounds weird, but it’s a common interview question that tests our understanding of both structures.

The idea: use one stack for pushing (inStack) and one for popping (outStack). When we need to dequeue and outStack is empty, we pour everything from inStack into outStack. This reverses the order — and reversing LIFO gives us FIFO.

Each element gets moved at most twice (pushed to inStack, moved to outStack), so amortized cost per operation is O(1).

// Queue using two stacks -- amortized O(1) per operation
class MyQueue {
  constructor() {
    this.inStack = [];   // for push
    this.outStack = [];  // for pop/peek
  }

  push(val) {
    this.inStack.push(val);
  }

  pop() {
    this._transfer();
    return this.outStack.pop();
  }

  peek() {
    this._transfer();
    return this.outStack[this.outStack.length - 1];
  }

  empty() {
    return !this.inStack.length && !this.outStack.length;
  }

  _transfer() {
    // only transfer when outStack is empty
    if (!this.outStack.length) {
      while (this.inStack.length) {
        this.outStack.push(this.inStack.pop());
      }
    }
  }
}
# Queue using two stacks -- amortized O(1) per operation
class MyQueue:
    def __init__(self):
        self.in_stack = []   # for push
        self.out_stack = []  # for pop/peek

    def push(self, val):
        self.in_stack.append(val)

    def pop(self):
        self._transfer()
        return self.out_stack.pop()

    def peek(self):
        self._transfer()
        return self.out_stack[-1]

    def empty(self):
        return not self.in_stack and not self.out_stack

    def _transfer(self):
        # only transfer when out_stack is empty
        if not self.out_stack:
            while self.in_stack:
                self.out_stack.append(self.in_stack.pop())
// Queue using two stacks -- amortized O(1) per operation
class MyQueue {
    Deque<Integer> inStack = new ArrayDeque<>();
    Deque<Integer> outStack = new ArrayDeque<>();

    void push(int val) { inStack.push(val); }

    int pop() { transfer(); return outStack.pop(); }

    int peek() { transfer(); return outStack.peek(); }

    boolean empty() { return inStack.isEmpty() && outStack.isEmpty(); }

    private void transfer() {
        if (outStack.isEmpty()) {
            while (!inStack.isEmpty()) {
                outStack.push(inStack.pop());
            }
        }
    }
}

LRU Cache — The Big One

Design a cache with a fixed capacity that evicts the Least Recently Used item when full. It needs:

  • get(key) — return the value in O(1), mark as recently used
  • put(key, value) — insert/update in O(1), evict LRU if over capacity

This is probably the most commonly asked design problem. Let’s build it.

The Insight

We need two things working together:

  1. Hash map for O(1) key lookup
  2. Doubly linked list for O(1) insertion/removal and tracking recency order

The most recently used item goes to the front of the list. The least recently used is at the back. When we access or insert an item, we move it to the front. When we need to evict, we remove from the back.

LRU Cache: Hash Map + Doubly Linked List
Hash Map:
A -> node1 B -> node2 C -> node3
DLL: HEAD <--> C (MRU) <--> B <--> A (LRU) <--> TAIL
get(A) -> move A to front | put(D) when full -> evict A (closest to TAIL)

We use sentinel head and tail nodes to avoid null checks. Every real node sits between them.

Full Implementation

// LRU Cache -- O(1) get and put
class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.map = new Map();
    // sentinel nodes -- avoid null checks
    this.head = { prev: null, next: null };
    this.tail = { prev: null, next: null };
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }

  get(key) {
    if (!this.map.has(key)) return -1;
    const node = this.map.get(key);
    this._remove(node);
    this._addToFront(node);
    return node.val;
  }

  put(key, value) {
    if (this.map.has(key)) {
      this._remove(this.map.get(key));
    }
    const node = { key, val: value, prev: null, next: null };
    this._addToFront(node);
    this.map.set(key, node);

    if (this.map.size > this.capacity) {
      const lru = this.tail.prev; // least recently used
      this._remove(lru);
      this.map.delete(lru.key);
    }
  }

  _remove(node) {
    node.prev.next = node.next;
    node.next.prev = node.prev;
  }

  _addToFront(node) {
    node.next = this.head.next;
    node.prev = this.head;
    this.head.next.prev = node;
    this.head.next = node;
  }
}
# LRU Cache -- O(1) get and put
class DLLNode:
    def __init__(self, key=0, val=0):
        self.key = key
        self.val = val
        self.prev = None
        self.next = None

class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.map = {}
        # sentinel nodes -- avoid null checks
        self.head = DLLNode()
        self.tail = DLLNode()
        self.head.next = self.tail
        self.tail.prev = self.head

    def get(self, key):
        if key not in self.map:
            return -1
        node = self.map[key]
        self._remove(node)
        self._add_to_front(node)
        return node.val

    def put(self, key, value):
        if key in self.map:
            self._remove(self.map[key])
        node = DLLNode(key, value)
        self._add_to_front(node)
        self.map[key] = node

        if len(self.map) > self.capacity:
            lru = self.tail.prev  # least recently used
            self._remove(lru)
            del self.map[lru.key]

    def _remove(self, node):
        node.prev.next = node.next
        node.next.prev = node.prev

    def _add_to_front(self, node):
        node.next = self.head.next
        node.prev = self.head
        self.head.next.prev = node
        self.head.next = node
// LRU Cache -- O(1) get and put
class LRUCache {
    class DLLNode {
        int key, val;
        DLLNode prev, next;
        DLLNode(int k, int v) { key = k; val = v; }
    }

    int capacity;
    Map<Integer, DLLNode> map = new HashMap<>();
    DLLNode head = new DLLNode(0, 0); // sentinel
    DLLNode tail = new DLLNode(0, 0); // sentinel

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

    int get(int key) {
        if (!map.containsKey(key)) return -1;
        DLLNode node = map.get(key);
        remove(node);
        addToFront(node);
        return node.val;
    }

    void put(int key, int value) {
        if (map.containsKey(key)) remove(map.get(key));
        DLLNode node = new DLLNode(key, value);
        addToFront(node);
        map.put(key, node);

        if (map.size() > capacity) {
            DLLNode lru = tail.prev;
            remove(lru);
            map.remove(lru.key);
        }
    }

    void remove(DLLNode node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    void addToFront(DLLNode node) {
        node.next = head.next;
        node.prev = head;
        head.next.prev = node;
        head.next = node;
    }
}

Python Shortcut

Python’s OrderedDict maintains insertion order and supports move_to_end. This makes LRU cache a 10-line solution:

# LRU Cache using OrderedDict -- Python shortcut
from collections import OrderedDict

class LRUCache:
    def __init__(self, capacity):
        self.cache = OrderedDict()
        self.capacity = capacity

    def get(self, key):
        if key not in self.cache:
            return -1
        self.cache.move_to_end(key)  # mark as recently used
        return self.cache[key]

    def put(self, key, value):
        if key in self.cache:
            self.cache.move_to_end(key)
        self.cache[key] = value
        if len(self.cache) > self.capacity:
            self.cache.popitem(last=False)  # remove oldest (LRU)

Know both approaches. The OrderedDict version is great for speed in interviews, but the interviewer might ask us to implement it from scratch.

Java Shortcut

Java’s LinkedHashMap maintains insertion order too:

// LRU Cache using LinkedHashMap -- Java shortcut
class LRUCache extends LinkedHashMap<Integer, Integer> {
    int capacity;

    LRUCache(int capacity) {
        super(capacity, 0.75f, true); // true = access-order
        this.capacity = capacity;
    }

    int get(int key) {
        return super.getOrDefault(key, -1);
    }

    void put(int key, int value) {
        super.put(key, value);
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
        return size() > capacity; // auto-evict when over capacity
    }
}

Tips for Design Problems in Interviews

  1. Clarify requirements first. What operations? What time complexities? What constraints?
  2. Start with the brute force. “We could use a list and scan it each time, but that’s O(n)…”
  3. Identify the bottleneck. Which operation is too slow? What data structure speeds it up?
  4. Combine data structures. The magic is usually in combining two simple structures (hash map + linked list, two stacks, etc.).
  5. Handle edge cases. Empty state, single element, at capacity. The interviewer will ask about these.

Design problems feel intimidating, but they all follow the same pattern: figure out what’s slow, pick the right data structure to fix it, and combine structures when one isn’t enough.


Trees & Tries

18

Binary Trees

beginner binary-tree traversal DFS BFS data-structure

A tree is a collection of nodes connected by edges, with no cycles. It’s like a family tree — one node at the top (the root), and everything branches downward. A binary tree is a tree where each node has at most two children — a left child and a right child.

Why do we care? Trees show up everywhere. File systems, HTML DOM, org charts, database indexes — they’re all trees. And in interviews, tree problems are probably the most commonly asked category.

Terminology

Let’s get the vocabulary down first:

Binary Tree Anatomy
root, depth 0 →
8
← height 3
depth 1 →
3
10
depth 2 →
1
6
9
14
Leaves: 1, 6, 9, 14 (no children)  |  Internal nodes: 8, 3, 10 (have children)
  • Root — the topmost node (8 in our tree). It has no parent.
  • Leaf — a node with no children (1, 6, 9, 14).
  • Internal node — a node that has at least one child.
  • Depth — how far a node is from the root. Root is depth 0.
  • Height — the longest path from a node down to a leaf. The tree’s height is the height of the root.
  • Parent / Child — node 3 is a child of 8. Node 8 is the parent of 3.
  • Subtree — any node and all its descendants form a subtree.

Node Definition

Every tree node holds a value and pointers to its left and right children.

class TreeNode {
  constructor(val) {
    this.val = val;
    this.left = null;   // left child
    this.right = null;  // right child
  }
}
class TreeNode:
    def __init__(self, val=0):
        self.val = val
        self.left = None   # left child
        self.right = None  # right child
class TreeNode {
    int val;
    TreeNode left;    // left child
    TreeNode right;   // right child
    TreeNode(int val) { this.val = val; }
}

Tree Traversals

Traversal means visiting every node exactly once. There are four main ways to do it, and they come up in almost every tree interview problem.

Inorder (Left, Node, Right)

Go left as far as we can, visit the node, then go right. For a BST, this gives us nodes in sorted order.

function inorder(node, result = []) {
  if (!node) return result;
  inorder(node.left, result);    // go left
  result.push(node.val);         // visit node
  inorder(node.right, result);   // go right
  return result;
}
// Tree above: [1, 3, 6, 8, 9, 10, 14]
def inorder(node, result=None):
    if result is None: result = []
    if not node: return result
    inorder(node.left, result)    # go left
    result.append(node.val)       # visit node
    inorder(node.right, result)   # go right
    return result
# Tree above: [1, 3, 6, 8, 9, 10, 14]
void inorder(TreeNode node, List<Integer> result) {
    if (node == null) return;
    inorder(node.left, result);    // go left
    result.add(node.val);          // visit node
    inorder(node.right, result);   // go right
}
// Tree above: [1, 3, 6, 8, 9, 10, 14]

Preorder (Node, Left, Right)

Visit the node first, then go left, then right. This is useful for copying/serializing a tree.

function preorder(node, result = []) {
  if (!node) return result;
  result.push(node.val);          // visit node first
  preorder(node.left, result);    // then left
  preorder(node.right, result);   // then right
  return result;
}
// Tree above: [8, 3, 1, 6, 10, 9, 14]
def preorder(node, result=None):
    if result is None: result = []
    if not node: return result
    result.append(node.val)        # visit node first
    preorder(node.left, result)    # then left
    preorder(node.right, result)   # then right
    return result
# Tree above: [8, 3, 1, 6, 10, 9, 14]
void preorder(TreeNode node, List<Integer> result) {
    if (node == null) return;
    result.add(node.val);          // visit node first
    preorder(node.left, result);   // then left
    preorder(node.right, result);  // then right
}
// Tree above: [8, 3, 1, 6, 10, 9, 14]

Postorder (Left, Right, Node)

Go left, go right, then visit the node last. This is useful for deleting a tree (delete children before parent).

function postorder(node, result = []) {
  if (!node) return result;
  postorder(node.left, result);   // go left
  postorder(node.right, result);  // go right
  result.push(node.val);          // visit node last
  return result;
}
// Tree above: [1, 6, 3, 9, 14, 10, 8]
def postorder(node, result=None):
    if result is None: result = []
    if not node: return result
    postorder(node.left, result)   # go left
    postorder(node.right, result)  # go right
    result.append(node.val)        # visit node last
    return result
# Tree above: [1, 6, 3, 9, 14, 10, 8]
void postorder(TreeNode node, List<Integer> result) {
    if (node == null) return;
    postorder(node.left, result);   // go left
    postorder(node.right, result);  // go right
    result.add(node.val);           // visit node last
}
// Tree above: [1, 6, 3, 9, 14, 10, 8]

Level-Order (BFS)

Visit nodes level by level, left to right. This uses a queue instead of recursion.

function levelOrder(root) {
  if (!root) return [];
  const result = [], queue = [root];
  while (queue.length) {
    const node = queue.shift();    // dequeue front
    result.push(node.val);
    if (node.left) queue.push(node.left);
    if (node.right) queue.push(node.right);
  }
  return result;
}
// Tree above: [8, 3, 10, 1, 6, 9, 14]
from collections import deque

def level_order(root):
    if not root: return []
    result, queue = [], deque([root])
    while queue:
        node = queue.popleft()     # dequeue front
        result.append(node.val)
        if node.left: queue.append(node.left)
        if node.right: queue.append(node.right)
    return result
# Tree above: [8, 3, 10, 1, 6, 9, 14]
List<Integer> levelOrder(TreeNode root) {
    List<Integer> result = new ArrayList<>();
    if (root == null) return result;
    Queue<TreeNode> queue = new LinkedList<>();
    queue.add(root);
    while (!queue.isEmpty()) {
        TreeNode node = queue.poll(); // dequeue front
        result.add(node.val);
        if (node.left != null) queue.add(node.left);
        if (node.right != null) queue.add(node.right);
    }
    return result;
}
// Tree above: [8, 3, 10, 1, 6, 9, 14]

Traversal Summary

Traversal Order Cheatsheet
Inorder Left → Node → Right → sorted order (BST)
Preorder Node → Left → Right → copy/serialize tree
Postorder Left → Right → Node → delete tree
Level-order Level by level (BFS) → shortest path, level grouping

Max Depth of a Binary Tree

This is one of the most classic tree problems. The depth of a tree is 1 + max(depth of left subtree, depth of right subtree).

function maxDepth(node) {
  if (!node) return 0;             // empty tree has depth 0
  const left = maxDepth(node.left);
  const right = maxDepth(node.right);
  return 1 + Math.max(left, right); // current node + deeper subtree
}
def max_depth(node):
    if not node: return 0            # empty tree has depth 0
    left = max_depth(node.left)
    right = max_depth(node.right)
    return 1 + max(left, right)      # current node + deeper subtree
int maxDepth(TreeNode node) {
    if (node == null) return 0;       // empty tree has depth 0
    int left = maxDepth(node.left);
    int right = maxDepth(node.right);
    return 1 + Math.max(left, right); // current node + deeper subtree
}

Time: O(n) — we visit every node. Space: O(h) — where h is the height (call stack depth).

Check if Tree is Symmetric

A tree is symmetric if the left subtree is a mirror reflection of the right subtree.

function isSymmetric(root) {
  if (!root) return true;
  return isMirror(root.left, root.right);
}
function isMirror(a, b) {
  if (!a && !b) return true;         // both null = mirror
  if (!a || !b) return false;        // one null = not mirror
  return a.val === b.val             // same value
    && isMirror(a.left, b.right)     // outer pair
    && isMirror(a.right, b.left);    // inner pair
}
def is_symmetric(root):
    if not root: return True
    return is_mirror(root.left, root.right)

def is_mirror(a, b):
    if not a and not b: return True   # both None = mirror
    if not a or not b: return False   # one None = not mirror
    return (a.val == b.val            # same value
        and is_mirror(a.left, b.right)  # outer pair
        and is_mirror(a.right, b.left)) # inner pair
boolean isSymmetric(TreeNode root) {
    if (root == null) return true;
    return isMirror(root.left, root.right);
}
boolean isMirror(TreeNode a, TreeNode b) {
    if (a == null && b == null) return true;
    if (a == null || b == null) return false;
    return a.val == b.val              // same value
        && isMirror(a.left, b.right)   // outer pair
        && isMirror(a.right, b.left);  // inner pair
}

Time: O(n) — visit every node once. Space: O(h) — recursion stack.

Key Takeaways

  • A binary tree is just nodes with at most two children. No special ordering required.
  • The four traversals (inorder, preorder, postorder, level-order) are the bread and butter of tree problems. Learn them cold.
  • DFS traversals (inorder, preorder, postorder) use recursion (or a stack). BFS (level-order) uses a queue.
  • Most tree problems follow the same recursive pattern: handle null, do something with current node, recurse on left and right.

In simple language, if we can write a recursive function that handles the base case (null node) and combines results from left and right subtrees, we can solve most tree problems.


19

Binary Search Trees

intermediate BST binary-search-tree data-structure

A Binary Search Tree (BST) is a binary tree with one extra rule: for every node, all values in its left subtree are smaller, and all values in its right subtree are larger. This one rule gives us the power of binary search on a tree structure.

Why does this matter? Because searching, inserting, and deleting all become O(log n) on average. That’s the same speed as binary search on a sorted array, but with the flexibility to insert and delete without shifting elements.

The BST Property

BST Property: Left < Node < Right
Valid BST
8
3
10
1
6
14
Inorder: 1, 3, 6, 8, 10, 14 (sorted!)
NOT a Valid BST
8
3
10
1
12
12 is in left subtree of 8, but 12 > 8!

The key insight: an inorder traversal of a BST gives us elements in sorted order. This is how many BST problems are solved.

Search in a BST

Searching is elegant. Compare the target with the current node. If it’s smaller, go left. If it’s larger, go right. If it matches, we found it.

function searchBST(node, target) {
  if (!node) return null;              // not found
  if (target === node.val) return node; // found it
  if (target < node.val)
    return searchBST(node.left, target);  // go left
  return searchBST(node.right, target);   // go right
}
def search_bst(node, target):
    if not node: return None              # not found
    if target == node.val: return node    # found it
    if target < node.val:
        return search_bst(node.left, target)  # go left
    return search_bst(node.right, target)     # go right
TreeNode searchBST(TreeNode node, int target) {
    if (node == null) return null;          // not found
    if (target == node.val) return node;    // found it
    if (target < node.val)
        return searchBST(node.left, target);  // go left
    return searchBST(node.right, target);     // go right
}

Time: O(h) where h is the height. For a balanced tree that’s O(log n). For a skewed tree, it degrades to O(n).

Insert into a BST

Inserting follows the same logic as search. We walk down the tree until we find an empty spot, then place the new node there.

function insertBST(node, val) {
  if (!node) return new TreeNode(val);  // found empty spot
  if (val < node.val)
    node.left = insertBST(node.left, val);   // go left
  else
    node.right = insertBST(node.right, val); // go right
  return node;
}
def insert_bst(node, val):
    if not node: return TreeNode(val)    # found empty spot
    if val < node.val:
        node.left = insert_bst(node.left, val)   # go left
    else:
        node.right = insert_bst(node.right, val) # go right
    return node
TreeNode insertBST(TreeNode node, int val) {
    if (node == null) return new TreeNode(val); // found empty spot
    if (val < node.val)
        node.left = insertBST(node.left, val);   // go left
    else
        node.right = insertBST(node.right, val); // go right
    return node;
}

Time: O(h) — same as search.

Delete from a BST

Deletion is the trickiest operation. There are three cases:

  1. Node is a leaf (no children) — just remove it.
  2. Node has one child — replace the node with its child.
  3. Node has two children — find the inorder successor (smallest node in right subtree), copy its value to the current node, then delete the successor.
function deleteBST(node, val) {
  if (!node) return null;
  if (val < node.val) node.left = deleteBST(node.left, val);
  else if (val > node.val) node.right = deleteBST(node.right, val);
  else {
    if (!node.left) return node.right;  // case 1 & 2
    if (!node.right) return node.left;  // case 2
    // case 3: find inorder successor
    let succ = node.right;
    while (succ.left) succ = succ.left;
    node.val = succ.val;
    node.right = deleteBST(node.right, succ.val);
  }
  return node;
}
def delete_bst(node, val):
    if not node: return None
    if val < node.val: node.left = delete_bst(node.left, val)
    elif val > node.val: node.right = delete_bst(node.right, val)
    else:
        if not node.left: return node.right   # case 1 & 2
        if not node.right: return node.left   # case 2
        # case 3: find inorder successor
        succ = node.right
        while succ.left: succ = succ.left
        node.val = succ.val
        node.right = delete_bst(node.right, succ.val)
    return node
TreeNode deleteBST(TreeNode node, int val) {
    if (node == null) return null;
    if (val < node.val) node.left = deleteBST(node.left, val);
    else if (val > node.val) node.right = deleteBST(node.right, val);
    else {
        if (node.left == null) return node.right;  // case 1 & 2
        if (node.right == null) return node.left;  // case 2
        // case 3: find inorder successor
        TreeNode succ = node.right;
        while (succ.left != null) succ = succ.left;
        node.val = succ.val;
        node.right = deleteBST(node.right, succ.val);
    }
    return node;
}

Validate a BST

A common interview problem. The trick is that we can’t just check left < node < right locally — we need to track the valid range for each node.

For example, in the invalid BST above, 12 is correctly to the right of 3, but it violates the rule that everything in the left subtree of 8 must be less than 8.

function isValidBST(node, min = -Infinity, max = Infinity) {
  if (!node) return true;
  if (node.val <= min || node.val >= max) return false;
  return isValidBST(node.left, min, node.val)    // left must be < node
      && isValidBST(node.right, node.val, max);  // right must be > node
}
def is_valid_bst(node, lo=float('-inf'), hi=float('inf')):
    if not node: return True
    if node.val <= lo or node.val >= hi: return False
    return (is_valid_bst(node.left, lo, node.val)    # left must be < node
        and is_valid_bst(node.right, node.val, hi))  # right must be > node
boolean isValidBST(TreeNode node, long min, long max) {
    if (node == null) return true;
    if (node.val <= min || node.val >= max) return false;
    return isValidBST(node.left, min, node.val)    // left must be < node
        && isValidBST(node.right, node.val, max);  // right must be > node
}
// Call: isValidBST(root, Long.MIN_VALUE, Long.MAX_VALUE)

Time: O(n) — we check every node. Space: O(h).

Kth Smallest Element

Since inorder traversal gives us sorted order, the kth smallest is just the kth element in an inorder traversal. We can stop early once we’ve counted k nodes.

function kthSmallest(root, k) {
  let count = 0, result = null;
  function inorder(node) {
    if (!node || result !== null) return;
    inorder(node.left);
    count++;
    if (count === k) { result = node.val; return; }
    inorder(node.right);
  }
  inorder(root);
  return result;
}
def kth_smallest(root, k):
    count, result = [0], [None]  # use lists to mutate in closure
    def inorder(node):
        if not node or result[0] is not None: return
        inorder(node.left)
        count[0] += 1
        if count[0] == k:
            result[0] = node.val
            return
        inorder(node.right)
    inorder(root)
    return result[0]
int kthSmallest(TreeNode root, int k) {
    int[] state = {k, 0}; // [remaining count, result]
    inorderK(root, state);
    return state[1];
}
void inorderK(TreeNode node, int[] state) {
    if (node == null) return;
    inorderK(node.left, state);
    state[0]--;
    if (state[0] == 0) { state[1] = node.val; return; }
    inorderK(node.right, state);
}

Time: O(h + k) — we go down to the leftmost node then visit k nodes. Space: O(h).

BST Complexity Summary

OperationAverageWorst (Skewed)
SearchO(log n)O(n)
InsertO(log n)O(n)
DeleteO(log n)O(n)

The worst case happens when the tree becomes a straight line (like inserting sorted data). Self-balancing trees (AVL, Red-Black) fix this by ensuring the height stays O(log n), but those are rarely asked in interviews.

Key Takeaways

  • The BST property (left < node < right) gives us O(log n) operations on average.
  • Inorder traversal of a BST = sorted order. This is the most important thing to remember.
  • Deletion has three cases. The two-children case uses the inorder successor.
  • Validating a BST requires tracking min/max bounds, not just comparing parent and child.

In simple language, a BST is like a sorted array that we can insert into and delete from efficiently. The tradeoff is that it can become unbalanced, but for interview purposes, we usually assume reasonable balance.


21

Tree Construction and Serialization

intermediate tree serialization construction

Building a tree from scratch and converting it to/from a string are classic interview problems. They test whether we truly understand how traversals work — not just how to run them, but how they encode a tree’s structure.

Why We Need Two Traversals

A single traversal doesn’t uniquely define a tree. For example, the preorder [1, 2, 3] could be a left-skewed tree, a right-skewed tree, or a balanced tree. But if we have both preorder and inorder, the tree is uniquely determined.

The key insight: preorder tells us the root, inorder tells us what’s left and right of that root.

How Preorder + Inorder Defines a Tree
Preorder:
[3 9 20 15 7]
← first element is always the root
Inorder:
[9 3 15 20 7]
← left of root | root | right of root
Root = 3  |  Left subtree: [9]  |  Right subtree: [15, 20, 7]

Build Tree from Preorder + Inorder

The algorithm is recursive:

  1. The first element in preorder is the root.
  2. Find that root in inorder — everything to its left goes in the left subtree, everything to the right goes in the right subtree.
  3. Recurse on both halves.
function buildTree(preorder, inorder) {
  if (!preorder.length) return null;
  const root = new TreeNode(preorder[0]);
  const mid = inorder.indexOf(preorder[0]); // root's position in inorder
  root.left = buildTree(
    preorder.slice(1, mid + 1),    // left portion of preorder
    inorder.slice(0, mid)          // left portion of inorder
  );
  root.right = buildTree(
    preorder.slice(mid + 1),       // right portion of preorder
    inorder.slice(mid + 1)         // right portion of inorder
  );
  return root;
}
def build_tree(preorder, inorder):
    if not preorder: return None
    root = TreeNode(preorder[0])
    mid = inorder.index(preorder[0])  # root's position in inorder
    root.left = build_tree(
        preorder[1:mid + 1],    # left portion of preorder
        inorder[:mid]           # left portion of inorder
    )
    root.right = build_tree(
        preorder[mid + 1:],     # right portion of preorder
        inorder[mid + 1:]       # right portion of inorder
    )
    return root
int preIdx = 0;
Map<Integer, Integer> inMap = new HashMap<>();

TreeNode buildTree(int[] preorder, int[] inorder) {
    for (int i = 0; i < inorder.length; i++)
        inMap.put(inorder[i], i); // O(1) lookup for root position
    return build(preorder, 0, inorder.length - 1);
}
TreeNode build(int[] pre, int lo, int hi) {
    if (lo > hi) return null;
    TreeNode root = new TreeNode(pre[preIdx++]);
    int mid = inMap.get(root.val);
    root.left = build(pre, lo, mid - 1);
    root.right = build(pre, mid + 1, hi);
    return root;
}

Time: O(n) with a hash map for inorder lookups (O(n^2) without). Space: O(n).

The Java version is optimized — it uses a hash map to avoid the linear indexOf call and passes indices instead of creating new arrays.

Serialization: Tree to String

Serialization means converting a tree to a string so we can store it or send it over a network. Deserialization is the reverse — building the tree back from the string.

The simplest approach uses level-order (BFS) traversal. We include null markers for missing children so we know exactly where each node belongs.

Serialize: Tree → String
1
2
3
4
5
Serialized: "1,2,3,null,4,5,null"

Serialize (BFS Approach)

function serialize(root) {
  if (!root) return "";
  const result = [], queue = [root];
  while (queue.length) {
    const node = queue.shift();
    if (node) {
      result.push(node.val);
      queue.push(node.left);
      queue.push(node.right);
    } else {
      result.push("null");
    }
  }
  return result.join(",");
}
from collections import deque

def serialize(root):
    if not root: return ""
    result, queue = [], deque([root])
    while queue:
        node = queue.popleft()
        if node:
            result.append(str(node.val))
            queue.append(node.left)
            queue.append(node.right)
        else:
            result.append("null")
    return ",".join(result)
String serialize(TreeNode root) {
    if (root == null) return "";
    StringBuilder sb = new StringBuilder();
    Queue<TreeNode> queue = new LinkedList<>();
    queue.add(root);
    while (!queue.isEmpty()) {
        TreeNode node = queue.poll();
        if (node != null) {
            sb.append(node.val).append(",");
            queue.add(node.left);
            queue.add(node.right);
        } else {
            sb.append("null,");
        }
    }
    return sb.toString();
}

Deserialize (BFS Approach)

We reverse the process: read values one by one, using a queue to assign children to each node in level order.

function deserialize(data) {
  if (!data) return null;
  const vals = data.split(",");
  const root = new TreeNode(parseInt(vals[0]));
  const queue = [root];
  let i = 1;
  while (queue.length && i < vals.length) {
    const node = queue.shift();
    if (vals[i] !== "null") {
      node.left = new TreeNode(parseInt(vals[i]));
      queue.push(node.left);
    }
    i++;
    if (vals[i] !== "null") {
      node.right = new TreeNode(parseInt(vals[i]));
      queue.push(node.right);
    }
    i++;
  }
  return root;
}
def deserialize(data):
    if not data: return None
    vals = data.split(",")
    root = TreeNode(int(vals[0]))
    queue, i = deque([root]), 1
    while queue and i < len(vals):
        node = queue.popleft()
        if vals[i] != "null":
            node.left = TreeNode(int(vals[i]))
            queue.append(node.left)
        i += 1
        if vals[i] != "null":
            node.right = TreeNode(int(vals[i]))
            queue.append(node.right)
        i += 1
    return root
TreeNode deserialize(String data) {
    if (data.isEmpty()) return null;
    String[] vals = data.split(",");
    TreeNode root = new TreeNode(Integer.parseInt(vals[0]));
    Queue<TreeNode> queue = new LinkedList<>();
    queue.add(root);
    int i = 1;
    while (!queue.isEmpty() && i < vals.length) {
        TreeNode node = queue.poll();
        if (!vals[i].equals("null")) {
            node.left = new TreeNode(Integer.parseInt(vals[i]));
            queue.add(node.left);
        }
        i++;
        if (!vals[i].equals("null")) {
            node.right = new TreeNode(Integer.parseInt(vals[i]));
            queue.add(node.right);
        }
        i++;
    }
    return root;
}

Time: O(n) for both serialize and deserialize. Space: O(n) for the queue and output.

Which Traversal Pairs Work?

Not all pairs of traversals can uniquely reconstruct a tree:

PairWorks?Why
Preorder + InorderYesPreorder gives root, inorder splits left/right
Postorder + InorderYesPostorder gives root (last element), inorder splits
Preorder + PostorderOnly for full binary treesCan’t determine left vs right without inorder
Level-order + InorderYesLevel-order gives root, inorder splits

Key Takeaways

  • We need two traversals (and one must be inorder) to uniquely reconstruct a binary tree.
  • The recursive construction pattern: find root from preorder/postorder, split using inorder, recurse on both halves.
  • Serialization with null markers lets us reconstruct from a single traversal — because the nulls encode the structure.
  • BFS-based serialization is the most intuitive and commonly asked in interviews.

In simple language, constructing a tree is about figuring out “who’s the root?” and “what goes left vs right?” Serialization is just saving enough info to answer those questions later.


22

Lowest Common Ancestor

intermediate LCA tree recursion

The Lowest Common Ancestor (LCA) of two nodes p and q is the deepest node that is an ancestor of both. In simple language, it’s the first node where the paths from p and q to the root meet.

This is a classic interview problem because it tests recursion, tree understanding, and comes up as a building block in other problems (like finding the distance between two nodes).

What LCA Looks Like

LCA Examples
LCA(4, 5) = 2
3
2
7
4
5
Both 4 and 5 are children of 2
LCA(4, 7) = 3
3
2
7
4
5
4 is in left subtree, 7 is in right

LCA in a BST (The Easy Version)

In a BST, we can use the BST property to our advantage. If both p and q are smaller than the current node, the LCA is in the left subtree. If both are larger, it’s in the right subtree. If they split (one left, one right), the current node IS the LCA.

function lcaBST(root, p, q) {
  if (p.val < root.val && q.val < root.val)
    return lcaBST(root.left, p, q);   // both in left subtree
  if (p.val > root.val && q.val > root.val)
    return lcaBST(root.right, p, q);  // both in right subtree
  return root; // split point — this is the LCA
}
def lca_bst(root, p, q):
    if p.val < root.val and q.val < root.val:
        return lca_bst(root.left, p, q)   # both in left
    if p.val > root.val and q.val > root.val:
        return lca_bst(root.right, p, q)  # both in right
    return root  # split point — this is the LCA
TreeNode lcaBST(TreeNode root, TreeNode p, TreeNode q) {
    if (p.val < root.val && q.val < root.val)
        return lcaBST(root.left, p, q);   // both in left
    if (p.val > root.val && q.val > root.val)
        return lcaBST(root.right, p, q);  // both in right
    return root; // split point — this is the LCA
}

Time: O(h) — we follow a single path down. Space: O(h) for recursion (O(1) if we use a loop).

LCA in a General Binary Tree

Without the BST property, we can’t use value comparisons. Instead, we use a beautiful recursive approach:

  1. If the current node is null, p, or q, return it.
  2. Recurse on left and right subtrees.
  3. If both sides return non-null, the current node is the LCA (p and q are on different sides).
  4. If only one side returns non-null, that’s where both nodes are — return that side.
function lowestCommonAncestor(root, p, q) {
  if (!root || root === p || root === q) return root;
  const left = lowestCommonAncestor(root.left, p, q);
  const right = lowestCommonAncestor(root.right, p, q);
  if (left && right) return root;  // p and q on different sides
  return left || right;            // both on the same side
}
def lowest_common_ancestor(root, p, q):
    if not root or root == p or root == q:
        return root
    left = lowest_common_ancestor(root.left, p, q)
    right = lowest_common_ancestor(root.right, p, q)
    if left and right: return root  # p and q on different sides
    return left or right            # both on the same side
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; // different sides
    return left != null ? left : right;              // same side
}

Time: O(n) — we might visit every node. Space: O(h) — recursion stack.

Walking Through the Logic

Let’s trace through finding LCA(4, 7) in our tree [3, 2, 7, 4, 5]:

Trace: LCA(4, 7)
At node 3: recurse left and right
At node 2: recurse left and right
At node 4: root === p, return 4
At node 5: not p or q, left=null, right=null → return null
Back at 2: left=4, right=null → return 4 (only left found)
At node 7: root === q, return 7
Back at 3: left=4, right=7 → both non-null, return 3 (LCA!)

Distance Between Two Nodes

A common follow-up: find the distance (number of edges) between two nodes. Once we have the LCA, the distance is:

distance(p, q) = depth(p) - depth(LCA) + depth(q) - depth(LCA)

Or equivalently: find the LCA, then calculate the distance from LCA to p and LCA to q, and add them.

function distFromNode(root, target, dist = 0) {
  if (!root) return -1;                   // not found
  if (root === target) return dist;
  const left = distFromNode(root.left, target, dist + 1);
  if (left !== -1) return left;           // found in left subtree
  return distFromNode(root.right, target, dist + 1);
}

function distBetween(root, p, q) {
  const lca = lowestCommonAncestor(root, p, q);
  return distFromNode(lca, p) + distFromNode(lca, q);
}
def dist_from_node(root, target, dist=0):
    if not root: return -1                # not found
    if root == target: return dist
    left = dist_from_node(root.left, target, dist + 1)
    if left != -1: return left            # found in left
    return dist_from_node(root.right, target, dist + 1)

def dist_between(root, p, q):
    lca = lowest_common_ancestor(root, p, q)
    return dist_from_node(lca, p) + dist_from_node(lca, q)
int distFromNode(TreeNode root, TreeNode target, int dist) {
    if (root == null) return -1;            // not found
    if (root == target) return dist;
    int left = distFromNode(root.left, target, dist + 1);
    if (left != -1) return left;            // found in left
    return distFromNode(root.right, target, dist + 1);
}

int distBetween(TreeNode root, TreeNode p, TreeNode q) {
    TreeNode lca = lowestCommonAncestor(root, p, q);
    return distFromNode(lca, p, 0) + distFromNode(lca, q, 0);
}

Time: O(n) for finding LCA + O(n) for distances = O(n) total.

Key Takeaways

  • LCA in a BST is easy — just follow the BST property until the paths to p and q split.
  • LCA in a general binary tree uses a clever recursive approach: recurse both sides, and if both return non-null, the current node is the answer.
  • The general LCA code is surprisingly short (about 5 lines) but the logic is deep. Make sure we can trace through it step by step.
  • Distance between two nodes = distance from LCA to p + distance from LCA to q.

In simple language, LCA is about finding where two paths from the root diverge. In a BST we can use value comparisons to walk down efficiently. In a general tree, we let recursion do the heavy lifting.


23

Tries (Prefix Trees)

intermediate trie prefix-tree string data-structure

A trie (pronounced “try”) is a tree-like data structure where each node represents a single character. We build words by walking down from the root, one character at a time. The name comes from “retrieval” — because tries are incredibly fast at looking up strings by prefix.

Why use a trie? Imagine we have a million words and we want to find all words starting with “app”. A hash map would need to check every single word. A trie just walks down the path a → p → p and everything below that node is a match. That’s the superpower of tries — prefix-based queries are lightning fast.

How a Trie Looks

Let’s say we insert the words: “cat”, “car”, “card”, “dog”, “do”.

Trie storing: "cat", "car", "card", "dog", "do"
root
c
|
a
t
end
r
end |
d
end
d
|
o
end |
g
end
Nodes marked "end" indicate a complete word ends there.
"car" is a prefix of "card" — both share the same path down to 'r'.

Key observations:

  • The root is empty — it doesn’t represent any character.
  • Common prefixes share the same path. “cat” and “car” share c → a.
  • We mark nodes where a word ends with an isEnd flag. Without it, we couldn’t tell if “car” is a word or just a prefix of “card”.

Trie Node Definition

Each trie node has a map of children (character → child node) and a boolean marking if it’s the end of a word.

class TrieNode {
  constructor() {
    this.children = {};   // char -> TrieNode
    this.isEnd = false;   // does a word end here?
  }
}
class TrieNode:
    def __init__(self):
        self.children = {}   # char -> TrieNode
        self.is_end = False  # does a word end here?
class TrieNode {
    Map<Character, TrieNode> children = new HashMap<>();
    boolean isEnd = false;  // does a word end here?
}

Full Trie Implementation

The three core operations are insert, search, and startsWith (prefix check).

class Trie {
  constructor() { this.root = new TrieNode(); }

  insert(word) {
    let node = this.root;
    for (const ch of word) {
      if (!node.children[ch]) node.children[ch] = new TrieNode();
      node = node.children[ch]; // move down
    }
    node.isEnd = true; // mark end of word
  }

  search(word) {
    const node = this._findNode(word);
    return node !== null && node.isEnd; // must be end of word
  }

  startsWith(prefix) {
    return this._findNode(prefix) !== null; // just needs to exist
  }

  _findNode(str) {
    let node = this.root;
    for (const ch of str) {
      if (!node.children[ch]) return null; // path doesn't exist
      node = node.children[ch];
    }
    return node;
  }
}
class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for ch in word:
            if ch not in node.children:
                node.children[ch] = TrieNode()
            node = node.children[ch]  # move down
        node.is_end = True  # mark end of word

    def search(self, word):
        node = self._find_node(word)
        return node is not None and node.is_end

    def starts_with(self, prefix):
        return self._find_node(prefix) is not None

    def _find_node(self, s):
        node = self.root
        for ch in s:
            if ch not in node.children: return None
            node = node.children[ch]
        return node
class Trie {
    TrieNode root = new TrieNode();

    void insert(String word) {
        TrieNode node = root;
        for (char ch : word.toCharArray()) {
            node.children.putIfAbsent(ch, new TrieNode());
            node = node.children.get(ch); // move down
        }
        node.isEnd = true; // mark end of word
    }

    boolean search(String word) {
        TrieNode node = findNode(word);
        return node != null && node.isEnd;
    }

    boolean startsWith(String prefix) {
        return findNode(prefix) != null;
    }

    private TrieNode findNode(String s) {
        TrieNode node = root;
        for (char ch : s.toCharArray()) {
            if (!node.children.containsKey(ch)) return null;
            node = node.children.get(ch);
        }
        return node;
    }
}

Time for all operations: O(m) where m is the length of the word/prefix. Space: O(n * m) where n is the number of words.

Autocomplete with a Trie

This is where tries really shine. Given a prefix, find all words that start with it. We walk down to the prefix node, then do a DFS to collect all words below.

function autocomplete(trie, prefix) {
  const node = trie._findNode(prefix);
  if (!node) return [];                // prefix not in trie
  const results = [];
  function dfs(current, path) {
    if (current.isEnd) results.push(path);
    for (const [ch, child] of Object.entries(current.children))
      dfs(child, path + ch);
  }
  dfs(node, prefix);
  return results;
}
// autocomplete(trie, "ca") → ["cat", "car", "card"]
def autocomplete(trie, prefix):
    node = trie._find_node(prefix)
    if not node: return []              # prefix not in trie
    results = []
    def dfs(current, path):
        if current.is_end: results.append(path)
        for ch, child in current.children.items():
            dfs(child, path + ch)
    dfs(node, prefix)
    return results
# autocomplete(trie, "ca") → ["cat", "car", "card"]
List<String> autocomplete(Trie trie, String prefix) {
    TrieNode node = trie.findNode(prefix);
    List<String> results = new ArrayList<>();
    if (node == null) return results;    // prefix not in trie
    dfs(node, new StringBuilder(prefix), results);
    return results;
}
void dfs(TrieNode node, StringBuilder path, List<String> results) {
    if (node.isEnd) results.add(path.toString());
    for (var entry : node.children.entrySet()) {
        path.append(entry.getKey());
        dfs(entry.getValue(), path, results);
        path.deleteCharAt(path.length() - 1);
    }
}
// autocomplete(trie, "ca") → ["cat", "car", "card"]

Trie vs Hash Map

When should we use a trie instead of a hash set or map?

FeatureHash MapTrie
Exact lookupO(m) — hash the keyO(m) — walk the path
Prefix searchO(n * m) — check every keyO(m + k) — walk prefix, collect matches
Sorted iterationNot possibleNatural alphabetical order (DFS)
SpaceLower for few long stringsCan be large but shares prefixes
AutocompleteImpracticalBuilt for this

In simple language: if we need prefix-based operations (autocomplete, spell check, IP routing), use a trie. For simple key-value lookups, a hash map is simpler and usually faster.

Word Search in a Board

A classic hard interview problem: given a 2D board of characters and a list of words, find all words that can be formed by adjacent cells. The trick is to build a trie from the word list, then DFS on the board using the trie to prune dead-end paths early.

function findWords(board, words) {
  const trie = new Trie();
  for (const w of words) trie.insert(w);
  const result = new Set(), rows = board.length, cols = board[0].length;

  function dfs(r, c, node, path) {
    if (node.isEnd) result.add(path);
    if (r < 0 || r >= rows || c < 0 || c >= cols) return;
    const ch = board[r][c];
    if (ch === '#' || !node.children[ch]) return;
    board[r][c] = '#'; // mark visited
    const next = node.children[ch];
    for (const [dr, dc] of [[0,1],[0,-1],[1,0],[-1,0]])
      dfs(r + dr, c + dc, next, path + ch);
    board[r][c] = ch; // restore
  }
  for (let r = 0; r < rows; r++)
    for (let c = 0; c < cols; c++)
      dfs(r, c, trie.root, "");
  return [...result];
}
def find_words(board, words):
    trie = Trie()
    for w in words: trie.insert(w)
    result, rows, cols = set(), len(board), len(board[0])

    def dfs(r, c, node, path):
        if node.is_end: result.add(path)
        if r < 0 or r >= rows or c < 0 or c >= cols: return
        ch = board[r][c]
        if ch == '#' or ch not in node.children: return
        board[r][c] = '#'  # mark visited
        nxt = node.children[ch]
        for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
            dfs(r + dr, c + dc, nxt, path + ch)
        board[r][c] = ch   # restore
    for r in range(rows):
        for c in range(cols):
            dfs(r, c, trie.root, "")
    return list(result)
// Simplified — uses same Trie class from above
Set<String> findWords(char[][] board, String[] words) {
    Trie trie = new Trie();
    for (String w : words) trie.insert(w);
    Set<String> result = new HashSet<>();
    for (int r = 0; r < board.length; r++)
        for (int c = 0; c < board[0].length; c++)
            dfs(board, r, c, trie.root, "", result);
    return result;
}
void dfs(char[][] board, int r, int c, TrieNode node,
         String path, Set<String> result) {
    if (node.isEnd) result.add(path);
    if (r<0||r>=board.length||c<0||c>=board[0].length) return;
    char ch = board[r][c];
    if (ch=='#'||!node.children.containsKey(ch)) return;
    board[r][c] = '#';
    TrieNode next = node.children.get(ch);
    int[][] dirs = {{0,1},{0,-1},{1,0},{-1,0}};
    for (int[] d : dirs)
        dfs(board, r+d[0], c+d[1], next, path+ch, result);
    board[r][c] = ch;
}

Key Takeaways

  • A trie stores strings character by character in a tree structure. Common prefixes share nodes.
  • The three core operations (insert, search, startsWith) all run in O(m) time where m is the string length.
  • Tries are the go-to for prefix queries, autocomplete, and spell checking.
  • The isEnd flag is critical — without it we can’t distinguish complete words from prefixes.
  • For the word search board problem, a trie lets us prune entire branches of invalid paths during DFS.

In simple language, a trie is a tree optimized for strings. It trades space for blazing-fast prefix lookups. When we hear “prefix,” “autocomplete,” or “dictionary,” we should think trie.


Graphs

24

Graph Representations

beginner graph adjacency-list adjacency-matrix data-structure

A graph is just a collection of nodes (also called vertices) connected by edges. That’s it. Think of a social network: people are nodes, friendships are edges.

Graphs are everywhere in interviews. Shortest path, connected components, dependency resolution — all graph problems. Before we can solve any of them, we need to know how to represent a graph in code.

Types of Graphs

Undirected Graph
A ————— B ————— C
Edges go both ways. A↔B means B↔A too. Like friendships.
Directed Graph (Digraph)
A ——→ B ——→ C
Edges have direction. A→B doesn't mean B→A. Like Twitter follows.
Weighted Graph
A —5— B —3— C
Edges have costs/weights. Like distances between cities.

Representation 1: Adjacency List (Most Common)

This is what we’ll use 90% of the time in interviews. For each node, we store a list of its neighbors.

In simple language, it’s like a phone book. Each person’s entry lists who they’re connected to.

// Build adjacency list from edge list (undirected)
function buildGraph(edges, n) {
  const graph = Array.from({ length: n }, () => []);
  for (const [u, v] of edges) {
    graph[u].push(v); // u connects to v
    graph[v].push(u); // v connects to u (undirected)
  }
  return graph;
}
// edges = [[0,1], [0,2], [1,3]]
// graph = [[1,2], [0,3], [0], [1]]
# Build adjacency list from edge list (undirected)
from collections import defaultdict

def build_graph(edges, n):
    graph = defaultdict(list)
    for u, v in edges:
        graph[u].append(v)  # u connects to v
        graph[v].append(u)  # v connects to u (undirected)
    return graph
# edges = [[0,1], [0,2], [1,3]]
# graph = {0: [1,2], 1: [0,3], 2: [0], 3: [1]}
// Build adjacency list from edge list (undirected)
List<List<Integer>> buildGraph(int[][] edges, int n) {
    List<List<Integer>> graph = new ArrayList<>();
    for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
    for (int[] e : edges) {
        graph.get(e[0]).add(e[1]); // u connects to v
        graph.get(e[1]).add(e[0]); // v connects to u
    }
    return graph;
}

For a directed graph, we just skip the second line — only add u → v, not v → u.

For a weighted graph, we store [neighbor, weight] pairs instead of just neighbors:

// Weighted adjacency list
function buildWeightedGraph(edges) {
  const graph = new Map();
  for (const [u, v, weight] of edges) {
    if (!graph.has(u)) graph.set(u, []);
    if (!graph.has(v)) graph.set(v, []);
    graph.get(u).push([v, weight]);
    graph.get(v).push([u, weight]); // skip for directed
  }
  return graph;
}
# Weighted adjacency list
def build_weighted_graph(edges):
    graph = defaultdict(list)
    for u, v, weight in edges:
        graph[u].append((v, weight))
        graph[v].append((u, weight))  # skip for directed
    return graph
// Weighted adjacency list — store int[] {neighbor, weight}
Map<Integer, List<int[]>> buildWeightedGraph(int[][] edges) {
    Map<Integer, List<int[]>> graph = new HashMap<>();
    for (int[] e : edges) {
        graph.computeIfAbsent(e[0], k -> new ArrayList<>()).add(new int[]{e[1], e[2]});
        graph.computeIfAbsent(e[1], k -> new ArrayList<>()).add(new int[]{e[0], e[2]});
    }
    return graph;
}

Representation 2: Adjacency Matrix

A 2D array where matrix[i][j] = 1 means there’s an edge from node i to node j. For weighted graphs, store the weight instead of 1.

// Build adjacency matrix (undirected)
function buildMatrix(edges, n) {
  const matrix = Array.from({ length: n }, () => Array(n).fill(0));
  for (const [u, v] of edges) {
    matrix[u][v] = 1;
    matrix[v][u] = 1; // undirected
  }
  return matrix;
}
# Build adjacency matrix (undirected)
def build_matrix(edges, n):
    matrix = [[0] * n for _ in range(n)]
    for u, v in edges:
        matrix[u][v] = 1
        matrix[v][u] = 1  # undirected
    return matrix
// Build adjacency matrix (undirected)
int[][] buildMatrix(int[][] edges, int n) {
    int[][] matrix = new int[n][n];
    for (int[] e : edges) {
        matrix[e[0]][e[1]] = 1;
        matrix[e[1]][e[0]] = 1; // undirected
    }
    return matrix;
}

When to Use Which?

Adjacency List
Adjacency Matrix
Space
O(V + E)
O(V²)
Check edge exists
O(neighbors)
O(1)
Get all neighbors
O(neighbors)
O(V)
Best for
Sparse graphs (most interview problems)
Dense graphs, quick edge lookup

Interview rule of thumb: Use adjacency list unless the problem specifically needs O(1) edge lookups. Most graph problems give us sparse graphs (way fewer edges than V²), so adjacency list saves space and is easier to traverse.

Common Input Format: Edge List

Many LeetCode problems give us edges as [[0,1], [1,2], [2,0]] plus the number of nodes n. Our first step is always converting this to an adjacency list. That’s the “build the graph” step before we do BFS/DFS.

Sometimes we get a different format — like a grid (2D array) where each cell is a node. We’ll cover grid traversal in the BFS/DFS topic.

Key Terminology

  • Degree — number of edges connected to a node. In directed graphs: in-degree (incoming edges) and out-degree (outgoing edges).
  • Path — sequence of nodes where each consecutive pair has an edge.
  • Cycle — a path that starts and ends at the same node.
  • Connected — there’s a path between every pair of nodes (undirected graphs).
  • DAG — Directed Acyclic Graph. A directed graph with no cycles. Super important for topological sort.

Graphs are the foundation for everything coming next — BFS, DFS, shortest paths, topological sort. Get comfortable building adjacency lists from edge lists, because we’ll be doing it in every graph problem.


25

BFS and DFS

intermediate BFS DFS graph traversal algorithm

BFS and DFS are the two fundamental ways to explore a graph. Every single graph algorithm builds on one of these. Master them, and graph problems become way less scary.

In simple language, imagine we’re exploring a maze. BFS checks all rooms one step away, then all rooms two steps away, spreading outward like ripples. DFS picks one path and goes as deep as possible before backtracking.

Visual Comparison

BFS — Level by Level (Queue)
Level 0 1
Level 1 2 3
Level 2 4 5 6
Visit order: 1 → 2 → 3 → 4 → 5 → 6. Finds shortest path in unweighted graphs.
DFS — Go Deep First (Stack/Recursion)
1 2 4 5
backtrack
_ 3 6
Visit order: 1 → 2 → 4 → 5 → 3 → 6. Great for exploring all paths, backtracking.

BFS Implementation

BFS uses a queue — first in, first out. We process nodes level by level. This is why BFS naturally gives us the shortest path in unweighted graphs.

// BFS traversal on adjacency list
function bfs(graph, start) {
  const visited = new Set([start]);
  const queue = [start];
  while (queue.length > 0) {
    const node = queue.shift(); // dequeue front
    console.log(node); // process node
    for (const neighbor of graph[node]) {
      if (!visited.has(neighbor)) {
        visited.add(neighbor);
        queue.push(neighbor); // enqueue
      }
    }
  }
}
# BFS traversal on adjacency list
from collections import deque

def bfs(graph, start):
    visited = {start}
    queue = deque([start])
    while queue:
        node = queue.popleft()  # dequeue front
        print(node)  # process node
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)  # enqueue
// BFS traversal on adjacency list
void bfs(List<List<Integer>> graph, int start) {
    Set<Integer> visited = new HashSet<>();
    Queue<Integer> queue = new LinkedList<>();
    visited.add(start);
    queue.add(start);
    while (!queue.isEmpty()) {
        int node = queue.poll(); // dequeue front
        System.out.println(node); // process node
        for (int neighbor : graph.get(node)) {
            if (!visited.contains(neighbor)) {
                visited.add(neighbor);
                queue.add(neighbor);
            }
        }
    }
}

Key detail: We mark nodes as visited when we add them to the queue, not when we process them. This prevents duplicates in the queue.

DFS Implementation

DFS goes deep before going wide. We can implement it with recursion (implicit stack) or an explicit stack.

Recursive DFS

// DFS recursive on adjacency list
function dfs(graph, node, visited = new Set()) {
  visited.add(node);
  console.log(node); // process node
  for (const neighbor of graph[node]) {
    if (!visited.has(neighbor)) {
      dfs(graph, neighbor, visited);
    }
  }
}
# DFS recursive on adjacency list
def dfs(graph, node, visited=None):
    if visited is None:
        visited = set()
    visited.add(node)
    print(node)  # process node
    for neighbor in graph[node]:
        if neighbor not in visited:
            dfs(graph, neighbor, visited)
// DFS recursive on adjacency list
void dfs(List<List<Integer>> graph, int node, Set<Integer> visited) {
    visited.add(node);
    System.out.println(node); // process node
    for (int neighbor : graph.get(node)) {
        if (!visited.contains(neighbor)) {
            dfs(graph, neighbor, visited);
        }
    }
}

Iterative DFS

Same logic, but we manage the stack ourselves. Useful when recursion depth might cause a stack overflow.

// DFS iterative on adjacency list
function dfsIterative(graph, start) {
  const visited = new Set();
  const stack = [start];
  while (stack.length > 0) {
    const node = stack.pop(); // pop from top
    if (visited.has(node)) continue;
    visited.add(node);
    console.log(node); // process node
    for (const neighbor of graph[node]) {
      if (!visited.has(neighbor)) stack.push(neighbor);
    }
  }
}
# DFS iterative on adjacency list
def dfs_iterative(graph, start):
    visited = set()
    stack = [start]
    while stack:
        node = stack.pop()  # pop from top
        if node in visited:
            continue
        visited.add(node)
        print(node)  # process node
        for neighbor in graph[node]:
            if neighbor not in visited:
                stack.append(neighbor)
// DFS iterative on adjacency list
void dfsIterative(List<List<Integer>> graph, int start) {
    Set<Integer> visited = new HashSet<>();
    Deque<Integer> stack = new ArrayDeque<>();
    stack.push(start);
    while (!stack.isEmpty()) {
        int node = stack.pop();
        if (visited.contains(node)) continue;
        visited.add(node);
        System.out.println(node);
        for (int neighbor : graph.get(node))
            if (!visited.contains(neighbor)) stack.push(neighbor);
    }
}

Grid Traversal: Number of Islands

Grids are just graphs in disguise. Each cell is a node. Its neighbors are the 4 (or 8) adjacent cells. The classic problem: count connected groups of 1s in a 2D grid.

// Number of Islands — DFS on grid
function numIslands(grid) {
  let count = 0;
  const rows = grid.length, cols = grid[0].length;
  function dfs(r, c) {
    if (r < 0 || r >= rows || c < 0 || c >= cols) return;
    if (grid[r][c] !== '1') return;
    grid[r][c] = '0'; // mark visited by sinking
    dfs(r + 1, c); dfs(r - 1, c); // down, up
    dfs(r, c + 1); dfs(r, c - 1); // right, left
  }
  for (let r = 0; r < rows; r++)
    for (let c = 0; c < cols; c++)
      if (grid[r][c] === '1') { dfs(r, c); count++; }
  return count;
}
# Number of Islands — DFS on grid
def num_islands(grid):
    count = 0
    rows, cols = len(grid), len(grid[0])
    def dfs(r, c):
        if r < 0 or r >= rows or c < 0 or c >= cols:
            return
        if grid[r][c] != '1':
            return
        grid[r][c] = '0'  # mark visited by sinking
        dfs(r + 1, c); dfs(r - 1, c)  # down, up
        dfs(r, c + 1); dfs(r, c - 1)  # right, left
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == '1':
                dfs(r, c)
                count += 1
    return count
// Number of Islands — DFS on grid
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') { dfs(grid, r, c); count++; }
    return count;
}
void dfs(char[][] grid, int r, int c) {
    if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length) return;
    if (grid[r][c] != '1') return;
    grid[r][c] = '0'; // mark visited
    dfs(grid, r + 1, c); dfs(grid, r - 1, c);
    dfs(grid, r, c + 1); dfs(grid, r, c - 1);
}

The trick: Instead of maintaining a separate visited set, we modify the grid in place — change '1' to '0' when we visit a cell. This is the standard approach for grid DFS problems.

Connected Components

Sometimes we need to count how many separate groups (connected components) exist. The pattern is simple: loop through all nodes, BFS/DFS from each unvisited one, and count.

// Count connected components in undirected graph
function countComponents(n, edges) {
  const graph = Array.from({ length: n }, () => []);
  for (const [u, v] of edges) {
    graph[u].push(v);
    graph[v].push(u);
  }
  const visited = new Set();
  let count = 0;
  for (let i = 0; i < n; i++) {
    if (!visited.has(i)) {
      bfs(graph, i, visited); // or dfs
      count++;
    }
  }
  return count;
}
# Count connected components in undirected graph
def count_components(n, edges):
    graph = [[] for _ in range(n)]
    for u, v in edges:
        graph[u].append(v)
        graph[v].append(u)
    visited = set()
    count = 0
    for i in range(n):
        if i not in visited:
            bfs(graph, i, visited)  # or dfs
            count += 1
    return count
// Count connected components in undirected graph
int countComponents(int n, int[][] edges) {
    List<List<Integer>> graph = new ArrayList<>();
    for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
    for (int[] e : edges) { graph.get(e[0]).add(e[1]); graph.get(e[1]).add(e[0]); }
    Set<Integer> visited = new HashSet<>();
    int count = 0;
    for (int i = 0; i < n; i++) {
        if (!visited.contains(i)) { bfs(graph, i, visited); count++; }
    }
    return count;
}

BFS vs DFS: When to Use Which?

SituationUseWhy
Shortest path (unweighted)BFSBFS explores level by level, guarantees minimum steps
Explore all paths / backtrackingDFSNatural fit for recursion and trying all options
Cycle detectionDFSTrack the recursion stack to detect back edges
Topological sortDFS (or BFS with Kahn’s)Post-order DFS gives reverse topological order
Level-order anythingBFSWe process one level at a time
Connected componentsEitherBoth work equally well

Complexity

Both BFS and DFS visit every node and every edge exactly once:

  • Time: O(V + E) for adjacency list
  • Space: O(V) for the visited set + queue/stack

These two patterns are the backbone of graph algorithms. Everything from shortest paths to topological sort to cycle detection builds directly on BFS and DFS.


26

Topological Sort

intermediate topological-sort DAG graph algorithm

Topological sort is an ordering of nodes in a directed graph where for every edge A → B, node A comes before node B. In simple language, it’s like figuring out the right order to do things when some tasks depend on others.

Think of college courses. We can’t take “Advanced Algorithms” before “Intro to Programming.” Topological sort gives us a valid order to take all courses.

Important: Topological sort only works on DAGs (Directed Acyclic Graphs). If there’s a cycle, no valid ordering exists — we can’t resolve circular dependencies.

Visual Example

Course Dependencies
Math ——→ Physics ——→ Engineering
Intro CS ——→ Algorithms —↗
Valid order: Math → Intro CS → Physics → Algorithms → Engineering
Also valid: Intro CS → Math → Algorithms → Physics → Engineering
Multiple valid orderings can exist!

Approach 1: Kahn’s Algorithm (BFS with In-degree)

This is the most intuitive approach. The idea:

  1. Count in-degree (number of incoming edges) for each node
  2. Start with all nodes that have in-degree 0 (no dependencies)
  3. Process them, reduce in-degree of their neighbors
  4. When a neighbor’s in-degree hits 0, add it to the queue
  5. If we process all nodes, we have a valid ordering. If not, there’s a cycle.
// Kahn's Algorithm — BFS topological sort
function topoSort(numNodes, edges) {
  const graph = Array.from({ length: numNodes }, () => []);
  const inDegree = Array(numNodes).fill(0);
  for (const [u, v] of edges) {
    graph[u].push(v);
    inDegree[v]++;
  }
  const queue = [];
  for (let i = 0; i < numNodes; i++)
    if (inDegree[i] === 0) queue.push(i); // start with no-dependency nodes
  const order = [];
  while (queue.length > 0) {
    const node = queue.shift();
    order.push(node);
    for (const neighbor of graph[node]) {
      inDegree[neighbor]--;
      if (inDegree[neighbor] === 0) queue.push(neighbor);
    }
  }
  return order.length === numNodes ? order : []; // empty = cycle exists
}
# Kahn's Algorithm — BFS topological sort
from collections import deque

def topo_sort(num_nodes, edges):
    graph = [[] for _ in range(num_nodes)]
    in_degree = [0] * num_nodes
    for u, v in edges:
        graph[u].append(v)
        in_degree[v] += 1
    queue = deque(i for i in range(num_nodes) if in_degree[i] == 0)
    order = []
    while queue:
        node = queue.popleft()
        order.append(node)
        for neighbor in graph[node]:
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)
    return order if len(order) == num_nodes else []  # empty = cycle
// Kahn's Algorithm — BFS topological sort
int[] topoSort(int numNodes, int[][] edges) {
    List<List<Integer>> graph = new ArrayList<>();
    int[] inDegree = new int[numNodes];
    for (int i = 0; i < numNodes; i++) graph.add(new ArrayList<>());
    for (int[] e : edges) { graph.get(e[0]).add(e[1]); inDegree[e[1]]++; }
    Queue<Integer> queue = new LinkedList<>();
    for (int i = 0; i < numNodes; i++)
        if (inDegree[i] == 0) queue.add(i);
    int[] order = new int[numNodes];
    int idx = 0;
    while (!queue.isEmpty()) {
        int node = queue.poll();
        order[idx++] = node;
        for (int neighbor : graph.get(node))
            if (--inDegree[neighbor] == 0) queue.add(neighbor);
    }
    return idx == numNodes ? order : new int[0]; // empty = cycle
}

Approach 2: DFS-based Topological Sort

The DFS approach uses post-order traversal. We do DFS, and after we’ve fully explored all descendants of a node, we add it to a stack. The stack gives us the reverse topological order.

// DFS-based topological sort
function topoSortDFS(numNodes, edges) {
  const graph = Array.from({ length: numNodes }, () => []);
  for (const [u, v] of edges) graph[u].push(v);
  const visited = new Set();
  const stack = []; // result in reverse order
  function dfs(node) {
    visited.add(node);
    for (const neighbor of graph[node])
      if (!visited.has(neighbor)) dfs(neighbor);
    stack.push(node); // add AFTER processing all children
  }
  for (let i = 0; i < numNodes; i++)
    if (!visited.has(i)) dfs(i);
  return stack.reverse(); // reverse gives topological order
}
# DFS-based topological sort
def topo_sort_dfs(num_nodes, edges):
    graph = [[] for _ in range(num_nodes)]
    for u, v in edges:
        graph[u].append(v)
    visited = set()
    stack = []  # result in reverse order
    def dfs(node):
        visited.add(node)
        for neighbor in graph[node]:
            if neighbor not in visited:
                dfs(neighbor)
        stack.append(node)  # add AFTER processing all children
    for i in range(num_nodes):
        if i not in visited:
            dfs(i)
    return stack[::-1]  # reverse gives topological order
// DFS-based topological sort
int[] topoSortDFS(int numNodes, int[][] edges) {
    List<List<Integer>> graph = new ArrayList<>();
    for (int i = 0; i < numNodes; i++) graph.add(new ArrayList<>());
    for (int[] e : edges) graph.get(e[0]).add(e[1]);
    boolean[] visited = new boolean[numNodes];
    Deque<Integer> stack = new ArrayDeque<>();
    for (int i = 0; i < numNodes; i++)
        if (!visited[i]) dfs(graph, i, visited, stack);
    int[] order = new int[numNodes];
    for (int i = 0; i < numNodes; i++) order[i] = stack.pop();
    return order;
}
void dfs(List<List<Integer>> graph, int node, boolean[] visited, Deque<Integer> stack) {
    visited[node] = true;
    for (int neighbor : graph.get(node))
        if (!visited[neighbor]) dfs(graph, neighbor, visited, stack);
    stack.push(node); // post-order
}

Classic Problem: Course Schedule

LeetCode 207 — Given numCourses and prerequisite pairs, determine if we can finish all courses. This is just cycle detection in a directed graph. If we can topologically sort all nodes, there’s no cycle.

// Can we finish all courses? (cycle detection via Kahn's)
function canFinish(numCourses, prerequisites) {
  const graph = Array.from({ length: numCourses }, () => []);
  const inDegree = Array(numCourses).fill(0);
  for (const [course, prereq] of prerequisites) {
    graph[prereq].push(course);
    inDegree[course]++;
  }
  const queue = [];
  for (let i = 0; i < numCourses; i++)
    if (inDegree[i] === 0) queue.push(i);
  let count = 0;
  while (queue.length > 0) {
    const node = queue.shift();
    count++;
    for (const next of graph[node])
      if (--inDegree[next] === 0) queue.push(next);
  }
  return count === numCourses; // processed all = no cycle
}
# Can we finish all courses? (cycle detection via Kahn's)
from collections import deque

def can_finish(num_courses, prerequisites):
    graph = [[] for _ in range(num_courses)]
    in_degree = [0] * num_courses
    for course, prereq in prerequisites:
        graph[prereq].append(course)
        in_degree[course] += 1
    queue = deque(i for i in range(num_courses) if in_degree[i] == 0)
    count = 0
    while queue:
        node = queue.popleft()
        count += 1
        for nxt in graph[node]:
            in_degree[nxt] -= 1
            if in_degree[nxt] == 0:
                queue.append(nxt)
    return count == num_courses  # processed all = no cycle
// Can we finish all courses? (cycle detection via Kahn's)
boolean canFinish(int numCourses, int[][] prerequisites) {
    List<List<Integer>> graph = new ArrayList<>();
    int[] inDegree = new int[numCourses];
    for (int i = 0; i < numCourses; i++) graph.add(new ArrayList<>());
    for (int[] p : prerequisites) {
        graph.get(p[1]).add(p[0]);
        inDegree[p[0]]++;
    }
    Queue<Integer> queue = new LinkedList<>();
    for (int i = 0; i < numCourses; i++)
        if (inDegree[i] == 0) queue.add(i);
    int count = 0;
    while (!queue.isEmpty()) {
        int node = queue.poll();
        count++;
        for (int next : graph.get(node))
            if (--inDegree[next] == 0) queue.add(next);
    }
    return count == numCourses;
}

Kahn’s vs DFS: Which to Use?

Kahn’s (BFS)DFS-based
IntuitionEasy to understand — remove nodes with no depsLess intuitive (post-order)
Cycle detectionBuilt-in (if count != numNodes)Need extra state (3-color marking)
Interview preferenceMore common, easier to explainShorter code

Interview tip: Kahn’s algorithm is usually the safer choice. It’s easier to explain, cycle detection comes for free, and interviewers see it more often.

Complexity

  • Time: O(V + E) — we visit every node and edge once
  • Space: O(V + E) — graph storage plus the queue/stack

Topological sort shows up in build systems, package managers, course scheduling, and task dependency resolution. Whenever we see “ordering with dependencies” in an interview, this is our tool.


27

Shortest Path Algorithms

intermediate dijkstra shortest-path graph algorithm

Finding the shortest path between nodes is one of the most common graph problems. The algorithm we pick depends entirely on the type of graph.

In simple language, we’re asking: “What’s the cheapest/fastest way to get from A to B?” The answer depends on whether our roads have different lengths (weighted) or are all equal (unweighted).

The Decision Tree

Which Shortest Path Algorithm?
Is the graph weighted?
No
BFS
O(V + E)
Yes
Negative weights?
No
Dijkstra's
O((V+E) log V)
Yes
Bellman-Ford
O(V × E)

BFS for Unweighted Graphs

When all edges have the same weight (or weight = 1), BFS naturally finds the shortest path. The first time we reach a node, that’s the shortest distance. We covered BFS in the previous topic — here we just track distances.

// Shortest path in unweighted graph using BFS
function shortestPath(graph, start, end) {
  const dist = new Map([[start, 0]]);
  const queue = [start];
  while (queue.length > 0) {
    const node = queue.shift();
    if (node === end) return dist.get(end);
    for (const neighbor of graph[node]) {
      if (!dist.has(neighbor)) {
        dist.set(neighbor, dist.get(node) + 1);
        queue.push(neighbor);
      }
    }
  }
  return -1; // unreachable
}
# Shortest path in unweighted graph using BFS
from collections import deque

def shortest_path(graph, start, end):
    dist = {start: 0}
    queue = deque([start])
    while queue:
        node = queue.popleft()
        if node == end:
            return dist[end]
        for neighbor in graph[node]:
            if neighbor not in dist:
                dist[neighbor] = dist[node] + 1
                queue.append(neighbor)
    return -1  # unreachable
// Shortest path in unweighted graph using BFS
int shortestPath(List<List<Integer>> graph, int start, int end) {
    int[] dist = new int[graph.size()];
    Arrays.fill(dist, -1);
    dist[start] = 0;
    Queue<Integer> queue = new LinkedList<>();
    queue.add(start);
    while (!queue.isEmpty()) {
        int node = queue.poll();
        if (node == end) return dist[end];
        for (int neighbor : graph.get(node)) {
            if (dist[neighbor] == -1) {
                dist[neighbor] = dist[node] + 1;
                queue.add(neighbor);
            }
        }
    }
    return -1; // unreachable
}

Dijkstra’s Algorithm

Dijkstra’s handles weighted graphs with non-negative weights. The idea: always process the node with the smallest known distance next. We use a priority queue (min-heap) to efficiently pick the closest unvisited node.

Think of it like spreading water from a source. Water always flows to the nearest unfilled spot first.

// Dijkstra's using a min-heap (priority queue)
function dijkstra(graph, start, n) {
  const dist = Array(n).fill(Infinity);
  dist[start] = 0;
  // Min-heap: [distance, node] — JS needs a manual heap or sorted insert
  const pq = [[0, start]]; // [dist, node]
  while (pq.length > 0) {
    pq.sort((a, b) => a[0] - b[0]); // simulating min-heap
    const [d, node] = pq.shift();
    if (d > dist[node]) continue; // skip outdated entry
    for (const [neighbor, weight] of graph[node]) {
      const newDist = d + weight;
      if (newDist < dist[neighbor]) {
        dist[neighbor] = newDist;
        pq.push([newDist, neighbor]);
      }
    }
  }
  return dist; // dist[i] = shortest distance from start to i
}
# Dijkstra's using a min-heap (priority queue)
import heapq

def dijkstra(graph, start, n):
    dist = [float('inf')] * n
    dist[start] = 0
    pq = [(0, start)]  # (distance, node)
    while pq:
        d, node = heapq.heappop(pq)
        if d > dist[node]:  # skip outdated entry
            continue
        for neighbor, weight in graph[node]:
            new_dist = d + weight
            if new_dist < dist[neighbor]:
                dist[neighbor] = new_dist
                heapq.heappush(pq, (new_dist, neighbor))
    return dist  # dist[i] = shortest distance from start to i
// Dijkstra's using a min-heap (priority queue)
int[] dijkstra(List<List<int[]>> graph, int start, int n) {
    int[] dist = new int[n];
    Arrays.fill(dist, Integer.MAX_VALUE);
    dist[start] = 0;
    // PQ stores [distance, node]
    PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[0] - b[0]);
    pq.add(new int[]{0, start});
    while (!pq.isEmpty()) {
        int[] top = pq.poll();
        int d = top[0], node = top[1];
        if (d > dist[node]) continue; // skip outdated
        for (int[] edge : graph.get(node)) {
            int newDist = d + edge[1];
            if (newDist < dist[edge[0]]) {
                dist[edge[0]] = newDist;
                pq.add(new int[]{newDist, edge[0]});
            }
        }
    }
    return dist;
}

The if (d > dist[node]) continue check is crucial. Since we can’t efficiently update entries in the priority queue, we add duplicates. This line skips stale entries where we’ve already found a better path.

Bellman-Ford Algorithm

Bellman-Ford handles negative edge weights (which Dijkstra’s can’t). It’s slower but more versatile. The idea: relax every edge V-1 times. If we can still relax after V-1 rounds, there’s a negative cycle.

// Bellman-Ford — handles negative weights
function bellmanFord(edges, n, start) {
  const dist = Array(n).fill(Infinity);
  dist[start] = 0;
  for (let i = 0; i < n - 1; i++) { // V-1 relaxations
    for (const [u, v, w] of edges) {
      if (dist[u] !== Infinity && dist[u] + w < dist[v]) {
        dist[v] = dist[u] + w; // relax edge
      }
    }
  }
  // Check for negative cycles (one more round)
  for (const [u, v, w] of edges)
    if (dist[u] + w < dist[v]) return null; // negative cycle!
  return dist;
}
# Bellman-Ford — handles negative weights
def bellman_ford(edges, n, start):
    dist = [float('inf')] * n
    dist[start] = 0
    for _ in range(n - 1):  # V-1 relaxations
        for u, v, w in edges:
            if dist[u] != float('inf') and dist[u] + w < dist[v]:
                dist[v] = dist[u] + w  # relax edge
    # Check for negative cycles
    for u, v, w in edges:
        if dist[u] + w < dist[v]:
            return None  # negative cycle!
    return dist
// Bellman-Ford — handles negative weights
int[] bellmanFord(int[][] edges, int n, int start) {
    int[] dist = new int[n];
    Arrays.fill(dist, Integer.MAX_VALUE);
    dist[start] = 0;
    for (int i = 0; i < n - 1; i++) // V-1 relaxations
        for (int[] e : edges)
            if (dist[e[0]] != Integer.MAX_VALUE && dist[e[0]] + e[2] < dist[e[1]])
                dist[e[1]] = dist[e[0]] + e[2]; // relax
    // Check for negative cycles
    for (int[] e : edges)
        if (dist[e[0]] != Integer.MAX_VALUE && dist[e[0]] + e[2] < dist[e[1]])
            return null; // negative cycle!
    return dist;
}

Quick Comparison

Algorithm
Time
Works With
Data Structure
BFS
O(V + E)
Unweighted only
Queue
Dijkstra's
O((V+E) log V)
Non-negative weights
Min-Heap
Bellman-Ford
O(V × E)
Any weights
Edge list

Interview Tips

  • Unweighted graph? Just use BFS. Don’t overcomplicate it.
  • Weighted with non-negative? Dijkstra’s. This is the most common interview scenario.
  • Negative weights mentioned? Bellman-Ford. Rare in interviews, but know it exists.
  • “Network Delay Time” (LeetCode 743) is the classic Dijkstra’s problem: find the time it takes for a signal to reach all nodes.
  • Why can’t Dijkstra’s handle negative weights? Once we mark a node as “done” (shortest distance found), we never revisit it. A negative edge could later provide a shorter path, breaking this assumption.

Most interview shortest path problems boil down to Dijkstra’s with a priority queue. Get that implementation solid, and we’re covered for 90% of cases.


28

Union-Find (Disjoint Set)

intermediate union-find disjoint-set graph data-structure

Union-Find (also called Disjoint Set Union, DSU) is a data structure that tracks which elements belong to the same group. It supports two operations: find (which group is this element in?) and union (merge two groups together).

In simple language, imagine people at a party forming friend groups. Union-Find lets us quickly answer: “Are Alice and Bob in the same friend group?” and “Merge Alice’s group with Bob’s group.”

Why Not Just Use BFS/DFS?

We could use BFS/DFS for connected components. But Union-Find shines when:

  • Edges arrive one at a time (streaming/online)
  • We need to repeatedly check if two nodes are connected
  • We’re building connections incrementally (like Kruskal’s MST)

With Union-Find, each operation is nearly O(1). With BFS/DFS, we’d need to re-traverse the whole graph each time.

How It Works

Union-Find: Tree-based Groups
Initially: each node is its own parent (own group)
parent[0]=0 0
parent[1]=1 1
parent[2]=2 2
parent[3]=3 3
After union(0,1) and union(2,3):
0 1
2 3
Two groups: {0,1} and {2,3}. Roots are 0 and 2.
After union(1,3) — merges the two groups:
0
1
2 3
One group: {0,1,2,3}. find(3) → 2 → 0 (root).

Find follows parent pointers up to the root. Two nodes are in the same group if they have the same root.

Union connects two roots, merging their groups.

The Two Optimizations

Without optimizations, trees can become long chains (O(n) per find). Two tricks make it nearly O(1):

  1. Path Compression — During find, make every node point directly to the root. Flattens the tree.
  2. Union by Rank — Always attach the shorter tree under the taller one. Keeps trees balanced.

Together, they give us O(α(n)) per operation — that’s the inverse Ackermann function, which is effectively constant for any practical input.

Full Implementation

// Union-Find with path compression + union by rank
class UnionFind {
  constructor(n) {
    this.parent = Array.from({ length: n }, (_, i) => i);
    this.rank = Array(n).fill(0);
    this.count = n; // number of connected components
  }
  find(x) {
    if (this.parent[x] !== x)
      this.parent[x] = this.find(this.parent[x]); // path compression
    return this.parent[x];
  }
  union(x, y) {
    const px = this.find(x), py = this.find(y);
    if (px === py) return false; // already connected
    if (this.rank[px] < this.rank[py]) this.parent[px] = py;
    else if (this.rank[px] > this.rank[py]) this.parent[py] = px;
    else { this.parent[py] = px; this.rank[px]++; }
    this.count--; // one less component
    return true;
  }
  connected(x, y) { return this.find(x) === this.find(y); }
}
# Union-Find with path compression + union by rank
class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n
        self.count = n  # number of connected components

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])  # path compression
        return self.parent[x]

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py:
            return False  # already connected
        if self.rank[px] < self.rank[py]:
            px, py = py, px  # ensure px has higher rank
        self.parent[py] = px
        if self.rank[px] == self.rank[py]:
            self.rank[px] += 1
        self.count -= 1
        return True

    def connected(self, x, y):
        return self.find(x) == self.find(y)
// Union-Find with path compression + union by rank
class UnionFind {
    int[] parent, rank;
    int count;
    UnionFind(int n) {
        parent = new int[n]; rank = new int[n]; count = 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 x, int y) {
        int px = find(x), py = find(y);
        if (px == py) return false;
        if (rank[px] < rank[py]) { int tmp = px; px = py; py = tmp; }
        parent[py] = px;
        if (rank[px] == rank[py]) rank[px]++;
        count--;
        return true;
    }
    boolean connected(int x, int y) { return find(x) == find(y); }
}

Problem: Number of Connected Components

Given n nodes and a list of edges, count the connected components. Classic Union-Find problem.

// Count connected components using Union-Find
function countComponents(n, edges) {
  const uf = new UnionFind(n);
  for (const [u, v] of edges) uf.union(u, v);
  return uf.count; // that's it!
}
# Count connected components using Union-Find
def count_components(n, edges):
    uf = UnionFind(n)
    for u, v in edges:
        uf.union(u, v)
    return uf.count  # that's it!
// Count connected components using Union-Find
int countComponents(int n, int[][] edges) {
    UnionFind uf = new UnionFind(n);
    for (int[] e : edges) uf.union(e[0], e[1]);
    return uf.count; // that's it!
}

Problem: Redundant Connection

Given a graph that was originally a tree plus one extra edge, find that extra edge. The first edge that connects two already-connected nodes is our answer.

// Find the redundant edge (one that creates a cycle)
function findRedundantConnection(edges) {
  const uf = new UnionFind(edges.length + 1);
  for (const [u, v] of edges) {
    if (!uf.union(u, v)) return [u, v]; // already connected = cycle!
  }
}
# Find the redundant edge (one that creates a cycle)
def find_redundant_connection(edges):
    uf = UnionFind(len(edges) + 1)
    for u, v in edges:
        if not uf.union(u, v):
            return [u, v]  # already connected = cycle!
// Find the redundant edge (one that creates a cycle)
int[] findRedundantConnection(int[][] edges) {
    UnionFind uf = new UnionFind(edges.length + 1);
    for (int[] e : edges)
        if (!uf.union(e[0], e[1])) return e; // already connected = cycle!
    return new int[0];
}

The trick: process edges one by one. If union returns false, both nodes are already in the same group — adding this edge creates a cycle.

When Union-Find vs BFS/DFS?

ScenarioBest Choice
Static graph, single traversalBFS/DFS
Edges added incrementallyUnion-Find
Repeated “are X and Y connected?” queriesUnion-Find
Need shortest pathBFS/DFS
Kruskal’s MSTUnion-Find
Number of islands (grid)Either (DFS is simpler)

Complexity

  • Find: O(α(n)) — nearly constant
  • Union: O(α(n)) — nearly constant
  • Space: O(n) for parent and rank arrays

Union-Find is one of those data structures that seems niche until we realize how many problems it solves elegantly. Connected components, cycle detection, MST — it’s a Swiss Army knife for graph connectivity.


29

Advanced Graph Problems

advanced graph cycle-detection bipartite MST advanced

These are the graph problems that show up in senior-level interviews. They build on everything we’ve covered — BFS, DFS, Union-Find — and add a layer of complexity. Let’s break them down one at a time.

Cycle Detection in Directed Graphs

In a directed graph, we detect cycles using DFS with three states (also called 3-color marking):

  • White (0) — Not visited yet
  • Gray (1) — Currently in the recursion stack (being explored)
  • Black (2) — Fully processed (all descendants explored)

If we encounter a gray node during DFS, we’ve found a cycle. That gray node is an ancestor in our current path, meaning we’ve looped back to it.

3-Color DFS for Cycle Detection
White — unvisited
Gray — in current path
Black — done
A B C → A  CYCLE! (A is gray)
// Detect cycle in directed graph (3-color DFS)
function hasCycle(graph, n) {
  const color = Array(n).fill(0); // 0=white, 1=gray, 2=black
  function dfs(node) {
    color[node] = 1; // mark gray (in progress)
    for (const neighbor of graph[node]) {
      if (color[neighbor] === 1) return true;  // gray = cycle!
      if (color[neighbor] === 0 && dfs(neighbor)) return true;
    }
    color[node] = 2; // mark black (done)
    return false;
  }
  for (let i = 0; i < n; i++)
    if (color[i] === 0 && dfs(i)) return true;
  return false;
}
# Detect cycle in directed graph (3-color DFS)
def has_cycle(graph, n):
    color = [0] * n  # 0=white, 1=gray, 2=black
    def dfs(node):
        color[node] = 1  # mark gray (in progress)
        for neighbor in graph[node]:
            if color[neighbor] == 1:  # gray = cycle!
                return True
            if color[neighbor] == 0 and dfs(neighbor):
                return True
        color[node] = 2  # mark black (done)
        return False
    return any(color[i] == 0 and dfs(i) for i in range(n))
// Detect cycle in directed graph (3-color DFS)
boolean hasCycle(List<List<Integer>> graph, int n) {
    int[] color = new int[n]; // 0=white, 1=gray, 2=black
    for (int i = 0; i < n; i++)
        if (color[i] == 0 && dfs(graph, i, color)) return true;
    return false;
}
boolean dfs(List<List<Integer>> graph, int node, int[] color) {
    color[node] = 1; // gray
    for (int neighbor : graph.get(node)) {
        if (color[neighbor] == 1) return true; // cycle!
        if (color[neighbor] == 0 && dfs(graph, neighbor, color)) return true;
    }
    color[node] = 2; // black
    return false;
}

Cycle Detection in Undirected Graphs

For undirected graphs, it’s simpler. During DFS, if we visit a node that’s already been visited and it’s not our parent, we found a cycle.

// Detect cycle in undirected graph
function hasCycleUndirected(graph, n) {
  const visited = new Set();
  function dfs(node, parent) {
    visited.add(node);
    for (const neighbor of graph[node]) {
      if (!visited.has(neighbor)) {
        if (dfs(neighbor, node)) return true;
      } else if (neighbor !== parent) {
        return true; // visited and not parent = cycle!
      }
    }
    return false;
  }
  for (let i = 0; i < n; i++)
    if (!visited.has(i) && dfs(i, -1)) return true;
  return false;
}
# Detect cycle in undirected graph
def has_cycle_undirected(graph, n):
    visited = set()
    def dfs(node, parent):
        visited.add(node)
        for neighbor in graph[node]:
            if neighbor not in visited:
                if dfs(neighbor, node):
                    return True
            elif neighbor != parent:
                return True  # visited and not parent = cycle!
        return False
    return any(i not in visited and dfs(i, -1) for i in range(n))
// Detect cycle in undirected graph
boolean hasCycleUndirected(List<List<Integer>> graph, int n) {
    boolean[] visited = new boolean[n];
    for (int i = 0; i < n; i++)
        if (!visited[i] && dfs(graph, i, -1, visited)) return true;
    return false;
}
boolean dfs(List<List<Integer>> graph, int node, int parent, boolean[] visited) {
    visited[node] = true;
    for (int neighbor : graph.get(node)) {
        if (!visited[neighbor]) {
            if (dfs(graph, neighbor, node, visited)) return true;
        } else if (neighbor != parent) return true; // cycle!
    }
    return false;
}

Bipartite Check (Graph 2-Coloring)

A graph is bipartite if we can color every node with one of two colors such that no two adjacent nodes share the same color. In simple language, can we split all nodes into two teams where no one on the same team is connected?

This shows up as: “Is this graph 2-colorable?” or “Can we divide into two groups?”

We use BFS and try to assign alternating colors. If we find a neighbor with the same color as the current node, it’s not bipartite.

// Check if graph is bipartite using BFS
function isBipartite(graph, n) {
  const color = Array(n).fill(-1); // -1 = uncolored
  for (let i = 0; i < n; i++) {
    if (color[i] !== -1) continue; // already colored
    color[i] = 0;
    const queue = [i];
    while (queue.length > 0) {
      const node = queue.shift();
      for (const neighbor of graph[node]) {
        if (color[neighbor] === -1) {
          color[neighbor] = 1 - color[node]; // alternate color
          queue.push(neighbor);
        } else if (color[neighbor] === color[node]) {
          return false; // same color = not bipartite
        }
      }
    }
  }
  return true;
}
# Check if graph is bipartite using BFS
from collections import deque

def is_bipartite(graph, n):
    color = [-1] * n  # -1 = uncolored
    for i in range(n):
        if color[i] != -1:
            continue
        color[i] = 0
        queue = deque([i])
        while queue:
            node = queue.popleft()
            for neighbor in graph[node]:
                if color[neighbor] == -1:
                    color[neighbor] = 1 - color[node]  # alternate
                    queue.append(neighbor)
                elif color[neighbor] == color[node]:
                    return False  # not bipartite
    return True
// Check if graph is bipartite using BFS
boolean isBipartite(List<List<Integer>> graph, int n) {
    int[] color = new int[n];
    Arrays.fill(color, -1);
    for (int i = 0; i < n; i++) {
        if (color[i] != -1) continue;
        color[i] = 0;
        Queue<Integer> queue = new LinkedList<>();
        queue.add(i);
        while (!queue.isEmpty()) {
            int node = queue.poll();
            for (int neighbor : graph.get(node)) {
                if (color[neighbor] == -1) {
                    color[neighbor] = 1 - color[node];
                    queue.add(neighbor);
                } else if (color[neighbor] == color[node]) return false;
            }
        }
    }
    return true;
}

Minimum Spanning Tree (Kruskal’s)

A Minimum Spanning Tree (MST) connects all nodes in a weighted undirected graph with the minimum total edge weight, using exactly V-1 edges and no cycles.

In simple language, imagine connecting all cities with roads. MST gives us the cheapest way to connect everyone without any redundant roads.

Kruskal’s Algorithm:

  1. Sort all edges by weight
  2. Process edges from lightest to heaviest
  3. Add an edge if it doesn’t create a cycle (use Union-Find to check!)
  4. Stop when we have V-1 edges
// Kruskal's MST using Union-Find
function kruskalMST(n, edges) {
  edges.sort((a, b) => a[2] - b[2]); // sort by weight
  const uf = new UnionFind(n); // from previous topic
  let totalWeight = 0;
  const mstEdges = [];
  for (const [u, v, w] of edges) {
    if (uf.union(u, v)) { // no cycle? add it
      totalWeight += w;
      mstEdges.push([u, v, w]);
      if (mstEdges.length === n - 1) break; // MST complete
    }
  }
  return { totalWeight, mstEdges };
}
# Kruskal's MST using Union-Find
def kruskal_mst(n, edges):
    edges.sort(key=lambda e: e[2])  # sort by weight
    uf = UnionFind(n)  # from previous topic
    total_weight = 0
    mst_edges = []
    for u, v, w in edges:
        if uf.union(u, v):  # no cycle? add it
            total_weight += w
            mst_edges.append((u, v, w))
            if len(mst_edges) == n - 1:  # MST complete
                break
    return total_weight, mst_edges
// Kruskal's MST using Union-Find
int kruskalMST(int n, int[][] edges) {
    Arrays.sort(edges, (a, b) -> a[2] - b[2]); // sort by weight
    UnionFind uf = new UnionFind(n); // from previous topic
    int totalWeight = 0, edgeCount = 0;
    for (int[] e : edges) {
        if (uf.union(e[0], e[1])) { // no cycle? add it
            totalWeight += e[2];
            if (++edgeCount == n - 1) break; // MST complete
        }
    }
    return totalWeight;
}

Notice how Kruskal’s is basically Union-Find doing the heavy lifting. We sort edges, then greedily pick the lightest ones that don’t create cycles.

When These Come Up in Interviews

Problem Type
Algorithm
Example
Can we complete all tasks?
Cycle detection (directed)
Course Schedule
Split into two groups?
Bipartite check
Is Graph Bipartite?
Extra/redundant connection?
Union-Find cycle check
Redundant Connection
Minimum cost to connect all?
Kruskal's MST
Min Cost to Connect All Points

Complexity Summary

AlgorithmTimeSpace
Cycle Detection (directed)O(V + E)O(V)
Cycle Detection (undirected)O(V + E)O(V)
Bipartite CheckO(V + E)O(V)
Kruskal’s MSTO(E log E)O(V)

These are the capstone graph problems. If we can comfortably code cycle detection, bipartite checking, and Kruskal’s MST, we’re well-prepared for even senior-level graph questions. Each one is really just a clever application of DFS, BFS, or Union-Find that we already know.


Dynamic Programming

30

Dynamic Programming Fundamentals

intermediate dynamic-programming memoization tabulation

Dynamic Programming (DP) is just a fancy name for remembering answers to subproblems so we don’t solve them again. That’s it. If we’ve already computed something, we save it and reuse it next time.

In simple language, it’s like writing answers on a cheat sheet as we go. Instead of recalculating the same thing 50 times, we look it up from our notes.

The Two Key Properties

A problem is a good candidate for DP when it has both of these:

  1. Overlapping Subproblems — the same smaller problems come up again and again. If every subproblem is unique, DP won’t help us.
  2. Optimal Substructure — the best solution to the big problem can be built from best solutions to its subproblems.

Fibonacci: The Hello World of DP

Let’s start with the classic. The Fibonacci sequence: 0, 1, 1, 2, 3, 5, 8, 13…

Each number is the sum of the two before it: fib(n) = fib(n-1) + fib(n-2).

The Naive Way (Exponential — Terrible)

function fib(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2); // same calls happen over and over
}
// fib(5) triggers 15 function calls!
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)  # same calls happen over and over
# fib(5) triggers 15 function calls!
static int fib(int n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2); // same calls happen over and over
}
// fib(5) triggers 15 function calls!

Here’s the problem — look at how many times the same values get computed:

Recursive Calls for fib(5) — Overlapping Subproblems
fib(5)
fib(4)
fib(3)
fib(2) ⚡
fib(1)
fib(2) ⚡
fib(3) ⚡
fib(2) ⚡
fib(1)
⚡ = repeated computation. fib(2) gets computed 3 times, fib(3) gets computed 2 times!

This is O(2ⁿ) time. For fib(50), that’s over a trillion calls. Yikes.

Approach 1: Memoization (Top-Down)

We start from the top (the original problem) and recurse down, but we cache results in a hash map. Before computing anything, we check if we already know the answer.

function fib(n, memo = {}) {
  if (n <= 1) return n;
  if (n in memo) return memo[n];     // already solved? return it
  memo[n] = fib(n - 1, memo) + fib(n - 2, memo);
  return memo[n];
}
// fib(50) now runs instantly — O(n) time, O(n) space
def fib(n, memo={}):
    if n <= 1:
        return n
    if n in memo:
        return memo[n]              # already solved? return it
    memo[n] = fib(n - 1, memo) + fib(n - 2, memo)
    return memo[n]
# fib(50) now runs instantly — O(n) time, O(n) space
static Map<Integer, Integer> memo = new HashMap<>();
static int fib(int n) {
    if (n <= 1) return n;
    if (memo.containsKey(n)) return memo.get(n); // already solved?
    memo.put(n, fib(n - 1) + fib(n - 2));
    return memo.get(n);
}
// fib(50) now runs instantly — O(n) time, O(n) space

Approach 2: Tabulation (Bottom-Up)

We start from the smallest subproblems and build up to the answer using an array (the “table”). No recursion at all.

function fib(n) {
  if (n <= 1) return n;
  const dp = [0, 1];                // base cases
  for (let i = 2; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2]; // build from bottom up
  }
  return dp[n];
}
def fib(n):
    if n <= 1:
        return n
    dp = [0, 1]                      # base cases
    for i in range(2, n + 1):
        dp.append(dp[i - 1] + dp[i - 2])  # build from bottom up
    return dp[n]
static int fib(int n) {
    if (n <= 1) return n;
    int[] dp = new int[n + 1];
    dp[0] = 0; dp[1] = 1;             // base cases
    for (int i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2]; // build from bottom up
    }
    return dp[n];
}

Space-Optimized (Constant Space)

We only ever need the last two values. No need for a whole array.

function fib(n) {
  if (n <= 1) return n;
  let prev2 = 0, prev1 = 1;
  for (let i = 2; i <= n; i++) {
    const curr = prev1 + prev2;   // only keep what we need
    prev2 = prev1;
    prev1 = curr;
  }
  return prev1; // O(1) space!
}
def fib(n):
    if n <= 1:
        return n
    prev2, prev1 = 0, 1
    for i in range(2, n + 1):
        curr = prev1 + prev2     # only keep what we need
        prev2 = prev1
        prev1 = curr
    return prev1  # O(1) space!
static int fib(int n) {
    if (n <= 1) return n;
    int prev2 = 0, prev1 = 1;
    for (int i = 2; i <= n; i++) {
        int curr = prev1 + prev2; // only keep what we need
        prev2 = prev1;
        prev1 = curr;
    }
    return prev1; // O(1) space!
}

Memoization vs Tabulation

Memoization (Top-Down) Tabulation (Bottom-Up)
DirectionTop → Bottom (recursive)Bottom → Top (iterative)
StorageHash map / dictionaryArray / table
Stack overflow?Possible (deep recursion)Never (just loops)
ComputesOnly needed subproblemsAll subproblems
Easier to write?Usually yes (add cache to recursion)Sometimes tricky (need correct order)

Pro tip: In interviews, start with memoization (it’s easier to think about). Then optimize to tabulation if the interviewer asks.

How to Recognize DP Problems

Look for these signals:

  1. “Find the minimum/maximum…” — optimization problems love DP.
  2. “How many ways to…” — counting problems are classic DP.
  3. “Can we reach / is it possible…” — feasibility checks with choices.
  4. The choices at each step affect future choices — greedy won’t work, we need to explore combinations.
  5. The problem has a recursive structure — and we see the same subproblems repeating.

Common keywords: minimum cost, maximum profit, number of ways, longest/shortest, can we partition, is it possible.

The DP Recipe

Here’s a step-by-step process that works for most DP problems:

  1. Define the state — what does dp[i] (or dp[i][j]) represent?
  2. Find the recurrence — how does dp[i] relate to smaller subproblems?
  3. Identify base cases — what are the trivial answers we know right away?
  4. Determine computation order — which direction do we fill the table?
  5. Optimize space — can we drop dimensions we no longer need?

This recipe will carry us through 90% of DP problems in interviews. Let’s apply it across the next few notes.


31

1D DP Patterns

intermediate dynamic-programming 1D-DP coin-change pattern

1D DP problems are the most common type we’ll see in interviews. The pattern is simple: dp[i] depends on some previous values like dp[i-1], dp[i-2], etc. We fill a single array from left to right.

Once we nail these, the harder DP problems are just extensions of the same idea.

Climbing Stairs

Problem: We can climb 1 or 2 steps at a time. How many distinct ways to reach step n?

This is literally Fibonacci in disguise. To reach step i, we either came from step i-1 (took 1 step) or step i-2 (took 2 steps).

State: dp[i] = number of ways to reach step i

Recurrence: dp[i] = dp[i-1] + dp[i-2]

function climbStairs(n) {
  if (n <= 2) return n;
  let prev2 = 1, prev1 = 2;      // dp[1] = 1, dp[2] = 2
  for (let i = 3; i <= n; i++) {
    const curr = prev1 + prev2;   // two ways to arrive here
    prev2 = prev1;
    prev1 = curr;
  }
  return prev1;
}
// Time: O(n), Space: O(1)
def climb_stairs(n):
    if n <= 2:
        return n
    prev2, prev1 = 1, 2           # dp[1] = 1, dp[2] = 2
    for i in range(3, n + 1):
        curr = prev1 + prev2      # two ways to arrive here
        prev2 = prev1
        prev1 = curr
    return prev1
# Time: O(n), Space: O(1)
static int climbStairs(int n) {
    if (n <= 2) return n;
    int prev2 = 1, prev1 = 2;      // dp[1] = 1, dp[2] = 2
    for (int i = 3; i <= n; i++) {
        int curr = prev1 + prev2;   // two ways to arrive here
        prev2 = prev1;
        prev1 = curr;
    }
    return prev1;
}
// Time: O(n), Space: O(1)

House Robber

Problem: We’re a robber with a row of houses. Each has some money. We can’t rob two adjacent houses (alarm triggers). Find the max money we can steal.

At each house we have a choice: rob it (take its money + best from 2 houses back) or skip it (keep the best from the previous house).

State: dp[i] = max money we can get from the first i houses

Recurrence: dp[i] = max(dp[i-1], dp[i-2] + nums[i])

House Robber Decision — nums = [2, 7, 9, 3, 1]
$2
rob
$7
skip
$9
rob
$3
skip
$1
rob
Best: $2 + $9 + $1 = $12
function rob(nums) {
  if (nums.length === 1) return nums[0];
  let prev2 = 0, prev1 = 0;
  for (const money of nums) {
    const curr = Math.max(prev1, prev2 + money); // skip or rob
    prev2 = prev1;
    prev1 = curr;
  }
  return prev1;
}
// Time: O(n), Space: O(1)
def rob(nums):
    if len(nums) == 1:
        return nums[0]
    prev2, prev1 = 0, 0
    for money in nums:
        curr = max(prev1, prev2 + money)  # skip or rob
        prev2 = prev1
        prev1 = curr
    return prev1
# Time: O(n), Space: O(1)
static int rob(int[] nums) {
    if (nums.length == 1) return nums[0];
    int prev2 = 0, prev1 = 0;
    for (int money : nums) {
        int curr = Math.max(prev1, prev2 + money); // skip or rob
        prev2 = prev1;
        prev1 = curr;
    }
    return prev1;
}
// Time: O(n), Space: O(1)

Coin Change

Problem: Given coin denominations and a target amount, find the minimum number of coins needed. If it’s impossible, return -1.

This one is different from the above — instead of looking at just i-1 and i-2, we look at dp[i - coin] for every coin denomination.

State: dp[i] = minimum coins to make amount i

Recurrence: dp[i] = min(dp[i - coin] + 1) for each coin

Let’s see both memoized and tabulated approaches.

Memoized (Top-Down)

function coinChange(coins, amount) {
  const memo = {};
  function dp(rem) {
    if (rem === 0) return 0;           // no coins needed
    if (rem < 0) return Infinity;       // invalid path
    if (rem in memo) return memo[rem];
    let best = Infinity;
    for (const coin of coins) {
      best = Math.min(best, dp(rem - coin) + 1);
    }
    return memo[rem] = best;
  }
  const res = dp(amount);
  return res === Infinity ? -1 : res;
}
def coin_change(coins, amount):
    memo = {}
    def dp(rem):
        if rem == 0: return 0           # no coins needed
        if rem < 0: return float('inf') # invalid path
        if rem in memo: return memo[rem]
        best = float('inf')
        for coin in coins:
            best = min(best, dp(rem - coin) + 1)
        memo[rem] = best
        return best
    res = dp(amount)
    return -1 if res == float('inf') else res
static int coinChange(int[] coins, int amount) {
    Map<Integer, Integer> memo = new HashMap<>();
    int res = dp(coins, amount, memo);
    return res == Integer.MAX_VALUE ? -1 : res;
}
static int dp(int[] coins, int rem, Map<Integer, Integer> memo) {
    if (rem == 0) return 0;
    if (rem < 0) return Integer.MAX_VALUE;
    if (memo.containsKey(rem)) return memo.get(rem);
    int best = Integer.MAX_VALUE;
    for (int coin : coins) {
        int sub = dp(coins, rem - coin, memo);
        if (sub != Integer.MAX_VALUE) best = Math.min(best, sub + 1);
    }
    memo.put(rem, best);
    return best;
}

Tabulated (Bottom-Up)

function coinChange(coins, amount) {
  const dp = new Array(amount + 1).fill(Infinity);
  dp[0] = 0;                          // base case: 0 coins for $0
  for (let i = 1; i <= amount; i++) {
    for (const coin of coins) {
      if (i - coin >= 0) {
        dp[i] = Math.min(dp[i], dp[i - coin] + 1);
      }
    }
  }
  return dp[amount] === Infinity ? -1 : dp[amount];
}
// Time: O(amount * coins), Space: O(amount)
def coin_change(coins, amount):
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0                          # base case: 0 coins for $0
    for i in range(1, amount + 1):
        for coin in coins:
            if i - coin >= 0:
                dp[i] = min(dp[i], dp[i - coin] + 1)
    return -1 if dp[amount] == float('inf') else dp[amount]
# Time: O(amount * coins), Space: O(amount)
static int coinChange(int[] coins, int amount) {
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, Integer.MAX_VALUE);
    dp[0] = 0;                          // base case: 0 coins for $0
    for (int i = 1; i <= amount; i++) {
        for (int coin : coins) {
            if (i - coin >= 0 && dp[i - coin] != Integer.MAX_VALUE) {
                dp[i] = Math.min(dp[i], dp[i - coin] + 1);
            }
        }
    }
    return dp[amount] == Integer.MAX_VALUE ? -1 : dp[amount];
}
// Time: O(amount * coins), Space: O(amount)

Longest Increasing Subsequence (LIS)

Problem: Find the length of the longest strictly increasing subsequence.

For each element, we check all previous elements — if they’re smaller, we can extend their subsequence.

State: dp[i] = length of LIS ending at index i

Recurrence: dp[i] = max(dp[j] + 1) for all j < i where nums[j] < nums[i]

function lengthOfLIS(nums) {
  const dp = new Array(nums.length).fill(1); // each element is a subsequence of length 1
  for (let i = 1; i < nums.length; i++) {
    for (let j = 0; j < i; j++) {
      if (nums[j] < nums[i]) {
        dp[i] = Math.max(dp[i], dp[j] + 1); // extend the subsequence
      }
    }
  }
  return Math.max(...dp);
}
// Time: O(n²), Space: O(n)
def length_of_lis(nums):
    dp = [1] * len(nums)  # each element is a subsequence of length 1
    for i in range(1, len(nums)):
        for j in range(i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)  # extend the subsequence
    return max(dp)
# Time: O(n²), Space: O(n)
static int lengthOfLIS(int[] nums) {
    int[] dp = new int[nums.length];
    Arrays.fill(dp, 1);  // each element is a subsequence of length 1
    int maxLen = 1;
    for (int i = 1; i < nums.length; i++) {
        for (int j = 0; j < i; j++) {
            if (nums[j] < nums[i]) {
                dp[i] = Math.max(dp[i], dp[j] + 1);
            }
        }
        maxLen = Math.max(maxLen, dp[i]);
    }
    return maxLen;
}
// Time: O(n²), Space: O(n)

There’s an O(n log n) solution using binary search + patience sorting, but the O(n²) DP approach is what interviewers want us to explain first.

Word Break

Problem: Given a string and a dictionary of words, can we segment the string into a space-separated sequence of dictionary words?

State: dp[i] = true if the substring s[0..i-1] can be segmented

Recurrence: dp[i] = true if there’s some j < i where dp[j] is true AND s[j..i] is in the dictionary

function wordBreak(s, wordDict) {
  const words = new Set(wordDict);
  const dp = new Array(s.length + 1).fill(false);
  dp[0] = true;                        // empty string is valid
  for (let i = 1; i <= s.length; i++) {
    for (let j = 0; j < i; j++) {
      if (dp[j] && words.has(s.slice(j, i))) {
        dp[i] = true;
        break;                          // found one valid split
      }
    }
  }
  return dp[s.length];
}
def word_break(s, word_dict):
    words = set(word_dict)
    dp = [False] * (len(s) + 1)
    dp[0] = True                        # empty string is valid
    for i in range(1, len(s) + 1):
        for j in range(i):
            if dp[j] and s[j:i] in words:
                dp[i] = True
                break                   # found one valid split
    return dp[len(s)]
static boolean wordBreak(String s, List<String> wordDict) {
    Set<String> words = new HashSet<>(wordDict);
    boolean[] dp = new boolean[s.length() + 1];
    dp[0] = true;                       // empty string is valid
    for (int i = 1; i <= s.length(); i++) {
        for (int j = 0; j < i; j++) {
            if (dp[j] && words.contains(s.substring(j, i))) {
                dp[i] = true;
                break;                  // found one valid split
            }
        }
    }
    return dp[s.length()];
}

The 1D DP Pattern Cheat Sheet

Here’s the mental model for all 1D DP problems:

  1. Define dp[i] — what does the answer at position i mean?
  2. Find the transition — which previous states does dp[i] depend on?
  3. Set base casesdp[0], dp[1], etc.
  4. Loop and fill — iterate through the array, filling each dp[i]
  5. Return the answer — usually dp[n] or max(dp)

If dp[i] only depends on the last 1-2 values, we can optimize space to O(1). If it depends on all previous values (like LIS), we need O(n) space.


32

2D DP Patterns

advanced dynamic-programming 2D-DP edit-distance LCS

2D DP kicks in when our state needs two dimensions to describe. Instead of a single array dp[i], we use a matrix dp[i][j]. The value at each cell depends on its neighbors — usually the cell above, to the left, or diagonally.

In simple language, we’re filling a grid instead of a line. Same idea, one extra dimension.

Unique Paths

Problem: We’re at the top-left of an m x n grid. We can only move right or down. How many unique paths to the bottom-right corner?

State: dp[i][j] = number of paths to reach cell (i, j)

Recurrence: dp[i][j] = dp[i-1][j] + dp[i][j-1] (came from above or left)

Unique Paths DP Table (3x4 grid)
1 1 1 1
1 2 3 4
1 3 6 10
First row and column are all 1s (only one way to get there). Each cell = top + left.
function uniquePaths(m, n) {
  const dp = Array.from({ length: m }, () => new Array(n).fill(1));
  for (let i = 1; i < m; i++) {
    for (let j = 1; j < n; j++) {
      dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; // top + left
    }
  }
  return dp[m - 1][n - 1];
}
// Time: O(m*n), Space: O(m*n) — can optimize to O(n)
def unique_paths(m, n):
    dp = [[1] * n for _ in range(m)]
    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = dp[i - 1][j] + dp[i][j - 1]  # top + left
    return dp[m - 1][n - 1]
# Time: O(m*n), Space: O(m*n) — can optimize to O(n)
static int uniquePaths(int m, int n) {
    int[][] dp = new int[m][n];
    for (int[] row : dp) Arrays.fill(row, 1);
    for (int i = 1; i < m; i++) {
        for (int j = 1; j < n; j++) {
            dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; // top + left
        }
    }
    return dp[m - 1][n - 1];
}
// Time: O(m*n), Space: O(m*n) — can optimize to O(n)

Longest Common Subsequence (LCS)

Problem: Given two strings, find the length of their longest common subsequence. A subsequence doesn’t need to be contiguous — we can skip characters.

Example: "abcde" and "ace" have LCS of "ace" (length 3).

State: dp[i][j] = LCS length of text1[0..i-1] and text2[0..j-1]

Recurrence:

  • If text1[i-1] == text2[j-1]: dp[i][j] = dp[i-1][j-1] + 1 (characters match, extend)
  • Else: dp[i][j] = max(dp[i-1][j], dp[i][j-1]) (skip one or the other)
LCS Table: "abcde" vs "ace"
"" a b c d e
"" 0 0 0 0 0 0
a 0 1 1 1 1 1
c 0 1 1 2 2 2
e 0 1 1 2 2 3
Highlighted cells = character match (diagonal + 1). Answer is bottom-right: 3.
function longestCommonSubsequence(text1, text2) {
  const m = text1.length, n = text2.length;
  const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (text1[i - 1] === text2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1] + 1;        // match! extend diagonal
      } else {
        dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); // skip one
      }
    }
  }
  return dp[m][n];
}
// Time: O(m*n), Space: O(m*n)
def longest_common_subsequence(text1, text2):
    m, n = len(text1), len(text2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if text1[i - 1] == text2[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1        # match! extend
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])  # skip one
    return dp[m][n]
# Time: O(m*n), Space: O(m*n)
static int longestCommonSubsequence(String text1, String text2) {
    int m = text1.length(), n = text2.length();
    int[][] dp = new int[m + 1][n + 1];
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
                dp[i][j] = dp[i - 1][j - 1] + 1;       // match! extend
            } else {
                dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
    }
    return dp[m][n];
}
// Time: O(m*n), Space: O(m*n)

Edit Distance (Levenshtein Distance)

Problem: Given two strings, find the minimum number of operations (insert, delete, replace) to convert one into the other. This is a big interview favorite.

State: dp[i][j] = minimum edits to convert word1[0..i-1] into word2[0..j-1]

Recurrence:

  • If characters match: dp[i][j] = dp[i-1][j-1] (no edit needed)
  • Else: dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) (replace, delete, insert)
Where Each Operation Comes From
dp[i-1][j-1]
Replace
(diagonal)
dp[i-1][j]
Delete
(from above)
dp[i][j-1]
Insert
(from left)
function minDistance(word1, word2) {
  const m = word1.length, n = word2.length;
  const dp = Array.from({ length: m + 1 }, (_, i) =>
    new Array(n + 1).fill(0).map((_, j) => (i === 0 ? j : j === 0 ? i : 0))
  );
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (word1[i - 1] === word2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1];              // chars match, no edit
      } else {
        dp[i][j] = 1 + Math.min(
          dp[i - 1][j - 1],  // replace
          dp[i - 1][j],      // delete
          dp[i][j - 1]       // insert
        );
      }
    }
  }
  return dp[m][n];
}
def min_distance(word1, word2):
    m, n = len(word1), len(word2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(m + 1): dp[i][0] = i   # deleting all chars
    for j in range(n + 1): dp[0][j] = j   # inserting all chars
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if word1[i - 1] == word2[j - 1]:
                dp[i][j] = dp[i - 1][j - 1]         # chars match
            else:
                dp[i][j] = 1 + min(
                    dp[i - 1][j - 1],  # replace
                    dp[i - 1][j],      # delete
                    dp[i][j - 1]       # insert
                )
    return dp[m][n]
static int minDistance(String word1, String word2) {
    int m = word1.length(), n = word2.length();
    int[][] dp = new int[m + 1][n + 1];
    for (int i = 0; i <= m; i++) dp[i][0] = i;  // delete all
    for (int j = 0; j <= n; j++) dp[0][j] = j;  // insert all
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                dp[i][j] = 1 + Math.min(dp[i - 1][j - 1],
                    Math.min(dp[i - 1][j], dp[i][j - 1]));
            }
        }
    }
    return dp[m][n];
}

Minimum Path Sum

Problem: Given a grid with non-negative numbers, find the path from top-left to bottom-right that minimizes the sum. We can only move right or down.

This is like unique paths but instead of counting paths, we’re minimizing cost.

State: dp[i][j] = minimum sum to reach cell (i, j)

function minPathSum(grid) {
  const m = grid.length, n = grid[0].length;
  for (let i = 0; i < m; i++) {
    for (let j = 0; j < n; j++) {
      if (i === 0 && j === 0) continue;     // starting cell
      const top = i > 0 ? grid[i - 1][j] : Infinity;
      const left = j > 0 ? grid[i][j - 1] : Infinity;
      grid[i][j] += Math.min(top, left);     // add cheaper path
    }
  }
  return grid[m - 1][n - 1];
}
// We modified the input grid in-place — O(1) extra space!
def min_path_sum(grid):
    m, n = len(grid), len(grid[0])
    for i in range(m):
        for j in range(n):
            if i == 0 and j == 0: continue   # starting cell
            top = grid[i - 1][j] if i > 0 else float('inf')
            left = grid[i][j - 1] if j > 0 else float('inf')
            grid[i][j] += min(top, left)      # add cheaper path
    return grid[m - 1][n - 1]
# We modified the input grid in-place — O(1) extra space!
static int minPathSum(int[][] grid) {
    int m = grid.length, n = grid[0].length;
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            if (i == 0 && j == 0) continue;
            int top = i > 0 ? grid[i - 1][j] : Integer.MAX_VALUE;
            int left = j > 0 ? grid[i][j - 1] : Integer.MAX_VALUE;
            grid[i][j] += Math.min(top, left);
        }
    }
    return grid[m - 1][n - 1];
}
// We modified the input grid in-place — O(1) extra space!

The 2D DP Pattern

The pattern across all these problems:

  1. State needs two indices — two strings, grid row/col, two pointers into sequences
  2. Fill order is top-to-bottom, left-to-right — so dependencies are already computed
  3. Base cases fill the first row and first column (or the 0th row/column if we use padding)
  4. Answer is at dp[m][n] (or dp[m-1][n-1] depending on indexing)

Space optimization trick: If each row only depends on the previous row, we can use a 1D array and overwrite it as we go. This drops space from O(m*n) to O(n).


33

Knapsack Patterns

advanced dynamic-programming knapsack subset-sum

The knapsack is one of the most important DP patterns. A shocking number of interview problems are just knapsack in disguise. Once we learn this pattern, we unlock a whole family of problems.

In simple language, we have a bag with limited capacity and items to choose from. We want to pick the best combination without exceeding the limit.

0/1 Knapsack

Problem: We have n items, each with a weight and value. Our bag holds at most W weight. Find the maximum value we can carry. Each item can only be used once (take it or leave it — that’s the “0/1”).

State: dp[i][w] = max value using first i items with capacity w

Recurrence: For each item, we either skip it or take it (if it fits):

  • Skip: dp[i][w] = dp[i-1][w]
  • Take: dp[i][w] = dp[i-1][w - weight[i]] + value[i]
  • Pick whichever gives more value.

The Decision Tree

0/1 Knapsack: Items [(wt:1,val:6), (wt:2,val:10), (wt:3,val:12)], Capacity: 5
Item 1 (wt:1, val:6)
Skip
cap=5, val=0
Take
cap=4, val=6
Each branch continues with Item 2, then Item 3...
Overlapping subproblems appear when different paths reach the same (item, capacity) state
Best: Take items 2+3 = value 22 (weight 5)

The DP Table

DP Table — rows: items, cols: capacity 0-5
0 1 2 3 4 5
no items 0 0 0 0 0 0
wt:1 v:6 0 6 6 6 6 6
wt:2 v:10 0 6 10 16 16 16
wt:3 v:12 0 6 10 16 18 22
Answer at bottom-right: max value = 22 with capacity 5
function knapsack(weights, values, capacity) {
  const n = weights.length;
  const dp = Array.from({ length: n + 1 },
    () => new Array(capacity + 1).fill(0));
  for (let i = 1; i <= n; i++) {
    for (let w = 0; w <= capacity; w++) {
      dp[i][w] = dp[i - 1][w];                     // skip this item
      if (weights[i - 1] <= w) {                    // can we take it?
        dp[i][w] = Math.max(dp[i][w],
          dp[i - 1][w - weights[i - 1]] + values[i - 1]); // take it
      }
    }
  }
  return dp[n][capacity];
}
// Time: O(n * capacity), Space: O(n * capacity)
def knapsack(weights, values, capacity):
    n = len(weights)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for w in range(capacity + 1):
            dp[i][w] = dp[i - 1][w]                  # skip this item
            if weights[i - 1] <= w:                   # can we take it?
                dp[i][w] = max(dp[i][w],
                    dp[i - 1][w - weights[i - 1]] + values[i - 1])
    return dp[n][capacity]
# Time: O(n * capacity), Space: O(n * capacity)
static int knapsack(int[] weights, int[] values, int capacity) {
    int n = weights.length;
    int[][] dp = new int[n + 1][capacity + 1];
    for (int i = 1; i <= n; i++) {
        for (int w = 0; w <= capacity; w++) {
            dp[i][w] = dp[i - 1][w];                 // skip
            if (weights[i - 1] <= w) {                // can we take it?
                dp[i][w] = Math.max(dp[i][w],
                    dp[i - 1][w - weights[i - 1]] + values[i - 1]);
            }
        }
    }
    return dp[n][capacity];
}
// Time: O(n * capacity), Space: O(n * capacity)

Space-Optimized 0/1 Knapsack

We only ever look at the previous row. So we can use a single 1D array — but we must iterate capacity backwards to avoid using an item twice.

function knapsack(weights, values, capacity) {
  const dp = new Array(capacity + 1).fill(0);
  for (let i = 0; i < weights.length; i++) {
    for (let w = capacity; w >= weights[i]; w--) {  // backwards!
      dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
    }
  }
  return dp[capacity];
}
// Space: O(capacity) — much better!
def knapsack(weights, values, capacity):
    dp = [0] * (capacity + 1)
    for i in range(len(weights)):
        for w in range(capacity, weights[i] - 1, -1):  # backwards!
            dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
    return dp[capacity]
# Space: O(capacity) — much better!
static int knapsack(int[] weights, int[] values, int capacity) {
    int[] dp = new int[capacity + 1];
    for (int i = 0; i < weights.length; i++) {
        for (int w = capacity; w >= weights[i]; w--) { // backwards!
            dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
        }
    }
    return dp[capacity];
}
// Space: O(capacity) — much better!

Why backwards? Going forward would let us use the same item multiple times (since dp[w - weight] was already updated in this round). Going backwards ensures we only use each item once.

Unbounded Knapsack

Same problem but each item can be used unlimited times. The only change? We iterate capacity forwards instead of backwards.

This is actually the same idea as coin change — coins are items, denominations are weights, and we want to maximize value (or minimize count).

function unboundedKnapsack(weights, values, capacity) {
  const dp = new Array(capacity + 1).fill(0);
  for (let i = 0; i < weights.length; i++) {
    for (let w = weights[i]; w <= capacity; w++) {  // forwards = reuse!
      dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
    }
  }
  return dp[capacity];
}
def unbounded_knapsack(weights, values, capacity):
    dp = [0] * (capacity + 1)
    for i in range(len(weights)):
        for w in range(weights[i], capacity + 1):     # forwards = reuse!
            dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
    return dp[capacity]
static int unboundedKnapsack(int[] weights, int[] values, int capacity) {
    int[] dp = new int[capacity + 1];
    for (int i = 0; i < weights.length; i++) {
        for (int w = weights[i]; w <= capacity; w++) { // forwards = reuse!
            dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
        }
    }
    return dp[capacity];
}

Subset Sum

Problem: Given an array of positive integers and a target, can we find a subset that sums to exactly the target?

This is 0/1 knapsack where every item’s “value” equals its “weight” and we want to hit exactly the capacity.

State: dp[s] = can we make sum s?

function canSubsetSum(nums, target) {
  const dp = new Array(target + 1).fill(false);
  dp[0] = true;                          // empty subset has sum 0
  for (const num of nums) {
    for (let s = target; s >= num; s--) { // backwards = use each once
      if (dp[s - num]) dp[s] = true;
    }
  }
  return dp[target];
}
def can_subset_sum(nums, target):
    dp = [False] * (target + 1)
    dp[0] = True                          # empty subset has sum 0
    for num in nums:
        for s in range(target, num - 1, -1):  # backwards = use each once
            if dp[s - num]:
                dp[s] = True
    return dp[target]
static boolean canSubsetSum(int[] nums, int target) {
    boolean[] dp = new boolean[target + 1];
    dp[0] = true;                          // empty subset has sum 0
    for (int num : nums) {
        for (int s = target; s >= num; s--) { // backwards = use each once
            if (dp[s - num]) dp[s] = true;
        }
    }
    return dp[target];
}

Partition Equal Subset Sum

Problem: Can we partition an array into two subsets with equal sum?

This is just subset sum in disguise. If the total sum is odd, it’s impossible. If it’s even, we just need to find a subset that sums to totalSum / 2.

function canPartition(nums) {
  const total = nums.reduce((a, b) => a + b, 0);
  if (total % 2 !== 0) return false;       // odd sum = impossible
  return canSubsetSum(nums, total / 2);    // reuse subset sum!
}
def can_partition(nums):
    total = sum(nums)
    if total % 2 != 0: return False        # odd sum = impossible
    return can_subset_sum(nums, total // 2)  # reuse subset sum!
static boolean canPartition(int[] nums) {
    int total = Arrays.stream(nums).sum();
    if (total % 2 != 0) return false;       // odd sum = impossible
    return canSubsetSum(nums, total / 2);   // reuse subset sum!
}

Target Sum

Problem: Given an array and a target, assign + or - to each number so they sum to the target. Count the number of ways.

Trick: If we add + to some numbers (set P) and - to others (set N), then P - N = target and P + N = total. So P = (total + target) / 2. This turns into a count subset sum problem!

function findTargetSumWays(nums, target) {
  const total = nums.reduce((a, b) => a + b, 0);
  if ((total + target) % 2 !== 0 || Math.abs(target) > total) return 0;
  const subsetSum = (total + target) / 2;
  const dp = new Array(subsetSum + 1).fill(0);
  dp[0] = 1;                                // one way to make sum 0
  for (const num of nums) {
    for (let s = subsetSum; s >= num; s--) {
      dp[s] += dp[s - num];                 // add number of ways
    }
  }
  return dp[subsetSum];
}
def find_target_sum_ways(nums, target):
    total = sum(nums)
    if (total + target) % 2 != 0 or abs(target) > total:
        return 0
    subset_sum = (total + target) // 2
    dp = [0] * (subset_sum + 1)
    dp[0] = 1                                # one way to make sum 0
    for num in nums:
        for s in range(subset_sum, num - 1, -1):
            dp[s] += dp[s - num]             # add number of ways
    return dp[subset_sum]
static int findTargetSumWays(int[] nums, int target) {
    int total = Arrays.stream(nums).sum();
    if ((total + target) % 2 != 0 || Math.abs(target) > total) return 0;
    int subsetSum = (total + target) / 2;
    int[] dp = new int[subsetSum + 1];
    dp[0] = 1;                                // one way to make sum 0
    for (int num : nums) {
        for (int s = subsetSum; s >= num; s--) {
            dp[s] += dp[s - num];
        }
    }
    return dp[subsetSum];
}

The Knapsack Family

Here’s the key insight — all these problems share the same skeleton:

ProblemTypeWhat changes?
0/1 KnapsackMax valueEach item used at most once
Unbounded KnapsackMax valueItems can repeat
Coin ChangeMin countItems can repeat, minimize instead of maximize
Subset SumBooleanCan we hit exact target?
Partition Equal SubsetBooleanSubset sum where target = totalSum/2
Target SumCount waysCount subsets that hit target

When we see a problem with “choose items” + “capacity constraint”, think knapsack.


34

Interval and String DP

advanced dynamic-programming interval-DP palindrome

Interval DP is a pattern where dp[i][j] represents the answer for the substring or subarray from index i to j. We build up from small intervals to large ones.

In simple language, we start by solving tiny substrings (length 1, length 2), then use those answers to solve bigger and bigger substrings. Think of it like building a pyramid from the bottom up.

These are interview favorites, especially the palindrome family.

Longest Palindromic Subsequence

Problem: Find the length of the longest subsequence that reads the same forwards and backwards. We can skip characters (it’s a subsequence, not a substring).

Example: "bbbab" has LPS "bbbb" (length 4).

State: dp[i][j] = length of longest palindromic subsequence in s[i..j]

Recurrence:

  • If s[i] == s[j]: dp[i][j] = dp[i+1][j-1] + 2 (both ends match, shrink inward)
  • Else: dp[i][j] = max(dp[i+1][j], dp[i][j-1]) (try dropping one end)

Base case: Every single character is a palindrome of length 1.

Interval DP: How We Fill the Table
Length 1
dp[i][i] = 1 (single chars)
Length 2
dp[i][i+1] = 1 or 2 (pairs)
Length 3
uses answers from length 1 and 2
↓ ... ↓
Length n
dp[0][n-1] = our answer!
function longestPalindromeSubseq(s) {
  const n = s.length;
  const dp = Array.from({ length: n }, () => new Array(n).fill(0));
  for (let i = 0; i < n; i++) dp[i][i] = 1;  // single char = palindrome
  for (let len = 2; len <= n; len++) {         // grow the interval
    for (let i = 0; i <= n - len; i++) {
      const j = i + len - 1;
      if (s[i] === s[j]) {
        dp[i][j] = dp[i + 1][j - 1] + 2;     // both ends match
      } else {
        dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]); // skip one
      }
    }
  }
  return dp[0][n - 1];
}
// Time: O(n²), Space: O(n²)
def longest_palindrome_subseq(s):
    n = len(s)
    dp = [[0] * n for _ in range(n)]
    for i in range(n):
        dp[i][i] = 1                           # single char = palindrome
    for length in range(2, n + 1):             # grow the interval
        for i in range(n - length + 1):
            j = i + length - 1
            if s[i] == s[j]:
                dp[i][j] = dp[i + 1][j - 1] + 2     # both ends match
            else:
                dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])  # skip one
    return dp[0][n - 1]
# Time: O(n²), Space: O(n²)
static int longestPalindromeSubseq(String s) {
    int n = s.length();
    int[][] dp = new int[n][n];
    for (int i = 0; i < n; i++) dp[i][i] = 1;
    for (int len = 2; len <= n; len++) {       // grow the interval
        for (int i = 0; i <= n - len; i++) {
            int j = i + len - 1;
            if (s.charAt(i) == s.charAt(j)) {
                dp[i][j] = dp[i + 1][j - 1] + 2;
            } else {
                dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
            }
        }
    }
    return dp[0][n - 1];
}
// Time: O(n²), Space: O(n²)

Fun fact: LPS is equivalent to finding the LCS of s and reverse(s). Two ways to solve the same problem.

Longest Palindromic Substring

Problem: Find the longest substring (contiguous!) that’s a palindrome.

This one is slightly different from the subsequence version. Instead of tracking length, we track whether s[i..j] is a palindrome.

State: dp[i][j] = true if s[i..j] is a palindrome

Recurrence: dp[i][j] = (s[i] == s[j]) && dp[i+1][j-1]

function longestPalindrome(s) {
  const n = s.length;
  const dp = Array.from({ length: n }, () => new Array(n).fill(false));
  let start = 0, maxLen = 1;
  for (let i = 0; i < n; i++) dp[i][i] = true;     // single chars
  for (let len = 2; len <= n; len++) {
    for (let i = 0; i <= n - len; i++) {
      const j = i + len - 1;
      if (s[i] === s[j] && (len <= 3 || dp[i + 1][j - 1])) {
        dp[i][j] = true;
        if (len > maxLen) { start = i; maxLen = len; }
      }
    }
  }
  return s.slice(start, start + maxLen);
}
def longest_palindrome(s):
    n = len(s)
    dp = [[False] * n for _ in range(n)]
    start, max_len = 0, 1
    for i in range(n):
        dp[i][i] = True                              # single chars
    for length in range(2, n + 1):
        for i in range(n - length + 1):
            j = i + length - 1
            if s[i] == s[j] and (length <= 3 or dp[i + 1][j - 1]):
                dp[i][j] = True
                if length > max_len:
                    start, max_len = i, length
    return s[start:start + max_len]
static String longestPalindrome(String s) {
    int n = s.length();
    boolean[][] dp = new boolean[n][n];
    int start = 0, maxLen = 1;
    for (int i = 0; i < n; i++) dp[i][i] = true;
    for (int len = 2; len <= n; len++) {
        for (int i = 0; i <= n - len; i++) {
            int j = i + len - 1;
            if (s.charAt(i) == s.charAt(j) && (len <= 3 || dp[i+1][j-1])) {
                dp[i][j] = true;
                if (len > maxLen) { start = i; maxLen = len; }
            }
        }
    }
    return s.substring(start, start + maxLen);
}

Note: There’s also a clever expand-around-center approach that uses O(1) space. But the DP version is what demonstrates the interval pattern.

Palindrome Partitioning II (Minimum Cuts)

Problem: Given a string, find the minimum number of cuts to split it so that every piece is a palindrome.

Example: "aab" needs 1 cut: "aa" | "b".

We need two things:

  1. A palindrome lookup table (is s[i..j] a palindrome?)
  2. A 1D DP for minimum cuts

State: dp[i] = minimum cuts for s[0..i]

function minCut(s) {
  const n = s.length;
  // Step 1: precompute palindrome table
  const isPalin = Array.from({ length: n }, () => new Array(n).fill(false));
  for (let i = n - 1; i >= 0; i--) {
    for (let j = i; j < n; j++) {
      isPalin[i][j] = s[i] === s[j] && (j - i <= 2 || isPalin[i+1][j-1]);
    }
  }
  // Step 2: find min cuts
  const dp = new Array(n).fill(0);
  for (let i = 0; i < n; i++) {
    if (isPalin[0][i]) { dp[i] = 0; continue; } // whole prefix is palindrome
    dp[i] = Infinity;
    for (let j = 1; j <= i; j++) {
      if (isPalin[j][i]) dp[i] = Math.min(dp[i], dp[j - 1] + 1);
    }
  }
  return dp[n - 1];
}
def min_cut(s):
    n = len(s)
    # Step 1: precompute palindrome table
    is_palin = [[False] * n for _ in range(n)]
    for i in range(n - 1, -1, -1):
        for j in range(i, n):
            is_palin[i][j] = s[i] == s[j] and (j - i <= 2 or is_palin[i+1][j-1])
    # Step 2: find min cuts
    dp = [0] * n
    for i in range(n):
        if is_palin[0][i]:
            dp[i] = 0; continue      # whole prefix is palindrome
        dp[i] = float('inf')
        for j in range(1, i + 1):
            if is_palin[j][i]:
                dp[i] = min(dp[i], dp[j - 1] + 1)
    return dp[n - 1]
static int minCut(String s) {
    int n = s.length();
    boolean[][] isPalin = new boolean[n][n];
    for (int i = n - 1; i >= 0; i--) {
        for (int j = i; j < n; j++) {
            isPalin[i][j] = s.charAt(i) == s.charAt(j)
                && (j - i <= 2 || isPalin[i+1][j-1]);
        }
    }
    int[] dp = new int[n];
    for (int i = 0; i < n; i++) {
        if (isPalin[0][i]) { dp[i] = 0; continue; }
        dp[i] = Integer.MAX_VALUE;
        for (int j = 1; j <= i; j++) {
            if (isPalin[j][i]) dp[i] = Math.min(dp[i], dp[j - 1] + 1);
        }
    }
    return dp[n - 1];
}

Decode Ways

Problem: A message encoded with ‘A’=1, ‘B’=2, …, ‘Z’=26. Given a digit string, how many ways can we decode it?

Example: "226" can be decoded as “BZ” (2,26), “VF” (22,6), or “BBF” (2,2,6) = 3 ways.

This is actually more of a 1D DP problem, but it involves string parsing with choices, which is why it fits here.

State: dp[i] = number of ways to decode s[0..i-1]

function numDecodings(s) {
  if (s[0] === '0') return 0;
  const n = s.length;
  const dp = new Array(n + 1).fill(0);
  dp[0] = 1;                                // empty string = 1 way
  dp[1] = 1;                                // first char (not '0')
  for (let i = 2; i <= n; i++) {
    const oneDigit = parseInt(s[i - 1]);     // take last 1 digit
    const twoDigit = parseInt(s.slice(i - 2, i)); // take last 2 digits
    if (oneDigit >= 1) dp[i] += dp[i - 1];
    if (twoDigit >= 10 && twoDigit <= 26) dp[i] += dp[i - 2];
  }
  return dp[n];
}
def num_decodings(s):
    if s[0] == '0': return 0
    n = len(s)
    dp = [0] * (n + 1)
    dp[0] = 1                                # empty string = 1 way
    dp[1] = 1                                # first char (not '0')
    for i in range(2, n + 1):
        one_digit = int(s[i - 1])            # take last 1 digit
        two_digit = int(s[i - 2:i])          # take last 2 digits
        if one_digit >= 1: dp[i] += dp[i - 1]
        if 10 <= two_digit <= 26: dp[i] += dp[i - 2]
    return dp[n]
static int numDecodings(String s) {
    if (s.charAt(0) == '0') return 0;
    int n = s.length();
    int[] dp = new int[n + 1];
    dp[0] = 1; dp[1] = 1;
    for (int i = 2; i <= n; i++) {
        int oneDigit = s.charAt(i - 1) - '0';
        int twoDigit = Integer.parseInt(s.substring(i - 2, i));
        if (oneDigit >= 1) dp[i] += dp[i - 1];
        if (twoDigit >= 10 && twoDigit <= 26) dp[i] += dp[i - 2];
    }
    return dp[n];
}

The Interval DP Pattern

Here’s the template that works for most interval problems:

for length from 2 to n:        // interval size
  for i from 0 to n-length:    // start index
    j = i + length - 1          // end index
    dp[i][j] = combine(dp[i+1][j], dp[i][j-1], dp[i+1][j-1])

The key insight: we process intervals by increasing length. When we compute dp[i][j], all smaller intervals are already filled in.

Common interview problems in this family:

  • Longest palindromic subsequence/substring
  • Palindrome partitioning
  • Burst balloons
  • Matrix chain multiplication
  • Stone game

35

DP on Trees and Graphs

advanced dynamic-programming tree-DP graph

So far, our DP has been on arrays and strings. But DP can also work on trees and graphs. The core idea stays the same — break a problem into subproblems and cache results. The difference is that our subproblems follow the tree structure instead of array indices.

In simple language, we solve the problem for leaf nodes first, then work our way up to the root. This naturally happens with post-order traversal (solve children before parent).

The Tree DP Pattern

The general approach:

  1. Traverse the tree (usually post-order / DFS)
  2. At each node, compute the answer using the answers from its children
  3. Return the result up to the parent
  4. Memoize if the same subtree can be reached multiple ways (rare in trees, common in graphs)

House Robber III

Problem: Same house robber rules (can’t rob adjacent), but now the houses are arranged in a binary tree. Two directly linked nodes can’t both be robbed.

At each node we have two choices:

  • Rob it — take this node’s value + the “skip” results from both children
  • Skip it — take the best results from both children (whether they robbed or skipped)

We return a pair [robThis, skipThis] from each node.

House Robber III — Rob or Skip Each Node
3
2
3
3
1
robbed skipped
Rob root(3) + grandchildren(3+1) = 7
function rob(root) {
  function dfs(node) {
    if (!node) return [0, 0];        // [rob, skip]
    const left = dfs(node.left);
    const right = dfs(node.right);
    const robThis = node.val + left[1] + right[1]; // rob me + skip kids
    const skipThis = Math.max(...left) + Math.max(...right); // best of kids
    return [robThis, skipThis];
  }
  return Math.max(...dfs(root));
}
// Time: O(n), Space: O(h) where h = tree height
def rob(root):
    def dfs(node):
        if not node:
            return (0, 0)            # (rob, skip)
        left = dfs(node.left)
        right = dfs(node.right)
        rob_this = node.val + left[1] + right[1]   # rob me + skip kids
        skip_this = max(left) + max(right)          # best of kids
        return (rob_this, skip_this)
    return max(dfs(root))
# Time: O(n), Space: O(h) where h = tree height
static int rob(TreeNode root) {
    int[] res = dfs(root);
    return Math.max(res[0], res[1]);
}
static int[] dfs(TreeNode node) {
    if (node == null) return new int[]{0, 0}; // {rob, skip}
    int[] left = dfs(node.left);
    int[] right = dfs(node.right);
    int robThis = node.val + left[1] + right[1];
    int skipThis = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
    return new int[]{robThis, skipThis};
}
// Time: O(n), Space: O(h) where h = tree height

The key trick: instead of memoizing in a hash map, we return both states (rob/skip) from each recursive call. Clean and efficient.

Tree Diameter

Problem: Find the length of the longest path between any two nodes in a binary tree. The path doesn’t need to go through the root.

At each node, the longest path passing through it equals leftDepth + rightDepth. We track the global maximum while computing depths.

function diameterOfBinaryTree(root) {
  let maxDiameter = 0;
  function depth(node) {
    if (!node) return 0;
    const left = depth(node.left);
    const right = depth(node.right);
    maxDiameter = Math.max(maxDiameter, left + right); // path through node
    return 1 + Math.max(left, right);  // return depth to parent
  }
  depth(root);
  return maxDiameter;
}
// Time: O(n), Space: O(h)
def diameter_of_binary_tree(root):
    max_diameter = 0
    def depth(node):
        nonlocal max_diameter
        if not node: return 0
        left = depth(node.left)
        right = depth(node.right)
        max_diameter = max(max_diameter, left + right)  # path through node
        return 1 + max(left, right)    # return depth to parent
    depth(root)
    return max_diameter
# Time: O(n), Space: O(h)
static int maxDiameter = 0;
static int diameterOfBinaryTree(TreeNode root) {
    maxDiameter = 0;
    depth(root);
    return maxDiameter;
}
static int depth(TreeNode node) {
    if (node == null) return 0;
    int left = depth(node.left);
    int right = depth(node.right);
    maxDiameter = Math.max(maxDiameter, left + right);
    return 1 + Math.max(left, right);
}
// Time: O(n), Space: O(h)

Binary Tree Maximum Path Sum

Problem: Find the maximum path sum in a binary tree. A path can start and end at any node. Node values can be negative.

This is the harder cousin of tree diameter. Same pattern, but we track sums instead of depths, and we need to handle negative values.

At each node:

  • The path through this node = node.val + leftGain + rightGain
  • But we can only return one branch to the parent (can’t fork upward)
  • Negative gains should be treated as 0 (just don’t take that branch)
function maxPathSum(root) {
  let maxSum = -Infinity;
  function gain(node) {
    if (!node) return 0;
    const left = Math.max(0, gain(node.left));   // ignore negative paths
    const right = Math.max(0, gain(node.right));
    maxSum = Math.max(maxSum, node.val + left + right); // path through node
    return node.val + Math.max(left, right);  // return best single branch
  }
  gain(root);
  return maxSum;
}
def max_path_sum(root):
    max_sum = float('-inf')
    def gain(node):
        nonlocal max_sum
        if not node: return 0
        left = max(0, gain(node.left))         # ignore negative paths
        right = max(0, gain(node.right))
        max_sum = max(max_sum, node.val + left + right)  # path through node
        return node.val + max(left, right)     # return best single branch
    gain(root)
    return max_sum
static int maxSum;
static int maxPathSum(TreeNode root) {
    maxSum = Integer.MIN_VALUE;
    gain(root);
    return maxSum;
}
static int gain(TreeNode node) {
    if (node == null) return 0;
    int left = Math.max(0, gain(node.left));    // ignore negative
    int right = Math.max(0, gain(node.right));
    maxSum = Math.max(maxSum, node.val + left + right);
    return node.val + Math.max(left, right);    // best single branch
}

The Tree DP Template

All three problems above follow the same structure:

  1. DFS / post-order traversal — solve children first
  2. Combine children results at each node
  3. Update a global answer (diameter, max sum, etc.)
  4. Return local result to parent (depth, gain, etc.)

The trick is figuring out what to return vs what to track globally:

Problem Return to Parent Track Globally
House Robber III[robValue, skipValue]--
Tree Diameterdepth (single branch)left + right (through node)
Max Path Summax gain (single branch)val + left + right (through node)

Graph DP: Floyd-Warshall

Trees are easy because there’s no cycles — we just do DFS. Graphs are trickier, but one classic graph DP is Floyd-Warshall for finding shortest paths between ALL pairs of nodes.

Idea: We try using each node k as an intermediate stop. Can going through k shorten the path from i to j?

State: dp[i][j] = shortest distance from node i to node j

Recurrence: dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j])

function floydWarshall(graph) {
  const n = graph.length;
  const dp = graph.map(row => [...row]);    // copy the adjacency matrix
  for (let k = 0; k < n; k++) {             // try each intermediate node
    for (let i = 0; i < n; i++) {
      for (let j = 0; j < n; j++) {
        if (dp[i][k] + dp[k][j] < dp[i][j]) {
          dp[i][j] = dp[i][k] + dp[k][j];  // shorter path through k
        }
      }
    }
  }
  return dp; // dp[i][j] = shortest path from i to j
}
// Time: O(V³), Space: O(V²)
def floyd_warshall(graph):
    n = len(graph)
    dp = [row[:] for row in graph]          # copy the adjacency matrix
    for k in range(n):                      # try each intermediate node
        for i in range(n):
            for j in range(n):
                if dp[i][k] + dp[k][j] < dp[i][j]:
                    dp[i][j] = dp[i][k] + dp[k][j]  # shorter path
    return dp  # dp[i][j] = shortest path from i to j
# Time: O(V³), Space: O(V²)
static int[][] floydWarshall(int[][] graph) {
    int n = graph.length;
    int[][] dp = new int[n][n];
    for (int i = 0; i < n; i++) dp[i] = graph[i].clone();
    for (int k = 0; k < n; k++) {           // try each intermediate node
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                if (dp[i][k] + dp[k][j] < dp[i][j]) {
                    dp[i][j] = dp[i][k] + dp[k][j];
                }
            }
        }
    }
    return dp;
}
// Time: O(V³), Space: O(V²)

Floyd-Warshall is O(V^3), which is fine for small graphs (up to ~500 nodes). For single-source shortest paths, Dijkstra is faster. But when we need all pairs, Floyd-Warshall is beautifully simple.

When to Use Tree/Graph DP

  • Tree DP: Whenever we need to compute something for every node based on its subtree. Post-order DFS is our friend.
  • Graph DP: When the problem has overlapping subproblems on a graph structure. Less common in interviews than tree DP, but Floyd-Warshall shows up regularly.
  • Key difference: Trees have no cycles, so memoization is usually not needed (each node is visited once). Graphs may need explicit visited tracking or topological ordering.

Greedy, Backtracking & Interview Patterns

36

Greedy Algorithms

intermediate greedy algorithm interval-scheduling

A greedy algorithm makes the best-looking choice at each step, hoping that locally optimal decisions lead to a globally optimal solution. No going back, no reconsidering. Just pick what looks best right now and move on.

In simple language, it’s like eating at a buffet where we always grab the most delicious item in front of us. Sometimes that strategy works perfectly. Sometimes we end up with five desserts and no actual meal.

When Does Greedy Work?

Greedy works when two conditions hold:

  1. Greedy choice property — a locally optimal choice leads to a globally optimal solution
  2. Optimal substructure — an optimal solution contains optimal solutions to subproblems

The tricky part? Not every problem has these properties. That’s why greedy doesn’t always work, and why interviewers love asking us to prove it does.

Greedy vs DP -- When to Use What?
Greedy
Make one choice per step, never look back. O(n) or O(n log n) usually.
DP
Try all choices per step, cache results. O(n^2) or O(n*m) usually.
Rule of thumb: if greedy gives the wrong answer on a small example, we need DP. Always test with a counterexample first.

Activity Selection / Interval Scheduling

This is THE classic greedy problem. Given a list of activities with start and end times, pick the maximum number of non-overlapping activities.

The greedy insight: always pick the activity that ends earliest. This leaves the most room for future activities.

function activitySelection(activities) {
  // sort by end time
  activities.sort((a, b) => a[1] - b[1]);
  const selected = [activities[0]];
  let lastEnd = activities[0][1];

  for (let i = 1; i < activities.length; i++) {
    if (activities[i][0] >= lastEnd) { // no overlap
      selected.push(activities[i]);
      lastEnd = activities[i][1];
    }
  }
  return selected;
}
// activitySelection([[1,3],[2,5],[3,6],[5,7],[8,9]])
// => [[1,3],[3,6],[8,9]] -- 3 activities, no overlaps
def activity_selection(activities):
    # sort by end time
    activities.sort(key=lambda x: x[1])
    selected = [activities[0]]
    last_end = activities[0][1]

    for start, end in activities[1:]:
        if start >= last_end:  # no overlap
            selected.append([start, end])
            last_end = end
    return selected

# activity_selection([[1,3],[2,5],[3,6],[5,7],[8,9]])
# => [[1,3],[3,6],[8,9]] -- 3 activities, no overlaps
static List<int[]> activitySelection(int[][] activities) {
    // sort by end time
    Arrays.sort(activities, (a, b) -> a[1] - b[1]);
    List<int[]> selected = new ArrayList<>();
    selected.add(activities[0]);
    int lastEnd = activities[0][1];

    for (int i = 1; i < activities.length; i++) {
        if (activities[i][0] >= lastEnd) { // no overlap
            selected.add(activities[i]);
            lastEnd = activities[i][1];
        }
    }
    return selected;
}

Time: O(n log n) for sorting. The greedy pass itself is O(n). Space: O(n) for the result.

Jump Game

Given an array where each element tells us the maximum we can jump from that position, determine if we can reach the last index (LeetCode #55).

The greedy insight: track the farthest position we can reach. If we ever land on a position beyond our farthest reach, we’re stuck.

function canJump(nums) {
  let farthest = 0;
  for (let i = 0; i < nums.length; i++) {
    if (i > farthest) return false; // can't reach here
    farthest = Math.max(farthest, i + nums[i]);
  }
  return true;
}
// canJump([2,3,1,1,4]) => true  (0->1->4 or 0->2->3->4)
// canJump([3,2,1,0,4]) => false (stuck at index 3)
def can_jump(nums):
    farthest = 0
    for i in range(len(nums)):
        if i > farthest:
            return False  # can't reach here
        farthest = max(farthest, i + nums[i])
    return True

# can_jump([2,3,1,1,4]) => True
# can_jump([3,2,1,0,4]) => False
static boolean canJump(int[] nums) {
    int farthest = 0;
    for (int i = 0; i < nums.length; i++) {
        if (i > farthest) return false; // can't reach here
        farthest = Math.max(farthest, i + nums[i]);
    }
    return true;
}
// canJump([2,3,1,1,4]) => true
// canJump([3,2,1,0,4]) => false

Time: O(n), Space: O(1). One pass through the array. Beautiful.

Gas Station

There are n gas stations in a circle. Station i has gas[i] fuel and costs cost[i] to reach the next station. Find the starting station that lets us complete the full circuit, or return -1 (LeetCode #134).

The greedy insight: if the total gas >= total cost, a solution must exist. And if we can’t reach station j from station i, then no station between i and j can be the start either. So we reset our start to j+1.

function canCompleteCircuit(gas, cost) {
  let totalTank = 0, currTank = 0, start = 0;
  for (let i = 0; i < gas.length; i++) {
    const diff = gas[i] - cost[i];
    totalTank += diff;
    currTank += diff;
    if (currTank < 0) { // can't reach next station
      start = i + 1;    // reset start to next station
      currTank = 0;
    }
  }
  return totalTank >= 0 ? start : -1;
}
def can_complete_circuit(gas, cost):
    total_tank = curr_tank = start = 0
    for i in range(len(gas)):
        diff = gas[i] - cost[i]
        total_tank += diff
        curr_tank += diff
        if curr_tank < 0:  # can't reach next station
            start = i + 1  # reset start to next station
            curr_tank = 0
    return start if total_tank >= 0 else -1
static int canCompleteCircuit(int[] gas, int[] cost) {
    int totalTank = 0, currTank = 0, start = 0;
    for (int i = 0; i < gas.length; i++) {
        int diff = gas[i] - cost[i];
        totalTank += diff;
        currTank += diff;
        if (currTank < 0) { // can't reach next station
            start = i + 1;  // reset start to next station
            currTank = 0;
        }
    }
    return totalTank >= 0 ? start : -1;
}

Time: O(n), Space: O(1). Single pass with two running sums.

Proving Greedy Correctness in Interviews

Interviewers often ask: “How do we know greedy works here?” There are two standard approaches:

1. Exchange Argument

Show that swapping any non-greedy choice with a greedy one never makes the solution worse.

“If our greedy picks activity A (earliest end), and some optimal solution picks activity B instead, we can swap B for A. Since A ends earlier, everything after still fits. So the greedy solution is at least as good.”

2. Greedy Stays Ahead

Show that at every step, the greedy solution is at least as good as any other.

“After picking k activities, greedy’s last end time is <= any other strategy’s last end time. Proof by induction on k.”

In simple language, we don’t need a formal proof in an interview. But we do need to say something like: “The greedy choice never blocks a better option later, because…” and give an intuitive argument. If we can’t articulate why greedy works, we should probably use DP instead.

Common Greedy Problems to Practice

ProblemGreedy StrategyComplexity
Activity SelectionPick earliest end timeO(n log n)
Jump GameTrack farthest reachableO(n)
Gas StationReset start when tank < 0O(n)
Assign CookiesSort both, match smallestO(n log n)
Best Time to Buy/Sell StockTrack min price so farO(n)
Minimum PlatformsSort arrivals/departuresO(n log n)

The pattern with greedy: sort the input (usually), then make one pass making the locally best choice. If that gives the right answer, great. If not, we need DP or backtracking.


37

Backtracking

intermediate backtracking recursion permutations combinations

Backtracking is recursion with an undo button. We try a choice, explore what happens, and if it doesn’t work out, we undo it and try the next option. It’s the algorithmic equivalent of “let me try this path… nope, dead end, let me go back and try another.”

In simple language, imagine navigating a maze. At each fork, we pick a direction. If we hit a wall, we walk back to the last fork and try a different direction. That’s backtracking.

The Decision Tree

Every backtracking problem can be visualized as a tree. Each node is a decision point, and each branch is a choice we make.

Decision Tree for Subsets of [1, 2, 3]
[ ]
include 1?    /        \
[1]
include 2? /   \
[1,2]
3? / \
[1,2,3] [1,2]
[1]
3? / \
[1,3] [1]
[ ]
include 2? /   \
[2]
3? / \
[2,3] [2]
[ ]
3? / \
[3] [ ]
Leaves (green) = all 8 subsets. Each path = a sequence of include/exclude decisions.

The Universal Backtracking Template

Here’s the beautiful thing — almost every backtracking problem uses the exact same skeleton. Learn this template and we can solve dozens of problems.

function backtrack(result, current, choices, start) {
  // 1. Base case: found a valid solution
  if (/* goal reached */) {
    result.push([...current]); // copy current state
    return;
  }

  // 2. Try each choice
  for (let i = start; i < choices.length; i++) {
    current.push(choices[i]);   // make a choice
    backtrack(result, current, choices, i + 1); // explore
    current.pop();              // UNDO the choice (backtrack!)
  }
}
def backtrack(result, current, choices, start):
    # 1. Base case: found a valid solution
    if True:  # goal reached
        result.append(current[:])  # copy current state
        return

    # 2. Try each choice
    for i in range(start, len(choices)):
        current.append(choices[i])   # make a choice
        backtrack(result, current, choices, i + 1)  # explore
        current.pop()                # UNDO the choice (backtrack!)
void backtrack(List<List<Integer>> result, List<Integer> current,
               int[] choices, int start) {
    // 1. Base case: found a valid solution
    if (/* goal reached */) {
        result.add(new ArrayList<>(current)); // copy current
        return;
    }

    // 2. Try each choice
    for (int i = start; i < choices.length; i++) {
        current.add(choices[i]);    // make a choice
        backtrack(result, current, choices, i + 1); // explore
        current.remove(current.size() - 1); // UNDO (backtrack!)
    }
}

The three steps are always: choose, explore, unchoose. The current.pop() (or remove) is the undo — that’s the whole magic of backtracking.

Subsets (Power Set)

Generate all subsets of a given array (LeetCode #78). For each element, we either include it or skip it.

function subsets(nums) {
  const result = [];
  function backtrack(start, current) {
    result.push([...current]); // every path is a valid subset
    for (let i = start; i < nums.length; i++) {
      current.push(nums[i]);       // include nums[i]
      backtrack(i + 1, current);   // explore with it
      current.pop();               // exclude nums[i]
    }
  }
  backtrack(0, []);
  return result;
}
// subsets([1,2,3]) => [[],[1],[1,2],[1,2,3],[1,3],[2],[2,3],[3]]
def subsets(nums):
    result = []
    def backtrack(start, current):
        result.append(current[:])  # every path is a valid subset
        for i in range(start, len(nums)):
            current.append(nums[i])     # include nums[i]
            backtrack(i + 1, current)   # explore with it
            current.pop()               # exclude nums[i]
    backtrack(0, [])
    return result

# subsets([1,2,3]) => [[],[1],[1,2],[1,2,3],[1,3],[2],[2,3],[3]]
static List<List<Integer>> subsets(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    backtrack(nums, 0, new ArrayList<>(), result);
    return result;
}
static void backtrack(int[] nums, int start,
                      List<Integer> current, List<List<Integer>> result) {
    result.add(new ArrayList<>(current)); // every path is valid
    for (int i = start; i < nums.length; i++) {
        current.add(nums[i]);
        backtrack(nums, i + 1, current, result);
        current.remove(current.size() - 1);
    }
}

Time: O(n * 2^n) — there are 2^n subsets, and we copy each one. Space: O(n) for the recursion stack.

Permutations

Generate all permutations of a given array (LeetCode #46). Unlike subsets, order matters and we use every element.

The key difference from subsets: instead of start, we use a used set to track which elements we’ve already placed.

function permute(nums) {
  const result = [];
  const used = new Set();
  function backtrack(current) {
    if (current.length === nums.length) {
      result.push([...current]);
      return;
    }
    for (let i = 0; i < nums.length; i++) {
      if (used.has(i)) continue; // skip already used
      used.add(i);
      current.push(nums[i]);
      backtrack(current);
      current.pop();             // undo
      used.delete(i);            // undo
    }
  }
  backtrack([]);
  return result;
}
// permute([1,2,3]) => [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
def permute(nums):
    result = []
    used = set()
    def backtrack(current):
        if len(current) == len(nums):
            result.append(current[:])
            return
        for i in range(len(nums)):
            if i in used:
                continue  # skip already used
            used.add(i)
            current.append(nums[i])
            backtrack(current)
            current.pop()      # undo
            used.discard(i)    # undo
    backtrack([])
    return result

# permute([1,2,3]) => [[1,2,3],[1,3,2],[2,1,3],...]
static List<List<Integer>> permute(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    boolean[] used = new boolean[nums.length];
    backtrack(nums, used, new ArrayList<>(), result);
    return result;
}
static void backtrack(int[] nums, boolean[] used,
                      List<Integer> current, List<List<Integer>> result) {
    if (current.size() == nums.length) {
        result.add(new ArrayList<>(current));
        return;
    }
    for (int i = 0; i < nums.length; i++) {
        if (used[i]) continue;
        used[i] = true;
        current.add(nums[i]);
        backtrack(nums, used, current, result);
        current.remove(current.size() - 1); // undo
        used[i] = false;                     // undo
    }
}

Time: O(n * n!) — there are n! permutations. Space: O(n) for recursion depth.

Combinations

Choose k elements from n elements (LeetCode #77). Like subsets, but we only collect paths of length k.

function combine(n, k) {
  const result = [];
  function backtrack(start, current) {
    if (current.length === k) {
      result.push([...current]);
      return; // don't go deeper
    }
    for (let i = start; i <= n; i++) {
      current.push(i);
      backtrack(i + 1, current);
      current.pop();
    }
  }
  backtrack(1, []);
  return result;
}
// combine(4, 2) => [[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]
def combine(n, k):
    result = []
    def backtrack(start, current):
        if len(current) == k:
            result.append(current[:])
            return  # don't go deeper
        for i in range(start, n + 1):
            current.append(i)
            backtrack(i + 1, current)
            current.pop()
    backtrack(1, [])
    return result

# combine(4, 2) => [[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]
static List<List<Integer>> combine(int n, int k) {
    List<List<Integer>> result = new ArrayList<>();
    backtrack(n, k, 1, new ArrayList<>(), result);
    return result;
}
static void backtrack(int n, int k, int start,
                      List<Integer> current, List<List<Integer>> result) {
    if (current.size() == k) {
        result.add(new ArrayList<>(current));
        return;
    }
    for (int i = start; i <= n; i++) {
        current.add(i);
        backtrack(n, k, i + 1, current, result);
        current.remove(current.size() - 1);
    }
}

Time: O(k * C(n,k)) — C(n,k) combinations, each copied in O(k).

The Three Flavors Compared

ProblemLoop starts atUses used set?Base case
Subsetsstart (moves forward)NoEvery node is valid
Permutations0 (always)YesLength == n
Combinationsstart (moves forward)NoLength == k

That’s it. Same template, slightly different parameters. Once we see this, backtracking problems stop being scary.

N-Queens Concept

Place N queens on an N x N chessboard so no two queens attack each other. This is the classic constraint satisfaction problem.

We place queens row by row. For each row, we try each column. Before placing, we check if any existing queen attacks that position (same column, same diagonal). If no conflict, we place it and move to the next row. If we get stuck, we backtrack.

The key optimization is tracking which columns and diagonals are occupied using sets, so our conflict check is O(1) instead of O(n).

Sudoku Solver Concept

Fill in a 9x9 grid so each row, column, and 3x3 box contains digits 1-9. Same idea:

  1. Find an empty cell
  2. Try digits 1-9
  3. If valid (no conflict in row, column, or box), place it and recurse
  4. If we get stuck, undo and try the next digit

Both N-Queens and Sudoku follow the exact same template — choose, explore, unchoose. The only difference is the constraint checking logic.

When to Use Backtracking

Look for these signals:

  • “Find all combinations/permutations/subsets”
  • “Generate all valid configurations”
  • “Can we place/arrange items following constraints?”
  • The problem says “all possible” — that’s almost always backtracking
  • Constraint satisfaction (Sudoku, crosswords, N-Queens)

In simple language, if the problem asks us to explore all possibilities and there’s no shortcut to avoid it, backtracking is our tool. We’re systematically trying everything, but we’re smart about it — we prune branches that can’t lead to solutions instead of blindly checking every path.


38

Interval Problems

intermediate intervals merge-intervals sweep-line pattern

Interval problems show up constantly in interviews. They’re their own category because the same core trick keeps appearing: sort by start time, then process one by one while tracking overlaps.

In simple language, think of scheduling meetings. Each meeting has a start and end time. We need to figure out things like “do any meetings overlap?” or “how many rooms do we need?” These are all interval problems.

When Do Two Intervals Overlap?

This is the foundation. Two intervals [a, b] and [c, d] overlap if and only if a < d AND c < b. But it’s easier to check the opposite — they DON’T overlap when one ends before the other starts.

Interval Overlap Visualization
Overlapping
15
37
Merged result: [1, 7]
Not Overlapping
13
58
No merge needed. They stay separate.

Merge Intervals

Given a collection of intervals, merge all overlapping ones (LeetCode #56). This is the bread and butter of interval problems.

Strategy: Sort by start time. Then walk through, extending the current interval if there’s overlap, or starting a new one if there’s a gap.

function merge(intervals) {
  intervals.sort((a, b) => a[0] - b[0]); // sort by start
  const merged = [intervals[0]];

  for (let i = 1; i < intervals.length; i++) {
    const last = merged[merged.length - 1];
    if (intervals[i][0] <= last[1]) {
      // overlapping -- extend the end
      last[1] = Math.max(last[1], intervals[i][1]);
    } else {
      // no overlap -- start a new interval
      merged.push(intervals[i]);
    }
  }
  return merged;
}
// merge([[1,3],[2,6],[8,10],[15,18]])
// => [[1,6],[8,10],[15,18]]
def merge(intervals):
    intervals.sort(key=lambda x: x[0])  # sort by start
    merged = [intervals[0]]

    for start, end in intervals[1:]:
        last = merged[-1]
        if start <= last[1]:
            # overlapping -- extend the end
            last[1] = max(last[1], end)
        else:
            # no overlap -- start a new interval
            merged.append([start, end])
    return merged

# merge([[1,3],[2,6],[8,10],[15,18]])
# => [[1,6],[8,10],[15,18]]
static int[][] merge(int[][] intervals) {
    Arrays.sort(intervals, (a, b) -> a[0] - b[0]); // sort by start
    List<int[]> merged = new ArrayList<>();
    merged.add(intervals[0]);

    for (int i = 1; i < intervals.length; i++) {
        int[] last = merged.get(merged.size() - 1);
        if (intervals[i][0] <= last[1]) {
            last[1] = Math.max(last[1], intervals[i][1]); // extend
        } else {
            merged.add(intervals[i]); // new interval
        }
    }
    return merged.toArray(new int[0][]);
}

Time: O(n log n) for sorting. The merge pass is O(n). Space: O(n) for the result.

Insert Interval

Given sorted, non-overlapping intervals and a new interval, insert it and merge if needed (LeetCode #57).

Strategy: Three phases — add intervals that come entirely before the new one, merge all overlapping intervals with the new one, add intervals that come entirely after.

function insert(intervals, newInterval) {
  const result = [];
  let i = 0;

  // Phase 1: add all intervals ending before new one starts
  while (i < intervals.length && intervals[i][1] < newInterval[0]) {
    result.push(intervals[i++]);
  }
  // Phase 2: merge overlapping intervals
  while (i < intervals.length && intervals[i][0] <= newInterval[1]) {
    newInterval[0] = Math.min(newInterval[0], intervals[i][0]);
    newInterval[1] = Math.max(newInterval[1], intervals[i][1]);
    i++;
  }
  result.push(newInterval);
  // Phase 3: add remaining intervals
  while (i < intervals.length) {
    result.push(intervals[i++]);
  }
  return result;
}
def insert(intervals, new_interval):
    result = []
    i = 0

    # Phase 1: add all intervals ending before new one starts
    while i < len(intervals) and intervals[i][1] < new_interval[0]:
        result.append(intervals[i])
        i += 1
    # Phase 2: merge overlapping intervals
    while i < len(intervals) and intervals[i][0] <= new_interval[1]:
        new_interval[0] = min(new_interval[0], intervals[i][0])
        new_interval[1] = max(new_interval[1], intervals[i][1])
        i += 1
    result.append(new_interval)
    # Phase 3: add remaining intervals
    result.extend(intervals[i:])
    return result
static int[][] insert(int[][] intervals, int[] newInterval) {
    List<int[]> result = new ArrayList<>();
    int i = 0;

    // Phase 1: before overlap
    while (i < intervals.length && intervals[i][1] < newInterval[0]) {
        result.add(intervals[i++]);
    }
    // Phase 2: merge overlapping
    while (i < intervals.length && intervals[i][0] <= newInterval[1]) {
        newInterval[0] = Math.min(newInterval[0], intervals[i][0]);
        newInterval[1] = Math.max(newInterval[1], intervals[i][1]);
        i++;
    }
    result.add(newInterval);
    // Phase 3: after overlap
    while (i < intervals.length) result.add(intervals[i++]);
    return result.toArray(new int[0][]);
}

Time: O(n), Space: O(n). No sorting needed since input is already sorted.

Meeting Rooms I: Can We Attend All?

Given an array of meeting time intervals, determine if a person can attend all meetings (no overlaps). LeetCode #252.

This one’s straightforward. Sort by start time, then check if any meeting starts before the previous one ends.

function canAttendMeetings(intervals) {
  intervals.sort((a, b) => a[0] - b[0]);
  for (let i = 1; i < intervals.length; i++) {
    if (intervals[i][0] < intervals[i - 1][1]) {
      return false; // overlap found
    }
  }
  return true;
}
// canAttendMeetings([[0,30],[5,10],[15,20]]) => false
// canAttendMeetings([[7,10],[2,4]]) => true
def can_attend_meetings(intervals):
    intervals.sort(key=lambda x: x[0])
    for i in range(1, len(intervals)):
        if intervals[i][0] < intervals[i - 1][1]:
            return False  # overlap found
    return True

# can_attend_meetings([[0,30],[5,10],[15,20]]) => False
static boolean canAttendMeetings(int[][] intervals) {
    Arrays.sort(intervals, (a, b) -> a[0] - b[0]);
    for (int i = 1; i < intervals.length; i++) {
        if (intervals[i][0] < intervals[i - 1][1]) {
            return false; // overlap found
        }
    }
    return true;
}

Time: O(n log n), Space: O(1) (ignoring sort space).

Meeting Rooms II: Minimum Rooms Needed

Now the harder version. Given meeting times, find the minimum number of conference rooms required (LeetCode #253).

Strategy: Use the sweep line technique. Separate starts and ends. Sort them. Walk through events: a start means +1 room needed, an end means -1 room freed. The peak is our answer.

function minMeetingRooms(intervals) {
  const starts = intervals.map(i => i[0]).sort((a, b) => a - b);
  const ends = intervals.map(i => i[1]).sort((a, b) => a - b);

  let rooms = 0, maxRooms = 0, s = 0, e = 0;
  while (s < starts.length) {
    if (starts[s] < ends[e]) {
      rooms++;    // new meeting starts before earliest end
      s++;
    } else {
      rooms--;    // a meeting ended, free a room
      e++;
    }
    maxRooms = Math.max(maxRooms, rooms);
  }
  return maxRooms;
}
// minMeetingRooms([[0,30],[5,10],[15,20]]) => 2
def min_meeting_rooms(intervals):
    starts = sorted(i[0] for i in intervals)
    ends = sorted(i[1] for i in intervals)

    rooms = max_rooms = 0
    s = e = 0
    while s < len(starts):
        if starts[s] < ends[e]:
            rooms += 1  # new meeting starts before earliest end
            s += 1
        else:
            rooms -= 1  # a meeting ended, free a room
            e += 1
        max_rooms = max(max_rooms, rooms)
    return max_rooms

# min_meeting_rooms([[0,30],[5,10],[15,20]]) => 2
static int minMeetingRooms(int[][] intervals) {
    int[] starts = new int[intervals.length];
    int[] ends = new int[intervals.length];
    for (int i = 0; i < intervals.length; i++) {
        starts[i] = intervals[i][0];
        ends[i] = intervals[i][1];
    }
    Arrays.sort(starts);
    Arrays.sort(ends);

    int rooms = 0, maxRooms = 0, s = 0, e = 0;
    while (s < starts.length) {
        if (starts[s] < ends[e]) {
            rooms++;
            s++;
        } else {
            rooms--;
            e++;
        }
        maxRooms = Math.max(maxRooms, rooms);
    }
    return maxRooms;
}

Time: O(n log n), Space: O(n) for the sorted arrays.

The Sweep Line Technique

Meeting Rooms II uses the sweep line — a powerful general technique. The idea is:

  1. Break each interval into two events: a start (+1) and an end (-1)
  2. Sort all events by time
  3. Walk through events, maintaining a running count
  4. The maximum count at any point is our answer
Sweep Line: Meetings [[0,30], [5,10], [15,20]]
t=0 +1 start rooms = 1
t=5 +1 start rooms = 2 (peak!)
t=10 -1 end rooms = 1
t=15 +1 start rooms = 2 (peak!)
t=20 -1 end rooms = 1
t=30 -1 end rooms = 0
Answer: 2 rooms needed (the peak concurrent count).

Sweep line works for a lot more than meeting rooms. Any problem asking “what’s the maximum overlap?” or “how many things are active at the same time?” is a sweep line candidate.

Interval Problem Cheatsheet

ProblemKey TrickComplexity
Merge IntervalsSort by start, extend endO(n log n)
Insert IntervalThree-phase scanO(n)
Meeting Rooms ISort, check adjacent overlapsO(n log n)
Meeting Rooms IISweep line (separate starts/ends)O(n log n)
Non-overlapping IntervalsSort by end, greedy countO(n log n)
Interval List IntersectionTwo pointers on sorted listsO(n + m)

The golden rule for interval problems: sort first, then process linearly. Almost every interval problem starts with sorting by start time (or end time for greedy selection). Once sorted, the overlaps become obvious to handle.


39

Binary Search on Answer

advanced binary-search optimization advanced pattern

Here’s a mind-bending insight: binary search doesn’t just work on sorted arrays. It works on any monotonic condition. If there’s a point where the answer flips from “no” to “yes” (or vice versa), we can binary search on it.

In simple language, instead of searching for a value in an array, we’re searching for the answer itself. We pick a candidate answer, check if it works, and narrow our search range based on the result. It’s binary search, but on the solution space.

The Key Insight

Traditional binary search: “Is the value at index mid our target?”

Binary search on answer: “Is mid a valid answer? If yes, can we do better?”

Binary Search on Answer -- The Mental Model
Answer space: 1 2 3 4 5 6 7
Feasible? No No No Yes Yes Yes Yes
The answer flips from No to Yes at 4. Binary search finds this boundary in O(log n) checks.

How to Recognize These Problems

Look for these phrases in the problem statement:

  • “Minimize the maximum…” (e.g., minimize the maximum pages a student reads)
  • “Maximize the minimum…” (e.g., maximize the minimum distance between cows)
  • “What is the minimum speed/capacity/time to…”
  • “Can we do X within Y constraint?” where Y is a number we can binary search on

The key pattern: there’s a monotonic relationship. If speed 5 works, speed 6 definitely works too. If speed 3 doesn’t work, speed 2 definitely doesn’t either.

The Template

Every binary search on answer problem follows this structure:

function binarySearchOnAnswer(input) {
  let lo = /* minimum possible answer */;
  let hi = /* maximum possible answer */;

  while (lo < hi) {
    const mid = Math.floor((lo + hi) / 2);
    if (isFeasible(mid, input)) {
      hi = mid;     // mid works, try smaller (minimize)
    } else {
      lo = mid + 1; // mid doesn't work, need bigger
    }
  }
  return lo; // lo === hi, the smallest feasible answer
}

function isFeasible(candidate, input) {
  // check if 'candidate' is a valid answer
  // this is the problem-specific part
}
def binary_search_on_answer(input_data):
    lo = 0   # minimum possible answer
    hi = 10**9  # maximum possible answer

    while lo < hi:
        mid = (lo + hi) // 2
        if is_feasible(mid, input_data):
            hi = mid       # mid works, try smaller (minimize)
        else:
            lo = mid + 1   # mid doesn't work, need bigger

    return lo  # lo == hi, the smallest feasible answer

def is_feasible(candidate, input_data):
    # check if 'candidate' is a valid answer
    pass  # problem-specific logic
static int binarySearchOnAnswer(int[] input) {
    int lo = 0;  // minimum possible answer
    int hi = 1_000_000_000; // maximum possible answer

    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (isFeasible(mid, input)) {
            hi = mid;       // mid works, try smaller
        } else {
            lo = mid + 1;   // mid doesn't work, need bigger
        }
    }
    return lo;
}

static boolean isFeasible(int candidate, int[] input) {
    // problem-specific check
    return true;
}

Two things to note: we write the isFeasible function separately (keeps it clean), and for “minimize” problems we do hi = mid, for “maximize” problems we do lo = mid.

Koko Eating Bananas

Koko has piles of bananas and h hours. She eats at speed k bananas/hour (one pile at a time, even if she finishes a pile early). Find the minimum k so she finishes in h hours (LeetCode #875).

Why binary search on answer? If she can finish at speed 5, she can definitely finish at speed 6. Monotonic condition! We binary search on the speed k.

function minEatingSpeed(piles, h) {
  let lo = 1;
  let hi = Math.max(...piles); // worst case: eat largest pile in 1 hr

  while (lo < hi) {
    const mid = Math.floor((lo + hi) / 2);
    // count hours needed at speed mid
    const hours = piles.reduce(
      (sum, p) => sum + Math.ceil(p / mid), 0
    );
    if (hours <= h) {
      hi = mid;     // can finish, try slower
    } else {
      lo = mid + 1; // too slow, need faster
    }
  }
  return lo;
}
// minEatingSpeed([3,6,7,11], 8) => 4
def min_eating_speed(piles, h):
    lo, hi = 1, max(piles)

    while lo < hi:
        mid = (lo + hi) // 2
        # count hours needed at speed mid
        hours = sum(math.ceil(p / mid) for p in piles)
        if hours <= h:
            hi = mid       # can finish, try slower
        else:
            lo = mid + 1   # too slow, need faster
    return lo

# min_eating_speed([3,6,7,11], 8) => 4
static int minEatingSpeed(int[] piles, int h) {
    int lo = 1, hi = 0;
    for (int p : piles) hi = Math.max(hi, p);

    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        int hours = 0;
        for (int p : piles) hours += (p + mid - 1) / mid; // ceil
        if (hours <= h) hi = mid;
        else lo = mid + 1;
    }
    return lo;
}

Time: O(n * log(max(piles))) — binary search over speeds, each check scans all piles. Space: O(1).

Capacity to Ship Packages

We need to ship packages in order within d days. Each day we load packages onto a ship with capacity cap. Find the minimum capacity (LeetCode #1011).

Same pattern. If capacity 10 works in d days, capacity 11 works too. Binary search on the capacity.

function shipWithinDays(weights, days) {
  let lo = Math.max(...weights);        // must fit the heaviest
  let hi = weights.reduce((a, b) => a + b); // ship everything at once

  while (lo < hi) {
    const mid = Math.floor((lo + hi) / 2);
    // simulate: how many days needed with capacity mid?
    let daysNeeded = 1, currentLoad = 0;
    for (const w of weights) {
      if (currentLoad + w > mid) {
        daysNeeded++;
        currentLoad = 0;
      }
      currentLoad += w;
    }
    if (daysNeeded <= days) hi = mid;
    else lo = mid + 1;
  }
  return lo;
}
def ship_within_days(weights, days):
    lo = max(weights)         # must fit the heaviest
    hi = sum(weights)         # ship everything at once

    while lo < hi:
        mid = (lo + hi) // 2
        # simulate: how many days with capacity mid?
        days_needed, current_load = 1, 0
        for w in weights:
            if current_load + w > mid:
                days_needed += 1
                current_load = 0
            current_load += w
        if days_needed <= days:
            hi = mid
        else:
            lo = mid + 1
    return lo
static int shipWithinDays(int[] weights, int days) {
    int lo = 0, hi = 0;
    for (int w : weights) { lo = Math.max(lo, w); hi += w; }

    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        int daysNeeded = 1, currentLoad = 0;
        for (int w : weights) {
            if (currentLoad + w > mid) {
                daysNeeded++;
                currentLoad = 0;
            }
            currentLoad += w;
        }
        if (daysNeeded <= days) hi = mid;
        else lo = mid + 1;
    }
    return lo;
}

Time: O(n * log(sum(weights))), Space: O(1).

Split Array Largest Sum

Split an array into m subarrays to minimize the largest subarray sum (LeetCode #410). This one sounds like DP, but binary search on answer is cleaner.

The insight: we’re asking “what’s the minimum possible largest sum?” If the max sum is S and we can split into <= m parts, then S works. Binary search on S.

function splitArray(nums, m) {
  let lo = Math.max(...nums);
  let hi = nums.reduce((a, b) => a + b);

  while (lo < hi) {
    const mid = Math.floor((lo + hi) / 2);
    // count splits needed if max sum per split is mid
    let splits = 1, currSum = 0;
    for (const n of nums) {
      if (currSum + n > mid) {
        splits++;
        currSum = 0;
      }
      currSum += n;
    }
    if (splits <= m) hi = mid;  // can do it, try smaller max
    else lo = mid + 1;          // too many splits, need larger max
  }
  return lo;
}
// splitArray([7,2,5,10,8], 2) => 18 ([7,2,5] and [10,8])
def split_array(nums, m):
    lo, hi = max(nums), sum(nums)

    while lo < hi:
        mid = (lo + hi) // 2
        splits, curr_sum = 1, 0
        for n in nums:
            if curr_sum + n > mid:
                splits += 1
                curr_sum = 0
            curr_sum += n
        if splits <= m:
            hi = mid
        else:
            lo = mid + 1
    return lo

# split_array([7,2,5,10,8], 2) => 18
static int splitArray(int[] nums, int m) {
    int lo = 0, hi = 0;
    for (int n : nums) { lo = Math.max(lo, n); hi += n; }

    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        int splits = 1, currSum = 0;
        for (int n : nums) {
            if (currSum + n > mid) { splits++; currSum = 0; }
            currSum += n;
        }
        if (splits <= m) hi = mid;
        else lo = mid + 1;
    }
    return lo;
}

Time: O(n * log(sum - max)), Space: O(1).

Notice how the last two problems (ship packages and split array) have essentially identical code? That’s the beauty of this pattern. Once we recognize it, the implementation almost writes itself.

Recipe for Binary Search on Answer

  1. Define the search space. What’s the minimum and maximum possible answer? (lo and hi)
  2. Write the feasibility check. Given a candidate answer mid, can we satisfy the constraints? This is usually a greedy simulation.
  3. Binary search. If feasible, try smaller (for minimize) or larger (for maximize). If not feasible, go the other way.

In simple language, the hard part isn’t the binary search itself — that’s always the same. The hard part is recognizing that binary search on answer applies, and writing the feasibility check. Once we have those two pieces, the rest is mechanical.


40

Common Interview Patterns Cheatsheet

intermediate patterns cheatsheet interview

This is a cheatsheet. Bookmark it, print it, tape it to the wall. The fastest way to solve interview problems isn’t memorizing solutions — it’s recognizing which pattern applies. Once we know the pattern, the solution flows naturally.

The Master Pattern Table

Problem Signal → Pattern Mapping
Two Pointers
Sorted array + find pair • Reverse in-place • Palindrome check • Remove duplicates from sorted
Keywords: "sorted", "pair", "in-place", "two sum"  |  TC: O(n)
Sliding Window
Contiguous subarray/substring + max/min/count • "At most k distinct" • Fixed-size window sum
Keywords: "subarray", "substring", "contiguous", "window"  |  TC: O(n)
Hash Map / Set
Count frequency • Find duplicates • Two sum (unsorted) • Group anagrams • O(1) lookup needed
Keywords: "frequency", "count", "duplicate", "anagram"  |  TC: O(n), SC: O(n)
Prefix Sum
Range sum queries • Subarray sum equals k • "Sum between index i and j"
Keywords: "subarray sum", "range sum", "cumulative"  |  TC: O(n) build, O(1) query
Binary Search
Sorted array search • "Minimize the maximum" • "Find boundary" • Rotated sorted array
Keywords: "sorted", "log n", "minimize maximum", "search space"  |  TC: O(log n)
BFS
Shortest path (unweighted) • Level-order traversal • "Minimum steps" • Grid shortest path
Keywords: "shortest", "minimum steps", "level by level", "nearest"  |  TC: O(V + E)
DFS
Tree traversal • Connected components • "Does a path exist?" • Island counting • Cycle detection
Keywords: "path", "connected", "island", "tree", "explore all"  |  TC: O(V + E)
Dynamic Prog.
"How many ways" • "Min/max cost to reach" • Overlapping subproblems • Can't use greedy
Keywords: "ways", "minimum cost", "maximum profit", "can we reach"  |  TC: O(n^2) or O(n*m)
Greedy
Interval scheduling • Jump game • "Optimal local choice leads to global optimum" • Activity selection
Keywords: "schedule", "intervals", "jump", "gas station"  |  TC: O(n log n) usually
Backtracking
"All possible combinations/permutations" • Subsets • N-Queens • Sudoku • Word search
Keywords: "all possible", "generate all", "combinations", "permutations"  |  TC: O(2^n) or O(n!)
Monotonic Stack
Next greater/smaller element • Largest rectangle in histogram • Stock span • Daily temperatures
Keywords: "next greater", "next smaller", "span", "histogram"  |  TC: O(n)
Heap / Priority Q
Kth largest/smallest • Merge k sorted lists • Top k frequent • Running median • Task scheduling
Keywords: "kth", "top k", "merge k sorted", "median stream"  |  TC: O(n log k)
Union-Find
Connected components • "Are X and Y connected?" • Kruskal's MST • Accounts merge • Redundant connection
Keywords: "connected", "union", "group", "component"  |  TC: O(α(n)) per operation
Trie
Autocomplete • Prefix matching • Word dictionary • Spell checker • "Starts with" queries
Keywords: "prefix", "dictionary", "autocomplete", "starts with"  |  TC: O(L) per operation

Quick Decision Flowchart

Not sure which pattern to use? Walk through this:

Pattern Selection Flowchart
1.
Is it about a sorted array or search space?
→ Binary Search. If "minimize the max" → Binary Search on Answer.
2.
Is it about a contiguous subarray or substring?
→ Sliding Window. If it involves sums → maybe Prefix Sum too.
3.
Is it about pairs in a sorted array?
→ Two Pointers.
4.
Is it about a tree or graph?
→ BFS for shortest path / level-order. DFS for everything else. Union-Find for connectivity.
5.
Does it say "all possible" or "generate all"?
→ Backtracking.
6.
Does it ask "how many ways" or "min/max cost"?
→ Dynamic Programming. Try greedy first -- if it fails, it's DP.
7.
Does it involve "next greater" or "next smaller"?
→ Monotonic Stack.
8.
Does it say "kth largest" or "top k"?
→ Heap / Priority Queue.
9.
Does it involve lookups, counting, or grouping?
→ Hash Map / Set. When in doubt, a hash map is almost always useful.

Common Combinations

Sometimes one pattern isn’t enough. Here are combos that show up frequently:

Problem TypeCombo
Subarray sum equals kPrefix Sum + Hash Map
Minimum window substringSliding Window + Hash Map
Meeting rooms IIIntervals + Sweep Line (or Heap)
Course scheduleGraph + Topological Sort (BFS/DFS)
Word search in gridDFS + Backtracking
Kth smallest in sorted matrixBinary Search + Heap
Accounts mergeUnion-Find + Hash Map
Autocomplete with rankingTrie + Heap

Time Complexity Quick Reference

PatternTypical TimeTypical Space
Two PointersO(n)O(1)
Sliding WindowO(n)O(k)
Hash MapO(n)O(n)
Prefix SumO(n) build, O(1) queryO(n)
Binary SearchO(log n)O(1)
BFS/DFSO(V + E)O(V)
DP (1D)O(n)O(n)
DP (2D)O(n * m)O(n * m)
BacktrackingO(2^n) or O(n!)O(n)
Heap operationsO(n log k)O(k)
Union-FindO(n * a(n))O(n)
TrieO(L) per opO(total chars)

General Interview Tips for DSA

Before coding:

  • Repeat the problem back to the interviewer. Make sure we understand edge cases.
  • Ask about constraints. Array size tells us the expected complexity (n <= 10^5 means O(n log n) is fine, n <= 20 means O(2^n) might be expected).
  • Walk through 1-2 examples by hand. This often reveals the pattern.

During coding:

  • Start with brute force and optimize. Say “the brute force is O(n^2), but we can do better with…” Interviewers love hearing the thought process.
  • Name variables clearly. left, right, seen, result — not i, j, x, y.
  • Talk while we code. Silence is the enemy in interviews.

After coding:

  • Trace through the code with a small example. Catch bugs before the interviewer does.
  • State the time and space complexity without being asked.
  • Mention edge cases: empty input, single element, all duplicates, negative numbers.

The meta-strategy:

  • If stuck for more than 2 minutes, say so. “I’m considering a few approaches…” is better than awkward silence.
  • If we recognize the pattern but can’t remember the exact implementation, describe the approach. Interviewers give partial credit for understanding.
  • If the interviewer gives a hint, take it. Don’t fight it. They’re trying to help us succeed.

In simple language, the interviewer isn’t testing if we’ve memorized LeetCode solutions. They’re testing if we can break a problem down, pick the right tool, and implement it cleanly. The patterns above are our toolbox. The more we practice recognizing which tool fits, the faster we’ll solve new problems we’ve never seen before.