medium tree, graph, dp questions

This commit is contained in:
2025-04-28 23:04:27 +01:00
parent f8350bfdaf
commit 49c37548c0
4 changed files with 541 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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