From 49c37548c09340626ddfeaf6fc66abdf9b39c33f Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Mon, 28 Apr 2025 23:04:27 +0100 Subject: [PATCH] medium tree, graph, dp questions --- .../questions/binary-tree-level-order.yaml | 135 +++++++++++++++ backend/data/questions/coin-change.yaml | 125 ++++++++++++++ backend/data/questions/number-of-islands.yaml | 157 ++++++++++++++++++ backend/data/questions/word-search.yaml | 124 ++++++++++++++ 4 files changed, 541 insertions(+) create mode 100644 backend/data/questions/binary-tree-level-order.yaml create mode 100644 backend/data/questions/coin-change.yaml create mode 100644 backend/data/questions/number-of-islands.yaml create mode 100644 backend/data/questions/word-search.yaml diff --git a/backend/data/questions/binary-tree-level-order.yaml b/backend/data/questions/binary-tree-level-order.yaml new file mode 100644 index 0000000..6223c49 --- /dev/null +++ b/backend/data/questions/binary-tree-level-order.yaml @@ -0,0 +1,135 @@ +title: Binary Tree Level Order Traversal +slug: binary-tree-level-order +difficulty: medium +leetcode_id: 102 +leetcode_url: https://leetcode.com/problems/binary-tree-level-order-traversal/ +categories: + - trees + - queue +patterns: + - bfs + +description: | + Given the `root` of a binary tree, return the level order traversal of its nodes' values + (i.e., from left to right, level by level). + +constraints: | + - The number of nodes in the tree is in the range [0, 2000]. + - -1000 <= Node.val <= 1000 + +examples: + - input: "root = [3,9,20,null,null,15,7]" + output: "[[3],[9,20],[15,7]]" + explanation: "Level 0 has 3, level 1 has 9 and 20, level 2 has 15 and 7." + - input: "root = [1]" + output: "[[1]]" + explanation: "Single node at level 0." + - input: "root = []" + output: "[]" + explanation: "Empty tree returns empty list." + +explanation: + approach: | + 1. Use a queue for BFS traversal + 2. Track the number of nodes at current level + 3. Process all nodes at current level before moving to next + 4. Add children to queue as we process each node + 5. Collect values for each level in a separate list + + intuition: | + BFS naturally visits nodes level by level. By tracking how many nodes are in the queue + at the start of each level, we know exactly when one level ends and the next begins. + + The key insight is that after processing all nodes of level k, the queue contains + exactly all nodes of level k+1. + + common_pitfalls: + - title: Not tracking level boundaries + description: | + Without tracking level size, you can't separate nodes into their levels. + Capture queue size at the start of each level iteration. + wrong_approach: "Processing queue without counting level size" + correct_approach: "level_size = len(queue); process level_size nodes" + + - title: Forgetting null check + description: | + Empty tree (null root) should return empty list, not cause an error. + + - title: Using list as queue + description: | + Using list.pop(0) is O(n). Use collections.deque for O(1) popleft. + + key_takeaways: + - BFS with queue is the standard for level-order traversal + - Track level size to group nodes by level + - This pattern extends to many tree problems (zigzag, right side view, etc.) + - deque is more efficient than list for queue operations + + time_complexity: "O(n)" + space_complexity: "O(n)" + complexity_explanation: | + Time: Visit each node exactly once. + Space: Queue holds at most one level of nodes, which is O(n) in worst case (complete tree). + +solutions: + - approach_name: BFS with Queue (Optimal) + is_optimal: true + code: | + from collections import deque + + class TreeNode: + def __init__(self, val=0, left=None, right=None): + self.val = val + self.left = left + self.right = right + + def level_order(root: TreeNode | None) -> list[list[int]]: + if not root: + return [] + + result = [] + queue = deque([root]) + + while queue: + level_size = len(queue) + level_values = [] + + for _ in range(level_size): + node = queue.popleft() + level_values.append(node.val) + + if node.left: + queue.append(node.left) + if node.right: + queue.append(node.right) + + result.append(level_values) + + return result + explanation: | + Process nodes level by level using a queue. + Track level size to know when to start a new level list. + + - approach_name: DFS with Level Tracking + is_optimal: false + code: | + def level_order(root: TreeNode | None) -> list[list[int]]: + result = [] + + def dfs(node: TreeNode | None, level: int) -> None: + if not node: + return + + if level == len(result): + result.append([]) + + result[level].append(node.val) + + dfs(node.left, level + 1) + dfs(node.right, level + 1) + + dfs(root, 0) + return result + explanation: | + DFS alternative: pass level as parameter and append to appropriate list. + Same time complexity but uses recursion stack space. diff --git a/backend/data/questions/coin-change.yaml b/backend/data/questions/coin-change.yaml new file mode 100644 index 0000000..cf79b4a --- /dev/null +++ b/backend/data/questions/coin-change.yaml @@ -0,0 +1,125 @@ +title: Coin Change +slug: coin-change +difficulty: medium +leetcode_id: 322 +leetcode_url: https://leetcode.com/problems/coin-change/ +categories: + - dynamic-programming + - arrays +patterns: + - dynamic-programming + +description: | + You are given an integer array `coins` representing coins of different denominations and an + integer `amount` representing a total amount of money. + + Return the fewest number of coins that you need to make up that amount. If that amount of + money cannot be made up by any combination of the coins, return -1. + + You may assume that you have an infinite number of each kind of coin. + +constraints: | + - 1 <= coins.length <= 12 + - 1 <= coins[i] <= 2^31 - 1 + - 0 <= amount <= 10^4 + +examples: + - input: "coins = [1,2,5], amount = 11" + output: "3" + explanation: "11 = 5 + 5 + 1" + - input: "coins = [2], amount = 3" + output: "-1" + explanation: "Cannot make amount 3 with only coin 2." + - input: "coins = [1], amount = 0" + output: "0" + explanation: "Amount 0 needs 0 coins." + +explanation: + approach: | + 1. Create a DP array where dp[i] = min coins needed for amount i + 2. Initialize dp[0] = 0 (zero coins for zero amount) + 3. For each amount from 1 to target, try each coin + 4. If coin <= current amount, dp[i] = min(dp[i], dp[i - coin] + 1) + 5. Return dp[amount] if valid, else -1 + + intuition: | + This is the classic unbounded knapsack problem. For each amount, we ask: "What's the + minimum coins needed if I use coin c as the last coin?" + + If we use coin c last, we need 1 + dp[amount - c] coins. We try all possible "last coins" + and take the minimum. This optimal substructure makes it perfect for DP. + + common_pitfalls: + - title: Wrong initialization + description: | + Initialize dp array to infinity (or amount + 1), not 0. + dp[0] = 0 is the only base case. + wrong_approach: "Initializing all dp values to 0" + correct_approach: "dp = [float('inf')] * (amount + 1); dp[0] = 0" + + - title: Not checking if subproblem is solvable + description: | + Before using dp[i - coin], ensure i >= coin and dp[i - coin] is valid. + + - title: Returning wrong value for impossible case + description: | + If dp[amount] is still infinity, return -1, not infinity. + + key_takeaways: + - Classic unbounded knapsack problem + - Bottom-up DP builds solution from smaller amounts + - Try each coin as the "last coin" for each amount + - Greedy doesn't work here (counterexample: coins=[1,3,4], amount=6) + + time_complexity: "O(amount × coins)" + space_complexity: "O(amount)" + complexity_explanation: | + Time: For each amount (1 to target), we try each coin. + Space: DP array of size amount + 1. + +solutions: + - approach_name: Bottom-Up DP (Optimal) + is_optimal: true + code: | + def coin_change(coins: list[int], amount: int) -> int: + dp = [float('inf')] * (amount + 1) + dp[0] = 0 + + for i in range(1, amount + 1): + for coin in coins: + if coin <= i and dp[i - coin] != float('inf'): + dp[i] = min(dp[i], dp[i - coin] + 1) + + return dp[amount] if dp[amount] != float('inf') else -1 + explanation: | + Build up from amount 0. For each amount, try using each coin as the last coin. + Take the minimum of all valid options. + + - approach_name: BFS (Alternative) + is_optimal: false + code: | + from collections import deque + + def coin_change(coins: list[int], amount: int) -> int: + if amount == 0: + return 0 + + visited = {0} + queue = deque([(0, 0)]) # (current_sum, num_coins) + + while queue: + current, num_coins = queue.popleft() + + for coin in coins: + next_sum = current + coin + if next_sum == amount: + return num_coins + 1 + if next_sum < amount and next_sum not in visited: + visited.add(next_sum) + queue.append((next_sum, num_coins + 1)) + + return -1 + explanation: | + BFS finds shortest path in unweighted graph. + First time we reach 'amount' is the minimum coins. + Less space-efficient than DP for this problem. diff --git a/backend/data/questions/number-of-islands.yaml b/backend/data/questions/number-of-islands.yaml new file mode 100644 index 0000000..9a15034 --- /dev/null +++ b/backend/data/questions/number-of-islands.yaml @@ -0,0 +1,157 @@ +title: Number of Islands +slug: number-of-islands +difficulty: medium +leetcode_id: 200 +leetcode_url: https://leetcode.com/problems/number-of-islands/ +categories: + - graphs + - arrays +patterns: + - dfs + - bfs + +description: | + Given an m x n 2D binary grid `grid` which represents a map of '1's (land) and '0's (water), + return the number of islands. + + An island is surrounded by water and is formed by connecting adjacent lands horizontally + or vertically. You may assume all four edges of the grid are surrounded by water. + +constraints: | + - m == grid.length + - n == grid[i].length + - 1 <= m, n <= 300 + - grid[i][j] is '0' or '1' + +examples: + - input: | + grid = [ + ["1","1","1","1","0"], + ["1","1","0","1","0"], + ["1","1","0","0","0"], + ["0","0","0","0","0"] + ] + output: "1" + explanation: "All land cells are connected, forming one island." + - input: | + grid = [ + ["1","1","0","0","0"], + ["1","1","0","0","0"], + ["0","0","1","0","0"], + ["0","0","0","1","1"] + ] + output: "3" + explanation: "Three separate connected components of land." + +explanation: + approach: | + 1. Iterate through every cell in the grid + 2. When a '1' (land) is found, increment island count + 3. Use DFS/BFS to mark all connected land cells as visited + 4. Continue iteration until all cells are processed + + intuition: | + Each island is a connected component of '1's. We need to count these components. + + When we find an unvisited '1', we've discovered a new island. We then "sink" the entire + island by marking all connected '1's as visited (either change to '0' or use a visited set). + This ensures we don't count the same island multiple times. + + common_pitfalls: + - title: Not marking visited cells + description: | + Without marking cells as visited, you'll count the same island multiple times + or get infinite loops in DFS/BFS. + wrong_approach: "Not modifying grid or using visited set" + correct_approach: "Mark cell as '0' or add to visited set when processing" + + - title: Diagonal connections + description: | + Islands only connect horizontally and vertically, not diagonally. + Only explore 4 directions, not 8. + + - title: Boundary checks + description: | + Always check if row/col are within bounds before accessing grid. + + key_takeaways: + - Grid problems often reduce to graph traversal + - DFS or BFS both work for exploring connected components + - Modifying input can serve as "visited" tracking + - This pattern applies to many "count components" problems + + time_complexity: "O(m × n)" + space_complexity: "O(m × n)" + complexity_explanation: | + Time: Each cell is visited at most once. + Space: DFS recursion stack or BFS queue can hold O(m × n) cells in worst case. + +solutions: + - approach_name: DFS (Optimal) + is_optimal: true + code: | + def num_islands(grid: list[list[str]]) -> int: + if not grid: + return 0 + + rows, cols = len(grid), len(grid[0]) + islands = 0 + + def dfs(r: int, c: int) -> None: + if r < 0 or r >= rows or c < 0 or c >= cols: + return + if grid[r][c] != '1': + return + + grid[r][c] = '0' # Mark as visited + + dfs(r + 1, c) + dfs(r - 1, c) + dfs(r, c + 1) + dfs(r, c - 1) + + for r in range(rows): + for c in range(cols): + if grid[r][c] == '1': + islands += 1 + dfs(r, c) + + return islands + explanation: | + When land is found, increment count and sink the entire island using DFS. + Modifying the grid serves as our visited marker. + + - approach_name: BFS + is_optimal: true + code: | + from collections import deque + + def num_islands(grid: list[list[str]]) -> int: + if not grid: + return 0 + + rows, cols = len(grid), len(grid[0]) + islands = 0 + + def bfs(start_r: int, start_c: int) -> None: + queue = deque([(start_r, start_c)]) + grid[start_r][start_c] = '0' + + while queue: + r, c = queue.popleft() + for dr, dc in [(1, 0), (-1, 0), (0, 1), (0, -1)]: + nr, nc = r + dr, c + dc + if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == '1': + grid[nr][nc] = '0' + queue.append((nr, nc)) + + for r in range(rows): + for c in range(cols): + if grid[r][c] == '1': + islands += 1 + bfs(r, c) + + return islands + explanation: | + Same logic using BFS instead of DFS. + Avoids recursion stack but uses queue space. diff --git a/backend/data/questions/word-search.yaml b/backend/data/questions/word-search.yaml new file mode 100644 index 0000000..c1e65c6 --- /dev/null +++ b/backend/data/questions/word-search.yaml @@ -0,0 +1,124 @@ +title: Word Search +slug: word-search +difficulty: medium +leetcode_id: 79 +leetcode_url: https://leetcode.com/problems/word-search/ +categories: + - arrays + - recursion +patterns: + - backtracking + - dfs + +description: | + Given an m x n grid of characters `board` and a string `word`, return true if `word` exists + in the grid. + + The word can be constructed from letters of sequentially adjacent cells, where adjacent cells + are horizontally or vertically neighboring. The same letter cell may not be used more than once. + +constraints: | + - m == board.length + - n == board[i].length + - 1 <= m, n <= 6 + - 1 <= word.length <= 15 + - board and word consist of only lowercase and uppercase English letters + +examples: + - input: 'board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"' + output: "true" + explanation: "Path exists starting from top-left corner." + - input: 'board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "SEE"' + output: "true" + explanation: "Path exists." + - input: 'board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCB"' + output: "false" + explanation: "Would need to reuse 'B' cell." + +explanation: + approach: | + 1. For each cell, try to start the word from there + 2. Use DFS with backtracking to explore all paths + 3. Mark cells as visited during exploration + 4. Unmark cells when backtracking (restore state) + 5. If entire word is matched, return true + + intuition: | + This is a classic backtracking problem. We explore paths character by character, + and if we reach a dead end (no valid next character), we backtrack and try a + different direction. + + The key is marking cells as visited during exploration to avoid reusing them, + then unmarking when we backtrack to allow other paths to use them. + + common_pitfalls: + - title: Not restoring visited state + description: | + After exploring a path, you must unmark the cell as visited. + Otherwise, other paths from earlier cells can't use it. + wrong_approach: "Only marking, never unmarking" + correct_approach: "Mark before recursion, unmark after" + + - title: Modifying board permanently + description: | + If you change board[r][c] to mark as visited, restore it after backtracking. + + - title: Checking word completion too late + description: | + Check if entire word is matched (index == len(word)) at the start of DFS, + before any bounds/character checks. + + key_takeaways: + - Backtracking = DFS with state restoration + - Mark and unmark visited cells around recursive calls + - Early termination when full word is found + - Grid constraints allow brute force (small board size) + + time_complexity: "O(m × n × 3^L)" + space_complexity: "O(L)" + complexity_explanation: | + Time: Start from each cell, explore up to 3 directions (not the one we came from) for L characters. + Space: Recursion depth is at most word length L. + +solutions: + - approach_name: DFS with Backtracking (Optimal) + is_optimal: true + code: | + def exist(board: list[list[str]], word: str) -> bool: + rows, cols = len(board), len(board[0]) + + def dfs(r: int, c: int, i: int) -> bool: + if i == len(word): + return True + + if r < 0 or r >= rows or c < 0 or c >= cols: + return False + if board[r][c] != word[i]: + return False + + # Mark as visited + temp = board[r][c] + board[r][c] = '#' + + # Explore all 4 directions + found = ( + dfs(r + 1, c, i + 1) or + dfs(r - 1, c, i + 1) or + dfs(r, c + 1, i + 1) or + dfs(r, c - 1, i + 1) + ) + + # Restore (backtrack) + board[r][c] = temp + + return found + + for r in range(rows): + for c in range(cols): + if dfs(r, c, 0): + return True + + return False + explanation: | + Try starting from each cell. Use DFS to match characters one by one. + Mark cells temporarily, then restore when backtracking.