Quick-reference mental models and decision rules โ not a full learning sheet, a "remember this the instant you see the shape" cheat sheet.
-1 on edges AND source/target (#2)visited.add(start) ยท Dijkstra dist[src] = 0 (#3)parent[] to rebuild the path) ยท choices on the path change the next move โ backtracking (#5)O(1) per query (#7)if (d > dist[u]) continue โ TC O(E log E) ยท SC O(V + E) (#9)Math.min ยท via outermost (#13)dist[node][extra] (#14)r * cols + c (305) ยท new node count++, real union count-- (#15)n โ 1 edges + connected (#18)+2 per needed edge (Trees #4)O(MN) (#20, #21)๐ง Blocked check first: the problem mentions a wall, thorn, obstacle or -1? Check the start and the end before any BFS/DFS. Blocked โ return the answer right away. Don't go down the search.
if (isWall(start) || isWall(end)) return -1 // before BFS/DFS
๐งญ Directions: always Top โ Right โ Down โ Left (clockwise):
const dirs = [
[-1, 0], // top
[ 0, 1], // right
[ 1, 0], // down
[ 0, -1], // left
]
-1 on Edges AND on Source/TargetArrays are 0..n-1, so every label from the input gets -1, in two places:
graph[u - 1].push([v - 1, w])dist[k - 1] = 0, pq.enqueue([0, k - 1]). This is the one people forget, because it sits outside the edge loop.dist[n] is out of bounds but JS doesn't throw (JS Gotchas #1).-1 on the edges AND on the source/target.visited, Dijkstra distvisited (array or set).dist = Infinity everywhere, then dist[src] = 0 and push the start.// BFS
queue = [start]; visited.add(start) // mark when you push, not when you pop
// Dijkstra
dist.fill(Infinity); dist[src] = 0; pq.enqueue([0, src])
visited, Dijkstra โ dist[src] = 0.visited vs parent?| Case | Use | Why |
|---|---|---|
| ๐ณ Tree | parent (visited also works) | each node has one parent, the only way back is where you came from |
| ๐ฒ๐ฒ Forest | parent + visited | visited tells the outer loop which trees are done |
| โก๏ธ DAG (no cycle) | visited | a node can have many parents, so it is reached many times ๐ |
| ๐ Any graph, just traversing | visited | seen it โ skip |
| ๐ Undirected, find a cycle | visited + parent | a seen node that isn't my parent โ cycle (#17) |
| โก๏ธ Directed, find a cycle | 3 states (0/1/2) or Kahn's | hit a 1 โ cycle ยท Kahn's processed < n โ cycle (#17) |
๐ DAG diamond: 0โ1โ3 and 0โ2โ3, so 3 is reached twice. Chain 20 diamonds โ 4,194,301 DFS calls without visited, 61 with it (checked in Node).
parent only, on a graph with a loop (0 โ 1 โ 2 โ 0) โ infinite loop. Parent stops one step back, visited stops every step back. Exception: LC 797 All Paths skips visited on purpose, because it wants every path.parent. Anything else โ visited. Undirected cycle โ add parent. Directed cycle โ 3 states or Kahn's.| The question asks for | Use | Examples |
|---|---|---|
| ๐ข Fewest steps from source to target (unweighted) | BFS, mark visited on push ยท need the path too? store parent[next] = cur and walk back | Rotting Oranges, Word Ladder |
| ๐ Visit everything / count groups (components) | BFS or DFS, mark visited forever | Number of Islands |
| ๐ Choices on the current path change what you can do next (a cell can't be reused on this path), or you list every valid path | Backtracking = DFS + undo: mark โ explore โ unmark | Word Search, Max Gold, Unique Paths III |
parent[] if you need the path). Choices on the path change the next move โ backtracking. BFS holds many paths at once in its queue, so it can't undo one.๐ฏ When: in a BFS, one node can jump to MANY nodes that share a key (same value, same prime factor). Re-scanning the whole array from each such node is O(nยฒ).
๐ LC 3629 (Prime Teleportation): jump iยฑ1, or, when nums[i] is a prime p, to any j with nums[j] % p === 0.
โ Fix: build the buckets once, and delete a bucket the first time you use it:
// prime -> [indices whose value has this prime factor], built once before BFS
const nextIdxs = primeToIdx.get(currVal)
for (const idx of nextIdxs ?? []) { /* visit */ }
primeToIdx.delete(currVal) // every index in it is already queued
๐ Why O(n log maxVal): each bucket is read once in the whole BFS, so total work = sum of bucket sizes. A number with k distinct primes is โฅ 2^k, so k โค logโ(maxVal) buckets per number.
๐ LC 1462 Course Schedule IV: many "is u a prerequisite of v?" queries on one graph.
Q ร (V+E)reach[u][v] (a reachability table, not the adjacency matrix). After that, each query is O(1).reach costs Vยฒ memory: fine for n = 100, impossible for n = 10โต. There, BFS per query.O(1).| Edges | Use |
|---|---|
| All cost 1 | ๐ข BFS |
| Different weights, all โฅ 0 | ๐ต Dijkstra (#9) |
| Negative weights, or "at most K stops" | ๐ Bellman-Ford (#12) |
| Every pair of nodes | ๐ฃ Floyd-Warshall (#13) |
| Connect everyone cheapest (not a path) | ๐ฒ Kruskal's MST (#16) |
Why BFS breaks with weights: BFS trusts "visited first = shortest". ๐ startโA costs 10, startโBโA costs 1+1 = 2. BFS reaches A first by the direct edge, locks in 10, and the cheaper path via B is blocked.
O(E log E) ยท ๐พ SC = O(V + E)TC, counted from edges:
E pushes.E entries costs log E โ E ร log E.O(V) to set up dist โ strictly O(V + E log E). Usually written O(E log E) = O(E log V) (since E โค Vยฒ makes log E โค 2 log V). The V only matters with many isolated nodes.SC: adjacency list O(V + E) + dist[] O(V) + heap up to E entries โ O(V + E).
A node can sit in the heap several times, once per cheaper path found. Only the entry matching dist[node] is current. Skip the rest:
if (currTime > dist[node]) continue // stale: a cheaper path already won
O(E log E), SC O(V + E). Pop โ stale? skip. Expand a node only at its lowest cost.๐ฏ All THREE use Dijkstra. The ONE difference: can you WAIT in place?
if (neiTime <= currTime+1) nextTime = currTime+1
else if ((neiTime-currTime)%2===0) nextTime = neiTime+1
else nextTime = neiTime
โ ๏ธ Special case: (0,0) has NO parent to bounce with. If BOTH its neighbors need >1 to enter, you're stuck immediately:
if (grid[0][1]>1 && grid[1][0]>1) return -1
โ 3341/3342 (Reach Last Room I/II): FREE waiting. Just sit and wait, then move:
nextTime = max(currTime, moveTime[nr][nc]) + moveCost
+1. 3342 = alternates +1/+2 based on (row+col)%2.๐ฏ "Can I reach X using only edges โค mid?" โ BFS. "What's the CHEAPEST path?" โ Dijkstra.
๐ Binary search already picked the max allowed weight and filtered the graph, so the check only needs YES/NO reachability.
๐ LC 3419 (minimize the max edge weight so every node can reach node 0): check(mid) = keep edges โค mid, reverse them, BFS from 0, did it reach every node? No distances needed.
๐จ Negative weights โ Bellman-Ford. Dijkstra treats a popped node as final, and a negative edge found later can break that. Relax every edge for Vโ1 rounds, O(VยทE). If an extra round still improves something โ a negative cycle reachable from the source.
๐ฏ At most K stops (LC 787): K stops = K+1 edges = K+1 rounds.
for (let round = 0; round <= k; round++) {
const prev = [...dist] // read last round only
for (const [u, v, w] of flights)
if (prev[u] + w < dist[v]) dist[v] = prev[u] + w
}
prev?Each round allows at most one more edge. Read from dist while you update it, and a value set earlier in the same round is used again, so one round walks several edges.
LC 787, k = 1
edges: 0โ1 100, 1โ2 100,
2โ3 200, 1โ3 600
โ no prev, round 1:
dist[1]=100 โ dist[2]=200 โ dist[3]=400
3 edges in 1 round = 2 stops
โ
prev
round 1: dist[1]=100
round 2: dist[2]=200, dist[3]=700
K+1 rounds, each reading from prev, so a round adds only one edge.Math.min, via Outermost๐ฏ Shortest distance between EVERY pair of nodes, O(Vยณ).
dist (n ร n)dist[i][i] = 0 (top-left โ bottom-right). The distance from a node to itself is 0.Number.MAX_SAFE_INTEGER or Infinity).[u, v, c] โ keep the cheaper: dist[u][v] = Math.min(dist[u][v], c). The same u โ v can come twice with different costs. Undirected โ set dist[v][u] too.ch.charCodeAt(0) - 97, name โ a Map of name โ index.via outermost)๐ Your LC 2976 (Minimum Cost to Convert String I), with letters as nodes:
const dist = Array.from({length: 26}, () => Array(26).fill(Number.MAX_SAFE_INTEGER));
for (let i = 0; i < 26; i++) dist[i][i] = 0; // diagonal
for (let i = 0; i < m; i++) {
const u = ori[i].charCodeAt(0) - 97; // letter โ index
const v = ch[i].charCodeAt(0) - 97;
dist[u][v] = Math.min(dist[u][v], cost[i]); // keep the cheaper edge
}
for (let via = 0; via < 26; via++) // MUST be outermost
for (let s = 0; s < 26; s++)
for (let d = 0; d < 26; d++)
if (dist[s][via] !== Number.MAX_SAFE_INTEGER && dist[via][d] !== Number.MAX_SAFE_INTEGER)
dist[s][d] = Math.min(dist[s][d], dist[s][via] + dist[via][d]);
// answer: sum dist[src[i]][tar[i]], any MAX_SAFE_INTEGER โ return -1
๐ก๏ธ Why the !== guard: MAX_SAFE_INTEGER + anything is past the safe range. With Infinity you can drop the guard, because Infinity + x stays Infinity.
Math.min on edges โ a later, costlier duplicate overwrites the cheaper one: aโb 2, then aโb 5 โ keeps 5 โ. No 0 on the diagonal โ "a to a" stays "no path".Checked in Node: your code gives the LC 2976 answers 28, 12, โ1 โ
. The duplicate above gives 2 with Math.min and 5 without.
Math.min (both ways if undirected) ยท via (middle stop) outermost.dist[node][extra]โ Ask: "can I decide the next move from the node alone?"
dist[node]dist[node][extra]๐ฐ Layered graph: every (node, extra) pair is its own node in a bigger graph. Run plain Dijkstra on it, nothing else changes. Same for BFS: queue [node, extra], visited[node][extra].
normal: node 1 has ONE cost
states: (1, run 1) and (1, run 2) each have their OWN cost
๐ Why dist[node] breaks (LC 3970, at most k = 2 same labels in a row):
labels: 0=a 1=a 2=b 3=a
edges: 0โ1 (1) ยท 0โ2 (5) ยท 2โ1 (5) ยท 1โ3 (1)
๐ cheap: 0โ1, cost 1, run "aa" = 2 โ 1โ3 makes "aaa" โ
๐ข dear: 0โ2โ1, cost 10, run "a" = 1 โ 1โ3 โ
total 11
dist[1] = 1 already โ cost 10 is thrown away โ returns -1 โ (real: 11)
โ
Fix: one cost per (node, count):
const dist = Array.from({ length: n }, () => Array(k + 1).fill(Infinity))
dist[0][1] = 0
if (currDist > dist[node][count]) continue // stale per (node, count)
if (neiDist < dist[nei][newCount]) {
dist[nei][newCount] = neiDist
pq.enqueue([neiDist, nei, newCount])
}
// answer = Math.min(...dist[n - 1])
๐จ Trigger words:
dist[node][stops] (LC 787, or Bellman-Ford #12)dist[node][count] (LC 3970)dist[cell][removalsLeft] (LC 1293, BFS)dist[node][fuel], dist[node][keysMask] (LC 864, BFS)โฑ๏ธ States = n ยท k โ TC O(Eยทk log(Eยทk)). Same question as the param test in Recursion #1: does the future need it? Checked in Node: on 5,000 random graphs, dist[node] was wrong 7 times, dist[node][count] never.
๐ฏ The Union-Find engine never changes: new node โ count++, real union (two different roots) โ count--. What changes is what you call a node.
| ๐ชจ LC 947 Stones Removed | ๐๏ธ LC 305 Islands II ๐ | |
|---|---|---|
| Joined when | same row or same column | cells touch (4 directions) |
| ๐ Node | a row or a column, not the stone | a cell, id = r * cols + c |
| ๐ Union | each stone joins its row and its column | new land + each neighbour that is land |
| ๐ฏ Answer | stones โ components, once at the end | count after every add |
// 947: stone = edge (row, col)
uf.add(r); uf.add(c + 10001); uf.union(r, c + 10001)
// 305: a cell is a node
const id = r * cols + c
๐ชจ Why stones โ components: in a group of k connected stones you can remove all but one, so each group gives k โ 1.
๐๏ธ Why Union-Find, not BFS: land arrives one cell at a time. BFS would rescan the grid after every add. Union-Find only joins the new cell to its neighbours.
c + 10001, coordinates go up to 10โด), or row 1 and column 1 become the same node. Checked in Node: stones [[0,1],[1,0]] โ 1 without the shift, 0 (correct) with it. 305: a position can repeat. Re-adding land must not change the count.๐ LC 305 is Premium, free copy: LintCode 434. Checked in Node: 947 examples โ 5, 3, 0 ยท 305 example โ [1, 1, 2, 3]. Union-Find also powers Kruskal (#16) and the undirected cycle check (#17).
(r, c) to one id.๐ฏ Connect ALL nodes (directly or through others) with the MINIMUM TOTAL edge cost. Not "shortest path between two nodes": that's Dijkstra, a different question. Dijkstra adds up distance from a source; MST doesn't care about distance from anywhere.
Problems: LC 1584 Min Cost to Connect All Points, LC 1135 Connecting Cities With Minimum Cost ๐.
removeStones, magnificentSets). Prim's: just know it exists.// general MST: n nodes, edges = [u, v, weight]
function minCostToConnectAllNodes(n: number, edges: number[][]): number {
const parent = Array.from({ length: n }, (_, i) => i);
const rank = Array(n).fill(0);
function find(x: number): number {
let root = x;
while (parent[root] !== root) root = parent[root];
while (parent[x] !== root) {
const next = parent[x];
parent[x] = root;
x = next;
}
return root;
}
function union(x: number, y: number): boolean {
const px = find(x), py = find(y);
if (px === py) return false;
if (rank[px] < rank[py]) parent[px] = py;
else if (rank[px] > rank[py]) parent[py] = px;
else { parent[px] = py; rank[py]++; }
return true;
}
edges.sort((a, b) => a[2] - b[2]); // sort by weight ascending
let totalCost = 0, edgesUsed = 0;
for (const [u, v, weight] of edges) {
if (union(u, v)) {
totalCost += weight;
edgesUsed++;
if (edgesUsed === n - 1) break; // tree complete
}
}
return edgesUsed === n - 1 ? totalCost : -1; // -1 if graph can't be fully connected
}
Kruskal's skips any edge that would make a cycle. "Would it make a cycle?" = "are u and v already in the same group?" โ union(u, v) returns false. So every Kruskal-style question is sort, then union in order.
| Question | Kruskal twist |
|---|---|
| LC 1584 Min Cost to Connect All Points | plain MST over all point pairs |
| LintCode 629 Minimum Spanning Tree | plain MST, city names โ index first |
| LC 1579 Remove Max Edges to Keep Graph Fully Traversable | 2 Union-Finds (Alice, Bob), shared type-3 edges first ยท failed union = removable edge |
| LC 1489 Critical & Pseudo-Critical Edges in MST | rerun Kruskal per edge: skip it โ cost goes up = critical ยท force it in โ same cost = pseudo-critical |
| LC 3600 Maximize Spanning Tree Stability | maximize the weakest edge โ required edges first, then strongest first ยท upgrade the k weakest chosen edges |
| LC 778 Swim in Rising Water | sort cells by height, union land neighbours, stop when start and end connect โ that height |
Checked in Node: all 6 Kruskal versions give the LC example answers (1584 โ 20 ยท 1579 โ 2, 0, โ1 ยท 1489 โ [[0,1],[2,3,4,5]] ยท 3600 โ 2, 6, โ1 ยท 778 โ 3, 16).
1. DFS + parent-skip:
dfs(node, parent):
visited[node] = true
for nei in graph[node]:
if nei === parent: skip
if visited[nei]: CYCLE
dfs(nei, node)
2. BFS + parent-skip: same idea, queue holds (node, parent) pairs instead of recursion.
visited + parent. visited finds the cycle (a second path to a seen node). parent stops a false alarm: edge uโv is stored both ways, so v always sees u as visited. Duplicate edges (uโv twice)? Skip the parent edge id, not the parent node.3. Union-Find:
for each edge (u,v):
if find(u) === find(v): CYCLE
else union(u,v)
1. DFS with 0/1/2 marking:
state: 0=unvisited, 1=exploring, 2=done
dfs(node):
state[node] = 1
for nei in graph[node]:
if state[nei]===1: CYCLE (back edge to node still exploring)
if state[nei]===0: dfs(nei)
state[node] = 2
2. Kahn's (BFS topological sort): keep peeling nodes with indegree 0. Nodes on a cycle, and nodes that come after one, never reach 0.
queue = all nodes with indegree 0
process queue, decrementing neighbors' indegree, adding new indegree-0 nodes
if processedCount < n: CYCLE (leftovers = on a cycle or after one)
processed < n โ cycle). Union-Find can't tell direction, so it can't detect directed cycles.๐ฏ Any tree (binary, ternary, any number of children) is valid when:
Binary vs ternary only limits children per node. It doesn't change what makes it a tree.
๐ ๏ธ How to check:
n โ 1 edges + connected (no cycle follows from that)๐ฏ Eulerian path = use every EDGE once, not every node. Push the node to the result on the way OUT (post-order), then reverse at the end.
โฑ๏ธ TC: the walk itself is O(E), because each edge is removed once (graph[node].pop()) and a node is visited once per outgoing edge. LC 332 also sorts each airport's tickets first โ O(E log E) end to end.
โก๏ธ Add an airport to res ONLY after all its tickets are used. The first time you get stuck, you are at the final stop, so the stuck node goes in first. res builds the trip backwards โ reverse at the end.
tickets: JFKโA, JFKโB, BโJFK
(smallest first: A before B)
โ pre-order (add on arrival)
JFK โ A โ B โ JFK
no ticket AโB! A written too early
โ
post-order
res = [A, JFK, B, JFK]
reverse โ JFK โ B โ JFK โ A
O(E) (LC 332 sorts first โ O(E log E)). ๐ง Dead end = finish line โ write it first, read it backwards.๐ฏ V+E really means V ร (work per node) + E ร (work per edge).
โ
Work per node/edge is O(1) (a comparison, a neighbour check) โ just write V+E.
๐ Example โ removeInvalidParentheses (n = string length): up to 2^n states, each makes up to n children (edges), and each child is an O(n) string slice.
Total = 2^n states ร n children ร O(n) slice = O(2^n ยท nยฒ)
V+E. O(k) โ multiply k in.Grid MรN: V = MN, E โค 4MN โ V+E = O(MN) (the 4 drops)
Dense: E = O(Vยฒ) โ max for a simple graph
Sparse: E = O(V)
Tree: E = V โ 1 โ always sparse
๐ฏ Values between V and Vยฒ exist (V log V, VโV), but interviews only need the two bounds. Union-Find follows the same split: O(E ยท ฮฑ(V)) โ sparse โ O(V), dense โ O(Vยฒ).
O(MN).+2 per needed edge (#4)โlogโ nโ ยท Perfect every level full, 2สฐโบยน โ 1 nodes (#5)Inorder on a BST = sorted order. Use for:
Trees only give CHILD pointers (left/right) โ no way UP.
๐ฏ Don't build a full adjacency-list graph โ just add the ONE missing piece: parent.
๐ One DFS pass, passing (node, parent), building parentMap.set(node, parent).
left, right, AND parentMap.get(node) = 3 directions.Used for: All Nodes Distance K in Binary Tree (863) โ any problem needing "go up" in a tree.
๐ฏ Don't make dfs return "time to reach here". Return does this subtree have an apple? A child says yes โ you must walk that edge down and back โ totalTime += 2 (LC 1443).
// dfs(node, parent) -> tell if subtree has apple or not
let currTreeHasApple = hasApple[node]
for (const nei of graph[node]) {
if (nei === parent) continue
if (dfs(nei, node)) { totalTime += 2; currTreeHasApple = true }
}
return currTreeHasApple
โฑ๏ธ TC O(n): each node once (parent guard), edges scanned once in total, E = n โ 1. SC O(n): adjacency list + recursion stack (path-shaped tree).
| Shape | Rule | Height (n nodes) |
|---|---|---|
| ๐ข Full | every node has 0 or 2 children | no promise: log n โฆ (n โ 1) / 2 |
| ๐ต Complete | every level full except the last, filled left โ right | โlogโ nโ |
| ๐ฃ Perfect | every level full | logโ(n + 1) โ 1 ยท nodes = 2สฐโบยน โ 1, leaves = 2สฐ |
log n, so push/pop is O(log n), and it fits in an array (children of i = 2i + 1, 2i + 2).n โ 1 โ BST ops become O(n).log n. Anything else โ can be n.b^d ยท yes/no slots โ 2^slots ยท split point โ Catalan ยท else "exponential" (#6)b^d counts leaves. TC = nodes ร work per node (#7)(i, rule) (#10)dp[i] = best ending at i; prevIdx[] rebuilds the items (#11)max(1, need ahead โ cell) (#13)best.๐ผ๏ธ Min path sum on [[1, 3], [2, 1]] (move right or down). All three give 4.
๐ค Which one? Ask: can a loser now become a winner later?
+, min, max), one number โ ๐ ask โ memo โ DP (LC 64)1 ^ 2 = 3 worse, 3 ^ 2 = 1 better) โ ๐ tell + return (LC 3882)// ๐ LC 3882 Min XOR Path (bounds checks left out)
const y = x ^ grid[r][c]; // โฌ๏ธ tell: XOR so far
if (r === m - 1 && c === n - 1) return y; // ๐ last cell returns the finished XOR
best = Math.min(dfs(r + 1, c, y), dfs(r, c + 1, y)); // ๐ pick among finished answers
๐งช Why XOR can't just ask: [[2,7,1],[4,5,3]]. Keeping only the smallest XOR gives 3. The real answer is 0 (2 โ 4 โ 5 โ 3).
๐งช Param test: "does the FUTURE need this to decide?"
row, prevCol, XOR so far x) โ keep it, that's the state. x < 1024 โ memo [r][c][x] fits (Math #4)currSum) โ drop it and ask instead. With the past in the params, nothing repeats and the memo never hits.๐ฏ The REAL question: does my function have an EXPLICIT base case, or is the "seed value" secretly ACTING as the base case?
โ
Explicit base case exists (if(...) return 0; / a clean, dedicated return) โ sentinel-seeding is SAFE, don't overthink it. The base case ALWAYS overrides the sentinel with a real answer.
function dfs(row, col) {
if (invalid) return 0; // explicit base case, resolves cleanly
...
let maxGold = 0; // sentinel is fine, loop always gets overridden
for (...) maxGold = Math.max(maxGold, dfs(...));
return currGold + maxGold; // add currGold ONCE, here
}
function dfs(row, col) {
// no explicit base case โ dead end = loop simply doesn't run
let maxGold = currGold; // seed = the correct dead-end answer itself
for (...) maxGold = Math.max(maxGold, currGold + dfs(...));
return maxGold;
}
used[], Reuse and Duplicates: Ask 3 Questions Before the Loop๐ฏ Before writing any backtracking code, answer three questions in order. Together they decide the whole loop: where it starts, what you recurse with, and whether you need used[] or a skip.
Test with a tiny example: is [1,2] the same answer as [2,1]?
0 every call (any element can follow any element)start (never look back at earlier indices)[1,2] and [2,1] as 2 separate ways, not 1. Trust the tiny example, not the title.// Combination: reuse is decided by what you recurse with
for(let i = start; i < n; i++) dfs(i, ...); // reuse allowed (Combination Sum)
for(let i = start; i < n; i++) dfs(i + 1, ...); // no reuse (Combination Sum II, Subsets)
// Permutation, NO reuse: used[] blocks an element already on the path (Permutations)
for(let i = 0; i < n; i++) {
if(used[i]) continue;
used[i] = true;
path.push(nums[i]);
dfs();
path.pop();
used[i] = false;
}
// Permutation, reuse allowed: no used[] at all (Combination Sum IV)
for(let i = 0; i < n; i++) dfs(...);
0 WITHOUT used[] lets the same element be picked again. That's right for Combination Sum IV, but in plain Permutations [1,2,3] it produces [1,1,1].Sort first, then skip a repeated value at the SAME level of the tree:
nums.sort((a, b) => a - b);
// Combination / Subset (Subsets II, Combination Sum II)
if(i > start && nums[i] === nums[i - 1]) continue;
๐ Permutations II (LC 47) โ same sort, then two used[] checks:
for(let i = 0; i < n; i++) {
if(used[i]) continue; // already in my path
if(i > 0 && nums[i] === nums[i - 1] && !used[i - 1]) continue; // same value as left twin, twin not in my path
used[i] = true; path.push(nums[i]);
backtrack();
path.pop(); used[i] = false;
}
๐ Can't sort? Use a LOCAL Set instead (LC 491 Non-decreasing Subsequences โ sorting would destroy the subsequence order). Create it INSIDE each call, right before the loop:
const seen = new Set(); // fresh per call
for(let i = start; i < n; i++) {
if(seen.has(nums[i])) continue; // already tried this VALUE at this level
seen.add(nums[i]); // never removed โ no undo
path.push(nums[i]); dfs(i + 1); path.pop();
}
[4,7] after [4,6,7] used the 7, which is a different path and must stay allowed.0 or start), reuse picks i vs i + 1 (or used[] for permutations), duplicates add sort + skip. Three answers give you the whole loop.๐ฏ When the options are small integers (columns 0โฆnโ1, digits 1โฆ9), a Set of them fits in one integer: number x is in the set โ bit x is ON. Same algorithm, cheaper operations. (#8 uses a mask to memoize; here it only speeds up plain backtracking.)
bit = 1 << x
set.has(x) โ mask & bit
set.add(x) โ mask |= bit
set.delete(x) โ mask &= ~bit
// Set
const cols = new Set<number>()
cols.has(col)
cols.add(col)
cols.delete(col)
// Bitmask
let cols = 0
cols & (1 << col)
cols |= 1 << col
cols &= ~(1 << col)
diag1 and diag2 work exactly the same (with d1 and d2). ๐ Example: queens in columns 1 and 3 โ bits 1010 = 10.
// Set
const rows = Array.from({length: 9}, () => new Set<string>())
rows[r].has('5')
// Bitmask
const rows = new Array(9).fill(0)
const bit = 1 << Number('5')
rows[r] & bit
rows[r] |= bit
rows[r] &= ~bit
'5'), but a bit position needs a number โ convert with Number(ch). Same for cols[9] and blocks[9].0โฆ31: 1 << 32 wraps back to 1. N-Queens diagonals use bits up to 2nโ2, so n โค 16 is safe and n = 17 breaks.x on means x is taken. Same algorithm, smaller constant."๐งญ Which pattern? Ask in order:
๐ A shopping list is one person going item by item, so the state is an index. โ๏ธ Chess has two players alternating, so you ask ONE question: "Is there a move after which my opponent loses?"
canWin(state):
for each move:
if (!canWin(stateAfterMove)) return true // opponent is stuck, I win
return false // no good move, I lose
// Score games: best = max over moves of (gain - dfs(stateAfterMove)) โ #14
โ ๏ธ Check the base case in the rules. "No moves left โ lose" fits Flip Game II and Stone Game IV. In Can I Win, reaching the target wins.
๐ State = what's left after a move. Clean (only ends or front) โ i or (i, j). Messy โ the whole board, or a bitmask (#4).
| Problem | State | Answer |
|---|---|---|
| Predict the Winner 486 | (i, j) | score difference |
| Stone Game III 1406 | i | score difference |
| Stone Game IV 1510 | stones left | win / lose |
| Can I Win 464 | bitmask | win / lose |
| Flip Game II 294 | whole board | win / lose |
n % 4 !== 0, Divisor Game 1025 is n even, Stone Game 877 is always true. (Nim-sum / Sprague-Grundy theory is not covered here.)canWin. Score โ max(gain โ dfs(next)).๐ฏ Ask these in order. Stop at the first one that fits.
Q1 โ Constant branching? (same # choices every call)
โ branches^depth. (take/skip โ 2โฟ)
O(4^(MรN))O(2^(M+N))Loose bounds โ actual cost depends on visited-tracking and work per call.
Q2 โ Variable branching, but is there a FIXED set of positions each getting an independent yes/no?
โ 2^(that count).
Stone Game II: n-1 gaps โ cut or not โ 2^(n-1)
LC 131 Palindrome Partitioning: n-1 gaps โ cut or not โ 2^(n-1)
Q3 โ Branching is a SPLIT POINT choice (pick k, recurse both sides)?
โ Catalan-shaped: dfs(i,j) = ฮฃ dfs(i,k) โ dfs(k,j)
function dfs(i: number, j: number): number {
if (i >= j) return 1;
let total = 0;
for (let k = i; k < j; k++) {
total += dfs(i, k) * dfs(k + 1, j); // k = split point
}
return total;
}
k = root (96 Unique BST), last-op (241), last-burst (312 Burst Balloons), matrix-split (Matrix Chain Mult โ the textbook Catalan recurrence itself).
None of the above fits?
โ Just say "exponential, no clean polynomial form" โ honest, accepted answer.
LC 301 Remove Invalid Parentheses โ removable set shifts as string changes
LC 3629 prime teleport (brute force) โ branch count varies per index
LC 291 Word Pattern II, LC 139 Word Break (brute) โ branch = segment length, a range not a yes/no
2^slots. Split-and-recurse-both-sides โ Catalan sum. Nothing fits โ say "exponential."bแต Counts LEAVES, Not Every Node๐ฏ 2โฟ (take/skip) and nแต (a for loop inside recursion) are the number of leaves. The tree has more nodes than that, but not a different big-O.
branching b โฅ 2, depth d
leaves = b^d
nodes = 1 + b + โฆ + b^d = (b^(d+1) โ 1) / (b โ 1) โค 2ยทb^d
take/skip: leaves 2^n, nodes 2^(n+1) โ 1 โ still O(2^n)
permutations: leaves n!, nodes โ eยทn! โ still O(n!)
๐ Why: the last level is bigger than all the levels above it put together, so it decides the big-O.
โ๏ธ What actually changes the answer is the work per node:
O(bแต)O(n ยท bแต)O(n ยท bแต)d + 1 nodes. Count nodes only when work per node is not the same everywhere. See #6 for the bแต shapes.bแต, so the only thing left to ask is the work at each node.๐ฏ Read the question TWICE before any recursion or DP:
dfs RETURNS.dfs params).๐งช Friend test: pause the recursion and hand it to a friend. The MINIMUM they need to finish the job is your state.
| Shape | State | Example |
|---|---|---|
| 1๏ธโฃ Position | i | House Robber 198 |
| 2๏ธโฃ Remaining value | amount | Coin Change 322 |
| 3๏ธโฃ Position + one constraint | (i, holding), (i, k) | Stock Cooldown 309 |
| 4๏ธโฃ Two sequences | (i, j) | LCS 1143 |
| 5๏ธโฃ Interval, try every split | (l, r) | Burst Balloons 312 |
| 6๏ธโฃ Grid | (r, c) | Min Path Sum 64 |
| 7๏ธโฃ Size, not position | n | Unique BST 96 |
| 8๏ธโฃ Set of used items | mask | Matchsticks to Square 473 |
k is the balloon burst LAST in (l, r), not first. Then its neighbours are fixed (lโ1, r+1), which is what makes the state work.holding).c2 = r1 + c1 โ r2).prev in LIS).childSums[])dp[child][mask].๐ Constraints hint the shape: n โค 20 โ mask ยท n โค 500 โ (l, r) ยท n โค 10โต โ 1D.
๐ฏ Workflow: write the recursion first, find its recurrence, then read direction + start straight off it โ don't guess either one.
Direction โ which way does the recurrence point?
dp[i] depends on dp[i+1], dp[i+2]... (LARGER index) โ loop i: n-1 DOWN TO 0
dp[i] depends on dp[i-1], dp[i-2]... (SMALLER index) โ loop i: 0 UP TO n
Starting index โ ask ONE question: is this index's value already hardcoded, or does the recurrence still need to compute it?
Already hardcoded outside the loop (a true base case, e.g. dp[n] = 0)
โ loop EXCLUDES it, starts one step further in
Not hardcoded โ computed BY the recurrence using a virtual sentinel
before/after the array (e.g. treating dp[-1] as 0)
โ loop INCLUDES it, starts right there
๐ Example โ hardcoded base (excluded from loop):
dp[n] = 0; // base case, set once, outside
for (i = n - 1; i >= 0; i--) { ... } // excludes n
๐ Example โ computed via sentinel (included in loop):
// dp[i] built from dp[i-1], "nothing before index 0" = sentinel 0
for (i = 0; i <= n; i++) {
dp[i] = (i === 0 ? 0 : dp[i - 1]) + something;
}
๐ฏ Skip/Take works when "take or skip" depends on ONE thing: comparing to the LAST picked element.
โ
LIS: "is nums[i] > nums[prev]?" โ one comparison, one reference point. Skip/Take works fine.
๐ 1027 Longest Arithmetic Subsequence: "does nums[i] continue the SAME DIFFERENCE this chain already has?" โ but a chain only HAS a difference once it's got 2+ elements. Skip/Take (state = just prev) can't express this.
i, do I need just the LAST element, or a RULE the chain already locked in?"
(i, prev)(i, thatRule), found via PAIRWISE scan, not skip/take๐ Problems in this "committed rule" family:
(j, i)Not in this family: 673 Number of LIS and 368 Largest Divisible Subset only compare with the last element, so they are plain LIS shape (#11).
๐ข LIS pseudocode (skip/take works โ state = just prev):
dfs(i, prevIdx):
if i === n: return 0
skip = dfs(i+1, prevIdx)
take = 0
if prevIdx === -1 or nums[i] > nums[prevIdx]:
take = 1 + dfs(i+1, i)
return max(skip, take)
๐ด 1027 Longest Arithmetic Subsequence โ recursive framing (state = committed rule, "ending at i"):
// dfs(i, diff) -> longest arithmetic subsequence ENDING AT index i, using common difference diff
memo = map, keyed by (i, diff)
dfs(i, diff):
if memo has (i, diff): return memo[(i, diff)]
best = 1 // just this element alone, base length
for j in 0..i:
if nums[i] - nums[j] === diff:
best = max(best, 1 + dfs(j, diff))
memo[(i, diff)] = best
return best
answer = max over all i, all diff = nums[i]-nums[j] for j<i, of dfs(i, diff)
i" is the STANDARD framing for this whole family โ anchor the state at the LAST index, then ask "for every earlier j, if I extend THAT chain (ending at j, same diff) by adding nums[i], what do I get?" State = (i, diff), not (i, prevIndex) โ the DIFFERENCE itself is what got "committed" and must be carried forward.dp[i][diff] = length of the longest arithmetic chain ending at i with common difference diff. Diff can be negative (-500..500), so offset it to use as an array index.function longestArithSeqLength(nums: number[]): number {
const n = nums.length;
// diff range -500 to 500
const OFFSET = 500;
const dp = Array.from({length: n}, () => Array(1001).fill(1));
let ans = 1;
for(let i = 0; i < n; i++) {
for(let j = 0; j < i; j++) {
const diff = nums[i] - nums[j];
const key = diff + OFFSET;
dp[i][key] = Math.max(dp[i][key], 1 + dp[j][key]);
ans = Math.max(ans, dp[i][key]);
}
}
return ans;
}
๐ Why dp[j][key] (same key): nums[i] - nums[j] = diff extends the chain ending at j with that SAME diff, so read dp[j] at the same key. It is the tabulated dfs(i, diff) above.
๐ฏ For LIS-shaped problems, the recursion you'd naturally write and the DP everyone actually uses are TWO DIFFERENT state definitions โ the DP is NOT a rolling-array compression of the recursion's memo table.
dfs(i, prevIdx), take/skip:dfs(i, prevIdx):
skip = dfs(i+1, prevIdx)
take = 0
if condition(nums[i], nums[prevIdx]):
take = 1 + dfs(i+1, i)
return max(skip, take)
โ O(2โฟ) raw. Memoized: state = (i, prevIdx) โ O(nยฒ) states ร O(1) work = O(nยฒ) time, O(nยฒ) space.
dp[i] = "best chain ENDING AT i", found by scanning j < i:for i in 0..n:
for j in 0..i:
if condition(nums[i], nums[j]) and dp[j]+1 > dp[i]:
dp[i] = dp[j] + 1
โ Also O(nยฒ), but with O(n) space โ a smaller, DIFFERENT state (dp[i] alone, no prevIdx dimension).
Problems in this family: LC 300 Longest Increasing Subsequence, LC 368 Largest Divisible Subset, LC 673 Number of LIS. LC 1027 also ends at i, but needs the diff in its state (#3).
%Sort first. Then nums[i] % nums[j] === 0 for j < i is enough, because divisibility carries along the sorted order. length[i] = longest chain ending at i, and prevIdx[] lets you walk back to rebuild the subset itself.
function largestDivisibleSubset(nums: number[]): number[] {
const n = nums.length;
nums.sort((a, b) => a - b);
const length = Array(n).fill(1);
const prevIdx = Array(n).fill(-1);
let maxLen = 1;
let lastChosenIdx = 0;
for (let i = 0; i < n; i++) {
for (let j = 0; j < i; j++) {
if (nums[i] % nums[j] !== 0) continue;
if (length[j] + 1 > length[i]) {
length[i] = length[j] + 1;
prevIdx[i] = j;
}
if (length[i] > maxLen) {
maxLen = length[i];
lastChosenIdx = i;
}
}
}
const res: number[] = [];
while (lastChosenIdx !== -1) {
res.push(nums[lastChosenIdx]);
lastChosenIdx = prevIdx[lastChosenIdx];
}
return res;
}
(i, prevIdx) take/skip) and the DP everyone actually codes (dp[i] ending-at-i, scan j<i) are different STATE DEFINITIONS that coincidentally match on TC โ not one compressed from the other. Need the items, not just the count? Keep prevIdx[] and walk back.๐ฏ Don't treat "min cuts" as a new problem. It's the SAME dfs shape as counting/listing palindrome partitions โ just optimize Math.min instead of collecting paths, then subtract 1 at the very end.
k palindrome pieces needs exactly k-1 cuts (fence-post logic โ 3 pieces, 2 gaps between them)."aab" โ pieces: ["aa","b"] โ 2 pieces โ 1 cut
Same skeleton for both LC 131 (list all) and LC 132 (min cuts):
function dfs(start: number): number {
if (start === n) return 0; // 0 pieces needed for empty suffix
let minPieces = Infinity;
for (let end = start + 1; end <= n; end++) {
if (isPalin[start][end - 1]) {
minPieces = Math.min(minPieces, 1 + dfs(end));
}
}
return minPieces;
}
return dfs(0) - 1; // pieces โ cuts
Math.min), then subtract 1 at the end to convert pieces โ cuts.๐ฏ dfs(r, c) = the minimum HP needed walking INTO this cell. Leap of faith: trust dfs to give the need for the rest of the trip.
// past the destination โ 1 (just survive). Off the grid โ Infinity
const neededAhead = Math.min(dfs(r + 1, c), dfs(r, c + 1))
return Math.max(1, neededAhead - dungeon[r][c])
โ Why subtract: damage -2 โ need goes UP by 2. Heal +8 โ need goes DOWN by 8. One formula covers both.
๐ก๏ธ Why max(1, โฆ): HP can never be 0 or below, even if a big heal "covers" everything ahead.
Math.min-the-opponentEvery move removes exactly ONE pile from either END (i or j) โ state stays a clean interval [i,j]. Two explicit choices only โ no loop needed. Opponent (next player) wants to MINIMIZE what's left for you:
function dfs(i: number, j: number): number {
// base case
if(i > j) return 0;
const pickStart = piles[i] + Math.min(dfs(i + 2, j), dfs(i + 1, j - 1));
const pickEnd = piles[j] + Math.min(dfs(i, j - 2), dfs(i + 1, j - 1));
return Math.max(pickStart, pickEnd);
}
Move size X isn't fixed (1..2M) โ single advancing pointer i, no "other end." For-loop tries every X, subtracts the opponent's best from here, takes the max:
function dfs(idx: number, m: number): number {
// base case
if(idx >= n) return 0;
if(idx + 2 * m >= n) return suffix[idx]; // must be suffix[idx], NOT suffix[0] โ remaining sum, not total sum
let best = Number.MIN_SAFE_INTEGER;
for(let x = 1; x <= 2 * m; x++) {
const taken = suffix[idx] - suffix[idx + x];
const oppoTake = dfs(idx + x, Math.max(m, x));
best = Math.max(best, taken - oppoTake);
}
return best;
}
Like SG1, picks from either end โ but the SCORE for a move isn't piles[i]/piles[j] directly, it's the SUM of whatever stones remain after removing that pile. Needs a prefix-sum array for O(1) range-sum lookup. Shape follows SG1 (2 explicit choices, no loop) since it's still a fixed-one-pile-per-move front/back game:
function dfs(i: number, j: number): number {
// base case
if(i >= j) return 0;
const pickFront = (prefix[j + 1] - prefix[i + 1]) - dfs(i + 1, j);
const pickBack = (prefix[j] - prefix[i]) - dfs(i, j - 1);
return Math.max(pickFront, pickBack);
}
0 โฆ n โ 1, values lowest โฆ highest, low <= high (#1)ans = mid, move high to search left, low to search right (#1)Math.floor((lo + hi) / 2), not >> 1 (JS #3)anslow, high = the real range, inclusive. Indices โ 0 โฆ n โ 1. Values โ lowest โฆ highest. Nothing extra.while (low <= high), mid = Math.floor((low + high) / 2).ans = mid. Search left โ high = mid โ 1. Search right โ low = mid + 1.let low = 0, high = n - 1, ans = -1
while (low <= high) {
const mid = Math.floor((low + high) / 2)
if (condition(mid)) { ans = mid; high = mid - 1 } // search LEFT
else low = mid + 1
}
return ans
๐งช Examples (only the start and the if change):
// ๐ Exact โ LC 704: [-1,0,3,5,9,12], 9 โ 4
low = 0, high = n - 1
if (arr[mid] === target) return mid
else if (arr[mid] < target) low = mid + 1
else high = mid - 1
// โฌ
๏ธ First that works โ LC 35: [1,3,5,6], 2 โ 1
low = 0, high = n - 1, ans = n
if (arr[mid] >= target) { ans = mid; high = mid - 1 }
else low = mid + 1
// โก๏ธ Last that works โ LC 69 Sqrt: 8 โ 2 (values, not indices)
low = 0, high = x, ans = 0
if (mid * mid <= x) { ans = mid; low = mid + 1 }
else high = mid - 1
Checked in Node: LC 704 โ 4, โ1 ยท LC 35 โ 2, 1, 4 ยท LC 69 โ 2, 2, 0, 1, 46340 ยท first/last match brute force on 20,000 random arrays.
ans = mid, move high to search left, move low to search right.p[j+1] โ p[i], the +1 goes on j (#1)atMost(k) โ atMost(kโ1) (#3)((x % n) + n) % n (#4)left[] and right[] once, not a scan per index (#7)โค or < (#8)jindex: 0 1 2 3 4 5
piles: [ 2 ][ 7 ][ 9 ][ 4 ][ 4 ]
suffix: [26 ][24 ][17 ][ 8 ][ 4 ][ 0 ]
โ sentinel (nothing after index 4)
prefix: [ 0 ][ 2 ][ 9 ][18 ][22 ][26 ]
โ sentinel (nothing before index 0)
๐ฏ Suffix sentinel sits at the END. Prefix sentinel sits at the START. Both mean the same thing: "empty range โ 0."
suffix[i] INCLUDES piles[i]. prefix[i] EXCLUDES it (sum of everything before i).
โ Sum of i..j (inclusive): the +1 always goes on j.
prefix[j + 1] - prefix[i] // 1..3 โ 22 - 2 = 20 (7 + 9 + 4)
suffix[i] - suffix[j + 1] // 1..3 โ 24 - 4 = 20
๐ Same trick, other interview shapes:
seen[prefix - k]. Start with seen = {0: 1}, that's the sentinel (empty prefix).px[j + 1] ^ px[i], same shape as sum.P[r2+1][c2+1] - P[r1][c2+1] - P[r2+1][c1] + P[r1][c1] (add back the corner you subtracted twice).diff[i] += v, diff[j + 1] -= v, then prefix-sum it. The +1 lands on j again.left[i] * right[i], both EXCLUDE i.i..j the +1 always goes on j. Prefix: p[j+1] - p[i]. Suffix: s[i] - s[j+1].| Numbers | Use |
|---|---|
| โ all positive | ๐ช sliding window |
| โ all negative | ๐ flip signs (รโ1), then window |
| โโ mixed | ๐๏ธ prefix sum + map (#1, LC 560) |
๐ Why: a window needs one direction. Adding an element must always push the sum the same way, so you know which pointer to move. Mixed signs break that.
๐ฅ [5, -3, 1], target 3: the window drops 5 as "too big" before -3 arrives, and never finds the whole array.
atMost(k) โ atMost(kโ1) or prefix sum + map.atMost()atMost(k) // shrink while count > k, then res += right - left + 1
atLeast(k) = total - atMost(k - 1) // total = n * (n + 1) / 2
exactly(k) = atMost(k) - atMost(k - 1)
๐ Why: every subarray is โฅ k or โค k โ 1, never both, so all = atLeast(k) + atMost(k โ 1).
๐ 5 students: 3 scored โฅ 60, 2 scored โค 59 โ atLeast(60) = 5 โ 2 = 3.
atMost(-1) must return 0 (matters when k = 0). Works only when the count can't drop as the window grows (odd numbers, distinct values). Negative numbers โ prefix sum + map (#2).atMost once and subtract.+ n๐ฏ Cue: a circular array where you jump by a value that can be negative (LC 457 Circular Array Loop, LC 3379 Transformed Array).
next = ((i + step) % n + n) % n; // always 0โฆn-1
% keeps the sign of the left side, so (0 - 1) % 5 is -1, an invalid index.% n brings it into -(n-1)โฆn-1, + n makes it non-negative, the last % n folds it back under n.๐ฏ Cue: "each X maps to exactly one Y, and no two X share a Y". LC 205 Isomorphic Strings, LC 290 Word Pattern.
"ab" โ "aa" passes XโY but fails YโX.๐ฏ A string of n characters has n โ 1 gaps. k pieces = pick k โ 1 gaps (3 pieces โ 2 cuts ๐ช๐ช). This is the same fence-post idea as Recursion #12, here used to write the loop bounds.
๐ Read it: write the index below each number and the cut in the gap before it. Cut 0 and cut n (dashed) are the ends, not real cuts, so real cuts are 1 โฆ n โ 1. Cut c = slice(โฆ, c) ends before index c.
| ๐ช Slice (cut positions) | ๐ฆ Array (inclusive end) | |
|---|---|---|
| Pieces | [0,i) [i,j) [j,n) | [0..i] [i+1..j] [j+1..n-1] |
| i | 1 to n โ 2 | 0 to n โ 3 |
| j | i + 1 to n โ 1 | i + 1 to n โ 2 |
for (let i = 1; i <= n - 2; i++) // slice cuts
for (let j = i + 1; j <= n - 1; j++) {}
for (let i = 0; i < n - 2; i++) // array ends
for (let j = i + 1; j < n - 1; j++) {}
๐ Why the limits stop early: every piece after a cut needs at least 1 character. The 3rd piece needs 1 โ j stops 1 early. The 2nd and 3rd need 2 โ i stops 2 early. The array version is just the slice version shifted by 1.
j starts at i + 1 (not 0) and stops at n โ 2 (not n โ 1). Running j up to n and then writing if (j === n) break gives the same splits โ that guard IS the "3rd piece can't be empty" rule โ but putting the limit in the loop bounds is cleaner (LC 306 Additive Number).1..nโ1. Array โ piece ends in 0..nโ2. Number of splits = C(nโ1, kโ1) (n = 5, 3 pieces โ 6).๐ข Brute force: for each i, loop left to get the max, loop right to get the max โ n ร n = O(nยฒ).
๐ก Spot the waste: every i rescans the same cells. The max up to i is just the max up to i โ 1, plus h[i]. So one pass each way โ O(n).
๐ง๏ธ LC 42 Trapping Rain Water, h = [4,2,0,3,2,5]: water at i = min(leftMax, rightMax) โ h[i]
| i | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| h | 4 | 2 | 0 | 3 | 2 | 5 |
| โก๏ธ leftMax | 4 | 4 | 4 | 4 | 4 | 5 |
| โฌ ๏ธ rightMax | 5 | 5 | 5 | 5 | 5 | 5 |
| ๐ง water | 0 | 2 | 4 | 1 | 2 | 0 |
Total = 9.
for (let i = 0; i < n; i++) left[i] = Math.max(h[i], i > 0 ? left[i - 1] : 0)
for (let i = n - 1; i >= 0; i--) right[i] = Math.max(h[i], i < n - 1 ? right[i + 1] : 0)
๐งฑ Include h[i] in both maxes, so the water is never negative.
โ๏ธ Same shape, other operations: LC 238 Product Except Self = product before i ร product after i. Range sums are #1.
Checked in Node: brute force and left/right arrays both give 6 and 9 on the LC 42 examples, and agree on 2,000 random arrays.
left[] and right[], then one loop uses them. O(nยฒ) โ O(n).| Goal | Sort by | LC |
|---|---|---|
| ๐ Merge / insert / meeting rooms | start | 56, 57, 253 |
| ๐ Keep the most non-overlapping / min arrows | end: finish early = room for more | 435, 452 |
s2 โค e1 (does the next one start before this one ends?)max(s1, s2) โค min(e1, e2)[1,2] & [2,3]| Problem | Overlap if | Touching โ |
|---|---|---|
| 56 Merge | โค | merge โ [1,3] |
| 452 Arrows | โค | one arrow at 2 hits both |
| 435 Remove | < | both stay, remove 0 |
| 253 Rooms | < | 1 room (one ends 2pm, next starts 2pm) |
Checked in Node: the overlap formula matches a brute-force point check on 5,000 random pairs, and 56, 435, 452, 253 give the LC example answers plus the touching results above. Full patterns: Intervals cheat sheet.
โค or <.TC = O(n log log n) โ sum of n/p over all primes p โค n.A % B when A < B โ Always Just A๐ฏ Smaller % bigger = the number itself. B goes into A zero times โ nothing gets subtracted off.
2 % 6 = 2 3 % 10 = 3 1 % 2 = 1
% always "shrinks" a number โ it doesn't, if the left side is already smaller than the right.A % B with A < B (both positive) โ always A, untouched.| What | Count | Loop |
|---|---|---|
| ๐ค Pairs (2 different items) | nC2 = n(nโ1)/2 | i: 0 โ nโ1 ยท j: i+1 โ nโ1 |
| โ๏ธ Ordered pairs (AโB โ BโA) | n(nโ1) = 2ร pairs | i: 0 โ nโ1 ยท j: 0 โ nโ1, skip i === j |
๐งฉ Subarrays (j can equal i) | n(n+1)/2 | i: 0 โ nโ1 ยท j: i โ nโ1 |
๐ Every (i, j), self too | nยฒ | i: 0 โ nโ1 ยท j: 0 โ nโ1 |
| ๐ Subsets (any size) | 2โฟ | not a double loop: each item in or out |
n = 3
pairs: (0,1) (0,2) (1,2) = 3
subarrays: 3 pairs + (0,0)(1,1)(2,2) = 6
every (i, j): 3 ร 3 = 9
subsets: 2 ร 2 ร 2 = 8
2โฟ includes the empty subset. Non-empty only โ 2โฟ โ 1.j starts picks the count: i+1 โ nC2 ยท i โ n(n+1)/2 ยท 0 โ nยฒ.0 โฆ 2แต โ 1 = Your Memo Size๐ง Rule: max value โ how many bits (b) โ XOR stays inside those bits โ range 0 โฆ 2แต โ 1 โ that's your memo / array size.
| constraint | bits | XOR range | size |
|---|---|---|---|
| values < 1024 | 10 | 0 โฆ 1023 | 1024 |
| values โค 1000 | 10 | 0 โฆ 1023 | 1024 |
| values โค 10โต | 17 | 0 โฆ 131071 | 131072 |
๐ก Other ops:
| โ only turns bits on, never adds a new bit โ same range& โ only turns bits off โ โค the smallest value+ โ carries add new bits (1023 + 1 = 1024)const b = 32 - Math.clz32(maxVal) // 1000 โ 10
const size = 1 << b // 1024 slots
Where it's used: LC 3882 Min XOR Path, memo on (i, j, x). Checked in Node: XOR / OR of 200,000 random sets never left 0 โฆ 2แต โ 1, AND never passed the smallest value.
๐ฏ arr[100] (too big) or arr[-1] (negative) โ both silently return undefined. NO crash, NO error.
arr[-1] is the last element there. JS does neither, so the bug stays hidden.๐ฅ One case does crash: a missing ROW. dp[n] is undefined, so dp[n][j] throws TypeError: Cannot read properties of undefined.
undefined silently flows into math. Math.min(5, undefined) = NaN. Once NaN appears, it POISONS every calculation built on top of it โ but nothing crashes, so the bug hides until the FINAL wrong answer shows up.๐ Where I hit this: Stone Game DP โ dp[i+1][j-1] when j=0 reads dp[1][-1] โ undefined โ Math.min(0, undefined) = NaN โ cascaded corruption through the whole table.
โ
The fix: ?? 0 (or another sensible default) right at the access point:
dp[i+1][j-1] ?? 0 // undefined โ 0, before it reaches Math.min/arithmetic
undefined, not a crash. Anywhere an index MIGHT go negative or past the end, guard with ?? default right at the read, or the bug hides until a wrong final answer.|| / && Short-Circuit SILENTLY Skips a Second Call With Side Effects๐ฏ A || B and A && B don't always evaluate both sides โ JS stops early the moment the result is already decided.
A || B โ if A is truthy, B is NEVER CALLED (result already true)
A && B โ if A is falsy, B is NEVER CALLED (result already false)
B is a function call with its OWN side effect (mutates state, unions a DSU, pushes to an array) โ not just a pure boolean check.๐ Where it bit โ LC 1579 (Remove Max Edges, dual Union-Find for Alice+Bob):
if(alice.union(u, v) || bob.union(u, v)) edgesUsed++; // โ
If alice.union(u, v) returns true (union succeeded), bob.union(u, v) never runs at all โ short-circuited. For a "shared" edge meant to help BOTH graphs, Bob's DSU silently misses the merge whenever Alice's happens to succeed first. No error, no crash โ just a wrong final answer.
โ Fix โ call both FIRST, combine the booleans AFTER:
const usedByAlice = alice.union(u, v);
const usedByBob = bob.union(u, v);
if (usedByAlice || usedByBob) edgesUsed++;
||/&& operand is a function call that MUTATES something (not just reads a value), short-circuiting becomes a correctness bug โ call every side-effecting function separately first, THEN combine the results in a boolean expression.>> 1 Overflows Past 2ยณยน โ 1, Use Math.floor| Range | |
|---|---|
| ๐ข Normal JS number | โ(2โตยณ โ 1) โฆ 2โตยณ โ 1 (โ ยฑ9 ร 10ยนโต) |
โ ๏ธ After a bitwise op (>>, |, &) | โ2ยณยน โฆ 2ยณยน โ 1 (โ ยฑ2.1 billion), 32-bit signed |
๐ฅ The bug: lo and hi each fit, but lo + hi can pass 2ยณยน โ 1. Then >> 1 overflows โ rubbish mid โ rubbish answer (or an endless loop โ TLE).
lo = 1.5e9, hi = 2e9 // each fits โ
, sum = 3.5e9 โ
(lo + hi) >> 1 // -397483648 ๐ฅ rubbish
Math.floor((lo + hi) / 2) // 1750000000 โ
lo + ((hi - lo) >> 1) // 1750000000 โ
uses the gap, not the sum
Checked in Node: the outputs above, and a search on [0, 2e9] with >> 1 never ends.
n โค 2ยณยน โ 1) โ Math.floor((lo + hi) / 2). Small values (array indices โค 10โต) โ >> 1 is fine.