Patterns
Interview questions are not infinite. Sixteen shapes cover the great majority of them, and the skill being tested is recognising which one you are looking at within the first minute.
This page is the recognition layer. Each pattern gets the signal that should trigger it, a template you can run and read, its real cost, the mistake that most often ruins it, and the problems it opens up. The templates are working code, not pseudocode: press Run on any of them.
Read it once now, then come back to it every time you finish a problem and ask which pattern it was. That habit — naming the shape after the fact — is what builds recognition faster than volume alone.
The lookup table
What you read in the problem statement, and what it should make you reach for. When you are stuck, work down this list rather than staring at the problem.
“sorted array”, find a pair or triple
“contiguous”, “subarray”, “substring”
many range-sum queries, or negative numbers in a subarray-sum question
“have I seen this”, “how many of each”, “group by”
sorted input, or “smallest value that works”
order does not matter and size order makes each choice obvious
input is [start, end] pairs
“next greater”, “previous smaller”, histograms, spans
“k largest”, “k closest”, “median of a stream”
a linked list, and O(1) space demanded
a tree, and the answer builds up from the children
“shortest”, “fewest steps”, “level by level”, “spreads at once”
“prerequisites”, “dependencies”, “build order”
“connected”, “same group”, edges arriving one at a time
“all”, “every”, list every arrangement, n ≤ 20
“count the ways”, “minimum cost”, overlapping choices
The sixteen
Two pointers
Lessons 05, 08Reach for it when: A sorted array and you need a pair, a triple, or a partition. Or one list to be walked with two different speeds. If your first instinct is a nested loop over a sorted input, this is almost always the replacement.
Two variants, and they look different enough that many people learn them as separate topics.
Opposite ends. One index at each end, moving inward. The sortedness tells you which one to move: too small a sum means the left must rise, too large means the right must fall. Each step eliminates a whole row of the pair matrix a nested loop would have checked.
Same direction, different speeds. A slow index marks where the next kept element goes while a fast index scans ahead. This is how in-place removal and de-duplication work without allocating. Push it further and the two speeds detect a cycle, because a pointer moving twice as fast gains exactly one position per step on the slow one and must eventually land on it.
// Variant A: opposite ends. Find a pair summing to the target.
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, hi];
if (sum < target) lo++; // need more: only the left can grow
else hi--; // need less: only the right can shrink
}
return null;
}
// Variant B: same direction. Keep only what passes, in place.
function removeValue(nums, value) {
let write = 0;
for (let read = 0; read < nums.length; read++) {
if (nums[read] !== value) nums[write++] = nums[read];
}
return write; // the new logical length
}
// Variant C: different speeds. Find the middle of a linked list.
function middleNode(head) {
let slow = head, fast = head;
while (fast && fast.next) { slow = slow.next; fast = fast.next.next; }
return slow;
}
console.log(twoSumSorted([2, 7, 11, 15], 9)); // [0,1]
console.log(twoSumSorted([2, 7, 11, 15], 100)); // null
const arr = [3, 2, 2, 3, 4];
console.log('kept', removeValue(arr, 3), 'of 5 ->', arr.slice(0, removeValue([3, 2, 2, 3, 4], 3)));
const list = { v: 1, next: { v: 2, next: { v: 3, next: { v: 4, next: null } } } };
console.log('middle of 1-2-3-4:', middleNode(list).v); // 3, the second middle
Output
Cost. O(n) time after sorting, O(1) extra space. If the input arrives unsorted, sorting first makes the whole thing O(n log n) — still far better than O(n²).
Problems this unlocks (12)
- Two Sum II
- 3Sum
- 4Sum
- Container With Most Water
- Trapping Rain Water
- Valid Palindrome
- Remove Duplicates from Sorted Array
- Move Zeroes
- Sort Colors
- Squares of a Sorted Array
- Linked List Cycle
- Find the Duplicate Number
Sliding window
Lesson 06Reach for it when: The words contiguous, subarray, substring, or consecutive. Plus either a fixed length, or a condition that gets worse as the window grows and better as it shrinks.
The window is a range [left, right]. The right edge always advances; the left edge advances only to restore the condition. Because neither edge ever moves backwards, the total work is at most 2n steps no matter how much shrinking happens — which is why this is linear and not quadratic.
Fixed-size windows are easier: add the entering element, remove the leaving one, no shrinking loop. Variable-size windows come in two flavours, and knowing which you have decides where the answer is recorded. For longest valid, shrink until valid then record. For shortest valid, record while valid then keep shrinking.
// Variable window, longest valid: shrink until legal, then measure.
function longestWithAtMostKDistinct(s, k) {
const counts = new Map();
let best = 0, left = 0;
for (let right = 0; right < s.length; right++) {
counts.set(s[right], (counts.get(s[right]) || 0) + 1);
while (counts.size > k) { // too many distinct: shrink
const out = s[left++];
counts.set(out, counts.get(out) - 1);
if (counts.get(out) === 0) counts.delete(out);
}
best = Math.max(best, right - left + 1); // record AFTER it is legal
}
return best;
}
// Variable window, shortest valid: record while legal, keep shrinking.
function shortestSumAtLeast(nums, target) {
let best = Infinity, sum = 0, left = 0;
for (let right = 0; right < nums.length; right++) {
sum += nums[right];
while (sum >= target) { // legal: record, then shrink
best = Math.min(best, right - left + 1);
sum -= nums[left++];
}
}
return best === Infinity ? 0 : best;
}
// Fixed window: no inner loop at all.
function maxSumOfSize(nums, k) {
let sum = 0;
for (let i = 0; i < k; i++) sum += nums[i];
let best = sum;
for (let right = k; right < nums.length; right++) {
sum += nums[right] - nums[right - k]; // one in, one out
best = Math.max(best, sum);
}
return best;
}
console.log(longestWithAtMostKDistinct('eceba', 2)); // 3, "ece"
console.log(longestWithAtMostKDistinct('aa', 1)); // 2
console.log(shortestSumAtLeast([2, 3, 1, 2, 4, 3], 7)); // 2, [4,3]
console.log(shortestSumAtLeast([1, 1, 1], 99)); // 0, impossible
console.log(maxSumOfSize([2, 1, 5, 1, 3, 2], 3)); // 9
Output
Cost. O(n) time — each index enters once and leaves once. Space is O(k) or O(alphabet) for the counts, O(1) if you are only tracking a sum.
Problems this unlocks (9)
- Longest Substring Without Repeating Characters
- Minimum Window Substring
- Longest Repeating Character Replacement
- Permutation in String
- Find All Anagrams in a String
- Maximum Average Subarray
- Minimum Size Subarray Sum
- Fruit Into Baskets
- Max Consecutive Ones III
Prefix sums and difference arrays
Lesson 07Reach for it when: Many range queries on data that does not change. Or "count the subarrays whose sum equals k", where a window will not work because the values can be negative.
Build one array where prefix[i] is the total of everything before index i. Then any range sum is a single subtraction, and a question that looked like O(n) per query becomes O(1).
The second use is subtler and appears more often. If you are hunting subarrays with a target sum, then sum(i..j) = prefix[j+1] - prefix[i], so for each right end you want to know how many earlier prefixes equal prefix[j+1] - target. A hash map of prefix counts answers that in O(1), turning a nested loop into one pass. The same trick counts subarrays divisible by k (store the remainder) and finds the longest balanced subarray (map value to first index).
A difference array is the same idea inverted: to add a value across a whole range, add at the start and subtract just past the end, then take a running sum at the finish. Many range updates become O(1) each.
// 1. Range sums in O(1) after an O(n) build.
function buildPrefix(nums) {
const prefix = new Array(nums.length + 1).fill(0);
for (let i = 0; i < nums.length; i++) prefix[i + 1] = prefix[i] + nums[i];
return prefix; // prefix[0] = 0 removes every edge case
}
const rangeSum = (prefix, lo, hi) => prefix[hi + 1] - prefix[lo];
// 2. Count subarrays summing to k. Works with negatives; a window does not.
function countSubarraysWithSum(nums, k) {
const seen = new Map([[0, 1]]); // one empty prefix, sum 0
let running = 0, count = 0;
for (const n of nums) {
running += n;
count += seen.get(running - k) || 0; // every earlier prefix that completes k
seen.set(running, (seen.get(running) || 0) + 1);
}
return count;
}
// 3. Difference array: many range updates, then one pass to materialise.
function applyRangeUpdates(length, updates) {
const delta = new Array(length + 1).fill(0);
for (const [lo, hi, amount] of updates) {
delta[lo] += amount;
delta[hi + 1] -= amount; // cancel just past the end
}
const out = [];
let running = 0;
for (let i = 0; i < length; i++) { running += delta[i]; out.push(running); }
return out;
}
const prefix = buildPrefix([3, 1, 4, 1, 5, 9]);
console.log('sum of 1..3:', rangeSum(prefix, 1, 3)); // 6
console.log('sum of 0..5:', rangeSum(prefix, 0, 5)); // 23
console.log(countSubarraysWithSum([1, 1, 1], 2)); // 2
console.log(countSubarraysWithSum([1, -1, 0], 0)); // 3
console.log(applyRangeUpdates(5, [[1, 3, 2], [0, 1, 1]])); // [1,3,2,2,0]
Output
Cost. O(n) to build, O(1) per query, O(n) space. The hash-map variant is O(n) time and space in one pass.
Problems this unlocks (9)
- Range Sum Query Immutable
- Subarray Sum Equals K
- Continuous Subarray Sum
- Subarray Sums Divisible by K
- Contiguous Array
- Product of Array Except Self
- Corporate Flight Bookings
- Car Pooling
- Find Pivot Index
Hash map counting and lookup
Lesson 04Reach for it when: "Have I seen this before", "how many times does each thing appear", or "group these by something". Any time you catch yourself scanning the array again from inside a loop.
The least glamorous pattern and by far the most useful. A Map turns a repeated O(n) search into an O(1) lookup, trading memory for time. Three shapes cover nearly everything: a set for membership, a counter for frequency, and a map from key to list for grouping.
The grouping shape deserves attention because the insight is always the choice of key. Anagrams group by their sorted letters. Points on a line group by slope. Words group by their letter-count signature. When a problem asks you to find things that are equivalent in some way, you are looking for the canonical form that makes equivalent things collide.
// Shape 1: seen-before, in one pass.
function firstDuplicate(nums) {
const seen = new Set();
for (const n of nums) {
if (seen.has(n)) return n;
seen.add(n);
}
return null;
}
// Shape 2: frequency counter, then read the answer off it.
function firstUniqueChar(s) {
const counts = new Map();
for (const ch of s) counts.set(ch, (counts.get(ch) || 0) + 1);
for (let i = 0; i < s.length; i++) if (counts.get(s[i]) === 1) return i;
return -1;
}
// Shape 3: group by a canonical key. The key IS the insight.
function groupAnagrams(words) {
const groups = new Map();
for (const word of words) {
const key = [...word].sort().join(''); // canonical form
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(word);
}
return [...groups.values()];
}
// Shape 4: map a value to where you saw it, so you can pair them up.
function twoSumUnsorted(nums, target) {
const indexOf = new Map();
for (let i = 0; i < nums.length; i++) {
if (indexOf.has(target - nums[i])) return [indexOf.get(target - nums[i]), i];
indexOf.set(nums[i], i); // set AFTER checking, or n+n=target lies
}
return null;
}
console.log(firstDuplicate([2, 1, 3, 1, 4])); // 1
console.log(firstUniqueChar('leetcode')); // 0
console.log(groupAnagrams(['eat', 'tea', 'tan', 'ate', 'nat'])); // 2 groups
console.log(twoSumUnsorted([3, 2, 4], 6)); // [1,2]
console.log(twoSumUnsorted([3, 3], 6)); // [0,1]
Output
Cost. O(n) time and O(n) space, with the caveat that hashing is O(1) on average and the constant is real. Sorting a small array can beat a map in wall-clock time even though it is asymptotically worse.
Problems this unlocks (12)
- Two Sum
- Contains Duplicate
- Valid Anagram
- Group Anagrams
- Top K Frequent Elements
- Longest Consecutive Sequence
- Isomorphic Strings
- Word Pattern
- Ransom Note
- Majority Element
- Intersection of Two Arrays
- Happy Number
Binary search, on the array and on the answer
Lesson 12Reach for it when: A sorted input. Or — and this is the harder recognition — a question of the form "find the smallest capacity / largest size / minimum speed that works", where you can check a candidate answer quickly even though you cannot compute it directly.
The array version is the one everybody knows and still gets wrong. Use one form and use it every time: lo and hi as inclusive bounds, while (lo <= hi), and mid = lo + ((hi - lo) >> 1). Every branch must exclude mid, or the loop hangs.
Binary search on the answer is the more valuable half. If a candidate answer x being feasible implies every larger x is also feasible, the feasibility function is a sorted array of booleans — false false false true true true — and you can binary search for the boundary. You are no longer searching the input at all; you are searching the range of possible answers. Once you see it, a whole family of "minimise the maximum" problems collapses into twelve lines.
// Form 1: exact match in a sorted array.
function search(nums, target) {
let lo = 0, hi = nums.length - 1;
while (lo <= hi) {
const mid = lo + ((hi - lo) >> 1); // no overflow, and floors correctly
if (nums[mid] === target) return mid;
if (nums[mid] < target) lo = mid + 1; // both branches exclude mid
else hi = mid - 1;
}
return -1;
}
// Form 2: leftmost position where a predicate becomes true. The workhorse.
function lowerBound(nums, target) {
let lo = 0, hi = nums.length; // hi is EXCLUSIVE here
while (lo < hi) {
const mid = lo + ((hi - lo) >> 1);
if (nums[mid] < target) lo = mid + 1;
else hi = mid; // keep mid as a candidate
}
return lo; // insertion point if not found
}
// Form 3: binary search on the answer.
// "What is the smallest capacity that ships everything within d days?"
function shipWithinDays(weights, days) {
const feasible = (capacity) => {
let need = 1, load = 0;
for (const w of weights) {
if (load + w > capacity) { need++; load = 0; }
load += w;
}
return need <= days;
};
// The answer is somewhere between the biggest single item and the total.
let lo = Math.max(...weights), hi = weights.reduce((a, b) => a + b, 0);
while (lo < hi) {
const mid = lo + ((hi - lo) >> 1);
if (feasible(mid)) hi = mid; // works: try smaller
else lo = mid + 1; // fails: must go bigger
}
return lo;
}
console.log(search([-1, 0, 3, 5, 9, 12], 9)); // 4
console.log(search([-1, 0, 3, 5, 9, 12], 2)); // -1
console.log(lowerBound([1, 2, 2, 2, 3], 2)); // 1, first 2
console.log(lowerBound([1, 3, 5], 4)); // 2, where 4 would go
console.log(shipWithinDays([1,2,3,4,5,6,7,8,9,10], 5)); // 15
console.log(shipWithinDays([3, 2, 2, 4, 1, 4], 3)); // 6
Output
Cost. O(log n) for the array form. Searching the answer space costs O(log(range) × cost of one feasibility check), which is usually O(n log(range)).
Problems this unlocks (12)
- Binary Search
- Search Insert Position
- First Bad Version
- Find Minimum in Rotated Sorted Array
- Search in Rotated Sorted Array
- Find First and Last Position
- Koko Eating Bananas
- Capacity to Ship Packages
- Split Array Largest Sum
- Median of Two Sorted Arrays
- Find Peak Element
- Sqrt(x)
Sort, then sweep
Lesson 21Reach for it when: The answer does not depend on the original order, and processing things in size or time order makes each decision obvious. Also the default move when you are stuck: ask what sorting would buy you.
Sorting costs O(n log n) and frequently buys you an O(n) pass afterwards where the naive approach was O(n²). The skill is knowing which key to sort on, because that choice is usually the entire solution.
Greedy sweeps live here. Choosing the maximum number of non-overlapping activities means sorting by end time, because the earliest finisher leaves the most room for everything after it. Pairing people into boats means sorting by weight and pairing from both ends. Arranging numbers to form the largest value means a custom comparator on the concatenation. In each case, once sorted, a single pass with a running variable is the whole algorithm.
Greedy is also the pattern most likely to be silently wrong, so it comes with an obligation: state why the local choice is safe before you write it.
// Greedy after sorting by end time: the maximum non-overlapping set.
function maxNonOverlapping(intervals) {
const sorted = [...intervals].sort((a, b) => a[1] - b[1]); // by END
let count = 0, lastEnd = -Infinity;
for (const [start, end] of sorted) {
if (start >= lastEnd) { count++; lastEnd = end; }
}
return count;
}
// Greedy after sorting by size: two pointers from both ends.
function rescueBoats(people, limit) {
const sorted = [...people].sort((a, b) => a - b);
let boats = 0, lo = 0, hi = sorted.length - 1;
while (lo <= hi) {
if (sorted[lo] + sorted[hi] <= limit) lo++; // lightest rides along
hi--; // heaviest always boards
boats++;
}
return boats;
}
// The insight is sometimes the comparator itself.
function largestConcatenation(nums) {
const parts = nums.map(String).sort((a, b) => (b + a).localeCompare(a + b));
const joined = parts.join('');
return joined[0] === '0' ? '0' : joined; // all zeros collapse
}
console.log(maxNonOverlapping([[1, 4], [3, 5], [0, 6], [5, 7], [8, 11]])); // 3
console.log(rescueBoats([3, 2, 2, 1], 3)); // 3
console.log(rescueBoats([1, 2], 3)); // 1
console.log(largestConcatenation([3, 30, 34, 5, 9])); // '9534330'
console.log(largestConcatenation([0, 0])); // '0'
Output
Cost. O(n log n) time from the sort, then O(n) for the sweep. Space is O(n) if you copy the input, which you should.
Problems this unlocks (12)
- Merge Intervals
- Non-overlapping Intervals
- Minimum Number of Arrows
- Boats to Save People
- Assign Cookies
- Largest Number
- Meeting Rooms
- Task Scheduler
- Queue Reconstruction by Height
- Jump Game
- Gas Station
- Partition Labels
Merge intervals
Lesson 22Reach for it when: Input that is pairs of [start, end]. Merging, inserting, counting overlaps, allocating rooms, or finding free time.
Sort by start, then walk forward holding one interval open: if the next one begins at or before the open interval's end, extend the end; otherwise close it and open a new one. That is merging, and inserting is the same loop split into three phases — before, overlapping, after.
When the question is "how many are happening at once", stop thinking in intervals and think in events: a +1 at each start, a -1 at each end, sorted by time, then a running total whose peak is the answer. That reframing is called a sweep line and it generalises to problems where merging does not help at all.
function merge(intervals) {
if (intervals.length === 0) return [];
const sorted = [...intervals].sort((a, b) => a[0] - b[0]); // by START
const out = [sorted[0].slice()]; // copy 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;
}
// Sweep line: peak concurrency, without merging anything.
function peakConcurrent(intervals) {
const events = [];
for (const [s, e] of intervals) { events.push([s, 1]); events.push([e, -1]); }
events.sort((a, b) => a[0] - b[0] || a[1] - b[1]); // -1 before +1 on ties
let active = 0, peak = 0;
for (const [, delta] of events) { active += delta; peak = Math.max(peak, active); }
return peak;
}
console.log(merge([[1, 3], [2, 6], [8, 10], [15, 18]])); // [[1,6],[8,10],[15,18]]
console.log(merge([[1, 10], [2, 3]])); // [[1,10]] contained
console.log(merge([[1, 4], [4, 5]])); // [[1,5]] touching
console.log(peakConcurrent([[0, 30], [5, 10], [15, 20]])); // 2
console.log(peakConcurrent([[1, 5], [2, 6], [3, 7]])); // 3
console.log(peakConcurrent([[1, 2], [2, 3]])); // 1, only touching
Output
Cost. O(n log n) from the sort, O(n) for the pass and the output. If the coordinates are small bounded integers, a difference array does it in O(n + range) with no sort.
Problems this unlocks (10)
- Merge Intervals
- Insert Interval
- Non-overlapping Intervals
- Meeting Rooms I and II
- Interval List Intersections
- Employee Free Time
- Minimum Number of Arrows
- Car Pooling
- My Calendar I
- Remove Covered Intervals
Monotonic stack
Lesson 09Reach for it when: The phrase next greater, previous smaller, nearest larger to the right, or anything about spans, histograms and rectangles. Also: any problem where you find yourself scanning backwards looking for the first element that beats the current one.
Keep a stack whose values are always increasing (or always decreasing). Before pushing a new element, pop everything it invalidates — and the moment you pop something, you have just discovered its answer. That is the trick: the popping is the computation, not cleanup.
Every element is pushed once and popped at most once, so despite the inner while loop the whole thing is O(n). That amortised argument is worth being able to say out loud, because the code looks quadratic at a glance and interviewers will ask.
Store indices rather than values on the stack. You almost always need the distance between positions, and you can always read the value back with the index.
// Next greater element to the right, for every index.
function nextGreater(nums) {
const answer = new Array(nums.length).fill(-1);
const stack = []; // indices, values decreasing
for (let i = 0; i < nums.length; i++) {
// nums[i] is the next greater element for everything smaller on the stack.
while (stack.length && nums[stack[stack.length - 1]] < nums[i]) {
answer[stack.pop()] = nums[i]; // the pop resolves that index
}
stack.push(i);
}
return answer; // anything left never found one
}
// Same skeleton, different bookkeeping: largest rectangle in a histogram.
function largestRectangle(heights) {
const stack = []; // indices, heights increasing
let best = 0;
const bars = [...heights, 0]; // sentinel 0 flushes the stack
for (let i = 0; i < bars.length; i++) {
while (stack.length && bars[stack[stack.length - 1]] >= bars[i]) {
const height = bars[stack.pop()];
const left = stack.length ? stack[stack.length - 1] + 1 : 0;
best = Math.max(best, height * (i - left));
}
stack.push(i);
}
return best;
}
// Distances rather than values: days until a warmer temperature.
function daysUntilWarmer(temps) {
const answer = new Array(temps.length).fill(0);
const stack = [];
for (let i = 0; i < temps.length; i++) {
while (stack.length && temps[stack[stack.length - 1]] < temps[i]) {
const j = stack.pop();
answer[j] = i - j; // this is why we store indices
}
stack.push(i);
}
return answer;
}
console.log(nextGreater([2, 1, 2, 4, 3])); // [4,2,4,-1,-1]
console.log(largestRectangle([2, 1, 5, 6, 2, 3])); // 10
console.log(largestRectangle([2, 4])); // 4
console.log(daysUntilWarmer([73, 74, 75, 71, 69, 72, 76, 73])); // [1,1,4,2,1,1,0,0]
Output
Cost. O(n) time — amortised, because each index is pushed and popped at most once. O(n) space for the stack in the worst case.
Problems this unlocks (11)
- Next Greater Element I and II
- Daily Temperatures
- Largest Rectangle in Histogram
- Maximal Rectangle
- Trapping Rain Water
- Remove K Digits
- Remove Duplicate Letters
- Sum of Subarray Minimums
- Online Stock Span
- Valid Parentheses
- Min Stack
Top-k with a heap
Lesson 16Reach for it when: The words k largest, k closest, k most frequent, median of a stream, or merge k sorted things. Anything where you repeatedly need the current best without needing everything in order.
Sorting to get the top k costs O(n log n) and answers a question you did not ask. A heap of size k costs O(n log k), and when k is small that is close to linear.
The counter-intuitive part: to find the k largest, keep a min-heap of size k. The smallest of your current best k sits on top, so comparing each new element against it is O(1) and evicting is O(log k). Reverse it for the k smallest.
Two heaps facing each other track a running median: a max-heap of the lower half, a min-heap of the upper half, rebalanced so their sizes differ by at most one. And a heap seeded with the head of every list merges k sorted lists in O(n log k).
// A binary heap, because 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 parent = (i - 1) >> 1;
if (this.compare(this.a[parent], this.a[i]) <= 0) break;
[this.a[parent], this.a[i]] = [this.a[i], this.a[parent]];
i = parent;
}
}
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;
}
}
// k largest: a MIN-heap capped at k. The weakest survivor is always on top.
function kLargest(nums, k) {
const heap = new Heap(); // min-heap
for (const n of nums) {
heap.push(n);
if (heap.size > k) heap.pop(); // evict the smallest
}
const out = [];
while (heap.size) out.push(heap.pop());
return out.reverse();
}
// k most frequent: count first, then heap on the counts.
function topKFrequent(nums, k) {
const counts = new Map();
for (const n of nums) counts.set(n, (counts.get(n) || 0) + 1);
const heap = new Heap((a, b) => a[1] - b[1]); // compare by count
for (const entry of counts) {
heap.push(entry);
if (heap.size > k) heap.pop();
}
return heap.a.map(e => e[0]).sort((a, b) => counts.get(b) - counts.get(a));
}
console.log(kLargest([3, 2, 1, 5, 6, 4], 2)); // [6,5]
console.log(kLargest([7], 1)); // [7]
console.log(topKFrequent([1, 1, 1, 2, 2, 3], 2)); // [1,2]
console.log(topKFrequent([4, 4, 4, 5, 5, 6], 1)); // [4]
Output
Cost. O(n log k) to scan with a capped heap, versus O(n log n) to sort. push and pop are O(log n); peek is O(1). Building a heap from an existing array can be done in O(n).
Problems this unlocks (10)
- Kth Largest Element in an Array
- Top K Frequent Elements
- K Closest Points to Origin
- Merge k Sorted Lists
- Find Median from Data Stream
- Task Scheduler
- Reorganize String
- Meeting Rooms II
- Sliding Window Median
- Last Stone Weight
In-place linked-list surgery
Lesson 08Reach for it when: A linked list, plus the words reverse, reorder, remove, merge, or a demand for O(1) space.
Almost every linked-list question is one of four moves: reverse a run of nodes, find a position with two pointers at different speeds, merge two sorted lists, or detect a cycle. Combine them and you have solved the hard ones — "reorder list" is find the middle, reverse the second half, then interleave.
Two habits remove most of the pain. Use a dummy head whenever the first node might change, so you never write a special case for it. And when reversing, hold prev, curr and next at once, because the instant you rewrite curr.next the rest of the list is unreachable unless you saved it.
function toList(values) {
const dummy = { v: null, next: null };
let tail = dummy;
for (const v of values) { tail.next = { v, next: null }; tail = tail.next; }
return dummy.next;
}
const toArray = (head) => { const out = []; for (let n = head; n; n = n.next) out.push(n.v); return out; };
// Reverse. Save next BEFORE you overwrite it.
function reverse(head) {
let prev = null, curr = head;
while (curr) {
const next = curr.next; // save it or lose the rest of the list
curr.next = prev;
prev = curr;
curr = next;
}
return prev; // the old tail is the new head
}
// Merge two sorted lists. The dummy head removes every special case.
function mergeSorted(a, b) {
const dummy = { v: null, next: null };
let tail = dummy;
while (a && b) {
if (a.v <= b.v) { tail.next = a; a = a.next; }
else { tail.next = b; b = b.next; }
tail = tail.next;
}
tail.next = a || b; // one of them still has nodes
return dummy.next;
}
// Cycle detection, and where the cycle begins.
function cycleStart(head) {
let slow = head, fast = head;
while (fast && fast.next) {
slow = slow.next;
fast = fast.next.next;
if (slow === fast) { // they met, so there is a loop
let walk = head;
while (walk !== slow) { walk = walk.next; slow = slow.next; }
return walk; // distance from head equals distance from meeting
}
}
return null;
}
console.log(toArray(reverse(toList([1, 2, 3, 4, 5])))); // [5,4,3,2,1]
console.log(toArray(reverse(toList([1])))); // [1]
console.log(toArray(reverse(toList([])))); // []
console.log(toArray(mergeSorted(toList([1, 3, 5]), toList([2, 4])))); // [1,2,3,4,5]
const looped = toList([3, 2, 0, -4]);
looped.next.next.next.next = looped.next; // tail -> index 1
console.log('cycle starts at value:', cycleStart(looped).v); // 2
console.log('no cycle:', cycleStart(toList([1, 2, 3]))); // null
Output
Cost. O(n) time and O(1) space for all of it. The O(1) space is the point; copying into an array is easier and usually disallowed.
Problems this unlocks (12)
- Reverse Linked List I and II
- Merge Two Sorted Lists
- Merge k Sorted Lists
- Linked List Cycle I and II
- Remove Nth Node From End
- Reorder List
- Palindrome Linked List
- Add Two Numbers
- Copy List with Random Pointer
- Swap Nodes in Pairs
- Rotate List
- Odd Even Linked List
Tree depth-first traversal
Lessons 14, 15Reach for it when: A binary tree and a question about paths, depth, subtree properties, or validity. If the answer for a node can be assembled from the answers for its children, this is it.
Three orders, one recursion. Pre-order handles the node before its children and is right for copying and serialising. In-order handles the left child, then the node, then the right — on a binary search tree that emits the values in sorted order, which is the answer to a surprising number of questions. Post-order handles both children first, which is what you need whenever a node's answer depends on results coming up from below.
The recurring shape is post-order: return something useful from the recursion while updating an answer held outside it. Height, diameter, balance, and "is this a valid BST" are all one post-order pass. When a candidate solves diameter in O(n²) by recomputing height at every node, the fix is to return height and update the diameter in the same call.
function buildTree(values) {
// Level-order array, null for a missing child.
if (!values.length || values[0] === null) return null;
const root = { v: values[0], left: null, right: null };
const queue = [root];
let i = 1;
while (i < values.length) {
const node = queue.shift();
if (i < values.length && values[i] !== null) { node.left = { v: values[i], left: null, right: null }; queue.push(node.left); }
i++;
if (i < values.length && values[i] !== null) { node.right = { v: values[i], left: null, right: null }; queue.push(node.right); }
i++;
}
return root;
}
// In-order: on a BST this comes out sorted.
function inorder(node, out = []) {
if (!node) return out;
inorder(node.left, out);
out.push(node.v);
inorder(node.right, out);
return out;
}
// Post-order, the important shape: return one thing, accumulate another.
function diameter(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); // path THROUGH this node
return 1 + Math.max(left, right); // height, for the parent
}
height(root);
return best;
}
// Validity needs a range passed downward, not a local check.
function isValidBST(node, low = -Infinity, high = Infinity) {
if (!node) return true;
if (node.v <= low || node.v >= high) return false;
return isValidBST(node.left, low, node.v) && isValidBST(node.right, node.v, high);
}
const tree = buildTree([1, 2, 3, 4, 5]);
console.log('in-order:', inorder(tree)); // [4,2,5,1,3]
console.log('diameter:', diameter(tree)); // 3
console.log('valid BST [2,1,3]:', isValidBST(buildTree([2, 1, 3]))); // true
console.log('valid BST [5,1,4,null,null,3,6]:', isValidBST(buildTree([5, 1, 4, null, null, 3, 6]))); // false
Output
Cost. O(n) time, since each node is visited once. Space is O(h) for the call stack — O(log n) on a balanced tree, O(n) on a degenerate one.
Problems this unlocks (12)
- Maximum Depth of Binary Tree
- Same Tree
- Invert Binary Tree
- Balanced Binary Tree
- Diameter of Binary Tree
- Path Sum I and II
- Lowest Common Ancestor
- Validate BST
- Kth Smallest in a BST
- Binary Tree Maximum Path Sum
- Serialize and Deserialize Binary Tree
- Construct Tree from Preorder and Inorder
Breadth-first search
Lessons 17, 23Reach for it when: Shortest path or fewest steps in an unweighted graph or grid. Level by level in a tree. Or several sources spreading at once — infection, rot, flooding, distance to the nearest anything.
BFS visits nodes in order of distance, which is why it finds shortest paths when every edge costs the same. Depth-first search does not; it finds a path. Mixing them up is a substantive error, not a stylistic one.
Two details carry most of the correctness. Mark a node visited when you enqueue it, never when you dequeue, or the same node gets queued by every neighbour and the queue blows up. And process the queue one level at a time — capture the current length before the inner loop — whenever the answer is a count of steps.
Multi-source is the variant people miss. Seeding the queue with every starting point before the loop begins makes all sources expand together, so "distance to the nearest zero" is one BFS, not one per cell.
// Grid BFS with level counting. The template for "fewest steps".
function shortestPath(grid) {
const rows = grid.length, cols = grid[0].length;
if (grid[0][0] === 1) return -1;
const DIRS = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const seen = Array.from({ length: rows }, () => new Array(cols).fill(false));
let queue = [[0, 0]];
seen[0][0] = true; // mark on ENQUEUE
let steps = 0;
while (queue.length) {
const next = [];
for (const [r, c] of queue) { // one whole level
if (r === rows - 1 && c === cols - 1) return steps;
for (const [dr, dc] of DIRS) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
if (grid[nr][nc] === 1 || seen[nr][nc]) continue;
seen[nr][nc] = true;
next.push([nr, nc]);
}
}
queue = next;
steps++;
}
return -1;
}
// Multi-source: seed every start before the loop.
function distanceToNearestZero(grid) {
const rows = grid.length, cols = grid[0].length;
const dist = Array.from({ length: rows }, () => new Array(cols).fill(-1));
const queue = [];
for (let r = 0; r < rows; r++)
for (let c = 0; c < cols; c++)
if (grid[r][c] === 0) { dist[r][c] = 0; queue.push([r, c]); }
const DIRS = [[-1, 0], [1, 0], [0, -1], [0, 1]];
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 (dist[nr][nc] !== -1) continue; // already reached, so already closer
dist[nr][nc] = dist[r][c] + 1;
queue.push([nr, nc]);
}
}
return dist;
}
console.log(shortestPath([[0, 0, 0], [0, 1, 0], [0, 0, 0]])); // 4
console.log(shortestPath([[0, 1], [1, 0]])); // -1
console.log(distanceToNearestZero([[0, 0, 0], [0, 1, 0], [1, 1, 1]]));
// [[0,0,0],[0,1,0],[1,2,1]]
Output
Cost. O(V + E) for a graph, O(rows × cols) for a grid. Space is the same in the worst case, because a level can contain most of the graph.
Problems this unlocks (12)
- Binary Tree Level Order Traversal
- Number of Islands
- Rotting Oranges
- 01 Matrix
- Word Ladder
- Open the Lock
- Shortest Path in Binary Matrix
- Walls and Gates
- Surrounded Regions
- Pacific Atlantic Water Flow
- Clone Graph
- Minimum Genetic Mutation
Topological sort
Lesson 18Reach for it when: Anything phrased as prerequisites, dependencies, build order, or "can this be completed". Directed edges plus a need for a valid order, or a need to detect a cycle.
Count how many prerequisites each node has. Start with everything at zero, and each time you take a node off the queue, decrement its dependents; anything that drops to zero is now ready. This is Kahn's algorithm and it is BFS wearing different clothes.
Its second gift is cycle detection for free: if you finish and have emitted fewer nodes than exist, the leftovers are tangled in a cycle. That makes "is this course schedule possible" and "order the courses" the same twenty lines with a different return value.
function topologicalOrder(nodeCount, edges) {
// edges are [from, to]: from must come before to.
const adjacency = Array.from({ length: nodeCount }, () => []);
const inDegree = new Array(nodeCount).fill(0);
for (const [from, to] of edges) {
adjacency[from].push(to);
inDegree[to]++;
}
// Everything with no prerequisites is ready immediately.
const queue = [];
for (let n = 0; n < nodeCount; n++) if (inDegree[n] === 0) queue.push(n);
const order = [];
for (let head = 0; head < queue.length; head++) {
const node = queue[head];
order.push(node);
for (const next of adjacency[node]) {
if (--inDegree[next] === 0) queue.push(next); // now unblocked
}
}
// Fewer than every node means a cycle swallowed the rest.
return order.length === nodeCount ? order : null;
}
console.log(topologicalOrder(4, [[0, 1], [0, 2], [1, 3], [2, 3]])); // [0,1,2,3]
console.log(topologicalOrder(2, [[1, 0]])); // [1,0]
console.log(topologicalOrder(2, [[0, 1], [1, 0]])); // null, cycle
console.log(topologicalOrder(3, [])); // [0,1,2]
console.log('possible?', topologicalOrder(2, [[0, 1], [1, 0]]) !== null); // false
Output
Cost. O(V + E) time and space. Every node is queued once and every edge is examined once.
Problems this unlocks (7)
- Course Schedule I and II
- Alien Dictionary
- Minimum Height Trees
- Sequence Reconstruction
- Parallel Courses
- Sort Items by Groups Respecting Dependencies
- Find Eventual Safe States
Union-find
Lesson 18Reach for it when: Connected components, "are these two in the same group", merging sets, or detecting a cycle in an undirected graph. Especially when edges arrive one at a time and you must answer as you go.
Each element points at a parent; following the chain reaches a representative that identifies the whole group. Union links two representatives. That is it.
Two optimisations turn it from mediocre to effectively constant. Path compression flattens the chain during a lookup so subsequent lookups are immediate. Union by size attaches the smaller tree beneath the larger one, keeping things shallow. With both, the amortised cost per operation is inverse-Ackermann, which for any input you will ever see is below five.
Choose union-find over BFS when the graph is being built incrementally. Choose BFS when you also need distances, which union-find cannot give you.
class UnionFind {
constructor(n) {
this.parent = Array.from({ length: n }, (_, i) => i);
this.size = new Array(n).fill(1);
this.groups = n;
}
find(x) {
// Path compression: point everything on the way up straight at the root.
while (this.parent[x] !== x) {
this.parent[x] = this.parent[this.parent[x]];
x = this.parent[x];
}
return x;
}
union(a, b) {
let rootA = this.find(a), rootB = this.find(b);
if (rootA === rootB) return false; // already together
// Union by size: hang the smaller tree under the bigger one.
if (this.size[rootA] < this.size[rootB]) [rootA, rootB] = [rootB, rootA];
this.parent[rootB] = rootA;
this.size[rootA] += this.size[rootB];
this.groups--;
return true; // a real merge happened
}
connected(a, b) { return this.find(a) === this.find(b); }
}
const uf = new UnionFind(6);
uf.union(0, 1); uf.union(1, 2); uf.union(4, 5);
console.log('0 and 2 connected:', uf.connected(0, 2)); // true
console.log('0 and 4 connected:', uf.connected(0, 4)); // false
console.log('components:', uf.groups); // 3: {0,1,2} {3} {4,5}
// The boolean return of union() detects a cycle in one pass.
function hasCycle(n, edges) {
const find = new UnionFind(n);
for (const [a, b] of edges) if (!find.union(a, b)) return true;
return false;
}
console.log('cycle in a triangle:', hasCycle(3, [[0, 1], [1, 2], [2, 0]])); // true
console.log('cycle in a line: ', hasCycle(3, [[0, 1], [1, 2]])); // false
Output
Cost. Effectively O(1) amortised per operation with both optimisations, formally O(α(n)). O(n) space. Processing m edges is O(m) for practical purposes.
Problems this unlocks (9)
- Number of Connected Components
- Redundant Connection
- Graph Valid Tree
- Accounts Merge
- Number of Islands
- Number of Provinces
- Most Stones Removed
- Satisfiability of Equality Equations
- Smallest String With Swaps
Backtracking
Lesson 19Reach for it when: All or every: all subsets, every permutation, all valid arrangements. Combined with small constraints, typically n ≤ 20, because the output itself is exponential.
One template: choose, explore, un-choose. Push a choice onto your working state, recurse, then pop it before trying the next option. Missing the pop leaks choices between branches, and that is the bug in almost every broken attempt.
Three variations cover the family. i + 1 in the recursive call means each element is used at most once. Passing i allows reuse. Starting from 0 every time, 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, because a repeat deeper in the tree is legitimate while a repeat as a sibling duplicates a whole branch.
Pruning is what makes it usable. Abandon a branch the moment it cannot lead to an answer.
// The template. Everything below is this with different details.
function subsets(nums) {
const results = [];
function walk(start, current) {
results.push([...current]); // COPY, or every result aliases
for (let i = start; i < nums.length; i++) {
current.push(nums[i]); // choose
walk(i + 1, current); // explore; i + 1 = no reuse
current.pop(); // un-choose
}
}
walk(0, []);
return results;
}
// Permutations: start from 0 each time, track what is taken.
function permutations(nums) {
const results = [], 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++) {
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;
}
// Reuse allowed, plus a prune that makes it practical.
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 everything after 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;
}
console.log(subsets([1, 2, 3]).length, 'subsets of 3'); // 8
console.log(permutations([1, 2, 3]).length, 'permutations'); // 6
console.log(combinationSum([2, 3, 6, 7], 7)); // [[2,2,3],[7]]
console.log(combinationSum([2], 1)); // []
Output
Cost. O(n × 2ⁿ) for subsets, O(n × n!) for permutations. Space is O(n) for the recursion and working state, not counting the output. Exponential is the correct answer here, not an apology.
Problems this unlocks (11)
- Subsets I and II
- Permutations I and II
- Combination Sum I to IV
- Letter Combinations of a Phone Number
- Generate Parentheses
- Palindrome Partitioning
- Word Search
- N-Queens
- Sudoku Solver
- Restore IP Addresses
- Combinations
Dynamic programming
Lesson 20Reach for it when: Count the ways, minimum cost, maximum value, is it possible — combined with choices that overlap, so the same subproblem is reached along different routes. If a recursive solution is correct but recomputes the same call, that is the tell.
Write the recursion first, honestly and slowly. Then notice you are solving the same subproblem repeatedly, and cache it. That is memoisation, and it is usually enough. Converting to a bottom-up table afterwards is an optimisation, not a separate skill — and it is what lets you then drop the table to a couple of variables.
The work is in defining the state. Ask what arguments a subproblem needs, and keep that list as short as possible: every extra dimension multiplies the cost. "The best answer using the first i items with c capacity left" is a two-dimensional state; if you find yourself needing three, check whether one is derivable from the others.
Recognising the classic shapes saves the most time: linear (climbing stairs, house robber), knapsack (subset sum, coin change), two sequences (edit distance, longest common subsequence), intervals on a string (palindromes), and grid paths.
// Step 1: the honest recursion, memoised. Usually where you should stop.
function coinChangeMemo(coins, amount, memo = new Map()) {
if (amount === 0) return 0;
if (amount < 0) return Infinity;
if (memo.has(amount)) return memo.get(amount);
let best = Infinity;
for (const coin of coins) {
best = Math.min(best, 1 + coinChangeMemo(coins, amount - coin, memo));
}
memo.set(amount, best);
return best;
}
// Step 2: the same recurrence bottom-up. No recursion, no stack limit.
function coinChangeTable(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];
}
// Step 3: when a row only needs the row before it, keep two variables.
function climbStairs(n) {
let twoBack = 1, oneBack = 1;
for (let i = 2; i <= n; i++) {
const current = oneBack + twoBack;
twoBack = oneBack;
oneBack = current;
}
return oneBack; // O(1) space
}
// Two sequences: a 2D table, one axis each.
function longestCommonSubsequence(a, b) {
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 the diagonal
: Math.max(table[i - 1][j], table[i][j - 1]); // skip one or the other
}
}
return table[a.length][b.length];
}
console.log(coinChangeTable([1, 3, 4], 6)); // 2, not 3
console.log(coinChangeTable([2], 3)); // -1
console.log(coinChangeMemo([1, 3, 4], 6)); // 2
console.log(climbStairs(10)); // 89
console.log(longestCommonSubsequence('abcde', 'ace')); // 3
console.log(longestCommonSubsequence('abc', 'def')); // 0
Output
Cost. O(states × work per state). One dimension of size n with k choices is O(nk); two sequences is O(mn). Space matches the table, often reducible to one or two rows.
Problems this unlocks (14)
- Climbing Stairs
- House Robber I and II
- Coin Change I and II
- Longest Increasing Subsequence
- Longest Common Subsequence
- Edit Distance
- Word Break
- Partition Equal Subset Sum
- Unique Paths
- Minimum Path Sum
- Longest Palindromic Substring
- Best Time to Buy and Sell Stock III and IV
- Decode Ways
- Target Sum
How to use this to get faster
Recognition is a lookup you are building in your own head, and it is built by labelling, not by reading. Three habits do most of the work.
- Name the pattern before you code. Say it out loud: “this is a sliding window because it says contiguous”. If you cannot name one, say what you can see and start with brute force — an
O(n²)answer you can then improve beats silence. - Label every problem you finish. One line: the problem, the pattern, and the detail that tripped you. After forty problems that list is worth more than any article, because it is a map of your own gaps.
- Reproduce the templates from memory weekly. Not reading them, writing them. Binary search, BFS, the heap, union-find and the backtracking skeleton should cost you no thinking time at all, so all of it goes to the part of the problem that is actually new.
When you can look at a problem statement and name the pattern in under a minute, most of the interview difficulty is already gone. The problem set is grouped by pattern for exactly this reason — do them in groups, and the shape becomes visible in a way that random practice never produces.