Emmanuel Akinfulubi

Problem set

96 problems, grouped by pattern rather than scattered. Each one has a hint ladder you can climb only as far as you need, and a solution that runs and tests itself.

Doing problems in pattern groups is deliberate. Five sliding-window problems in a row teach you what a sliding-window problem looks like, which is the skill that actually transfers to an interview. Five random problems teach you five problems.

Current spread: 20 easy, 69 medium, 7 hard. That ratio is close to what a real loop draws from, though the medium band is where almost every hiring decision is made.

The groups

Arrays and hashing

9 problems — 3 easy, 6 medium. Pattern · Lessons 02, 04

Two pointers

7 problems — 2 easy, 4 medium, 1 hard. Pattern · Lesson 05

Sliding window

6 problems — 1 easy, 4 medium, 1 hard. Pattern · Lesson 06

Prefix sums and counting

5 problems — 1 easy, 4 medium. Pattern · Lesson 07

Binary search

6 problems — 1 easy, 4 medium, 1 hard. Pattern · Lesson 12

Stacks and monotonic stacks

6 problems — 1 easy, 4 medium, 1 hard. Pattern · Lesson 09

Linked lists

8 problems — 4 easy, 3 medium, 1 hard. Pattern · Lesson 08

Trees and binary search trees

10 problems — 5 easy, 4 medium, 1 hard. Pattern · Lessons 14, 15

Heaps and top-k

5 problems — 1 easy, 3 medium, 1 hard. Pattern · Lesson 16

Graphs and grids

9 problems — 9 medium. Pattern · Lessons 17, 18, 23

Backtracking

6 problems — 6 medium. Pattern · Lesson 19

Dynamic programming

11 problems — 1 easy, 10 medium. Pattern · Lesson 20

Greedy and intervals

8 problems — 8 medium. Pattern · Lessons 21, 22

A routine that works

  1. Read the problem and restate it out loud in one sentence. If you cannot, you have not understood it yet, and coding will not fix that.
  2. Say the pattern. Even a wrong guess is useful; it gives you something to disprove.
  3. Do the brute force in your head and name its complexity. This is your floor, and in a real interview it is worth saying before you optimise.
  4. Write the real solution without running it. Then test it on paper against the empty input, the single element, and the duplicate before you press Run.
  5. After it passes, write one line in your own notes: the pattern, and the detail that cost you time. That line is the point of the exercise.

Arrays and hashing

9 problems · the pattern · Lessons 02, 04

Start here. Every one of these is solved by trading memory for time: a Set for “have I seen it”, a Map for “how many”, or a map from a canonical key to a group. The pattern is easy; the practice is in picking the right key, which is where the actual thinking lives.

#217

Contains Duplicate

Easy

Given an array of integers, return true if any value appears more than once.

Hint 1

The brute-force answer compares every pair, which is O(n²). What would let you answer “have I seen this before?” in constant time?

Hint 2

A Set remembers what you have already passed. Add as you go, and check before adding.

The approach

One pass. If seen.has(n) then you are done; otherwise seen.add(n) and continue. You can also compare new Set(nums).size with nums.length, though the early exit is better on a long array with an early duplicate.

Solution and tests

Cost. O(n) time, O(n) space.

#217 Contains Duplicate

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function containsDuplicate(nums) {
  const seen = new Set();
  for (const n of nums) {
    if (seen.has(n)) return true;      // exit early, no need to see the rest
    seen.add(n);
  }
  return false;
}

test('has a duplicate', containsDuplicate([1, 2, 3, 1]), true);
test('all distinct', containsDuplicate([1, 2, 3, 4]), false);
test('immediate repeat', containsDuplicate([1, 1, 1, 3, 3, 4, 3, 2, 4, 2]), true);
test('single element', containsDuplicate([7]), false);
test('empty', containsDuplicate([]), false);
test('negatives', containsDuplicate([-1, -2, -1]), true);

Output


      
#242

Valid Anagram

Easy

Given two strings, decide whether one is a rearrangement of the other — the same characters, the same number of times, in any order.

Hint 1

Two strings of different lengths can never be anagrams. Check that first and you have removed most of the work.

Hint 2

Count how many times each character appears in the first string, then spend those counts down using the second.

The approach

One Map: increment for every character of a, decrement for every character of b. If any count goes negative, or any is left non-zero, they differ. Sorting both strings also works and is O(n log n).

Solution and tests

Cost. O(n) time, O(k) space for the alphabet in use.

#242 Valid Anagram

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function isAnagram(a, b) {
  if (a.length !== b.length) return false;

  const counts = new Map();
  for (const ch of a) counts.set(ch, (counts.get(ch) || 0) + 1);

  for (const ch of b) {
    const left = counts.get(ch);
    if (!left) return false;           // absent, or already used up
    counts.set(ch, left - 1);
  }
  return true;                          // lengths matched, so nothing can remain
}

test('anagram', isAnagram('anagram', 'nagaram'), true);
test('not an anagram', isAnagram('rat', 'car'), false);
test('different lengths', isAnagram('a', 'ab'), false);
test('both empty', isAnagram('', ''), true);
test('same letters, wrong counts', isAnagram('aabb', 'abbb'), false);
test('unicode', isAnagram('héllo', 'olléh'), true);

Output


      
#1

Two Sum

Easy

Given an array of integers and a target, return the indices of the two values that add to the target. Exactly one such pair exists, and you may not use the same index twice.

Hint 1

For each element you need to know whether its complement — target - current — is somewhere in the array. Scanning for it is O(n) per element.

Hint 2

Store values you have already passed in a Map from value to index, so the complement lookup is constant time.

The approach

Check for the complement before inserting the current element. Insert first and an element whose double equals the target will pair with itself.

Solution and tests

Cost. O(n) time, O(n) space.

#1 Two Sum

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function twoSum(nums, target) {
  const indexOf = new Map();            // value -> index

  for (let i = 0; i < nums.length; i++) {
    const need = target - nums[i];
    if (indexOf.has(need)) return [indexOf.get(need), i];
    indexOf.set(nums[i], i);            // AFTER the check, or n pairs with itself
  }
  return [];
}

test('basic', twoSum([2, 7, 11, 15], 9), [0, 1]);
test('later in the array', twoSum([3, 2, 4], 6), [1, 2]);
test('equal values', twoSum([3, 3], 6), [0, 1]);
test('negatives', twoSum([-3, 4, 3, 90], 0), [0, 2]);
test('no pair', twoSum([1, 2], 100), []);

Output


      
#49

Group Anagrams

Medium

Given a list of words, group together the ones that are rearrangements of each other. Return the groups in any order.

Hint 1

Two words belong together if some normalised form of them is identical. What form makes anagrams collide?

Hint 2

Sorting a word's letters gives every anagram the same string. Use it as a map key.

The approach

For a tighter key, count the 26 letters and join the counts — that is O(n) per word instead of O(n log n), and it matters when words are long.

Solution and tests

Cost. O(total characters × log(word length)) with the sorted key, or O(total characters) with the count key.

#49 Group Anagrams

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function groupAnagrams(words) {
  const groups = new Map();

  for (const word of words) {
    // Count-based key: 26 slots, so building it is linear in the word length.
    const counts = new Array(26).fill(0);
    for (const ch of word) counts[ch.charCodeAt(0) - 97]++;
    const key = counts.join(',');

    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(word);
  }
  return [...groups.values()];
}

// Compare as sorted group contents so the test does not depend on ordering.
const shape = (groups) => groups.map(g => [...g].sort()).sort((a, b) => a[0].localeCompare(b[0]));

test('classic', shape(groupAnagrams(['eat', 'tea', 'tan', 'ate', 'nat', 'bat'])),
  [['ate', 'eat', 'tea'], ['bat'], ['nat', 'tan']]);
test('one empty word', shape(groupAnagrams([''])), [['']]);
test('single letter', shape(groupAnagrams(['a'])), [['a']]);
test('no anagrams', shape(groupAnagrams(['abc', 'def'])), [['abc'], ['def']]);
test('empty input', groupAnagrams([]), []);

Output


      
#347

Top K Frequent Elements

Medium

Given an array of integers and a number k, return the k values that appear most often, in any order.

Hint 1

Two steps that are easy to conflate: count the frequencies, then find the top k of those counts.

Hint 2

Sorting the counts is O(m log m) and works. A heap of size k is O(m log k).

The approach

There is an O(n) answer. A count can never exceed n, so make an array of buckets indexed by count, drop each value into its bucket, and read from the high end downward.

Solution and tests

Cost. O(n) time and space with bucket counting. The sorting version is O(n log n) and is an acceptable first answer.

#347 Top K Frequent Elements

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function topKFrequent(nums, k) {
  const counts = new Map();
  for (const n of nums) counts.set(n, (counts.get(n) || 0) + 1);

  // A frequency can be at most nums.length, so bucket by count directly.
  const buckets = Array.from({ length: nums.length + 1 }, () => []);
  for (const [value, count] of counts) buckets[count].push(value);

  const out = [];
  for (let count = buckets.length - 1; count > 0 && out.length < k; count--) {
    for (const value of buckets[count]) {
      out.push(value);
      if (out.length === k) break;
    }
  }
  return out;
}

const sorted = (a) => [...a].sort((x, y) => x - y);

test('two most frequent', sorted(topKFrequent([1, 1, 1, 2, 2, 3], 2)), [1, 2]);
test('k = 1', topKFrequent([1], 1), [1]);
test('all equal counts', sorted(topKFrequent([1, 2, 3], 3)), [1, 2, 3]);
test('k covers everything', sorted(topKFrequent([4, 4, 5], 2)), [4, 5]);
test('negatives', topKFrequent([-1, -1, 2], 1), [-1]);

Output


      
#238

Product of Array Except Self

Medium

For each index, compute the product of every other element. Do it without division, in O(n) time.

Hint 1

The answer at index i is (everything to the left of i) times (everything to the right of i).

Hint 2

Two passes. Sweep left to right accumulating a running product into the output, then right to left multiplying by a running product from the other side.

The approach

The output array itself can hold the left products, so you need only one extra variable rather than a second array. Division is banned because a single zero would break it.

Solution and tests

Cost. O(n) time, O(1) extra space beyond the output.

#238 Product of Array Except Self

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function productExceptSelf(nums) {
  const out = new Array(nums.length).fill(1);

  // Pass 1: out[i] becomes the product of everything to the LEFT of i.
  let running = 1;
  for (let i = 0; i < nums.length; i++) {
    out[i] = running;
    running *= nums[i];
  }

  // Pass 2: multiply in the product of everything to the RIGHT of i.
  running = 1;
  for (let i = nums.length - 1; i >= 0; i--) {
    out[i] *= running;
    running *= nums[i];
  }
  return out;
}

test('basic', productExceptSelf([1, 2, 3, 4]), [24, 12, 8, 6]);
test('with a zero', productExceptSelf([-1, 1, 0, -3, 3]), [0, 0, 9, 0, 0]);
test('two zeros', productExceptSelf([0, 0, 2]), [0, 0, 0]);
test('two elements', productExceptSelf([2, 3]), [3, 2]);
test('negatives', productExceptSelf([-1, -2, -3]), [6, 3, 2]);

Output


      
#128

Longest Consecutive Sequence

Medium

Given an unsorted array of integers, find the length of the longest run of consecutive numbers. The run does not have to be contiguous in the array. Aim for O(n).

Hint 1

Sorting makes this easy and costs O(n log n). The O(n) solution needs a set.

Hint 2

Put everything in a Set. A number starts a sequence only if n - 1 is absent.

The approach

Walk upward only from those starts. Each element is visited at most twice overall — once by the start check and once by exactly one upward walk — which is what makes the whole thing linear despite the inner loop.

Solution and tests

Cost. O(n) time and space. The inner while does not make it quadratic, because only sequence starts enter it.

#128 Longest Consecutive Sequence

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function longestConsecutive(nums) {
  const pool = new Set(nums);
  let best = 0;

  for (const n of pool) {
    if (pool.has(n - 1)) continue;      // not the start of a run, skip it

    let length = 1;
    while (pool.has(n + length)) length++;
    best = Math.max(best, length);
  }
  return best;
}

test('classic', longestConsecutive([100, 4, 200, 1, 3, 2]), 4);
test('with duplicates', longestConsecutive([0, 3, 7, 2, 5, 8, 4, 6, 0, 1]), 9);
test('empty', longestConsecutive([]), 0);
test('single', longestConsecutive([5]), 1);
test('no run', longestConsecutive([10, 30, 20]), 1);
test('negatives across zero', longestConsecutive([-1, 0, 1, 2]), 4);

Output


      
#36

Valid Sudoku

Medium

Given a partly filled 9×9 grid, decide whether it breaks any rule: no repeated digit within a row, a column, or a 3×3 box. Empty cells are dots and are ignored. You do not need to decide whether it is solvable.

Hint 1

Three independent constraints, so three collections of sets: one per row, one per column, one per box.

Hint 2

The box index for cell (r, c) is Math.floor(r / 3) * 3 + Math.floor(c / 3). Derive it once and this becomes bookkeeping.

The approach

A single pass over all 81 cells. For each filled cell, try to add the digit to its three sets; if any already contains it, the board is invalid.

Solution and tests

Cost. O(1) — the board is a fixed 9×9, so this is 81 constant-time steps. Say that rather than O(n²).

#36 Valid Sudoku

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function isValidSudoku(board) {
  const rows = Array.from({ length: 9 }, () => new Set());
  const cols = Array.from({ length: 9 }, () => new Set());
  const boxes = Array.from({ length: 9 }, () => new Set());

  for (let r = 0; r < 9; r++) {
    for (let c = 0; c < 9; c++) {
      const value = board[r][c];
      if (value === '.') continue;

      const box = Math.floor(r / 3) * 3 + Math.floor(c / 3);
      if (rows[r].has(value) || cols[c].has(value) || boxes[box].has(value)) return false;

      rows[r].add(value);
      cols[c].add(value);
      boxes[box].add(value);
    }
  }
  return true;
}

const valid = [
  ['5','3','.','.','7','.','.','.','.'],
  ['6','.','.','1','9','5','.','.','.'],
  ['.','9','8','.','.','.','.','6','.'],
  ['8','.','.','.','6','.','.','.','3'],
  ['4','.','.','8','.','3','.','.','1'],
  ['7','.','.','.','2','.','.','.','6'],
  ['.','6','.','.','.','.','2','8','.'],
  ['.','.','.','4','1','9','.','.','5'],
  ['.','.','.','.','8','.','.','7','9'],
];
test('a valid board', isValidSudoku(valid), true);

// Same board, but the leading 5 is moved down so the top-left box has two 5s.
const boxClash = valid.map(row => [...row]);
boxClash[1][0] = '5';
test('duplicate in a box', isValidSudoku(boxClash), false);

const rowClash = valid.map(row => [...row]);
rowClash[0][2] = '3';
test('duplicate in a row', isValidSudoku(rowClash), false);

const empty = Array.from({ length: 9 }, () => new Array(9).fill('.'));
test('all empty is valid', isValidSudoku(empty), true);

Output


      
#271

Encode and Decode Strings

Medium

Write two functions: one that joins a list of strings into a single string, and one that recovers the original list exactly. The strings may contain any characters, including whatever separator you pick.

Hint 1

Any separator character can also appear inside a string, so joining on a delimiter alone cannot work.

Hint 2

Prefix each string with its length. Then the decoder never has to guess where a string ends.

The approach

Use a format like length#contents. The decoder reads digits up to the #, converts to a number, then slices exactly that many characters — so a # inside the payload is harmless.

Solution and tests

Cost. O(total length) for both directions.

#271 Encode and Decode Strings

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function encode(list) {
  // length + '#' + contents. The length prefix makes the payload unambiguous.
  let out = '';
  for (const s of list) out += s.length + '#' + s;
  return out;
}

function decode(encoded) {
  const out = [];
  let i = 0;
  while (i < encoded.length) {
    let hash = i;
    while (encoded[hash] !== '#') hash++;          // read the digits

    const length = Number(encoded.slice(i, hash));
    out.push(encoded.slice(hash + 1, hash + 1 + length));
    i = hash + 1 + length;                          // jump past this record
  }
  return out;
}

const roundTrip = (list) => decode(encode(list));

test('plain words', roundTrip(['lint', 'code', 'love', 'you']), ['lint', 'code', 'love', 'you']);
test('contains the delimiter', roundTrip(['a#b', '#', '2#x']), ['a#b', '#', '2#x']);
test('empty strings', roundTrip(['', '', 'a']), ['', '', 'a']);
test('empty list', roundTrip([]), []);
test('digits that look like lengths', roundTrip(['12', '3#4']), ['12', '3#4']);
test('encoded form', encode(['ab', 'c']), '2#ab1#c');

Output


      

Two pointers

7 problems · the pattern · Lesson 05

Every one of these replaces a nested loop with two indices. The question to keep asking is what tells me which pointer to move — with a sorted array it is the comparison against the target, with a container it is the shorter wall, with in-place removal it is whether the element survives. Get that right and the loop writes itself.

#125

Valid Palindrome

Easy

Decide whether a string reads the same forwards and backwards, considering only letters and digits and ignoring case.

Hint 1

Building a cleaned copy and comparing it with its reverse works and is perfectly acceptable. Doing it in place is what the question is really after.

Hint 2

One index at each end. Skip anything that is not alphanumeric, then compare the two characters case-insensitively.

The approach

The skip loops need their own bounds check (lo < hi), or a string of pure punctuation walks off the end.

Solution and tests

Cost. O(n) time, O(1) space.

#125 Valid Palindrome

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function isPalindrome(s) {
  const ok = (ch) => /[a-z0-9]/i.test(ch);
  let lo = 0, hi = s.length - 1;

  while (lo < hi) {
    while (lo < hi && !ok(s[lo])) lo++;        // the lo < hi guard matters
    while (lo < hi && !ok(s[hi])) hi--;
    if (s[lo].toLowerCase() !== s[hi].toLowerCase()) return false;
    lo++; hi--;
  }
  return true;
}

test('with punctuation', isPalindrome('A man, a plan, a canal: Panama'), true);
test('not a palindrome', isPalindrome('race a car'), false);
test('only punctuation', isPalindrome(' '), true);
test('empty', isPalindrome(''), true);
test('single letter', isPalindrome('a'), true);
test('digits', isPalindrome('0P'), false);
test('mixed case', isPalindrome('Aa'), true);

Output


      
#26

Remove Duplicates from Sorted Array

Easy

Given a sorted array, move the unique values to the front in order and return how many there are. The tail of the array may hold anything.

Hint 1

Sorted means duplicates are adjacent, so you only ever compare against the previous kept value.

Hint 2

A slow index marks where the next unique value goes; a fast index scans ahead.

The approach

Write only when nums[fast] !== nums[write - 1]. Start write at 1 so the first element is kept unconditionally, and handle the empty array separately.

Solution and tests

Cost. O(n) time, O(1) space.

#26 Remove Duplicates from Sorted Array

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function removeDuplicates(nums) {
  if (nums.length === 0) return 0;

  let write = 1;                              // nums[0] is always kept
  for (let read = 1; read < nums.length; read++) {
    if (nums[read] !== nums[write - 1]) nums[write++] = nums[read];
  }
  return write;
}

// Helper so the tests can look at the meaningful prefix.
const front = (nums) => { const k = removeDuplicates(nums); return nums.slice(0, k); };

test('with duplicates', front([1, 1, 2]), [1, 2]);
test('longer run', front([0, 0, 1, 1, 1, 2, 2, 3, 3, 4]), [0, 1, 2, 3, 4]);
test('all identical', front([5, 5, 5]), [5]);
test('already unique', front([1, 2, 3]), [1, 2, 3]);
test('empty', front([]), []);
test('returns the count', removeDuplicates([1, 1, 2]), 2);

Output


      
#167

Two Sum II, Input Array Is Sorted

Medium

Given a sorted array and a target, find the one pair that sums to it. Return the two positions as 1-based indices. Use constant extra space.

Hint 1

The hash-map answer to Two Sum uses O(n) space. Sortedness lets you drop that to O(1).

Hint 2

Start at both ends. If the sum is too small, only moving the left index up can help; if too large, only moving the right index down can.

The approach

Each step eliminates an entire row or column of the pair grid, which is why one pass suffices.

Solution and tests

Cost. O(n) time, O(1) space.

#167 Two Sum II, Input Array Is Sorted

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function twoSumSorted(nums, target) {
  let lo = 0, hi = nums.length - 1;

  while (lo < hi) {
    const sum = nums[lo] + nums[hi];
    if (sum === target) return [lo + 1, hi + 1];    // 1-based, as asked
    if (sum < target) lo++;                          // need bigger
    else hi--;                                       // need smaller
  }
  return [];
}

test('basic', twoSumSorted([2, 7, 11, 15], 9), [1, 2]);
test('middle pair', twoSumSorted([2, 3, 4], 6), [1, 3]);
test('negatives', twoSumSorted([-1, 0], -1), [1, 2]);
test('two elements', twoSumSorted([1, 2], 3), [1, 2]);
test('no pair', twoSumSorted([1, 2, 3], 100), []);

Output


      
#15

3Sum

Medium

Find every distinct triple in an array that sums to zero. Two triples are the same if they contain the same three values, regardless of order.

Hint 1

Sort first. Then fix one element and the rest is the sorted two-pointer problem on the remainder.

Hint 2

Fix index i, then two-pointer over i + 1 .. n - 1 looking for -nums[i].

The approach

Duplicates are the entire difficulty. Skip the outer index when it repeats the previous value, and after recording a match advance both inner pointers past their equal neighbours. Also break out early once nums[i] > 0, since three positives cannot sum to zero.

Solution and tests

Cost. O(n²) time — O(n log n) to sort, then n two-pointer sweeps. O(1) extra space beyond the output.

#15 3Sum

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function threeSum(nums) {
  const sorted = [...nums].sort((a, b) => a - b);
  const out = [];

  for (let i = 0; i < sorted.length - 2; i++) {
    if (sorted[i] > 0) break;                            // sorted: no way back to 0
    if (i > 0 && sorted[i] === sorted[i - 1]) continue;  // skip a repeated anchor

    let lo = i + 1, hi = sorted.length - 1;
    while (lo < hi) {
      const sum = sorted[i] + sorted[lo] + sorted[hi];
      if (sum < 0) lo++;
      else if (sum > 0) hi--;
      else {
        out.push([sorted[i], sorted[lo], sorted[hi]]);
        lo++; hi--;
        // Step past duplicates so the same triple is not emitted twice.
        while (lo < hi && sorted[lo] === sorted[lo - 1]) lo++;
        while (lo < hi && sorted[hi] === sorted[hi + 1]) hi--;
      }
    }
  }
  return out;
}

test('classic', threeSum([-1, 0, 1, 2, -1, -4]), [[-1, -1, 2], [-1, 0, 1]]);
test('no triple', threeSum([0, 1, 1]), []);
test('all zeros', threeSum([0, 0, 0]), [[0, 0, 0]]);
test('four zeros, still one triple', threeSum([0, 0, 0, 0]), [[0, 0, 0]]);
test('too short', threeSum([1, 2]), []);
test('many duplicates', threeSum([-2, 0, 0, 2, 2]), [[-2, 0, 2]]);

Output


      
#11

Container With Most Water

Medium

Each number is the height of a vertical wall at that index. Pick two walls so that the water held between them is greatest. The volume is the distance between them times the height of the shorter one.

Hint 1

Area is (hi - lo) × min(height[lo], height[hi]). Start with the widest possible pair.

Hint 2

Every move inward loses width, so it can only pay off by gaining height. Which of the two walls could possibly give you that?

The approach

Move the shorter wall. Keeping it and moving the taller one can never increase the minimum, so every pair involving the shorter wall is already accounted for.

Solution and tests

Cost. O(n) time, O(1) space.

#11 Container With Most Water

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function maxArea(heights) {
  let best = 0, lo = 0, hi = heights.length - 1;

  while (lo < hi) {
    const height = Math.min(heights[lo], heights[hi]);
    best = Math.max(best, height * (hi - lo));

    // Move the shorter wall: it is the binding constraint.
    if (heights[lo] < heights[hi]) lo++;
    else hi--;
  }
  return best;
}

test('classic', maxArea([1, 8, 6, 2, 5, 4, 8, 3, 7]), 49);
test('two walls', maxArea([1, 1]), 1);
test('increasing', maxArea([1, 2, 3, 4, 5]), 6);
test('tall ends', maxArea([9, 1, 1, 9]), 27);
test('single wall holds nothing', maxArea([5]), 0);

Output


      
#75

Sort Colors

Medium

An array holds only the values 0, 1 and 2. Sort it in place in a single pass, without using a sorting function.

Hint 1

Counting how many of each and rewriting the array is two passes and is a fine first answer.

Hint 2

One pass needs three regions: settled zeros at the front, settled twos at the back, and the unexamined middle.

The approach

Keep lo, mid and hi. On a 0, swap to lo and advance both lo and mid. On a 2, swap to hi, decrement hi, and do not advance mid — the value you just swapped in is still unexamined.

Solution and tests

Cost. O(n) time, O(1) space, one pass. This is the Dutch national flag partition.

#75 Sort Colors

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function sortColors(nums) {
  let lo = 0, mid = 0, hi = nums.length - 1;

  while (mid <= hi) {
    if (nums[mid] === 0) {
      [nums[lo], nums[mid]] = [nums[mid], nums[lo]];
      lo++; mid++;                        // what came from lo is a settled 1 or 0
    } else if (nums[mid] === 2) {
      [nums[mid], nums[hi]] = [nums[hi], nums[mid]];
      hi--;                                // do NOT advance mid: new value unseen
    } else {
      mid++;                               // a 1 is already in the right region
    }
  }
  return nums;
}

test('classic', sortColors([2, 0, 2, 1, 1, 0]), [0, 0, 1, 1, 2, 2]);
test('short', sortColors([2, 0, 1]), [0, 1, 2]);
test('single', sortColors([0]), [0]);
test('already sorted', sortColors([0, 1, 2]), [0, 1, 2]);
test('reversed', sortColors([2, 2, 1, 1, 0, 0]), [0, 0, 1, 1, 2, 2]);
test('one value only', sortColors([1, 1, 1]), [1, 1, 1]);

Output


      
#42

Trapping Rain Water

Hard

Given heights forming an elevation profile, compute how much rain water is trapped between the peaks after it rains.

Hint 1

The water sitting above index i is min(tallest to the left, tallest to the right) - height[i], or zero if that is negative.

Hint 2

Precomputing those two arrays gives an O(n) time, O(n) space answer, and is a perfectly good solution to state first.

The approach

For O(1) space, two pointers with running maxima. Whichever side has the smaller running max is the side whose water level is already determined, so process that side and move inward.

Solution and tests

Cost. O(n) time, O(1) space. The prefix-array version is O(n) space and equally acceptable.

#42 Trapping Rain Water

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function trap(heights) {
  if (heights.length === 0) return 0;

  let lo = 0, hi = heights.length - 1;
  let leftMax = heights[lo], rightMax = heights[hi];
  let water = 0;

  while (lo < hi) {
    // The smaller running max bounds the water level, so that side is settled.
    if (leftMax <= rightMax) {
      lo++;
      leftMax = Math.max(leftMax, heights[lo]);
      water += leftMax - heights[lo];
    } else {
      hi--;
      rightMax = Math.max(rightMax, heights[hi]);
      water += rightMax - heights[hi];
    }
  }
  return water;
}

test('classic', trap([0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]), 6);
test('simple basin', trap([4, 2, 0, 3, 2, 5]), 9);
test('monotonic holds nothing', trap([1, 2, 3, 4]), 0);
test('descending holds nothing', trap([4, 3, 2, 1]), 0);
test('empty', trap([]), 0);
test('flat', trap([2, 2, 2]), 0);
test('single dip', trap([3, 0, 3]), 3);

Output


      

Sliding window

6 problems · the pattern · Lesson 06

All six say contiguous, substring or subarray, and all six are one loop where the right edge always advances and the left edge advances only to restore a condition. The only decision that varies is where you record the answer: after shrinking for a longest-valid question, before shrinking for a shortest-valid one.

#121

Best Time to Buy and Sell Stock

Easy

Given daily prices, buy on one day and sell on a later day to maximise profit. Return the best profit, or 0 if every choice loses money.

Hint 1

You need the largest gap where the smaller number comes first. Checking every pair is O(n²).

Hint 2

Walk forward tracking the cheapest price seen so far.

The approach

At each day, the best sale today is price - cheapestSoFar. Take the maximum of those, and never let it go below zero.

Solution and tests

Cost. O(n) time, O(1) space.

#121 Best Time to Buy and Sell Stock

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function maxProfit(prices) {
  let cheapest = Infinity, best = 0;

  for (const price of prices) {
    cheapest = Math.min(cheapest, price);      // best day to have bought
    best = Math.max(best, price - cheapest);   // best sale if we sell today
  }
  return best;
}

test('classic', maxProfit([7, 1, 5, 3, 6, 4]), 5);
test('only falls', maxProfit([7, 6, 4, 3, 1]), 0);
test('single day', maxProfit([5]), 0);
test('empty', maxProfit([]), 0);
test('rises throughout', maxProfit([1, 2, 3, 4]), 3);
test('best pair at the end', maxProfit([3, 2, 6, 5, 0, 3]), 4);

Output


      
#3

Longest Substring Without Repeating Characters

Medium

Find the length of the longest run of characters in a string with no repeats.

Hint 1

The window is valid while every character inside it is distinct. Adding a character can break that; removing from the left can fix it.

Hint 2

A Set plus a while loop that evicts from the left until the duplicate is gone works and is the version most people write.

The approach

A Map from character to its last index lets you jump the left edge in one step instead of a loop. Guard the jump with lastSeen.get(ch) >= left, or a stale index outside the window drags left backwards.

Solution and tests

Cost. O(n) time, O(min(n, alphabet)) space.

#3 Longest Substring Without Repeating Characters

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function lengthOfLongestSubstring(s) {
  const lastSeen = new Map();
  let best = 0, left = 0;

  for (let right = 0; right < s.length; right++) {
    const ch = s[right];
    // Only jump if the previous sighting is inside the current window.
    if (lastSeen.has(ch) && lastSeen.get(ch) >= left) left = lastSeen.get(ch) + 1;

    lastSeen.set(ch, right);
    best = Math.max(best, right - left + 1);
  }
  return best;
}

test('classic', lengthOfLongestSubstring('abcabcbb'), 3);
test('all identical', lengthOfLongestSubstring('bbbbb'), 1);
test('window must slide', lengthOfLongestSubstring('pwwkew'), 3);
test('empty', lengthOfLongestSubstring(''), 0);
test('stale index trap', lengthOfLongestSubstring('dvdf'), 3);
test('single char', lengthOfLongestSubstring('a'), 1);
test('all distinct', lengthOfLongestSubstring('abcdef'), 6);

Output


      
#424

Longest Repeating Character Replacement

Medium

You may change up to k characters in a string to any other letter. Find the longest run of one repeated character you can produce.

Hint 1

A window is achievable when the characters you must replace — window length minus the count of the most common character in it — is at most k.

Hint 2

Track character counts in the window and the highest count seen.

The approach

You never need to decrease the recorded maximum count. The window only grows when a genuinely better maximum appears, so a stale high value cannot produce an answer that is too large. That is a subtle but sound shortcut worth being able to justify.

Solution and tests

Cost. O(n) time, O(alphabet) space.

#424 Longest Repeating Character Replacement

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function characterReplacement(s, k) {
  const counts = new Map();
  let best = 0, left = 0, mostCommon = 0;

  for (let right = 0; right < s.length; right++) {
    counts.set(s[right], (counts.get(s[right]) || 0) + 1);
    mostCommon = Math.max(mostCommon, counts.get(s[right]));

    // Too many characters would need replacing: shrink from the left.
    while (right - left + 1 - mostCommon > k) {
      counts.set(s[left], counts.get(s[left]) - 1);
      left++;
    }
    best = Math.max(best, right - left + 1);
  }
  return best;
}

test('one replacement', characterReplacement('ABAB', 2), 4);
test('classic', characterReplacement('AABABBA', 1), 4);
test('no replacements allowed', characterReplacement('ABCD', 0), 1);
test('all identical', characterReplacement('AAAA', 2), 4);
test('single char', characterReplacement('A', 1), 1);
test('empty', characterReplacement('', 2), 0);

Output


      
#567

Permutation in String

Medium

Given two strings, decide whether any rearrangement of the first appears as a contiguous block inside the second.

Hint 1

A permutation has exactly the same character counts. So you are looking for a window whose counts match.

Hint 2

The window has a fixed length — the length of the pattern — so no shrinking loop is needed. One character enters and one leaves each step.

The approach

Comparing two count maps every step is O(alphabet) and fine. To do better, keep a running tally of how many of the 26 counts currently match, and adjust it as characters enter and leave.

Solution and tests

Cost. O(n) time with a fixed 26-letter alphabet, O(1) space.

#567 Permutation in String

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function checkInclusion(pattern, text) {
  if (pattern.length > text.length) return false;

  const need = new Array(26).fill(0);
  const have = new Array(26).fill(0);
  const at = (ch) => ch.charCodeAt(0) - 97;

  for (const ch of pattern) need[at(ch)]++;
  for (let i = 0; i < pattern.length; i++) have[at(text[i])]++;

  let matching = 0;
  for (let i = 0; i < 26; i++) if (need[i] === have[i]) matching++;

  if (matching === 26) return true;

  for (let right = pattern.length; right < text.length; right++) {
    const entering = at(text[right]);
    const leaving = at(text[right - pattern.length]);

    // Adjust only the two letters whose counts changed.
    have[entering]++;
    if (have[entering] === need[entering]) matching++;
    else if (have[entering] === need[entering] + 1) matching--;

    have[leaving]--;
    if (have[leaving] === need[leaving]) matching++;
    else if (have[leaving] === need[leaving] - 1) matching--;

    if (matching === 26) return true;
  }
  return false;
}

test('present', checkInclusion('ab', 'eidbaooo'), true);
test('absent', checkInclusion('ab', 'eidboaoo'), false);
test('exact match', checkInclusion('abc', 'cba'), true);
test('pattern too long', checkInclusion('abcd', 'abc'), false);
test('single char present', checkInclusion('a', 'a'), true);
test('a rearrangement counts', checkInclusion('aab', 'aba'), true);
test('wrong repeat counts', checkInclusion('aab', 'abb'), false);

Output


      
#209

Minimum Size Subarray Sum

Medium

Given positive integers and a target, find the length of the shortest contiguous block whose sum is at least the target. Return 0 if none exists.

Hint 1

This is a shortest-valid question, so the answer is recorded while the window is legal, not after fixing it.

Hint 2

Grow the right edge until the sum reaches the target, then shrink from the left as far as you can while it still does.

The approach

Note that the values must be positive for this to work: shrinking has to reliably reduce the sum. With negatives it becomes a prefix-sum problem instead.

Solution and tests

Cost. O(n) time, O(1) space.

#209 Minimum Size Subarray Sum

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function minSubArrayLen(target, nums) {
  let best = Infinity, sum = 0, left = 0;

  for (let right = 0; right < nums.length; right++) {
    sum += nums[right];

    // While it is still valid, record and shrink. Shortest-valid shape.
    while (sum >= target) {
      best = Math.min(best, right - left + 1);
      sum -= nums[left++];
    }
  }
  return best === Infinity ? 0 : best;
}

test('classic', minSubArrayLen(7, [2, 3, 1, 2, 4, 3]), 2);
test('single element suffices', minSubArrayLen(4, [1, 4, 4]), 1);
test('impossible', minSubArrayLen(11, [1, 1, 1, 1, 1, 1, 1, 1]), 0);
test('whole array needed', minSubArrayLen(6, [1, 2, 3]), 3);
test('empty', minSubArrayLen(5, []), 0);
test('exact single', minSubArrayLen(5, [5]), 1);

Output


      
#76

Minimum Window Substring

Hard

Given a text and a pattern, find the shortest block of the text containing every character of the pattern, counting repeats. Return the empty string if there is none.

Hint 1

Track how many characters you still need. A window is valid when that count reaches zero.

Hint 2

Grow right until valid, then shrink left while it stays valid, recording the best as you go — the shortest-valid shape.

The approach

The bookkeeping is where this goes wrong. Decrement the "still needed" total only when a character's count is still positive, so extra copies of an already-satisfied character do not count as progress.

Solution and tests

Cost. O(n + m) time, O(m) space for the pattern counts.

#76 Minimum Window Substring

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function minWindow(text, pattern) {
  if (pattern.length === 0 || text.length < pattern.length) return '';

  const need = new Map();
  for (const ch of pattern) need.set(ch, (need.get(ch) || 0) + 1);

  let missing = pattern.length;          // total characters still owed
  let bestStart = 0, bestLength = Infinity;
  let left = 0;

  for (let right = 0; right < text.length; right++) {
    const ch = text[right];
    // Only a still-owed character reduces the debt.
    if (need.get(ch) > 0) missing--;
    need.set(ch, (need.get(ch) || 0) - 1);   // can go negative: a surplus

    while (missing === 0) {                   // valid: record, then shrink
      if (right - left + 1 < bestLength) {
        bestLength = right - left + 1;
        bestStart = left;
      }
      const out = text[left++];
      need.set(out, need.get(out) + 1);
      if (need.get(out) > 0) missing++;       // we just gave one back
    }
  }
  return bestLength === Infinity ? '' : text.slice(bestStart, bestStart + bestLength);
}

test('classic', minWindow('ADOBECODEBANC', 'ABC'), 'BANC');
test('whole string', minWindow('a', 'a'), 'a');
test('impossible', minWindow('a', 'aa'), '');
test('empty pattern', minWindow('abc', ''), '');
test('repeats needed', minWindow('aabbb', 'aabb'), 'aabb');
test('must span a gap', minWindow('aaflslflsldkabbb', 'aabb'), 'aflslflsldkabb');
test('pattern longer than text', minWindow('ab', 'abc'), '');

Output


      

Prefix sums and counting

5 problems · the pattern · Lesson 07

The pattern that rescues you when a sliding window will not work, which is any time the array can contain negative numbers. All five rest on one identity: sum(i..j) = prefix[j+1] - prefix[i]. Rearrange it and the question “which earlier prefix completes my target?” becomes a hash-map lookup. The last one runs the identity backwards, which is a difference array.

#303

Range Sum Query, Immutable

Easy

Build a structure over a fixed array that answers “what is the sum from index i to index j inclusive” many times, quickly.

Hint 1

Summing the range per query is O(n) each time. Since the array never changes, you can pay once up front instead.

Hint 2

Precompute running totals, then a range is the difference of two of them.

The approach

Make the prefix array one longer than the input, with prefix[0] = 0. That removes the special case for a range starting at index 0.

Solution and tests

Cost. O(n) to build, O(1) per query, O(n) space.

#303 Range Sum Query, Immutable

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

class NumArray {
  constructor(nums) {
    // One longer than the input, so prefix[0] = 0 and there is no edge case.
    this.prefix = new Array(nums.length + 1).fill(0);
    for (let i = 0; i < nums.length; i++) this.prefix[i + 1] = this.prefix[i] + nums[i];
  }

  sumRange(lo, hi) {
    return this.prefix[hi + 1] - this.prefix[lo];
  }
}

const arr = new NumArray([-2, 0, 3, -5, 2, -1]);
test('middle range', arr.sumRange(0, 2), 1);
test('from index 2', arr.sumRange(2, 5), -1);
test('whole array', arr.sumRange(0, 5), -3);
test('single element', arr.sumRange(3, 3), -5);
test('first element only', arr.sumRange(0, 0), -2);

const one = new NumArray([7]);
test('one-element array', one.sumRange(0, 0), 7);

Output


      
#560

Subarray Sum Equals K

Medium

Count how many contiguous blocks of an array sum to exactly k. The array may contain negative numbers.

Hint 1

A sliding window fails here, because with negatives a growing sum is not monotonic — you cannot decide to shrink.

Hint 2

If the running total at index j is S, then a block ending at j sums to k exactly when some earlier prefix equalled S - k.

The approach

Keep a Map from prefix total to how many times it has occurred, and seed it with {0: 1} — that single entry is what counts the blocks starting at index 0.

Solution and tests

Cost. O(n) time and space, one pass.

#560 Subarray Sum Equals K

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function subarraySum(nums, k) {
  const seen = new Map([[0, 1]]);     // one empty prefix with total 0
  let running = 0, count = 0;

  for (const n of nums) {
    running += n;
    count += seen.get(running - k) || 0;          // earlier prefixes that complete k
    seen.set(running, (seen.get(running) || 0) + 1);
  }
  return count;
}

test('classic', subarraySum([1, 1, 1], 2), 2);
test('sums to k once', subarraySum([1, 2, 3], 3), 2);
test('with negatives', subarraySum([1, -1, 0], 0), 3);
test('single match', subarraySum([1], 1), 1);
test('no match', subarraySum([1, 2], 7), 0);
test('all zeros, k = 0', subarraySum([0, 0, 0], 0), 6);
test('needs the seeded zero', subarraySum([3, 4, 7], 7), 2);

Output


      
#525

Contiguous Array

Medium

Given an array of only 0s and 1s, find the longest contiguous block containing an equal number of each.

Hint 1

Equal counts is awkward to track. Can you turn it into something a running sum measures?

Hint 2

Treat 0 as -1. Then an equal-count block is exactly a block summing to zero.

The approach

Store the first index at which each running total appeared. When a total repeats, everything between the two sightings sums to zero. Never overwrite an earlier index, or you shorten your own answer.

Solution and tests

Cost. O(n) time and space.

#525 Contiguous Array

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function findMaxLength(nums) {
  // Map from running total to the FIRST index that produced it.
  const firstAt = new Map([[0, -1]]);   // total 0 exists before the array starts
  let running = 0, best = 0;

  for (let i = 0; i < nums.length; i++) {
    running += nums[i] === 1 ? 1 : -1;             // 0 counts as -1

    if (firstAt.has(running)) {
      best = Math.max(best, i - firstAt.get(running));   // the gap sums to zero
    } else {
      firstAt.set(running, i);                      // only the first sighting
    }
  }
  return best;
}

test('simple pair', findMaxLength([0, 1]), 2);
test('extra one', findMaxLength([0, 1, 0]), 2);
test('longer', findMaxLength([0, 1, 1, 0, 1, 1, 1, 0]), 4);
test('no balance', findMaxLength([1, 1, 1]), 0);
test('whole array', findMaxLength([0, 0, 1, 1]), 4);
test('empty', findMaxLength([]), 0);

Output


      
#974

Subarray Sums Divisible by K

Medium

Count the contiguous blocks whose sum is a multiple of k.

Hint 1

Same shape as counting blocks that sum to k, but the thing you store is different.

Hint 2

Two prefixes with the same remainder modulo k have a difference divisible by k. So count remainders, not totals.

The approach

In JavaScript -1 % 5 is -1, not 4. Normalise with ((x % k) + k) % k or negative sums land in the wrong bucket.

Solution and tests

Cost. O(n) time, O(k) space.

#974 Subarray Sums Divisible by K

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function subarraysDivByK(nums, k) {
  const counts = new Map([[0, 1]]);
  let running = 0, total = 0;

  for (const n of nums) {
    running += n;
    // JavaScript % keeps the sign of the dividend, so normalise into 0..k-1.
    const remainder = ((running % k) + k) % k;

    total += counts.get(remainder) || 0;
    counts.set(remainder, (counts.get(remainder) || 0) + 1);
  }
  return total;
}

test('classic', subarraysDivByK([4, 5, 0, -2, -3, 1], 5), 7);
test('none divisible', subarraysDivByK([5], 9), 0);
test('single divisible', subarraysDivByK([5], 5), 1);
test('negatives', subarraysDivByK([-1, 2, 9], 2), 2);
test('all zeros', subarraysDivByK([0, 0], 3), 3);
test('k of 1 takes everything', subarraysDivByK([1, 2, 3], 1), 6);

Output


      
#1094

Car Pooling

Medium

A car with a fixed number of seats drives forward along a line of stops. Each trip is a number of passengers, a pickup stop and a drop-off stop. Decide whether the car can complete every trip without ever exceeding its capacity.

Hint 1

You need the passenger count at every point along the route, and each trip affects a whole range of stops.

Hint 2

Instead of adding to every stop in the range, add at the pickup and subtract at the drop-off. A running sum then reconstructs the true count.

The approach

That is a difference array — the prefix-sum identity used backwards. Because the stops are bounded small integers, you can index them directly and skip sorting entirely, which makes the whole thing linear.

Solution and tests

Cost. O(n + range) time, O(range) space. Sorting the events instead would be O(n log n).

#1094 Car Pooling

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function carPooling(trips, capacity) {
  // Stops are bounded (0..1000), so bucket the deltas rather than sorting.
  const delta = new Array(1002).fill(0);

  for (const [passengers, from, to] of trips) {
    delta[from] += passengers;      // they board here
    delta[to] -= passengers;        // and leave here, freeing the seats
  }

  let onboard = 0;
  for (let stop = 0; stop < delta.length; stop++) {
    onboard += delta[stop];                    // the running sum is the real count
    if (onboard > capacity) return false;
  }
  return true;
}

test('too full', carPooling([[2, 1, 5], [3, 3, 7]], 4), false);
test('just fits', carPooling([[2, 1, 5], [3, 3, 7]], 5), true);
test('no overlap', carPooling([[2, 1, 5], [3, 5, 7]], 3), true);
test('single trip', carPooling([[4, 0, 1]], 4), true);
test('one seat short', carPooling([[5, 0, 1]], 4), false);
test('no trips', carPooling([], 1), true);
test('drop-off frees a seat', carPooling([[3, 2, 7], [3, 7, 9]], 3), true);

Output


      

Stacks and monotonic stacks

6 problems · the pattern · Lesson 09

The first two are plain stacks: something must be remembered until its partner arrives. The last four are monotonic stacks, where the stack is kept sorted and the act of popping is the computation — the moment an element is popped, you have just discovered its answer. That reframing is the whole pattern, and it turns several apparently hard problems into ten lines.

#20

Valid Parentheses

Easy

Given a string of brackets — round, square and curly — decide whether every one is closed by the matching type, in the right order.

Hint 1

Counting brackets is not enough: ([)] has balanced counts and is still wrong.

Hint 2

The most recently opened bracket must be the first to close. That is a stack.

The approach

Push openers; on a closer, pop and check the pair matches. Two failure modes to handle: popping an empty stack, and a non-empty stack at the end.

Solution and tests

Cost. O(n) time and space.

#20 Valid Parentheses

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function isValid(s) {
  const closes = { ')': '(', ']': '[', '}': '{' };
  const stack = [];

  for (const ch of s) {
    if (ch === '(' || ch === '[' || ch === '{') {
      stack.push(ch);
    } else {
      // Pops empty when a closer arrives with nothing open.
      if (stack.pop() !== closes[ch]) return false;
    }
  }
  return stack.length === 0;             // anything left open is invalid
}

test('simple pair', isValid('()'), true);
test('nested and sequential', isValid('()[]{}'), true);
test('mismatched types', isValid('(]'), false);
test('interleaved', isValid('([)]'), false);
test('correctly nested', isValid('{[]}'), true);
test('unclosed', isValid('('), false);
test('closer first', isValid(')'), false);
test('empty', isValid(''), true);

Output


      
#155

Min Stack

Medium

Build a stack supporting push, pop, top and retrieving the minimum, with every operation running in O(1).

Hint 1

Scanning for the minimum is O(n). A single stored minimum breaks as soon as you pop it.

Hint 2

What you need is the minimum as of each point in the stack's history.

The approach

Keep a second stack that records the running minimum at each depth. Push Math.min(value, currentMin) onto it every time, and pop both together — so the mins stack always mirrors the main one.

Solution and tests

Cost. O(1) for every operation, O(n) space.

#155 Min Stack

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

class MinStack {
  constructor() {
    this.items = [];
    this.mins = [];                       // running minimum at each depth
  }

  push(value) {
    this.items.push(value);
    const currentMin = this.mins.length ? this.mins[this.mins.length - 1] : Infinity;
    this.mins.push(Math.min(value, currentMin));
  }

  pop() {
    this.mins.pop();                      // always in lockstep
    return this.items.pop();
  }

  top() { return this.items[this.items.length - 1]; }
  getMin() { return this.mins[this.mins.length - 1]; }
}

const st = new MinStack();
st.push(-2); st.push(0); st.push(-3);
test('min with -3 on top', st.getMin(), -3);
st.pop();
test('top after pop', st.top(), 0);
test('min recovered', st.getMin(), -2);

const st2 = new MinStack();
st2.push(5);
test('single element min', st2.getMin(), 5);
st2.push(5);
st2.pop();
test('duplicate min survives a pop', st2.getMin(), 5);

const st3 = new MinStack();
st3.push(2); st3.push(1); st3.push(3);
test('min is not the top', st3.getMin(), 1);
st3.pop();
test('min unchanged after popping a larger value', st3.getMin(), 1);

Output


      
#739

Daily Temperatures

Medium

For each day, how many days until a warmer temperature? Answer 0 for days where none follows.

Hint 1

Scanning forward from each day is O(n²). Days waiting for a warmer day can be remembered instead.

Hint 2

Keep a stack of indices whose answer is still unknown, with temperatures decreasing from bottom to top.

The approach

When today is warmer than the top of the stack, pop it — today is its answer, and the distance is i - poppedIndex. That is why you store indices rather than temperatures.

Solution and tests

Cost. O(n) time amortised — each index is pushed and popped at most once. O(n) space.

#739 Daily Temperatures

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function dailyTemperatures(temps) {
  const answer = new Array(temps.length).fill(0);
  const stack = [];                        // indices, temperatures decreasing

  for (let i = 0; i < temps.length; i++) {
    while (stack.length && temps[stack[stack.length - 1]] < temps[i]) {
      const day = stack.pop();
      answer[day] = i - day;               // indices, so the distance is free
    }
    stack.push(i);
  }
  return answer;                            // anything unpopped keeps its 0
}

test('classic', dailyTemperatures([73, 74, 75, 71, 69, 72, 76, 73]), [1, 1, 4, 2, 1, 1, 0, 0]);
test('rising', dailyTemperatures([30, 40, 50, 60]), [1, 1, 1, 0]);
test('with a plateau', dailyTemperatures([30, 60, 90]), [1, 1, 0]);
test('falling', dailyTemperatures([90, 80, 70]), [0, 0, 0]);
test('all equal', dailyTemperatures([50, 50, 50]), [0, 0, 0]);
test('single day', dailyTemperatures([40]), [0]);

Output


      
#150

Evaluate Reverse Polish Notation

Medium

Evaluate an expression written in postfix form, where each operator follows its two operands. Division truncates toward zero.

Hint 1

Postfix needs no brackets because the order is unambiguous. Operands wait for their operator.

Hint 2

Push numbers. On an operator, pop two, apply, push the result.

The approach

Order matters: the first value popped is the right operand. And JavaScript division produces a float, so truncate with Math.trunc — not Math.floor, which rounds the wrong way for negatives.

Solution and tests

Cost. O(n) time and space.

#150 Evaluate Reverse Polish Notation

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function evalRPN(tokens) {
  const stack = [];
  const apply = {
    '+': (a, b) => a + b,
    '-': (a, b) => a - b,
    '*': (a, b) => a * b,
    '/': (a, b) => Math.trunc(a / b),      // trunc, not floor: -7/2 is -3
  };

  for (const token of tokens) {
    if (token in apply) {
      const right = stack.pop();            // popped FIRST, so it is the right side
      const left = stack.pop();
      stack.push(apply[token](left, right));
    } else {
      stack.push(Number(token));
    }
  }
  return stack[0];
}

test('addition then multiply', evalRPN(['2', '1', '+', '3', '*']), 9);
test('division', evalRPN(['4', '13', '5', '/', '+']), 6);
test('subtraction order', evalRPN(['5', '3', '-']), 2);
test('negative truncation', evalRPN(['-7', '2', '/']), -3);
test('single number', evalRPN(['42']), 42);
test('longer', evalRPN(['10', '6', '9', '3', '+', '-11', '*', '/', '*', '17', '+', '5', '+']), 22);

Output


      
#227

Basic Calculator II

Medium

Evaluate a string of non-negative integers with + - * / and spaces, respecting normal precedence. Division truncates toward zero. No brackets.

Hint 1

Precedence means you cannot evaluate strictly left to right. But you can defer the additions.

Hint 2

Push each term onto a stack. When you meet * or /, apply it immediately to the top of the stack; when you meet + or -, just push the next number with the right sign.

The approach

Summing the stack at the end performs all the additions at once. Parse multi-digit numbers by accumulating value * 10 + digit while the character is a digit.

Solution and tests

Cost. O(n) time and space.

#227 Basic Calculator II

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function calculate(s) {
  const stack = [];
  let number = 0, operator = '+';

  for (let i = 0; i < s.length; i++) {
    const ch = s[i];
    if (ch >= '0' && ch <= '9') number = number * 10 + Number(ch);   // multi-digit

    // Act on an operator, or at the very end of the string.
    if ((ch < '0' && ch !== ' ') || i === s.length - 1) {
      if (operator === '+') stack.push(number);
      else if (operator === '-') stack.push(-number);
      else if (operator === '*') stack.push(stack.pop() * number);
      else stack.push(Math.trunc(stack.pop() / number));

      operator = ch;                        // remember it for the next term
      number = 0;
    }
  }
  return stack.reduce((a, b) => a + b, 0);   // the deferred additions
}

test('precedence', calculate('3+2*2'), 7);
test('integer division', calculate(' 3/2 '), 1);
test('mixed', calculate(' 3+5 / 2 '), 5);
test('multi-digit', calculate('12+34'), 46);
test('single number', calculate('42'), 42);
test('subtraction chain', calculate('10-2-3'), 5);
test('multiply chain', calculate('2*3*4'), 24);

Output


      
#84

Largest Rectangle in Histogram

Hard

Given bar heights of equal width, find the area of the largest rectangle that fits entirely inside the histogram.

Hint 1

A rectangle is limited by its shortest bar. For each bar, how far left and right can it extend before hitting something shorter?

Hint 2

A stack with heights increasing from bottom to top. When a shorter bar arrives, everything taller is now bounded on the right.

The approach

When you pop a bar, its left boundary is whatever is beneath it on the stack, so the width is i - (stackTopBelow + 1). Append a sentinel height of 0 to flush the stack at the end instead of writing a second loop.

Solution and tests

Cost. O(n) time amortised, O(n) space.

#84 Largest Rectangle in Histogram

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function largestRectangleArea(heights) {
  const bars = [...heights, 0];            // sentinel 0 flushes the stack
  const stack = [];                         // indices, heights increasing
  let best = 0;

  for (let i = 0; i < bars.length; i++) {
    while (stack.length && bars[stack[stack.length - 1]] >= bars[i]) {
      const height = bars[stack.pop()];
      // Left boundary is whatever remains beneath, or the very start.
      const left = stack.length ? stack[stack.length - 1] + 1 : 0;
      best = Math.max(best, height * (i - left));
    }
    stack.push(i);
  }
  return best;
}

test('classic', largestRectangleArea([2, 1, 5, 6, 2, 3]), 10);
test('two bars', largestRectangleArea([2, 4]), 4);
test('single bar', largestRectangleArea([5]), 5);
test('flat', largestRectangleArea([3, 3, 3]), 9);
test('increasing', largestRectangleArea([1, 2, 3, 4]), 6);
test('decreasing', largestRectangleArea([4, 3, 2, 1]), 6);
test('a zero splits it', largestRectangleArea([2, 0, 2]), 2);

Output


      

Linked lists

8 problems · the pattern · Lesson 08

Four moves solve all of these: reverse a run, find a position with two pointers at different speeds, merge two sorted lists, and detect a cycle. The hard problems are combinations — reorder list is find the middle, reverse the second half, then interleave. Two habits prevent most bugs: a dummy head whenever the first node might change, and saving next before you overwrite it.

#206

Reverse Linked List

Easy

Reverse a singly linked list and return the new head.

Hint 1

You need three references at once. Which one disappears the instant you rewrite a pointer?

Hint 2

Hold prev, curr and next. Save curr.next before overwriting it, or the rest of the list is unreachable.

The approach

When the loop ends, curr is null and prev is the old tail, which is the new head. Return prev, not curr.

Solution and tests

Cost. O(n) time, O(1) space.

#206 Reverse Linked List

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function ListNode(val, next = null) { this.val = val; this.next = next; }
const toList = (values) => {
  const dummy = new ListNode(0);
  let tail = dummy;
  for (const v of values) { tail.next = new ListNode(v); tail = tail.next; }
  return dummy.next;
};
const toArray = (head) => { const out = []; for (let n = head; n; n = n.next) out.push(n.val); return out; };

function reverseList(head) {
  let prev = null, curr = head;

  while (curr) {
    const next = curr.next;      // save it FIRST, or lose the rest of the list
    curr.next = prev;
    prev = curr;
    curr = next;
  }
  return prev;                    // the old tail
}

test('five nodes', toArray(reverseList(toList([1, 2, 3, 4, 5]))), [5, 4, 3, 2, 1]);
test('two nodes', toArray(reverseList(toList([1, 2]))), [2, 1]);
test('single node', toArray(reverseList(toList([1]))), [1]);
test('empty', toArray(reverseList(toList([]))), []);
test('duplicates', toArray(reverseList(toList([1, 1, 2]))), [2, 1, 1]);

Output


      
#21

Merge Two Sorted Lists

Easy

Given two sorted linked lists, splice them into one sorted list and return its head. Reuse the existing nodes.

Hint 1

Compare the two heads, take the smaller, advance that list. Repeat.

Hint 2

The awkward part is the first node, because you do not yet know which list it comes from.

The approach

A dummy head removes that entirely: build onto dummy and return dummy.next. When one list runs out, attach the other whole — there is no need to keep looping.

Solution and tests

Cost. O(m + n) time, O(1) space.

#21 Merge Two Sorted Lists

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function ListNode(val, next = null) { this.val = val; this.next = next; }
const toList = (values) => {
  const dummy = new ListNode(0);
  let tail = dummy;
  for (const v of values) { tail.next = new ListNode(v); tail = tail.next; }
  return dummy.next;
};
const toArray = (head) => { const out = []; for (let n = head; n; n = n.next) out.push(n.val); return out; };

function mergeTwoLists(a, b) {
  const dummy = new ListNode(0);       // removes the "which list starts it" case
  let tail = dummy;

  while (a && b) {
    if (a.val <= b.val) { tail.next = a; a = a.next; }
    else { tail.next = b; b = b.next; }
    tail = tail.next;
  }
  tail.next = a || b;                   // attach whatever remains, in one step
  return dummy.next;
}

test('interleaved', toArray(mergeTwoLists(toList([1, 2, 4]), toList([1, 3, 4]))), [1, 1, 2, 3, 4, 4]);
test('both empty', toArray(mergeTwoLists(toList([]), toList([]))), []);
test('one empty', toArray(mergeTwoLists(toList([]), toList([0]))), [0]);
test('no overlap', toArray(mergeTwoLists(toList([1, 2]), toList([8, 9]))), [1, 2, 8, 9]);
test('one much longer', toArray(mergeTwoLists(toList([5]), toList([1, 2, 3]))), [1, 2, 3, 5]);

Output


      
#141

Linked List Cycle

Easy

Decide whether a linked list contains a cycle, using constant extra space.

Hint 1

A Set of visited nodes answers it in O(n) space. The constant-space version needs a different idea.

Hint 2

Two pointers moving at different speeds. On a circular track, a faster runner laps a slower one.

The approach

The fast pointer gains exactly one position per step, so if there is a loop it cannot skip past the slow pointer — it must land on it. Guard both fast and fast.next before advancing twice.

Solution and tests

Cost. O(n) time, O(1) space.

#141 Linked List Cycle

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function ListNode(val, next = null) { this.val = val; this.next = next; }
const toList = (values) => {
  const dummy = new ListNode(0);
  let tail = dummy;
  for (const v of values) { tail.next = new ListNode(v); tail = tail.next; }
  return dummy.next;
};
const toArray = (head) => { const out = []; for (let n = head; n; n = n.next) out.push(n.val); return out; };

function hasCycle(head) {
  let slow = head, fast = head;

  while (fast && fast.next) {          // both guards needed before a double step
    slow = slow.next;
    fast = fast.next.next;
    if (slow === fast) return true;     // fast gains 1 per step, so it must land
  }
  return false;                          // fast reached the end: no loop
}

const looped = toList([3, 2, 0, -4]);
looped.next.next.next.next = looped.next;      // tail points back to index 1
test('has a cycle', hasCycle(looped), true);

const twoLoop = toList([1, 2]);
twoLoop.next.next = twoLoop;
test('two-node cycle', hasCycle(twoLoop), true);

const selfLoop = toList([1]);
selfLoop.next = selfLoop;
test('self loop', hasCycle(selfLoop), true);

test('no cycle', hasCycle(toList([1, 2, 3])), false);
test('single node', hasCycle(toList([1])), false);
test('empty', hasCycle(toList([])), false);

Output


      
#234

Palindrome Linked List

Easy

Decide whether the values in a linked list read the same forwards and backwards.

Hint 1

Copying to an array and using two pointers is O(n) space and is a fine answer to state.

Hint 2

For O(1) space: find the middle, reverse the second half, compare the halves.

The approach

Compare only until the shorter half runs out, so an odd-length list's middle element is naturally ignored.

Solution and tests

Cost. O(n) time, O(1) space.

#234 Palindrome Linked List

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function ListNode(val, next = null) { this.val = val; this.next = next; }
const toList = (values) => {
  const dummy = new ListNode(0);
  let tail = dummy;
  for (const v of values) { tail.next = new ListNode(v); tail = tail.next; }
  return dummy.next;
};
const toArray = (head) => { const out = []; for (let n = head; n; n = n.next) out.push(n.val); return out; };

function isPalindrome(head) {
  if (!head || !head.next) return true;

  // Find the middle.
  let slow = head, fast = head;
  while (fast.next && fast.next.next) { slow = slow.next; fast = fast.next.next; }

  // Reverse the second half.
  let prev = null, curr = slow.next;
  while (curr) {
    const next = curr.next;
    curr.next = prev;
    prev = curr;
    curr = next;
  }

  // Compare. The shorter half ends first, so an odd middle is ignored.
  let front = head, back = prev;
  while (back) {
    if (front.val !== back.val) return false;
    front = front.next;
    back = back.next;
  }
  return true;
}

test('even palindrome', isPalindrome(toList([1, 2, 2, 1])), true);
test('not a palindrome', isPalindrome(toList([1, 2])), false);
test('odd palindrome', isPalindrome(toList([1, 2, 1])), true);
test('single node', isPalindrome(toList([1])), true);
test('all equal', isPalindrome(toList([7, 7, 7, 7])), true);
test('near miss', isPalindrome(toList([1, 2, 3, 2, 2])), false);

Output


      
#19

Remove Nth Node From End of List

Medium

Remove the nth node counting from the end, and return the head. Do it in one pass.

Hint 1

Counting the length first, then walking again, is two passes and perfectly valid. One pass needs a gap.

Hint 2

Advance one pointer n steps ahead, then move both together. When the leader reaches the end, the follower is at the node before the target.

The approach

Removing the head is the edge case. A dummy node in front of the list makes it disappear, because then even the head has a predecessor.

Solution and tests

Cost. O(n) time, O(1) space, one pass.

#19 Remove Nth Node From End of List

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function ListNode(val, next = null) { this.val = val; this.next = next; }
const toList = (values) => {
  const dummy = new ListNode(0);
  let tail = dummy;
  for (const v of values) { tail.next = new ListNode(v); tail = tail.next; }
  return dummy.next;
};
const toArray = (head) => { const out = []; for (let n = head; n; n = n.next) out.push(n.val); return out; };

function removeNthFromEnd(head, n) {
  const dummy = new ListNode(0, head);   // so the head has a predecessor too
  let lead = dummy, trail = dummy;

  for (let i = 0; i < n; i++) lead = lead.next;   // open a gap of n

  while (lead.next) { lead = lead.next; trail = trail.next; }

  trail.next = trail.next.next;           // trail sits just before the target
  return dummy.next;
}

test('remove the second from last', toArray(removeNthFromEnd(toList([1, 2, 3, 4, 5]), 2)), [1, 2, 3, 5]);
test('remove the only node', toArray(removeNthFromEnd(toList([1]), 1)), []);
test('remove the head', toArray(removeNthFromEnd(toList([1, 2]), 2)), [2]);
test('remove the tail', toArray(removeNthFromEnd(toList([1, 2]), 1)), [1]);
test('longer list', toArray(removeNthFromEnd(toList([1, 2, 3, 4, 5]), 5)), [2, 3, 4, 5]);

Output


      
#143

Reorder List

Medium

Rearrange a list so it alternates first node, last node, second node, second-to-last, and so on. Do it in place.

Hint 1

Three separate techniques you already know, applied in sequence.

Hint 2

Find the middle with slow and fast pointers. Reverse the second half. Then weave the two halves together.

The approach

Cut the list at the middle before reversing, or the halves stay connected and the weave produces a cycle. Test with both an odd and an even length.

Solution and tests

Cost. O(n) time, O(1) space.

#143 Reorder List

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function ListNode(val, next = null) { this.val = val; this.next = next; }
const toList = (values) => {
  const dummy = new ListNode(0);
  let tail = dummy;
  for (const v of values) { tail.next = new ListNode(v); tail = tail.next; }
  return dummy.next;
};
const toArray = (head) => { const out = []; for (let n = head; n; n = n.next) out.push(n.val); return out; };

function reorderList(head) {
  if (!head || !head.next) return head;

  // 1. Find the middle. For even lengths this leaves slow at the end of half one.
  let slow = head, fast = head;
  while (fast.next && fast.next.next) { slow = slow.next; fast = fast.next.next; }

  // 2. Cut, then reverse the second half.
  let second = slow.next;
  slow.next = null;                       // the cut: without it you build a cycle
  let prev = null;
  while (second) {
    const next = second.next;
    second.next = prev;
    prev = second;
    second = next;
  }

  // 3. Weave the two halves.
  let first = head, back = prev;
  while (back) {
    const nextFirst = first.next, nextBack = back.next;
    first.next = back;
    back.next = nextFirst;
    first = nextFirst;
    back = nextBack;
  }
  return head;
}

test('even length', toArray(reorderList(toList([1, 2, 3, 4]))), [1, 4, 2, 3]);
test('odd length', toArray(reorderList(toList([1, 2, 3, 4, 5]))), [1, 5, 2, 4, 3]);
test('two nodes', toArray(reorderList(toList([1, 2]))), [1, 2]);
test('single node', toArray(reorderList(toList([1]))), [1]);
test('three nodes', toArray(reorderList(toList([1, 2, 3]))), [1, 3, 2]);

Output


      
#2

Add Two Numbers

Medium

Two numbers are stored as linked lists with the least significant digit first. Add them and return the sum in the same form.

Hint 1

Least-significant-first is convenient: you can add left to right, exactly as you would on paper.

Hint 2

Carry the tens digit forward. Keep looping while either list has nodes or a carry remains.

The approach

The final carry is easy to forget — [9] + [1] must produce two nodes. Putting the carry in the loop condition handles it without a special case.

Solution and tests

Cost. O(max(m, n)) time and space.

#2 Add Two Numbers

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function ListNode(val, next = null) { this.val = val; this.next = next; }
const toList = (values) => {
  const dummy = new ListNode(0);
  let tail = dummy;
  for (const v of values) { tail.next = new ListNode(v); tail = tail.next; }
  return dummy.next;
};
const toArray = (head) => { const out = []; for (let n = head; n; n = n.next) out.push(n.val); return out; };

function addTwoNumbers(a, b) {
  const dummy = new ListNode(0);
  let tail = dummy, carry = 0;

  // The carry belongs in the condition, or 9 + 1 loses its leading digit.
  while (a || b || carry) {
    const sum = (a ? a.val : 0) + (b ? b.val : 0) + carry;
    carry = Math.floor(sum / 10);
    tail.next = new ListNode(sum % 10);
    tail = tail.next;
    if (a) a = a.next;
    if (b) b = b.next;
  }
  return dummy.next;
}

test('342 + 465', toArray(addTwoNumbers(toList([2, 4, 3]), toList([5, 6, 4]))), [7, 0, 8]);
test('zero plus zero', toArray(addTwoNumbers(toList([0]), toList([0]))), [0]);
test('carry cascades', toArray(addTwoNumbers(toList([9, 9, 9]), toList([1]))), [0, 0, 0, 1]);
test('final carry', toArray(addTwoNumbers(toList([9]), toList([1]))), [0, 1]);
test('different lengths', toArray(addTwoNumbers(toList([1]), toList([9, 9]))), [0, 0, 1]);

Output


      
#23

Merge k Sorted Lists

Hard

Given an array of sorted linked lists, merge them all into one sorted list.

Hint 1

Merging them one at a time into an accumulator is O(kn), because early nodes get copied through every merge.

Hint 2

Two better approaches: pair them up and merge in rounds, or keep a heap of the current heads.

The approach

Pairwise merging halves the number of lists each round, giving log k rounds over n total nodes. It needs no heap, which makes it the easier one to get right under pressure.

Solution and tests

Cost. O(n log k) time where n is the total node count. O(1) extra space for the pairwise version.

#23 Merge k Sorted Lists

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function ListNode(val, next = null) { this.val = val; this.next = next; }
const toList = (values) => {
  const dummy = new ListNode(0);
  let tail = dummy;
  for (const v of values) { tail.next = new ListNode(v); tail = tail.next; }
  return dummy.next;
};
const toArray = (head) => { const out = []; for (let n = head; n; n = n.next) out.push(n.val); return out; };

function mergeTwo(a, b) {
  const dummy = new ListNode(0);
  let tail = dummy;
  while (a && b) {
    if (a.val <= b.val) { tail.next = a; a = a.next; }
    else { tail.next = b; b = b.next; }
    tail = tail.next;
  }
  tail.next = a || b;
  return dummy.next;
}

function mergeKLists(lists) {
  if (lists.length === 0) return null;

  // Merge in pairs, halving the count each round: log k rounds.
  let current = lists;
  while (current.length > 1) {
    const next = [];
    for (let i = 0; i < current.length; i += 2) {
      next.push(mergeTwo(current[i], current[i + 1] ?? null));
    }
    current = next;
  }
  return current[0];
}

test('three lists', toArray(mergeKLists([toList([1, 4, 5]), toList([1, 3, 4]), toList([2, 6])])),
  [1, 1, 2, 3, 4, 4, 5, 6]);
test('no lists', toArray(mergeKLists([])), []);
test('one empty list', toArray(mergeKLists([toList([])])), []);
test('odd count', toArray(mergeKLists([toList([1]), toList([2]), toList([3])])), [1, 2, 3]);
test('some empty', toArray(mergeKLists([toList([]), toList([1]), toList([])])), [1]);
test('single list', toArray(mergeKLists([toList([2, 4])])), [2, 4]);

Output


      

Trees and binary search trees

10 problems · the pattern · Lessons 14, 15

Nearly all of these are one recursive function. The shape that recurs, and the one worth internalising, is post-order with an accumulator: recurse into both children, return something useful to the parent, and update an answer held outside the recursion. Height, diameter, balance and maximum path sum are all that same function with different bookkeeping.

#104

Maximum Depth of Binary Tree

Easy

Find the number of nodes along the longest path from the root down to a leaf.

Hint 1

The depth of a tree is one more than the depth of its deeper subtree.

Hint 2

The base case is the empty tree, which has depth 0.

The approach

That is the whole function: 1 + Math.max(left, right), with 0 for null. Every other tree problem in this group is a variation on this three-line shape.

Solution and tests

Cost. O(n) time, O(h) space for the call stack.

#104 Maximum Depth of Binary Tree

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function TreeNode(val, left = null, right = null) { this.val = val; this.left = left; this.right = right; }
// Build from a level-order array, using null for a missing child.
const buildTree = (values) => {
  if (!values.length || values[0] === null) return null;
  const root = new TreeNode(values[0]);
  const queue = [root];
  let i = 1, head = 0;
  while (i < values.length) {
    const node = queue[head++];
    if (i < values.length) { if (values[i] !== null) { node.left = new TreeNode(values[i]); queue.push(node.left); } i++; }
    if (i < values.length) { if (values[i] !== null) { node.right = new TreeNode(values[i]); queue.push(node.right); } i++; }
  }
  return root;
};

function maxDepth(root) {
  if (!root) return 0;                                   // empty tree
  return 1 + Math.max(maxDepth(root.left), maxDepth(root.right));
}

test('balanced-ish', maxDepth(buildTree([3, 9, 20, null, null, 15, 7])), 3);
test('leaning', maxDepth(buildTree([1, null, 2])), 2);
test('single node', maxDepth(buildTree([1])), 1);
test('empty', maxDepth(buildTree([])), 0);
test('full three levels', maxDepth(buildTree([1, 2, 3, 4, 5, 6, 7])), 3);

Output


      
#226

Invert Binary Tree

Easy

Mirror a binary tree: every node's left and right children swap, all the way down.

Hint 1

Swapping one node's children is easy. The recursion handles the rest.

Hint 2

Swap at the current node, then invert both subtrees. Or invert both first, then swap — either order works.

The approach

The base case is null, and returning it rather than throwing is what lets the recursive calls stay unguarded.

Solution and tests

Cost. O(n) time, O(h) space.

#226 Invert Binary Tree

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function TreeNode(val, left = null, right = null) { this.val = val; this.left = left; this.right = right; }
// Build from a level-order array, using null for a missing child.
const buildTree = (values) => {
  if (!values.length || values[0] === null) return null;
  const root = new TreeNode(values[0]);
  const queue = [root];
  let i = 1, head = 0;
  while (i < values.length) {
    const node = queue[head++];
    if (i < values.length) { if (values[i] !== null) { node.left = new TreeNode(values[i]); queue.push(node.left); } i++; }
    if (i < values.length) { if (values[i] !== null) { node.right = new TreeNode(values[i]); queue.push(node.right); } i++; }
  }
  return root;
};

function invertTree(root) {
  if (!root) return null;

  [root.left, root.right] = [root.right, root.left];     // swap here
  invertTree(root.left);                                  // then recurse
  invertTree(root.right);
  return root;
}

// Read back as a level-order array so the test is readable.
const levels = (root) => {
  if (!root) return [];
  const out = [], queue = [root];
  for (let i = 0; i < queue.length; i++) {
    const node = queue[i];
    out.push(node.val);
    if (node.left) queue.push(node.left);
    if (node.right) queue.push(node.right);
  }
  return out;
};

test('classic', levels(invertTree(buildTree([4, 2, 7, 1, 3, 6, 9]))), [4, 7, 2, 9, 6, 3, 1]);
test('three nodes', levels(invertTree(buildTree([2, 1, 3]))), [2, 3, 1]);
test('empty', levels(invertTree(buildTree([]))), []);
test('single', levels(invertTree(buildTree([1]))), [1]);
test('one-sided', levels(invertTree(buildTree([1, 2]))), [1, 2]);

Output


      
#100

Same Tree

Easy

Decide whether two binary trees have identical structure and identical values.

Hint 1

Compare the roots, then compare the subtrees pairwise.

Hint 2

Three base cases: both null is true, one null is false, different values is false.

The approach

Order the checks so the null cases come first, otherwise you dereference a null when comparing values.

Solution and tests

Cost. O(n) time, O(h) space.

#100 Same Tree

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function TreeNode(val, left = null, right = null) { this.val = val; this.left = left; this.right = right; }
// Build from a level-order array, using null for a missing child.
const buildTree = (values) => {
  if (!values.length || values[0] === null) return null;
  const root = new TreeNode(values[0]);
  const queue = [root];
  let i = 1, head = 0;
  while (i < values.length) {
    const node = queue[head++];
    if (i < values.length) { if (values[i] !== null) { node.left = new TreeNode(values[i]); queue.push(node.left); } i++; }
    if (i < values.length) { if (values[i] !== null) { node.right = new TreeNode(values[i]); queue.push(node.right); } i++; }
  }
  return root;
};

function isSameTree(a, b) {
  if (!a && !b) return true;               // both ended together
  if (!a || !b) return false;              // one ended early
  if (a.val !== b.val) return false;       // safe now: both exist

  return isSameTree(a.left, b.left) && isSameTree(a.right, b.right);
}

test('identical', isSameTree(buildTree([1, 2, 3]), buildTree([1, 2, 3])), true);
test('mirrored, not equal', isSameTree(buildTree([1, 2]), buildTree([1, null, 2])), false);
test('different values', isSameTree(buildTree([1, 2, 1]), buildTree([1, 1, 2])), false);
test('both empty', isSameTree(buildTree([]), buildTree([])), true);
test('one empty', isSameTree(buildTree([1]), buildTree([])), false);

Output


      
#110

Balanced Binary Tree

Easy

Decide whether a tree is height-balanced: at every node, the two subtree heights differ by at most one.

Hint 1

Computing the height at every node separately is O(n²), because heights get recomputed all the way down.

Hint 2

One post-order pass can return the height and report imbalance at the same time.

The approach

Return a sentinel — -1 works — to mean "already unbalanced below here", and propagate it up immediately. That short-circuits the rest of the traversal.

Solution and tests

Cost. O(n) time in one pass, O(h) space.

#110 Balanced Binary Tree

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function TreeNode(val, left = null, right = null) { this.val = val; this.left = left; this.right = right; }
// Build from a level-order array, using null for a missing child.
const buildTree = (values) => {
  if (!values.length || values[0] === null) return null;
  const root = new TreeNode(values[0]);
  const queue = [root];
  let i = 1, head = 0;
  while (i < values.length) {
    const node = queue[head++];
    if (i < values.length) { if (values[i] !== null) { node.left = new TreeNode(values[i]); queue.push(node.left); } i++; }
    if (i < values.length) { if (values[i] !== null) { node.right = new TreeNode(values[i]); queue.push(node.right); } i++; }
  }
  return root;
};

function isBalanced(root) {
  // Returns the height, or -1 meaning "unbalanced somewhere below".
  function height(node) {
    if (!node) return 0;

    const left = height(node.left);
    if (left === -1) return -1;             // propagate the failure upward
    const right = height(node.right);
    if (right === -1) return -1;

    if (Math.abs(left - right) > 1) return -1;
    return 1 + Math.max(left, right);
  }

  return height(root) !== -1;
}

test('balanced', isBalanced(buildTree([3, 9, 20, null, null, 15, 7])), true);
test('unbalanced', isBalanced(buildTree([1, 2, 2, 3, 3, null, null, 4, 4])), false);
test('empty is balanced', isBalanced(buildTree([])), true);
test('single node', isBalanced(buildTree([1])), true);
test('a chain of three', isBalanced(buildTree([1, 2, null, 3])), false);
test('two levels', isBalanced(buildTree([1, 2, 3])), true);

Output


      
#543

Diameter of Binary Tree

Easy

Find the number of edges on the longest path between any two nodes. The path need not pass through the root.

Hint 1

For each node, the longest path through it is the left height plus the right height.

Hint 2

So you need every node's height, and the best sum seen anywhere. Both come from one traversal.

The approach

The function returns the height to its parent while updating a diameter variable held outside it. Returning one value and accumulating another is the pattern to recognise.

Solution and tests

Cost. O(n) time, O(h) space.

#543 Diameter of Binary Tree

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function TreeNode(val, left = null, right = null) { this.val = val; this.left = left; this.right = right; }
// Build from a level-order array, using null for a missing child.
const buildTree = (values) => {
  if (!values.length || values[0] === null) return null;
  const root = new TreeNode(values[0]);
  const queue = [root];
  let i = 1, head = 0;
  while (i < values.length) {
    const node = queue[head++];
    if (i < values.length) { if (values[i] !== null) { node.left = new TreeNode(values[i]); queue.push(node.left); } i++; }
    if (i < values.length) { if (values[i] !== null) { node.right = new TreeNode(values[i]); queue.push(node.right); } i++; }
  }
  return root;
};

function diameterOfBinaryTree(root) {
  let best = 0;

  function height(node) {
    if (!node) return 0;
    const left = height(node.left);
    const right = height(node.right);

    best = Math.max(best, left + right);      // the path THROUGH this node
    return 1 + Math.max(left, right);          // the height, for the parent
  }

  height(root);
  return best;
}

test('classic', diameterOfBinaryTree(buildTree([1, 2, 3, 4, 5])), 3);
test('two nodes', diameterOfBinaryTree(buildTree([1, 2])), 1);
test('single node', diameterOfBinaryTree(buildTree([1])), 0);
test('empty', diameterOfBinaryTree(buildTree([])), 0);
test('deep on one side', diameterOfBinaryTree(buildTree([1, 2, null, 3, 4, 5, 6])), 3);

// Built by hand: the longest path is 7-5-3-2-4-6-8, six edges, and it never
// touches the root. Through the root would only be five.
const offCentre = new TreeNode(1,
  new TreeNode(2,
    new TreeNode(3, new TreeNode(5, new TreeNode(7), null), null),
    new TreeNode(4, null, new TreeNode(6, null, new TreeNode(8)))),
  new TreeNode(9));
test('longest path skips the root', diameterOfBinaryTree(offCentre), 6);

Output


      
#102

Binary Tree Level Order Traversal

Medium

Return the values level by level, each level as its own array, from the top down.

Hint 1

Depth-first traversal visits nodes in the wrong order for this. You want breadth-first.

Hint 2

A queue. But you also need to know where one level ends and the next begins.

The approach

Capture the queue length before processing a level; that count is exactly the size of the current level, because everything queued during it belongs to the next.

Solution and tests

Cost. O(n) time, O(width) space.

#102 Binary Tree Level Order Traversal

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function TreeNode(val, left = null, right = null) { this.val = val; this.left = left; this.right = right; }
// Build from a level-order array, using null for a missing child.
const buildTree = (values) => {
  if (!values.length || values[0] === null) return null;
  const root = new TreeNode(values[0]);
  const queue = [root];
  let i = 1, head = 0;
  while (i < values.length) {
    const node = queue[head++];
    if (i < values.length) { if (values[i] !== null) { node.left = new TreeNode(values[i]); queue.push(node.left); } i++; }
    if (i < values.length) { if (values[i] !== null) { node.right = new TreeNode(values[i]); queue.push(node.right); } i++; }
  }
  return root;
};

function levelOrder(root) {
  if (!root) return [];

  const out = [];
  let level = [root];

  while (level.length) {
    out.push(level.map(node => node.val));

    const next = [];
    for (const node of level) {
      if (node.left) next.push(node.left);
      if (node.right) next.push(node.right);
    }
    level = next;                             // swapping arrays keeps levels clean
  }
  return out;
}

test('three levels', levelOrder(buildTree([3, 9, 20, null, null, 15, 7])), [[3], [9, 20], [15, 7]]);
test('single node', levelOrder(buildTree([1])), [[1]]);
test('empty', levelOrder(buildTree([])), []);
test('left chain', levelOrder(buildTree([1, 2, null, 3])), [[1], [2], [3]]);
test('full tree', levelOrder(buildTree([1, 2, 3, 4, 5, 6, 7])), [[1], [2, 3], [4, 5, 6, 7]]);

Output


      
#98

Validate Binary Search Tree

Medium

Decide whether a binary tree is a valid BST: every value in a node's left subtree is smaller than it, every value on the right is larger, all the way down.

Hint 1

Checking only node.left.val < node.val is wrong. A node can satisfy its parent and still violate a grandparent.

Hint 2

Each node is constrained by a range inherited from all its ancestors, not just its parent.

The approach

Pass low and high down, narrowing one of them at each step. Alternatively, an in-order traversal of a valid BST is strictly increasing — check that instead.

Solution and tests

Cost. O(n) time, O(h) space.

#98 Validate Binary Search Tree

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function TreeNode(val, left = null, right = null) { this.val = val; this.left = left; this.right = right; }
// Build from a level-order array, using null for a missing child.
const buildTree = (values) => {
  if (!values.length || values[0] === null) return null;
  const root = new TreeNode(values[0]);
  const queue = [root];
  let i = 1, head = 0;
  while (i < values.length) {
    const node = queue[head++];
    if (i < values.length) { if (values[i] !== null) { node.left = new TreeNode(values[i]); queue.push(node.left); } i++; }
    if (i < values.length) { if (values[i] !== null) { node.right = new TreeNode(values[i]); queue.push(node.right); } i++; }
  }
  return root;
};

function isValidBST(root, low = -Infinity, high = Infinity) {
  if (!root) return true;

  // The range comes from every ancestor, not just the parent.
  if (root.val <= low || root.val >= high) return false;

  return isValidBST(root.left, low, root.val) &&
         isValidBST(root.right, root.val, high);
}

test('valid', isValidBST(buildTree([2, 1, 3])), true);
test('grandparent violated', isValidBST(buildTree([5, 1, 4, null, null, 3, 6])), false);
test('single node', isValidBST(buildTree([1])), true);
test('empty', isValidBST(buildTree([])), true);
test('duplicate is invalid', isValidBST(buildTree([2, 2])), false);
test('deep valid tree', isValidBST(buildTree([8, 4, 12, 2, 6, 10, 14])), true);
test('subtly wrong', isValidBST(buildTree([10, 5, 15, null, null, 6, 20])), false);

Output


      
#235

Lowest Common Ancestor of a BST

Medium

Given a BST and two of its nodes, find the deepest node that has both of them as descendants. A node counts as a descendant of itself.

Hint 1

The generic tree solution recurses into both subtrees. A BST lets you do much better.

Hint 2

If both values are smaller than the current node, the answer is entirely in the left subtree; if both are larger, it is in the right.

The approach

The first node where the two values fall on opposite sides — or where one equals the node — is the answer. No recursion needed at all, just a walk down.

Solution and tests

Cost. O(h) time — O(log n) on a balanced tree — and O(1) space iteratively.

#235 Lowest Common Ancestor of a BST

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function TreeNode(val, left = null, right = null) { this.val = val; this.left = left; this.right = right; }
// Build from a level-order array, using null for a missing child.
const buildTree = (values) => {
  if (!values.length || values[0] === null) return null;
  const root = new TreeNode(values[0]);
  const queue = [root];
  let i = 1, head = 0;
  while (i < values.length) {
    const node = queue[head++];
    if (i < values.length) { if (values[i] !== null) { node.left = new TreeNode(values[i]); queue.push(node.left); } i++; }
    if (i < values.length) { if (values[i] !== null) { node.right = new TreeNode(values[i]); queue.push(node.right); } i++; }
  }
  return root;
};

function lowestCommonAncestor(root, p, q) {
  let node = root;

  while (node) {
    if (p < node.val && q < node.val) node = node.left;        // both smaller
    else if (p > node.val && q > node.val) node = node.right;  // both larger
    else return node.val;                                       // they split here
  }
  return null;
}

const bst = buildTree([6, 2, 8, 0, 4, 7, 9, null, null, 3, 5]);
test('split at the root', lowestCommonAncestor(bst, 2, 8), 6);
test('one is the ancestor', lowestCommonAncestor(bst, 2, 4), 2);
test('deep pair', lowestCommonAncestor(bst, 3, 5), 4);
test('both on the right', lowestCommonAncestor(bst, 7, 9), 8);
test('same node twice', lowestCommonAncestor(bst, 4, 4), 4);

const small = buildTree([2, 1]);
test('two-node tree', lowestCommonAncestor(small, 1, 2), 2);

Output


      
#230

Kth Smallest Element in a BST

Medium

Find the kth smallest value in a binary search tree, counting from 1.

Hint 1

What traversal order visits a BST's values in sorted order?

Hint 2

In-order: left subtree, node, right subtree. So you want the kth node that traversal reaches.

The approach

Count as you go and stop early rather than collecting everything. An explicit stack makes the early exit clean, and it is what you would want if the tree were large.

Solution and tests

Cost. O(h + k) time with the early exit, O(h) space.

#230 Kth Smallest Element in a BST

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function TreeNode(val, left = null, right = null) { this.val = val; this.left = left; this.right = right; }
// Build from a level-order array, using null for a missing child.
const buildTree = (values) => {
  if (!values.length || values[0] === null) return null;
  const root = new TreeNode(values[0]);
  const queue = [root];
  let i = 1, head = 0;
  while (i < values.length) {
    const node = queue[head++];
    if (i < values.length) { if (values[i] !== null) { node.left = new TreeNode(values[i]); queue.push(node.left); } i++; }
    if (i < values.length) { if (values[i] !== null) { node.right = new TreeNode(values[i]); queue.push(node.right); } i++; }
  }
  return root;
};

function kthSmallest(root, k) {
  const stack = [];
  let node = root;

  while (node || stack.length) {
    while (node) { stack.push(node); node = node.left; }   // dive left

    node = stack.pop();                                     // visit
    if (--k === 0) return node.val;                         // stop as soon as found

    node = node.right;                                      // then go right
  }
  return -1;
}

test('first smallest', kthSmallest(buildTree([3, 1, 4, null, 2]), 1), 1);
test('third smallest', kthSmallest(buildTree([5, 3, 6, 2, 4, null, null, 1]), 3), 3);
test('single node', kthSmallest(buildTree([1]), 1), 1);
test('largest', kthSmallest(buildTree([2, 1, 3]), 3), 3);
test('middle', kthSmallest(buildTree([2, 1, 3]), 2), 2);
test('k beyond the tree', kthSmallest(buildTree([1]), 5), -1);

Output


      
#124

Binary Tree Maximum Path Sum

Hard

Find the largest sum along any path in the tree. A path is any sequence of connected nodes and does not have to include the root, but it may not branch.

Hint 1

Same structure as diameter: at each node, consider the path that turns there — left contribution plus node plus right contribution.

Hint 2

What you return upward is different from what you record. A parent can only use a straight path, so return the better single side.

The approach

Negative subtree sums should be discarded: clamp each contribution with Math.max(0, ...). Since a single node counts as a path, the answer for an all-negative tree is its largest single value.

Solution and tests

Cost. O(n) time, O(h) space.

#124 Binary Tree Maximum Path Sum

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// Helpers, so each panel stands alone.
function TreeNode(val, left = null, right = null) { this.val = val; this.left = left; this.right = right; }
// Build from a level-order array, using null for a missing child.
const buildTree = (values) => {
  if (!values.length || values[0] === null) return null;
  const root = new TreeNode(values[0]);
  const queue = [root];
  let i = 1, head = 0;
  while (i < values.length) {
    const node = queue[head++];
    if (i < values.length) { if (values[i] !== null) { node.left = new TreeNode(values[i]); queue.push(node.left); } i++; }
    if (i < values.length) { if (values[i] !== null) { node.right = new TreeNode(values[i]); queue.push(node.right); } i++; }
  }
  return root;
};

function maxPathSum(root) {
  let best = -Infinity;

  // Returns the best downward path starting at this node.
  function contribution(node) {
    if (!node) return 0;

    // Clamp at 0: a negative branch is worse than not going that way.
    const left = Math.max(0, contribution(node.left));
    const right = Math.max(0, contribution(node.right));

    best = Math.max(best, node.val + left + right);   // the path turning here
    return node.val + Math.max(left, right);           // straight path, for the parent
  }

  contribution(root);
  return best;
}

test('simple', maxPathSum(buildTree([1, 2, 3])), 6);
test('negative root', maxPathSum(buildTree([-10, 9, 20, null, null, 15, 7])), 42);
test('single node', maxPathSum(buildTree([5])), 5);
test('all negative', maxPathSum(buildTree([-3])), -3);
test('negative children', maxPathSum(buildTree([2, -1, -2])), 2);
test('two nodes', maxPathSum(buildTree([-2, 1])), 1);

Output


      

Heaps and top-k

5 problems · the pattern · Lesson 16

The counter-intuitive rule to internalise: to find the k largest things, keep a min-heap of size k. The weakest of your current best k sits on top, so testing a newcomer costs nothing and evicting costs O(log k). Since JavaScript ships no priority queue, the heap itself is part of the answer — write it enough times that it costs you no thought.

#1046

Last Stone Weight

Easy

Repeatedly take the two heaviest stones and smash them: if equal both are destroyed, otherwise the difference remains. Return the weight of the last stone, or 0 if none is left.

Hint 1

You need the two largest values, repeatedly, from a changing collection. Re-sorting each round is O(n² log n).

Hint 2

A max-heap gives both in O(log n) each.

The approach

Invert the comparator to turn a min-heap into a max-heap. Push the difference back only when it is non-zero.

Solution and tests

Cost. O(n log n) time, O(n) space.

#1046 Last Stone Weight

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// A binary min-heap. JavaScript has no built-in priority queue.
class Heap {
  constructor(compare = (a, b) => a - b) { this.a = []; this.compare = compare; }
  get size() { return this.a.length; }
  peek() { return this.a[0]; }
  push(v) {
    this.a.push(v);
    let i = this.a.length - 1;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.compare(this.a[p], this.a[i]) <= 0) break;
      [this.a[p], this.a[i]] = [this.a[i], this.a[p]];
      i = p;
    }
  }
  pop() {
    const top = this.a[0], last = this.a.pop();
    if (this.a.length) {
      this.a[0] = last;
      let i = 0;
      for (;;) {
        const l = 2 * i + 1, r = l + 1;
        let best = i;
        if (l < this.a.length && this.compare(this.a[l], this.a[best]) < 0) best = l;
        if (r < this.a.length && this.compare(this.a[r], this.a[best]) < 0) best = r;
        if (best === i) break;
        [this.a[best], this.a[i]] = [this.a[i], this.a[best]];
        i = best;
      }
    }
    return top;
  }
}

function lastStoneWeight(stones) {
  const heap = new Heap((a, b) => b - a);        // inverted: a max-heap
  for (const s of stones) heap.push(s);

  while (heap.size > 1) {
    const heaviest = heap.pop();
    const second = heap.pop();
    if (heaviest !== second) heap.push(heaviest - second);
  }
  return heap.size ? heap.peek() : 0;
}

test('classic', lastStoneWeight([2, 7, 4, 1, 8, 1]), 1);
test('single stone', lastStoneWeight([1]), 1);
test('pair destroyed', lastStoneWeight([2, 2]), 0);
test('unequal pair', lastStoneWeight([5, 3]), 2);
test('all identical, even count', lastStoneWeight([3, 3, 3, 3]), 0);
test('all identical, odd count', lastStoneWeight([3, 3, 3]), 3);

Output


      
#215

Kth Largest Element in an Array

Medium

Find the kth largest value in an unsorted array, counting duplicates as separate positions.

Hint 1

Sorting is O(n log n) and completely acceptable to offer first.

Hint 2

A min-heap capped at k is O(n log k), which is better when k is small.

The approach

After the scan, the heap holds exactly the k largest and its root is the smallest of those — which is the kth largest overall. Quickselect achieves O(n) on average and is worth naming.

Solution and tests

Cost. O(n log k) time, O(k) space. Quickselect averages O(n).

#215 Kth Largest Element in an Array

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// A binary min-heap. JavaScript has no built-in priority queue.
class Heap {
  constructor(compare = (a, b) => a - b) { this.a = []; this.compare = compare; }
  get size() { return this.a.length; }
  peek() { return this.a[0]; }
  push(v) {
    this.a.push(v);
    let i = this.a.length - 1;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.compare(this.a[p], this.a[i]) <= 0) break;
      [this.a[p], this.a[i]] = [this.a[i], this.a[p]];
      i = p;
    }
  }
  pop() {
    const top = this.a[0], last = this.a.pop();
    if (this.a.length) {
      this.a[0] = last;
      let i = 0;
      for (;;) {
        const l = 2 * i + 1, r = l + 1;
        let best = i;
        if (l < this.a.length && this.compare(this.a[l], this.a[best]) < 0) best = l;
        if (r < this.a.length && this.compare(this.a[r], this.a[best]) < 0) best = r;
        if (best === i) break;
        [this.a[best], this.a[i]] = [this.a[i], this.a[best]];
        i = best;
      }
    }
    return top;
  }
}

function findKthLargest(nums, k) {
  const heap = new Heap();                    // min-heap of the best k so far

  for (const n of nums) {
    heap.push(n);
    if (heap.size > k) heap.pop();            // evict the weakest survivor
  }
  return heap.peek();                          // smallest of the top k
}

test('classic', findKthLargest([3, 2, 1, 5, 6, 4], 2), 5);
test('with duplicates', findKthLargest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4), 4);
test('largest', findKthLargest([1, 2, 3], 1), 3);
test('smallest', findKthLargest([1, 2, 3], 3), 1);
test('single element', findKthLargest([7], 1), 7);
test('all identical', findKthLargest([2, 2, 2], 2), 2);

Output


      
#973

K Closest Points to Origin

Medium

Given points on a plane, return the k nearest to the origin. Any order is fine.

Hint 1

Distance from the origin is sqrt(x² + y²). Do you actually need the square root?

Hint 2

No — comparing squared distances gives the same ordering, and avoids the floating-point work entirely.

The approach

To keep the k closest, cap a max-heap at k, so the farthest of your current best sits on top and is the one to evict.

Solution and tests

Cost. O(n log k) time, O(k) space.

#973 K Closest Points to Origin

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// A binary min-heap. JavaScript has no built-in priority queue.
class Heap {
  constructor(compare = (a, b) => a - b) { this.a = []; this.compare = compare; }
  get size() { return this.a.length; }
  peek() { return this.a[0]; }
  push(v) {
    this.a.push(v);
    let i = this.a.length - 1;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.compare(this.a[p], this.a[i]) <= 0) break;
      [this.a[p], this.a[i]] = [this.a[i], this.a[p]];
      i = p;
    }
  }
  pop() {
    const top = this.a[0], last = this.a.pop();
    if (this.a.length) {
      this.a[0] = last;
      let i = 0;
      for (;;) {
        const l = 2 * i + 1, r = l + 1;
        let best = i;
        if (l < this.a.length && this.compare(this.a[l], this.a[best]) < 0) best = l;
        if (r < this.a.length && this.compare(this.a[r], this.a[best]) < 0) best = r;
        if (best === i) break;
        [this.a[best], this.a[i]] = [this.a[i], this.a[best]];
        i = best;
      }
    }
    return top;
  }
}

function kClosest(points, k) {
  // No sqrt: comparing squares gives the same order.
  const distance = ([x, y]) => x * x + y * y;

  // Max-heap on distance, so the FARTHEST of the current best is on top.
  const heap = new Heap((a, b) => distance(b) - distance(a));

  for (const point of points) {
    heap.push(point);
    if (heap.size > k) heap.pop();            // drop the farthest
  }
  return heap.a;
}

const shape = (points) => [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);

test('one point', shape(kClosest([[1, 3], [-2, 2]], 1)), [[-2, 2]]);
test('two points', shape(kClosest([[3, 3], [5, -1], [-2, 4]], 2)), [[-2, 4], [3, 3]]);
test('k equals the count', shape(kClosest([[1, 1], [2, 2]], 2)), [[1, 1], [2, 2]]);
test('origin included', shape(kClosest([[0, 0], [9, 9]], 1)), [[0, 0]]);
test('single point', shape(kClosest([[4, 5]], 1)), [[4, 5]]);

Output


      
#621

Task Scheduler

Medium

Given task labels and a cooldown n, find the least time to run them all. Identical tasks must be separated by at least n intervals; idle slots are allowed and each task takes one interval.

Hint 1

The most frequent task is what forces the schedule. Everything else fills the gaps it leaves.

Hint 2

If the most frequent task appears m times, it creates m - 1 gaps of length n that need filling.

The approach

The frame is (m - 1) × (n + 1) plus the number of tasks tied for most frequent. But if there are enough distinct tasks to fill every gap, no idling is needed and the answer is simply the task count — so take the maximum of the two.

Solution and tests

Cost. O(n) time, O(1) space with a fixed alphabet. The heap simulation is O(total time × log k).

#621 Task Scheduler

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function leastInterval(tasks, n) {
  const counts = new Map();
  for (const t of tasks) counts.set(t, (counts.get(t) || 0) + 1);

  const most = Math.max(...counts.values());
  // How many tasks are tied at that maximum frequency?
  let tied = 0;
  for (const c of counts.values()) if (c === most) tied++;

  // The frame the most frequent task forces, plus its tied partners.
  const framed = (most - 1) * (n + 1) + tied;

  // If there are enough distinct tasks to fill every gap, nothing idles.
  return Math.max(framed, tasks.length);
}

test('cooldown 2', leastInterval(['A', 'A', 'A', 'B', 'B', 'B'], 2), 8);
test('no cooldown', leastInterval(['A', 'A', 'A', 'B', 'B', 'B'], 0), 6);
test('long cooldown', leastInterval(['A', 'A', 'A', 'B', 'B', 'B'], 3), 10);
test('single task', leastInterval(['A'], 5), 1);
test('all distinct', leastInterval(['A', 'B', 'C'], 2), 3);
test('enough filler, no idling', leastInterval(['A', 'A', 'B', 'C', 'D', 'E'], 1), 6);

Output


      
#295

Find Median from Data Stream

Hard

Support adding numbers one at a time and asking for the median of everything added so far.

Hint 1

Keeping a sorted array makes insertion O(n). You only ever need the middle, not the whole order.

Hint 2

Two heaps facing each other: a max-heap of the lower half, a min-heap of the upper half.

The approach

Always push into one heap and immediately move its top to the other, then rebalance so the sizes differ by at most one. That unconditional shuffle keeps the two halves correctly separated without any comparison logic.

Solution and tests

Cost. O(log n) per insertion, O(1) to read the median, O(n) space.

#295 Find Median from Data Stream

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

// A binary min-heap. JavaScript has no built-in priority queue.
class Heap {
  constructor(compare = (a, b) => a - b) { this.a = []; this.compare = compare; }
  get size() { return this.a.length; }
  peek() { return this.a[0]; }
  push(v) {
    this.a.push(v);
    let i = this.a.length - 1;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.compare(this.a[p], this.a[i]) <= 0) break;
      [this.a[p], this.a[i]] = [this.a[i], this.a[p]];
      i = p;
    }
  }
  pop() {
    const top = this.a[0], last = this.a.pop();
    if (this.a.length) {
      this.a[0] = last;
      let i = 0;
      for (;;) {
        const l = 2 * i + 1, r = l + 1;
        let best = i;
        if (l < this.a.length && this.compare(this.a[l], this.a[best]) < 0) best = l;
        if (r < this.a.length && this.compare(this.a[r], this.a[best]) < 0) best = r;
        if (best === i) break;
        [this.a[best], this.a[i]] = [this.a[i], this.a[best]];
        i = best;
      }
    }
    return top;
  }
}

class MedianFinder {
  constructor() {
    this.low = new Heap((a, b) => b - a);     // max-heap: the smaller half
    this.high = new Heap();                    // min-heap: the larger half
  }

  addNum(value) {
    // Push into low, then hand its largest to high. The unconditional move
    // guarantees every element in low is <= every element in high.
    this.low.push(value);
    this.high.push(this.low.pop());

    // Rebalance so low is never smaller than high.
    if (this.high.size > this.low.size) this.low.push(this.high.pop());
  }

  findMedian() {
    if (this.low.size > this.high.size) return this.low.peek();
    return (this.low.peek() + this.high.peek()) / 2;
  }
}

const m = new MedianFinder();
m.addNum(1);
test('one value', m.findMedian(), 1);
m.addNum(2);
test('two values', m.findMedian(), 1.5);
m.addNum(3);
test('three values', m.findMedian(), 2);

const m2 = new MedianFinder();
for (const v of [5, 15, 1, 3]) m2.addNum(v);
test('unsorted arrivals', m2.findMedian(), 4);

const m3 = new MedianFinder();
for (const v of [-1, -2, -3]) m3.addNum(v);
test('negatives', m3.findMedian(), -2);

const m4 = new MedianFinder();
for (const v of [2, 2, 2, 2]) m4.addNum(v);
test('all identical', m4.findMedian(), 2);

Output


      

Graphs and grids

9 problems · the pattern · Lessons 17, 18, 23

A grid is a graph in disguise: cells are nodes, the four offsets are edges. That means these split cleanly by what is being asked. Is it connected is DFS or union-find. How far is BFS, because only BFS visits in order of distance. What order is a topological sort. Choosing DFS for a shortest-path question is a substantive error, not a stylistic one.

#200

Number of Islands

Medium

A grid of land and water cells. Count the islands, where an island is land connected horizontally or vertically.

Hint 1

Walk every cell. When you find unvisited land, you have found a new island — then consume the whole connected blob so you do not count it again.

Hint 2

The consuming step is a flood fill, either DFS or BFS. Overwriting visited land with water is the simplest way to mark it.

The approach

Copy the grid first if you are not allowed to modify the input. Mention that trade-off out loud: mutating is O(1) space, a separate visited set is O(rows × cols).

Solution and tests

Cost. O(rows × cols) time. Space is O(rows × cols) worst case for the stack or queue.

#200 Number of Islands

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function numIslands(grid) {
  if (!grid.length) return 0;
  // Copy so the caller's grid is untouched.
  const g = grid.map(row => [...row]);
  const rows = g.length, cols = g[0].length;
  let islands = 0;

  function sink(r, c) {
    if (r < 0 || r >= rows || c < 0 || c >= cols) return;
    if (g[r][c] !== '1') return;             // water, or already sunk
    g[r][c] = '0';                            // mark BEFORE recursing
    sink(r + 1, c); sink(r - 1, c); sink(r, c + 1); sink(r, c - 1);
  }

  for (let r = 0; r < rows; r++)
    for (let c = 0; c < cols; c++)
      if (g[r][c] === '1') { islands++; sink(r, c); }

  return islands;
}

test('three islands', numIslands([
  ['1', '1', '0', '0', '0'],
  ['1', '1', '0', '0', '0'],
  ['0', '0', '1', '0', '0'],
  ['0', '0', '0', '1', '1'],
]), 3);
test('one big island', numIslands([['1', '1'], ['1', '1']]), 1);
test('all water', numIslands([['0', '0'], ['0', '0']]), 0);
test('single land cell', numIslands([['1']]), 1);
test('diagonal does not connect', numIslands([['1', '0'], ['0', '1']]), 2);
test('empty grid', numIslands([]), 0);

Output


      
#695

Max Area of Island

Medium

Same grid of land and water. Return the number of cells in the largest connected island, or 0 if there is none.

Hint 1

Identical traversal to counting islands. The difference is what the flood fill returns.

Hint 2

Have the recursion return the size of the blob it consumed.

The approach

The size is 1 + the four recursive calls, with 0 for water or out of bounds. That makes the base cases do all the work.

Solution and tests

Cost. O(rows × cols) time and space.

#695 Max Area of Island

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function maxAreaOfIsland(grid) {
  if (!grid.length) return 0;
  const g = grid.map(row => [...row]);
  const rows = g.length, cols = g[0].length;

  // Returns the size of the blob consumed from here.
  function measure(r, c) {
    if (r < 0 || r >= rows || c < 0 || c >= cols || g[r][c] !== 1) return 0;
    g[r][c] = 0;
    return 1 + measure(r + 1, c) + measure(r - 1, c)
             + measure(r, c + 1) + measure(r, c - 1);
  }

  let best = 0;
  for (let r = 0; r < rows; r++)
    for (let c = 0; c < cols; c++)
      if (g[r][c] === 1) best = Math.max(best, measure(r, c));

  return best;
}

// Two islands: three cells top-left, and five in the bottom-right block
// (the lone 1 at row 1 joins them via the cell directly below it).
test('largest of several', maxAreaOfIsland([
  [1, 1, 0, 0], [1, 0, 0, 1], [0, 0, 1, 1], [0, 0, 1, 1],
]), 5);
test('one cell', maxAreaOfIsland([[1]]), 1);
test('no land', maxAreaOfIsland([[0, 0], [0, 0]]), 0);
test('whole grid', maxAreaOfIsland([[1, 1], [1, 1]]), 4);
test('a long snake', maxAreaOfIsland([[1, 1, 1], [0, 0, 1], [1, 1, 1]]), 7);

Output


      
#994

Rotting Oranges

Medium

A grid holds empty cells, fresh oranges and rotten ones. Each minute, every rotten orange rots its four neighbours. Return the minutes until none are fresh, or -1 if some can never rot.

Hint 1

Everything spreads at the same time, so this is not one search per rotten orange.

Hint 2

Seed a queue with every rotten orange before the loop starts. That makes them all expand in lockstep.

The approach

Process one level per minute, and count the fresh oranges up front so you can tell at the end whether any were unreachable.

Solution and tests

Cost. O(rows × cols) time and space.

#994 Rotting Oranges

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function orangesRotting(grid) {
  const g = grid.map(row => [...row]);
  const rows = g.length, cols = g[0].length;
  const DIRS = [[-1, 0], [1, 0], [0, -1], [0, 1]];

  const queue = [];
  let fresh = 0;
  // Seed with EVERY rotten orange: multi-source BFS.
  for (let r = 0; r < rows; r++)
    for (let c = 0; c < cols; c++) {
      if (g[r][c] === 2) queue.push([r, c]);
      else if (g[r][c] === 1) fresh++;
    }

  let minutes = 0, head = 0;
  while (head < queue.length && fresh > 0) {
    const levelSize = queue.length - head;    // one minute's worth
    minutes++;
    for (let i = 0; i < levelSize; i++) {
      const [r, c] = queue[head++];
      for (const [dr, dc] of DIRS) {
        const nr = r + dr, nc = c + dc;
        if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
        if (g[nr][nc] !== 1) continue;
        g[nr][nc] = 2;
        fresh--;
        queue.push([nr, nc]);
      }
    }
  }
  return fresh === 0 ? minutes : -1;
}

test('four minutes', orangesRotting([[2, 1, 1], [1, 1, 0], [0, 1, 1]]), 4);
test('one unreachable', orangesRotting([[2, 1, 1], [0, 1, 1], [1, 0, 1]]), -1);
test('nothing fresh', orangesRotting([[0, 2]]), 0);
test('no rotten source', orangesRotting([[1]]), -1);
test('empty cells only', orangesRotting([[0, 0]]), 0);
test('already all rotten', orangesRotting([[2, 2]]), 0);

Output


      
#133

Clone Graph

Medium

Given a reference to a node in a connected undirected graph, return a deep copy of the whole graph. Each node holds a value and a list of neighbours.

Hint 1

Naive recursion loops forever, because the graph has cycles.

Hint 2

Keep a map from original node to its copy. Consult it before creating anything.

The approach

Create the copy and record it in the map before recursing into neighbours. Doing it after means a cycle re-enters the same node and never terminates.

Solution and tests

Cost. O(V + E) time, O(V) space.

#133 Clone Graph

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function Node(val, neighbors = []) { this.val = val; this.neighbors = neighbors; }

function cloneGraph(node, copies = new Map()) {
  if (!node) return null;
  if (copies.has(node)) return copies.get(node);      // already cloned

  const copy = new Node(node.val);
  copies.set(node, copy);                              // record BEFORE recursing

  for (const neighbor of node.neighbors) {
    copy.neighbors.push(cloneGraph(neighbor, copies));
  }
  return copy;
}

// Build a square: 1-2-3-4-1
const a = new Node(1), b = new Node(2), c = new Node(3), d = new Node(4);
a.neighbors = [b, d]; b.neighbors = [a, c]; c.neighbors = [b, d]; d.neighbors = [a, c];

const cloned = cloneGraph(a);
test('root value', cloned.val, 1);
test('neighbour values', cloned.neighbors.map(n => n.val), [2, 4]);
test('it is a copy, not the original', cloned === a, false);
test('neighbours are copies too', cloned.neighbors[0] === b, false);
test('the cycle is preserved', cloned.neighbors[0].neighbors[0] === cloned, true);
test('walks all the way round', cloned.neighbors[0].neighbors[1].neighbors[1].val, 4);
test('null input', cloneGraph(null), null);

const lone = new Node(7);
test('single node', cloneGraph(lone).val, 7);

Output


      
#207

Course Schedule

Medium

Given a number of courses and a list of prerequisite pairs, decide whether it is possible to finish them all. A pair [a, b] means b must be taken before a.

Hint 1

This asks whether a directed graph has a cycle. A cycle makes it impossible.

Hint 2

Kahn's algorithm: count prerequisites per course, start with those needing none, and release dependents as you go.

The approach

If you finish having emitted fewer courses than exist, the remainder are locked in a cycle. Get the edge direction right — it is a's prerequisite count that rises.

Solution and tests

Cost. O(V + E) time and space.

#207 Course Schedule

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function canFinish(numCourses, prerequisites) {
  const dependents = Array.from({ length: numCourses }, () => []);
  const remaining = new Array(numCourses).fill(0);

  // [a, b] means b must come before a, so the edge runs b -> a.
  for (const [a, b] of prerequisites) {
    dependents[b].push(a);
    remaining[a]++;
  }

  const queue = [];
  for (let c = 0; c < numCourses; c++) if (remaining[c] === 0) queue.push(c);

  let taken = 0;
  for (let head = 0; head < queue.length; head++) {
    taken++;
    for (const next of dependents[queue[head]]) {
      if (--remaining[next] === 0) queue.push(next);   // now unblocked
    }
  }
  return taken === numCourses;      // short of the total means a cycle
}

test('possible', canFinish(2, [[1, 0]]), true);
test('circular', canFinish(2, [[1, 0], [0, 1]]), false);
test('no prerequisites', canFinish(3, []), true);
test('chain', canFinish(4, [[1, 0], [2, 1], [3, 2]]), true);
test('diamond', canFinish(4, [[1, 0], [2, 0], [3, 1], [3, 2]]), true);
test('three-cycle', canFinish(3, [[0, 1], [1, 2], [2, 0]]), false);
test('single course', canFinish(1, []), true);

Output


      
#210

Course Schedule II

Medium

Same setup, but return an order in which every course can be taken. Return an empty array if it is impossible.

Hint 1

The same algorithm. The only change is what you return.

Hint 2

Collect the courses in the order they leave the queue.

The approach

That order is a valid topological sort. If its length is short of the total, return an empty array instead.

Solution and tests

Cost. O(V + E) time and space.

#210 Course Schedule II

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function findOrder(numCourses, prerequisites) {
  const dependents = Array.from({ length: numCourses }, () => []);
  const remaining = new Array(numCourses).fill(0);

  for (const [a, b] of prerequisites) {
    dependents[b].push(a);
    remaining[a]++;
  }

  const order = [];
  for (let c = 0; c < numCourses; c++) if (remaining[c] === 0) order.push(c);

  for (let head = 0; head < order.length; head++) {
    for (const next of dependents[order[head]]) {
      if (--remaining[next] === 0) order.push(next);
    }
  }
  return order.length === numCourses ? order : [];
}

test('single edge', findOrder(2, [[1, 0]]), [0, 1]);
test('no constraints', findOrder(2, []), [0, 1]);
test('chain', findOrder(4, [[1, 0], [2, 1], [3, 2]]), [0, 1, 2, 3]);
test('impossible', findOrder(2, [[0, 1], [1, 0]]), []);
test('one course', findOrder(1, []), [0]);
test('branching', findOrder(4, [[1, 0], [2, 0], [3, 1], [3, 2]]), [0, 1, 2, 3]);

Output


      
#323

Number of Connected Components

Medium

Given n nodes numbered from 0 and a list of undirected edges, count the connected components.

Hint 1

Two approaches: DFS from every unvisited node, or union-find.

Hint 2

Union-find is the better fit when edges arrive incrementally, and it is shorter.

The approach

Start with n components and decrement every time a union actually merges two different groups. Have union return whether it did.

Solution and tests

Cost. Effectively O(n + m) with path compression and union by size. O(n) space.

#323 Number of Connected Components

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

class UnionFind {
  constructor(n) {
    this.parent = Array.from({ length: n }, (_, i) => i);
    this.size = new Array(n).fill(1);
    this.groups = n;
  }
  find(x) {
    while (this.parent[x] !== x) {
      this.parent[x] = this.parent[this.parent[x]];   // path compression
      x = this.parent[x];
    }
    return x;
  }
  union(a, b) {
    let ra = this.find(a), rb = this.find(b);
    if (ra === rb) return false;                       // already joined
    if (this.size[ra] < this.size[rb]) [ra, rb] = [rb, ra];
    this.parent[rb] = ra;
    this.size[ra] += this.size[rb];
    this.groups--;
    return true;
  }
}

function countComponents(n, edges) {
  const uf = new UnionFind(n);
  for (const [a, b] of edges) uf.union(a, b);
  return uf.groups;
}

test('two components', countComponents(5, [[0, 1], [1, 2], [3, 4]]), 2);
test('one component', countComponents(5, [[0, 1], [1, 2], [2, 3], [3, 4]]), 1);
test('no edges', countComponents(4, []), 4);
test('single node', countComponents(1, []), 1);
test('redundant edges', countComponents(3, [[0, 1], [1, 2], [0, 2]]), 1);
test('self loop', countComponents(2, [[0, 0]]), 2);

Output


      
#417

Pacific Atlantic Water Flow

Medium

A grid of heights. Water flows from a cell to a neighbour of equal or lower height. Find every cell from which water can reach both the top-left edges and the bottom-right edges.

Hint 1

Searching outward from every cell to see whether it reaches both oceans is far too slow.

Hint 2

Reverse the direction: start at each ocean's border and walk uphill, marking everything that could have drained there.

The approach

Two floods produce two sets. The answer is their intersection — and each flood is a linear pass, so the whole thing is linear.

Solution and tests

Cost. O(rows × cols) time and space.

#417 Pacific Atlantic Water Flow

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function pacificAtlantic(heights) {
  if (!heights.length) return [];
  const rows = heights.length, cols = heights[0].length;
  const DIRS = [[-1, 0], [1, 0], [0, -1], [0, 1]];

  const flood = (starts) => {
    const seen = Array.from({ length: rows }, () => new Array(cols).fill(false));
    const queue = [...starts];
    for (const [r, c] of starts) seen[r][c] = true;

    for (let head = 0; head < queue.length; head++) {
      const [r, c] = queue[head];
      for (const [dr, dc] of DIRS) {
        const nr = r + dr, nc = c + dc;
        if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
        if (seen[nr][nc]) continue;
        // Walking UPHILL: water would have flowed the other way.
        if (heights[nr][nc] < heights[r][c]) continue;
        seen[nr][nc] = true;
        queue.push([nr, nc]);
      }
    }
    return seen;
  };

  const pacificEdge = [], atlanticEdge = [];
  for (let r = 0; r < rows; r++) {
    pacificEdge.push([r, 0]);
    atlanticEdge.push([r, cols - 1]);
  }
  for (let c = 0; c < cols; c++) {
    pacificEdge.push([0, c]);
    atlanticEdge.push([rows - 1, c]);
  }

  const pacific = flood(pacificEdge), atlantic = flood(atlanticEdge);
  const out = [];
  for (let r = 0; r < rows; r++)
    for (let c = 0; c < cols; c++)
      if (pacific[r][c] && atlantic[r][c]) out.push([r, c]);

  return out;
}

const grid = [
  [1, 2, 2, 3, 5],
  [3, 2, 3, 4, 4],
  [2, 4, 5, 3, 1],
  [6, 7, 1, 4, 5],
  [5, 1, 1, 2, 4],
];
test('classic count', pacificAtlantic(grid).length, 7);
test('single cell reaches both', pacificAtlantic([[1]]), [[0, 0]]);
test('flat grid: every cell', pacificAtlantic([[1, 1], [1, 1]]).length, 4);
test('empty', pacificAtlantic([]), []);
test('one row', pacificAtlantic([[1, 2, 3]]).length, 3);

Output


      
#743

Network Delay Time

Medium

A directed weighted graph of transmission times. Starting from one node, how long until a signal reaches every node? Return -1 if some node is unreachable.

Hint 1

Edges have different weights, so BFS does not give shortest paths here.

Hint 2

Dijkstra: repeatedly settle the nearest unsettled node, relaxing its outgoing edges.

The approach

A heap keyed on distance provides the nearest unsettled node in O(log V). Skip a popped entry whose distance is worse than one already recorded — that is how you avoid a decrease-key operation.

Solution and tests

Cost. O(E log V) time, O(V + E) space.

#743 Network Delay Time

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

class Heap {
  constructor(compare = (a, b) => a - b) { this.a = []; this.compare = compare; }
  get size() { return this.a.length; }
  push(v) {
    this.a.push(v);
    let i = this.a.length - 1;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.compare(this.a[p], this.a[i]) <= 0) break;
      [this.a[p], this.a[i]] = [this.a[i], this.a[p]];
      i = p;
    }
  }
  pop() {
    const top = this.a[0], last = this.a.pop();
    if (this.a.length) {
      this.a[0] = last;
      let i = 0;
      for (;;) {
        const l = 2 * i + 1, r = l + 1;
        let best = i;
        if (l < this.a.length && this.compare(this.a[l], this.a[best]) < 0) best = l;
        if (r < this.a.length && this.compare(this.a[r], this.a[best]) < 0) best = r;
        if (best === i) break;
        [this.a[best], this.a[i]] = [this.a[i], this.a[best]];
        i = best;
      }
    }
    return top;
  }
}

function networkDelayTime(times, n, start) {
  const adjacency = new Map();
  for (const [from, to, weight] of times) {
    if (!adjacency.has(from)) adjacency.set(from, []);
    adjacency.get(from).push([to, weight]);
  }

  const best = new Map([[start, 0]]);
  const heap = new Heap((a, b) => a[0] - b[0]);       // [distance, node]
  heap.push([0, start]);

  while (heap.size) {
    const [distance, node] = heap.pop();
    // A better route to this node was already settled: this entry is stale.
    if (distance > (best.get(node) ?? Infinity)) continue;

    for (const [next, weight] of adjacency.get(node) || []) {
      const candidate = distance + weight;
      if (candidate < (best.get(next) ?? Infinity)) {
        best.set(next, candidate);
        heap.push([candidate, next]);
      }
    }
  }

  if (best.size !== n) return -1;                      // something unreachable
  return Math.max(...best.values());
}

test('classic', networkDelayTime([[2, 1, 1], [2, 3, 1], [3, 4, 1]], 4, 2), 2);
test('two nodes', networkDelayTime([[1, 2, 1]], 2, 1), 1);
test('unreachable', networkDelayTime([[1, 2, 1]], 2, 2), -1);
test('single node', networkDelayTime([], 1, 1), 0);
test('prefers the cheaper route', networkDelayTime([[1, 2, 9], [1, 3, 1], [3, 2, 1]], 3, 1), 2);
test('parallel edges', networkDelayTime([[1, 2, 5], [1, 2, 2]], 2, 1), 2);

Output


      

Backtracking

6 problems · the pattern · Lesson 19

One template, six problems: choose, explore, un-choose. The variations are small and worth memorising exactly. i + 1 means no reuse; passing i allows reuse; starting from 0 with a used-set gives permutations. For duplicate inputs, sort and skip when i > start && nums[i] === nums[i-1] — i > start, not i > 0, and that single character is the whole difficulty.

#78

Subsets

Medium

Return every possible subset of an array of distinct integers, in any order.

Hint 1

Each element is either in or out, so there are 2ⁿ subsets. That is also the size of the output, so exponential is correct here.

Hint 2

The start-index style is the one that generalises: record at every node of the recursion, not only at the leaves.

The approach

Push a copy of your working array. Pushing the live array makes every result alias the same thing, and they all end up empty once the recursion unwinds.

Solution and tests

Cost. O(n × 2ⁿ) time, O(n) space beyond the output.

#78 Subsets

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function subsets(nums) {
  const results = [];

  function walk(start, current) {
    results.push([...current]);              // COPY, or everything aliases
    for (let i = start; i < nums.length; i++) {
      current.push(nums[i]);                 // choose
      walk(i + 1, current);                  // explore; i + 1 means no reuse
      current.pop();                         // un-choose
    }
  }

  walk(0, []);
  return results;
}

test('three elements', subsets([1, 2, 3]).length, 8);
test('contents', subsets([1, 2]), [[], [1], [1, 2], [2]]);
test('single', subsets([0]), [[], [0]]);
test('empty input', subsets([]), [[]]);
test('four elements', subsets([1, 2, 3, 4]).length, 16);
test('the empty subset is included', subsets([9])[0], []);

Output


      
#46

Permutations

Medium

Return every ordering of an array of distinct integers.

Hint 1

Order matters, so unlike subsets you start the loop from 0 every time and track what has already been taken.

Hint 2

A boolean array of used flags, or a set.

The approach

Undo both pieces of state — pop the value and clear the used flag. Forgetting the flag silently returns too few results.

Solution and tests

Cost. O(n × n!) time, O(n) space beyond the output.

#46 Permutations

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function permute(nums) {
  const results = [];
  const used = new Array(nums.length).fill(false);

  function walk(current) {
    if (current.length === nums.length) { results.push([...current]); return; }

    for (let i = 0; i < nums.length; i++) {   // from 0: any unused element is next
      if (used[i]) continue;
      used[i] = true;
      current.push(nums[i]);
      walk(current);
      current.pop();
      used[i] = false;                        // undo BOTH pieces of state
    }
  }

  walk([]);
  return results;
}

test('three elements', permute([1, 2, 3]).length, 6);
test('contents', permute([0, 1]), [[0, 1], [1, 0]]);
test('single', permute([1]), [[1]]);
test('four elements', permute([1, 2, 3, 4]).length, 24);
test('all are distinct', new Set(permute([1, 2, 3]).map(p => p.join(''))).size, 6);

Output


      
#39

Combination Sum

Medium

Given distinct candidate numbers and a target, find every combination summing to the target. A candidate may be used any number of times. Combinations differing only in order count as the same.

Hint 1

Reuse is allowed, so the recursive call passes i rather than i + 1.

Hint 2

Passing i instead of 0 is what prevents the same combination appearing in different orders.

The approach

Sort first, then break once a candidate exceeds what remains — everything after it is larger, so the rest of the loop is hopeless. That prune is what makes it fast.

Solution and tests

Cost. Roughly O(n^(target/min)) in the worst case. Pruning matters enormously in practice.

#39 Combination Sum

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function combinationSum(candidates, target) {
  const sorted = [...candidates].sort((a, b) => a - b);
  const results = [];

  function walk(start, current, remaining) {
    if (remaining === 0) { results.push([...current]); return; }

    for (let i = start; i < sorted.length; i++) {
      if (sorted[i] > remaining) break;      // sorted, so the rest is too big
      current.push(sorted[i]);
      walk(i, current, remaining - sorted[i]);   // i, not i + 1: reuse allowed
      current.pop();
    }
  }

  walk(0, [], target);
  return results;
}

test('classic', combinationSum([2, 3, 6, 7], 7), [[2, 2, 3], [7]]);
test('multiple ways', combinationSum([2, 3, 5], 8), [[2, 2, 2, 2], [2, 3, 3], [3, 5]]);
test('impossible', combinationSum([2], 1), []);
test('exact single', combinationSum([7], 7), [[7]]);
test('repeat one candidate', combinationSum([3], 9), [[3, 3, 3]]);
test('target of zero', combinationSum([1, 2], 0), [[]]);

Output


      
#90

Subsets II

Medium

Return every distinct subset of an array that may contain duplicates. No two subsets in the output may be identical.

Hint 1

The plain subsets template produces duplicates here. Sorting first puts equal values next to each other so they can be spotted.

Hint 2

Skip a value when it repeats the previous one at the same level of the recursion.

The approach

The condition is i > start && sorted[i] === sorted[i-1]. Using i > 0 instead wrongly excludes legitimate subsets like [2,2], because a repeat deeper in the tree is fine — it is only a repeat as a sibling that duplicates a branch.

Solution and tests

Cost. O(n × 2ⁿ) time, O(n) space beyond the output.

#90 Subsets II

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function subsetsWithDup(nums) {
  const sorted = [...nums].sort((a, b) => a - b);
  const results = [];

  function walk(start, current) {
    results.push([...current]);
    for (let i = start; i < sorted.length; i++) {
      // i > start, NOT i > 0: block repeats as siblings, allow them deeper.
      if (i > start && sorted[i] === sorted[i - 1]) continue;
      current.push(sorted[i]);
      walk(i + 1, current);
      current.pop();
    }
  }

  walk(0, []);
  return results;
}

test('one duplicate', subsetsWithDup([1, 2, 2]), [[], [1], [1, 2], [1, 2, 2], [2], [2, 2]]);
test('count is six', subsetsWithDup([1, 2, 2]).length, 6);
test('all identical', subsetsWithDup([2, 2, 2]), [[], [2], [2, 2], [2, 2, 2]]);
test('no duplicates behaves normally', subsetsWithDup([1, 2]).length, 4);
test('single', subsetsWithDup([0]), [[], [0]]);
test('two pairs', subsetsWithDup([1, 1, 2, 2]).length, 9);

Output


      
#22

Generate Parentheses

Medium

Given n, produce every well-formed arrangement of n pairs of round brackets.

Hint 1

Generating all 2^(2n) strings and filtering is wasteful. Prune while building instead.

Hint 2

Track how many brackets you have opened and closed.

The approach

Two rules make every generated string valid by construction: you may open while open < n, and you may close only while close < open. No validity check is ever needed.

Solution and tests

Cost. The nth Catalan number of results, O(4ⁿ / √n). O(n) recursion depth.

#22 Generate Parentheses

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function generateParenthesis(n) {
  const results = [];

  function walk(current, open, close) {
    if (current.length === 2 * n) { results.push(current); return; }

    if (open < n) walk(current + '(', open + 1, close);
    if (close < open) walk(current + ')', open, close + 1);   // never over-close
  }

  walk('', 0, 0);
  return results;
}

test('n = 1', generateParenthesis(1), ['()']);
test('n = 2', generateParenthesis(2), ['(())', '()()']);
test('n = 3 count', generateParenthesis(3).length, 5);
test('n = 4 count', generateParenthesis(4).length, 14);
test('n = 0', generateParenthesis(0), ['']);
test('every result is the right length', generateParenthesis(3).every(s => s.length === 6), true);

Output


      
#79

Word Search

Medium

Given a grid of letters and a word, decide whether the word can be traced through horizontally or vertically adjacent cells. A cell may not be reused within one path.

Hint 1

Try starting a search from every cell. From there, match one character at a time in four directions.

Hint 2

You need to know which cells the current path has already used — but a global visited set is wrong, because a different path may use them.

The approach

Overwrite the cell with a sentinel before recursing and restore it afterwards. That gives O(1) path membership with no allocation, and the restore is backtracking's undo step doing real work.

Solution and tests

Cost. O(rows × cols × 4^len) worst case, O(len) recursion depth.

#79 Word Search

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function exist(board, word) {
  const rows = board.length, cols = board[0].length;
  const grid = board.map(row => [...row]);        // do not disturb the caller's

  function search(r, c, index) {
    if (index === word.length) return true;
    if (r < 0 || r >= rows || c < 0 || c >= cols) return false;
    if (grid[r][c] !== word[index]) return false;

    const saved = grid[r][c];
    grid[r][c] = '#';                              // mark as used on THIS path

    const found = search(r + 1, c, index + 1) || search(r - 1, c, index + 1) ||
                  search(r, c + 1, index + 1) || search(r, c - 1, index + 1);

    grid[r][c] = saved;                            // restore for other paths
    return found;
  }

  for (let r = 0; r < rows; r++)
    for (let c = 0; c < cols; c++)
      if (search(r, c, 0)) return true;

  return false;
}

const board = [['A', 'B', 'C', 'E'], ['S', 'F', 'C', 'S'], ['A', 'D', 'E', 'E']];
test('winding path', exist(board, 'ABCCED'), true);
test('short word', exist(board, 'SEE'), true);
test('cannot reuse a cell', exist(board, 'ABCB'), false);
test('single letter', exist([['A']], 'A'), true);
test('absent letter', exist([['A']], 'B'), false);
test('whole row', exist([['A', 'B']], 'AB'), true);
test('backwards', exist([['A', 'B']], 'BA'), true);

Output


      

Dynamic programming

11 problems · the pattern · Lesson 20

The group that decides most interview outcomes, and the one where the method matters more than the memorised answer. Write the honest recursion first, notice it recomputes the same subproblem, and cache it. Only then convert to a table, and only then shrink the table. Doing it in that order means you always have something working, which is worth far more than a half-remembered table.

#70

Climbing Stairs

Easy

You climb a staircase of n steps, taking either one or two steps at a time. How many distinct ways are there to reach the top?

Hint 1

To arrive at step n you came from either n - 1 or n - 2. So the counts add.

Hint 2

That is the Fibonacci recurrence. The naive recursion is O(2ⁿ) because it recomputes constantly.

The approach

You only ever need the previous two values, so two variables replace the whole table and the space becomes O(1).

Solution and tests

Cost. O(n) time, O(1) space.

#70 Climbing Stairs

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function climbStairs(n) {
  // Only the previous two values are ever needed, so no table.
  let twoBack = 1, oneBack = 1;

  for (let i = 2; i <= n; i++) {
    const current = oneBack + twoBack;
    twoBack = oneBack;
    oneBack = current;
  }
  return oneBack;
}

test('two steps', climbStairs(2), 2);
test('three steps', climbStairs(3), 3);
test('one step', climbStairs(1), 1);
test('zero steps', climbStairs(0), 1);
test('five steps', climbStairs(5), 8);
test('tenth', climbStairs(10), 89);
test('no overflow at 40', climbStairs(40), 165580141);

Output


      
#198

House Robber

Medium

Each house holds some money, but you cannot rob two adjacent houses. Maximise the total taken.

Hint 1

At each house you have two choices: take it and skip the previous, or skip it and keep the best so far.

Hint 2

best[i] = Math.max(best[i-1], best[i-2] + money[i]).

The approach

As with the stairs, only two previous values matter, so it collapses to two variables.

Solution and tests

Cost. O(n) time, O(1) space.

#198 House Robber

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function rob(nums) {
  let skip = 0, take = 0;      // best excluding / including the previous house

  for (const money of nums) {
    // Robbing this house means we could not have robbed the previous one.
    const robThis = skip + money;
    skip = Math.max(skip, take);
    take = robThis;
  }
  return Math.max(skip, take);
}

test('alternating', rob([1, 2, 3, 1]), 4);
test('skip two', rob([2, 7, 9, 3, 1]), 12);
test('single house', rob([5]), 5);
test('empty street', rob([]), 0);
test('two houses', rob([2, 1]), 2);
test('all equal', rob([4, 4, 4, 4]), 8);

Output


      
#213

House Robber II

Medium

Same rule, but the houses are in a circle, so the first and the last are adjacent.

Hint 1

The circle only matters because the first and last houses conflict. What if you removed that conflict by hand?

Hint 2

Either you skip the first house, or you skip the last. Those two cases cover every possibility.

The approach

Run the linear solver twice — once on nums[0..n-2], once on nums[1..n-1] — and take the better. The single-house case needs handling separately, because both slices would be empty.

Solution and tests

Cost. O(n) time, O(1) space — two linear passes.

#213 House Robber II

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function rob(nums) {
  if (nums.length === 1) return nums[0];      // both slices would be empty

  const linear = (houses) => {
    let skip = 0, take = 0;
    for (const money of houses) {
      const robThis = skip + money;
      skip = Math.max(skip, take);
      take = robThis;
    }
    return Math.max(skip, take);
  };

  // Either the first house is out, or the last one is. Never both in.
  return Math.max(linear(nums.slice(0, -1)), linear(nums.slice(1)));
}

test('circle forces a choice', rob([2, 3, 2]), 3);
test('classic', rob([1, 2, 3, 1]), 4);
test('single house', rob([5]), 5);
test('two houses', rob([1, 2]), 2);
test('four equal', rob([4, 4, 4, 4]), 8);
test('longer', rob([1, 2, 3, 4, 5, 1]), 9);

Output


      
#322

Coin Change

Medium

Given coin denominations and an amount, return the fewest coins that make the amount exactly, or -1 if it is impossible. You have unlimited coins of each type.

Hint 1

Greedy — always take the largest coin that fits — is wrong. Coins [1,3,4] for 6 gives three coins greedily, but two suffice.

Hint 2

For each amount from 1 upward, try every coin and take the best result for the remainder.

The approach

Initialise the table to Infinity and set best[0] = 0. That base case is the whole thing: zero coins make zero.

Solution and tests

Cost. O(amount × coins) time, O(amount) space.

#322 Coin Change

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function coinChange(coins, amount) {
  const best = new Array(amount + 1).fill(Infinity);
  best[0] = 0;                                     // zero coins make zero

  for (let a = 1; a <= amount; a++) {
    for (const coin of coins) {
      if (coin <= a) best[a] = Math.min(best[a], best[a - coin] + 1);
    }
  }
  return best[amount] === Infinity ? -1 : best[amount];
}

test('classic', coinChange([1, 2, 5], 11), 3);
test('impossible', coinChange([2], 3), -1);
test('zero amount', coinChange([1], 0), 0);
test('greedy would fail here', coinChange([1, 3, 4], 6), 2);
test('exact single coin', coinChange([7], 7), 1);
test('many of one coin', coinChange([2], 8), 4);
test('no coins', coinChange([], 5), -1);

Output


      
#518

Coin Change II

Medium

Count how many distinct combinations of coins make the amount. Order does not matter, so 1+2 and 2+1 are the same combination.

Hint 1

This counts combinations rather than minimising coins, which changes the loop order — and that is the entire difficulty.

Hint 2

Put the coin loop outside and the amount loop inside.

The approach

With coins outermost, each combination is built in a fixed coin order, so 1+2 and 2+1 are never counted twice. Swap the loops and you count permutations instead — which is a different, also-real problem.

Solution and tests

Cost. O(amount × coins) time, O(amount) space.

#518 Coin Change II

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function change(amount, coins) {
  const ways = new Array(amount + 1).fill(0);
  ways[0] = 1;                                 // one way to make nothing

  // Coins OUTSIDE: each combination is built in one fixed coin order,
  // so 1+2 and 2+1 are never counted separately.
  for (const coin of coins) {
    for (let a = coin; a <= amount; a++) {
      ways[a] += ways[a - coin];
    }
  }
  return ways[amount];
}

test('classic', change(5, [1, 2, 5]), 4);
test('impossible', change(3, [2]), 0);
test('exact', change(10, [10]), 1);
test('zero amount', change(0, [1, 2]), 1);
test('only ones', change(4, [1]), 1);
test('order does not matter', change(4, [1, 2]), 3);

Output


      
#300

Longest Increasing Subsequence

Medium

Find the length of the longest strictly increasing subsequence. The elements need not be adjacent, but their order must be preserved.

Hint 1

The O(n²) answer: for each index, the best subsequence ending there is one more than the best ending at any smaller earlier element.

Hint 2

There is an O(n log n) solution using an auxiliary array of the smallest possible tail for each length.

The approach

Keep tails, where tails[k] is the smallest value that can end an increasing subsequence of length k+1. For each number, binary search for the first tail that is not smaller and overwrite it. The array's length is the answer — though its contents are not necessarily a valid subsequence.

Solution and tests

Cost. O(n log n) time, O(n) space. State the O(n²) version first; it is often accepted.

#300 Longest Increasing Subsequence

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function lengthOfLIS(nums) {
  // tails[k] = the smallest value that can end an increasing run of length k+1.
  const tails = [];

  for (const n of nums) {
    // Binary search for the first tail >= n.
    let lo = 0, hi = tails.length;
    while (lo < hi) {
      const mid = lo + ((hi - lo) >> 1);
      if (tails[mid] < n) lo = mid + 1;
      else hi = mid;
    }
    tails[lo] = n;                     // extend, or improve an existing tail
  }
  return tails.length;
}

test('classic', lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18]), 4);
test('with duplicates', lengthOfLIS([0, 1, 0, 3, 2, 3]), 4);
test('all identical', lengthOfLIS([7, 7, 7, 7]), 1);
test('single', lengthOfLIS([1]), 1);
test('empty', lengthOfLIS([]), 0);
test('strictly decreasing', lengthOfLIS([5, 4, 3, 2]), 1);
test('strictly increasing', lengthOfLIS([1, 2, 3, 4]), 4);

Output


      
#1143

Longest Common Subsequence

Medium

Given two strings, find the length of the longest sequence of characters appearing in both, in the same relative order but not necessarily adjacent.

Hint 1

Two sequences means a two-dimensional table: one axis per string.

Hint 2

table[i][j] is the answer for the first i characters of one and the first j of the other.

The approach

If the current characters match, extend the diagonal. If not, take the better of dropping one character from either string. The zero row and column represent an empty prefix and need no special handling.

Solution and tests

Cost. O(mn) time, O(mn) space — reducible to O(min(m, n)) with two rows.

#1143 Longest Common Subsequence

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function longestCommonSubsequence(a, b) {
  // Row and column 0 mean "empty prefix", so they stay 0 and remove edge cases.
  const table = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));

  for (let i = 1; i <= a.length; i++) {
    for (let j = 1; j <= b.length; j++) {
      table[i][j] = a[i - 1] === b[j - 1]
        ? table[i - 1][j - 1] + 1                        // matched: extend
        : Math.max(table[i - 1][j], table[i][j - 1]);     // drop one or the other
    }
  }
  return table[a.length][b.length];
}

test('classic', longestCommonSubsequence('abcde', 'ace'), 3);
test('identical', longestCommonSubsequence('abc', 'abc'), 3);
test('nothing in common', longestCommonSubsequence('abc', 'def'), 0);
test('one empty', longestCommonSubsequence('', 'abc'), 0);
test('single match', longestCommonSubsequence('a', 'a'), 1);
test('interleaved', longestCommonSubsequence('bsbininm', 'jmjkbkjkv'), 1);

Output


      
#139

Word Break

Medium

Given a string and a dictionary of words, decide whether the string can be cut into a sequence of dictionary words. Words may be reused.

Hint 1

Ask: can the first i characters be segmented? That is your state.

Hint 2

reachable[i] is true when some j < i is reachable and the slice from j to i is a word.

The approach

reachable[0] = true — the empty prefix is trivially segmentable — and that base case is what starts the chain. Put the dictionary in a Set for constant-time lookup.

Solution and tests

Cost. O(n² × word length) time with the slicing, O(n) space.

#139 Word Break

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function wordBreak(s, wordDict) {
  const words = new Set(wordDict);
  const reachable = new Array(s.length + 1).fill(false);
  reachable[0] = true;                          // the empty prefix

  for (let end = 1; end <= s.length; end++) {
    for (let start = 0; start < end; start++) {
      if (reachable[start] && words.has(s.slice(start, end))) {
        reachable[end] = true;
        break;                                   // one way is enough
      }
    }
  }
  return reachable[s.length];
}

test('two words', wordBreak('leetcode', ['leet', 'code']), true);
test('reuses a word', wordBreak('applepenapple', ['apple', 'pen']), true);
test('impossible', wordBreak('catsandog', ['cats', 'dog', 'sand', 'and', 'cat']), false);
test('single letter', wordBreak('a', ['a']), true);
test('not in the dictionary', wordBreak('b', ['a']), false);
test('greedy would fail', wordBreak('aaaaaaa', ['aaaa', 'aaa']), true);

Output


      
#416

Partition Equal Subset Sum

Medium

Decide whether an array of positive integers can be split into two groups with equal sums.

Hint 1

If the total is odd, the answer is immediately no. Otherwise each half must sum to total / 2.

Hint 2

So the real question is: can any subset reach exactly half the total? That is a subset-sum, which is a knapsack.

The approach

A boolean array over reachable sums. Iterate each number and walk the sums downward, so a number is not reused within a single pass.

Solution and tests

Cost. O(n × sum) time, O(sum) space.

#416 Partition Equal Subset Sum

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function canPartition(nums) {
  const total = nums.reduce((a, b) => a + b, 0);
  if (total % 2 !== 0) return false;             // cannot halve an odd total

  const target = total / 2;
  const reachable = new Array(target + 1).fill(false);
  reachable[0] = true;                            // a sum of zero always is

  for (const n of nums) {
    // Downward, so this number is not used twice in one pass.
    for (let sum = target; sum >= n; sum--) {
      if (reachable[sum - n]) reachable[sum] = true;
    }
  }
  return reachable[target];
}

test('splittable', canPartition([1, 5, 11, 5]), true);
test('not splittable', canPartition([1, 2, 3, 5]), false);
test('odd total', canPartition([1, 2]), false);
test('equal pair', canPartition([2, 2]), true);
test('single element', canPartition([1]), false);
test('four equal', canPartition([3, 3, 3, 3]), true);

Output


      
#62

Unique Paths

Medium

A robot starts at the top-left of an m × n grid and may only move right or down. How many distinct paths reach the bottom-right?

Hint 1

The ways to reach a cell are the ways to reach the cell above plus the cell to its left.

Hint 2

The top row and left column each have exactly one path, which gives you the base cases.

The approach

Each row only needs the row above, so a single array updated in place is enough — space drops from O(mn) to O(n).

Solution and tests

Cost. O(mn) time, O(n) space.

#62 Unique Paths

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function uniquePaths(m, n) {
  // One row, reused. row[c] means "ways to reach column c on the current row".
  const row = new Array(n).fill(1);              // the top row: one path each

  for (let r = 1; r < m; r++) {
    for (let c = 1; c < n; c++) {
      row[c] += row[c - 1];                       // from above + from the left
    }
  }
  return row[n - 1];
}

test('3 by 7', uniquePaths(3, 7), 28);
test('3 by 2', uniquePaths(3, 2), 3);
test('single row', uniquePaths(1, 5), 1);
test('single column', uniquePaths(5, 1), 1);
test('one cell', uniquePaths(1, 1), 1);
test('square', uniquePaths(3, 3), 6);

Output


      
#5

Longest Palindromic Substring

Medium

Find the longest contiguous run of characters in a string that reads the same both ways.

Hint 1

Checking every substring for palindromicity is O(n³). There is a neat O(n²) approach with no table at all.

Hint 2

Grow outward from a centre. Every palindrome has one.

The approach

There are 2n - 1 centres, not n: each character, plus each gap between characters for even-length palindromes. Handling both cases is what people forget.

Solution and tests

Cost. O(n²) time, O(1) space.

#5 Longest Palindromic Substring

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function longestPalindrome(s) {
  if (s.length < 2) return s;
  let bestStart = 0, bestLength = 1;

  const grow = (left, right) => {
    while (left >= 0 && right < s.length && s[left] === s[right]) { left--; right++; }
    // The loop overshoots by one on each side.
    const length = right - left - 1;
    if (length > bestLength) { bestLength = length; bestStart = left + 1; }
  };

  for (let i = 0; i < s.length; i++) {
    grow(i, i);          // odd length, centred on a character
    grow(i, i + 1);      // even length, centred on a gap
  }
  return s.slice(bestStart, bestStart + bestLength);
}

test('odd centre', longestPalindrome('babad'), 'bab');
test('even centre', longestPalindrome('cbbd'), 'bb');
test('single char', longestPalindrome('a'), 'a');
test('whole string', longestPalindrome('racecar'), 'racecar');
test('no repeat', longestPalindrome('abcd'), 'a');
test('empty', longestPalindrome(''), '');
test('all identical', longestPalindrome('aaaa'), 'aaaa');

Output


      

Greedy and intervals

8 problems · the pattern · Lessons 21, 22

Two related habits. For intervals, the only real decision is sort by start or sort by end: start for merging and inserting, end for choosing a maximum non-overlapping set. For greedy generally, the obligation is to say why the local choice is safe before you write it — an exchange argument or a bound. Greedy that happens to be wrong fails silently, which is what makes it dangerous.

#53

Maximum Subarray

Medium

Find the largest sum of any contiguous block of an array. The array may be all negative, in which case the answer is the largest single element.

Hint 1

At each element you face one choice: extend the block you are building, or start a new one here.

Hint 2

Extend when the running total is still helping; restart when it has gone negative.

The approach

This is Kadane's algorithm. Initialise from the first element rather than from zero, or an all-negative array wrongly answers 0.

Solution and tests

Cost. O(n) time, O(1) space.

#53 Maximum Subarray

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function maxSubArray(nums) {
  if (nums.length === 0) return 0;

  // Start from nums[0], not 0, or an all-negative array answers 0.
  let running = nums[0], best = nums[0];

  for (let i = 1; i < nums.length; i++) {
    // Extend, or start fresh here — whichever is larger.
    running = Math.max(nums[i], running + nums[i]);
    best = Math.max(best, running);
  }
  return best;
}

test('classic', maxSubArray([-2, 1, -3, 4, -1, 2, 1, -5, 4]), 6);
test('single element', maxSubArray([1]), 1);
test('all positive', maxSubArray([5, 4, -1, 7, 8]), 23);
test('all negative', maxSubArray([-3, -1, -2]), -1);
test('single negative', maxSubArray([-1]), -1);
test('empty', maxSubArray([]), 0);

Output


      
#55

Jump Game

Medium

Each value is the maximum number of steps you may jump forward from that index. Starting at index 0, decide whether the last index is reachable.

Hint 1

You do not need to know which route works, only whether the end is reachable.

Hint 2

Track the furthest index reachable so far as you walk forward.

The approach

If you ever stand on an index beyond that frontier, it is unreachable — and so is everything after it, so return false immediately.

Solution and tests

Cost. O(n) time, O(1) space.

#55 Jump Game

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function canJump(nums) {
  let furthest = 0;

  for (let i = 0; i < nums.length; i++) {
    if (i > furthest) return false;             // unreachable, and so is the end
    furthest = Math.max(furthest, i + nums[i]);
  }
  return true;
}

test('reachable', canJump([2, 3, 1, 1, 4]), true);
test('blocked by a zero', canJump([3, 2, 1, 0, 4]), false);
test('single element', canJump([0]), true);
test('one step needed', canJump([1, 0]), true);
test('cannot start', canJump([0, 1]), false);
test('big first jump', canJump([5, 0, 0, 0, 0]), true);

Output


      
#45

Jump Game II

Medium

Same setup, but the last index is guaranteed reachable. Return the minimum number of jumps needed.

Hint 1

This is a shortest-path question on an unweighted graph, so think in levels rather than in individual jumps.

Hint 2

A jump takes you to any index within a range. Each jump therefore opens the next range.

The approach

Track the end of the current range and the furthest the next range reaches. When the loop index hits the current end, you must jump. That is BFS with no queue.

Solution and tests

Cost. O(n) time, O(1) space.

#45 Jump Game II

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function jump(nums) {
  let jumps = 0, currentEnd = 0, furthest = 0;

  // Stop before the last index: arriving there needs no further jump.
  for (let i = 0; i < nums.length - 1; i++) {
    furthest = Math.max(furthest, i + nums[i]);

    if (i === currentEnd) {          // exhausted this range: must jump now
      jumps++;
      currentEnd = furthest;
    }
  }
  return jumps;
}

test('classic', jump([2, 3, 1, 1, 4]), 2);
test('alternative', jump([2, 3, 0, 1, 4]), 2);
test('already there', jump([0]), 0);
test('one jump', jump([1, 1]), 1);
test('two jumps', jump([1, 1, 1]), 2);
test('single big jump', jump([5, 1, 1, 1, 1]), 1);

Output


      
#763

Partition Labels

Medium

Cut a string into as many pieces as possible, so that no letter appears in more than one piece. Return the piece sizes in order.

Hint 1

A piece cannot end before the last occurrence of every letter it contains.

Hint 2

Record each character's last index in one pass.

The approach

Then sweep: extend the current piece's end to cover each character's last occurrence, and cut when the index reaches that end.

Solution and tests

Cost. O(n) time, O(1) space for a fixed alphabet.

#763 Partition Labels

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function partitionLabels(s) {
  const lastIndex = new Map();
  for (let i = 0; i < s.length; i++) lastIndex.set(s[i], i);

  const sizes = [];
  let start = 0, end = 0;

  for (let i = 0; i < s.length; i++) {
    // This piece must stretch at least to this character's final appearance.
    end = Math.max(end, lastIndex.get(s[i]));
    if (i === end) { sizes.push(end - start + 1); start = i + 1; }
  }
  return sizes;
}

test('classic', partitionLabels('ababcbacadefegdehijhklij'), [9, 7, 8]);
test('one piece', partitionLabels('eccbbbbdec'), [10]);
test('single character', partitionLabels('a'), [1]);
test('all distinct', partitionLabels('abc'), [1, 1, 1]);
test('repeated pair', partitionLabels('abab'), [4]);
test('empty', partitionLabels(''), []);

Output


      
#56

Merge Intervals

Medium

Given a list of intervals, merge every group that overlaps and return the non-overlapping result.

Hint 1

Sort by start time so overlapping intervals become adjacent.

Hint 2

Hold one interval open; extend it while the next one begins at or before its end, otherwise close it and open a new one.

The approach

Extend with Math.max(open.end, current.end). Sorting by start does not sort the ends, so a fully contained interval would otherwise shrink your merged range.

Solution and tests

Cost. O(n log n) time from the sort, O(n) space.

#56 Merge Intervals

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function merge(intervals) {
  if (intervals.length === 0) return [];

  const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
  const out = [sorted[0].slice()];               // copy: never mutate the input

  for (let i = 1; i < sorted.length; i++) {
    const open = out[out.length - 1];
    if (sorted[i][0] <= open[1]) {
      open[1] = Math.max(open[1], sorted[i][1]);  // Math.max is NOT optional
    } else {
      out.push(sorted[i].slice());
    }
  }
  return out;
}

test('classic', merge([[1, 3], [2, 6], [8, 10], [15, 18]]), [[1, 6], [8, 10], [15, 18]]);
test('touching merges', merge([[1, 4], [4, 5]]), [[1, 5]]);
test('fully contained', merge([[1, 10], [2, 3]]), [[1, 10]]);
test('unsorted input', merge([[5, 6], [1, 2]]), [[1, 2], [5, 6]]);
test('single interval', merge([[1, 4]]), [[1, 4]]);
test('empty', merge([]), []);
test('all identical', merge([[1, 2], [1, 2]]), [[1, 2]]);

Output


      
#57

Insert Interval

Medium

Given a sorted list of non-overlapping intervals, insert a new one and merge where necessary. The result must stay sorted and non-overlapping.

Hint 1

The list is already sorted, so no sorting is needed — this can be one linear pass.

Hint 2

Three phases: intervals entirely before the new one, intervals that overlap it, and intervals entirely after.

The approach

Write it as three sequential while loops sharing an index, not one loop full of conditionals. Each loop then has exactly one job.

Solution and tests

Cost. O(n) time, O(n) space for the output.

#57 Insert Interval

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function insert(intervals, newInterval) {
  const out = [];
  let [start, end] = newInterval;
  let i = 0;

  // Phase 1: everything that finishes before the new interval begins.
  while (i < intervals.length && intervals[i][1] < start) out.push(intervals[i++]);

  // Phase 2: absorb everything that overlaps.
  while (i < intervals.length && intervals[i][0] <= end) {
    start = Math.min(start, intervals[i][0]);
    end = Math.max(end, intervals[i][1]);
    i++;
  }
  out.push([start, end]);

  // Phase 3: the rest, untouched.
  while (i < intervals.length) out.push(intervals[i++]);

  return out;
}

test('simple overlap', insert([[1, 3], [6, 9]], [2, 5]), [[1, 5], [6, 9]]);
test('absorbs several', insert([[1, 2], [3, 5], [6, 7], [8, 10], [12, 16]], [4, 8]),
  [[1, 2], [3, 10], [12, 16]]);
test('into an empty list', insert([], [5, 7]), [[5, 7]]);
test('swallowed whole', insert([[1, 5]], [2, 3]), [[1, 5]]);
test('goes after', insert([[1, 5]], [6, 8]), [[1, 5], [6, 8]]);
test('goes before', insert([[3, 5]], [1, 2]), [[1, 2], [3, 5]]);

Output


      
#435

Non-overlapping Intervals

Medium

Find the minimum number of intervals to remove so that the rest do not overlap.

Hint 1

Removing the fewest is the same as keeping the most. That reframing makes it a classic greedy problem.

Hint 2

Sort by end time, not start. Keeping the earliest finisher leaves the most room for whatever follows.

The approach

The exchange argument: swap the first interval of any optimal schedule for the earliest-finishing one and the schedule is still valid and no shorter. Sorting by start instead fails, because one long early interval can swallow several short ones.

Solution and tests

Cost. O(n log n) time, O(n) space for the copy.

#435 Non-overlapping Intervals

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function eraseOverlapIntervals(intervals) {
  if (intervals.length === 0) return 0;

  // Sort by END. This is the whole insight.
  const sorted = [...intervals].sort((a, b) => a[1] - b[1]);
  let kept = 1, lastEnd = sorted[0][1];

  for (let i = 1; i < sorted.length; i++) {
    if (sorted[i][0] >= lastEnd) { kept++; lastEnd = sorted[i][1]; }
  }
  return intervals.length - kept;
}

test('remove one', eraseOverlapIntervals([[1, 2], [2, 3], [3, 4], [1, 3]]), 1);
test('all identical', eraseOverlapIntervals([[1, 2], [1, 2], [1, 2]]), 2);
test('already disjoint', eraseOverlapIntervals([[1, 2], [2, 3]]), 0);
test('empty', eraseOverlapIntervals([]), 0);
test('single', eraseOverlapIntervals([[1, 2]]), 0);
test('nested intervals', eraseOverlapIntervals([[1, 100], [11, 22], [1, 11], [2, 12]]), 2);

Output


      
#253

Meeting Rooms II

Medium

Given meeting start and end times, find the minimum number of rooms needed to hold them all.

Hint 1

The answer is the peak number of meetings running at the same instant.

Hint 2

Stop thinking about intervals and think about events: a +1 at each start, a -1 at each end.

The approach

Sort the events by time and sweep, tracking a running total. On a tie, process the -1 first — a meeting ending at 10 frees the room for one starting at 10.

Solution and tests

Cost. O(n log n) time, O(n) space.

#253 Meeting Rooms II

const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);

function minMeetingRooms(intervals) {
  const events = [];
  for (const [start, end] of intervals) {
    events.push([start, 1]);       // a room is taken
    events.push([end, -1]);        // a room is freed
  }

  // On a tie, -1 sorts before +1, so a room frees up before the next start.
  events.sort((a, b) => a[0] - b[0] || a[1] - b[1]);

  let active = 0, peak = 0;
  for (const [, delta] of events) {
    active += delta;
    peak = Math.max(peak, active);
  }
  return peak;
}

test('two rooms', minMeetingRooms([[0, 30], [5, 10], [15, 20]]), 2);
test('one room', minMeetingRooms([[7, 10], [2, 4]]), 1);
test('three overlapping', minMeetingRooms([[1, 5], [2, 6], [3, 7]]), 3);
test('back to back needs one', minMeetingRooms([[1, 2], [2, 3], [3, 4]]), 1);
test('single meeting', minMeetingRooms([[1, 2]]), 1);
test('no meetings', minMeetingRooms([]), 0);

Output


      

When you are stuck for more than half an hour

Stuck is normal and it is where the learning is, right up until it becomes unproductive. The signal to stop is when you are no longer generating new ideas, only re-checking old ones. At that point, in order: reread the constraints for a hint about the intended complexity, try the brute force to get something working, then climb the hints one step at a time.

What matters is what happens afterwards. Close everything, wait a day, and solve it again from an empty editor. A problem you have read the solution to is not a problem you can solve — the gap between recognising an answer and producing one is exactly the gap an interview measures.

If a whole pattern feels opaque, go back to its lesson rather than grinding more problems in it. Twenty minutes rereading the pattern and its template will save you an evening.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.