questions B (backspace - burst-balloons)

This commit is contained in:
2025-05-24 22:06:49 +01:00
parent 0b83eff6f8
commit c4662f5001
67 changed files with 13945 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
title: Backspace String Compare
slug: backspace-string-compare
difficulty: easy
leetcode_id: 844
leetcode_url: https://leetcode.com/problems/backspace-string-compare/
categories:
- strings
- stack
- two-pointers
patterns:
- two-pointers
description: |
Given two strings `s` and `t`, return `true` *if they are equal when both are typed into empty text editors*. `'#'` means a backspace character.
Note that after backspacing an empty text, the text will continue empty.
constraints: |
- `1 <= s.length, t.length <= 200`
- `s` and `t` only contain lowercase letters and `'#'` characters
examples:
- input: 's = "ab#c", t = "ad#c"'
output: "true"
explanation: "Both s and t become \"ac\"."
- input: 's = "ab##", t = "c#d#"'
output: "true"
explanation: "Both s and t become \"\"."
- input: 's = "a#c", t = "b"'
output: "false"
explanation: "s becomes \"c\" while t becomes \"b\"."
explanation:
intuition: |
Imagine typing on a keyboard where `#` is the backspace key. Each time you press backspace, the last character you typed gets deleted. If the text is already empty, nothing happens.
The simplest mental model is to **simulate the typing process**: walk through each string character by character, building up the final result. When you see a letter, add it; when you see `#`, remove the last letter (if any).
However, there's a more elegant approach that uses **O(1) space**: process both strings **from right to left**. Why right to left? Because a backspace only affects characters *before* it, not after. By starting from the end, when we encounter a `#`, we know exactly how many characters to skip. We can then compare the "valid" characters of both strings one at a time without building the full result.
Think of it like this: instead of simulating forward typing, we're **rewinding** from the final cursor position, skipping over characters that would have been deleted.
approach: |
We solve this using a **Two Pointers (Reverse Traversal) Approach**:
**Step 1: Initialise pointers and skip counters**
- `i`: Pointer starting at the end of string `s` (index `len(s) - 1`)
- `j`: Pointer starting at the end of string `t` (index `len(t) - 1`)
- `skip_s`, `skip_t`: Counters tracking how many characters to skip in each string
&nbsp;
**Step 2: Process both strings from right to left**
- While either pointer is valid (>= 0):
- **Handle skips in `s`**: If current char is `#`, increment `skip_s` and move left. If `skip_s > 0` and current char isn't `#`, decrement `skip_s` and move left (skip this char)
- **Handle skips in `t`**: Same logic with `skip_t` and pointer `j`
- After skipping, compare the characters at positions `i` and `j`
&nbsp;
**Step 3: Compare characters**
- If both pointers are valid, characters must match
- If one pointer is valid and the other isn't, strings differ in length
- Move both pointers left and continue
&nbsp;
**Step 4: Return result**
- If we process both strings completely with all characters matching, return `true`
- Return `false` if any mismatch occurs
&nbsp;
This approach works because backspaces only affect preceding characters. By going backwards and counting skips, we can identify which characters "survive" without actually building the result strings.
common_pitfalls:
- title: Building Full Strings (Wasteful Space)
description: |
A common first approach is to build the final string for both `s` and `t` using a stack or list, then compare them.
While this works and is O(n) time, it uses **O(n) space**. The follow-up challenge asks for O(1) space, which the two-pointer approach achieves.
For interviews, mention both approaches: stack-based for clarity, two-pointer for optimal space.
wrong_approach: "Build both result strings, then compare"
correct_approach: "Compare character-by-character using reverse traversal"
- title: Processing Left to Right
description: |
Trying to process strings left-to-right with O(1) space is tricky because you don't know how many future backspaces will delete current characters.
For example, in `"abc###"`, when you see `'a'`, you don't know yet that it will be deleted by a later `#`. You'd need to look ahead or store characters.
Going **right to left** avoids this: when you see `#`, you immediately know to skip the next valid character to the left.
wrong_approach: "Left-to-right with lookahead"
correct_approach: "Right-to-left with skip counter"
- title: Forgetting Edge Cases with Multiple Backspaces
description: |
Consider `"ab##"`: both characters get deleted. Or `"a###"`: one character deleted, two backspaces on empty text (no effect).
Your skip counter must handle:
- Multiple consecutive `#` characters (accumulate skips)
- More backspaces than characters (skips that have nothing to delete)
- Empty result strings (both pointers go past 0)
key_takeaways:
- "**Reverse traversal pattern**: When an operation affects preceding elements (like backspace), consider processing from the end"
- "**Two-pointer comparison**: Instead of building and comparing, compare elements one at a time using coordinated pointers"
- "**Space optimisation**: The stack approach is intuitive (O(n) space), but the two-pointer approach achieves O(1) space"
- "**Skip counter technique**: Track \"pending operations\" as a counter to avoid storing intermediate results"
time_complexity: "O(n + m). We traverse each string at most twice (once for skipping, once for comparing), where `n` and `m` are the lengths of `s` and `t`."
space_complexity: "O(1) for two-pointer approach. We only use a constant number of variables regardless of input size. The stack approach uses O(n + m) space."
solutions:
- approach_name: Two Pointers (Reverse Traversal)
is_optimal: true
code: |
def backspace_compare(s: str, t: str) -> bool:
# Start from the end of both strings
i, j = len(s) - 1, len(t) - 1
skip_s = skip_t = 0
# Process both strings from right to left
while i >= 0 or j >= 0:
# Find the next valid character in s (after applying backspaces)
while i >= 0:
if s[i] == '#':
skip_s += 1 # Count backspace
i -= 1
elif skip_s > 0:
skip_s -= 1 # Skip this character (it's deleted)
i -= 1
else:
break # Found a valid character
# Find the next valid character in t (after applying backspaces)
while j >= 0:
if t[j] == '#':
skip_t += 1 # Count backspace
j -= 1
elif skip_t > 0:
skip_t -= 1 # Skip this character (it's deleted)
j -= 1
else:
break # Found a valid character
# Compare the valid characters
if i >= 0 and j >= 0:
if s[i] != t[j]:
return False # Characters don't match
elif i >= 0 or j >= 0:
return False # One string has characters left, other doesn't
# Move to next characters
i -= 1
j -= 1
return True # All characters matched
explanation: |
**Time Complexity:** O(n + m) — Each character in both strings is visited at most twice.
**Space Complexity:** O(1) — Only a constant number of variables used.
We traverse both strings from right to left, using skip counters to track pending backspaces. When we find a `#`, we increment the skip counter. When we find a regular character with pending skips, we decrement and continue. Otherwise, we've found a "surviving" character to compare. This elegantly handles the temporal nature of backspaces without building intermediate strings.
- approach_name: Stack Simulation
is_optimal: false
code: |
def backspace_compare(s: str, t: str) -> bool:
def build_string(string: str) -> str:
"""Simulate typing and return final result."""
stack = []
for char in string:
if char == '#':
if stack: # Only pop if stack not empty
stack.pop()
else:
stack.append(char)
return ''.join(stack)
# Build both final strings and compare
return build_string(s) == build_string(t)
explanation: |
**Time Complexity:** O(n + m) — Single pass through each string.
**Space Complexity:** O(n + m) — Stack can hold up to all characters.
This approach directly simulates the typing process using a stack. Letters get pushed, `#` causes a pop (if possible). While not space-optimal, this solution is very intuitive and easy to understand. It's a good first answer in an interview before optimising to the two-pointer approach.

View File

@@ -0,0 +1,232 @@
title: Bag of Tokens
slug: bag-of-tokens
difficulty: medium
leetcode_id: 948
leetcode_url: https://leetcode.com/problems/bag-of-tokens/
categories:
- arrays
- sorting
- two-pointers
patterns:
- two-pointers
- greedy
description: |
You start with an initial **power** of `power`, an initial **score** of `0`, and a bag of tokens given as an integer array `tokens`, where each `tokens[i]` denotes the value of the i<sup>th</sup> token.
Your goal is to **maximise** the total **score** by strategically playing these tokens. In one move, you can play an **unplayed** token in one of two ways (but not both for the same token):
- **Face-up**: If your current power is **at least** `tokens[i]`, you may play the token, losing `tokens[i]` power and gaining `1` score.
- **Face-down**: If your current score is **at least** `1`, you may play the token, gaining `tokens[i]` power and losing `1` score.
Return *the **maximum** possible score you can achieve after playing **any** number of tokens*.
constraints: |
- `0 <= tokens.length <= 1000`
- `0 <= tokens[i], power < 10^4`
examples:
- input: "tokens = [100], power = 50"
output: "0"
explanation: "Since your score is 0 initially, you cannot play the token face-down. You also cannot play it face-up since your power (50) is less than tokens[0] (100)."
- input: "tokens = [200, 100], power = 150"
output: "1"
explanation: "Play token 1 (100) face-up, reducing your power to 50 and increasing your score to 1. There is no need to play token 0, since you cannot play it face-up to add to your score."
- input: "tokens = [100, 200, 300, 400], power = 200"
output: "2"
explanation: "Play token 0 (100) face-up, reducing power to 100 and increasing score to 1. Play token 3 (400) face-down, increasing power to 500 and reducing score to 0. Play token 1 (200) face-up, reducing power to 300 and increasing score to 1. Play token 2 (300) face-up, reducing power to 0 and increasing score to 2."
explanation:
intuition: |
Imagine you're playing a strategic card game where you have two currencies: **power** (energy) and **score** (points). Each token card can be played in two directions:
- Play it "face-up" to spend power and earn a point
- Play it "face-down" to spend a point and gain power
The key insight is that **not all tokens are equal**. A cheap token (low value) is valuable for earning points because it costs little power. An expensive token (high value) is valuable for gaining power when you need to sacrifice a point.
Think of it like this: you want to **buy low and sell high**. You "buy" score by spending power on cheap tokens, and you "sell" score to gain power from expensive tokens.
This naturally suggests sorting the tokens and using two pointers — one at each end. The left pointer points to the cheapest unplayed token (ideal for gaining score), while the right pointer points to the most expensive unplayed token (ideal for gaining power).
The greedy strategy becomes clear:
1. If you can afford the cheapest token, play it face-up (gain score cheaply)
2. If you can't afford anything but have score to spare, play the most expensive token face-down (maximise power gained per score spent)
3. Keep track of the maximum score achieved at any point
approach: |
We solve this using a **Two Pointers with Greedy** approach:
**Step 1: Sort the tokens array**
- Sorting allows us to access the smallest tokens (cheapest to play face-up) and largest tokens (best for playing face-down) efficiently
- After sorting, `tokens[0]` is the cheapest and `tokens[n-1]` is the most expensive
&nbsp;
**Step 2: Initialise variables**
- `left`: Pointer starting at index `0` (smallest token)
- `right`: Pointer starting at index `n-1` (largest token)
- `score`: Current score, starting at `0`
- `max_score`: Best score achieved so far, starting at `0`
&nbsp;
**Step 3: Process tokens using two pointers**
- While `left <= right` (unplayed tokens remain):
- **If we can afford the cheapest token** (`power >= tokens[left]`):
- Play it face-up: subtract `tokens[left]` from power, add `1` to score
- Move `left` pointer right
- Update `max_score` if current score is higher
- **Else if we have score to spend** (`score >= 1`):
- Play the most expensive token face-down: add `tokens[right]` to power, subtract `1` from score
- Move `right` pointer left
- **Else**: We can't make any more moves, break out of the loop
&nbsp;
**Step 4: Return the result**
- Return `max_score` — the highest score achieved at any point during play
- Note: We track `max_score` separately because spending score to gain power might temporarily decrease our score, but the final answer is the maximum we ever achieved
common_pitfalls:
- title: Not Sorting First
description: |
Without sorting, you can't efficiently decide which token to play face-up or face-down. You'd need to search for the minimum and maximum unplayed tokens each iteration, leading to O(n^2) complexity.
The greedy strategy **only works on sorted data** where you can always access the cheapest (left) and most expensive (right) tokens in O(1).
wrong_approach: "Processing tokens in original order"
correct_approach: "Sort first, then use two pointers"
- title: Returning Current Score Instead of Maximum Score
description: |
Your score can go up and down during play. Consider `tokens = [100, 200, 300, 400]` with `power = 200`:
- Play 100 face-up: score = 1 ✓
- Play 400 face-down: score = 0
- Play 200 face-up: score = 1
- Play 300 face-up: score = 2 ✓
If you only track the final score, you'd correctly get 2. But imagine a case where the optimal strategy involves temporarily sacrificing score and you can't recover it all — you need to remember the maximum you ever reached.
wrong_approach: "Return score at the end of processing"
correct_approach: "Track and return max_score throughout"
- title: Playing Face-Down When Not Beneficial
description: |
Don't automatically play face-down whenever you can't afford face-up. Only sacrifice score for power if it will eventually help you gain more score.
The two-pointer approach handles this naturally: we only play face-down when `left <= right` (there are still cheaper tokens we might afford after gaining power). If `left > right`, we've processed all tokens and should stop.
wrong_approach: "Always play face-down when face-up isn't possible"
correct_approach: "Only play face-down when unprocessed tokens remain"
- title: Empty Array Edge Case
description: |
When `tokens` is empty, there are no moves to make. The answer is `0` (initial score).
The two-pointer loop condition `left <= right` handles this: with an empty array, `left = 0` and `right = -1`, so the loop never executes and we correctly return `max_score = 0`.
key_takeaways:
- "**Greedy with sorting**: When optimising choices from a collection, sorting often reveals the optimal strategy (pick extremes first)"
- "**Two pointers on sorted data**: Use left pointer for minimum operations, right pointer for maximum operations"
- "**Track running maximum**: When a value can fluctuate (increase and decrease), track the best value seen, not just the final value"
- "**Resource conversion problems**: This pattern (trading one resource for another at different rates) appears in many problems — always consider which trades give the best value"
time_complexity: "O(n log n). Dominated by the sorting step. The two-pointer traversal is O(n) since each pointer moves at most n times."
space_complexity: "O(1) or O(n) depending on the sorting algorithm. The two-pointer logic uses only constant extra space. Python's Timsort uses O(n) space in the worst case, but many implementations sort in-place."
solutions:
- approach_name: Two Pointers (Greedy)
is_optimal: true
code: |
def bag_of_tokens_score(tokens: list[int], power: int) -> int:
# Sort to access cheapest and most expensive tokens easily
tokens.sort()
left = 0
right = len(tokens) - 1
score = 0
max_score = 0
while left <= right:
if power >= tokens[left]:
# Can afford cheapest token: play face-up for +1 score
power -= tokens[left]
score += 1
left += 1
# Update best score achieved
max_score = max(max_score, score)
elif score >= 1:
# Can't afford anything, but have score: trade for power
power += tokens[right]
score -= 1
right -= 1
else:
# Can't play face-up (no power) or face-down (no score)
break
return max_score
explanation: |
**Time Complexity:** O(n log n) — Sorting dominates; the two-pointer loop is O(n).
**Space Complexity:** O(1) extra space for the pointers and counters. Sorting may use O(n) depending on implementation.
We greedily play the cheapest available token face-up when we have enough power, and play the most expensive token face-down when we need more power. By always making the locally optimal choice, we achieve the globally maximum score.
- approach_name: Simulation (Brute Force)
is_optimal: false
code: |
def bag_of_tokens_score(tokens: list[int], power: int) -> int:
from itertools import permutations
def simulate(order: list[int], power: int) -> int:
"""Simulate playing tokens in given order, maximising score."""
score = 0
max_score = 0
played = [False] * len(order)
# Try to play each token, making greedy face-up/face-down choices
changed = True
while changed:
changed = False
for i, token in enumerate(order):
if played[i]:
continue
# Try face-up first (gaining score is our goal)
if power >= token:
power -= token
score += 1
max_score = max(max_score, score)
played[i] = True
changed = True
break
if not changed and score >= 1:
# Find largest unplayed token for face-down
best_idx = -1
best_val = -1
for i, token in enumerate(order):
if not played[i] and token > best_val:
best_val = token
best_idx = i
if best_idx != -1:
power += best_val
score -= 1
played[best_idx] = True
changed = True
return max_score
if not tokens:
return 0
# For small inputs, we could try all permutations
# But this is O(n! * n) which is only feasible for tiny n
return simulate(tokens, power)
explanation: |
**Time Complexity:** O(n^2) for this greedy simulation, or O(n! * n) if trying all permutations.
**Space Complexity:** O(n) for the played array.
This approach simulates the process by repeatedly finding the cheapest affordable token for face-up plays and the most expensive for face-down plays. While correct, it's less elegant and slower than the sorted two-pointer approach. A true brute force trying all permutations would be exponential and impractical.

View File

@@ -0,0 +1,206 @@
title: Balance a Binary Search Tree
slug: balance-a-binary-search-tree
difficulty: medium
leetcode_id: 1382
leetcode_url: https://leetcode.com/problems/balance-a-binary-search-tree/
categories:
- trees
patterns:
- tree-traversal
- dfs
description: |
Given the `root` of a binary search tree, return *a **balanced** binary search tree with the same node values*. If there is more than one answer, return **any of them**.
A binary search tree is **balanced** if the depth of the two subtrees of every node never differs by more than `1`.
constraints: |
- The number of nodes in the tree is in the range `[1, 10^4]`
- `1 <= Node.val <= 10^5`
examples:
- input: "root = [1,null,2,null,3,null,4,null,null]"
output: "[2,1,3,null,null,null,4]"
explanation: "This is not the only correct answer, [3,1,4,null,2] is also correct."
- input: "root = [2,1,3]"
output: "[2,1,3]"
explanation: "The tree is already balanced, so the same structure is returned."
explanation:
intuition: |
Picture an unbalanced BST that's essentially become a linked list — each node only has a right child, forming a long chain. This degrades search operations from O(log n) to O(n).
The key insight is that a **BST's in-order traversal always produces a sorted array**. This property is fundamental: as you traverse left-root-right, you visit nodes in ascending order.
Think of it like this: if you have a sorted array and want to build the most balanced BST possible, you would naturally pick the **middle element** as the root. This ensures roughly half the elements go to the left subtree and half to the right — perfectly balanced!
By combining these two insights, the problem becomes straightforward:
1. Convert the BST to a sorted array (in-order traversal)
2. Build a balanced BST from the sorted array (recursive divide-and-conquer)
The beauty of this approach is that picking the middle element recursively guarantees the tree will be height-balanced.
approach: |
We solve this using **In-order Traversal + Divide-and-Conquer Reconstruction**:
**Step 1: Extract nodes via in-order traversal**
- Perform an in-order DFS traversal (left → root → right)
- Store each node's value in a list as we visit
- This produces a **sorted array** of all values
&nbsp;
**Step 2: Build balanced BST from sorted array**
- Use a recursive helper function that takes a range `[left, right]`
- Find the middle index: `mid = (left + right) // 2`
- Create a new node with the middle value — this becomes the subtree's root
- Recursively build the left subtree from `[left, mid - 1]`
- Recursively build the right subtree from `[mid + 1, right]`
&nbsp;
**Step 3: Handle base case**
- When `left > right`, the range is empty — return `None`
- This terminates the recursion at leaf boundaries
&nbsp;
**Step 4: Return the new root**
- The initial call with the full range `[0, n - 1]` returns the root of the balanced BST
&nbsp;
By always choosing the middle element, we ensure each subtree has at most half the remaining elements, guaranteeing the tree is balanced.
common_pitfalls:
- title: Modifying the Original Tree In-Place
description: |
Attempting to rebalance by rotating nodes in the original tree is complex and error-prone. While AVL or Red-Black tree rotations exist, they're overkill here.
The cleaner approach is to extract all values and rebuild from scratch — this is O(n) time and O(n) space regardless, which matches any rotation-based solution.
wrong_approach: "Complex tree rotations on the original structure"
correct_approach: "Extract values, rebuild from sorted array"
- title: Forgetting BST In-Order Property
description: |
If you try to extract values using pre-order or post-order traversal, you won't get a sorted array. Only **in-order traversal** (left → root → right) produces sorted output from a BST.
This property is essential — the reconstruction algorithm relies on the array being sorted to place elements correctly.
wrong_approach: "Using pre-order or BFS to extract values"
correct_approach: "In-order DFS traversal for sorted output"
- title: Off-By-One in Middle Calculation
description: |
When calculating the middle index, using `(left + right) // 2` works correctly. However, be careful with the recursive ranges:
- Left subtree: `[left, mid - 1]` — excludes the middle
- Right subtree: `[mid + 1, right]` — excludes the middle
Including the middle in either subtree would duplicate values.
wrong_approach: "Overlapping ranges that include mid twice"
correct_approach: "Exclusive ranges: [left, mid-1] and [mid+1, right]"
- title: Not Handling Empty Ranges
description: |
The base case `left > right` must return `None`. This happens when:
- A node has no left child: recursive call gets an empty range
- A node has no right child: recursive call gets an empty range
Missing this base case causes infinite recursion or index errors.
key_takeaways:
- "**BST property**: In-order traversal of a BST always produces a sorted sequence — this is fundamental to many BST algorithms"
- "**Divide-and-conquer**: Building a balanced structure from sorted data by recursively picking the middle element is a powerful pattern"
- "**Rebuild vs modify**: Sometimes reconstructing a data structure is simpler and equally efficient as modifying in place"
- "**Related problems**: This technique applies to *Convert Sorted Array to BST* (LC 108) and *Convert Sorted List to BST* (LC 109)"
time_complexity: "O(n). We visit each node exactly twice — once during in-order traversal to extract values, and once during reconstruction."
space_complexity: "O(n). We store all `n` values in an array, plus O(log n) recursion stack depth for the balanced tree construction."
solutions:
- approach_name: In-order Traversal + Rebuild
is_optimal: true
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def balance_bst(root: TreeNode) -> TreeNode:
# Step 1: Extract all values via in-order traversal (produces sorted array)
values = []
def inorder(node):
if not node:
return
inorder(node.left) # Visit left subtree
values.append(node.val) # Record current node
inorder(node.right) # Visit right subtree
inorder(root)
# Step 2: Build balanced BST from sorted array
def build(left: int, right: int) -> TreeNode | None:
if left > right:
return None # Base case: empty range
# Pick middle element as root for balance
mid = (left + right) // 2
node = TreeNode(values[mid])
# Recursively build left and right subtrees
node.left = build(left, mid - 1)
node.right = build(mid + 1, right)
return node
return build(0, len(values) - 1)
explanation: |
**Time Complexity:** O(n) — In-order traversal visits each node once, and reconstruction visits each value once.
**Space Complexity:** O(n) — The array stores all values. Recursion stack is O(log n) for the balanced tree.
This solution elegantly combines two fundamental operations: BST in-order traversal (which guarantees sorted output) and divide-and-conquer tree construction (which guarantees balance). The middle element becomes the root at each level, ensuring subtrees have equal sizes.
- approach_name: Iterative In-order + Rebuild
is_optimal: false
code: |
def balance_bst(root: TreeNode) -> TreeNode:
# Iterative in-order traversal using explicit stack
values = []
stack = []
current = root
while stack or current:
# Go as far left as possible
while current:
stack.append(current)
current = current.left
# Process node and move right
current = stack.pop()
values.append(current.val)
current = current.right
# Build balanced BST (same as recursive approach)
def build(left: int, right: int) -> TreeNode | None:
if left > right:
return None
mid = (left + right) // 2
node = TreeNode(values[mid])
node.left = build(left, mid - 1)
node.right = build(mid + 1, right)
return node
return build(0, len(values) - 1)
explanation: |
**Time Complexity:** O(n) — Same as recursive approach.
**Space Complexity:** O(n) — Array for values plus O(h) for the traversal stack where h is tree height.
This variant uses iterative in-order traversal with an explicit stack, which can be useful if recursion depth is a concern for extremely unbalanced trees. The reconstruction phase remains the same.

View File

@@ -0,0 +1,210 @@
title: Balanced Binary Tree
slug: balanced-binary-tree
difficulty: easy
leetcode_id: 110
leetcode_url: https://leetcode.com/problems/balanced-binary-tree/
categories:
- trees
- recursion
patterns:
- dfs
- tree-traversal
description: |
Given a binary tree, determine if it is **height-balanced**.
A **height-balanced** binary tree is a binary tree in which the depth of the two subtrees of every node never differs by more than one.
constraints: |
- The number of nodes in the tree is in the range `[0, 5000]`
- `-10^4 <= Node.val <= 10^4`
examples:
- input: "root = [3,9,20,null,null,15,7]"
output: "true"
explanation: "The left subtree has height 1 (just node 9), and the right subtree has height 2 (node 20 with children 15, 7). At every node, the height difference between subtrees is at most 1."
- input: "root = [1,2,2,3,3,null,null,4,4]"
output: "false"
explanation: "The left subtree of the root has height 3, while the right subtree has height 1. The difference is 2, which exceeds the allowed difference of 1."
- input: "root = []"
output: "true"
explanation: "An empty tree is considered balanced by definition."
explanation:
intuition: |
Think of a balanced tree like a well-organised bookshelf where no section is dramatically taller than its neighbours.
The key insight is that a tree is balanced if and only if **every single node** in the tree has balanced subtrees. It's not enough to just check the root — you need to verify this property recursively at every node.
Imagine you're a building inspector checking floor heights. You start at the top floor (root) and work your way down. At each floor, you measure the height of the left wing and the right wing. If the difference is more than one floor, the building fails inspection immediately. You continue checking every sub-section until you've verified the entire structure.
The clever optimisation is to combine the height calculation with the balance check. Instead of calculating heights separately and then checking balance (which would be redundant work), we can propagate a "failure signal" up the tree if any subtree is unbalanced.
approach: |
We solve this using a **Bottom-Up DFS** approach that combines height calculation with balance checking:
**Step 1: Define the recursive helper function**
- Create a function `check_height(node)` that returns the height of the subtree if balanced, or `-1` if unbalanced
- This dual-purpose return value eliminates the need for separate balance checks
&nbsp;
**Step 2: Handle base case**
- If the node is `None`, return `0` (an empty tree has height 0 and is balanced)
&nbsp;
**Step 3: Recursively check left subtree**
- Call `check_height(node.left)` to get the left subtree's height
- If the result is `-1`, the left subtree is unbalanced — propagate `-1` upward immediately
&nbsp;
**Step 4: Recursively check right subtree**
- Call `check_height(node.right)` to get the right subtree's height
- If the result is `-1`, the right subtree is unbalanced — propagate `-1` upward immediately
&nbsp;
**Step 5: Check balance at current node**
- Calculate `abs(left_height - right_height)`
- If the difference exceeds `1`, return `-1` to signal imbalance
- Otherwise, return `max(left_height, right_height) + 1` as the height of this subtree
&nbsp;
**Step 6: Return the final result**
- Call `check_height(root)` and return `True` if the result is not `-1`, otherwise `False`
common_pitfalls:
- title: Top-Down Redundant Computation
description: |
A naive approach calculates the height of each subtree at every node independently:
```python
def is_balanced(root):
if not root:
return True
left_height = height(root.left)
right_height = height(root.right)
if abs(left_height - right_height) > 1:
return False
return is_balanced(root.left) and is_balanced(root.right)
```
This results in **O(n^2) time complexity** for skewed trees because `height()` is called repeatedly on the same nodes. For a tree with 5000 nodes, this causes significant performance degradation.
wrong_approach: "Calculate height separately at each node"
correct_approach: "Combine height calculation with balance checking in a single pass"
- title: Forgetting to Check All Nodes
description: |
Some solutions only check the balance condition at the root node. A tree can have balanced immediate children but have deeply unbalanced subtrees further down.
For example, a root with left child having a long left-skewed subtree and right child being a single node — the root's children might look balanced locally, but the overall tree isn't.
The recursive approach naturally handles this by checking every node.
wrong_approach: "Only check the root node's children"
correct_approach: "Recursively verify balance at every node in the tree"
- title: Incorrect Height Definition
description: |
Be careful with the definition of height. The height of a node is the number of edges on the longest path from that node to a leaf. An empty tree has height 0 (or -1 in some definitions), and a single node has height 0 (or 1).
Using inconsistent definitions will cause off-by-one errors in the balance check.
wrong_approach: "Mix up height definitions between edges and nodes"
correct_approach: "Use consistent height definition: empty tree = 0, leaf node = 1"
key_takeaways:
- "**Bottom-up recursion**: When you need to check a property at every node AND compute something (like height), combine both operations in one traversal"
- "**Early termination with sentinel values**: Using `-1` to signal failure allows the algorithm to short-circuit and avoid unnecessary computation"
- "**Foundation for tree problems**: This pattern of returning either a valid value or a failure signal is common in many tree problems (e.g., validating BST, finding LCA)"
- "**Time-space tradeoff**: The O(n) solution uses O(h) stack space where h is the tree height, which is optimal for this problem"
time_complexity: "O(n). Each node is visited exactly once, and we perform O(1) work at each node."
space_complexity: "O(h) where h is the height of the tree. This is the recursion stack space, which is O(log n) for a balanced tree and O(n) for a skewed tree."
solutions:
- approach_name: Bottom-Up DFS
is_optimal: true
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def is_balanced(root: TreeNode | None) -> bool:
def check_height(node: TreeNode | None) -> int:
# Base case: empty tree has height 0 and is balanced
if not node:
return 0
# Check left subtree - propagate failure immediately
left_height = check_height(node.left)
if left_height == -1:
return -1
# Check right subtree - propagate failure immediately
right_height = check_height(node.right)
if right_height == -1:
return -1
# Check balance at current node
if abs(left_height - right_height) > 1:
return -1 # Signal that tree is unbalanced
# Return height of this subtree
return max(left_height, right_height) + 1
# Tree is balanced if check_height doesn't return -1
return check_height(root) != -1
explanation: |
**Time Complexity:** O(n) — Each node is visited exactly once.
**Space Complexity:** O(h) — Recursion stack where h is tree height.
This bottom-up approach combines height calculation with balance verification. By returning `-1` as a sentinel value for unbalanced subtrees, we achieve early termination and avoid redundant computation.
- approach_name: Top-Down (Naive)
is_optimal: false
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def is_balanced(root: TreeNode | None) -> bool:
def height(node: TreeNode | None) -> int:
# Calculate height of subtree
if not node:
return 0
return max(height(node.left), height(node.right)) + 1
# Base case: empty tree is balanced
if not root:
return True
# Check balance at current node
left_height = height(root.left)
right_height = height(root.right)
if abs(left_height - right_height) > 1:
return False
# Recursively check both subtrees
return is_balanced(root.left) and is_balanced(root.right)
explanation: |
**Time Complexity:** O(n^2) — For each node, we calculate heights which visits all descendants.
**Space Complexity:** O(h) — Recursion stack where h is tree height.
This naive approach calculates height separately at each node, leading to redundant traversals. For a skewed tree of n nodes, we perform approximately n + (n-1) + (n-2) + ... + 1 = O(n^2) operations. Included here to illustrate why the bottom-up approach is preferred.

View File

@@ -0,0 +1,181 @@
title: Base 7
slug: base-7
difficulty: easy
leetcode_id: 504
leetcode_url: https://leetcode.com/problems/base-7/
categories:
- strings
- math
patterns:
- greedy
description: |
Given an integer `num`, return *a string of its **base 7** representation*.
constraints: |
- `-10^7 <= num <= 10^7`
examples:
- input: "num = 100"
output: "202"
explanation: "100 in base 10 equals 2*49 + 0*7 + 2*1 = 202 in base 7."
- input: "num = -7"
output: "-10"
explanation: "-7 in base 10 equals -1*7 + 0*1 = -10 in base 7."
explanation:
intuition: |
Think of how we normally write numbers in base 10. The number 345 means 3 hundreds + 4 tens + 5 ones, or `3*10^2 + 4*10^1 + 5*10^0`. Each digit represents how many of that power of 10 we have.
Converting to base 7 works the same way, but with powers of 7 instead. We need to figure out how many 49s (7^2), how many 7s (7^1), and how many 1s (7^0) make up our number.
The key insight is that **repeatedly dividing by 7 and collecting remainders** gives us the digits from right to left. When you divide a number by 7:
- The **remainder** tells you the rightmost digit (the "ones" place in base 7)
- The **quotient** represents what's left to convert for the higher digits
For example, with `num = 100`:
- `100 % 7 = 2` (rightmost digit), `100 // 7 = 14` (continue with this)
- `14 % 7 = 0` (middle digit), `14 // 7 = 2` (continue with this)
- `2 % 7 = 2` (leftmost digit), `2 // 7 = 0` (done!)
- Reading remainders in reverse: **202**
approach: |
We solve this using **Repeated Division**:
**Step 1: Handle the special case**
- If `num` is `0`, return `"0"` immediately
- This avoids an empty result from the main loop
&nbsp;
**Step 2: Handle the sign**
- If `num` is negative, remember this fact and work with the absolute value
- We'll prepend the minus sign at the end
&nbsp;
**Step 3: Repeatedly divide by 7**
- While `num > 0`:
- Calculate `num % 7` to get the current digit
- Prepend this digit to your result (or append and reverse later)
- Update `num = num // 7` to move to the next higher digit
&nbsp;
**Step 4: Add the sign and return**
- If the original number was negative, prepend `"-"` to the result
- Return the final string
common_pitfalls:
- title: Forgetting the Zero Case
description: |
If `num = 0`, the while loop `while num > 0` never executes, leaving you with an empty string.
You must handle `num = 0` as a special case and return `"0"` directly.
wrong_approach: "Relying on the loop to handle zero"
correct_approach: "Check for zero before entering the loop"
- title: Incorrect Sign Handling
description: |
In Python, the modulo operator with negative numbers can give unexpected results. For example, `-7 % 7 = 0` in Python, but the division `-7 // 7 = -1` (floor division).
To avoid confusion, convert to absolute value first, then add the minus sign at the end.
wrong_approach: "Performing modulo on negative numbers directly"
correct_approach: "Use abs(num) and track the sign separately"
- title: Building the String in Wrong Order
description: |
The digits come out in reverse order (least significant first). If you append each digit, you'll need to reverse at the end.
Alternatively, prepend each digit to build the string in the correct order from the start.
wrong_approach: "Appending digits without reversing"
correct_approach: "Either prepend digits or reverse at the end"
key_takeaways:
- "**Base conversion pattern**: Repeated division by the target base, collecting remainders, gives digits from least to most significant"
- "**Sign handling**: Work with absolute values and track sign separately to avoid edge cases with negative modulo operations"
- "**Generalises to any base**: This same algorithm works for converting to binary, octal, hex, or any base — just change the divisor"
- "**Foundation for encoding problems**: Understanding base conversion helps with problems involving bit manipulation, encoding schemes, and number representations"
time_complexity: "O(log_7(n)). Each division reduces the number by a factor of 7, so we perform approximately log base 7 of n iterations."
space_complexity: "O(log_7(n)). The output string has one character per digit, which is proportional to log base 7 of n."
solutions:
- approach_name: Repeated Division
is_optimal: true
code: |
def convert_to_base7(num: int) -> str:
# Special case: zero is just "0"
if num == 0:
return "0"
# Track if negative, then work with absolute value
negative = num < 0
num = abs(num)
digits = []
while num > 0:
# Get the rightmost digit in base 7
digits.append(str(num % 7))
# Move to the next higher digit
num //= 7
# Digits are collected in reverse order, so reverse them
result = ''.join(reversed(digits))
# Add minus sign if original was negative
return '-' + result if negative else result
explanation: |
**Time Complexity:** O(log_7(n)) — We divide by 7 until the number becomes 0.
**Space Complexity:** O(log_7(n)) — The result string contains one digit per division.
We repeatedly extract the least significant digit using modulo 7, then shift right by dividing by 7. The digits come out in reverse order, so we reverse at the end.
- approach_name: Recursion
is_optimal: false
code: |
def convert_to_base7(num: int) -> str:
# Base case: single digit
if num >= 0 and num < 7:
return str(num)
# Handle negatives
if num < 0:
return '-' + convert_to_base7(-num)
# Recursive case: higher digits + current digit
return convert_to_base7(num // 7) + str(num % 7)
explanation: |
**Time Complexity:** O(log_7(n)) — Same number of operations as iterative.
**Space Complexity:** O(log_7(n)) — Call stack depth plus string concatenation.
The recursive approach naturally builds digits from most significant to least significant. The base case handles single digits (0-6), and the recursive case processes the quotient first before appending the current remainder. While elegant, this uses additional stack space.
- approach_name: Built-in (Python-specific)
is_optimal: false
code: |
def convert_to_base7(num: int) -> str:
# Handle negative numbers
if num < 0:
return '-' + convert_to_base7(-num)
# Handle zero
if num == 0:
return "0"
# Use divmod for cleaner extraction
result = []
while num:
num, remainder = divmod(num, 7)
result.append(str(remainder))
return ''.join(reversed(result))
explanation: |
**Time Complexity:** O(log_7(n)) — Same as other approaches.
**Space Complexity:** O(log_7(n)) — Same as other approaches.
This version uses Python's `divmod()` function which returns both quotient and remainder in one operation. While not fundamentally different, it's a cleaner idiom that some interviewers appreciate. Note that Python has no built-in for arbitrary base conversion, so the algorithm remains the same.

View File

@@ -0,0 +1,199 @@
title: Baseball Game
slug: baseball-game
difficulty: easy
leetcode_id: 682
leetcode_url: https://leetcode.com/problems/baseball-game/
categories:
- arrays
- stack
patterns:
- monotonic-stack
description: |
You are keeping the scores for a baseball game with strange rules. At the beginning of the game, you start with an empty record.
You are given a list of strings `operations`, where `operations[i]` is the i<sup>th</sup> operation you must apply to the record and is one of the following:
- An integer `x` — Record a new score of `x`.
- `'+'` — Record a new score that is the sum of the previous two scores.
- `'D'` — Record a new score that is the double of the previous score.
- `'C'` — Invalidate the previous score, removing it from the record.
Return *the sum of all the scores on the record after applying all the operations*.
The test cases are generated such that the answer and all intermediate calculations fit in a **32-bit** integer and that all operations are valid.
constraints: |
- `1 <= operations.length <= 1000`
- `operations[i]` is `"C"`, `"D"`, `"+"`, or a string representing an integer in the range `[-3 * 10^4, 3 * 10^4]`
- For operation `"+"`, there will always be at least two previous scores on the record
- For operations `"C"` and `"D"`, there will always be at least one previous score on the record
examples:
- input: 'ops = ["5","2","C","D","+"]'
output: "30"
explanation: |
"5" - Add 5 to the record, record is now [5].
"2" - Add 2 to the record, record is now [5, 2].
"C" - Invalidate and remove the previous score, record is now [5].
"D" - Add 2 * 5 = 10 to the record, record is now [5, 10].
"+" - Add 5 + 10 = 15 to the record, record is now [5, 10, 15].
The total sum is 5 + 10 + 15 = 30.
- input: 'ops = ["5","-2","4","C","D","9","+","+"]'
output: "27"
explanation: |
"5" - Add 5 to the record, record is now [5].
"-2" - Add -2 to the record, record is now [5, -2].
"4" - Add 4 to the record, record is now [5, -2, 4].
"C" - Invalidate and remove the previous score, record is now [5, -2].
"D" - Add 2 * -2 = -4 to the record, record is now [5, -2, -4].
"9" - Add 9 to the record, record is now [5, -2, -4, 9].
"+" - Add -4 + 9 = 5 to the record, record is now [5, -2, -4, 9, 5].
"+" - Add 9 + 5 = 14 to the record, record is now [5, -2, -4, 9, 5, 14].
The total sum is 5 + -2 + -4 + 9 + 5 + 14 = 27.
- input: 'ops = ["1","C"]'
output: "0"
explanation: |
"1" - Add 1 to the record, record is now [1].
"C" - Invalidate and remove the previous score, record is now [].
Since the record is empty, the total sum is 0.
explanation:
intuition: |
Imagine you're keeping score on a notepad where you can only add entries at the bottom and erase the most recent entry. This is exactly how a **stack** works — Last In, First Out (LIFO).
The key insight is that every operation either:
- **Adds** a new score to the end of the record (integer, `+`, `D`)
- **Removes** the most recent score (`C`)
This matches perfectly with stack operations: `push` to add and `pop` to remove. The stack always maintains the current valid scores in order, so we can easily access the last one or two elements when needed.
Think of it like this: the record is a stack of paper slips. When you get a new score, you place a slip on top. When you need to double (`D`) or sum (`+`), you peek at the top slip(s), calculate, and add a new slip. When you invalidate (`C`), you simply remove the top slip.
approach: |
We solve this using a **Stack Simulation** approach:
**Step 1: Initialise an empty stack**
- `record`: An empty list to act as our stack, storing valid scores
&nbsp;
**Step 2: Process each operation**
- For each operation in the input:
- If it's an **integer**: Convert to int and push onto the stack
- If it's `"+"`: Add the sum of the last two scores (`record[-1] + record[-2]`) to the stack
- If it's `"D"`: Add double the last score (`2 * record[-1]`) to the stack
- If it's `"C"`: Pop the last score from the stack
&nbsp;
**Step 3: Return the sum**
- Return `sum(record)` — the sum of all remaining valid scores
&nbsp;
The stack naturally maintains only the valid scores at any point. Since the problem guarantees all operations are valid (e.g., `+` always has at least two previous scores), we don't need additional boundary checks.
common_pitfalls:
- title: Not Recognising the Stack Pattern
description: |
Some developers try to use complex conditionals or maintain separate variables instead of recognising this as a classic stack problem.
The operations map directly to stack operations:
- Integer → `push(value)`
- `"+"` → `push(peek(-1) + peek(-2))`
- `"D"` → `push(2 * peek(-1))`
- `"C"` → `pop()`
Once you see this mapping, the solution becomes straightforward.
wrong_approach: "Complex state tracking with multiple variables"
correct_approach: "Use a stack to track the record"
- title: Integer Parsing Edge Cases
description: |
Remember that scores can be **negative** (e.g., `"-2"`). When checking if an operation is an integer, don't just check if the first character is a digit — it could be a minus sign.
In Python, using `try/except` with `int()` or checking with `lstrip('-').isdigit()` handles this correctly. Alternatively, check if the operation is not one of the three special characters.
wrong_approach: "Checking only if first char is digit"
correct_approach: "Check if operation is not in {'C', 'D', '+'}"
- title: Off-by-One in Index Access
description: |
When accessing the last two elements for the `"+"` operation, remember:
- `record[-1]` is the most recent (last) score
- `record[-2]` is the second-to-last score
The problem guarantees at least two scores exist for `"+"`, so this access is always safe.
key_takeaways:
- "**Stack pattern recognition**: When operations involve 'most recent' or 'undo', think stack"
- "**Simulation problems**: Walk through the operations step-by-step, maintaining state as you go"
- "**Python negative indexing**: `list[-1]` and `list[-2]` provide clean access to recent elements"
- "**Trust the constraints**: When the problem guarantees valid operations, you can skip defensive checks"
time_complexity: "O(n). We process each operation exactly once, where `n` is the length of `operations`."
space_complexity: "O(n). In the worst case, all operations are integers, so the stack stores `n` elements."
solutions:
- approach_name: Stack Simulation
is_optimal: true
code: |
def cal_points(operations: list[str]) -> int:
# Stack to track valid scores
record = []
for op in operations:
if op == '+':
# Sum of the last two scores
record.append(record[-1] + record[-2])
elif op == 'D':
# Double the last score
record.append(2 * record[-1])
elif op == 'C':
# Remove the last score
record.pop()
else:
# It's an integer score
record.append(int(op))
# Return sum of all valid scores
return sum(record)
explanation: |
**Time Complexity:** O(n) — Single pass through operations, each operation is O(1).
**Space Complexity:** O(n) — Stack can grow to size n in the worst case.
We simulate the game by processing each operation and maintaining a stack of valid scores. The stack's LIFO property perfectly matches the problem's requirement to access and modify the most recent scores.
- approach_name: Stack with Running Sum
is_optimal: false
code: |
def cal_points(operations: list[str]) -> int:
record = []
total = 0
for op in operations:
if op == '+':
score = record[-1] + record[-2]
elif op == 'D':
score = 2 * record[-1]
elif op == 'C':
# Subtract the removed score from total
total -= record.pop()
continue
else:
score = int(op)
record.append(score)
total += score
return total
explanation: |
**Time Complexity:** O(n) — Single pass through operations.
**Space Complexity:** O(n) — Same stack storage.
This variation maintains a running total, adding scores as they're pushed and subtracting when popped. While it avoids calling `sum()` at the end, the overall complexity is the same. The first approach is cleaner and preferred.

View File

@@ -0,0 +1,205 @@
title: Basic Calculator II
slug: basic-calculator-ii
difficulty: medium
leetcode_id: 227
leetcode_url: https://leetcode.com/problems/basic-calculator-ii/
categories:
- strings
- stack
- math
patterns:
- monotonic-stack
description: |
Given a string `s` which represents an expression, *evaluate this expression and return its value*.
The integer division should truncate toward zero.
You may assume that the given expression is always valid. All intermediate results will be in the range of `[-2^31, 2^31 - 1]`.
**Note:** You are not allowed to use any built-in function which evaluates strings as mathematical expressions, such as `eval()`.
constraints: |
- `1 <= s.length <= 3 * 10^5`
- `s` consists of integers and operators (`'+'`, `'-'`, `'*'`, `'/'`) separated by some number of spaces
- `s` represents a valid expression
- All the integers in the expression are non-negative integers in the range `[0, 2^31 - 1]`
- The answer is guaranteed to fit in a 32-bit integer
examples:
- input: 's = "3+2*2"'
output: "7"
explanation: "Following operator precedence, multiplication is evaluated first: 2*2 = 4, then addition: 3+4 = 7."
- input: 's = " 3/2 "'
output: "1"
explanation: "3 divided by 2 equals 1.5, which truncates toward zero to give 1. Spaces are ignored."
- input: 's = " 3+5 / 2 "'
output: "5"
explanation: "Division has higher precedence: 5/2 = 2 (truncated), then 3+2 = 5."
explanation:
intuition: |
Think of this problem like a human would evaluate the expression: you scan from left to right, but you need to handle **operator precedence** — multiplication and division must be computed before addition and subtraction.
Imagine you're building up a sum of "terms". Each term is either a single number (from `+` or `-` operations) or the result of a multiplication/division chain. When you see `3+2*4`, you can't immediately add 3 and 2 — you need to wait and see what happens to the 2.
The key insight is to use a **stack to defer addition and subtraction**. When we encounter `*` or `/`, we immediately apply them to the most recent number (pop from stack, compute, push result). When we encounter `+` or `-`, we simply push the current number onto the stack (with appropriate sign). At the end, we sum everything on the stack.
Think of it like this: the stack accumulates all the "terms" that need to be added together. Multiplication and division modify the last term before it gets added, while addition and subtraction just push new terms.
approach: |
We solve this using a **Stack-Based Evaluation** approach:
**Step 1: Initialise variables**
- `stack`: An empty list to hold intermediate values (terms to be summed)
- `current_num`: Set to `0` to accumulate digits of multi-digit numbers
- `operation`: Set to `'+'` as the implicit operator before the first number
&nbsp;
**Step 2: Iterate through each character**
- If the character is a digit, accumulate it into `current_num` (handle multi-digit numbers)
- If the character is an operator (`+`, `-`, `*`, `/`) or we've reached the end of the string:
- Apply the **previous** operation to `current_num`:
- `'+'`: Push `current_num` onto the stack
- `'-'`: Push `-current_num` onto the stack
- `'*'`: Pop the top, multiply by `current_num`, push the result
- `'/'`: Pop the top, divide by `current_num` (truncate toward zero), push the result
- Update `operation` to the current operator
- Reset `current_num` to `0`
- Skip spaces entirely
&nbsp;
**Step 3: Sum the stack**
- Return the sum of all values in the stack
- This gives us the final result since all terms have been properly evaluated
&nbsp;
The elegance of this approach is that we handle precedence naturally: `*` and `/` immediately modify the last term on the stack, while `+` and `-` just add new terms to be summed later.
common_pitfalls:
- title: Forgetting Operator Precedence
description: |
Processing operators left-to-right without considering precedence will give wrong answers.
For example, `"3+2*4"` processed left-to-right would compute `(3+2)*4 = 20`, but the correct answer is `3+(2*4) = 11`.
The stack-based approach handles this by immediately applying `*` and `/` to the previous operand, while deferring `+` and `-` operations until the end.
wrong_approach: "Evaluate operators left-to-right as encountered"
correct_approach: "Use a stack to defer addition/subtraction while immediately computing multiplication/division"
- title: Integer Division Truncation Direction
description: |
Python's `//` operator performs floor division, which rounds toward negative infinity. However, this problem requires truncation toward zero.
For example, `-7 // 2 = -4` in Python (floor), but the problem expects `-3` (truncate toward zero).
Use `int(a / b)` instead of `a // b` to get truncation toward zero behaviour.
wrong_approach: "Using `//` for division"
correct_approach: "Using `int(a / b)` for truncation toward zero"
- title: Multi-Digit Numbers
description: |
The expression may contain multi-digit numbers like `"123+456"`. You must accumulate consecutive digits into a single number before applying any operation.
A common mistake is treating each digit as a separate number, which would interpret `"12"` as `1` and `2` separately.
wrong_approach: "Processing each digit independently"
correct_approach: "Accumulate digits: `current_num = current_num * 10 + int(char)`"
- title: Processing the Last Number
description: |
After the loop ends, the last number hasn't been processed yet because there's no trailing operator to trigger processing.
You must either add a dummy operator at the end of the string, or check if you're at the last character inside the loop and process accordingly.
wrong_approach: "Only processing numbers when an operator is encountered"
correct_approach: "Also process when reaching the end of the string"
key_takeaways:
- "**Stack for deferred computation**: Use a stack when you need to delay certain operations (addition/subtraction) while immediately handling others (multiplication/division)"
- "**Operator precedence handling**: Higher precedence operators modify the previous operand; lower precedence operators push new terms"
- "**Track the previous operator**: Process numbers when you see the *next* operator, not the current one — this naturally handles the final number"
- "**Foundation for calculator problems**: This pattern extends to Basic Calculator I (with parentheses) and III (with parentheses and negation)"
time_complexity: "O(n). We traverse the string once, and each character is processed in constant time. Stack operations (push, pop, sum) are all O(1) amortised or O(n) total."
space_complexity: "O(n). In the worst case with all addition operations like `1+2+3+...+n`, the stack holds n/2 numbers."
solutions:
- approach_name: Stack-Based Evaluation
is_optimal: true
code: |
def calculate(s: str) -> int:
stack = []
current_num = 0
operation = '+' # Implicit + before first number
for i, char in enumerate(s):
# Accumulate digits for multi-digit numbers
if char.isdigit():
current_num = current_num * 10 + int(char)
# Process when we hit an operator or end of string
if char in '+-*/' or i == len(s) - 1:
if operation == '+':
stack.append(current_num)
elif operation == '-':
stack.append(-current_num)
elif operation == '*':
# Multiply with the last term
stack.append(stack.pop() * current_num)
elif operation == '/':
# Truncate toward zero (not floor division)
stack.append(int(stack.pop() / current_num))
# Update for next iteration
operation = char
current_num = 0
# Sum all terms on the stack
return sum(stack)
explanation: |
**Time Complexity:** O(n) — Single pass through the string, with O(n) for the final sum.
**Space Complexity:** O(n) — Stack can hold up to n/2 numbers in the worst case.
We use the previous operator to decide what to do with each number. Addition and subtraction push signed values to the stack (deferred computation). Multiplication and division immediately combine with the top of the stack (higher precedence). The final sum gives us the result.
- approach_name: No Stack (Optimised Space)
is_optimal: false
code: |
def calculate(s: str) -> int:
result = 0 # Running total of all completed terms
last_num = 0 # The last term (may still be modified by * or /)
current_num = 0 # Number being built from digits
operation = '+'
for i, char in enumerate(s):
if char.isdigit():
current_num = current_num * 10 + int(char)
if char in '+-*/' or i == len(s) - 1:
if operation == '+':
result += last_num # Finalise previous term
last_num = current_num
elif operation == '-':
result += last_num
last_num = -current_num
elif operation == '*':
last_num *= current_num
elif operation == '/':
last_num = int(last_num / current_num)
operation = char
current_num = 0
return result + last_num # Don't forget the last term
explanation: |
**Time Complexity:** O(n) — Single pass through the string.
**Space Complexity:** O(1) — Only uses a fixed number of variables.
Instead of a stack, we track `result` (sum of completed terms) and `last_num` (the current term that might still be modified by `*` or `/`). When we see `+` or `-`, we know the previous term is complete and add it to `result`. This achieves O(1) space but is slightly harder to reason about.

View File

@@ -0,0 +1,417 @@
title: Basic Calculator IV
slug: basic-calculator-iv
difficulty: hard
leetcode_id: 770
leetcode_url: https://leetcode.com/problems/basic-calculator-iv/
categories:
- strings
- hash-tables
- recursion
- stack
- math
patterns:
- backtracking
description: |
Given an expression such as `expression = "e + 8 - a + 5"` and an evaluation map such as `{"e": 1}` (given in terms of `evalvars = ["e"]` and `evalints = [1]`), return a list of tokens representing the **simplified expression**, such as `["-1*a","14"]`.
An expression alternates chunks and symbols, with a space separating each chunk and symbol.
A chunk is either an expression in parentheses, a variable, or a non-negative integer.
A variable is a string of lowercase letters (not including digits). Note that variables can be multiple letters, and note that variables never have a leading coefficient or unary operator like `"2x"` or `"-x"`.
Expressions are evaluated in the usual order: brackets first, then multiplication, then addition and subtraction.
For example, `expression = "1 + 2 * 3"` has an answer of `["7"]`.
&nbsp;
**Output Format:**
- For each term of free variables with a non-zero coefficient, write the free variables within a term in **sorted order lexicographically** (e.g., `"a*b*c"`, never `"b*a*c"`)
- Terms have degrees equal to the number of free variables being multiplied, counting multiplicity. Write the **largest degree terms first**, breaking ties by lexicographic order ignoring the leading coefficient
- The leading coefficient is placed directly to the left with an asterisk separating it from the variables. A leading coefficient of `1` is still printed
- Terms with coefficient `0` are not included
An example of a well-formatted answer is `["-2*a*a*a", "3*a*a*b", "3*b*b", "4*a", "5*c", "-6"]`.
constraints: |
- `1 <= expression.length <= 250`
- `expression` consists of lowercase English letters, digits, `'+'`, `'-'`, `'*'`, `'('`, `')'`, `' '`
- `expression` does not contain any leading or trailing spaces
- All tokens in `expression` are separated by a single space
- `0 <= evalvars.length <= 100`
- `1 <= evalvars[i].length <= 20`
- `evalvars[i]` consists of lowercase English letters
- `evalints.length == evalvars.length`
- `-100 <= evalints[i] <= 100`
examples:
- input: 'expression = "e + 8 - a + 5", evalvars = ["e"], evalints = [1]'
output: '["-1*a","14"]'
explanation: "Substituting e = 1 gives: 1 + 8 - a + 5 = 14 - a. Rearranged with highest degree first: -1*a + 14."
- input: 'expression = "e - 8 + temperature - pressure", evalvars = ["e", "temperature"], evalints = [1, 12]'
output: '["-1*pressure","5"]'
explanation: "Substituting e = 1 and temperature = 12 gives: 1 - 8 + 12 - pressure = 5 - pressure."
- input: 'expression = "(e + 8) * (e - 8)", evalvars = [], evalints = []'
output: '["1*e*e","-64"]'
explanation: "No substitutions. Using the difference of squares formula: (e + 8)(e - 8) = e² - 64."
explanation:
intuition: |
This problem combines **expression parsing** with **polynomial arithmetic** — two challenging concepts rolled into one.
Think of it like building a symbolic calculator that can handle algebraic expressions. When you type `(x + 2) * (x - 3)` into such a calculator, it expands this to `x² - x - 6`. That's essentially what we're implementing here.
The key insight is to separate concerns:
1. **Parsing**: Convert the string expression into a structure we can evaluate, respecting operator precedence (parentheses > multiplication > addition/subtraction)
2. **Polynomial representation**: Instead of computing a single number, we track a *polynomial* — a collection of terms where each term is a coefficient times some product of variables (like `3*a*b` or `-5*x*x`)
3. **Polynomial arithmetic**: Define how to add, subtract, and multiply polynomials together
The clever representation is to use a **map (dictionary)** where the key is a sorted tuple of variables (like `("a", "b")` or `("x", "x")`) and the value is the coefficient. This makes combining like terms trivial — just add coefficients for matching keys!
approach: |
We solve this using **recursive descent parsing** combined with a **polynomial class** that handles arithmetic operations.
**Step 1: Define the Polynomial representation**
- Use a dictionary mapping `tuple[str, ...]` → `int`
- The tuple contains variables in sorted order (e.g., `("a", "b")` for term `a*b`)
- Empty tuple `()` represents a constant term
- Example: `3*a*b - 5` is `{("a", "b"): 3, (): -5}`
&nbsp;
**Step 2: Implement polynomial operations**
- **Addition**: Merge dictionaries, adding coefficients for matching keys
- **Subtraction**: Same as addition but negate the second polynomial's coefficients
- **Multiplication**: For each pair of terms, multiply coefficients and merge variable tuples (keeping them sorted)
&nbsp;
**Step 3: Tokenise the expression**
- Split by spaces to get tokens: numbers, variables, operators, parentheses
- Create a map of evaluation values for variable substitution
&nbsp;
**Step 4: Recursive descent parsing**
- `parse_expression()`: Handles `+` and `-` (lowest precedence)
- `parse_term()`: Handles `*` (higher precedence)
- `parse_factor()`: Handles parentheses, variables, and numbers (highest precedence)
Each function returns a Polynomial, and we combine results using our polynomial operations.
&nbsp;
**Step 5: Format the output**
- Filter out terms with zero coefficient
- Sort by: degree (descending), then lexicographically by variables
- Format each term as `"coefficient*var1*var2*..."` or just `"coefficient"` for constants
common_pitfalls:
- title: Ignoring Operator Precedence
description: |
A naive left-to-right evaluation of `1 + 2 * 3` gives `9` instead of the correct `7`.
The expression `a + b * c` must parse `b * c` first, then add `a`. This requires either:
- Recursive descent parsing with separate functions for each precedence level
- Shunting-yard algorithm to convert to postfix notation
- Operator precedence climbing
Without respecting precedence, your results will be mathematically incorrect.
wrong_approach: "Evaluate operators left to right as encountered"
correct_approach: "Use recursive descent with precedence levels or convert to postfix"
- title: Incorrect Term Ordering in Output
description: |
The output requires a specific ordering:
1. Higher degree terms first
2. Ties broken by lexicographic order of variables
For `3*a + 2*b*b + 5`, the correct output is `["2*b*b", "3*a", "5"]` not `["3*a", "2*b*b", "5"]`.
The term `2*b*b` has degree 2, while `3*a` has degree 1, so `b*b` comes first despite `a < b` lexicographically.
wrong_approach: "Sort terms alphabetically or in evaluation order"
correct_approach: "Sort by (-degree, variables_tuple) to get correct ordering"
- title: Forgetting to Combine Like Terms
description: |
When you multiply `(a + b) * (a + b)`, you get `a*a + a*b + b*a + b*b`.
The terms `a*b` and `b*a` are actually the same term and must be combined into `2*a*b`. This is why we sort variables within each term — `a*b` and `b*a` both become the key `("a", "b")`, allowing us to add their coefficients.
Missing this step produces duplicate or incorrect terms.
wrong_approach: "Keep a*b and b*a as separate terms"
correct_approach: "Always sort variables in each term; same variable set = same term"
- title: Not Handling Zero Coefficients
description: |
After operations like `(a - a)`, you'll have terms with coefficient `0`. These must be excluded from output.
Similarly, the expression `"0"` should return `[]`, not `["0"]`.
Always filter the polynomial before formatting output.
wrong_approach: "Include all terms in output regardless of coefficient"
correct_approach: "Filter out zero-coefficient terms before formatting"
key_takeaways:
- "**Recursive descent parsing** is a powerful technique for evaluating expressions with operator precedence — each precedence level gets its own parsing function"
- "**Polynomials as dictionaries** with sorted variable tuples as keys makes combining like terms automatic"
- "**Separation of concerns**: parsing logic is independent of polynomial arithmetic — define clean interfaces between them"
- "This pattern extends to building interpreters, compilers, and symbolic math systems"
time_complexity: "O(2^n + m) where n is the number of distinct variables and m is the expression length. In the worst case, multiplying polynomials can create exponentially many terms, though practical cases are much smaller."
space_complexity: "O(2^n) to store all possible polynomial terms in the worst case. Each term requires space proportional to the number of variables it contains."
solutions:
- approach_name: Recursive Descent with Polynomial Class
is_optimal: true
code: |
from collections import Counter
class Poly:
"""Polynomial represented as {variable_tuple: coefficient}"""
def __init__(self, terms=None):
# terms maps (var1, var2, ...) -> coefficient
# Empty tuple () represents constant term
self.terms = Counter(terms) if terms else Counter()
@staticmethod
def from_const(c: int) -> 'Poly':
"""Create polynomial from a constant"""
return Poly({(): c} if c else {})
@staticmethod
def from_var(var: str) -> 'Poly':
"""Create polynomial from a single variable"""
return Poly({(var,): 1})
def __add__(self, other: 'Poly') -> 'Poly':
"""Add two polynomials"""
result = Poly(self.terms)
result.terms.update(other.terms)
# Remove zero coefficients
return Poly({k: v for k, v in result.terms.items() if v})
def __sub__(self, other: 'Poly') -> 'Poly':
"""Subtract two polynomials"""
result = Poly(self.terms)
for k, v in other.terms.items():
result.terms[k] -= v
return Poly({k: v for k, v in result.terms.items() if v})
def __mul__(self, other: 'Poly') -> 'Poly':
"""Multiply two polynomials"""
result = Counter()
for vars1, coef1 in self.terms.items():
for vars2, coef2 in other.terms.items():
# Merge and sort variables
new_vars = tuple(sorted(vars1 + vars2))
result[new_vars] += coef1 * coef2
return Poly({k: v for k, v in result.items() if v})
def to_list(self) -> list[str]:
"""Convert to output format"""
# Sort by: degree descending, then lexicographically
sorted_terms = sorted(
self.terms.items(),
key=lambda x: (-len(x[0]), x[0])
)
result = []
for variables, coef in sorted_terms:
if coef == 0:
continue
if variables:
# Has variables: "coef*var1*var2*..."
result.append(f"{coef}*" + "*".join(variables))
else:
# Constant term
result.append(str(coef))
return result
class Solution:
def basicCalculatorIV(
self,
expression: str,
evalvars: list[str],
evalints: list[int]
) -> list[str]:
# Build evaluation map
eval_map = dict(zip(evalvars, evalints))
# Tokenise
tokens = expression.replace('(', ' ( ').replace(')', ' ) ').split()
self.pos = 0
self.tokens = tokens
self.eval_map = eval_map
# Parse and return result
result = self.parse_expression()
return result.to_list()
def parse_expression(self) -> Poly:
"""Parse addition and subtraction (lowest precedence)"""
result = self.parse_term()
while self.pos < len(self.tokens) and self.tokens[self.pos] in '+-':
op = self.tokens[self.pos]
self.pos += 1
right = self.parse_term()
if op == '+':
result = result + right
else:
result = result - right
return result
def parse_term(self) -> Poly:
"""Parse multiplication (higher precedence)"""
result = self.parse_factor()
while self.pos < len(self.tokens) and self.tokens[self.pos] == '*':
self.pos += 1
right = self.parse_factor()
result = result * right
return result
def parse_factor(self) -> Poly:
"""Parse parentheses, variables, numbers (highest precedence)"""
token = self.tokens[self.pos]
if token == '(':
# Parenthesised expression
self.pos += 1
result = self.parse_expression()
self.pos += 1 # Skip ')'
return result
elif token.lstrip('-').isdigit():
# Number
self.pos += 1
return Poly.from_const(int(token))
else:
# Variable
self.pos += 1
if token in self.eval_map:
# Substitute with known value
return Poly.from_const(self.eval_map[token])
else:
# Keep as free variable
return Poly.from_var(token)
explanation: |
**Time Complexity:** O(2^n * m) where n is the number of distinct free variables and m is the expression length. Polynomial multiplication can create exponentially many terms.
**Space Complexity:** O(2^n) for storing polynomial terms.
The solution uses recursive descent parsing to handle operator precedence correctly. Each level of precedence (expression → term → factor) is a separate method. The `Poly` class encapsulates polynomial arithmetic, making the parsing code clean and readable.
- approach_name: Stack-Based Parsing
is_optimal: false
code: |
from collections import Counter
def basic_calculator_iv(
expression: str,
evalvars: list[str],
evalints: list[int]
) -> list[str]:
"""Alternative using explicit stacks instead of recursion"""
eval_map = dict(zip(evalvars, evalints))
def make_poly(token: str) -> Counter:
"""Convert token to polynomial"""
if token.lstrip('-').isdigit():
c = int(token)
return Counter({(): c}) if c else Counter()
elif token in eval_map:
c = eval_map[token]
return Counter({(): c}) if c else Counter()
else:
return Counter({(token,): 1})
def combine(poly1: Counter, poly2: Counter, op: str) -> Counter:
"""Combine two polynomials with given operation"""
if op == '+':
result = poly1.copy()
result.update(poly2)
elif op == '-':
result = poly1.copy()
for k, v in poly2.items():
result[k] -= v
else: # '*'
result = Counter()
for v1, c1 in poly1.items():
for v2, c2 in poly2.items():
key = tuple(sorted(v1 + v2))
result[key] += c1 * c2
# Remove zeros
return Counter({k: v for k, v in result.items() if v})
def apply_ops(polys: list, ops: list, min_prec: int):
"""Apply operations with precedence >= min_prec"""
prec = {'+': 1, '-': 1, '*': 2}
while ops and ops[-1] != '(' and prec.get(ops[-1], 0) >= min_prec:
op = ops.pop()
right = polys.pop()
left = polys.pop()
polys.append(combine(left, right, op))
# Tokenise
tokens = expression.replace('(', ' ( ').replace(')', ' ) ').split()
polys = [] # Operand stack
ops = [] # Operator stack
prec = {'+': 1, '-': 1, '*': 2}
for token in tokens:
if token == '(':
ops.append(token)
elif token == ')':
# Apply all ops until matching '('
while ops[-1] != '(':
op = ops.pop()
right = polys.pop()
left = polys.pop()
polys.append(combine(left, right, op))
ops.pop() # Remove '('
elif token in prec:
# Apply ops with higher/equal precedence
apply_ops(polys, ops, prec[token])
ops.append(token)
else:
# Number or variable
polys.append(make_poly(token))
# Apply remaining operators
apply_ops(polys, ops, 0)
# Format output
result = polys[0] if polys else Counter()
sorted_terms = sorted(result.items(), key=lambda x: (-len(x[0]), x[0]))
output = []
for variables, coef in sorted_terms:
if coef == 0:
continue
if variables:
output.append(f"{coef}*" + "*".join(variables))
else:
output.append(str(coef))
return output
explanation: |
**Time Complexity:** O(2^n * m) — same as recursive approach.
**Space Complexity:** O(2^n + m) — polynomial storage plus explicit stacks.
This approach uses the shunting-yard algorithm concept with explicit operand and operator stacks. It's equivalent to recursive descent but uses iteration instead. Some find this easier to reason about; others prefer the recursive version's clarity.

View File

@@ -0,0 +1,232 @@
title: Basic Calculator
slug: basic-calculator
difficulty: hard
leetcode_id: 224
leetcode_url: https://leetcode.com/problems/basic-calculator/
categories:
- strings
- stack
- math
patterns:
- monotonic-stack
description: |
Given a string `s` representing a valid expression, implement a basic calculator to evaluate it, and return *the result of the evaluation*.
**Note:** You are **not** allowed to use any built-in function which evaluates strings as mathematical expressions, such as `eval()`.
constraints: |
- `1 <= s.length <= 3 * 10^5`
- `s` consists of digits, `'+'`, `'-'`, `'('`, `')'`, and `' '`
- `s` represents a valid expression
- `'+'` is **not** used as a unary operation (i.e., `"+1"` and `"+(2 + 3)"` is invalid)
- `'-'` could be used as a unary operation (i.e., `"-1"` and `"-(2 + 3)"` is valid)
- There will be no two consecutive operators in the input
- Every number and running calculation will fit in a signed 32-bit integer
examples:
- input: 's = "1 + 1"'
output: "2"
explanation: "Simple addition of two numbers."
- input: 's = " 2-1 + 2 "'
output: "3"
explanation: "2 - 1 = 1, then 1 + 2 = 3. Spaces are ignored."
- input: 's = "(1+(4+5+2)-3)+(6+8)"'
output: "23"
explanation: "Inner parentheses: (4+5+2) = 11, so (1+11-3) = 9. Then (6+8) = 14. Finally 9 + 14 = 23."
explanation:
intuition: |
Imagine you're evaluating a mathematical expression by hand. When you encounter parentheses, you mentally "pause" what you were doing, compute the inner expression first, and then come back to where you left off.
This is exactly what a **stack** enables programmatically. Think of the stack as a "memory" for the outer context. When you see an opening parenthesis `(`, you save your current progress (the running result and the sign before the parenthesis) onto the stack. Then you start fresh to compute the inner expression. When you hit a closing parenthesis `)`, you pop the saved context and combine it with the inner result.
The key insight is that addition and subtraction are **left-associative** and have the **same precedence**, so we can evaluate the expression in a single left-to-right pass. The only complication is parentheses, which create nested scopes that we handle with a stack.
For the sign, we track whether the next number should be added or subtracted. A `+` means add (sign = 1), and a `-` means subtract (sign = -1). Unary minus (like `-5` or `-(3+2)`) is handled naturally by setting the sign before parsing the number or entering the parentheses.
approach: |
We solve this using a **Stack-Based Single Pass** approach:
**Step 1: Initialise variables**
- `result`: Set to `0` to accumulate the running total
- `sign`: Set to `1` (positive) representing whether to add or subtract the next value
- `stack`: Empty list to save context when entering parentheses
- `i`: Index to iterate through the string
&nbsp;
**Step 2: Iterate through each character**
- **Digit**: Build the full number (could be multi-digit), then add `sign * number` to result
- **`+`**: Set sign to `1` (next value will be added)
- **`-`**: Set sign to `-1` (next value will be subtracted)
- **`(`**: Push current `result` and `sign` onto stack, then reset `result = 0` and `sign = 1` to start evaluating the sub-expression
- **`)`**: Pop the saved sign and previous result from the stack. Compute `popped_result + popped_sign * result` to combine inner result with outer context
- **Space**: Skip whitespace characters
&nbsp;
**Step 3: Return the result**
- After processing all characters, `result` contains the final evaluated value
&nbsp;
The stack stores pairs of `(previous_result, sign_before_parenthesis)`. This lets us "freeze" the outer computation, evaluate the inner expression independently, and then seamlessly merge them when we close the parenthesis.
common_pitfalls:
- title: Not Handling Multi-Digit Numbers
description: |
A common mistake is treating each digit character as a separate number. For example, `"123"` should be parsed as the number `123`, not `1`, `2`, `3`.
You need a loop that continues reading digits while the current character is a digit, building the number: `num = num * 10 + int(char)`.
wrong_approach: "Treating each digit as a separate number"
correct_approach: "Build multi-digit numbers in a loop"
- title: Forgetting Unary Minus
description: |
The problem states that `-` can be used as a unary operator, like `-1` or `-(3+2)`. If you only handle binary subtraction, expressions like `"-(1+2)"` will fail.
The sign variable naturally handles this: when we see `-` followed by `(`, we push the current state with sign `-1`, so the entire inner result gets negated.
wrong_approach: "Only handling binary subtraction"
correct_approach: "Track sign separately; it applies to both numbers and sub-expressions"
- title: Incorrect Stack Order
description: |
When pushing to and popping from the stack, order matters. You need to push `result` first, then `sign`, and pop in reverse order (sign first, then result).
If you get this backwards, you'll apply the wrong sign or add to the wrong accumulated value.
wrong_approach: "Inconsistent push/pop order"
correct_approach: "Push (result, sign) in order; pop sign first, then result"
- title: Not Resetting After Opening Parenthesis
description: |
After pushing the current context onto the stack, you must reset `result = 0` and `sign = 1` to start evaluating the inner expression from scratch.
Forgetting to reset means you'll incorrectly mix the outer and inner computations.
wrong_approach: "Continuing with old result/sign after '('"
correct_approach: "Reset result = 0 and sign = 1 after pushing to stack"
key_takeaways:
- "**Stack for nested structures**: Stacks are ideal for problems involving parentheses or any nested scope. Push context on open, pop on close."
- "**Sign tracking**: Instead of handling `+` and `-` as operators between terms, track a sign multiplier that applies to the next value."
- "**Single pass efficiency**: Despite the apparent complexity, expression evaluation with `+`, `-`, and parentheses can be done in O(n) time."
- "**Foundation for harder calculators**: This pattern extends to Basic Calculator II (with `*` and `/`) and III (with all operators and parentheses)."
time_complexity: "O(n). We iterate through each character in the string exactly once."
space_complexity: "O(n). In the worst case with deeply nested parentheses like `(((...)))`, the stack stores O(n) pairs."
solutions:
- approach_name: Stack-Based Single Pass
is_optimal: true
code: |
def calculate(s: str) -> int:
result = 0 # Running total for current scope
sign = 1 # 1 for positive, -1 for negative
stack = [] # Stores (result, sign) when entering parentheses
i = 0
n = len(s)
while i < n:
char = s[i]
if char.isdigit():
# Build the full number (could be multi-digit)
num = 0
while i < n and s[i].isdigit():
num = num * 10 + int(s[i])
i += 1
# Add (or subtract) this number to our result
result += sign * num
continue # i already advanced past the number
elif char == '+':
# Next number should be added
sign = 1
elif char == '-':
# Next number should be subtracted
sign = -1
elif char == '(':
# Save current context and start fresh
stack.append(result)
stack.append(sign)
result = 0
sign = 1
elif char == ')':
# Combine inner result with saved outer context
prev_sign = stack.pop()
prev_result = stack.pop()
result = prev_result + prev_sign * result
# Skip spaces (char == ' ')
i += 1
return result
explanation: |
**Time Complexity:** O(n) — Single pass through the string.
**Space Complexity:** O(n) — Stack depth proportional to nesting level, worst case O(n).
We process each character once. Digits form multi-digit numbers, operators update the sign, and parentheses push/pop context from the stack. The sign variable elegantly handles both binary subtraction and unary minus.
- approach_name: Recursive Descent
is_optimal: false
code: |
def calculate(s: str) -> int:
# Remove all spaces for easier parsing
s = s.replace(' ', '')
index = [0] # Use list for mutable reference in nested function
def parse_expression() -> int:
result = 0
sign = 1
while index[0] < len(s):
char = s[index[0]]
if char.isdigit():
# Parse multi-digit number
num = 0
while index[0] < len(s) and s[index[0]].isdigit():
num = num * 10 + int(s[index[0]])
index[0] += 1
result += sign * num
continue
elif char == '+':
sign = 1
index[0] += 1
elif char == '-':
sign = -1
index[0] += 1
elif char == '(':
index[0] += 1 # Skip '('
# Recursively evaluate sub-expression
inner = parse_expression()
result += sign * inner
elif char == ')':
index[0] += 1 # Skip ')'
return result # Return to caller
else:
index[0] += 1 # Skip unexpected characters
return result
return parse_expression()
explanation: |
**Time Complexity:** O(n) — Each character is visited once.
**Space Complexity:** O(n) — Recursion depth proportional to nesting level.
This approach uses recursion to handle parentheses. When we see `(`, we recursively call `parse_expression()` to evaluate the inner content. When we see `)`, we return the inner result to the caller. The recursion stack implicitly does what the explicit stack does in the iterative solution.
While equally efficient, this approach may hit Python's recursion limit for very deeply nested expressions, making the iterative stack solution preferred.

View File

@@ -0,0 +1,191 @@
title: Battleships in a Board
slug: battleships-in-a-board
difficulty: medium
leetcode_id: 419
leetcode_url: https://leetcode.com/problems/battleships-in-a-board/
categories:
- arrays
- graphs
patterns:
- matrix-traversal
description: |
Given an `m x n` matrix `board` where each cell is a battleship `'X'` or empty `'.'`, return *the number of **battleships** on the board*.
**Battleships** can only be placed horizontally or vertically on `board`. In other words, they can only be made of the shape `1 x k` (1 row, `k` columns) or `k x 1` (`k` rows, 1 column), where `k` can be of any size. At least one horizontal or vertical cell separates between two battleships (i.e., there are no adjacent battleships).
constraints: |
- `m == board.length`
- `n == board[i].length`
- `1 <= m, n <= 200`
- `board[i][j]` is either `'.'` or `'X'`
examples:
- input: 'board = [["X",".",".","X"],[".",".",".","X"],[".",".",".","X"]]'
output: "2"
explanation: "There are two battleships: one horizontal at position (0,0) and one vertical spanning positions (0,3), (1,3), (2,3)."
- input: 'board = [["."]]'
output: "0"
explanation: "The board contains no battleships, only empty cells."
explanation:
intuition: |
Imagine you're scanning a radar display looking for ships. Each ship appears as a contiguous line of `'X'` marks, either horizontal or vertical. Your task is to count how many distinct ships are present.
The **key insight** is that you don't need to trace the entire shape of each battleship. Instead, you only need to identify the **"head"** of each ship — the top-left cell where a battleship begins.
Think of it like this: as you scan the grid from left to right, top to bottom (like reading a book), the first `'X'` you encounter for any battleship is always its top-left corner. Every other `'X'` in that ship will either be below or to the right of this starting point.
So instead of asking "is this an `'X'`?", ask "is this the **start** of a new battleship?" A cell is the start of a new battleship if:
- It contains `'X'`
- There's no `'X'` directly above it (otherwise it's part of a vertical ship that started earlier)
- There's no `'X'` directly to its left (otherwise it's part of a horizontal ship that started earlier)
This elegant observation lets us count battleships in a single pass without any extra data structures or recursion.
approach: |
We solve this using a **Single Pass (Count Heads)** approach:
**Step 1: Initialise the counter**
- `count`: Set to `0` to track the number of battleships found
&nbsp;
**Step 2: Iterate through each cell**
- Traverse the grid row by row, column by column (standard left-to-right, top-to-bottom order)
- For each cell at position `(i, j)`, check if it's the "head" of a battleship
&nbsp;
**Step 3: Identify battleship heads**
- Skip if `board[i][j] == '.'` (empty cell)
- Skip if `i > 0` and `board[i-1][j] == 'X'` (this cell is part of a vertical ship that started above)
- Skip if `j > 0` and `board[i][j-1] == 'X'` (this cell is part of a horizontal ship that started to the left)
- If none of the above, this is a new battleship head — increment `count`
&nbsp;
**Step 4: Return the result**
- Return `count` after processing all cells
&nbsp;
This approach works because each battleship has exactly one "head" cell (its top-left corner), and we count each head exactly once.
common_pitfalls:
- title: Using DFS/BFS for Each Ship
description: |
A natural instinct is to use flood-fill (DFS or BFS) to explore each battleship when you encounter an `'X'`:
- Mark all connected `'X'` cells as visited
- Increment counter after exploring the entire ship
While this works correctly, it's **overkill** for this problem. The problem statement guarantees that battleships are always straight lines with no adjacency, so you don't need to trace their full extent.
More importantly, DFS/BFS approaches typically require either:
- O(m*n) extra space for a visited array, or
- Modifying the board to mark visited cells
The follow-up explicitly asks for O(1) space without modification.
wrong_approach: "DFS/BFS flood-fill with visited tracking"
correct_approach: "Count only the top-left head of each ship"
- title: Counting Every X Cell
description: |
Simply counting all `'X'` cells gives the wrong answer because a single battleship can span multiple cells.
For example, with a 3-cell vertical ship, counting all `'X'` cells would report 3 instead of 1.
wrong_approach: "Increment counter for every 'X' cell"
correct_approach: "Only count cells that are battleship heads"
- title: Forgetting Boundary Checks
description: |
When checking the cell above (`board[i-1][j]`) or to the left (`board[i][j-1]`), you must ensure you're not accessing invalid indices.
- Only check `board[i-1][j]` when `i > 0`
- Only check `board[i][j-1]` when `j > 0`
Failing to add these guards causes an index-out-of-bounds error on the first row or column.
wrong_approach: "Check neighbors without boundary validation"
correct_approach: "Guard neighbor checks with i > 0 and j > 0"
key_takeaways:
- "**Counting heads pattern**: When objects span multiple cells, identify a unique 'anchor' point (like the top-left corner) to count each object exactly once"
- "**Leverage problem constraints**: The guarantee of no adjacent ships and only horizontal/vertical placement enables the O(1) space solution"
- "**Single-pass matrix traversal**: Scanning in reading order (left-to-right, top-to-bottom) naturally processes heads before their continuations"
- "**Question the obvious approach**: DFS/BFS is often the go-to for connected components, but simpler patterns exist for constrained inputs"
time_complexity: "O(m * n). We visit each cell exactly once, performing O(1) work per cell."
space_complexity: "O(1). We only use a single counter variable, regardless of the board size."
solutions:
- approach_name: Single Pass (Count Heads)
is_optimal: true
code: |
def count_battleships(board: list[list[str]]) -> int:
# Counter for battleship heads
count = 0
m, n = len(board), len(board[0])
for i in range(m):
for j in range(n):
# Skip empty cells
if board[i][j] == '.':
continue
# Skip if this is a continuation of a vertical ship
if i > 0 and board[i - 1][j] == 'X':
continue
# Skip if this is a continuation of a horizontal ship
if j > 0 and board[i][j - 1] == 'X':
continue
# This is a battleship head — count it
count += 1
return count
explanation: |
**Time Complexity:** O(m * n) — Single pass through all cells.
**Space Complexity:** O(1) — Only a counter variable is used.
We scan each cell once and only count `'X'` cells that have no `'X'` neighbor above or to the left. Since we traverse top-to-bottom and left-to-right, such cells are exactly the top-left starting points of each battleship.
- approach_name: DFS Flood Fill
is_optimal: false
code: |
def count_battleships(board: list[list[str]]) -> int:
m, n = len(board), len(board[0])
visited = [[False] * n for _ in range(m)]
count = 0
def dfs(i: int, j: int) -> None:
# Mark current cell as visited
visited[i][j] = True
# Explore all 4 directions
for di, dj in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
ni, nj = i + di, j + dj
# Check bounds, if unvisited, and if part of ship
if 0 <= ni < m and 0 <= nj < n:
if not visited[ni][nj] and board[ni][nj] == 'X':
dfs(ni, nj)
for i in range(m):
for j in range(n):
# Found an unvisited battleship cell
if board[i][j] == 'X' and not visited[i][j]:
dfs(i, j) # Mark entire ship as visited
count += 1 # Count this ship
return count
explanation: |
**Time Complexity:** O(m * n) — Each cell is visited at most once by DFS.
**Space Complexity:** O(m * n) — The visited array plus recursion stack.
This approach uses standard flood-fill DFS to explore each battleship completely when first encountered. While correct, it uses extra space for the visited array and is more complex than necessary given the problem's constraints. Included to show the classic connected-components approach.

View File

@@ -0,0 +1,185 @@
title: Beautiful Arrangement II
slug: beautiful-arrangement-ii
difficulty: medium
leetcode_id: 667
leetcode_url: https://leetcode.com/problems/beautiful-arrangement-ii/
categories:
- arrays
- math
patterns:
- greedy
description: |
Given two integers `n` and `k`, construct a list `answer` that contains `n` different positive integers ranging from `1` to `n` and obeys the following requirement:
Suppose this list is `answer = [a1, a2, a3, ..., an]`, then the list `[|a1 - a2|, |a2 - a3|, |a3 - a4|, ..., |an-1 - an|]` has exactly `k` distinct integers.
Return *the list* `answer`. If there are multiple valid answers, return **any of them**.
constraints: |
- `1 <= k < n <= 10^4`
examples:
- input: "n = 3, k = 1"
output: "[1, 2, 3]"
explanation: "The array [1, 2, 3] has three different positive integers ranging from 1 to 3, and the differences [|1-2|, |2-3|] = [1, 1] has exactly 1 distinct integer: 1."
- input: "n = 3, k = 2"
output: "[1, 3, 2]"
explanation: "The array [1, 3, 2] has three different positive integers ranging from 1 to 3, and the differences [|1-3|, |3-2|] = [2, 1] has exactly 2 distinct integers: 1 and 2."
explanation:
intuition: |
Think of this problem like arranging numbered tiles on a line, where you want to control how many *different gap sizes* appear between adjacent tiles.
The key insight is understanding what happens with a simple sorted sequence versus an alternating one. If you arrange numbers as `[1, 2, 3, ..., n]`, every adjacent difference is exactly `1` — you get only **1 distinct difference**. But if you zigzag between the smallest and largest remaining numbers, like `[1, n, 2, n-1, 3, ...]`, you generate the maximum variety of differences: `n-1, n-2, n-3, ...`
Here's the clever observation: with a sequence of `n` numbers, you can create anywhere from `1` to `n-1` distinct differences. To get exactly `k` distinct differences:
1. Use the first `k+1` numbers (`1` to `k+1`) in an alternating pattern to generate `k` distinct differences
2. Fill the rest with the remaining numbers in sorted order — these contribute only difference `1`, which we already have
This works because the alternating pattern on `k+1` elements produces differences `k, k-1, k-2, ..., 1` (exactly `k` distinct values), and appending sorted numbers only adds more `1`s.
approach: |
We solve this using a **Greedy Construction Approach**:
**Step 1: Initialise two pointers**
- `low`: Start at `1` (smallest available number)
- `high`: Start at `k + 1` (to create differences from `k` down to `1`)
&nbsp;
**Step 2: Build the alternating prefix**
- Alternate between picking `low` and `high`
- First pick `low`, then `high`, then `low + 1`, then `high - 1`, and so on
- Continue until `low > high` — this uses exactly `k + 1` numbers
- This produces differences: `k, k-1, k-2, ..., 1` (exactly `k` distinct values)
&nbsp;
**Step 3: Append the sorted suffix**
- Append the remaining numbers `k + 2, k + 3, ..., n` in order
- Each consecutive pair has difference `1`, which we already counted
- This doesn't introduce any new distinct differences
&nbsp;
**Step 4: Return the result**
- Return the constructed array
common_pitfalls:
- title: Overcomplicating the Construction
description: |
Many people try complex formulas or recursive approaches when a simple two-pointer alternation works perfectly.
The key realisation is that you only need to create exactly `k` distinct differences, and the alternating pattern on the first `k + 1` numbers does exactly that. The rest is just padding with sorted numbers.
wrong_approach: "Complex mathematical formulas or backtracking"
correct_approach: "Simple alternating pattern for first k+1 elements, then sorted remainder"
- title: Off-by-One Errors
description: |
It's easy to get confused about whether to use `k` or `k + 1` elements for the alternating part.
Remember: to get `k` *distinct* differences, you need `k + 1` numbers (since `n` numbers produce `n - 1` differences). The alternating pattern on `[1, 2, ..., k+1]` produces differences `k, k-1, ..., 1`.
wrong_approach: "Using k elements instead of k + 1"
correct_approach: "Use first k + 1 numbers for alternating, rest sorted"
- title: Not Handling the Full Range
description: |
The problem requires using all integers from `1` to `n`. If you only focus on getting `k` distinct differences but forget to include all numbers, your answer will be invalid.
The two-phase approach (alternating prefix + sorted suffix) naturally uses every number exactly once.
wrong_approach: "Forgetting to include numbers k+2 through n"
correct_approach: "Append remaining numbers in sorted order after the alternating prefix"
key_takeaways:
- "**Constructive algorithms**: Sometimes the best approach is to directly build the answer rather than search for it"
- "**Boundary analysis**: Understanding that `n` elements produce `n-1` differences helps identify that `k+1` elements can produce `k` distinct differences"
- "**Greedy construction**: The alternating pattern greedily maximises variety when needed, then we switch to minimal variety"
- "**Similar problems**: This technique of mixing high-variance and low-variance sections appears in problems like *Wiggle Sort* and other array construction tasks"
time_complexity: "O(n). We iterate through the array once to construct the result."
space_complexity: "O(n). We store the result array of size `n` (or O(1) if we don't count the output)."
solutions:
- approach_name: Two Pointers (Alternating Construction)
is_optimal: true
code: |
def construct_array(n: int, k: int) -> list[int]:
result = []
low, high = 1, k + 1
# Alternate between low and high to create k distinct differences
while low <= high:
result.append(low)
low += 1
if low <= high:
result.append(high)
high -= 1
# Append remaining numbers in sorted order (only adds difference of 1)
for num in range(k + 2, n + 1):
result.append(num)
return result
explanation: |
**Time Complexity:** O(n) — Single pass to build the array.
**Space Complexity:** O(n) — The result array of size `n`.
The alternating phase creates the sequence `[1, k+1, 2, k, 3, k-1, ...]` which has differences `[k, k-1, k-2, ..., 1]`. The sorted suffix `[k+2, k+3, ..., n]` adds only difference `1` (already present), giving exactly `k` distinct differences.
- approach_name: Direct Formula Construction
is_optimal: true
code: |
def construct_array(n: int, k: int) -> list[int]:
result = []
# Build the alternating prefix using a toggle
toggle = True
low, high = 1, k + 1
for _ in range(k + 1):
if toggle:
result.append(low)
low += 1
else:
result.append(high)
high -= 1
toggle = not toggle
# Append the sorted suffix
result.extend(range(k + 2, n + 1))
return result
explanation: |
**Time Complexity:** O(n) — Linear construction.
**Space Complexity:** O(n) — The result array.
This is an alternative formulation using an explicit toggle flag. The logic is identical: alternate between smallest and largest within `[1, k+1]`, then append `[k+2, n]` in order. Some find the toggle pattern more readable than the nested while loop.
- approach_name: Reverse Segment Approach
is_optimal: true
code: |
def construct_array(n: int, k: int) -> list[int]:
# Start with sorted array [1, 2, 3, ..., n]
result = list(range(1, n + 1))
# Reverse the first k+1 elements in a zigzag pattern
# Each reversal introduces one new distinct difference
for i in range(1, k + 1):
# Reverse from index i to index k
result[i:k+1] = result[i:k+1][::-1]
return result
explanation: |
**Time Complexity:** O(n * k) — Multiple reversals, but can be optimised.
**Space Complexity:** O(n) — The result array.
This approach starts with `[1, 2, ..., n]` (1 distinct difference) and repeatedly reverses suffixes to introduce new differences one at a time. While conceptually interesting, it's less efficient than the direct construction. Included to show an alternative way of thinking about the problem.

View File

@@ -0,0 +1,230 @@
title: Beautiful Arrangement
slug: beautiful-arrangement
difficulty: medium
leetcode_id: 526
leetcode_url: https://leetcode.com/problems/beautiful-arrangement/
categories:
- arrays
- recursion
patterns:
- backtracking
description: |
Suppose you have `n` integers labelled `1` through `n`. A permutation of those `n` integers `perm` (**1-indexed**) is considered a **beautiful arrangement** if for every `i` (`1 <= i <= n`), **either** of the following is true:
- `perm[i]` is divisible by `i`.
- `i` is divisible by `perm[i]`.
Given an integer `n`, return *the **number** of the **beautiful arrangements** that you can construct*.
constraints: |
- `1 <= n <= 15`
examples:
- input: "n = 2"
output: "2"
explanation: |
The first beautiful arrangement is [1, 2]:
- perm[1] = 1 is divisible by i = 1
- perm[2] = 2 is divisible by i = 2
The second beautiful arrangement is [2, 1]:
- perm[1] = 2 is divisible by i = 1
- i = 2 is divisible by perm[2] = 1
- input: "n = 1"
output: "1"
explanation: "The only arrangement is [1], which satisfies perm[1] = 1 divisible by i = 1."
explanation:
intuition: |
Imagine you're filling `n` numbered boxes (positions 1 through n) with `n` numbered balls (values 1 through n). Each ball can only go in a box if there's a "divisibility relationship" between them — either the ball number divides the box number, or vice versa.
This is fundamentally a **constraint satisfaction problem**. You need to count all valid ways to assign balls to boxes where every assignment satisfies the divisibility rule.
Think of it like this: start filling boxes from position 1. For each position, try placing each unused number and check if it satisfies the divisibility condition. If it does, recursively try to fill the remaining positions. If you successfully fill all positions, you've found one beautiful arrangement. If you hit a dead end (no valid number for a position), backtrack and try a different number at the previous position.
The key insight is that `n <= 15` is small enough that exploring the solution space via backtracking is feasible — we won't hit time limits even though there are `n!` potential permutations, because we **prune invalid branches early**.
approach: |
We solve this using **Backtracking** to explore all valid permutations:
**Step 1: Define the recursive function**
- Create a helper function `backtrack(position, used)` that tries to fill `position` using numbers from the `used` set
- `position`: The current position we're trying to fill (1-indexed)
- `used`: A set tracking which numbers have already been placed
&nbsp;
**Step 2: Base case**
- If `position > n`, we've successfully filled all positions — increment our count and return
- This means we found one valid beautiful arrangement
&nbsp;
**Step 3: Try each unused number**
- For each number from `1` to `n`:
- Skip if already used
- Check the divisibility condition: `num % position == 0` or `position % num == 0`
- If valid, mark the number as used and recurse to the next position
- After returning, unmark the number (backtrack) to try other possibilities
&nbsp;
**Step 4: Return the count**
- After exploring all branches, return the total count of valid arrangements
&nbsp;
The backtracking naturally prunes invalid branches — if no number works for a position, that branch terminates without exploring further, making this much faster than generating all `n!` permutations.
common_pitfalls:
- title: Generating All Permutations First
description: |
A naive approach might generate all `n!` permutations and then filter for valid ones. With `n = 15`, that's over 1.3 trillion permutations — clearly infeasible.
The backtracking approach is far superior because it **prunes invalid branches early**. If position 1 can only accept certain numbers, we never explore branches where position 1 has an invalid number.
wrong_approach: "Generate all permutations, then filter"
correct_approach: "Backtrack with early pruning on divisibility check"
- title: Off-by-One with 1-Indexed Positions
description: |
The problem uses **1-indexed** positions, meaning positions range from `1` to `n`, not `0` to `n-1`.
If you start your recursion at position `0` and use `position % num` or `num % position`, you'll get division by zero or incorrect results. Always start at position `1`.
wrong_approach: "Start recursion at position 0"
correct_approach: "Start recursion at position 1 (1-indexed)"
- title: Forgetting to Backtrack
description: |
After recursively exploring a branch with a number placed, you must **remove it from the used set** before trying the next number. Forgetting this means numbers stay "used" across different branches, leading to incorrect (lower) counts.
Always pair "add to used" with "remove from used" after the recursive call returns.
wrong_approach: "Add to used set but never remove"
correct_approach: "Remove from used set after recursive call (backtrack)"
key_takeaways:
- "**Backtracking pattern**: When counting or finding all valid configurations, systematically explore choices and undo them (backtrack) to try alternatives"
- "**Early pruning**: Check constraints as early as possible to avoid exploring invalid branches — this is what makes backtracking efficient"
- "**Small constraints hint at exponential solutions**: When `n <= 15-20`, backtracking or bitmask DP is often the intended approach"
- "**Foundation for harder problems**: This technique extends to N-Queens, Sudoku solvers, and other constraint satisfaction problems"
time_complexity: "O(k) where k is the number of valid permutations. In the worst case, this approaches O(n!) but heavy pruning makes it much faster in practice."
space_complexity: "O(n). The recursion stack depth is at most `n`, and we use a set of size `n` to track used numbers."
solutions:
- approach_name: Backtracking
is_optimal: true
code: |
def count_arrangement(n: int) -> int:
count = 0
def backtrack(position: int, used: set[int]) -> None:
nonlocal count
# Base case: filled all positions successfully
if position > n:
count += 1
return
# Try each number from 1 to n
for num in range(1, n + 1):
# Skip if already used
if num in used:
continue
# Check divisibility condition
if num % position == 0 or position % num == 0:
# Place this number and recurse
used.add(num)
backtrack(position + 1, used)
# Backtrack: remove the number to try others
used.remove(num)
backtrack(1, set())
return count
explanation: |
**Time Complexity:** O(k) where k is the number of valid arrangements — significantly less than O(n!) due to pruning.
**Space Complexity:** O(n) — recursion depth and the used set.
We explore positions from 1 to n, trying each unused number that satisfies the divisibility condition. Invalid branches are pruned immediately, and we backtrack after each recursive call to explore all possibilities.
- approach_name: Backtracking with Visited Array
is_optimal: true
code: |
def count_arrangement(n: int) -> int:
# Track which numbers are used (index 0 unused for 1-indexing)
visited = [False] * (n + 1)
count = 0
def backtrack(position: int) -> None:
nonlocal count
# Successfully filled all positions
if position > n:
count += 1
return
for num in range(1, n + 1):
# Skip used numbers or those failing divisibility check
if visited[num]:
continue
if num % position != 0 and position % num != 0:
continue
# Choose this number for current position
visited[num] = True
backtrack(position + 1)
# Undo choice (backtrack)
visited[num] = False
backtrack(1)
return count
explanation: |
**Time Complexity:** O(k) where k is the number of valid arrangements.
**Space Complexity:** O(n) — visited array and recursion stack.
This variant uses a boolean array instead of a set for tracking used numbers. The logic is identical: try each valid number, recurse, then backtrack. Array lookups are O(1) like set operations, so performance is similar.
- approach_name: Bitmask Dynamic Programming
is_optimal: true
code: |
def count_arrangement(n: int) -> int:
# dp[mask] = number of ways to arrange numbers in mask
# where mask has k bits set and we've filled positions 1..k
dp = [0] * (1 << n)
dp[0] = 1 # Empty arrangement: one way to arrange nothing
for mask in range(1 << n):
# Count bits set = number of positions filled
position = bin(mask).count('1') + 1
# If we've filled more than n positions, skip
if position > n + 1:
continue
# Try adding each number not yet in mask
for num in range(1, n + 1):
bit = 1 << (num - 1)
# Skip if number already used
if mask & bit:
continue
# Check divisibility condition
if num % position == 0 or position % num == 0:
dp[mask | bit] += dp[mask]
# Full mask (all numbers used) gives our answer
return dp[(1 << n) - 1]
explanation: |
**Time Complexity:** O(n * 2^n) — iterate through all 2^n masks, trying n numbers each.
**Space Complexity:** O(2^n) — the DP table.
This approach uses bitmask DP where `dp[mask]` represents the number of ways to fill positions using exactly the numbers represented by `mask`. We iterate through all subsets and transition by adding one number at a time. With `n <= 15`, 2^15 = 32,768 states is manageable.

View File

@@ -0,0 +1,162 @@
title: Beautiful Array
slug: beautiful-array
difficulty: medium
leetcode_id: 932
leetcode_url: https://leetcode.com/problems/beautiful-array/
categories:
- arrays
- math
patterns:
- dynamic-programming
description: |
An array `nums` of length `n` is **beautiful** if:
- `nums` is a permutation of the integers in the range `[1, n]`.
- For every `0 <= i < j < n`, there is no index `k` with `i < k < j` where `2 * nums[k] == nums[i] + nums[j]`.
Given the integer `n`, return *any beautiful array* `nums` *of length* `n`. There will be at least one valid answer for the given `n`.
constraints: |
- `1 <= n <= 1000`
examples:
- input: "n = 4"
output: "[2, 1, 4, 3]"
explanation: "This is a beautiful array because no three elements at positions i < k < j satisfy 2 * nums[k] == nums[i] + nums[j]."
- input: "n = 5"
output: "[3, 1, 2, 5, 4]"
explanation: "This permutation satisfies the beautiful array property — no arithmetic mean constraint is violated."
explanation:
intuition: |
The key insight is understanding what makes an array *not* beautiful: three elements `nums[i]`, `nums[k]`, `nums[j]` where `i < k < j` and `nums[k]` is the **arithmetic mean** of `nums[i]` and `nums[j]`.
Think of it like this: if `2 * nums[k] == nums[i] + nums[j]`, then `nums[k]` lies exactly halfway between `nums[i]` and `nums[j]`. For this to happen, `nums[i] + nums[j]` must be **even**, which requires `nums[i]` and `nums[j]` to have the **same parity** (both odd or both even).
Here's the breakthrough: if we arrange all **odd numbers** on one side and all **even numbers** on the other side, then for any `i < j` where `nums[i]` and `nums[j]` have different parities, the sum `nums[i] + nums[j]` is odd. An odd sum divided by 2 cannot be an integer, so there's no valid `nums[k]` that could violate the condition!
But what about pairs with the same parity? We apply the same logic **recursively**. Within the odd section and within the even section, we recursively build beautiful sub-arrays. This divide-and-conquer approach ensures no violations exist at any level.
approach: |
We solve this using a **Divide and Conquer** approach based on parity separation:
**Step 1: Understand the transformation properties**
- If `A` is a beautiful array, then `2 * A - 1` (making all elements odd) is also beautiful
- If `A` is a beautiful array, then `2 * A` (making all elements even) is also beautiful
- Concatenating `[odd elements] + [even elements]` preserves the beautiful property across the join
&nbsp;
**Step 2: Build recursively with memoisation**
- Base case: `n = 1` returns `[1]`
- For larger `n`, recursively build beautiful arrays for `(n + 1) // 2` elements (odd positions) and `n // 2` elements (even positions)
- Transform the first array to odd numbers: `2 * x - 1`
- Transform the second array to even numbers: `2 * x`
- Concatenate and filter to keep only values `<= n`
&nbsp;
**Step 3: Return the result**
- The concatenation of transformed odd and even arrays gives us our beautiful array
&nbsp;
This works because the parity separation guarantees no arithmetic mean can exist between elements from different halves, and recursion ensures the same property within each half.
common_pitfalls:
- title: Brute Force Validation
description: |
A naive approach might try to generate random permutations and validate each one by checking all triplets `(i, k, j)`.
With `n = 1000`, there are `n!` permutations and O(n^3) validation per permutation. This is astronomically slow and will never complete.
wrong_approach: "Random permutation generation with triplet validation"
correct_approach: "Constructive divide-and-conquer algorithm"
- title: Missing the Parity Insight
description: |
Without recognising that `2 * nums[k] == nums[i] + nums[j]` requires `nums[i]` and `nums[j]` to have the same parity, you might not see the path to a solution.
The sum of an odd and even number is always odd. Half of an odd number is not an integer, so it cannot equal any `nums[k]`.
wrong_approach: "Trying complex arrangements without mathematical insight"
correct_approach: "Separate by parity, then recurse"
- title: Off-by-One Errors in Recursion
description: |
When splitting `n` elements into odd and even counts, be careful:
- Odd count: `(n + 1) // 2` (ceiling division)
- Even count: `n // 2` (floor division)
After transformation, filter to keep only values `<= n` since the transformation might produce values outside the valid range.
wrong_approach: "Incorrect ceiling/floor division"
correct_approach: "Use `(n + 1) // 2` for odds, `n // 2` for evens, then filter"
key_takeaways:
- "**Parity separation**: When an arithmetic relationship requires an even sum, separating odds and evens breaks the relationship across the boundary"
- "**Constructive algorithms**: Instead of searching for valid solutions, construct them using mathematical properties"
- "**Affine transformations preserve structure**: If `A` is beautiful, then `k * A + c` is also beautiful for any constants `k`, `c`"
- "**Divide and conquer with invariants**: The beautiful property is preserved through concatenation when the halves are parity-separated"
time_complexity: "O(n log n). We build arrays of size roughly n/2 at each recursion level, with log n levels total."
space_complexity: "O(n log n). The memoisation cache stores arrays for sizes 1 through n, with total elements proportional to n log n."
solutions:
- approach_name: Divide and Conquer
is_optimal: true
code: |
def beautiful_array(n: int) -> list[int]:
# Memoisation to avoid recomputing the same sizes
memo = {1: [1]}
def build(size: int) -> list[int]:
if size in memo:
return memo[size]
# Recursively build smaller beautiful arrays
# Odd positions: transform to 2*x - 1 (odd numbers)
odds = build((size + 1) // 2)
# Even positions: transform to 2*x (even numbers)
evens = build(size // 2)
# Transform and concatenate: all odds first, then all evens
# This ensures no arithmetic mean exists across the boundary
result = [2 * x - 1 for x in odds] + [2 * x for x in evens]
# Filter to keep only values <= size
result = [x for x in result if x <= size]
memo[size] = result
return result
return build(n)
explanation: |
**Time Complexity:** O(n log n) — At each level of recursion, we process O(n) elements total, with O(log n) levels.
**Space Complexity:** O(n log n) — The memoisation cache stores arrays of decreasing sizes, summing to O(n log n) total elements.
The algorithm exploits that separating odds and evens prevents arithmetic means across the boundary. By recursively applying this within each half, we construct a beautiful array directly.
- approach_name: Iterative Construction
is_optimal: true
code: |
def beautiful_array(n: int) -> list[int]:
# Start with the simplest beautiful array
result = [1]
# Build up by applying transformations
while len(result) < n:
# Odd transformation: 2*x - 1, then even: 2*x
# Concatenate odds first, then evens
result = [2 * x - 1 for x in result] + [2 * x for x in result]
# Filter to keep only values in range [1, n]
return [x for x in result if x <= n]
explanation: |
**Time Complexity:** O(n log n) — We double the array size in each iteration, performing O(n) work per iteration across O(log n) iterations.
**Space Complexity:** O(n) — We only store the current result array.
This iterative version builds bottom-up instead of top-down. Starting from `[1]`, we repeatedly transform to create odds and evens, doubling the size until we exceed `n`, then filter. This is equivalent to the recursive approach but uses less memory.

View File

@@ -0,0 +1,179 @@
title: Best Poker Hand
slug: best-poker-hand
difficulty: easy
leetcode_id: 2347
leetcode_url: https://leetcode.com/problems/best-poker-hand/
categories:
- arrays
- hash-tables
patterns:
- greedy
description: |
You are given an integer array `ranks` and a character array `suits`. You have `5` cards where the i<sup>th</sup> card has a rank of `ranks[i]` and a suit of `suits[i]`.
The following are the types of **poker hands** you can make from best to worst:
1. `"Flush"`: Five cards of the same suit.
2. `"Three of a Kind"`: Three cards of the same rank.
3. `"Pair"`: Two cards of the same rank.
4. `"High Card"`: Any single card.
Return *a string representing the **best** type of **poker hand** you can make with the given cards.*
**Note** that the return values are **case-sensitive**.
constraints: |
- `ranks.length == suits.length == 5`
- `1 <= ranks[i] <= 13`
- `'a' <= suits[i] <= 'd'`
- No two cards have the same rank and suit.
examples:
- input: 'ranks = [13,2,3,1,9], suits = ["a","a","a","a","a"]'
output: '"Flush"'
explanation: "The hand with all the cards consists of 5 cards with the same suit, so we have a \"Flush\"."
- input: 'ranks = [4,4,2,4,4], suits = ["d","a","a","b","c"]'
output: '"Three of a Kind"'
explanation: "The hand with the first, second, and fourth card consists of 3 cards with the same rank, so we have a \"Three of a Kind\". Note that we could also make a \"Pair\" hand but \"Three of a Kind\" is a better hand."
- input: 'ranks = [10,10,2,12,9], suits = ["a","b","c","a","d"]'
output: '"Pair"'
explanation: "The hand with the first and second card consists of 2 cards with the same rank, so we have a \"Pair\". Note that we cannot make a \"Flush\" or a \"Three of a Kind\"."
explanation:
intuition: |
Think of this problem like a poker player evaluating their hand. You're dealt 5 cards and need to identify the **best possible hand** you can claim.
The key insight is that the hand types are ordered from best to worst, so we should check for them **in that order**. As soon as we find a match, we can return immediately — no need to check lesser hands.
For a Flush, we need all 5 cards to share the same suit. This is easy to check: if there's only one unique suit among all cards, it's a Flush.
For Three of a Kind or Pair, we need to count how many times each rank appears. The **maximum frequency** of any rank tells us our best option:
- If any rank appears 3+ times → "Three of a Kind"
- If any rank appears exactly 2 times → "Pair"
- Otherwise → "High Card"
The problem is simplified by the fixed hand size of 5 cards, which means we can use simple counting with hash tables.
approach: |
We solve this using a **Priority Check with Counting** approach:
**Step 1: Check for Flush**
- Count the unique suits in the `suits` array
- If there's only 1 unique suit, all 5 cards match → return `"Flush"`
- We can use a set to find unique elements efficiently
&nbsp;
**Step 2: Count rank frequencies**
- Use a hash map (Counter/dictionary) to count occurrences of each rank
- Find the maximum frequency among all ranks
&nbsp;
**Step 3: Determine hand based on max frequency**
- If `max_count >= 3` → return `"Three of a Kind"`
- If `max_count == 2` → return `"Pair"`
- Otherwise → return `"High Card"`
&nbsp;
This greedy approach works because we check hands in order of priority. The moment we find a qualifying hand, we return it — guaranteeing we report the best possible hand.
common_pitfalls:
- title: Checking in Wrong Order
description: |
A common mistake is checking for Pair before Three of a Kind, or checking ranks before suits.
The problem states the hand types from best to worst. If you have four cards of the same rank (like `[4,4,4,4,2]`), that qualifies as both "Three of a Kind" and "Pair". You must return "Three of a Kind" because it's ranked higher.
Always check in priority order: Flush → Three of a Kind → Pair → High Card.
wrong_approach: "Check for Pair before Three of a Kind"
correct_approach: "Check hands in order of priority (best to worst)"
- title: Overcomplicating the Flush Check
description: |
Some solutions iterate through the suits array comparing elements when a simpler approach exists.
Since we have exactly 5 cards and 5 suits, checking if all suits are the same is equivalent to checking if there's only 1 unique suit. Using `len(set(suits)) == 1` is cleaner and more Pythonic.
wrong_approach: "Loop comparing suits[i] == suits[0] for all i"
correct_approach: "Use len(set(suits)) == 1"
- title: Missing the Four of a Kind Case
description: |
The problem doesn't list "Four of a Kind" as a separate hand type. When you have 4 cards of the same rank, it still only counts as "Three of a Kind" per the problem definition.
Don't add extra logic for four-of-a-kind — the `max_count >= 3` check handles it correctly by returning "Three of a Kind".
key_takeaways:
- "**Priority-based evaluation**: When categorising into ranked tiers, check from best to worst and return on first match"
- "**Hash tables for counting**: `Counter` or dictionaries make frequency counting trivial — finding max frequency is O(n)"
- "**Sets for uniqueness**: Converting to a set instantly tells you how many unique elements exist"
- "**Fixed input size**: With only 5 cards, even 'inefficient' approaches are fast — but clean code still matters"
time_complexity: "O(1). We process exactly 5 cards, making this effectively constant time regardless of implementation details."
space_complexity: "O(1). The hash map stores at most 5 entries (one per card), and the set stores at most 4 suits — both bounded by constants."
solutions:
- approach_name: Priority Check with Counting
is_optimal: true
code: |
from collections import Counter
def best_hand(ranks: list[int], suits: list[str]) -> str:
# Check for Flush: all 5 cards same suit
if len(set(suits)) == 1:
return "Flush"
# Count frequency of each rank
rank_counts = Counter(ranks)
max_count = max(rank_counts.values())
# Check for Three of a Kind (3 or more of same rank)
if max_count >= 3:
return "Three of a Kind"
# Check for Pair (exactly 2 of same rank)
if max_count == 2:
return "Pair"
# Default: High Card
return "High Card"
explanation: |
**Time Complexity:** O(1) — We always process exactly 5 cards.
**Space Complexity:** O(1) — Counter and set are bounded by the fixed hand size.
We check for each hand type in order of priority. The Flush check uses a set for O(1) uniqueness counting. The Counter gives us rank frequencies, and we only need the maximum to determine Three of a Kind vs Pair vs High Card.
- approach_name: Manual Counting (No Imports)
is_optimal: false
code: |
def best_hand(ranks: list[int], suits: list[str]) -> str:
# Check for Flush: compare all suits to the first
is_flush = all(s == suits[0] for s in suits)
if is_flush:
return "Flush"
# Count rank frequencies using a dictionary
rank_counts = {}
for rank in ranks:
rank_counts[rank] = rank_counts.get(rank, 0) + 1
# Find max frequency
max_count = max(rank_counts.values())
if max_count >= 3:
return "Three of a Kind"
if max_count == 2:
return "Pair"
return "High Card"
explanation: |
**Time Complexity:** O(1) — Fixed 5-card input.
**Space Complexity:** O(1) — Dictionary bounded by hand size.
This version avoids importing `Counter` by manually building a frequency dictionary. The `all()` function provides a readable Flush check. Functionally equivalent to the optimal solution, just more verbose.

View File

@@ -0,0 +1,269 @@
title: Best Position for a Service Centre
slug: best-position-for-a-service-centre
difficulty: hard
leetcode_id: 1515
leetcode_url: https://leetcode.com/problems/best-position-for-a-service-centre/
categories:
- arrays
- math
patterns:
- greedy
description: |
A delivery company wants to build a new service center in a new city. The company knows the positions of all the customers in this city on a 2D-Map and wants to build the new center in a position such that **the sum of the Euclidean distances to all customers is minimum**.
Given an array `positions` where `positions[i] = [x_i, y_i]` is the position of the i<sup>th</sup> customer on the map, return *the minimum sum of the Euclidean distances* to all customers.
In other words, you need to choose the position of the service center `[x_centre, y_centre]` such that the following formula is minimised:
`∑ sqrt((x_centre - x_i)² + (y_centre - y_i)²)`
Answers within `10^-5` of the actual value will be accepted.
constraints: |
- `1 <= positions.length <= 50`
- `positions[i].length == 2`
- `0 <= x_i, y_i <= 100`
examples:
- input: "positions = [[0,1],[1,0],[1,2],[2,1]]"
output: "4.00000"
explanation: "Choosing [x_centre, y_centre] = [1, 1] makes the distance to each customer = 1, so the sum of all distances is 4, which is the minimum possible."
- input: "positions = [[1,1],[3,3]]"
output: "2.82843"
explanation: "The minimum possible sum of distances = sqrt(2) + sqrt(2) = 2.82843. The optimal centre lies on the line between the two points."
explanation:
intuition: |
Imagine you're dropping a pin on a map, and rubber bands connect it to every customer location. The pin naturally settles at the position where the total tension (sum of distances) is minimised. This point is called the **geometric median** or **Fermat point**.
Unlike the *centroid* (arithmetic mean of coordinates), which minimises the sum of **squared** distances, the geometric median minimises the sum of **actual** distances. There's no closed-form formula for this — we must use iterative optimisation.
Think of it like this: start somewhere reasonable (the centroid is a good initial guess), then repeatedly nudge the point in the direction that reduces the total distance. Each step, we compute the gradient of our objective function and move against it.
The key insight is that while the objective function (sum of Euclidean distances) isn't simple to solve analytically, it's **convex** — meaning any local minimum is the global minimum. This guarantees that gradient-based methods will converge to the optimal solution.
approach: |
We solve this using **Gradient Descent** (or alternatively, Weiszfeld's algorithm):
**Step 1: Define the objective function**
- `f(x, y)` = sum of Euclidean distances from `(x, y)` to all customer positions
- `f(x, y) = ∑ sqrt((x - x_i)² + (y - y_i)²)`
&nbsp;
**Step 2: Initialise the starting point**
- Compute the centroid of all positions as a reasonable starting point
- `x = mean(x_i)`, `y = mean(y_i)`
&nbsp;
**Step 3: Compute the gradient**
- The partial derivatives tell us which direction increases the objective:
- `∂f/∂x = ∑ (x - x_i) / dist_i` where `dist_i = sqrt((x - x_i)² + (y - y_i)²)`
- `∂f/∂y = ∑ (y - y_i) / dist_i`
- We move in the *opposite* direction to decrease the total distance
&nbsp;
**Step 4: Iteratively update the position**
- Use a learning rate `α` that decreases over time for stable convergence
- `x_new = x - α * ∂f/∂x`
- `y_new = y - α * ∂f/∂y`
- Repeat until the change is smaller than the required precision (`10^-7`)
&nbsp;
**Step 5: Return the minimum sum**
- Compute and return `f(x_final, y_final)`
&nbsp;
The algorithm is guaranteed to converge because the sum of Euclidean distances is a convex function, meaning there's exactly one global minimum.
common_pitfalls:
- title: Confusing Geometric Median with Centroid
description: |
The centroid (arithmetic mean of x and y coordinates separately) minimises the sum of **squared** distances, not actual distances. For this problem, you cannot simply compute:
- `x_centre = mean(x_i)`, `y_centre = mean(y_i)`
While the centroid is a good starting point for gradient descent, it's rarely the optimal answer. For example, with points at `[[0,0], [0,3], [4,0]]`, the centroid is `(4/3, 1)`, but the geometric median is different.
wrong_approach: "Return the centroid directly"
correct_approach: "Use centroid as starting point, then optimise with gradient descent"
- title: Fixed Learning Rate Causing Oscillation
description: |
Using a constant learning rate can cause the algorithm to oscillate around the minimum without converging precisely enough to meet the `10^-5` tolerance.
The learning rate should decrease over iterations. A common strategy is to start with a larger rate (e.g., `1.0`) and multiply by a decay factor (e.g., `0.999`) each iteration, or halve it when no improvement is made.
wrong_approach: "Use fixed learning rate α = 0.01"
correct_approach: "Use adaptive or decaying learning rate"
- title: Division by Zero at Customer Positions
description: |
When computing the gradient, if the current position exactly matches a customer position, `dist_i = 0` causes division by zero.
Handle this by adding a small epsilon (e.g., `10^-10`) to the distance, or skip that customer's contribution when the distance is effectively zero.
wrong_approach: "Compute gradient without checking for zero distance"
correct_approach: "Add epsilon to distance or handle zero-distance case"
key_takeaways:
- "**Geometric median vs centroid**: The centroid minimises squared distances; the geometric median minimises actual distances — they require different algorithms"
- "**Gradient descent for continuous optimisation**: When no closed-form solution exists, iterative methods like gradient descent can find optimal solutions"
- "**Convexity guarantees convergence**: The sum of Euclidean distances is convex, so gradient descent will find the global minimum"
- "**Adaptive learning rate**: For numerical precision, decrease the step size as you approach the solution"
time_complexity: "O(n × k) where n is the number of positions and k is the number of iterations. Typically k is in the hundreds to achieve the required precision."
space_complexity: "O(1). We only store the current position and gradient values, regardless of input size."
solutions:
- approach_name: Gradient Descent
is_optimal: true
code: |
import math
def get_min_dist_sum(positions: list[list[int]]) -> float:
def total_distance(x: float, y: float) -> float:
"""Calculate sum of Euclidean distances from (x, y) to all positions."""
return sum(
math.sqrt((x - px) ** 2 + (y - py) ** 2)
for px, py in positions
)
def compute_gradient(x: float, y: float) -> tuple[float, float]:
"""Compute partial derivatives of the distance sum."""
grad_x, grad_y = 0.0, 0.0
for px, py in positions:
dist = math.sqrt((x - px) ** 2 + (y - py) ** 2)
if dist > 1e-10: # Avoid division by zero
grad_x += (x - px) / dist
grad_y += (y - py) / dist
return grad_x, grad_y
# Start at centroid (good initial guess)
x = sum(p[0] for p in positions) / len(positions)
y = sum(p[1] for p in positions) / len(positions)
# Gradient descent with decaying learning rate
learning_rate = 1.0
decay = 0.999
epsilon = 1e-7
for _ in range(100000):
grad_x, grad_y = compute_gradient(x, y)
# Update position (move against gradient)
new_x = x - learning_rate * grad_x
new_y = y - learning_rate * grad_y
# Check for convergence
if abs(new_x - x) < epsilon and abs(new_y - y) < epsilon:
break
x, y = new_x, new_y
learning_rate *= decay
return total_distance(x, y)
explanation: |
**Time Complexity:** O(n × k) — Each iteration computes distances to all n points, running for up to k iterations.
**Space Complexity:** O(1) — Only stores current position and gradient values.
We start at the centroid and iteratively move in the direction that decreases total distance. The decaying learning rate ensures we take smaller steps as we approach the optimum, achieving the required precision.
- approach_name: Weiszfeld's Algorithm
is_optimal: true
code: |
import math
def get_min_dist_sum(positions: list[list[int]]) -> float:
def total_distance(x: float, y: float) -> float:
"""Calculate sum of Euclidean distances from (x, y) to all positions."""
return sum(
math.sqrt((x - px) ** 2 + (y - py) ** 2)
for px, py in positions
)
# Start at centroid
x = sum(p[0] for p in positions) / len(positions)
y = sum(p[1] for p in positions) / len(positions)
epsilon = 1e-7
for _ in range(1000):
# Compute weights (inverse distances)
weights = []
for px, py in positions:
dist = math.sqrt((x - px) ** 2 + (y - py) ** 2)
# Handle case when we're exactly on a point
weights.append(1.0 / max(dist, epsilon))
# Weighted average gives new position
total_weight = sum(weights)
new_x = sum(w * p[0] for w, p in zip(weights, positions)) / total_weight
new_y = sum(w * p[1] for w, p in zip(weights, positions)) / total_weight
# Check convergence
if abs(new_x - x) < epsilon and abs(new_y - y) < epsilon:
break
x, y = new_x, new_y
return total_distance(x, y)
explanation: |
**Time Complexity:** O(n × k) — Similar to gradient descent, each iteration visits all n points.
**Space Complexity:** O(n) — Stores weights for all positions.
Weiszfeld's algorithm is a specialised iterative method for the geometric median. Each iteration computes a weighted average where weights are inverse distances. Points closer to the current estimate have higher influence, naturally pulling the estimate toward the optimal position.
- approach_name: Simulated Annealing
is_optimal: false
code: |
import math
import random
def get_min_dist_sum(positions: list[list[int]]) -> float:
def total_distance(x: float, y: float) -> float:
return sum(
math.sqrt((x - px) ** 2 + (y - py) ** 2)
for px, py in positions
)
# Start at centroid
x = sum(p[0] for p in positions) / len(positions)
y = sum(p[1] for p in positions) / len(positions)
best_dist = total_distance(x, y)
# Simulated annealing
temp = 100.0
cooling_rate = 0.9999
for _ in range(100000):
# Random step proportional to temperature
new_x = x + (random.random() - 0.5) * temp
new_y = y + (random.random() - 0.5) * temp
new_dist = total_distance(new_x, new_y)
# Accept if better, or probabilistically if worse
if new_dist < best_dist:
x, y = new_x, new_y
best_dist = new_dist
elif random.random() < math.exp((best_dist - new_dist) / temp):
x, y = new_x, new_y
temp *= cooling_rate
return best_dist
explanation: |
**Time Complexity:** O(n × k) — Similar iteration count to gradient descent.
**Space Complexity:** O(1) — Only stores current and best positions.
Simulated annealing is a probabilistic approach that can escape local minima. While the geometric median problem is convex (no local minima), this approach still works and demonstrates an alternative optimisation technique. It's less deterministic than gradient descent but more robust for non-convex problems.

View File

@@ -0,0 +1,174 @@
title: Best Sightseeing Pair
slug: best-sightseeing-pair
difficulty: medium
leetcode_id: 1014
leetcode_url: https://leetcode.com/problems/best-sightseeing-pair/
categories:
- arrays
- dynamic-programming
patterns:
- greedy
description: |
You are given an integer array `values` where `values[i]` represents the value of the i<sup>th</sup> sightseeing spot. Two sightseeing spots `i` and `j` have a **distance** `j - i` between them.
The score of a pair (`i < j`) of sightseeing spots is `values[i] + values[j] + i - j`: the sum of the values of the sightseeing spots, minus the distance between them.
Return *the maximum score of a pair of sightseeing spots*.
constraints: |
- `2 <= values.length <= 5 * 10^4`
- `1 <= values[i] <= 1000`
examples:
- input: "values = [8,1,5,2,6]"
output: "11"
explanation: "i = 0, j = 2, values[i] + values[j] + i - j = 8 + 5 + 0 - 2 = 11"
- input: "values = [1,2]"
output: "2"
explanation: "i = 0, j = 1, values[i] + values[j] + i - j = 1 + 2 + 0 - 1 = 2"
explanation:
intuition: |
The key insight comes from **algebraically rearranging** the score formula.
The score for a pair `(i, j)` where `i < j` is:
```
score = values[i] + values[j] + i - j
```
We can regroup this as:
```
score = (values[i] + i) + (values[j] - j)
```
Think of it like this: each sightseeing spot has two "personalities":
- As a **starting point** (spot `i`): its contribution is `values[i] + i`
- As an **ending point** (spot `j`): its contribution is `values[j] - j`
The total score is simply the sum of the best starting contribution plus the current ending contribution. As we walk through the array from left to right, we track the **maximum starting contribution** we've seen so far. For each new position, we calculate what score we'd get if we ended here.
This transforms an O(n^2) problem of checking all pairs into an O(n) single-pass solution.
approach: |
We solve this using a **Single Pass (Greedy) Approach** by tracking the best starting point as we iterate:
**Step 1: Understand the formula decomposition**
- Original: `values[i] + values[j] + i - j`
- Rearranged: `(values[i] + i) + (values[j] - j)`
- The first term depends only on `i`, the second only on `j`
&nbsp;
**Step 2: Initialise variables**
- `max_start`: Set to `values[0] + 0` (the starting contribution of the first spot)
- `max_score`: Set to `0` (will be updated as we find valid pairs)
&nbsp;
**Step 3: Iterate from index 1 to end**
- For each position `j`, calculate the ending contribution: `values[j] - j`
- Compute the score if we pair with our best starting point: `max_start + (values[j] - j)`
- Update `max_score` if this is better than what we've seen
- Update `max_start` if the current position has a better starting contribution: `values[j] + j`
&nbsp;
**Step 4: Return the result**
- Return `max_score` after processing all positions
&nbsp;
The greedy choice works because we always pair the current ending point with the best possible starting point seen so far, guaranteeing we don't miss the optimal pair.
common_pitfalls:
- title: The Brute Force Trap
description: |
The naive approach is to check every pair `(i, j)` where `i < j`:
- Outer loop for `i` from `0` to `n-2`
- Inner loop for `j` from `i+1` to `n-1`
This results in **O(n^2) time complexity**. With `values.length <= 5 * 10^4`, this means up to 1.25 billion operations, which will cause a **Time Limit Exceeded (TLE)** error.
wrong_approach: "Nested loops checking all pairs"
correct_approach: "Single pass tracking maximum starting contribution"
- title: Forgetting the Index Contribution
description: |
It's tempting to just track the maximum value seen so far, but the **index matters**. A spot with `values[i] = 100` at index `i = 0` has starting contribution `100`, while the same value at index `i = 50` has contribution `150`.
The formula `values[i] + i` captures both the spot's value and its position advantage. Earlier spots "decay" as `j` increases, so a later spot with a slightly lower value might actually be better.
wrong_approach: "Tracking max(values[i]) instead of max(values[i] + i)"
correct_approach: "Track max(values[i] + i) as the starting contribution"
- title: Not Updating max_start After Calculating Score
description: |
The order of operations matters. You must:
1. First, calculate the score using the current `max_start`
2. Then, update `max_start` if the current position is better
If you update `max_start` before calculating the score at position `j`, you might incorrectly use `j` as both the starting and ending point, violating the `i < j` constraint.
wrong_approach: "Updating max_start before calculating current score"
correct_approach: "Calculate score first, then update max_start"
key_takeaways:
- "**Formula decomposition**: Rearranging mathematical expressions can reveal independent components that enable efficient algorithms"
- "**Greedy optimisation**: When a formula splits into parts depending on different indices, track the optimal value of earlier parts as you iterate"
- "**Similar to Best Time to Buy and Sell Stock**: Both problems track a running optimal value while iterating, transforming O(n^2) into O(n)"
- "**Index as part of value**: Some problems embed positional information into the scoring, requiring you to consider both value and position together"
time_complexity: "O(n). We traverse the array exactly once, performing constant-time operations at each step."
space_complexity: "O(1). We only use two variables (`max_start` and `max_score`), regardless of input size."
solutions:
- approach_name: Single Pass (Greedy)
is_optimal: true
code: |
def max_score_sightseeing_pair(values: list[int]) -> int:
# Starting contribution of first spot: values[0] + 0
max_start = values[0]
max_score = 0
# Iterate from second spot onwards
for j in range(1, len(values)):
# Calculate score if we end at position j
# Using best starting point seen so far
current_score = max_start + values[j] - j
max_score = max(max_score, current_score)
# Update best starting contribution for future positions
# Current position j has starting contribution values[j] + j
max_start = max(max_start, values[j] + j)
return max_score
explanation: |
**Time Complexity:** O(n) — Single pass through the array.
**Space Complexity:** O(1) — Only two variables used.
We decompose the score formula into `(values[i] + i) + (values[j] - j)`. By tracking the maximum `values[i] + i` seen so far, we can compute the optimal score ending at each position `j` in constant time.
- approach_name: Brute Force
is_optimal: false
code: |
def max_score_sightseeing_pair(values: list[int]) -> int:
max_score = 0
n = len(values)
# Try every pair (i, j) where i < j
for i in range(n):
for j in range(i + 1, n):
# Calculate score for this pair
score = values[i] + values[j] + i - j
max_score = max(max_score, score)
return max_score
explanation: |
**Time Complexity:** O(n^2) — Nested loops checking all pairs.
**Space Complexity:** O(1) — Only tracking max_score.
This approach checks every valid pair and computes the score directly from the formula. While correct, it's too slow for large inputs and will result in TLE on LeetCode. Included here to illustrate why the optimised approach is necessary.

View File

@@ -0,0 +1,186 @@
title: Best Team With No Conflicts
slug: best-team-with-no-conflicts
difficulty: medium
leetcode_id: 1626
leetcode_url: https://leetcode.com/problems/best-team-with-no-conflicts/
categories:
- arrays
- dynamic-programming
- sorting
patterns:
- dynamic-programming
description: |
You are the manager of a basketball team. For the upcoming tournament, you want to choose the team with the highest overall score. The score of the team is the **sum** of scores of all the players in the team.
However, the basketball team is not allowed to have **conflicts**. A **conflict** exists if a younger player has a **strictly higher** score than an older player. A conflict does **not** occur between players of the same age.
Given two lists, `scores` and `ages`, where each `scores[i]` and `ages[i]` represents the score and age of the i<sup>th</sup> player, respectively, return *the highest overall score of all possible basketball teams*.
constraints: |
- `1 <= scores.length, ages.length <= 1000`
- `scores.length == ages.length`
- `1 <= scores[i] <= 10^6`
- `1 <= ages[i] <= 1000`
examples:
- input: "scores = [1,3,5,10,15], ages = [1,2,3,4,5]"
output: "34"
explanation: "You can choose all the players. Since ages are strictly increasing and scores are also strictly increasing, no conflicts exist."
- input: "scores = [4,5,6,5], ages = [2,1,2,1]"
output: "16"
explanation: "It is best to choose the last 3 players (scores 5, 6, 5 with ages 1, 2, 1). Notice that you are allowed to choose multiple people of the same age."
- input: "scores = [1,2,3,5], ages = [8,9,10,1]"
output: "6"
explanation: "It is best to choose the first 3 players (scores 1, 2, 3). The player with score 5 and age 1 cannot be included because they are younger but have a higher score than the others."
explanation:
intuition: |
Imagine you're sorting players into a roster where **seniority must be respected** — older players shouldn't be outscored by younger ones.
The key insight is that this is a **variant of the Longest Increasing Subsequence (LIS) problem**, but instead of finding the longest subsequence, we want the one with the **maximum sum**.
Think of it this way: if we sort all players by age (and by score as a tiebreaker for same-age players), then we need to find a subsequence where scores are **non-decreasing**. Why? Because after sorting by age, any player we pick later is at least as old. If their score is also at least as high, there's no conflict.
This transformation is powerful — it converts a 2D constraint (age AND score) into a 1D problem (just scores, after sorting).
approach: |
We use **Dynamic Programming** after sorting the players:
**Step 1: Combine and sort players**
- Create pairs of `(age, score)` for each player
- Sort by age first, then by score (both ascending)
- This ensures when we process player `j` after player `i`, player `j` is at least as old
&nbsp;
**Step 2: Define the DP state**
- `dp[i]`: Maximum total score of a valid team ending with the i<sup>th</sup> player (after sorting)
- Base case: Each player alone forms a valid team, so `dp[i] = score[i]` initially
&nbsp;
**Step 3: Fill the DP table**
- For each player `i`, look at all previous players `j` where `j < i`
- If `score[j] <= score[i]`, player `j` can be on the same team as player `i` (no conflict)
- Update: `dp[i] = max(dp[i], dp[j] + score[i])`
&nbsp;
**Step 4: Return the answer**
- The answer is `max(dp)` — the best team might end at any player
&nbsp;
The sorting step is crucial: it ensures that when comparing players `j` and `i` (with `j < i`), player `j` is no older than player `i`. Combined with checking `score[j] <= score[i]`, we guarantee no conflicts.
common_pitfalls:
- title: Forgetting to Sort by Score as Tiebreaker
description: |
When two players have the same age, their relative order matters. If you only sort by age, you might miss valid combinations.
For example, with ages `[2, 2]` and scores `[5, 3]`, sorting only by age could give either order. If you process the player with score 5 first and then score 3, you'd incorrectly think you can't include both (since 3 < 5 but same age means no conflict!).
By sorting by score as a secondary key, players of the same age are ordered by score, ensuring the DP comparison works correctly.
wrong_approach: "Sort by age only"
correct_approach: "Sort by (age, score) to handle same-age players correctly"
- title: Looking for Longest Subsequence Instead of Maximum Sum
description: |
This problem looks like LIS but the goal is different. In LIS, we count elements. Here, we sum scores.
If you use LIS logic directly (`dp[i] = max length`), you'll get the team with the most players, not the highest score. A team of 2 high-scoring players can beat a team of 5 low-scoring players.
wrong_approach: "Count players in valid subsequence"
correct_approach: "Sum scores in valid subsequence"
- title: Misunderstanding the Conflict Rule
description: |
A conflict only exists when a **younger** player has a **strictly higher** score. Same age never causes conflicts, and equal or lower scores don't cause conflicts.
Be careful with the inequality: it's `score[younger] > score[older]` that's forbidden, not `>=`.
wrong_approach: "Treating equal scores as conflicts"
correct_approach: "Only strictly higher scores from younger players cause conflicts"
key_takeaways:
- "**Reduce dimensions via sorting**: Sorting by one attribute lets you focus on the other, turning 2D constraints into 1D problems"
- "**LIS variant pattern**: Many problems involve finding optimal subsequences with constraints — recognize when LIS-style DP applies"
- "**Secondary sort keys matter**: When primary keys are equal, the secondary sort order affects correctness"
- "**Sum vs count**: This is maximum sum subsequence, not longest — the DP state tracks cumulative value, not length"
time_complexity: "O(n^2). After O(n log n) sorting, we have nested loops: for each of the n players, we check all previous players."
space_complexity: "O(n). We store the sorted player list and the DP array, both of size n."
solutions:
- approach_name: Dynamic Programming with Sorting
is_optimal: true
code: |
def best_team_score(scores: list[int], ages: list[int]) -> int:
n = len(scores)
# Combine age and score, sort by age then score
players = sorted(zip(ages, scores))
# dp[i] = max score of valid team ending with player i
dp = [0] * n
for i in range(n):
# Player i alone is a valid team
dp[i] = players[i][1] # score of player i
# Try extending from all previous players
for j in range(i):
# No conflict if previous player's score <= current score
# (age is already guaranteed: players[j].age <= players[i].age)
if players[j][1] <= players[i][1]:
dp[i] = max(dp[i], dp[j] + players[i][1])
# Best team could end at any player
return max(dp)
explanation: |
**Time Complexity:** O(n^2) — Nested loops over n players after sorting.
**Space Complexity:** O(n) — Storage for sorted players and DP array.
By sorting players by (age, score), we ensure that when we process player `i`, all previous players `j` are no older. The conflict condition simplifies to checking if `score[j] <= score[i]`. We track the maximum sum achievable ending at each player, then return the global maximum.
- approach_name: Brute Force (Exponential)
is_optimal: false
code: |
def best_team_score(scores: list[int], ages: list[int]) -> int:
n = len(scores)
max_score = 0
# Try all 2^n subsets
for mask in range(1, 1 << n):
team_scores = []
team_ages = []
# Extract players in this subset
for i in range(n):
if mask & (1 << i):
team_scores.append(scores[i])
team_ages.append(ages[i])
# Check for conflicts
valid = True
for i in range(len(team_ages)):
for j in range(len(team_ages)):
if team_ages[i] < team_ages[j] and team_scores[i] > team_scores[j]:
valid = False
break
if not valid:
break
if valid:
max_score = max(max_score, sum(team_scores))
return max_score
explanation: |
**Time Complexity:** O(2^n * n^2) — Enumerate all subsets, check each for conflicts.
**Space Complexity:** O(n) — Storage for current team being checked.
This approach tries every possible team composition and validates each one. With n up to 1000, this is completely infeasible (2^1000 subsets). Included to illustrate why the DP approach is necessary.

View File

@@ -0,0 +1,189 @@
title: Best Time to Buy and Sell Stock II
slug: best-time-to-buy-and-sell-stock-ii
difficulty: medium
leetcode_id: 122
leetcode_url: https://leetcode.com/problems/best-time-to-buy-and-sell-stock-ii/
categories:
- arrays
- dynamic-programming
patterns:
- greedy
description: |
You are given an integer array `prices` where `prices[i]` is the price of a given stock on the i<sup>th</sup> day.
On each day, you may decide to buy and/or sell the stock. You can only hold **at most one** share of the stock at any time. However, you can buy and sell on the **same day** (buy then immediately sell, or sell then immediately buy).
Return *the **maximum** profit you can achieve*.
constraints: |
- `1 <= prices.length <= 3 * 10^4`
- `0 <= prices[i] <= 10^4`
examples:
- input: "prices = [7,1,5,3,6,4]"
output: "7"
explanation: "Buy on day 2 (price = 1) and sell on day 3 (price = 5), profit = 5 - 1 = 4. Then buy on day 4 (price = 3) and sell on day 5 (price = 6), profit = 6 - 3 = 3. Total profit is 4 + 3 = 7."
- input: "prices = [1,2,3,4,5]"
output: "4"
explanation: "Buy on day 1 (price = 1) and sell on day 5 (price = 5), profit = 5 - 1 = 4. Equivalently, you could buy and sell every consecutive day for the same total profit."
- input: "prices = [7,6,4,3,1]"
output: "0"
explanation: "There is no way to make a positive profit, so we never buy the stock to achieve the maximum profit of 0."
explanation:
intuition: |
Unlike the original "Best Time to Buy and Sell Stock" problem where you can only make one transaction, here you can make **unlimited transactions**. This changes the problem fundamentally.
Imagine visualising the stock prices as a line graph. In the original problem, you needed to find the single largest "valley to peak" rise. But now, you can capture **every upward movement** in the graph.
Think of it like this: if you could time-travel and knew all the prices in advance, you'd want to buy at every local minimum and sell at every local maximum. But here's the key insight — you don't actually need to identify peaks and valleys explicitly.
Instead, consider this: if the price goes up from day `i` to day `i+1`, you could have bought on day `i` and sold on day `i+1` for a profit of `prices[i+1] - prices[i]`. By **summing up all the positive differences** between consecutive days, you capture every upward movement.
This greedy approach works because every upward price movement represents profit you could have captured, and skipping any positive gain would leave money on the table.
approach: |
We solve this using a **Greedy Sum of Gains** approach:
**Step 1: Initialise the profit tracker**
- `total_profit`: Set to `0`, as we accumulate gains from each profitable day-to-day movement
&nbsp;
**Step 2: Iterate through consecutive day pairs**
- For each pair of consecutive days (from index `1` to `n-1`), compare today's price with yesterday's price
- If `prices[i] > prices[i-1]`, we have a gain — add the difference to `total_profit`
- If `prices[i] <= prices[i-1]`, the price dropped or stayed flat — skip it (we wouldn't have held the stock)
&nbsp;
**Step 3: Return the accumulated profit**
- After processing all consecutive pairs, `total_profit` contains the maximum achievable profit
- This captures every upward movement in the price series
&nbsp;
**Why this works:** The sum of all positive consecutive differences equals the maximum profit from any sequence of transactions. Whether you hold through a multi-day rise or buy/sell each day, the total gain is identical.
common_pitfalls:
- title: Trying to Find Peaks and Valleys
description: |
A natural instinct is to explicitly identify local minima (buy points) and local maxima (sell points), then calculate profits between them.
While this works, it's unnecessarily complex. You need to handle edge cases like plateaus, the array start/end, and consecutive rises/falls. The simple "sum all positive differences" approach achieves the same result with much cleaner code.
wrong_approach: "Explicitly finding local minima and maxima"
correct_approach: "Sum all positive consecutive differences"
- title: Using Dynamic Programming When Greedy Suffices
description: |
This problem can be solved with DP using states like `hold` and `not_hold` for each day. While correct, it's overkill.
The greedy solution runs in O(n) time and O(1) space with simpler logic. DP is useful for variants with constraints (cooldown, transaction fees, limited transactions), but for unlimited transactions, greedy is optimal.
wrong_approach: "DP with hold/not_hold states"
correct_approach: "Single pass greedy sum"
- title: Confusing with Single Transaction Problem
description: |
In the original problem (one transaction), you track `min_price` and find the maximum `price - min_price`. That logic doesn't apply here.
With unlimited transactions, you're not looking for one optimal buy-sell pair — you want to capture **every** upward movement. The problems look similar but require different approaches.
wrong_approach: "Tracking minimum price for single best transaction"
correct_approach: "Summing all profitable day-to-day gains"
key_takeaways:
- "**Greedy insight**: When you can act unlimited times, capture every positive gain rather than searching for optimal single actions"
- "**Consecutive differences**: The sum of all positive `prices[i] - prices[i-1]` equals the maximum profit from any transaction sequence"
- "**Problem variant awareness**: This extends the single-transaction problem; further variants add cooldowns, fees, or transaction limits"
- "**Simplicity over complexity**: Sometimes the elegant solution is simpler than the obvious one — summing gains beats finding peaks/valleys"
time_complexity: "O(n). We traverse the prices array exactly once, comparing each element to its predecessor."
space_complexity: "O(1). We only use a single variable (`total_profit`) regardless of the input size."
solutions:
- approach_name: Greedy Sum of Gains
is_optimal: true
code: |
def max_profit(prices: list[int]) -> int:
# Accumulate profit from every upward price movement
total_profit = 0
# Compare each day with the previous day
for i in range(1, len(prices)):
# If price went up, we could have profited from this movement
if prices[i] > prices[i - 1]:
total_profit += prices[i] - prices[i - 1]
return total_profit
explanation: |
**Time Complexity:** O(n) — Single pass through the array.
**Space Complexity:** O(1) — Only one variable used.
We iterate through the array once, adding every positive price difference to our total. This captures all upward movements, which is equivalent to buying at every local minimum and selling at every local maximum.
- approach_name: Peak Valley Approach
is_optimal: false
code: |
def max_profit(prices: list[int]) -> int:
if not prices:
return 0
total_profit = 0
i = 0
n = len(prices)
while i < n - 1:
# Find the valley (local minimum)
while i < n - 1 and prices[i] >= prices[i + 1]:
i += 1
valley = prices[i]
# Find the peak (local maximum)
while i < n - 1 and prices[i] <= prices[i + 1]:
i += 1
peak = prices[i]
# Add profit from this valley-peak pair
total_profit += peak - valley
return total_profit
explanation: |
**Time Complexity:** O(n) — Each element is visited at most twice.
**Space Complexity:** O(1) — Only tracking indices and profit.
This approach explicitly finds local minima (valleys) and maxima (peaks), calculating profit from each pair. While correct and equally efficient, it's more complex than the simple consecutive difference sum. Included to show an alternative perspective on the problem.
- approach_name: Dynamic Programming
is_optimal: false
code: |
def max_profit(prices: list[int]) -> int:
if not prices:
return 0
# hold = max profit if we currently hold a stock
# cash = max profit if we don't hold any stock
hold = -prices[0] # Buy on day 0
cash = 0 # Do nothing on day 0
for i in range(1, len(prices)):
# Either keep holding, or buy today (from cash state)
new_hold = max(hold, cash - prices[i])
# Either keep cash, or sell today (from hold state)
new_cash = max(cash, hold + prices[i])
hold = new_hold
cash = new_cash
# Maximum profit is when we don't hold any stock
return cash
explanation: |
**Time Complexity:** O(n) — Single pass through the array.
**Space Complexity:** O(1) — Only two state variables.
This DP approach tracks two states: `hold` (owning stock) and `cash` (not owning). At each day, we decide optimally whether to buy, sell, or do nothing. While overkill for this problem, this pattern extends naturally to variants with cooldowns or transaction fees.

View File

@@ -0,0 +1,223 @@
title: Best Time to Buy and Sell Stock III
slug: best-time-to-buy-and-sell-stock-iii
difficulty: hard
leetcode_id: 123
leetcode_url: https://leetcode.com/problems/best-time-to-buy-and-sell-stock-iii/
categories:
- arrays
- dynamic-programming
patterns:
- dynamic-programming
description: |
You are given an array `prices` where `prices[i]` is the price of a given stock on the i<sup>th</sup> day.
Find the maximum profit you can achieve. You may complete **at most two transactions**.
**Note:** You may not engage in multiple transactions simultaneously (i.e., you must sell the stock before you buy again).
constraints: |
- `1 <= prices.length <= 10^5`
- `0 <= prices[i] <= 10^5`
examples:
- input: "prices = [3,3,5,0,0,3,1,4]"
output: "6"
explanation: "Buy on day 4 (price = 0) and sell on day 6 (price = 3), profit = 3-0 = 3. Then buy on day 7 (price = 1) and sell on day 8 (price = 4), profit = 4-1 = 3. Total profit = 6."
- input: "prices = [1,2,3,4,5]"
output: "4"
explanation: "Buy on day 1 (price = 1) and sell on day 5 (price = 5), profit = 5-1 = 4. Even though we're allowed two transactions, one transaction achieves the maximum profit here."
- input: "prices = [7,6,4,3,1]"
output: "0"
explanation: "Prices only decrease, so no profitable transaction is possible. Return 0."
explanation:
intuition: |
This problem extends the classic "Best Time to Buy and Sell Stock" by allowing **at most two transactions** instead of one. The key insight is that we need to track the state of our trading at each point in time.
Think of it like this: at any moment, you're in one of several states:
- You haven't done anything yet
- You've bought your first stock (holding it)
- You've completed your first transaction (sold it)
- You've bought your second stock (holding it)
- You've completed both transactions
The brilliant insight is that we can track **four variables** representing the best outcomes at each state:
- `buy1`: The maximum money we can have after buying the first stock (this is negative since we spent money)
- `sell1`: The maximum profit after selling the first stock
- `buy2`: The maximum money we can have after buying the second stock (profit from first transaction minus the second purchase)
- `sell2`: The maximum profit after selling the second stock
As we iterate through prices, we update these states greedily. The magic is that `buy2` builds upon `sell1`, allowing the second transaction to benefit from the first.
approach: |
We solve this using a **State Machine DP Approach** with four variables:
**Step 1: Initialise the state variables**
- `buy1`: Set to negative infinity (or `-prices[0]`) — represents the cost of buying the first stock
- `sell1`: Set to `0` — no profit until we sell
- `buy2`: Set to negative infinity (or `-prices[0]`) — represents buying the second stock
- `sell2`: Set to `0` — no profit until we complete both transactions
&nbsp;
**Step 2: Iterate through prices and update states**
For each price, we update our states in this order (from last to first to avoid using updated values):
- `buy1 = max(buy1, -price)` — Either keep previous buy1, or buy today
- `sell1 = max(sell1, buy1 + price)` — Either keep previous sell1, or sell today
- `buy2 = max(buy2, sell1 - price)` — Either keep previous buy2, or buy second stock today using profit from first transaction
- `sell2 = max(sell2, buy2 + price)` — Either keep previous sell2, or complete the second sale
&nbsp;
**Step 3: Return the result**
- Return `sell2` — this represents the maximum profit from at most two transactions
- Note: `sell2` can represent 0, 1, or 2 transactions (it captures the best case)
&nbsp;
This approach works because each state depends only on the previous states, and we process them in the right order to ensure consistency.
common_pitfalls:
- title: Using O(n) Space with Two Passes
description: |
A common approach is to split the array into two parts and find the best single transaction for each part:
- `left[i]`: Max profit from day 0 to day i
- `right[i]`: Max profit from day i to day n-1
Then find `max(left[i] + right[i+1])`. While this works and is O(n) time, it uses O(n) space for the two arrays.
The four-variable approach achieves the same result with O(1) space by recognising that we only need the running best states.
wrong_approach: "Two arrays tracking left and right max profits"
correct_approach: "Four variables tracking states of transactions"
- title: Wrong Update Order
description: |
When updating the four variables, the order matters. If you update `buy1` first and then use its new value for `sell1`, you might use today's buy price for today's sell price in the same iteration.
However, in this specific formulation, updating from `buy1` to `sell2` in sequence actually works correctly because:
- `buy1` uses only the current price
- `sell1` uses `buy1` which may have just been updated, but this just means we could buy and sell on the same day (profit = 0), which is valid
- The same logic applies to `buy2` and `sell2`
The key insight is that this "same day" operation doesn't hurt us — it just represents not doing a transaction.
wrong_approach: "Incorrect state transition order causing invalid states"
correct_approach: "Process states in sequence: buy1 → sell1 → buy2 → sell2"
- title: Not Handling Zero or One Transaction Cases
description: |
You might think you need special handling when:
- No profitable transaction exists (return 0)
- Only one transaction is optimal
But the state machine handles this naturally:
- `sell2` starts at 0, so if no profit is possible, it remains 0
- If one transaction is best, `buy2` can equal `sell1 - price` where we immediately "rebuy" at the same price, making the second transaction have zero effect
wrong_approach: "Special case handling for different transaction counts"
correct_approach: "Let the state machine naturally handle all cases"
key_takeaways:
- "**State machine DP**: Complex transaction problems can be modeled as state transitions — identify the states and their transitions"
- "**Space optimisation**: When DP states only depend on previous iteration, you can reduce from O(n) arrays to O(1) variables"
- "**Generalisation**: This approach extends to k transactions by using 2k variables (or a 2D array for large k)"
- "**Foundation for harder variants**: The same state machine concept applies to problems with cooldowns, fees, or unlimited transactions"
time_complexity: "O(n). We traverse the prices array exactly once, performing constant-time operations at each step."
space_complexity: "O(1). We only use four variables (`buy1`, `sell1`, `buy2`, `sell2`) regardless of input size."
solutions:
- approach_name: State Machine DP
is_optimal: true
code: |
def max_profit(prices: list[int]) -> int:
# Initialise states for two transactions
# buy1: best outcome after first buy
# sell1: best profit after first sell
# buy2: best outcome after second buy (using profit from first)
# sell2: best profit after both transactions complete
buy1 = buy2 = float('-inf')
sell1 = sell2 = 0
for price in prices:
# Update states - order matters for clarity but works in sequence
buy1 = max(buy1, -price) # Buy first stock
sell1 = max(sell1, buy1 + price) # Sell first stock
buy2 = max(buy2, sell1 - price) # Buy second using first profit
sell2 = max(sell2, buy2 + price) # Sell second stock
return sell2
explanation: |
**Time Complexity:** O(n) — Single pass through the array.
**Space Complexity:** O(1) — Only four variables used.
We track four states representing different stages of completing up to two transactions. At each price, we update what the best outcome would be if we took that action today. The final `sell2` contains the maximum profit achievable.
- approach_name: Two-Pass with Left/Right Arrays
is_optimal: false
code: |
def max_profit(prices: list[int]) -> int:
if not prices:
return 0
n = len(prices)
# left[i] = max profit from a single transaction in prices[0:i+1]
left = [0] * n
min_price = prices[0]
for i in range(1, n):
min_price = min(min_price, prices[i])
left[i] = max(left[i - 1], prices[i] - min_price)
# right[i] = max profit from a single transaction in prices[i:n]
right = [0] * n
max_price = prices[n - 1]
for i in range(n - 2, -1, -1):
max_price = max(max_price, prices[i])
right[i] = max(right[i + 1], max_price - prices[i])
# Find the best split point for two transactions
max_profit = 0
for i in range(n):
max_profit = max(max_profit, left[i] + right[i])
return max_profit
explanation: |
**Time Complexity:** O(n) — Three passes through the array.
**Space Complexity:** O(n) — Two arrays of size n.
This approach splits the problem: find the best single transaction ending at or before day i (`left[i]`), and the best single transaction starting at or after day i (`right[i]`). The answer is the maximum sum of `left[i] + right[i]` for any split point. While correct, it uses more space than the state machine approach.
- approach_name: Generalised k-Transactions DP
is_optimal: false
code: |
def max_profit(prices: list[int]) -> int:
if not prices:
return 0
k = 2 # At most 2 transactions
n = len(prices)
# dp[t][0] = max profit with t transactions, not holding stock
# dp[t][1] = max profit with t transactions, holding stock
dp = [[0, float('-inf')] for _ in range(k + 1)]
for price in prices:
for t in range(1, k + 1):
# Not holding: either stay not holding, or sell today
dp[t][0] = max(dp[t][0], dp[t][1] + price)
# Holding: either stay holding, or buy today (using t-1 profit)
dp[t][1] = max(dp[t][1], dp[t - 1][0] - price)
return dp[k][0]
explanation: |
**Time Complexity:** O(n * k) — For each price, update k transaction states.
**Space Complexity:** O(k) — Array of size k for transaction states.
This generalised approach handles any number of transactions k. For k=2, it's equivalent to the four-variable solution but structured to scale. Each `dp[t]` tracks the best outcomes for exactly t transactions. This is useful when k is a variable input rather than fixed at 2.

View File

@@ -0,0 +1,225 @@
title: Best Time to Buy and Sell Stock IV
slug: best-time-to-buy-and-sell-stock-iv
difficulty: hard
leetcode_id: 188
leetcode_url: https://leetcode.com/problems/best-time-to-buy-and-sell-stock-iv/
categories:
- arrays
- dynamic-programming
patterns:
- dynamic-programming
description: |
You are given an integer array `prices` where `prices[i]` is the price of a given stock on the i<sup>th</sup> day, and an integer `k`.
Find the maximum profit you can achieve. You may complete **at most `k` transactions**: i.e., you may buy at most `k` times and sell at most `k` times.
**Note:** You may not engage in multiple transactions simultaneously (i.e., you must sell the stock before you buy again).
constraints: |
- `1 <= k <= 100`
- `1 <= prices.length <= 1000`
- `0 <= prices[i] <= 1000`
examples:
- input: "k = 2, prices = [2,4,1]"
output: "2"
explanation: "Buy on day 1 (price = 2) and sell on day 2 (price = 4), profit = 4 - 2 = 2."
- input: "k = 2, prices = [3,2,6,5,0,3]"
output: "7"
explanation: "Buy on day 2 (price = 2) and sell on day 3 (price = 6), profit = 6 - 2 = 4. Then buy on day 5 (price = 0) and sell on day 6 (price = 3), profit = 3 - 0 = 3. Total profit = 4 + 3 = 7."
explanation:
intuition: |
Imagine you're a trader who can make at most `k` round-trip trades (buy then sell). Each day you look at the stock price and must decide: should I buy, sell, or hold? The catch is that you can only hold one stock at a time and have limited transactions.
Think of it like having `k` "transaction slots". Each slot can capture one buy-sell pair. Your goal is to fill these slots with the most profitable trades, but trades can't overlap — you must complete one before starting another.
The key insight is that on any given day, your state depends on two things: **how many transactions you've completed** and **whether you currently hold a stock**. This naturally leads to a DP formulation where we track the maximum profit for each possible state.
For each of the `k` transactions, we track two values:
- The maximum profit if we're currently **holding** a stock (bought but not yet sold)
- The maximum profit if we're **not holding** a stock (either haven't bought or already sold)
By updating these states as we walk through each day, we can find the optimal profit using all available transactions.
approach: |
We solve this using **Dynamic Programming with State Tracking**:
**Step 1: Handle the edge case**
- If `k >= n // 2` where `n` is the number of days, we can make as many transactions as we want (unlimited transactions)
- In this case, simply sum all profitable price increases: add `prices[i] - prices[i-1]` whenever it's positive
- This optimisation prevents memory issues when `k` is very large
&nbsp;
**Step 2: Initialise state arrays**
- Create arrays `buy[k+1]` and `sell[k+1]` for tracking profit at each transaction count
- `buy[i]`: Maximum profit when we've completed `i-1` sells and are currently holding a stock
- `sell[i]`: Maximum profit when we've completed `i` full transactions (not holding)
- Initialise `buy[i] = -infinity` (impossible state initially) and `sell[i] = 0`
&nbsp;
**Step 3: Process each day**
- For each price, update states from transaction `k` down to `1`:
- `buy[j] = max(buy[j], sell[j-1] - price)` — either keep holding, or buy today using profit from `j-1` sells
- `sell[j] = max(sell[j], buy[j] + price)` — either stay sold, or sell today
&nbsp;
**Step 4: Return the result**
- Return `sell[k]` — maximum profit after at most `k` complete transactions
&nbsp;
This approach works because we're building up the optimal solution for each number of transactions, ensuring we never miss a better combination.
common_pitfalls:
- title: Ignoring the Large k Optimisation
description: |
When `k` is very large (e.g., `k >= n/2`), creating arrays of size `k` can cause memory issues or TLE.
If you can make `n/2` or more transactions on `n` days, you can effectively capture every upward price movement. In this case, simplify to the "unlimited transactions" problem: sum all positive differences.
wrong_approach: "Always use full DP regardless of k size"
correct_approach: "Check if k >= n/2 and use greedy sum for large k"
- title: Wrong State Transition Order
description: |
When updating states, you must be careful about the order of operations. If you update `sell[j]` before `buy[j]` in the same iteration, or process transactions in the wrong order, you might use stale values.
The safest approach is to iterate transactions from `k` down to `1`, ensuring each state update uses values from the previous day.
wrong_approach: "Update buy and sell in arbitrary order"
correct_approach: "Process transactions in reverse order (k to 1)"
- title: Off-by-One in Transaction Counting
description: |
A "transaction" consists of a buy AND a sell. It's easy to confuse:
- Number of buys allowed
- Number of sells allowed
- Number of complete buy-sell pairs
Be clear that `k` transactions means `k` complete round trips. After the k<sup>th</sup> sell, no more buying is allowed.
wrong_approach: "Treating k as number of buys OR sells separately"
correct_approach: "k is the number of complete buy-sell pairs"
- title: Not Handling Empty or Single-Day Arrays
description: |
With only one day, no transaction can complete (you need at least 2 days to buy then sell). Similarly, if `k = 0`, no transactions are allowed.
Both cases should return `0` profit.
wrong_approach: "Not checking for trivial edge cases"
correct_approach: "Return 0 if n < 2 or k == 0"
key_takeaways:
- "**State machine DP**: Model the problem as states (holding/not holding × transaction count) with transitions"
- "**Optimisation for large k**: When transactions are effectively unlimited, fall back to the simpler greedy approach"
- "**Generalisation of stock problems**: This is the most general single-stock problem — simpler variants (I, II, III) are special cases"
- "**Space optimisation**: We only need the previous day's states, reducing space from O(n×k) to O(k)"
time_complexity: "O(n × k). For each of the `n` prices, we update `k` transaction states. With the large-k optimisation, it's O(n) when `k >= n/2`."
space_complexity: "O(k). We maintain two arrays of size `k+1` for tracking buy and sell states."
solutions:
- approach_name: Dynamic Programming with State Arrays
is_optimal: true
code: |
def max_profit(k: int, prices: list[int]) -> int:
n = len(prices)
if n < 2 or k == 0:
return 0
# Optimisation: if k >= n/2, we can capture all gains (unlimited transactions)
if k >= n // 2:
profit = 0
for i in range(1, n):
# Add every upward price movement
if prices[i] > prices[i - 1]:
profit += prices[i] - prices[i - 1]
return profit
# buy[j] = max profit holding a stock, having completed j-1 sells
# sell[j] = max profit not holding, having completed j sells
buy = [float('-inf')] * (k + 1)
sell = [0] * (k + 1)
for price in prices:
# Update from k down to 1 to avoid using today's values
for j in range(k, 0, -1):
# Option: sell today (complete transaction j)
sell[j] = max(sell[j], buy[j] + price)
# Option: buy today (start transaction j)
buy[j] = max(buy[j], sell[j - 1] - price)
return sell[k]
explanation: |
**Time Complexity:** O(n × k) — For each price, we update k states. O(n) when k >= n/2.
**Space Complexity:** O(k) — Two arrays of size k+1.
We track the best profit for each transaction count, distinguishing between holding and not holding a stock. The reverse iteration (k to 1) ensures we use consistent state values within each day.
- approach_name: 2D DP Table
is_optimal: false
code: |
def max_profit(k: int, prices: list[int]) -> int:
n = len(prices)
if n < 2 or k == 0:
return 0
# Optimisation for large k
if k >= n // 2:
return sum(max(0, prices[i] - prices[i - 1]) for i in range(1, n))
# dp[i][j] = max profit using at most j transactions up to day i
dp = [[0] * (k + 1) for _ in range(n)]
for j in range(1, k + 1):
# Track best profit if we bought on some previous day
max_diff = -prices[0]
for i in range(1, n):
# Either don't trade on day i, or sell on day i
dp[i][j] = max(dp[i - 1][j], prices[i] + max_diff)
# Update best buy point: buy on day i using profit from j-1 transactions
max_diff = max(max_diff, dp[i][j - 1] - prices[i])
return dp[n - 1][k]
explanation: |
**Time Complexity:** O(n × k) — Nested loops over days and transactions.
**Space Complexity:** O(n × k) — Full DP table stored.
This classic DP formulation uses `dp[i][j]` to represent the maximum profit achievable up to day `i` using at most `j` transactions. The `max_diff` variable tracks the best "buy point" considering previous transaction profits, enabling O(1) updates per cell.
- approach_name: Brute Force (Exponential)
is_optimal: false
code: |
def max_profit(k: int, prices: list[int]) -> int:
def backtrack(day: int, transactions: int, holding: bool) -> int:
# Base cases
if day >= len(prices) or transactions == 0:
return 0
# Option 1: Do nothing today
profit = backtrack(day + 1, transactions, holding)
if holding:
# Option 2: Sell today (complete a transaction)
profit = max(profit, prices[day] + backtrack(day + 1, transactions - 1, False))
else:
# Option 2: Buy today
profit = max(profit, -prices[day] + backtrack(day + 1, transactions, True))
return profit
return backtrack(0, k, False)
explanation: |
**Time Complexity:** O(2^n) — Exponential due to exploring all buy/sell combinations.
**Space Complexity:** O(n) — Recursion stack depth.
This recursive approach explores all possible decisions (buy, sell, or hold) at each day. While correct, it's far too slow for the given constraints. Included to show the problem structure before optimisation. Adding memoisation would make this equivalent to the DP solution.

View File

@@ -0,0 +1,189 @@
title: Best Time to Buy and Sell Stock with Cooldown
slug: best-time-to-buy-and-sell-stock-with-cooldown
difficulty: medium
leetcode_id: 309
leetcode_url: https://leetcode.com/problems/best-time-to-buy-and-sell-stock-with-cooldown/
categories:
- arrays
- dynamic-programming
patterns:
- dynamic-programming
description: |
You are given an array `prices` where `prices[i]` is the price of a given stock on the i<sup>th</sup> day.
Find the maximum profit you can achieve. You may complete as many transactions as you like (i.e., buy one and sell one share of the stock multiple times) with the following restrictions:
- After you sell your stock, you cannot buy stock on the next day (i.e., cooldown one day).
**Note:** You may not engage in multiple transactions simultaneously (i.e., you must sell the stock before you buy again).
constraints: |
- `1 <= prices.length <= 5000`
- `0 <= prices[i] <= 1000`
examples:
- input: "prices = [1,2,3,0,2]"
output: "3"
explanation: "transactions = [buy, sell, cooldown, buy, sell]"
- input: "prices = [1]"
output: "0"
explanation: "Only one day available, no transaction possible."
explanation:
intuition: |
Think of this problem as a **state machine** where you're navigating between different states each day.
Imagine you're a trader who can be in one of three situations on any given day:
1. **Holding stock** — You own a share and are waiting for the right time to sell
2. **Just sold (cooldown)** — You just sold and must wait one day before buying again
3. **Ready to buy** — You don't own stock and can buy whenever you want
Each day, you transition between these states based on your action (buy, sell, or rest). The cooldown rule means that after selling, you're forced into the "cooldown" state before you can return to "ready to buy."
The key insight is that on each day, you only need to track the **maximum profit achievable** in each of these three states. By calculating how to transition optimally between states, you build up to the final answer.
This is a classic example of **state-based dynamic programming** — instead of tracking all possible transaction histories, we compress everything into a few meaningful states.
approach: |
We solve this using **State Machine Dynamic Programming**:
**Step 1: Define the states**
- `hold`: Maximum profit when holding a stock at the end of day `i`
- `sold`: Maximum profit when in cooldown (just sold) at the end of day `i`
- `rest`: Maximum profit when ready to buy (not holding, not in cooldown) at the end of day `i`
&nbsp;
**Step 2: Initialise the states**
- `hold = -prices[0]`: If we buy on day 0, our "profit" is negative the price
- `sold = 0`: Cannot have sold anything yet
- `rest = 0`: Starting with no stock and no profit
&nbsp;
**Step 3: Iterate and update states**
For each day `i` from 1 to `n-1`, calculate new states based on previous:
- `new_hold = max(hold, rest - prices[i])`: Either keep holding, or buy today (must come from rest)
- `new_sold = hold + prices[i]`: Sell today (must have been holding)
- `new_rest = max(rest, sold)`: Either stay resting, or transition from cooldown
&nbsp;
**Step 4: Return the answer**
- Return `max(sold, rest)` — the best profit when not holding stock
- We exclude `hold` because we want to have sold everything by the end
&nbsp;
The transitions ensure the cooldown constraint: to buy (`rest -> hold`), we must not have sold yesterday. The `sold` state forces a one-day gap before `rest` is updated.
common_pitfalls:
- title: Ignoring the Cooldown Constraint
description: |
A common mistake is to treat this like "Best Time to Buy and Sell Stock II" where you can buy and sell freely.
Without respecting cooldown, you might try to buy immediately after selling. For `prices = [1,2,3,0,2]`, buying on day 4 (price=0) right after selling on day 3 violates the rule.
The state machine approach naturally handles this — you can only buy from the `rest` state, which requires passing through `sold` first.
wrong_approach: "Greedy buy/sell without tracking cooldown"
correct_approach: "State machine with explicit cooldown state"
- title: Incorrect State Transitions
description: |
When updating states, you must use the **previous day's values**, not the current day's updated values.
For example, if you update `hold` first and then use that new `hold` value to calculate `sold`, you'll get wrong answers. The transitions should all be based on yesterday's state.
Use temporary variables or update all states simultaneously to avoid this bug.
wrong_approach: "Sequential updates using already-modified values"
correct_approach: "Simultaneous updates or temporary variables"
- title: Forgetting to Handle Single-Day Input
description: |
With only one price (e.g., `prices = [5]`), no transaction is possible. Your algorithm should return `0`.
Initialising `sold = 0` and `rest = 0` handles this — after the loop (which doesn't run), the max of `sold` and `rest` is `0`.
key_takeaways:
- "**State machine DP**: Model complex rules as states and transitions rather than tracking full history"
- "**Compress information**: Instead of remembering all past decisions, track only what matters for future choices"
- "**Stock problem family**: This pattern extends to problems with transaction fees, at most k transactions, or other constraints"
- "**Space optimisation**: Since we only need the previous day's states, we can use O(1) space instead of O(n)"
time_complexity: "O(n). We iterate through the prices array once, performing constant-time state updates at each step."
space_complexity: "O(1). We only maintain three variables (`hold`, `sold`, `rest`) regardless of input size."
solutions:
- approach_name: State Machine DP
is_optimal: true
code: |
def max_profit(prices: list[int]) -> int:
if not prices:
return 0
n = len(prices)
# Initial states on day 0
hold = -prices[0] # Bought on day 0
sold = 0 # Can't have sold yet
rest = 0 # No stock, no transactions
for i in range(1, n):
# Calculate new states based on previous day
# Use temp variables to avoid using updated values
new_hold = max(hold, rest - prices[i]) # Keep holding or buy today
new_sold = hold + prices[i] # Sell today
new_rest = max(rest, sold) # Stay resting or exit cooldown
# Update states for next iteration
hold, sold, rest = new_hold, new_sold, new_rest
# Return max profit when not holding stock
return max(sold, rest)
explanation: |
**Time Complexity:** O(n) — Single pass through the array.
**Space Complexity:** O(1) — Only three state variables used.
We model the problem as a state machine with three states. Each day, we compute the optimal way to transition into each state. The cooldown rule is enforced by requiring buys to come from `rest`, which can only be reached after passing through `sold`.
- approach_name: DP with Arrays
is_optimal: false
code: |
def max_profit(prices: list[int]) -> int:
if not prices:
return 0
n = len(prices)
# DP arrays for each state
hold = [0] * n # Max profit when holding stock
sold = [0] * n # Max profit in cooldown (just sold)
rest = [0] * n # Max profit when ready to buy
# Base case: day 0
hold[0] = -prices[0]
sold[0] = 0
rest[0] = 0
for i in range(1, n):
# Transition equations
hold[i] = max(hold[i-1], rest[i-1] - prices[i])
sold[i] = hold[i-1] + prices[i]
rest[i] = max(rest[i-1], sold[i-1])
# Best profit when not holding stock
return max(sold[n-1], rest[n-1])
explanation: |
**Time Complexity:** O(n) — Single pass through the array.
**Space Complexity:** O(n) — Three arrays of length n.
This version explicitly stores all states for each day, making the DP transitions clearer. While easier to understand and debug, it uses more memory than necessary. The space-optimised version above is preferred for production use.

View File

@@ -0,0 +1,178 @@
title: Best Time to Buy and Sell Stock with Transaction Fee
slug: best-time-to-buy-and-sell-stock-with-transaction-fee
difficulty: medium
leetcode_id: 714
leetcode_url: https://leetcode.com/problems/best-time-to-buy-and-sell-stock-with-transaction-fee/
categories:
- arrays
- dynamic-programming
patterns:
- dynamic-programming
- greedy
description: |
You are given an array `prices` where `prices[i]` is the price of a given stock on the i<sup>th</sup> day, and an integer `fee` representing a transaction fee.
Find the maximum profit you can achieve. You may complete as many transactions as you like, but you need to pay the transaction fee for each transaction.
**Note:**
- You may not engage in multiple transactions simultaneously (i.e., you must sell the stock before you buy again).
- The transaction fee is only charged once for each stock purchase and sale.
constraints: |
- `1 <= prices.length <= 5 * 10^4`
- `1 <= prices[i] < 5 * 10^4`
- `0 <= fee < 5 * 10^4`
examples:
- input: "prices = [1,3,2,8,4,9], fee = 2"
output: "8"
explanation: "The maximum profit can be achieved by: Buying at prices[0] = 1, selling at prices[3] = 8, buying at prices[4] = 4, selling at prices[5] = 9. The total profit is ((8 - 1) - 2) + ((9 - 4) - 2) = 8."
- input: "prices = [1,3,7,5,10,3], fee = 3"
output: "6"
explanation: "Buy at prices[0] = 1, sell at prices[4] = 10. Profit = (10 - 1) - 3 = 6."
explanation:
intuition: |
Think of this as an extension of the classic "Best Time to Buy and Sell Stock II" problem, where you could make unlimited transactions for free. The fee introduces a **threshold for action** — a small profit that doesn't cover the fee isn't worth the transaction.
Imagine you're a trader who has to pay a broker fee every time you complete a buy-sell cycle. This fee changes the decision calculus: you might skip a small profitable opportunity because the fee would eat into (or exceed) your gains.
The key insight is to track **two states** at each day:
- **Holding state**: The maximum profit if you currently hold a stock (either you bought today, or you're continuing to hold from before)
- **Not holding state**: The maximum profit if you don't hold a stock (either you sold today, or you were already not holding)
At each day, you transition between these states. The fee is paid when you sell (completing the transaction), which affects whether selling is worthwhile.
This state-machine thinking lets us solve the problem in a single pass, updating our two states based on the optimal choice at each step.
approach: |
We use a **State Machine DP** approach with two variables tracking our optimal profit in each state:
**Step 1: Initialise two state variables**
- `hold`: The maximum profit when holding a stock. Initially `-prices[0]` because buying on day 0 costs us that amount
- `cash`: The maximum profit when not holding a stock. Initially `0` because we start with no stock and no profit
&nbsp;
**Step 2: Iterate through prices starting from day 1**
For each day, we have two choices depending on our current state:
- **If we want to end up holding**: Either we already held (keep `hold`), or we buy today (`cash - prices[i]`). Take the maximum.
- **If we want to end up with cash**: Either we already had cash (keep `cash`), or we sell today (`hold + prices[i] - fee`). Take the maximum.
&nbsp;
**Step 3: Update states simultaneously**
Use temporary variables or update in the right order to avoid using updated values in the same iteration:
- `new_hold = max(hold, cash - prices[i])`
- `new_cash = max(cash, hold + prices[i] - fee)`
&nbsp;
**Step 4: Return the cash state**
After processing all days, `cash` contains the maximum profit (we want to end without holding stock).
common_pitfalls:
- title: Greedy Without Considering the Fee
description: |
In the version without fees, you can greedily take every small upward movement: if `prices[i] > prices[i-1]`, add the difference.
With fees, this greedy approach fails. For example, with `prices = [1, 2, 3]` and `fee = 2`:
- Greedy without fee thinking: Buy at 1, sell at 2 (profit 1), buy at 2, sell at 3 (profit 1). Total = 2.
- But with fee = 2, each transaction costs 2, so you'd lose money!
- Optimal: Buy at 1, sell at 3, one fee. Profit = (3 - 1) - 2 = 0.
The fee means you should **consolidate transactions** rather than making many small ones.
wrong_approach: "Greedily taking every price increase"
correct_approach: "Track states and let DP decide when selling is worthwhile"
- title: Forgetting State Dependency
description: |
When updating `hold` and `cash`, you might accidentally use the newly updated value instead of the old one.
For example, if you update `cash` first, then use that new `cash` to compute `hold`, you're allowing buying and selling on the same day, which is invalid.
Always compute new values using the old states, then assign them.
wrong_approach: "Sequential updates: cash = ..., hold = max(hold, cash - price)"
correct_approach: "Simultaneous updates using temp variables or tuple unpacking"
- title: Charging Fee Incorrectly
description: |
The fee should be charged exactly once per complete transaction (buy + sell). You can charge it either:
- When buying: `hold = cash - price - fee`
- When selling: `cash = hold + price - fee`
Either works, but be consistent. Charging on both would double-charge.
wrong_approach: "Charging fee on both buy and sell"
correct_approach: "Charge fee on exactly one of buy or sell"
key_takeaways:
- "**State machine DP**: Model problems with distinct states (holding/not holding) and transitions between them"
- "**Fee as a threshold**: Transaction costs create a minimum profit threshold that changes optimal decisions"
- "**O(1) space optimisation**: When DP only depends on the previous state, you can reduce from O(n) to O(1) space"
- "**Foundation for stock problems**: This pattern extends to problems with cooldowns, limited transactions, or other constraints"
time_complexity: "O(n). We iterate through the prices array exactly once, performing constant-time operations at each step."
space_complexity: "O(1). We only maintain two variables (`hold` and `cash`) regardless of input size."
solutions:
- approach_name: State Machine DP
is_optimal: true
code: |
def max_profit(prices: list[int], fee: int) -> int:
# hold: max profit if we currently own a stock
# cash: max profit if we don't own a stock
hold = -prices[0] # Buy on day 0
cash = 0 # Start with no profit
for i in range(1, len(prices)):
# To hold: either keep holding, or buy today
# To have cash: either keep cash, or sell today (pay fee)
hold, cash = (
max(hold, cash - prices[i]),
max(cash, hold + prices[i] - fee)
)
# End without holding stock for maximum profit
return cash
explanation: |
**Time Complexity:** O(n) — Single pass through the prices array.
**Space Complexity:** O(1) — Only two variables used.
We track two states: the best profit when holding a stock, and when not holding. At each day, we compute the optimal way to reach each state. The tuple assignment ensures we use old values for both calculations. The fee is deducted when selling.
- approach_name: 2D DP Array
is_optimal: false
code: |
def max_profit(prices: list[int], fee: int) -> int:
n = len(prices)
# dp[i][0] = max profit on day i without holding stock
# dp[i][1] = max profit on day i while holding stock
dp = [[0, 0] for _ in range(n)]
dp[0][0] = 0 # Day 0, no stock
dp[0][1] = -prices[0] # Day 0, bought stock
for i in range(1, n):
# Not holding: either stayed that way, or sold today
dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i] - fee)
# Holding: either stayed that way, or bought today
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i])
# Return max profit without holding stock
return dp[n-1][0]
explanation: |
**Time Complexity:** O(n) — Single pass through the prices array.
**Space Complexity:** O(n) — DP array of size n with 2 states each.
This version explicitly stores the DP table, making the state transitions clearer. Each cell `dp[i][state]` represents the maximum profit achievable by day `i` in that state. The optimal solution above uses the observation that we only need the previous day's values.

View File

@@ -0,0 +1,164 @@
title: Binary Gap
slug: binary-gap
difficulty: easy
leetcode_id: 868
leetcode_url: https://leetcode.com/problems/binary-gap/
categories:
- math
patterns:
- two-pointers
description: |
Given a positive integer `n`, find and return *the **longest distance** between any two **adjacent*** `1`*'s in the binary representation of* `n`. If there are no two adjacent `1`'s, return `0`.
Two `1`'s are **adjacent** if there are only `0`'s separating them (possibly no `0`'s). The **distance** between two `1`'s is the absolute difference between their bit positions. For example, the two `1`'s in `"1001"` have a distance of 3.
constraints: |
- `1 <= n <= 10^9`
examples:
- input: "n = 22"
output: "2"
explanation: "22 in binary is '10110'. The first adjacent pair of 1's has a distance of 2, and the second pair has a distance of 1. The answer is the largest of these distances, which is 2."
- input: "n = 8"
output: "0"
explanation: "8 in binary is '1000'. There is only one 1-bit, so there are no adjacent pairs of 1's."
- input: "n = 5"
output: "2"
explanation: "5 in binary is '101'. The two 1's are separated by one 0, giving a distance of 2."
explanation:
intuition: |
Imagine the binary representation of a number as a row of light switches, where `1` means "on" and `0` means "off". Your goal is to find the **longest gap** between any two consecutive "on" switches.
The key insight is that we only care about the positions of the `1` bits. As we scan through the binary representation, we need to track where the **previous** `1` was located. Each time we encounter a new `1`, we calculate the distance from the previous one and keep the maximum.
Think of it like this: you're walking along a number line, and every time you see a `1`, you measure how far you've walked since the last `1`. The answer is the longest stretch you measured.
We can extract bits from `n` one at a time using bitwise operations: `n & 1` gives the rightmost bit, and `n >> 1` shifts everything right (effectively dividing by 2).
approach: |
We solve this using a **Single Pass with Bit Extraction**:
**Step 1: Initialise tracking variables**
- `last_one`: Set to `-1` to indicate we haven't found a `1` yet
- `max_gap`: Set to `0` since we return `0` if there are fewer than two `1`s
- `position`: Set to `0` to track the current bit position as we iterate
&nbsp;
**Step 2: Extract bits from right to left**
- Use `n & 1` to get the rightmost bit
- If the bit is `1`:
- If `last_one != -1`, calculate the gap: `position - last_one`
- Update `max_gap` if this gap is larger
- Update `last_one` to the current position
- Shift `n` right by 1 (`n >>= 1`) to move to the next bit
- Increment `position`
- Continue until `n` becomes `0`
&nbsp;
**Step 3: Return the result**
- Return `max_gap` after processing all bits
&nbsp;
This approach processes each bit exactly once, giving us optimal O(log n) time complexity.
common_pitfalls:
- title: Converting to String Unnecessarily
description: |
A common first approach is to convert the number to a binary string with `bin(n)` and then iterate through the characters.
While this works, it uses **O(log n) extra space** for the string and involves string manipulation overhead. The bitwise approach is more efficient and teaches fundamental bit manipulation skills.
wrong_approach: "bin(n) and string iteration"
correct_approach: "Bitwise extraction with n & 1 and n >> 1"
- title: Not Handling Single 1-Bit Case
description: |
Numbers like `8` (binary `1000`) or powers of 2 have only one `1` bit. Your algorithm must return `0` in these cases since there are no "adjacent" pairs.
Initialising `last_one = -1` and only computing gaps when `last_one != -1` handles this naturally.
wrong_approach: "Assuming there are always at least two 1-bits"
correct_approach: "Track whether we've seen a previous 1 before computing gaps"
- title: Confusing Bit Position with Bit Value
description: |
The problem asks for the **distance** between positions, not the number of zeros between them.
For `n = 5` (binary `101`), the `1`s are at positions 0 and 2, so the gap is `2 - 0 = 2`, not `1` (the count of zeros between them).
However, since consecutive bit positions differ by 1, the distance equals "zeros between + 1".
wrong_approach: "Counting zeros between 1s"
correct_approach: "Tracking positions and computing differences"
key_takeaways:
- "**Bit extraction pattern**: Use `n & 1` to get the rightmost bit and `n >>= 1` to shift right — this is fundamental for many bit manipulation problems"
- "**Position tracking**: When dealing with bit positions, maintain a counter that increments as you process each bit"
- "**Gap detection**: This pattern of tracking the 'last seen' position applies to many problems involving distances or intervals in sequences"
- "**Space efficiency**: Bitwise operations avoid the memory overhead of string conversion"
time_complexity: "O(log n). We process each bit of `n` exactly once, and a number `n` has `log2(n)` bits."
space_complexity: "O(1). We only use a fixed number of variables (`last_one`, `max_gap`, `position`) regardless of input size."
solutions:
- approach_name: Bitwise Extraction
is_optimal: true
code: |
def binary_gap(n: int) -> int:
# Track the position of the last 1 we saw (-1 means none yet)
last_one = -1
# Track the maximum gap found
max_gap = 0
# Current bit position (starting from rightmost = 0)
position = 0
while n > 0:
# Check if the rightmost bit is 1
if n & 1:
# If we've seen a 1 before, calculate the gap
if last_one != -1:
gap = position - last_one
max_gap = max(max_gap, gap)
# Update the position of the last 1
last_one = position
# Move to the next bit (shift right)
n >>= 1
position += 1
return max_gap
explanation: |
**Time Complexity:** O(log n) — We process each of the log2(n) bits exactly once.
**Space Complexity:** O(1) — Only three integer variables used.
We iterate through the bits from right to left using bitwise AND and right shift. Each time we find a `1`, we check if there was a previous `1` and calculate the gap. This approach is memory-efficient and demonstrates fundamental bit manipulation techniques.
- approach_name: String Conversion
is_optimal: false
code: |
def binary_gap(n: int) -> int:
# Convert to binary string (e.g., '0b10110' -> '10110')
binary = bin(n)[2:]
max_gap = 0
last_one = -1
for i, bit in enumerate(binary):
if bit == '1':
if last_one != -1:
gap = i - last_one
max_gap = max(max_gap, gap)
last_one = i
return max_gap
explanation: |
**Time Complexity:** O(log n) — Converting to string and iterating both take O(log n).
**Space Complexity:** O(log n) — The binary string requires O(log n) space.
This approach converts the integer to its binary string representation and then iterates through the characters. While correct and readable, it uses extra space for the string. The bitwise approach is preferred for interviews to demonstrate low-level manipulation skills.

View File

@@ -0,0 +1,171 @@
title: Binary Number with Alternating Bits
slug: binary-number-with-alternating-bits
difficulty: easy
leetcode_id: 693
leetcode_url: https://leetcode.com/problems/binary-number-with-alternating-bits/
categories:
- math
patterns:
- greedy
description: |
Given a positive integer `n`, check whether it has **alternating bits**: namely, if two adjacent bits will always have different values.
Return `true` if the given integer has alternating bits, otherwise return `false`.
constraints: |
- `1 <= n <= 2^31 - 1`
examples:
- input: "n = 5"
output: "true"
explanation: "The binary representation of 5 is 101, where each adjacent pair of bits differs."
- input: "n = 7"
output: "false"
explanation: "The binary representation of 7 is 111, where adjacent bits are the same."
- input: "n = 11"
output: "false"
explanation: "The binary representation of 11 is 1011, where the two rightmost bits are both 1."
explanation:
intuition: |
Think of the binary representation of a number as a sequence of 0s and 1s. A number has "alternating bits" if the pattern looks like `...1010...` or `...0101...` — no two adjacent bits are the same.
Imagine walking through the binary digits from right to left (least significant to most significant). At each step, you compare the current bit with the previous one. If they're ever the same, you know the bits don't alternate.
The key insight is that we can extract the last bit of any number using `n & 1` (bitwise AND with 1), and then shift the number right by one position using `n >> 1` to move to the next bit. This lets us examine each bit in sequence without converting to a string.
Another elegant approach uses a mathematical trick: if we XOR the number with itself shifted by one position (`n ^ (n >> 1)`), a number with alternating bits produces all 1s. For example, `5 = 101` shifted gives `010`, and `101 ^ 010 = 111`. If the result is all 1s, we can verify by checking if adding 1 creates a power of two.
approach: |
We solve this using **Bit-by-Bit Comparison**:
**Step 1: Extract the last bit**
- Use `prev_bit = n & 1` to get the rightmost bit
- Shift `n` right by 1 to prepare for the next iteration
&nbsp;
**Step 2: Iterate through remaining bits**
- While `n > 0`, extract the current last bit with `curr_bit = n & 1`
- Compare `curr_bit` with `prev_bit`
- If they're equal, return `false` immediately — adjacent bits are the same
- Otherwise, update `prev_bit = curr_bit` and shift `n` right by 1
&nbsp;
**Step 3: Return the result**
- If we complete the loop without finding equal adjacent bits, return `true`
&nbsp;
This approach examines each bit exactly once, making it efficient and straightforward.
common_pitfalls:
- title: Converting to String
description: |
A tempting first approach is to convert the number to a binary string using `bin(n)` and then check adjacent characters:
```python
s = bin(n)[2:] # Remove '0b' prefix
for i in range(len(s) - 1):
if s[i] == s[i+1]:
return False
```
While this works, it uses O(log n) extra space for the string and is less efficient than bit manipulation. String operations also have overhead compared to bitwise operations.
wrong_approach: "Convert to binary string and compare characters"
correct_approach: "Use bit manipulation to extract and compare bits directly"
- title: Off-by-One in Bit Extraction
description: |
When using `n % 2` instead of `n & 1`, the logic is equivalent but ensure you're consistent. A common mistake is forgetting to shift the number after extracting a bit, leading to an infinite loop.
Always pair bit extraction with `n >>= 1` (or `n //= 2`) to advance through the bits.
wrong_approach: "Extract bit without shifting"
correct_approach: "Always shift after extracting each bit"
- title: Not Handling Single Bit Numbers
description: |
Numbers like `1` or `2` have only one bit set (binary `1` or `10`). These trivially have "alternating bits" since there's no pair of adjacent 1s.
Make sure your loop handles these cases correctly — they should return `true`.
key_takeaways:
- "**Bit extraction pattern**: Use `n & 1` to get the last bit and `n >> 1` to shift right — this is fundamental for bit manipulation"
- "**XOR trick**: `n ^ (n >> 1)` produces all 1s for alternating bit patterns — useful for one-liner solutions"
- "**Avoid string conversion**: Direct bit manipulation is more space-efficient and often faster than string operations"
- "**Powers of two check**: A number with all bits set to 1 plus 1 equals a power of two — `(x & (x + 1)) == 0` verifies this"
time_complexity: "O(log n). We examine each bit of `n` exactly once, and there are `log n` bits in a number `n`."
space_complexity: "O(1). We only use a constant number of variables regardless of input size."
solutions:
- approach_name: Bit-by-Bit Comparison
is_optimal: true
code: |
def has_alternating_bits(n: int) -> bool:
# Extract the last bit to start comparison
prev_bit = n & 1
n >>= 1 # Move to the next bit
while n > 0:
# Extract current last bit
curr_bit = n & 1
# Adjacent bits must be different
if curr_bit == prev_bit:
return False
# Move to next bit
prev_bit = curr_bit
n >>= 1
return True
explanation: |
**Time Complexity:** O(log n) — We iterate through each bit once.
**Space Complexity:** O(1) — Only a few integer variables used.
We extract bits from right to left using bitwise AND and right shift. By comparing each bit with the previous one, we can detect any adjacent pair of identical bits.
- approach_name: XOR Trick
is_optimal: true
code: |
def has_alternating_bits(n: int) -> bool:
# XOR n with n shifted right by 1
# For alternating bits, this produces all 1s
xor_result = n ^ (n >> 1)
# Check if xor_result is all 1s: (x & (x + 1)) == 0
# All 1s like 111 plus 1 gives 1000, AND gives 0
return (xor_result & (xor_result + 1)) == 0
explanation: |
**Time Complexity:** O(1) — Constant number of bitwise operations.
**Space Complexity:** O(1) — Only one additional variable.
This elegant approach exploits a property of alternating bits. When we XOR a number with its right-shifted version, alternating patterns like `101` become `111` (all 1s). We then verify the result is all 1s by checking if adding 1 and ANDing gives zero.
- approach_name: String Conversion
is_optimal: false
code: |
def has_alternating_bits(n: int) -> bool:
# Convert to binary string (without '0b' prefix)
binary = bin(n)[2:]
# Check each adjacent pair of characters
for i in range(len(binary) - 1):
if binary[i] == binary[i + 1]:
return False
return True
explanation: |
**Time Complexity:** O(log n) — Converting to string and iterating through it.
**Space Complexity:** O(log n) — The binary string requires space proportional to the number of bits.
While straightforward, this approach uses extra space for the string representation. Included here to show a more readable but less efficient alternative.

View File

@@ -0,0 +1,151 @@
title: Binary Prefix Divisible By 5
slug: binary-prefix-divisible-by-5
difficulty: easy
leetcode_id: 1018
leetcode_url: https://leetcode.com/problems/binary-prefix-divisible-by-5/
categories:
- arrays
- math
patterns:
- prefix-sum
description: |
You are given a binary array `nums` (**0-indexed**).
We define `x_i` as the number whose binary representation is the subarray `nums[0..i]` (from most-significant-bit to least-significant-bit).
For example, if `nums = [1,0,1]`, then `x_0 = 1`, `x_1 = 2`, and `x_2 = 5`.
Return *an array of booleans* `answer` *where* `answer[i]` *is* `true` *if* `x_i` *is divisible by* `5`.
constraints: |
- `1 <= nums.length <= 10^5`
- `nums[i]` is either `0` or `1`
examples:
- input: "nums = [0,1,1]"
output: "[true, false, false]"
explanation: "The input numbers in binary are 0, 01, 011; which are 0, 1, and 3 in base-10. Only the first number is divisible by 5, so answer[0] is true."
- input: "nums = [1,1,1]"
output: "[false, false, false]"
explanation: "The binary numbers are 1, 11, 111; which are 1, 3, and 7 in base-10. None are divisible by 5."
- input: "nums = [1,0,1,0]"
output: "[false, false, true, false]"
explanation: "The binary numbers are 1, 10, 101, 1010; which are 1, 2, 5, and 10 in base-10. The values 5 and 10 are divisible by 5, but 10 appears at index 3, so answer = [false, false, true, false]."
explanation:
intuition: |
Imagine you're reading a binary number digit by digit, from left to right. Each time you read a new digit, you're essentially building up a larger number.
Think of it like this: if you have the number `5` (binary `101`) and you append a `0`, you get `1010` which is `10` in decimal. Appending a digit doubles the previous number and adds the new bit. Mathematically: `new_number = old_number * 2 + new_bit`.
The key insight is that we don't need to track the actual number — it could grow astronomically large (up to 2<sup>100000</sup>). Instead, we only care about **divisibility by 5**, which means we only need to track the **remainder when divided by 5**.
This works because of **modular arithmetic**: if we know the remainder of `old_number % 5`, we can compute `new_number % 5` directly as `(old_number * 2 + new_bit) % 5`. The intermediate large numbers never need to be stored.
approach: |
We solve this using a **Running Remainder** approach:
**Step 1: Initialise variables**
- `current`: Set to `0` to represent the running number mod 5
- `result`: An empty list to collect our boolean answers
&nbsp;
**Step 2: Iterate through the binary array**
- For each bit in `nums`, update `current` using the formula: `current = (current * 2 + bit) % 5`
- This simulates "shifting left and adding the new bit" while keeping only the remainder
- Append `True` to `result` if `current == 0`, otherwise append `False`
&nbsp;
**Step 3: Return the result**
- Return the `result` list containing booleans for each prefix
&nbsp;
This approach works because `(a * 2 + b) % 5 = ((a % 5) * 2 + b) % 5`. We maintain the invariant that `current` always holds `x_i % 5`.
common_pitfalls:
- title: Computing the Actual Binary Number
description: |
A naive approach might try to build the actual integer value at each step:
```python
num = int(''.join(map(str, nums[:i+1])), 2)
```
With `nums.length <= 10^5`, the binary number can have 100,000 digits. This number is astronomically large (approximately 2<sup>100000</sup>) and will cause **memory issues** and **timeout errors**.
Instead, use modular arithmetic to track only the remainder.
wrong_approach: "Converting binary subarray to integer"
correct_approach: "Track running remainder mod 5"
- title: Forgetting to Apply Modulo After Each Step
description: |
If you compute `current = current * 2 + bit` without taking `% 5`, the value will still overflow (in languages with fixed-size integers) or grow unnecessarily large.
Always apply `% 5` at each step: `current = (current * 2 + bit) % 5`.
wrong_approach: "Accumulating without modulo"
correct_approach: "Apply % 5 after each update"
- title: Off-by-One in Understanding the Prefix
description: |
The prefix `x_i` includes `nums[0]` through `nums[i]` inclusive. Make sure you're checking divisibility at the right moment — after incorporating the i<sup>th</sup> bit, not before.
key_takeaways:
- "**Modular arithmetic** allows you to track divisibility without storing huge numbers"
- "**Building numbers bit by bit**: `new = old * 2 + bit` is the fundamental operation for constructing binary numbers left-to-right"
- "**Property preservation**: If you only care about a property (like divisibility), you may not need the full value"
- "**Pattern recognition**: This technique applies to any divisibility check on incrementally built numbers"
time_complexity: "O(n). We traverse the array once, performing constant-time arithmetic at each step."
space_complexity: "O(n). We store the result array of length `n`. The running remainder uses O(1) extra space."
solutions:
- approach_name: Running Remainder
is_optimal: true
code: |
def prefixes_div_by5(nums: list[int]) -> list[bool]:
# Track the current number mod 5
current = 0
result = []
for bit in nums:
# Shift left (multiply by 2) and add new bit, keep only remainder
current = (current * 2 + bit) % 5
# Check if current prefix is divisible by 5
result.append(current == 0)
return result
explanation: |
**Time Complexity:** O(n) — Single pass through the array.
**Space Complexity:** O(n) — Output array of size n; O(1) auxiliary space.
We use modular arithmetic to track divisibility without computing the actual (potentially huge) binary numbers. At each step, `current` holds the value of the prefix modulo 5.
- approach_name: Brute Force (Convert to Integer)
is_optimal: false
code: |
def prefixes_div_by5(nums: list[int]) -> list[bool]:
result = []
for i in range(len(nums)):
# Build binary string from prefix
binary_str = ''.join(str(b) for b in nums[:i+1])
# Convert to integer
num = int(binary_str, 2)
# Check divisibility
result.append(num % 5 == 0)
return result
explanation: |
**Time Complexity:** O(n^2) — For each of n prefixes, we build a string of length up to n.
**Space Complexity:** O(n) — The binary string can grow up to length n.
This approach works for small inputs but fails on large arrays due to the enormous numbers involved. With `n = 10^5`, the final number has 100,000 binary digits — far too large to handle efficiently. Included to illustrate why modular arithmetic is essential.

View File

@@ -0,0 +1,211 @@
title: Binary Search Tree Iterator
slug: binary-search-tree-iterator
difficulty: medium
leetcode_id: 173
leetcode_url: https://leetcode.com/problems/binary-search-tree-iterator/
categories:
- trees
- stack
patterns:
- tree-traversal
- monotonic-stack
description: |
Implement the `BSTIterator` class that represents an iterator over the **in-order traversal** of a binary search tree (BST):
- `BSTIterator(TreeNode root)` Initializes an object of the `BSTIterator` class. The `root` of the BST is given as part of the constructor. The pointer should be initialized to a non-existent number smaller than any element in the BST.
- `boolean hasNext()` Returns `true` if there exists a number in the traversal to the right of the pointer, otherwise returns `false`.
- `int next()` Moves the pointer to the right, then returns the number at the pointer.
Notice that by initializing the pointer to a non-existent smallest number, the first call to `next()` will return the smallest element in the BST.
You may assume that `next()` calls will always be valid. That is, there will be at least a next number in the in-order traversal when `next()` is called.
constraints: |
- `1 <= number of nodes <= 10^5`
- `0 <= Node.val <= 10^6`
- At most `10^5` calls will be made to `hasNext` and `next`
examples:
- input: |
["BSTIterator", "next", "next", "hasNext", "next", "hasNext", "next", "hasNext", "next", "hasNext"]
[[[7, 3, 15, null, null, 9, 20]], [], [], [], [], [], [], [], [], []]
output: "[null, 3, 7, true, 9, true, 15, true, 20, false]"
explanation: |
BSTIterator bSTIterator = new BSTIterator([7, 3, 15, null, null, 9, 20]);
bSTIterator.next(); // return 3
bSTIterator.next(); // return 7
bSTIterator.hasNext(); // return True
bSTIterator.next(); // return 9
bSTIterator.hasNext(); // return True
bSTIterator.next(); // return 15
bSTIterator.hasNext(); // return True
bSTIterator.next(); // return 20
bSTIterator.hasNext(); // return False
explanation:
intuition: |
Imagine you're reading a book where each page references other pages before and after it. An in-order traversal of a BST visits nodes in **sorted order** (left subtree → node → right subtree). The challenge is to deliver this sorted sequence one element at a time, on demand, without storing the entire traversal upfront.
Think of it like this: instead of printing all nodes at once during a traversal, you want to **pause** the traversal after each node and resume when the caller asks for the next value. This is the essence of an iterator pattern.
The key insight is that a recursive in-order traversal uses the **call stack** to remember where to return. We can simulate this explicitly using our own stack. At any point, the stack holds the path of "left ancestors" — nodes we've visited but haven't processed yet because we went left first.
When we need the next element, we pop from the stack (that's our current node), then push all nodes along the left path of the right subtree. This controlled traversal gives us O(h) memory usage and amortized O(1) time per `next()` call.
approach: |
We solve this using a **Controlled In-Order Traversal with Stack**:
**Step 1: Initialize the iterator**
- Create an empty stack to simulate the call stack of recursive traversal
- Call a helper function `_push_left(node)` starting from the root
- This helper pushes the node and all its left descendants onto the stack
- After initialization, the stack contains the leftmost path from root
&nbsp;
**Step 2: Implement `_push_left` helper**
- While the current node is not null:
- Push the node onto the stack
- Move to the left child
- This ensures we always have the "next smallest" nodes ready
&nbsp;
**Step 3: Implement `next()`**
- Pop the top node from the stack — this is the next smallest element
- If this node has a right child, call `_push_left(right_child)`
- This prepares the stack for future calls by adding the right subtree's leftmost path
- Return the popped node's value
&nbsp;
**Step 4: Implement `hasNext()`**
- Simply check if the stack is non-empty
- If there are nodes on the stack, there are more elements to iterate
&nbsp;
This approach achieves O(h) space complexity where h is the tree height, and amortized O(1) time per operation because each node is pushed and popped exactly once across all operations.
common_pitfalls:
- title: Flattening the Entire Tree Upfront
description: |
A naive approach is to perform a complete in-order traversal in the constructor and store all values in a list:
```python
def __init__(self, root):
self.values = []
self._inorder(root) # O(n) time and space
self.index = 0
```
While this works and gives O(1) for `next()` and `hasNext()`, it uses **O(n) space** to store all node values. The follow-up challenge asks for O(h) space, which is more memory-efficient for tall, sparse trees.
wrong_approach: "Store all values in a list during construction"
correct_approach: "Use a stack to control traversal on-demand"
- title: Forgetting to Process Right Subtrees
description: |
After popping a node in `next()`, you must check if it has a right child. If you forget this step, you'll skip all nodes in right subtrees.
For example, in a tree `[2, 1, 3]`, after returning `1` (left child), if you don't push `3` (right child of root), you'll miss it entirely.
wrong_approach: "Only pop from stack without handling right children"
correct_approach: "After popping, push the left path of the right subtree"
- title: Incorrect Stack Initialization
description: |
The stack must be initialized with the leftmost path from the root, not just the root itself. If you only push the root, the first `next()` call won't return the smallest element.
For tree `[7, 3, 15]`, the first `next()` should return `3`, not `7`. This requires pushing `7` then `3` during initialization.
wrong_approach: "Push only the root node"
correct_approach: "Push all nodes along the leftmost path"
key_takeaways:
- "**Iterator pattern**: Controlled traversal delivers elements on-demand without pre-computing the entire sequence"
- "**Explicit stack**: Simulates the call stack of recursive traversal, giving you control over when to pause and resume"
- "**Amortized analysis**: Each node is pushed and popped exactly once, so n operations cost O(n) total — O(1) amortized per operation"
- "**Space optimization**: O(h) stack space is better than O(n) for balanced trees (h = log n) and acceptable for skewed trees (h = n)"
time_complexity: "O(1) amortized per operation. Each node is pushed and popped from the stack exactly once across all `next()` calls, so n calls take O(n) total time."
space_complexity: "O(h) where h is the height of the tree. The stack holds at most h nodes (the leftmost path). For a balanced tree, h = O(log n); for a skewed tree, h = O(n)."
solutions:
- approach_name: Controlled Traversal with Stack
is_optimal: true
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class BSTIterator:
def __init__(self, root: TreeNode):
# Stack to simulate the call stack of recursive traversal
self.stack = []
# Push all left nodes from root to leftmost leaf
self._push_left(root)
def _push_left(self, node: TreeNode) -> None:
"""Push node and all its left descendants onto the stack."""
while node:
self.stack.append(node)
node = node.left
def next(self) -> int:
# Pop the next smallest element
node = self.stack.pop()
# If it has a right subtree, push its leftmost path
if node.right:
self._push_left(node.right)
return node.val
def hasNext(self) -> bool:
# There are more elements if stack is non-empty
return len(self.stack) > 0
explanation: |
**Time Complexity:** O(1) amortized per operation — each node pushed/popped once.
**Space Complexity:** O(h) — stack holds at most the tree height.
The stack maintains the "frontier" of unvisited nodes along the leftmost path. Each `next()` call advances the traversal by one step, lazily exploring right subtrees only when needed.
- approach_name: Flatten to List
is_optimal: false
code: |
class BSTIterator:
def __init__(self, root: TreeNode):
# Store all values from in-order traversal
self.values = []
self.index = 0
self._inorder(root)
def _inorder(self, node: TreeNode) -> None:
"""Recursively collect all values in sorted order."""
if not node:
return
self._inorder(node.left)
self.values.append(node.val)
self._inorder(node.right)
def next(self) -> int:
# Return current value and advance pointer
val = self.values[self.index]
self.index += 1
return val
def hasNext(self) -> bool:
# Check if we've exhausted the list
return self.index < len(self.values)
explanation: |
**Time Complexity:** O(n) for constructor, O(1) for `next()` and `hasNext()`.
**Space Complexity:** O(n) — stores all node values.
This approach pre-computes the entire traversal. It's simpler to implement but uses more memory. Useful when you know you'll iterate through all elements anyway, but doesn't meet the O(h) space constraint in the follow-up.

View File

@@ -0,0 +1,225 @@
title: Binary Search Tree to Greater Sum Tree
slug: binary-search-tree-to-greater-sum-tree
difficulty: medium
leetcode_id: 1038
leetcode_url: https://leetcode.com/problems/binary-search-tree-to-greater-sum-tree/
categories:
- trees
patterns:
- dfs
- tree-traversal
description: |
Given the `root` of a Binary Search Tree (BST), convert it to a Greater Tree such that every key of the original BST is changed to the original key plus the sum of all keys greater than the original key in BST.
As a reminder, a *binary search tree* is a tree that satisfies these constraints:
- The left subtree of a node contains only nodes with keys **less than** the node's key.
- The right subtree of a node contains only nodes with keys **greater than** the node's key.
- Both the left and right subtrees must also be binary search trees.
constraints: |
- `1 <= number of nodes <= 100`
- `0 <= Node.val <= 100`
- All values in the tree are **unique**
examples:
- input: "root = [4,1,6,0,2,5,7,null,null,null,3,null,null,null,8]"
output: "[30,36,21,36,35,26,15,null,null,null,33,null,null,null,8]"
explanation: "Each node's value becomes the sum of itself plus all nodes with greater values. For example, node 4 becomes 4+5+6+7+8=30."
- input: "root = [0,null,1]"
output: "[1,null,1]"
explanation: "Node 1 has no greater values so stays 1. Node 0 becomes 0+1=1."
explanation:
intuition: |
The key insight is understanding what "all keys greater than the original key" means in a BST.
In a BST, an **inorder traversal** (left → node → right) visits nodes in ascending order. If we **reverse** this traversal order (right → node → left), we visit nodes in **descending order** — from largest to smallest.
Think of it like this: imagine you have a sorted list of all node values `[0, 1, 2, 3, 4, 5, 6, 7, 8]`. For each value, you need to add all values to its right. If you traverse from right to left, you can maintain a **running sum** that accumulates as you go.
When you visit node with value 8 (the largest), no values are greater, so it stays 8. When you visit 7, you add the running sum (8), making it 15. When you visit 6, you add the running sum (8+7=15), making it 21. And so on.
By traversing in reverse-inorder (right → node → left), each node we visit is smaller than all previously visited nodes, so our running sum naturally represents "the sum of all greater values."
approach: |
We use a **Reverse Inorder Traversal** with a running sum:
**Step 1: Understand the traversal order**
- Normal inorder: left → node → right (ascending order)
- Reverse inorder: right → node → left (descending order)
- By visiting nodes from largest to smallest, we can build up the sum incrementally
&nbsp;
**Step 2: Maintain a running sum**
- `running_sum`: Tracks the sum of all nodes visited so far
- Since we visit in descending order, this equals "sum of all greater values"
- Initialise to `0` before traversal
&nbsp;
**Step 3: Perform reverse inorder traversal**
- Recursively visit the right subtree first (larger values)
- At the current node:
- Add the node's value to `running_sum`
- Update the node's value to `running_sum`
- Recursively visit the left subtree (smaller values)
&nbsp;
**Step 4: Return the modified root**
- The tree is modified in-place
- Return the original root reference
&nbsp;
This approach processes each node exactly once, and the running sum at each node naturally represents the sum of all greater values plus the current value.
common_pitfalls:
- title: Using Normal Inorder Instead of Reverse
description: |
A common mistake is performing a normal inorder traversal (left → node → right). This visits nodes in ascending order, which means when you reach a node, you've only seen smaller values — not the greater ones you need to sum.
With reverse inorder (right → node → left), you visit larger values first, so your running sum correctly represents "sum of all greater values."
wrong_approach: "Normal inorder traversal (left → node → right)"
correct_approach: "Reverse inorder traversal (right → node → left)"
- title: Forgetting to Update Node Value Before Recursing Left
description: |
The order of operations matters. You must:
1. Visit right subtree
2. Add current value to running sum
3. Update current node's value
4. Visit left subtree
If you update the node after visiting the left subtree, nodes in the left subtree won't include this node's contribution in their sums.
- title: Not Modifying In-Place
description: |
The problem asks you to modify the tree in-place and return the same root. Don't create a new tree or return a different root node.
Simply update `node.val = running_sum` at each node during traversal.
key_takeaways:
- "**Reverse inorder traversal** visits BST nodes in descending order — useful when you need to process from largest to smallest"
- "**Running sum pattern**: When accumulating values during traversal, consider which direction gives you the values you need"
- "**BST property exploitation**: The structure of a BST provides natural ordering that can be leveraged for efficient solutions"
- "This problem is identical to LeetCode 538 (Convert BST to Greater Tree) — recognising equivalent problems is a valuable skill"
time_complexity: "O(n). We visit each node exactly once during the reverse inorder traversal."
space_complexity: "O(h) where h is the height of the tree. This is the recursion stack depth, which is O(log n) for a balanced tree or O(n) for a skewed tree."
solutions:
- approach_name: Reverse Inorder Traversal (Recursive)
is_optimal: true
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def bst_to_gst(root: TreeNode) -> TreeNode:
# Running sum of all nodes visited so far (larger values)
running_sum = 0
def reverse_inorder(node: TreeNode) -> None:
nonlocal running_sum
if not node:
return
# Visit right subtree first (larger values)
reverse_inorder(node.right)
# Add current value to running sum, then update node
running_sum += node.val
node.val = running_sum
# Visit left subtree (smaller values)
reverse_inorder(node.left)
reverse_inorder(root)
return root
explanation: |
**Time Complexity:** O(n) — Each node is visited exactly once.
**Space Complexity:** O(h) — Recursion stack depth equals tree height.
We traverse the tree in reverse inorder (right → node → left), maintaining a running sum. At each node, we add its value to the sum and update the node to hold this accumulated value. Since we visit larger nodes first, the running sum always represents the sum of all greater values.
- approach_name: Iterative with Stack
is_optimal: true
code: |
def bst_to_gst(root: TreeNode) -> TreeNode:
running_sum = 0
stack = []
current = root
# Iterative reverse inorder: right -> node -> left
while stack or current:
# Go as far right as possible
while current:
stack.append(current)
current = current.right
# Process current node
current = stack.pop()
running_sum += current.val
current.val = running_sum
# Move to left subtree
current = current.left
return root
explanation: |
**Time Complexity:** O(n) — Each node is visited exactly once.
**Space Complexity:** O(h) — Stack stores at most h nodes (tree height).
This is the iterative version using an explicit stack. We simulate the recursion by first pushing all right children onto the stack, processing nodes as we pop them, then moving to left children. This avoids recursion overhead but has the same time and space complexity.
- approach_name: Morris Traversal
is_optimal: true
code: |
def bst_to_gst(root: TreeNode) -> TreeNode:
running_sum = 0
current = root
while current:
if not current.right:
# No right subtree: process node, move left
running_sum += current.val
current.val = running_sum
current = current.left
else:
# Find inorder predecessor in right subtree
# (leftmost node in right subtree)
successor = current.right
while successor.left and successor.left != current:
successor = successor.left
if not successor.left:
# Create temporary link back to current
successor.left = current
current = current.right
else:
# Remove temporary link, process node
successor.left = None
running_sum += current.val
current.val = running_sum
current = current.left
return root
explanation: |
**Time Complexity:** O(n) — Each edge is traversed at most twice.
**Space Complexity:** O(1) — No recursion stack or explicit stack used.
Morris traversal achieves O(1) space by temporarily modifying the tree structure. We create temporary links from nodes back to their "successors" in the reverse inorder. This is an advanced technique that trades code complexity for space efficiency.

View File

@@ -0,0 +1,197 @@
title: Binary Search
slug: binary-search
difficulty: easy
leetcode_id: 704
leetcode_url: https://leetcode.com/problems/binary-search/
categories:
- arrays
- binary-search
patterns:
- binary-search
function_signature: "def search(nums: list[int], target: int) -> int:"
test_cases:
visible:
- input: { nums: [-1, 0, 3, 5, 9, 12], target: 9 }
expected: 4
- input: { nums: [-1, 0, 3, 5, 9, 12], target: 2 }
expected: -1
hidden:
- input: { nums: [5], target: 5 }
expected: 0
- input: { nums: [5], target: 1 }
expected: -1
- input: { nums: [1, 2, 3, 4, 5], target: 1 }
expected: 0
- input: { nums: [1, 2, 3, 4, 5], target: 5 }
expected: 4
- input: { nums: [1, 2, 3, 4, 5], target: 3 }
expected: 2
description: |
Given an array of integers `nums` which is sorted in ascending order, and an integer `target`, write a function to search `target` in `nums`.
If `target` exists, return its index. Otherwise, return `-1`.
You must write an algorithm with **O(log n)** runtime complexity.
constraints: |
- `1 <= nums.length <= 10^4`
- `-10^4 < nums[i], target < 10^4`
- All the integers in `nums` are **unique**
- `nums` is sorted in **ascending order**
examples:
- input: "nums = [-1,0,3,5,9,12], target = 9"
output: "4"
explanation: "9 exists in nums at index 4."
- input: "nums = [-1,0,3,5,9,12], target = 2"
output: "-1"
explanation: "2 does not exist in nums, so return -1."
explanation:
intuition: |
Imagine you're looking up a word in a physical dictionary. You don't start at page 1 and read every page — you open to the middle, see if your word comes before or after, then flip to the middle of the remaining half. You repeat this until you find the word.
Think of it like a **guessing game**: "I'm thinking of a number between 1 and 100." The optimal strategy is always to guess the middle — each guess eliminates half the possibilities.
This is the essence of **binary search**: by comparing the target with the middle element, we eliminate half the search space with each comparison. Instead of O(n) comparisons with linear search, we need only O(log n) comparisons.
The key requirement is that the array must be **sorted** — without sorted order, we can't know which half to eliminate.
approach: |
We solve this using **Iterative Binary Search**:
**Step 1: Initialise the search boundaries**
- Set `left = 0` (start of array)
- Set `right = len(nums) - 1` (end of array)
- Our target, if it exists, must be within `[left, right]`
&nbsp;
**Step 2: Loop while the search space is valid**
- Continue while `left <= right` (search space has at least one element)
- Calculate the middle index: `mid = left + (right - left) // 2`
- Note: We use `left + (right - left) // 2` instead of `(left + right) // 2` to prevent integer overflow in some languages
&nbsp;
**Step 3: Compare and narrow the search space**
- If `nums[mid] == target`: Found it! Return `mid`
- If `nums[mid] < target`: Target is in the right half, set `left = mid + 1`
- If `nums[mid] > target`: Target is in the left half, set `right = mid - 1`
&nbsp;
**Step 4: Handle not found**
- If the loop exits (left > right), the target doesn't exist
- Return `-1` to indicate not found
&nbsp;
Each iteration eliminates half the remaining elements, giving us O(log n) time complexity.
common_pitfalls:
- title: Integer Overflow in Mid Calculation
description: |
Using `(left + right) / 2` can cause integer overflow in languages with fixed-size integers (like Java or C++) when `left` and `right` are both large.
For example, if `left = 2,000,000,000` and `right = 2,000,000,000`, their sum overflows a 32-bit integer.
The safe formula `left + (right - left) // 2` avoids this by never computing a sum larger than `right`.
wrong_approach: "mid = (left + right) / 2"
correct_approach: "mid = left + (right - left) // 2"
- title: Off-by-One in Loop Condition
description: |
Using `while left < right` instead of `while left <= right` can miss the target when it's the only element left in the search space.
For example, if `left == right == 3` and `nums[3]` is the target, `while left < right` exits immediately without checking!
The `<=` ensures we check when the search space has exactly one element.
wrong_approach: "while left < right"
correct_approach: "while left <= right"
- title: Incorrect Boundary Updates
description: |
After checking `mid`, it must be excluded from the next search. Using `left = mid` or `right = mid` can cause infinite loops.
If `nums[mid] < target`, we know `mid` isn't the answer, so `left = mid + 1` excludes it.
If `nums[mid] > target`, we know `mid` isn't the answer, so `right = mid - 1` excludes it.
wrong_approach: "left = mid or right = mid"
correct_approach: "left = mid + 1 or right = mid - 1"
key_takeaways:
- "**Sorted array prerequisite**: Binary search only works on sorted data — always verify this condition"
- "**O(log n) power**: Halving the search space each iteration is incredibly efficient — 1 billion elements needs only ~30 comparisons"
- "**Template for variations**: This basic pattern extends to finding insertion points, rotated arrays, peak elements, and more"
- "**Boundary precision matters**: Off-by-one errors are the most common bugs — always think carefully about `<` vs `<=` and `mid ± 1`"
time_complexity: "O(log n). Each iteration halves the search space, so we need at most log₂(n) iterations."
space_complexity: "O(1). Only three variables (`left`, `right`, `mid`) are used, regardless of input size."
solutions:
- approach_name: Iterative Binary Search
is_optimal: true
code: |
def search(nums: list[int], target: int) -> int:
# Initialise search boundaries
left, right = 0, len(nums) - 1
# Continue while search space is valid
while left <= right:
# Calculate middle index (overflow-safe)
mid = left + (right - left) // 2
if nums[mid] == target:
# Found the target
return mid
elif nums[mid] < target:
# Target is in right half, exclude mid
left = mid + 1
else:
# Target is in left half, exclude mid
right = mid - 1
# Target not found
return -1
explanation: |
**Time Complexity:** O(log n) — Search space halves each iteration.
**Space Complexity:** O(1) — Only constant extra space used.
Classic binary search: compare the middle element with the target and eliminate half the search space each iteration. The loop terminates when the target is found or the search space is exhausted.
- approach_name: Recursive Binary Search
is_optimal: false
code: |
def search(nums: list[int], target: int) -> int:
def binary_search(left: int, right: int) -> int:
# Base case: search space exhausted
if left > right:
return -1
# Calculate middle index
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
# Search right half
return binary_search(mid + 1, right)
else:
# Search left half
return binary_search(left, mid - 1)
return binary_search(0, len(nums) - 1)
explanation: |
**Time Complexity:** O(log n) — Same number of comparisons as iterative.
**Space Complexity:** O(log n) — Recursion stack depth.
Recursive implementation with the same logic. While elegant, it uses O(log n) stack space. The iterative version is preferred for its O(1) space complexity.

View File

@@ -0,0 +1,155 @@
title: Binary String With Substrings Representing 1 To N
slug: binary-string-with-substrings-representing-1-to-n
difficulty: medium
leetcode_id: 1016
leetcode_url: https://leetcode.com/problems/binary-string-with-substrings-representing-1-to-n/
categories:
- strings
- math
patterns:
- sliding-window
description: |
Given a binary string `s` and a positive integer `n`, return `true` *if the binary representation of all the integers in the range* `[1, n]` *are **substrings** of* `s`*, or* `false` *otherwise*.
A **substring** is a contiguous sequence of characters within a string.
constraints: |
- `1 <= s.length <= 1000`
- `s[i]` is either `'0'` or `'1'`
- `1 <= n <= 10^9`
examples:
- input: 's = "0110", n = 3'
output: "true"
explanation: "The binary representations are: 1 = '1', 2 = '10', 3 = '11'. All of these are substrings of '0110': '1' appears at index 1, '10' appears at index 2, '11' appears at index 1."
- input: 's = "0110", n = 4'
output: "false"
explanation: "The binary representation of 4 is '100', which is not a substring of '0110'."
explanation:
intuition: |
At first glance, this problem seems daunting: how can we possibly check all numbers from 1 to n when n can be as large as 10<sup>9</sup>?
The key insight is a **length constraint**. A string of length `m` can contain at most `m` substrings of any given length `k` (specifically, `m - k + 1` substrings). Since our string has at most 1000 characters, it can contain at most about 1000 distinct substrings of each length.
Think of it like this: if you need to fit all binary representations from 1 to n into a string of length 1000, there's a natural limit. The binary representation of n has roughly `log2(n)` digits. For n = 10<sup>9</sup>, that's about 30 digits. A string of length 1000 can have at most ~970 substrings of length 30, ~971 substrings of length 29, etc.
This means if n is too large relative to the string length, we can immediately return `false`. In practice, if n exceeds roughly 2000-3000, the string simply cannot contain enough distinct substrings. This transforms a seemingly impossible O(n) problem into a very manageable one.
**Another critical observation**: if a number k appears as a substring, then `k/2` (integer division) also appears. Why? Because `k/2` in binary is just k with the last bit removed. If "10110" is a substring, then "1011" is too (it's the prefix). This means we only need to check the **upper half** of the range `[n/2 + 1, n]`, since the lower half is automatically covered!
approach: |
We solve this using a **String Containment Check with Early Termination**:
**Step 1: Handle the length constraint**
- If the string is too short to possibly contain all required substrings, we could return `false` early
- In practice, we let the iteration handle this naturally since it will fail quickly
&nbsp;
**Step 2: Check only the upper half of the range**
- Instead of checking all numbers from 1 to n, we only check from `n // 2 + 1` to `n`
- This is because if binary(k) exists as substring, binary(k // 2) also exists (it's a prefix)
- This optimization cuts our work in half
&nbsp;
**Step 3: Iterate and check containment**
- For each number `i` in the range `[n // 2 + 1, n]`:
- Convert `i` to its binary representation (without the '0b' prefix)
- Check if this binary string is a substring of `s`
- If any number is not found, immediately return `false`
&nbsp;
**Step 4: Return the result**
- If all numbers in the upper half are found, return `true`
- The lower half is guaranteed to exist due to the prefix property
common_pitfalls:
- title: Checking All Numbers From 1 to N
description: |
A naive approach might try to check every number from 1 to n:
```python
for i in range(1, n + 1):
if bin(i)[2:] not in s:
return False
```
With n up to 10<sup>9</sup>, this would require a billion iterations. Even though each `in` check is O(m) where m is the string length, this is far too slow.
The key insight is that we only need to check the upper half `[n/2 + 1, n]` because smaller numbers are automatically covered as prefixes.
wrong_approach: "Check all n numbers"
correct_approach: "Only check numbers in [n/2 + 1, n]"
- title: Ignoring String Length Constraints
description: |
A string of length 1000 can only contain about 1000 distinct substrings of any particular length. If n requires more distinct substrings than the string can possibly hold, the answer is automatically `false`.
For example, if n = 10000, we'd need binary strings of length up to 14 bits. But a 1000-character string can only have ~987 substrings of length 14. Since we need ~5000 distinct numbers in just the upper half, it's impossible.
In practice, the iteration will fail quickly anyway, but understanding this constraint helps explain why the problem is tractable.
wrong_approach: "Assume any n is possible"
correct_approach: "Recognize string length limits the maximum feasible n"
- title: Including the '0b' Prefix in Binary Conversion
description: |
Python's `bin()` function returns strings like `'0b101'` for the number 5. If you forget to strip the `'0b'` prefix with `bin(i)[2:]`, your substring checks will fail.
For example, checking if `'0b11'` is in `'0110'` returns `False`, but checking if `'11'` is in `'0110'` returns `True`.
wrong_approach: "Use bin(i) directly"
correct_approach: "Use bin(i)[2:] to strip the prefix"
key_takeaways:
- "**Leverage mathematical properties**: The prefix relationship between binary(k) and binary(k/2) cuts the search space in half"
- "**Consider physical constraints**: A finite string has a limited number of substrings, which bounds the maximum n that's feasible"
- "**Early termination**: Return `false` as soon as any required substring is missing"
- "**This pattern appears in coverage problems**: Checking if a set of items exists within a container, where relationships between items can reduce the search space"
time_complexity: "O(n * m) in the worst case, where m is the string length. However, due to the upper-half optimization, we only check n/2 numbers, and each check is O(m). In practice, for large n, the algorithm fails quickly due to string length constraints."
space_complexity: "O(log n). We create binary string representations of each number, which have length O(log n)."
solutions:
- approach_name: Upper Half Check
is_optimal: true
code: |
def query_string(s: str, n: int) -> bool:
# Only check upper half: if k is found, k//2 is also found (prefix)
for i in range(n, n // 2, -1):
# Convert to binary without '0b' prefix
binary_rep = bin(i)[2:]
# Check if this binary representation is a substring
if binary_rep not in s:
return False
return True
explanation: |
**Time Complexity:** O(n/2 * m) where m is the string length. We check at most n/2 numbers, and each substring check is O(m).
**Space Complexity:** O(log n) for storing the binary representation of each number.
We iterate from n down to n/2 + 1, checking if each number's binary representation exists in s. Due to the prefix property of binary numbers, finding all numbers in the upper half guarantees all numbers in the lower half exist too.
- approach_name: Brute Force (All Numbers)
is_optimal: false
code: |
def query_string(s: str, n: int) -> bool:
# Check every number from 1 to n
for i in range(1, n + 1):
# Convert to binary without '0b' prefix
binary_rep = bin(i)[2:]
# Check if this binary representation is a substring
if binary_rep not in s:
return False
return True
explanation: |
**Time Complexity:** O(n * m) where m is the string length. We check all n numbers.
**Space Complexity:** O(log n) for storing the binary representation.
This naive approach checks every number from 1 to n. While correct, it's twice as slow as the optimized version since it doesn't exploit the prefix property. For very large n, this would be prohibitively slow, though the string length constraint means the loop typically terminates early anyway.

View File

@@ -0,0 +1,201 @@
title: Binary Subarrays With Sum
slug: binary-subarrays-with-sum
difficulty: medium
leetcode_id: 930
leetcode_url: https://leetcode.com/problems/binary-subarrays-with-sum/
categories:
- arrays
- hash-tables
patterns:
- sliding-window
- prefix-sum
description: |
Given a binary array `nums` and an integer `goal`, return *the number of non-empty **subarrays** with a sum equal to* `goal`.
A **subarray** is a contiguous part of the array.
constraints: |
- `1 <= nums.length <= 3 * 10^4`
- `nums[i]` is either `0` or `1`
- `0 <= goal <= nums.length`
examples:
- input: "nums = [1,0,1,0,1], goal = 2"
output: "4"
explanation: "The 4 subarrays with sum 2 are: [1,0,1], [1,0,1,0], [0,1,0,1], and [1,0,1]."
- input: "nums = [0,0,0,0,0], goal = 0"
output: "15"
explanation: "Every non-empty subarray has sum 0. With 5 elements, there are 5 + 4 + 3 + 2 + 1 = 15 subarrays."
explanation:
intuition: |
Imagine you're walking along the array, keeping a running total of all the `1`s you've seen so far. At any position, your running total is called the **prefix sum**.
Here's the key insight: if you know the prefix sum at position `j` is `P[j]`, and you want to find subarrays ending at `j` with sum equal to `goal`, you need to find earlier positions `i` where `P[i] = P[j] - goal`. Why? Because `P[j] - P[i]` gives you the sum of elements between positions `i+1` and `j`.
Think of it like this: you're standing at a point on a trail and you've climbed 10 metres total from the start. If you want to find segments of trail where you climbed exactly 3 metres, you need to find all earlier points where you had climbed exactly 7 metres (10 - 3 = 7). The number of such points equals the number of valid segments ending at your current position.
By using a hash map to count how many times each prefix sum has occurred, we can instantly look up how many valid subarrays end at any position.
approach: |
We solve this using a **Prefix Sum with Hash Map** approach:
**Step 1: Initialise tracking variables**
- `count`: Set to `0` to accumulate the total number of valid subarrays
- `prefix_sum`: Set to `0` to track the running sum as we iterate
- `prefix_count`: A hash map initialised with `{0: 1}` — this handles subarrays starting from index 0
&nbsp;
**Step 2: Iterate through the array**
- For each element, add it to `prefix_sum`
- Calculate `target = prefix_sum - goal`
- If `target` exists in `prefix_count`, add `prefix_count[target]` to `count` — this is the number of subarrays ending at the current index with sum equal to `goal`
- Increment `prefix_count[prefix_sum]` by 1 to record this prefix sum for future iterations
&nbsp;
**Step 3: Return the result**
- Return `count` after processing all elements
&nbsp;
The hash map approach transforms what would be an O(n^2) problem (checking all subarrays) into an O(n) solution by leveraging the prefix sum property.
common_pitfalls:
- title: Forgetting the Initial Prefix Sum
description: |
A common mistake is not initialising the hash map with `{0: 1}`.
Consider `nums = [1, 1]` with `goal = 2`. The prefix sums are `[1, 2]`. When we reach prefix sum 2 and look for `2 - 2 = 0`, we need to find one occurrence of 0 to count the subarray `[1, 1]`.
Without the initial `{0: 1}`, subarrays that start from index 0 and sum to `goal` would be missed entirely.
wrong_approach: "Initialise prefix_count as empty {}"
correct_approach: "Initialise prefix_count with {0: 1}"
- title: The Brute Force Trap
description: |
The naive approach checks every possible subarray using nested loops:
- Outer loop for start index `i`
- Inner loop for end index `j`
- Calculate sum for each `(i, j)` pair
This results in **O(n^2) or O(n^3) time complexity**. With the constraint `nums.length <= 3 * 10^4`, this means up to ~1 billion operations, causing **Time Limit Exceeded (TLE)**.
The prefix sum approach reduces this to O(n) by avoiding redundant sum calculations.
wrong_approach: "Nested loops checking all subarrays"
correct_approach: "Prefix sum with hash map for O(n) lookup"
- title: Handling goal = 0 Incorrectly
description: |
When `goal = 0`, we're looking for subarrays of consecutive zeros. This is where the algorithm handles an edge case elegantly.
For `nums = [0, 0]`, the prefix sums are `[0, 0]`. At each zero, we look for `prefix_sum - 0 = prefix_sum` in our map. The count naturally accumulates because each repeated prefix sum value indicates another valid subarray.
Some implementations fail here by not properly counting the growing number of ways to form zero-sum subarrays.
key_takeaways:
- "**Prefix sum transformation**: Converting subarray sum problems into prefix difference problems is a powerful technique"
- "**Hash map for O(1) lookup**: Storing prefix sum frequencies enables instant counting of valid subarrays"
- "**Initial state matters**: The `{0: 1}` initialisation handles subarrays starting from index 0"
- "**Related problems**: This pattern applies to Subarray Sum Equals K, Contiguous Array, and many other subarray counting problems"
time_complexity: "O(n). We traverse the array once, performing O(1) hash map operations at each step."
space_complexity: "O(n). In the worst case, all prefix sums are unique, requiring O(n) space in the hash map."
solutions:
- approach_name: Prefix Sum with Hash Map
is_optimal: true
code: |
def num_subarrays_with_sum(nums: list[int], goal: int) -> int:
count = 0
prefix_sum = 0
# {0: 1} handles subarrays starting from index 0
prefix_count = {0: 1}
for num in nums:
# Update running prefix sum
prefix_sum += num
# How many earlier positions have prefix_sum - goal?
# Each such position marks the start of a valid subarray
target = prefix_sum - goal
if target in prefix_count:
count += prefix_count[target]
# Record this prefix sum for future iterations
prefix_count[prefix_sum] = prefix_count.get(prefix_sum, 0) + 1
return count
explanation: |
**Time Complexity:** O(n) — Single pass through the array with O(1) hash map operations.
**Space Complexity:** O(n) — Hash map stores at most n+1 unique prefix sums.
This approach uses the prefix sum property: `sum(i, j) = prefix[j] - prefix[i-1]`. By maintaining a count of each prefix sum seen so far, we can instantly find how many subarrays ending at the current position have the target sum.
- approach_name: Sliding Window
is_optimal: true
code: |
def num_subarrays_with_sum(nums: list[int], goal: int) -> int:
def at_most(k: int) -> int:
"""Count subarrays with sum <= k."""
if k < 0:
return 0
count = 0
left = 0
current_sum = 0
for right in range(len(nums)):
current_sum += nums[right]
# Shrink window if sum exceeds k
while current_sum > k:
current_sum -= nums[left]
left += 1
# All subarrays ending at right with sum <= k
count += right - left + 1
return count
# Subarrays with sum exactly k = at_most(k) - at_most(k-1)
return at_most(goal) - at_most(goal - 1)
explanation: |
**Time Complexity:** O(n) — Two passes, each O(n).
**Space Complexity:** O(1) — Only uses a few variables.
This approach uses a clever trick: subarrays with sum exactly `k` equals subarrays with sum at most `k` minus subarrays with sum at most `k-1`. The sliding window efficiently counts "at most" subarrays because we can shrink the window when the sum exceeds the threshold.
- approach_name: Brute Force
is_optimal: false
code: |
def num_subarrays_with_sum(nums: list[int], goal: int) -> int:
count = 0
n = len(nums)
# Try every starting position
for i in range(n):
current_sum = 0
# Extend subarray from i to j
for j in range(i, n):
current_sum += nums[j]
if current_sum == goal:
count += 1
# Optimisation: early exit if sum exceeds goal (since all nums >= 0)
elif current_sum > goal:
break
return count
explanation: |
**Time Complexity:** O(n^2) — Nested loops checking all subarrays.
**Space Complexity:** O(1) — Only tracking current sum and count.
This checks every subarray starting at each index. The early break when sum exceeds goal provides some optimisation, but worst case (when goal is large or many zeros) remains O(n^2). Included to illustrate why the prefix sum approach is necessary for large inputs.

View File

@@ -0,0 +1,236 @@
title: Binary Tree Cameras
slug: binary-tree-cameras
difficulty: hard
leetcode_id: 968
leetcode_url: https://leetcode.com/problems/binary-tree-cameras/
categories:
- trees
- dynamic-programming
patterns:
- dfs
- greedy
description: |
You are given the `root` of a binary tree. We install cameras on the tree nodes where each camera at a node can monitor its parent, itself, and its immediate children.
Return *the minimum number of cameras needed to monitor all nodes of the tree*.
constraints: |
- The number of nodes in the tree is in the range `[1, 1000]`
- `Node.val == 0`
examples:
- input: "root = [0,0,null,0,0]"
output: "1"
explanation: "One camera is enough to monitor all nodes if placed as shown. Place a camera on the node at depth 1 (the left child of root), and it monitors its parent (root), itself, and both its children."
- input: "root = [0,0,null,0,null,0,null,null,0]"
output: "2"
explanation: "At least two cameras are needed to monitor all nodes of the tree. The tree forms a longer chain, so strategically placing cameras at every third level minimises the count."
explanation:
intuition: |
Imagine you're a security manager placing cameras in a building shaped like an upside-down tree. Each camera can see the room it's in, the room directly above (parent), and rooms directly below (children). Your goal is to use the **fewest cameras** while ensuring every room is monitored.
The key insight is to think **from the leaves upward**. Leaves are the most "vulnerable" nodes — they have no children to place cameras on that could cover them. If we place a camera on a leaf, we're wasting its monitoring power (it has no children to watch). Instead, it's more efficient to place cameras on the **parents of leaves**, which can monitor multiple nodes at once.
This leads to a greedy strategy: process the tree bottom-up using DFS. At each node, we decide its state based on what its children need. We classify each node into one of three states:
- **State 0 (Needs coverage)**: This node has no camera and isn't covered by a child's camera
- **State 1 (Has camera)**: This node has a camera installed
- **State 2 (Covered)**: This node is covered by a child's camera (doesn't need its own)
The greedy choice is: only place a camera when absolutely necessary — specifically, when a child needs coverage.
approach: |
We solve this using a **Post-order DFS with Greedy State Tracking**:
**Step 1: Define node states**
- `0`: Node needs to be covered (uncovered leaf or uncovered node)
- `1`: Node has a camera
- `2`: Node is covered (by a child's camera)
&nbsp;
**Step 2: Perform post-order DFS**
- Process children first (left, then right), then the current node
- This ensures we make decisions bottom-up
- `null` nodes return state `2` (covered) — they don't need monitoring
&nbsp;
**Step 3: Determine each node's state**
- If **any child needs coverage** (state `0`): place a camera here → return state `1`
- If **any child has a camera** (state `1`): this node is covered → return state `2`
- Otherwise: this node needs coverage → return state `0`
&nbsp;
**Step 4: Handle the root**
- After DFS completes, if root needs coverage (state `0`), add one more camera
- The root has no parent to cover it, so we must handle this edge case
&nbsp;
**Why this works**: By delaying camera placement until a child explicitly needs coverage, we ensure cameras are placed as "high" as possible in the tree, maximising their coverage. A camera at a parent covers more nodes than a camera at a leaf.
common_pitfalls:
- title: Placing Cameras on Leaves
description: |
A natural first instinct might be to place cameras on leaf nodes to ensure they're covered. However, this is wasteful:
- A leaf camera only covers itself and its parent (no children)
- A camera on the leaf's parent covers the leaf, the parent, and potentially the parent's parent
For example, in a chain of 3 nodes `A → B → C` (where C is a leaf):
- Placing camera on C: covers C and B (need another for A) = 2 cameras
- Placing camera on B: covers A, B, and C = 1 camera
wrong_approach: "Place cameras on all leaves"
correct_approach: "Place cameras on parents of uncovered nodes"
- title: Forgetting the Root Edge Case
description: |
After the DFS completes, the root might return state `0` (needs coverage). This happens when the root's children are both covered but neither has a camera directly.
For example, a tree with just a root node: DFS returns state `0` for the root (it's a leaf that needs coverage), but there's no parent to cover it. We must add a camera.
Always check: if `dfs(root) == 0`, increment the camera count.
wrong_approach: "Assume DFS handles all nodes"
correct_approach: "Check root state after DFS and add camera if needed"
- title: Wrong State Transitions
description: |
The order of checking states matters:
1. First check if any child needs coverage (state `0`) — must place camera
2. Then check if any child has camera (state `1`) — we're covered
3. Default: we need coverage (state `0`)
If you check in the wrong order, you might skip placing a necessary camera or place unnecessary ones.
wrong_approach: "Check states in arbitrary order"
correct_approach: "Prioritise: uncovered children first, then covered check, then default"
key_takeaways:
- "**Bottom-up greedy**: Process leaves first and make decisions that propagate upward — cameras placed higher cover more nodes"
- "**State machine on trees**: Using discrete states (`0`, `1`, `2`) for each node simplifies complex decision logic into clear transitions"
- "**Post-order DFS pattern**: When a node's decision depends on its children's states, process children first (post-order)"
- "**Similar problems**: This greedy tree coverage pattern appears in problems like House Robber III, distributing coins in a tree, and tree colouring problems"
time_complexity: "O(n). We visit each node exactly once during the post-order DFS traversal."
space_complexity: "O(h) where h is the height of the tree. The recursion stack can grow up to the tree's height. In the worst case (skewed tree), this is O(n); for a balanced tree, it's O(log n)."
solutions:
- approach_name: Post-order DFS with Greedy States
is_optimal: true
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def min_camera_cover(root: TreeNode) -> int:
# State meanings:
# 0 = node needs to be covered
# 1 = node has a camera
# 2 = node is covered (by child's camera)
cameras = 0
def dfs(node: TreeNode) -> int:
nonlocal cameras
# Null nodes are considered "covered" — they don't need monitoring
if not node:
return 2
# Post-order: process children first
left_state = dfs(node.left)
right_state = dfs(node.right)
# If any child needs coverage, we MUST place a camera here
if left_state == 0 or right_state == 0:
cameras += 1
return 1 # This node now has a camera
# If any child has a camera, this node is covered
if left_state == 1 or right_state == 1:
return 2 # Covered by child's camera
# Both children are covered but no camera nearby
# This node needs to be covered by its parent
return 0
# Run DFS and handle root edge case
root_state = dfs(root)
# If root still needs coverage, add a camera
# (root has no parent to cover it)
if root_state == 0:
cameras += 1
return cameras
explanation: |
**Time Complexity:** O(n) — Each node is visited exactly once.
**Space Complexity:** O(h) — Recursion stack depth equals tree height.
We use post-order DFS to process children before parents. Each node returns a state indicating whether it needs coverage, has a camera, or is already covered. The greedy choice to place cameras only when children need coverage ensures minimal camera usage. The root check handles the edge case where the root itself needs coverage.
- approach_name: Dynamic Programming with Three States
is_optimal: false
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def min_camera_cover(root: TreeNode) -> int:
# For each node, compute minimum cameras needed for three scenarios:
# dp[0] = cameras needed if this node is NOT covered and has no camera
# dp[1] = cameras needed if this node HAS a camera
# dp[2] = cameras needed if this node is covered (by child) but no camera here
def dfs(node: TreeNode) -> tuple[int, int, int]:
if not node:
# Null: doesn't need camera, considered covered
# State 0 returns infinity (not valid for real nodes)
# States 1 and 2 return 0 (no camera needed)
return float('inf'), 0, 0
left = dfs(node.left)
right = dfs(node.right)
# dp[0]: Node uncovered, no camera
# Children must be covered (state 2) — they can't have cameras
# pointing at us since we're not covered
dp0 = left[2] + right[2]
# dp[1]: Node has camera (covers itself, parent, and children)
# Children can be in any valid state — take minimum
dp1 = 1 + min(left) + min(right)
# dp[2]: Node covered by child's camera, no camera here
# At least one child must have a camera (state 1)
# The other child can be covered or have camera
dp2 = min(
left[1] + min(right[1], right[2]), # Left has camera
right[1] + min(left[1], left[2]) # Right has camera
)
return dp0, dp1, dp2
result = dfs(root)
# Root must be covered: either has camera (state 1) or covered (state 2)
return min(result[1], result[2])
explanation: |
**Time Complexity:** O(n) — Each node visited once.
**Space Complexity:** O(h) — Recursion stack.
This DP approach explicitly tracks all three states for each node. While it achieves the same optimal result, it's more complex than the greedy solution. The greedy approach is preferred for its simplicity and clearer logic. This solution is included to show how the problem can be framed as classical tree DP.

View File

@@ -0,0 +1,219 @@
title: Binary Tree Coloring Game
slug: binary-tree-coloring-game
difficulty: medium
leetcode_id: 1145
leetcode_url: https://leetcode.com/problems/binary-tree-coloring-game/
categories:
- trees
patterns:
- dfs
- tree-traversal
description: |
Two players play a turn-based game on a binary tree. We are given the `root` of this binary tree, and the number of nodes `n` in the tree. `n` is odd, and each node has a distinct value from `1` to `n`.
Initially, the first player names a value `x` with `1 <= x <= n`, and the second player names a value `y` with `1 <= y <= n` and `y != x`. The first player colours the node with value `x` red, and the second player colours the node with value `y` blue.
Then, the players take turns starting with the first player. In each turn, that player chooses a node of their colour (red if player 1, blue if player 2) and colours an **uncoloured** neighbour of the chosen node (either the left child, right child, or parent of the chosen node).
If (and only if) a player cannot choose such a node in this way, they must pass their turn. If both players pass their turn, the game ends, and the winner is the player that coloured more nodes.
You are the second player. If it is possible to choose such a `y` to ensure you win the game, return `true`. If it is not possible, return `false`.
constraints: |
- The number of nodes in the tree is `n`
- `1 <= x <= n <= 100`
- `n` is odd
- `1 <= Node.val <= n`
- All the values of the tree are **unique**
examples:
- input: "root = [1,2,3,4,5,6,7,8,9,10,11], n = 11, x = 3"
output: "true"
explanation: "The second player can choose the node with value 2."
- input: "root = [1,2,3], n = 3, x = 1"
output: "false"
explanation: "No matter which node the second player chooses, the first player will win."
explanation:
intuition: |
Imagine the binary tree as a network of connected rooms. When the first player colours node `x` red, they effectively "block" that node — splitting the tree into **at most three separate regions**:
1. The **left subtree** of node `x`
2. The **right subtree** of node `x`
3. The **parent region** (everything connected through `x`'s parent)
Think of it like placing a roadblock at node `x`. Once placed, you can no longer freely move between the three regions — they're isolated from each other.
The key insight is that as the second player, your optimal strategy is to **claim one entire region by colouring a node adjacent to `x`**. If you colour `x`'s left child, you get the entire left subtree. If you colour `x`'s right child, you get the entire right subtree. If you colour `x`'s parent, you get everything *except* `x`'s subtree.
Since each player expands from their starting node to uncoloured neighbours, once you claim a region adjacent to `x`, the first player can never enter it — they're blocked by their own red node!
You win if any of these three regions contains **more than half** the nodes (i.e., more than `n / 2`). The game is about counting: find the size of each region and check if any exceeds `n / 2`.
approach: |
We solve this by counting the nodes in each of the three regions created when player 1 chooses node `x`:
**Step 1: Find node `x` in the tree**
- Use DFS to locate the node with value `x`
- This is our "pivot point" that divides the tree
&nbsp;
**Step 2: Count nodes in the left and right subtrees of `x`**
- `left_count`: Number of nodes in `x`'s left subtree
- `right_count`: Number of nodes in `x`'s right subtree
- Use a recursive helper function to count nodes in any subtree
&nbsp;
**Step 3: Calculate the parent region size**
- The parent region contains all nodes *not* in `x`'s subtree
- `parent_count = n - left_count - right_count - 1` (subtract 1 for node `x` itself)
&nbsp;
**Step 4: Check if any region exceeds half**
- We win if `left_count > n / 2` OR `right_count > n / 2` OR `parent_count > n / 2`
- Since `n` is odd, `n / 2` uses integer division, so we need strictly more than `n // 2` nodes
&nbsp;
The beauty of this approach is that we don't need to simulate the game — we just need to recognise that the optimal second move is always adjacent to `x`, and then count.
common_pitfalls:
- title: Simulating the Entire Game
description: |
A tempting approach is to simulate every possible game state: try each choice of `y`, then simulate alternating turns to see who wins.
This is massively overcomplicated. The game has a simple structure: once both players choose their starting nodes, the outcome is deterministic based on region sizes. There's no need for game simulation, minimax, or dynamic programming.
The insight that player 2 should always choose a neighbour of `x` reduces this from a complex game tree to a simple counting problem.
wrong_approach: "Simulating turn-by-turn gameplay"
correct_approach: "Count nodes in three regions and compare to n/2"
- title: Forgetting the Parent Region
description: |
It's easy to only consider the left and right subtrees of `x`, forgetting that player 2 could choose `x`'s parent.
If `x` is deep in the tree with small subtrees, the parent region (everything except `x`'s subtree) could be huge. For example, if `x` is a leaf node, its parent region contains `n - 1` nodes!
Always calculate all three regions: left subtree, right subtree, and parent region.
wrong_approach: "Only checking left and right subtree sizes"
correct_approach: "Check all three regions including parent"
- title: Off-by-One in Parent Count
description: |
When calculating the parent region size, remember to subtract 1 for node `x` itself:
`parent_count = n - left_count - right_count - 1`
The parent region contains all nodes except `x` and its subtrees. Forgetting the `-1` overcounts by including `x` in the parent region.
wrong_approach: "parent_count = n - left_count - right_count"
correct_approach: "parent_count = n - left_count - right_count - 1"
key_takeaways:
- "**Game theory simplification**: Complex games often have simple optimal strategies. Here, player 2's best move is always adjacent to `x`"
- "**Tree partitioning**: Removing a node from a tree creates independent subtrees. Count the sizes to analyse connectivity"
- "**The majority principle**: In a zero-sum game with `n` items, controlling more than `n/2` guarantees victory"
- "**DFS for tree metrics**: Recursive subtree counting is a fundamental tree operation — master it for many tree problems"
time_complexity: "O(n). We traverse the tree once to find node `x` and count subtree sizes."
space_complexity: "O(h) where `h` is the height of the tree. This is the recursion stack depth, which is O(log n) for balanced trees and O(n) for skewed trees."
solutions:
- approach_name: DFS Subtree Counting
is_optimal: true
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def btree_game_winning_move(root: TreeNode, n: int, x: int) -> bool:
left_count = 0
right_count = 0
def count_nodes(node: TreeNode) -> int:
"""Count total nodes in a subtree."""
if not node:
return 0
return 1 + count_nodes(node.left) + count_nodes(node.right)
def find_x(node: TreeNode) -> bool:
"""Find node x and count its left/right subtrees."""
nonlocal left_count, right_count
if not node:
return False
if node.val == x:
# Found x! Count its children's subtrees
left_count = count_nodes(node.left)
right_count = count_nodes(node.right)
return True
# Keep searching in left and right subtrees
return find_x(node.left) or find_x(node.right)
# Find node x and get subtree counts
find_x(root)
# Calculate parent region size
# (all nodes not in x's subtree, excluding x itself)
parent_count = n - left_count - right_count - 1
# Player 2 wins if any region has more than half the nodes
half = n // 2
return left_count > half or right_count > half or parent_count > half
explanation: |
**Time Complexity:** O(n) — We visit each node at most twice: once to find `x`, once to count subtrees.
**Space Complexity:** O(h) — Recursion stack depth equals tree height.
We use DFS to find node `x` and count its left and right subtree sizes. The parent region is calculated by subtraction. If any region exceeds `n // 2` nodes, player 2 can win by choosing a node adjacent to `x` in that region.
- approach_name: Single DFS Pass
is_optimal: true
code: |
def btree_game_winning_move(root: TreeNode, n: int, x: int) -> bool:
left_count = 0
right_count = 0
def dfs(node: TreeNode) -> int:
"""Count nodes in subtree, capturing x's children counts."""
nonlocal left_count, right_count
if not node:
return 0
# Recursively count left and right subtrees
left = dfs(node.left)
right = dfs(node.right)
# If this is node x, save the subtree counts
if node.val == x:
left_count = left
right_count = right
return 1 + left + right
# Single DFS traversal
dfs(root)
# Check if player 2 can claim a majority region
parent_count = n - left_count - right_count - 1
half = n // 2
return max(left_count, right_count, parent_count) > half
explanation: |
**Time Complexity:** O(n) — Single traversal visiting each node exactly once.
**Space Complexity:** O(h) — Recursion stack depth.
This optimised version uses a single DFS pass. As we return from each recursive call, we check if the current node is `x` and capture its subtree sizes. This avoids the separate `find_x` and `count_nodes` functions, making the code more elegant.

View File

@@ -0,0 +1,236 @@
title: Binary Tree Inorder Traversal
slug: binary-tree-inorder-traversal
difficulty: easy
leetcode_id: 94
leetcode_url: https://leetcode.com/problems/binary-tree-inorder-traversal/
categories:
- trees
- stack
- recursion
patterns:
- tree-traversal
- dfs
description: |
Given the `root` of a binary tree, return *the inorder traversal of its nodes' values*.
**Inorder traversal** visits nodes in the order: **left subtree**, then **root**, then **right subtree**.
For a Binary Search Tree (BST), inorder traversal returns nodes in sorted ascending order.
constraints: |
- The number of nodes in the tree is in the range `[0, 100]`
- `-100 <= Node.val <= 100`
examples:
- input: "root = [1,null,2,3]"
output: "[1,3,2]"
explanation: "The tree has root 1, with right child 2, which has left child 3. Inorder visits: 1 (no left child), then 3 (left of 2), then 2."
- input: "root = [1,2,3,4,5,null,8,null,null,6,7,9]"
output: "[4,2,6,5,7,1,3,9,8]"
explanation: "Full traversal of a more complex tree, visiting left subtree first, then root, then right subtree at each level."
- input: "root = []"
output: "[]"
explanation: "An empty tree returns an empty list."
- input: "root = [1]"
output: "[1]"
explanation: "A single node tree returns just that node's value."
explanation:
intuition: |
Think of inorder traversal like reading a book where you must finish all the pages on the left before reading the current page, then read all the pages on the right.
Imagine you're standing at the root of the tree. Before you can "visit" (record) the current node, you must first completely explore everything to your left. Only after exhausting the left subtree do you record the current node, then move to explore the right.
This **left-root-right** pattern has a beautiful property for Binary Search Trees: it visits nodes in **sorted order**. This is because in a BST, all left descendants are smaller and all right descendants are larger than the current node.
The key insight is that this naturally recursive pattern can also be implemented iteratively using a **stack** to simulate the call stack. The stack helps us remember which nodes we still need to "come back to" after exploring their left subtrees.
approach: |
We present two approaches: **Recursive** (elegant and intuitive) and **Iterative** (uses explicit stack).
**Recursive Approach:**
**Step 1: Define the base case**
- If the current node is `None`, return (nothing to process)
&nbsp;
**Step 2: Apply the inorder pattern**
- Recursively traverse the left subtree
- Add the current node's value to the result
- Recursively traverse the right subtree
&nbsp;
**Iterative Approach:**
**Step 1: Initialise data structures**
- `result`: Empty list to store the traversal order
- `stack`: Empty stack to track nodes we need to return to
- `current`: Pointer starting at the root
&nbsp;
**Step 2: Traverse using the stack**
- While `current` is not None OR stack is not empty:
- Go as far left as possible, pushing each node onto the stack
- When you can't go left anymore, pop from stack
- Add the popped node's value to result (this is the "visit")
- Move to the right child and repeat
&nbsp;
**Step 3: Return the result**
- The `result` list now contains values in inorder sequence
&nbsp;
The iterative approach mimics exactly what the recursive approach does, but uses an explicit stack instead of the implicit call stack.
common_pitfalls:
- title: Confusing Traversal Orders
description: |
There are three depth-first traversals, and mixing them up is common:
- **Preorder**: root, left, right (process node first)
- **Inorder**: left, root, right (process node in middle)
- **Postorder**: left, right, root (process node last)
For inorder, remember "**in** the middle" — the root is visited **in** between left and right.
wrong_approach: "Visiting root before left subtree"
correct_approach: "Always complete left subtree before visiting current node"
- title: Forgetting to Handle Empty Trees
description: |
An empty tree (root is `None`) should return an empty list `[]`, not cause an error.
Always check for the empty tree case, either as a base case in recursion or as an initial condition in iteration.
wrong_approach: "Assuming root always exists"
correct_approach: "Handle None root as base case returning empty list"
- title: Iterative Stack Logic Errors
description: |
In the iterative approach, a common mistake is not understanding when to push vs. pop:
- **Push**: When moving left, push current node (we'll come back to it)
- **Pop**: When we can't go left anymore, pop to visit the node
- **Move right**: After visiting, move to right child
The loop condition `while current or stack` is crucial — we continue if either there's a current node to process OR nodes waiting on the stack.
wrong_approach: "Only looping while current is not None"
correct_approach: "Loop while current exists OR stack is not empty"
key_takeaways:
- "**Inorder = left-root-right**: The middle position of 'in-order' helps remember that root is visited in the middle"
- "**BST property**: Inorder traversal of a BST yields sorted output — useful for validation and conversion problems"
- "**Stack simulates recursion**: Any recursive traversal can be converted to iterative using an explicit stack"
- "**Foundation problem**: This pattern extends to many tree problems like validating BSTs, finding kth smallest element, and tree serialisation"
time_complexity: "O(n). We visit each of the `n` nodes exactly once."
space_complexity: "O(h) where `h` is the height of the tree. In the worst case (skewed tree), `h = n`, so O(n). For a balanced tree, O(log n)."
solutions:
- approach_name: Recursive
is_optimal: true
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def inorder_traversal(root: TreeNode | None) -> list[int]:
result = []
def inorder(node: TreeNode | None) -> None:
# Base case: empty node
if node is None:
return
# Left subtree first
inorder(node.left)
# Visit current node (add to result)
result.append(node.val)
# Right subtree last
inorder(node.right)
inorder(root)
return result
explanation: |
**Time Complexity:** O(n) — Each node is visited exactly once.
**Space Complexity:** O(h) — Recursive call stack depth equals tree height. Worst case O(n) for skewed tree, O(log n) for balanced tree.
The recursive approach directly expresses the inorder definition: traverse left, visit node, traverse right. The helper function captures results in the outer scope's `result` list.
- approach_name: Iterative with Stack
is_optimal: true
code: |
def inorder_traversal(root: TreeNode | None) -> list[int]:
result = []
stack = []
current = root
# Continue while there's a node to process or nodes on stack
while current or stack:
# Go as far left as possible
while current:
stack.append(current)
current = current.left
# Leftmost reached, pop and visit
current = stack.pop()
result.append(current.val)
# Move to right subtree
current = current.right
return result
explanation: |
**Time Complexity:** O(n) — Each node is pushed and popped exactly once.
**Space Complexity:** O(h) — Stack holds at most `h` nodes (the height of the tree).
This iterative version uses an explicit stack to simulate the recursive call stack. We push nodes while going left, pop to visit, then move right. This is the standard pattern for iterative tree traversal and is preferred in interviews when asked to avoid recursion.
- approach_name: Morris Traversal
is_optimal: false
code: |
def inorder_traversal(root: TreeNode | None) -> list[int]:
result = []
current = root
while current:
if current.left is None:
# No left subtree, visit and go right
result.append(current.val)
current = current.right
else:
# Find inorder predecessor (rightmost in left subtree)
predecessor = current.left
while predecessor.right and predecessor.right != current:
predecessor = predecessor.right
if predecessor.right is None:
# Create thread: link predecessor to current
predecessor.right = current
current = current.left
else:
# Thread exists, we've returned; remove it and visit
predecessor.right = None
result.append(current.val)
current = current.right
return result
explanation: |
**Time Complexity:** O(n) — Though it looks like more work, each edge is traversed at most twice.
**Space Complexity:** O(1) — No stack or recursion; uses tree structure itself for navigation.
Morris traversal achieves O(1) space by temporarily modifying the tree (creating "threads" from predecessors back to their successors). While optimal in space, it's more complex and modifies the tree during traversal. Useful when space is critical, but the iterative stack approach is usually preferred for clarity.

View File

@@ -0,0 +1,200 @@
title: Binary Tree Level Order Traversal II
slug: binary-tree-level-order-traversal-ii
difficulty: medium
leetcode_id: 107
leetcode_url: https://leetcode.com/problems/binary-tree-level-order-traversal-ii/
categories:
- trees
- queue
patterns:
- bfs
- tree-traversal
description: |
Given the `root` of a binary tree, return *the bottom-up level order traversal of its nodes' values* (i.e., from left to right, level by level from leaf to root).
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: "[[15,7],[9,20],[3]]"
explanation: "The deepest level (15, 7) comes first, then level 1 (9, 20), and finally the root level (3)."
- input: "root = [1]"
output: "[[1]]"
explanation: "Single node forms its own level."
- input: "root = []"
output: "[]"
explanation: "Empty tree returns an empty list."
explanation:
intuition: |
This problem is a twist on the classic level order traversal. Instead of reading the tree from top to bottom, we want to read it from bottom to top — like viewing a family photo starting with the grandchildren and working up to the grandparents.
Think of it like this: if we already know how to do a standard level order traversal (BFS), we get levels in the order `[root, level1, level2, ...]`. To get the bottom-up order, we simply **reverse the result** at the end: `[level2, level1, root]`.
The key insight is that **we don't need a fundamentally different algorithm**. BFS naturally processes the tree level by level from top to bottom. Rather than trying to traverse bottom-up (which is awkward), we traverse top-down and then reverse. This is both simpler and equally efficient.
Alternatively, we can build the result in reverse order by inserting each level at the front of the result list, but this is less efficient in most languages.
approach: |
We solve this using **BFS with Final Reversal**:
**Step 1: Handle the empty tree case**
- If `root` is `None`, return an empty list immediately
&nbsp;
**Step 2: Initialise the queue and result**
- Create a queue starting with just the root node
- Create an empty result list to hold each level's values
&nbsp;
**Step 3: Process level by level (standard BFS)**
- While the queue is not empty:
- Capture `level_size = len(queue)` — this is how many nodes are in the current level
- Create a `level_values` list for this level
- Process exactly `level_size` nodes:
- Dequeue a node, add its value to `level_values`
- Enqueue its left child (if exists)
- Enqueue its right child (if exists)
- Append `level_values` to the result
&nbsp;
**Step 4: Reverse and return**
- Reverse the result list so the deepest level comes first
- Return the reversed result
&nbsp;
The reversal is O(n) and doesn't change the overall complexity. This approach is clean because it reuses the well-known BFS level order pattern.
common_pitfalls:
- title: Over-Engineering the Traversal
description: |
A common mistake is trying to traverse the tree from bottom to top directly. This is unnecessarily complex — you'd need to first find the depth of the tree, then process nodes by depth in reverse order.
The simpler approach: do standard BFS (which is well-understood and easy to implement), then reverse the result at the end.
wrong_approach: "Trying to traverse bottom-up directly"
correct_approach: "Standard BFS, then reverse the result"
- title: Inserting at Front Instead of Reversing
description: |
Some solutions insert each level at index 0 of the result list: `result.insert(0, level_values)`. While this produces the correct output, `insert(0, ...)` on a Python list is O(n) for each insertion.
With k levels, this leads to O(n × k) operations. Appending and reversing at the end is O(n) total.
wrong_approach: "result.insert(0, level_values) for each level"
correct_approach: "result.append(level_values), then result.reverse() at the end"
- title: Using List as Queue
description: |
In Python, `list.pop(0)` is O(n) because all remaining elements must shift. For a tree with n nodes, this makes the algorithm O(n²) instead of O(n).
Use `collections.deque` which has O(1) `popleft()`.
wrong_approach: "queue = []; queue.pop(0)"
correct_approach: "queue = deque(); queue.popleft()"
- title: Forgetting the Empty Tree Check
description: |
If `root` is `None`, the queue starts empty, and the while loop never executes — but some implementations might crash trying to access root.
Always check `if not root: return []` at the start.
wrong_approach: "Assuming root is never None"
correct_approach: "if not root: return []"
key_takeaways:
- "**Transform, don't reinvent**: When a problem is a variation of a classic pattern, solve the classic version and transform the output"
- "**BFS = level order**: Breadth-first search naturally processes trees level by level — master this fundamental pattern"
- "**Reversal is cheap**: Reversing a list is O(n), same as the traversal — don't avoid it to pursue a 'clever' but slower solution"
- "**Same pattern, many problems**: This technique extends to zigzag traversal, right side view, level averages, and more"
time_complexity: "O(n). Every node is visited exactly once, and the final reversal is also O(n)."
space_complexity: "O(n). The queue holds at most one level of nodes (~n/2 in a complete binary tree), and the result stores all n node values."
solutions:
- approach_name: BFS with Reversal
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_bottom(root: TreeNode | None) -> list[list[int]]:
# Handle empty tree
if not root:
return []
result = []
queue = deque([root])
while queue:
# Capture current level size BEFORE processing
level_size = len(queue)
level_values = []
# Process exactly level_size nodes (all nodes at current level)
for _ in range(level_size):
node = queue.popleft()
level_values.append(node.val)
# Add children for next level
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
# This level is complete
result.append(level_values)
# Reverse to get bottom-up order
result.reverse()
return result
explanation: |
**Time Complexity:** O(n) — Each node added and removed from queue exactly once, plus O(n) for the reversal.
**Space Complexity:** O(n) — Queue can hold up to n/2 nodes, and result stores all n values.
Standard BFS processes levels top-to-bottom. We collect each level, then reverse the result at the end. The reversal adds O(n) time but doesn't change the overall complexity.
- approach_name: DFS with Level Tracking
is_optimal: false
code: |
def level_order_bottom(root: TreeNode | None) -> list[list[int]]:
result = []
def dfs(node: TreeNode | None, level: int) -> None:
if not node:
return
# First time at this level? Create new list
if level == len(result):
result.append([])
# Add current node to its level
result[level].append(node.val)
# Recurse to children (level + 1)
dfs(node.left, level + 1)
dfs(node.right, level + 1)
dfs(root, 0)
# Reverse to get bottom-up order
result.reverse()
return result
explanation: |
**Time Complexity:** O(n) — Visit each node once, plus O(n) for reversal.
**Space Complexity:** O(h) — Recursion stack depth equals tree height (O(log n) for balanced, O(n) for skewed).
DFS alternative: pass the level as a parameter. Each node appends to its level's list. Left-to-right order is preserved because we recurse left before right. Finally, reverse the result for bottom-up order.

View File

@@ -0,0 +1,205 @@
title: Binary Tree Level Order Traversal
slug: binary-tree-level-order-traversal
difficulty: medium
leetcode_id: 102
leetcode_url: https://leetcode.com/problems/binary-tree-level-order-traversal/
categories:
- trees
- queue
patterns:
- bfs
- tree-traversal
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: "The tree has 3 levels: root node 3 at level 0, nodes 9 and 20 at level 1, and nodes 15 and 7 at level 2."
- input: "root = [1]"
output: "[[1]]"
explanation: "A single node tree has only one level containing that node."
- input: "root = []"
output: "[]"
explanation: "An empty tree has no levels to traverse."
explanation:
intuition: |
Imagine you're taking a group photo of a family tree — literally. You want everyone on the same generation (level) to stand in the same row, from left to right. You'd naturally process the photo row by row, starting with the oldest generation at the top.
This is exactly what **level order traversal** does: visit all nodes at depth 0, then all nodes at depth 1, then depth 2, and so on. The key insight is that this is a **Breadth-First Search (BFS)** problem — we explore all neighbors (children) at the current level before moving deeper.
Think of it like ripples spreading outward from a stone dropped in water. The ripples expand level by level, touching all points at distance 1 before reaching distance 2. A **queue** is the perfect data structure for this because it processes nodes in First-In-First-Out (FIFO) order — exactly what we need to respect the level-by-level ordering.
The trick is knowing when one level ends and the next begins. We do this by processing all nodes currently in the queue (one level's worth) before moving to the next batch.
approach: |
We solve this using **Breadth-First Search (BFS)** with a queue:
**Step 1: Handle the empty tree case**
- If `root` is `None`, return an empty list `[]`
- This avoids errors when trying to add `None` to the queue
&nbsp;
**Step 2: Initialise the data structures**
- `result`: An empty list to store each level's values
- `queue`: A deque (double-ended queue) initialised with the root node
&nbsp;
**Step 3: Process the tree level by level**
- While the queue is not empty:
- Determine the number of nodes at the current level: `level_size = len(queue)`
- Create an empty list `current_level` to store this level's values
- Process exactly `level_size` nodes (this is the key to separating levels):
- Dequeue a node from the front
- Add its value to `current_level`
- Enqueue its left child (if it exists)
- Enqueue its right child (if it exists)
- After processing all nodes at this level, append `current_level` to `result`
&nbsp;
**Step 4: Return the result**
- Return `result` containing all levels from top to bottom
&nbsp;
The queue ensures we process nodes in the correct left-to-right order, and capturing `level_size` at the start of each iteration guarantees we process exactly one level before moving to the next.
common_pitfalls:
- title: Not Separating Levels Correctly
description: |
A common mistake is to process nodes one at a time without tracking where one level ends and the next begins. This results in a flat list of values rather than grouped levels.
For example, processing `[3,9,20,null,null,15,7]` incorrectly might give `[3, 9, 20, 15, 7]` instead of `[[3], [9, 20], [15, 7]]`.
The fix is to capture `level_size = len(queue)` at the start of each outer loop iteration and process exactly that many nodes before moving to the next level.
wrong_approach: "Process nodes one at a time without level boundaries"
correct_approach: "Capture level_size at start of each iteration"
- title: Forgetting to Check for Null Children
description: |
When enqueueing children, you must check if they exist. Adding `None` to the queue will cause errors when you try to access `.val`, `.left`, or `.right` on a null node.
Always use `if node.left:` and `if node.right:` guards before enqueueing.
wrong_approach: "queue.append(node.left) without checking"
correct_approach: "if node.left: queue.append(node.left)"
- title: Using a List Instead of a Deque
description: |
While you can use a regular Python list as a queue with `append()` and `pop(0)`, this is inefficient. `pop(0)` is O(n) because it shifts all remaining elements.
Using `collections.deque` with `append()` and `popleft()` gives O(1) operations for both enqueue and dequeue, which matters for larger trees.
wrong_approach: "list.pop(0) for dequeue — O(n) per operation"
correct_approach: "deque.popleft() — O(1) per operation"
- title: Forgetting the Empty Tree Edge Case
description: |
If `root` is `None`, the function should return `[]`. If you initialise the queue with `root` without checking, you'll add `None` to the queue and crash when accessing its attributes.
Always handle the empty tree case first.
key_takeaways:
- "**BFS is the natural fit** for level-by-level traversal — the queue's FIFO ordering preserves the left-to-right, top-to-bottom order"
- "**Level separation trick**: Capture `len(queue)` at the start of each iteration to know exactly how many nodes belong to the current level"
- "**Deque for efficiency**: Use `collections.deque` for O(1) enqueue and dequeue operations"
- "**Foundation for related problems**: This pattern extends to zigzag traversal, right side view, average of levels, and many other tree problems"
time_complexity: "O(n). We visit each node exactly once, where n is the number of nodes in the tree."
space_complexity: "O(n). In the worst case (a complete binary tree), the last level contains up to n/2 nodes, all of which would be in the queue simultaneously."
solutions:
- approach_name: BFS with Queue
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]]:
# Handle empty tree
if not root:
return []
result = []
queue = deque([root]) # Start with the root node
while queue:
# Number of nodes at current level
level_size = len(queue)
current_level = []
# Process all nodes at this level
for _ in range(level_size):
node = queue.popleft()
current_level.append(node.val)
# Add children for next level (left to right order)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
# Add this level to result
result.append(current_level)
return result
explanation: |
**Time Complexity:** O(n) — Each node is visited exactly once.
**Space Complexity:** O(n) — The queue holds at most one level of nodes. For a complete binary tree, the last level has approximately n/2 nodes.
This BFS approach processes nodes level by level. The key insight is using `level_size = len(queue)` to know exactly how many nodes belong to the current level before we start adding their children for the next level.
- approach_name: DFS with Level Tracking
is_optimal: false
code: |
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]]:
result = []
def dfs(node: TreeNode | None, level: int) -> None:
if not node:
return
# Create new level list if needed
if level == len(result):
result.append([])
# Add node to its level
result[level].append(node.val)
# Recurse on children with incremented level
dfs(node.left, level + 1)
dfs(node.right, level + 1)
dfs(root, 0)
return result
explanation: |
**Time Complexity:** O(n) — Each node is visited exactly once.
**Space Complexity:** O(h) for recursion stack where h is the tree height, plus O(n) for the result. In the worst case (skewed tree), h = n.
This DFS approach tracks the current level as a parameter. When visiting a node, we add it to the list for its level. By visiting left before right, we maintain left-to-right order within each level.
While this also works, BFS is more intuitive for level-order problems and doesn't risk stack overflow on very deep trees.

View File

@@ -0,0 +1,220 @@
title: Binary Tree Maximum Path Sum
slug: binary-tree-maximum-path-sum
difficulty: hard
leetcode_id: 124
leetcode_url: https://leetcode.com/problems/binary-tree-maximum-path-sum/
categories:
- trees
- dynamic-programming
- recursion
patterns:
- dfs
- dynamic-programming
description: |
A **path** in a binary tree is a sequence of nodes where each pair of adjacent nodes in the sequence has an edge connecting them. A node can only appear in the sequence **at most once**. Note that the path does not need to pass through the root.
The **path sum** of a path is the sum of the node's values in the path.
Given the `root` of a binary tree, return *the maximum **path sum** of any **non-empty** path*.
constraints: |
- The number of nodes in the tree is in the range `[1, 3 * 10^4]`
- `-1000 <= Node.val <= 1000`
examples:
- input: "root = [1,2,3]"
output: "6"
explanation: "The optimal path is 2 -> 1 -> 3 with a path sum of 2 + 1 + 3 = 6."
- input: "root = [-10,9,20,null,null,15,7]"
output: "42"
explanation: "The optimal path is 15 -> 20 -> 7 with a path sum of 15 + 20 + 7 = 42."
explanation:
intuition: |
Imagine each node in the tree as a potential **turning point** or **apex** of a path. A path can go through a node in three ways:
1. Enter from the left subtree, stop at the node
2. Enter from the right subtree, stop at the node
3. Enter from the left, pass through the node, and exit through the right (forming an "inverted V" shape)
The key insight is that **every valid path has exactly one apex node** — the topmost node where the path changes direction or ends. At this apex, the path can include contributions from both the left and right subtrees.
Think of it like finding the best hiking trail in a mountain range: at each peak (node), you consider the best trail coming up from the left valley, the best trail from the right valley, and whether combining them through this peak creates the overall best route.
The challenge is that we need to track two things simultaneously:
- The **best complete path** found anywhere in the tree (can turn at any node)
- The **best "arm"** extending from each node upward (for its parent to potentially use)
approach: |
We solve this using **DFS with Post-order Traversal**, computing the maximum gain from each subtree while tracking the global maximum path sum.
**Step 1: Define the recursive return value**
- The helper function returns the **maximum gain** that can be obtained by extending a path from the current node upward to its parent
- This means the path can only go in one direction (either left or right child, not both)
- We return `0` if including any child would decrease the sum (negative contribution)
&nbsp;
**Step 2: Compute gains from children**
- Recursively get the maximum gain from the left subtree: `left_gain = max(0, dfs(node.left))`
- Recursively get the maximum gain from the right subtree: `right_gain = max(0, dfs(node.right))`
- Taking `max(0, ...)` means we only include a subtree if it adds positive value
&nbsp;
**Step 3: Update the global maximum**
- At each node, compute the path sum if this node is the apex: `node.val + left_gain + right_gain`
- This path goes left-child → node → right-child (the full "V" shape)
- Update the global maximum if this path is better than any previously found
&nbsp;
**Step 4: Return the maximum single-direction gain**
- Return `node.val + max(left_gain, right_gain)` to the parent
- We can only extend in one direction since the parent will connect from above
- This value represents the best "arm" starting at this node and going downward
&nbsp;
**Step 5: Handle the base case**
- If the node is `None`, return `0` (no contribution to the path)
common_pitfalls:
- title: Confusing the Two Path Types
description: |
There are two distinct concepts in this problem:
1. **Complete path** — can turn at any node, used to update the global maximum
2. **Extendable arm** — can only go one direction, returned to the parent
A common mistake is returning `node.val + left_gain + right_gain` to the parent. This would be invalid because the parent can't connect to a path that already branches both ways — paths don't fork.
wrong_approach: "Return the complete path sum to parent"
correct_approach: "Return only the best single-direction gain to parent"
- title: Forgetting Negative Values
description: |
Node values can be negative (`-1000 <= Node.val <= 1000`). This means:
- The maximum path might consist of a single node
- We should not force including children if they decrease the sum
- Taking `max(0, child_gain)` lets us "prune" negative contributions
For example, in a tree `[-3]`, the answer is `-3`, not `0`. But if a child returns `-5`, we take `max(0, -5) = 0` to exclude it.
wrong_approach: "Always include child contributions"
correct_approach: "Use max(0, child_gain) to optionally exclude negative paths"
- title: Not Initializing Global Maximum Correctly
description: |
Since all node values could be negative, initializing `max_sum = 0` is wrong.
With `root = [-3, -2, -1]`, the answer should be `-1` (the single node with the least negative value), not `0`.
Initialize with negative infinity or the root's value.
wrong_approach: "Initialize max_sum = 0"
correct_approach: "Initialize max_sum = float('-inf')"
key_takeaways:
- "**Post-order DFS pattern**: Process children before the parent — essential when the parent's computation depends on children's results"
- "**Two-value tracking**: Maintain a global maximum for the answer while returning a different value (single-direction gain) for recursion"
- "**Optional inclusion**: Use `max(0, value)` to optionally exclude negative contributions without special-casing"
- "**Path apex insight**: Every tree path has exactly one 'top' node where direction changes — iterate over all possible apex positions"
time_complexity: "O(n). We visit each node exactly once during the DFS traversal."
space_complexity: "O(h) where h is the height of the tree. The recursion stack can go as deep as the tree height, which is O(log n) for balanced trees and O(n) for skewed trees."
solutions:
- approach_name: DFS with Post-order Traversal
is_optimal: true
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def max_path_sum(root: TreeNode) -> int:
# Track the global maximum path sum
max_sum = float('-inf')
def dfs(node: TreeNode) -> int:
nonlocal max_sum
# Base case: null nodes contribute nothing
if not node:
return 0
# Get max gain from left and right subtrees
# Use max(0, ...) to ignore negative contributions
left_gain = max(0, dfs(node.left))
right_gain = max(0, dfs(node.right))
# Path sum if current node is the apex (turning point)
# This path goes: left subtree -> node -> right subtree
path_through_node = node.val + left_gain + right_gain
# Update global maximum if this path is better
max_sum = max(max_sum, path_through_node)
# Return max gain if we extend path to parent
# Can only go one direction (left OR right, not both)
return node.val + max(left_gain, right_gain)
dfs(root)
return max_sum
explanation: |
**Time Complexity:** O(n) — Each node is visited exactly once.
**Space Complexity:** O(h) — Recursion stack depth equals tree height.
The DFS function serves a dual purpose: it updates the global maximum considering the current node as an apex, while returning the maximum single-direction gain for the parent to use. This elegant design handles all path configurations in a single traversal.
- approach_name: DFS with Tuple Return
is_optimal: false
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def max_path_sum(root: TreeNode) -> int:
def dfs(node: TreeNode) -> tuple[int, int]:
"""
Returns (max_path_in_subtree, max_extendable_arm)
- max_path_in_subtree: best complete path found in this subtree
- max_extendable_arm: best path starting at node going downward
"""
if not node:
# No path possible, arm contributes nothing
return float('-inf'), 0
# Recurse on children
left_max_path, left_arm = dfs(node.left)
right_max_path, right_arm = dfs(node.right)
# Best arm extending from this node (can skip children if negative)
best_arm = node.val + max(0, left_arm, right_arm)
# Best path with this node as apex
path_through_node = node.val + max(0, left_arm) + max(0, right_arm)
# Best path in entire subtree rooted here
best_path = max(left_max_path, right_max_path, path_through_node)
return best_path, max(0, best_arm)
max_path, _ = dfs(root)
return max_path
explanation: |
**Time Complexity:** O(n) — Each node is visited exactly once.
**Space Complexity:** O(h) — Recursion stack depth equals tree height.
This variant avoids using a global variable by returning two values: the best complete path found in the subtree and the best extendable arm. It's functionally equivalent to the optimal solution but makes the dual-tracking more explicit. Some prefer this for avoiding nonlocal variables.

View File

@@ -0,0 +1,205 @@
title: Binary Tree Paths
slug: binary-tree-paths
difficulty: easy
leetcode_id: 257
leetcode_url: https://leetcode.com/problems/binary-tree-paths/
categories:
- trees
- strings
- recursion
patterns:
- dfs
- backtracking
- tree-traversal
description: |
Given the `root` of a binary tree, return *all root-to-leaf paths in **any order***.
A **leaf** is a node with no children.
constraints: |
- The number of nodes in the tree is in the range `[1, 100]`
- `-100 <= Node.val <= 100`
examples:
- input: "root = [1,2,3,null,5]"
output: '["1->2->5","1->3"]'
explanation: "The tree has two leaf nodes: 5 and 3. The path from root to 5 is 1->2->5, and the path from root to 3 is 1->3."
- input: "root = [1]"
output: '["1"]'
explanation: "The tree has only one node which is both root and leaf, so there is only one path."
explanation:
intuition: |
Imagine standing at the root of a tree and needing to explore every possible route down to each leaf node, recording your path as you go.
This is a classic **tree traversal problem** where we need to visit every node while keeping track of the path we've taken. The key insight is that **depth-first search (DFS)** naturally models this exploration: we go as deep as possible down one branch, record the path when we hit a leaf, then backtrack and explore other branches.
Think of it like navigating a maze where you need to find all exits (leaves). You walk down one corridor, marking your route. When you hit a dead end (leaf), you record the complete path, then retrace your steps to try other corridors.
The core insight is recognizing when we've reached a leaf node: a node where **both** the left and right children are `None`. At that point, we've completed a valid root-to-leaf path.
approach: |
We solve this using **Depth-First Search with Path Tracking**:
**Step 1: Handle the base case**
- If the root is `None`, return an empty list (no paths exist)
&nbsp;
**Step 2: Initialise the DFS traversal**
- Create a result list to store all complete paths
- Start DFS from the root with the initial path containing just the root's value
&nbsp;
**Step 3: Define the recursive DFS function**
- For each node, check if it's a leaf (no left or right children)
- If it's a leaf, the current path is complete - add it to results
- If not a leaf, recursively explore left and right children
- When exploring children, extend the current path with `"->"` and the child's value
&nbsp;
**Step 4: Return all collected paths**
- After DFS completes, return the result list containing all root-to-leaf paths
&nbsp;
This approach naturally handles all tree shapes because DFS explores every branch, and the path string is built incrementally as we descend.
common_pitfalls:
- title: Forgetting the Leaf Check
description: |
A common mistake is adding paths at every node rather than only at leaves. This would result in incomplete paths being added to your result.
The correct leaf check requires **both** children to be `None`:
```python
if not node.left and not node.right: # This is a leaf
```
Not just checking one side:
```python
if not node.left: # Wrong - node could still have a right child
```
wrong_approach: "Adding paths at every node"
correct_approach: "Only add path when both children are None (leaf node)"
- title: Incorrect Path String Building
description: |
Building the path string incorrectly can lead to malformed output. A common error is adding the arrow `"->"` before or after incorrectly.
The pattern should be: start with the root value, then prepend `"->"` before each subsequent node value:
- Path starts as `"1"`
- When visiting child with value 2: `"1->2"`
- When visiting grandchild with value 5: `"1->2->5"`
Don't add a trailing arrow or forget to convert node values to strings.
wrong_approach: "Adding arrow before first node or after last node"
correct_approach: "Build path as 'current_path + \"->\" + str(node.val)'"
- title: Mutating Shared Path State
description: |
When using a list to build the path (instead of a string), forgetting to properly backtrack can corrupt your paths.
If you use a mutable list like `path = [1, 2]` and pass it to recursive calls, you need to either:
- Remove the element after returning (explicit backtracking)
- Pass a copy of the list to each recursive call
Using strings avoids this issue since strings are immutable in Python.
wrong_approach: "Mutating a shared list without backtracking"
correct_approach: "Use immutable strings or explicitly backtrack"
key_takeaways:
- "**DFS for path enumeration**: When you need all paths in a tree, DFS naturally explores each branch completely before backtracking"
- "**Leaf detection pattern**: A leaf node has no children - check `not node.left and not node.right`"
- "**Immutable path building**: Using strings for path construction avoids backtracking bugs since strings are immutable"
- "**Foundation for path problems**: This pattern extends to problems like path sum, longest path, and path with maximum value"
time_complexity: "O(n). We visit each node exactly once during the DFS traversal, where `n` is the number of nodes in the tree."
space_complexity: "O(n). In the worst case (a skewed tree), the recursion stack can be `n` levels deep. Additionally, we store all paths which in total can have O(n) nodes across all paths."
solutions:
- approach_name: DFS with String Path
is_optimal: true
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def binary_tree_paths(root: TreeNode | None) -> list[str]:
# Handle empty tree
if not root:
return []
result = []
def dfs(node: TreeNode, path: str) -> None:
# Check if we've reached a leaf node
if not node.left and not node.right:
# Leaf reached - add complete path to results
result.append(path)
return
# Explore left subtree if it exists
if node.left:
dfs(node.left, path + "->" + str(node.left.val))
# Explore right subtree if it exists
if node.right:
dfs(node.right, path + "->" + str(node.right.val))
# Start DFS from root with initial path
dfs(root, str(root.val))
return result
explanation: |
**Time Complexity:** O(n) — We visit each node once.
**Space Complexity:** O(n) — Recursion stack depth plus storage for paths.
This solution uses DFS to explore all root-to-leaf paths. The path is built as a string, which is immutable, so we don't need explicit backtracking. When we reach a leaf (no children), we add the complete path to our result list.
- approach_name: Iterative DFS with Stack
is_optimal: false
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def binary_tree_paths(root: TreeNode | None) -> list[str]:
if not root:
return []
result = []
# Stack stores tuples of (node, path_so_far)
stack = [(root, str(root.val))]
while stack:
node, path = stack.pop()
# Check if leaf node
if not node.left and not node.right:
result.append(path)
continue
# Add children to stack with extended paths
if node.right:
stack.append((node.right, path + "->" + str(node.right.val)))
if node.left:
stack.append((node.left, path + "->" + str(node.left.val)))
return result
explanation: |
**Time Complexity:** O(n) — We process each node once.
**Space Complexity:** O(n) — Stack can hold up to n nodes in worst case.
This iterative approach uses an explicit stack instead of recursion. Each stack entry contains both the node and the path built so far. This avoids potential stack overflow for very deep trees and makes the traversal order explicit. We add right child first so left is processed first (LIFO order).

View File

@@ -0,0 +1,218 @@
title: Binary Tree Postorder Traversal
slug: binary-tree-postorder-traversal
difficulty: easy
leetcode_id: 145
leetcode_url: https://leetcode.com/problems/binary-tree-postorder-traversal/
categories:
- trees
- stack
- recursion
patterns:
- dfs
- tree-traversal
description: |
Given the `root` of a binary tree, return *the postorder traversal of its nodes' values*.
**Postorder traversal** visits nodes in the order: **left subtree → right subtree → root**.
examples:
- input: "root = [1,null,2,3]"
output: "[3,2,1]"
explanation: "The tree has structure: 1 -> right: 2 -> left: 3. Postorder visits 3 first (leftmost leaf), then 2, then 1 (root)."
- input: "root = [1,2,3,4,5,null,8,null,null,6,7,9]"
output: "[4,6,7,5,2,9,8,3,1]"
explanation: "Visit all left descendants first, then right descendants, then roots - working bottom-up."
- input: "root = []"
output: "[]"
explanation: "Empty tree returns empty list."
- input: "root = [1]"
output: "[1]"
explanation: "Single node tree returns just that node."
constraints: |
- `0 <= number of nodes <= 100`
- `-100 <= Node.val <= 100`
explanation:
intuition: |
Think of postorder traversal as **processing children before their parent**. Imagine you're a postal worker who must deliver packages to every house in a neighbourhood, but with one rule: you can only mark a house as "visited" after you've visited all houses in its left and right branches.
The order is: **Left → Right → Root**. This means for any node, you first completely explore its left subtree, then completely explore its right subtree, and only then do you process the node itself.
Why is this useful? Postorder is the natural order for **bottom-up computations** - calculating subtree heights, deleting trees (delete children before parent), or evaluating expression trees (evaluate operands before operators).
The recursive approach mirrors this definition directly: recurse left, recurse right, then add the current node. The iterative approach is trickier because we need to remember whether we've already processed a node's children.
approach: |
We'll cover both the **Recursive** and **Iterative** approaches:
**Recursive Approach**
**Step 1: Define the base case**
- If the current node is `None`, return immediately (nothing to process)
&nbsp;
**Step 2: Recurse in postorder**
- First, recursively traverse the left subtree
- Then, recursively traverse the right subtree
- Finally, add the current node's value to the result
&nbsp;
**Iterative Approach (Two Stacks / Reverse Pre-order)**
The trick is to recognise that postorder (Left → Right → Root) is the **reverse** of a modified preorder (Root → Right → Left).
**Step 1: Use a stack for traversal**
- Push the root onto the stack
- Pop a node, add its value to the result
- Push left child first, then right child (so right is processed first)
&nbsp;
**Step 2: Reverse the result**
- The traversal gives us Root → Right → Left
- Reversing gives us Left → Right → Root (postorder!)
&nbsp;
Alternatively, you can use a single stack with a "last visited" pointer to track whether you're returning from a left or right child, but the two-stack method is more intuitive.
common_pitfalls:
- title: Confusing Traversal Orders
description: |
The three DFS traversals differ only in *when* you process the current node:
- **Preorder**: Root → Left → Right (process node first)
- **Inorder**: Left → Root → Right (process node in middle)
- **Postorder**: Left → Right → Root (process node last)
A common mistake is mixing up the order. Remember: "post" means "after" - process the node *after* its children.
wrong_approach: "Adding node value before recursing on children"
correct_approach: "Add node value after both recursive calls complete"
- title: Iterative Postorder is Harder
description: |
Unlike preorder (which maps directly to a stack), postorder requires extra bookkeeping. You need to know when you're "done" with a node's children before processing it.
The simplest iterative solution uses the reverse-preorder trick: do Root → Right → Left traversal, then reverse. Trying to do true iterative postorder with one stack requires tracking the "last visited" node.
wrong_approach: "Using the same pattern as iterative preorder"
correct_approach: "Use reverse preorder trick or track last visited node"
- title: Forgetting the Empty Tree Case
description: |
When `root` is `None`, you should return an empty list `[]`. Forgetting this base case causes `AttributeError` when trying to access `root.val`.
wrong_approach: "Assuming root is always valid"
correct_approach: "Check for None at the start"
key_takeaways:
- "**Traversal order matters**: Postorder (Left → Right → Root) processes children before parents - essential for bottom-up tree computations"
- "**Recursive vs Iterative**: Recursion naturally matches tree structure, but iterative solutions use explicit stacks to simulate the call stack"
- "**Reverse preorder trick**: Postorder is the reverse of modified preorder (Root → Right → Left) - a clever way to avoid complex bookkeeping"
- "**Foundation for tree problems**: Understanding all three DFS traversals (pre/in/post) is essential for tree manipulation problems"
time_complexity: "O(n). Each node is visited exactly once, where `n` is the number of nodes in the tree."
space_complexity: "O(h) for recursion or O(n) for iterative. The recursive call stack uses space proportional to tree height `h`. In the worst case (skewed tree), `h = n`. The iterative approach uses O(n) for the output list and O(h) for the stack."
solutions:
- approach_name: Recursive DFS
is_optimal: true
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def postorder_traversal(root: TreeNode | None) -> list[int]:
result = []
def dfs(node: TreeNode | None) -> None:
if node is None:
return
# Postorder: Left -> Right -> Root
dfs(node.left) # Process entire left subtree first
dfs(node.right) # Then process entire right subtree
result.append(node.val) # Finally, add current node
dfs(root)
return result
explanation: |
**Time Complexity:** O(n) — Visit each node exactly once.
**Space Complexity:** O(h) — Recursion stack depth equals tree height. Worst case O(n) for skewed tree, O(log n) for balanced tree.
The recursive solution directly implements the postorder definition: process left subtree, then right subtree, then current node. The call stack implicitly handles the order.
- approach_name: Iterative (Reverse Preorder)
is_optimal: true
code: |
def postorder_traversal(root: TreeNode | None) -> list[int]:
if root is None:
return []
result = []
stack = [root]
while stack:
node = stack.pop()
result.append(node.val)
# Push left first, then right
# So right is popped first -> gives Root, Right, Left order
if node.left:
stack.append(node.left)
if node.right:
stack.append(node.right)
# Reverse to get Left, Right, Root (postorder)
return result[::-1]
explanation: |
**Time Complexity:** O(n) — Visit each node once, plus O(n) for reversal.
**Space Complexity:** O(n) — Stack holds at most O(h) nodes, result list holds n values.
This approach exploits a clever observation: postorder (L→R→Root) is the reverse of modified preorder (Root→R→L). We do the modified preorder using a stack, then reverse the result.
- approach_name: Iterative (Single Stack with Tracking)
is_optimal: false
code: |
def postorder_traversal(root: TreeNode | None) -> list[int]:
if root is None:
return []
result = []
stack = []
current = root
last_visited = None
while stack or current:
# Go as far left as possible
while current:
stack.append(current)
current = current.left
# Peek at the top node
node = stack[-1]
# If right child exists and we haven't visited it yet
if node.right and node.right != last_visited:
current = node.right
else:
# Process the node - both children are done
result.append(node.val)
last_visited = stack.pop()
return result
explanation: |
**Time Complexity:** O(n) — Each node is pushed and popped once.
**Space Complexity:** O(h) — Stack depth equals tree height.
This is the "true" iterative postorder without reversing. We track the last visited node to know whether we're returning from the left or right child. Only process a node when both its children are done.

View File

@@ -0,0 +1,235 @@
title: Binary Tree Preorder Traversal
slug: binary-tree-preorder-traversal
difficulty: easy
leetcode_id: 144
leetcode_url: https://leetcode.com/problems/binary-tree-preorder-traversal/
categories:
- trees
- stack
- recursion
patterns:
- tree-traversal
- dfs
description: |
Given the `root` of a binary tree, return *the preorder traversal of its nodes' values*.
**Preorder traversal** visits nodes in the order: **root**, then **left subtree**, then **right subtree**.
This is a depth-first traversal where we process the current node before exploring its children.
constraints: |
- The number of nodes in the tree is in the range `[0, 100]`
- `-100 <= Node.val <= 100`
examples:
- input: "root = [1,null,2,3]"
output: "[1,2,3]"
explanation: "The tree has root 1, with right child 2, which has left child 3. Preorder visits: 1 (root first), then no left child, then 2, then 3 (left of 2)."
- input: "root = [1,2,3,4,5,null,8,null,null,6,7,9]"
output: "[1,2,4,5,6,7,3,8,9]"
explanation: "Full traversal of a complex tree. Visit root 1, then fully traverse left subtree (2,4,5,6,7), then right subtree (3,8,9)."
- input: "root = []"
output: "[]"
explanation: "An empty tree returns an empty list."
- input: "root = [1]"
output: "[1]"
explanation: "A single node tree returns just that node's value."
explanation:
intuition: |
Think of preorder traversal like exploring a cave system where you mark each chamber as soon as you enter it, before exploring any passages leading deeper.
Imagine you're a tour guide walking through a tree structure. Your rule is simple: **announce the current room immediately**, then explore all the rooms on the left, and finally explore all the rooms on the right.
The key insight is that "pre" in preorder means we process the node **before** (pre) its children. This makes preorder intuitive for tasks like:
- **Copying a tree**: You need to create a node before creating its children
- **Prefix expression evaluation**: Operators come before operands
- **Serialising a tree**: The root-first order makes deserialisation straightforward
Like all depth-first traversals, this recursive pattern can be converted to an iterative approach using a **stack**. The stack lets us remember which nodes still have unexplored right children after we've gone left.
approach: |
We present two approaches: **Recursive** (elegant and intuitive) and **Iterative** (uses explicit stack).
**Recursive Approach:**
**Step 1: Define the base case**
- If the current node is `None`, return (nothing to process)
&nbsp;
**Step 2: Apply the preorder pattern**
- Add the current node's value to the result (visit first!)
- Recursively traverse the left subtree
- Recursively traverse the right subtree
&nbsp;
**Iterative Approach:**
**Step 1: Initialise data structures**
- `result`: Empty list to store the traversal order
- `stack`: Initialise with the root node (if it exists)
&nbsp;
**Step 2: Process nodes using the stack**
- While the stack is not empty:
- Pop a node from the stack
- Add its value to the result (this is the "visit")
- Push the **right child first**, then the **left child** (so left is processed first due to LIFO)
&nbsp;
**Step 3: Return the result**
- The `result` list now contains values in preorder sequence
&nbsp;
The iterative approach pushes right before left because a stack is LIFO (Last In, First Out). Since we want to process left before right, we push right first so that left ends up on top.
common_pitfalls:
- title: Confusing Traversal Orders
description: |
There are three depth-first traversals, and mixing them up is common:
- **Preorder**: root, left, right (process node **pre**-children)
- **Inorder**: left, root, right (process node **in** the middle)
- **Postorder**: left, right, root (process node **post**-children)
For preorder, remember "**pre** means before" — visit the node **before** visiting children.
wrong_approach: "Visiting children before the current node"
correct_approach: "Always visit current node first, then left, then right"
- title: Wrong Stack Order in Iterative Solution
description: |
In the iterative approach, a critical mistake is pushing left child before right child onto the stack.
Since a stack is LIFO (Last In, First Out), the last item pushed is the first item popped. If we want to process left before right, we must push right first!
- Push right child → Push left child → Left gets popped first ✓
- Push left child → Push right child → Right gets popped first ✗
wrong_approach: "Pushing left child before right child"
correct_approach: "Push right child first, then left child"
- title: Forgetting to Handle Empty Trees
description: |
An empty tree (root is `None`) should return an empty list `[]`, not cause an error.
In the iterative approach, check if root is `None` before adding to the stack, or handle the empty stack case gracefully.
wrong_approach: "Assuming root always exists"
correct_approach: "Handle None root as base case returning empty list"
key_takeaways:
- "**Preorder = root-left-right**: The 'pre' prefix helps remember that the root is visited before (pre) its children"
- "**Stack order matters**: In iterative preorder, push right before left so that left is processed first (LIFO principle)"
- "**Tree copying pattern**: Preorder is ideal for tasks where you need to process a node before its children, like cloning a tree"
- "**Foundation problem**: Understanding preorder helps with tree serialisation, expression trees, and many recursive tree algorithms"
time_complexity: "O(n). We visit each of the `n` nodes exactly once."
space_complexity: "O(h) where `h` is the height of the tree. In the worst case (skewed tree), `h = n`, so O(n). For a balanced tree, O(log n)."
solutions:
- approach_name: Recursive
is_optimal: true
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def preorder_traversal(root: TreeNode | None) -> list[int]:
result = []
def preorder(node: TreeNode | None) -> None:
# Base case: empty node
if node is None:
return
# Visit current node first (preorder!)
result.append(node.val)
# Then traverse left subtree
preorder(node.left)
# Finally traverse right subtree
preorder(node.right)
preorder(root)
return result
explanation: |
**Time Complexity:** O(n) — Each node is visited exactly once.
**Space Complexity:** O(h) — Recursive call stack depth equals tree height. Worst case O(n) for skewed tree, O(log n) for balanced tree.
The recursive approach directly expresses the preorder definition: visit node, traverse left, traverse right. The order of these three lines determines the traversal type.
- approach_name: Iterative with Stack
is_optimal: true
code: |
def preorder_traversal(root: TreeNode | None) -> list[int]:
if root is None:
return []
result = []
stack = [root]
while stack:
# Pop and visit the current node
node = stack.pop()
result.append(node.val)
# Push right first so left is processed first (LIFO)
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)
return result
explanation: |
**Time Complexity:** O(n) — Each node is pushed and popped exactly once.
**Space Complexity:** O(h) — Stack holds at most `h` nodes (the height of the tree).
This iterative version uses a stack to simulate recursion. The key insight is pushing right before left — since stacks are LIFO, this ensures left children are processed before right children. This is the cleanest iterative preorder implementation.
- approach_name: Morris Traversal
is_optimal: false
code: |
def preorder_traversal(root: TreeNode | None) -> list[int]:
result = []
current = root
while current:
if current.left is None:
# No left subtree, visit and go right
result.append(current.val)
current = current.right
else:
# Find inorder predecessor (rightmost in left subtree)
predecessor = current.left
while predecessor.right and predecessor.right != current:
predecessor = predecessor.right
if predecessor.right is None:
# First visit: add to result, create thread, go left
result.append(current.val)
predecessor.right = current
current = current.left
else:
# Thread exists, we've returned; remove thread and go right
predecessor.right = None
current = current.right
return result
explanation: |
**Time Complexity:** O(n) — Each edge is traversed at most twice.
**Space Complexity:** O(1) — No stack or recursion; uses tree structure itself for navigation.
Morris traversal achieves O(1) space by temporarily modifying the tree (creating "threads" from predecessors back to current node). Unlike inorder Morris where we visit after removing the thread, in preorder we visit when first encountering the node (before creating the thread). More complex but useful when space is critical.

View File

@@ -0,0 +1,219 @@
title: Binary Tree Pruning
slug: binary-tree-pruning
difficulty: medium
leetcode_id: 814
leetcode_url: https://leetcode.com/problems/binary-tree-pruning/
categories:
- trees
- recursion
patterns:
- dfs
- tree-traversal
description: |
Given the `root` of a binary tree, return *the same tree where every subtree (of the given tree) not containing a* `1` *has been removed*.
A subtree of a node `node` is `node` plus every node that is a descendant of `node`.
constraints: |
- `1 <= number of nodes <= 200`
- `Node.val` is either `0` or `1`
examples:
- input: "root = [1,null,0,0,1]"
output: "[1,null,0,null,1]"
explanation: "Only nodes that are part of a subtree containing a 1 are kept. The left child of the 0 node (which was 0) is removed since that subtree contains no 1s."
- input: "root = [1,0,1,0,0,0,1]"
output: "[1,null,1,null,1]"
explanation: "The entire left subtree of the root (rooted at 0) is removed because it contains no 1s. The left subtree of the right child (also 0) is removed for the same reason."
- input: "root = [1,1,0,1,1,0,1,0]"
output: "[1,1,0,1,1,null,1]"
explanation: "Most of the tree is preserved since there are 1s throughout. Only the leftmost leaf (0) and one subtree containing only 0s are pruned."
explanation:
intuition: |
Imagine you're a gardener pruning a tree. You need to remove all branches that don't bear any fruit (in this case, nodes with value `1`). But here's the catch: you can only tell if a branch is fruitful by checking the entire branch, all the way down to the leaves.
The key insight is that **you must check the children before deciding about the parent**. A node should be kept if:
1. It has value `1`, OR
2. At least one of its children (after pruning) still exists
Think of it like this: start at the leaves and work your way up. A leaf node with value `0` can be removed. But a node with value `0` that has a child containing a `1` somewhere must stay — it's the path to that `1`.
This naturally leads to a **post-order traversal** (left, right, node): process children first, then decide about the current node based on what remains.
approach: |
We solve this using **Post-Order DFS (Depth-First Search)**:
**Step 1: Define the recursive function**
- The function takes a node and returns either the pruned node or `None` if the entire subtree should be removed
- Base case: if the node is `None`, return `None`
&nbsp;
**Step 2: Recursively prune children**
- First, recursively prune the left subtree: `node.left = prune(node.left)`
- Then, recursively prune the right subtree: `node.right = prune(node.right)`
- This is the "post-order" part — we handle children before the current node
&nbsp;
**Step 3: Decide whether to keep the current node**
- After pruning children, check if this node should be removed
- Remove the node (return `None`) if ALL of these are true:
- `node.val == 0` (node itself doesn't contain a 1)
- `node.left is None` (left subtree was fully pruned)
- `node.right is None` (right subtree was fully pruned)
- Otherwise, keep the node (return `node`)
&nbsp;
**Step 4: Handle the root**
- Call the recursive function on the root
- The root itself might be pruned if the entire tree contains no 1s
&nbsp;
The post-order traversal ensures we always have complete information about children before deciding about the parent.
common_pitfalls:
- title: Pre-Order Instead of Post-Order
description: |
A common mistake is trying to decide about a node before processing its children:
```python
# WRONG: checking node before children
if node.val == 0:
return None # But what if children have 1s?
```
This fails because a `0` node might be the parent of a subtree containing `1`s. You must process children first (post-order) to know if they contain any `1`s.
wrong_approach: "Check node value before recursing on children"
correct_approach: "Recurse on children first, then decide about current node"
- title: Forgetting to Update Child Pointers
description: |
After recursively pruning, you must update the parent's pointers:
```python
# WRONG: not updating pointers
prune(node.left)
prune(node.right)
# CORRECT: update the pointers
node.left = prune(node.left)
node.right = prune(node.right)
```
Without updating pointers, the tree structure isn't actually modified — pruned subtrees remain connected.
wrong_approach: "Call recursive function without assigning result"
correct_approach: "Assign recursive result back to node.left/node.right"
- title: Not Handling the Root Being Pruned
description: |
The root itself might need to be removed if the entire tree contains no `1`s:
```python
# Example: root = [0, 0, 0]
# After pruning, the entire tree should become None
```
Make sure your solution can return `None` for the root, not just for subtrees. The recursive structure naturally handles this if implemented correctly.
key_takeaways:
- "**Post-order traversal pattern**: When a decision depends on children's state, process children first (left → right → node)"
- "**Recursive tree modification**: Update child pointers with the result of recursive calls to actually modify the tree structure"
- "**Condition for keeping a node**: A node survives if it has value `1` OR has at least one surviving child"
- "**Foundation for tree problems**: This pattern of 'decide based on children' appears in many tree problems like finding tree diameter, checking balance, or computing heights"
time_complexity: "O(n). We visit each node exactly once during the DFS traversal, where `n` is the number of nodes in the tree."
space_complexity: "O(h). The recursion stack can grow up to `h` frames deep, where `h` is the height of the tree. In the worst case (skewed tree), `h = n`. For a balanced tree, `h = log(n)`."
solutions:
- approach_name: Post-Order DFS
is_optimal: true
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def prune_tree(root: TreeNode | None) -> TreeNode | None:
# Base case: empty tree
if root is None:
return None
# Post-order: process children first
# Update left subtree with pruned result
root.left = prune_tree(root.left)
# Update right subtree with pruned result
root.right = prune_tree(root.right)
# Now decide about current node
# Remove if: value is 0 AND both children are gone
if root.val == 0 and root.left is None and root.right is None:
return None
# Keep this node
return root
explanation: |
**Time Complexity:** O(n) — We visit each node exactly once.
**Space Complexity:** O(h) — Recursion stack depth equals tree height.
The post-order traversal guarantees that when we examine a node, both its children have already been pruned. This gives us complete information to decide: if the node is `0` and has no remaining children, it can be safely removed.
- approach_name: Iterative with Parent Tracking
is_optimal: false
code: |
def prune_tree(root: TreeNode | None) -> TreeNode | None:
if root is None:
return None
# Use stack for post-order traversal
# Store (node, parent, is_left_child, visited_children)
stack = [(root, None, None, False)]
while stack:
node, parent, is_left, visited = stack[-1]
if not visited:
# First visit: mark as visited, add children
stack[-1] = (node, parent, is_left, True)
if node.right:
stack.append((node.right, node, False, False))
if node.left:
stack.append((node.left, node, True, False))
else:
# Second visit: children processed, decide about this node
stack.pop()
# Should this node be pruned?
should_prune = (
node.val == 0 and
node.left is None and
node.right is None
)
if should_prune and parent:
# Remove from parent
if is_left:
parent.left = None
else:
parent.right = None
elif should_prune and parent is None:
# Root should be pruned
return None
return root
explanation: |
**Time Complexity:** O(n) — Each node is pushed and popped from the stack once.
**Space Complexity:** O(n) — Stack can hold all nodes in worst case.
This iterative approach simulates post-order traversal using a stack with parent tracking. While it avoids recursion, it's more complex and uses more space due to storing parent references. The recursive solution is preferred for its clarity.

View File

@@ -0,0 +1,194 @@
title: Binary Tree Right Side View
slug: binary-tree-right-side-view
difficulty: medium
leetcode_id: 199
leetcode_url: https://leetcode.com/problems/binary-tree-right-side-view/
categories:
- trees
patterns:
- bfs
- dfs
- tree-traversal
description: |
Given the `root` of a binary tree, imagine yourself standing on the **right side** of it, return *the values of the nodes you can see ordered from top to bottom*.
constraints: |
- The number of nodes in the tree is in the range `[0, 100]`
- `-100 <= Node.val <= 100`
examples:
- input: "root = [1,2,3,null,5,null,4]"
output: "[1,3,4]"
explanation: "Standing on the right side, you see node 1 at level 0, node 3 at level 1 (it's rightmost), and node 4 at level 2 (it's rightmost even though it's a child of node 3)."
- input: "root = [1,2,3,4,null,null,null,5]"
output: "[1,3,4,5]"
explanation: "At each level, you see the rightmost node: 1, then 3, then 4 (left child of 2, but rightmost at that level since 3 has no children at that depth), then 5."
- input: "root = [1,null,3]"
output: "[1,3]"
explanation: "The tree only has right children, so you see both nodes from the right side."
- input: "root = []"
output: "[]"
explanation: "An empty tree has no nodes to see."
explanation:
intuition: |
Imagine you're standing to the right of a building where each floor has rooms arranged left to right. From your vantage point, you can only see the **rightmost room on each floor** — the other rooms are hidden behind it.
A binary tree works similarly when viewed from the right side. At each *level* (or depth) of the tree, only the **rightmost node** is visible. All other nodes at that level are obscured.
The key insight is that "right side view" means collecting **one node per level** — specifically, the last node encountered when traversing that level from left to right. This naturally suggests a **level-order traversal** (BFS) where we process nodes level by level and remember the last node at each level.
Alternatively, with DFS, we can traverse the tree visiting the right subtree before the left. The first node we encounter at each new depth becomes the visible node from the right.
approach: |
We solve this using a **Level-Order Traversal (BFS)** approach:
**Step 1: Handle edge case**
- If `root` is `None`, return an empty list — there are no nodes to see
&nbsp;
**Step 2: Initialise data structures**
- `result`: Empty list to store the right side view
- `queue`: Initialise with the root node for BFS traversal
&nbsp;
**Step 3: Process level by level**
- While the queue is not empty, determine the number of nodes at the current level (`level_size`)
- Iterate through all nodes at this level
- For each node, add its children (left then right) to the queue for the next level
- The **last node** processed in each level is the rightmost — add its value to the result
&nbsp;
**Step 4: Return the result**
- After processing all levels, `result` contains the right side view from top to bottom
&nbsp;
The BFS approach naturally processes nodes left-to-right at each level, so the last node we see at each level is exactly what we'd see from the right side.
common_pitfalls:
- title: Confusing Right Side View with Right Children Only
description: |
A common mistake is thinking the right side view only includes nodes that are right children. This is incorrect.
Consider a tree where node 2 is a left child but has no sibling on the right at its level — node 2 would be visible from the right side. For example, in `[1,2,null]`, both 1 and 2 are visible from the right even though 2 is a left child.
The right side view includes the **rightmost node at each level**, regardless of whether it's a left or right child.
wrong_approach: "Only collecting right children"
correct_approach: "Collecting the last node at each level via BFS"
- title: Not Processing Full Levels in BFS
description: |
When using BFS, you must process **all nodes at a level** before moving to the next level. A common bug is to simply take the last element from the queue without tracking level boundaries.
The fix is to record `level_size = len(queue)` at the start of each level and iterate exactly that many times. This ensures you correctly identify where each level ends.
wrong_approach: "Processing queue without tracking level boundaries"
correct_approach: "Using level_size to process exactly one level at a time"
- title: Wrong DFS Order
description: |
When using DFS for this problem, you must visit the **right subtree before the left**. If you visit left first, the first node you see at each depth will be the leftmost, not the rightmost.
The DFS approach works by recording the first node encountered at each new depth — so visiting right-first ensures we see the right side view.
wrong_approach: "DFS visiting left subtree before right"
correct_approach: "DFS visiting right subtree before left"
key_takeaways:
- "**Level-order traversal** (BFS) is ideal when you need to process nodes level by level or find the first/last node at each depth"
- "**Right side view = rightmost at each level**, not just right children — understanding this distinction is crucial"
- "**DFS alternative**: Visit right-before-left and track depth to achieve the same result with O(h) space instead of O(w)"
- "This pattern extends to **left side view** (take first node per level) and other level-based tree problems"
time_complexity: "O(n). We visit each node exactly once during the BFS traversal."
space_complexity: "O(w) where w is the maximum width of the tree. In the worst case (a complete binary tree), the last level can have up to n/2 nodes, so this is O(n) in the worst case."
solutions:
- approach_name: BFS Level-Order Traversal
is_optimal: true
code: |
from collections import deque
from typing import Optional
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def right_side_view(root: Optional[TreeNode]) -> list[int]:
# Edge case: empty tree
if not root:
return []
result = []
queue = deque([root])
while queue:
# Number of nodes at current level
level_size = len(queue)
for i in range(level_size):
node = queue.popleft()
# Add children for next level (left before right)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
# Last node in level is the rightmost (visible from right)
if i == level_size - 1:
result.append(node.val)
return result
explanation: |
**Time Complexity:** O(n) — Every node is visited exactly once.
**Space Complexity:** O(w) — The queue holds at most one level of nodes. For a complete binary tree, the widest level has ~n/2 nodes.
We use BFS to traverse level by level. At each level, we process all nodes left-to-right, adding their children to the queue. The last node we process at each level is the rightmost, which is what we'd see from the right side.
- approach_name: DFS Right-First Traversal
is_optimal: false
code: |
from typing import Optional
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def right_side_view(root: Optional[TreeNode]) -> list[int]:
result = []
def dfs(node: Optional[TreeNode], depth: int) -> None:
if not node:
return
# First time reaching this depth? This node is visible from right
if depth == len(result):
result.append(node.val)
# Visit right subtree first to see rightmost nodes first
dfs(node.right, depth + 1)
dfs(node.left, depth + 1)
dfs(root, 0)
return result
explanation: |
**Time Complexity:** O(n) — Every node is visited exactly once.
**Space Complexity:** O(h) — The recursion stack uses space proportional to the tree height. For a balanced tree h = log(n), for a skewed tree h = n.
We use DFS, visiting the right subtree before the left. The first time we reach a new depth, that node must be the rightmost at that level (since we explore right-first). We track this by comparing `depth` with `len(result)`.
This approach uses less space than BFS for tall, narrow trees but more space for short, wide trees.

View File

@@ -0,0 +1,185 @@
title: Binary Tree Tilt
slug: binary-tree-tilt
difficulty: easy
leetcode_id: 563
leetcode_url: https://leetcode.com/problems/binary-tree-tilt/
categories:
- trees
- recursion
patterns:
- dfs
- tree-traversal
description: |
Given the `root` of a binary tree, return *the sum of every tree node's **tilt***.
The **tilt** of a tree node is the **absolute difference** between the sum of all left subtree node values and all right subtree node values. If a node does not have a left child, then the sum of the left subtree node values is treated as `0`. The rule is similar if the node does not have a right child.
constraints: |
- The number of nodes in the tree is in the range `[0, 10^4]`
- `-1000 <= Node.val <= 1000`
examples:
- input: "root = [1,2,3]"
output: "1"
explanation: "Tilt of node 2: |0-0| = 0 (no children). Tilt of node 3: |0-0| = 0 (no children). Tilt of node 1: |2-3| = 1 (left subtree sum is 2, right subtree sum is 3). Total tilt: 0 + 0 + 1 = 1."
- input: "root = [4,2,9,3,5,null,7]"
output: "15"
explanation: "Tilt of leaves (3, 5, 7): 0 each. Tilt of node 2: |3-5| = 2. Tilt of node 9: |0-7| = 7. Tilt of node 4: |10-16| = 6. Total: 0 + 0 + 0 + 2 + 7 + 6 = 15."
- input: "root = [21,7,14,1,1,2,2,3,3]"
output: "9"
explanation: "Sum the absolute differences between left and right subtree sums for each node."
explanation:
intuition: |
Imagine each node in the tree as a balance scale. On the left pan sits the total weight (sum) of all nodes in the left subtree, and on the right pan sits the total weight of the right subtree. The *tilt* is how much the scale tips — the absolute difference between these two weights.
Think of it like this: we need to visit every node and measure how "unbalanced" it is. But here's the insight — to calculate a node's tilt, we need to know the sum of its entire left subtree and its entire right subtree. This naturally suggests a **bottom-up approach**: calculate the sums from the leaves up, because a parent's subtree sum depends on its children's subtree sums.
The recursive pattern emerges: for each node, we recursively get the total sum of the left subtree and the right subtree. The tilt at this node is `|left_sum - right_sum|`. We accumulate all tilts as we traverse, and the node returns its own value plus both subtree sums (so its parent can use it).
This is a classic example of **postorder traversal** — we process children before the parent because we need the children's results to compute the parent's answer.
approach: |
We solve this using **DFS with Postorder Traversal**:
**Step 1: Set up a variable to accumulate total tilt**
- Use a variable (or class attribute) `total_tilt` initialised to `0`
- This accumulates the tilt of every node as we traverse
&nbsp;
**Step 2: Define a recursive helper function**
- The helper takes a node and returns the **sum of all values in that subtree**
- Base case: if node is `None`, return `0` (empty subtree has sum 0)
&nbsp;
**Step 3: Recursively compute subtree sums**
- Recursively call the helper on `node.left` to get `left_sum`
- Recursively call the helper on `node.right` to get `right_sum`
- This is postorder: we process children before doing work at the current node
&nbsp;
**Step 4: Calculate tilt and accumulate**
- Compute this node's tilt: `|left_sum - right_sum|`
- Add it to `total_tilt`
&nbsp;
**Step 5: Return the subtree sum**
- Return `node.val + left_sum + right_sum`
- This gives the parent node everything it needs to calculate its own tilt
&nbsp;
After the traversal completes, `total_tilt` contains the answer.
common_pitfalls:
- title: Confusing Tilt with Subtree Sum
description: |
The tilt at a node is NOT the sum of its subtree — it's the **absolute difference** between left and right subtree sums.
A common mistake is returning the tilt instead of the subtree sum from the recursive function. Remember: the function returns subtree sum (so parents can use it), but we **accumulate** tilt separately.
wrong_approach: "Returning tilt from recursive function"
correct_approach: "Return subtree sum; accumulate tilt in a separate variable"
- title: Forgetting to Include the Node's Own Value
description: |
When returning the subtree sum, you must include the current node's value: `node.val + left_sum + right_sum`.
If you only return `left_sum + right_sum`, the parent won't get the correct subtree sum, and its tilt calculation will be wrong.
wrong_approach: "return left_sum + right_sum"
correct_approach: "return node.val + left_sum + right_sum"
- title: Using Preorder Instead of Postorder
description: |
You cannot calculate a node's tilt before knowing its subtree sums. If you try to process the node before its children (preorder), you won't have the information you need.
The solution requires **postorder**: left subtree → right subtree → current node.
wrong_approach: "Process node before recursing into children"
correct_approach: "Recurse into children first, then calculate tilt"
key_takeaways:
- "**Postorder for aggregation**: When a node's answer depends on its children's results, use postorder traversal (process children before parent)"
- "**Dual-purpose recursion**: The recursive function returns subtree sum (for parent's use) while accumulating tilt (for the final answer)"
- "**Bottom-up thinking**: Many tree problems become clearer when you think from leaves up to root"
- "**Pattern recognition**: This same technique applies to problems like finding unbalanced nodes, calculating tree diameter, or validating BST properties"
time_complexity: "O(n). We visit each node exactly once, performing O(1) work at each node."
space_complexity: "O(h) where h is the tree height. The recursion stack depth equals the tree height — O(log n) for balanced trees, O(n) for skewed trees."
solutions:
- approach_name: DFS Postorder Traversal
is_optimal: true
code: |
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def find_tilt(root: TreeNode | None) -> int:
total_tilt = 0
def subtree_sum(node: TreeNode | None) -> int:
nonlocal total_tilt
# Base case: empty subtree has sum 0
if not node:
return 0
# Postorder: get children's sums first
left_sum = subtree_sum(node.left)
right_sum = subtree_sum(node.right)
# Calculate and accumulate this node's tilt
total_tilt += abs(left_sum - right_sum)
# Return total sum of this subtree for parent's use
return node.val + left_sum + right_sum
subtree_sum(root)
return total_tilt
explanation: |
**Time Complexity:** O(n) — Visit each node once.
**Space Complexity:** O(h) — Recursion stack depth equals tree height.
We use postorder DFS where each call returns the subtree sum while accumulating tilt. The `nonlocal` keyword lets the inner function modify `total_tilt`. After traversing all nodes, we have the sum of all tilts.
- approach_name: DFS with Class Variable
is_optimal: false
code: |
class Solution:
def find_tilt(self, root: TreeNode | None) -> int:
self.total_tilt = 0
def dfs(node: TreeNode | None) -> int:
if not node:
return 0
# Get subtree sums from children
left_sum = dfs(node.left)
right_sum = dfs(node.right)
# Add this node's tilt to running total
self.total_tilt += abs(left_sum - right_sum)
# Return subtree sum including this node
return node.val + left_sum + right_sum
dfs(root)
return self.total_tilt
explanation: |
**Time Complexity:** O(n) — Same as the functional approach.
**Space Complexity:** O(h) — Recursion stack depth.
This class-based version uses `self.total_tilt` instead of `nonlocal`. Some find this more readable, and it's the typical LeetCode solution format. The logic is identical to the functional approach.

View File

@@ -0,0 +1,229 @@
title: Binary Tree Zigzag Level Order Traversal
slug: binary-tree-zigzag-level-order-traversal
difficulty: medium
leetcode_id: 103
leetcode_url: https://leetcode.com/problems/binary-tree-zigzag-level-order-traversal/
categories:
- trees
- queue
patterns:
- bfs
- tree-traversal
description: |
Given the `root` of a binary tree, return *the zigzag level order traversal of its nodes' values* (i.e., from left to right, then right to left for the next level and alternate between).
constraints: |
- The number of nodes in the tree is in the range `[0, 2000]`
- `-100 <= Node.val <= 100`
examples:
- input: "root = [3,9,20,null,null,15,7]"
output: "[[3],[20,9],[15,7]]"
explanation: "Level 0 (left to right): [3]. Level 1 (right to left): [20, 9]. Level 2 (left to right): [15, 7]."
- input: "root = [1]"
output: "[[1]]"
explanation: "Single node tree returns one level with one element."
- input: "root = []"
output: "[]"
explanation: "Empty tree returns an empty list."
explanation:
intuition: |
Imagine reading a book where alternating pages are printed upside down. To read the content, you'd read one page normally (left to right), then flip the book to read the next page (effectively right to left), and keep alternating.
This problem applies the same concept to tree traversal. A standard **level order traversal** (BFS) visits nodes level by level, always left to right. The "zigzag" twist requires us to **alternate the direction** at each level: left-to-right, then right-to-left, then left-to-right again, and so on.
The core insight is that the underlying traversal is still BFS — we process nodes level by level using a queue. The only change is how we **arrange the values within each level**. We can either reverse every other level's values, or use a deque to build each level's list in the appropriate direction.
Think of it like this: BFS gives us nodes in consistent left-to-right order. We just need a simple flag to track whether the current level should be reversed, and apply that transformation before adding to the result.
approach: |
We solve this using **BFS with level direction tracking**:
**Step 1: Handle the empty tree edge case**
- If `root` is `None`, return an empty list immediately
&nbsp;
**Step 2: Initialise BFS structures**
- `queue`: A deque starting with the root node for level-order traversal
- `result`: An empty list to store the final zigzag traversal
- `left_to_right`: A boolean flag set to `True`, indicating the direction for the current level
&nbsp;
**Step 3: Process level by level**
- While the queue is not empty:
- Record the current level size (number of nodes at this level)
- Create an empty list `level` to hold values for this level
- Process all nodes at the current level:
- Dequeue each node from the front
- Append its value to `level`
- Enqueue its left child (if exists), then right child (if exists)
- If `left_to_right` is `False`, reverse the `level` list
- Append `level` to `result`
- Toggle `left_to_right` for the next level
&nbsp;
**Step 4: Return the result**
- Return `result` containing all levels in zigzag order
&nbsp;
The key is that we always traverse children left-to-right in the queue, but we reverse the values of alternate levels before adding them to the result. This keeps the BFS logic clean and simple.
common_pitfalls:
- title: Modifying Queue Order Instead of Output
description: |
A tempting approach is to change the order of adding children to the queue based on direction — adding right child first for even levels, left child first for odd levels.
This **breaks the traversal** for subsequent levels. If you add children in reverse order, the next level receives nodes in the wrong sequence, causing cascading errors.
The correct approach keeps queue insertion order consistent (always left then right) and only reverses the output values for alternate levels.
wrong_approach: "Alternate which child gets enqueued first"
correct_approach: "Always enqueue left then right, reverse output on alternate levels"
- title: Using a Stack Instead of Reversing
description: |
Some solutions try using a stack for zigzag levels. While this can work, it significantly complicates the code and is prone to off-by-one errors in tracking which structure to use.
Reversing a level list in Python is O(k) where k is the level size, and since we visit each node exactly once overall, this doesn't change the O(n) time complexity. The simpler approach is almost always better.
- title: Forgetting the Empty Tree Case
description: |
If `root` is `None`, attempting to add it to the queue and process it will cause errors. Always check for an empty tree at the start and return `[]` immediately.
key_takeaways:
- "**BFS for level-order**: Use a queue to process nodes level by level, tracking level boundaries with the queue's size"
- "**Separate traversal from transformation**: Keep the core BFS logic clean; apply direction changes only to the output"
- "**Flag-based alternation**: A simple boolean toggle handles the zigzag pattern elegantly"
- "**Foundation for variants**: This pattern extends to spiral matrix traversal and other alternating-direction problems"
time_complexity: "O(n). We visit each of the `n` nodes exactly once during traversal. Reversing levels takes O(n) total across all levels."
space_complexity: "O(n). The queue holds at most one level of nodes at a time (up to `n/2` in a complete binary tree), and the result stores all `n` node values."
solutions:
- approach_name: BFS with Level Reversal
is_optimal: true
code: |
from collections import deque
def zigzag_level_order(root: TreeNode | None) -> list[list[int]]:
# Handle empty tree
if not root:
return []
result = []
queue = deque([root])
left_to_right = True # First level goes left to right
while queue:
level_size = len(queue)
level = []
# Process all nodes at current level
for _ in range(level_size):
node = queue.popleft()
level.append(node.val)
# Always add children left to right
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
# Reverse if this level should go right to left
if not left_to_right:
level.reverse()
result.append(level)
left_to_right = not left_to_right # Toggle direction
return result
explanation: |
**Time Complexity:** O(n) — Each node is visited once, and reversing all levels takes O(n) total.
**Space Complexity:** O(n) — Queue holds at most one level (up to n/2 nodes), result stores all n values.
This approach uses standard BFS with a direction flag. We always process children left-to-right in the queue, but reverse the values of alternate levels before adding to the result. This keeps the code simple and avoids the complexity of managing different queue orderings.
- approach_name: BFS with Deque Insertion
is_optimal: true
code: |
from collections import deque
def zigzag_level_order(root: TreeNode | None) -> list[list[int]]:
if not root:
return []
result = []
queue = deque([root])
left_to_right = True
while queue:
level_size = len(queue)
level = deque() # Use deque to build level in correct order
for _ in range(level_size):
node = queue.popleft()
# Insert at appropriate end based on direction
if left_to_right:
level.append(node.val) # Add to right
else:
level.appendleft(node.val) # Add to left
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
result.append(list(level))
left_to_right = not left_to_right
return result
explanation: |
**Time Complexity:** O(n) — Each node visited once, deque operations are O(1).
**Space Complexity:** O(n) — Same as the reversal approach.
Instead of reversing after collecting values, this approach builds each level's list in the correct order using a deque. For left-to-right levels, we append to the right; for right-to-left levels, we prepend to the left. Both approaches are equally optimal.
- approach_name: Recursive DFS with Level Tracking
is_optimal: false
code: |
def zigzag_level_order(root: TreeNode | None) -> list[list[int]]:
result = []
def dfs(node: TreeNode | None, level: int) -> None:
if not node:
return
# Extend result if we've reached a new level
if level >= len(result):
result.append([])
# Insert based on level parity
if level % 2 == 0:
result[level].append(node.val) # Left to right
else:
result[level].insert(0, node.val) # Right to left
# Recurse on children
dfs(node.left, level + 1)
dfs(node.right, level + 1)
dfs(root, 0)
return result
explanation: |
**Time Complexity:** O(n^2) in worst case — `insert(0, val)` is O(k) for a list of size k, making odd levels costly.
**Space Complexity:** O(n) for result plus O(h) recursion stack where h is tree height.
This DFS approach tracks the current level and inserts values accordingly. While elegant, using `insert(0, val)` on a list is O(k), making this less efficient than BFS approaches for large trees. Included to show an alternative traversal strategy.

View File

@@ -0,0 +1,197 @@
title: Binary Trees With Factors
slug: binary-trees-with-factors
difficulty: medium
leetcode_id: 823
leetcode_url: https://leetcode.com/problems/binary-trees-with-factors/
categories:
- arrays
- hash-tables
- dynamic-programming
- sorting
patterns:
- dynamic-programming
description: |
Given an array of unique integers, `arr`, where each integer `arr[i]` is strictly greater than `1`.
We make a binary tree using these integers, and each number may be used for any number of times. Each non-leaf node's value should be equal to the product of the values of its children.
Return *the number of binary trees we can make*. The answer may be too large so return the answer **modulo** `10^9 + 7`.
constraints: |
- `1 <= arr.length <= 1000`
- `2 <= arr[i] <= 10^9`
- All the values of `arr` are **unique**
examples:
- input: "arr = [2, 4]"
output: "3"
explanation: "We can make these trees: [2], [4], [4, 2, 2]. Each single node counts as a tree, and 4 can be the root with two children of value 2 since 2 * 2 = 4."
- input: "arr = [2, 4, 5, 10]"
output: "7"
explanation: "We can make these trees: [2], [4], [5], [10], [4, 2, 2], [10, 2, 5], [10, 5, 2]. Note that [10, 2, 5] and [10, 5, 2] are different trees because the left and right children are swapped."
explanation:
intuition: |
Imagine building binary trees like stacking building blocks. Each node can either stand alone (a single-node tree) or become a parent by placing two children beneath it — but only if the parent's value equals the product of its children.
The key insight is that **the number of trees with a given root depends on the number of trees we can build with its potential children**. If node `x` can have children `a` and `b` (where `a * b = x`), then the number of trees rooted at `x` using this pair equals `trees(a) * trees(b)` — we can combine any tree rooted at `a` with any tree rooted at `b`.
Think of it like this: for each number in the array, ask "which pairs of numbers from my array multiply to give me this number?" For each valid pair, multiply the number of ways to build the left subtree by the number of ways to build the right subtree.
This naturally leads to a **bottom-up dynamic programming** approach. If we process numbers in sorted order (smallest to largest), when we reach a number `x`, we've already computed the tree counts for all potential children (since children must be smaller than their product).
approach: |
We solve this using **Dynamic Programming with Hash Map**:
**Step 1: Sort the array and create a lookup structure**
- Sort `arr` in ascending order so we process smaller values before larger ones
- Create a hash map `dp` where `dp[x]` represents the number of binary trees with `x` as the root
- Initially, every number can form a single-node tree, so `dp[x] = 1` for all `x`
&nbsp;
**Step 2: Build tree counts bottom-up**
- For each number `x` in sorted order:
- For each smaller number `a` in the array:
- Check if `x` is divisible by `a` (i.e., `x % a == 0`)
- Calculate `b = x / a`
- If `b` exists in our array, then `a` and `b` can be children of `x`
- Add `dp[a] * dp[b]` to `dp[x]` — this counts all ways to combine subtrees rooted at `a` and `b`
&nbsp;
**Step 3: Sum all tree counts**
- Return the sum of all `dp[x]` values, modulo `10^9 + 7`
- Each `dp[x]` counts trees rooted at `x`, so summing gives total trees
&nbsp;
The sorted order guarantees that when computing `dp[x]`, both `dp[a]` and `dp[b]` are already computed (since `a` and `b` are smaller than `x`).
common_pitfalls:
- title: Not Counting Left-Right Permutations
description: |
When `a != b` and both `a * b = x`, the trees `[x, a, b]` and `[x, b, a]` are distinct because binary trees distinguish between left and right children.
You might think we need special handling, but iterating through all `a` values naturally handles this: when `a = 2, b = 5` we count `dp[2] * dp[5]`, and when `a = 5, b = 2` we count `dp[5] * dp[2]` — both get added to `dp[10]`.
wrong_approach: "Only counting each pair once"
correct_approach: "Let the iteration naturally count both orderings"
- title: Integer Overflow
description: |
With `arr[i]` up to `10^9`, the product of two elements could exceed 32-bit integer limits. Additionally, multiplying tree counts (`dp[a] * dp[b]`) can overflow.
Apply modulo `10^9 + 7` after each multiplication, not just at the end. Use 64-bit integers or Python's arbitrary precision to avoid overflow during intermediate calculations.
wrong_approach: "Only applying modulo at the final sum"
correct_approach: "Apply modulo after each dp multiplication"
- title: Forgetting Single-Node Trees
description: |
Every element in the array can form a valid single-node binary tree, regardless of whether it can be a product of other elements.
Initialize `dp[x] = 1` for every `x` in the array before computing parent-child relationships. The final answer includes these base cases.
wrong_approach: "Only counting trees with children"
correct_approach: "Initialize dp[x] = 1 for all elements"
- title: Not Sorting First
description: |
The DP recurrence relies on children being processed before parents. If we don't sort, we might try to use `dp[a]` before it's fully computed.
For example, with `arr = [10, 2, 5]`, if we process `10` first, `dp[2]` and `dp[5]` would still be at their initial values, missing any trees built from their factors.
wrong_approach: "Processing elements in original order"
correct_approach: "Sort array so smaller elements are processed first"
key_takeaways:
- "**Counting with DP**: When counting combinations of substructures, multiply the counts — if left subtree has `L` ways and right has `R` ways, the combined tree has `L * R` ways"
- "**Hash map for O(1) lookup**: Using a hash map to check if a factor exists in the array turns an O(n) search into O(1)"
- "**Process in dependency order**: Sorting ensures we compute values for children before we need them for parents — a common DP technique"
- "**Modular arithmetic**: For problems with large answers, apply modulo after each operation to prevent overflow"
time_complexity: "O(n^2). After sorting (O(n log n)), for each of the n elements, we iterate through all smaller elements to find valid factor pairs."
space_complexity: "O(n). We store a hash map with one entry per element in the array."
solutions:
- approach_name: Dynamic Programming with Hash Map
is_optimal: true
code: |
def num_factored_binary_trees(arr: list[int]) -> int:
MOD = 10**9 + 7
# Sort so we process smaller values first
arr.sort()
# dp[x] = number of binary trees with root x
# Every element can be a single-node tree
dp = {x: 1 for x in arr}
# Convert to set for O(1) factor lookup
arr_set = set(arr)
for x in arr:
# Try every smaller element as potential left child
for a in arr:
if a >= x:
break # a must be smaller than x
# Check if x is divisible by a
if x % a == 0:
b = x // a
# If b exists in array, (a, b) can be children of x
if b in arr_set:
# Add all combinations of subtrees
dp[x] = (dp[x] + dp[a] * dp[b]) % MOD
# Sum all tree counts
return sum(dp.values()) % MOD
explanation: |
**Time Complexity:** O(n^2) — For each element, we check all smaller elements as potential factors.
**Space Complexity:** O(n) — Hash map storing tree count for each element.
We sort the array to ensure children are processed before parents. For each number, we find all pairs of factors from the array and multiply their tree counts. The hash set enables O(1) lookup for the second factor.
- approach_name: Brute Force with Recursion
is_optimal: false
code: |
def num_factored_binary_trees(arr: list[int]) -> int:
MOD = 10**9 + 7
arr_set = set(arr)
# Memoization cache
memo = {}
def count_trees(root: int) -> int:
"""Count trees that can be rooted at 'root'."""
if root in memo:
return memo[root]
# Base case: single node tree
total = 1
# Try all pairs (a, b) where a * b = root
for a in arr:
if root % a == 0:
b = root // a
if b in arr_set and a != root and b != root:
# Recursively count subtrees
total = (total + count_trees(a) * count_trees(b)) % MOD
memo[root] = total
return total
# Sum trees rooted at each element
result = 0
for x in arr:
result = (result + count_trees(x)) % MOD
return result
explanation: |
**Time Complexity:** O(n^2) with memoization — Same as the iterative approach, but uses recursion.
**Space Complexity:** O(n) — Memoization cache plus recursion stack.
This top-down approach uses recursion with memoization. For each potential root, we recursively count subtrees for all valid child pairs. While conceptually clearer, it has additional overhead from recursion and may hit stack limits for deep recursion. The iterative bottom-up approach is preferred.

View File

@@ -0,0 +1,181 @@
title: Binary Watch
slug: binary-watch
difficulty: easy
leetcode_id: 401
leetcode_url: https://leetcode.com/problems/binary-watch/
categories:
- math
- recursion
patterns:
- backtracking
description: |
A binary watch has 4 LEDs on the top to represent the hours (0-11), and 6 LEDs on the bottom to represent the minutes (0-59). Each LED represents a zero or one, with the least significant bit on the right.
For example, if 3 LEDs are on showing binary values `100` for hours and `110011` for minutes, the watch reads `"4:51"`.
Given an integer `turnedOn` which represents the number of LEDs that are currently on, return *all possible times the watch could represent*. You may return the answer in **any order**.
The hour must not contain a leading zero.
- For example, `"01:00"` is not valid. It should be `"1:00"`.
The minute must consist of two digits and may contain a leading zero.
- For example, `"10:2"` is not valid. It should be `"10:02"`.
constraints: |
- `0 <= turnedOn <= 10`
examples:
- input: "turnedOn = 1"
output: '["0:01","0:02","0:04","0:08","0:16","0:32","1:00","2:00","4:00","8:00"]'
explanation: "With exactly 1 LED on, we can represent hours 1, 2, 4, or 8 (single bits in the 4-bit hour display), or minutes 1, 2, 4, 8, 16, or 32 (single bits in the 6-bit minute display)."
- input: "turnedOn = 9"
output: "[]"
explanation: "With 9 LEDs on out of 10 total, no valid time can be formed. The maximum valid hour (11) needs 3 bits and maximum valid minute (59) needs 6 bits, but 9 bits would exceed valid ranges."
explanation:
intuition: |
Think of this problem as **distributing lit LEDs between two displays**: 4 LEDs for hours and 6 LEDs for minutes.
A binary watch is essentially two separate binary counters. The hour display uses 4 bits (representing 0-15, though only 0-11 are valid hours), and the minute display uses 6 bits (representing 0-63, though only 0-59 are valid minutes).
The key insight is that **each number's binary representation has a fixed count of 1-bits**. For example:
- `3` in binary is `11` (two 1-bits)
- `7` in binary is `111` (three 1-bits)
- `5` in binary is `101` (two 1-bits)
So if we need exactly `k` LEDs to be on, we need to find all combinations where:
- The hour value has `i` bits set (where `0 <= i <= k`)
- The minute value has `k - i` bits set
- The hour is valid (0-11) and the minute is valid (0-59)
Rather than generating combinations of which LEDs to turn on (a backtracking approach), we can simply **enumerate all valid times** and count how many bits are set in each. This turns a potentially complex combinatorial problem into a simple iteration.
approach: |
We use a **Bit Counting Enumeration** approach:
**Step 1: Enumerate all valid times**
- Loop through all possible hours: `0` to `11`
- For each hour, loop through all possible minutes: `0` to `59`
- This gives us `12 × 60 = 720` possible times to check
&nbsp;
**Step 2: Count bits for each time**
- For each (hour, minute) pair, count the number of 1-bits in both values
- Use `bin(n).count('1')` or a built-in popcount function
- If `bits_in_hour + bits_in_minute == turnedOn`, this is a valid answer
&nbsp;
**Step 3: Format and collect results**
- Format valid times as `"{hour}:{minute:02d}"` (hour without leading zero, minute with leading zero if needed)
- Add to the result list
&nbsp;
This approach is elegant because it avoids complex backtracking logic. With only 720 iterations and O(1) bit counting, it's efficient enough for this constrained problem.
common_pitfalls:
- title: Overcomplicating with Backtracking
description: |
A natural instinct is to use backtracking to choose which of the 10 LEDs to turn on. While this works, it's unnecessarily complex.
With 10 LEDs and choosing `k` of them, you'd generate C(10, k) combinations, then map each combination back to a time value and validate it.
The enumeration approach is simpler: just check all 720 valid times directly. Sometimes brute force on a small domain is the cleanest solution.
wrong_approach: "Backtracking through LED combinations"
correct_approach: "Enumerate all valid times and count bits"
- title: Forgetting Output Format Requirements
description: |
The problem has specific formatting rules:
- Hours must NOT have a leading zero: `"1:00"` not `"01:00"`
- Minutes MUST be two digits: `"1:05"` not `"1:5"`
Forgetting these will cause wrong answers even if your logic is correct. Use format specifiers like `f"{hour}:{minute:02d}"` to ensure correct padding.
wrong_approach: "Using consistent padding for both"
correct_approach: "No padding for hours, zero-padding for minutes"
- title: Invalid Hour/Minute Ranges
description: |
The 4-bit hour display can represent 0-15, but only 0-11 are valid hours. The 6-bit minute display can represent 0-63, but only 0-59 are valid minutes.
If using bit manipulation to generate values, ensure you filter out invalid ranges like hour=12 or minute=60.
wrong_approach: "Accepting all values the bits can represent"
correct_approach: "Filter to valid hour (0-11) and minute (0-59) ranges"
key_takeaways:
- "**Enumeration over generation**: When the search space is small (720 times), iterating through all possibilities is often simpler than generating combinations"
- "**Bit counting as a filter**: The `popcount` operation (counting 1-bits) is a powerful way to filter numbers by their binary properties"
- "**Problem reframing**: Reframing 'which LEDs to turn on' as 'which times have the right bit count' dramatically simplifies the solution"
- "**Format strings matter**: Pay close attention to output format requirements — they're easy to overlook but cause wrong answers"
time_complexity: "O(1). We check exactly 12 × 60 = 720 possible times, and bit counting is O(1) for fixed-size integers. The complexity is constant regardless of input."
space_complexity: "O(1). The output list size is bounded by 720 (all possible times), and we use no auxiliary data structures. Since the maximum output is constant, space is O(1)."
solutions:
- approach_name: Bit Counting Enumeration
is_optimal: true
code: |
def read_binary_watch(turned_on: int) -> list[str]:
result = []
# Enumerate all valid hours (0-11) and minutes (0-59)
for hour in range(12):
for minute in range(60):
# Count total bits set in both hour and minute
# bin(n).count('1') counts the number of 1-bits
if bin(hour).count('1') + bin(minute).count('1') == turned_on:
# Format: hour without leading zero, minute with 2 digits
result.append(f"{hour}:{minute:02d}")
return result
explanation: |
**Time Complexity:** O(1) — Fixed 720 iterations regardless of input.
**Space Complexity:** O(1) — Output bounded by constant 720 possible times.
We iterate through every valid (hour, minute) combination and use bit counting to check if the total number of lit LEDs matches `turned_on`. The formatting ensures hours have no leading zeros while minutes are always two digits.
- approach_name: Backtracking
is_optimal: false
code: |
def read_binary_watch(turned_on: int) -> list[str]:
result = []
# LED values: first 4 are hours (8,4,2,1), last 6 are minutes (32,16,8,4,2,1)
leds = [8, 4, 2, 1, 32, 16, 8, 4, 2, 1]
def backtrack(index: int, remaining: int, hour: int, minute: int):
# Base case: used all required LEDs
if remaining == 0:
if hour < 12 and minute < 60:
result.append(f"{hour}:{minute:02d}")
return
# Pruning: not enough LEDs left
if index >= len(leds) or len(leds) - index < remaining:
return
# Choice 1: turn on this LED
if index < 4:
backtrack(index + 1, remaining - 1, hour + leds[index], minute)
else:
backtrack(index + 1, remaining - 1, hour, minute + leds[index])
# Choice 2: skip this LED
backtrack(index + 1, remaining, hour, minute)
backtrack(0, turned_on, 0, 0)
return result
explanation: |
**Time Complexity:** O(2^10) — In the worst case, we explore all subsets of 10 LEDs, though pruning reduces this significantly.
**Space Complexity:** O(k) — Recursion depth is at most 10 (number of LEDs).
This approach explicitly models choosing which LEDs to turn on. We use backtracking to try including or excluding each LED, building up hour and minute values. While correct, it's more complex than the enumeration approach for this problem size.

View File

@@ -0,0 +1,185 @@
title: Bitwise AND of Numbers Range
slug: bitwise-and-of-numbers-range
difficulty: medium
leetcode_id: 201
leetcode_url: https://leetcode.com/problems/bitwise-and-of-numbers-range/
categories:
- math
patterns:
- binary-search
description: |
Given two integers `left` and `right` that represent the range `[left, right]`, return *the bitwise AND of all numbers in this range, inclusive*.
constraints: |
- `0 <= left <= right <= 2^31 - 1`
examples:
- input: "left = 5, right = 7"
output: "4"
explanation: "The bitwise AND of 5 (101), 6 (110), and 7 (111) is 4 (100)."
- input: "left = 0, right = 0"
output: "0"
explanation: "The only number in the range is 0, so the result is 0."
- input: "left = 1, right = 2147483647"
output: "0"
explanation: "The range includes both odd and even numbers spanning many bit positions. Since at least one number has a 0 in every bit position (except none share all 1s), the result is 0."
explanation:
intuition: |
Imagine writing out all numbers from `left` to `right` in binary, stacked vertically like a table. When you AND all these numbers together, any bit position that has even a single `0` in the column becomes `0` in the result.
The key insight is this: as numbers increment, the **rightmost bits flip frequently**. Every time a number increases by 1, the least significant bit toggles. Every 2 increments, the second bit toggles. And so on.
Think of it like an odometer: the rightmost digit changes fastest. If `left` and `right` differ by any amount, the lower bits will have seen both `0` and `1` values at some point in the range — meaning those bits will AND to `0`.
The only bits that survive the AND operation are the **common prefix bits** — the leftmost bits where `left` and `right` are identical. These bits never change throughout the entire range.
So the problem reduces to: **find the common prefix of `left` and `right` in binary**.
approach: |
We solve this by finding the **common binary prefix** of `left` and `right`:
**Step 1: Understand the core operation**
- We need to shift both numbers right until they become equal
- This effectively removes the differing lower bits
- Count how many shifts we perform
&nbsp;
**Step 2: Shift until equal**
- While `left != right`:
- Right-shift both `left` and `right` by 1
- Increment a shift counter
- When they're equal, we've found the common prefix
&nbsp;
**Step 3: Restore the prefix to its original position**
- Left-shift the common prefix back by the number of shifts we counted
- This gives us the answer with zeros in all the "volatile" bit positions
&nbsp;
**Alternative: Brian Kernighan's Algorithm**
- Instead of shifting both numbers, we can repeatedly clear the rightmost set bit of `right` until `right <= left`
- The expression `right & (right - 1)` clears the rightmost `1` bit
- When `right <= left`, whatever remains in `right` is the common prefix
common_pitfalls:
- title: The Brute Force Trap
description: |
The naive approach is to iterate from `left` to `right` and AND all numbers:
```python
result = left
for num in range(left + 1, right + 1):
result &= num
```
With constraints up to `2^31 - 1`, this can mean iterating over **2 billion numbers**. This will cause a Time Limit Exceeded (TLE) error.
The key realisation is that we don't need to AND every number — we just need to find where the binary representations diverge.
wrong_approach: "Iterating and ANDing all numbers in range"
correct_approach: "Find common prefix using bit shifts"
- title: Missing the Pattern in Binary
description: |
Consider `left = 5` (101) and `right = 7` (111):
```
5: 101
6: 110
7: 111
```
Notice that the two rightmost bits vary across the range. Only the leftmost bit (position 2) stays constant. The AND result is `100` = 4.
If you try to analyse this without understanding the binary pattern, you might miss that we're simply looking for where the numbers "agree" in their most significant bits.
wrong_approach: "Trying to compute AND bit by bit without seeing the prefix pattern"
correct_approach: "Recognise that only the common prefix survives"
- title: Off-by-One in Shift Count
description: |
When implementing the shift approach, ensure you're counting shifts correctly and shifting back by the same amount.
A common mistake is forgetting to shift the result back to its original position, returning just the prefix value instead of the prefix in its correct bit position.
wrong_approach: "Returning the prefix without left-shifting back"
correct_approach: "Track shift count and restore position at the end"
key_takeaways:
- "**Bit manipulation insight**: When ANDing a range of consecutive integers, only the common binary prefix survives — all differing lower bits become 0"
- "**Brian Kernighan's trick**: `n & (n - 1)` clears the rightmost set bit — useful for many bit manipulation problems"
- "**Avoid iteration**: Problems involving ranges of numbers often have mathematical shortcuts that avoid iterating through every element"
- "**Binary perspective**: Many number-range problems become clearer when you visualise the numbers in binary and look for patterns"
time_complexity: "O(log n). We perform at most 31 shifts (for 32-bit integers), as each shift reduces the number of significant bits by one."
space_complexity: "O(1). We only use a constant number of variables (`shift` counter) regardless of input size."
solutions:
- approach_name: Bit Shifting
is_optimal: true
code: |
def range_bitwise_and(left: int, right: int) -> int:
# Count how many bits we need to shift off
shift = 0
# Shift both numbers right until they're equal
# This finds the common prefix
while left < right:
left >>= 1
right >>= 1
shift += 1
# Shift the common prefix back to its original position
return left << shift
explanation: |
**Time Complexity:** O(log n) — At most 31 iterations for 32-bit integers.
**Space Complexity:** O(1) — Only one counter variable used.
We shift both numbers right until they match, counting the shifts. The remaining value is the common prefix. We then shift it back left to restore the original bit positions, filling the shifted positions with zeros.
- approach_name: Brian Kernighan's Algorithm
is_optimal: true
code: |
def range_bitwise_and(left: int, right: int) -> int:
# Keep clearing the rightmost set bit of right
# until right is <= left
while right > left:
# n & (n-1) clears the rightmost 1 bit
right = right & (right - 1)
# What remains is the common prefix
return right
explanation: |
**Time Complexity:** O(log n) — Each iteration clears one bit, at most 31 bits.
**Space Complexity:** O(1) — No additional space used.
Brian Kernighan's bit trick: `n & (n - 1)` turns off the rightmost `1` bit. We repeatedly apply this to `right` until it's no longer greater than `left`. The remaining bits are exactly the common prefix that all numbers in the range share.
- approach_name: Brute Force
is_optimal: false
code: |
def range_bitwise_and(left: int, right: int) -> int:
result = left
# AND all numbers in the range
for num in range(left + 1, right + 1):
result &= num
# Early exit if result becomes 0
if result == 0:
break
return result
explanation: |
**Time Complexity:** O(n) where n = right - left — Must iterate through entire range in worst case.
**Space Complexity:** O(1) — Only tracking the running result.
This approach directly computes the AND of all numbers. While correct, it's far too slow for large ranges. The early exit optimisation helps when the result reaches 0, but doesn't save us for ranges where the answer is non-zero. Included to illustrate why the bit manipulation approach is necessary.

View File

@@ -0,0 +1,175 @@
title: Bitwise ORs of Subarrays
slug: bitwise-ors-of-subarrays
difficulty: medium
leetcode_id: 898
leetcode_url: https://leetcode.com/problems/bitwise-ors-of-subarrays/
categories:
- arrays
- dynamic-programming
- math
patterns:
- dynamic-programming
description: |
Given an integer array `arr`, return *the number of distinct bitwise ORs of all the non-empty subarrays of* `arr`.
The bitwise OR of a subarray is the bitwise OR of each integer in the subarray. The bitwise OR of a subarray of one integer is that integer.
A **subarray** is a contiguous non-empty sequence of elements within an array.
constraints: |
- `1 <= arr.length <= 5 * 10^4`
- `0 <= arr[i] <= 10^9`
examples:
- input: "arr = [0]"
output: "1"
explanation: "There is only one possible result: 0."
- input: "arr = [1,1,2]"
output: "3"
explanation: "The possible subarrays are [1], [1], [2], [1, 1], [1, 2], [1, 1, 2]. These yield the results 1, 1, 2, 1, 3, 3. There are 3 unique values, so the answer is 3."
- input: "arr = [1,2,4]"
output: "6"
explanation: "The possible results are 1, 2, 3, 4, 6, and 7."
explanation:
intuition: |
Think of this problem like building up OR values layer by layer.
The key insight is understanding how bitwise OR behaves: once a bit is set to `1`, it stays `1` no matter what you OR it with. This means as we extend a subarray by adding more elements, the OR value can only **stay the same or increase** — it can never decrease.
Imagine you're at position `i` in the array. You want to know all possible OR values for subarrays **ending at position `i`**. These come from two sources:
- The element `arr[i]` itself (the subarray of length 1)
- Taking each OR value from subarrays ending at `i-1` and OR-ing it with `arr[i]`
Here's the clever part: even though there could be many subarrays ending at `i-1`, the number of **distinct** OR values is limited. Since OR can only add bits (never remove them), and numbers up to `10^9` have at most 30 bits, there can be at most 30 distinct OR values for subarrays ending at any position.
This transforms what seems like an O(n^2) problem into something much more efficient.
approach: |
We solve this using a **Dynamic Set Approach**:
**Step 1: Initialise data structures**
- `result`: A set to collect all distinct OR values we find across all subarrays
- `prev`: A set to track distinct OR values of subarrays ending at the previous position (starts empty)
&nbsp;
**Step 2: Iterate through each element**
- For each element `num` in the array:
- Create a new set `curr` for OR values of subarrays ending at this position
- Add `num` itself (the single-element subarray)
- For each OR value `v` in `prev`, compute `v | num` and add to `curr`
- This gives us all distinct OR values for subarrays ending at the current position
&nbsp;
**Step 3: Update tracking sets**
- Add all values from `curr` to our global `result` set
- Set `prev = curr` for the next iteration
- The size of `curr` is bounded by the number of bits (at most 30), keeping this efficient
&nbsp;
**Step 4: Return the result**
- Return the size of `result`, which contains all distinct OR values from all subarrays
common_pitfalls:
- title: The Brute Force Trap
description: |
The naive approach generates all O(n^2) subarrays and computes each OR:
- Outer loop for start index
- Inner loop for end index
- Compute OR for each subarray
With `n = 5 * 10^4`, this means up to 2.5 billion operations, causing **Time Limit Exceeded**.
The insight that saves us is recognizing that distinct OR values are bounded by the bit width, not by the number of subarrays.
wrong_approach: "Generate all subarrays and compute OR for each"
correct_approach: "Track only distinct OR values ending at each position"
- title: Missing the Monotonicity of OR
description: |
A common oversight is not realizing that OR values can only grow (or stay same) as subarrays extend.
For example: `1 | 2 = 3`, `3 | 4 = 7`, `7 | 8 = 15`. Each step either adds new bits or keeps existing ones.
This monotonicity means that for subarrays ending at position `i`, there are at most `log(max_value) + 1` distinct values (about 30 for values up to `10^9`).
Understanding this is crucial for seeing why the "set of sets" approach is efficient.
wrong_approach: "Assuming O(n) distinct values per position"
correct_approach: "Recognizing at most O(log(max_value)) distinct values per position"
- title: Using a List Instead of a Set
description: |
If you track OR values in a list instead of a set, you'll get duplicate values that slow down the algorithm.
For `arr = [1, 1, 1, 1]`, using lists would accumulate values like: `[1]`, `[1, 1]`, `[1, 1, 1]`, etc.
Using sets automatically deduplicates, keeping the working set small.
key_takeaways:
- "**Bitwise OR monotonicity**: OR can only set bits, never clear them, so values only grow as subarrays extend"
- "**Bounded distinct values**: For numbers with `b` bits, at most `b` distinct OR values exist for subarrays ending at any position"
- "**Dynamic set pattern**: Track distinct values at each step instead of all subarrays — useful for many bit manipulation problems"
- "**Related problems**: Similar techniques apply to Bitwise AND of subarrays, maximum XOR of subarrays, and other aggregate operations"
time_complexity: "O(n * log(max_value)). For each of the `n` elements, we process at most `log(max_value)` distinct OR values (about 30 for values up to `10^9`)."
space_complexity: "O(n * log(max_value)). The result set can contain at most `n * log(max_value)` distinct values, though in practice it's often much smaller."
solutions:
- approach_name: Dynamic Set
is_optimal: true
code: |
def subarray_bitwise_ors(arr: list[int]) -> int:
# Collect all distinct OR values across all subarrays
result = set()
# Track distinct OR values of subarrays ending at previous position
prev = set()
for num in arr:
# OR values for subarrays ending at current position:
# 1. The element itself (single-element subarray)
# 2. Each previous OR value combined with current element
curr = {num} | {v | num for v in prev}
# Add current position's values to global result
result |= curr
# Current becomes previous for next iteration
prev = curr
return len(result)
explanation: |
**Time Complexity:** O(n * log(max_value)) — For each element, we process at most 30 distinct OR values.
**Space Complexity:** O(n * log(max_value)) — The result set stores all distinct OR values.
This approach exploits the key insight that bitwise OR is monotonically increasing. As we extend subarrays, OR values can only grow or stay the same. Since values are bounded by `10^9` (about 30 bits), there are at most 30 distinct OR values for subarrays ending at any position.
- approach_name: Brute Force
is_optimal: false
code: |
def subarray_bitwise_ors(arr: list[int]) -> int:
result = set()
n = len(arr)
# Try every possible subarray
for i in range(n):
or_value = 0
# Extend subarray from position i
for j in range(i, n):
# Accumulate OR as we extend
or_value |= arr[j]
result.add(or_value)
return len(result)
explanation: |
**Time Complexity:** O(n^2) — Nested loops to consider all subarrays.
**Space Complexity:** O(n * log(max_value)) — Result set stores distinct OR values.
This approach is conceptually simple but too slow for large inputs. It generates all O(n^2) subarrays and computes their OR values. While we optimize slightly by accumulating OR as we extend (rather than recomputing), it still times out for `n = 5 * 10^4`. Included to illustrate why the dynamic set approach is necessary.

View File

@@ -0,0 +1,183 @@
title: Bitwise XOR of All Pairings
slug: bitwise-xor-of-all-pairings
difficulty: medium
leetcode_id: 2425
leetcode_url: https://leetcode.com/problems/bitwise-xor-of-all-pairings/
categories:
- arrays
- math
patterns:
- greedy
description: |
You are given two **0-indexed** arrays, `nums1` and `nums2`, consisting of non-negative integers.
Let there be another array, `nums3`, which contains the bitwise XOR of **all pairings** of integers between `nums1` and `nums2` (every integer in `nums1` is paired with every integer in `nums2` **exactly once**).
Return *the **bitwise XOR** of all integers in* `nums3`.
constraints: |
- `1 <= nums1.length, nums2.length <= 10^5`
- `0 <= nums1[i], nums2[j] <= 10^9`
examples:
- input: "nums1 = [2,1,3], nums2 = [10,2,5,0]"
output: "13"
explanation: "A possible nums3 array is [8,0,7,2,11,3,4,1,9,1,6,3]. The bitwise XOR of all these numbers is 13."
- input: "nums1 = [1,2], nums2 = [3,4]"
output: "0"
explanation: "All possible pairs of bitwise XORs are nums1[0] ^ nums2[0], nums1[0] ^ nums2[1], nums1[1] ^ nums2[0], and nums1[1] ^ nums2[1]. Thus, one possible nums3 array is [2,5,1,6]. 2 ^ 5 ^ 1 ^ 6 = 0, so we return 0."
explanation:
intuition: |
At first glance, this problem seems to require generating all `m * n` pairwise XORs (where `m = len(nums1)` and `n = len(nums2)`), then XORing them all together. With constraints up to `10^5` for both arrays, that's potentially `10^10` operations — far too slow.
The key insight comes from a fundamental property of XOR: **a value XORed with itself cancels out** (`x ^ x = 0`). This means if any value appears an **even number of times** in the final XOR, it contributes nothing to the result.
Think of it like this: imagine laying out all pairwise XORs in a grid. Each element `nums1[i]` appears in **exactly `n` pairs** (once with each element of `nums2`). Similarly, each element `nums2[j]` appears in **exactly `m` pairs**.
When we XOR all pairs together, we're effectively XORing each `nums1[i]` a total of `n` times, and each `nums2[j]` a total of `m` times. If `n` is even, all `nums1` elements cancel out. If `m` is even, all `nums2` elements cancel out.
This reduces the problem to simple parity checks on the array lengths.
approach: |
We solve this using **XOR properties and parity analysis**:
**Step 1: Understand the contribution of each element**
- Each element in `nums1` is XORed with every element in `nums2`, so it appears in `len(nums2)` pairs
- Each element in `nums2` is XORed with every element in `nums1`, so it appears in `len(nums1)` pairs
- When we XOR all pairs, each `nums1[i]` is XORed `len(nums2)` times total
&nbsp;
**Step 2: Apply the cancellation property**
- If `len(nums2)` is even, each `nums1[i]` appears an even number of times and cancels out
- If `len(nums2)` is odd, each `nums1[i]` appears an odd number of times and contributes once
- The same logic applies to `nums2` elements based on `len(nums1)`
&nbsp;
**Step 3: Compute the result based on parity**
- `result = 0`
- If `len(nums2)` is odd: XOR all elements of `nums1` into result
- If `len(nums1)` is odd: XOR all elements of `nums2` into result
- Return the final result
&nbsp;
This elegant solution avoids generating any pairs at all, working in O(m + n) time.
common_pitfalls:
- title: The Brute Force Trap
description: |
The naive approach generates all `m * n` pairwise XORs:
```python
for a in nums1:
for b in nums2:
result ^= (a ^ b)
```
With `m, n <= 10^5`, this results in up to `10^10` operations — a guaranteed **Time Limit Exceeded**. The mathematical insight about parity is essential to solve this efficiently.
wrong_approach: "Nested loops generating all pairs"
correct_approach: "Use parity to determine which elements contribute"
- title: Forgetting Both Arrays Can Contribute
description: |
It's easy to focus on just one array's contribution. Remember:
- `nums1` contributes if `len(nums2)` is **odd**
- `nums2` contributes if `len(nums1)` is **odd**
Both conditions can be true simultaneously! For example, if both arrays have odd lengths, elements from **both** arrays contribute to the final XOR.
wrong_approach: "Only checking one array's length parity"
correct_approach: "Check both len(nums1) and len(nums2) independently"
- title: Confusing Which Length Matters
description: |
A subtle error is checking the wrong length for each array:
- `nums1` elements appear `len(nums2)` times (not `len(nums1)` times)
- `nums2` elements appear `len(nums1)` times (not `len(nums2)` times)
If you check `len(nums1) % 2` to decide whether to include `nums1`, you'll get the wrong answer. The **other** array's length determines the contribution.
wrong_approach: "Checking len(nums1) % 2 for nums1's contribution"
correct_approach: "Check len(nums2) % 2 for nums1's contribution"
key_takeaways:
- "**XOR cancellation**: `x ^ x = 0` — values appearing an even number of times cancel out entirely"
- "**Parity analysis**: When elements repeat based on array lengths, check if the count is odd or even to determine contribution"
- "**Avoid unnecessary computation**: Mathematical insight can reduce O(n^2) or worse problems to O(n)"
- "**Bit manipulation patterns**: Many XOR problems have elegant solutions based on fundamental properties like `x ^ x = 0` and `x ^ 0 = x`"
time_complexity: "O(m + n). We iterate through each array at most once, where `m = len(nums1)` and `n = len(nums2)`."
space_complexity: "O(1). We only use a single variable to accumulate the XOR result."
solutions:
- approach_name: Parity-Based XOR
is_optimal: true
code: |
def xor_all_nums(nums1: list[int], nums2: list[int]) -> int:
result = 0
len1, len2 = len(nums1), len(nums2)
# Each nums1 element appears len2 times in all pairs
# If len2 is odd, it contributes to the final XOR
if len2 % 2 == 1:
for num in nums1:
result ^= num
# Each nums2 element appears len1 times in all pairs
# If len1 is odd, it contributes to the final XOR
if len1 % 2 == 1:
for num in nums2:
result ^= num
return result
explanation: |
**Time Complexity:** O(m + n) — Single pass through each array at most once.
**Space Complexity:** O(1) — Only one variable used.
We leverage the XOR cancellation property: elements appearing an even number of times contribute nothing. Each element in `nums1` appears `len(nums2)` times, and vice versa. We only XOR elements whose appearance count is odd.
- approach_name: Pythonic One-Liner
is_optimal: true
code: |
from functools import reduce
from operator import xor
def xor_all_nums(nums1: list[int], nums2: list[int]) -> int:
# XOR nums1 if len(nums2) is odd, XOR nums2 if len(nums1) is odd
return (
(reduce(xor, nums1, 0) if len(nums2) % 2 else 0) ^
(reduce(xor, nums2, 0) if len(nums1) % 2 else 0)
)
explanation: |
**Time Complexity:** O(m + n) — Same as the explicit version.
**Space Complexity:** O(1) — Constant extra space.
A more compact version using `reduce` to XOR all elements. The logic is identical: include `nums1`'s XOR if `len(nums2)` is odd, include `nums2`'s XOR if `len(nums1)` is odd.
- approach_name: Brute Force
is_optimal: false
code: |
def xor_all_nums(nums1: list[int], nums2: list[int]) -> int:
result = 0
# XOR every possible pair
for a in nums1:
for b in nums2:
result ^= (a ^ b)
return result
explanation: |
**Time Complexity:** O(m * n) — Nested loops over both arrays.
**Space Complexity:** O(1) — Only tracking result.
This directly computes the XOR of all pairs. While correct, it's far too slow for large inputs (up to `10^10` operations). Included to illustrate why the mathematical insight is necessary. This will cause TLE on LeetCode.

View File

@@ -0,0 +1,233 @@
title: Boats to Save People
slug: boats-to-save-people
difficulty: medium
leetcode_id: 881
leetcode_url: https://leetcode.com/problems/boats-to-save-people/
categories:
- arrays
- two-pointers
- sorting
patterns:
- two-pointers
- greedy
description: |
You are given an array `people` where `people[i]` is the weight of the i<sup>th</sup> person, and an **infinite number of boats** where each boat can carry a maximum weight of `limit`. Each boat carries **at most two people** at the same time, provided the sum of the weight of those people is at most `limit`.
Return *the minimum number of boats to carry every given person*.
constraints: |
- `1 <= people.length <= 5 * 10^4`
- `1 <= people[i] <= limit <= 3 * 10^4`
examples:
- input: "people = [1,2], limit = 3"
output: "1"
explanation: "1 boat carries both people (1 + 2 = 3 <= limit)."
- input: "people = [3,2,2,1], limit = 3"
output: "3"
explanation: "3 boats needed: (1, 2), (2), and (3). The heaviest person (3) must go alone."
- input: "people = [3,5,3,4], limit = 5"
output: "4"
explanation: "4 boats needed: (3), (3), (4), (5). No two people can share a boat."
explanation:
intuition: |
Imagine you're organising an evacuation with limited boats. Each boat can hold at most two people, and there's a weight limit. Your goal is to minimise the number of boats used.
The key insight is that **heavy people are harder to pair**. A person weighing close to the limit can only share a boat with someone very light — or must go alone. Meanwhile, light people are flexible and can potentially pair with anyone.
This suggests a **greedy strategy**: try to pair the heaviest remaining person with the lightest remaining person. If they fit together, great — you've efficiently used one boat for two people. If they don't fit, the heavy person must travel alone, and you move on.
To efficiently find the heaviest and lightest unpaired people, **sort the array first**. Then use two pointers: one at the start (lightest) and one at the end (heaviest). This lets us make pairing decisions in O(1) time per boat.
approach: |
We solve this using a **Two Pointers Greedy Approach**:
**Step 1: Sort the people by weight**
- Sorting allows us to efficiently consider the lightest and heaviest people
- After sorting, `people[0]` is lightest, `people[n-1]` is heaviest
&nbsp;
**Step 2: Initialise two pointers and a boat counter**
- `left`: Points to the lightest unpaired person (index `0`)
- `right`: Points to the heaviest unpaired person (index `n - 1`)
- `boats`: Counter starting at `0`
&nbsp;
**Step 3: Greedily assign boats**
- While `left <= right` (there are unpaired people):
- The heaviest person (`people[right]`) **always** needs a boat this round
- Check if the lightest person can join: `people[left] + people[right] <= limit`
- If yes: both board together, move `left` right (`left += 1`)
- Either way: the heavy person is seated, move `right` left (`right -= 1`)
- Increment `boats` by 1
&nbsp;
**Step 4: Return the result**
- Return `boats` — the total number of boats used
&nbsp;
This greedy approach is optimal because pairing the heaviest with the lightest maximises the chance of fitting two people per boat. If the lightest can't pair with the heaviest, no one else can either (since everyone else is heavier).
common_pitfalls:
- title: Forgetting to Sort
description: |
The two-pointer technique requires the array to be sorted. Without sorting, you can't reliably find the lightest and heaviest unpaired people using pointers.
For example, with `people = [3,1,2]` and `limit = 3`, an unsorted approach might try pairing `3` with `1`, then `2` alone — but the optimal pairing is `(1,2)` and `(3)`, which requires seeing the sorted order.
wrong_approach: "Using two pointers on unsorted array"
correct_approach: "Sort first, then use two pointers"
- title: Trying to Fit More Than Two People
description: |
The problem explicitly states each boat carries **at most two people**. Some solvers try to pack as many light people as possible into one boat.
Even if `people = [1,1,1]` and `limit = 10`, you still need 2 boats: `(1,1)` and `(1)`. The weight limit isn't the only constraint — the capacity of 2 people is.
wrong_approach: "Fitting three or more people per boat"
correct_approach: "Maximum two people per boat, regardless of weight"
- title: Not Moving the Right Pointer
description: |
The heaviest person (`people[right]`) always boards a boat each iteration — whether alone or paired. Forgetting to decrement `right` causes an infinite loop.
Every iteration must move at least `right` leftward. If a pairing occurs, `left` also moves.
wrong_approach: "Only moving left when pairing succeeds"
correct_approach: "Always decrement right; conditionally increment left"
- title: Using Greedy Without Proof
description: |
While greedy works here, it's worth understanding **why**. If the lightest person can't pair with the heaviest, then no one can pair with the heaviest (everyone else is heavier). So the heaviest must go alone — no better option exists.
This local optimal choice (pair heaviest with lightest if possible) leads to a global optimum.
wrong_approach: "Assuming greedy works without reasoning"
correct_approach: "Understanding why pairing extremes is optimal"
key_takeaways:
- "**Sort to enable two-pointer greedy**: Sorting transforms the problem into one where extreme elements (lightest/heaviest) are easily accessible"
- "**Greedy pairing strategy**: Pair the heaviest with the lightest — if they can't fit, the heaviest goes alone"
- "**Two-pointer efficiency**: After sorting, each person is processed exactly once, giving O(n) for the pairing phase"
- "**Constraint awareness**: The two-person limit per boat is crucial — this isn't a pure bin-packing problem"
time_complexity: "O(n log n). Sorting dominates at O(n log n), followed by O(n) for the two-pointer traversal."
space_complexity: "O(log n) to O(n). Depends on the sorting algorithm's space usage. The two-pointer logic itself uses O(1) extra space."
solutions:
- approach_name: Two Pointers (Greedy)
is_optimal: true
code: |
def num_rescue_boats(people: list[int], limit: int) -> int:
# Sort to enable greedy pairing
people.sort()
# Two pointers: lightest and heaviest unpaired people
left, right = 0, len(people) - 1
boats = 0
while left <= right:
# Check if lightest can pair with heaviest
if people[left] + people[right] <= limit:
# Both can share a boat
left += 1
# Heaviest person always boards (alone or paired)
right -= 1
boats += 1
return boats
explanation: |
**Time Complexity:** O(n log n) — Dominated by sorting.
**Space Complexity:** O(log n) to O(n) — Sorting space overhead.
After sorting, we use two pointers to greedily pair people. The heaviest person always boards each iteration. If the lightest can join them (combined weight <= limit), we advance the left pointer too. This ensures minimum boats because if the lightest can't pair with the heaviest, no one can.
- approach_name: Counting Sort Optimisation
is_optimal: false
code: |
def num_rescue_boats(people: list[int], limit: int) -> int:
# Count frequency of each weight
count = [0] * (limit + 1)
for weight in people:
count[weight] += 1
# Two pointers on weight values
left, right = 1, limit
boats = 0
while left <= right:
# Skip weights with no people
while left <= right and count[left] == 0:
left += 1
while left <= right and count[right] == 0:
right -= 1
if left > right:
break
# Try to pair lightest with heaviest
if left + right <= limit:
# Can pair: use one of each
count[left] -= 1
count[right] -= 1
if left == right and count[left] == 1:
# Only one person left at this weight
count[left] -= 1
boats += 1
else:
# Can't pair: heaviest goes alone
count[right] -= 1
boats += 1
return boats
explanation: |
**Time Complexity:** O(n + limit) — Linear in input size plus weight range.
**Space Complexity:** O(limit) — Array to count each weight.
When the weight range is small relative to the number of people, counting sort can be faster than comparison-based sorting. We count frequencies and use two pointers on weight values rather than indices. This approach is more complex and only beneficial when `limit` is small.
- approach_name: Brute Force (Backtracking)
is_optimal: false
code: |
def num_rescue_boats(people: list[int], limit: int) -> int:
n = len(people)
min_boats = [n] # Worst case: everyone gets their own boat
def backtrack(index: int, boats: int, current_boat: int):
# Pruning: if we've already found a better solution, stop
if boats >= min_boats[0]:
return
# All people assigned
if index == n:
min_boats[0] = min(min_boats[0], boats)
return
weight = people[index]
# Option 1: Start a new boat with this person
backtrack(index + 1, boats + 1, weight)
# Option 2: Add to current boat if possible and space for 2
if current_boat > 0 and current_boat + weight <= limit:
# This simplified version doesn't track boat occupancy properly
# Full implementation would need more state
pass
backtrack(0, 0, 0)
return min_boats[0]
explanation: |
**Time Complexity:** O(2^n) — Exponential in the worst case.
**Space Complexity:** O(n) — Recursion stack depth.
This brute force approach tries all possible assignments of people to boats. While correct in principle, it's far too slow for the given constraints (`n <= 5 * 10^4`). Included to illustrate why a greedy approach is necessary — exhaustive search is infeasible.

View File

@@ -0,0 +1,267 @@
title: Booking Concert Tickets in Groups
slug: booking-concert-tickets-in-groups
difficulty: hard
leetcode_id: 2286
leetcode_url: https://leetcode.com/problems/booking-concert-tickets-in-groups/
categories:
- arrays
- binary-search
patterns:
- binary-search
description: |
A concert hall has `n` rows numbered from `0` to `n - 1`, each with `m` seats, numbered from `0` to `m - 1`. You need to design a ticketing system that can allocate seats in the following cases:
- If a group of `k` spectators can sit **together** in a row.
- If **every** member of a group of `k` spectators can get a seat. They may or **may not** sit together.
Note that the spectators are very picky. Hence:
- They will book seats only if each member of their group can get a seat with row number **less than or equal** to `maxRow`. `maxRow` can **vary** from group to group.
- In case there are multiple rows to choose from, the row with the **smallest** number is chosen. If there are multiple seats to choose in the same row, the seat with the **smallest** number is chosen.
Implement the `BookMyShow` class:
- `BookMyShow(int n, int m)` Initializes the object with `n` as the number of rows and `m` as the number of seats per row.
- `int[] gather(int k, int maxRow)` Returns an array of length `2` denoting the row and seat number (respectively) of the **first seat** being allocated to the `k` members of the group, who must sit **together**. In other words, it returns the smallest possible `r` and `c` such that all `[c, c + k - 1]` seats are valid and empty in row `r`, and `r <= maxRow`. Returns `[]` if it is **not possible** to allocate seats to the group.
- `boolean scatter(int k, int maxRow)` Returns `true` if all `k` members of the group can be allocated seats in rows `0` to `maxRow`, who may or **may not** sit together. If the seats can be allocated, it allocates `k` seats to the group with the **smallest** row numbers, and the smallest possible seat numbers in each row. Otherwise, returns `false`.
constraints: |
- `1 <= n <= 5 * 10^4`
- `1 <= m, k <= 10^9`
- `0 <= maxRow <= n - 1`
- At most `5 * 10^4` calls **in total** will be made to `gather` and `scatter`.
examples:
- input: |
["BookMyShow", "gather", "gather", "scatter", "scatter"]
[[2, 5], [4, 0], [2, 0], [5, 1], [5, 1]]
output: "[null, [0, 0], [], true, false]"
explanation: |
BookMyShow bms = new BookMyShow(2, 5); // There are 2 rows with 5 seats each
bms.gather(4, 0); // return [0, 0] - The group books seats [0, 3] of row 0.
bms.gather(2, 0); // return [] - There is only 1 seat left in row 0, so it is not possible to book 2 consecutive seats.
bms.scatter(5, 1); // return True - The group books seat 4 of row 0 and seats [0, 3] of row 1.
bms.scatter(5, 1); // return False - There is only one seat left in the hall.
explanation:
intuition: |
Imagine you're managing a concert venue where groups constantly request seats. Some groups need to sit together (like families), while others just need any available seats (like colleagues who don't mind being separated).
The naive approach of iterating through rows one-by-one for each request would be too slow with up to `5 * 10^4` operations and `5 * 10^4` rows. We need a data structure that can efficiently answer two types of queries:
1. **Find the first row** (within a range) that has at least `k` consecutive empty seats
2. **Calculate the total** available seats across a range of rows
A **Segment Tree** is the perfect fit here. It can answer range queries and perform updates in O(log n) time. We'll build a segment tree where each node stores:
- The **maximum available consecutive seats** in any row within its range (for `gather`)
- The **sum of available seats** across all rows in its range (for `scatter`)
Think of it like a hierarchical index: instead of checking every row, we can quickly narrow down to the relevant section by looking at summary statistics at each level.
approach: |
We use a **Segment Tree** with two values per node: `max_val` (maximum seats available in any single row) and `sum_val` (total seats available across all rows in the range).
**Step 1: Initialize the segment tree**
- Create arrays to store `max_val` and `sum_val` for each segment tree node
- Initially, every row has `m` seats available
- Build the tree bottom-up: leaf nodes represent individual rows, parent nodes aggregate their children
&nbsp;
**Step 2: Implement `gather(k, maxRow)`**
- Use the segment tree to find the **leftmost row** in `[0, maxRow]` where `max_val >= k`
- Binary search through the tree: if the left child's max is sufficient, go left; otherwise, go right
- Once found, allocate `k` seats from that row (update the row's available count)
- Return `[row, first_seat_index]`
- If no row found, return `[]`
&nbsp;
**Step 3: Implement `scatter(k, maxRow)`**
- Query the segment tree for the **sum** of available seats in `[0, maxRow]`
- If `sum < k`, return `false`
- Otherwise, greedily fill rows starting from the smallest index:
- Track a pointer to the first row that might have seats
- Fill each row as much as possible until `k` seats are allocated
- Update the segment tree after each row modification
- Return `true`
&nbsp;
**Step 4: Segment tree operations**
- `update(row, new_val)`: Update a single row's available seats, then propagate changes up
- `query_max(l, r)`: Find the maximum available seats in any row within `[l, r]`
- `query_sum(l, r)`: Find the total available seats in `[l, r]`
- `find_first(k, maxRow)`: Binary search for the leftmost row with at least `k` seats
common_pitfalls:
- title: Using O(n) Linear Scan Per Query
description: |
A naive implementation might iterate through all rows for each `gather` or `scatter` call. With `n = 5 * 10^4` rows and `5 * 10^4` queries, this results in `O(n * q) = 2.5 * 10^9` operations, causing TLE.
The segment tree reduces each query to O(log n), giving us `O(q * log n)` total time.
wrong_approach: "Linear scan through all rows for each query"
correct_approach: "Segment tree with O(log n) queries"
- title: Integer Overflow in Sum Calculations
description: |
With `m` up to `10^9` and `n` up to `5 * 10^4`, the total seats can reach `5 * 10^13`, which exceeds the 32-bit integer limit (`~2 * 10^9`).
Always use 64-bit integers (Python handles this automatically, but in other languages use `long long` or similar).
wrong_approach: "Using 32-bit integers for seat counts"
correct_approach: "Use 64-bit integers for sum calculations"
- title: Not Tracking the First Non-Empty Row for Scatter
description: |
In `scatter`, if you always start from row 0, you waste time iterating through already-full rows. After filling rows, keep track of the first row that might still have available seats.
This optimization is crucial because `scatter` might fill many rows across multiple calls, and re-checking full rows adds unnecessary overhead.
wrong_approach: "Always start scatter from row 0"
correct_approach: "Maintain a pointer to the first potentially non-empty row"
- title: Incorrect Binary Search in Segment Tree
description: |
When finding the leftmost row with sufficient seats, you must check the left subtree first. If the left subtree's maximum is sufficient, the answer is there. Only check the right subtree if the left cannot satisfy the requirement.
Getting this order wrong will return a valid row but not necessarily the **smallest** valid row number.
wrong_approach: "Not prioritizing the left subtree in binary search"
correct_approach: "Always check left child first, only go right if left is insufficient"
key_takeaways:
- "**Segment trees for range queries**: When you need to efficiently query and update ranges (max, sum, min), segment trees provide O(log n) operations"
- "**Dual-purpose nodes**: A single segment tree can track multiple aggregates (max and sum) by storing both values in each node"
- "**Binary search within segment tree**: Finding the leftmost element satisfying a condition can be done in O(log n) by binary searching through the tree structure"
- "**Design problems require thoughtful data structures**: The key insight is recognizing that a segment tree's properties match the query patterns perfectly"
time_complexity: "O(n + q * log n). Building the segment tree takes O(n), and each of the q operations takes O(log n) for queries and updates."
space_complexity: "O(n). The segment tree requires O(4n) = O(n) space for storing node values."
solutions:
- approach_name: Segment Tree
is_optimal: true
code: |
class BookMyShow:
def __init__(self, n: int, m: int):
self.n = n
self.m = m
# Segment tree arrays: 4*n size to handle all nodes
self.max_tree = [m] * (4 * n) # Max seats in any row in range
self.sum_tree = [m] * (4 * n) # Total seats in range
# Track first row that might have seats (optimization for scatter)
self.first_row = 0
# Build the segment tree
self._build(1, 0, n - 1)
def _build(self, node: int, start: int, end: int) -> None:
"""Build segment tree with initial values."""
if start == end:
# Leaf node: single row with m seats
self.max_tree[node] = self.m
self.sum_tree[node] = self.m
return
mid = (start + end) // 2
left, right = 2 * node, 2 * node + 1
self._build(left, start, mid)
self._build(right, mid + 1, end)
# Parent aggregates children
self.max_tree[node] = max(self.max_tree[left], self.max_tree[right])
self.sum_tree[node] = self.sum_tree[left] + self.sum_tree[right]
def _update(self, node: int, start: int, end: int, idx: int, val: int) -> None:
"""Update a single row's available seats."""
if start == end:
# Leaf node: set new value
self.max_tree[node] = val
self.sum_tree[node] = val
return
mid = (start + end) // 2
left, right = 2 * node, 2 * node + 1
if idx <= mid:
self._update(left, start, mid, idx, val)
else:
self._update(right, mid + 1, end, idx, val)
# Recalculate parent after child update
self.max_tree[node] = max(self.max_tree[left], self.max_tree[right])
self.sum_tree[node] = self.sum_tree[left] + self.sum_tree[right]
def _query_sum(self, node: int, start: int, end: int, l: int, r: int) -> int:
"""Query total available seats in range [l, r]."""
if r < start or end < l:
return 0 # Out of range
if l <= start and end <= r:
return self.sum_tree[node] # Fully within range
mid = (start + end) // 2
left, right = 2 * node, 2 * node + 1
return (self._query_sum(left, start, mid, l, r) +
self._query_sum(right, mid + 1, end, l, r))
def _find_first_row(self, node: int, start: int, end: int, k: int, max_row: int) -> int:
"""Find leftmost row in [start, min(end, max_row)] with at least k seats."""
if self.max_tree[node] < k or start > max_row:
return -1 # No valid row in this subtree
if start == end:
return start # Found the row
mid = (start + end) // 2
left, right = 2 * node, 2 * node + 1
# Check left subtree first (we want smallest row number)
left_result = self._find_first_row(left, start, mid, k, max_row)
if left_result != -1:
return left_result
# Only check right if left didn't have valid row
return self._find_first_row(right, mid + 1, end, k, max_row)
def _get_row_seats(self, node: int, start: int, end: int, idx: int) -> int:
"""Get current available seats in a specific row."""
if start == end:
return self.sum_tree[node]
mid = (start + end) // 2
if idx <= mid:
return self._get_row_seats(2 * node, start, mid, idx)
return self._get_row_seats(2 * node + 1, mid + 1, end, idx)
def gather(self, k: int, maxRow: int) -> list[int]:
"""Find k consecutive seats in a single row."""
# Find the first row with enough consecutive seats
row = self._find_first_row(1, 0, self.n - 1, k, maxRow)
if row == -1:
return []
# Get current seats in this row to find starting position
current_seats = self._get_row_seats(1, 0, self.n - 1, row)
# First empty seat is at position (m - current_seats)
first_seat = self.m - current_seats
# Update the row: k fewer seats available
self._update(1, 0, self.n - 1, row, current_seats - k)
return [row, first_seat]
def scatter(self, k: int, maxRow: int) -> bool:
"""Allocate k seats across multiple rows if possible."""
# Check if enough total seats exist in range [0, maxRow]
total = self._query_sum(1, 0, self.n - 1, 0, maxRow)
if total < k:
return False
# Greedily fill rows starting from first_row
remaining = k
row = self.first_row
while remaining > 0 and row <= maxRow:
seats = self._get_row_seats(1, 0, self.n - 1, row)
if seats > 0:
take = min(seats, remaining)
remaining -= take
self._update(1, 0, self.n - 1, row, seats - take)
# If row is now full, we can skip it next time
if seats - take == 0:
self.first_row = row + 1
row += 1
return True
explanation: |
**Time Complexity:** O(n + q * log n) — Building takes O(n), each query/update is O(log n).
**Space Complexity:** O(n) — Segment tree arrays use O(4n) space.
The segment tree stores two values per node: maximum seats in any row (for `gather`) and total seats (for `scatter`). The `gather` operation uses binary search within the tree to find the leftmost suitable row. The `scatter` operation checks total availability first, then greedily fills rows. The `first_row` pointer optimizes scatter by skipping already-full rows.

View File

@@ -0,0 +1,287 @@
title: Brace Expansion II
slug: brace-expansion-ii
difficulty: hard
leetcode_id: 1096
leetcode_url: https://leetcode.com/problems/brace-expansion-ii/
categories:
- strings
- recursion
- stack
patterns:
- backtracking
- dfs
description: |
Under the grammar given below, strings can represent a set of lowercase words. Let `R(expr)` denote the set of words the expression represents.
The grammar can best be understood through simple examples:
- Single letters represent a singleton set containing that word.
- `R("a") = {"a"}`
- `R("w") = {"w"}`
- When we take a comma-delimited list of two or more expressions, we take the **union** of possibilities.
- `R("{a,b,c}") = {"a","b","c"}`
- `R("{{a,b},{b,c}}") = {"a","b","c"}` (notice the final set only contains each word at most once)
- When we concatenate two expressions, we take the set of possible **concatenations** between two words where the first word comes from the first expression and the second word comes from the second expression.
- `R("{a,b}{c,d}") = {"ac","ad","bc","bd"}`
- `R("a{b,c}{d,e}f{g,h}") = {"abdfg", "abdfh", "abefg", "abefh", "acdfg", "acdfh", "acefg", "acefh"}`
Formally, the three rules for our grammar:
- For every lowercase letter `x`, we have `R(x) = {x}`.
- For expressions `e1, e2, ... , ek` with `k >= 2`, we have `R({e1, e2, ...}) = R(e1) R(e2) ...`
- For expressions `e1` and `e2`, we have `R(e1 + e2) = {a + b for (a, b) in R(e1) × R(e2)}`, where `+` denotes concatenation, and `×` denotes the cartesian product.
Given an expression representing a set of words under the given grammar, return *the sorted list of words that the expression represents*.
constraints: |
- `1 <= expression.length <= 60`
- `expression[i]` consists of `'{'`, `'}'`, `','` or lowercase English letters
- The given expression represents a set of words based on the grammar given in the description
examples:
- input: 'expression = "{a,b}{c,{d,e}}"'
output: '["ac","ad","ae","bc","bd","be"]'
explanation: "We expand {a,b} to get {a, b}, then expand {c,{d,e}} to get {c, d, e}. The Cartesian product gives us all combinations: ac, ad, ae, bc, bd, be."
- input: 'expression = "{{a,z},a{b,c},{ab,z}}"'
output: '["a","ab","ac","z"]'
explanation: "Each distinct word is written only once in the final answer. The union of {a,z}, {ab,ac}, and {ab,z} gives {a, ab, ac, z}."
explanation:
intuition: |
Think of this problem like evaluating a mathematical expression, but instead of numbers and arithmetic operators, we have **sets of strings** and two operations: **union** (comma) and **concatenation** (adjacency).
Imagine you're building a tree where each node represents an expression. Leaf nodes are single letters (like `"a"`), and internal nodes combine their children using either union or concatenation. The braces `{}` group expressions together, and commas `,` inside braces indicate union.
The key insight is recognising this as a **recursive parsing problem**. The grammar has a natural hierarchy:
- At the top level, we have concatenation of terms
- Inside braces, we have union of sub-expressions
- Each sub-expression can itself contain nested braces
Think of it like evaluating `2 * (3 + 4)` — you need to handle the parentheses first, then apply the multiplication. Here, braces act like parentheses, commas act like addition (union), and adjacency acts like multiplication (Cartesian product).
approach: |
We solve this using a **Recursive Descent Parser** that mimics the grammar structure:
**Step 1: Define the grammar hierarchy**
- `expr`: One or more terms concatenated together
- `term`: Either a single letter or a braced group `{...}`
- `group`: Comma-separated expressions inside braces (union)
&nbsp;
**Step 2: Implement recursive parsing**
- Use an index pointer to track position in the expression string
- `parse_expr()`: Parse concatenated terms and compute their Cartesian product
- `parse_term()`: Parse a single letter or delegate to `parse_group()` for braces
- `parse_group()`: Parse comma-separated expressions inside `{...}` and compute their union
&nbsp;
**Step 3: Handle the operations**
- **Union**: When we see a comma, combine two sets with set union
- **Concatenation**: When terms are adjacent, compute Cartesian product of strings
- Use a `set` throughout to automatically handle duplicates
&nbsp;
**Step 4: Return sorted result**
- Convert the final set to a sorted list before returning
&nbsp;
The recursive structure naturally handles nested expressions because each call to `parse_group()` can trigger new calls to `parse_expr()`, which handles the nested content.
common_pitfalls:
- title: Confusing Union and Concatenation
description: |
The comma operator `,` performs **union** (adding to the set), while adjacency performs **concatenation** (Cartesian product).
For example, `"{a,b}c"` means: union of `a` and `b`, then concatenate with `c`, giving `{ac, bc}`.
But `"{a}{b,c}"` means: the set `{a}` concatenated with the union `{b, c}`, giving `{ab, ac}`.
Getting these operators mixed up leads to completely wrong results.
wrong_approach: "Treating comma as concatenation or adjacency as union"
correct_approach: "Comma = union (), adjacency = Cartesian product (×)"
- title: Not Handling Nested Braces
description: |
Expressions can be deeply nested: `"{{a,b},{c,{d,e}}}"`. A simple single-pass approach won't work because you need to fully evaluate inner expressions before combining them.
For `"{{a,b},{c,{d,e}}}"`:
- Inner `{a,b}` → `{a, b}`
- Inner `{d,e}` → `{d, e}`
- `{c,{d,e}}` → `{c, d, e}` (union)
- Outer union → `{a, b, c, d, e}`
Each level of braces must be fully resolved before the outer level can proceed.
wrong_approach: "Single-pass string manipulation"
correct_approach: "Recursive parsing that evaluates from innermost to outermost"
- title: Duplicate Handling
description: |
The result must contain each word **at most once**. For example, `"{{a,z},{ab,z}}"` should return `["a", "ab", "z"]`, not `["a", "ab", "z", "z"]`.
If you use lists instead of sets during computation, you'll have duplicates that need deduplication at the end. Using sets throughout is cleaner.
wrong_approach: "Using lists and forgetting to deduplicate"
correct_approach: "Use sets throughout, convert to sorted list at the end"
- title: Incorrect Cartesian Product
description: |
When concatenating two sets, every string from the first set must be paired with every string from the second set.
For `{a, b}` concatenated with `{c, d}`:
- Result: `{ac, ad, bc, bd}` (4 elements, not 2)
A common mistake is to zip the sets instead of taking the full Cartesian product.
wrong_approach: "Zipping sets element-by-element"
correct_approach: "Nested loops or itertools.product for all combinations"
key_takeaways:
- "**Recursive descent parsing** is a powerful technique for grammar-based problems — the code structure mirrors the grammar rules"
- "**Distinguish your operators**: union (comma) combines sets, concatenation (adjacency) computes Cartesian products"
- "**Use sets for uniqueness**: when the problem requires distinct elements, sets handle deduplication automatically"
- "**Similar problems**: Expression parsing, calculator problems, and nested structure evaluation all use similar recursive techniques"
time_complexity: "O(n × 2^(n/2)) in the worst case, where `n` is the expression length. Each character can contribute to exponential string combinations through Cartesian products."
space_complexity: "O(2^(n/2)) for storing all possible strings in the result set, plus O(n) recursion depth for parsing."
solutions:
- approach_name: Recursive Descent Parser
is_optimal: true
code: |
def brace_expansion_ii(expression: str) -> list[str]:
# Index pointer for parsing
idx = 0
n = len(expression)
def parse_expr() -> set[str]:
"""Parse concatenated terms and compute Cartesian product."""
nonlocal idx
# Start with set containing empty string (identity for concatenation)
result = {""}
while idx < n and expression[idx] not in ",}":
# Parse next term
term_set = parse_term()
# Cartesian product: combine each existing string with each new string
result = {a + b for a in result for b in term_set}
return result
def parse_term() -> set[str]:
"""Parse a single term: letter or braced group."""
nonlocal idx
if expression[idx] == "{":
# It's a group — delegate to parse_group
return parse_group()
else:
# It's a single letter
letter = expression[idx]
idx += 1
return {letter}
def parse_group() -> set[str]:
"""Parse comma-separated expressions inside braces (union)."""
nonlocal idx
idx += 1 # Skip opening '{'
result = set()
while True:
# Parse an expression and add to union
result |= parse_expr()
if expression[idx] == "}":
idx += 1 # Skip closing '}'
break
else:
idx += 1 # Skip comma ','
return result
# Parse the entire expression and return sorted result
return sorted(parse_expr())
explanation: |
**Time Complexity:** O(n × 2^(n/2)) — Parsing is O(n), but the Cartesian products can produce exponentially many strings.
**Space Complexity:** O(2^(n/2)) — The result set can contain exponentially many strings in the worst case.
The parser uses three mutually recursive functions that mirror the grammar:
- `parse_expr` handles concatenation (Cartesian product)
- `parse_term` handles single letters or delegates to groups
- `parse_group` handles union of comma-separated expressions
Using a `nonlocal` index pointer allows clean parsing without slicing strings.
- approach_name: Stack-Based Parser
is_optimal: false
code: |
def brace_expansion_ii(expression: str) -> list[str]:
# Stack holds sets of strings
# We use two stacks: one for sets, one for operators
stack = []
# Current set being built
current = {""}
i = 0
while i < len(expression):
char = expression[i]
if char == "{":
# Push current state and start fresh
stack.append(current)
stack.append("{") # Marker for brace level
current = {""}
elif char == "}":
# Pop and combine until we hit the opening brace marker
# First, handle any pending concatenation
combined = current
while stack and stack[-1] != "{":
prev = stack.pop()
if isinstance(prev, set):
# This was a union operand
combined = combined | prev
stack.pop() # Remove the "{" marker
# Now concatenate with what was before the brace
if stack:
prev_set = stack.pop()
current = {a + b for a in prev_set for b in combined}
else:
current = combined
elif char == ",":
# Union: push current set and start fresh
stack.append(current)
current = {""}
else:
# Lowercase letter: concatenate with current
current = {s + char for s in current}
i += 1
# Combine any remaining union operands
while stack:
prev = stack.pop()
if isinstance(prev, set):
current = current | prev
return sorted(current)
explanation: |
**Time Complexity:** O(n × 2^(n/2)) — Same as recursive approach.
**Space Complexity:** O(n × 2^(n/2)) — Stack can hold multiple sets.
This iterative approach uses explicit stacks instead of recursion. While it avoids recursion depth limits, it's harder to read and maintain. The recursive descent parser is preferred for its clarity and direct correspondence to the grammar.

View File

@@ -0,0 +1,169 @@
title: Break a Palindrome
slug: break-a-palindrome
difficulty: medium
leetcode_id: 1328
leetcode_url: https://leetcode.com/problems/break-a-palindrome/
categories:
- strings
patterns:
- greedy
description: |
Given a palindromic string of lowercase English letters `palindrome`, replace **exactly one** character with any lowercase English letter so that the resulting string is **not** a palindrome and that it is the **lexicographically smallest** one possible.
Return *the resulting string. If there is no way to replace a character to make it not a palindrome, return an **empty string***.
A string `a` is lexicographically smaller than a string `b` (of the same length) if in the first position where `a` and `b` differ, `a` has a character strictly smaller than the corresponding character in `b`. For example, `"abcc"` is lexicographically smaller than `"abcd"` because the first position they differ is at the fourth character, and `'c'` is smaller than `'d'`.
constraints: |
- `1 <= palindrome.length <= 1000`
- `palindrome` consists of only lowercase English letters.
examples:
- input: 'palindrome = "abccba"'
output: '"aaccba"'
explanation: 'There are many ways to make "abccba" not a palindrome, such as "zbccba", "aaccba", and "abacba". Of all the ways, "aaccba" is the lexicographically smallest.'
- input: 'palindrome = "a"'
output: '""'
explanation: 'There is no way to replace a single character to make "a" not a palindrome, so return an empty string.'
explanation:
intuition: |
Think of a palindrome as a mirror: the first half reflects onto the second half. To break this symmetry, we need to change exactly one character.
The key insight is understanding what makes a string "lexicographically smallest." We want the earliest characters to be as small as possible. Since `'a'` is the smallest lowercase letter, our goal is to place an `'a'` as early as possible in the string.
Here's the greedy strategy: scan from the left side of the string. If we find any character that is **not** `'a'`, we can change it to `'a'` to make the string lexicographically smaller. But there's a catch: in a palindrome, changing a character in the first half automatically affects the symmetry, which is exactly what we want.
However, we must be careful with the **middle character** in odd-length palindromes. Changing only the middle character keeps the string as a palindrome (since it mirrors to itself). So we skip the middle character when looking for a character to replace.
What if all characters in the first half are already `'a'`? Then we can't make the string smaller by changing anything in the first half. Our fallback is to change the **last character** to `'b'` — this breaks the palindrome while keeping the result as small as possible.
approach: |
We use a **Greedy Single-Pass Approach**:
**Step 1: Handle the edge case**
- If the string has length `1`, return an empty string — there's no way to replace a single character and make a single-character string non-palindromic
&nbsp;
**Step 2: Scan the first half of the string**
- Iterate through indices `0` to `n // 2 - 1` (exclusive of the middle in odd-length strings)
- For each character, check if it's **not** `'a'`
- If we find a non-`'a'` character, replace it with `'a'` and return the result immediately
- This guarantees the lexicographically smallest result because we change the leftmost possible character to the smallest possible value
&nbsp;
**Step 3: Fallback — change the last character**
- If all characters in the first half are `'a'`, we cannot make the string smaller by changing them
- Change the **last character** to `'b'`
- This breaks the palindrome (since the first character remains `'a'`) while resulting in the smallest possible string
&nbsp;
The greedy approach works because we always prioritize changes that affect the leftmost positions first, and we always choose the smallest possible replacement character.
common_pitfalls:
- title: Forgetting the Single Character Edge Case
description: |
A string of length `1` like `"a"` is always a palindrome no matter what character it contains. Changing `"a"` to `"b"` still gives you a palindrome (any single character is a palindrome by definition).
You must check for this case at the start and return an empty string.
wrong_approach: "Trying to process single-character strings normally"
correct_approach: "Return empty string immediately if length is 1"
- title: Changing the Middle Character in Odd-Length Strings
description: |
In a palindrome like `"aba"`, the middle character `'b'` mirrors to itself. If you change it to `'a'`, you get `"aaa"` — still a palindrome!
The middle character of an odd-length palindrome doesn't affect the palindrome property when changed alone. Only scan up to `n // 2` (exclusive) to avoid this trap.
wrong_approach: "Scanning the entire first half including middle character"
correct_approach: "Only scan indices 0 to n // 2 - 1"
- title: Replacing 'a' with 'a'
description: |
If you find an `'a'` and "replace" it with `'a'`, you haven't actually changed anything, and the string remains a palindrome.
Only replace characters that are **not** `'a'`. If a character is already `'a'`, skip it and continue scanning.
wrong_approach: "Replacing the first character regardless of its value"
correct_approach: "Only replace if the character is not 'a'"
- title: Not Handling All-'a' Palindromes
description: |
For a string like `"aaa"` or `"aaaa"`, every character in the first half is already `'a'`. You can't make the string lexicographically smaller by changing any of them.
The solution is to change the **last** character to `'b'`. This breaks the palindrome (first is `'a'`, last is `'b'`) and gives the smallest possible result.
wrong_approach: "Giving up or returning empty string for all-'a' palindromes"
correct_approach: "Change the last character to 'b' as a fallback"
key_takeaways:
- "**Greedy leftmost replacement**: To minimise a string lexicographically, make changes as early (leftmost) as possible with the smallest possible character"
- "**Palindrome symmetry**: Only the first half of a palindrome needs to be checked — the second half is a mirror"
- "**Edge cases matter**: Single-character strings and middle characters in odd-length strings require special handling"
- "**Fallback strategy**: When the primary approach doesn't apply (all `'a'`s), have a clear secondary strategy (change last to `'b'`)"
time_complexity: "O(n). We scan at most half the string once, where `n` is the length of the palindrome."
space_complexity: "O(n). We create a new string to return the result (strings are immutable in Python)."
solutions:
- approach_name: Greedy Single Pass
is_optimal: true
code: |
def break_palindrome(palindrome: str) -> str:
n = len(palindrome)
# Single character palindromes cannot be broken
if n == 1:
return ""
# Convert to list for easy character replacement
chars = list(palindrome)
# Scan the first half (excluding middle for odd-length)
for i in range(n // 2):
# Found a character that's not 'a'? Replace it with 'a'
if chars[i] != 'a':
chars[i] = 'a'
return ''.join(chars)
# All characters in first half are 'a'
# Change the last character to 'b' to break palindrome
chars[-1] = 'b'
return ''.join(chars)
explanation: |
**Time Complexity:** O(n) — Single pass through at most half the string.
**Space Complexity:** O(n) — We create a list copy of the string to modify it.
The greedy strategy ensures we always find the lexicographically smallest result: we try to place an `'a'` as early as possible, and only fall back to changing the last character when necessary.
- approach_name: Two-Case Analysis
is_optimal: true
code: |
def break_palindrome(palindrome: str) -> str:
n = len(palindrome)
# Cannot break a single-character palindrome
if n == 1:
return ""
# Case 1: Find first non-'a' in the first half
half = n // 2
for i in range(half):
if palindrome[i] != 'a':
# Replace it with 'a' for smallest result
return palindrome[:i] + 'a' + palindrome[i+1:]
# Case 2: All chars in first half are 'a'
# Replace last char with 'b'
return palindrome[:-1] + 'b'
explanation: |
**Time Complexity:** O(n) — Single pass through at most half the string.
**Space Complexity:** O(n) — String concatenation creates new strings.
This version uses string slicing instead of converting to a list. Both approaches are equivalent in complexity, but this one may be slightly more Pythonic when working with small strings.

View File

@@ -0,0 +1,169 @@
title: Brick Wall
slug: brick-wall
difficulty: medium
leetcode_id: 554
leetcode_url: https://leetcode.com/problems/brick-wall/
categories:
- arrays
- hash-tables
patterns:
- prefix-sum
description: |
There is a rectangular brick wall in front of you with `n` rows of bricks. The i<sup>th</sup> row has some number of bricks each of the same height (i.e., one unit) but they can be of different widths. The total width of each row is the same.
Draw a vertical line from the top to the bottom and cross the **least bricks**. If your line goes through the edge of a brick, then the brick is not considered as crossed. You cannot draw a line just along one of the two vertical edges of the wall, in which case the line will obviously cross no bricks.
Given the 2D array `wall` that contains the information about the wall, return *the minimum number of crossed bricks after drawing such a vertical line*.
constraints: |
- `n == wall.length`
- `1 <= n <= 10^4`
- `1 <= wall[i].length <= 10^4`
- `1 <= sum(wall[i].length) <= 2 * 10^4`
- `sum(wall[i])` is the same for each row `i`
- `1 <= wall[i][j] <= 2^31 - 1`
examples:
- input: "wall = [[1,2,2,1],[3,1,2],[1,3,2],[2,4],[3,1,2],[1,3,1,1]]"
output: "2"
explanation: "Drawing a vertical line at position 4 (from the left) passes through the edges of bricks in 4 rows, crossing only 2 bricks."
- input: "wall = [[1],[1],[1]]"
output: "3"
explanation: "Each row has only one brick spanning the entire width. Any vertical line must cross all 3 bricks."
explanation:
intuition: |
Imagine you're looking at the wall from above, marking where all the brick edges line up. Some vertical positions have many edges aligned (like a seam running through multiple rows), while others have few.
The **core insight** is to flip the problem: instead of minimising bricks crossed, **maximise edges passed through**. If a vertical line passes through `k` edges, it crosses `n - k` bricks (where `n` is the total number of rows).
Think of it like this: each brick edge creates a "gap" that a vertical line can slip through without crossing that brick. By counting how many gaps align at each horizontal position, we find where to draw the line.
We use a hash map to count edge positions. For each row, we compute the cumulative width (prefix sum) as we go through its bricks. Each cumulative sum (except the rightmost edge) represents an edge position. The position with the highest count is our optimal line placement.
approach: |
We solve this using a **Hash Map Edge Counting** approach:
**Step 1: Initialise an edge counter**
- `edge_count`: A hash map (dictionary) that maps each horizontal position to the number of brick edges at that position
&nbsp;
**Step 2: Iterate through each row**
- For each row, track the cumulative width as we iterate through its bricks
- For each brick (except the last one in the row), add its width to the cumulative sum
- This cumulative sum represents an edge position — increment its count in the hash map
- We skip the last brick because its edge is the wall boundary (not a valid line position)
&nbsp;
**Step 3: Find the maximum edge count**
- Scan the hash map to find the position with the most edges aligned
- If the hash map is empty (every row has only one brick), the maximum is `0`
&nbsp;
**Step 4: Calculate the answer**
- Return `n - max_edges`, where `n` is the number of rows
- This gives us the minimum number of bricks crossed
&nbsp;
The key observation is that we transform a minimisation problem into a maximisation problem, making it much easier to solve with simple counting.
common_pitfalls:
- title: Including the Wall Boundary
description: |
A common mistake is to include the rightmost edge of each row (the wall boundary) in the edge count.
For `wall = [[3], [3], [3]]`, if you count position `3` for each row, you'd incorrectly find `max_edges = 3` and return `0` bricks crossed.
But the problem states: "You cannot draw a line just along one of the two vertical edges of the wall." Always exclude the last brick's edge from your counting.
wrong_approach: "Counting cumulative sum of ALL bricks including the last"
correct_approach: "Skip the last brick in each row when counting edges"
- title: Brute Force Position Checking
description: |
A naive approach is to iterate through every possible x-position and count how many bricks it crosses.
With wall width up to `2^31 - 1`, this is impossibly slow. The insight is that we only need to consider positions where edges exist — the hash map naturally handles this by only storing edge positions.
wrong_approach: "Checking every integer x from 1 to wall_width-1"
correct_approach: "Only consider positions where at least one edge exists"
- title: Empty Hash Map Edge Case
description: |
When every row contains exactly one brick (e.g., `[[1], [1], [1]]`), no internal edges exist. The hash map will be empty.
In this case, `max_edges = 0`, so the answer is `n - 0 = n`. Make sure your code handles the case where the hash map has no entries.
key_takeaways:
- "**Flip the problem**: Minimising bricks crossed is equivalent to maximising edges passed through"
- "**Prefix sum for positions**: Cumulative sums of brick widths give you the edge positions"
- "**Hash map counting**: When you need to find the most common value in a stream, a hash map is the natural choice"
- "**Boundary awareness**: Be careful about edge cases involving boundaries — they're often excluded from valid solutions"
time_complexity: "O(n * m). We visit each brick once across all rows, where `n` is the number of rows and `m` is the average number of bricks per row."
space_complexity: "O(w). The hash map stores at most `w` unique edge positions, where `w` is bounded by the total number of bricks."
solutions:
- approach_name: Hash Map Edge Counting
is_optimal: true
code: |
def least_bricks(wall: list[list[int]]) -> int:
# Hash map to count edges at each horizontal position
edge_count = {}
for row in wall:
# Track cumulative width as we traverse the row
position = 0
# Skip the last brick (its edge is the wall boundary)
for i in range(len(row) - 1):
position += row[i]
# Increment edge count at this position
edge_count[position] = edge_count.get(position, 0) + 1
# Find the position with the most edges aligned
# If no edges exist (single-brick rows), max_edges is 0
max_edges = max(edge_count.values()) if edge_count else 0
# Minimum bricks crossed = total rows - edges we pass through
return len(wall) - max_edges
explanation: |
**Time Complexity:** O(n * m) — We iterate through each brick in the wall once.
**Space Complexity:** O(w) — The hash map stores edge positions, bounded by total brick count.
By counting edges instead of counting crossed bricks, we transform the problem into finding the maximum value in a frequency map. The answer is simply the total number of rows minus the maximum edge count.
- approach_name: Hash Map with Counter
is_optimal: true
code: |
from collections import Counter
def least_bricks(wall: list[list[int]]) -> int:
# Use Counter for cleaner frequency counting
edge_count = Counter()
for row in wall:
position = 0
# Accumulate edge positions (excluding last brick)
for brick_width in row[:-1]:
position += brick_width
edge_count[position] += 1
# max() on empty Counter raises error, so handle it
max_edges = max(edge_count.values(), default=0)
return len(wall) - max_edges
explanation: |
**Time Complexity:** O(n * m) — Same as the basic approach.
**Space Complexity:** O(w) — Same space usage for the Counter.
This is a Pythonic variant using `Counter` from the collections module. The `default=0` in `max()` handles the empty case elegantly. Using `row[:-1]` is a clean way to skip the last brick.

View File

@@ -0,0 +1,310 @@
title: Bricks Falling When Hit
slug: bricks-falling-when-hit
difficulty: hard
leetcode_id: 803
leetcode_url: https://leetcode.com/problems/bricks-falling-when-hit/
categories:
- arrays
- graphs
patterns:
- union-find
- matrix-traversal
description: |
You are given an `m x n` binary `grid`, where each `1` represents a brick and `0` represents an empty space. A brick is **stable** if:
- It is directly connected to the top of the grid, or
- At least one other brick in its four adjacent cells is **stable**.
You are also given an array `hits`, which is a sequence of erasures we want to apply. Each time we want to erase the brick at the location `hits[i] = (row_i, col_i)`. The brick on that location (if it exists) will disappear. Some other bricks may no longer be stable because of that erasure and will **fall**. Once a brick falls, it is **immediately** erased from the `grid` (i.e., it does not land on other stable bricks).
Return *an array* `result`*, where each* `result[i]` *is the number of bricks that will **fall** after the* i<sup>th</sup> *erasure is applied.*
**Note** that an erasure may refer to a location with no brick, and if it does, no bricks drop.
constraints: |
- `m == grid.length`
- `n == grid[i].length`
- `1 <= m, n <= 200`
- `grid[i][j]` is `0` or `1`
- `1 <= hits.length <= 4 * 10^4`
- `hits[i].length == 2`
- `0 <= row_i <= m - 1`
- `0 <= col_i <= n - 1`
- All `(row_i, col_i)` are unique
examples:
- input: "grid = [[1,0,0,0],[1,1,1,0]], hits = [[1,0]]"
output: "[2]"
explanation: "Starting with the grid [[1,0,0,0],[1,1,1,0]], we erase the brick at (1,0). The two bricks at (1,1) and (1,2) are no longer connected to the top nor adjacent to another stable brick, so they fall. Hence the result is [2]."
- input: "grid = [[1,0,0,0],[1,1,0,0]], hits = [[1,1],[1,0]]"
output: "[0,0]"
explanation: "After erasing (1,1), the brick at (1,0) is still connected to the top via (0,0), so nothing falls. After erasing (1,0), (0,0) is directly on top so it remains stable. No bricks fall in either case."
explanation:
intuition: |
Imagine you have a wall of bricks where each brick must be connected to the ceiling (top row) to stay up. When you remove a brick, any bricks that lose their connection to the ceiling will fall like a cascade.
The **naive approach** would be to simulate each hit forward in time: remove a brick, then run a BFS/DFS to find which bricks are still stable, and count the fallen ones. However, with up to `4 * 10^4` hits on a `200 x 200` grid, this would be too slow.
The **key insight** is to **reverse the problem**: instead of removing bricks and watching them fall, we can **add bricks in reverse order** and count how many become newly connected to the ceiling. Think of it like rewinding a video of bricks falling — in reverse, they "fly up" and attach themselves.
Here's why this works: Union-Find is excellent at *adding* connections but terrible at *removing* them. By processing hits in reverse, we convert a "removal" problem into an "addition" problem. When we add a brick back:
- If it connects some floating bricks to the ceiling, those bricks "become stable"
- The count of newly stable bricks equals the count of bricks that *fell* when this brick was originally hit
We use a **virtual ceiling node** that connects to all bricks in the top row. A brick is stable if and only if it's in the same connected component as this ceiling node.
approach: |
We solve this using **Union-Find with Reverse Time Processing**:
**Step 1: Mark all hit locations**
- Create a copy of the grid
- For each hit location, if there's a brick (`grid[r][c] == 1`), mark it as removed by setting it to `0`
- This gives us the "final state" after all hits
&nbsp;
**Step 2: Build initial Union-Find structure**
- Create a Union-Find with `m * n + 1` nodes (one extra for the virtual ceiling)
- The ceiling node is at index `m * n`
- For each remaining brick (`grid[r][c] == 1`):
- If it's in the top row, union it with the ceiling
- Union it with any adjacent bricks (right and down to avoid double-counting)
&nbsp;
**Step 3: Process hits in reverse order**
- For each hit from last to first:
- If the original grid had no brick at this location, result is `0`
- Otherwise, count how many bricks are currently connected to the ceiling
- Add the brick back by setting `grid[r][c] = 1`
- Union this brick with the ceiling (if top row) and all adjacent bricks
- Count how many bricks are now connected to the ceiling
- The difference (minus 1 for the brick itself) is the number of bricks that fell
&nbsp;
**Step 4: Reverse the results**
- Since we processed in reverse, reverse the result array to get the correct order
&nbsp;
This approach works because Union-Find efficiently tracks connected components as we add connections, and the "reverse time" trick converts brick removal into brick addition.
common_pitfalls:
- title: Forward Simulation is Too Slow
description: |
The tempting approach is to simulate each hit forward:
1. Remove the brick
2. Run BFS/DFS from the top row to mark all stable bricks
3. Count unmarked bricks as fallen
With `4 * 10^4` hits and a `200 x 200` grid, each BFS could visit 40,000 cells. This gives O(hits × m × n) = O(1.6 × 10^9) operations — far too slow.
The reverse Union-Find approach processes each hit in near-constant time (amortised), giving O(hits × α(m×n)) ≈ O(hits) total.
wrong_approach: "Forward simulation with BFS after each hit"
correct_approach: "Reverse time processing with Union-Find"
- title: Forgetting the Virtual Ceiling Node
description: |
Without a virtual ceiling, you'd need to check if any brick in the top row is in the same component — requiring O(n) checks per query.
By creating a single ceiling node that unions with all top-row bricks, checking stability becomes a single O(1) find operation: `find(brick) == find(ceiling)`.
wrong_approach: "Checking connection to each top-row brick separately"
correct_approach: "Use a virtual ceiling node connected to all top-row bricks"
- title: Not Handling Empty Hit Locations
description: |
A hit can target a cell that was already empty (`grid[r][c] == 0`) or was emptied by a previous hit. In these cases, no bricks fall.
You must check the **original** grid to determine if a brick existed at the hit location, not the modified grid during reverse processing.
wrong_approach: "Assuming all hits target existing bricks"
correct_approach: "Check original grid values and return 0 for empty cells"
- title: Off-by-One in Fallen Count
description: |
When you add a brick back and count the newly stable bricks, you must subtract 1 because the brick you just added is counted in the new total but wasn't a "fallen" brick — it was the one that was hit.
`fallen = new_stable_count - old_stable_count - 1`
wrong_approach: "Counting the hit brick itself as a fallen brick"
correct_approach: "Subtract 1 from the difference to exclude the hit brick"
key_takeaways:
- "**Reverse time trick**: When Union-Find needs to handle deletions, reverse the problem to convert deletions into additions"
- "**Virtual node pattern**: A single virtual node connecting multiple boundary elements simplifies connectivity queries to O(1)"
- "**Union-Find with size tracking**: Storing component sizes enables efficient counting of connected elements"
- "**Amortised efficiency**: Union-Find operations are nearly O(1) with path compression and union by rank, making it ideal for incremental connectivity problems"
time_complexity: "O(h × α(m×n) + m×n) where h is the number of hits and α is the inverse Ackermann function. The α term is effectively constant (≤ 4 for any practical input), so this is essentially O(h + m×n)."
space_complexity: "O(m × n) for the Union-Find parent and size arrays, plus O(h) for storing results."
solutions:
- approach_name: Reverse Union-Find
is_optimal: true
code: |
class UnionFind:
def __init__(self, n: int):
self.parent = list(range(n))
self.size = [1] * n
def find(self, x: int) -> int:
# Path compression: point directly to root
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x: int, y: int) -> None:
# Union by size: attach smaller tree to larger
px, py = self.find(x), self.find(y)
if px == py:
return
if self.size[px] < self.size[py]:
px, py = py, px
self.parent[py] = px
self.size[px] += self.size[py]
def get_size(self, x: int) -> int:
return self.size[self.find(x)]
def hit_bricks(grid: list[list[int]], hits: list[list[int]]) -> list[int]:
m, n = len(grid), len(grid[0])
CEILING = m * n # Virtual node representing the ceiling
# Step 1: Create a copy and remove all hit bricks
grid_copy = [row[:] for row in grid]
for r, c in hits:
grid_copy[r][c] = 0
# Step 2: Build Union-Find on the final state (after all hits)
uf = UnionFind(m * n + 1)
def index(r: int, c: int) -> int:
return r * n + c
# Connect remaining bricks
for r in range(m):
for c in range(n):
if grid_copy[r][c] == 1:
# Top row connects to ceiling
if r == 0:
uf.union(index(r, c), CEILING)
# Connect to adjacent bricks (only right and down)
if r > 0 and grid_copy[r - 1][c] == 1:
uf.union(index(r, c), index(r - 1, c))
if c > 0 and grid_copy[r][c - 1] == 1:
uf.union(index(r, c), index(r, c - 1))
# Step 3: Process hits in reverse, adding bricks back
result = []
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
for r, c in reversed(hits):
# If original grid had no brick here, nothing falls
if grid[r][c] == 0:
result.append(0)
continue
# Count bricks connected to ceiling before adding this brick
prev_ceiling_size = uf.get_size(CEILING)
# Add the brick back
grid_copy[r][c] = 1
# Connect to ceiling if in top row
if r == 0:
uf.union(index(r, c), CEILING)
# Connect to all adjacent bricks
for dr, dc in directions:
nr, nc = r + dr, c + dc
if 0 <= nr < m and 0 <= nc < n and grid_copy[nr][nc] == 1:
uf.union(index(r, c), index(nr, nc))
# Count bricks connected to ceiling after adding
new_ceiling_size = uf.get_size(CEILING)
# Fallen bricks = newly connected bricks - 1 (the brick we added)
fallen = max(0, new_ceiling_size - prev_ceiling_size - 1)
result.append(fallen)
# Reverse to get correct order
return result[::-1]
explanation: |
**Time Complexity:** O(h × α(m×n) + m×n) — Building initial Union-Find takes O(m×n), and each of the h hits requires constant Union-Find operations (amortised).
**Space Complexity:** O(m × n) — For the Union-Find data structure storing parent and size arrays.
The algorithm reverses time to convert brick removal into brick addition, which Union-Find handles efficiently. A virtual ceiling node simplifies stability checks to a single find operation.
- approach_name: Forward BFS Simulation
is_optimal: false
code: |
from collections import deque
def hit_bricks_bfs(grid: list[list[int]], hits: list[list[int]]) -> list[int]:
m, n = len(grid), len(grid[0])
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
def count_stable() -> set[tuple[int, int]]:
"""BFS from top row to find all stable bricks."""
stable = set()
queue = deque()
# Start from all bricks in top row
for c in range(n):
if grid[0][c] == 1:
stable.add((0, c))
queue.append((0, c))
# BFS to find all connected bricks
while queue:
r, c = queue.popleft()
for dr, dc in directions:
nr, nc = r + dr, c + dc
if (0 <= nr < m and 0 <= nc < n and
grid[nr][nc] == 1 and (nr, nc) not in stable):
stable.add((nr, nc))
queue.append((nr, nc))
return stable
result = []
for r, c in hits:
# If no brick at hit location, nothing falls
if grid[r][c] == 0:
result.append(0)
continue
# Remove the brick
grid[r][c] = 0
# Find all stable bricks after removal
stable_before = sum(row.count(1) for row in grid)
stable_after = len(count_stable())
# Fallen = total bricks - stable bricks
fallen = stable_before - stable_after
result.append(fallen)
# Actually remove fallen bricks from grid
stable_set = count_stable()
for i in range(m):
for j in range(n):
if grid[i][j] == 1 and (i, j) not in stable_set:
grid[i][j] = 0
return result
explanation: |
**Time Complexity:** O(h × m × n) — Each hit requires a BFS that can visit all cells, and we have h hits.
**Space Complexity:** O(m × n) — For the BFS queue and stable set.
This approach simulates each hit forward: remove a brick, run BFS to find stable bricks, and count the fallen ones. While correct and intuitive, it's too slow for the given constraints and will result in TLE on LeetCode. Included to illustrate why the reverse Union-Find approach is necessary.

View File

@@ -0,0 +1,180 @@
title: Broken Calculator
slug: broken-calculator
difficulty: medium
leetcode_id: 991
leetcode_url: https://leetcode.com/problems/broken-calculator/
categories:
- math
patterns:
- greedy
description: |
There is a broken calculator that has the integer `startValue` on its display initially. In one operation, you can:
- Multiply the number on display by `2`, or
- Subtract `1` from the number on display.
Given two integers `startValue` and `target`, return *the minimum number of operations needed to display* `target` *on the calculator*.
constraints: |
- `1 <= startValue, target <= 10^9`
examples:
- input: "startValue = 2, target = 3"
output: "2"
explanation: "Use double operation and then decrement operation {2 -> 4 -> 3}."
- input: "startValue = 5, target = 8"
output: "2"
explanation: "Use decrement and then double {5 -> 4 -> 8}."
- input: "startValue = 3, target = 10"
output: "3"
explanation: "Use double, decrement and double {3 -> 6 -> 5 -> 10}."
explanation:
intuition: |
The forward direction (from `startValue` to `target`) involves choices that are hard to evaluate: should you double now or subtract first? The decision tree branches exponentially.
The key insight is to **think backwards**: work from `target` back to `startValue`. In reverse, the operations become:
- Divide by `2` (inverse of multiply)
- Add `1` (inverse of subtract)
Why does this help? Because now there's a **greedy rule**: if `target` is even, we *must* divide by 2 (it's always better than adding 1 multiple times). If `target` is odd, we have no choice but to add 1 first to make it even.
Think of it like this: imagine you're trying to reduce a number to a smaller one. Dividing by 2 is a powerful operation that halves the distance quickly. Adding 1 is weak but necessary when division isn't possible. By always choosing division when available, we minimise the total operations.
approach: |
We solve this using a **Reverse Greedy Approach**:
**Step 1: Handle the trivial case**
- If `startValue >= target`, we can only subtract, so return `startValue - target`
&nbsp;
**Step 2: Work backwards from target**
- While `target > startValue`:
- If `target` is even: divide by 2 (one operation)
- If `target` is odd: add 1 to make it even (one operation)
- Increment the operation counter
&nbsp;
**Step 3: Add remaining difference**
- After the loop, `target <= startValue`
- Add `startValue - target` to the operation count (these are the subtractions needed in the forward direction, or additions in reverse)
&nbsp;
**Step 4: Return the total**
- Return the accumulated operation count
&nbsp;
This greedy approach is optimal because dividing by 2 is always the most efficient way to reduce a number, and we only add 1 when forced to by odd numbers.
common_pitfalls:
- title: Forward Simulation Trap
description: |
A tempting approach is to simulate forward from `startValue` to `target`, using BFS or recursion to explore all possible paths. This leads to exponential time complexity.
With constraints up to `10^9`, this approach will cause **Time Limit Exceeded** or **Memory Limit Exceeded**. The state space is too large to explore exhaustively.
wrong_approach: "BFS or recursion exploring all forward paths"
correct_approach: "Work backwards with greedy decisions"
- title: Forgetting the Base Case
description: |
When `startValue >= target`, you might try to apply the backward algorithm, but division doesn't help when you're already above the target.
For example, `startValue = 10, target = 5`: the answer is simply `10 - 5 = 5` subtractions. No multiplication/division is useful here because multiplying only takes you further from the target.
wrong_approach: "Applying the same logic regardless of start vs target"
correct_approach: "Handle startValue >= target as a special case"
- title: Integer Overflow Concerns
description: |
When working backwards, you only divide and add 1, so values decrease or increase by 1. There's no overflow risk.
However, if you tried a forward approach with multiplication, values could overflow quickly. This is another reason the backward approach is superior.
key_takeaways:
- "**Reverse thinking**: When forward decisions are complex, try working backwards where the choices may be clearer"
- "**Greedy with proof**: The greedy choice (always divide when even) is provably optimal because division is strictly more powerful than addition"
- "**Constraint awareness**: With `10^9` constraints, any exponential or even O(n) simulation would be too slow; we need O(log n)"
- "**Related problems**: This pattern of reversing operations appears in problems like *Reaching Points* and other math/greedy puzzles"
time_complexity: "O(log(target)). Each division halves the target, so we perform at most O(log(target)) divisions. The additions between divisions are bounded."
space_complexity: "O(1). We only use a few integer variables regardless of input size."
solutions:
- approach_name: Reverse Greedy
is_optimal: true
code: |
def broken_calc(start_value: int, target: int) -> int:
# If we're already at or above target, just subtract
if start_value >= target:
return start_value - target
operations = 0
# Work backwards from target to start_value
while target > start_value:
if target % 2 == 0:
# Even: divide by 2 (reverse of multiply)
target //= 2
else:
# Odd: add 1 (reverse of subtract)
target += 1
operations += 1
# Add the remaining difference (subtractions in forward direction)
return operations + (start_value - target)
explanation: |
**Time Complexity:** O(log(target)) — Each division halves the value, giving logarithmic iterations.
**Space Complexity:** O(1) — Only a few integer variables used.
By working backwards, we transform a complex decision problem into a simple greedy one. When target is even, dividing is always optimal. When odd, we must add 1 first. This guarantees the minimum operations.
- approach_name: BFS (Suboptimal)
is_optimal: false
code: |
from collections import deque
def broken_calc(start_value: int, target: int) -> int:
# BFS from start_value to target
# WARNING: This is too slow for large inputs!
if start_value >= target:
return start_value - target
queue = deque([(start_value, 0)]) # (current_value, operations)
visited = {start_value}
while queue:
current, ops = queue.popleft()
# Try multiply by 2
doubled = current * 2
if doubled == target:
return ops + 1
if doubled < target * 2 and doubled not in visited:
visited.add(doubled)
queue.append((doubled, ops + 1))
# Try subtract 1
decremented = current - 1
if decremented == target:
return ops + 1
if decremented > 0 and decremented not in visited:
visited.add(decremented)
queue.append((decremented, ops + 1))
return -1 # Should never reach here
explanation: |
**Time Complexity:** O(2^n) in worst case — Exponential state space exploration.
**Space Complexity:** O(2^n) — Storing visited states.
This BFS approach explores all possible paths forward. While conceptually correct, it's far too slow for the given constraints (`target` up to `10^9`). Included to illustrate why the greedy reverse approach is necessary.

View File

@@ -0,0 +1,187 @@
title: Buddy Strings
slug: buddy-strings
difficulty: easy
leetcode_id: 859
leetcode_url: https://leetcode.com/problems/buddy-strings/
categories:
- strings
- hash-tables
patterns:
- two-pointers
description: |
Given two strings `s` and `goal`, return `true` *if you can swap two letters in* `s` *so the result is equal to* `goal`, *otherwise, return* `false`.
Swapping letters is defined as taking two indices `i` and `j` (0-indexed) such that `i != j` and swapping the characters at `s[i]` and `s[j]`.
For example, swapping at indices `0` and `2` in `"abcd"` results in `"cbad"`.
constraints: |
- `1 <= s.length, goal.length <= 2 * 10^4`
- `s` and `goal` consist of lowercase letters
examples:
- input: 's = "ab", goal = "ba"'
output: "true"
explanation: "You can swap s[0] = 'a' and s[1] = 'b' to get \"ba\", which is equal to goal."
- input: 's = "ab", goal = "ab"'
output: "false"
explanation: "The only letters you can swap are s[0] = 'a' and s[1] = 'b', which results in \"ba\" != goal."
- input: 's = "aa", goal = "aa"'
output: "true"
explanation: "You can swap s[0] = 'a' and s[1] = 'a' to get \"aa\", which is equal to goal."
explanation:
intuition: |
Think of this problem as a **character mismatch puzzle**. You have two strings and exactly one swap to make them equal.
The key insight is that a valid swap creates a very specific pattern: if two strings differ, they must differ at **exactly two positions**, and the characters at those positions must be **swapped mirrors** of each other. For example, if `s = "ab"` and `goal = "ba"`, position 0 has `'a'` vs `'b'` and position 1 has `'b'` vs `'a'` — a perfect cross-swap.
But there's a subtle edge case: what if the strings are **already identical**? You still must perform exactly one swap. The only way to swap two characters and end up with the same string is if you swap **two identical characters**. This means the string must have at least one repeated character (like `"aa"` or `"aab"`).
So the problem reduces to two distinct cases:
1. **Strings differ**: Find exactly 2 mismatched positions where swapping fixes both
2. **Strings are identical**: Check if any character appears more than once
approach: |
We solve this by handling the two cases separately:
**Step 1: Check length equality**
- If `len(s) != len(goal)`, return `False` immediately
- Swapping characters can never change string length
&nbsp;
**Step 2: Handle identical strings**
- If `s == goal`, we need to check if a "self-swap" is possible
- This requires at least one duplicate character in `s`
- Use a set to detect duplicates: if `len(set(s)) < len(s)`, duplicates exist
- Return `True` if duplicates exist, `False` otherwise
&nbsp;
**Step 3: Find mismatched positions**
- Iterate through both strings simultaneously
- Collect indices where `s[i] != goal[i]`
- Store these in a list called `diff`
&nbsp;
**Step 4: Validate the swap**
- If `len(diff) != 2`, return `False` (must have exactly 2 mismatches)
- Let the two mismatch indices be `i` and `j`
- Check if swapping fixes both: `s[i] == goal[j]` and `s[j] == goal[i]`
- Return `True` if both conditions hold, `False` otherwise
common_pitfalls:
- title: Forgetting the Identical Strings Case
description: |
When `s == goal`, many solutions incorrectly return `False` because "no swap is needed."
But the problem requires you to **perform exactly one swap**. If `s = "ab"` and `goal = "ab"`, swapping any two distinct characters gives `"ba" != "ab"`, so the answer is `False`.
However, if `s = "aa"` and `goal = "aa"`, swapping the two `'a'` characters still gives `"aa" == "aa"`, so the answer is `True`.
Always check for duplicate characters when strings are identical.
wrong_approach: "Return False when s == goal"
correct_approach: "Check for duplicate characters when s == goal"
- title: Not Checking for Exactly Two Differences
description: |
Some solutions only verify that swapping works at the first two mismatched positions without confirming there are exactly two mismatches.
For example, with `s = "abcd"` and `goal = "badc"`, positions 0-1 form a valid swap pair (`'a'`/`'b'`), but positions 2-3 also differ (`'c'`/`'d'`). One swap cannot fix four differences!
Always count the total number of mismatched positions and verify it equals exactly 2.
wrong_approach: "Only check if first two mismatches can be swapped"
correct_approach: "Verify exactly 2 mismatches exist before checking swap validity"
- title: Ignoring Length Mismatch
description: |
If `s` and `goal` have different lengths, no amount of swapping within `s` can make them equal.
This should be the first check to avoid index-out-of-bounds errors when comparing characters.
wrong_approach: "Start comparing characters without length check"
correct_approach: "Return False immediately if lengths differ"
key_takeaways:
- "**Case analysis**: Breaking problems into distinct cases (identical vs different strings) simplifies logic"
- "**Duplicate detection**: Using `len(set(s)) < len(s)` is a clean O(n) way to check for duplicates"
- "**Exactly-k pattern**: When a problem says 'exactly k operations', verify both minimum and maximum constraints"
- "**Edge case awareness**: The identical strings case is a classic interview trap — always consider what happens when inputs are equal"
time_complexity: "O(n). We traverse both strings once to find mismatches, and potentially build a set of characters."
space_complexity: "O(n). In the worst case, we store all characters in a set when checking for duplicates. The diff list uses O(1) space since it contains at most 2 elements."
solutions:
- approach_name: Single Pass with Case Analysis
is_optimal: true
code: |
def buddy_strings(s: str, goal: str) -> bool:
# Different lengths? Impossible to match with a swap
if len(s) != len(goal):
return False
# Identical strings: need duplicate chars for valid self-swap
if s == goal:
# If any char appears twice, we can swap them
return len(set(s)) < len(s)
# Find all positions where characters differ
diff = []
for i in range(len(s)):
if s[i] != goal[i]:
diff.append(i)
# Must have exactly 2 differences for one swap to work
if len(diff) != 2:
return False
# Check if swapping these positions makes strings equal
i, j = diff
return s[i] == goal[j] and s[j] == goal[i]
explanation: |
**Time Complexity:** O(n) — Single pass through both strings.
**Space Complexity:** O(n) — Set storage for duplicate detection in the identical strings case.
We handle two cases: (1) identical strings require a duplicate character for a valid self-swap, and (2) different strings need exactly two mismatched positions that form a valid swap pair.
- approach_name: Counter-Based Approach
is_optimal: false
code: |
from collections import Counter
def buddy_strings(s: str, goal: str) -> bool:
# Different lengths can never match
if len(s) != len(goal):
return False
# Character frequencies must match for any swap to work
if Counter(s) != Counter(goal):
return False
# Find positions where strings differ
diff = [i for i in range(len(s)) if s[i] != goal[i]]
# Case 1: Identical strings - need a duplicate for self-swap
if len(diff) == 0:
return len(s) != len(set(s))
# Case 2: Exactly 2 differences - verify swap works
if len(diff) == 2:
i, j = diff
return s[i] == goal[j] and s[j] == goal[i]
# Any other number of differences: impossible
return False
explanation: |
**Time Complexity:** O(n) — Building Counter objects and finding differences.
**Space Complexity:** O(k) — Where k is the alphabet size (26 for lowercase letters).
This approach first verifies character frequencies match (a necessary condition), then handles the same cases as the optimal solution. The Counter check is redundant when we verify the swap, but provides an early exit for obviously invalid cases.

View File

@@ -0,0 +1,252 @@
title: Build a Matrix With Conditions
slug: build-a-matrix-with-conditions
difficulty: hard
leetcode_id: 2392
leetcode_url: https://leetcode.com/problems/build-a-matrix-with-conditions/
categories:
- arrays
- graphs
patterns:
- topological-sort
description: |
You are given a **positive** integer `k`. You are also given:
- a 2D integer array `rowConditions` of size `n` where `rowConditions[i] = [above_i, below_i]`, and
- a 2D integer array `colConditions` of size `m` where `colConditions[i] = [left_i, right_i]`.
The two arrays contain integers from `1` to `k`.
You have to build a `k x k` matrix that contains each of the numbers from `1` to `k` **exactly once**. The remaining cells should have the value `0`.
The matrix should also satisfy the following conditions:
- The number `above_i` should appear in a **row** that is strictly **above** the row at which the number `below_i` appears for all `i` from `0` to `n - 1`.
- The number `left_i` should appear in a **column** that is strictly **left** of the column at which the number `right_i` appears for all `i` from `0` to `m - 1`.
Return *any matrix that satisfies the conditions*. If no answer exists, return an empty matrix.
constraints: |
- `2 <= k <= 400`
- `1 <= rowConditions.length, colConditions.length <= 10^4`
- `rowConditions[i].length == colConditions[i].length == 2`
- `1 <= above_i, below_i, left_i, right_i <= k`
- `above_i != below_i`
- `left_i != right_i`
examples:
- input: "k = 3, rowConditions = [[1,2],[3,2]], colConditions = [[2,1],[3,2]]"
output: "[[3,0,0],[0,0,1],[0,2,0]]"
explanation: "Number 1 is in row 1 and number 2 is in row 2, so 1 is above 2. Number 3 is in row 0 and number 2 is in row 2, so 3 is above 2. For columns: 2 is in column 1 and 1 is in column 2, so 2 is left of 1. Number 3 is in column 0 and 2 is in column 1, so 3 is left of 2. Note that multiple valid answers exist."
- input: "k = 3, rowConditions = [[1,2],[2,3],[3,1],[2,3]], colConditions = [[2,1]]"
output: "[]"
explanation: "From the first two conditions, 3 has to be below 1. But the third condition needs 3 to be above 1. No matrix can satisfy all conditions, so we return an empty matrix."
explanation:
intuition: |
Imagine you're arranging people in a grid for a photo, but with specific ordering rules: "Person A must be in a row above Person B" and "Person C must be in a column left of Person D."
The key insight is that **row positions and column positions are independent problems**. Where a number sits vertically (its row) has nothing to do with where it sits horizontally (its column). This means we can solve for row ordering and column ordering separately, then combine them.
Each ordering problem is actually a **topological sort**. The condition `[above, below]` means `above → below` is a directed edge — "above must come before below." If we can find a valid ordering (no cycles), we get a sequence like `[3, 1, 2]` meaning "3 goes in row 0, 1 in row 1, 2 in row 2."
Think of it like course prerequisites: if Course A requires Course B, you must take B first. If there's a circular dependency (A needs B, B needs C, C needs A), it's impossible — that's a cycle. Similarly, if our row or column conditions form a cycle, no valid matrix exists.
Once we have valid row and column orderings, placing numbers is straightforward: each number's position is `(row_order[num], col_order[num])`.
approach: |
We solve this by performing **two independent topological sorts** — one for row constraints and one for column constraints.
**Step 1: Build adjacency lists for both dimensions**
- Create a graph for row conditions where edge `(a, b)` means `a` must be in a row above `b`
- Create a separate graph for column conditions where edge `(a, b)` means `a` must be in a column left of `b`
- Track in-degrees (number of incoming edges) for each node in both graphs
&nbsp;
**Step 2: Perform topological sort using Kahn's algorithm (BFS)**
- Initialise a queue with all nodes having in-degree `0` (no prerequisites)
- Process nodes one by one: add to result, then reduce in-degree of neighbours
- When a neighbour's in-degree becomes `0`, add it to the queue
- If the result doesn't contain all `k` numbers, a cycle exists — return empty matrix
&nbsp;
**Step 3: Create position mappings from topological orders**
- Convert the row order `[3, 1, 2]` to a mapping: `{3: 0, 1: 1, 2: 2}` (number → row index)
- Do the same for column order
- Numbers not in any condition can be placed in any remaining position
&nbsp;
**Step 4: Construct the matrix**
- Create a `k x k` matrix filled with zeros
- For each number from `1` to `k`, place it at position `(row_pos[num], col_pos[num])`
- Return the completed matrix
common_pitfalls:
- title: Forgetting Cycle Detection
description: |
The most critical part is detecting when no valid ordering exists. A cycle in row conditions like `[1,2], [2,3], [3,1]` makes it impossible: 1 must be above 2, 2 above 3, but 3 above 1 creates a contradiction.
If your topological sort doesn't return exactly `k` elements, there's a cycle. Always check the length of your result before proceeding.
wrong_approach: "Assuming topological sort always succeeds"
correct_approach: "Check if topological sort returns exactly k elements; if not, return []"
- title: Treating Row and Column as One Problem
description: |
It might seem like you need to simultaneously satisfy both row and column constraints. This makes the problem feel impossibly complex.
The insight is that row position and column position are **completely independent**. A number's row is determined only by row conditions; its column only by column conditions. Solve them separately, then combine.
wrong_approach: "Trying to solve both dimensions together"
correct_approach: "Separate topological sorts for rows and columns"
- title: Handling Numbers Without Constraints
description: |
Not every number from `1` to `k` appears in the conditions. For example, if `k = 5` but conditions only mention numbers `1, 2, 3`, then `4` and `5` have no constraints.
These unconstrained numbers have in-degree `0` from the start and can be placed anywhere in the ordering. Make sure your topological sort processes all `k` numbers, not just those in the conditions.
wrong_approach: "Only processing numbers mentioned in conditions"
correct_approach: "Initialise in-degree for all numbers 1 to k, starting unconstrained ones with 0"
- title: Confusing Edge Direction
description: |
The condition `[above, below]` means `above` must come **before** `below` in the ordering. The directed edge goes `above → below`, not the reverse.
Getting this backwards will produce invalid orderings where constraints are violated.
wrong_approach: "Edge from below to above"
correct_approach: "Edge from above to below (prerequisite points to dependent)"
key_takeaways:
- "**Decomposition**: When constraints affect independent dimensions, solve each separately and combine"
- "**Topological sort for ordering**: Whenever you have \"A must come before B\" constraints, think topological sort"
- "**Cycle detection is essential**: A cycle in directed constraints means no valid solution exists"
- "**Kahn's algorithm (BFS)**: Process nodes with no remaining prerequisites; if not all nodes processed, there's a cycle"
time_complexity: "O(k + n + m). Building graphs takes O(n + m) for the conditions. Topological sort visits each node once and each edge once, giving O(k + n + m). Matrix construction is O(k^2) but dominated by k being at most 400."
space_complexity: "O(k + n + m). We store adjacency lists with up to n + m edges total, in-degree arrays of size k, and the k × k output matrix."
solutions:
- approach_name: Topological Sort (Kahn's Algorithm)
is_optimal: true
code: |
from collections import deque
def build_matrix(k: int, row_conditions: list[list[int]], col_conditions: list[list[int]]) -> list[list[int]]:
def topological_sort(conditions: list[list[int]]) -> list[int]:
# Build adjacency list and in-degree count
graph = [[] for _ in range(k + 1)]
in_degree = [0] * (k + 1)
for a, b in conditions:
graph[a].append(b) # a must come before b
in_degree[b] += 1
# Start with all nodes that have no prerequisites
queue = deque()
for node in range(1, k + 1):
if in_degree[node] == 0:
queue.append(node)
order = []
while queue:
node = queue.popleft()
order.append(node)
# Process all neighbours
for neighbour in graph[node]:
in_degree[neighbour] -= 1
if in_degree[neighbour] == 0:
queue.append(neighbour)
# If we didn't process all nodes, there's a cycle
return order if len(order) == k else []
# Get valid orderings for rows and columns
row_order = topological_sort(row_conditions)
col_order = topological_sort(col_conditions)
# If either has a cycle, no valid matrix exists
if not row_order or not col_order:
return []
# Convert orders to position mappings
row_pos = {num: idx for idx, num in enumerate(row_order)}
col_pos = {num: idx for idx, num in enumerate(col_order)}
# Build the matrix
matrix = [[0] * k for _ in range(k)]
for num in range(1, k + 1):
matrix[row_pos[num]][col_pos[num]] = num
return matrix
explanation: |
**Time Complexity:** O(k + n + m) — We process each node and edge once in topological sort, plus O(k^2) for matrix construction.
**Space Complexity:** O(k + n + m) — Adjacency lists, in-degree arrays, and the output matrix.
We perform two independent topological sorts using Kahn's algorithm (BFS-based). Each sort produces an ordering where all "must come before" constraints are satisfied. If either sort fails to include all k numbers, a cycle exists and we return an empty matrix. Otherwise, we use the orderings to determine each number's row and column position.
- approach_name: Topological Sort (DFS)
is_optimal: false
code: |
def build_matrix(k: int, row_conditions: list[list[int]], col_conditions: list[list[int]]) -> list[list[int]]:
def topological_sort_dfs(conditions: list[list[int]]) -> list[int]:
# Build adjacency list
graph = [[] for _ in range(k + 1)]
for a, b in conditions:
graph[a].append(b)
# 0 = unvisited, 1 = visiting (in current path), 2 = visited
state = [0] * (k + 1)
order = []
has_cycle = False
def dfs(node: int) -> None:
nonlocal has_cycle
if has_cycle or state[node] == 2:
return
if state[node] == 1: # Back edge = cycle
has_cycle = True
return
state[node] = 1 # Mark as visiting
for neighbour in graph[node]:
dfs(neighbour)
state[node] = 2 # Mark as visited
order.append(node) # Add to order after all descendants
# Run DFS from all nodes
for node in range(1, k + 1):
if state[node] == 0:
dfs(node)
if has_cycle:
return []
# Reverse to get correct topological order
return order[::-1]
row_order = topological_sort_dfs(row_conditions)
col_order = topological_sort_dfs(col_conditions)
if not row_order or not col_order:
return []
row_pos = {num: idx for idx, num in enumerate(row_order)}
col_pos = {num: idx for idx, num in enumerate(col_order)}
matrix = [[0] * k for _ in range(k)]
for num in range(1, k + 1):
matrix[row_pos[num]][col_pos[num]] = num
return matrix
explanation: |
**Time Complexity:** O(k + n + m) — Same as BFS approach, visiting each node and edge once.
**Space Complexity:** O(k + n + m) — Plus O(k) recursion stack depth in the worst case.
This DFS-based approach uses three states to detect cycles: unvisited, visiting (in current DFS path), and visited. A back edge to a "visiting" node indicates a cycle. Nodes are added to the order after all their descendants are processed, then reversed to get the correct topological order. While equally correct, the BFS approach is often preferred as it avoids recursion depth issues.

View File

@@ -0,0 +1,163 @@
title: Build an Array With Stack Operations
slug: build-an-array-with-stack-operations
difficulty: easy
leetcode_id: 1441
leetcode_url: https://leetcode.com/problems/build-an-array-with-stack-operations/
categories:
- arrays
- stack
patterns:
- two-pointers
description: |
You are given an integer array `target` and an integer `n`.
You have an empty stack with the two following operations:
- **`"Push"`**: pushes an integer to the top of the stack.
- **`"Pop"`**: removes the integer on the top of the stack.
You also have a stream of the integers in the range `[1, n]`.
Use the two stack operations to make the numbers in the stack (from the bottom to the top) equal to `target`. You should follow the following rules:
- If the stream of the integers is not empty, pick the next integer from the stream and push it to the top of the stack.
- If the stack is not empty, pop the integer at the top of the stack.
- If, at any moment, the elements in the stack (from the bottom to the top) are equal to `target`, do not read new integers from the stream and do not do more operations on the stack.
Return *the stack operations needed to build* `target` *following the mentioned rules*. If there are multiple valid answers, return **any of them**.
constraints: |
- `1 <= target.length <= 100`
- `1 <= n <= 100`
- `1 <= target[i] <= n`
- `target` is strictly increasing
examples:
- input: "target = [1,3], n = 3"
output: '["Push","Push","Pop","Push"]'
explanation: "Read 1 from stream and push (s = [1]). Read 2 and push (s = [1,2]). Pop 2 (s = [1]). Read 3 and push (s = [1,3])."
- input: "target = [1,2,3], n = 3"
output: '["Push","Push","Push"]'
explanation: "Read 1, 2, 3 from stream and push each. All target values appear consecutively, so no pops needed."
- input: "target = [1,2], n = 4"
output: '["Push","Push"]'
explanation: "Read 1 and push, read 2 and push. Stack equals target, so stop. Do not read 3 or 4."
explanation:
intuition: |
Imagine you're standing at a conveyor belt that delivers numbers `1, 2, 3, ...` in order. You have a stack beside you, and your goal is to end up with specific numbers in the stack (the `target` array).
The key insight is that you **must process numbers in order** from the stream. You cannot skip ahead to grab a number you want — you must push each number as it arrives. If a number isn't in your target, you immediately pop it off.
Think of it like sorting mail: every piece of mail comes to your desk in sequence. You keep the ones addressed to you (push and keep) and discard the ones that aren't (push then pop). The "strictly increasing" constraint on `target` means you'll never need to rearrange — you just need to filter.
The question becomes: for each number in the stream, is it in the target or not? If it is, keep it. If it's not, push and immediately pop (to simulate "processing" it without keeping it).
approach: |
We solve this by **simulating the stream** and comparing each number against the target:
**Step 1: Initialise variables**
- `operations`: An empty list to store our "Push" and "Pop" commands
- `target_idx`: A pointer starting at `0` to track which target element we're looking for next
&nbsp;
**Step 2: Iterate through the stream from 1 to the largest target value**
- We only need to iterate up to `target[-1]` (the last element), not `n`, since we stop when the stack matches the target
- For each number `i` in the stream:
- Always append `"Push"` (we must push every number from the stream)
- If `i` equals `target[target_idx]`, it's a number we want to keep — increment `target_idx`
- If `i` does not equal `target[target_idx]`, it's not wanted — append `"Pop"` to remove it
&nbsp;
**Step 3: Return the operations list**
- The list now contains the exact sequence of operations to build the target stack
common_pitfalls:
- title: Iterating Up to n Instead of target[-1]
description: |
The problem states you can read integers up to `n`, but you should **stop as soon as the stack matches the target**. If `target = [1, 2]` and `n = 100`, you only need to process numbers 1 and 2.
Reading beyond the last target element would add unnecessary operations and could produce incorrect output (the problem explicitly says to stop when the stack equals target).
wrong_approach: "Iterate from 1 to n"
correct_approach: "Iterate from 1 to target[-1]"
- title: Forgetting to Pop Unwanted Numbers
description: |
Every number from the stream must be pushed (you cannot skip numbers). If the number isn't in the target, you must immediately pop it.
For `target = [1, 3]`, when you encounter `2`, you cannot ignore it. You must push 2 (stream rules), then pop 2 (to remove it from the stack).
wrong_approach: "Only push target numbers"
correct_approach: "Push every number, pop non-target numbers immediately"
- title: Using Actual Stack Data Structure
description: |
You don't need to maintain an actual stack. Since the target is strictly increasing, you just need to track which target element you're looking for next using a pointer.
This keeps the solution simple and avoids unnecessary memory usage.
wrong_approach: "Maintain a stack and check contents"
correct_approach: "Use a pointer to track position in target"
key_takeaways:
- "**Simulation pattern**: When a problem describes a process with rules, simulate it step by step"
- "**Two-pointer technique**: One pointer for the stream (loop variable), one for the target array"
- "**Strictly increasing constraint**: This guarantees we never need to backtrack or reorder — a hint that a single pass works"
- "**Optimise the range**: Only iterate as far as needed (`target[-1]`), not the full range (`n`)"
time_complexity: "O(k) where k is the maximum value in `target`. We iterate from 1 to `target[-1]`, performing constant work per iteration."
space_complexity: "O(k) for the output array. The number of operations is at most `2 * target[-1]` (push and optional pop for each number)."
solutions:
- approach_name: Stream Simulation
is_optimal: true
code: |
def build_array(target: list[int], n: int) -> list[str]:
operations = []
target_idx = 0 # Points to the next target element we need
# Only iterate up to the largest target value
for i in range(1, target[-1] + 1):
# Every number from stream must be pushed
operations.append("Push")
if i == target[target_idx]:
# This number is in our target, keep it
target_idx += 1
else:
# This number isn't wanted, pop it immediately
operations.append("Pop")
return operations
explanation: |
**Time Complexity:** O(k) where k = `target[-1]` — we iterate from 1 to the largest target value.
**Space Complexity:** O(k) — the output list contains at most 2k operations.
We simulate reading from the stream. Each number gets pushed. If it matches our current target, we keep it and move to the next target. If not, we pop it immediately. The strictly increasing nature of target ensures we never need to revisit decisions.
- approach_name: Set-Based Lookup
is_optimal: false
code: |
def build_array(target: list[int], n: int) -> list[str]:
operations = []
target_set = set(target) # For O(1) lookup
max_target = target[-1]
for i in range(1, max_target + 1):
operations.append("Push")
if i not in target_set:
# Number not needed, remove it
operations.append("Pop")
return operations
explanation: |
**Time Complexity:** O(k) where k = `target[-1]` — same iteration range.
**Space Complexity:** O(m + k) where m is the length of target (for the set) and k for the output.
This approach uses a set for O(1) membership testing instead of a pointer. While equally correct, it uses extra space for the set. The pointer approach is more elegant since the strictly increasing property makes sequential comparison sufficient.

View File

@@ -0,0 +1,154 @@
title: Build Array from Permutation
slug: build-array-from-permutation
difficulty: easy
leetcode_id: 1920
leetcode_url: https://leetcode.com/problems/build-array-from-permutation/
categories:
- arrays
patterns:
- matrix-traversal
description: |
Given a **zero-based permutation** `nums` (**0-indexed**), build an array `ans` of the **same length** where `ans[i] = nums[nums[i]]` for each `0 <= i < nums.length` and return it.
A **zero-based permutation** `nums` is an array of **distinct** integers from `0` to `nums.length - 1` (**inclusive**).
constraints: |
- `1 <= nums.length <= 1000`
- `0 <= nums[i] < nums.length`
- The elements in `nums` are **distinct**
examples:
- input: "nums = [0,2,1,5,3,4]"
output: "[0,1,2,4,5,3]"
explanation: "ans[i] = nums[nums[i]]. For i=0: nums[nums[0]] = nums[0] = 0. For i=1: nums[nums[1]] = nums[2] = 1. And so on."
- input: "nums = [5,0,1,2,3,4]"
output: "[4,5,0,1,2,3]"
explanation: "For i=0: nums[nums[0]] = nums[5] = 4. For i=1: nums[nums[1]] = nums[0] = 5. And so on."
explanation:
intuition: |
Think of this problem as following a chain of pointers. Each element in the array tells you "go look at this index," and then you report what you find there.
Imagine the array as a treasure map where each location contains coordinates to another location. For each position `i`, you first look at `nums[i]` to get a new location, then you go to that location and record what you find. The result is your answer for position `i`.
The key insight is that since `nums` is a permutation of `0` to `n-1`, every index is valid and every value appears exactly once. This guarantees that `nums[nums[i]]` will never go out of bounds — every value in the array is a valid index.
For the follow-up challenge of O(1) space, we can encode two values in each position using the mathematical property that for any value `a` and `b` where both are less than `n`, we can store `a + n * b` and later extract `a` as `value % n` and `b` as `value // n`.
approach: |
We solve this using **direct simulation**:
**Step 1: Create a result array**
- Initialise an empty array `ans` of the same length as `nums`
- We'll fill each position with the required value
&nbsp;
**Step 2: Apply the transformation**
- For each index `i` from `0` to `n-1`:
- Look up `nums[i]` to get the intermediate index
- Look up `nums[nums[i]]` to get the final value
- Store this value in `ans[i]`
&nbsp;
**Step 3: Return the result**
- Return the completed `ans` array
&nbsp;
For the **O(1) space** solution, we use a clever encoding trick:
**Step 1: Encode both values in each position**
- For each index `i`, we want to store both the original `nums[i]` and the new `nums[nums[i]]`
- Use the formula: `nums[i] = nums[i] + n * (nums[nums[i]] % n)`
- The `% n` is crucial because some positions may already have been encoded
&nbsp;
**Step 2: Decode to get final values**
- For each index `i`, extract the encoded value using integer division: `nums[i] = nums[i] // n`
common_pitfalls:
- title: Modifying Array While Reading
description: |
In the O(1) space approach, if you simply set `nums[i] = nums[nums[i]]`, you corrupt the array for later indices that need to read the original values.
For example, with `nums = [0, 2, 1]`, if you set `nums[0] = nums[nums[0]] = nums[0] = 0`, then when computing `nums[1]`, you need `nums[nums[1]] = nums[2] = 1`, which is still correct. But consider `nums = [1, 0]`: setting `nums[0] = nums[1] = 0` means when computing `nums[1]`, you need `nums[nums[1]] = nums[0]`, but `nums[0]` is now `0` instead of `1`.
wrong_approach: "Overwriting values directly in the input array"
correct_approach: "Either use a separate result array or encode both values together"
- title: Forgetting Modulo When Encoding
description: |
When using the encoding trick, the value at `nums[i]` might already be encoded (contains both old and new values). Reading `nums[nums[i]]` directly would give the wrong result.
Always use `nums[nums[i] % n]` to extract the original value, since `original_value = encoded_value % n`.
wrong_approach: "nums[i] += n * nums[nums[i]]"
correct_approach: "nums[i] += n * (nums[nums[i]] % n)"
- title: Integer Overflow Concerns
description: |
In some languages, `nums[i] + n * encoded_value` could overflow if `n` is large. With the constraint `n <= 1000`, the maximum encoded value is `999 + 1000 * 999 = 999,999`, which fits comfortably in a 32-bit integer.
In Python, integers have arbitrary precision, so this isn't a concern, but be mindful in languages like C++ or Java.
key_takeaways:
- "**Index chaining**: When array values represent indices, you can follow chains with `arr[arr[i]]`"
- "**Encoding two values**: The formula `a + n * b` stores two values `< n` in one integer, extractable via `% n` and `// n`"
- "**Permutation properties**: A permutation of `0` to `n-1` guarantees all indices are valid and all values are distinct"
- "**In-place modification**: When modifying an array in-place, ensure you can still recover original values when needed"
time_complexity: "O(n). We iterate through the array once (or twice for the O(1) space solution)."
space_complexity: "O(n) for the straightforward solution using a result array. O(1) for the encoding approach that modifies the input in-place."
solutions:
- approach_name: Direct Simulation
is_optimal: true
code: |
def buildArray(nums: list[int]) -> list[int]:
n = len(nums)
# Create result array with the same length
ans = [0] * n
# For each index, follow the chain: i -> nums[i] -> nums[nums[i]]
for i in range(n):
ans[i] = nums[nums[i]]
return ans
explanation: |
**Time Complexity:** O(n) — Single pass through the array.
**Space Complexity:** O(n) — We create a new array of size n.
This is the most straightforward approach: create a new array and fill each position by following the index chain. Clean, readable, and efficient.
- approach_name: In-Place with Encoding
is_optimal: false
code: |
def buildArray(nums: list[int]) -> list[int]:
n = len(nums)
# First pass: encode both old and new values
# nums[i] = old_value + n * new_value
for i in range(n):
# Use % n to get original value (in case already encoded)
new_value = nums[nums[i] % n] % n
nums[i] = nums[i] + n * new_value
# Second pass: decode to get only the new values
for i in range(n):
nums[i] = nums[i] // n
return nums
explanation: |
**Time Complexity:** O(n) — Two passes through the array.
**Space Complexity:** O(1) — Only modifies the input array in-place.
This clever approach stores two values in each position using the encoding `old + n * new`. The first pass encodes, the second pass decodes. While it achieves O(1) space, it's harder to understand and modifies the input, which may not always be desirable.

View File

@@ -0,0 +1,246 @@
title: Build Array Where You Can Find The Maximum Exactly K Comparisons
slug: build-array-where-you-can-find-the-maximum-exactly-k-comparisons
difficulty: hard
leetcode_id: 1420
leetcode_url: https://leetcode.com/problems/build-array-where-you-can-find-the-maximum-exactly-k-comparisons/
categories:
- arrays
- dynamic-programming
patterns:
- dynamic-programming
- prefix-sum
description: |
You are given three integers `n`, `m` and `k`. Consider the following algorithm to find the maximum element of an array of positive integers:
```
maximum = arr[0]
search_cost = 1
for i in range(1, len(arr)):
if arr[i] > maximum:
maximum = arr[i]
search_cost += 1
```
You should build the array `arr` which has the following properties:
- `arr` has exactly `n` integers.
- `1 <= arr[i] <= m` where `(0 <= i < n)`.
- After applying the mentioned algorithm to `arr`, the value `search_cost` is equal to `k`.
Return *the number of ways* to build the array `arr` under the mentioned conditions. As the answer may grow large, the answer **must be** computed modulo `10^9 + 7`.
constraints: |
- `1 <= n <= 50`
- `1 <= m <= 100`
- `0 <= k <= n`
examples:
- input: "n = 2, m = 3, k = 1"
output: "6"
explanation: "The possible arrays are [1, 1], [2, 1], [2, 2], [3, 1], [3, 2], [3, 3]"
- input: "n = 5, m = 2, k = 3"
output: "0"
explanation: "There are no possible arrays that satisfy the mentioned conditions."
- input: "n = 9, m = 1, k = 1"
output: "1"
explanation: "The only possible array is [1, 1, 1, 1, 1, 1, 1, 1, 1]"
explanation:
intuition: |
Imagine building an array element by element, keeping track of two things: the **current maximum** value we've placed, and how many times we've **increased** that maximum (the search cost).
The key insight is that each element we place falls into one of two categories:
1. **Non-increasing**: The element is less than or equal to the current maximum. This doesn't change the search cost.
2. **New maximum**: The element is strictly greater than the current maximum. This increases the search cost by 1.
Think of it like climbing stairs where each "step up" to a new maximum counts as a comparison. We need exactly `k` such steps across all `n` positions.
This naturally leads to a 3D dynamic programming approach where we track:
- How many positions we've filled (`i`)
- What the current maximum value is (`max_val`)
- How many times we've found a new maximum (`cost`)
For each state, we count how many ways we can reach it by considering all possible values for the next element.
approach: |
We use **3D Dynamic Programming** with prefix sum optimisation:
**Step 1: Define the DP state**
- `dp[i][max_val][cost]`: Number of ways to build an array of length `i` where:
- The current maximum element is `max_val`
- We've encountered exactly `cost` new maximums so far
&nbsp;
**Step 2: Establish base cases**
- For a single-element array (length 1), any value `v` from 1 to `m` gives us exactly 1 search cost
- `dp[1][v][1] = 1` for all `v` in `[1, m]`
&nbsp;
**Step 3: Define transitions**
For each position `i` from 2 to `n`:
- **Adding a non-increasing element**: If the current max is `max_val`, we can add any value from 1 to `max_val` without changing the cost. This contributes `max_val * dp[i-1][max_val][cost]` ways.
- **Adding a new maximum**: If we want the new max to be `new_max`, we can transition from any previous state where the max was less than `new_max`. This increases the cost by 1.
&nbsp;
**Step 4: Apply prefix sum optimisation**
The naive transition for adding a new maximum requires summing over all previous max values, giving O(m) per state. We can use prefix sums to compute these sums in O(1), reducing the overall complexity.
&nbsp;
**Step 5: Compute the answer**
- Sum `dp[n][max_val][k]` for all possible `max_val` from 1 to `m`
- Return the result modulo `10^9 + 7`
common_pitfalls:
- title: Forgetting the Modulo Operation
description: |
With constraints up to `n = 50` and `m = 100`, the number of valid arrays can be astronomically large. Forgetting to apply the modulo `10^9 + 7` at each step will cause integer overflow.
Always apply the modulo after every addition and multiplication in DP transitions.
wrong_approach: "Compute final answer, then apply modulo once"
correct_approach: "Apply modulo after each addition in transitions"
- title: O(n * m^2 * k) Time Limit Exceeded
description: |
A straightforward DP transition where we iterate over all previous max values for each new max results in O(m) per state transition. With states of size O(n * m * k) and O(m) transitions, this gives O(n * m^2 * k) complexity.
For `n = 50`, `m = 100`, `k = 50`, this is about 25 million operations per transition factor, which may be too slow.
Using prefix sums to precompute cumulative counts reduces transitions to O(1), bringing total complexity to O(n * m * k).
wrong_approach: "Nested loop over all previous max values"
correct_approach: "Prefix sum for O(1) transition lookups"
- title: Off-by-One in Search Cost
description: |
The search cost starts at 1 (the first element is always the initial maximum), not 0. Be careful when initialising base cases and when handling `k = 0`.
If `k = 0`, there are **no valid arrays** since placing even one element gives a search cost of at least 1.
wrong_approach: "Initialise cost from 0"
correct_approach: "Base case has cost = 1 for single-element arrays"
key_takeaways:
- "**3D DP for counting**: When counting arrangements with multiple constraints (length, max value, cost), use multi-dimensional DP where each dimension tracks one constraint"
- "**Prefix sum optimisation**: When DP transitions involve summing over a range of previous states, precompute prefix sums to reduce per-state transition cost from O(m) to O(1)"
- "**Modular arithmetic**: For counting problems with large answers, apply modulo at every step to prevent overflow"
- "**State definition is key**: Identifying the right state variables (position, current max, cost) makes the recurrence relation straightforward"
time_complexity: "O(n * m * k). We fill a 3D DP table of size `n * m * k`, and with prefix sum optimisation, each state transition takes O(1) time."
space_complexity: "O(n * m * k) for the DP table. Can be optimised to O(m * k) by only keeping two layers (current and previous position)."
solutions:
- approach_name: 3D Dynamic Programming with Prefix Sum
is_optimal: true
code: |
def num_of_arrays(n: int, m: int, k: int) -> int:
MOD = 10**9 + 7
# dp[i][max_val][cost] = number of ways to build array of length i
# with current max = max_val and search_cost = cost
# Using 1-indexed for max_val and cost for clarity
dp = [[[0] * (k + 2) for _ in range(m + 2)] for _ in range(n + 1)]
# Base case: arrays of length 1
# Any value v from 1 to m gives search_cost = 1
for v in range(1, m + 1):
dp[1][v][1] = 1
# Fill DP table
for i in range(2, n + 1):
# Prefix sum for transitioning to new maximum
# prefix[cost] = sum of dp[i-1][1..max_val-1][cost-1]
prefix = [0] * (k + 2)
for max_val in range(1, m + 1):
# Update prefix sums with previous max_val's contribution
for cost in range(1, k + 1):
prefix[cost] = (prefix[cost] + dp[i - 1][max_val - 1][cost - 1]) % MOD
for cost in range(1, k + 1):
# Case 1: Add element <= max_val (no cost increase)
# We can add any of 1..max_val, so multiply by max_val
ways = (dp[i - 1][max_val][cost] * max_val) % MOD
# Case 2: This position sets a new maximum
# We transition from states where old max < max_val
# and cost was (cost - 1)
ways = (ways + prefix[cost]) % MOD
dp[i][max_val][cost] = ways
# Sum all ways to build array of length n with search_cost = k
result = 0
for max_val in range(1, m + 1):
result = (result + dp[n][max_val][k]) % MOD
return result
explanation: |
**Time Complexity:** O(n * m * k) — We iterate through all states and use prefix sums for O(1) transitions.
**Space Complexity:** O(n * m * k) — Full 3D DP table storage.
The key optimisation is maintaining prefix sums as we iterate through `max_val`. When we're at `max_val`, the prefix sum already contains the cumulative count of all states with smaller max values, allowing us to compute the "new maximum" transition in O(1).
- approach_name: Space-Optimised DP
is_optimal: false
code: |
def num_of_arrays(n: int, m: int, k: int) -> int:
MOD = 10**9 + 7
# Only keep current and previous layers
# prev[max_val][cost] = ways for previous position
# curr[max_val][cost] = ways for current position
prev = [[0] * (k + 2) for _ in range(m + 2)]
curr = [[0] * (k + 2) for _ in range(m + 2)]
# Base case: length 1 arrays
for v in range(1, m + 1):
prev[v][1] = 1
# Build array position by position
for i in range(2, n + 1):
# Reset current layer
for max_val in range(m + 2):
for cost in range(k + 2):
curr[max_val][cost] = 0
# Prefix sum for new maximum transitions
prefix = [0] * (k + 2)
for max_val in range(1, m + 1):
# Update prefix with previous max_val
for cost in range(1, k + 1):
prefix[cost] = (prefix[cost] + prev[max_val - 1][cost - 1]) % MOD
for cost in range(1, k + 1):
# Non-increasing: multiply by max_val choices
ways = (prev[max_val][cost] * max_val) % MOD
# New maximum: use prefix sum
ways = (ways + prefix[cost]) % MOD
curr[max_val][cost] = ways
# Swap layers
prev, curr = curr, prev
# Sum final answers
result = 0
for max_val in range(1, m + 1):
result = (result + prev[max_val][k]) % MOD
return result
explanation: |
**Time Complexity:** O(n * m * k) — Same as the full DP solution.
**Space Complexity:** O(m * k) — We only store two layers instead of all `n` layers.
This optimisation works because each position only depends on the previous position. We alternate between two 2D arrays, reducing memory usage significantly for large `n`.

View File

@@ -0,0 +1,204 @@
title: Building Boxes
slug: building-boxes
difficulty: hard
leetcode_id: 1739
leetcode_url: https://leetcode.com/problems/building-boxes/
categories:
- math
- binary-search
patterns:
- binary-search
- greedy
description: |
You have a cubic storeroom where the width, length, and height of the room are all equal to `n` units. You are asked to place `n` boxes in this room where each box is a cube of unit side length. There are however some rules to placing the boxes:
- You can place the boxes anywhere on the floor.
- If box `x` is placed on top of box `y`, then each side of the four vertical sides of box `y` **must** either be adjacent to another box or to a wall.
Given an integer `n`, return *the **minimum** possible number of boxes touching the floor*.
constraints: |
- `1 <= n <= 10^9`
examples:
- input: "n = 3"
output: "3"
explanation: "Place three boxes in the corner of the room, all touching the floor."
- input: "n = 4"
output: "3"
explanation: "Place three boxes on the floor in the corner, with one box stacked on top of a corner box (which has all four sides supported by walls/boxes)."
- input: "n = 10"
output: "6"
explanation: "Place boxes in a stepped pyramid pattern in the corner. Six boxes touch the floor, supporting four more boxes above them."
explanation:
intuition: |
Imagine you're stacking boxes in the corner of a room. The corner is special because it provides two walls as support. A box can only support another box on top if all four of its vertical sides are covered — either by walls or other boxes.
Think of it like building a **stepped pyramid** in a corner. The optimal strategy is to build "layers" where:
- Layer 1 (bottom floor): boxes form a triangular staircase pattern in the corner
- Layer 2: boxes sit on top of layer 1, also forming a smaller triangle
- And so on...
The key insight is recognising that a complete pyramid of height `h` has:
- **Floor boxes**: `1 + 2 + 3 + ... + h = h(h+1)/2` (triangular number)
- **Total boxes**: `1 + 3 + 6 + ... + h(h+1)/2` (sum of triangular numbers = tetrahedral number)
The total boxes in a complete pyramid is `h(h+1)(h+2)/6` — this is the h<sup>th</sup> **tetrahedral number**.
Our strategy: find the largest complete pyramid that fits within `n` boxes, then add floor boxes one-by-one until we can fit all `n` boxes. Each additional floor box lets us stack a "column" of boxes on top of the existing structure.
approach: |
We solve this using **Mathematical Analysis + Greedy Addition**:
**Step 1: Find the largest complete pyramid**
- A complete pyramid of height `h` contains `h(h+1)(h+2)/6` total boxes
- Use binary search or iteration to find the largest `h` where this formula gives `<= n`
- Let `total` be the number of boxes in this complete pyramid
- Let `floor` be the floor boxes in this pyramid: `h(h+1)/2`
&nbsp;
**Step 2: Handle remaining boxes**
- Calculate `remaining = n - total` (boxes left to place)
- If `remaining == 0`, return `floor`
&nbsp;
**Step 3: Add floor boxes one at a time**
- Each new floor box at position `k` allows us to place `k` additional boxes (a column of height `k`)
- Add floor boxes with `k = 1, 2, 3, ...` until the cumulative column heights cover `remaining`
- This is because the k<sup>th</sup> additional floor box can support a column of `k` boxes above it
&nbsp;
**Step 4: Return the total floor boxes**
- Return `floor + (number of additional floor boxes needed)`
&nbsp;
The greedy addition works because adding floor boxes in order (1, 2, 3, ...) maximises the boxes we can stack per floor box added.
common_pitfalls:
- title: Ignoring the Corner Constraint
description: |
Without using the corner, you'd need far more floor boxes. The corner provides two walls, meaning boxes placed against both walls can immediately support boxes on top.
The optimal solution always uses the corner to minimise floor contact.
wrong_approach: "Placing boxes in the middle of the room"
correct_approach: "Build a pyramid in the corner using wall support"
- title: Integer Overflow with Large n
description: |
With `n` up to `10^9`, computing `h(h+1)(h+2)` can overflow 32-bit integers. In Python this isn't an issue, but in other languages you need to use 64-bit integers or be careful with the order of operations.
Also, the maximum pyramid height for `n = 10^9` is around 1817, so iteration is feasible.
wrong_approach: "Using 32-bit integers for intermediate calculations"
correct_approach: "Use 64-bit integers or Python's arbitrary precision"
- title: Off-by-One in Additional Boxes
description: |
When adding floor boxes after a complete pyramid, the k<sup>th</sup> additional floor box supports a column of `k` boxes (including itself as the base counted towards remaining). Be careful about whether you're counting the floor box itself.
The cumulative sum `1 + 2 + ... + k = k(k+1)/2` should cover `remaining`.
wrong_approach: "Miscounting how many boxes each floor box supports"
correct_approach: "The k-th new floor box adds capacity for k boxes total"
key_takeaways:
- "**Triangular and tetrahedral numbers**: Complete pyramids follow `h(h+1)(h+2)/6` — recognising this pattern is key"
- "**Greedy completion**: After the largest complete pyramid, add floor boxes greedily to cover remaining capacity"
- "**Geometric reasoning**: Visualising 3D stacking as layered 2D triangles simplifies the math"
- "**Binary search for inverse functions**: Finding the largest `h` where `f(h) <= n` is a classic binary search application"
time_complexity: "O(n^(1/3)). The pyramid height h is proportional to the cube root of n, and we iterate up to h steps."
space_complexity: "O(1). Only a few variables for tracking the pyramid dimensions and remaining boxes."
solutions:
- approach_name: Mathematical Analysis
is_optimal: true
code: |
def minimum_boxes(n: int) -> int:
# Step 1: Find the largest complete pyramid height
# Tetrahedral number: h(h+1)(h+2)/6
h = 0
total = 0 # Total boxes in complete pyramid
floor = 0 # Floor boxes in complete pyramid
# Build complete layers until we exceed n
while total + (h + 1) * (h + 2) // 2 <= n:
h += 1
# Add triangular number h(h+1)/2 to floor
floor += h
# Total boxes = sum of triangular numbers = h(h+1)(h+2)/6
total += h * (h + 1) // 2
# Step 2: Add individual floor boxes for remaining capacity
remaining = n - total
extra_floor = 0
capacity = 0 # How many boxes we can add with extra floor boxes
# Each new floor box k allows stacking k boxes
while capacity < remaining:
extra_floor += 1
capacity += extra_floor # The k-th floor box adds k capacity
return floor + extra_floor
explanation: |
**Time Complexity:** O(n^(1/3)) — The pyramid height is O(n^(1/3)), and we iterate through layers and additional floor boxes.
**Space Complexity:** O(1) — Only constant extra space for variables.
We first build the largest complete pyramid possible (where each layer is a triangular arrangement). Then we greedily add floor boxes one by one. Each additional floor box at position k lets us add a column of k boxes. We stop when we have enough capacity for all n boxes.
- approach_name: Binary Search
is_optimal: false
code: |
def minimum_boxes(n: int) -> int:
# Binary search for the largest complete pyramid height
def tetrahedral(h: int) -> int:
"""Total boxes in a complete pyramid of height h."""
return h * (h + 1) * (h + 2) // 6
def triangular(k: int) -> int:
"""k-th triangular number."""
return k * (k + 1) // 2
# Find largest h where tetrahedral(h) <= n
lo, hi = 0, 2000 # Max h for n=10^9 is ~1817
while lo < hi:
mid = (lo + hi + 1) // 2
if tetrahedral(mid) <= n:
lo = mid
else:
hi = mid - 1
h = lo
total = tetrahedral(h)
floor = triangular(h)
remaining = n - total
if remaining == 0:
return floor
# Binary search for minimum extra floor boxes needed
# k extra floor boxes give capacity 1+2+...+k = k(k+1)/2
lo, hi = 1, 2000
while lo < hi:
mid = (lo + hi) // 2
if triangular(mid) >= remaining:
hi = mid
else:
lo = mid + 1
return floor + lo
explanation: |
**Time Complexity:** O(log n) — Two binary searches with O(log(n^(1/3))) = O(log n) complexity.
**Space Complexity:** O(1) — Only constant extra space.
This approach uses binary search twice: first to find the largest complete pyramid, then to find the minimum additional floor boxes needed. While asymptotically faster, the constants are similar since n^(1/3) for n=10^9 is only ~1000. The iterative approach is often clearer and equally fast in practice.

View File

@@ -0,0 +1,239 @@
title: Building H2O
slug: building-h2o
difficulty: medium
leetcode_id: 1117
leetcode_url: https://leetcode.com/problems/building-h2o/
categories:
- concurrency
patterns:
- synchronization
description: |
There are two kinds of threads: `oxygen` and `hydrogen`. Your goal is to group these threads to form water molecules.
There is a barrier where each thread has to wait until a complete molecule can be formed. Hydrogen and oxygen threads will be given `releaseHydrogen` and `releaseOxygen` methods respectively, which will allow them to pass the barrier. These threads should pass the barrier in groups of three, and they must immediately bond with each other to form a water molecule.
You must guarantee that all the threads from one molecule bond **before** any other threads from the next molecule do.
In other words:
- If an oxygen thread arrives at the barrier when no hydrogen threads are present, it must wait for two hydrogen threads.
- If a hydrogen thread arrives at the barrier when no other threads are present, it must wait for an oxygen thread and another hydrogen thread.
We do not have to worry about matching the threads up explicitly; the threads do not necessarily know which other threads they are paired up with. The key is that threads pass the barriers in complete sets; thus, if we examine the sequence of threads that bond and divide them into groups of three, each group should contain one oxygen and two hydrogen threads.
Write synchronization code for oxygen and hydrogen molecules that enforces these constraints.
constraints: |
- `3 * n == water.length`
- `1 <= n <= 20`
- `water[i]` is either `'H'` or `'O'`
- There will be exactly `2 * n` `'H'` in `water`
- There will be exactly `n` `'O'` in `water`
examples:
- input: 'water = "HOH"'
output: '"HHO"'
explanation: '"HOH" and "OHH" are also valid answers.'
- input: 'water = "OOHHHH"'
output: '"HHOHHO"'
explanation: '"HOHHHO", "OHHHHO", "HHOHOH", "HOHHOH", "OHHHOH", "HHOOHH", "HOHOHH" and "OHHOHH" are also valid answers.'
explanation:
intuition: |
Think of this problem like a chemistry experiment where you need to assemble water molecules (H<sub>2</sub>O) on an assembly line.
Each water molecule requires exactly **two hydrogen atoms and one oxygen atom**. Imagine atoms (threads) arriving at a bonding station one by one in random order. The challenge is to hold each atom at the gate until exactly the right combination arrives, then release them together as a complete molecule.
The key insight is that we need a **coordination mechanism** that:
1. Counts how many hydrogen and oxygen atoms are waiting
2. Only allows atoms to proceed when a complete molecule (2H + 1O) can be formed
3. Ensures atoms from one molecule all bond before the next molecule starts forming
This is a classic **barrier synchronization** problem. We use semaphores (or similar synchronization primitives) as "tokens" — hydrogen threads need to acquire hydrogen tokens, oxygen threads need oxygen tokens, and we carefully control when these tokens become available.
approach: |
We solve this using **Semaphores with Barrier Synchronization**:
**Step 1: Set up synchronization primitives**
- `hydrogen_semaphore`: Controls how many hydrogen threads can proceed. Initially `2` (a molecule needs 2 hydrogens)
- `oxygen_semaphore`: Controls how many oxygen threads can proceed. Initially `1` (a molecule needs 1 oxygen)
- `barrier`: A barrier that waits for exactly 3 threads before releasing them all together
&nbsp;
**Step 2: Implement the hydrogen method**
- Acquire a permit from `hydrogen_semaphore` (blocks if no permits available)
- Call `releaseHydrogen()` to output 'H'
- Wait at the barrier for the other 2 threads in this molecule
- After the barrier releases, if this is the last thread (determined by barrier return value), replenish the semaphores for the next molecule
&nbsp;
**Step 3: Implement the oxygen method**
- Acquire a permit from `oxygen_semaphore` (blocks if no permits available)
- Call `releaseOxygen()` to output 'O'
- Wait at the barrier for the other 2 threads in this molecule
- After the barrier releases, if this is the last thread, replenish the semaphores for the next molecule
&nbsp;
**Step 4: Reset for next molecule**
- The barrier has a reset mechanism — when all 3 threads pass through, one of them (the "winner") is responsible for releasing 2 hydrogen permits and 1 oxygen permit for the next molecule
&nbsp;
The semaphores ensure the correct ratio (2H:1O), and the barrier ensures all three atoms of a molecule bond together before the next molecule can start forming.
common_pitfalls:
- title: Race Conditions Without Proper Synchronization
description: |
Without proper synchronization, threads can interleave incorrectly. For example, you might get output like "HHHHO" where 3 hydrogens bond before any oxygen.
Using simple counters with locks is error-prone because you need to handle the "wait until condition" pattern. Semaphores and barriers are purpose-built for this.
wrong_approach: "Using plain counters and busy-waiting"
correct_approach: "Use semaphores to control thread admission and barriers to synchronize release"
- title: Forgetting to Reset for Next Molecule
description: |
After a molecule is formed, the semaphores are exhausted (0 permits remaining). If you forget to replenish them, all subsequent threads will block forever.
The key is to detect when a complete molecule has passed the barrier, then add back 2 hydrogen permits and 1 oxygen permit.
wrong_approach: "Not replenishing semaphore permits after each molecule"
correct_approach: "Use barrier's return value to identify one thread responsible for resetting permits"
- title: Deadlock from Incorrect Semaphore Order
description: |
If you try to acquire multiple semaphores in different orders across threads, you risk deadlock. In this problem, each thread type only needs one semaphore, so this is less of a concern, but the pattern is important.
The barrier also helps prevent deadlock by ensuring all threads proceed together.
- title: Not Understanding Barrier Semantics
description: |
A barrier blocks threads until a specified number (3 in this case) have arrived. Then it releases all of them simultaneously. This is perfect for our "bond together" requirement.
In Python's `threading.Barrier`, the `wait()` method returns an integer from `0` to `parties-1`, with exactly one thread receiving `0`. This can be used to designate one thread to perform cleanup/reset actions.
key_takeaways:
- "**Semaphores for counting resources**: Use semaphores when you need to limit how many threads can access a resource (here, 2 hydrogen slots and 1 oxygen slot per molecule)"
- "**Barriers for synchronization points**: When multiple threads must reach a point before any can continue, barriers are the right tool"
- "**Producer-consumer with quotas**: This problem is a variant where the 'quota' is the molecular formula H<sub>2</sub>O"
- "**Thread coordination patterns**: This classic problem teaches synchronization primitives that apply to many real-world scenarios like connection pooling, batch processing, and resource allocation"
time_complexity: "O(n). Each of the `3n` threads performs constant-time operations (semaphore acquire/release, barrier wait)."
space_complexity: "O(1). We use a fixed number of synchronization primitives regardless of input size."
solutions:
- approach_name: Semaphores with Barrier
is_optimal: true
code: |
from threading import Semaphore, Barrier
class H2O:
def __init__(self):
# Allow 2 hydrogen threads to proceed per molecule
self.hydrogen_sem = Semaphore(2)
# Allow 1 oxygen thread to proceed per molecule
self.oxygen_sem = Semaphore(1)
# Wait for 3 threads (2H + 1O) before releasing
self.barrier = Barrier(3)
def hydrogen(self, releaseHydrogen: 'Callable[[], None]') -> None:
# Wait for a hydrogen slot to be available
self.hydrogen_sem.acquire()
# releaseHydrogen() outputs "H". Do not change or remove this line.
releaseHydrogen()
# Wait at barrier for the other atoms in this molecule
# Returns 0 for exactly one thread (the "winner")
winner = self.barrier.wait()
# One thread resets permits for the next molecule
if winner == 0:
self.hydrogen_sem.release()
self.hydrogen_sem.release()
self.oxygen_sem.release()
def oxygen(self, releaseOxygen: 'Callable[[], None]') -> None:
# Wait for an oxygen slot to be available
self.oxygen_sem.acquire()
# releaseOxygen() outputs "O". Do not change or remove this line.
releaseOxygen()
# Wait at barrier for the other atoms in this molecule
winner = self.barrier.wait()
# One thread resets permits for the next molecule
if winner == 0:
self.hydrogen_sem.release()
self.hydrogen_sem.release()
self.oxygen_sem.release()
explanation: |
**Time Complexity:** O(1) per thread — Each thread does constant work (acquire, release, barrier wait).
**Space Complexity:** O(1) — Fixed number of synchronization primitives.
The semaphores enforce the 2:1 ratio of hydrogen to oxygen, while the barrier ensures all three atoms of a molecule bond together. After each molecule forms, one thread replenishes the semaphores for the next molecule.
- approach_name: Condition Variables
is_optimal: false
code: |
from threading import Lock, Condition
class H2O:
def __init__(self):
self.lock = Lock()
self.condition = Condition(self.lock)
# Track how many of each type are waiting
self.hydrogen_count = 0
self.oxygen_count = 0
def hydrogen(self, releaseHydrogen: 'Callable[[], None]') -> None:
with self.condition:
# Wait until we can add a hydrogen to a molecule
# Need: hydrogen_count < 2 (room for more H)
while self.hydrogen_count >= 2:
self.condition.wait()
self.hydrogen_count += 1
# releaseHydrogen() outputs "H"
releaseHydrogen()
# Check if molecule is complete (2H + 1O)
if self.hydrogen_count == 2 and self.oxygen_count == 1:
# Reset for next molecule
self.hydrogen_count = 0
self.oxygen_count = 0
# Wake up waiting threads for next molecule
self.condition.notify_all()
def oxygen(self, releaseOxygen: 'Callable[[], None]') -> None:
with self.condition:
# Wait until we can add an oxygen to a molecule
while self.oxygen_count >= 1:
self.condition.wait()
self.oxygen_count += 1
# releaseOxygen() outputs "O"
releaseOxygen()
# Check if molecule is complete
if self.hydrogen_count == 2 and self.oxygen_count == 1:
# Reset for next molecule
self.hydrogen_count = 0
self.oxygen_count = 0
self.condition.notify_all()
explanation: |
**Time Complexity:** O(1) per thread — Each thread does constant work.
**Space Complexity:** O(1) — Fixed counters and synchronization objects.
This approach uses condition variables to coordinate threads. Each thread waits until there's room for its type in the current molecule, then checks if the molecule is complete. While this works, it's more verbose and easier to get wrong than the semaphore approach. The semaphore solution more directly expresses the "quota" concept.

View File

@@ -0,0 +1,221 @@
title: Bulb Switcher II
slug: bulb-switcher-ii
difficulty: medium
leetcode_id: 672
leetcode_url: https://leetcode.com/problems/bulb-switcher-ii/
categories:
- math
patterns:
- greedy
description: |
There is a room with `n` bulbs labelled from `1` to `n` that all are turned on initially, and **four buttons** on the wall. Each of the four buttons has a different functionality:
- **Button 1:** Flips the status of all the bulbs.
- **Button 2:** Flips the status of all the bulbs with even labels (i.e., `2, 4, ...`).
- **Button 3:** Flips the status of all the bulbs with odd labels (i.e., `1, 3, ...`).
- **Button 4:** Flips the status of all the bulbs with a label `j = 3k + 1` where `k = 0, 1, 2, ...` (i.e., `1, 4, 7, 10, ...`).
You must make **exactly** `presses` button presses in total. For each press, you may pick **any** of the four buttons to press.
Given the two integers `n` and `presses`, return *the number of **different possible statuses** after performing all* `presses` *button presses*.
constraints: |
- `1 <= n <= 1000`
- `0 <= presses <= 1000`
examples:
- input: "n = 1, presses = 1"
output: "2"
explanation: "Status can be: [off] by pressing button 1, or [on] by pressing button 2 (even bulbs only, so bulb 1 is unaffected)."
- input: "n = 2, presses = 1"
output: "3"
explanation: "Status can be: [off, off] by pressing button 1, [on, off] by pressing button 2, or [off, on] by pressing button 3."
- input: "n = 3, presses = 1"
output: "4"
explanation: "Status can be: [off, off, off] by button 1, [on, off, on] by button 2, [off, on, off] by button 3, or [off, on, on] by button 4."
explanation:
intuition: |
At first glance, this problem seems to require simulating all possible sequences of button presses — potentially exponential in complexity. But there's a beautiful mathematical insight that reduces this to a simple case analysis.
The key observations are:
**1. Pressing a button twice cancels out.** Each button is essentially a toggle. Pressing button 1 twice returns all bulbs to their original state. This means we only care about whether each button is pressed an **odd or even** number of times — effectively a binary choice per button.
**2. The order of presses doesn't matter.** Since each button acts independently (toggling certain bulbs), the final state depends only on *which* buttons were pressed an odd number of times, not the sequence.
**3. Only the first 3 bulbs determine uniqueness.** Bulbs follow repeating patterns based on the button rules:
- Button 1 affects all bulbs
- Button 2 affects even-labelled bulbs (2, 4, 6, ...)
- Button 3 affects odd-labelled bulbs (1, 3, 5, ...)
- Button 4 affects bulbs at positions `3k + 1` (1, 4, 7, ...)
The pattern of which buttons affect which bulbs repeats every 6 positions. However, to count *distinct* states, we only need to look at bulbs 1, 2, and 3 — the state of these three bulbs uniquely determines the overall configuration pattern.
With these insights, the problem becomes: given `presses` button presses, how many distinct combinations of (odd/even presses per button) can we achieve, considering we must use exactly `presses` total presses?
approach: |
We solve this using **Mathematical Case Analysis** based on the number of bulbs and presses:
**Step 1: Handle the base case**
- If `presses = 0`, no buttons are pressed, so all bulbs remain on — only **1** possible state
&nbsp;
**Step 2: Analyse by number of bulbs**
The answer depends on both `n` (bulbs) and `presses`. We consider three cases based on `n`:
&nbsp;
**Case: n = 1 (single bulb)**
- Only button 1 and button 4 affect bulb 1 (both toggle it)
- Button 2 has no effect (no even-labelled bulbs)
- Button 3 toggles bulb 1
- With 1 press: 2 states (on or off)
- With 2+ presses: 2 states (the parity determines on/off)
&nbsp;
**Case: n = 2 (two bulbs)**
- Bulb 1: affected by buttons 1, 3, 4
- Bulb 2: affected by buttons 1, 2
- With 1 press: 3 states (each of buttons 1, 2, 3 gives a unique state; button 4 = button 3 for these bulbs)
- With 2 presses: 4 states
- With 3+ presses: 4 states (all reachable combinations achieved)
&nbsp;
**Case: n >= 3 (three or more bulbs)**
- All four buttons have distinct effects
- With 1 press: 4 states (one per button)
- With 2 presses: 7 states
- With 3+ presses: 8 states (all 2^3 combinations of the 3 representative bulbs, though limited by parity)
&nbsp;
**Step 3: Return the result based on case analysis**
- Use conditional logic to return the correct count based on `n` and `presses`
common_pitfalls:
- title: Simulating All Button Sequences
description: |
A naive approach tries to simulate all possible sequences of `presses` button presses. With 4 buttons and up to 1000 presses, this means `4^1000` possibilities — astronomically large and completely infeasible.
The key insight is that order doesn't matter and pressing twice cancels out, so we only need to consider which buttons are pressed an odd number of times.
wrong_approach: "BFS/DFS exploring all press sequences"
correct_approach: "Mathematical case analysis based on parity"
- title: Considering All n Bulbs Independently
description: |
You might think you need to track the state of all `n` bulbs separately. But since the button patterns repeat, only the first 3 bulbs matter for determining distinct states.
For example, bulb 4 behaves exactly like bulb 1 under buttons 1-3, and bulbs 5, 6 follow bulbs 2, 3 respectively. Button 4 adds some variation but the pattern still cycles.
wrong_approach: "Tracking state of all n bulbs"
correct_approach: "Analyse only the first 3 bulbs as representatives"
- title: Forgetting the Parity Constraint
description: |
When `presses` is odd, you must press an odd total number of buttons. When `presses` is even, you must press an even total. This constrains which combinations are reachable.
For instance, with `presses = 1`, you can't press two buttons to cancel each other out — you must change something.
wrong_approach: "Ignoring how presses constrains reachable states"
correct_approach: "Consider parity when counting achievable combinations"
key_takeaways:
- "**Pattern recognition over simulation**: When the state space seems huge, look for mathematical structure that reduces the problem"
- "**Toggle operations have special properties**: Pressing twice cancels out, order doesn't matter — this is XOR-like behaviour"
- "**Periodicity reduces complexity**: When operations follow repeating patterns, analyse one period rather than the entire input"
- "**Case analysis is valid**: Sometimes the cleanest solution is carefully enumerating small cases rather than finding a general formula"
time_complexity: "O(1). The solution uses direct case analysis with constant-time conditionals, regardless of the input values."
space_complexity: "O(1). Only a few variables are used for the conditional logic."
solutions:
- approach_name: Mathematical Case Analysis
is_optimal: true
code: |
def flip_lights(n: int, presses: int) -> int:
# Base case: no presses means all bulbs stay on
if presses == 0:
return 1
# Only 1 bulb: can be on or off (2 states max)
if n == 1:
return 2
# 2 bulbs: limited combinations possible
if n == 2:
# 1 press: 3 distinct states achievable
if presses == 1:
return 3
# 2+ presses: all 4 combinations of 2 bulbs reachable
return 4
# n >= 3: pattern is determined by first 3 bulbs
if presses == 1:
# 4 buttons = 4 distinct single-press states
return 4
if presses == 2:
# 7 states achievable with exactly 2 presses
return 7
# 3+ presses: all 8 possible states of 3 bulbs reachable
return 8
explanation: |
**Time Complexity:** O(1) — Direct conditionals with no loops.
**Space Complexity:** O(1) — No additional data structures.
This solution leverages the mathematical insight that the problem reduces to a small number of cases based on `n` and `presses`. The key observations are:
1. Only the first 3 bulbs matter for distinguishing states
2. Button presses are commutative and self-cancelling
3. The achievable states are constrained by parity
- approach_name: BFS Simulation (For Verification)
is_optimal: false
code: |
def flip_lights(n: int, presses: int) -> int:
# Limit n to 3 since pattern repeats
n = min(n, 3)
# Start with all bulbs on (represented as tuple of 1s)
start = tuple([1] * n)
# Define button effects on first 3 bulbs
def press_button(state: tuple, button: int) -> tuple:
state = list(state)
for i in range(len(state)):
bulb_num = i + 1 # 1-indexed bulb number
if button == 1: # All bulbs
state[i] ^= 1
elif button == 2 and bulb_num % 2 == 0: # Even bulbs
state[i] ^= 1
elif button == 3 and bulb_num % 2 == 1: # Odd bulbs
state[i] ^= 1
elif button == 4 and (bulb_num - 1) % 3 == 0: # 3k+1 bulbs
state[i] ^= 1
return tuple(state)
# BFS to find all reachable states after exactly 'presses' presses
current_states = {start}
for _ in range(presses):
next_states = set()
for state in current_states:
for button in range(1, 5):
next_states.add(press_button(state, button))
current_states = next_states
return len(current_states)
explanation: |
**Time Complexity:** O(presses × states × buttons) — In practice O(presses) since states ≤ 8.
**Space Complexity:** O(states) — At most 8 states tracked.
This BFS approach simulates all possible button press sequences level by level. While not necessary for the final solution, it's useful for verifying the mathematical analysis and understanding why certain state counts are achieved. The key optimisation is limiting `n` to 3 since larger values don't create new distinct patterns.

View File

@@ -0,0 +1,153 @@
title: Bulb Switcher
slug: bulb-switcher
difficulty: medium
leetcode_id: 319
leetcode_url: https://leetcode.com/problems/bulb-switcher/
categories:
- math
patterns:
- greedy
description: |
There are `n` bulbs that are initially off. You first turn on all the bulbs, then you turn off every second bulb.
On the third round, you toggle every third bulb (turning on if it's off or turning off if it's on). For the i<sup>th</sup> round, you toggle every i<sup>th</sup> bulb. For the n<sup>th</sup> round, you only toggle the last bulb.
Return *the number of bulbs that are on after `n` rounds*.
constraints: |
- `0 <= n <= 10^9`
examples:
- input: "n = 3"
output: "1"
explanation: "At first, the three bulbs are [off, off, off]. After round 1: [on, on, on]. After round 2: [on, off, on]. After round 3: [on, off, off]. Only bulb 1 remains on."
- input: "n = 0"
output: "0"
explanation: "There are no bulbs, so zero bulbs are on."
- input: "n = 1"
output: "1"
explanation: "There is one bulb. After round 1, it is turned on and stays on."
explanation:
intuition: |
Imagine each bulb as a light switch that gets flipped every time its position number is divisible by the current round number.
Consider bulb `k`. It gets toggled in round `i` if and only if `i` divides `k` evenly. For example, bulb 6 gets toggled in rounds 1, 2, 3, and 6 — exactly four times (the divisors of 6). Since it starts OFF and is toggled an **even** number of times, it ends up OFF.
Now consider bulb 9. Its divisors are 1, 3, and 9 — that's **three** toggles (odd). Starting OFF and toggling an odd number of times leaves it ON.
Here's the key insight: **most numbers have divisors that come in pairs**. If `d` divides `n`, then so does `n/d`. For example, with 12: (1, 12), (2, 6), (3, 4) — three pairs, six divisors.
But **perfect squares are special**. For 9: the pair (3, 3) is really just one divisor counted once, giving us an odd total. Only perfect squares have an odd number of divisors, so only bulbs at positions 1, 4, 9, 16, 25, ... remain ON.
The answer is simply: how many perfect squares are there from 1 to `n`? That's `floor(sqrt(n))`.
approach: |
We solve this using a **Mathematical Insight** approach:
**Step 1: Understand the toggle pattern**
- Bulb `k` is toggled once for each divisor of `k`
- A bulb ends up ON if toggled an odd number of times
- Only perfect squares have an odd number of divisors
&nbsp;
**Step 2: Count perfect squares**
- Perfect squares from 1 to `n` are: 1, 4, 9, 16, ..., up to the largest ≤ `n`
- The count of perfect squares ≤ `n` is `floor(sqrt(n))`
&nbsp;
**Step 3: Return the result**
- Return `int(sqrt(n))` which gives us the count of bulbs that remain ON
&nbsp;
This mathematical reduction transforms an apparently complex simulation into a single operation, making it O(1) in time.
common_pitfalls:
- title: The Simulation Trap
description: |
A naive approach is to simulate all `n` rounds, toggling bulbs in an array:
- Create an array of `n` bulbs
- For each round `i` from 1 to `n`, toggle every i<sup>th</sup> bulb
- Count bulbs that are ON
This is **O(n²) time** and **O(n) space**. With `n` up to `10^9`, this would require billions of operations and gigabytes of memory — completely infeasible.
Even an optimised simulation counting toggles per bulb is O(n × d) where d is average divisors, still too slow.
wrong_approach: "Simulating n rounds with array manipulation"
correct_approach: "Mathematical formula: floor(sqrt(n))"
- title: Missing the Divisor Insight
description: |
Without recognising the divisor pattern, you might try various optimisations that still don't achieve O(1):
- Counting divisors for each bulb individually
- Looking for patterns by computing small cases
The breakthrough comes from understanding WHY perfect squares behave differently: their square root is paired with itself, creating an odd divisor count.
wrong_approach: "Counting divisors for each position"
correct_approach: "Recognise only perfect squares have odd divisors"
- title: Integer Overflow in Square Root
description: |
With `n` up to `10^9`, you need to ensure your square root calculation handles large integers correctly.
In Python, `int(n ** 0.5)` works well, but be aware that floating-point precision can occasionally cause off-by-one errors for very large perfect squares. Using `math.isqrt(n)` (Python 3.8+) provides exact integer square root.
wrong_approach: "Assuming float sqrt is always exact"
correct_approach: "Use math.isqrt() for guaranteed integer precision"
key_takeaways:
- "**Divisor counting**: The number of times a position is toggled equals its divisor count"
- "**Perfect square property**: Only perfect squares have an odd number of divisors, because the square root pairs with itself"
- "**Mathematical reduction**: Recognising patterns can reduce O(n²) simulations to O(1) formulas"
- "**Brainteaser technique**: When simulation is infeasible, look for mathematical invariants in the problem structure"
time_complexity: "O(1). Computing the integer square root is a constant-time operation."
space_complexity: "O(1). We only store the result, no additional data structures needed."
solutions:
- approach_name: Mathematical Formula
is_optimal: true
code: |
import math
def bulb_switch(n: int) -> int:
# Only perfect squares have odd number of divisors
# Count of perfect squares from 1 to n is floor(sqrt(n))
return math.isqrt(n)
explanation: |
**Time Complexity:** O(1) — Single square root computation.
**Space Complexity:** O(1) — No additional memory used.
The key insight is that bulb `k` ends up ON only if it has an odd number of divisors. Since divisors come in pairs (d, n/d) except when d = n/d (i.e., n is a perfect square), only perfect square positions remain ON. We count perfect squares ≤ n with `floor(sqrt(n))`.
- approach_name: Simulation (Brute Force)
is_optimal: false
code: |
def bulb_switch(n: int) -> int:
if n == 0:
return 0
# Create array of bulbs (False = off, True = on)
bulbs = [False] * (n + 1) # 1-indexed
# Simulate each round
for round_num in range(1, n + 1):
# Toggle every round_num-th bulb
for bulb in range(round_num, n + 1, round_num):
bulbs[bulb] = not bulbs[bulb]
# Count bulbs that are on
return sum(bulbs)
explanation: |
**Time Complexity:** O(n²) — For each of n rounds, we toggle up to n/i bulbs, summing to O(n log n) toggles, but the outer loop dominates.
**Space Complexity:** O(n) — Array to store bulb states.
This approach directly simulates the problem but is far too slow for large inputs. With n up to 10^9, this would require billions of operations. Included to illustrate why the mathematical approach is essential.

View File

@@ -0,0 +1,214 @@
title: Bulls and Cows
slug: bulls-and-cows
difficulty: medium
leetcode_id: 299
leetcode_url: https://leetcode.com/problems/bulls-and-cows/
categories:
- strings
- hash-tables
patterns:
- two-pointers
description: |
You are playing the **Bulls and Cows** game with your friend.
You write down a secret number and ask your friend to guess what the number is. When your friend makes a guess, you provide a hint with the following info:
- The number of "bulls", which are digits in the guess that are in the correct position.
- The number of "cows", which are digits in the guess that are in your secret number but are located in the wrong position. Specifically, the non-bull digits in the guess that could be rearranged such that they become bulls.
Given the secret number `secret` and your friend's guess `guess`, return *the hint for your friend's guess*.
The hint should be formatted as `"xAyB"`, where `x` is the number of bulls and `y` is the number of cows. Note that both `secret` and `guess` may contain duplicate digits.
constraints: |
- `1 <= secret.length, guess.length <= 1000`
- `secret.length == guess.length`
- `secret` and `guess` consist of digits only
examples:
- input: 'secret = "1807", guess = "7810"'
output: '"1A3B"'
explanation: "The digit '8' is in the correct position (bull). The digits '1', '0', and '7' appear in both strings but in wrong positions (cows)."
- input: 'secret = "1123", guess = "0111"'
output: '"1A1B"'
explanation: "The second '1' in the guess matches the second position in secret (bull). Only one of the remaining '1's can be a cow since secret only has one other '1' available."
explanation:
intuition: |
Think of this as a two-phase matching problem. First, we need to identify **exact matches** (bulls) — digits that are in the right position. Then, for the remaining unmatched digits, we count how many digits from the guess appear *somewhere* in the secret (cows).
The tricky part is handling duplicates correctly. Consider `secret = "1123"` and `guess = "0111"`. The guess has three `1`s, but secret only has two. After matching one `1` as a bull (at position 1), only one more `1` can be a cow — not two.
The key insight is to use **frequency counting**. After removing bulls, we count how many of each digit remains in both strings. For each digit, the number of cows is the *minimum* of its remaining count in secret and guess — you can only match as many as the lesser string has.
approach: |
We solve this using a **Single Pass with Frequency Arrays**:
**Step 1: Initialise counters**
- `bulls`: Counter for exact position matches
- `cows`: Counter for correct digits in wrong positions
- `secret_count`: Array of size 10 to track unmatched digit frequencies in secret
- `guess_count`: Array of size 10 to track unmatched digit frequencies in guess
&nbsp;
**Step 2: Iterate through both strings simultaneously**
- For each position `i`, compare `secret[i]` with `guess[i]`
- If they match, increment `bulls` — this is an exact match
- If they don't match:
- Increment the count for `secret[i]` in `secret_count`
- Increment the count for `guess[i]` in `guess_count`
&nbsp;
**Step 3: Calculate cows from frequency arrays**
- For each digit `0` through `9`:
- Add `min(secret_count[digit], guess_count[digit])` to `cows`
- This ensures we only count as many cows as can actually be matched
&nbsp;
**Step 4: Format and return the result**
- Return the string `"{bulls}A{cows}B"`
common_pitfalls:
- title: Counting Cows Before Removing Bulls
description: |
A common mistake is to count all matching digits as potential cows, then subtract bulls. This fails with duplicates.
For `secret = "1122"` and `guess = "2211"`: if you count all matching digits first (two `1`s and two `2`s = 4), then subtract bulls (0), you get 4 cows. But the correct answer is `"0A4B"` — the logic happens to work here, but the approach is fragile.
By separating bulls from the frequency counting, we avoid double-counting entirely.
wrong_approach: "Count all matching digits, then subtract bulls"
correct_approach: "Only add to frequency arrays when positions don't match"
- title: Not Handling Duplicate Digits Correctly
description: |
With `secret = "1123"` and `guess = "0111"`, the guess has three `1`s but secret only has two. After one `1` matches as a bull, only *one* more `1` can be a cow.
Using `min(secret_count[d], guess_count[d])` handles this automatically — if guess has 2 remaining `1`s but secret has only 1, we count just 1 cow.
wrong_approach: "Count all occurrences of matching digits as cows"
correct_approach: "Use minimum of frequencies from both strings"
- title: Using String Contains Instead of Frequency
description: |
Checking `if digit in secret` for each guess digit overcounts when duplicates exist. Each digit in secret can only match one digit in guess as a cow.
For `secret = "1000"` and `guess = "0111"`, using `in` would count all three `1`s as present in secret, but secret only has one `1` to match.
wrong_approach: "Check if each guess digit exists in secret string"
correct_approach: "Use frequency arrays to track available matches"
key_takeaways:
- "**Frequency counting** is essential when matching with duplicates — use `min(count_a, count_b)` to avoid overcounting"
- "**Separate exact matches first**: Process bulls before cows to avoid double-counting positions"
- "**Arrays vs Hash Maps**: For digit-only problems (0-9), a fixed-size array of 10 is cleaner and faster than a hash map"
- "This pattern extends to problems like **word matching** and **anagram detection** where position and frequency both matter"
time_complexity: "O(n). We iterate through both strings once to count bulls and populate frequency arrays, then iterate through 10 digits to sum cows."
space_complexity: "O(1). We use two arrays of fixed size 10 (for digits 0-9), which doesn't grow with input size."
solutions:
- approach_name: Single Pass with Frequency Arrays
is_optimal: true
code: |
def get_hint(secret: str, guess: str) -> str:
bulls = 0
cows = 0
# Frequency arrays for digits 0-9
secret_count = [0] * 10
guess_count = [0] * 10
# Single pass: count bulls and build frequency arrays
for s, g in zip(secret, guess):
if s == g:
# Exact match - it's a bull
bulls += 1
else:
# Not a match - add to frequency counts
secret_count[int(s)] += 1
guess_count[int(g)] += 1
# Count cows: for each digit, take minimum of both frequencies
for i in range(10):
cows += min(secret_count[i], guess_count[i])
return f"{bulls}A{cows}B"
explanation: |
**Time Complexity:** O(n) — One pass through the strings plus O(10) = O(1) for summing cows.
**Space Complexity:** O(1) — Two fixed-size arrays of 10 elements each.
We identify bulls in a single pass while simultaneously building frequency counts for non-bull digits. Then we calculate cows by taking the minimum frequency for each digit — this ensures we only count as many matches as both strings can provide.
- approach_name: Two Pass with Hash Maps
is_optimal: false
code: |
from collections import Counter
def get_hint(secret: str, guess: str) -> str:
bulls = 0
# First pass: count bulls and collect non-bull characters
secret_remaining = []
guess_remaining = []
for s, g in zip(secret, guess):
if s == g:
bulls += 1
else:
secret_remaining.append(s)
guess_remaining.append(g)
# Second pass: count cows using Counter intersection
secret_counter = Counter(secret_remaining)
guess_counter = Counter(guess_remaining)
# Sum of minimum counts for each digit
cows = sum((secret_counter & guess_counter).values())
return f"{bulls}A{cows}B"
explanation: |
**Time Complexity:** O(n) — Two passes through the data.
**Space Complexity:** O(n) — Storing remaining characters in lists.
This approach is more intuitive but less efficient. We first separate bulls from non-bulls, then use Python's Counter intersection (`&`) to find common elements with their minimum counts. The Counter intersection automatically handles the "take minimum" logic.
- approach_name: One Pass Optimised
is_optimal: true
code: |
def get_hint(secret: str, guess: str) -> str:
bulls = 0
cows = 0
counts = [0] * 10 # Combined count: positive = secret, negative = guess
for s, g in zip(secret, guess):
if s == g:
bulls += 1
else:
s_digit = int(s)
g_digit = int(g)
# If secret digit was previously seen in guess, it's a cow
if counts[s_digit] < 0:
cows += 1
# If guess digit was previously seen in secret, it's a cow
if counts[g_digit] > 0:
cows += 1
# Update counts: secret adds, guess subtracts
counts[s_digit] += 1
counts[g_digit] -= 1
return f"{bulls}A{cows}B"
explanation: |
**Time Complexity:** O(n) — Single pass with no second loop.
**Space Complexity:** O(1) — Single array of 10 elements.
This clever variant uses a single array where positive values represent unmatched secret digits and negative values represent unmatched guess digits. When we see a secret digit that was previously in guess (negative count) or a guess digit that was previously in secret (positive count), we've found a cow match. This avoids the second loop entirely.

View File

@@ -0,0 +1,216 @@
title: Burst Balloons
slug: burst-balloons
difficulty: hard
leetcode_id: 312
leetcode_url: https://leetcode.com/problems/burst-balloons/
categories:
- arrays
- dynamic-programming
patterns:
- dynamic-programming
description: |
You are given `n` balloons, indexed from `0` to `n - 1`. Each balloon is painted with a number on it represented by an array `nums`. You are asked to burst all the balloons.
If you burst the i<sup>th</sup> balloon, you will get `nums[i - 1] * nums[i] * nums[i + 1]` coins. If `i - 1` or `i + 1` goes out of bounds of the array, then treat it as if there is a balloon with a `1` painted on it.
Return *the maximum coins you can collect by bursting the balloons wisely*.
constraints: |
- `n == nums.length`
- `1 <= n <= 300`
- `0 <= nums[i] <= 100`
examples:
- input: "nums = [3,1,5,8]"
output: "167"
explanation: "nums = [3,1,5,8] → [3,5,8] → [3,8] → [8] → []. coins = 3×1×5 + 3×5×8 + 1×3×8 + 1×8×1 = 15 + 120 + 24 + 8 = 167"
- input: "nums = [1,5]"
output: "10"
explanation: "We can burst balloon 0 first (1×1×5 = 5) then balloon 1 (1×5×1 = 5), or burst balloon 1 first (1×5×1 = 5) then balloon 0 (1×1×1 = 1). The first order gives 10 coins total."
explanation:
intuition: |
This problem seems straightforward at first — just try all possible orders and pick the best one. But with `n` balloons, there are `n!` possible orders, making brute force infeasible.
The key insight comes from **thinking backwards**. Instead of asking "which balloon do I burst first?", ask "which balloon do I burst *last* in a given range?"
Imagine you have a subarray of balloons between indices `left` and `right`. If you decide that balloon `k` will be the **last balloon burst** in this range, something magical happens: when balloon `k` is finally burst, all other balloons between `left` and `right` are already gone. This means the coins you get for bursting `k` are simply `nums[left-1] * nums[k] * nums[right+1]` — the balloons at the boundaries of the range.
Think of it like this: you're building up the solution from the end state backwards. By fixing which balloon is burst last, you divide the problem into two independent subproblems — the balloons to the left of `k` and the balloons to the right of `k`. The subproblems are independent because when you burst balloons in the left portion, balloon `k` still exists as a boundary; same for the right portion.
This **interval DP** pattern transforms an exponential problem into a polynomial one by cleverly reframing the question.
approach: |
We solve this using **Interval Dynamic Programming**:
**Step 1: Pad the array with boundary values**
- Create a new array with `1` at both ends: `[1] + nums + [1]`
- This handles the boundary condition where out-of-bounds neighbours are treated as `1`
- The new array has length `n + 2`
&nbsp;
**Step 2: Define the DP state**
- `dp[left][right]`: Maximum coins obtainable by bursting all balloons in the **open interval** `(left, right)` — that is, balloons at indices `left+1` to `right-1`
- We use open intervals so the boundaries `left` and `right` represent virtual "walls" that haven't been burst yet
&nbsp;
**Step 3: Fill the DP table by interval length**
- Start with intervals of length 2 (no balloons inside) — these are base cases with value `0`
- For each interval length from 3 to `n + 2`:
- For each starting position `left`:
- Try each balloon `k` in `(left, right)` as the **last balloon to burst**
- When `k` is burst last, it sees `nums[left]` and `nums[right]` as neighbours
- Coins = `dp[left][k]` + `nums[left] * nums[k] * nums[right]` + `dp[k][right]`
- Take the maximum over all choices of `k`
&nbsp;
**Step 4: Return the result**
- Return `dp[0][n+1]`, which represents bursting all balloons in the original array
common_pitfalls:
- title: Thinking Forward Instead of Backward
description: |
The natural instinct is to simulate bursting balloons in order: "I burst balloon 2, now the array changes, then I burst balloon 0..." This leads to a complex state space where the order of previous bursts affects what neighbours exist.
The breakthrough is to think about which balloon is burst **last** in a range. When it's burst last, its neighbours are fixed — they're the boundaries of the range. This transforms a complex ordering problem into a clean interval DP.
wrong_approach: "Simulate bursting order and track array changes"
correct_approach: "Fix the last-burst balloon to create independent subproblems"
- title: Forgetting to Pad the Array
description: |
The problem states that out-of-bounds neighbours are treated as `1`. If you forget to pad the array with `1`s at both ends, your boundary calculations will be wrong.
With padding `[1] + nums + [1]`, the indices work cleanly: the original balloons are at indices `1` to `n`, and the virtual boundaries at `0` and `n+1` provide the correct multipliers.
wrong_approach: "Handle boundaries with special case logic"
correct_approach: "Pad array with 1s so boundaries are handled uniformly"
- title: Wrong Interval Iteration Order
description: |
The DP recurrence `dp[left][right] = max(dp[left][k] + ... + dp[k][right])` requires that smaller intervals are computed before larger ones.
If you iterate in the wrong order (e.g., left-to-right, then top-to-bottom), you'll reference DP values that haven't been computed yet. You must iterate by **interval length** from smallest to largest.
wrong_approach: "Iterate by row then column in DP table"
correct_approach: "Iterate by interval length from 2 to n+2"
key_takeaways:
- "**Interval DP pattern**: When subproblems overlap based on ranges, define `dp[left][right]` for intervals and iterate by length"
- "**Think backwards**: Asking 'what happens last?' can transform a complex ordering problem into manageable subproblems"
- "**Boundary padding**: Adding sentinel values at array boundaries often simplifies edge case handling"
- "**Foundation for harder problems**: This interval DP technique appears in problems like Matrix Chain Multiplication, Minimum Cost to Merge Stones, and Palindrome Partitioning"
time_complexity: "O(n^3). We have O(n^2) intervals, and for each interval we try O(n) choices for the last balloon to burst."
space_complexity: "O(n^2). We use a 2D DP table of size `(n+2) × (n+2)` to store the maximum coins for each interval."
solutions:
- approach_name: Interval DP (Bottom-Up)
is_optimal: true
code: |
def max_coins(nums: list[int]) -> int:
# Pad the array with 1s at both ends for boundary handling
nums = [1] + nums + [1]
n = len(nums)
# dp[left][right] = max coins from bursting all balloons in (left, right)
dp = [[0] * n for _ in range(n)]
# Iterate by interval length (gap between left and right)
for length in range(2, n): # length 2 means no balloons inside
for left in range(n - length):
right = left + length
# Try each balloon k as the last one to burst in this interval
for k in range(left + 1, right):
# Coins from bursting k last: left and right are its neighbours
coins = nums[left] * nums[k] * nums[right]
# Add coins from left subproblem and right subproblem
total = dp[left][k] + coins + dp[k][right]
dp[left][right] = max(dp[left][right], total)
# Result is for interval (0, n-1) which covers all original balloons
return dp[0][n - 1]
explanation: |
**Time Complexity:** O(n^3) — Three nested loops: O(n) for length, O(n) for left position, O(n) for choice of k.
**Space Complexity:** O(n^2) — The 2D DP table stores results for all intervals.
We iterate by interval length to ensure smaller subproblems are solved before larger ones. For each interval, we try every balloon as the "last to burst" and take the maximum. The key insight is that when balloon k is burst last in interval (left, right), its neighbours are exactly nums[left] and nums[right].
- approach_name: Memoised Recursion (Top-Down)
is_optimal: true
code: |
def max_coins(nums: list[int]) -> int:
# Pad array with boundary 1s
nums = [1] + nums + [1]
n = len(nums)
# Memoization cache
memo = {}
def dp(left: int, right: int) -> int:
# Base case: no balloons in the open interval
if right - left < 2:
return 0
if (left, right) in memo:
return memo[(left, right)]
result = 0
# Try each balloon k as the last to burst in (left, right)
for k in range(left + 1, right):
# k sees left and right as neighbours when burst last
coins = nums[left] * nums[k] * nums[right]
total = dp(left, k) + coins + dp(k, right)
result = max(result, total)
memo[(left, right)] = result
return result
return dp(0, n - 1)
explanation: |
**Time Complexity:** O(n^3) — Same as bottom-up; each of O(n^2) states computed once with O(n) work.
**Space Complexity:** O(n^2) — Memoization cache plus O(n) recursion stack.
This top-down approach is often more intuitive. We recursively solve for the maximum coins in interval (left, right) by trying each balloon as the last to burst. Memoization ensures each subproblem is solved only once.
- approach_name: Brute Force (Backtracking)
is_optimal: false
code: |
def max_coins(nums: list[int]) -> int:
def get_coins(arr: list[int], i: int) -> int:
"""Calculate coins for bursting balloon at index i."""
left = arr[i - 1] if i > 0 else 1
right = arr[i + 1] if i < len(arr) - 1 else 1
return left * arr[i] * right
def backtrack(arr: list[int]) -> int:
if not arr:
return 0
max_coins = 0
# Try bursting each balloon
for i in range(len(arr)):
# Coins from bursting this balloon
coins = get_coins(arr, i)
# Recurse on remaining balloons
remaining = arr[:i] + arr[i + 1:]
total = coins + backtrack(remaining)
max_coins = max(max_coins, total)
return max_coins
return backtrack(nums)
explanation: |
**Time Complexity:** O(n!) — We try all n! permutations of bursting order.
**Space Complexity:** O(n^2) — Recursion depth O(n) with O(n) array copies at each level.
This brute force approach tries every possible order of bursting balloons. While correct, it's far too slow for the constraint n ≤ 300. It's included to illustrate why the interval DP insight is essential — we go from factorial time to polynomial time.

View File

@@ -0,0 +1,229 @@
title: Bus Routes
slug: bus-routes
difficulty: hard
leetcode_id: 815
leetcode_url: https://leetcode.com/problems/bus-routes/
categories:
- graphs
- hash-tables
- arrays
patterns:
- bfs
description: |
You are given an array `routes` representing bus routes where `routes[i]` is a bus route that the i<sup>th</sup> bus repeats forever.
For example, if `routes[0] = [1, 5, 7]`, this means that the 0<sup>th</sup> bus travels in the sequence `1 -> 5 -> 7 -> 1 -> 5 -> 7 -> 1 -> ...` forever.
You will start at the bus stop `source` (you are not on any bus initially), and you want to go to the bus stop `target`. You can travel between bus stops by buses only.
Return *the least number of buses you must take to travel from* `source` *to* `target`. Return `-1` if it is not possible.
constraints: |
- `1 <= routes.length <= 500`
- `1 <= routes[i].length <= 10^5`
- All the values of `routes[i]` are **unique**
- `sum(routes[i].length) <= 10^5`
- `0 <= routes[i][j] < 10^6`
- `0 <= source, target < 10^6`
examples:
- input: "routes = [[1,2,7],[3,6,7]], source = 1, target = 6"
output: "2"
explanation: "The best strategy is take the first bus to the bus stop 7, then take the second bus to the bus stop 6."
- input: "routes = [[7,12],[4,5,15],[6],[15,19],[9,12,13]], source = 15, target = 12"
output: "-1"
explanation: "There is no way to reach stop 12 from stop 15 using the available bus routes."
explanation:
intuition: |
Imagine you're at a bus station looking at a transit map. Each bus route is a loop connecting certain stops, and you want to find the **minimum number of buses** to reach your destination.
The key insight is to think about this problem at the **bus level**, not the stop level. Why? Because once you board a bus, you can reach *any* stop on that route without taking another bus. The cost comes from **switching buses**, not from travelling between stops.
Think of it like this: each bus route is a "super-node" that connects all its stops. Your goal is to find the shortest path from any route containing the `source` stop to any route containing the `target` stop.
This naturally suggests **BFS (Breadth-First Search)**: start from all buses that pass through the source, explore all buses you can transfer to at one level, then two levels, and so on. BFS guarantees that when you first reach a bus containing the target, you've found the minimum number of buses.
approach: |
We solve this using **BFS on buses** with a stop-to-buses mapping:
**Step 1: Handle the edge case**
- If `source == target`, return `0` immediately (no buses needed)
&nbsp;
**Step 2: Build a stop-to-buses mapping**
- Create a dictionary where each stop maps to the list of bus indices that visit it
- This allows O(1) lookup to find which buses you can catch at any stop
&nbsp;
**Step 3: Initialise BFS**
- Start with all buses that contain the `source` stop
- Add these to a queue with distance `1` (taking one bus)
- Mark these buses as visited to avoid reprocessing
&nbsp;
**Step 4: BFS traversal**
- For each bus in the queue:
- Check all stops on this bus route
- If any stop equals `target`, return the current bus count
- For each stop, find all other buses that visit it (potential transfers)
- Add unvisited buses to the queue with distance + 1
&nbsp;
**Step 5: Return the result**
- If BFS completes without finding `target`, return `-1`
&nbsp;
This approach is efficient because we explore buses level by level. The first time we find a bus containing the target, we've found the shortest path.
common_pitfalls:
- title: BFS on Stops Instead of Buses
description: |
A natural first instinct is to run BFS on stops: from the source stop, explore all reachable stops, then their neighbours, and so on.
The problem with this approach is that it doesn't correctly count "number of buses". Moving between stops on the *same* bus shouldn't increment the counter, but standard BFS on stops would treat each edge equally.
While this can be fixed with careful bookkeeping, it's much cleaner to run BFS on buses directly, where each level naturally represents one bus taken.
wrong_approach: "BFS where each stop is a node"
correct_approach: "BFS where each bus route is a node"
- title: Not Handling source == target
description: |
If `source` and `target` are the same stop, the answer is `0` — you don't need any bus. This edge case should be checked before building any data structures.
Missing this check would cause the algorithm to incorrectly return `1` (if source is on some bus route) or even error out.
wrong_approach: "Starting BFS without checking if already at target"
correct_approach: "Return 0 immediately if source == target"
- title: Revisiting Buses
description: |
Without marking buses as visited, you might process the same bus multiple times from different stops. This leads to:
- Incorrect distance counting (processing a bus at level 3 when it was already found at level 1)
- TLE due to exponential exploration
Always mark a bus as visited when first adding it to the queue, not when processing it.
wrong_approach: "No visited set for buses"
correct_approach: "Mark buses visited when enqueuing"
- title: Building Stop Adjacency Graph
description: |
Some solutions try to build a graph where stops are nodes connected if they're on the same route. With up to `10^6` stops and routes containing `10^5` stops, this could create O(n^2) edges per route — far too slow.
The stop-to-buses mapping avoids this by keeping the mapping sparse.
wrong_approach: "Creating edges between all stops on each route"
correct_approach: "Map stops to bus indices instead"
key_takeaways:
- "**Graph abstraction**: The key insight is treating buses (not stops) as nodes — this aligns the BFS level with the metric we're minimising"
- "**BFS for shortest path**: When finding the minimum number of steps/transfers, BFS is the go-to algorithm because it explores level by level"
- "**Mapping for efficiency**: The stop-to-buses mapping enables O(1) transfer lookups without building a dense graph"
- "**Related problems**: This pattern applies to word ladders, gene mutations, and other problems where you traverse between sets of connected items"
time_complexity: "O(N * M) where N is the number of routes and M is the total number of stops across all routes. We build the mapping in O(M) and BFS visits each bus at most once, processing all its stops."
space_complexity: "O(N + M). The stop-to-buses mapping stores each (stop, bus) pair once, and we need O(N) for the visited set and queue."
solutions:
- approach_name: BFS on Buses
is_optimal: true
code: |
from collections import deque, defaultdict
def num_buses_to_destination(routes: list[list[int]], source: int, target: int) -> int:
# Edge case: already at destination
if source == target:
return 0
# Build mapping: stop -> list of bus indices
stop_to_buses = defaultdict(list)
for bus_idx, route in enumerate(routes):
for stop in route:
stop_to_buses[stop].append(bus_idx)
# BFS initialisation: start with all buses containing source
visited_buses = set()
queue = deque()
for bus_idx in stop_to_buses[source]:
visited_buses.add(bus_idx)
queue.append((bus_idx, 1)) # (bus index, number of buses taken)
# BFS traversal
while queue:
bus_idx, num_buses = queue.popleft()
# Check all stops on this bus route
for stop in routes[bus_idx]:
# Found the target!
if stop == target:
return num_buses
# Find all buses we can transfer to at this stop
for next_bus in stop_to_buses[stop]:
if next_bus not in visited_buses:
visited_buses.add(next_bus)
queue.append((next_bus, num_buses + 1))
# Target not reachable
return -1
explanation: |
**Time Complexity:** O(N * M) — We iterate through all stops to build the mapping, and BFS processes each bus once, checking all its stops.
**Space Complexity:** O(N + M) — The mapping stores each stop-bus pair, plus O(N) for visited set and queue.
We treat each bus route as a node in our BFS graph. Starting from buses containing the source, we explore transfers level by level. The first bus containing the target gives us the minimum count.
- approach_name: BFS with Stop-Level Tracking
is_optimal: false
code: |
from collections import deque, defaultdict
def num_buses_to_destination(routes: list[list[int]], source: int, target: int) -> int:
if source == target:
return 0
# Build stop to buses mapping
stop_to_buses = defaultdict(set)
for bus_idx, route in enumerate(routes):
for stop in route:
stop_to_buses[stop].add(bus_idx)
# BFS on stops, but track which bus we're on
visited_stops = {source}
visited_buses = set()
queue = deque([(source, 0)]) # (stop, buses_taken)
while queue:
stop, buses = queue.popleft()
# Try each bus at this stop
for bus_idx in stop_to_buses[stop]:
if bus_idx in visited_buses:
continue
visited_buses.add(bus_idx)
# Visit all stops on this bus
for next_stop in routes[bus_idx]:
if next_stop == target:
return buses + 1
if next_stop not in visited_stops:
visited_stops.add(next_stop)
queue.append((next_stop, buses + 1))
return -1
explanation: |
**Time Complexity:** O(N * M) — Similar to the optimal solution.
**Space Complexity:** O(N + S) where S is the number of unique stops — We track both visited stops and visited buses.
This alternative approach runs BFS on stops but correctly counts buses by marking entire bus routes as visited. It's slightly less elegant but demonstrates that stop-level BFS can work with proper bookkeeping.