๐Ÿง  DSA Thoughts

Quick-reference mental models and decision rules โ€” not a full learning sheet, a "remember this the instant you see the shape" cheat sheet.

๐Ÿ•ธ๏ธ Graph ยท ๐Ÿง  must remember

๐Ÿงฑ Grid & Input Basics

๐Ÿงญ Traversal โ€” BFS / DFS

๐Ÿ›ฃ๏ธ Shortest Path

๐Ÿ”— Union-Find

๐Ÿ”„ Cycles & Trees

โœˆ๏ธ Euler Path

โฑ๏ธ Complexity

๐ŸŒณ Trees ยท ๐Ÿง  must remember

๐Ÿ”€ Recursion, Backtracking & DP ยท ๐Ÿง  must remember

๐ŸŒฑ Basics

๐Ÿ”€ Backtracking

๐ŸŒฒ Recursion TC

๐Ÿงฉ Dynamic Programming

๐ŸŽฏ Binary Search ยท ๐Ÿง  must remember

๐ŸชŸ Arrays & Strings ยท ๐Ÿง  must remember

๐Ÿ”ข Math ยท ๐Ÿง  must remember

๐Ÿšจ JS Gotchas ยท ๐Ÿง  must remember

๐Ÿง  Graph โ€” Must Remember

Graph section ยท #1โ€“2๐Ÿงฑ Grid & Input Basics

1 ยท ๐Ÿงฑ Grid Basics โ€” Blocked Start/End First, Directions Clockwise

๐Ÿšง 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
]
๐Ÿ“Œ Blocked start/end โ†’ return early. Directions โ†’ top, right, down, left.

2 ยท ๐Ÿ”ข Nodes Labeled 1..n โ†’ -1 on Edges AND on Source/Target

Arrays are 0..n-1, so every label from the input gets -1, in two places:

  1. ๐Ÿ”— Building the graph: graph[u - 1].push([v - 1, w])
  2. ๐ŸŽฏ Source / target given as 1..n: dist[k - 1] = 0, pq.enqueue([0, k - 1]). This is the one people forget, because it sits outside the edge loop.
โš ๏ธ Forget step 2 โ†’ no crash, just a wrong answer: the wrong node gets distance 0, and dist[n] is out of bounds but JS doesn't throw (JS Gotchas #1).
๐Ÿ“Œ Labels 1..n โ†’ -1 on the edges AND on the source/target.
Graph section ยท #3โ€“7๐Ÿงญ Traversal โ€” BFS / DFS

3 ยท ๐ŸŽฏ Mark the Start Before the Loop โ€” BFS visited, Dijkstra dist

// 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])
๐Ÿ“Œ Before the loop starts, the start is already marked: BFS โ†’ visited, Dijkstra โ†’ dist[src] = 0.

4 ยท ๐Ÿ—บ๏ธ When Do I Need visited vs parent?

CaseUseWhy
๐ŸŒณ Treeparent (visited also works)each node has one parent, the only way back is where you came from
๐ŸŒฒ๐ŸŒฒ Forestparent + visitedvisited tells the outer loop which trees are done
โžก๏ธ DAG (no cycle)visiteda node can have many parents, so it is reached many times ๐Ÿ’Ž
๐ŸŒ Any graph, just traversingvisitedseen it โ†’ skip
๐Ÿ” Undirected, find a cyclevisited + parenta seen node that isn't my parent โ†’ cycle (#17)
โžก๏ธ Directed, find a cycle3 states (0/1/2) or Kahn'shit 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.
๐Ÿ“Œ Tree โ†’ parent. Anything else โ†’ visited. Undirected cycle โ†’ add parent. Directed cycle โ†’ 3 states or Kahn's.

5 ยท ๐Ÿ”ฆ BFS or Backtracking? โ€” Fewest Steps vs Choices That Change the Next Move

The question asks forUseExamples
๐ŸŸข Fewest steps from source to target (unweighted)BFS, mark visited on push ยท need the path too? store parent[next] = cur and walk backRotting Oranges, Word Ladder
๐ŸŒ Visit everything / count groups (components)BFS or DFS, mark visited foreverNumber 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 pathBacktracking = DFS + undo: mark โ†’ explore โ†’ unmarkWord Search, Max Gold, Unique Paths III
๐Ÿ“Œ Fewest steps โ†’ BFS (+ 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.

6 ยท ๐Ÿชฃ BFS Jumps by a Shared Key โ†’ Bucket Once, Delete After Use

๐ŸŽฏ 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.

๐Ÿ“Œ Many nodes linked by a shared key โ†’ bucket by key, delete after first use. Work = sum of bucket sizes, not nodes ร— scan.

7 ยท ๐Ÿ—‚๏ธ Many Queries, Same Graph โ†’ Precompute Once

๐Ÿ“ LC 1462 Course Schedule IV: many "is u a prerequisite of v?" queries on one graph.

โš ๏ธ reach costs Vยฒ memory: fine for n = 100, impossible for n = 10โต. There, BFS per query.
๐Ÿ“Œ Many queries on a fixed graph โ†’ precompute once, answer each in O(1).
Graph section ยท #8โ€“14๐Ÿ›ฃ๏ธ Shortest Path

8 ยท ๐Ÿšฆ Which Shortest-Path Tool?

EdgesUse
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.

๐Ÿ“Œ Weight-1 edges โ†’ BFS. Weights โ‰ฅ 0 โ†’ Dijkstra ("visited first" no longer means shortest). Negative weights โ†’ Bellman-Ford, never plain Dijkstra.

9 ยท ๐ŸŽฏ Dijkstra โ€” TC & SC, and Skipping Stale Entries

1๏ธโƒฃ โฑ๏ธ TC = O(E log E) ยท ๐Ÿ’พ SC = O(V + E)

TC, counted from edges:

SC: adjacency list O(V + E) + dist[] O(V) + heap up to E entries โ†’ O(V + E).

2๏ธโƒฃ โญ๏ธ Skip stale entries

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
๐Ÿ“Œ TC O(E log E), SC O(V + E). Pop โ†’ stale? skip. Expand a node only at its lowest cost.

10 ยท โณ 2577 vs 3341/3342 โ€” Can You Wait, or Must You Move?

๐ŸŽฏ All THREE use Dijkstra. The ONE difference: can you WAIT in place?

๐Ÿšจ 2577 (Minimum Time to Visit a Cell in a Grid): NO waiting. Forced to move every step. Arriving too early โ†’ must BOUNCE (move away, come back) to kill time โ†’ needs PARITY math:
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
๐Ÿ”‘ 3341 vs 3342 only the move cost differs: 3341 = fixed +1. 3342 = alternates +1/+2 based on (row+col)%2.
๐Ÿ“Œ Can't wait โ†’ bounce + parity math (2577, plus the (0,0)-no-parent edge case). Can wait โ†’ simple max()+cost (3341/3342).

11 ยท ๐ŸŽฏ Binary Search + BFS vs Binary Search + Dijkstra

๐ŸŽฏ "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.

๐Ÿ“Œ Dijkstra finds the CHEAPEST path. BFS finds IF a path exists. Binary-search-on-threshold problems usually only need the second.

12 ยท ๐ŸŸ  Bellman-Ford โ€” Negative Weights or At Most K Stops

๐Ÿšจ 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
}

โ“ Why 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
๐Ÿ“Œ Negatives or "at most K stops" โ†’ Bellman-Ford. K stops โ†’ K+1 rounds, each reading from prev, so a round adds only one edge.

13 ยท ๐ŸŒŠ Floyd-Warshall โ€” Diagonal 0, Edges with Math.min, via Outermost

๐ŸŽฏ Shortest distance between EVERY pair of nodes, O(Vยณ).

1๏ธโƒฃ ๐Ÿงฑ Build dist (n ร— n)

2๏ธโƒฃ ๐Ÿ” Relax through every middle stop (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.

โš ๏ธ No 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.

๐Ÿ“Œ Diagonal 0 ยท rest "no path" ยท edges with Math.min (both ways if undirected) ยท via (middle stop) outermost.

14 ยท ๐Ÿฐ Dijkstra on States โ€” Node Alone Isn't Enough โ†’ dist[node][extra]

โ“ Ask: "can I decide the next move from the node alone?"

๐Ÿฐ 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:

โฑ๏ธ 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.

๐Ÿ“Œ If the node alone can't tell you what's allowed next, the state = node + what's missing.
Graph section ยท #15โ€“16๐Ÿ”— Union-Find

15 ยท ๐Ÿ”— Union-Find โ€” First Ask "What Is a Node?" (947 vs 305)

๐ŸŽฏ 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 whensame row or same columncells touch (4 directions)
๐Ÿ”‘ Nodea row or a column, not the stonea cell, id = r * cols + c
๐Ÿ”— Unioneach stone joins its row and its columnnew land + each neighbour that is land
๐ŸŽฏ Answerstones โˆ’ components, once at the endcount 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.

โš ๏ธ 947: shift the columns (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).

๐Ÿ“Œ Before Union-Find, ask: what are my nodes, and what joins them? Share a row/column โ†’ rows + columns are nodes. Touch โ†’ cells are nodes, flatten (r, c) to one id.

16 ยท ๐Ÿ•ธ๏ธ MST โ€” Kruskal's = Sort Edges + Union-Find (Skip Prim's)

๐ŸŽฏ 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 ๐Ÿ”’.

๐Ÿ”‘ Kruskal's and Prim's give the same MST. Master Kruskal's: sort edges, add the cheapest one that doesn't make a cycle (Union-Find, same as cycle detection, 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
}

๐Ÿงฉ The Kruskal family โ€” why Union-Find keeps showing up

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.

QuestionKruskal twist
LC 1584 Min Cost to Connect All Pointsplain MST over all point pairs
LintCode 629 Minimum Spanning Treeplain MST, city names โ†’ index first
LC 1579 Remove Max Edges to Keep Graph Fully Traversable2 Union-Finds (Alice, Bob), shared type-3 edges first ยท failed union = removable edge
LC 1489 Critical & Pseudo-Critical Edges in MSTrerun Kruskal per edge: skip it โ†’ cost goes up = critical ยท force it in โ†’ same cost = pseudo-critical
LC 3600 Maximize Spanning Tree Stabilitymaximize the weakest edge โ†’ required edges first, then strongest first ยท upgrade the k weakest chosen edges
LC 778 Swim in Rising Watersort cells by height, union land neighbours, stop when start and end connect โ†’ that height
โš ๏ธ LC 778 is not a spanning tree. It's a minimax path (minimize the biggest step). Union in sorted order and stop the moment start and end join. Dijkstra or binary search + BFS also work (#11).

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).

๐Ÿ“Œ Connect everyone cheaply, or "smallest max / largest min" over connections โ†’ sort + Union-Find. Dijkstra = cheapest path from ONE source, a different question.
Graph section ยท #17โ€“18๐Ÿ”„ Cycles & Trees

17 ยท ๐Ÿ”„ Cycle Detection โ€” All Methods, At a Glance

๐Ÿ”— UNDIRECTED โ€” 3 ways

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.

๐Ÿšจ Always both BFS/DFS on an undirected graph ALWAYS needs 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)

โžก๏ธ DIRECTED โ€” 2 ways (Union-Find does NOT apply โ€” no sense of direction)

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)
๐Ÿ“Œ Undirected โ†’ DFS/BFS parent-skip or Union-Find. Directed โ†’ 0/1/2 DFS or Kahn's (processed < n โ†’ cycle). Union-Find can't tell direction, so it can't detect directed cycles.

18 ยท ๐ŸŒณ Valid Tree โ€” Just Two Rules: Connected + No Cycle

๐ŸŽฏ Any tree (binary, ternary, any number of children) is valid when:

  1. ๐Ÿ”— Connected: one component, every node reachable
  2. ๐Ÿšซ No cycle

Binary vs ternary only limits children per node. It doesn't change what makes it a tree.

๐Ÿ› ๏ธ How to check:

๐Ÿ“Œ Tree = connected + no cycle. Everything else is a way to count it.
Graph section ยท #19โœˆ๏ธ Euler Path

19 ยท โœˆ๏ธ Hierholzer's โ€” Every Edge Once, Post-Order, Reverse

๐ŸŽฏ 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.

๐Ÿšจ Why post-order? (LC 332 Reconstruct Itinerary)

โžก๏ธ 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
โš ๏ธ Pre-order passes both LC 332 examples (no dead ends there). It fails when the smallest choice leads to a dead end before all tickets are used, like A above.
๐Ÿ“Œ DFS that consumes each edge once โ†’ O(E) (LC 332 sorts first โ†’ O(E log E)). ๐Ÿง  Dead end = finish line โ†’ write it first, read it backwards.
Graph section ยท #20โ€“21โฑ๏ธ Complexity

20 ยท โš–๏ธ V+E Is a Shortcut โ€” Only Safe When Work Is O(1)

๐ŸŽฏ 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.

๐Ÿšจ Work is more than O(1) (building a string, hashing it) โ†’ multiply it in.

๐Ÿ“ 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ยฒ)
๐Ÿ“Œ Check the cost of ONE unit of work first. O(1) โ†’ V+E. O(k) โ†’ multiply k in.

21 ยท ๐Ÿ“Š How Many Edges? Grid, Dense, Sparse, Tree

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ยฒ).

๐Ÿšจ Sparse โ‰  tree. A tree is one kind of sparse graph.
๐Ÿ“Œ Tree โŠ‚ Sparse โŠ‚ Graph. Grid BFS/DFS โ†’ O(MN).

๐Ÿง  Trees โ€” Must Remember

1 ยท ๐ŸŒฒ Serialize/Deserialize Tree โ†’ think PREORDER

2 ยท ๐Ÿ” BST โ†’ think INORDER first

Inorder on a BST = sorted order. Use for:

โš ๏ธ Not universal โ€” sometimes bounds/preorder/postorder fit better.

3 ยท ๐ŸŒณ Tree Traversal as Undirected Graph โ€” Parent Map

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).

๐Ÿ”‘ Then treat the tree as undirected: 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.

4 ยท ๐ŸŽ Collect Items in a Tree โ€” dfs Returns "Has Item?", Not Time

๐ŸŽฏ 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
โš ๏ธ A subtree with no apple costs 0, you never walk into it. Returning "time" pushes you to add paths you don't need.

โฑ๏ธ 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).

๐Ÿ“Œ Collect things in a tree and come back โ†’ dfs returns "is anything needed below?", each needed edge costs 2.

5 ยท ๐Ÿ—๏ธ Full vs Complete vs Perfect โ€” and Their Height

๐ŸŸข Full123670 or 2 kids ยท not complete (gap)๐Ÿ”ต Complete1234left โ†’ right, no gaps๐ŸŸฃ Perfect1234567every level full
ShapeRuleHeight (n nodes)
๐ŸŸข Fullevery node has 0 or 2 childrenno promise: log n โ€ฆ (n โˆ’ 1) / 2
๐Ÿ”ต Completeevery level full except the last, filled left โ†’ rightโŒŠlogโ‚‚ nโŒ‹
๐ŸŸฃ Perfectevery level fulllogโ‚‚(n + 1) โˆ’ 1 ยท nodes = 2สฐโบยน โˆ’ 1, leaves = 2สฐ
๐Ÿ“Œ Full = 0 or 2 kids. Complete = no gaps, left โ†’ right. Perfect = every level full. Complete or perfect โ†’ height log n. Anything else โ†’ can be n.

๐Ÿง  Recursion, Backtracking & DP โ€” Must Remember

Recursion section ยท #1โ€“2๐ŸŒฑ Basics

1 ยท โ†•๏ธ Ask the Child, Tell the Child, or Both โ€” Where Does the Answer Travel?

๐Ÿ–ผ๏ธ Min path sum on [[1, 3], [2, 1]] (move right or down). All three give 4.

๐Ÿ”™ Ask the childcell 11 + min(4, 3) = 4cell 3โ†‘ 3 + 1 = 4cell 2โ†‘ 2 + 1 = 3cell 1โ†‘ 1cell 1โ†‘ 1โœ… root returns 4โฌ‡๏ธ Tell the childcell 1โ†“ sum = 1cell 3โ†“ 1 + 3 = 4cell 2โ†“ 1 + 2 = 3cell 14 + 1 = 5cell 13 + 1 = 4โœ… best = min(5, 4) = 4๐Ÿ” Tell + returncell 1โ†“ 1 ยท โ†‘ min(5, 4) = 4cell 3โ†“ 4 ยท โ†‘ 5cell 2โ†“ 3 ยท โ†‘ 4cell 1โ†‘ 4 + 1 = 5cell 1โ†‘ 3 + 1 = 4โœ… root returns 4

๐Ÿค” Which one? Ask: can a loser now become a winner later?

// ๐Ÿ” 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?"

๐Ÿ“Œ ๐Ÿ”™ Ask โ†’ answers climb UP. โฌ‡๏ธ Tell โ†’ state walks DOWN. ๐Ÿ” Both โ†’ tell down, return the finished value up. Crown the winner only when the race can't change anymore.

2 ยท ๐ŸŽฏ Sentinel-Seeding: When It's Safe vs When It's Not

๐ŸŽฏ 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
}
๐Ÿšจ NO explicit base case (a "dead end" is just "the loop found nothing," no dedicated return) โ†’ the SEED value ITSELF is the base case's answer. It MUST equal the correct "nothing more to add" result โ€” NOT a generic sentinel.
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;
}
๐Ÿ“Œ Explicit base case โ†’ sentinel-seeding is safe, base case always resolves it. NO explicit base case โ†’ the seed IS the base case, so it must already equal the correct answer for "nothing more happens here" โ€” not a placeholder.
Recursion section ยท #3โ€“5๐Ÿ”€ Backtracking

3 ยท ๐Ÿ”€ Backtracking โ€” 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.

1๏ธโƒฃ Does order matter?

Test with a tiny example: is [1,2] the same answer as [2,1]?

๐Ÿšจ Problem names LIE. "Combination Sum IV" is actually a PERMUTATION problem โ€” LeetCode counts [1,2] and [2,1] as 2 separate ways, not 1. Trust the tiny example, not the title.

2๏ธโƒฃ Can an element be reused?

// 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(...);
๐Ÿšจ A permutation loop from 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].

3๏ธโƒฃ Does the input have duplicate values?

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();
}
๐Ÿšจ The Set must be LOCAL (created inside each call): a shared Set would also block [4,7] after [4,6,7] used the 7, which is a different path and must stay allowed.
๐Ÿ“Œ Order โ†’ Reuse โ†’ Duplicates. Order picks the loop start (0 or start), reuse picks i vs i + 1 (or used[] for permutations), duplicates add sort + skip. Three answers give you the whole loop.

4 ยท ๐Ÿ”ข Set โ†’ Bitmask โ€” A Set of Small Integers Is ONE Integer

๐ŸŽฏ 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

โ™Ÿ๏ธ N-Queens: 3 Sets โ†’ 3 integers

// 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.

๐Ÿ”ข Sudoku: array of 9 Sets โ†’ array of 9 integers

// 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
๐Ÿšจ The board holds digit strings ('5'), but a bit position needs a number โ€” convert with Number(ch). Same for cols[9] and blocks[9].
๐Ÿšจ Gain and limit Same Big-O, about 5x faster in N-Queens (n = 10 to 12, Node 22). A mask holds only bits 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.
๐Ÿ“Œ "Options are small integers, so each Set can be one integer: bit x on means x is taken. Same algorithm, smaller constant."

5 ยท โ™Ÿ๏ธ Game Search โ€” Is There a Move After Which My Opponent Loses?

๐Ÿงญ Which pattern? Ask in order:

  1. Does an opponent reply after my move? โ†’ โ™Ÿ๏ธ game search
  2. Do I decide each item once, in order? โ†’ ๐Ÿ›’ take / not-take with an index
  3. Building an arrangement, one option per slot? โ†’ ๐Ÿงฉ for-loop backtracking

๐Ÿ›’ 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).

ProblemStateAnswer
Predict the Winner 486(i, j)score difference
Stone Game III 1406iscore difference
Stone Game IV 1510stones leftwin / lose
Can I Win 464bitmaskwin / lose
Flip Game II 294whole boardwin / lose
โš ๏ธ Spend 2 minutes looking for a pattern first: Nim 292 is n % 4 !== 0, Divisor Game 1025 is n even, Stone Game 877 is always true. (Nim-sum / Sprague-Grundy theory is not covered here.)
๐Ÿ“Œ Opponent replies โ†’ ask "is there a move after which they lose?" Win/lose โ†’ canWin. Score โ†’ max(gain โˆ’ dfs(next)).
Recursion section ยท #6โ€“7๐ŸŒฒ Recursion TC

6 ยท ๐ŸŒฒ Unmemoized Recursion TC โ€” 3-Question Framework

๐ŸŽฏ Ask these in order. Stop at the first one that fits.

Q1 โ€” Constant branching? (same # choices every call)

โ†’ branches^depth. (take/skip โ†’ 2โฟ)

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
๐Ÿ“Œ Constant โ†’ power. Fixed yes/no slots โ†’ 2^slots. Split-and-recurse-both-sides โ†’ Catalan sum. Nothing fits โ†’ say "exponential."

7 ยท ๐Ÿƒ Recursion Tree โ€” 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:

๐Ÿšจ Only a chain (b = 1) breaks "nodes โ‰ˆ leaves": 1 leaf but d + 1 nodes. Count nodes only when work per node is not the same everywhere. See #6 for the bแตˆ shapes.
๐Ÿ“Œ TC = (number of nodes) ร— (work per node). For b โ‰ฅ 2, nodes = O(leaves) = bแตˆ, so the only thing left to ask is the work at each node.
Recursion section ยท #8โ€“14๐Ÿงฉ Dynamic Programming

8 ยท ๐Ÿง  Recursion / DP โ€” Designing the STATE: Read the Question Twice

๐ŸŽฏ Read the question TWICE before any recursion or DP:

๐Ÿšจ Don't mix them up. Coin Change returns min COINS, but the state is the AMOUNT LEFT.

๐Ÿงช Friend test: pause the recursion and hand it to a friend. The MINIMUM they need to finish the job is your state.

๐Ÿ—บ๏ธ State shapes โ€” almost every problem is one of these

ShapeStateExample
1๏ธโƒฃ PositioniHouse Robber 198
2๏ธโƒฃ Remaining valueamountCoin 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 positionnUnique BST 96
8๏ธโƒฃ Set of used itemsmaskMatchsticks to Square 473
๐Ÿšจ Burst Balloons: 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.

โœ‚๏ธ 3 rules to get the state right

โš–๏ธ Small state vs big state

๐Ÿ“ Constraints hint the shape: n โ‰ค 20 โ†’ mask ยท n โ‰ค 500 โ†’ (l, r) ยท n โ‰ค 10โต โ†’ 1D.

๐Ÿ“Œ Read 1 = what I RETURN. Read 2 = what I CARRY. Small carry โ†’ memoize (DP). Big carry โ†’ undo (backtracking).

9 ยท ๐Ÿงญ Recursion โ†’ DP: Direction and Starting Index Come FROM the Recurrence

๐ŸŽฏ 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;
}
๐Ÿ“Œ Direction = which side the recurrence reads from. Starting index = the first slot NOT already hardcoded before the loop runs. Don't memorize "0 to n" or "n-1 to 0" as a rule โ€” re-derive both from the recurrence every time.

10 ยท ๐ŸŽฏ Skip/Take vs "Committed Rule" DP โ€” When Skip/Take Breaks

๐ŸŽฏ 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.

๐Ÿšจ Some problems need a rule the CHAIN ALREADY COMMITTED TO โ€” not just the last value:

๐Ÿ“ 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.

๐Ÿง  Simple test "To decide take-or-skip at i, do I need just the LAST element, or a RULE the chain already locked in?"

๐Ÿ“Œ Problems in this "committed rule" family:

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)
๐Ÿ”‘ "Ending at 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.

๐Ÿ”ด 1027 as tabulation

๐Ÿ”‘ 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.
Full LC 1027 code
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.

๐Ÿ“Œ If the extension rule only needs the LAST value, it's skip/take. If it needs a relationship the chain already locked in, the state must CARRY that relationship โ€” found by scanning PAIRS, not by skip/take.

11 ยท ๐Ÿ”— LIS-Family: Natural Recursion โ‰  Standard DP (Different States, Not a Compression)

๐ŸŽฏ 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.

๐Ÿ”ด Natural recursion 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.

โœ… Standard/cleaner DP 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).

๐Ÿ”— LC 368 Largest Divisible Subset โ€” LIS with %

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.

Full LC 368 code
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;
}
๐Ÿ”‘ The recursion you'd write first ((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.

12 ยท โœ‚๏ธ Palindrome Partitioning I vs II โ€” Min Cuts = Min Pieces โˆ’ 1

๐ŸŽฏ 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.

๐Ÿ”‘ Why subtract 1 cutting a string into 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
๐Ÿ“Œ Solve "minimum palindrome PIECES" (identical recursion to LC 131, swap path-collection for Math.min), then subtract 1 at the end to convert pieces โ†’ cuts.

13 ยท ๐Ÿ—ก๏ธ Dungeon Game โ€” Need Here = Need Ahead โˆ’ This Cell (at least 1)

๐ŸŽฏ 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.

๐Ÿ“Œ Need here = need ahead โˆ’ this cell, never below 1. After the last cell, need ahead = 1.

14 ยท ๐ŸŽฒ Stone Game Family โ€” Each Variant Has Its OWN Shape, Don't Force-Fit

๐Ÿšจ Don't carry one Stone Game's shape over to another by default โ€” the move rule decides the recursion shape.

๐Ÿ…ฐ๏ธ Stone Game I โ€” front/back pick, NO for-loop, Math.min-the-opponent

Every 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);
}

๐Ÿ…ฑ๏ธ Stone Game II โ€” variable move size, for-loop + suffix sum

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;
}

๐Ÿ…ฒ Stone Game VII โ€” front/back pick like SG1, but needs PREFIX SUM for scoring

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);
}
๐ŸŽฏ When prefix/suffix? Only when a move's value is a RANGE. SG2 takes a chunk, SG7 scores the stretch that's left. SG1 takes one pile, so there is nothing to sum.
๐Ÿ“Œ The MOVE RULE decides the shape โ€” copy the move rule, not the code.

๐Ÿง  Binary Search โ€” Must Remember

1 ยท ๐ŸŽฏ Binary Search โ€” One Pattern: Inclusive Range + ans

  1. ๐Ÿ“ฆ low, high = the real range, inclusive. Indices โ†’ 0 โ€ฆ n โˆ’ 1. Values โ†’ lowest โ€ฆ highest. Nothing extra.
  2. ๐Ÿ” while (low <= high), mid = Math.floor((low + high) / 2).
  3. โœ… Condition true โ†’ it could be the answer โ†’ ans = mid. Search left โ†’ high = mid โˆ’ 1. Search right โ†’ low = mid + 1.
  4. โŒ Else โ†’ move the other way.
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.

๐Ÿ“Œ Inclusive range, nothing extra. It works โ†’ ans = mid, move high to search left, move low to search right.

๐Ÿง  Arrays & Strings โ€” Must Remember

1 ยท ๐Ÿ—บ๏ธ Prefix / Suffix โ€” Range Sum in O(1), the +1 Goes on j

index:      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:

๐Ÿ“Œ Size n+1, sentinel 0 = empty range. For i..j the +1 always goes on j. Prefix: p[j+1] - p[i]. Suffix: s[i] - s[j+1].

2 ยท ๐ŸชŸ Subarray Sum: Sliding Window or Prefix Sum + Map?

NumbersUse
โž• 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.

โš ๏ธ Zeros: "sum โ‰ฅ target, min length" still works with a window. Counting exact sums with zeros (LC 930) needs atMost(k) โˆ’ atMost(kโˆ’1) or prefix sum + map.
๐Ÿ“Œ All positive โ†’ window. Any negatives โ†’ prefix sum + map.

3 ยท ๐Ÿงฎ Count Subarrays: Turn Everything Into 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).
๐Ÿ“Œ Counting "exactly" or "at least"? Write atMost once and subtract.

4 ยท ๐Ÿ”„ Circular Index โ€” Wrap Any Jump With + 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
๐Ÿ“Œ Step can be negative? Use ((x % n) + n) % n, never plain x % n. JS detail: JS #11.

5 ยท ๐Ÿ”— One-to-One Mapping โ€” Check Both Directions

๐ŸŽฏ Cue: "each X maps to exactly one Y, and no two X share a Y". LC 205 Isomorphic Strings, LC 290 Word Pattern.

๐Ÿ“Œ One-to-one means check both directions.

6 ยท โœ‚๏ธ Split Into k Pieces โ€” kโˆ’1 Cuts, and Each Cut Leaves Room for the Pieces After It

๐ŸŽฏ 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.

โœ‚๏ธ Cut c sits just BEFORE index ccutidx0123451021326394cuts 2, 4 โ†’ [1, 2] ยท [3, 6] ยท [9]

๐Ÿ‘€ 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]
i1 to n โˆ’ 20 to n โˆ’ 3
ji + 1 to n โˆ’ 1i + 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.

๐Ÿšจ Array version: 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).
๐Ÿ“Œ k pieces = k โˆ’ 1 cuts. Each cut leaves room for the pieces after it. Slice โ†’ cuts in 1..nโˆ’1. Array โ†’ piece ends in 0..nโˆ’2. Number of splits = C(nโˆ’1, kโˆ’1) (n = 5, 3 pieces โ†’ 6).

7 ยท ๐Ÿ” Each Index Needs Its Left + Right โ†’ Precompute Both, Don't Rescan

๐Ÿข 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]

i012345
h420325
โžก๏ธ leftMax444445
โฌ…๏ธ rightMax555555
๐Ÿ’ง water024120

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.

๐Ÿ“Œ "For each index I need something from its left and its right" โ†’ two passes fill left[] and right[], then one loop uses them. O(nยฒ) โ†’ O(n).

8 ยท ๐Ÿ“ Intervals โ€” Sort by Start or End, Overlap = Latest Start โ‰ค Earliest End

1๏ธโƒฃ Sort first: by start or by end?

GoalSort byLC
๐Ÿ”— Merge / insert / meeting roomsstart56, 57, 253
๐Ÿ Keep the most non-overlapping / min arrowsend: finish early = room for more435, 452

2๏ธโƒฃ Overlap check

3๏ธโƒฃ Touching edges โš ๏ธ [1,2] & [2,3]

ProblemOverlap ifTouching โ†’
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.

๐Ÿ“Œ Overlap = latest start โ‰ค earliest end. Merge โ†’ sort by start. Keep the most โ†’ sort by end. Touching? Read the problem: โ‰ค or <.

๐Ÿง  Math โ€” Must Remember

1 ยท ๐Ÿ”ข Sieve of Eratosthenes

๐Ÿ“Œ One-line TC = O(n log log n) โ€” sum of n/p over all primes p โ‰ค n.

2 ยท ๐Ÿ• 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
๐Ÿ”‘ Where this bites guessing % 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.

3 ยท ๐Ÿงฎ Pairs vs Subarrays vs Subsets โ€” How Many?

WhatCountLoop
๐Ÿค Pairs (2 different items)nC2 = n(nโˆ’1)/2i: 0 โ†’ nโˆ’1 ยท j: i+1 โ†’ nโˆ’1
โœ‰๏ธ Ordered pairs (Aโ†’B โ‰  Bโ†’A)n(nโˆ’1) = 2ร— pairsi: 0 โ†’ nโˆ’1 ยท j: 0 โ†’ nโˆ’1, skip i === j
๐Ÿงฉ Subarrays (j can equal i)n(n+1)/2i: 0 โ†’ nโˆ’1 ยท j: i โ†’ nโˆ’1
๐Ÿ” Every (i, j), self toonยฒ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.
๐Ÿ“Œ Where j starts picks the count: i+1 โ†’ nC2 ยท i โ†’ n(n+1)/2 ยท 0 โ†’ nยฒ.

4 ยท โšก XOR Stays Inside the Bits โ†’ Range 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.

constraintbitsXOR rangesize
values < 1024100 โ€ฆ 10231024
values โ‰ค 1000100 โ€ฆ 10231024
values โ‰ค 10โต170 โ€ฆ 131071131072

๐Ÿ’ก Other ops:

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.

๐Ÿ“Œ Bit operations stay inside the bits you start with. Only arithmetic can grow them.

๐Ÿง  JS Gotchas โ€” Must Remember

1 ยท ๐Ÿšจ JS Never Throws on Out-of-Bounds Array Access

๐ŸŽฏ arr[100] (too big) or arr[-1] (negative) โ†’ both silently return undefined. NO crash, NO error.

๐Ÿšจ Java throws on both. Python throws on too big, but 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.

๐Ÿ”‘ Why this is dangerous 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
๐Ÿ“Œ JS out-of-bounds = silent 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.

2 ยท ๐Ÿšจ || / && 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)
๐Ÿšจ This is invisible and dangerous when 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++;
๐Ÿ”‘ The moment a ||/&& 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.

3 ยท โž— Binary Search Mid โ€” >> 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.

๐Ÿ“Œ Values can pass 10โน (e.g. n โ‰ค 2ยณยน โˆ’ 1) โ†’ Math.floor((lo + hi) / 2). Small values (array indices โ‰ค 10โต) โ†’ >> 1 is fine.