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
9 problems — 3 easy, 6 medium. Pattern · Lessons 02, 04
7 problems — 2 easy, 4 medium, 1 hard. Pattern · Lesson 05
6 problems — 1 easy, 4 medium, 1 hard. Pattern · Lesson 06
5 problems — 1 easy, 4 medium. Pattern · Lesson 07
6 problems — 1 easy, 4 medium, 1 hard. Pattern · Lesson 12
6 problems — 1 easy, 4 medium, 1 hard. Pattern · Lesson 09
8 problems — 4 easy, 3 medium, 1 hard. Pattern · Lesson 08
10 problems — 5 easy, 4 medium, 1 hard. Pattern · Lessons 14, 15
5 problems — 1 easy, 3 medium, 1 hard. Pattern · Lesson 16
9 problems — 9 medium. Pattern · Lessons 17, 18, 23
6 problems — 6 medium. Pattern · Lesson 19
11 problems — 1 easy, 10 medium. Pattern · Lesson 20
8 problems — 8 medium. Pattern · Lessons 21, 22
A routine that works
- 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.
- Say the pattern. Even a wrong guess is useful; it gives you something to disprove.
- 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.
- 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.
- 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.
Contains Duplicate
EasyGiven 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.
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
Valid Anagram
EasyGiven 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.
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
Two Sum
EasyGiven 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.
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
Group Anagrams
MediumGiven 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.
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
Top K Frequent Elements
MediumGiven 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.
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
Product of Array Except Self
MediumFor 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.
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
Longest Consecutive Sequence
MediumGiven 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.
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
Valid Sudoku
MediumGiven 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²).
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
Encode and Decode Strings
MediumWrite 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.
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.
Valid Palindrome
EasyDecide 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.
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
Remove Duplicates from Sorted Array
EasyGiven 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.
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
Two Sum II, Input Array Is Sorted
MediumGiven 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.
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
3Sum
MediumFind 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.
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
Container With Most Water
MediumEach 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.
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
Sort Colors
MediumAn 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.
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
Trapping Rain Water
HardGiven 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.
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.
Best Time to Buy and Sell Stock
EasyGiven 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.
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
Longest Substring Without Repeating Characters
MediumFind 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.
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
Longest Repeating Character Replacement
MediumYou 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.
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
Permutation in String
MediumGiven 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.
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
Minimum Size Subarray Sum
MediumGiven 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.
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
Minimum Window Substring
HardGiven 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.
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.
Range Sum Query, Immutable
EasyBuild 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.
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
Subarray Sum Equals K
MediumCount 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.
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
Contiguous Array
MediumGiven 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.
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
Subarray Sums Divisible by K
MediumCount 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.
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
Car Pooling
MediumA 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).
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
Binary search
6 problems · the pattern · Lesson 12
The first three search a sorted array. The last three search the space of possible answers, which is the version worth real practice: if you can check a candidate answer quickly, and feasibility only improves as the candidate grows, you can binary search for the boundary even though the input is unsorted. Recognising that is worth more than any other single trick here.
Binary Search
EasyFind the index of a target in a sorted array, or -1 if it is absent. Must run in O(log n).
Hint 1
Halve the range each step. Compare against the middle and discard the half that cannot contain the target.
Hint 2
Use inclusive bounds and while (lo <= hi). Miss the = and a one-element range never gets checked.
The approach
Compute mid as lo + ((hi - lo) >> 1). Both branches must exclude mid — mid + 1 and mid - 1 — or the loop can spin forever.
Solution and tests
Cost. O(log n) time, O(1) space.
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 search(nums, target) {
let lo = 0, hi = nums.length - 1; // both inclusive
while (lo <= hi) { // <= or a 1-element range is skipped
const mid = lo + ((hi - lo) >> 1);
if (nums[mid] === target) return mid;
if (nums[mid] < target) lo = mid + 1; // both branches exclude mid
else hi = mid - 1;
}
return -1;
}
test('found in the middle', search([-1, 0, 3, 5, 9, 12], 9), 4);
test('absent', search([-1, 0, 3, 5, 9, 12], 2), -1);
test('first element', search([1, 2, 3], 1), 0);
test('last element', search([1, 2, 3], 3), 2);
test('single hit', search([5], 5), 0);
test('single miss', search([5], 3), -1);
test('empty', search([], 1), -1);
Output
Find Minimum in Rotated Sorted Array
MediumA sorted array of distinct values has been rotated some unknown number of times. Find the smallest value in O(log n).
Hint 1
The array is two sorted runs. The minimum is the first element of the second run.
Hint 2
Compare the middle with the right end. If nums[mid] > nums[hi], the rotation point is to the right of mid.
The approach
Comparing with the right end is more robust than comparing with the left, because it needs no separate check for a non-rotated array. Use the lo < hi form and return nums[lo].
Solution and tests
Cost. O(log n) time, O(1) space.
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 findMin(nums) {
let lo = 0, hi = nums.length - 1;
while (lo < hi) {
const mid = lo + ((hi - lo) >> 1);
// Comparing against the RIGHT end handles the unrotated case for free.
if (nums[mid] > nums[hi]) lo = mid + 1; // pivot is strictly right of mid
else hi = mid; // mid could be the minimum
}
return nums[lo];
}
test('rotated', findMin([3, 4, 5, 1, 2]), 1);
test('rotated further', findMin([4, 5, 6, 7, 0, 1, 2]), 0);
test('not rotated', findMin([1, 2, 3]), 1);
test('single', findMin([5]), 5);
test('two, rotated', findMin([2, 1]), 1);
test('two, in order', findMin([1, 2]), 1);
Output
Search in Rotated Sorted Array
MediumFind a target in a rotated sorted array of distinct values, in O(log n). Return its index or -1.
Hint 1
At any midpoint, one of the two halves is guaranteed to be properly sorted. Work out which.
Hint 2
If nums[lo] <= nums[mid] the left half is sorted; otherwise the right half is.
The approach
Once you know which half is sorted, you can check whether the target lies inside its range with two comparisons, and discard one half either way.
Solution and tests
Cost. O(log n) time, O(1) space.
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 search(nums, target) {
let lo = 0, hi = nums.length - 1;
while (lo <= hi) {
const mid = lo + ((hi - lo) >> 1);
if (nums[mid] === target) return mid;
if (nums[lo] <= nums[mid]) {
// Left half is sorted: is the target inside it?
if (target >= nums[lo] && target < nums[mid]) hi = mid - 1;
else lo = mid + 1;
} else {
// Right half is sorted.
if (target > nums[mid] && target <= nums[hi]) lo = mid + 1;
else hi = mid - 1;
}
}
return -1;
}
test('in the right half', search([4, 5, 6, 7, 0, 1, 2], 0), 4);
test('absent', search([4, 5, 6, 7, 0, 1, 2], 3), -1);
test('in the left half', search([4, 5, 6, 7, 0, 1, 2], 5), 1);
test('single miss', search([1], 0), -1);
test('single hit', search([1], 1), 0);
test('unrotated', search([1, 2, 3, 4], 3), 2);
test('pivot itself', search([5, 1, 3], 5), 0);
Output
Koko Eating Bananas
MediumThere are piles of bananas and h hours available. At a chosen speed of k bananas per hour, each pile takes ceil(pile / k) hours and you never move to another pile within the same hour. Find the smallest k that finishes in time.
Hint 1
You cannot compute k directly, but given a candidate k you can total the hours in one pass.
Hint 2
A faster speed is never worse. So feasibility looks like false false ... true true across the range of speeds — a sorted boolean array to binary search.
The approach
The range runs from 1 to the largest pile. Use the lo < hi form: if a speed works, set hi = mid to keep looking for something smaller.
Solution and tests
Cost. O(n log(max pile)) time, O(1) space.
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 minEatingSpeed(piles, h) {
const hoursAt = (speed) => {
let hours = 0;
for (const pile of piles) hours += Math.ceil(pile / speed);
return hours;
};
let lo = 1, hi = Math.max(...piles); // the answer is in this range
while (lo < hi) {
const mid = lo + ((hi - lo) >> 1);
if (hoursAt(mid) <= h) hi = mid; // fast enough: try slower
else lo = mid + 1; // too slow: must speed up
}
return lo;
}
test('classic', minEatingSpeed([3, 6, 7, 11], 8), 4);
test('tight schedule', minEatingSpeed([30, 11, 23, 4, 20], 5), 30);
test('more time', minEatingSpeed([30, 11, 23, 4, 20], 6), 23);
test('single pile, one hour', minEatingSpeed([12], 1), 12);
test('plenty of time', minEatingSpeed([1, 1, 1], 3), 1);
test('two piles', minEatingSpeed([2, 2], 2), 2);
Output
Capacity to Ship Packages Within D Days
MediumPackages must ship in their given order. Each day you load a ship until the next package would exceed its capacity. Find the smallest capacity that ships everything within days days.
Hint 1
Identical shape to the bananas problem: you cannot derive the capacity, but you can simulate a candidate in one pass.
Hint 2
The lower bound is the heaviest single package — anything less can never ship it. The upper bound is the total weight, which ships everything in one day.
The approach
Simulate greedily: add to the current day until it would overflow, then start a new day. Count the days and compare.
Solution and tests
Cost. O(n log(total weight)) time, O(1) space.
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 shipWithinDays(weights, days) {
const daysNeeded = (capacity) => {
let used = 1, load = 0;
for (const w of weights) {
if (load + w > capacity) { used++; load = 0; } // start a new day
load += w;
}
return used;
};
let lo = Math.max(...weights); // must fit the heaviest
let hi = weights.reduce((a, b) => a + b, 0); // everything in one day
while (lo < hi) {
const mid = lo + ((hi - lo) >> 1);
if (daysNeeded(mid) <= days) hi = mid;
else lo = mid + 1;
}
return lo;
}
test('classic', shipWithinDays([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 5), 15);
test('three days', shipWithinDays([3, 2, 2, 4, 1, 4], 3), 6);
test('one per day', shipWithinDays([1, 2, 3, 1, 1], 4), 3);
test('single package', shipWithinDays([7], 1), 7);
test('one day for everything', shipWithinDays([1, 2, 3], 1), 6);
test('a day each', shipWithinDays([5, 5, 5], 3), 5);
Output
Median of Two Sorted Arrays
HardGiven two sorted arrays, find the median of their combined contents in O(log(m + n)).
Hint 1
Merging them is O(m + n) and is worth saying as your baseline before attempting better.
Hint 2
You are not searching for a value; you are searching for a split point. Choose how many elements to take from the smaller array, and the count from the other array follows.
The approach
A split is correct when everything on the left is at most everything on the right. Binary search the split on the shorter array, using ±Infinity as sentinels so the edges need no special cases.
Solution and tests
Cost. O(log(min(m, n))) time, O(1) space.
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 findMedianSortedArrays(a, b) {
// Always binary search the SHORTER array, so the split count stays valid.
if (a.length > b.length) [a, b] = [b, a];
const total = a.length + b.length;
const half = Math.floor((total + 1) / 2); // size of the left partition
let lo = 0, hi = a.length;
while (lo <= hi) {
const takeA = lo + ((hi - lo) >> 1); // from a
const takeB = half - takeA; // the rest from b
// Sentinels remove every boundary special case.
const aLeft = takeA > 0 ? a[takeA - 1] : -Infinity;
const aRight = takeA < a.length ? a[takeA] : Infinity;
const bLeft = takeB > 0 ? b[takeB - 1] : -Infinity;
const bRight = takeB < b.length ? b[takeB] : Infinity;
if (aLeft <= bRight && bLeft <= aRight) { // a correct split
if (total % 2 === 1) return Math.max(aLeft, bLeft);
return (Math.max(aLeft, bLeft) + Math.min(aRight, bRight)) / 2;
}
if (aLeft > bRight) hi = takeA - 1; // took too many from a
else lo = takeA + 1; // took too few
}
return 0;
}
test('even total', findMedianSortedArrays([1, 3], [2]), 2);
test('odd total', findMedianSortedArrays([1, 2], [3, 4]), 2.5);
test('one empty', findMedianSortedArrays([], [1]), 1);
test('other empty', findMedianSortedArrays([2], []), 2);
test('no overlap', findMedianSortedArrays([1, 2], [7, 8]), 4.5);
test('identical', findMedianSortedArrays([1, 1], [1, 1]), 1);
test('uneven lengths', findMedianSortedArrays([1, 3, 5, 7], [2]), 3);
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.
Valid Parentheses
EasyGiven 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.
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
Min Stack
MediumBuild 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.
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
Daily Temperatures
MediumFor 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.
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
Evaluate Reverse Polish Notation
MediumEvaluate 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.
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
Basic Calculator II
MediumEvaluate 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.
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
Largest Rectangle in Histogram
HardGiven 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.
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.
Reverse Linked List
EasyReverse 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.
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
Merge Two Sorted Lists
EasyGiven 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.
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
Linked List Cycle
EasyDecide 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.
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
Palindrome Linked List
EasyDecide 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.
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
Remove Nth Node From End of List
MediumRemove 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.
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
Reorder List
MediumRearrange 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.
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
Add Two Numbers
MediumTwo 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.
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
Merge k Sorted Lists
HardGiven 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.
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.
Maximum Depth of Binary Tree
EasyFind 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.
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
Invert Binary Tree
EasyMirror 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.
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
Same Tree
EasyDecide 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.
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
Balanced Binary Tree
EasyDecide 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.
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
Diameter of Binary Tree
EasyFind 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.
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
Binary Tree Level Order Traversal
MediumReturn 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.
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
Validate Binary Search Tree
MediumDecide 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.
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
Lowest Common Ancestor of a BST
MediumGiven 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.
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
Kth Smallest Element in a BST
MediumFind 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.
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
Binary Tree Maximum Path Sum
HardFind 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.
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.
Last Stone Weight
EasyRepeatedly 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.
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
Kth Largest Element in an Array
MediumFind 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).
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
K Closest Points to Origin
MediumGiven 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.
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
Task Scheduler
MediumGiven 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).
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
Find Median from Data Stream
HardSupport 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.
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.
Number of Islands
MediumA 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.
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
Max Area of Island
MediumSame 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.
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
Rotting Oranges
MediumA 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.
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
Clone Graph
MediumGiven 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.
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
Course Schedule
MediumGiven 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.
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
Course Schedule II
MediumSame 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.
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
Number of Connected Components
MediumGiven 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.
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
Pacific Atlantic Water Flow
MediumA 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.
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
Network Delay Time
MediumA 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.
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.
Subsets
MediumReturn 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.
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
Permutations
MediumReturn 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.
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
Combination Sum
MediumGiven 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.
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
Subsets II
MediumReturn 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.
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
Generate Parentheses
MediumGiven 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.
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
Word Search
MediumGiven 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.
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.
Climbing Stairs
EasyYou 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.
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
House Robber
MediumEach 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.
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
House Robber II
MediumSame 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.
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
Coin Change
MediumGiven 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.
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
Coin Change II
MediumCount 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.
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
Longest Increasing Subsequence
MediumFind 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.
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
Longest Common Subsequence
MediumGiven 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.
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
Word Break
MediumGiven 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.
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
Partition Equal Subset Sum
MediumDecide 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.
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
Unique Paths
MediumA 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.
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
Longest Palindromic Substring
MediumFind 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.
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.
Maximum Subarray
MediumFind 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.
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
Jump Game
MediumEach 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.
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
Jump Game II
MediumSame 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.
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
Partition Labels
MediumCut 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.
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
Merge Intervals
MediumGiven 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.
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
Insert Interval
MediumGiven 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.
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
Non-overlapping Intervals
MediumFind 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.
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
Meeting Rooms II
MediumGiven 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.
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.