questions D-E

This commit is contained in:
2025-05-25 11:08:40 +01:00
parent e6a22f98f8
commit ecf95bd23d
18 changed files with 4022 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
title: Daily Temperatures
slug: daily-temperatures
difficulty: medium
leetcode_id: 739
leetcode_url: https://leetcode.com/problems/daily-temperatures/
categories:
- arrays
- stack
patterns:
- monotonic-stack
description: |
Given an array of integers `temperatures` represents the daily temperatures, return *an array* `answer` *such that* `answer[i]` *is the number of days you have to wait after the* i<sup>th</sup> *day to get a warmer temperature*.
If there is no future day for which this is possible, keep `answer[i] == 0` instead.
constraints: |
- `1 <= temperatures.length <= 10^5`
- `30 <= temperatures[i] <= 100`
examples:
- input: "temperatures = [73,74,75,71,69,72,76,73]"
output: "[1,1,4,2,1,1,0,0]"
explanation: "Day 0: 73, warmer on day 1 (74), wait 1 day. Day 1: 74, warmer on day 2 (75), wait 1 day. Day 2: 75, warmer on day 6 (76), wait 4 days. Day 3: 71, warmer on day 5 (72), wait 2 days. Day 4: 69, warmer on day 5 (72), wait 1 day. Day 5: 72, warmer on day 6 (76), wait 1 day. Days 6-7 have no warmer future day, so answer is 0."
- input: "temperatures = [30,40,50,60]"
output: "[1,1,1,0]"
explanation: "Each day is immediately followed by a warmer day, except the last day which has no future days."
- input: "temperatures = [30,60,90]"
output: "[1,1,0]"
explanation: "Strictly increasing temperatures, so each day (except the last) waits just 1 day for a warmer temperature."
explanation:
intuition: |
Imagine you're tracking weather and for each day you want to know: "How many days until it gets warmer?"
The naive approach would be to look ahead from each day until you find a warmer one, but this could be very slow. Instead, think about it differently: **as you encounter each day, which previous days are "waiting" for this temperature?**
Picture a stack of days that are still waiting for a warmer temperature. When you see a new day's temperature, you check if it's warmer than any of the waiting days. If it is, those days have found their answer! They can be "resolved" and removed from the waiting stack.
The key insight is that days with higher temperatures will stay in the stack longer, while days with lower temperatures get resolved quickly. This creates a **monotonically decreasing stack** - temperatures in the stack always decrease from bottom to top. When a new temperature breaks this pattern (it's higher than the top), we pop and resolve all the days that are now "answered".
approach: |
We solve this using a **Monotonic Stack** that tracks indices of days waiting for a warmer temperature:
**Step 1: Initialise data structures**
- `answer`: Array of size `n` filled with `0` (default: no warmer day found)
- `stack`: Empty stack to hold indices of days awaiting warmer temperatures
&nbsp;
**Step 2: Iterate through each day**
- For each index `i` with temperature `temperatures[i]`:
- While the stack is not empty AND current temperature is warmer than the temperature at the index on top of the stack:
- Pop the index from the stack (let's call it `prev_idx`)
- Calculate the wait time: `i - prev_idx`
- Store this in `answer[prev_idx]`
- Push the current index `i` onto the stack (this day now waits for its warmer day)
&nbsp;
**Step 3: Return the result**
- Return `answer` - any indices still in the stack never found a warmer day, but they're already `0` by default
&nbsp;
This approach works because we process days in order. When we find a warmer day, all colder days waiting on the stack get their answer immediately. Days that never find a warmer temperature remain at `0`.
common_pitfalls:
- title: The Brute Force Trap
description: |
The instinctive approach is to use nested loops: for each day `i`, scan forward to find the first warmer day.
```python
for i in range(n):
for j in range(i + 1, n):
if temperatures[j] > temperatures[i]:
answer[i] = j - i
break
```
This is **O(n^2)** time complexity. With `temperatures.length <= 10^5`, this means up to 10 billion operations in the worst case (strictly decreasing temperatures), causing **Time Limit Exceeded (TLE)**.
wrong_approach: "Nested loops scanning forward from each day"
correct_approach: "Monotonic stack resolving days in reverse order"
- title: Storing Temperatures Instead of Indices
description: |
A common mistake is pushing temperatures onto the stack instead of indices.
You need indices for two reasons:
1. To calculate the wait time (`i - prev_idx`)
2. To update the correct position in the `answer` array
Always push indices and look up temperatures using `temperatures[stack[-1]]`.
wrong_approach: "Pushing temperature values onto the stack"
correct_approach: "Pushing indices and accessing temperatures via index lookup"
- title: Off-by-One in Wait Calculation
description: |
The wait time is `current_index - previous_index`, not `current_index - previous_index - 1`.
If you buy on day 3 and find warmth on day 5, you wait `5 - 3 = 2` days (days 4 and 5). The calculation is a simple subtraction of indices.
wrong_approach: "Using i - prev_idx - 1"
correct_approach: "Using i - prev_idx"
key_takeaways:
- "**Monotonic stack pattern**: When you need to find the next greater/smaller element for each position, a monotonic stack gives O(n) time"
- "**Process in order, resolve backwards**: The stack holds unresolved items; new elements resolve older ones that they satisfy"
- "**Store indices, not values**: In problems requiring position information, push indices and look up values when needed"
- "**Foundation for similar problems**: This pattern applies to Next Greater Element, Stock Span, Largest Rectangle in Histogram, and many more"
time_complexity: "O(n). Each element is pushed onto the stack once and popped at most once, giving 2n operations total."
space_complexity: "O(n). The stack can hold up to n indices in the worst case (strictly decreasing temperatures), plus the output array of size n."
solutions:
- approach_name: Monotonic Stack
is_optimal: true
code: |
def daily_temperatures(temperatures: list[int]) -> list[int]:
n = len(temperatures)
# Initialise answer array with zeros (default: no warmer day)
answer = [0] * n
# Stack stores indices of days waiting for a warmer temperature
stack = []
for i in range(n):
# While current temp is warmer than temp at index on top of stack
while stack and temperatures[i] > temperatures[stack[-1]]:
# Pop the index - this day found its warmer day
prev_idx = stack.pop()
# Calculate how many days it had to wait
answer[prev_idx] = i - prev_idx
# Current day now waits for its warmer day
stack.append(i)
return answer
explanation: |
**Time Complexity:** O(n) — Each index is pushed and popped at most once.
**Space Complexity:** O(n) — Stack can hold up to n indices in the worst case.
The monotonic stack maintains indices in decreasing temperature order. When we encounter a warmer temperature, we pop all indices with lower temperatures and calculate their wait times. This processes all n elements with at most 2n stack operations total.
- approach_name: Brute Force
is_optimal: false
code: |
def daily_temperatures(temperatures: list[int]) -> list[int]:
n = len(temperatures)
answer = [0] * n
# For each day, scan forward to find the next warmer day
for i in range(n):
for j in range(i + 1, n):
if temperatures[j] > temperatures[i]:
answer[i] = j - i
break # Found the first warmer day
return answer
explanation: |
**Time Complexity:** O(n^2) — Nested loops in the worst case (strictly decreasing).
**Space Complexity:** O(1) — Only the output array, no additional space.
This straightforward approach checks every future day for each position. While correct, it's too slow for large inputs and will cause TLE on LeetCode. Included to illustrate why the monotonic stack optimisation is necessary.

View File

@@ -0,0 +1,200 @@
title: Decode String
slug: decode-string
difficulty: medium
leetcode_id: 394
leetcode_url: https://leetcode.com/problems/decode-string/
categories:
- strings
- stack
- recursion
patterns:
- monotonic-stack
description: |
Given an encoded string, return its decoded string.
The encoding rule is: `k[encoded_string]`, where the `encoded_string` inside the square brackets is being repeated exactly `k` times. Note that `k` is guaranteed to be a positive integer.
You may assume that the input string is always valid; there are no extra white spaces, square brackets are well-formed, etc. Furthermore, you may assume that the original data does not contain any digits and that digits are only for those repeat numbers, `k`. For example, there will not be input like `3a` or `2[4]`.
The test cases are generated so that the length of the output will never exceed `10^5`.
constraints: |
- `1 <= s.length <= 30`
- `s` consists of lowercase English letters, digits, and square brackets `'[]'`
- `s` is guaranteed to be a valid input
- All the integers in `s` are in the range `[1, 300]`
examples:
- input: 's = "3[a]2[bc]"'
output: '"aaabcbc"'
explanation: "The pattern 3[a] expands to 'aaa' and 2[bc] expands to 'bcbc', giving 'aaabcbc'."
- input: 's = "3[a2[c]]"'
output: '"accaccacc"'
explanation: "The inner 2[c] expands to 'cc', making 3[acc], which expands to 'accaccacc'."
- input: 's = "2[abc]3[cd]ef"'
output: '"abcabccdcdcdef"'
explanation: "2[abc] becomes 'abcabc', 3[cd] becomes 'cdcdcd', plus 'ef' at the end."
explanation:
intuition: |
Think of this problem like unpacking nested boxes. Each `k[...]` is a box containing a message that needs to be repeated `k` times. The tricky part is that boxes can be nested inside other boxes — you need to unpack the innermost boxes first before you can complete the outer ones.
This "last opened, first closed" pattern is exactly what a **stack** excels at. When you encounter an opening bracket `[`, you're entering a new nested level. When you hit a closing bracket `]`, you've completed the innermost pattern and need to expand it before continuing with the outer context.
Imagine reading `3[a2[c]]`:
- You see `3[` — push your current work aside, start fresh for what's inside
- You see `a2[` — push again, start fresh for the innermost part
- You see `c]` — you've completed `c`, multiply by `2` to get `cc`, pop back to the previous context
- Now you have `acc`, hit `]` — multiply by `3` to get `accaccacc`
The stack lets you "pause" your current string-building work, dive into a nested pattern, resolve it, and then resume where you left off.
approach: |
We solve this using a **Stack-Based Approach**:
**Step 1: Initialise data structures**
- `stack`: Holds pairs of (previous_string, repeat_count) when we enter a new `[`
- `current_string`: The string we're building at the current nesting level
- `current_num`: Accumulates multi-digit numbers like `12` or `300`
&nbsp;
**Step 2: Iterate through each character**
- **If digit**: Accumulate into `current_num` (handle multi-digit numbers with `current_num = current_num * 10 + int(char)`)
- **If `[`**: Push `(current_string, current_num)` onto the stack, then reset both to start fresh for the nested content
- **If `]`**: Pop from stack, multiply `current_string` by the popped count, and prepend the popped string
- **If letter**: Simply append to `current_string`
&nbsp;
**Step 3: Return the result**
- After processing all characters, `current_string` contains the fully decoded string
&nbsp;
The key insight is that we save our "context" (what we've built so far and how many times to repeat the upcoming section) whenever we enter a new nesting level, then restore and combine when we exit.
common_pitfalls:
- title: Forgetting Multi-Digit Numbers
description: |
The repeat count `k` can be more than one digit! For example, `12[a]` means repeat `a` twelve times, not `1` followed by `2[a]`.
When you encounter a digit, you need to accumulate: `current_num = current_num * 10 + int(char)`. This handles numbers like `123` correctly by building up `1` → `12` → `123`.
wrong_approach: "Treating each digit as a separate number"
correct_approach: "Accumulate digits: current_num = current_num * 10 + digit"
- title: Not Resetting State After Opening Bracket
description: |
When you push to the stack upon seeing `[`, you must reset `current_string` to empty and `current_num` to `0`. Otherwise, you'll mix content from different nesting levels.
For `3[a2[c]]`, after pushing at the first `[`, you need a fresh start to build `a` before encountering `2[c]`.
wrong_approach: "Continuing to append to the same string after ["
correct_approach: "Reset current_string and current_num after pushing to stack"
- title: String Concatenation in Wrong Order
description: |
When popping from the stack after `]`, the order matters:
- `prev_string + (current_string * count)` is correct
- `(current_string * count) + prev_string` is wrong
The `prev_string` is what came *before* the `k[...]` pattern, so it should stay at the front.
wrong_approach: "Appending prev_string after the repeated content"
correct_approach: "Prepend prev_string: result = prev_string + current_string * count"
- title: Using Recursion Without Tracking Position
description: |
A recursive approach is valid but requires careful handling of the current position. You need to return both the decoded string *and* the index where you stopped, so the caller knows where to resume.
The iterative stack approach avoids this complexity by maintaining state explicitly.
key_takeaways:
- "**Stack for nested structures**: Whenever you see nested patterns with matching pairs (brackets, parentheses), think stack"
- "**Save and restore context**: Push your current state when entering a new level, pop and combine when exiting"
- "**Multi-digit number handling**: Always accumulate digits with `num = num * 10 + digit` for robustness"
- "**Pattern recognition**: This stack technique applies to many parsing problems — balanced parentheses, expression evaluation, nested HTML tags"
time_complexity: "O(n * m) where `n` is the length of the encoded string and `m` is the maximum length of the decoded string. Each character is processed once, but string concatenation may involve copying characters multiple times due to repetition."
space_complexity: "O(n + m) for the stack storing intermediate strings and the output string. In the worst case of deeply nested patterns, the stack depth approaches `n`."
solutions:
- approach_name: Stack
is_optimal: true
code: |
def decodeString(s: str) -> str:
stack = [] # Stores (prev_string, repeat_count) pairs
current_string = ""
current_num = 0
for char in s:
if char.isdigit():
# Accumulate multi-digit numbers
current_num = current_num * 10 + int(char)
elif char == '[':
# Save current context and start fresh
stack.append((current_string, current_num))
current_string = ""
current_num = 0
elif char == ']':
# Pop context and apply repetition
prev_string, repeat_count = stack.pop()
current_string = prev_string + current_string * repeat_count
else:
# Regular letter - just append
current_string += char
return current_string
explanation: |
**Time Complexity:** O(n * m) — We process each character once, but string operations may copy characters multiple times based on repeat counts.
**Space Complexity:** O(n + m) — Stack space for nested levels plus the decoded output string.
The stack elegantly handles nested patterns by saving context when entering brackets and restoring when exiting. Each `]` triggers a "resolve and combine" operation that builds up the final string from the inside out.
- approach_name: Recursion
is_optimal: false
code: |
def decodeString(s: str) -> str:
def decode(index: int) -> tuple[str, int]:
result = ""
num = 0
while index < len(s):
char = s[index]
if char.isdigit():
# Accumulate the repeat count
num = num * 10 + int(char)
elif char == '[':
# Recurse to decode the nested content
decoded, index = decode(index + 1)
result += decoded * num
num = 0
elif char == ']':
# End of current level - return to caller
return result, index
else:
# Regular letter
result += char
index += 1
return result, index
decoded_string, _ = decode(0)
return decoded_string
explanation: |
**Time Complexity:** O(n * m) — Similar to the stack approach, processing each character with potential string repetition.
**Space Complexity:** O(n) — Recursion depth proportional to nesting level, plus O(m) for output.
The recursive approach mirrors the stack solution but uses the call stack implicitly. Each `[` triggers a recursive call, and each `]` returns to the caller. The key is returning both the decoded string *and* the current index so the caller knows where to resume parsing.

View File

@@ -0,0 +1,241 @@
title: Decode Ways
slug: decode-ways
difficulty: medium
leetcode_id: 91
leetcode_url: https://leetcode.com/problems/decode-ways/
categories:
- strings
- dynamic-programming
patterns:
- dynamic-programming
description: |
You have intercepted a secret message encoded as a string of numbers. The message is **decoded** via the following mapping:
`"1" -> 'A', "2" -> 'B', ..., "25" -> 'Y', "26" -> 'Z'`
However, while decoding the message, you realise that there are many different ways you can decode the message because some codes are contained in other codes (`"2"` and `"5"` vs `"25"`).
For example, `"11106"` can be decoded into:
- `"AAJF"` with the grouping `(1, 1, 10, 6)`
- `"KJF"` with the grouping `(11, 10, 6)`
- The grouping `(1, 11, 06)` is invalid because `"06"` is not a valid code (only `"6"` is valid).
Note: there may be strings that are impossible to decode.
Given a string `s` containing only digits, return *the number of ways to decode it*. If the entire string cannot be decoded in any valid way, return `0`.
The test cases are generated so that the answer fits in a **32-bit** integer.
constraints: |
- `1 <= s.length <= 100`
- `s` contains only digits and may contain leading zero(s).
examples:
- input: 's = "12"'
output: "2"
explanation: '"12" could be decoded as "AB" (1 2) or "L" (12).'
- input: 's = "226"'
output: "3"
explanation: '"226" could be decoded as "BZ" (2 26), "VF" (22 6), or "BBF" (2 2 6).'
- input: 's = "06"'
output: "0"
explanation: '"06" cannot be mapped to "F" because of the leading zero ("6" is different from "06"). In this case, the string is not a valid encoding, so return 0.'
explanation:
intuition: |
Think of this problem like climbing a staircase, but with rules about which steps you can take.
Imagine you're reading the encoded string character by character. At each position, you have a decision to make: do you decode just the current digit as a single letter, or do you combine it with the next digit to form a two-digit number?
The key insight is that this is a **counting problem with overlapping subproblems**. The number of ways to decode a string depends on the number of ways to decode its suffixes. If you know how many ways there are to decode the remaining string after taking one digit, and how many ways after taking two digits, you can combine these counts.
Think of it like this: at position `i`, the total decodings equals:
- The number of decodings if you use `s[i]` as a single digit (valid if `s[i] != '0'`)
- Plus the number of decodings if you use `s[i:i+2]` as a two-digit number (valid if it's between `10` and `26`)
This recurrence relation is the foundation of the dynamic programming solution.
approach: |
We solve this using **Dynamic Programming** with a bottom-up approach:
**Step 1: Handle edge cases**
- If the string is empty or starts with `'0'`, return `0` immediately
- A leading zero means no valid decoding exists
&nbsp;
**Step 2: Initialise the DP array**
- Create an array `dp` where `dp[i]` represents the number of ways to decode `s[0:i]`
- `dp[0] = 1`: An empty string has one way to decode (the empty decoding)
- `dp[1] = 1`: A single non-zero digit has exactly one way to decode
&nbsp;
**Step 3: Fill the DP array**
- For each position `i` from `2` to `n`:
- **Single digit check**: If `s[i-1] != '0'`, add `dp[i-1]` to `dp[i]`
- **Two digit check**: If the two-digit number `s[i-2:i]` is between `10` and `26`, add `dp[i-2]` to `dp[i]`
&nbsp;
**Step 4: Return the result**
- Return `dp[n]`, which contains the total number of ways to decode the entire string
&nbsp;
The DP builds up from smaller subproblems. Each position accumulates counts from valid single-digit and two-digit decodings.
common_pitfalls:
- title: Ignoring Leading Zeros
description: |
A common mistake is forgetting that `'0'` cannot be decoded on its own. The mapping starts at `'1' -> 'A'`, not `'0' -> something`.
For example, `"06"` has zero valid decodings because you cannot use `'0'` as a single digit, and `"06"` is not a valid two-digit code (only `"10"` to `"26"` are valid).
Always check that a single digit is not `'0'` before counting it as a valid decoding.
wrong_approach: "Treating '0' as a valid single-digit code"
correct_approach: "Skip single-digit decodings when the digit is '0'"
- title: Incorrect Two-Digit Range
description: |
The valid two-digit codes are `"10"` through `"26"` only. Common errors include:
- Accepting `"00"` through `"09"` (leading zeros are invalid)
- Accepting `"27"` through `"99"` (no letters map to these)
The check should be: the two-digit number must be `>= 10` AND `<= 26`.
wrong_approach: "Checking only if two-digit value <= 26"
correct_approach: "Check if 10 <= two_digit_value <= 26"
- title: Confusing Index vs String Length
description: |
When implementing the DP, be careful about indices. `dp[i]` represents decodings for the first `i` characters, so:
- `dp[0]` = 1 (base case: empty prefix)
- `dp[1]` = 1 if `s[0] != '0'`, else 0
- For `dp[i]`, check `s[i-1]` for single digit and `s[i-2:i]` for two digits
Off-by-one errors here lead to incorrect results or index out of bounds.
key_takeaways:
- "**DP recurrence**: `dp[i] = dp[i-1]` (if valid single digit) `+ dp[i-2]` (if valid two digits)"
- "**Fibonacci-like pattern**: This problem is similar to Climbing Stairs but with validity conditions at each step"
- "**Space optimisation**: Since we only need the previous two values, we can reduce space from O(n) to O(1)"
- "**Foundation for variants**: This pattern extends to Decode Ways II (with wildcards) and other string partition problems"
time_complexity: "O(n). We iterate through the string once, performing constant-time operations at each position."
space_complexity: "O(n) for the DP array, or O(1) if optimised to use only two variables."
solutions:
- approach_name: Dynamic Programming (Array)
is_optimal: true
code: |
def num_decodings(s: str) -> int:
# Edge case: string starts with '0'
if not s or s[0] == '0':
return 0
n = len(s)
# dp[i] = number of ways to decode s[0:i]
dp = [0] * (n + 1)
# Base cases
dp[0] = 1 # Empty string has one way (empty decoding)
dp[1] = 1 # First char is valid (we checked it's not '0')
for i in range(2, n + 1):
# Single digit: check if current char is valid (not '0')
if s[i - 1] != '0':
dp[i] += dp[i - 1]
# Two digits: check if previous two chars form valid code (10-26)
two_digit = int(s[i - 2:i])
if 10 <= two_digit <= 26:
dp[i] += dp[i - 2]
return dp[n]
explanation: |
**Time Complexity:** O(n) — Single pass through the string.
**Space Complexity:** O(n) — DP array stores one value per position.
We build up the solution by counting valid decodings at each position. The recurrence combines counts from single-digit and two-digit valid decodings.
- approach_name: Dynamic Programming (Space Optimised)
is_optimal: true
code: |
def num_decodings(s: str) -> int:
# Edge case: string starts with '0'
if not s or s[0] == '0':
return 0
# Only need previous two values, not full array
prev2 = 1 # dp[i-2]: ways to decode up to 2 positions back
prev1 = 1 # dp[i-1]: ways to decode up to 1 position back
for i in range(1, len(s)):
current = 0
# Single digit: current char is valid (not '0')
if s[i] != '0':
current += prev1
# Two digits: chars at i-1 and i form valid code (10-26)
two_digit = int(s[i - 1:i + 1])
if 10 <= two_digit <= 26:
current += prev2
# Slide the window forward
prev2 = prev1
prev1 = current
return prev1
explanation: |
**Time Complexity:** O(n) — Single pass through the string.
**Space Complexity:** O(1) — Only two variables used regardless of input size.
Since each position only depends on the previous two values, we can use a sliding window of two variables instead of a full array. This is the Fibonacci optimisation pattern.
- approach_name: Recursion with Memoisation
is_optimal: false
code: |
def num_decodings(s: str) -> int:
memo = {}
def decode(index: int) -> int:
# Reached the end: one valid way found
if index == len(s):
return 1
# Leading zero: no valid decoding
if s[index] == '0':
return 0
# Return cached result if available
if index in memo:
return memo[index]
# Option 1: Take single digit
ways = decode(index + 1)
# Option 2: Take two digits if valid
if index + 1 < len(s):
two_digit = int(s[index:index + 2])
if two_digit <= 26:
ways += decode(index + 2)
memo[index] = ways
return ways
return decode(0)
explanation: |
**Time Complexity:** O(n) — Each index is computed once and cached.
**Space Complexity:** O(n) — Recursion stack and memoisation dictionary.
This top-down approach is often more intuitive. We recursively try both options (one digit or two digits) and cache results. The base case is reaching the end of the string (one valid decoding found).

View File

@@ -0,0 +1,209 @@
title: Delete Leaves With a Given Value
slug: delete-leaves-with-a-given-value
difficulty: medium
leetcode_id: 1325
leetcode_url: https://leetcode.com/problems/delete-leaves-with-a-given-value/
categories:
- trees
- recursion
patterns:
- dfs
- tree-traversal
description: |
Given a binary tree `root` and an integer `target`, delete all the **leaf nodes** with value `target`.
Note that once you delete a leaf node with value `target`, if its parent node becomes a leaf node and has the value `target`, it should also be deleted (you need to continue doing that until you cannot).
constraints: |
- The number of nodes in the tree is in the range `[1, 3000]`
- `1 <= Node.val, target <= 1000`
examples:
- input: "root = [1,2,3,2,null,2,4], target = 2"
output: "[1,null,3,null,4]"
explanation: "Leaf nodes with value 2 are removed. After removing, new nodes become leaf nodes with value 2 and are also removed."
- input: "root = [1,3,3,3,2], target = 3"
output: "[1,3,null,null,2]"
explanation: "The leaf node with value 3 on the left subtree is removed."
- input: "root = [1,2,null,2,null,2], target = 2"
output: "[1]"
explanation: "Leaf nodes with value 2 are removed at each step, cascading up the tree until only the root remains."
explanation:
intuition: |
Imagine you're pruning a plant by removing dead leaves. When you remove a leaf, you might expose a new leaf underneath that also needs to be removed. This cascading effect is the key insight.
The problem requires **post-order traversal** — we must process children before their parent. Why? Because a node can only become a leaf *after* its children have been removed. If we tried to process nodes top-down (pre-order), we wouldn't know which nodes would eventually become leaves.
Think of it like this: you're cleaning a tree from the bottom up. First, check the deepest leaves and remove any that match the target. Then move up one level — some nodes that had children might now be leaves themselves. Repeat until no more matching leaves exist.
The elegant recursive solution handles this naturally: by recursing to children first and then checking the current node, we automatically process in post-order. If both children return `None` (either they were removed or didn't exist), and the current node's value matches the target, we've found a new leaf to delete.
approach: |
We solve this using **Post-Order DFS (Depth-First Search)**:
**Step 1: Define the recursive function**
- The function takes a node and returns the modified subtree (or `None` if the node should be deleted)
- Base case: if the node is `None`, return `None`
&nbsp;
**Step 2: Recursively process children first**
- Call the function on `node.left` and assign the result back to `node.left`
- Call the function on `node.right` and assign the result back to `node.right`
- This ensures we process the deepest nodes first (post-order)
&nbsp;
**Step 3: Check if the current node should be deleted**
- After processing children, check if this node is now a leaf (`left` and `right` are both `None`)
- If it's a leaf AND its value equals `target`, return `None` to delete it
- Otherwise, return the node (keep it in the tree)
&nbsp;
**Step 4: Handle the root case**
- Apply the function to the root and return the result
- The root itself might be deleted if it becomes a matching leaf
&nbsp;
The post-order traversal ensures cascading deletions happen automatically — when we check a parent, its children have already been processed and potentially removed.
common_pitfalls:
- title: Using Pre-Order Instead of Post-Order
description: |
A common mistake is checking whether to delete a node *before* processing its children:
```python
# Wrong: checking before recursing
if is_leaf(node) and node.val == target:
return None
node.left = recurse(node.left)
node.right = recurse(node.right)
```
This fails because a node might not be a leaf initially, but becomes one after its children are deleted. By checking first, you'd miss these cascading deletions.
For example, with `[1,2,null,2]` and `target = 2`: the inner `2` would be deleted, but then the outer `2` would be missed because it wasn't a leaf when first visited.
wrong_approach: "Check node before recursing to children"
correct_approach: "Recurse to children first, then check node (post-order)"
- title: Not Updating Parent References
description: |
When deleting a node, you must update the parent's reference to that child:
```python
# Wrong: just returning None without updating
recurse(node.left) # Doesn't update the reference!
# Correct: assign the result back
node.left = recurse(node.left)
```
If you don't assign the result back to `node.left` or `node.right`, the deleted nodes remain connected to the tree.
wrong_approach: "Recursing without assignment"
correct_approach: "Assign recursive result back to node.left and node.right"
- title: Forgetting the Root Can Be Deleted
description: |
The root node itself can become a leaf and match the target. For example, `[2]` with `target = 2` should return an empty tree (`None`).
Make sure your function handles this by returning the result of calling the recursive function on the root, not just always returning the original root.
wrong_approach: "Always returning the original root"
correct_approach: "Return the result of the recursive call on root"
key_takeaways:
- "**Post-order for cascading effects**: When deletions can trigger more deletions, process children before parents"
- "**Recursive return value pattern**: Return `None` to signal deletion, return the node to keep it — let the parent handle the assignment"
- "**Tree modification in-place**: Update `node.left` and `node.right` with recursive results to properly disconnect deleted nodes"
- "**Similar problems**: This pattern applies to pruning BSTs, removing subtrees, and any problem where changes propagate upward"
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. This accounts for the recursion stack, which in the worst case (skewed tree) is O(n), but for a balanced tree is O(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 removeLeafNodes(root: TreeNode, target: int) -> TreeNode:
# Base case: empty node
if not root:
return None
# Process children first (post-order)
# Assign results back to update the tree structure
root.left = removeLeafNodes(root.left, target)
root.right = removeLeafNodes(root.right, target)
# Now check: is this node a leaf with the target value?
is_leaf = root.left is None and root.right is None
if is_leaf and root.val == target:
# Delete this node by returning None
return None
# Keep this node
return root
explanation: |
**Time Complexity:** O(n) — Each node is visited exactly once.
**Space Complexity:** O(h) — Recursion stack depth equals tree height.
The post-order traversal (left, right, then current) ensures we process children before deciding whether to delete the parent. This naturally handles cascading deletions without needing multiple passes.
- approach_name: Iterative with Parent Tracking
is_optimal: false
code: |
def removeLeafNodes(root: TreeNode, target: int) -> TreeNode:
# Use a dummy parent for uniform handling of root deletion
dummy = TreeNode(0)
dummy.left = root
# Stack stores (node, parent, is_left_child)
stack = [(root, dummy, True)]
# Track which nodes we've fully processed
visited = set()
while stack:
node, parent, is_left = stack[-1]
if node is None:
stack.pop()
continue
# If children haven't been visited, push them first
if node not in visited:
visited.add(node)
if node.right:
stack.append((node.right, node, False))
if node.left:
stack.append((node.left, node, True))
else:
# Children processed, now handle this node
stack.pop()
is_leaf = node.left is None and node.right is None
if is_leaf and node.val == target:
# Delete by updating parent reference
if is_left:
parent.left = None
else:
parent.right = None
return dummy.left
explanation: |
**Time Complexity:** O(n) — Each node visited twice (once to push children, once to process).
**Space Complexity:** O(n) — Stack and visited set can hold all nodes.
This iterative approach simulates post-order traversal using a stack and a visited set. It's more complex than the recursive solution and uses more space. The dummy node simplifies handling root deletion. Included to show how post-order can be done iteratively, but the recursive solution is cleaner.

View File

@@ -0,0 +1,220 @@
title: Delete Node in a BST
slug: delete-node-in-a-bst
difficulty: medium
leetcode_id: 450
leetcode_url: https://leetcode.com/problems/delete-node-in-a-bst/
categories:
- trees
patterns:
- tree-traversal
- dfs
description: |
Given a root node reference of a BST and a key, delete the node with the given key in the BST. Return *the **root node reference** (possibly updated) of the BST*.
Basically, the deletion can be divided into two stages:
1. Search for a node to remove.
2. If the node is found, delete the node.
constraints: |
- `0 <= number of nodes <= 10^4`
- `-10^5 <= Node.val <= 10^5`
- Each node has a **unique** value
- `root` is a valid binary search tree
- `-10^5 <= key <= 10^5`
examples:
- input: "root = [5,3,6,2,4,null,7], key = 3"
output: "[5,4,6,2,null,null,7]"
explanation: "Given key to delete is 3. So we find the node with value 3 and delete it. One valid answer is [5,4,6,2,null,null,7]. Another valid answer is [5,2,6,null,4,null,7]."
- input: "root = [5,3,6,2,4,null,7], key = 0"
output: "[5,3,6,2,4,null,7]"
explanation: "The tree does not contain a node with value = 0, so the tree remains unchanged."
- input: "root = [], key = 0"
output: "[]"
explanation: "The tree is empty, so there is nothing to delete."
explanation:
intuition: |
Think of a BST like a filing cabinet organised alphabetically. When you want to remove a folder, you first need to **find it** using the BST property (smaller values go left, larger go right), and then **reorganise** the remaining folders to maintain the alphabetical order.
The tricky part is the reorganisation. When deleting a node, there are three cases to consider:
- **Leaf node (no children):** Simply remove it — no reorganisation needed
- **One child:** Replace the deleted node with its only child — the BST property is preserved
- **Two children:** This is the interesting case. You need to find a replacement that keeps the BST valid
For the two-children case, the key insight is to use the **in-order successor** (the smallest node in the right subtree) or the **in-order predecessor** (the largest node in the left subtree). These are the only values that can replace the deleted node while maintaining the BST property.
Why does the in-order successor work? It's the smallest value greater than the deleted node, so it's larger than everything in the left subtree but smaller than everything else in the right subtree — exactly what we need for a valid BST.
approach: |
We solve this using **recursive BST traversal** with three deletion cases:
**Step 1: Search for the node to delete**
- If `root` is `None`, return `None` (key not found)
- If `key < root.val`, recursively search in the left subtree
- If `key > root.val`, recursively search in the right subtree
- If `key == root.val`, we've found the node to delete
&nbsp;
**Step 2: Handle the three deletion cases**
- **Case 1 (No left child):** Return the right child to replace this node
- **Case 2 (No right child):** Return the left child to replace this node
- **Case 3 (Two children):** Find the in-order successor (minimum in right subtree), copy its value to the current node, then recursively delete the successor from the right subtree
&nbsp;
**Step 3: Return the modified tree**
- Each recursive call returns the root of the modified subtree
- Parent nodes automatically get updated through the recursive returns
&nbsp;
The recursive approach naturally handles the BST search and the parent-child reconnection without needing explicit parent pointers.
common_pitfalls:
- title: Forgetting to Handle the Two-Children Case
description: |
When a node has two children, you cannot simply remove it or replace it with one child. Both subtrees must remain in the result.
The solution is to find the in-order successor (or predecessor), copy its value to the current node, and then delete the successor. This effectively "moves" the successor's value up while removing the actual successor node (which has at most one child).
wrong_approach: "Trying to directly remove a node with two children"
correct_approach: "Replace with in-order successor/predecessor, then delete that node"
- title: Not Returning the Modified Subtree
description: |
A common mistake is to not properly return the modified subtree back up the recursion chain. Each recursive call should return the root of the (possibly modified) subtree.
For example, when deleting a leaf node, returning `None` tells the parent to disconnect that child.
wrong_approach: "Modifying nodes without returning the new subtree root"
correct_approach: "Always return the root of the modified subtree from each recursive call"
- title: Incorrect In-Order Successor Finding
description: |
The in-order successor is the **smallest** node in the right subtree, found by going right once, then left as far as possible.
A common mistake is to confuse this with the largest node in the left subtree (which is the in-order predecessor — also valid, but different).
wrong_approach: "Going right repeatedly or confusing successor with predecessor"
correct_approach: "Go right once, then left until reaching a node with no left child"
key_takeaways:
- "**BST property enables O(h) search**: Use comparisons to navigate directly to the target node without checking every node"
- "**Recursive structure simplifies parent updates**: By returning the modified subtree, parent-child links update automatically"
- "**In-order successor/predecessor for two-children case**: These are the only valid replacements that maintain BST ordering"
- "**Foundation for self-balancing trees**: Understanding BST deletion is essential for AVL trees, Red-Black trees, and other balanced structures"
time_complexity: "O(h) where h is the height of the tree. In the worst case (skewed tree), this is O(n). In a balanced BST, this is O(log n)."
space_complexity: "O(h) for the recursion stack. In the worst case O(n), in a balanced tree O(log n)."
solutions:
- approach_name: Recursive BST Deletion
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 deleteNode(root: TreeNode | None, key: int) -> TreeNode | None:
if not root:
# Base case: key not found
return None
if key < root.val:
# Key is in the left subtree
root.left = deleteNode(root.left, key)
elif key > root.val:
# Key is in the right subtree
root.right = deleteNode(root.right, key)
else:
# Found the node to delete
if not root.left:
# Case 1: No left child, return right child
return root.right
if not root.right:
# Case 2: No right child, return left child
return root.left
# Case 3: Two children - find in-order successor
# (smallest node in right subtree)
successor = root.right
while successor.left:
successor = successor.left
# Copy successor's value to current node
root.val = successor.val
# Delete the successor from right subtree
root.right = deleteNode(root.right, successor.val)
return root
explanation: |
**Time Complexity:** O(h) — We traverse at most the height of the tree to find the node, plus potentially another O(h) to find and delete the successor.
**Space Complexity:** O(h) — Recursion stack depth equals the height of the tree.
This approach uses the BST property to efficiently find the node, then handles each deletion case appropriately. The recursive structure elegantly handles parent-child reconnection.
- approach_name: Iterative Approach
is_optimal: false
code: |
def deleteNode(root: TreeNode | None, key: int) -> TreeNode | None:
if not root:
return None
# Find the node and its parent
parent = None
current = root
while current and current.val != key:
parent = current
if key < current.val:
current = current.left
else:
current = current.right
# Key not found
if not current:
return root
# Helper to find minimum in a subtree
def find_min(node):
while node.left:
node = node.left
return node
# Get the replacement node
if not current.left:
replacement = current.right
elif not current.right:
replacement = current.left
else:
# Two children: find successor and swap
successor = find_min(current.right)
current.val = successor.val
# Recursively delete successor (has at most one child)
current.right = deleteNode(current.right, successor.val)
return root
# Connect replacement to parent
if not parent:
return replacement
if parent.left == current:
parent.left = replacement
else:
parent.right = replacement
return root
explanation: |
**Time Complexity:** O(h) — Same as recursive approach.
**Space Complexity:** O(1) for the search phase, but still O(h) in worst case due to the recursive call for two-children case.
This iterative approach explicitly tracks the parent node during traversal. While it avoids some recursion, the two-children case still benefits from recursion for simplicity. A fully iterative solution is possible but more complex.

View File

@@ -0,0 +1,219 @@
title: Design Add and Search Words Data Structure
slug: design-add-and-search-words-data-structure
difficulty: medium
leetcode_id: 211
leetcode_url: https://leetcode.com/problems/design-add-and-search-words-data-structure/
categories:
- strings
- trees
patterns:
- trie
- dfs
description: |
Design a data structure that supports adding new words and finding if a string matches any previously added string.
Implement the `WordDictionary` class:
- `WordDictionary()` Initializes the object.
- `void addWord(word)` Adds `word` to the data structure, it can be matched later.
- `bool search(word)` Returns `true` if there is any string in the data structure that matches `word` or `false` otherwise. `word` may contain dots `'.'` where dots can be matched with **any letter**.
constraints: |
- `1 <= word.length <= 25`
- `word` in `addWord` consists of lowercase English letters.
- `word` in `search` consist of `'.'` or lowercase English letters.
- There will be at most `2` dots in `word` for `search` queries.
- At most `10^4` calls will be made to `addWord` and `search`.
examples:
- input: |
["WordDictionary","addWord","addWord","addWord","search","search","search","search"]
[[],["bad"],["dad"],["mad"],["pad"],["bad"],[".ad"],["b.."]]
output: "[null,null,null,null,false,true,true,true]"
explanation: |
WordDictionary wordDictionary = new WordDictionary();
wordDictionary.addWord("bad");
wordDictionary.addWord("dad");
wordDictionary.addWord("mad");
wordDictionary.search("pad"); // return False, no word matches "pad"
wordDictionary.search("bad"); // return True, exact match
wordDictionary.search(".ad"); // return True, matches "bad", "dad", "mad"
wordDictionary.search("b.."); // return True, matches "bad"
explanation:
intuition: |
This problem combines two classic concepts: the **Trie** (prefix tree) data structure and **depth-first search** with wildcards.
Think of a Trie as an autocomplete system: each node represents a character, and following a path from root to a marked "end" node spells out a complete word. For "bad", "dad", and "mad", the Trie would have three branches from the root (for 'b', 'd', 'm'), each leading through 'a' to 'd', with the final 'd' nodes marked as word endings.
The twist here is the wildcard `.` character, which can match **any** letter. When we encounter a `.` during search, we can't just follow one path — we must explore **all possible branches** at that level. This is where DFS comes in: we recursively try every child node when we see a dot.
Imagine searching for ".ad" in our Trie. At the root, the `.` means we try the 'b', 'd', and 'm' branches simultaneously. Each path then continues matching 'a' and 'd' literally. If any path reaches the end and finds a complete word, we return `true`.
approach: |
We implement a Trie with wildcard search using DFS:
**Step 1: Define the TrieNode structure**
- Each node has a dictionary `children` mapping characters to child nodes
- Each node has a boolean `is_end` marking whether a complete word ends here
&nbsp;
**Step 2: Implement `addWord`**
- Start at the root node
- For each character in the word, create a child node if it doesn't exist
- Move to that child node
- After processing all characters, mark the final node as `is_end = True`
&nbsp;
**Step 3: Implement `search` with DFS**
- Use a helper function `dfs(index, node)` that searches from position `index` in the word starting at `node`
- **Base case:** If `index == len(word)`, return `node.is_end` (did we reach a valid word ending?)
- **Literal character:** If the current character is a letter, check if it exists in `children`. If yes, recurse; if no, return `False`
- **Wildcard `.`:** Try **all** children nodes recursively. If any path returns `True`, the search succeeds
&nbsp;
**Step 4: Return the result**
- Start the DFS from index `0` and the root node
- Return `True` if any valid path is found
common_pitfalls:
- title: Using a List or Set Instead of a Trie
description: |
A naive approach stores all words in a list or set and checks each word against the pattern during search.
For exact matches, this works. But with wildcards, you'd need to iterate through all stored words and check character-by-character, resulting in **O(n × m)** per search where `n` is the number of words and `m` is the word length.
With a Trie, exact searches are **O(m)**, and wildcard searches only branch when necessary, making it significantly faster for large dictionaries.
wrong_approach: "Store words in a list, iterate and match on search"
correct_approach: "Use a Trie for O(m) prefix-based lookup"
- title: Not Handling the Wildcard Correctly
description: |
When encountering `.`, you must explore **all** children, not just one. A common mistake is treating `.` as matching a specific character or stopping at the first match found without exploring other branches.
For example, searching ".ad" in a Trie with "bad" and "dad" should try both the 'b' and 'd' branches. If you only try one and it fails, you might incorrectly return `False`.
wrong_approach: "Match '.' to a single arbitrary character"
correct_approach: "Use DFS to try all children when encountering '.'"
- title: Forgetting the Word-End Check
description: |
The Trie might contain "bad" but not "ba". If you search for "ba", you'll successfully traverse 'b' → 'a', but that doesn't mean "ba" is a stored word.
Always check `is_end` at the final node. A successful traversal only counts if the final node marks a complete word.
wrong_approach: "Return True if traversal completes"
correct_approach: "Return node.is_end to verify word existence"
key_takeaways:
- "**Trie fundamentals**: A Trie stores strings character-by-character, enabling efficient prefix operations and exact lookups in O(m) time"
- "**Wildcard + DFS**: When a pattern can match multiple characters, use DFS to explore all possibilities — this pattern appears in regex matching, file globbing, and game AI"
- "**Space-time tradeoff**: Tries use more memory than a simple list, but provide much faster search operations, especially for prefix-based queries"
- "**Design pattern**: This problem demonstrates the classic pattern of combining a specialised data structure (Trie) with a traversal algorithm (DFS) to solve complex matching problems"
time_complexity: "O(m) for `addWord` where `m` is the word length. O(26^k × m) worst case for `search` where `k` is the number of wildcards, though typically much faster due to pruning."
space_complexity: "O(n × m) where `n` is the number of words and `m` is the average word length, to store all characters in the Trie."
solutions:
- approach_name: Trie with DFS
is_optimal: true
code: |
class TrieNode:
def __init__(self):
# Maps character to child TrieNode
self.children: dict[str, 'TrieNode'] = {}
# True if a complete word ends at this node
self.is_end = False
class WordDictionary:
def __init__(self):
# Root node of the Trie
self.root = TrieNode()
def addWord(self, word: str) -> None:
# Start at root and traverse/create nodes for each char
node = self.root
for char in word:
# Create child node if it doesn't exist
if char not in node.children:
node.children[char] = TrieNode()
# Move to child node
node = node.children[char]
# Mark the end of a complete word
node.is_end = True
def search(self, word: str) -> bool:
def dfs(index: int, node: TrieNode) -> bool:
# Base case: reached end of search word
if index == len(word):
return node.is_end
char = word[index]
if char == '.':
# Wildcard: try ALL children
for child in node.children.values():
if dfs(index + 1, child):
return True
return False
else:
# Literal character: must match exactly
if char not in node.children:
return False
return dfs(index + 1, node.children[char])
# Start search from root at index 0
return dfs(0, self.root)
explanation: |
**Time Complexity:**
- `addWord`: O(m) where `m` is the word length — we traverse/create one node per character.
- `search`: O(m) for exact matches. O(26^k × m) worst case when there are `k` wildcards, as each `.` can branch into up to 26 children. In practice, the Trie structure prunes many branches.
**Space Complexity:** O(n × m) for `n` words of average length `m`. Each character potentially creates a new node, though shared prefixes reduce actual usage.
The Trie enables efficient prefix-based storage, and DFS handles wildcard matching by exploring all valid paths.
- approach_name: Brute Force with List
is_optimal: false
code: |
class WordDictionary:
def __init__(self):
# Store all words in a list
self.words: list[str] = []
def addWord(self, word: str) -> None:
# Simply append to the list
self.words.append(word)
def search(self, word: str) -> bool:
def matches(pattern: str, candidate: str) -> bool:
# Length must match
if len(pattern) != len(candidate):
return False
# Check character by character
for p, c in zip(pattern, candidate):
# '.' matches anything, otherwise must be equal
if p != '.' and p != c:
return False
return True
# Check every word in the list
for stored_word in self.words:
if matches(word, stored_word):
return True
return False
explanation: |
**Time Complexity:**
- `addWord`: O(1) — just append to list.
- `search`: O(n × m) where `n` is the number of stored words and `m` is the word length. We must check every word against the pattern.
**Space Complexity:** O(n × m) to store all words.
This approach is simpler but becomes slow with many words. For `10^4` operations, the Trie approach significantly outperforms this brute force solution, especially for repeated searches.

View File

@@ -0,0 +1,285 @@
title: Design Circular Queue
slug: design-circular-queue
difficulty: medium
leetcode_id: 622
leetcode_url: https://leetcode.com/problems/design-circular-queue/
categories:
- arrays
- queue
patterns:
- two-pointers
description: |
Design your implementation of the circular queue. The circular queue is a linear data structure in which the operations are performed based on FIFO (First In First Out) principle, and the last position is connected back to the first position to make a circle. It is also called "Ring Buffer".
One of the benefits of the circular queue is that we can make use of the spaces in front of the queue. In a normal queue, once the queue becomes full, we cannot insert the next element even if there is a space in front of the queue. But using the circular queue, we can use the space to store new values.
Implement the `MyCircularQueue` class:
- `MyCircularQueue(k)` Initializes the object with the size of the queue to be `k`.
- `int Front()` Gets the front item from the queue. If the queue is empty, return `-1`.
- `int Rear()` Gets the last item from the queue. If the queue is empty, return `-1`.
- `boolean enQueue(int value)` Inserts an element into the circular queue. Return `true` if the operation is successful.
- `boolean deQueue()` Deletes an element from the circular queue. Return `true` if the operation is successful.
- `boolean isEmpty()` Checks whether the circular queue is empty or not.
- `boolean isFull()` Checks whether the circular queue is full or not.
You must solve the problem without using the built-in queue data structure in your programming language.
constraints: |
- `1 <= k <= 1000`
- `0 <= value <= 1000`
- At most `3000` calls will be made to `enQueue`, `deQueue`, `Front`, `Rear`, `isEmpty`, and `isFull`.
examples:
- input: |
["MyCircularQueue", "enQueue", "enQueue", "enQueue", "enQueue", "Rear", "isFull", "deQueue", "enQueue", "Rear"]
[[3], [1], [2], [3], [4], [], [], [], [4], []]
output: "[null, true, true, true, false, 3, true, true, true, 4]"
explanation: |
MyCircularQueue myCircularQueue = new MyCircularQueue(3);
myCircularQueue.enQueue(1); // return True
myCircularQueue.enQueue(2); // return True
myCircularQueue.enQueue(3); // return True, queue is now [1, 2, 3]
myCircularQueue.enQueue(4); // return False, queue is full
myCircularQueue.Rear(); // return 3
myCircularQueue.isFull(); // return True
myCircularQueue.deQueue(); // return True, removes 1, queue is [2, 3]
myCircularQueue.enQueue(4); // return True, queue is now [2, 3, 4]
myCircularQueue.Rear(); // return 4
explanation:
intuition: |
Imagine a circular track where runners line up at different positions. When the track is full and someone at the front leaves, a new runner can take that vacated spot — the track "wraps around" from the end back to the beginning.
A circular queue works the same way. Unlike a standard array-based queue where you might shift all elements when dequeuing (expensive!) or waste space at the front after dequeuing, a circular queue uses **modular arithmetic** to wrap indices around. When the `rear` pointer reaches the end of the array, it wraps back to index `0` if there's space available.
The key insight is that we need to track two things:
1. **Where the front of the queue is** (for dequeue and peek operations)
2. **How many elements are currently in the queue** (to know if it's empty or full)
With these two pieces of information, we can compute where the rear is, and we can use the modulo operator (`%`) to wrap indices around the fixed-size array.
approach: |
We implement the circular queue using a **fixed-size array** with two pointers and a count:
**Step 1: Initialise the data structure**
- `data`: A fixed-size array of length `k` to store elements
- `head`: Points to the front element (where we dequeue from)
- `count`: Tracks the current number of elements in the queue
&nbsp;
**Step 2: Implement enQueue (insert at rear)**
- If the queue is full, return `false`
- Calculate the rear index: `(head + count) % k`
- Place the new element at this position
- Increment `count`
- Return `true`
&nbsp;
**Step 3: Implement deQueue (remove from front)**
- If the queue is empty, return `false`
- Move `head` forward: `head = (head + 1) % k`
- Decrement `count`
- Return `true`
&nbsp;
**Step 4: Implement Front and Rear**
- `Front()`: Return `data[head]` if not empty, else `-1`
- `Rear()`: Calculate rear index as `(head + count - 1) % k`, return that element if not empty
&nbsp;
**Step 5: Implement isEmpty and isFull**
- `isEmpty()`: Return `count == 0`
- `isFull()`: Return `count == k`
&nbsp;
The modulo operation is the magic that makes the "circular" behaviour work — it ensures indices wrap around when they exceed the array bounds.
common_pitfalls:
- title: Confusing Head and Tail Management
description: |
A common mistake is maintaining both `head` and `tail` pointers and struggling to differentiate between empty and full states. With two pointers, both `head == tail` could mean empty OR full!
Using a `count` variable instead of a `tail` pointer eliminates this ambiguity entirely. You always know the state: `count == 0` means empty, `count == k` means full.
wrong_approach: "Using head and tail pointers without a count"
correct_approach: "Use head pointer + count, compute tail when needed"
- title: Off-by-One Errors in Rear Calculation
description: |
When computing the rear index, remember that `rear` points to the *last element*, not the next empty slot. The formula is:
`rear = (head + count - 1) % k`
Not `(head + count) % k`, which would be the next insertion point.
For example, if `head = 0`, `count = 3`, and `k = 5`, the rear is at index `2`, not `3`.
wrong_approach: "rear = (head + count) % k"
correct_approach: "rear = (head + count - 1) % k"
- title: Forgetting to Handle Empty Queue Edge Cases
description: |
When the queue is empty, `Front()` and `Rear()` should return `-1`, not cause an index error or return garbage.
Always check `isEmpty()` before accessing elements:
```python
def Front(self):
if self.isEmpty():
return -1
return self.data[self.head]
```
wrong_approach: "Directly accessing data[head] without checking empty"
correct_approach: "Always check isEmpty() before accessing elements"
key_takeaways:
- "**Ring buffer pattern**: Circular queues are foundational for streaming data, bounded buffers, and producer-consumer scenarios"
- "**Modular arithmetic**: The `%` operator is the key to wrapping indices around a fixed-size array"
- "**Count vs. two pointers**: Tracking `count` separately simplifies empty/full detection and avoids ambiguous states"
- "**O(1) operations**: All operations are constant time, making this ideal for high-throughput scenarios"
time_complexity: "O(1). All operations (`enQueue`, `deQueue`, `Front`, `Rear`, `isEmpty`, `isFull`) execute in constant time."
space_complexity: "O(k). We use a fixed-size array of length `k` to store up to `k` elements."
solutions:
- approach_name: Array with Head and Count
is_optimal: true
code: |
class MyCircularQueue:
def __init__(self, k: int):
# Fixed-size array to store elements
self.data = [0] * k
# Index of the front element
self.head = 0
# Current number of elements
self.count = 0
# Maximum capacity
self.capacity = k
def enQueue(self, value: int) -> bool:
# Cannot insert if queue is full
if self.isFull():
return False
# Calculate rear position using modular arithmetic
# This wraps around when we reach the end of the array
rear = (self.head + self.count) % self.capacity
self.data[rear] = value
self.count += 1
return True
def deQueue(self) -> bool:
# Cannot remove if queue is empty
if self.isEmpty():
return False
# Move head forward (with wrap-around)
self.head = (self.head + 1) % self.capacity
self.count -= 1
return True
def Front(self) -> int:
# Return -1 for empty queue
if self.isEmpty():
return -1
return self.data[self.head]
def Rear(self) -> int:
# Return -1 for empty queue
if self.isEmpty():
return -1
# Rear is at (head + count - 1), wrapped around
rear = (self.head + self.count - 1) % self.capacity
return self.data[rear]
def isEmpty(self) -> bool:
return self.count == 0
def isFull(self) -> bool:
return self.count == self.capacity
explanation: |
**Time Complexity:** O(1) — All operations are constant time with no loops.
**Space Complexity:** O(k) — Fixed array size determined at initialization.
This implementation uses a single `head` pointer combined with a `count` variable. The rear position is computed on-demand using `(head + count) % capacity`. This approach is cleaner than maintaining separate head and tail pointers because the `count` variable unambiguously indicates whether the queue is empty or full.
- approach_name: Linked List Implementation
is_optimal: false
code: |
class ListNode:
def __init__(self, val: int):
self.val = val
self.next = None
class MyCircularQueue:
def __init__(self, k: int):
self.capacity = k
self.count = 0
# Head and tail pointers for the linked list
self.head = None
self.tail = None
def enQueue(self, value: int) -> bool:
if self.isFull():
return False
new_node = ListNode(value)
if self.isEmpty():
# First element: both head and tail point to it
self.head = new_node
self.tail = new_node
else:
# Append to tail
self.tail.next = new_node
self.tail = new_node
self.count += 1
return True
def deQueue(self) -> bool:
if self.isEmpty():
return False
# Move head to next node
self.head = self.head.next
self.count -= 1
# If queue becomes empty, reset tail too
if self.isEmpty():
self.tail = None
return True
def Front(self) -> int:
if self.isEmpty():
return -1
return self.head.val
def Rear(self) -> int:
if self.isEmpty():
return -1
return self.tail.val
def isEmpty(self) -> bool:
return self.count == 0
def isFull(self) -> bool:
return self.count == self.capacity
explanation: |
**Time Complexity:** O(1) — All operations are constant time.
**Space Complexity:** O(k) — At most `k` nodes in the linked list.
This alternative uses a singly linked list instead of an array. While it also achieves O(1) operations, it has higher memory overhead due to node objects and pointers. The array-based approach is generally preferred for its better cache locality and lower memory footprint. However, the linked list approach demonstrates that the "circular" concept is about the logical structure, not necessarily a physical ring in memory.

View File

@@ -0,0 +1,233 @@
title: Design HashMap
slug: design-hashmap
difficulty: easy
leetcode_id: 706
leetcode_url: https://leetcode.com/problems/design-hashmap/
categories:
- arrays
- hash-tables
- linked-lists
patterns:
- heap
description: |
Design a HashMap without using any built-in hash table libraries.
Implement the `MyHashMap` class:
- `MyHashMap()` initialises the object with an empty map.
- `void put(int key, int value)` inserts a `(key, value)` pair into the HashMap. If the `key` already exists in the map, update the corresponding `value`.
- `int get(int key)` returns the `value` to which the specified `key` is mapped, or `-1` if this map contains no mapping for the `key`.
- `void remove(int key)` removes the `key` and its corresponding `value` if the map contains the mapping for the `key`.
constraints: |
- `0 <= key, value <= 10^6`
- At most `10^4` calls will be made to `put`, `get`, and `remove`.
examples:
- input: |
["MyHashMap", "put", "put", "get", "get", "put", "get", "remove", "get"]
[[], [1, 1], [2, 2], [1], [3], [2, 1], [2], [2], [2]]
output: "[null, null, null, 1, -1, null, 1, null, -1]"
explanation: |
MyHashMap myHashMap = new MyHashMap();
myHashMap.put(1, 1); // The map is now [[1,1]]
myHashMap.put(2, 2); // The map is now [[1,1], [2,2]]
myHashMap.get(1); // return 1
myHashMap.get(3); // return -1 (not found)
myHashMap.put(2, 1); // The map is now [[1,1], [2,1]] (update existing value)
myHashMap.get(2); // return 1
myHashMap.remove(2); // remove the mapping for 2, map is now [[1,1]]
myHashMap.get(2); // return -1 (not found)
explanation:
intuition: |
A hash map is one of the most fundamental data structures in computer science. At its core, it provides **constant-time average lookup** by converting keys into array indices using a *hash function*.
Think of it like a library with numbered shelves. Instead of searching every shelf for a book, you use the book's title to calculate which shelf number it belongs on. When you want to find it later, you perform the same calculation and go directly to that shelf.
The challenge arises when two different keys "hash" to the same index — this is called a **collision**. Imagine two books that both map to shelf #42. We need a strategy to handle this:
1. **Chaining**: Each shelf holds a list (or linked list) of all items that hashed there
2. **Open Addressing**: If a shelf is occupied, look for the next empty shelf
For this problem, **chaining with linked lists** is the most intuitive approach. Each "bucket" in our array holds a linked list of key-value pairs. When we add, get, or remove, we first hash the key to find the bucket, then traverse the linked list to find the specific entry.
approach: |
We implement a hash map using **chaining** with an array of linked list heads.
**Step 1: Choose an array size and hash function**
- Create an array of size `1000` (a reasonable size that balances memory and collision rate)
- Use a simple modulo hash function: `hash(key) = key % size`
- Each array position is a "bucket" that will hold a linked list head
&nbsp;
**Step 2: Define the linked list node structure**
- Each node stores a `key`, `value`, and pointer to the `next` node
- This allows multiple key-value pairs with the same hash to coexist in one bucket
&nbsp;
**Step 3: Implement `put(key, value)`**
- Calculate the bucket index using the hash function
- Traverse the linked list at that bucket
- If the key already exists, update its value
- If not found, append a new node to the end of the list
&nbsp;
**Step 4: Implement `get(key)`**
- Calculate the bucket index
- Traverse the linked list looking for the key
- Return the value if found, or `-1` if not found
&nbsp;
**Step 5: Implement `remove(key)`**
- Calculate the bucket index
- Traverse the linked list to find the key
- Remove the node by updating the previous node's `next` pointer
- Use a dummy head node to simplify edge cases (removing the first node)
common_pitfalls:
- title: Forgetting to Handle Collisions
description: |
A naive approach might use the hash directly as an array index without handling cases where multiple keys hash to the same bucket.
For example, with `size = 1000`, both `key = 5` and `key = 1005` hash to index `5`. Without a collision resolution strategy, one would overwrite the other.
Using linked list chaining ensures all keys with the same hash coexist in the same bucket.
wrong_approach: "Single value per bucket (overwrites on collision)"
correct_approach: "Linked list per bucket to chain colliding entries"
- title: Not Updating Existing Keys
description: |
The `put` operation must check if the key already exists before inserting. If it exists, we update the value rather than adding a duplicate entry.
Failing to do this creates multiple nodes with the same key, causing `get` to return stale values and wasting memory.
wrong_approach: "Always append without checking for existing key"
correct_approach: "Traverse the list first, update if found, else append"
- title: Removal Edge Cases
description: |
Removing a node from a linked list requires updating the `next` pointer of the **previous** node. This is tricky when removing the first node in a bucket.
Using a dummy head node as a sentinel simplifies the logic — the dummy's `next` always points to the real first node, so removal logic is uniform.
wrong_approach: "Special-case removal of head node separately"
correct_approach: "Use a dummy head node so all removals follow the same pattern"
key_takeaways:
- "**Hash function basics**: A good hash function distributes keys uniformly across buckets to minimise collisions"
- "**Chaining vs open addressing**: Chaining uses linked lists for collision resolution; open addressing probes for empty slots"
- "**Trade-off between space and time**: More buckets mean fewer collisions (faster lookup) but more memory; fewer buckets save memory but increase collision likelihood"
- "**Foundation for real hash tables**: This simple implementation illustrates the core mechanics behind Python's `dict`, Java's `HashMap`, and other production hash tables"
time_complexity: "O(n/k) average for all operations, where `n` is the number of keys and `k` is the number of buckets. With a good hash function and sufficient buckets, this approaches O(1)."
space_complexity: "O(k + n). We use `k` buckets for the array plus `n` nodes for the stored key-value pairs."
solutions:
- approach_name: Chaining with Linked Lists
is_optimal: true
code: |
class ListNode:
"""Node for the linked list in each bucket."""
def __init__(self, key: int = -1, value: int = -1, next: 'ListNode' = None):
self.key = key
self.value = value
self.next = next
class MyHashMap:
def __init__(self):
# Choose a prime number for better distribution
self.size = 1000
# Each bucket starts with a dummy head node
self.buckets = [ListNode() for _ in range(self.size)]
def _hash(self, key: int) -> int:
"""Simple modulo hash function."""
return key % self.size
def put(self, key: int, value: int) -> None:
# Find the bucket for this key
index = self._hash(key)
curr = self.buckets[index]
# Traverse to find if key exists (skip dummy head)
while curr.next:
if curr.next.key == key:
# Key exists - update value
curr.next.value = value
return
curr = curr.next
# Key not found - append new node
curr.next = ListNode(key, value)
def get(self, key: int) -> int:
# Find the bucket for this key
index = self._hash(key)
curr = self.buckets[index].next # Skip dummy head
# Traverse looking for the key
while curr:
if curr.key == key:
return curr.value
curr = curr.next
# Key not found
return -1
def remove(self, key: int) -> None:
# Find the bucket for this key
index = self._hash(key)
curr = self.buckets[index]
# Find the node before the one to remove
while curr.next:
if curr.next.key == key:
# Remove by skipping over the node
curr.next = curr.next.next
return
curr = curr.next
explanation: |
**Time Complexity:** O(n/k) average for `put`, `get`, and `remove`, where `n` is the total number of keys and `k` is the number of buckets (1000). In the worst case (all keys hash to one bucket), operations are O(n).
**Space Complexity:** O(k + n) — `k` bucket heads plus `n` nodes for stored entries.
This implementation uses chaining with linked lists. Each bucket has a dummy head node to simplify insertion and removal logic. The hash function is a simple modulo operation. While not optimal for production use, it demonstrates the core concepts of hash table implementation.
- approach_name: Direct Array (Large Array)
is_optimal: false
code: |
class MyHashMap:
def __init__(self):
# Use array size of 10^6 + 1 to cover all possible keys
# Each position stores the value, or -1 if empty
self.data = [-1] * (10**6 + 1)
def put(self, key: int, value: int) -> None:
# Direct indexing - no hash function needed
self.data[key] = value
def get(self, key: int) -> int:
# Return value at index, -1 if never set
return self.data[key]
def remove(self, key: int) -> None:
# Reset to -1 to indicate removed
self.data[key] = -1
explanation: |
**Time Complexity:** O(1) for all operations — direct array indexing.
**Space Complexity:** O(10^6) — fixed array size regardless of actual usage.
This approach exploits the constraint that keys are in range `[0, 10^6]`. By allocating an array large enough to hold all possible keys, we avoid hashing and collisions entirely. Each key maps directly to its index.
While this achieves true O(1) time, it wastes significant memory when few keys are stored. It's included to show the trade-off between time and space. The chaining approach is preferred in practice as it scales with actual usage.

View File

@@ -0,0 +1,203 @@
title: Design HashSet
slug: design-hashset
difficulty: easy
leetcode_id: 705
leetcode_url: https://leetcode.com/problems/design-hashset/
categories:
- arrays
- hash-tables
patterns:
- heap
description: |
Design a HashSet without using any built-in hash table libraries.
Implement `MyHashSet` class:
- `void add(key)` Inserts the value `key` into the HashSet.
- `bool contains(key)` Returns whether the value `key` exists in the HashSet or not.
- `void remove(key)` Removes the value `key` in the HashSet. If `key` does not exist in the HashSet, do nothing.
constraints: |
- `0 <= key <= 10^6`
- At most `10^4` calls will be made to `add`, `remove`, and `contains`.
examples:
- input: '["MyHashSet", "add", "add", "contains", "contains", "add", "contains", "remove", "contains"], [[], [1], [2], [1], [3], [2], [2], [2], [2]]'
output: "[null, null, null, true, false, null, true, null, false]"
explanation: |
MyHashSet myHashSet = new MyHashSet();
myHashSet.add(1); // set = [1]
myHashSet.add(2); // set = [1, 2]
myHashSet.contains(1); // return True
myHashSet.contains(3); // return False, (not found)
myHashSet.add(2); // set = [1, 2]
myHashSet.contains(2); // return True
myHashSet.remove(2); // set = [1]
myHashSet.contains(2); // return False, (already removed)
explanation:
intuition: |
Think of a hash set like a large filing cabinet with numbered drawers. Instead of searching through every drawer to find a document, you use a formula (the **hash function**) to instantly determine which drawer to check.
The core insight is that we need to map potentially millions of keys (`0` to `10^6`) to a manageable number of storage locations. This is done using the **modulo operation**: `key % num_buckets` gives us a bucket index. For example, with 1000 buckets, keys `5`, `1005`, and `2005` all map to bucket `5`.
But wait — multiple keys can map to the same bucket! This is called a **collision**. To handle collisions, each bucket stores a list of all keys that hash to it. When we add, remove, or search for a key, we first compute its bucket, then operate on the list within that bucket.
The art of hash set design lies in choosing the right number of buckets — enough to keep the lists short (for fast operations), but not so many that we waste memory.
approach: |
We use **Separate Chaining** with an array of buckets, where each bucket is a list that handles collisions.
**Step 1: Choose the number of buckets**
- Use a prime number like `1000` or `10007` to distribute keys evenly
- A prime reduces clustering from patterns in input data
- With `10^4` operations and `1000` buckets, average list length is ~10 (very fast)
&nbsp;
**Step 2: Initialise the data structure**
- Create an array of `num_buckets` empty lists
- `self.buckets`: The array where `buckets[i]` holds all keys with hash `i`
&nbsp;
**Step 3: Implement the hash function**
- `_hash(key)`: Returns `key % num_buckets`
- This maps any key to a valid bucket index in range `[0, num_buckets - 1]`
&nbsp;
**Step 4: Implement add(key)**
- Compute bucket index using `_hash(key)`
- Check if key already exists in that bucket's list (sets don't allow duplicates)
- If not present, append the key to the list
&nbsp;
**Step 5: Implement remove(key)**
- Compute bucket index using `_hash(key)`
- Search for the key in that bucket's list
- If found, remove it; if not found, do nothing
&nbsp;
**Step 6: Implement contains(key)**
- Compute bucket index using `_hash(key)`
- Return `True` if key is in that bucket's list, `False` otherwise
common_pitfalls:
- title: Using a Boolean Array
description: |
A tempting "simple" approach is to create a boolean array of size `10^6 + 1` where `arr[key] = True` means the key exists.
While this works and gives O(1) operations, it wastes **1 MB of memory** just for the array, even if you only store 10 keys. This fails the spirit of the problem (designing a hash set) and may cause memory issues.
wrong_approach: "Boolean array of size 10^6"
correct_approach: "Hash table with buckets using modulo"
- title: Forgetting Duplicate Prevention
description: |
A set must not contain duplicates. If you blindly append to the bucket list on every `add()` call, you'll end up with duplicate entries.
For example, calling `add(5)` three times should result in `5` appearing once, not three times. Always check for existence before adding.
wrong_approach: "Always append key to bucket list"
correct_approach: "Check if key exists before appending"
- title: Poor Bucket Count Choice
description: |
Using too few buckets (e.g., 10) means each bucket holds many keys on average, making operations slow — approaching O(n) in the worst case.
Using a non-prime number (e.g., 1000) can cause clustering if keys follow patterns (e.g., all multiples of 100 land in the same bucket).
A prime like `769` or `10007` distributes keys more uniformly.
wrong_approach: "Using 10 buckets or a round number like 1000"
correct_approach: "Use a prime number of buckets (e.g., 769, 1009, 10007)"
- title: Not Handling remove() on Non-Existent Key
description: |
The problem states: "If `key` does not exist in the HashSet, do nothing."
If you use `list.remove(key)` without checking, Python raises `ValueError` when the key isn't found. Always check existence first or use a try/except block.
wrong_approach: "Directly calling list.remove(key)"
correct_approach: "Check if key in bucket before removing"
key_takeaways:
- "**Hash function fundamentals**: The modulo operation is the simplest way to map large key spaces to bounded array indices"
- "**Collision handling**: Separate chaining (lists in buckets) is intuitive and works well for moderate load factors"
- "**Prime bucket counts**: Using a prime number of buckets reduces collision clustering from patterned input data"
- "**Design problems**: Understanding the *why* behind data structures (not just using built-ins) is crucial for interviews and system design"
time_complexity: "O(n/k) average for all operations, where `n` is the number of keys and `k` is the number of buckets. With a good hash function and sufficient buckets, this approaches O(1)."
space_complexity: "O(k + n), where `k` is the number of buckets and `n` is the number of stored keys. We allocate `k` empty lists upfront, then store `n` keys across them."
solutions:
- approach_name: Separate Chaining with Array of Lists
is_optimal: true
code: |
class MyHashSet:
def __init__(self):
# Use a prime number of buckets to reduce collision clustering
self.num_buckets = 769
# Each bucket is a list to handle collisions via chaining
self.buckets = [[] for _ in range(self.num_buckets)]
def _hash(self, key: int) -> int:
# Map any key to a valid bucket index
return key % self.num_buckets
def add(self, key: int) -> None:
bucket_index = self._hash(key)
bucket = self.buckets[bucket_index]
# Only add if not already present (sets don't allow duplicates)
if key not in bucket:
bucket.append(key)
def remove(self, key: int) -> None:
bucket_index = self._hash(key)
bucket = self.buckets[bucket_index]
# Only remove if present (avoid ValueError)
if key in bucket:
bucket.remove(key)
def contains(self, key: int) -> bool:
bucket_index = self._hash(key)
bucket = self.buckets[bucket_index]
# Check membership in the bucket's list
return key in bucket
explanation: |
**Time Complexity:** O(n/k) average per operation — where `n` is keys stored and `k` is bucket count. With 769 buckets and up to 10^4 operations, average bucket size stays small, making operations effectively O(1).
**Space Complexity:** O(k + n) — We pre-allocate `k` empty lists (769 pointers) plus store `n` actual keys distributed across buckets.
This approach uses separate chaining to handle collisions. Each bucket is a Python list that stores all keys hashing to that index. The prime bucket count (769) helps distribute keys evenly even when input has patterns.
- approach_name: Boolean Array (Space-Inefficient)
is_optimal: false
code: |
class MyHashSet:
def __init__(self):
# Allocate array for entire key range (wasteful)
# Uses ~1MB of memory regardless of actual usage
self.data = [False] * (10**6 + 1)
def add(self, key: int) -> None:
# Direct indexing - O(1) but wastes space
self.data[key] = True
def remove(self, key: int) -> None:
self.data[key] = False
def contains(self, key: int) -> bool:
return self.data[key]
explanation: |
**Time Complexity:** O(1) for all operations — Direct array indexing.
**Space Complexity:** O(max_key) = O(10^6) — Allocates memory for entire key range upfront.
While this achieves O(1) time complexity, it defeats the purpose of the exercise. Real hash sets don't know the key range in advance and must handle arbitrary keys efficiently. This approach wastes ~1MB of memory even for storing just a few keys, and doesn't teach the fundamental concepts of hashing and collision resolution.

View File

@@ -0,0 +1,250 @@
title: Design Twitter
slug: design-twitter
difficulty: medium
leetcode_id: 355
leetcode_url: https://leetcode.com/problems/design-twitter/
categories:
- hash-tables
- heap
patterns:
- heap
description: |
Design a simplified version of Twitter where users can post tweets, follow/unfollow another user, and is able to see the `10` most recent tweets in the user's news feed.
Implement the `Twitter` class:
- `Twitter()` Initialises your twitter object.
- `void postTweet(int userId, int tweetId)` Composes a new tweet with ID `tweetId` by the user `userId`. Each call to this function will be made with a unique `tweetId`.
- `List<Integer> getNewsFeed(int userId)` Retrieves the `10` most recent tweet IDs in the user's news feed. Each item in the news feed must be posted by users who the user followed or by the user themself. Tweets must be **ordered from most recent to least recent**.
- `void follow(int followerId, int followeeId)` The user with ID `followerId` started following the user with ID `followeeId`.
- `void unfollow(int followerId, int followeeId)` The user with ID `followerId` started unfollowing the user with ID `followeeId`.
constraints: |
- `1 <= userId, followerId, followeeId <= 500`
- `0 <= tweetId <= 10^4`
- All the tweets have **unique** IDs.
- At most `3 * 10^4` calls will be made to `postTweet`, `getNewsFeed`, `follow`, and `unfollow`.
- A user cannot follow himself.
examples:
- input: |
["Twitter", "postTweet", "getNewsFeed", "follow", "postTweet", "getNewsFeed", "unfollow", "getNewsFeed"]
[[], [1, 5], [1], [1, 2], [2, 6], [1], [1, 2], [1]]
output: "[null, null, [5], null, null, [6, 5], null, [5]]"
explanation: |
Twitter twitter = new Twitter();
twitter.postTweet(1, 5); // User 1 posts a new tweet (id = 5).
twitter.getNewsFeed(1); // User 1's news feed should return [5].
twitter.follow(1, 2); // User 1 follows user 2.
twitter.postTweet(2, 6); // User 2 posts a new tweet (id = 6).
twitter.getNewsFeed(1); // Returns [6, 5]. Tweet 6 precedes tweet 5 because it was posted later.
twitter.unfollow(1, 2); // User 1 unfollows user 2.
twitter.getNewsFeed(1); // Returns [5], since user 1 no longer follows user 2.
explanation:
intuition: |
Think of this problem as building two interconnected systems: a **social graph** (who follows whom) and a **timeline aggregator** (combining tweets from multiple sources in chronological order).
The social graph is straightforward — we need to track follow relationships efficiently. A hash map where each user maps to a set of people they follow works perfectly.
The interesting challenge is the news feed. When a user requests their feed, we need to find the 10 most recent tweets from potentially many users. Imagine each user has their own stream of tweets, and we need to **merge these streams** while keeping only the top 10 most recent.
This is exactly what a **min-heap** excels at! We can use a heap to efficiently track the k largest (most recent) elements from multiple sorted streams. Think of it like merging k sorted lists — we maintain a heap of "candidate" tweets, always pulling the most recent one next.
The key insight is that we don't need to look at every tweet ever posted. We only need to consider the most recent tweets from each followed user, and a heap lets us do this efficiently.
approach: |
We use **Hash Maps + Min-Heap** to solve this problem:
**Step 1: Design the data structures**
- `user_tweets`: A hash map where each user ID maps to a list of `(timestamp, tweet_id)` tuples, stored in chronological order (newest at the end)
- `user_follows`: A hash map where each user ID maps to a set of user IDs they follow
- `timestamp`: A global counter that increments with each tweet, used to determine recency
&nbsp;
**Step 2: Implement postTweet**
- Increment the global timestamp
- Append `(timestamp, tweet_id)` to the user's tweet list
- Time: O(1)
&nbsp;
**Step 3: Implement follow/unfollow**
- `follow`: Add followee to the follower's set of followed users
- `unfollow`: Remove followee from the set (if present)
- Time: O(1) for both operations
&nbsp;
**Step 4: Implement getNewsFeed (the core algorithm)**
- Collect all users whose tweets should appear: the user themself + everyone they follow
- For each of these users, if they have tweets, add their most recent tweet to a max-heap
- Store `(timestamp, tweet_id, user_id, index)` in the heap, where index points to the tweet's position in that user's list
- Pop the most recent tweet from the heap, add it to the result
- Push the *next* tweet from that same user (if any) to the heap
- Repeat until we have 10 tweets or the heap is empty
- Time: O(k log n) where k is 10 and n is the number of followed users
&nbsp;
This approach efficiently merges multiple tweet streams without sorting all tweets together.
common_pitfalls:
- title: Sorting All Tweets
description: |
A naive approach collects all tweets from the user and their followees, sorts them by timestamp, and returns the top 10.
While correct, this is inefficient. If a user follows many active posters, you might be sorting thousands of tweets just to return 10. With `3 * 10^4` calls to `getNewsFeed`, this adds up quickly.
The heap-based approach only examines at most `10 * n` tweets (where n is the number of followed users), which is much more efficient when users have many tweets.
wrong_approach: "Collect all tweets, sort, take top 10"
correct_approach: "Use a heap to merge streams, pulling only what's needed"
- title: Forgetting Self-Tweets
description: |
The news feed must include the user's own tweets, not just tweets from people they follow.
Make sure when building the list of "users to check", you include the requesting user themselves, regardless of their follow list.
wrong_approach: "Only check followed users' tweets"
correct_approach: "Include self in the list of users to aggregate"
- title: Missing Edge Cases
description: |
Several edge cases need handling:
- User has no tweets and follows no one: return empty list
- User unfollows someone: their tweets should no longer appear
- User posts more than 10 tweets: only show the 10 most recent
- User follows themself (not allowed per constraints, but good to handle gracefully)
Using sets for the follow relationship and defaultdict for tweets handles most of these automatically.
key_takeaways:
- "**Heap for top-k from multiple streams**: When merging sorted streams and only needing the top k elements, a heap is the ideal data structure"
- "**Separate concerns**: The social graph (follows) and content storage (tweets) are independent — design them separately"
- "**Lazy evaluation**: Don't process all data upfront. The heap approach only examines tweets as needed"
- "**Foundation for real systems**: This pattern (fan-out on read with heap merge) is used in actual social media systems at scale"
time_complexity: "O(1) for `postTweet`, `follow`, and `unfollow`. O(k log n) for `getNewsFeed` where k = 10 and n is the number of followed users."
space_complexity: "O(U + T + F) where U is the number of users, T is the total number of tweets, and F is the total number of follow relationships."
solutions:
- approach_name: Hash Map + Max-Heap
is_optimal: true
code: |
import heapq
from collections import defaultdict
class Twitter:
def __init__(self):
# Global timestamp to track tweet order
self.timestamp = 0
# user_id -> list of (timestamp, tweet_id)
self.user_tweets = defaultdict(list)
# user_id -> set of followed user_ids
self.user_follows = defaultdict(set)
def postTweet(self, userId: int, tweetId: int) -> None:
# Store tweet with current timestamp, then increment
self.user_tweets[userId].append((self.timestamp, tweetId))
self.timestamp += 1
def getNewsFeed(self, userId: int) -> list[int]:
# Users whose tweets we care about: self + followed users
users_to_check = self.user_follows[userId] | {userId}
# Max-heap: store (-timestamp, tweet_id, user_id, index)
# Negative timestamp because heapq is a min-heap
max_heap = []
for uid in users_to_check:
tweets = self.user_tweets[uid]
if tweets:
# Start with most recent tweet (last in list)
idx = len(tweets) - 1
ts, tid = tweets[idx]
# Push negative timestamp for max-heap behavior
heapq.heappush(max_heap, (-ts, tid, uid, idx))
result = []
while max_heap and len(result) < 10:
neg_ts, tid, uid, idx = heapq.heappop(max_heap)
result.append(tid)
# If this user has more tweets, add the next one
if idx > 0:
idx -= 1
ts, tid = self.user_tweets[uid][idx]
heapq.heappush(max_heap, (-ts, tid, uid, idx))
return result
def follow(self, followerId: int, followeeId: int) -> None:
# Prevent self-follow (though constraints say it won't happen)
if followerId != followeeId:
self.user_follows[followerId].add(followeeId)
def unfollow(self, followerId: int, followeeId: int) -> None:
# Remove from set if present (discard won't raise error)
self.user_follows[followerId].discard(followeeId)
explanation: |
**Time Complexity:**
- `postTweet`: O(1) — append to list
- `follow`/`unfollow`: O(1) — set operations
- `getNewsFeed`: O(k log n) — where k = 10 tweets and n = number of followed users. We do at most k heap operations, each O(log n).
**Space Complexity:** O(U + T + F) — storing users, tweets, and follow relationships.
The heap elegantly merges multiple sorted tweet streams. By storing the index into each user's tweet list, we can efficiently pull the "next" tweet from any user when needed.
- approach_name: Collect and Sort
is_optimal: false
code: |
from collections import defaultdict
class Twitter:
def __init__(self):
self.timestamp = 0
self.user_tweets = defaultdict(list)
self.user_follows = defaultdict(set)
def postTweet(self, userId: int, tweetId: int) -> None:
self.user_tweets[userId].append((self.timestamp, tweetId))
self.timestamp += 1
def getNewsFeed(self, userId: int) -> list[int]:
# Collect ALL tweets from self and followed users
all_tweets = []
users_to_check = self.user_follows[userId] | {userId}
for uid in users_to_check:
all_tweets.extend(self.user_tweets[uid])
# Sort all tweets by timestamp (descending)
all_tweets.sort(key=lambda x: x[0], reverse=True)
# Return top 10 tweet IDs
return [tid for _, tid in all_tweets[:10]]
def follow(self, followerId: int, followeeId: int) -> None:
if followerId != followeeId:
self.user_follows[followerId].add(followeeId)
def unfollow(self, followerId: int, followeeId: int) -> None:
self.user_follows[followerId].discard(followeeId)
explanation: |
**Time Complexity:**
- `postTweet`: O(1)
- `follow`/`unfollow`: O(1)
- `getNewsFeed`: O(T log T) where T is the total number of tweets from followed users
**Space Complexity:** O(T) for collecting all tweets during `getNewsFeed`.
This approach is simpler but less efficient. For users who follow many active posters, collecting and sorting all their tweets becomes expensive. The heap approach is preferred for production systems.

View File

@@ -0,0 +1,237 @@
title: Detect Squares
slug: detect-squares
difficulty: medium
leetcode_id: 2013
leetcode_url: https://leetcode.com/problems/detect-squares/
categories:
- arrays
- hash-tables
patterns:
- heap
description: |
You are given a stream of points on the X-Y plane. Design an algorithm that:
- **Adds** new points from the stream into a data structure. **Duplicate** points are allowed and should be treated as different points.
- Given a query point, **counts** the number of ways to choose three points from the data structure such that the three points and the query point form an **axis-aligned square** with **positive area**.
An **axis-aligned square** is a square whose edges are all the same length and are either parallel or perpendicular to the x-axis and y-axis.
Implement the `DetectSquares` class:
- `DetectSquares()` Initializes the object with an empty data structure.
- `void add(int[] point)` Adds a new point `point = [x, y]` to the data structure.
- `int count(int[] point)` Counts the number of ways to form **axis-aligned squares** with point `point = [x, y]` as described above.
constraints: |
- `point.length == 2`
- `0 <= x, y <= 1000`
- At most `3000` calls in total will be made to `add` and `count`
examples:
- input: |
["DetectSquares", "add", "add", "add", "count", "count", "add", "count"]
[[], [[3, 10]], [[11, 2]], [[3, 2]], [[11, 10]], [[14, 8]], [[11, 2]], [[11, 10]]]
output: "[null, null, null, null, 1, 0, null, 2]"
explanation: |
DetectSquares detectSquares = new DetectSquares();
detectSquares.add([3, 10]);
detectSquares.add([11, 2]);
detectSquares.add([3, 2]);
detectSquares.count([11, 10]); // return 1 - forms a square with the three added points
detectSquares.count([14, 8]); // return 0 - no square can be formed
detectSquares.add([11, 2]); // duplicate points are allowed
detectSquares.count([11, 10]); // return 2 - two ways to form a square now
explanation:
intuition: |
Imagine you're standing at a point on a grid and looking for axis-aligned squares that include your position as one corner.
The key insight is that an **axis-aligned square** has a very specific geometric property: if you know two diagonally opposite corners, you can determine the other two corners exactly. For a square with corners at `(x1, y1)` and `(x2, y2)` to be axis-aligned, the side length must be `|x2 - x1| = |y2 - y1|`.
Think of it this way: given your query point `(px, py)`, you need to find points that could be the **diagonal opposite** corner. A point `(x, y)` qualifies as a diagonal opposite if `|px - x| == |py - y|` and this distance is greater than zero (positive area requirement).
Once you have the query point and a diagonal point, the other two corners are uniquely determined:
- Corner 3: `(px, y)` — same x as query, same y as diagonal
- Corner 4: `(x, py)` — same x as diagonal, same y as query
The number of valid squares is the product of how many times each of those other two corners appears in our data structure. If either is missing (count = 0), no square can be formed with that diagonal.
approach: |
We solve this using a **Hash Map with Point Counting**:
**Step 1: Choose the right data structure**
- Use a hash map (dictionary) that maps each point `(x, y)` to its count
- This allows O(1) lookup for point existence and handles duplicates naturally
- Also maintain a list of all points with their x-coordinates grouped for efficient iteration
&nbsp;
**Step 2: Implement the add operation**
- When adding a point `(x, y)`, increment its count in the hash map
- This is O(1) and correctly handles duplicate points
&nbsp;
**Step 3: Implement the count operation**
- Given query point `(px, py)`, iterate through all points `(x, y)` in the data structure
- For each point, check if it could be a diagonal corner: `|px - x| == |py - y|` and `px != x`
- If valid, calculate the two other corners: `(px, y)` and `(x, py)`
- The number of squares with this diagonal is: `count[(x, y)] * count[(px, y)] * count[(x, py)]`
- Sum up contributions from all valid diagonals
&nbsp;
**Step 4: Return the total count**
- Return the accumulated count of all possible squares
&nbsp;
This approach leverages the geometric constraint of axis-aligned squares to reduce a potentially O(n^3) problem (checking all triplets) to O(n) per query.
common_pitfalls:
- title: Checking All Triplets
description: |
A naive approach might try to check all combinations of three points from the data structure to see if they form a square with the query point.
With up to 3000 operations and potentially thousands of points, checking all triplets would be O(n^3) per query — far too slow.
Instead, use the geometric insight that knowing the diagonal determines the other two corners exactly.
wrong_approach: "Iterating through all triplets of stored points"
correct_approach: "Iterate through potential diagonals and look up the other two corners"
- title: Forgetting About Duplicates
description: |
The problem explicitly states that duplicate points should be treated as different points. If you store points in a set, you lose duplicate information.
For example, if `[3, 2]` is added twice, there are now two ways to use that corner in a square. You must multiply the counts of all three corners to get the correct answer.
wrong_approach: "Using a set to store unique points only"
correct_approach: "Use a dictionary mapping points to their counts"
- title: Missing the Positive Area Constraint
description: |
The problem requires squares with **positive area**. This means the diagonal distance must be greater than zero — the query point and the diagonal point must be different.
If you don't check `px != x` (or equivalently `py != y`), you might count degenerate "squares" with zero area.
wrong_approach: "Not filtering out same-point diagonals"
correct_approach: "Ensure diagonal point differs from query point"
- title: Only Checking One Diagonal Direction
description: |
Given a query point `(px, py)` and a potential diagonal at `(x, y)`, there are actually two possible squares if the side length matches. The diagonal could go "up-right to down-left" or "up-left to down-right".
When iterating through all stored points as potential diagonals, both directions are naturally covered because you check every point, not just those in one quadrant.
wrong_approach: "Only considering diagonals in one direction"
correct_approach: "Check all stored points as potential diagonals"
key_takeaways:
- "**Geometric insight reduces complexity**: Axis-aligned squares have only 2 degrees of freedom (diagonal corners), not 4 independent corners"
- "**Hash maps for counting**: When duplicates matter, map elements to their counts rather than using sets"
- "**Design pattern**: For streaming data with queries, choose data structures that optimize the most frequent operation (here, count queries benefit from O(1) point lookups)"
- "**Multiplication principle**: When counting combinations, multiply the counts of independent choices"
time_complexity: "O(1) for `add`, O(n) for `count` where n is the number of unique x-coordinates with stored points. Each query iterates through potential diagonal points and does O(1) lookups."
space_complexity: "O(n) where n is the total number of points added. We store each point's count in the hash map."
solutions:
- approach_name: Hash Map with Point Counting
is_optimal: true
code: |
from collections import defaultdict
class DetectSquares:
def __init__(self):
# Map (x, y) -> count of that point
self.point_count = defaultdict(int)
# Map x -> list of y values at that x coordinate
self.x_to_ys = defaultdict(list)
def add(self, point: list[int]) -> None:
x, y = point
# Track this point's count
self.point_count[(x, y)] += 1
# Record y at this x for efficient iteration
self.x_to_ys[x].append(y)
def count(self, point: list[int]) -> int:
px, py = point
result = 0
# Check all points that share x-coordinate with query
# These could be vertical edges of potential squares
for y in self.x_to_ys[px]:
# Skip if same point (zero area)
if y == py:
continue
# Side length of potential square
side = abs(py - y)
# Check both possible squares (left and right)
for dx in [-side, side]:
x2 = px + dx
# Count squares: multiply counts of the 3 other corners
# (px, y) is on vertical edge, (x2, py) and (x2, y) complete square
result += (
self.point_count[(px, y)] *
self.point_count[(x2, py)] *
self.point_count[(x2, y)]
)
return result
explanation: |
**Time Complexity:**
- `add`: O(1) — dictionary update and list append
- `count`: O(n) — iterate through points sharing x-coordinate with query, O(1) lookups for other corners
**Space Complexity:** O(n) — storing all points and their counts
We use two data structures: a point-to-count map for O(1) existence checks, and an x-to-ys map to efficiently find points that could form vertical edges with the query point. For each potential vertical edge, we check if the horizontal corners exist.
- approach_name: Simple Hash Map
is_optimal: false
code: |
from collections import defaultdict
class DetectSquares:
def __init__(self):
# Map (x, y) -> count of that point
self.point_count = defaultdict(int)
# Store all points for iteration
self.points = []
def add(self, point: list[int]) -> None:
x, y = point
self.point_count[(x, y)] += 1
self.points.append((x, y))
def count(self, point: list[int]) -> int:
px, py = point
result = 0
# Check every stored point as potential diagonal
for x, y in self.points:
# Must form valid diagonal: same side length, not same point
if abs(px - x) != abs(py - y) or x == px:
continue
# The other two corners are determined
# Multiply counts of all three other corners
result += (
self.point_count[(x, py)] *
self.point_count[(px, y)]
)
return result
explanation: |
**Time Complexity:**
- `add`: O(1)
- `count`: O(n) where n is total points added (including duplicates)
**Space Complexity:** O(n) — storing all points
This simpler approach iterates through all added points as potential diagonal corners. It's slightly less efficient than the optimized version because it iterates through duplicate points multiple times, but it's conceptually clearer. The diagonal point's count is implicitly handled by iterating through the points list.

View File

@@ -0,0 +1,233 @@
title: Diameter of Binary Tree
slug: diameter-of-binary-tree
difficulty: easy
leetcode_id: 543
leetcode_url: https://leetcode.com/problems/diameter-of-binary-tree/
categories:
- trees
- recursion
patterns:
- dfs
- tree-traversal
description: |
Given the `root` of a binary tree, return *the length of the **diameter** of the tree*.
The **diameter** of a binary tree is the **length** of the longest path between any two nodes in a tree. This path may or may not pass through the `root`.
The **length** of a path between two nodes is represented by the number of edges between them.
constraints: |
- The number of nodes in the tree is in the range `[1, 10^4]`
- `-100 <= Node.val <= 100`
examples:
- input: "root = [1,2,3,4,5]"
output: "3"
explanation: "3 is the length of the path [4,2,1,3] or [5,2,1,3]."
- input: "root = [1,2]"
output: "1"
explanation: "The diameter is the single edge connecting node 1 to node 2."
explanation:
intuition: |
Picture a binary tree as a network of roads connecting cities. The "diameter" is the longest road trip you can take between any two cities without backtracking.
Here's the key insight: **every path in a tree passes through exactly one "highest" node** — the node where the path changes direction from going "up" to going "down". At this pivot node, the path consists of going down into the left subtree and going down into the right subtree.
Think of it like this: if you're standing at any node and want to find the longest path that passes through you, you'd reach as deep as possible into your left subtree, then come back up through yourself, then reach as deep as possible into your right subtree. The total path length is `left_depth + right_depth`.
The diameter of the entire tree is the maximum such path across all possible pivot nodes. This means we need to visit every node and calculate the best path that passes through it, keeping track of the global maximum.
approach: |
We solve this using **DFS (Depth-First Search)** with a clever twist: while computing depths, we simultaneously track the diameter.
**Step 1: Define the recursive helper function**
- Create a helper function `depth(node)` that returns the maximum depth of the subtree rooted at `node`
- Depth is defined as the number of edges from `node` to its deepest descendant
- A `None` node has depth `-1` (so a leaf has depth `0`)
&nbsp;
**Step 2: Calculate diameter at each node**
- At each node, the longest path passing through it equals `left_depth + right_depth + 2`
- The `+2` accounts for the edges connecting the node to its left and right children
- Update the global `diameter` variable if this path is longer than any seen before
&nbsp;
**Step 3: Return the depth for parent calculations**
- Return `max(left_depth, right_depth) + 1` — this gives the depth of the current subtree
- The parent node needs this value to compute its own potential diameter
&nbsp;
**Step 4: Initiate DFS from root**
- Call `depth(root)` to traverse the entire tree
- Return the `diameter` variable which holds the maximum path length found
&nbsp;
This approach is elegant because we compute two things in one traversal: the depth (needed for parent calculations) and the diameter (our answer). Each node is visited exactly once.
common_pitfalls:
- title: Confusing Depth with Diameter
description: |
The depth of a node is the distance from that node to its deepest descendant. The diameter is the longest path between any two nodes.
A common mistake is returning depth instead of diameter, or confusing how they relate. Remember: **diameter at a node = left_depth + right_depth + 2** (the path going down left, up through node, down right).
wrong_approach: "Return depth as the answer"
correct_approach: "Track diameter separately while computing depths"
- title: Forgetting the Path Doesn't Have to Pass Through Root
description: |
Many people assume the longest path must go through the root node. Consider a tree like:
```
1
/
2
/ \
3 4
/ \
5 6
```
The diameter is `4` (path: 5→3→2→4→6), which doesn't pass through node 1. You must check the diameter at every node, not just the root.
wrong_approach: "Only calculate left_depth + right_depth at root"
correct_approach: "Update diameter at every node during traversal"
- title: Off-by-One Errors with Depth Definition
description: |
There are two common conventions for depth:
- Edges: `None` = -1, leaf = 0
- Nodes: `None` = 0, leaf = 1
If using the edges convention, the diameter formula is `left + right + 2`. If using the nodes convention, it's `left + right`. Mixing these up causes off-by-one errors.
This solution uses the edges convention for clarity.
wrong_approach: "Inconsistent depth definitions"
correct_approach: "Stick to one convention: None=-1, leaf=0, diameter=left+right+2"
key_takeaways:
- "**Compute while traversing**: When you need a global property (like diameter), compute it during DFS rather than making separate passes"
- "**The pivot node pattern**: Many tree path problems involve finding the best path that passes through each node as a pivot point"
- "**Depth vs. Diameter**: Understand that depth is a local property (one subtree) while diameter is a global property (spanning across subtrees)"
- "**Similar problems**: This pattern applies to Binary Tree Maximum Path Sum, Longest Univalue Path, and other tree path problems"
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. 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: DFS with Depth Calculation
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 diameter_of_binary_tree(root: TreeNode) -> int:
# Track the maximum diameter found across all nodes
diameter = 0
def depth(node: TreeNode) -> int:
nonlocal diameter
# Base case: empty node has depth -1
if not node:
return -1
# Recursively find depth of left and right subtrees
left_depth = depth(node.left)
right_depth = depth(node.right)
# Diameter through this node = left + right + 2 edges
# (one edge to left child, one edge to right child)
diameter = max(diameter, left_depth + right_depth + 2)
# Return depth of this subtree for parent's calculation
return max(left_depth, right_depth) + 1
depth(root)
return diameter
explanation: |
**Time Complexity:** O(n) — Each node is visited exactly once.
**Space Complexity:** O(h) — Recursion stack depth equals tree height.
We perform a post-order DFS, computing depths bottom-up. At each node, we calculate the potential diameter passing through it and update our global maximum. The depth returned to the parent is the longer of the two subtree depths plus one.
- approach_name: DFS with Class Variable
is_optimal: true
code: |
class Solution:
def diameter_of_binary_tree(self, root: TreeNode) -> int:
self.diameter = 0
def depth(node: TreeNode) -> int:
if not node:
return -1
left = depth(node.left)
right = depth(node.right)
# Update diameter if path through this node is longer
self.diameter = max(self.diameter, left + right + 2)
return max(left, right) + 1
depth(root)
return self.diameter
explanation: |
**Time Complexity:** O(n) — Single traversal of all nodes.
**Space Complexity:** O(h) — Recursion stack space.
This is the same algorithm but uses a class instance variable instead of `nonlocal`. This style is common in LeetCode submissions where the solution is wrapped in a class.
- approach_name: Iterative with Stack
is_optimal: false
code: |
def diameter_of_binary_tree(root: TreeNode) -> int:
if not root:
return 0
diameter = 0
depth_map = {} # Maps node to its depth
stack = [(root, False)] # (node, visited)
while stack:
node, visited = stack.pop()
if not node:
continue
if visited:
# Post-order: children already processed
left_depth = depth_map.get(node.left, -1)
right_depth = depth_map.get(node.right, -1)
# Calculate diameter through this node
diameter = max(diameter, left_depth + right_depth + 2)
# Store depth for parent's calculation
depth_map[node] = max(left_depth, right_depth) + 1
else:
# First visit: push back with visited=True, then children
stack.append((node, True))
stack.append((node.right, False))
stack.append((node.left, False))
return diameter
explanation: |
**Time Complexity:** O(n) — Each node is visited twice (once to push children, once to compute).
**Space Complexity:** O(n) — Stack and hash map both store up to n entries.
This iterative approach simulates post-order traversal using a stack. It's useful when recursion depth might cause stack overflow, but uses more space due to the hash map storing depths. For most practical cases, the recursive solution is cleaner.

View File

@@ -0,0 +1,214 @@
title: Distinct Subsequences
slug: distinct-subsequences
difficulty: hard
leetcode_id: 115
leetcode_url: https://leetcode.com/problems/distinct-subsequences/
categories:
- strings
- dynamic-programming
patterns:
- dynamic-programming
description: |
Given two strings `s` and `t`, return *the number of distinct **subsequences** of* `s` *which equals* `t`.
A **subsequence** of a string is a new string generated from the original string with some characters (can be none) deleted without changing the relative order of the remaining characters.
For example, `"ace"` is a subsequence of `"abcde"` while `"aec"` is not.
The test cases are generated so that the answer fits on a 32-bit signed integer.
constraints: |
- `1 <= s.length, t.length <= 1000`
- `s` and `t` consist of English letters
examples:
- input: 's = "rabbbit", t = "rabbit"'
output: "3"
explanation: "There are 3 ways to form 'rabbit' from 'rabbbit' by choosing different combinations of the three 'b' characters."
- input: 's = "babgbag", t = "bag"'
output: "5"
explanation: "There are 5 ways to form 'bag' from 'babgbag' by choosing different combinations of 'b', 'a', and 'g' characters."
explanation:
intuition: |
Imagine you're trying to "highlight" characters in string `s` such that the highlighted characters spell out `t`, maintaining their original order.
The key insight is that this is fundamentally a **counting problem with choices**. When we encounter a character in `s` that matches the current character we need in `t`, we have two options:
- **Use it**: Include this character as part of our subsequence match
- **Skip it**: Don't use this character, hoping to find another match later
Think of it like this: if you're looking for the word "cat" in "caat", when you reach the first 'a', you can either use it (and look for 't' next) or skip it (still looking for 'a'). Both paths might lead to valid subsequences.
This **"use or skip"** decision at each matching character creates a branching structure that screams **dynamic programming**. We need to count all possible ways, not just find one, which is why we sum up the counts from both choices.
The DP state naturally becomes: "How many ways can we match the first `j` characters of `t` using the first `i` characters of `s`?"
approach: |
We solve this using **2D Dynamic Programming**:
**Step 1: Define the DP state**
- `dp[i][j]`: The number of distinct subsequences of `s[0...i-1]` that equal `t[0...j-1]`
- We use 1-indexed DP for cleaner base cases (index 0 represents empty string)
&nbsp;
**Step 2: Establish base cases**
- `dp[i][0] = 1` for all `i`: There's exactly one way to form an empty string — delete all characters
- `dp[0][j] = 0` for all `j > 0`: Cannot form a non-empty string from an empty source
&nbsp;
**Step 3: Fill the DP table**
- For each position `(i, j)`, if `s[i-1] == t[j-1]` (characters match):
- `dp[i][j] = dp[i-1][j-1] + dp[i-1][j]`
- We can either **use** this character (`dp[i-1][j-1]`) or **skip** it (`dp[i-1][j]`)
- If characters don't match:
- `dp[i][j] = dp[i-1][j]`
- We must skip this character in `s`
&nbsp;
**Step 4: Return the result**
- Return `dp[m][n]` where `m = len(s)` and `n = len(t)`
&nbsp;
The recurrence captures the essence of the problem: at each step, we inherit all ways from skipping, plus (if characters match) all ways from using the current character.
common_pitfalls:
- title: Recursive Without Memoisation
description: |
A naive recursive solution tries all possibilities but recalculates the same subproblems repeatedly.
With `s.length` and `t.length` up to 1000, the exponential branching factor means a plain recursive solution will **Time Limit Exceed (TLE)**.
Always use memoisation or bottom-up DP to store computed results.
wrong_approach: "Plain recursion exploring all paths"
correct_approach: "Memoised recursion or bottom-up DP table"
- title: Incorrect Base Case
description: |
A common mistake is setting `dp[0][0] = 0` or forgetting that an empty pattern `t` can always be formed from any string `s` (by selecting no characters).
The correct base case is `dp[i][0] = 1` for all `i` — there's exactly one way to form an empty subsequence.
wrong_approach: "dp[i][0] = 0 or inconsistent base cases"
correct_approach: "dp[i][0] = 1 for all i (one way to form empty string)"
- title: Off-by-One Errors with Indexing
description: |
When using 1-indexed DP (which simplifies base cases), remember that `dp[i][j]` refers to the first `i` characters of `s` and first `j` characters of `t`.
When comparing characters, use `s[i-1]` and `t[j-1]`, not `s[i]` and `t[j]`.
wrong_approach: "Comparing s[i] with t[j] in 1-indexed DP"
correct_approach: "Compare s[i-1] with t[j-1] when using 1-indexed DP"
- title: Forgetting to Handle Integer Overflow
description: |
With large inputs, the number of subsequences can grow extremely large. The problem guarantees the answer fits in a 32-bit signed integer, but intermediate DP values might overflow in some languages.
In Python this isn't an issue due to arbitrary precision integers, but in C++/Java you may need modular arithmetic or careful overflow handling.
key_takeaways:
- "**Classic DP pattern**: The 'use or skip' decision structure appears in many string matching problems like edit distance and longest common subsequence"
- "**State definition is key**: `dp[i][j]` counting ways to match `t[0...j-1]` using `s[0...i-1]` naturally captures the subproblem structure"
- "**Space optimisation possible**: Since each row only depends on the previous row, you can reduce space from O(m*n) to O(n) using a 1D array (iterate right-to-left)"
- "**Foundation for harder problems**: This pattern extends to problems involving counting paths, combinations, and string transformations"
time_complexity: "O(m * n). We fill a 2D DP table of size `m * n` where `m = len(s)` and `n = len(t)`, with O(1) work per cell."
space_complexity: "O(m * n) for the 2D DP table. Can be optimised to O(n) using a 1D array since each row only depends on the previous row."
solutions:
- approach_name: 2D Dynamic Programming
is_optimal: true
code: |
def num_distinct(s: str, t: str) -> int:
m, n = len(s), len(t)
# dp[i][j] = number of ways to form t[0...j-1] from s[0...i-1]
dp = [[0] * (n + 1) for _ in range(m + 1)]
# Base case: empty t can be formed from any prefix of s (one way)
for i in range(m + 1):
dp[i][0] = 1
# Fill the DP table
for i in range(1, m + 1):
for j in range(1, n + 1):
# Always inherit ways from skipping s[i-1]
dp[i][j] = dp[i - 1][j]
# If characters match, add ways from using s[i-1]
if s[i - 1] == t[j - 1]:
dp[i][j] += dp[i - 1][j - 1]
return dp[m][n]
explanation: |
**Time Complexity:** O(m * n) — We iterate through each cell of the m x n DP table once.
**Space Complexity:** O(m * n) — We store the full 2D DP table.
The solution builds up the count of subsequences by considering each character in `s` and deciding whether to use it (if it matches) or skip it. The recurrence relation captures both choices and sums their contributions.
- approach_name: Space-Optimised 1D DP
is_optimal: true
code: |
def num_distinct(s: str, t: str) -> int:
m, n = len(s), len(t)
# 1D array: dp[j] = ways to form t[0...j-1]
dp = [0] * (n + 1)
dp[0] = 1 # One way to form empty string
# Process each character in s
for i in range(1, m + 1):
# Iterate right-to-left to avoid using updated values
for j in range(n, 0, -1):
if s[i - 1] == t[j - 1]:
# Add ways from using current character
dp[j] += dp[j - 1]
# dp[j] already inherits previous value (skip case)
return dp[n]
explanation: |
**Time Complexity:** O(m * n) — Same iteration as 2D approach.
**Space Complexity:** O(n) — Only one 1D array of length `n + 1`.
Since each row of the 2D DP only depends on the previous row, we can use a single array. The key insight is iterating right-to-left so we don't overwrite values we still need. The "skip" case is handled automatically since `dp[j]` retains its value from the previous iteration.
- approach_name: Recursive with Memoisation
is_optimal: false
code: |
def num_distinct(s: str, t: str) -> int:
from functools import lru_cache
@lru_cache(maxsize=None)
def count(i: int, j: int) -> int:
# Base case: matched all of t
if j == len(t):
return 1
# Base case: exhausted s but t remains
if i == len(s):
return 0
# Skip s[i]
result = count(i + 1, j)
# Use s[i] if it matches t[j]
if s[i] == t[j]:
result += count(i + 1, j + 1)
return result
return count(0, 0)
explanation: |
**Time Complexity:** O(m * n) — Each unique state (i, j) is computed once due to memoisation.
**Space Complexity:** O(m * n) for the memoisation cache, plus O(m + n) for the recursion stack.
This top-down approach is more intuitive: at each position, we either skip the current character in `s` or use it (if it matches). Memoisation ensures we don't recompute the same subproblems. The bottom-up approach is generally preferred for better constant factors and no recursion overhead.

View File

@@ -0,0 +1,194 @@
title: Dota2 Senate
slug: dota2-senate
difficulty: medium
leetcode_id: 649
leetcode_url: https://leetcode.com/problems/dota2-senate/
categories:
- strings
- queue
patterns:
- greedy
description: |
In the world of Dota2, there are two parties: the **Radiant** and the **Dire**.
The Dota2 senate consists of senators coming from two parties. Now the Senate wants to decide on a change in the Dota2 game. The voting for this change is a round-based procedure. In each round, each senator can exercise **one** of the two rights:
- **Ban one senator's right:** A senator can make another senator lose all his rights in this and all the following rounds.
- **Announce the victory:** If this senator found the senators who still have rights to vote are all from the same party, he can announce the victory and decide on the change in the game.
Given a string `senate` representing each senator's party belonging. The character `'R'` and `'D'` represent the Radiant party and the Dire party. Then if there are `n` senators, the size of the given string will be `n`.
The round-based procedure starts from the first senator to the last senator in the given order. This procedure will last until the end of voting. All the senators who have lost their rights will be skipped during the procedure.
Suppose every senator is smart enough and will play the best strategy for his own party. Predict which party will finally announce the victory and change the Dota2 game. The output should be `"Radiant"` or `"Dire"`.
constraints: |
- `n == senate.length`
- `1 <= n <= 10^4`
- `senate[i]` is either `'R'` or `'D'`
examples:
- input: 'senate = "RD"'
output: '"Radiant"'
explanation: "The first senator comes from Radiant and he can just ban the next senator's right in round 1. The second senator can't exercise any rights anymore since his right has been banned. In round 2, the first senator can announce the victory since he is the only one who can vote."
- input: 'senate = "RDD"'
output: '"Dire"'
explanation: "The first senator (Radiant) bans the second senator (Dire) in round 1. The third senator (Dire) bans the first senator (Radiant) in round 1. In round 2, only the third senator remains, so Dire wins."
explanation:
intuition: |
Imagine senators sitting in a circle, each waiting for their turn to act. When a senator's turn comes, their optimal strategy is always to **ban the next opponent** who would otherwise act before them in the next round.
Think of it like a game of elimination: each senator wants to silence the closest threat. Why the *next* opponent? Because banning someone who already acted this round doesn't help — they've already done their damage. But banning the next opponent in line prevents them from banning one of your allies.
The key insight is that this is a **circular process**. After reaching the end of the senate string, we wrap back to the beginning for the next round. Senators who survive keep participating until only one party remains.
We can simulate this efficiently using **two queues** — one for each party. Each queue stores the *indices* of active senators. When comparing the front of both queues, the senator with the smaller index acts first and bans the other. The winner then re-enters the queue with an updated index (adding `n` to simulate joining the next round).
approach: |
We solve this using a **Two Queue Simulation**:
**Step 1: Initialise two queues**
- `radiant`: Queue storing indices of all Radiant senators
- `dire`: Queue storing indices of all Dire senators
- Iterate through the senate string and add each senator's index to the appropriate queue
&nbsp;
**Step 2: Simulate the voting rounds**
- While both queues are non-empty, compare the front elements
- The senator with the **smaller index** acts first and bans the opponent
- The winning senator re-enters their queue with index `+ n` (to represent appearing in the next round)
- Remove both front elements and continue
&nbsp;
**Step 3: Determine the winner**
- When one queue becomes empty, the other party wins
- Return `"Radiant"` if the radiant queue is non-empty, otherwise `"Dire"`
&nbsp;
This simulation correctly handles the circular nature of rounds. By adding `n` to the winner's index, we ensure they appear *after* all senators from the current round, preserving relative order for the next round.
common_pitfalls:
- title: Ignoring the Circular Nature
description: |
A common mistake is treating the senate as a single pass. After the first round ends, surviving senators continue voting in subsequent rounds.
For example, with `senate = "DRRD"`, after round 1 we might have some senators banned, but the survivors wrap around for round 2. Using a simple linear scan misses this behaviour.
wrong_approach: "Single pass through the string"
correct_approach: "Simulate multiple rounds using queues"
- title: Banning the Wrong Opponent
description: |
The optimal strategy is to ban the **next opponent in order**, not just any opponent. Banning a senator who already acted this round wastes your move — they've already used their power.
The queue approach naturally handles this: we always compare indices and the smaller one acts first, banning their immediate threat.
wrong_approach: "Ban any random opponent"
correct_approach: "Ban the next opponent who would act before you in the next round"
- title: Not Tracking Bans Correctly
description: |
If you try to simulate with a single pass and mark banned senators, you need to handle the case where a senator is banned *before* their turn comes up in the same round.
With `senate = "RD"`, R bans D immediately. D never gets to act. The queue approach handles this cleanly — D is removed from the queue before being processed.
key_takeaways:
- "**Greedy optimal play**: Each senator's best move is banning the next opponent in turn order"
- "**Two-queue simulation**: Use queues to track active senators and their positions, enabling efficient circular simulation"
- "**Index manipulation for rounds**: Adding `n` to a winner's index elegantly models the circular round structure"
- "**Pattern recognition**: This problem combines queue simulation with greedy decision-making — a common pairing in competitive programming"
time_complexity: "O(n). Each senator is processed at most twice — once when initially added to a queue, and once when they either win or lose a confrontation."
space_complexity: "O(n). We store all senator indices across the two queues."
solutions:
- approach_name: Two Queue Simulation
is_optimal: true
code: |
from collections import deque
def predict_party_victory(senate: str) -> str:
n = len(senate)
# Queues store indices of active senators
radiant = deque()
dire = deque()
# Populate queues with initial positions
for i, s in enumerate(senate):
if s == 'R':
radiant.append(i)
else:
dire.append(i)
# Simulate until one party is eliminated
while radiant and dire:
r_idx = radiant.popleft()
d_idx = dire.popleft()
# Senator with smaller index acts first and bans the other
if r_idx < d_idx:
# Radiant wins this round, re-enters for next round
radiant.append(r_idx + n)
else:
# Dire wins this round, re-enters for next round
dire.append(d_idx + n)
# Whichever queue still has senators wins
return "Radiant" if radiant else "Dire"
explanation: |
**Time Complexity:** O(n) — Each senator participates in at most one confrontation.
**Space Complexity:** O(n) — Two queues storing all senator indices.
We use two queues to track the positions of active senators. In each iteration, we compare the front of both queues — the smaller index acts first and eliminates the opponent. The winner re-enters with an offset of `n`, ensuring correct ordering in subsequent rounds.
- approach_name: Greedy with Ban Counter
is_optimal: false
code: |
def predict_party_victory(senate: str) -> str:
senate = list(senate)
# Pending bans for each party
r_bans = 0
d_bans = 0
# Keep simulating until one party wins
while True:
new_senate = []
for s in senate:
if s == 'R':
if d_bans > 0:
# This Radiant senator is banned
d_bans -= 1
else:
# Radiant senator acts, queues a ban for Dire
r_bans += 1
new_senate.append('R')
else:
if r_bans > 0:
# This Dire senator is banned
r_bans -= 1
else:
# Dire senator acts, queues a ban for Radiant
d_bans += 1
new_senate.append('D')
# Check if only one party remains
if not any(s == 'D' for s in new_senate):
return "Radiant"
if not any(s == 'R' for s in new_senate):
return "Dire"
senate = new_senate
explanation: |
**Time Complexity:** O(n^2) in worst case — Multiple passes through the senate.
**Space Complexity:** O(n) — Storing the remaining senators each round.
This approach tracks pending bans for each party. When a senator acts, they queue a ban for the opposing party. If a senator's party has pending bans against them, they're eliminated instead of acting. This is less efficient than the two-queue solution but demonstrates the greedy logic more explicitly.

View File

@@ -0,0 +1,229 @@
title: Edit Distance
slug: edit-distance
difficulty: medium
leetcode_id: 72
leetcode_url: https://leetcode.com/problems/edit-distance/
categories:
- strings
- dynamic-programming
patterns:
- dynamic-programming
description: |
Given two strings `word1` and `word2`, return *the minimum number of operations required to convert `word1` to `word2`*.
You have the following three operations permitted on a word:
- Insert a character
- Delete a character
- Replace a character
constraints: |
- `0 <= word1.length, word2.length <= 500`
- `word1` and `word2` consist of lowercase English letters.
examples:
- input: 'word1 = "horse", word2 = "ros"'
output: "3"
explanation: 'horse -> rorse (replace ''h'' with ''r''), rorse -> rose (remove ''r''), rose -> ros (remove ''e'')'
- input: 'word1 = "intention", word2 = "execution"'
output: "5"
explanation: 'intention -> inention (remove ''t''), inention -> enention (replace ''i'' with ''e''), enention -> exention (replace ''n'' with ''x''), exention -> exection (replace ''n'' with ''c''), exection -> execution (insert ''u'')'
explanation:
intuition: |
Imagine you have two words written on paper and you want to transform the first word into the second using the fewest edits possible. Each edit is either inserting a letter, deleting a letter, or replacing one letter with another.
The key insight is that we can break this problem into **smaller subproblems**. If we're comparing two strings character by character from the end, at each position we face a decision:
- If the current characters **match**, we don't need any operation for this position — we simply move to the smaller subproblem of the remaining prefixes.
- If they **don't match**, we must perform one of three operations: insert, delete, or replace. Each operation leads to a different subproblem, and we pick the one with the minimum cost.
Think of it like building a grid where rows represent characters of `word1` and columns represent characters of `word2`. Each cell `(i, j)` stores the minimum edits needed to convert the first `i` characters of `word1` to the first `j` characters of `word2`. We fill this grid from the base cases (empty strings) toward the full strings.
This is a classic **dynamic programming** problem because:
1. It has **optimal substructure**: the optimal solution to the full problem depends on optimal solutions to subproblems
2. It has **overlapping subproblems**: the same subproblems are computed multiple times in a naive recursive approach
approach: |
We solve this using a **2D Dynamic Programming** approach:
**Step 1: Define the state**
- Let `dp[i][j]` represent the minimum number of operations to convert `word1[0..i-1]` to `word2[0..j-1]`
- `dp[0][0]` = 0 (empty string to empty string requires 0 operations)
&nbsp;
**Step 2: Initialise the base cases**
- `dp[i][0]` = `i` for all `i`: converting `word1[0..i-1]` to an empty string requires `i` deletions
- `dp[0][j]` = `j` for all `j`: converting an empty string to `word2[0..j-1]` requires `j` insertions
&nbsp;
**Step 3: Fill the DP table**
- For each cell `dp[i][j]`, compare `word1[i-1]` and `word2[j-1]`:
- If they are **equal**: `dp[i][j] = dp[i-1][j-1]` (no operation needed)
- If they are **different**: take the minimum of three options plus 1:
- `dp[i-1][j]` + 1: **Delete** from `word1` (we matched `word1[0..i-2]` to `word2[0..j-1]`, then delete `word1[i-1]`)
- `dp[i][j-1]` + 1: **Insert** into `word1` (we matched `word1[0..i-1]` to `word2[0..j-2]`, then insert `word2[j-1]`)
- `dp[i-1][j-1]` + 1: **Replace** `word1[i-1]` with `word2[j-1]`
&nbsp;
**Step 4: Return the result**
- Return `dp[m][n]` where `m = len(word1)` and `n = len(word2)`
common_pitfalls:
- title: Off-by-One Indexing Errors
description: |
The DP table has dimensions `(m+1) x (n+1)` to account for empty string cases. When accessing characters, remember that `dp[i][j]` corresponds to `word1[i-1]` and `word2[j-1]`.
A common mistake is using `word1[i]` when you should use `word1[i-1]`, leading to index out of bounds errors or incorrect comparisons.
wrong_approach: "Using dp[i][j] with word1[i] and word2[j]"
correct_approach: "Using dp[i][j] with word1[i-1] and word2[j-1]"
- title: Forgetting Base Case Initialisation
description: |
The first row and first column of the DP table must be initialised explicitly. `dp[i][0] = i` represents deleting all characters from `word1`, and `dp[0][j] = j` represents inserting all characters of `word2`.
Forgetting this initialisation leads to incorrect results because the recurrence relation depends on these base cases.
wrong_approach: "Leaving dp[i][0] and dp[0][j] as 0"
correct_approach: "Initialise dp[i][0] = i and dp[0][j] = j"
- title: Confusing the Three Operations
description: |
It's easy to mix up which direction in the DP table corresponds to which operation:
- Moving from `dp[i-1][j]` means we're "ignoring" the last character of `word1` — this is a **deletion**
- Moving from `dp[i][j-1]` means we're "adding" a character to match `word2` — this is an **insertion**
- Moving from `dp[i-1][j-1]` with different characters means we're **replacing**
Drawing out the DP table for a small example helps build intuition.
wrong_approach: "Guessing which transition is which operation"
correct_approach: "Understand that delete shrinks word1, insert grows word1, replace transforms a character"
key_takeaways:
- "**Classic DP pattern**: Edit Distance is a foundational problem that demonstrates 2D dynamic programming with string comparison"
- "**Three-way minimum**: When multiple choices exist at each step, take the minimum of all valid options"
- "**Base cases matter**: Proper initialisation of the DP table edges (empty string transformations) is crucial"
- "**Space optimisation possible**: Since each row only depends on the previous row, you can reduce space from O(mn) to O(n) using a rolling array"
time_complexity: "O(m * n). We fill a 2D table of size `(m+1) x (n+1)` where `m` and `n` are the lengths of the two strings."
space_complexity: "O(m * n). We use a 2D DP table to store intermediate results. This can be optimised to O(min(m, n)) using space optimisation techniques."
solutions:
- approach_name: 2D Dynamic Programming
is_optimal: true
code: |
def min_distance(word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
# Create DP table with dimensions (m+1) x (n+1)
# dp[i][j] = min operations to convert word1[0..i-1] to word2[0..j-1]
dp = [[0] * (n + 1) for _ in range(m + 1)]
# Base case: converting word1[0..i-1] to empty string needs i deletions
for i in range(m + 1):
dp[i][0] = i
# Base case: converting empty string to word2[0..j-1] needs j insertions
for j in range(n + 1):
dp[0][j] = j
# Fill the DP table
for i in range(1, m + 1):
for j in range(1, n + 1):
# If characters match, no operation needed
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
# Take minimum of insert, delete, replace (each costs 1)
dp[i][j] = 1 + min(
dp[i][j - 1], # Insert
dp[i - 1][j], # Delete
dp[i - 1][j - 1] # Replace
)
return dp[m][n]
explanation: |
**Time Complexity:** O(m * n) — We iterate through all cells in the `(m+1) x (n+1)` DP table.
**Space Complexity:** O(m * n) — We store the entire DP table.
This bottom-up DP approach builds the solution from smaller subproblems. Each cell represents the minimum edit distance for a prefix pair, and we fill the table row by row until we reach the answer at `dp[m][n]`.
- approach_name: Space-Optimised DP
is_optimal: true
code: |
def min_distance(word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
# Use only two rows since each row depends only on the previous row
prev = list(range(n + 1)) # Previous row
curr = [0] * (n + 1) # Current row
for i in range(1, m + 1):
# First column: converting word1[0..i-1] to empty string
curr[0] = i
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
# Characters match, no operation needed
curr[j] = prev[j - 1]
else:
# Minimum of insert, delete, replace
curr[j] = 1 + min(
curr[j - 1], # Insert (from left in current row)
prev[j], # Delete (from above in previous row)
prev[j - 1] # Replace (from diagonal in previous row)
)
# Swap rows for next iteration
prev, curr = curr, prev
# Result is in prev because we swapped at the end
return prev[n]
explanation: |
**Time Complexity:** O(m * n) — Same iteration through all cells.
**Space Complexity:** O(n) — We only keep two rows of the DP table at any time.
Since each row only depends on the previous row, we can reduce space from O(m * n) to O(n) by using a rolling array technique. We alternate between two arrays, updating the current row based on the previous row.
- approach_name: Recursive with Memoisation
is_optimal: false
code: |
def min_distance(word1: str, word2: str) -> int:
from functools import lru_cache
@lru_cache(maxsize=None)
def dp(i: int, j: int) -> int:
# Base case: if word1 is exhausted, insert remaining of word2
if i == 0:
return j
# Base case: if word2 is exhausted, delete remaining of word1
if j == 0:
return i
# If characters match, no operation needed
if word1[i - 1] == word2[j - 1]:
return dp(i - 1, j - 1)
# Try all three operations and take minimum
return 1 + min(
dp(i, j - 1), # Insert
dp(i - 1, j), # Delete
dp(i - 1, j - 1) # Replace
)
return dp(len(word1), len(word2))
explanation: |
**Time Complexity:** O(m * n) — Each unique state `(i, j)` is computed once due to memoisation.
**Space Complexity:** O(m * n) — For the memoisation cache, plus O(m + n) for the recursion stack.
This top-down approach is more intuitive to understand as it directly mirrors the problem definition. We recursively break down the problem and cache results to avoid redundant computation. However, it uses more space due to the recursion stack.

View File

@@ -0,0 +1,307 @@
title: Evaluate Division
slug: evaluate-division
difficulty: medium
leetcode_id: 399
leetcode_url: https://leetcode.com/problems/evaluate-division/
categories:
- graphs
- hash-tables
patterns:
- bfs
- dfs
- union-find
description: |
You are given an array of variable pairs `equations` and an array of real numbers `values`, where `equations[i] = [A_i, B_i]` and `values[i]` represent the equation `A_i / B_i = values[i]`. Each `A_i` or `B_i` is a string that represents a single variable.
You are also given some `queries`, where `queries[j] = [C_j, D_j]` represents the j<sup>th</sup> query where you must find the answer for `C_j / D_j = ?`.
Return *the answers to all queries*. If a single answer cannot be determined, return `-1.0`.
**Note:** The input is always valid. You may assume that evaluating the queries will not result in division by zero and that there is no contradiction.
**Note:** The variables that do not occur in the list of equations are undefined, so the answer cannot be determined for them.
constraints: |
- `1 <= equations.length <= 20`
- `equations[i].length == 2`
- `1 <= A_i.length, B_i.length <= 5`
- `values.length == equations.length`
- `0.0 < values[i] <= 20.0`
- `1 <= queries.length <= 20`
- `queries[i].length == 2`
- `1 <= C_j.length, D_j.length <= 5`
- `A_i, B_i, C_j, D_j` consist of lower case English letters and digits
examples:
- input: 'equations = [["a","b"],["b","c"]], values = [2.0,3.0], queries = [["a","c"],["b","a"],["a","e"],["a","a"],["x","x"]]'
output: "[6.00000,0.50000,-1.00000,1.00000,-1.00000]"
explanation: "Given: a / b = 2.0, b / c = 3.0. Queries are: a / c = 6.0 (via a/b * b/c), b / a = 0.5 (reciprocal), a / e = -1.0 (e undefined), a / a = 1.0 (same variable), x / x = -1.0 (x undefined)."
- input: 'equations = [["a","b"],["b","c"],["bc","cd"]], values = [1.5,2.5,5.0], queries = [["a","c"],["c","b"],["bc","cd"],["cd","bc"]]'
output: "[3.75000,0.40000,5.00000,0.20000]"
explanation: "a / c = 1.5 * 2.5 = 3.75, c / b = 1 / 2.5 = 0.4, bc / cd = 5.0 (given), cd / bc = 1 / 5.0 = 0.2."
- input: 'equations = [["a","b"]], values = [0.5], queries = [["a","b"],["b","a"],["a","c"],["x","y"]]'
output: "[0.50000,2.00000,-1.00000,-1.00000]"
explanation: "a / b = 0.5 (given), b / a = 2.0 (reciprocal), a / c = -1.0 (c undefined), x / y = -1.0 (both undefined)."
explanation:
intuition: |
The key insight is to **model this problem as a graph**. Think of each variable as a node, and each equation as a weighted edge connecting two nodes.
If `a / b = 2.0`, we can draw an edge from `a` to `b` with weight `2.0`. We also draw the reverse edge from `b` to `a` with weight `1/2.0 = 0.5` (the reciprocal).
Now, answering a query like `a / c = ?` becomes a **path-finding problem**: can we find a path from node `a` to node `c`? If we can, the answer is the **product of all edge weights along the path**.
Think of it like currency exchange: if 1 USD = 2 EUR and 1 EUR = 3 GBP, then 1 USD = 6 GBP. We're "chaining" the ratios together by multiplication.
This graph-based thinking transforms a seemingly algebraic problem into a classic graph traversal — we can use BFS or DFS to find paths and accumulate products along the way.
approach: |
We solve this using **Graph Construction + BFS/DFS**:
**Step 1: Build the graph**
- Create an adjacency list (dictionary of dictionaries) to store the graph
- For each equation `[A, B]` with value `v`:
- Add edge `A → B` with weight `v`
- Add edge `B → A` with weight `1/v` (the reciprocal)
&nbsp;
**Step 2: Process each query**
- For query `[C, D]`:
- If either `C` or `D` is not in the graph, return `-1.0`
- If `C == D`, return `1.0` (any variable divided by itself is 1)
- Otherwise, use BFS/DFS to find a path from `C` to `D`
&nbsp;
**Step 3: BFS to find the path and compute the result**
- Start BFS from node `C` with initial product `1.0`
- Use a queue storing `(current_node, accumulated_product)`
- Track visited nodes to avoid cycles
- For each neighbor, multiply the current product by the edge weight
- If we reach node `D`, return the accumulated product
- If BFS completes without finding `D`, return `-1.0`
&nbsp;
**Step 4: Collect and return all results**
- Apply the BFS query function to each query
- Return the list of results
common_pitfalls:
- title: Forgetting the Reciprocal Edge
description: |
Each equation gives us two pieces of information: if `a / b = 2`, then `b / a = 0.5`. You must add **both edges** to the graph.
Without the reverse edge, you can only traverse in one direction, missing valid paths. For example, with just `a → b`, you couldn't answer the query `b / a`.
wrong_approach: "Only adding edge A → B"
correct_approach: "Adding both A → B (weight v) and B → A (weight 1/v)"
- title: Not Handling Undefined Variables
description: |
Variables that don't appear in any equation are undefined. The query `x / y` where neither `x` nor `y` exists should return `-1.0`.
Even `x / x` returns `-1.0` if `x` is not in the graph — we can't assume undefined variables equal themselves because they're not defined at all.
wrong_approach: "Assuming any variable divided by itself equals 1"
correct_approach: "Check if variables exist in graph before assuming x/x = 1"
- title: Missing Cycle Detection
description: |
Without tracking visited nodes during BFS/DFS, you could get stuck in infinite loops. For example, with edges `a ↔ b ↔ c`, a naive traversal could bounce back and forth forever.
Always maintain a visited set and skip already-visited nodes.
wrong_approach: "BFS/DFS without visited tracking"
correct_approach: "Use a visited set to avoid revisiting nodes"
- title: Incorrect Path Product Accumulation
description: |
When traversing the path, you must **multiply** edge weights together, not add them. Division chains work multiplicatively: `a/b * b/c = a/c`.
Each BFS state needs to carry its accumulated product, not just the node.
wrong_approach: "Adding edge weights along the path"
correct_approach: "Multiplying edge weights along the path"
key_takeaways:
- "**Graph modeling**: Many ratio/relationship problems can be modeled as weighted graphs where finding answers means finding paths"
- "**Bidirectional edges**: Division relationships are symmetric — `a/b = v` implies `b/a = 1/v`. Always add both edges"
- "**BFS for path finding**: When you need to find any path between two nodes, BFS (or DFS) is the standard approach"
- "**Related problems**: This pattern applies to currency exchange, unit conversion, and any transitive relationship problems"
time_complexity: "O(Q × (V + E)) where Q is the number of queries, V is the number of unique variables, and E is the number of equations. Each BFS traverses at most all nodes and edges."
space_complexity: "O(V + E) for storing the graph. The BFS queue and visited set use O(V) additional space per query."
solutions:
- approach_name: BFS Graph Traversal
is_optimal: true
code: |
from collections import defaultdict, deque
def calc_equation(
equations: list[list[str]],
values: list[float],
queries: list[list[str]]
) -> list[float]:
# Build the weighted graph
graph = defaultdict(dict)
for (a, b), value in zip(equations, values):
graph[a][b] = value # a / b = value
graph[b][a] = 1 / value # b / a = 1/value
def bfs(start: str, end: str) -> float:
# Check if variables exist in graph
if start not in graph or end not in graph:
return -1.0
# Same variable divides to 1
if start == end:
return 1.0
# BFS: queue stores (node, accumulated_product)
queue = deque([(start, 1.0)])
visited = {start}
while queue:
node, product = queue.popleft()
# Check all neighbors
for neighbor, weight in graph[node].items():
if neighbor == end:
# Found the target - return accumulated product
return product * weight
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, product * weight))
# No path found
return -1.0
# Process all queries
return [bfs(c, d) for c, d in queries]
explanation: |
**Time Complexity:** O(Q × (V + E)) — For each query, BFS may visit all vertices and edges.
**Space Complexity:** O(V + E) — Graph storage dominates; BFS uses O(V) per query.
We build a bidirectional weighted graph where each equation creates two edges. For each query, BFS finds a path while accumulating the product of edge weights. This handles all cases: direct edges, multi-hop paths, undefined variables, and self-division.
- approach_name: DFS Graph Traversal
is_optimal: true
code: |
from collections import defaultdict
def calc_equation(
equations: list[list[str]],
values: list[float],
queries: list[list[str]]
) -> list[float]:
# Build the weighted graph
graph = defaultdict(dict)
for (a, b), value in zip(equations, values):
graph[a][b] = value
graph[b][a] = 1 / value
def dfs(start: str, end: str, visited: set) -> float:
# Variable not in graph
if start not in graph:
return -1.0
# Found the target
if start == end:
return 1.0
visited.add(start)
# Explore all neighbors
for neighbor, weight in graph[start].items():
if neighbor not in visited:
result = dfs(neighbor, end, visited)
# If path found, multiply weights
if result != -1.0:
return weight * result
# No path found from this node
return -1.0
results = []
for c, d in queries:
if c not in graph or d not in graph:
results.append(-1.0)
else:
results.append(dfs(c, d, set()))
return results
explanation: |
**Time Complexity:** O(Q × (V + E)) — Same as BFS; each query may explore all nodes/edges.
**Space Complexity:** O(V + E) — Graph storage plus O(V) recursion stack depth.
DFS achieves the same result as BFS with a recursive approach. We explore paths depth-first, multiplying edge weights as we backtrack. Both approaches are optimal for this problem size.
- approach_name: Union-Find with Weights
is_optimal: true
code: |
class UnionFind:
def __init__(self):
# parent[x] = (root, weight) where x / root = weight
self.parent = {}
def find(self, x: str) -> tuple[str, float]:
if x not in self.parent:
self.parent[x] = (x, 1.0)
return (x, 1.0)
if self.parent[x][0] == x:
return self.parent[x]
# Path compression with weight update
root, weight = self.find(self.parent[x][0])
self.parent[x] = (root, self.parent[x][1] * weight)
return self.parent[x]
def union(self, x: str, y: str, value: float) -> None:
# x / y = value
root_x, weight_x = self.find(x) # x / root_x = weight_x
root_y, weight_y = self.find(y) # y / root_y = weight_y
if root_x != root_y:
# Connect root_x to root_y
# root_x / root_y = (x / root_x)^-1 * (x / y) * (y / root_y)
# = weight_y * value / weight_x
self.parent[root_x] = (root_y, weight_y * value / weight_x)
def query(self, x: str, y: str) -> float:
if x not in self.parent or y not in self.parent:
return -1.0
root_x, weight_x = self.find(x)
root_y, weight_y = self.find(y)
if root_x != root_y:
return -1.0 # Different components
# x / y = (x / root) / (y / root) = weight_x / weight_y
return weight_x / weight_y
def calc_equation(
equations: list[list[str]],
values: list[float],
queries: list[list[str]]
) -> list[float]:
uf = UnionFind()
# Build union-find structure
for (a, b), value in zip(equations, values):
uf.union(a, b, value)
# Answer queries
return [uf.query(c, d) for c, d in queries]
explanation: |
**Time Complexity:** O((E + Q) × α(V)) — Near O(1) per operation with path compression, where α is the inverse Ackermann function.
**Space Complexity:** O(V) — Storage for parent pointers and weights.
Union-Find tracks connected components with weighted edges. Each node stores its ratio to its root. When querying `x / y`, we find both roots — if they match, the answer is `weight_x / weight_y`. This approach excels when there are many queries on the same graph.

View File

@@ -0,0 +1,191 @@
title: Evaluate Reverse Polish Notation
slug: evaluate-reverse-polish-notation
difficulty: medium
leetcode_id: 150
leetcode_url: https://leetcode.com/problems/evaluate-reverse-polish-notation/
categories:
- arrays
- stack
- math
patterns:
- monotonic-stack
description: |
You are given an array of strings `tokens` that represents an arithmetic expression in a [Reverse Polish Notation](http://en.wikipedia.org/wiki/Reverse_Polish_notation).
Evaluate the expression. Return *an integer that represents the value of the expression*.
**Note** that:
- The valid operators are `'+'`, `'-'`, `'*'`, and `'/'`.
- Each operand may be an integer or another expression.
- The division between two integers always **truncates toward zero**.
- There will not be any division by zero.
- The input represents a valid arithmetic expression in reverse polish notation.
- The answer and all the intermediate calculations can be represented in a **32-bit** integer.
constraints: |
- `1 <= tokens.length <= 10^4`
- `tokens[i]` is either an operator: `"+"`, `"-"`, `"*"`, or `"/"`, or an integer in the range `[-200, 200]`.
examples:
- input: 'tokens = ["2","1","+","3","*"]'
output: "9"
explanation: "((2 + 1) * 3) = 9"
- input: 'tokens = ["4","13","5","/","+"]'
output: "6"
explanation: "(4 + (13 / 5)) = 6"
- input: 'tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"]'
output: "22"
explanation: "((10 * (6 / ((9 + 3) * -11))) + 17) + 5 = ((10 * (6 / -132)) + 17) + 5 = ((10 * 0) + 17) + 5 = 22"
explanation:
intuition: |
Imagine you're a calculator that processes one token at a time, left to right. Unlike standard mathematical notation (called *infix notation*) where operators appear between operands like `3 + 4`, Reverse Polish Notation (RPN) places operators *after* their operands: `3 4 +`.
The beauty of RPN is that it **eliminates the need for parentheses** entirely. The order of operations is determined purely by the position of operators in the sequence. When you encounter an operator, you know it applies to the two most recent numbers you've seen.
Think of it like stacking plates: every time you see a number, you place it on top of a stack. When you see an operator, you take the top two plates off, combine them using that operation, and put the result back on top. At the end, you're left with exactly one plate — the final answer.
This "last in, first out" behavior is exactly what a **stack** data structure provides, making it the perfect tool for evaluating RPN expressions.
approach: |
We solve this using a **Stack-Based Evaluation**:
**Step 1: Initialise a stack**
- Create an empty stack to hold operands (numbers)
- Define a set of valid operators for quick lookup: `{'+', '-', '*', '/'}`
&nbsp;
**Step 2: Process each token left to right**
- If the token is a **number**: convert it to an integer and push onto the stack
- If the token is an **operator**: pop the top two elements, apply the operation, and push the result back
&nbsp;
**Step 3: Handle operator order carefully**
- When popping for an operator, the **first pop is the right operand** and the **second pop is the left operand**
- This matters for non-commutative operations: `a - b` is different from `b - a`
- For division, truncate toward zero (use `int(a / b)` in Python, not `//`)
&nbsp;
**Step 4: Return the final result**
- After processing all tokens, the stack contains exactly one element: the answer
- Return this value
&nbsp;
This approach works because RPN guarantees that when we encounter an operator, the stack always contains at least two operands ready for that operation.
common_pitfalls:
- title: Wrong Operand Order
description: |
The most common bug is getting the operand order backwards for subtraction and division.
When you pop twice from the stack, the **first value popped is the right operand** (it was pushed most recently), and the **second value is the left operand**.
For `["4", "2", "-"]`, the stack has `[4, 2]`. When we see `-`, we pop `2` first (right), then `4` (left), computing `4 - 2 = 2`, not `2 - 4 = -2`.
wrong_approach: "result = first_pop - second_pop"
correct_approach: "result = second_pop - first_pop"
- title: Division Truncation Direction
description: |
The problem states division **truncates toward zero**, not toward negative infinity.
In Python, the `//` operator performs floor division (toward negative infinity), which gives wrong results for negative numbers:
- `-7 // 2 = -4` (floor division, toward -∞)
- `int(-7 / 2) = -3` (truncation toward zero) ✓
Use `int(a / b)` to ensure correct truncation toward zero for all cases.
wrong_approach: "result = left // right"
correct_approach: "result = int(left / right)"
- title: Not Converting Strings to Integers
description: |
The input tokens are **strings**, not integers. Attempting arithmetic on strings will cause errors or unexpected behavior.
Always convert number tokens to integers with `int(token)` before pushing to the stack.
key_takeaways:
- "**Stack for expression evaluation**: Stacks naturally handle nested operations where the most recent operands are needed first"
- "**RPN eliminates ambiguity**: No parentheses needed because operator position determines evaluation order"
- "**Operand order matters**: For non-commutative operations, track which operand is left vs right"
- "**Classic interview pattern**: This problem tests stack fundamentals and appears frequently in coding interviews"
time_complexity: "O(n). We process each token exactly once, and stack operations (push/pop) are O(1)."
space_complexity: "O(n). In the worst case (all operands, then all operators), the stack holds approximately half the tokens."
solutions:
- approach_name: Stack-Based Evaluation
is_optimal: true
code: |
def eval_rpn(tokens: list[str]) -> int:
stack = []
operators = {'+', '-', '*', '/'}
for token in tokens:
if token in operators:
# Pop operands - first pop is RIGHT operand
right = stack.pop()
left = stack.pop()
# Apply the operator
if token == '+':
result = left + right
elif token == '-':
result = left - right
elif token == '*':
result = left * right
else: # token == '/'
# Truncate toward zero, not floor division
result = int(left / right)
stack.append(result)
else:
# It's a number - convert and push
stack.append(int(token))
# Final result is the only element left
return stack[0]
explanation: |
**Time Complexity:** O(n) — Single pass through all tokens.
**Space Complexity:** O(n) — Stack may hold up to n/2 elements.
We iterate through each token once. Numbers get pushed onto the stack; operators pop two values, compute the result, and push it back. The key insight is maintaining correct operand order for subtraction and division, and using truncation toward zero for division.
- approach_name: Using Lambda Functions
is_optimal: true
code: |
def eval_rpn(tokens: list[str]) -> int:
stack = []
# Map operators to their functions
ops = {
'+': lambda a, b: a + b,
'-': lambda a, b: a - b,
'*': lambda a, b: a * b,
'/': lambda a, b: int(a / b) # Truncate toward zero
}
for token in tokens:
if token in ops:
right = stack.pop()
left = stack.pop()
# Apply operator function
stack.append(ops[token](left, right))
else:
stack.append(int(token))
return stack[0]
explanation: |
**Time Complexity:** O(n) — Single pass through all tokens.
**Space Complexity:** O(n) — Stack storage plus constant space for operator map.
This variation uses a dictionary mapping operators to lambda functions, making the code more concise and eliminating the if-elif chain. The logic is identical to the basic approach, just expressed more elegantly using Python's functional features.

View File

@@ -0,0 +1,190 @@
title: Excel Sheet Column Title
slug: excel-sheet-column-title
difficulty: easy
leetcode_id: 168
leetcode_url: https://leetcode.com/problems/excel-sheet-column-title/
categories:
- strings
- math
patterns:
- greedy
function_signature: "def convert_to_title(column_number: int) -> str:"
test_cases:
visible:
- input: { column_number: 1 }
expected: "A"
- input: { column_number: 28 }
expected: "AB"
- input: { column_number: 701 }
expected: "ZY"
hidden:
- input: { column_number: 26 }
expected: "Z"
- input: { column_number: 27 }
expected: "AA"
- input: { column_number: 52 }
expected: "AZ"
description: |
Given an integer `columnNumber`, return *its corresponding column title as it appears in an Excel sheet*.
For example:
```
A -> 1
B -> 2
C -> 3
...
Z -> 26
AA -> 27
AB -> 28
...
```
constraints: |
- `1 <= columnNumber <= 2^31 - 1`
examples:
- input: "columnNumber = 1"
output: '"A"'
explanation: "The 1st column in Excel is labeled 'A'."
- input: "columnNumber = 28"
output: '"AB"'
explanation: "After Z (26), we wrap to AA (27), then AB (28)."
- input: "columnNumber = 701"
output: '"ZY"'
explanation: "701 = 26 * 26 + 25 = 676 + 25. The first character is Z (26th letter), and the second is Y (25th letter)."
explanation:
intuition: |
This problem is essentially converting a number to **base-26**, but with an important twist: Excel columns are **1-indexed**, not 0-indexed.
In standard base-26 (like hexadecimal is base-16), the digits would be 0-25. But Excel uses A-Z representing 1-26. There's no "zero" character!
Think of it like this: imagine you're reading an odometer, but instead of digits 0-9, you have letters A-Z. And unlike a normal odometer where 0 exists, this one starts at A (which represents 1).
The key insight is that before extracting each "digit", we need to **subtract 1** to shift from 1-indexed to 0-indexed. This converts our 1-26 range to 0-25, which maps perfectly to A-Z using modulo arithmetic.
For example, with `columnNumber = 28`:
- Subtract 1 → 27
- 27 % 26 = 1 → maps to 'B'
- 27 // 26 = 1
- Subtract 1 → 0
- 0 % 26 = 0 → maps to 'A'
- Result: "AB" (reversed from our extraction order)
approach: |
We solve this using **repeated division with 1-index adjustment**:
**Step 1: Initialise result string**
- `result`: Empty string to build our column title
&nbsp;
**Step 2: Extract characters from right to left**
- While `columnNumber > 0`:
- **Subtract 1** from `columnNumber` to convert from 1-indexed to 0-indexed
- Get the remainder when divided by 26 — this gives us the current character (0 = A, 1 = B, ..., 25 = Z)
- Convert the remainder to a character using `chr(remainder + ord('A'))`
- **Prepend** this character to the result (or append and reverse at the end)
- Divide `columnNumber` by 26 to move to the next position
&nbsp;
**Step 3: Return the result**
- Return the constructed column title string
&nbsp;
The subtraction step is crucial — it handles the fact that there's no "zero" in Excel's numbering. Without it, 'Z' (26) would incorrectly produce 'AZ' instead of just 'Z'.
common_pitfalls:
- title: Forgetting the 1-Index Adjustment
description: |
The most common mistake is treating this as standard base-26 conversion without accounting for 1-indexing.
For example, without subtracting 1:
- `columnNumber = 26` → 26 % 26 = 0, 26 // 26 = 1 → gives "A" + something
- But the answer should be just "Z"!
The subtraction converts our 1-26 range to 0-25, making the modulo operation work correctly.
wrong_approach: "Direct modulo without adjustment"
correct_approach: "Subtract 1 before each modulo operation"
- title: Building String in Wrong Order
description: |
When extracting digits via repeated division, we get them in reverse order (rightmost first). A common error is appending characters and forgetting to reverse, or getting confused about which end to add to.
For `28`:
- First extraction gives 'B' (the rightmost character)
- Second extraction gives 'A' (the leftmost character)
- If we append: "BA" (wrong!)
- If we prepend: "AB" (correct!)
wrong_approach: "Appending characters without reversing"
correct_approach: "Prepend each character, or append then reverse at the end"
- title: Off-By-One with Character Mapping
description: |
After the modulo operation, the remainder is 0-25. Some implementations incorrectly use `chr(remainder + ord('A') + 1)` or similar, resulting in off-by-one errors.
Remember: remainder 0 should map to 'A', remainder 1 to 'B', etc. Since `ord('A')` is 65, we use `chr(remainder + 65)` or `chr(remainder + ord('A'))`.
key_takeaways:
- "**1-indexed systems require adjustment**: When a numbering system starts at 1 instead of 0, subtract 1 before modulo operations"
- "**Base conversion pattern**: This technique of repeated division and modulo extends to any base conversion (binary, hex, etc.)"
- "**Build strings carefully**: When extracting digits right-to-left, remember to reverse or prepend"
- "**Related problems**: Excel Sheet Column Number (LC 171) is the inverse operation — converting title back to number"
time_complexity: "O(log<sub>26</sub> n). Each iteration divides the number by 26, so we perform approximately log base 26 of n iterations."
space_complexity: "O(log<sub>26</sub> n). The output string length is proportional to the number of iterations."
solutions:
- approach_name: Iterative Division
is_optimal: true
code: |
def convert_to_title(column_number: int) -> str:
result = []
while column_number > 0:
# Subtract 1 to convert from 1-indexed to 0-indexed
column_number -= 1
# Get the current character (0 = A, 1 = B, ..., 25 = Z)
remainder = column_number % 26
result.append(chr(remainder + ord('A')))
# Move to the next position
column_number //= 26
# We built the string right-to-left, so reverse it
return ''.join(reversed(result))
explanation: |
**Time Complexity:** O(log<sub>26</sub> n) — We divide by 26 each iteration.
**Space Complexity:** O(log<sub>26</sub> n) — The result list stores one character per iteration.
The key insight is subtracting 1 before each modulo operation. This converts from Excel's 1-indexed system (A=1, Z=26) to a 0-indexed system (A=0, Z=25) that works naturally with modulo arithmetic.
- approach_name: Recursive Solution
is_optimal: false
code: |
def convert_to_title(column_number: int) -> str:
if column_number == 0:
return ""
# Subtract 1 for 1-indexed adjustment
column_number -= 1
# Recursively get the prefix, then append current character
return convert_to_title(column_number // 26) + chr(column_number % 26 + ord('A'))
explanation: |
**Time Complexity:** O(log<sub>26</sub> n) — Same number of operations as iterative.
**Space Complexity:** O(log<sub>26</sub> n) — Call stack depth plus result string.
This recursive approach builds the string naturally in the correct order by recursing first (handling higher-order digits) then appending the current character. While elegant, it uses additional stack space compared to the iterative solution.